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