Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300//! 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 tea = @import("blacktea");const matte = @import("matte");const key = @import("key.zig");const util = @import("util.zig");
const t = std.testing;const Writer = std.Io.Writer;
const Help = @This();
const Styles = struct { short_key: matte.Style = .{ .foreground = .{ .rgb = .{ .r = 0x62, .g = 0x62, .b = 0x62 } } }, short_desc: matte.Style = .{ .foreground = .{ .rgb = .{ .r = 0x4A, .g = 0x4A, .b = 0x4A } } }, short_separator: matte.Style = .{ .foreground = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } } }, full_key: matte.Style = .{ .foreground = .{ .rgb = .{ .r = 0x62, .g = 0x62, .b = 0x62 } } }, full_desc: matte.Style = .{ .foreground = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } } }, full_separator: matte.Style = .{ .foreground = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } } }, ellipsis: matte.Style = .{ .foreground = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } } },
pub const unstyled: Styles = .{ .short_key = .none, .short_desc = .none, .short_separator = .none, .full_key = .none, .full_desc = .none, .full_separator = .none, .ellipsis = .none, };};
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 = .{},
model: tea.Model = .{ .vtable = &.{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, },},
fn vtableInit(_: *tea.Model) ?tea.Cmd { return null;}
fn vtableUpdate(_: *tea.Model, _: tea.Msg) ?tea.Cmd { return null;}
fn vtableView(_: *tea.Model, _: *Writer) Writer.Error!tea.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, writer: *Writer, bindings: []const key.Binding) Writer.Error!void { if (bindings.len == 0) return; const sep_w = matte.measure.cellWidth(self.short_separator); const ellipsis_w = matte.measure.cellWidth(self.ellipsis);
var total_w: u16 = 0; var first = true; for (bindings) |b| { if (!b.enabled()) continue; const item_w: u16 = matte.measure.cellWidth(b.help.key) + 1 + matte.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) { // If " <ellipsis>" fits, print it and stop. Otherwise bubbles // renders the overflowing item anyway (help.go:234-243), so fall // through. Threshold is strict `<`, matching Go. const tail_w: u16 = 1 + ellipsis_w; if (total_w + tail_w < self.width) { try writer.writeByte(' '); try util.writeInline(writer, self.styles.ellipsis, self.ellipsis); return; } }
if (!first) try util.writeInline(writer, self.styles.short_separator, self.short_separator); try util.writeInline(writer, self.styles.short_key, b.help.key); try writer.writeByte(' '); try util.writeInline(writer, 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.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, writer: *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 = matte.measure.cellWidth(b.help.key); const dw = matte.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 = matte.measure.cellWidth(self.full_separator); const ellipsis_w = matte.measure.cellWidth(self.ellipsis); var fit_cols: usize = cols; var show_tail = false; 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) { // Overflow. If " <ellipsis>" still fits, emit it and stop; // otherwise bubbles renders the overflowing column anyway // (help.go:234-243). Threshold is strict `<`, matching Go. if (running + 1 + ellipsis_w < self.width) { show_tail = true; break; } } running += piece_w; fit_cols = gi + 1; } }
var r: usize = 0; while (r < max_rows) : (r += 1) { for (groups[0..fit_cols], 0..) |group, gi| { if (gi > 0) { // Separator is a height-1 join element: render it on the first // row only, blank (same width) on continuation rows. if (r == 0) { try util.writeInline(writer, self.styles.full_separator, self.full_separator); } else { try writePad(writer, sep_w); } } const cell = nthVisible(group, r); if (cell) |b| { try util.writeInline(writer, self.styles.full_key, b.help.key); try writePad(writer, key_widths[gi] - matte.measure.cellWidth(b.help.key)); try writer.writeByte(' '); try util.writeInline(writer, self.styles.full_desc, b.help.desc); try writePad(writer, desc_widths[gi] - matte.measure.cellWidth(b.help.desc)); } else { try writePad(writer, key_widths[gi] + 1 + desc_widths[gi]); } } if (show_tail) { // Tail ellipsis is also height-1: text on row one, blank after. if (r == 0) { try writer.writeByte(' '); try util.writeInline(writer, self.styles.ellipsis, self.ellipsis); } else { try writePad(writer, 1 + ellipsis_w); } } if (r + 1 < max_rows) try writer.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(writer: *Writer, count: u16) Writer.Error!void { var i: u16 = 0; while (i < count) : (i += 1) try writer.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 = .unstyled, .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 t.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 = .unstyled, .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 t.expectEqualStrings("k up …", w.buffered());}
test "shortView renders the overflowing item when the ellipsis won't fit" { var buf: [128]u8 = undefined; var w: Writer = .fixed(&buf); // width 2 is too small even for " …" (2 cells needs total+2 < 2, false), // so bubbles renders the first item anyway (help.go:242-243). const help: Help = .{ .styles = .unstyled, .width = 2 }; const bindings = [_]key.Binding{ .{ .keys = &.{key.rune('a')}, .help = .{ .key = "a", .desc = "alpha" } }, }; try help.shortView(&w, &bindings); try t.expectEqualStrings("a alpha", w.buffered());}
test "short view joins enabled bindings" { var buf: [128]u8 = undefined; var w: Writer = .fixed(&buf); const help: Help = .{ .styles = .unstyled }; 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 t.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 = .unstyled }; 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. // Separator is height-1: it renders only on row 1; on row 2 it is blank // (3 cells). Empty col_b cell on row 2 pads (1+1+4)=6 spaces. try t.expectEqualStrings("↑ up | q quit\nkk two ", w.buffered());}