Add 3 new interactive demo examples

New examples:
- list_demo.zig: Interactive list with j/k navigation
- table_demo.zig: Table with row selection and status colors
- dashboard.zig: Multi-widget demo (Tabs, Gauges, Sparklines, List)

Also includes:
- build.zig: Added build targets for all new examples
- table.zig: Fixed Cell.content default, HighlightSpacing check, ColumnPos type

Run with: zig build list-demo / table-demo / dashboard

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 13:22:42 +01:00
parent 5556ee1370
commit b9223dec85
5 changed files with 736 additions and 13 deletions

View file

@ -61,4 +61,61 @@ pub fn build(b: *std.Build) void {
run_events_demo.step.dependOn(b.getInstallStep());
const events_demo_step = b.step("events-demo", "Run events demo");
events_demo_step.dependOn(&run_events_demo.step);
// Ejemplo: list_demo
const list_demo_exe = b.addExecutable(.{
.name = "list-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/list_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(list_demo_exe);
const run_list_demo = b.addRunArtifact(list_demo_exe);
run_list_demo.step.dependOn(b.getInstallStep());
const list_demo_step = b.step("list-demo", "Run list demo");
list_demo_step.dependOn(&run_list_demo.step);
// Ejemplo: table_demo
const table_demo_exe = b.addExecutable(.{
.name = "table-demo",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/table_demo.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(table_demo_exe);
const run_table_demo = b.addRunArtifact(table_demo_exe);
run_table_demo.step.dependOn(b.getInstallStep());
const table_demo_step = b.step("table-demo", "Run table demo");
table_demo_step.dependOn(&run_table_demo.step);
// Ejemplo: dashboard
const dashboard_exe = b.addExecutable(.{
.name = "dashboard",
.root_module = b.createModule(.{
.root_source_file = b.path("examples/dashboard.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zcatui", .module = zcatui_mod },
},
}),
});
b.installArtifact(dashboard_exe);
const run_dashboard = b.addRunArtifact(dashboard_exe);
run_dashboard.step.dependOn(b.getInstallStep());
const dashboard_step = b.step("dashboard", "Run dashboard demo");
dashboard_step.dependOn(&run_dashboard.step);
}

253
examples/dashboard.zig Normal file
View file

@ -0,0 +1,253 @@
//! Dashboard demo for zcatui.
//!
//! Demonstrates multiple widgets working together:
//! - Gauges showing system metrics
//! - Sparkline for real-time data
//! - List for logs/events
//! - Tabs for navigation
//!
//! Run with: zig build dashboard
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Buffer = zcatui.Buffer;
const Rect = zcatui.Rect;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Event = zcatui.Event;
const KeyCode = zcatui.KeyCode;
const Layout = zcatui.Layout;
const Constraint = zcatui.Constraint;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const Gauge = zcatui.widgets.Gauge;
const Sparkline = zcatui.widgets.Sparkline;
const List = zcatui.widgets.List;
const ListItem = zcatui.widgets.ListItem;
const Tabs = zcatui.widgets.Tabs;
const Paragraph = zcatui.widgets.Paragraph;
const Line = zcatui.Line;
const AppState = struct {
running: bool = true,
tick: u64 = 0,
tab_index: usize = 0,
cpu_history: [64]u64 = [_]u64{0} ** 64,
mem_history: [64]u64 = [_]u64{0} ** 64,
history_idx: usize = 0,
cpu: u64 = 0,
mem: u64 = 0,
fn update(self: *AppState) void {
self.tick += 1;
// Simulate changing metrics
const seed = @as(u32, @truncate(self.tick));
self.cpu = 30 + (seed * 7) % 50;
self.mem = 40 + (seed * 13) % 40;
// Update history
self.cpu_history[self.history_idx] = self.cpu;
self.mem_history[self.history_idx] = self.mem;
self.history_idx = (self.history_idx + 1) % 64;
}
fn nextTab(self: *AppState) void {
self.tab_index = (self.tab_index + 1) % 3;
}
fn prevTab(self: *AppState) void {
if (self.tab_index == 0) {
self.tab_index = 2;
} else {
self.tab_index -= 1;
}
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = AppState{};
while (state.running) {
state.update();
try term.drawWithContext(&state, render);
if (try term.pollEvent(50)) |event| {
handleEvent(&state, event);
}
}
}
fn handleEvent(state: *AppState, event: Event) void {
switch (event) {
.key => |key| {
switch (key.code) {
.esc => state.running = false,
.char => |c| {
switch (c) {
'q', 'Q' => state.running = false,
'1' => state.tab_index = 0,
'2' => state.tab_index = 1,
'3' => state.tab_index = 2,
else => {},
}
},
.tab => state.nextTab(),
.backtab => state.prevTab(),
.right => state.nextTab(),
.left => state.prevTab(),
else => {},
}
},
else => {},
}
}
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
// Main layout: tabs at top, content below
const main_chunks = Layout.vertical(&.{
Constraint.length(3),
Constraint.min(0),
}).split(area);
// Render tabs
renderTabs(state, main_chunks.get(0), buf);
// Render content based on selected tab
switch (state.tab_index) {
0 => renderOverview(state, main_chunks.get(1), buf),
1 => renderMetrics(state, main_chunks.get(1), buf),
2 => renderHelp(main_chunks.get(1), buf),
else => {},
}
}
fn renderTabs(state: *AppState, area: Rect, buf: *Buffer) void {
const titles = [_]Line{
Line.raw("Overview"),
Line.raw("Metrics"),
Line.raw("Help"),
};
const tabs = Tabs.init(&titles)
.setBlock(Block.init()
.title(" Dashboard ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.cyan)))
.select(state.tab_index)
.highlightStyle(Style.default.fg(Color.yellow).bold());
tabs.render(area, buf);
}
fn renderOverview(state: *AppState, area: Rect, buf: *Buffer) void {
// Split into gauges and sparklines
const chunks = Layout.vertical(&.{
Constraint.length(5),
Constraint.length(5),
Constraint.min(0),
}).split(area);
// CPU Gauge
const cpu_gauge = Gauge.init()
.setBlock(Block.init().title(" CPU ").setBorders(Borders.all))
.percent(@intCast(state.cpu))
.gaugeStyle(Style.default.fg(Color.green));
cpu_gauge.render(chunks.get(0), buf);
// Memory Gauge
const mem_gauge = Gauge.init()
.setBlock(Block.init().title(" Memory ").setBorders(Borders.all))
.percent(@intCast(state.mem))
.gaugeStyle(Style.default.fg(Color.blue));
mem_gauge.render(chunks.get(1), buf);
// Recent events
renderEvents(state, chunks.get(2), buf);
}
fn renderMetrics(state: *AppState, area: Rect, buf: *Buffer) void {
// Split for two sparklines
const chunks = Layout.vertical(&.{
Constraint.percentage(50),
Constraint.percentage(50),
}).split(area);
// CPU Sparkline
const cpu_spark = Sparkline.init()
.setBlock(Block.init().title(" CPU History ").setBorders(Borders.all))
.setData(&state.cpu_history)
.setMax(100)
.setStyle(Style.default.fg(Color.green));
cpu_spark.render(chunks.get(0), buf);
// Memory Sparkline
const mem_spark = Sparkline.init()
.setBlock(Block.init().title(" Memory History ").setBorders(Borders.all))
.setData(&state.mem_history)
.setMax(100)
.setStyle(Style.default.fg(Color.blue));
mem_spark.render(chunks.get(1), buf);
}
fn renderEvents(state: *AppState, area: Rect, buf: *Buffer) void {
// Generate some fake events based on tick
var event_strs: [5][64]u8 = undefined;
var event_items: [5]ListItem = undefined;
for (0..5) |i| {
const tick_val = state.tick -| (4 - i);
const written = std.fmt.bufPrint(&event_strs[i], "[{d:0>4}] System event {d}", .{ tick_val, i + 1 }) catch "???";
event_items[i] = ListItem.raw(written);
}
const list = List.init(&event_items)
.setBlock(Block.init()
.title(" Recent Events ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.yellow)));
list.render(area, buf);
}
fn renderHelp(area: Rect, buf: *Buffer) void {
const help_block = Block.init()
.title(" Help ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.magenta));
help_block.render(area, buf);
const inner = help_block.inner(area);
var y = inner.top();
const lines = [_][]const u8{
"Dashboard Demo - zcatui",
"",
"Navigation:",
" Tab/Shift+Tab - Switch tabs",
" Left/Right - Switch tabs",
" 1/2/3 - Jump to tab",
"",
"General:",
" q/ESC - Quit",
"",
"This demo shows multiple widgets:",
" - Tabs for navigation",
" - Gauges for percentages",
" - Sparklines for time series",
" - Lists for events/logs",
};
for (lines) |line| {
if (y < inner.bottom()) {
_ = buf.setString(inner.left(), y, line, Style.default);
y += 1;
}
}
}

188
examples/list_demo.zig Normal file
View file

@ -0,0 +1,188 @@
//! Interactive list demo for zcatui.
//!
//! Demonstrates a navigable list with keyboard controls.
//! - Up/Down or j/k: Navigate items
//! - Enter: Select item
//! - q/ESC: Quit
//!
//! Run with: zig build list-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Buffer = zcatui.Buffer;
const Rect = zcatui.Rect;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Event = zcatui.Event;
const KeyCode = zcatui.KeyCode;
const Layout = zcatui.Layout;
const Constraint = zcatui.Constraint;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const List = zcatui.widgets.List;
const ListItem = zcatui.widgets.ListItem;
const ListState = zcatui.widgets.ListState;
const items = [_][]const u8{
"Item 1 - First item in the list",
"Item 2 - Second item",
"Item 3 - Third item",
"Item 4 - Fourth item",
"Item 5 - Fifth item",
"Item 6 - Sixth item",
"Item 7 - Seventh item",
"Item 8 - Eighth item",
"Item 9 - Ninth item",
"Item 10 - Tenth item",
};
const AppState = struct {
list_state: ListState,
selected_item: ?usize = null,
running: bool = true,
fn init() AppState {
return .{
.list_state = ListState.default,
};
}
fn nextItem(self: *AppState) void {
const current = self.list_state.selected orelse 0;
if (current < items.len - 1) {
self.list_state.selected = current + 1;
}
}
fn prevItem(self: *AppState) void {
const current = self.list_state.selected orelse 0;
if (current > 0) {
self.list_state.selected = current - 1;
}
}
fn selectCurrent(self: *AppState) void {
self.selected_item = self.list_state.selected;
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = AppState.init();
state.list_state.selected = 0; // Start with first item selected
while (state.running) {
try term.drawWithContext(&state, render);
if (try term.pollEvent(100)) |event| {
handleEvent(&state, event);
}
}
}
fn handleEvent(state: *AppState, event: Event) void {
switch (event) {
.key => |key| {
switch (key.code) {
.esc => state.running = false,
.char => |c| {
switch (c) {
'q', 'Q' => state.running = false,
'j' => state.nextItem(),
'k' => state.prevItem(),
else => {},
}
},
.down => state.nextItem(),
.up => state.prevItem(),
.enter => state.selectCurrent(),
else => {},
}
},
else => {},
}
}
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
// Layout: list on left, info on right
const chunks = Layout.horizontal(&.{
Constraint.percentage(60),
Constraint.percentage(40),
}).split(area);
// Render list
renderList(state, chunks.get(0), buf);
// Render info panel
renderInfo(state, chunks.get(1), buf);
}
fn renderList(state: *AppState, area: Rect, buf: *Buffer) void {
// Create list items
var list_items: [items.len]ListItem = undefined;
for (items, 0..) |item, i| {
list_items[i] = ListItem.raw(item);
}
const list = List.init(&list_items)
.setBlock(Block.init()
.title(" Items (j/k or arrows) ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.cyan)))
.highlightStyle(Style.default.fg(Color.black).bg(Color.cyan))
.highlightSymbol("> ");
list.renderStateful(area, buf, &state.list_state);
}
fn renderInfo(state: *AppState, area: Rect, buf: *Buffer) void {
const info_block = Block.init()
.title(" Info ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.yellow));
info_block.render(area, buf);
const inner = info_block.inner(area);
var y = inner.top();
// Selected index
var idx_buf: [32]u8 = undefined;
const idx_str = if (state.list_state.selected) |sel|
std.fmt.bufPrint(&idx_buf, "Index: {d}", .{sel}) catch "?"
else
"Index: none";
_ = buf.setString(inner.left(), y, idx_str, Style.default);
y += 2;
// Last selected item
_ = buf.setString(inner.left(), y, "Last selected:", Style.default.bold());
y += 1;
if (state.selected_item) |sel| {
if (sel < items.len) {
_ = buf.setString(inner.left(), y, items[sel], Style.default.fg(Color.green));
}
} else {
_ = buf.setString(inner.left(), y, "(press Enter)", Style.default.fg(Color.white));
}
y += 3;
// Help
_ = buf.setString(inner.left(), y, "Controls:", Style.default.bold());
y += 1;
_ = buf.setString(inner.left(), y, "Up/k - Previous", Style.default);
y += 1;
_ = buf.setString(inner.left(), y, "Down/j - Next", Style.default);
y += 1;
_ = buf.setString(inner.left(), y, "Enter - Select", Style.default);
y += 1;
_ = buf.setString(inner.left(), y, "q/ESC - Quit", Style.default);
}

210
examples/table_demo.zig Normal file
View file

@ -0,0 +1,210 @@
//! Interactive table demo for zcatui.
//!
//! Demonstrates a table with row selection and scrolling.
//! - Up/Down or j/k: Navigate rows
//! - Left/Right or h/l: Change column widths
//! - q/ESC: Quit
//!
//! Run with: zig build table-demo
const std = @import("std");
const zcatui = @import("zcatui");
const Terminal = zcatui.Terminal;
const Buffer = zcatui.Buffer;
const Rect = zcatui.Rect;
const Style = zcatui.Style;
const Color = zcatui.Color;
const Event = zcatui.Event;
const KeyCode = zcatui.KeyCode;
const Layout = zcatui.Layout;
const Constraint = zcatui.Constraint;
const Block = zcatui.widgets.Block;
const Borders = zcatui.widgets.Borders;
const Table = zcatui.widgets.Table;
const TableRow = zcatui.widgets.TableRow;
const TableCell = zcatui.widgets.TableCell;
const TableState = zcatui.widgets.TableState;
const data = [_][4][]const u8{
.{ "1", "Alice", "alice@example.com", "Active" },
.{ "2", "Bob", "bob@example.com", "Active" },
.{ "3", "Charlie", "charlie@example.com", "Inactive" },
.{ "4", "Diana", "diana@example.com", "Active" },
.{ "5", "Eve", "eve@example.com", "Pending" },
.{ "6", "Frank", "frank@example.com", "Active" },
.{ "7", "Grace", "grace@example.com", "Inactive" },
.{ "8", "Henry", "henry@example.com", "Active" },
.{ "9", "Ivy", "ivy@example.com", "Pending" },
.{ "10", "Jack", "jack@example.com", "Active" },
.{ "11", "Kate", "kate@example.com", "Active" },
.{ "12", "Leo", "leo@example.com", "Inactive" },
};
const AppState = struct {
table_state: TableState,
running: bool = true,
fn init() AppState {
var state = AppState{
.table_state = TableState.init(),
};
state.table_state.selected = 0;
return state;
}
fn nextRow(self: *AppState) void {
const current = self.table_state.selected orelse 0;
if (current < data.len - 1) {
self.table_state.selected = current + 1;
}
}
fn prevRow(self: *AppState) void {
const current = self.table_state.selected orelse 0;
if (current > 0) {
self.table_state.selected = current - 1;
}
}
fn firstRow(self: *AppState) void {
self.table_state.selected = 0;
}
fn lastRow(self: *AppState) void {
self.table_state.selected = data.len - 1;
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var term = try Terminal.init(allocator);
defer term.deinit();
var state = AppState.init();
while (state.running) {
try term.drawWithContext(&state, render);
if (try term.pollEvent(100)) |event| {
handleEvent(&state, event);
}
}
}
fn handleEvent(state: *AppState, event: Event) void {
switch (event) {
.key => |key| {
switch (key.code) {
.esc => state.running = false,
.char => |c| {
switch (c) {
'q', 'Q' => state.running = false,
'j' => state.nextRow(),
'k' => state.prevRow(),
'g' => state.firstRow(),
'G' => state.lastRow(),
else => {},
}
},
.down => state.nextRow(),
.up => state.prevRow(),
.home => state.firstRow(),
.end => state.lastRow(),
else => {},
}
},
else => {},
}
}
fn render(state: *AppState, area: Rect, buf: *Buffer) void {
// Layout: table takes most space, status bar at bottom
const chunks = Layout.vertical(&.{
Constraint.min(0),
Constraint.length(3),
}).split(area);
// Render table
renderTable(state, chunks.get(0), buf);
// Render status bar
renderStatus(state, chunks.get(1), buf);
}
fn renderTable(state: *AppState, area: Rect, buf: *Buffer) void {
// Create header
var header_cells: [4]TableCell = undefined;
const headers = [_][]const u8{ "ID", "Name", "Email", "Status" };
for (headers, 0..) |h, i| {
header_cells[i] = TableCell.fromString(h);
}
const header = TableRow.init(&header_cells)
.setStyle(Style.default.fg(Color.yellow).bold());
// Create rows
var rows: [data.len]TableRow = undefined;
for (data, 0..) |row_data, i| {
var cells: [4]TableCell = undefined;
for (row_data, 0..) |cell_data, j| {
// Color status column
const cell_style = if (j == 3) blk: {
if (std.mem.eql(u8, cell_data, "Active")) {
break :blk Style.default.fg(Color.green);
} else if (std.mem.eql(u8, cell_data, "Inactive")) {
break :blk Style.default.fg(Color.red);
} else {
break :blk Style.default.fg(Color.yellow);
}
} else Style.default;
cells[j] = TableCell.fromString(cell_data).setStyle(cell_style);
}
rows[i] = TableRow.init(&cells);
}
// Column widths
const widths = [_]Constraint{
Constraint.length(4),
Constraint.length(12),
Constraint.min(20),
Constraint.length(10),
};
const table = Table.init()
.setRows(&rows)
.setWidths(&widths)
.setHeader(header)
.setBlock(Block.init()
.title(" Users Table ")
.setBorders(Borders.all)
.style(Style.default.fg(Color.cyan)))
.rowHighlightStyle(Style.default.fg(Color.black).bg(Color.cyan))
.highlightSymbol("> ");
table.renderStateful(area, buf, &state.table_state);
}
fn renderStatus(state: *AppState, area: Rect, buf: *Buffer) void {
const status_block = Block.init()
.setBorders(Borders.all)
.style(Style.default.fg(Color.blue));
status_block.render(area, buf);
const inner = status_block.inner(area);
// Row info
var row_buf: [64]u8 = undefined;
const row_str = if (state.table_state.selected) |sel|
std.fmt.bufPrint(&row_buf, "Row {d}/{d}", .{ sel + 1, data.len }) catch "?"
else
"No selection";
_ = buf.setString(inner.left(), inner.top(), row_str, Style.default);
// Help text
const help = "j/k:Navigate | g/G:First/Last | q:Quit";
const help_x = if (inner.width > help.len) inner.right() - @as(u16, @intCast(help.len)) else inner.left();
_ = buf.setString(help_x, inner.top(), help, Style.default.fg(Color.white));
}

View file

@ -28,6 +28,16 @@ const Constraint = layout_mod.Constraint;
const list_mod = @import("list.zig");
const HighlightSpacing = list_mod.HighlightSpacing;
// ============================================================================
// ColumnPos
// ============================================================================
/// Column position information used during rendering.
const ColumnPos = struct {
x: u16,
width: u16,
};
// ============================================================================
// Cell
// ============================================================================
@ -37,7 +47,7 @@ const HighlightSpacing = list_mod.HighlightSpacing;
/// You can style the cell and its content independently.
pub const Cell = struct {
/// The text content of the cell.
content: Text = Text.default,
content: Text = Text.empty,
/// Style applied to the cell area.
style: Style = Style.default,
@ -423,7 +433,12 @@ pub const Table = struct {
/// Returns the selection column width.
fn selectionWidth(self: Table, state: *TableState) u16 {
const has_selection = state.selected != null;
if (self.highlight_spacing.shouldAdd(has_selection)) {
const should_show = switch (self.highlight_spacing) {
.always => true,
.when_selected => has_selection,
.never => false,
};
if (should_show) {
return @intCast(text_mod.unicodeWidth(self.highlight_symbol));
}
return 0;
@ -470,7 +485,7 @@ pub const Table = struct {
const sel_width = self.selectionWidth(state);
// Calculate column widths
var column_positions: [64]struct { x: u16, width: u16 } = undefined;
var column_positions: [64]ColumnPos = undefined;
const positions = self.calculateColumnPositions(table_area.width, sel_width, col_count, &column_positions);
// Calculate layout areas
@ -519,8 +534,8 @@ pub const Table = struct {
max_width: u16,
selection_width: u16,
col_count: usize,
out: []struct { x: u16, width: u16 },
) []struct { x: u16, width: u16 } {
out: []ColumnPos,
) []ColumnPos {
if (col_count == 0) return out[0..0];
const actual_count = @min(col_count, out.len);
@ -543,13 +558,13 @@ pub const Table = struct {
const space_for_cols = available_width -| total_spacing;
for (out[0..actual_count], 0..) |*pos, i| {
const constraint = if (i < self.widths.len) self.widths[i] else Constraint{ .min = 0 };
const constraint = if (i < self.widths.len) self.widths[i] else Constraint.min(0);
const width: u16 = switch (constraint) {
.length => |l| @min(l, space_for_cols),
.percentage => |p| @intCast((space_for_cols * p) / 100),
.min => |m| m,
.max => |m| @min(m, space_for_cols),
.ratio => |r| @intCast(@as(u32, space_for_cols) * r.num / @max(1, r.den)),
.len => |l| @min(l, space_for_cols),
.pct => |p| @intCast((space_for_cols * p) / 100),
.min_size => |m| m,
.max_size => |m| @min(m, space_for_cols),
.rat => |r| @intCast(@as(u32, space_for_cols) * r.num / @max(1, r.den)),
};
pos.x = x;
pos.width = width;
@ -565,7 +580,7 @@ pub const Table = struct {
row: Row,
area: Rect,
buf: *Buffer,
positions: []struct { x: u16, width: u16 },
positions: []ColumnPos,
) void {
_ = self;
for (positions, 0..) |pos, i| {
@ -587,7 +602,7 @@ pub const Table = struct {
buf: *Buffer,
state: *TableState,
selection_width: u16,
positions: []struct { x: u16, width: u16 },
positions: []ColumnPos,
) void {
if (self.rows.len == 0) return;