//! Tabular list with header row, fixed-width columns, scrollable body, and //! row selection. Borrows columns and rows — caller owns the slices. //! //! Mirrors bubbles/table's rendering: cells are truncated to their column //! width (with an ellipsis) and styled by full matte styles — `header` and //! `cell` supply per-cell padding and attributes, `selected` wraps the whole //! cursor row. A bottom border on `header` renders the separator line between //! header and body. //! //! Sizes: //! - `width` is informational; rows render at the natural sum of column //! widths regardless. Set if you want to expose it via `Width()`. //! - `height` is the **body** height (rows below the header), matching //! bubbles' viewport semantics. `setHeight(h)` subtracts the //! header's rendered height (2 when the header style draws a //! bottom border, else 1) at call time — set it before enabling //! the border, like the bubbles example does via `New` + `SetStyles`. //! //! v1 scope: header + body + cursor + nav keys. Skipped from bubbles: //! recentering scroll heuristics (we just keep the cursor visible), //! `FromValues` with an empty separator, and the panic on rows longer than //! columns (we clamp instead). const std = @import("std"); const tea = @import("blacktea"); const matte = @import("matte"); const key = @import("key.zig"); const util = @import("util.zig"); const Help = @import("Help.zig"); const t = std.testing; const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const ArrayList = std.ArrayList; const Table = @This(); pub const Column = struct { title: []const u8, width: u16, }; pub const Row = []const []const u8; const KeyMap = struct { line_up: key.Binding = .{ .keys = &.{ key.special(.up), key.rune('k') }, .help = .{ .key = "↑/k", .desc = "up" }, }, line_down: key.Binding = .{ .keys = &.{ key.special(.down), key.rune('j') }, .help = .{ .key = "↓/j", .desc = "down" }, }, page_up: key.Binding = .{ .keys = &.{ key.special(.page_up), key.rune('b') }, .help = .{ .key = "b/pgup", .desc = "page up" }, }, page_down: key.Binding = .{ .keys = &.{ key.special(.page_down), key.rune('f'), key.special(.space) }, .help = .{ .key = "f/pgdn", .desc = "page down" }, }, half_page_up: key.Binding = .{ .keys = &.{ key.rune('u'), key.ctrl('u') }, .help = .{ .key = "u", .desc = "½ page up" }, }, half_page_down: key.Binding = .{ .keys = &.{ key.rune('d'), key.ctrl('d') }, .help = .{ .key = "d", .desc = "½ page down" }, }, goto_top: key.Binding = .{ .keys = &.{ key.special(.home), key.rune('g') }, .help = .{ .key = "g/home", .desc = "go to start" }, }, goto_bottom: key.Binding = .{ .keys = &.{ key.special(.end), key.shift('G') }, .help = .{ .key = "G/end", .desc = "go to end" }, }, }; /// Full styles per cell, mirroring bubbles' lipgloss `Styles`. Padding lives /// on the styles (`Padding(0, 1)` in bubbles' defaults) — there are no /// separate pad fields. A bottom border on `header` renders the separator /// line under the header (see the bubbles table example). const Styles = struct { header: matte.Style = .{ .bold = true, .padding = .{ .left = 1, .right = 1 } }, cell: matte.Style = .{ .padding = .{ .left = 1, .right = 1 } }, selected: matte.Style = .{ .bold = true, .foreground = .{ .ansi256 = 212 } }, pub const unstyled: Styles = .{ .header = .{}, .cell = .{}, .selected = .{}, }; }; cols: []const Column = &.{}, rows: []const Row = &.{}, cursor: usize = 0, focused: bool = false, styles: Styles = .{}, key_map: KeyMap = .{}, width: u16 = 0, /// Body height in rows (header renders on top of this). Bubbles default /// viewport height is 20. height: u16 = 20, y_offset: usize = 0, /// Embedded help; render via `helpView(w)`. Caller can override styles /// before calling. help: Help = .{}, /// Backing buffer + slice tables when rows are populated by `fromValues`. fv_content: ArrayList(u8) = .empty, fv_cells: ArrayList([]const u8) = .empty, fv_rows: ArrayList(Row) = .empty, model: tea.Model = .{ .vtable = &.{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, }, }, fn vtableInit(_: *tea.Model) ?tea.Cmd { return null; } fn vtableUpdate(m: *tea.Model, msg: tea.Msg) ?tea.Cmd { const self: *Table = @fieldParentPtr("model", m); return self.update(msg); } fn vtableView(m: *tea.Model, w: *Writer) Writer.Error!tea.View { const self: *Table = @fieldParentPtr("model", m); try self.view(w); return .{}; } pub fn deinit(self: *Table, allocator: Allocator) void { self.fv_content.deinit(allocator); self.fv_cells.deinit(allocator); self.fv_rows.deinit(allocator); } pub fn focus(self: *Table) void { self.focused = true; } pub fn blur(self: *Table) void { self.focused = false; } /// Populates rows from a plain string. Lines are split on `\n`; each line /// is split into cells by `separator`. The table owns the parsed copy /// for the lifetime of the Table (until next `fromValues` or `deinit`). pub fn fromValues( self: *Table, allocator: Allocator, text: []const u8, separator: []const u8, ) !void { self.fv_content.clearRetainingCapacity(); self.fv_cells.clearRetainingCapacity(); self.fv_rows.clearRetainingCapacity(); try self.fv_content.appendSlice(allocator, text); // Tracks (cell_start, cell_count) per row. Resolved into Row slices // after fv_cells is fully populated (so the slice base is stable). var row_bounds: ArrayList(struct { start: usize, count: usize }) = .empty; defer row_bounds.deinit(allocator); var line_start: usize = 0; var i: usize = 0; while (i <= self.fv_content.items.len) : (i += 1) { const at_end = i == self.fv_content.items.len; const is_nl = !at_end and self.fv_content.items[i] == '\n'; if (!is_nl and !at_end) continue; const line = self.fv_content.items[line_start..i]; const cell_start = self.fv_cells.items.len; var pos: usize = 0; while (pos <= line.len) { const found = if (separator.len == 0) @as(?usize, null) else std.mem.indexOfPos(u8, line, pos, separator); const cell_end = found orelse line.len; try self.fv_cells.append(allocator, line[pos..cell_end]); if (found == null) break; pos = cell_end + separator.len; } try row_bounds.append(allocator, .{ .start = cell_start, .count = self.fv_cells.items.len - cell_start, }); line_start = i + 1; if (at_end) break; } // fv_cells is now stable; resolve row slices. for (row_bounds.items) |b| { try self.fv_rows.append(allocator, self.fv_cells.items[b.start .. b.start + b.count]); } self.setRows(self.fv_rows.items); } pub fn setRows(self: *Table, rows: []const Row) void { self.rows = rows; if (rows.len == 0) { self.cursor = 0; } else if (self.cursor >= rows.len) { self.cursor = rows.len - 1; } self.ensureCursorVisible(); } pub fn setColumns(self: *Table, cols: []const Column) void { self.cols = cols; } /// Mirrors bubbles `SetHeight`: stores the body height after subtracting the /// header's rendered height (1 text line, +1 when the header style draws a /// bottom border under a visible column). Columns and styles at call time /// decide the header height — set this before enabling a header border. pub fn setHeight(self: *Table, h: u16) void { self.height = h -| self.headerHeight(); self.ensureCursorVisible(); } pub fn setWidth(self: *Table, w: u16) void { self.width = w; } pub fn setCursor(self: *Table, n: usize) void { if (self.rows.len == 0) { self.cursor = 0; } else { self.cursor = @min(n, self.rows.len - 1); } self.ensureCursorVisible(); } pub fn cursorPosition(self: *const Table) usize { return self.cursor; } pub fn selectedRow(self: *const Table) ?Row { if (self.cursor >= self.rows.len) return null; return self.rows[self.cursor]; } pub fn moveUp(self: *Table, n: usize) void { if (self.rows.len == 0) return; self.cursor = if (self.cursor > n) self.cursor - n else 0; self.ensureCursorVisible(); } pub fn moveDown(self: *Table, n: usize) void { if (self.rows.len == 0) return; self.cursor = @min(self.cursor + n, self.rows.len - 1); self.ensureCursorVisible(); } pub fn gotoTop(self: *Table) void { self.setCursor(0); } pub fn gotoBottom(self: *Table) void { if (self.rows.len > 0) self.setCursor(self.rows.len - 1); } /// Renders the embedded Help with the table's KeyMap as bindings. /// Bubbles mirror: ShortHelp = {Up, Down}; FullHelp = two columns: /// nav (Up, Down, GotoTop, GotoBottom) and paging. pub fn helpView(self: *const Table, w: *Writer) Writer.Error!void { if (self.help.show_all) { const nav = [_]key.Binding{ self.key_map.line_up, self.key_map.line_down, self.key_map.goto_top, self.key_map.goto_bottom, }; const paging = [_]key.Binding{ self.key_map.page_up, self.key_map.page_down, self.key_map.half_page_up, self.key_map.half_page_down, }; const groups = [_][]const key.Binding{ &nav, &paging }; try self.help.fullView(w, &groups); } else { const bindings = [_]key.Binding{ self.key_map.line_up, self.key_map.line_down }; try self.help.shortView(w, &bindings); } } pub fn update(self: *Table, msg: tea.Msg) ?tea.Cmd { if (!self.focused) return null; switch (msg) { .key_press => |k| { if (self.key_map.line_up.matches(k)) self.moveUp(1); if (self.key_map.line_down.matches(k)) self.moveDown(1); if (self.key_map.page_up.matches(k)) self.moveUp(self.height); if (self.key_map.page_down.matches(k)) self.moveDown(self.height); if (self.key_map.half_page_up.matches(k)) self.moveUp(self.height / 2); if (self.key_map.half_page_down.matches(k)) self.moveDown(self.height / 2); if (self.key_map.goto_top.matches(k)) self.gotoTop(); if (self.key_map.goto_bottom.matches(k)) self.gotoBottom(); }, else => {}, } return null; } pub fn view(self: *const Table, w: *Writer) Writer.Error!void { try self.writeHeader(w); const start = self.y_offset; const end = @min(start + self.height, self.rows.len); var i = start; while (i < end) : (i += 1) { try w.writeByte('\n'); try self.writeRow(w, i); } } fn ensureCursorVisible(self: *Table) void { const body = self.height; if (body == 0) { self.y_offset = 0; return; } if (self.cursor < self.y_offset) { self.y_offset = self.cursor; } else if (self.cursor >= self.y_offset + body) { self.y_offset = self.cursor + 1 - body; } // Clamp so we don't overscroll past the last row. const max_offset: usize = if (self.rows.len > body) self.rows.len - body else 0; if (self.y_offset > max_offset) self.y_offset = max_offset; } fn headerHeight(self: *const Table) u16 { // lipgloss.Height(headersView()): one text line, plus one when the header // style draws a bottom border under a visible column. if (self.styles.header.border != null and self.styles.header.border_bottom) { for (self.cols) |col| { if (col.width > 0) return 2; } } return 1; } /// Renders the header text line plus, when the header style has a bottom /// border, the separator line of per-column border segments (bubbles /// `headersView`, table.go:418-429). fn writeHeader(self: *const Table, w: *Writer) Writer.Error!void { var has_visible = false; for (self.cols) |col| { if (col.width > 0) { has_visible = true; break; } } // No visible columns renders an empty header line (JoinHorizontal of // nothing), so callers still see the leading blank line. if (!has_visible) return; for (self.cols) |col| { if (col.width == 0) continue; try self.writeStyledCell(w, self.styles.header, col.title, col.width); } if (self.styles.header.border != null and self.styles.header.border_bottom) { try w.writeByte('\n'); for (self.cols) |col| { if (col.width == 0) continue; try writeBorderSegment(w, self.styles.header, colTotalWidth(col, self.styles.header)); } } } /// Renders one body row: cells joined left to right, with the `selected` /// style wrapped around the whole row for the cursor row regardless of focus /// (bubbles `renderRow`, table.go:431-448). fn writeRow(self: *const Table, w: *Writer, idx: usize) Writer.Error!void { const row = self.rows[idx]; const is_selected = idx == self.cursor; if (is_selected) try openStyle(w, self.styles.selected); // Bubbles iterates the row's own cells — short rows render fewer columns // — and skips zero-width columns. (Go panics on rows longer than columns; // we clamp.) const n = @min(row.len, self.cols.len); for (row[0..n], 0..) |value, ci| { const col = self.cols[ci]; if (col.width == 0) continue; try self.writeStyledCell(w, self.styles.cell, value, col.width); } if (is_selected) try closeStyle(w, self.styles.selected); } /// Renders one width-padded cell under `s`: whitespace padding (background /// only, per lipgloss `ColorWhitespace`), then the styled truncated text. /// The caller truncates via [writeTruncatedPadded] and padding comes from /// `s.padding`, matching bubbles' `Padding(0, 1)` defaults. fn writeStyledCell( self: *const Table, w: *Writer, s: matte.Style, value: []const u8, col_width: u16, ) Writer.Error!void { _ = self; try writeWhitespace(w, s, s.padding.left); try openStyle(w, s); try writeTruncatedPadded(w, value, col_width); try closeStyle(w, s); try writeWhitespace(w, s, s.padding.right); } fn colTotalWidth(col: Column, s: matte.Style) u16 { return col.width +| s.padding.left +| s.padding.right; } /// Writes `width` bottom-border cells colored by the style's bottom-border /// colors — one column's slice of the header separator line. fn writeBorderSegment(w: *Writer, s: matte.Style, width: u16) Writer.Error!void { const border = s.border orelse return; if (width == 0) return; const fg = s.border_bottom_fg; const bg = s.border_bottom_bg; if (fg) |c| try c.writeSgr(w, .fg); if (bg) |c| try c.writeSgr(w, .bg); var i: u16 = 0; while (i < width) : (i += 1) try w.writeAll(border.bottom); if (fg != null or bg != null) try w.writeAll("\x1b[0m"); } /// Writes `n` spaces with lipgloss whitespace styling: only `reverse` and the /// background color (when `color_whitespace` is set) bleed onto padding. fn writeWhitespace(w: *Writer, s: matte.Style, n: u16) Writer.Error!void { if (n == 0) return; var any = false; if (s.reverse) { try w.writeAll("\x1b[7m"); if (s.foreground) |c| try c.writeSgr(w, .fg); any = true; } if (s.color_whitespace) { if (s.background) |c| { try c.writeSgr(w, .bg); any = true; } } try writePad(w, n); if (any) try w.writeAll("\x1b[0m"); } fn writePad(w: *Writer, n: u16) Writer.Error!void { var i: u16 = 0; while (i < n) : (i += 1) try w.writeByte(' '); } fn openStyle(w: *Writer, s: matte.Style) Writer.Error!void { if (s.bold) try w.writeAll("\x1b[1m"); if (s.faint) try w.writeAll("\x1b[2m"); if (s.italic) try w.writeAll("\x1b[3m"); switch (s.underline) { .none => {}, .single => try w.writeAll("\x1b[4m"), .double => try w.writeAll("\x1b[4:2m"), .curly => try w.writeAll("\x1b[4:3m"), .dotted => try w.writeAll("\x1b[4:4m"), .dashed => try w.writeAll("\x1b[4:5m"), } if (s.blink) try w.writeAll("\x1b[5m"); if (s.reverse) try w.writeAll("\x1b[7m"); if (s.strikethrough) try w.writeAll("\x1b[9m"); if (s.foreground) |c| try c.writeSgr(w, .fg); if (s.background) |c| try c.writeSgr(w, .bg); } fn closeStyle(w: *Writer, s: matte.Style) Writer.Error!void { if (hasAnyStyling(s)) try w.writeAll("\x1b[0m"); } fn hasAnyStyling(s: matte.Style) bool { return s.bold or s.italic or s.underline != .none or s.strikethrough or s.reverse or s.blink or s.faint or s.foreground != null or s.background != null; } /// Writes the visible portion of `value` truncated to `col_width` cells, /// then right-pads with spaces to exactly `col_width` cells. If `value` /// is wider than `col_width`, the last cell becomes `…`. fn writeTruncatedPadded(w: *Writer, value: []const u8, col_width: u16) Writer.Error!void { const value_w = matte.measure.cellWidth(value); if (value_w <= col_width) { try w.writeAll(value); var i: u16 = value_w; while (i < col_width) : (i += 1) try w.writeByte(' '); return; } // Truncate, leaving room for the ellipsis. if (col_width == 1) { try w.writeAll("…"); return; } const target_visible = col_width - 1; var emitted: u16 = 0; var i: usize = 0; while (i < value.len and emitted < target_visible) { if (value[i] == 0x1b) { // Pass through CSI sequence verbatim — doesn't count toward width. const start = i; i += 1; if (i < value.len and value[i] == '[') { i += 1; while (i < value.len and (value[i] < 0x40 or value[i] > 0x7e)) : (i += 1) {} if (i < value.len) i += 1; } else if (i < value.len) i += 1; try w.writeAll(value[start..i]); continue; } if (value[i] < 0x80) { try w.writeByte(value[i]); emitted += 1; i += 1; } else { const len = std.unicode.utf8ByteSequenceLength(value[i]) catch 1; const safe_len = @min(len, value.len - i); try w.writeAll(value[i .. i + safe_len]); emitted += 1; i += safe_len; } } try w.writeAll("…"); } test "moveDown advances cursor and scrolls" { var table: Table = .{ .height = 2, .focused = true }; const cols = [_]Column{.{ .title = "A", .width = 1 }}; table.setColumns(&cols); const r0 = [_][]const u8{"a"}; const r1 = [_][]const u8{"b"}; const r2 = [_][]const u8{"c"}; const r3 = [_][]const u8{"d"}; const rows = [_]Row{ &r0, &r1, &r2, &r3 }; table.setRows(&rows); table.moveDown(1); try t.expectEqual(@as(usize, 1), table.cursor); try t.expectEqual(@as(usize, 0), table.y_offset); table.moveDown(1); try t.expectEqual(@as(usize, 2), table.cursor); try t.expectEqual(@as(usize, 1), table.y_offset); } test "moveUp clamps at zero" { var table: Table = .{ .height = 2, .focused = true }; const cols = [_]Column{.{ .title = "A", .width = 1 }}; table.setColumns(&cols); const r0 = [_][]const u8{"a"}; const r1 = [_][]const u8{"b"}; const rows = [_]Row{ &r0, &r1 }; table.setRows(&rows); table.moveUp(5); try t.expectEqual(@as(usize, 0), table.cursor); } test "view renders header and visible rows" { var table: Table = .{ .height = 2, .focused = true, .styles = .unstyled, // disable styling for layout test }; const cols = [_]Column{ .{ .title = "ID", .width = 2 }, .{ .title = "NAME", .width = 4 }, }; table.setColumns(&cols); const r0 = [_][]const u8{ "01", "alpha" }; const r1 = [_][]const u8{ "02", "beta" }; const rows = [_]Row{ &r0, &r1 }; table.setRows(&rows); var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try table.view(&w); try t.expectEqualStrings("IDNAME\n01alp…\n02beta", w.buffered()); } test "default styles pad header and cells" { var table: Table = .{ .height = 1, .focused = false }; const cols = [_]Column{.{ .title = "X", .width = 1 }}; table.setColumns(&cols); const r0 = [_][]const u8{"a"}; const rows = [_]Row{&r0}; table.setRows(&rows); var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try table.view(&w); // Header: bold text inside 1-cell pads; selected row 0 wrapped in the // selected style with pads inside the span. try t.expectEqualStrings( " \x1b[1mX\x1b[0m \n\x1b[1m\x1b[38;5;212m a \x1b[0m", w.buffered(), ); } test "header bottom border renders per-column segments" { var table: Table = .{ .height = 1, .focused = false, .styles = .{ .header = .{ .border = .normal, .border_bottom_fg = .{ .ansi256 = 240 }, }, }, }; const cols = [_]Column{ .{ .title = "A", .width = 2 }, .{ .title = "B", .width = 3 }, }; table.setColumns(&cols); table.setRows(&.{}); var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try table.view(&w); try t.expectEqualStrings( "A B \n\x1b[38;5;240m──\x1b[0m\x1b[38;5;240m───\x1b[0m", w.buffered(), ); } test "setHeight subtracts header height at call time" { var table: Table = .{}; const cols = [_]Column{.{ .title = "A", .width = 1 }}; // Default styles: unbordered header is one line. table.setColumns(&cols); table.setHeight(7); try t.expectEqual(@as(u16, 6), table.height); // Once a header bottom border is enabled, a later setHeight subtracts 2. table.styles.header.border = .normal; table.setHeight(7); try t.expectEqual(@as(u16, 5), table.height); } test "selectedRow returns nil for empty rows" { var table: Table = .{}; try t.expect(table.selectedRow() == null); } test "fromValues parses lines/cells" { var table: Table = .{ .height = 5, .focused = true }; defer table.deinit(t.allocator); const cols = [_]Column{ .{ .title = "A", .width = 1 }, .{ .title = "B", .width = 1 }, }; table.setColumns(&cols); try table.fromValues(t.allocator, "1,2\n3,4", ","); try t.expectEqual(@as(usize, 2), table.rows.len); try t.expectEqualStrings("1", table.rows[0][0]); try t.expectEqualStrings("2", table.rows[0][1]); try t.expectEqualStrings("3", table.rows[1][0]); try t.expectEqualStrings("4", table.rows[1][1]); } test "selected row gets selected style; others get cell style" { var table: Table = .{ .height = 2, .focused = true }; const cols = [_]Column{.{ .title = "X", .width = 1 }}; table.setColumns(&cols); const r0 = [_][]const u8{"a"}; const r1 = [_][]const u8{"b"}; const rows = [_]Row{ &r0, &r1 }; table.setRows(&rows); table.setCursor(1); var buf: [512]u8 = undefined; var w: Writer = .fixed(&buf); try table.view(&w); // Selected row: the whole padded row is wrapped in bold + 212-fg. try t.expect(std.mem.find(u8, w.buffered(), "\x1b[1m\x1b[38;5;212m b \x1b[0m") != null); }