//! 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. //! //! Not yet ported from bubbles: completion suggestions and clipboard paste //! (matcha has no OS-clipboard hook yet — bracketed `paste` Msg is still //! handled if the terminal sends one). const std = @import("std"); const matcha = @import("matcha"); const blush = @import("blush"); const key = @import("key.zig"); const util = @import("util.zig"); const Cursor = @import("Cursor.zig"); const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const Cmd = matcha.Cmd; const Msg = matcha.Msg; const ArrayList = std.ArrayList; const TextInput = @This(); pub 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. pub const ValidateFn = *const fn (value: []const u21) bool; pub const KeyMap = struct { 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') } }, word_backward: key.Binding = .{ .keys = &.{ key.alt('b') } }, delete_word_backward: key.Binding = .{ .keys = &.{ key.ctrl('w') } }, delete_word_forward: key.Binding = .{ .keys = &.{ key.alt('d') } }, 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)} }, 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') } }, }; pub const Styles = struct { text: ?blush.Style = null, prompt: ?blush.Style = null, placeholder: ?blush.Style = null, /// Style applied to the cursor cell when blinked off (i.e. plain text). cursor_text: ?blush.Style = null, /// Style applied to the cursor cell when visible (reverse-video by /// default — see `Cursor.view`). cursor: ?blush.Style = null, /// Style applied to the inline completion suggestion shown after the /// cursor when `show_suggestions` is enabled. suggestion: ?blush.Style = null, }; /// Bubbles textinput defaults: prompt ANSI 7, placeholder ANSI 240. pub const default_styles: Styles = .{ .prompt = .{ .foreground = .{ .ansi256 = 7 } }, .placeholder = .{ .foreground = .{ .ansi256 = 240 } }, .suggestion = .{ .foreground = .{ .ansi256 = 240 } }, }; model: matcha.Model = .{ .vtable = &vtable }, 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 = default_styles, 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 `matcha.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: matcha.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 = .{}, const vtable: matcha.Model.VTable = .{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, }; fn vtableInit(m: *matcha.Model) ?Cmd { const self: *TextInput = @fieldParentPtr("model", m); return self.focus(m.gpa); } fn vtableUpdate(m: *matcha.Model, msg: Msg) ?Cmd { const self: *TextInput = @fieldParentPtr("model", m); return self.update(m.gpa, msg); } fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { const self: *TextInput = @fieldParentPtr("model", m); try self.view(w); 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) ?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) ?matcha.View.Cursor { if (self.use_virtual_cursor or !self.focused) return null; const prompt_w = blush.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: Msg) ?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.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 matcha.clipboard.pasteCmd(); } else if (self.show_suggestions and self.key_map.accept_suggestion.matches(k)) { self.acceptSuggestion(allocator); 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; } pub fn view(self: *TextInput, w: *Writer) Writer.Error!void { if (self.value.items.len == 0 and self.placeholder.len > 0) { try self.placeholderView(w); return; } try util.writeInline(w, self.styles.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(w, 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.cursor.style = self.styles.cursor; self.cursor.text_style = self.styles.cursor_text orelse self.styles.text; try self.cursor.view(w); } else { try util.writeInline(w, self.styles.text, cur_buf[0..len]); } // Text after the cursor. try self.writeTransformed(w, visible[local_pos + 1 ..]); } else if (self.use_virtual_cursor) { // At end of value: ghost cursor either holds the first char of an // unaccepted suggestion (if any) or a space. 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; self.cursor.text_style = self.styles.suggestion; try self.cursor.view(w); try util.writeInline(w, self.styles.suggestion, tail[len..]); } else { self.cursor.char = " "; self.cursor.style = self.styles.cursor; self.cursor.text_style = self.styles.cursor_text orelse self.styles.text; try self.cursor.view(w); } } else { self.cursor.char = " "; self.cursor.style = self.styles.cursor; self.cursor.text_style = self.styles.cursor_text orelse self.styles.text; try self.cursor.view(w); } } else { // Real cursor mode: still render the ghost text inline. if (self.currentSuggestionTail()) |tail| { try util.writeInline(w, self.styles.suggestion, tail); } } // Width-padding (so a styled background extends to the configured width). if (self.width > 0 and self.styles.text != null) { 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) { const style = self.styles.text.?; try util.writeInline(w, style, ""); var i: u16 = 0; while (i < pad) : (i += 1) try w.writeByte(' '); try w.writeAll("\x1b[0m"); } } } fn placeholderView(self: *TextInput, w: *Writer) Writer.Error!void { try util.writeInline(w, self.styles.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; self.cursor.text_style = self.styles.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; self.cursor.text_style = self.styles.placeholder; try self.cursor.view(w); } else { try util.writeInline(w, self.styles.placeholder, cur_buf[0..len]); } const rest = self.placeholder[len..]; try util.writeInline(w, self.styles.placeholder, rest); // Pad to width so the placeholder area stays a fixed width when set. if (self.width > 0) { const ph_cells = blush.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, w: *Writer, cps: []const u21) Writer.Error!void { if (self.echo_mode == .none) return; if (cps.len == 0) return; const had_style = self.styles.text != null; if (had_style) try (self.styles.text.?).foreground.?.writeSgr(w, .fg); var buf: [4]u8 = undefined; for (cps) |cp| { const masked = self.echoCp(cp); const len = std.unicode.utf8Encode(masked, &buf) catch 1; try w.writeAll(buf[0..len]); } if (had_style) try w.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)); } // ---- 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: 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: Msg = .{ .key_press = .{ .kind = .right } }; const left: 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: 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 "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: 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); }