//! 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; horizontal overflow is truncated (no soft-wrap in v1). //! //! v1 scope: char/word/line navigation, insert + delete, optional line //! numbers, prompt prefix per line, placeholder when empty, char limit, //! line-cap, paste handling. Skipped from bubbles: soft-wrap with the //! memoization cache, prompt function, dynamic height, transpose, case //! change, real-cursor mode, clipboard paste cmd. 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 Textarea = @This(); 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') } }, 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')} }, }; pub const Styles = struct { text: ?blush.Style = null, prompt: ?blush.Style = null, placeholder: ?blush.Style = null, line_number: ?blush.Style = null, cursor: ?blush.Style = null, cursor_text: ?blush.Style = null, }; pub const default_styles: Styles = .{ .prompt = .{ .foreground = .{ .ansi256 = 7 } }, .placeholder = .{ .foreground = .{ .ansi256 = 240 } }, .line_number = .{ .foreground = .{ .ansi256 = 7 } }, }; model: matcha.Model = .{ .vtable = &vtable }, 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 instead of being truncated. `y_offset` counts visual rows. /// Vertical nav (`line_previous`/`line_next`) moves by visual rows too. soft_wrap: bool = false, /// 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 = default_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: matcha.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, const vtable: matcha.Model.VTable = .{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, }; fn vtableInit(m: *matcha.Model) ?Cmd { const self: *Textarea = @fieldParentPtr("model", m); return self.focus(m.gpa); } fn vtableUpdate(m: *matcha.Model, msg: Msg) ?Cmd { const self: *Textarea = @fieldParentPtr("model", m); return self.update(m.gpa, msg); } fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.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) ?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) ?matcha.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 = blush.measure.cellWidth(self.prompt); const ln_w = self.lineNumberWidth(); const vcol: usize = if (self.soft_wrap) blk: { const cw = self.contentCellsLeft(); break :blk if (cw == 0) self.col else self.col % cw; } 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: Msg) ?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(); } 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 matcha.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, w: *Writer) Writer.Error!void { if (self.height == 0 or self.width == 0) return; if (self.lines.items.len == 0 and self.placeholder.len > 0 and !self.focused) { try util.writeInline(w, self.styles.prompt, self.prompt); try util.writeInline(w, self.styles.placeholder, self.placeholder); 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(w, 0, &.{}); var i: u16 = 1; while (i < self.height) : (i += 1) { try w.writeByte('\n'); try self.writeEndOfBuffer(w); } return; } if (self.soft_wrap) { try self.viewSoftWrap(w); } else { try self.viewHardCut(w); } } fn viewHardCut(self: *Textarea, w: *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 w.writeByte('\n'); try self.writeLine(w, idx, self.lines.items[idx].items); rendered += 1; } while (rendered < self.height) : (rendered += 1) { try w.writeByte('\n'); try self.writeEndOfBuffer(w); } } fn viewSoftWrap(self: *Textarea, w: *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; const rows = visualRowsForLine(line.len, content_w); 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 w.writeByte('\n'); try self.writeChunk(w, line_idx, line, chunk, content_w); rendered += 1; } } while (rendered < self.height) : (rendered += 1) { if (rendered > 0) try w.writeByte('\n'); try self.writeEndOfBuffer(w); } } fn writeChunk( self: *Textarea, w: *Writer, line_idx: usize, line: []const u21, chunk_idx: usize, content_w: u16, ) Writer.Error!void { try util.writeInline(w, self.styles.prompt, self.prompt); if (self.show_line_numbers) { if (chunk_idx == 0) { try self.writeLineNumber(w, line_idx); } else { // Continuation row: blank pad matching line-number width. const ln_w = self.lineNumberWidth(); var i: u16 = 0; while (i < ln_w) : (i += 1) try w.writeByte(' '); } } const start_cp: usize = chunk_idx * content_w; const end_cp: usize = @min(start_cp + content_w, line.len); const is_cursor_line = line_idx == self.row and self.focused; const cursor_col = if (is_cursor_line) self.col else line.len; var emitted: u16 = 0; var cur_buf: [4]u8 = undefined; var i: usize = start_cp; while (i < end_cp) : (i += 1) { if (emitted >= content_w) 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 = self.styles.cursor; self.cursor_blink.text_style = self.styles.cursor_text orelse self.styles.text; try self.cursor_blink.view(w); emitted += 1; continue; } const len = std.unicode.utf8Encode(line[i], &cur_buf) catch 1; if (self.styles.text) |s| try openInlineStyle(w, s); try w.writeAll(cur_buf[0..len]); if (self.styles.text) |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 < content_w and self.use_virtual_cursor) { self.cursor_blink.char = " "; self.cursor_blink.style = self.styles.cursor; self.cursor_blink.text_style = self.styles.cursor_text orelse self.styles.text; try self.cursor_blink.view(w); } } fn visualRowsForLine(len: usize, content_w: u16) usize { if (content_w == 0 or len == 0) return 1; return (len + content_w - 1) / content_w; } // ---- editing ---- 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) void { if (self.lines.items.len == 0) return; self.lines.items[self.row].shrinkRetainingCapacity(self.col); self.last_col_offset = self.col; } fn deleteBeforeCursor(self: *Textarea, allocator: Allocator) void { if (self.lines.items.len == 0 or self.col == 0) 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; _ = allocator; } 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.soft_wrap) { const cw = self.contentCellsLeft(); if (cw > 0 and self.col >= cw) { // Move up one visual row within the current logical line. self.col -= cw; return; } } if (self.row == 0) return; self.row -= 1; const prev = self.lines.items[self.row].items.len; if (self.soft_wrap) { const cw = self.contentCellsLeft(); if (cw > 0) { const last_vrow = visualRowsForLine(prev, cw) - 1; const intent_vcol = self.last_col_offset % cw; const new_col = last_vrow * cw + intent_vcol; self.col = @min(new_col, prev); return; } } self.col = @min(self.last_col_offset, prev); } fn lineNext(self: *Textarea) void { if (self.lines.items.len == 0) return; if (self.soft_wrap) { const cw = self.contentCellsLeft(); const line_len = self.lines.items[self.row].items.len; if (cw > 0) { const this_vrow = self.col / cw; const last_vrow = visualRowsForLine(line_len, cw) - 1; if (this_vrow < last_vrow) { self.col = @min(self.col + cw, line_len); return; } } } if (self.row + 1 >= self.lines.items.len) return; self.row += 1; const next_len = self.lines.items[self.row].items.len; if (self.soft_wrap) { const cw = self.contentCellsLeft(); if (cw > 0) { const intent_vcol = self.last_col_offset % cw; self.col = @min(intent_vcol, next_len); 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; 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.len, cw); } return v + self.col / cw; } 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.len, cw); return total; } // ---- rendering ---- fn writeLine(self: *Textarea, w: *Writer, idx: usize, line: []const u21) Writer.Error!void { try util.writeInline(w, self.styles.prompt, self.prompt); if (self.show_line_numbers) try self.writeLineNumber(w, idx); const cells_left = self.contentCellsLeft(); const is_cursor_line = idx == self.row and self.focused; const cursor_col = if (is_cursor_line) self.col else line.len; // sentinel "no cursor" // 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 = self.styles.cursor; self.cursor_blink.text_style = self.styles.cursor_text orelse self.styles.text; try self.cursor_blink.view(w); emitted += 1; continue; } const len = std.unicode.utf8Encode(line[i], &cur_buf) catch 1; if (self.styles.text) |s| try openInlineStyle(w, s); try w.writeAll(cur_buf[0..len]); if (self.styles.text) |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 = self.styles.cursor; self.cursor_blink.text_style = self.styles.cursor_text orelse self.styles.text; try self.cursor_blink.view(w); emitted += 1; } } 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(' '); } try w.writeByte(self.end_of_buffer); } fn writeLineNumber(self: *Textarea, w: *Writer, idx: usize) 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 (self.styles.line_number) |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 (self.styles.line_number) |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 = blush.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(w: *Writer, s: blush.Style) Writer.Error!void { if (s.bold) try w.writeAll("\x1b[1m"); if (s.faint) try w.writeAll("\x1b[2m"); if (s.italic) try w.writeAll("\x1b[3m"); switch (s.underline) { .none => {}, .single => try w.writeAll("\x1b[4m"), .double => try w.writeAll("\x1b[4:2m"), .curly => try w.writeAll("\x1b[4:3m"), .dotted => try w.writeAll("\x1b[4:4m"), .dashed => try w.writeAll("\x1b[4:5m"), } if (s.foreground) |c| try c.writeSgr(w, .fg); if (s.background) |c| try c.writeSgr(w, .bg); } fn closeInlineStyle(w: *Writer, s: blush.Style) Writer.Error!void { if (s.bold or s.italic or s.underline != .none or s.faint or s.foreground != null or s.background != null) try w.writeAll("\x1b[0m"); } // ---- tests ---- const testing = std.testing; 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(testing.allocator); try ta.setValue(testing.allocator, "hello\nworld"); try testing.expectEqual(@as(usize, 2), ta.lineCount()); var buf: [32]u8 = undefined; const out = try ta.encodeValue(&buf); try testing.expectEqualStrings("hello\nworld", out); } test "insertNewline splits current line" { var ta = newForTest(); defer ta.deinit(testing.allocator); _ = ta.focus(testing.allocator); try ta.setValue(testing.allocator, "hello world"); ta.row = 0; ta.col = 5; try ta.insertNewline(testing.allocator); var buf: [32]u8 = undefined; const out = try ta.encodeValue(&buf); try testing.expectEqualStrings("hello\n world", out); try testing.expectEqual(@as(usize, 1), ta.row); try testing.expectEqual(@as(usize, 0), ta.col); } test "deleteCharBackward at start of line merges with previous" { var ta = newForTest(); defer ta.deinit(testing.allocator); _ = ta.focus(testing.allocator); try ta.setValue(testing.allocator, "ab\ncd"); ta.row = 1; ta.col = 0; ta.deleteCharBackward(testing.allocator); var buf: [32]u8 = undefined; const out = try ta.encodeValue(&buf); try testing.expectEqualStrings("abcd", out); try testing.expectEqual(@as(usize, 0), ta.row); try testing.expectEqual(@as(usize, 2), ta.col); } test "lineNext preserves last_col_offset over short line" { var ta = newForTest(); defer ta.deinit(testing.allocator); _ = ta.focus(testing.allocator); try ta.setValue(testing.allocator, "abcdef\nxy\nlongerline"); ta.row = 0; ta.col = 5; ta.last_col_offset = 5; ta.lineNext(); try testing.expectEqual(@as(usize, 1), ta.row); try testing.expectEqual(@as(usize, 2), ta.col); // clamped to line length ta.lineNext(); try testing.expectEqual(@as(usize, 2), ta.row); try testing.expectEqual(@as(usize, 5), ta.col); // restored from intent } test "char_limit blocks insertion" { var ta = newForTest(); defer ta.deinit(testing.allocator); ta.char_limit = 3; _ = ta.focus(testing.allocator); try ta.insertText(testing.allocator, "hello"); try testing.expectEqual(@as(usize, 3), ta.length()); } test "soft-wrap wraps long line into visual rows" { var ta = newForTest(); defer ta.deinit(testing.allocator); ta.soft_wrap = true; ta.show_line_numbers = false; ta.prompt = ""; ta.styles = .{}; ta.width = 4; ta.height = 3; _ = ta.focus(testing.allocator); try ta.setValue(testing.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 testing.expectEqualStrings("abcd\nefgh\ni", w.buffered()); } test "soft-wrap linePrevious moves visual row" { var ta = newForTest(); defer ta.deinit(testing.allocator); ta.soft_wrap = true; ta.show_line_numbers = false; ta.prompt = ""; ta.width = 4; ta.height = 3; _ = ta.focus(testing.allocator); try ta.setValue(testing.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 testing.expectEqual(@as(usize, 5), ta.col); } test "view renders prompt, line numbers, lines" { var ta = newForTest(); defer ta.deinit(testing.allocator); ta.width = 30; ta.height = 3; ta.prompt = "> "; ta.show_line_numbers = true; ta.styles = .{}; _ = ta.focus(testing.allocator); try ta.setValue(testing.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 testing.expectEqualStrings(want, w.buffered()); }