Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274//! Renders help text for a set of `key.Binding`s. Two modes://! - `shortView(w, bindings)`: one-line "key desc • key desc" listing.//! - `fullView(allocator, w, groups)`: multi-column grid; each group is//! a column rendered as `key desc` rows aligned within the column.//!//! Disabled bindings are skipped.const std = @import("std");const matcha = @import("matcha");const blush = @import("blush");const key = @import("key.zig");const util = @import("util.zig");
const Writer = std.Io.Writer;const Cmd = matcha.Cmd;const Msg = matcha.Msg;
const Help = @This();
pub const Styles = struct { short_key: ?blush.Style = null, short_desc: ?blush.Style = null, short_separator: ?blush.Style = null, full_key: ?blush.Style = null, full_desc: ?blush.Style = null, full_separator: ?blush.Style = null, ellipsis: ?blush.Style = null,};
/// Default dark-theme palette, matching bubbles' `DefaultDarkStyles`.pub const default_key_color: blush.Style.Color = .{ .rgb = .{ .r = 0x62, .g = 0x62, .b = 0x62 } };pub const default_desc_color: blush.Style.Color = .{ .rgb = .{ .r = 0x4A, .g = 0x4A, .b = 0x4A } };pub const default_separator_color: blush.Style.Color = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } };
pub const default_styles: Styles = .{ .short_key = .{ .foreground = default_key_color }, .short_desc = .{ .foreground = default_desc_color }, .short_separator = .{ .foreground = default_separator_color }, .full_key = .{ .foreground = default_key_color }, .full_desc = .{ .foreground = default_desc_color }, .full_separator = .{ .foreground = default_separator_color }, .ellipsis = .{ .foreground = default_separator_color },};
model: matcha.Model = .{ .vtable = &vtable },
show_all: bool = false,short_separator: []const u8 = " • ",full_separator: []const u8 = " ",/// Marker rendered when content exceeds `width`. Empty means no marker.ellipsis: []const u8 = "…",/// Maximum render width in cells. 0 = unlimited (no truncation).width: u16 = 0,styles: Styles = default_styles,
const vtable: matcha.Model.VTable = .{ .init = vtableInit, .update = vtableUpdate, .view = vtableView,};
fn vtableInit(_: *matcha.Model) ?Cmd { return null;}
fn vtableUpdate(_: *matcha.Model, _: Msg) ?Cmd { return null;}
fn vtableView(_: *matcha.Model, _: *Writer) Writer.Error!matcha.View { // Help requires bindings supplied at render time; the vtable view is a // no-op. Use shortView / fullView from the parent's view callback. return .{};}
pub fn shortView(self: *const Help, w: *Writer, bindings: []const key.Binding) Writer.Error!void { if (bindings.len == 0) return; const sep_w = blush.measure.cellWidth(self.short_separator); const ellipsis_w = blush.measure.cellWidth(self.ellipsis);
var total_w: u16 = 0; var first = true; for (bindings) |b| { if (!b.enabled()) continue; const item_w: u16 = blush.measure.cellWidth(b.help.key) + 1 + blush.measure.cellWidth(b.help.desc); const piece_w: u16 = if (first) item_w else item_w + sep_w;
if (self.width > 0 and total_w + piece_w > self.width) { // Try to fit " <ellipsis>" instead. Matches bubbles' permissive // behaviour: if the tail itself would overflow we just stop. const tail_w: u16 = 1 + ellipsis_w; if (total_w + tail_w <= self.width) { try w.writeByte(' '); try util.writeInline(w, self.styles.ellipsis, self.ellipsis); } return; }
if (!first) try util.writeInline(w, self.styles.short_separator, self.short_separator); try util.writeInline(w, self.styles.short_key, b.help.key); try w.writeByte(' '); try util.writeInline(w, self.styles.short_desc, b.help.desc); total_w += piece_w; first = false; }}
/// Maximum number of columns supported by `fullView`. Bumps here are cheap/// (stack memory only); 16 covers any realistic help layout.pub const max_columns: usize = 16;
/// Renders `groups` as side-by-side columns. Within each column the `key`/// is right-padded to the column's max key width and the `desc` is/// right-padded to the column's max desc width, so the next column's/// separator starts at a consistent horizontal position on every row./// Extra columns beyond `max_columns` are silently dropped.pub fn fullView( self: *const Help, w: *Writer, groups: []const []const key.Binding,) Writer.Error!void { var key_widths: [max_columns]u16 = @splat(0); var desc_widths: [max_columns]u16 = @splat(0); const cols = @min(groups.len, max_columns); var max_rows: usize = 0; for (groups[0..cols], 0..) |group, gi| { var key_w: u16 = 0; var desc_w: u16 = 0; var rows: usize = 0; for (group) |b| { if (!b.enabled()) continue; const kw = blush.measure.cellWidth(b.help.key); const dw = blush.measure.cellWidth(b.help.desc); if (kw > key_w) key_w = kw; if (dw > desc_w) desc_w = dw; rows += 1; } key_widths[gi] = key_w; desc_widths[gi] = desc_w; if (rows > max_rows) max_rows = rows; }
// Determine how many columns fit within `width`. const sep_w = blush.measure.cellWidth(self.full_separator); const ellipsis_w = blush.measure.cellWidth(self.ellipsis); var fit_cols: usize = cols; if (self.width > 0) { var running: u16 = 0; fit_cols = 0; var gi: usize = 0; while (gi < cols) : (gi += 1) { const col_w = key_widths[gi] + 1 + desc_widths[gi]; const piece_w: u16 = if (gi == 0) col_w else col_w + sep_w; if (running + piece_w > self.width) break; running += piece_w; fit_cols = gi + 1; } } const truncated = fit_cols < cols; // Bubbles only renders the tail when there's room for " <ellipsis>". const tail_fits = self.width == 0 or (self.width > 0 and computeRowWidth(key_widths[0..fit_cols], desc_widths[0..fit_cols], sep_w) + 1 + ellipsis_w <= self.width);
var r: usize = 0; while (r < max_rows) : (r += 1) { for (groups[0..fit_cols], 0..) |group, gi| { if (gi > 0) try util.writeInline(w, self.styles.full_separator, self.full_separator); const cell = nthVisible(group, r); if (cell) |b| { try util.writeInline(w, self.styles.full_key, b.help.key); try writePad(w, key_widths[gi] - blush.measure.cellWidth(b.help.key)); try w.writeByte(' '); try util.writeInline(w, self.styles.full_desc, b.help.desc); try writePad(w, desc_widths[gi] - blush.measure.cellWidth(b.help.desc)); } else { try writePad(w, key_widths[gi] + 1 + desc_widths[gi]); } } if (truncated and tail_fits) { try w.writeByte(' '); try util.writeInline(w, self.styles.ellipsis, self.ellipsis); } if (r + 1 < max_rows) try w.writeByte('\n'); }}
fn computeRowWidth(key_widths: []const u16, desc_widths: []const u16, sep_w: u16) u16 { var total: u16 = 0; for (key_widths, desc_widths, 0..) |kw, dw, gi| { const col_w = kw + 1 + dw; total += if (gi == 0) col_w else col_w + sep_w; } return total;}
fn writePad(w: *Writer, count: u16) Writer.Error!void { var i: u16 = 0; while (i < count) : (i += 1) try w.writeByte(' ');}
fn nthVisible(group: []const key.Binding, n: usize) ?key.Binding { var i: usize = 0; for (group) |b| { if (!b.enabled()) continue; if (i == n) return b; i += 1; } return null;}
test "shortView truncates with ellipsis when over width" { var buf: [128]u8 = undefined; var w: Writer = .fixed(&buf); const help: Help = .{ .styles = .{}, .width = 12 }; const bindings = [_]key.Binding{ .{ .keys = &.{key.rune('a')}, .help = .{ .key = "a", .desc = "alpha" } }, .{ .keys = &.{key.rune('b')}, .help = .{ .key = "b", .desc = "beta" } }, .{ .keys = &.{key.rune('c')}, .help = .{ .key = "c", .desc = "gamma" } }, }; try help.shortView(&w, &bindings); // "a alpha" (7) + " • " (3) + "b beta" (6) = 16 > 12, so we should // stop after "a alpha" and append " …". try std.testing.expectEqualStrings("a alpha …", w.buffered());}
test "fullView truncates columns over width" { var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); const help: Help = .{ .styles = .{}, .full_separator = " | ", .width = 8 }; const col_a = [_]key.Binding{ .{ .keys = &.{key.rune('k')}, .help = .{ .key = "k", .desc = "up" } }, }; const col_b = [_]key.Binding{ .{ .keys = &.{key.rune('q')}, .help = .{ .key = "q", .desc = "quit" } }, }; const groups = [_][]const key.Binding{ &col_a, &col_b }; try help.fullView(&w, &groups); // col_a width = 1+1+2 = 4 fits. + sep " | " (3) + col_b (1+1+4=6) = 13 > 8, stop. // Tail " …" needs 4 + 1 + 1 = 6 <= 8, append. try std.testing.expectEqualStrings("k up …", w.buffered());}
test "short view joins enabled bindings" { var buf: [128]u8 = undefined; var w: Writer = .fixed(&buf); const help: Help = .{ .styles = .{} }; const bindings = [_]key.Binding{ .{ .keys = &.{key.special(.up)}, .help = .{ .key = "↑", .desc = "up" } }, .{ .keys = &.{key.special(.down)}, .help = .{ .key = "↓", .desc = "down" } }, .{ .keys = &.{key.rune('q')}, .help = .{ .key = "q", .desc = "quit" }, .disabled = true }, }; try help.shortView(&w, &bindings); try std.testing.expectEqualStrings("↑ up • ↓ down", w.buffered());}
test "full view two columns aligned" { var buf: [256]u8 = undefined; var w: Writer = .fixed(&buf); const help: Help = .{ .full_separator = " | ", .styles = .{} }; const col_a = [_]key.Binding{ .{ .keys = &.{key.special(.up)}, .help = .{ .key = "↑", .desc = "up" } }, .{ .keys = &.{key.rune('k')}, .help = .{ .key = "kk", .desc = "two" } }, }; const col_b = [_]key.Binding{ .{ .keys = &.{key.rune('q')}, .help = .{ .key = "q", .desc = "quit" } }, }; const groups = [_][]const key.Binding{ &col_a, &col_b }; try help.fullView(&w, &groups); // col_a key width 2 ("kk"), desc width 3 ("two"); col_b key 1, desc 4. // Empty col_b cell on row 1 pads (1+1+4)=6 spaces after the separator. try std.testing.expectEqualStrings("↑ up | q quit\nkk two | ", w.buffered());}