//! 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 " " 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 " " 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()); }