Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547//! 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());}