Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974//! Single-line text input with prompt, placeholder, blinking cursor, and//! horizontal-scroll clipping when `width` is set.//!//! Storage is a flat slice of codepoints (`u21`), with `pos` as a codepoint//! index. The embedded `Cursor` is driven by routing the parent's update//! messages through `update(allocator, msg)`; the parent kicks off cursor//! blink by calling `focus(allocator)` and including the returned Cmd.//!//! Completion suggestions (ghost text) and clipboard paste (via//! `tea.clipboard`) are supported. Not ported from bubbles: cell-width//! (as opposed to codepoint) measurement for wide glyphs, and the//! error-carrying validation surface.const std = @import("std");const tea = @import("blacktea");const matte = @import("matte");const util = @import("util.zig");const Cursor = @import("Cursor.zig");
const t = testing;const Allocator = std.mem.Allocator;const Writer = std.Io.Writer;const ArrayList = std.ArrayList;
const TextInput = @This();
const EchoMode = enum { normal, password, none };
/// Returns true if the value is acceptable. The input is the codepoint/// buffer. Set `err` from this fn's caller-side or via `Validate` plumbing/// — TextInput just records pass/fail.const ValidateFn = *const fn (value: []const u21) bool;
const KeyMap = struct { const key = @import("key.zig"); char_forward: key.Binding = .{ .keys = &.{ key.special(.right), key.ctrl('f') } }, char_backward: key.Binding = .{ .keys = &.{ key.special(.left), key.ctrl('b') } }, word_forward: key.Binding = .{ .keys = &.{ key.alt('f'), .{ .kind = .right, .mods = .{ .alt = true } }, .{ .kind = .right, .mods = .{ .ctrl = true } }, } }, word_backward: key.Binding = .{ .keys = &.{ key.alt('b'), .{ .kind = .left, .mods = .{ .alt = true } }, .{ .kind = .left, .mods = .{ .ctrl = true } }, } }, delete_word_backward: key.Binding = .{ .keys = &.{ key.ctrl('w'), .{ .kind = .backspace, .mods = .{ .alt = true } }, .{ .kind = .backspace, .mods = .{ .ctrl = true } }, } }, delete_word_forward: key.Binding = .{ .keys = &.{ key.alt('d'), .{ .kind = .delete, .mods = .{ .alt = true } }, .{ .kind = .delete, .mods = .{ .ctrl = true } }, } }, delete_after_cursor: key.Binding = .{ .keys = &.{key.ctrl('k')} }, delete_before_cursor: key.Binding = .{ .keys = &.{key.ctrl('u')} }, delete_char_backward: key.Binding = .{ .keys = &.{ key.special(.backspace), key.ctrl('h') } }, delete_char_forward: key.Binding = .{ .keys = &.{ key.special(.delete), key.ctrl('d') } }, line_start: key.Binding = .{ .keys = &.{ key.special(.home), key.ctrl('a') } }, line_end: key.Binding = .{ .keys = &.{ key.special(.end), key.ctrl('e') } }, paste: key.Binding = .{ .keys = &.{key.ctrl('v')} }, accept_suggestion: key.Binding = .{ .keys = &.{key.special(.tab)} }, next_suggestion: key.Binding = .{ .keys = &.{ key.special(.down), key.ctrl('n') } }, prev_suggestion: key.Binding = .{ .keys = &.{ key.special(.up), key.ctrl('p') } },};
/// Per-focus-state styles (bubbles' `StyleState`, styles.go:63-68).const StyleState = struct { text: matte.Style = .none, placeholder: matte.Style = .none, suggestion: matte.Style = .none, prompt: matte.Style = .none,};
/// Cursor styling (bubbles' `CursorStyle`, styles.go:71-96). `color` styles/// the cursor block (the fg is the cursor color for real cursors); `text_style`/// styles the cell when the cursor is blinked off (falls back to the active/// state's `text`).const CursorStyle = struct { color: matte.Style = .none, text_style: matte.Style = .none, blink: bool = true,};
/// Styles split by focus state, plus the cursor (bubbles' `Styles`,/// styles.go:50-54). `view` picks `focused`/`blurred` from `self.focused`.const Styles = struct { focused: StyleState = .{ .placeholder = .{ .foreground = .{ .ansi256 = 240 } }, .suggestion = .{ .foreground = .{ .ansi256 = 240 } }, .prompt = .{ .foreground = .{ .ansi256 = 7 } }, .text = .none, }, blurred: StyleState = .{ .placeholder = .{ .foreground = .{ .ansi256 = 240 } }, .suggestion = .{ .foreground = .{ .ansi256 = 240 } }, .prompt = .{ .foreground = .{ .ansi256 = 7 } }, .text = .none, }, cursor: CursorStyle = .{ .color = .{ .foreground = .{ .ansi256 = 7 } }, .blink = true },
pub const dark: Styles = .{ .blurred = .{ .text = .{ .foreground = .{ .ansi256 = 245 }, } } };
pub const light: Styles = .{ .blurred = .{ .text = .{ .foreground = .{ .ansi256 = 7 }, } } };};
prompt: []const u8 = "> ",placeholder: []const u8 = "",echo_mode: EchoMode = .normal,echo_char: u21 = '*',
/// Maximum number of codepoints accepted. 0 means unlimited.char_limit: usize = 0,/// Maximum visible cells for the value (excludes prompt). 0 means unlimited.width: u16 = 0,
styles: Styles = .dark,key_map: KeyMap = .{},validate: ?ValidateFn = null,/// Set by `update` to true when `validate` returned false on the latest/// edit. Caller can read this to show an error indicator.err: bool = false,
/// When true (the default), the cursor cell is rendered inline by the/// embedded `Cursor` leaf (reverse-video block, blinks via tick Cmd)./// When false, the leaf renders the cell as plain text and `cursorView()`/// returns a `tea.View.Cursor` for the parent to set on its `View`.use_virtual_cursor: bool = true,/// Real-cursor shape, used only when `use_virtual_cursor` is false.cursor_shape: tea.View.Cursor.Shape = .block,/// Whether the real cursor blinks. Only used when `use_virtual_cursor`/// is false.real_cursor_blink: bool = true,
value: ArrayList(u21) = .empty,focused: bool = false,/// Cursor position as a codepoint index in [0, value.items.len].pos: usize = 0,
/// Auto-completion suggestions. Caller-owned UTF-8 strings, borrowed by/// the input. Filtering is case-insensitive prefix match against `value`.show_suggestions: bool = false,suggestions: []const []const u8 = &.{},/// Indices into `suggestions` that match the current value. Refreshed/// whenever the value changes.matched_suggestions: ArrayList(usize) = .empty,/// Index into `matched_suggestions` selecting which one is offered as/// completion / shown as ghost text.current_suggestion: usize = 0,
/// Visible window into `value` when `width` clips. Indexes into `value`.offset: usize = 0,offset_right: usize = 0,
cursor: Cursor = .{},
model: tea.Model = .{ .vtable = &.{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, },},
fn vtableInit(m: *tea.Model) ?tea.Cmd { const self: *TextInput = @fieldParentPtr("model", m); return self.focus(m.gpa);}
fn vtableUpdate(m: *tea.Model, msg: tea.Msg) ?tea.Cmd { const self: *TextInput = @fieldParentPtr("model", m); return self.update(m.gpa, msg);}
fn vtableView(m: *tea.Model, writer: *Writer) Writer.Error!tea.View { const self: *TextInput = @fieldParentPtr("model", m); try self.view(writer); return .{};}
pub fn deinit(self: *TextInput, allocator: Allocator) void { self.value.deinit(allocator); self.matched_suggestions.deinit(allocator);}
/// Borrows `list`. Lifetime: caller must keep the slices alive for as/// long as the input is in use.pub fn setSuggestions(self: *TextInput, allocator: Allocator, list: []const []const u8) void { self.suggestions = list; self.refreshSuggestions(allocator);}
/// Returns the byte slice of the currently-selected suggestion's tail/// (the unaccepted part), or null if there's no usable completion.pub fn currentSuggestionTail(self: *const TextInput) ?[]const u8 { if (!self.show_suggestions) return null; if (self.matched_suggestions.items.len == 0) return null; if (self.current_suggestion >= self.matched_suggestions.items.len) return null; const sug_idx = self.matched_suggestions.items[self.current_suggestion]; if (sug_idx >= self.suggestions.len) return null; const sug = self.suggestions[sug_idx]; return suggestionTail(self.value.items, sug);}
fn suggestionTail(value: []const u21, suggestion: []const u8) ?[]const u8 { var iter = (std.unicode.Utf8View.init(suggestion) catch return null).iterator(); var i: usize = 0; while (i < value.len) : (i += 1) { const cp = iter.nextCodepoint() orelse return null; if (foldCase(cp) != foldCase(value[i])) return null; } return suggestion[iter.i..];}
fn foldCase(cp: u21) u21 { return if (cp >= 'A' and cp <= 'Z') cp + 32 else cp;}
fn refreshSuggestions(self: *TextInput, allocator: Allocator) void { if (!self.show_suggestions) { self.matched_suggestions.clearRetainingCapacity(); self.current_suggestion = 0; return; } self.matched_suggestions.clearRetainingCapacity(); if (self.value.items.len == 0 or self.suggestions.len == 0) { self.current_suggestion = 0; return; } for (self.suggestions, 0..) |sug, idx| { if (suggestionTail(self.value.items, sug) != null) { self.matched_suggestions.append(allocator, idx) catch break; } } if (self.current_suggestion >= self.matched_suggestions.items.len) self.current_suggestion = 0;}
fn acceptSuggestion(self: *TextInput, allocator: Allocator) void { const tail = self.currentSuggestionTail() orelse return; var iter = (std.unicode.Utf8View.init(tail) catch return).iterator(); while (iter.nextCodepoint()) |cp| { if (self.charLimitRemaining() == 0) break; self.value.append(allocator, cp) catch break; } self.pos = self.value.items.len;}
pub fn focus(self: *TextInput, allocator: Allocator) ?tea.Cmd { self.focused = true; if (!self.use_virtual_cursor) { // Hide the virtual cursor so it doesn't schedule blink ticks. self.cursor.mode = .hide; return null; } return self.cursor.focus(allocator);}
/// Toggles virtual vs real cursor. When set to false, hide the embedded/// cursor leaf and rely on the parent to plug `cursorView()` into its/// View.cursor.pub fn setVirtualCursor(self: *TextInput, use_virtual: bool) void { self.use_virtual_cursor = use_virtual; if (!use_virtual) self.cursor.mode = .hide;}
/// Returns the real-cursor descriptor when `use_virtual_cursor` is false/// and the input is focused. Caller is expected to add its own y/x offset/// for the input's position within the surrounding View, then assign to/// `view.cursor`.pub fn cursorView(self: *const TextInput) ?tea.View.Cursor { if (self.use_virtual_cursor or !self.focused) return null; const prompt_w = matte.measure.cellWidth(self.prompt); const local_pos: u16 = @intCast(self.pos -| self.offset); var x: u16 = prompt_w +| local_pos; if (self.width > 0 and local_pos > self.width) x = prompt_w +| self.width; return .{ .x = x, .y = 0, .shape = self.cursor_shape, .blink = self.real_cursor_blink, };}
pub fn blur(self: *TextInput) void { self.focused = false; self.cursor.blur();}
pub fn reset(self: *TextInput) void { self.value.clearRetainingCapacity(); self.pos = 0; self.offset = 0; self.offset_right = 0; self.err = false;}
pub fn setValue(self: *TextInput, allocator: Allocator, text: []const u8) !void { self.value.clearRetainingCapacity(); try appendSanitized(&self.value, allocator, text); if (self.char_limit > 0 and self.value.items.len > self.char_limit) self.value.shrinkRetainingCapacity(self.char_limit); self.pos = self.value.items.len; self.runValidate(); self.handleOverflow();}
pub fn position(self: *const TextInput) usize { return self.pos;}
pub fn setCursor(self: *TextInput, pos: usize) void { self.pos = @min(pos, self.value.items.len); self.handleOverflow();}
pub fn cursorStart(self: *TextInput) void { self.setCursor(0);}
pub fn cursorEnd(self: *TextInput) void { self.setCursor(self.value.items.len);}
pub fn setWidth(self: *TextInput, cells: u16) void { self.width = cells; self.handleOverflow();}
/// Borrow the current codepoint buffer. Caller must not retain across/// mutating calls (`update`, `setValue`, etc.).pub fn valueCodepoints(self: *const TextInput) []const u21 { return self.value.items;}
/// Encodes the current value to UTF-8 in the caller-supplied buffer./// Returns the encoded slice. If `out` is too small, returns/// `error.NoSpaceLeft` after partially filling.pub fn encodeValue(self: *const TextInput, out: []u8) ![]u8 { var w: Writer = .fixed(out); try writeCodepoints(&w, self.value.items); return out[0..w.end];}
pub fn update(self: *TextInput, allocator: Allocator, msg: tea.Msg) ?tea.Cmd { if (!self.focused) return null;
const old_pos = self.pos; var input_changed = false;
switch (msg) { .key_press => |k| { if (self.key_map.delete_word_backward.matches(k)) { self.deleteWordBackward(); input_changed = true; } else if (self.key_map.delete_char_backward.matches(k)) { if (self.pos > 0 and self.value.items.len > 0) { _ = self.value.orderedRemove(self.pos - 1); self.pos -= 1; input_changed = true; } } else if (self.key_map.word_backward.matches(k)) { self.wordBackward(); } else if (self.key_map.char_backward.matches(k)) { if (self.pos > 0) self.pos -= 1; } else if (self.key_map.word_forward.matches(k)) { self.wordForward(); } else if (self.key_map.char_forward.matches(k)) { if (self.pos < self.value.items.len) self.pos += 1; } else if (self.key_map.line_start.matches(k)) { self.cursorStart(); } else if (self.key_map.delete_char_forward.matches(k)) { if (self.pos < self.value.items.len) { _ = self.value.orderedRemove(self.pos); input_changed = true; } } else if (self.key_map.delete_word_forward.matches(k)) { self.deleteWordForward(); input_changed = true; } else if (self.key_map.line_end.matches(k)) { self.cursorEnd(); } else if (self.key_map.delete_after_cursor.matches(k)) { self.value.shrinkRetainingCapacity(self.pos); input_changed = true; } else if (self.key_map.delete_before_cursor.matches(k)) { std.mem.copyForwards( u21, self.value.items[0 .. self.value.items.len - self.pos], self.value.items[self.pos..], ); self.value.shrinkRetainingCapacity(self.value.items.len - self.pos); self.pos = 0; input_changed = true; } else if (self.key_map.paste.matches(k)) { return tea.clipboard.pasteCmd(); } else if (self.key_map.accept_suggestion.matches(k)) { // Go accepts only when a suggestion is available; otherwise // the (sanitized) tab falls through to normal input. if (self.matched_suggestions.items.len > 0) { self.acceptSuggestion(allocator); input_changed = true; } else { self.insertCodepoint(allocator, ' ') catch {}; input_changed = true; } } else if (self.show_suggestions and self.key_map.next_suggestion.matches(k)) { if (self.matched_suggestions.items.len > 0) self.current_suggestion = (self.current_suggestion + 1) % self.matched_suggestions.items.len; } else if (self.show_suggestions and self.key_map.prev_suggestion.matches(k)) { if (self.matched_suggestions.items.len > 0) self.current_suggestion = (self.current_suggestion + self.matched_suggestions.items.len - 1) % self.matched_suggestions.items.len; } else if (k.kind == .rune and !k.mods.ctrl and !k.mods.alt) { self.insertCodepoint(allocator, k.rune) catch {}; input_changed = true; } else if (k.kind == .space) { self.insertCodepoint(allocator, ' ') catch {}; input_changed = true; } }, .paste => |text| { appendSanitizedAt(&self.value, allocator, text, self.pos, self.charLimitRemaining()) catch {}; self.pos = countCodepointsUntilOriginal(&self.value, text, self.pos); input_changed = true; }, else => {}, }
if (input_changed) { self.runValidate(); self.refreshSuggestions(allocator); }
self.handleOverflow();
var cmd = self.cursor.update(allocator, msg); if (old_pos != self.pos and self.cursor.mode == .blink) { // Reset the blink so cursor movement feels snappy. self.cursor.is_blinked = false; } if (cmd == null and self.focused and self.cursor.mode == .blink and !self.cursor.running) cmd = self.cursor.focus(allocator); return cmd;}
/// The style state matching the current focus (bubbles picks focused/blurred).fn activeState(self: *const TextInput) StyleState { return if (self.focused) self.styles.focused else self.styles.blurred;}
/// Configures the embedded cursor's styles from `styles.cursor` and the active/// state's text style (used for the blinked-off cell).fn applyCursorStyle(self: *TextInput, ss: StyleState) void { self.cursor.style = self.styles.cursor.color; const styled = self.styles.cursor.text_style.hasAnyStyling(); self.cursor.text_style = if (styled) self.styles.cursor.text_style else ss.text;}
pub fn view(self: *TextInput, writer: *Writer) Writer.Error!void { const ss = self.activeState(); if (self.value.items.len == 0 and self.placeholder.len > 0) { try self.placeholderView(writer); return; }
try util.writeInline(writer, ss.prompt, self.prompt);
const visible = self.visibleSlice(); const local_pos = if (self.pos > self.offset) self.pos - self.offset else 0;
// Text before the cursor. try self.writeTransformed(writer, visible[0..@min(local_pos, visible.len)]);
// Cursor cell. With a real cursor, render plain text; the parent's // View.cursor handles drawing the cursor itself. var cur_buf: [4]u8 = undefined; if (local_pos < visible.len) { const cp = visible[local_pos]; const masked = self.echoCp(cp); const len = std.unicode.utf8Encode(masked, &cur_buf) catch 1; if (self.use_virtual_cursor) { self.cursor.char = cur_buf[0..len]; self.applyCursorStyle(ss); try self.cursor.view(writer); } else { try util.writeInline(writer, ss.text, cur_buf[0..len]); } // Text after the cursor. try self.writeTransformed(writer, visible[local_pos + 1 ..]); } else if (self.use_virtual_cursor and self.focused) { // At end of value: ghost cursor either holds the first char of an // unaccepted suggestion (if any) or a space. Suggestions only render // while focused (textinput.go:705). if (self.currentSuggestionTail()) |tail| { var t_iter = (std.unicode.Utf8View.init(tail) catch unreachable).iterator(); if (t_iter.nextCodepoint()) |cp| { const len = std.unicode.utf8Encode(cp, &cur_buf) catch 1; self.cursor.char = cur_buf[0..len]; self.cursor.style = self.styles.cursor.color; self.cursor.text_style = ss.suggestion; try self.cursor.view(writer); try util.writeInline(writer, ss.suggestion, tail[len..]); } else { self.cursor.char = " "; self.applyCursorStyle(ss); try self.cursor.view(writer); } } else { self.cursor.char = " "; self.applyCursorStyle(ss); try self.cursor.view(writer); } } else { // Real cursor mode: still render the ghost text inline while focused. if (self.focused) { if (self.currentSuggestionTail()) |tail| { try util.writeInline(writer, ss.suggestion, tail); } } }
// Width-padding (so a styled background extends to the configured width). if (self.width > 0 and ss.text.hasAnyStyling()) { const visible_cells: u16 = @intCast(visible.len); var pad: u16 = if (self.width > visible_cells) self.width - visible_cells else 0; if (local_pos < visible.len) {} else if (pad > 0) pad -= 1; // cursor occupied a cell if (pad > 0) { try util.writeInline(writer, ss.text, ""); var i: u16 = 0; while (i < pad) : (i += 1) try writer.writeByte(' '); try writer.writeAll("\x1b[0m"); } }}
fn placeholderView(self: *TextInput, w: *Writer) Writer.Error!void { const ss = self.activeState(); try util.writeInline(w, ss.prompt, self.prompt);
// First placeholder codepoint sits under the cursor; the rest renders // styled with `placeholder`. Real-cursor mode just renders the // placeholder verbatim and lets the parent draw the cursor on top. var iter = (std.unicode.Utf8View.init(self.placeholder) catch return).iterator(); const first_cp = iter.nextCodepoint() orelse { if (self.use_virtual_cursor) { self.cursor.char = " "; self.cursor.style = self.styles.cursor.color; self.cursor.text_style = ss.placeholder; try self.cursor.view(w); } return; };
var cur_buf: [4]u8 = undefined; const len = std.unicode.utf8Encode(first_cp, &cur_buf) catch 1; if (self.use_virtual_cursor) { self.cursor.char = cur_buf[0..len]; self.cursor.style = self.styles.cursor.color; self.cursor.text_style = ss.placeholder; try self.cursor.view(w); } else { try util.writeInline(w, ss.placeholder, cur_buf[0..len]); }
const rest = self.placeholder[len..]; try util.writeInline(w, ss.placeholder, rest);
// Pad to width so the placeholder area stays a fixed width when set. if (self.width > 0) { const ph_cells = matte.measure.cellWidth(self.placeholder); if (ph_cells < self.width) { const pad = self.width - ph_cells; var i: u16 = 0; while (i < pad) : (i += 1) try w.writeByte(' '); } }}
fn writeTransformed(self: *TextInput, writer: *Writer, cps: []const u21) Writer.Error!void { if (self.echo_mode == .none) return; if (cps.len == 0) return; const text_style = self.activeState().text; const fg: ?matte.Color = text_style.foreground; if (fg) |c| try c.writeSgr(writer, .fg); var buf: [4]u8 = undefined; for (cps) |cp| { const masked = self.echoCp(cp); const len = std.unicode.utf8Encode(masked, &buf) catch 1; try writer.writeAll(buf[0..len]); } if (fg) |_| try writer.writeAll("\x1b[0m");}
fn echoCp(self: *const TextInput, cp: u21) u21 { return switch (self.echo_mode) { .normal => cp, .password => self.echo_char, .none => ' ', };}
fn visibleSlice(self: *const TextInput) []const u21 { if (self.offset_right == 0 or self.offset_right > self.value.items.len) return self.value.items[self.offset..]; return self.value.items[self.offset..self.offset_right];}
fn handleOverflow(self: *TextInput) void { if (self.width == 0 or self.value.items.len <= self.width) { self.offset = 0; self.offset_right = self.value.items.len; return; }
self.offset_right = @min(self.offset_right, self.value.items.len);
if (self.pos < self.offset) { self.offset = self.pos; self.offset_right = @min(self.offset + self.width, self.value.items.len); } else if (self.pos >= self.offset_right) { self.offset_right = self.pos; self.offset = if (self.offset_right > self.width) self.offset_right - self.width else 0; }}
fn charLimitRemaining(self: *const TextInput) usize { if (self.char_limit == 0) return std.math.maxInt(usize); if (self.value.items.len >= self.char_limit) return 0; return self.char_limit - self.value.items.len;}
fn insertCodepoint(self: *TextInput, allocator: Allocator, cp: u21) !void { if (self.charLimitRemaining() == 0) return; try self.value.insert(allocator, self.pos, cp); self.pos += 1;}
fn runValidate(self: *TextInput) void { if (self.validate) |v| self.err = !v(self.value.items) else self.err = false;}
// ---- word movement ----
fn isWordSep(cp: u21) bool { return cp == ' ' or cp == '\t' or cp == '\n';}
fn wordBackward(self: *TextInput) void { if (self.pos == 0 or self.value.items.len == 0) return; if (self.echo_mode != .normal) { self.cursorStart(); return; } var i = self.pos; // Skip trailing separators just left of the cursor. while (i > 0 and isWordSep(self.value.items[i - 1])) : (i -= 1) {} // Skip the word. while (i > 0 and !isWordSep(self.value.items[i - 1])) : (i -= 1) {} self.pos = i;}
fn wordForward(self: *TextInput) void { if (self.pos >= self.value.items.len) return; if (self.echo_mode != .normal) { self.cursorEnd(); return; } var i = self.pos; // Skip separators at the cursor. while (i < self.value.items.len and isWordSep(self.value.items[i])) : (i += 1) {} // Skip the word. while (i < self.value.items.len and !isWordSep(self.value.items[i])) : (i += 1) {} self.pos = i;}
fn deleteWordBackward(self: *TextInput) void { if (self.pos == 0 or self.value.items.len == 0) return; if (self.echo_mode != .normal) { std.mem.copyForwards( u21, self.value.items[0 .. self.value.items.len - self.pos], self.value.items[self.pos..], ); self.value.shrinkRetainingCapacity(self.value.items.len - self.pos); self.pos = 0; return; } const start = self.pos; self.wordBackward(); const end = start; const dst = self.value.items[self.pos .. self.value.items.len - (end - self.pos)]; std.mem.copyForwards(u21, dst, self.value.items[end..]); self.value.shrinkRetainingCapacity(self.value.items.len - (end - self.pos));}
fn deleteWordForward(self: *TextInput) void { if (self.pos >= self.value.items.len or self.value.items.len == 0) return; if (self.echo_mode != .normal) { // Masked input: reveal nothing, wipe everything after the cursor // (Go's deleteAfterCursor). self.value.shrinkRetainingCapacity(self.pos); return; } const start = self.pos; self.wordForward(); const end = self.pos; self.pos = start; const dst = self.value.items[self.pos .. self.value.items.len - (end - self.pos)]; std.mem.copyForwards(u21, dst, self.value.items[end..]); self.value.shrinkRetainingCapacity(self.value.items.len - (end - self.pos));}
// ---- helpers ----
fn appendSanitized(list: *ArrayList(u21), allocator: Allocator, text: []const u8) !void { var iter = (std.unicode.Utf8View.init(text) catch return).iterator(); while (iter.nextCodepoint()) |cp| { const safe: u21 = if (cp == '\n' or cp == '\t' or cp == '\r') ' ' else cp; if (safe < 0x20 and safe != ' ') continue; // drop other control chars try list.append(allocator, safe); }}
fn appendSanitizedAt( list: *ArrayList(u21), allocator: Allocator, text: []const u8, at: usize, max_to_insert: usize,) !void { var iter = (std.unicode.Utf8View.init(text) catch return).iterator(); var inserted: usize = 0; var insert_at = at; while (iter.nextCodepoint()) |cp| { if (inserted >= max_to_insert) break; const safe: u21 = if (cp == '\n' or cp == '\t' or cp == '\r') ' ' else cp; if (safe < 0x20 and safe != ' ') continue; try list.insert(allocator, insert_at, safe); insert_at += 1; inserted += 1; }}
fn countCodepointsUntilOriginal(list: *ArrayList(u21), text: []const u8, start: usize) usize { _ = list; var iter = (std.unicode.Utf8View.init(text) catch return start).iterator(); var n: usize = 0; while (iter.nextCodepoint()) |cp| { if (cp < 0x20 and cp != ' ' and cp != '\n' and cp != '\t' and cp != '\r') continue; n += 1; } return start + n;}
fn writeCodepoints(w: *Writer, cps: []const u21) Writer.Error!void { var buf: [4]u8 = undefined; for (cps) |cp| { const len = std.unicode.utf8Encode(cp, &buf) catch 1; try w.writeAll(buf[0..len]); }}
// ---- tests ----
const testing = std.testing;
test "setValue / encodeValue roundtrip" { var ti: TextInput = .{}; defer ti.deinit(testing.allocator); try ti.setValue(testing.allocator, "hello"); var buf: [16]u8 = undefined; const out = try ti.encodeValue(&buf); try testing.expectEqualStrings("hello", out); try testing.expectEqual(@as(usize, 5), ti.pos);}
test "char_limit truncates setValue" { var ti: TextInput = .{ .char_limit = 3 }; defer ti.deinit(testing.allocator); try ti.setValue(testing.allocator, "abcdef"); try testing.expectEqual(@as(usize, 3), ti.value.items.len); try testing.expectEqual(@as(usize, 3), ti.pos);}
test "backspace removes codepoint before cursor" { var ti: TextInput = .{}; defer ti.deinit(testing.allocator); try ti.setValue(testing.allocator, "abc"); ti.cursor.mode = .static; // skip blink-tick allocation _ = ti.focus(testing.allocator); const bs: tea.Msg = .{ .key_press = .{ .kind = .backspace } }; _ = ti.update(testing.allocator, bs); try testing.expectEqual(@as(usize, 2), ti.value.items.len); try testing.expectEqual(@as(u21, 'b'), ti.value.items[1]); try testing.expectEqual(@as(usize, 2), ti.pos);}
test "char_forward / char_backward respects bounds" { var ti: TextInput = .{}; defer ti.deinit(testing.allocator); try ti.setValue(testing.allocator, "ab"); ti.cursor.mode = .static; // skip blink-tick allocation _ = ti.focus(testing.allocator); ti.cursorStart(); const right: tea.Msg = .{ .key_press = .{ .kind = .right } }; const left: tea.Msg = .{ .key_press = .{ .kind = .left } }; _ = ti.update(testing.allocator, right); _ = ti.update(testing.allocator, right); _ = ti.update(testing.allocator, right); // clamped try testing.expectEqual(@as(usize, 2), ti.pos); _ = ti.update(testing.allocator, left); _ = ti.update(testing.allocator, left); _ = ti.update(testing.allocator, left); // clamped try testing.expectEqual(@as(usize, 0), ti.pos);}
test "wordBackward jumps past spaces" { var ti: TextInput = .{}; defer ti.deinit(testing.allocator); try ti.setValue(testing.allocator, "foo bar baz"); ti.cursor.mode = .static; // skip blink-tick allocation _ = ti.focus(testing.allocator); ti.cursorEnd(); const ab: tea.Msg = .{ .key_press = .{ .kind = .rune, .rune = 'b', .mods = .{ .alt = true } } }; _ = ti.update(testing.allocator, ab); try testing.expectEqual(@as(usize, 8), ti.pos); // start of "baz" _ = ti.update(testing.allocator, ab); try testing.expectEqual(@as(usize, 4), ti.pos); // start of "bar"}
test "validate sets err flag" { const Validators = struct { fn nonEmpty(v: []const u21) bool { return v.len > 0; } }; var ti: TextInput = .{ .validate = Validators.nonEmpty }; defer ti.deinit(testing.allocator); try ti.setValue(testing.allocator, ""); try testing.expect(ti.err); try ti.setValue(testing.allocator, "x"); try testing.expect(!ti.err);}
test "suggestion match + accept" { var ti: TextInput = .{ .show_suggestions = true }; defer ti.deinit(testing.allocator); ti.cursor.mode = .static; _ = ti.focus(testing.allocator); const sugs = [_][]const u8{ "apple", "apricot", "banana" }; ti.setSuggestions(testing.allocator, &sugs); try ti.setValue(testing.allocator, "ap"); ti.refreshSuggestions(testing.allocator); try testing.expectEqual(@as(usize, 2), ti.matched_suggestions.items.len); try testing.expectEqualStrings("ple", ti.currentSuggestionTail().?); ti.acceptSuggestion(testing.allocator); var buf: [16]u8 = undefined; const out = try ti.encodeValue(&buf); try testing.expectEqualStrings("apple", out);}
test "deleteWordForward removes word after cursor" { var ti: TextInput = .{}; defer ti.deinit(testing.allocator); ti.cursor.mode = .static; _ = ti.focus(testing.allocator); try ti.setValue(testing.allocator, "foo bar baz"); ti.cursorStart(); const ad: tea.Msg = .{ .key_press = .{ .kind = .rune, .rune = 'd', .mods = .{ .alt = true } } }; _ = ti.update(testing.allocator, ad); var buf: [32]u8 = undefined; try testing.expectEqualStrings(" bar baz", try ti.encodeValue(&buf)); _ = ti.update(testing.allocator, ad); // skips the space, deletes "bar" try testing.expectEqualStrings(" baz", try ti.encodeValue(&buf)); _ = ti.update(testing.allocator, ad); // skips the space, deletes "baz" try testing.expectEqualStrings("", try ti.encodeValue(&buf));}
test "tab without matched suggestion inserts a space" { var ti: TextInput = .{}; defer ti.deinit(testing.allocator); ti.cursor.mode = .static; _ = ti.focus(testing.allocator); const tab: tea.Msg = .{ .key_press = .{ .kind = .tab } }; _ = ti.update(testing.allocator, tab); var buf: [16]u8 = undefined; try testing.expectEqualStrings(" ", try ti.encodeValue(&buf));}
test "tab accepts suggestion when one matches" { var ti: TextInput = .{ .show_suggestions = true }; defer ti.deinit(testing.allocator); ti.cursor.mode = .static; _ = ti.focus(testing.allocator); const sugs = [_][]const u8{"apple"}; ti.setSuggestions(testing.allocator, &sugs); try ti.setValue(testing.allocator, "ap"); ti.refreshSuggestions(testing.allocator); const tab: tea.Msg = .{ .key_press = .{ .kind = .tab } }; _ = ti.update(testing.allocator, tab); var buf: [16]u8 = undefined; try testing.expectEqualStrings("apple", try ti.encodeValue(&buf));}
test "suggestion cycle next" { var ti: TextInput = .{ .show_suggestions = true }; defer ti.deinit(testing.allocator); ti.cursor.mode = .static; _ = ti.focus(testing.allocator); const sugs = [_][]const u8{ "alpha", "alpine", "alabaster" }; ti.setSuggestions(testing.allocator, &sugs); try ti.setValue(testing.allocator, "al"); ti.refreshSuggestions(testing.allocator); try testing.expectEqual(@as(usize, 3), ti.matched_suggestions.items.len); try testing.expectEqualStrings("pha", ti.currentSuggestionTail().?); const down: tea.Msg = .{ .key_press = .{ .kind = .down } }; _ = ti.update(testing.allocator, down); try testing.expectEqualStrings("pine", ti.currentSuggestionTail().?);}
test "echo password masks output" { var ti: TextInput = .{ .echo_mode = .password, .echo_char = '*' }; defer ti.deinit(testing.allocator); try ti.setValue(testing.allocator, "abc"); var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try ti.view(&w); // Prompt then three '*' then a cursor space — accept any cursor styling. try testing.expect(std.mem.indexOf(u8, w.buffered(), "***") != null);}
test "blurred text is dimmed but focused text is not" { var ti: TextInput = .{}; defer ti.deinit(testing.allocator); ti.cursor.mode = .static; ti.use_virtual_cursor = false; // avoid virtual cursor cell in output try ti.setValue(testing.allocator, "abc");
// Focused: text carries no foreground (focused Text is unstyled). _ = ti.focus(testing.allocator); var fbuf: [256]u8 = undefined; var fw: Writer = .fixed(&fbuf); try ti.view(&fw); try testing.expect(std.mem.indexOf(u8, fw.buffered(), "\x1b[38;5;245m") == null);
// Blurred: text is dimmed with ANSI 245 (dark default). ti.blur(); var bbuf: [256]u8 = undefined; var bw: Writer = .fixed(&bbuf); try ti.view(&bw); try testing.expect(std.mem.indexOf(u8, bw.buffered(), "\x1b[38;5;245m") != null);}
test "light styles dim blurred text with ANSI 7" { var ti: TextInput = .{ .styles = .light }; defer ti.deinit(testing.allocator); ti.cursor.mode = .static; ti.use_virtual_cursor = false; try ti.setValue(testing.allocator, "hi"); ti.blur(); var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try ti.view(&w); // Light blurred text foreground is ANSI 7 (not the dark 245). try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[38;5;7m") != null); try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[38;5;245m") == null);}