Module: Rubino::UI::MenuView
- Defined in:
- lib/rubino/ui/menu_view.rb
Overview
The single presentational renderer shared by the two navigable dropdowns — the CompletionMenu (‘/` command + `@file` palette) and the AgentMenu (`↓` live-subagent picker). It owns ONLY the look: the scroll-window slice, the `❯`/`┊` glyph rows, the cyan-`❯` + inverse-video highlight, an optional header, the optional per-row sub-line, and the overflow footer. Neither menu’s state machine lives here — each keeps its own filtering / ‘◂ main` / sticky-Esc / self-close logic and just hands MenuView its already-decided rows, so a user who learns one dropdown recognises the other (#562).
A row descriptor is a Hash:
* :label — the visible text (caller pre-colours any status spans);
* :desc — an optional dim description shown in an aligned right
column (the command menu's one-liners), or nil;
* :sub — an optional dim sub-line drawn UNDER the row when selected
(the picker's live-activity line), or nil;
* :pad_key — the plain string measured for the :desc column alignment
(defaults to :label); descriptions align on the widest.
Class Method Summary collapse
-
.append_desc(line, row, pad, selected:) ⇒ Object
Append the dim description in an aligned column.
- .footer(selected, total, hints) ⇒ Object
- .format_row(row, pad, cols, selected:) ⇒ Object
- .pastel ⇒ Object
-
.render(rows, cols, window:, header: nil, hints: nil) ⇒ Object
Render the box: slice
rowsto thetop/max_rowswindow, mark theselectedrow with the cyan ❯ + inverse highlight (others a dim ┊), prepend a dim ‘┄ header ┄` when given, and append the dim `┄ <n>/<total> · <hints> ┄` footer when the list overflows the window. -
.safe(text) ⇒ Object
CWE-150 render-sink defense.
- .sub_row(sub, cols) ⇒ Object
-
.window_top(selected, size, top, max_rows) ⇒ Object
The visible-window top index keeping
selectedin view, given the currenttop.
Class Method Details
.append_desc(line, row, pad, selected:) ⇒ Object
Append the dim description in an aligned column. The inverse highlight widens the selected label by 2 (its padding spaces), so the unselected rows get +2 to line their descriptions up with the selected one.
81 82 83 84 85 |
# File 'lib/rubino/ui/menu_view.rb', line 81 def append_desc(line, row, pad, selected:) plain = safe(row[:pad_key] || row[:label]) line += " " * (pad - LiveRegion.display_width(plain) + (selected ? 0 : 2)) line + pastel.dim(safe(row[:desc])) end |
.footer(selected, total, hints) ⇒ Object
106 107 108 109 110 |
# File 'lib/rubino/ui/menu_view.rb', line 106 def (selected, total, hints) body = "#{selected + 1}/#{total}" body += " · #{hints}" if hints && !hints.empty? pastel.dim("┄ #{body} ┄") end |
.format_row(row, pad, cols, selected:) ⇒ Object
67 68 69 70 71 72 73 74 75 76 |
# File 'lib/rubino/ui/menu_view.rb', line 67 def format_row(row, pad, cols, selected:) label = safe(row[:label]) line = if selected "#{pastel.cyan("❯")} #{pastel.inverse(" #{label} ")}" else "#{pastel.dim("┊")} #{label}" end line = append_desc(line, row, pad, selected: selected) if row[:desc] LiveRegion.take_first_columns(line, cols) end |
.pastel ⇒ Object
112 113 114 |
# File 'lib/rubino/ui/menu_view.rb', line 112 def pastel @pastel ||= Pastel.new end |
.render(rows, cols, window:, header: nil, hints: nil) ⇒ Object
Render the box: slice rows to the top/max_rows window, mark the selected row with the cyan ❯ + inverse highlight (others a dim ┊), prepend a dim ‘┄ header ┄` when given, and append the dim `┄ <n>/<total> · <hints> ┄` footer when the list overflows the window.
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
# File 'lib/rubino/ui/menu_view.rb', line 38 def render(rows, cols, window:, header: nil, hints: nil) return [] if rows.empty? selected, top, max_rows = window.values_at(:selected, :top, :max_rows) slice = rows[top, max_rows] || [] pad = slice.map { |r| LiveRegion.display_width(safe(r[:pad_key] || r[:label])) }.max.to_i out = [] out << pastel.dim("┄ #{header} ┄") if header slice.each_with_index do |row, i| chosen = top + i == selected out << format_row(row, pad, cols, selected: chosen) out << sub_row(row[:sub], cols) if chosen && row[:sub] && !row[:sub].to_s.empty? end out << (selected, rows.size, hints) if rows.size > max_rows out end |
.safe(text) ⇒ Object
CWE-150 render-sink defense. Menu LABELS/descs/subs are UNTRUSTED — the ‘@file` palette feeds raw workspace filenames straight in, so a file named with embedded escapes (`e[2J` clear-screen, `e]0;…a` OSC title-set, `e[?1049h` alt-screen, CR/BEL) would otherwise be rendered VERBATIM to the TTY the instant the picker opens — pre-tool, no approval, no gesture (#563). Neutralize every dangerous control byte at THIS chokepoint so it covers ALL label sources (the @file completer, the / command menu, the agent picker). Use the SGR-preserving variant because callers legitimately pre-colour status spans (e.g. the picker’s live glyph) — those inert color codes survive; everything that can move the cursor, repaint, set the title, or write the clipboard is caret-ised.
102 103 104 |
# File 'lib/rubino/ui/menu_view.rb', line 102 def safe(text) Rubino::Util::Output.sanitize_terminal_keep_sgr(text.to_s) end |
.sub_row(sub, cols) ⇒ Object
87 88 89 |
# File 'lib/rubino/ui/menu_view.rb', line 87 def sub_row(sub, cols) LiveRegion.take_first_columns(pastel.dim(" #{safe(sub)}"), cols) end |
.window_top(selected, size, top, max_rows) ⇒ Object
The visible-window top index keeping selected in view, given the current top. Shared by both menus’ scroll math so the window never jumps the highlight out of view (used to be re-implemented in each).
59 60 61 62 63 64 65 |
# File 'lib/rubino/ui/menu_view.rb', line 59 def window_top(selected, size, top, max_rows) return 0 if size <= max_rows top = selected if selected < top top = selected - max_rows + 1 if selected >= top + max_rows top.clamp(0, size - max_rows) end |