//! 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); }