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