//! Tabular list with header row, fixed-width columns, scrollable body, and //! row selection. Borrows columns and rows — caller owns the slices. //! //! 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 total rendered height (header + body). Body lines = //! `height - 1`. Required to size the scroll window. //! //! v1 scope: header + body + cursor + nav keys. Skipped from bubbles: //! recentering scroll heuristics (we just keep the cursor visible), //! `FromValues`, and the embedded help model (use `key_map` with //! `leaves.Help` directly if you want help text). const std = @import("std"); const matcha = @import("matcha"); const blush = @import("blush"); const key = @import("key.zig"); const util = @import("util.zig"); const Help = @import("Help.zig"); const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const Cmd = matcha.Cmd; const Msg = matcha.Msg; const ArrayList = std.ArrayList; const Table = @This(); pub const Column = struct { title: []const u8, width: u16, }; pub const Row = []const []const u8; pub 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" }, }, }; pub const Styles = struct { header: ?blush.Style = null, cell: ?blush.Style = null, selected: ?blush.Style = null, }; /// Bubbles defaults: header bold, selected bold + ANSI 212 fg. pub const default_styles: Styles = .{ .header = .{ .bold = true }, .cell = null, .selected = .{ .bold = true, .foreground = .{ .ansi256 = 212 } }, }; model: matcha.Model = .{ .vtable = &vtable }, cols: []const Column = &.{}, rows: []const Row = &.{}, cursor: usize = 0, focused: bool = false, styles: Styles = default_styles, key_map: KeyMap = .{}, width: u16 = 0, height: u16 = 20, /// Cells of left/right padding rendered around each column's content. cell_left_pad: u16 = 1, cell_right_pad: u16 = 1, 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, const vtable: matcha.Model.VTable = .{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, }; fn vtableInit(_: *matcha.Model) ?Cmd { return null; } fn vtableUpdate(m: *matcha.Model, msg: Msg) ?Cmd { const self: *Table = @fieldParentPtr("model", m); return self.update(msg); } fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.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; } pub fn setHeight(self: *Table, h: u16) void { self.height = h; 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: Msg) ?Cmd { if (!self.focused) return null; switch (msg) { .key_press => |k| { const body = bodyHeight(self.height); 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(body); if (self.key_map.page_down.matches(k)) self.moveDown(body); if (self.key_map.half_page_up.matches(k)) self.moveUp(body / 2); if (self.key_map.half_page_down.matches(k)) self.moveDown(body / 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); if (self.height <= 1) return; const body = bodyHeight(self.height); const start = self.y_offset; const end = @min(start + body, 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 = bodyHeight(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 bodyHeight(height: u16) usize { return if (height > 1) height - 1 else 0; } fn writeHeader(self: *const Table, w: *Writer) Writer.Error!void { if (self.cols.len == 0) return; if (self.styles.header) |s| try openStyle(w, s); for (self.cols) |col| { try self.writeCell(w, col.title, col.width); } if (self.styles.header) |s| try closeStyle(w, s); } fn writeRow(self: *const Table, w: *Writer, idx: usize) Writer.Error!void { const is_selected = idx == self.cursor and self.focused; const style: ?blush.Style = if (is_selected) self.styles.selected else self.styles.cell; if (style) |s| try openStyle(w, s); const row = self.rows[idx]; for (self.cols, 0..) |col, ci| { const cell: []const u8 = if (ci < row.len) row[ci] else ""; try self.writeCell(w, cell, col.width); } if (style) |s| try closeStyle(w, s); } fn writeCell(self: *const Table, w: *Writer, value: []const u8, col_width: u16) Writer.Error!void { if (col_width == 0) return; try writePad(w, self.cell_left_pad); try writeTruncatedPadded(w, value, col_width); try writePad(w, self.cell_right_pad); } 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: blush.Style) Writer.Error!void { if (s.bold) try w.writeAll("\x1b[1m"); if (s.faint) try w.writeAll("\x1b[2m"); if (s.italic) try w.writeAll("\x1b[3m"); switch (s.underline) { .none => {}, .single => try w.writeAll("\x1b[4m"), .double => try w.writeAll("\x1b[4:2m"), .curly => try w.writeAll("\x1b[4:3m"), .dotted => try w.writeAll("\x1b[4:4m"), .dashed => try w.writeAll("\x1b[4:5m"), } if (s.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: blush.Style) Writer.Error!void { if (hasAnyStyling(s)) try w.writeAll("\x1b[0m"); } fn hasAnyStyling(s: blush.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 = blush.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("…"); } // ---- tests ---- const testing = std.testing; test "moveDown advances cursor and scrolls" { var t: Table = .{ .height = 3, .focused = true }; const cols = [_]Column{.{ .title = "A", .width = 1 }}; t.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 }; t.setRows(&rows); // body height = 2 t.moveDown(1); try testing.expectEqual(@as(usize, 1), t.cursor); try testing.expectEqual(@as(usize, 0), t.y_offset); t.moveDown(1); try testing.expectEqual(@as(usize, 2), t.cursor); try testing.expectEqual(@as(usize, 1), t.y_offset); } test "moveUp clamps at zero" { var t: Table = .{ .height = 3, .focused = true }; const cols = [_]Column{.{ .title = "A", .width = 1 }}; t.setColumns(&cols); const r0 = [_][]const u8{"a"}; const r1 = [_][]const u8{"b"}; const rows = [_]Row{ &r0, &r1 }; t.setRows(&rows); t.moveUp(5); try testing.expectEqual(@as(usize, 0), t.cursor); } test "view renders header and visible rows" { var t: Table = .{ .height = 3, .focused = true, .styles = .{}, // disable styling for layout test .cell_left_pad = 0, .cell_right_pad = 0, }; const cols = [_]Column{ .{ .title = "ID", .width = 2 }, .{ .title = "NAME", .width = 4 }, }; t.setColumns(&cols); const r0 = [_][]const u8{ "01", "alpha" }; const r1 = [_][]const u8{ "02", "beta" }; const rows = [_]Row{ &r0, &r1 }; t.setRows(&rows); var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); try t.view(&w); try testing.expectEqualStrings("IDNAME\n01alp…\n02beta", w.buffered()); } test "selectedRow returns nil for empty rows" { var t: Table = .{}; try testing.expect(t.selectedRow() == null); } test "fromValues parses lines/cells" { var t: Table = .{ .height = 5, .focused = true }; defer t.deinit(testing.allocator); const cols = [_]Column{ .{ .title = "A", .width = 1 }, .{ .title = "B", .width = 1 }, }; t.setColumns(&cols); try t.fromValues(testing.allocator, "1,2\n3,4", ","); try testing.expectEqual(@as(usize, 2), t.rows.len); try testing.expectEqualStrings("1", t.rows[0][0]); try testing.expectEqualStrings("2", t.rows[0][1]); try testing.expectEqualStrings("3", t.rows[1][0]); try testing.expectEqualStrings("4", t.rows[1][1]); } test "selected row gets selected style; others get cell style" { var t: Table = .{ .height = 3, .focused = true, .cell_left_pad = 0, .cell_right_pad = 0, }; const cols = [_]Column{.{ .title = "X", .width = 1 }}; t.setColumns(&cols); const r0 = [_][]const u8{"a"}; const r1 = [_][]const u8{"b"}; const rows = [_]Row{ &r0, &r1 }; t.setRows(&rows); t.setCursor(1); var buf: [512]u8 = undefined; var w: Writer = .fixed(&buf); try t.view(&w); // Selected row should contain the bold + 212-fg open and a reset. try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[1m\x1b[38;5;212mb\x1b[0m") != null); }