//! Multi-line text editor. Each line is an `ArrayList(u21)` of codepoints; //! `(row, col)` track the cursor in those coordinates. Vertical scroll is //! built in, plus optional soft-wrap. //! //! Supports char/word/line navigation, insert + delete (incl. Ctrl+K/Ctrl+U //! line merges), optional line numbers, a per-line prompt prefix, placeholder //! when empty, char limit, line-cap, paste, transpose, and word case change. //! Not ported from bubbles: the selection subsystem, the soft-wrap //! memoization cache, prompt function, real-cursor mode, and clipboard //! copy/paste error surfacing. const std = @import("std"); const tea = @import("blacktea"); const matte = @import("matte"); const util = @import("util.zig"); const Cursor = @import("Cursor.zig"); const t = std.testing; const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const ArrayList = std.ArrayList; const TextArea = @This(); 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')} }, word_backward: key.Binding = .{ .keys = &.{key.alt('b')} }, line_next: key.Binding = .{ .keys = &.{ key.special(.down), key.ctrl('n') } }, line_previous: key.Binding = .{ .keys = &.{ key.special(.up), key.ctrl('p') } }, 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')} }, insert_newline: key.Binding = .{ .keys = &.{ key.special(.enter), key.ctrl('m') } }, 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') } }, page_up: key.Binding = .{ .keys = &.{key.special(.page_up)} }, page_down: key.Binding = .{ .keys = &.{key.special(.page_down)} }, input_begin: key.Binding = .{ .keys = &.{key.alt('<')} }, input_end: key.Binding = .{ .keys = &.{key.alt('>')} }, paste: key.Binding = .{ .keys = &.{key.ctrl('v')} }, transpose_char_backward: key.Binding = .{ .keys = &.{key.ctrl('t')} }, uppercase_word_forward: key.Binding = .{ .keys = &.{key.alt('u')} }, lowercase_word_forward: key.Binding = .{ .keys = &.{key.alt('l')} }, capitalize_word_forward: key.Binding = .{ .keys = &.{key.alt('c')} }, }; const Styles = struct { text: matte.Style = .none, prompt: matte.Style = .{ .foreground = .{ .ansi256 = 7 } }, placeholder: matte.Style = .{ .foreground = .{ .ansi256 = 240 } }, line_number: matte.Style = .{ .foreground = .{ .ansi256 = 7 } }, /// Background highlight for the line the cursor sits on (bubbles' /// `CursorLine`). Applied across the full width when focused. cursor_line: matte.Style = .{ .background = .{ .ansi256 = 0 } }, /// Foreground for the line number on the cursor line (bubbles' /// `CursorLineNumber`). cursor_line_number: matte.Style = .{ .foreground = .{ .ansi256 = 240 } }, /// Style for the end-of-buffer marker (bubbles' `EndOfBuffer`). end_of_buffer: matte.Style = .{ .foreground = .{ .ansi256 = 0 } }, cursor: matte.Style = .none, cursor_text: matte.Style = .none, pub const unstyled: Styles = .{ .text = .none, .prompt = .none, .placeholder = .none, .line_number = .none, .cursor_line = .none, .cursor_line_number = .none, .end_of_buffer = .none, .cursor = .none, .cursor_text = .none, }; }; prompt: []const u8 = "┃ ", placeholder: []const u8 = "", show_line_numbers: bool = true, end_of_buffer: u8 = ' ', /// Maximum total codepoints (across all lines, excluding newlines). 0 = no /// limit. char_limit: usize = 0, /// Hard cap on the number of stored lines; insertion beyond this is dropped. max_lines: usize = 10_000, width: u16 = 40, height: u16 = 6, /// When true, `height` auto-resizes between `min_height` and `max_height` /// to fit the line count after each edit. dynamic_height: bool = false, min_height: u16 = 1, max_height: u16 = 99, /// When true, lines wider than the content width wrap onto continuation /// rows at word boundaries (bubbles always soft-wraps). `y_offset` counts /// visual rows. Vertical nav (`line_previous`/`line_next`) moves by visual /// rows too. soft_wrap: bool = true, /// Mouse wheel scrolls the viewport by this many rows per notch. Set to 0 /// to disable mouse-wheel scroll. mouse_wheel_delta: u16 = 3, styles: Styles = .{}, key_map: KeyMap = .{}, cursor_blink: Cursor = .{}, /// When true, the cursor cell is rendered inline by the embedded `Cursor` /// leaf. When false, the editor renders the cell as plain text and the /// parent reads `cursorView()` to drive a real terminal cursor. 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, lines: ArrayList(ArrayList(u21)) = .empty, focused: bool = false, /// Cursor row (line index) and column (codepoint index inside that line). row: usize = 0, col: usize = 0, /// Visible-column intent retained across vertical movements so up/down /// preserves the user's logical column over jagged lines. last_col_offset: usize = 0, y_offset: usize = 0, model: tea.Model = .{ .vtable = &.{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, } }, fn vtableInit(m: *tea.Model) ?tea.Cmd { const self: *TextArea = @fieldParentPtr("model", m); return self.focus(m.gpa); } fn vtableUpdate(m: *tea.Model, msg: tea.Msg) ?tea.Cmd { const self: *TextArea = @fieldParentPtr("model", m); return self.update(m.gpa, msg); } fn vtableView(m: *tea.Model, w: *Writer) Writer.Error!tea.View { const self: *TextArea = @fieldParentPtr("model", m); try self.view(w); return .{}; } pub fn deinit(self: *TextArea, allocator: Allocator) void { for (self.lines.items) |*line| line.deinit(allocator); self.lines.deinit(allocator); } pub fn focus(self: *TextArea, allocator: Allocator) ?tea.Cmd { self.focused = true; if (!self.use_virtual_cursor) { self.cursor_blink.mode = .hide; return null; } return self.cursor_blink.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: *TextArea, use_virtual: bool) void { self.use_virtual_cursor = use_virtual; if (!use_virtual) self.cursor_blink.mode = .hide; } /// Returns the real-cursor descriptor when `use_virtual_cursor` is false /// and the textarea is focused. The (x, y) is local to the textarea's /// rendered output; the parent should add its own offsets before /// assigning to `view.cursor`. pub fn cursorView(self: *const TextArea) ?tea.View.Cursor { if (self.use_virtual_cursor or !self.focused) return null; const cur_vrow = self.cursorVisualRow(); if (cur_vrow < self.y_offset) return null; const local_vrow = cur_vrow - self.y_offset; if (local_vrow >= self.height) return null; const prompt_w = matte.measure.cellWidth(self.prompt); const ln_w = self.lineNumberWidth(); const vcol: usize = if (self.soft_wrap) blk: { const cw = self.contentCellsLeft(); if (cw == 0) break :blk self.col; const line = self.lines.items[self.row].items; var starts: [max_wrap_rows]usize = undefined; const n = wrapRowStarts(line, cw, &starts); const vrow = rowIndexOf(starts[0..n], self.col); break :blk cellsOf(line[starts[vrow]..self.col]); } else self.col; const x: u16 = prompt_w +| ln_w +| @as(u16, @intCast(vcol)); return .{ .x = x, .y = @intCast(local_vrow), .shape = self.cursor_shape, .blink = self.real_cursor_blink, }; } pub fn blur(self: *TextArea) void { self.focused = false; self.cursor_blink.blur(); } pub fn reset(self: *TextArea, allocator: Allocator) void { for (self.lines.items) |*line| line.deinit(allocator); self.lines.clearRetainingCapacity(); self.row = 0; self.col = 0; self.y_offset = 0; self.last_col_offset = 0; } pub fn setValue(self: *TextArea, allocator: Allocator, text: []const u8) !void { self.reset(allocator); try self.insertText(allocator, text); self.row = self.lines.items.len -| 1; self.col = if (self.lines.items.len > 0) self.lines.items[self.row].items.len else 0; self.last_col_offset = self.col; self.ensureRowVisible(); } pub fn setWidth(self: *TextArea, w: u16) void { self.width = w; } pub fn setHeight(self: *TextArea, h: u16) void { self.height = h; self.ensureRowVisible(); } pub fn lineCount(self: *const TextArea) usize { return self.lines.items.len; } /// Total codepoint count across all lines (newlines not counted). pub fn length(self: *const TextArea) usize { var n: usize = 0; for (self.lines.items) |line| n += line.items.len; return n; } pub fn cursorPosition(self: *const TextArea) struct { row: usize, col: usize } { return .{ .row = self.row, .col = self.col }; } /// Encodes the value (lines joined by `\n`) into `out`. Returns the /// encoded slice; `error.NoSpaceLeft` when `out` is too small. pub fn encodeValue(self: *const TextArea, out: []u8) ![]u8 { var w: Writer = .fixed(out); var first = true; var buf: [4]u8 = undefined; for (self.lines.items) |line| { if (!first) try w.writeByte('\n'); for (line.items) |cp| { const len = std.unicode.utf8Encode(cp, &buf) catch 1; try w.writeAll(buf[0..len]); } first = false; } return out[0..w.end]; } pub fn update(self: *TextArea, allocator: Allocator, msg: tea.Msg) ?tea.Cmd { if (!self.focused) return null; const old_row = self.row; const old_col = self.col; switch (msg) { .key_press => |k| { if (self.key_map.delete_word_backward.matches(k)) { self.deleteWordBackward(allocator); } else if (self.key_map.delete_word_forward.matches(k)) { self.deleteWordForward(allocator); } else if (self.key_map.delete_char_backward.matches(k)) { self.deleteCharBackward(allocator); } else if (self.key_map.delete_char_forward.matches(k)) { self.deleteCharForward(allocator); } else if (self.key_map.delete_after_cursor.matches(k)) { self.deleteAfterCursor(allocator); } else if (self.key_map.delete_before_cursor.matches(k)) { self.deleteBeforeCursor(allocator); } else if (self.key_map.word_backward.matches(k)) { self.wordBackward(); } else if (self.key_map.word_forward.matches(k)) { self.wordForward(); } else if (self.key_map.char_backward.matches(k)) { self.charBackward(); } else if (self.key_map.char_forward.matches(k)) { self.charForward(); } else if (self.key_map.line_previous.matches(k)) { self.linePrevious(); } else if (self.key_map.line_next.matches(k)) { self.lineNext(); } else if (self.key_map.line_start.matches(k)) { self.lineStartAction(); } else if (self.key_map.line_end.matches(k)) { self.lineEndAction(); } else if (self.key_map.page_up.matches(k)) { self.pageUp(); } else if (self.key_map.page_down.matches(k)) { self.pageDown(); } else if (self.key_map.input_begin.matches(k)) { self.row = 0; self.col = 0; self.last_col_offset = 0; } else if (self.key_map.input_end.matches(k)) { self.row = self.lines.items.len -| 1; self.col = if (self.lines.items.len > 0) self.lines.items[self.row].items.len else 0; self.last_col_offset = self.col; } else if (self.key_map.insert_newline.matches(k)) { self.insertNewline(allocator) catch {}; } else if (self.key_map.transpose_char_backward.matches(k)) { self.transposeCharBackward(); } else if (self.key_map.uppercase_word_forward.matches(k)) { self.transformWordForward(.upper); } else if (self.key_map.lowercase_word_forward.matches(k)) { self.transformWordForward(.lower); } else if (self.key_map.capitalize_word_forward.matches(k)) { self.transformWordForward(.capitalize); } else if (self.key_map.paste.matches(k)) { return tea.clipboard.pasteCmd(); } else if (k.kind == .rune and !k.mods.ctrl and !k.mods.alt) { self.insertCodepoint(allocator, k.rune) catch {}; } else if (k.kind == .space) { self.insertCodepoint(allocator, ' ') catch {}; } else if (k.kind == .tab) { self.insertCodepoint(allocator, '\t') catch {}; } }, .paste => |text| { self.insertText(allocator, text) catch {}; }, .mouse_wheel => |m| { if (self.mouse_wheel_delta == 0) return null; switch (m.button) { .wheel_up => self.scrollViewUp(self.mouse_wheel_delta), .wheel_down => self.scrollViewDown(self.mouse_wheel_delta), else => {}, } }, else => {}, } self.recalculateHeight(); self.ensureRowVisible(); var cmd = self.cursor_blink.update(allocator, msg); if ((old_row != self.row or old_col != self.col) and self.cursor_blink.mode == .blink) { self.cursor_blink.is_blinked = false; } if (cmd == null and self.focused and self.cursor_blink.mode == .blink and !self.cursor_blink.running) cmd = self.cursor_blink.focus(allocator); return cmd; } pub fn view(self: *TextArea, writer: *Writer) Writer.Error!void { if (self.height == 0 or self.width == 0) return; // Go renders the placeholder whenever the value is empty and the cursor // sits at the origin, focused or not (textarea.go:1479-1481). It always // fills the full `height` so the layout stays a fixed size. const value_empty = self.lines.items.len == 0 or (self.lines.items.len == 1 and self.lines.items[0].items.len == 0); if (value_empty and self.row == 0 and self.col == 0 and self.placeholder.len > 0) { try self.placeholderView(writer); return; } // Lazily ensure at least one (possibly empty) line so cursor rendering // has something to anchor on. if (self.lines.items.len == 0) { try self.writeLine(writer, 0, &.{}); var i: u16 = 1; while (i < self.height) : (i += 1) { try writer.writeByte('\n'); try self.writeEndOfBuffer(writer); } return; } if (self.soft_wrap) { try self.viewSoftWrap(writer); } else { try self.viewHardCut(writer); } } fn viewHardCut(self: *TextArea, writer: *Writer) Writer.Error!void { const start = self.y_offset; const end = @min(start + self.height, self.lines.items.len); var rendered: u16 = 0; var idx = start; while (idx < end) : (idx += 1) { if (rendered > 0) try writer.writeByte('\n'); try self.writeLine(writer, idx, self.lines.items[idx].items); rendered += 1; } while (rendered < self.height) : (rendered += 1) { try writer.writeByte('\n'); try self.writeEndOfBuffer(writer); } } fn viewSoftWrap(self: *TextArea, writer: *Writer) Writer.Error!void { const content_w = self.contentCellsLeft(); if (content_w == 0) return; var skipped: usize = 0; var rendered: u16 = 0; var line_idx: usize = 0; while (line_idx < self.lines.items.len and rendered < self.height) : (line_idx += 1) { const line = self.lines.items[line_idx].items; var starts: [max_wrap_rows]usize = undefined; const rows = wrapRowStarts(line, content_w, &starts); var chunk: usize = 0; while (chunk < rows and rendered < self.height) : (chunk += 1) { if (skipped < self.y_offset) { skipped += 1; continue; } if (rendered > 0) try writer.writeByte('\n'); try self.writeChunk(writer, line_idx, line, starts[chunk], starts[chunk + 1], chunk == 0); rendered += 1; } } while (rendered < self.height) : (rendered += 1) { if (rendered > 0) try writer.writeByte('\n'); try self.writeEndOfBuffer(writer); } } fn writeChunk( self: *TextArea, w: *Writer, line_idx: usize, line: []const u21, start_cp: usize, end_cp: usize, is_first_row: bool, ) Writer.Error!void { const is_cursor_line = line_idx == self.row and self.focused; const cursor_col = if (is_cursor_line) self.col else line.len; // The whole logical line (all its wrapped rows) carries the cursor-line // background, matching bubbles (textarea.go:1498-1502); only the row // holding the cursor draws the cursor cell itself. const bg: matte.Style = if (is_cursor_line) self.styles.cursor_line else .none; try util.writeInline(w, withBackground(self.styles.prompt, bg), self.prompt); if (self.show_line_numbers) { if (is_first_row) { const ln_style = if (is_cursor_line) (self.styles.cursor_line_number.maybeStyle() orelse self.styles.line_number) else self.styles.line_number; try self.writeLineNumber(w, line_idx, withBackground(ln_style, bg)); } else { // Continuation row: blank pad matching line-number width. const ln_w = self.lineNumberWidth(); if (bg.maybeStyle()) |s| try openInlineStyle(w, s); var i: u16 = 0; while (i < ln_w) : (i += 1) try w.writeByte(' '); if (bg.maybeStyle()) |s| try closeInlineStyle(w, s); } } const text_style = withBackground(self.styles.text, bg); var emitted: u16 = 0; var cur_buf: [4]u8 = undefined; var i: usize = start_cp; while (i < end_cp) : (i += 1) { if (i == cursor_col and self.use_virtual_cursor) { const len = std.unicode.utf8Encode(line[i], &cur_buf) catch 1; self.cursor_blink.char = cur_buf[0..len]; self.cursor_blink.style = withBackground(self.styles.cursor, bg); self.cursor_blink.text_style = withBackground(self.styles.cursor_text.maybeStyle() orelse self.styles.text, bg); try self.cursor_blink.view(w); emitted += 1; continue; } const len = std.unicode.utf8Encode(line[i], &cur_buf) catch 1; if (text_style.maybeStyle()) |s| try openInlineStyle(w, s); try w.writeAll(cur_buf[0..len]); if (text_style.maybeStyle()) |s| try closeInlineStyle(w, s); emitted += 1; } // Cursor at end of line on the last chunk. if (is_cursor_line and cursor_col == line.len and end_cp == line.len and emitted < self.contentCellsLeft() and self.use_virtual_cursor) { self.cursor_blink.char = " "; self.cursor_blink.style = withBackground(self.styles.cursor, bg); self.cursor_blink.text_style = withBackground(self.styles.cursor_text.maybeStyle() orelse self.styles.text, bg); try self.cursor_blink.view(w); } // Extend the cursor-line background to the full width. if (bg.maybeStyle()) |s| try self.padToWidth(w, emitted, s); } fn isSpaceCp(cp: u21) bool { return cp == ' ' or cp == '\t'; } /// Upper bound on the number of visual rows a single line can wrap into. /// Long lines beyond this are clamped to hard codepoint chunks. const max_wrap_rows: usize = 1024; /// Word-wraps `line` to `content_w` cells, hard-wrapping words wider than /// `content_w` (bubbles' `wrap`, textarea.go:2020). Fills `starts[0..n]` with /// the codepoint index of each visual row's first codepoint and sets /// `starts[n] = line.len`. Returns the number of rows `n`. fn wrapRowStarts(line: []const u21, content_w: u16, starts: []usize) usize { if (content_w == 0 or line.len == 0) { starts[0] = 0; starts[1] = line.len; return 1; } var nrows: usize = 0; var i: usize = 0; while (i < line.len) { var row_cells: u16 = 0; var last_space: usize = 0; var has_space = false; var j = i; while (j < line.len) : (j += 1) { const w: u16 = @as(u16, matte.measure.codepointWidth(line[j])); if (row_cells + w > content_w) break; if (isSpaceCp(line[j])) { has_space = true; last_space = j; } row_cells += w; } // Prefer to break at the last space that fit on the row (word wrap); // otherwise hard-cut mid-word. var end = j; if (j < line.len and has_space and last_space > i) end = last_space; if (end == i) end = i + 1; // guarantee progress (e.g. wide char > width) // The next row starts after `end`, dropping the break space(s). var next = end; while (next < line.len and isSpaceCp(line[next])) : (next += 1) {} starts[nrows] = i; nrows += 1; i = next; if (nrows >= max_wrap_rows) break; } starts[nrows] = line.len; return nrows; } /// Number of visual rows `line` occupies at `content_w` (word-wrap aware). fn visualRowsForLine(line: []const u21, content_w: u16) usize { if (content_w == 0 or line.len == 0) return 1; var starts: [max_wrap_rows]usize = undefined; return wrapRowStarts(line, content_w, &starts); } /// 0-based visual row (within `line`) that contains `col`. fn colVisualRow(line: []const u21, content_w: u16, col: usize) usize { if (content_w == 0 or line.len == 0) return 0; var starts: [max_wrap_rows]usize = undefined; const n = wrapRowStarts(line, content_w, &starts); return rowIndexOf(starts[0..n], col); } /// Index of the row (in `starts`) whose span contains `col`. fn rowIndexOf(starts: []const usize, col: usize) usize { var r: usize = 0; while (r < starts.len and starts[r] <= col) : (r += 1) {} return if (r == 0) 0 else r - 1; } /// Total terminal cells spanned by a run of code points. fn cellsOf(cps: []const u21) u16 { var cells: u16 = 0; for (cps) |cp| cells += @as(u16, matte.measure.codepointWidth(cp)); return cells; } /// Codepoint offset within `cps` that reaches (at most) `cells` terminal /// cells, clamped to `cps.len`. fn colAtCells(cps: []const u21, cells: u16) usize { var acc: u16 = 0; var i: usize = 0; while (i < cps.len) : (i += 1) { const w: u16 = @as(u16, matte.measure.codepointWidth(cps[i])); if (acc + w > cells) break; acc += w; } return i; } /// The cursor's cell column within its current visual row (soft-wrap aware). fn currentCellCol(self: *const TextArea) u16 { if (!self.soft_wrap) return @intCast(@min(self.last_col_offset, std.math.maxInt(u16))); const cw = self.contentCellsLeft(); const line = self.lines.items[self.row].items; var starts: [max_wrap_rows]usize = undefined; const n = wrapRowStarts(line, cw, &starts); const vrow = rowIndexOf(starts[0..n], self.col); return cellsOf(line[starts[vrow]..self.col]); } fn insertCodepoint(self: *TextArea, allocator: Allocator, cp: u21) !void { if (self.atCharLimit()) return; if (self.lines.items.len == 0) try self.lines.append(allocator, .empty); try self.lines.items[self.row].insert(allocator, self.col, cp); self.col += 1; self.last_col_offset = self.col; } fn insertNewline(self: *TextArea, allocator: Allocator) !void { if (self.lines.items.len >= self.max_lines) return; if (self.lines.items.len == 0) try self.lines.append(allocator, .empty); // Split current line at col. var current = &self.lines.items[self.row]; var new_line: ArrayList(u21) = .empty; if (self.col < current.items.len) { try new_line.appendSlice(allocator, current.items[self.col..]); current.shrinkRetainingCapacity(self.col); } try self.lines.insert(allocator, self.row + 1, new_line); self.row += 1; self.col = 0; self.last_col_offset = 0; } fn insertText(self: *TextArea, allocator: Allocator, text: []const u8) !void { if (self.lines.items.len == 0) try self.lines.append(allocator, .empty); var iter = (std.unicode.Utf8View.init(text) catch return).iterator(); while (iter.nextCodepoint()) |cp| { if (self.atCharLimit()) break; if (cp == '\n') { try self.insertNewline(allocator); } else if (cp == '\r') { // ignore } else if (cp < 0x20 and cp != '\t') { // drop other control chars } else { try self.lines.items[self.row].insert(allocator, self.col, cp); self.col += 1; } } self.last_col_offset = self.col; } fn deleteCharBackward(self: *TextArea, allocator: Allocator) void { if (self.lines.items.len == 0) return; if (self.col > 0) { _ = self.lines.items[self.row].orderedRemove(self.col - 1); self.col -= 1; } else if (self.row > 0) { // Merge current line into previous. var prev = &self.lines.items[self.row - 1]; const new_col = prev.items.len; var current = self.lines.items[self.row]; prev.appendSlice(allocator, current.items) catch {}; current.deinit(allocator); _ = self.lines.orderedRemove(self.row); self.row -= 1; self.col = new_col; } self.last_col_offset = self.col; } fn deleteCharForward(self: *TextArea, allocator: Allocator) void { if (self.lines.items.len == 0) return; var line = &self.lines.items[self.row]; if (self.col < line.items.len) { _ = line.orderedRemove(self.col); } else if (self.row + 1 < self.lines.items.len) { // Merge next line into current. var next = self.lines.items[self.row + 1]; line.appendSlice(allocator, next.items) catch {}; next.deinit(allocator); _ = self.lines.orderedRemove(self.row + 1); } self.last_col_offset = self.col; } fn deleteAfterCursor(self: *TextArea, allocator: Allocator) void { if (self.lines.items.len == 0) return; var line = &self.lines.items[self.row]; if (self.col >= line.items.len) { // At end of line: merge the line below (Go textarea.go:1281-1285). if (self.row + 1 < self.lines.items.len) { var next = self.lines.items[self.row + 1]; line.appendSlice(allocator, next.items) catch {}; next.deinit(allocator); _ = self.lines.orderedRemove(self.row + 1); } } else { line.shrinkRetainingCapacity(self.col); } self.last_col_offset = self.col; } fn deleteBeforeCursor(self: *TextArea, allocator: Allocator) void { if (self.lines.items.len == 0) return; if (self.col == 0) { // At start of line: merge the line above (Go textarea.go:1293-1296). if (self.row == 0) return; self.col = self.lines.items[self.row - 1].items.len; self.row -= 1; var prev = &self.lines.items[self.row]; var current = self.lines.items[self.row + 1]; prev.appendSlice(allocator, current.items) catch {}; current.deinit(allocator); _ = self.lines.orderedRemove(self.row + 1); self.last_col_offset = self.col; return; } var line = &self.lines.items[self.row]; std.mem.copyForwards( u21, line.items[0 .. line.items.len - self.col], line.items[self.col..], ); line.shrinkRetainingCapacity(line.items.len - self.col); self.col = 0; self.last_col_offset = 0; } fn deleteWordBackward(self: *TextArea, allocator: Allocator) void { if (self.lines.items.len == 0 or (self.col == 0 and self.row == 0)) return; const start_row = self.row; const start_col = self.col; self.wordBackward(); if (self.row == start_row) { var line = &self.lines.items[self.row]; std.mem.copyForwards( u21, line.items[self.col .. line.items.len - (start_col - self.col)], line.items[start_col..], ); line.shrinkRetainingCapacity(line.items.len - (start_col - self.col)); } else { // Word movement crossed a line boundary; bridge by deleting line by // line via repeated backspace. while (self.row != start_row or self.col != start_col) { // walk forward, then issue a single delete-back loop break; } // Fallback: do the simpler line-merge erase. // Erase from new (row, col) to (start_row, start_col). // Approach: first wipe to end of current line, then merge until reach start_row. var first = &self.lines.items[self.row]; first.shrinkRetainingCapacity(self.col); var i = self.row + 1; while (i < start_row) : (i += 1) { var rm = self.lines.orderedRemove(self.row + 1); rm.deinit(allocator); } // Merge remainder of start_row (after start_col) into self.row. if (self.row + 1 < self.lines.items.len) { var tail = self.lines.orderedRemove(self.row + 1); defer tail.deinit(allocator); if (start_col < tail.items.len) { self.lines.items[self.row].appendSlice(allocator, tail.items[start_col..]) catch {}; } } } self.last_col_offset = self.col; } fn deleteWordForward(self: *TextArea, allocator: Allocator) void { if (self.lines.items.len == 0) return; const start_row = self.row; const start_col = self.col; self.wordForward(); const end_row = self.row; const end_col = self.col; self.row = start_row; self.col = start_col; if (start_row == end_row) { var line = &self.lines.items[self.row]; std.mem.copyForwards( u21, line.items[start_col .. line.items.len - (end_col - start_col)], line.items[end_col..], ); line.shrinkRetainingCapacity(line.items.len - (end_col - start_col)); } else { // Erase from (start_row, start_col) through (end_row, end_col). self.lines.items[start_row].shrinkRetainingCapacity(start_col); var i: usize = start_row + 1; while (i < end_row) : (i += 1) { var rm = self.lines.orderedRemove(start_row + 1); rm.deinit(allocator); } if (start_row + 1 < self.lines.items.len) { var tail = self.lines.orderedRemove(start_row + 1); defer tail.deinit(allocator); if (end_col < tail.items.len) { self.lines.items[start_row].appendSlice(allocator, tail.items[end_col..]) catch {}; } } } self.last_col_offset = self.col; } // ---- navigation ---- fn charBackward(self: *TextArea) void { if (self.col > 0) { self.col -= 1; } else if (self.row > 0) { self.row -= 1; self.col = self.lines.items[self.row].items.len; } self.last_col_offset = self.col; } fn charForward(self: *TextArea) void { if (self.lines.items.len == 0) return; if (self.col < self.lines.items[self.row].items.len) { self.col += 1; } else if (self.row + 1 < self.lines.items.len) { self.row += 1; self.col = 0; } self.last_col_offset = self.col; } fn linePrevious(self: *TextArea) void { if (self.lines.items.len == 0) return; const intent = self.currentCellCol(); if (self.soft_wrap) { const cw = self.contentCellsLeft(); if (cw > 0) { const line = self.lines.items[self.row].items; var starts: [max_wrap_rows]usize = undefined; const n = wrapRowStarts(line, cw, &starts); const vrow = rowIndexOf(starts[0..n], self.col); if (vrow > 0) { // Move up one visual row within the current logical line. self.col = starts[vrow - 1] + colAtCells(line[starts[vrow - 1]..starts[vrow]], intent); return; } } } if (self.row == 0) return; self.row -= 1; const prev = self.lines.items[self.row].items; if (self.soft_wrap) { const cw = self.contentCellsLeft(); if (cw > 0) { var starts: [max_wrap_rows]usize = undefined; const n = wrapRowStarts(prev, cw, &starts); const last_start = starts[n - 1]; self.col = last_start + colAtCells(prev[last_start..], intent); return; } } self.col = @min(self.last_col_offset, prev.len); } fn lineNext(self: *TextArea) void { if (self.lines.items.len == 0) return; const intent = self.currentCellCol(); if (self.soft_wrap) { const cw = self.contentCellsLeft(); if (cw > 0) { const line = self.lines.items[self.row].items; var starts: [max_wrap_rows]usize = undefined; const n = wrapRowStarts(line, cw, &starts); const vrow = rowIndexOf(starts[0..n], self.col); if (vrow + 1 < n) { // Next visual row within the current logical line. self.col = starts[vrow + 1] + colAtCells(line[starts[vrow + 1]..starts[vrow + 2]], intent); return; } } } if (self.row + 1 >= self.lines.items.len) return; self.row += 1; const next = self.lines.items[self.row].items; if (self.soft_wrap) { const cw = self.contentCellsLeft(); if (cw > 0) { self.col = colAtCells(next, intent); return; } } self.col = @min(self.last_col_offset, next.len); } fn lineStartAction(self: *TextArea) void { self.col = 0; self.last_col_offset = 0; } fn lineEndAction(self: *TextArea) void { if (self.lines.items.len == 0) return; self.col = self.lines.items[self.row].items.len; self.last_col_offset = self.col; } fn pageUp(self: *TextArea) void { self.row = if (self.row > self.height) self.row - self.height else 0; if (self.lines.items.len > 0) self.col = @min(self.last_col_offset, self.lines.items[self.row].items.len); } fn pageDown(self: *TextArea) void { if (self.lines.items.len == 0) return; self.row = @min(self.row + self.height, self.lines.items.len - 1); self.col = @min(self.last_col_offset, self.lines.items[self.row].items.len); } fn wordBackward(self: *TextArea) void { if (self.lines.items.len == 0) return; if (self.col == 0) { if (self.row == 0) return; self.row -= 1; self.col = self.lines.items[self.row].items.len; } const line = self.lines.items[self.row].items; var i = self.col; while (i > 0 and isWordSep(line[i - 1])) : (i -= 1) {} while (i > 0 and !isWordSep(line[i - 1])) : (i -= 1) {} self.col = i; self.last_col_offset = self.col; } fn wordForward(self: *TextArea) void { if (self.lines.items.len == 0) return; var line = self.lines.items[self.row].items; if (self.col >= line.len) { if (self.row + 1 >= self.lines.items.len) return; self.row += 1; self.col = 0; line = self.lines.items[self.row].items; } var i = self.col; while (i < line.len and isWordSep(line[i])) : (i += 1) {} while (i < line.len and !isWordSep(line[i])) : (i += 1) {} self.col = i; self.last_col_offset = self.col; } fn isWordSep(cp: u21) bool { return cp == ' ' or cp == '\t'; } const WordTransform = enum { upper, lower, capitalize }; fn transposeCharBackward(self: *TextArea) void { if (self.lines.items.len == 0) return; const line = &self.lines.items[self.row]; if (line.items.len < 2) return; if (self.col == 0) return; // If cursor is at end, swap last two and don't advance. const at_end = self.col >= line.items.len; const i = if (at_end) line.items.len - 1 else self.col; if (i == 0) return; const tmp = line.items[i - 1]; line.items[i - 1] = line.items[i]; line.items[i] = tmp; if (!at_end) self.col += 1; self.last_col_offset = self.col; } fn transformWordForward(self: *TextArea, tform: WordTransform) void { if (self.lines.items.len == 0) return; var line = self.lines.items[self.row].items; // Skip separators at cursor. var i = self.col; while (i < line.len and isWordSep(line[i])) : (i += 1) {} // Apply transform across the word. var first_in_word = true; while (i < line.len and !isWordSep(line[i])) : (i += 1) { const cp = line[i]; const replaced: u21 = switch (tform) { .upper => upperAscii(cp), .lower => lowerAscii(cp), .capitalize => if (first_in_word) upperAscii(cp) else lowerAscii(cp), }; line[i] = replaced; first_in_word = false; } self.col = i; self.last_col_offset = self.col; } fn upperAscii(cp: u21) u21 { return if (cp >= 'a' and cp <= 'z') cp - 32 else cp; } fn lowerAscii(cp: u21) u21 { return if (cp >= 'A' and cp <= 'Z') cp + 32 else cp; } // ---- limits / scroll ---- fn atCharLimit(self: *const TextArea) bool { return self.char_limit > 0 and self.length() >= self.char_limit; } fn scrollViewUp(self: *TextArea, n: u16) void { self.y_offset = if (self.y_offset > n) self.y_offset - n else 0; } fn scrollViewDown(self: *TextArea, n: u16) void { const total = self.lines.items.len; const max_off: usize = if (total > self.height) total - self.height else 0; const new_off = self.y_offset +| n; self.y_offset = @min(new_off, max_off); } fn recalculateHeight(self: *TextArea) void { if (!self.dynamic_height) return; const lines: u16 = @intCast(@min(self.lines.items.len, std.math.maxInt(u16))); const lo = @max(self.min_height, 1); var h = @max(lines, lo); if (self.max_height > 0 and h > self.max_height) h = self.max_height; self.height = h; } fn ensureRowVisible(self: *TextArea) void { if (self.height == 0) return; const cur = self.cursorVisualRow(); if (cur < self.y_offset) { self.y_offset = cur; } else if (cur >= self.y_offset + self.height) { self.y_offset = cur + 1 - self.height; } const total = self.totalVisualRows(); if (total > self.height) { const max_offset = total - self.height; if (self.y_offset > max_offset) self.y_offset = max_offset; } else { self.y_offset = 0; } } fn cursorVisualRow(self: *const TextArea) usize { if (!self.soft_wrap) return self.row; if (self.lines.items.len == 0) return self.row; const cw = self.contentCellsLeft(); if (cw == 0) return self.row; var v: usize = 0; var i: usize = 0; while (i < self.row) : (i += 1) { v += visualRowsForLine(self.lines.items[i].items, cw); } return v + colVisualRow(self.lines.items[self.row].items, cw, self.col); } fn totalVisualRows(self: *const TextArea) usize { if (!self.soft_wrap) return self.lines.items.len; const cw = self.contentCellsLeft(); if (cw == 0) return self.lines.items.len; var total: usize = 0; for (self.lines.items) |line| total += visualRowsForLine(line.items, cw); return total; } fn writeLine(self: *TextArea, w: *Writer, idx: usize, line: []const u21) Writer.Error!void { const is_cursor_line = idx == self.row and self.focused; const bg: matte.Style = if (is_cursor_line) self.styles.cursor_line else .none; try util.writeInline(w, withBackground(self.styles.prompt, bg), self.prompt); if (self.show_line_numbers) { const ln_style = if (is_cursor_line) (self.styles.cursor_line_number.maybeStyle() orelse self.styles.line_number) else self.styles.line_number; try self.writeLineNumber(w, idx, withBackground(ln_style, bg)); } const cells_left = self.contentCellsLeft(); const cursor_col = if (is_cursor_line) self.col else line.len; // sentinel "no cursor" const text_style = withBackground(self.styles.text, bg); // Render up to `cells_left` codepoints from `line`, drawing the // virtual cursor when we hit `cursor_col`. var emitted: u16 = 0; var cur_buf: [4]u8 = undefined; var i: usize = 0; while (i < line.len) : (i += 1) { if (emitted >= cells_left) break; if (i == cursor_col and self.use_virtual_cursor) { const len = std.unicode.utf8Encode(line[i], &cur_buf) catch 1; self.cursor_blink.char = cur_buf[0..len]; self.cursor_blink.style = withBackground(self.styles.cursor, bg); self.cursor_blink.text_style = withBackground(self.styles.cursor_text.maybeStyle() orelse self.styles.text, bg); try self.cursor_blink.view(w); emitted += 1; continue; } const len = std.unicode.utf8Encode(line[i], &cur_buf) catch 1; if (text_style.maybeStyle()) |s| try openInlineStyle(w, s); try w.writeAll(cur_buf[0..len]); if (text_style.maybeStyle()) |s| try closeInlineStyle(w, s); emitted += 1; } // Cursor at end of line. Only the virtual cursor renders an extra cell; // the real-cursor mode lets the parent's View.cursor mark the spot. if (is_cursor_line and cursor_col == line.len and emitted < cells_left and self.use_virtual_cursor) { self.cursor_blink.char = " "; self.cursor_blink.style = withBackground(self.styles.cursor, bg); self.cursor_blink.text_style = withBackground(self.styles.cursor_text.maybeStyle() orelse self.styles.text, bg); try self.cursor_blink.view(w); emitted += 1; } // Extend the cursor-line background to the full width. if (bg.maybeStyle()) |s| try self.padToWidth(w, emitted, s); } fn writeEndOfBuffer(self: *TextArea, w: *Writer) Writer.Error!void { try util.writeInline(w, self.styles.prompt, self.prompt); if (self.show_line_numbers) { // Right-aligned space block matching line-number column width. const ln_width = self.lineNumberWidth(); var i: u16 = 0; while (i < ln_width) : (i += 1) try w.writeByte(' '); } if (self.styles.end_of_buffer.maybeStyle()) |s| try openInlineStyle(w, s); try w.writeByte(self.end_of_buffer); if (self.styles.end_of_buffer.maybeStyle()) |s| try closeInlineStyle(w, s); } fn writeLineNumber(self: *TextArea, w: *Writer, idx: usize, style: matte.Style) Writer.Error!void { const total = @max(self.lines.items.len, 1); const digits = countDigits(total); var buf: [16]u8 = undefined; const num = std.fmt.bufPrint(&buf, "{d}", .{idx + 1}) catch "?"; if (style.maybeStyle()) |s| try openInlineStyle(w, s); var pad = digits -| num.len; while (pad > 0) : (pad -= 1) try w.writeByte(' '); try w.writeAll(num); try w.writeByte(' '); if (style.maybeStyle()) |s| try closeInlineStyle(w, s); } /// Returns `base` with its background overridden by `bg`'s background. Used /// so the cursor-line background survives the per-segment `\x1b[0m` resets /// emitted by `writeInline`/`openInlineStyle`/`closeInlineStyle`. fn withBackground(base: matte.Style, bg: matte.Style) matte.Style { if (bg.isNone()) return base; const color = bg.background orelse return base; var style = base; style.background = color; return style; } /// Pads the current row so that prompt + line-number gutter + `content_cells` /// together span the full `width`. Used to fill the cursor-line background. fn padToWidth(self: *const TextArea, writer: *Writer, content_cells: u16, bg: matte.Style) Writer.Error!void { const prompt_w = matte.measure.cellWidth(self.prompt); const ln_w = self.lineNumberWidth(); const used = prompt_w +| ln_w +| content_cells; if (used >= self.width) return; const pad = self.width - used; if (bg.maybeStyle()) |s| try openInlineStyle(writer, s); var k: u16 = 0; while (k < pad) : (k += 1) try writer.writeByte(' '); if (bg.maybeStyle()) |s| try closeInlineStyle(writer, s); } /// Renders the placeholder as a full `height`-row editor, mirroring bubbles' /// `placeholderView` (textarea.go:1652-1730): the first row is the cursor /// line (highlighted, line number `1`, placeholder text under the cursor); /// the remaining rows are blank end-of-buffer rows. fn placeholderView(self: *TextArea, w: *Writer) Writer.Error!void { const cells = self.contentCellsLeft(); const ln_w = self.lineNumberWidth(); var r: u16 = 0; while (r < self.height) : (r += 1) { if (r > 0) try w.writeByte('\n'); const is_cursor_line = r == 0 and self.focused; const bg: matte.Style = if (is_cursor_line) self.styles.cursor_line else .none; try util.writeInline(w, withBackground(self.styles.prompt, bg), self.prompt); if (self.show_line_numbers) { if (r == 0) { const ln_style = if (is_cursor_line) (self.styles.cursor_line_number.maybeStyle() orelse self.styles.line_number) else self.styles.line_number; try self.writeLineNumber(w, 0, withBackground(ln_style, bg)); } else { var i: u16 = 0; while (i < ln_w) : (i += 1) try w.writeByte(' '); } } // if (r != 0) { // if (self.styles.end_of_buffer) |s| try openInlineStyle(w, s); // try w.writeByte(self.end_of_buffer); // if (self.styles.end_of_buffer) |s| try closeInlineStyle(w, s); // } if (r == 0) { // First placeholder grapheme sits under the cursor; the rest // renders styled with `placeholder`. var iter = (std.unicode.Utf8View.init(self.placeholder) catch return).iterator(); var emitted: u16 = 0; var buf: [4]u8 = undefined; if (iter.nextCodepoint()) |first_cp| { const len = std.unicode.utf8Encode(first_cp, &buf) catch 1; if (is_cursor_line and self.use_virtual_cursor) { self.cursor_blink.char = buf[0..len]; self.cursor_blink.style = withBackground(self.styles.cursor, bg); self.cursor_blink.text_style = withBackground(self.styles.placeholder, bg); try self.cursor_blink.view(w); } else { try util.writeInline(w, withBackground(self.styles.placeholder, bg), buf[0..len]); } emitted += 1; } const placeholder_style = withBackground(self.styles.placeholder, bg); if (placeholder_style.maybeStyle()) |s| try openInlineStyle(w, s); while (iter.nextCodepoint()) |cp| { if (emitted >= cells) break; const len = std.unicode.utf8Encode(cp, &buf) catch 1; try w.writeAll(buf[0..len]); emitted += 1; } if (placeholder_style.maybeStyle()) |s| try closeInlineStyle(w, s); if (bg.maybeStyle()) |s| try self.padToWidth(w, emitted, s); } else { if (self.styles.end_of_buffer.maybeStyle()) |s| try openInlineStyle(w, s); try w.writeByte(self.end_of_buffer); if (self.styles.end_of_buffer.maybeStyle()) |s| try closeInlineStyle(w, s); } } } fn lineNumberWidth(self: *const TextArea) u16 { if (!self.show_line_numbers) return 0; return @intCast(countDigits(@max(self.lines.items.len, 1)) + 1); } fn contentCellsLeft(self: *const TextArea) u16 { const prompt_w = matte.measure.cellWidth(self.prompt); const ln_w = self.lineNumberWidth(); const overhead = prompt_w +| ln_w; return if (self.width > overhead) self.width - overhead else 0; } fn countDigits(n: usize) usize { var v = n; var d: usize = 1; while (v >= 10) : (v /= 10) d += 1; return d; } fn openInlineStyle(writer: *Writer, style: matte.Style) Writer.Error!void { if (style.bold) try writer.writeAll("\x1b[1m"); if (style.faint) try writer.writeAll("\x1b[2m"); if (style.italic) try writer.writeAll("\x1b[3m"); switch (style.underline) { .none => {}, .single => try writer.writeAll("\x1b[4m"), .double => try writer.writeAll("\x1b[4:2m"), .curly => try writer.writeAll("\x1b[4:3m"), .dotted => try writer.writeAll("\x1b[4:4m"), .dashed => try writer.writeAll("\x1b[4:5m"), } if (style.foreground) |color| try color.writeSgr(writer, .fg); if (style.background) |color| try color.writeSgr(writer, .bg); } fn closeInlineStyle(writer: *Writer, style: matte.Style) Writer.Error!void { if (style.bold or style.italic or style.underline != .none or style.faint or style.foreground != null or style.background != null) try writer.writeAll("\x1b[0m"); } fn newForTest() TextArea { var ta: TextArea = .{}; ta.cursor_blink.mode = .static; // skip blink-tick cmd allocation return ta; } test "setValue / encodeValue roundtrip" { var ta = newForTest(); defer ta.deinit(t.allocator); try ta.setValue(t.allocator, "hello\nworld"); try t.expectEqual(@as(usize, 2), ta.lineCount()); var buf: [32]u8 = undefined; const out = try ta.encodeValue(&buf); try t.expectEqualStrings("hello\nworld", out); } test "insertNewline splits current line" { var ta = newForTest(); defer ta.deinit(t.allocator); _ = ta.focus(t.allocator); try ta.setValue(t.allocator, "hello world"); ta.row = 0; ta.col = 5; try ta.insertNewline(t.allocator); var buf: [32]u8 = undefined; const out = try ta.encodeValue(&buf); try t.expectEqualStrings("hello\n world", out); try t.expectEqual(@as(usize, 1), ta.row); try t.expectEqual(@as(usize, 0), ta.col); } test "deleteCharBackward at start of line merges with previous" { var ta = newForTest(); defer ta.deinit(t.allocator); _ = ta.focus(t.allocator); try ta.setValue(t.allocator, "ab\ncd"); ta.row = 1; ta.col = 0; ta.deleteCharBackward(t.allocator); var buf: [32]u8 = undefined; const out = try ta.encodeValue(&buf); try t.expectEqualStrings("abcd", out); try t.expectEqual(@as(usize, 0), ta.row); try t.expectEqual(@as(usize, 2), ta.col); } test "deleteAfterCursor at end of line merges next line" { var ta = newForTest(); defer ta.deinit(t.allocator); _ = ta.focus(t.allocator); try ta.setValue(t.allocator, "ab\ncd"); ta.row = 0; ta.col = 2; // end of "ab" ta.deleteAfterCursor(t.allocator); var buf: [32]u8 = undefined; try t.expectEqualStrings("abcd", try ta.encodeValue(&buf)); try t.expectEqual(@as(usize, 2), ta.col); // Middle of a line still just truncates the tail. try ta.setValue(t.allocator, "abcdef"); ta.col = 2; ta.deleteAfterCursor(t.allocator); try t.expectEqualStrings("ab", try ta.encodeValue(&buf)); } test "deleteBeforeCursor at start of line merges line above" { var ta = newForTest(); defer ta.deinit(t.allocator); _ = ta.focus(t.allocator); try ta.setValue(t.allocator, "ab\ncd"); ta.row = 1; ta.col = 0; ta.deleteBeforeCursor(t.allocator); var buf: [32]u8 = undefined; try t.expectEqualStrings("abcd", try ta.encodeValue(&buf)); try t.expectEqual(@as(usize, 0), ta.row); try t.expectEqual(@as(usize, 2), ta.col); // Middle of a line still clears the head. try ta.setValue(t.allocator, "abcdef"); ta.row = 0; ta.col = 4; ta.deleteBeforeCursor(t.allocator); try t.expectEqualStrings("ef", try ta.encodeValue(&buf)); try t.expectEqual(@as(usize, 0), ta.col); } test "placeholder renders while focused too" { var ta = newForTest(); defer ta.deinit(t.allocator); ta.prompt = ""; ta.placeholder = "hint"; ta.styles = .unstyled; ta.use_virtual_cursor = false; // placeholder renders contiguously ta.focused = true; var buf: [128]u8 = undefined; var w: Writer = .fixed(&buf); ta.height = 1; ta.width = 10; try ta.view(&w); try t.expect(std.mem.find(u8, w.buffered(), "hint") != null); } test "placeholder fills the full height" { var ta = newForTest(); defer ta.deinit(t.allocator); ta.prompt = ""; ta.placeholder = "hint"; ta.styles = .unstyled; ta.use_virtual_cursor = false; ta.focused = true; ta.height = 4; ta.width = 10; var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try ta.view(&w); // 4 rows (3 newlines), each showing the line-number gutter or eob. try t.expectEqual(@as(usize, 3), std.mem.count(u8, w.buffered(), "\n")); try t.expect(std.mem.find(u8, w.buffered(), "hint") != null); } test "cursor line renders a background highlight" { var ta = newForTest(); defer ta.deinit(t.allocator); ta.prompt = ""; ta.placeholder = "hi"; ta.use_virtual_cursor = false; ta.focused = true; ta.height = 2; ta.width = 20; // default_styles has cursor_line background ansi 0. var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try ta.view(&w); try t.expect(std.mem.find(u8, w.buffered(), "\x1b[48;5;0m") != null); } test "lineNext preserves last_col_offset over short line" { var ta = newForTest(); defer ta.deinit(t.allocator); ta.soft_wrap = false; _ = ta.focus(t.allocator); try ta.setValue(t.allocator, "abcdef\nxy\nlongerline"); ta.row = 0; ta.col = 5; ta.last_col_offset = 5; ta.lineNext(); try t.expectEqual(@as(usize, 1), ta.row); try t.expectEqual(@as(usize, 2), ta.col); // clamped to line length ta.lineNext(); try t.expectEqual(@as(usize, 2), ta.row); try t.expectEqual(@as(usize, 5), ta.col); // restored from intent } test "char_limit blocks insertion" { var ta = newForTest(); defer ta.deinit(t.allocator); ta.char_limit = 3; _ = ta.focus(t.allocator); try ta.insertText(t.allocator, "hello"); try t.expectEqual(@as(usize, 3), ta.length()); } test "soft-wrap wraps long line into visual rows" { var ta = newForTest(); defer ta.deinit(t.allocator); ta.soft_wrap = true; ta.show_line_numbers = false; ta.prompt = ""; ta.styles = .unstyled; ta.width = 4; ta.height = 3; _ = ta.focus(t.allocator); try ta.setValue(t.allocator, "abcdefghi"); // Move cursor off the cursor cell so it doesn't appear in output. ta.blur(); var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try ta.view(&w); try t.expectEqualStrings("abcd\nefgh\ni", w.buffered()); } test "soft-wrap breaks at word boundaries" { var ta = newForTest(); defer ta.deinit(t.allocator); ta.soft_wrap = true; ta.show_line_numbers = false; ta.prompt = ""; ta.styles = .unstyled; ta.width = 8; ta.height = 3; _ = ta.focus(t.allocator); try ta.setValue(t.allocator, "the quick brown"); ta.blur(); var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try ta.view(&w); try t.expectEqualStrings("the \nquick \nbrown", w.buffered()); } test "soft-wrap cursor line renders a background highlight" { var ta = newForTest(); defer ta.deinit(t.allocator); ta.show_line_numbers = false; ta.prompt = ""; ta.use_virtual_cursor = false; ta.focused = true; ta.width = 8; ta.height = 2; try ta.setValue(t.allocator, "hello world"); // Cursor is at the end of "hello world" → the whole logical line (both // wrapped rows "hello " and "world") carries the background. var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try ta.view(&w); try t.expect(std.mem.find(u8, w.buffered(), "\x1b[48;5;0mh") != null); try t.expect(std.mem.find(u8, w.buffered(), "\x1b[48;5;0mw") != null); } test "soft-wrap linePrevious moves visual row" { var ta = newForTest(); defer ta.deinit(t.allocator); ta.soft_wrap = true; ta.show_line_numbers = false; ta.prompt = ""; ta.width = 4; ta.height = 3; _ = ta.focus(t.allocator); try ta.setValue(t.allocator, "abcdefghi"); // Cursor at end → row 0, col 9. Visual row 2 (vcol 1). ta.linePrevious(); // Now visual row 1: col should become 9 - 4 = 5. try t.expectEqual(@as(usize, 5), ta.col); } test "view renders prompt, line numbers, lines" { var ta = newForTest(); defer ta.deinit(t.allocator); ta.width = 30; ta.height = 3; ta.prompt = "> "; ta.show_line_numbers = true; ta.styles = .unstyled; _ = ta.focus(t.allocator); try ta.setValue(t.allocator, "ab\ncd"); var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try ta.view(&w); // Two content lines + one end-of-buffer line. Cursor sits at end of "cd". const want = "> 1 ab\n> 2 cd\x1b[7m \x1b[0m\n> "; try t.expectEqualStrings(want, w.buffered()); }