summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/markdown.zig740
-rw-r--r--src/tui_components.zig113
-rw-r--r--src/tui_engine.zig14
3 files changed, 804 insertions, 63 deletions
diff --git a/src/markdown.zig b/src/markdown.zig
index 13358b4..73c5a1f 100644
--- a/src/markdown.zig
+++ b/src/markdown.zig
@@ -12,6 +12,65 @@ const c = @cImport({
});
const Allocator = std.mem.Allocator;
+const max_style_depth = 64;
+const max_list_depth = 64;
+
+fn codepointWidth(cp: u21) usize {
+ if (cp == 0x200D) return 0;
+ if (cp >= 0x0300 and cp <= 0x036F) return 0;
+ if (cp >= 0x0483 and cp <= 0x0489) return 0;
+ if (cp >= 0x0591 and cp <= 0x05BD) return 0;
+ if (cp >= 0x0610 and cp <= 0x061A) return 0;
+ if (cp >= 0x064B and cp <= 0x065F) return 0;
+ if (cp >= 0x1AB0 and cp <= 0x1AFF) return 0;
+ if (cp >= 0x1DC0 and cp <= 0x1DFF) return 0;
+ if (cp >= 0x20D0 and cp <= 0x20FF) return 0;
+ if (cp >= 0xFE00 and cp <= 0xFE0F) return 0;
+ if (cp >= 0xFE20 and cp <= 0xFE2F) return 0;
+ if (cp >= 0xE0100 and cp <= 0xE01EF) return 0;
+
+ if (cp >= 0x1100 and cp <= 0x115F) return 2;
+ if (cp >= 0x2E80 and cp <= 0x303E) return 2;
+ if (cp >= 0x3041 and cp <= 0x33BF) return 2;
+ if (cp >= 0x3400 and cp <= 0x4DBF) return 2;
+ if (cp >= 0x4E00 and cp <= 0x9FFF) return 2;
+ if (cp >= 0xA000 and cp <= 0xA4CF) return 2;
+ if (cp >= 0xA960 and cp <= 0xA97F) return 2;
+ if (cp >= 0xAC00 and cp <= 0xD7AF) return 2;
+ if (cp >= 0xD7B0 and cp <= 0xD7FF) return 2;
+ if (cp >= 0xF900 and cp <= 0xFAFF) return 2;
+ if (cp >= 0xFE10 and cp <= 0xFE1F) return 2;
+ if (cp >= 0xFE30 and cp <= 0xFE4F) return 2;
+ if (cp >= 0xFF01 and cp <= 0xFF60) return 2;
+ if (cp >= 0xFFE0 and cp <= 0xFFE6) return 2;
+ if (cp >= 0x1B000 and cp <= 0x1B0FF) return 2;
+ if (cp >= 0x1F004 and cp <= 0x1F004) return 2;
+ if (cp >= 0x1F0CF and cp <= 0x1F0CF) return 2;
+ if (cp >= 0x1F200 and cp <= 0x1F2FF) return 2;
+ if (cp >= 0x1F300 and cp <= 0x1F64F) return 2;
+ if (cp >= 0x1F680 and cp <= 0x1F6FF) return 2;
+ if (cp >= 0x1F700 and cp <= 0x1F77F) return 2;
+ if (cp >= 0x1F780 and cp <= 0x1F7FF) return 2;
+ if (cp >= 0x1F800 and cp <= 0x1F8FF) return 2;
+ if (cp >= 0x1F900 and cp <= 0x1F9FF) return 2;
+ if (cp >= 0x1FA00 and cp <= 0x1FAFF) return 2;
+ if (cp >= 0x20000 and cp <= 0x2A6DF) return 2;
+ if (cp >= 0x2A700 and cp <= 0x2CEAF) return 2;
+ if (cp >= 0x2CEB0 and cp <= 0x2EBEF) return 2;
+ if (cp >= 0x2F800 and cp <= 0x2FA1F) return 2;
+ if (cp >= 0x30000 and cp <= 0x3134F) return 2;
+
+ return 1;
+}
+
+fn tabWidth(col: usize) usize {
+ const rem = col % 8;
+ return if (rem == 0) 8 else 8 - rem;
+}
+
+fn codepointWidthAt(cp: u21, col: usize) usize {
+ return if (cp == '\t') tabWidth(col) else codepointWidth(cp);
+}
/// Return the largest prefix that is safe to render while markdown is still
/// streaming. Avoid rendering an unterminated trailing line (which may still
@@ -64,27 +123,41 @@ pub fn streamingSafeCut(buffer: []const u8) []const u8 {
return safe[0..opener];
}
-fn isAnsiAt(s: []const u8, i: usize) bool {
- return i + 1 < s.len and s[i] == '\x1b' and s[i + 1] == '[';
-}
-
-fn skipAnsi(s: []const u8, start_i: usize) usize {
- var i = start_i + 2;
- while (i < s.len) : (i += 1) {
- const ch = s[i];
- if (ch >= '@' and ch <= '~') return i + 1;
+fn skipEscape(s: []const u8, start_i: usize) ?usize {
+ if (start_i + 1 >= s.len or s[start_i] != '\x1b') return null;
+ switch (s[start_i + 1]) {
+ '[' => {
+ var i = start_i + 2;
+ while (i < s.len) : (i += 1) {
+ if (s[i] >= '@' and s[i] <= '~') return i + 1;
+ }
+ return s.len;
+ },
+ ']' => {
+ var i = start_i + 2;
+ while (i < s.len) : (i += 1) {
+ if (s[i] == 0x07) return i + 1;
+ if (s[i] == '\x1b' and i + 1 < s.len and s[i + 1] == '\\') return i + 2;
+ }
+ return s.len;
+ },
+ else => return null,
}
- return s.len;
}
fn visibleWidth(s: []const u8) usize {
var w: usize = 0;
var i: usize = 0;
while (i < s.len) {
- if (isAnsiAt(s, i)) { i = skipAnsi(s, i); continue; }
+ if (skipEscape(s, i)) |next| {
+ i = next;
+ continue;
+ }
const n = std.unicode.utf8ByteSequenceLength(s[i]) catch 1;
- i += @min(n, s.len - i);
- w += 1;
+ const adv = @min(n, s.len - i);
+ const cp = std.unicode.utf8Decode(s[i .. i + adv]) catch '?';
+ i += adv;
+ w += codepointWidthAt(cp, w);
}
return w;
}
@@ -93,16 +166,28 @@ fn takeVisible(s: []const u8, max: usize) usize {
var w: usize = 0;
var i: usize = 0;
while (i < s.len) {
- if (isAnsiAt(s, i)) { i = skipAnsi(s, i); continue; }
+ if (skipEscape(s, i)) |next| {
+ i = next;
+ continue;
+ }
const n = std.unicode.utf8ByteSequenceLength(s[i]) catch 1;
const adv = @min(n, s.len - i);
- if (w + 1 > max) break;
+ const cp = std.unicode.utf8Decode(s[i .. i + adv]) catch '?';
+ const cp_w = codepointWidthAt(cp, w);
+ if (w + cp_w > max) break;
i += adv;
- w += 1;
+ w += cp_w;
}
return i;
}
+fn maxLineVisibleWidth(s: []const u8) usize {
+ var max: usize = 0;
+ var it = std.mem.splitScalar(u8, s, '\n');
+ while (it.next()) |line| max = @max(max, visibleWidth(line));
+ return max;
+}
+
/// ANSI-aware greedy wrapping. The input may contain CSI styling escapes.
pub fn wrapStyled(buf: []const u8, width: usize, out: *std.ArrayList(u8), alloc: Allocator) !void {
const w = @max(width, 1);
@@ -128,6 +213,49 @@ pub fn wrapStyled(buf: []const u8, width: usize, out: *std.ArrayList(u8), alloc:
}
}
+const TableCell = struct {
+ const Align = enum { left, center, right };
+
+ text: []const u8,
+ alignment: Align = .left,
+
+ fn deinit(self: *TableCell, alloc: Allocator) void {
+ alloc.free(self.text);
+ }
+};
+
+const TableRow = struct {
+ cells: std.ArrayList(TableCell) = .empty,
+ is_header: bool = false,
+
+ fn deinit(self: *TableRow, alloc: Allocator) void {
+ for (self.cells.items) |*cell| cell.deinit(alloc);
+ self.cells.deinit(alloc);
+ }
+};
+
+const CellLines = struct {
+ lines: std.ArrayList([]const u8) = .empty,
+
+ fn deinit(self: *CellLines, alloc: Allocator) void {
+ for (self.lines.items) |line| alloc.free(line);
+ self.lines.deinit(alloc);
+ }
+};
+
+const ListState = struct {
+ ordered: bool,
+ next: usize = 1,
+};
+
+const LinkState = struct {
+ href: []const u8,
+
+ fn deinit(self: *LinkState, alloc: Allocator) void {
+ alloc.free(self.href);
+ }
+};
+
pub const Renderer = struct {
alloc: Allocator,
width: usize,
@@ -138,20 +266,46 @@ pub const Renderer = struct {
list_depth: usize = 0,
list_item_depth: usize = 0,
list_item_first_paragraph: bool = false,
- list_item_depth_stack: [64]usize = [_]usize{0} ** 64,
- list_item_first_stack: [64]bool = [_]bool{false} ** 64,
+ list_stack: [max_list_depth]ListState = undefined,
+ list_item_depth_stack: [max_list_depth]usize = [_]usize{0} ** max_list_depth,
+ list_item_first_stack: [max_list_depth]bool = [_]bool{false} ** max_list_depth,
+ list_item_marker_stack: [max_list_depth][32]u8 = undefined,
+ list_item_marker_len_stack: [max_list_depth]usize = [_]usize{0} ** max_list_depth,
list_item_stack_len: usize = 0,
+ list_item_marker: [32]u8 = undefined,
+ list_item_marker_len: usize = 0,
heading_level: usize = 0,
+ quote_depth: usize = 0,
+ table_rows: std.ArrayList(TableRow) = .empty,
+ current_table_row: ?TableRow = null,
+ in_table: bool = false,
+ in_table_cell: bool = false,
+ current_cell_is_header: bool = false,
+ current_cell_align: TableCell.Align = .left,
+ style_stack: [max_style_depth][]const u8 = undefined,
+ style_stack_len: usize = 0,
+ current_link: ?LinkState = null,
err: ?anyerror = null,
pub fn render(self: *Renderer, src: []const u8) !void {
self.buf = .empty;
defer self.buf.deinit(self.alloc);
+ defer self.clearLinks();
self.err = null;
+ self.in_code_block = false;
+ self.list_depth = 0;
+ self.list_item_depth = 0;
+ self.list_item_first_paragraph = false;
+ self.list_item_stack_len = 0;
+ self.list_item_marker_len = 0;
+ self.heading_level = 0;
+ self.quote_depth = 0;
+ self.style_stack_len = 0;
+ self.clearTable();
var parser: c.MD_PARSER = std.mem.zeroes(c.MD_PARSER);
parser.abi_version = 0;
- parser.flags = c.MD_FLAG_TABLES | c.MD_FLAG_STRIKETHROUGH | c.MD_FLAG_PERMISSIVEURLAUTOLINKS;
+ parser.flags = c.MD_FLAG_TABLES | c.MD_FLAG_TASKLISTS | c.MD_FLAG_STRIKETHROUGH | c.MD_FLAG_PERMISSIVEURLAUTOLINKS;
parser.enter_block = enterBlock;
parser.leave_block = leaveBlock;
parser.enter_span = enterSpan;
@@ -168,7 +322,9 @@ pub const Renderer = struct {
}
}
- fn add(self: *Renderer, s: []const u8) !void { try self.buf.appendSlice(self.alloc, s); }
+ fn add(self: *Renderer, s: []const u8) !void {
+ try self.buf.appendSlice(self.alloc, s);
+ }
fn appendLine(self: *Renderer, s: []const u8) !void {
const line = try self.alloc.dupe(u8, s);
@@ -182,9 +338,22 @@ pub const Renderer = struct {
try self.appendLine("");
}
+ fn quotePrefix(self: *Renderer, out: *std.ArrayList(u8)) !void {
+ const quote = theme.default.fg(.dim);
+ var i: usize = 0;
+ while (i < self.quote_depth) : (i += 1) {
+ try out.appendSlice(self.alloc, quote.open());
+ try out.appendSlice(self.alloc, "┃ ");
+ try out.appendSlice(self.alloc, quote.close());
+ }
+ }
+
fn flushParagraph(self: *Renderer) !void {
const text = std.mem.trim(u8, self.buf.items, " \t\n");
- if (text.len == 0) { self.buf.clearRetainingCapacity(); return; }
+ if (text.len == 0) {
+ self.buf.clearRetainingCapacity();
+ return;
+ }
if (self.list_item_depth > 0) {
var first_prefix: std.ArrayList(u8) = .empty;
@@ -193,12 +362,14 @@ pub const Renderer = struct {
defer cont_prefix.deinit(self.alloc);
const indent = (self.list_item_depth - 1) * 2;
+ try self.quotePrefix(&first_prefix);
+ try self.quotePrefix(&cont_prefix);
try first_prefix.appendNTimes(self.alloc, ' ', indent);
- try cont_prefix.appendNTimes(self.alloc, ' ', indent + 2);
+ try cont_prefix.appendNTimes(self.alloc, ' ', indent + self.list_item_marker_len);
if (self.list_item_first_paragraph) {
- try first_prefix.appendSlice(self.alloc, "• ");
+ try first_prefix.appendSlice(self.alloc, self.list_item_marker[0..self.list_item_marker_len]);
} else {
- try first_prefix.appendNTimes(self.alloc, ' ', 2);
+ try first_prefix.appendNTimes(self.alloc, ' ', self.list_item_marker_len);
}
var wrapped: std.ArrayList(u8) = .empty;
@@ -217,11 +388,21 @@ pub const Renderer = struct {
}
self.list_item_first_paragraph = false;
} else {
+ var prefix: std.ArrayList(u8) = .empty;
+ defer prefix.deinit(self.alloc);
+ try self.quotePrefix(&prefix);
var wrapped: std.ArrayList(u8) = .empty;
defer wrapped.deinit(self.alloc);
- try wrapStyled(text, self.width, &wrapped, self.alloc);
+ const wrap_width = if (self.width > visibleWidth(prefix.items)) self.width - visibleWidth(prefix.items) else 1;
+ try wrapStyled(text, wrap_width, &wrapped, self.alloc);
var it = std.mem.splitScalar(u8, wrapped.items, '\n');
- while (it.next()) |line| try self.appendLine(line);
+ while (it.next()) |line| {
+ var tmp: std.ArrayList(u8) = .empty;
+ defer tmp.deinit(self.alloc);
+ try tmp.appendSlice(self.alloc, prefix.items);
+ try tmp.appendSlice(self.alloc, line);
+ try self.appendLine(tmp.items);
+ }
}
self.buf.clearRetainingCapacity();
}
@@ -242,31 +423,284 @@ pub const Renderer = struct {
self.buf.clearRetainingCapacity();
}
- fn fail(self: *Renderer, e: anyerror) c_int { self.err = e; return 1; }
+ fn appendTableCell(self: *Renderer) !void {
+ const text = std.mem.trim(u8, self.buf.items, " \t\n");
+ const row = if (self.current_table_row) |*row| row else return;
+ const owned = try self.alloc.dupe(u8, text);
+ errdefer self.alloc.free(owned);
+ try row.cells.append(self.alloc, .{ .text = owned, .alignment = self.current_cell_align });
+ row.is_header = row.is_header or self.current_cell_is_header;
+ self.buf.clearRetainingCapacity();
+ self.in_table_cell = false;
+ self.current_cell_is_header = false;
+ self.current_cell_align = .left;
+ }
+
+ fn appendTableBorder(
+ self: *Renderer,
+ widths: []const usize,
+ left: []const u8,
+ join: []const u8,
+ right: []const u8,
+ ) !void {
+ var line: std.ArrayList(u8) = .empty;
+ defer line.deinit(self.alloc);
+ try line.appendSlice(self.alloc, left);
+ for (widths, 0..) |w, i| {
+ var n: usize = 0;
+ while (n < w + 2) : (n += 1) try line.appendSlice(self.alloc, "─");
+ try line.appendSlice(self.alloc, if (i + 1 == widths.len) right else join);
+ }
+ try self.appendLine(line.items);
+ }
+
+ fn fitTableWidths(self: *Renderer, widths: []usize) void {
+ if (widths.len == 0) return;
+ const overhead = widths.len * 3 + 1;
+ const budget = if (self.width > overhead) self.width - overhead else widths.len;
+ for (widths) |*w| w.* = @max(w.*, 1);
+ while (true) {
+ var sum: usize = 0;
+ var biggest_i: usize = 0;
+ var biggest: usize = 0;
+ for (widths, 0..) |w, i| {
+ sum += w;
+ if (w > biggest) {
+ biggest = w;
+ biggest_i = i;
+ }
+ }
+ if (sum <= budget or biggest <= 1) break;
+ widths[biggest_i] -= 1;
+ }
+ }
+
+ fn appendPaddedCell(self: *Renderer, line: *std.ArrayList(u8), text: []const u8, width: usize, alignment: TableCell.Align) !void {
+ const pad = width -| visibleWidth(text);
+ const left_pad: usize, const right_pad: usize = switch (alignment) {
+ .left => .{ 0, pad },
+ .right => .{ pad, 0 },
+ .center => .{ pad / 2, pad - pad / 2 },
+ };
+ try line.append(self.alloc, ' ');
+ try line.appendNTimes(self.alloc, ' ', left_pad);
+ try line.appendSlice(self.alloc, text);
+ try line.appendNTimes(self.alloc, ' ', right_pad + 1);
+ }
+
+ fn renderTableRow(self: *Renderer, row: TableRow, widths: []const usize) !void {
+ var cells: std.ArrayList(CellLines) = .empty;
+ defer {
+ for (cells.items) |*cell| cell.deinit(self.alloc);
+ cells.deinit(self.alloc);
+ }
+
+ try cells.ensureTotalCapacity(self.alloc, widths.len);
+ var row_height: usize = 1;
+ for (widths, 0..) |w, i| {
+ var cell_lines: CellLines = .{};
+ errdefer cell_lines.deinit(self.alloc);
+ const text = if (i < row.cells.items.len) row.cells.items[i].text else "";
+ var wrapped: std.ArrayList(u8) = .empty;
+ defer wrapped.deinit(self.alloc);
+ try wrapStyled(text, w, &wrapped, self.alloc);
+ var it = std.mem.splitScalar(u8, wrapped.items, '\n');
+ while (it.next()) |part| try cell_lines.lines.append(self.alloc, try self.alloc.dupe(u8, part));
+ row_height = @max(row_height, cell_lines.lines.items.len);
+ cells.appendAssumeCapacity(cell_lines);
+ }
+
+ var line_i: usize = 0;
+ while (line_i < row_height) : (line_i += 1) {
+ var line: std.ArrayList(u8) = .empty;
+ defer line.deinit(self.alloc);
+ try line.appendSlice(self.alloc, "│");
+ for (widths, 0..) |w, i| {
+ const text = if (line_i < cells.items[i].lines.items.len) cells.items[i].lines.items[line_i] else "";
+ const alignment: TableCell.Align = if (i < row.cells.items.len) row.cells.items[i].alignment else .left;
+ try self.appendPaddedCell(&line, text, w, alignment);
+ try line.appendSlice(self.alloc, "│");
+ }
+ try self.appendLine(line.items);
+ }
+ }
+
+ fn renderTable(self: *Renderer) !void {
+ if (self.table_rows.items.len == 0) return;
+
+ var cols: usize = 0;
+ for (self.table_rows.items) |row| cols = @max(cols, row.cells.items.len);
+ if (cols == 0) return;
+
+ var widths = try self.alloc.alloc(usize, cols);
+ defer self.alloc.free(widths);
+ @memset(widths, 1);
+ for (self.table_rows.items) |row| {
+ for (row.cells.items, 0..) |cell, i| {
+ widths[i] = @max(widths[i], maxLineVisibleWidth(cell.text));
+ }
+ }
+ self.fitTableWidths(widths);
+
+ try self.appendTableBorder(widths, "┌", "┬", "┐");
+ for (self.table_rows.items, 0..) |row, i| {
+ try self.renderTableRow(row, widths);
+ if (row.is_header and i + 1 < self.table_rows.items.len) {
+ try self.appendTableBorder(widths, "├", "┼", "┤");
+ }
+ }
+ try self.appendTableBorder(widths, "└", "┴", "┘");
+ }
+
+ fn clearTable(self: *Renderer) void {
+ for (self.table_rows.items) |*row| row.deinit(self.alloc);
+ self.table_rows.deinit(self.alloc);
+ self.table_rows = .empty;
+ if (self.current_table_row) |*row| row.deinit(self.alloc);
+ self.current_table_row = null;
+ self.in_table = false;
+ self.in_table_cell = false;
+ self.current_cell_is_header = false;
+ self.current_cell_align = .left;
+ self.buf.clearRetainingCapacity();
+ }
+
+ fn clearLinks(self: *Renderer) void {
+ if (self.current_link) |*link| link.deinit(self.alloc);
+ self.current_link = null;
+ }
+
+ fn pushStyle(self: *Renderer, open_seq: []const u8) !void {
+ if (open_seq.len == 0) return;
+ if (self.style_stack_len < self.style_stack.len) {
+ self.style_stack[self.style_stack_len] = open_seq;
+ self.style_stack_len += 1;
+ }
+ try self.add(open_seq);
+ }
+
+ fn popStyle(self: *Renderer) !void {
+ if (self.style_stack_len > 0) self.style_stack_len -= 1;
+ try self.add(theme.reset);
+ for (self.style_stack[0..self.style_stack_len]) |open_seq| try self.add(open_seq);
+ }
+
+ fn appendAttribute(self: *Renderer, attr: c.MD_ATTRIBUTE) ![]const u8 {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(self.alloc);
+ try appendDecodedEntities(&out, self.alloc, attr.text[0..@intCast(attr.size)]);
+ return try out.toOwnedSlice(self.alloc);
+ }
+
+ fn pushLink(self: *Renderer, href: []const u8) !void {
+ self.clearLinks();
+ const owned = try self.alloc.dupe(u8, href);
+ errdefer self.alloc.free(owned);
+ self.current_link = .{ .href = owned };
+ try self.add("\x1b]8;;");
+ try self.add(href);
+ try self.add("\x1b\\");
+ try self.pushStyle("\x1b[4m");
+ }
+
+ fn popLink(self: *Renderer) !void {
+ try self.popStyle();
+ var link = self.current_link orelse return;
+ self.current_link = null;
+ defer link.deinit(self.alloc);
+ try self.add("\x1b]8;;\x1b\\");
+ try self.add(" (");
+ try self.add(link.href);
+ try self.add(")");
+ }
+
+ fn fail(self: *Renderer, e: anyerror) c_int {
+ self.err = e;
+ return 1;
+ }
fn enterBlock(t: c.MD_BLOCKTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int {
const self: *Renderer = @ptrCast(@alignCast(userdata.?));
switch (t) {
+ c.MD_BLOCK_QUOTE => {
+ self.flushParagraph() catch |e| return self.fail(e);
+ self.quote_depth += 1;
+ },
+ c.MD_BLOCK_TABLE => {
+ self.flushParagraph() catch |e| return self.fail(e);
+ self.clearTable();
+ self.in_table = true;
+ },
+ c.MD_BLOCK_TR => {
+ if (self.in_table) self.current_table_row = .{};
+ },
+ c.MD_BLOCK_TH, c.MD_BLOCK_TD => {
+ if (self.in_table) {
+ self.buf.clearRetainingCapacity();
+ self.in_table_cell = true;
+ self.current_cell_is_header = t == c.MD_BLOCK_TH;
+ const d: *c.MD_BLOCK_TD_DETAIL = @ptrCast(@alignCast(detail.?));
+ self.current_cell_align = switch (d.@"align") {
+ c.MD_ALIGN_RIGHT => .right,
+ c.MD_ALIGN_CENTER => .center,
+ else => .left,
+ };
+ }
+ },
c.MD_BLOCK_H => {
+ if (self.in_table) return 0;
self.flushParagraph() catch |e| return self.fail(e);
const d: *c.MD_BLOCK_H_DETAIL = @ptrCast(@alignCast(detail.?));
self.heading_level = @intCast(d.level);
self.add(theme.default.fg(.welcome).open()) catch |e| return self.fail(e);
- var i: usize = 0; while (i < self.heading_level) : (i += 1) self.add("#") catch |e| return self.fail(e);
+ var i: usize = 0;
+ while (i < self.heading_level) : (i += 1) self.add("#") catch |e| return self.fail(e);
self.add(" ") catch |e| return self.fail(e);
},
c.MD_BLOCK_P => {},
- c.MD_BLOCK_CODE => { self.in_code_block = true; self.buf.clearRetainingCapacity(); },
- c.MD_BLOCK_UL, c.MD_BLOCK_OL => self.list_depth += 1,
+ c.MD_BLOCK_CODE => if (!self.in_table) {
+ self.in_code_block = true;
+ self.buf.clearRetainingCapacity();
+ },
+ c.MD_BLOCK_UL => {
+ if (self.list_depth < self.list_stack.len) {
+ self.list_stack[self.list_depth] = .{ .ordered = false };
+ }
+ self.list_depth += 1;
+ },
+ c.MD_BLOCK_OL => {
+ const d: *c.MD_BLOCK_OL_DETAIL = @ptrCast(@alignCast(detail.?));
+ if (self.list_depth < self.list_stack.len) {
+ self.list_stack[self.list_depth] = .{ .ordered = true, .next = @intCast(d.start) };
+ }
+ self.list_depth += 1;
+ },
c.MD_BLOCK_LI => {
self.flushParagraph() catch |e| return self.fail(e);
if (self.list_item_stack_len < self.list_item_depth_stack.len) {
self.list_item_depth_stack[self.list_item_stack_len] = self.list_item_depth;
self.list_item_first_stack[self.list_item_stack_len] = self.list_item_first_paragraph;
+ self.list_item_marker_stack[self.list_item_stack_len] = self.list_item_marker;
+ self.list_item_marker_len_stack[self.list_item_stack_len] = self.list_item_marker_len;
self.list_item_stack_len += 1;
}
self.list_item_depth = self.list_depth;
self.list_item_first_paragraph = true;
+ const li: *c.MD_BLOCK_LI_DETAIL = @ptrCast(@alignCast(detail.?));
+ const list_i = if (self.list_depth == 0) 0 else self.list_depth - 1;
+ if (list_i < self.list_stack.len and self.list_stack[list_i].ordered) {
+ const n = self.list_stack[list_i].next;
+ self.list_stack[list_i].next += 1;
+ const marker = std.fmt.bufPrint(&self.list_item_marker, "{d}. ", .{n}) catch "?. ";
+ self.list_item_marker_len = marker.len;
+ } else if (li.is_task != 0) {
+ const mark = if (li.task_mark == 'x' or li.task_mark == 'X') "☑ " else "☐ ";
+ @memcpy(self.list_item_marker[0..mark.len], mark);
+ self.list_item_marker_len = mark.len;
+ } else {
+ @memcpy(self.list_item_marker[0.."• ".len], "• ");
+ self.list_item_marker_len = "• ".len;
+ }
},
c.MD_BLOCK_HR => self.appendLine("────────") catch |e| return self.fail(e),
else => {},
@@ -278,13 +712,35 @@ pub const Renderer = struct {
_ = detail;
const self: *Renderer = @ptrCast(@alignCast(userdata.?));
switch (t) {
+ c.MD_BLOCK_QUOTE => {
+ self.flushParagraph() catch |e| return self.fail(e);
+ if (self.quote_depth > 0) self.quote_depth -= 1;
+ self.appendBlank() catch |e| return self.fail(e);
+ },
+ c.MD_BLOCK_TABLE => {
+ self.renderTable() catch |e| return self.fail(e);
+ self.clearTable();
+ self.appendBlank() catch |e| return self.fail(e);
+ },
+ c.MD_BLOCK_TR => {
+ if (self.in_table) {
+ const row = self.current_table_row orelse return 0;
+ self.current_table_row = null;
+ self.table_rows.append(self.alloc, row) catch |e| return self.fail(e);
+ }
+ },
+ c.MD_BLOCK_TH, c.MD_BLOCK_TD => {
+ if (self.in_table_cell) self.appendTableCell() catch |e| return self.fail(e);
+ },
c.MD_BLOCK_H => {
+ if (self.in_table) return 0;
self.add(theme.default.fg(.welcome).close()) catch |e| return self.fail(e);
self.flushParagraph() catch |e| return self.fail(e);
self.appendBlank() catch |e| return self.fail(e);
self.heading_level = 0;
},
c.MD_BLOCK_P => {
+ if (self.in_table) return 0;
self.flushParagraph() catch |e| return self.fail(e);
if (self.list_item_depth == 0) self.appendBlank() catch |e| return self.fail(e);
},
@@ -294,9 +750,12 @@ pub const Renderer = struct {
self.list_item_stack_len -= 1;
self.list_item_depth = self.list_item_depth_stack[self.list_item_stack_len];
self.list_item_first_paragraph = self.list_item_first_stack[self.list_item_stack_len];
+ self.list_item_marker = self.list_item_marker_stack[self.list_item_stack_len];
+ self.list_item_marker_len = self.list_item_marker_len_stack[self.list_item_stack_len];
} else {
self.list_item_depth = 0;
self.list_item_first_paragraph = false;
+ self.list_item_marker_len = 0;
}
},
c.MD_BLOCK_CODE => {
@@ -314,24 +773,36 @@ pub const Renderer = struct {
}
fn enterSpan(t: c.MD_SPANTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int {
- _ = detail;
const self: *Renderer = @ptrCast(@alignCast(userdata.?));
const s = switch (t) {
c.MD_SPAN_STRONG => "\x1b[1m",
c.MD_SPAN_EM => "\x1b[3m",
c.MD_SPAN_CODE => theme.default.fg(.tool_header).open(),
- c.MD_SPAN_A => "\x1b[4m",
c.MD_SPAN_DEL => "\x1b[9m",
else => "",
};
- self.add(s) catch |e| return self.fail(e);
+ if (t == c.MD_SPAN_A) {
+ const d: *c.MD_SPAN_A_DETAIL = @ptrCast(@alignCast(detail.?));
+ const href = self.appendAttribute(d.href) catch |e| return self.fail(e);
+ defer self.alloc.free(href);
+ self.pushLink(href) catch |e| return self.fail(e);
+ } else {
+ self.pushStyle(s) catch |e| return self.fail(e);
+ }
return 0;
}
fn leaveSpan(t: c.MD_SPANTYPE, detail: ?*anyopaque, userdata: ?*anyopaque) callconv(.c) c_int {
- _ = t; _ = detail;
+ _ = detail;
const self: *Renderer = @ptrCast(@alignCast(userdata.?));
- self.add(theme.reset) catch |e| return self.fail(e);
+ if (t == c.MD_SPAN_A) {
+ self.popLink() catch |e| return self.fail(e);
+ } else switch (t) {
+ c.MD_SPAN_STRONG, c.MD_SPAN_EM, c.MD_SPAN_CODE, c.MD_SPAN_DEL => {
+ self.popStyle() catch |e| return self.fail(e);
+ },
+ else => {},
+ }
return 0;
}
@@ -341,20 +812,63 @@ pub const Renderer = struct {
switch (t) {
c.MD_TEXT_BR, c.MD_TEXT_SOFTBR => self.add("\n") catch |e| return self.fail(e),
c.MD_TEXT_NULLCHAR => self.add("�") catch |e| return self.fail(e),
- c.MD_TEXT_ENTITY => self.add(decodeEntity(s)) catch |e| return self.fail(e),
+ c.MD_TEXT_ENTITY => appendDecodedEntities(&self.buf, self.alloc, s) catch |e| return self.fail(e),
else => self.add(s) catch |e| return self.fail(e),
}
return 0;
}
};
-fn decodeEntity(s: []const u8) []const u8 {
- if (std.mem.eql(u8, s, "&amp;")) return "&";
- if (std.mem.eql(u8, s, "&lt;")) return "<";
- if (std.mem.eql(u8, s, "&gt;")) return ">";
- if (std.mem.eql(u8, s, "&quot;")) return "\"";
- if (std.mem.eql(u8, s, "&apos;")) return "'";
- return s;
+fn appendCodepoint(out: *std.ArrayList(u8), alloc: Allocator, cp: u21) !void {
+ var buf: [4]u8 = undefined;
+ const n = std.unicode.utf8Encode(cp, &buf) catch {
+ try out.appendSlice(alloc, "�");
+ return;
+ };
+ try out.appendSlice(alloc, buf[0..n]);
+}
+
+fn appendDecodedEntity(out: *std.ArrayList(u8), alloc: Allocator, entity: []const u8) !bool {
+ if (std.mem.eql(u8, entity, "&amp;")) {
+ try out.append(alloc, '&');
+ } else if (std.mem.eql(u8, entity, "&lt;")) {
+ try out.append(alloc, '<');
+ } else if (std.mem.eql(u8, entity, "&gt;")) {
+ try out.append(alloc, '>');
+ } else if (std.mem.eql(u8, entity, "&quot;")) {
+ try out.append(alloc, '"');
+ } else if (std.mem.eql(u8, entity, "&apos;")) {
+ try out.append(alloc, '\'');
+ } else if (std.mem.eql(u8, entity, "&nbsp;")) {
+ try out.appendSlice(alloc, "\u{00a0}");
+ } else if (entity.len >= 4 and entity[0] == '&' and entity[1] == '#' and entity[entity.len - 1] == ';') {
+ const body = entity[2 .. entity.len - 1];
+ const cp64 = if (body.len >= 2 and (body[0] == 'x' or body[0] == 'X'))
+ std.fmt.parseInt(u21, body[1..], 16) catch return false
+ else
+ std.fmt.parseInt(u21, body, 10) catch return false;
+ try appendCodepoint(out, alloc, cp64);
+ } else {
+ return false;
+ }
+ return true;
+}
+
+fn appendDecodedEntities(out: *std.ArrayList(u8), alloc: Allocator, text: []const u8) !void {
+ var i: usize = 0;
+ while (i < text.len) {
+ if (text[i] == '&') {
+ if (std.mem.indexOfScalarPos(u8, text, i, ';')) |semi| {
+ const entity = text[i .. semi + 1];
+ if (try appendDecodedEntity(out, alloc, entity)) {
+ i = semi + 1;
+ continue;
+ }
+ }
+ }
+ try out.append(alloc, text[i]);
+ i += 1;
+ }
}
const testing = std.testing;
@@ -371,6 +885,14 @@ test "wrapStyled wraps plain text" {
try testing.expectEqualStrings("the quick\nbrown fox", out.items);
}
+test "wrapStyled preserves hard tabs while counting tab stops" {
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(testing.allocator);
+ try wrapStyled("\treturn err", 14, &out, testing.allocator);
+ try testing.expectEqualStrings("\treturn\nerr", out.items);
+ try testing.expectEqual(@as(usize, 11), visibleWidth("\tfoo"));
+}
+
test "Renderer uses MD4C for common markdown" {
var out: std.ArrayList([]const u8) = .empty;
defer {
@@ -446,3 +968,135 @@ test "Renderer preserves nested list indentation" {
try testing.expectEqualStrings(" • child", out.items[1]);
try testing.expectEqualStrings("• next", out.items[2]);
}
+
+test "Renderer renders markdown tables with aligned columns" {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (out.items) |l| testing.allocator.free(l);
+ out.deinit(testing.allocator);
+ }
+ var r: Renderer = .{ .alloc = testing.allocator, .width = 120, .out_lines = &out };
+ try r.render(
+ \\| Option | Where logic lives | Touches sliderule? | Verdict |
+ \\|--------|-------------------|--------------------|---------|
+ \\| A | Rewrite AST in Go | No | Works today |
+ );
+ try testing.expectEqual(@as(usize, 5), out.items.len);
+ try testing.expect(std.mem.indexOf(u8, out.items[0], "┌") != null);
+ try testing.expect(std.mem.indexOf(u8, out.items[2], "├") != null);
+ try testing.expect(std.mem.indexOf(u8, out.items[4], "└") != null);
+ const option_sep = std.mem.indexOf(u8, out.items[1], "│ Where").?;
+ try testing.expectEqual(option_sep, std.mem.indexOf(u8, out.items[3], "│ Rewrite").?);
+ const verdict_sep = std.mem.indexOf(u8, out.items[1], "│ Verdict").?;
+ try testing.expectEqual(verdict_sep, std.mem.indexOf(u8, out.items[3], "│ Works").?);
+}
+
+test "Renderer honors markdown table alignment" {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (out.items) |l| testing.allocator.free(l);
+ out.deinit(testing.allocator);
+ }
+ var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out };
+ try r.render(
+ \\| Left | Center | Right |
+ \\|:-----|:------:|------:|
+ \\| x | y | z |
+ );
+ try testing.expect(std.mem.indexOf(u8, out.items[3], "│ x │") != null);
+ try testing.expect(std.mem.indexOf(u8, out.items[3], "│ y │") != null);
+ try testing.expect(std.mem.indexOf(u8, out.items[3], "│ z │") != null);
+}
+
+test "Renderer wraps wide markdown table cells to fit width" {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (out.items) |l| testing.allocator.free(l);
+ out.deinit(testing.allocator);
+ }
+ var r: Renderer = .{ .alloc = testing.allocator, .width = 24, .out_lines = &out };
+ try r.render(
+ \\| Short | Long |
+ \\|-------|------|
+ \\| A | one two three four five |
+ );
+ try testing.expect(out.items.len > 5);
+ for (out.items) |line| try testing.expect(visibleWidth(line) <= 24);
+}
+
+test "Renderer renders blockquotes with quote prefix" {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (out.items) |l| testing.allocator.free(l);
+ out.deinit(testing.allocator);
+ }
+ var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out };
+ try r.render("> quoted text");
+ try testing.expectEqual(@as(usize, 1), out.items.len);
+ try testing.expect(std.mem.indexOf(u8, out.items[0], "┃ ") != null);
+ try testing.expect(std.mem.indexOf(u8, out.items[0], "quoted text") != null);
+}
+
+test "Renderer numbers ordered lists from source start" {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (out.items) |l| testing.allocator.free(l);
+ out.deinit(testing.allocator);
+ }
+ var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out };
+ try r.render("3. three\n1. four\n1. five");
+ try testing.expectEqual(@as(usize, 3), out.items.len);
+ try testing.expect(std.mem.indexOf(u8, out.items[0], "3. three") != null);
+ try testing.expect(std.mem.indexOf(u8, out.items[1], "4. four") != null);
+ try testing.expect(std.mem.indexOf(u8, out.items[2], "5. five") != null);
+}
+
+test "Renderer renders task list markers" {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (out.items) |l| testing.allocator.free(l);
+ out.deinit(testing.allocator);
+ }
+ var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out };
+ try r.render("- [ ] todo\n- [x] done");
+ try testing.expectEqual(@as(usize, 2), out.items.len);
+ try testing.expect(std.mem.indexOf(u8, out.items[0], "☐ todo") != null);
+ try testing.expect(std.mem.indexOf(u8, out.items[1], "☑ done") != null);
+}
+
+test "Renderer shows link targets and emits OSC 8 hyperlink" {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (out.items) |l| testing.allocator.free(l);
+ out.deinit(testing.allocator);
+ }
+ var r: Renderer = .{ .alloc = testing.allocator, .width = 120, .out_lines = &out };
+ try r.render("[site](https://example.com)");
+ try testing.expect(std.mem.indexOf(u8, out.items[0], "\x1b]8;;https://example.com\x1b\\") != null);
+ try testing.expect(std.mem.indexOf(u8, out.items[0], "site") != null);
+ try testing.expect(std.mem.indexOf(u8, out.items[0], "(https://example.com)") != null);
+}
+
+test "Renderer restores parent inline style after nested link" {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (out.items) |l| testing.allocator.free(l);
+ out.deinit(testing.allocator);
+ }
+ var r: Renderer = .{ .alloc = testing.allocator, .width = 120, .out_lines = &out };
+ try r.render("**before [site](https://example.com) after**");
+ try testing.expect(std.mem.indexOf(u8, out.items[0], "\x1b[0m\x1b[1m") != null);
+ try testing.expect(std.mem.indexOf(u8, out.items[0], " after") != null);
+}
+
+test "Renderer decodes numeric entities and counts wide glyphs" {
+ var out: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (out.items) |l| testing.allocator.free(l);
+ out.deinit(testing.allocator);
+ }
+ var r: Renderer = .{ .alloc = testing.allocator, .width = 80, .out_lines = &out };
+ try r.render("&#x1F916; &#169; &amp;");
+ try testing.expect(std.mem.indexOf(u8, out.items[0], "🤖 © &") != null);
+ try testing.expectEqual(@as(usize, 6), visibleWidth("🤖 © &"));
+}
diff --git a/src/tui_components.zig b/src/tui_components.zig
index b93c30b..7532fac 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -97,6 +97,15 @@ pub fn codepointWidth(cp: u21) usize {
return 1;
}
+fn tabWidth(col: usize) usize {
+ const rem = col % 8;
+ return if (rem == 0) 8 else 8 - rem;
+}
+
+fn codepointWidthAt(cp: u21, col: usize) usize {
+ return if (cp == '\t') tabWidth(col) else codepointWidth(cp);
+}
+
/// Number of display columns occupied by `text`. `text` must be PLAIN (no
/// escape sequences); components wrap on plain text and only add styling
/// escapes afterward.
@@ -107,7 +116,7 @@ pub fn displayWidth(text: []const u8) usize {
const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const adv = @min(seq_len, text.len - i);
const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?';
- cols += codepointWidth(cp);
+ cols += codepointWidthAt(cp, cols);
i += adv;
}
return cols;
@@ -123,7 +132,7 @@ pub fn truncateToCols(text: []const u8, max_cols: usize) []const u8 {
const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const adv = @min(seq_len, text.len - i);
const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?';
- const w = codepointWidth(cp);
+ const w = codepointWidthAt(cp, cols);
if (cols + w > max_cols) break; // adding this glyph would exceed limit
i += adv;
cols += w;
@@ -138,24 +147,44 @@ pub fn displayWidthStyled(text: []const u8) usize {
var cols: usize = 0;
var i: usize = 0;
while (i < text.len) {
- if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') {
- i += 2;
- while (i < text.len) {
- const c = text[i];
- i += 1;
- if (c >= '@' and c <= '~') break;
- }
+ if (skipStyledEscape(text, i)) |next| {
+ i = next;
continue;
}
const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const adv = @min(seq_len, text.len - i);
const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?';
- cols += codepointWidth(cp);
+ cols += codepointWidthAt(cp, cols);
i += adv;
}
return cols;
}
+fn skipStyledEscape(text: []const u8, start_i: usize) ?usize {
+ if (start_i + 1 >= text.len or text[start_i] != '\x1b') return null;
+ switch (text[start_i + 1]) {
+ '[' => {
+ var i = start_i + 2;
+ while (i < text.len) {
+ const c = text[i];
+ i += 1;
+ if (c >= '@' and c <= '~') return i;
+ }
+ return text.len;
+ },
+ ']' => {
+ var i = start_i + 2;
+ while (i < text.len) {
+ if (text[i] == 0x07) return i + 1;
+ if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '\\') return i + 2;
+ i += 1;
+ }
+ return text.len;
+ },
+ else => return null,
+ }
+}
+
fn reapplyAfterReset(alloc: std.mem.Allocator, text: []const u8, bg_open: []const u8, fg_open: []const u8) ![]const u8 {
const reset = theme.reset;
var count: usize = 0;
@@ -186,19 +215,14 @@ pub fn truncateStyledToCols(text: []const u8, max_cols: usize) []const u8 {
var cols: usize = 0;
var i: usize = 0;
while (i < text.len) {
- if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') {
- i += 2;
- while (i < text.len) {
- const c = text[i];
- i += 1;
- if (c >= '@' and c <= '~') break;
- }
+ if (skipStyledEscape(text, i)) |next| {
+ i = next;
continue;
}
const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const adv = @min(seq_len, text.len - i);
const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?';
- const w = codepointWidth(cp);
+ const w = codepointWidthAt(cp, cols);
if (cols + w > max_cols) break;
i += adv;
cols += w;
@@ -234,7 +258,7 @@ fn wrapParagraph(text: []const u8, width: usize, out: *std.ArrayList([]const u8)
const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
const adv = @min(seq_len, text.len - i);
const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?';
- const cp_w = codepointWidth(cp);
+ const cp_w = codepointWidthAt(cp, line_cols);
const is_space = adv == 1 and text[i] == ' ';
if (is_space and line_cols == width) {
@@ -1430,7 +1454,7 @@ pub const InputBox = struct {
const seq_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1;
const adv = @min(seq_len, line.len - i);
const cp = std.unicode.utf8Decode(line[i .. i + adv]) catch '?';
- const w = codepointWidth(cp);
+ const w = codepointWidthAt(cp, cols);
if (cols > 0 and cols + w > width) break;
i += adv;
cols += w;
@@ -2650,6 +2674,12 @@ pub const ToolUse = struct {
@memset(rp, ' ');
const inner = try reapplyAfterReset(ctx.a, text, ctx.bg.open(), fg.open());
defer ctx.a.free(inner);
+ if (std.mem.indexOfScalar(u8, text, '\t') != null) {
+ const prepaint = try ctx.a.alloc(u8, ctx.width);
+ defer ctx.a.free(prepaint);
+ @memset(prepaint, ' ');
+ return std.fmt.allocPrint(ctx.a, "{s}{s}\r{s}{s}{s}{s}{s}{s}", .{ ctx.bg.open(), prepaint, ctx.bg.open(), fg.open(), indent, inner, rp, ansi_reset });
+ }
return std.fmt.allocPrint(ctx.a, "{s}{s}{s}{s}{s}{s}", .{ ctx.bg.open(), fg.open(), indent, inner, rp, ansi_reset });
}
fn blank(ctx: @This()) ![]u8 {
@@ -2778,8 +2808,13 @@ fn vw(line: []const u8) usize {
test "displayWidth counts codepoints; truncateToCols respects boundaries" {
try testing.expectEqual(@as(usize, 3), displayWidth("abc"));
try testing.expectEqual(@as(usize, 3), displayWidth("aé✓"));
+ try testing.expectEqual(@as(usize, 8), displayWidth("\t"));
+ try testing.expectEqual(@as(usize, 9), displayWidth("\tX"));
+ try testing.expectEqual(@as(usize, 9), displayWidth("a\tX"));
try testing.expectEqualStrings("aé", truncateToCols("aé✓", 2));
try testing.expectEqualStrings("abc", truncateToCols("abcdef", 3));
+ try testing.expectEqualStrings("", truncateToCols("\tX", 7));
+ try testing.expectEqualStrings("\t", truncateToCols("\tX", 8));
// Never splits a multibyte codepoint.
const t = truncateToCols("é", 1);
try testing.expectEqualStrings("é", t);
@@ -2792,6 +2827,16 @@ test "displayWidth counts codepoints; truncateToCols respects boundaries" {
try testing.expectEqualStrings("hello 🤖", truncateToCols("hello 🤖", 8));
}
+test "displayWidthStyled treats OSC hyperlinks as zero width" {
+ try testing.expectEqual(@as(usize, 4), displayWidthStyled("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"));
+ try testing.expectEqualStrings("\x1b]8;;https://example.com\x1b\\link", truncateStyledToCols("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\", 4));
+}
+
+test "displayWidthStyled counts styled hard tabs as tab stops" {
+ try testing.expectEqual(@as(usize, 8), displayWidthStyled("\x1b[48;2;40;50;40m\t\x1b[0m"));
+ try testing.expectEqual(@as(usize, 9), displayWidthStyled("\x1b[48;2;40;50;40m\tX\x1b[0m"));
+}
+
test "wrapParagraph word-wraps and hard-splits long words" {
var out: std.ArrayList([]const u8) = .empty;
defer out.deinit(testing.allocator);
@@ -2807,6 +2852,12 @@ test "wrapParagraph word-wraps and hard-splits long words" {
try testing.expectEqualStrings("abcd", out.items[0]);
try testing.expectEqualStrings("efgh", out.items[1]);
try testing.expectEqualStrings("ij", out.items[2]);
+
+ out.clearRetainingCapacity();
+ try wrapParagraph("\treturn err", 14, &out, testing.allocator);
+ try testing.expectEqual(@as(usize, 2), out.items.len);
+ try testing.expectEqualStrings("\treturn", out.items[0]);
+ try testing.expectEqualStrings("err", out.items[1]);
}
// -- AssistantText ---------------------------------------------------------
@@ -3999,3 +4050,25 @@ test "ToolUse: long output lines honor the width contract" {
const lines = try t.comp().render(10, testing.allocator);
for (lines) |l| try testing.expect(vw(l) <= 10);
}
+
+test "ToolUse: tabbed output prepaints background without expanding tabs" {
+ var t = ToolUse.init(testing.allocator);
+ defer t.deinit();
+ try t.setName("read");
+ try t.setInput("{}");
+ t.setCollapsed(false);
+ try t.setOutput("\treturn err");
+ t.setResultOk(true);
+
+ const lines = try t.comp().render(32, testing.allocator);
+ var found = false;
+ for (lines) |line| {
+ if (std.mem.indexOf(u8, line, "\treturn err")) |_| {
+ found = true;
+ try testing.expect(std.mem.indexOfScalar(u8, line, '\r') != null);
+ try testing.expect(vw(line) <= 32);
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, line, "\t"));
+ }
+ }
+ try testing.expect(found);
+}
diff --git a/src/tui_engine.zig b/src/tui_engine.zig
index 4924347..a779747 100644
--- a/src/tui_engine.zig
+++ b/src/tui_engine.zig
@@ -80,6 +80,17 @@ pub fn visibleWidth(line: []const u8) usize {
i += skipEscape(line[i..]);
continue;
}
+ if (b == '\t') {
+ const rem = cols % 8;
+ cols += if (rem == 0) 8 else 8 - rem;
+ i += 1;
+ continue;
+ }
+ if (b == '\r') {
+ cols = 0;
+ i += 1;
+ continue;
+ }
// Count one column per UTF-8 codepoint start byte.
const seq_len = std.unicode.utf8ByteSequenceLength(b) catch 1;
cols += 1;
@@ -1010,6 +1021,9 @@ test "visibleWidth strips CSI and counts codepoints" {
try testing.expectEqual(@as(usize, 5), visibleWidth("hello"));
try testing.expectEqual(@as(usize, 5), visibleWidth("\x1b[2mhello\x1b[0m"));
try testing.expectEqual(@as(usize, 0), visibleWidth("\x1b[0m"));
+ try testing.expectEqual(@as(usize, 8), visibleWidth("\t"));
+ try testing.expectEqual(@as(usize, 9), visibleWidth("a\tX"));
+ try testing.expectEqual(@as(usize, 9), visibleWidth("prepainted\r\tX"));
// CURSOR_MARKER (APC string) is zero-width.
try testing.expectEqual(@as(usize, 2), visibleWidth("a" ++ CURSOR_MARKER ++ "b"));
// multibyte UTF-8 counts one column per codepoint.