Class: Tuile::Component::MenuBar::Cascade
- Inherits:
-
Object
- Object
- Tuile::Component::MenuBar::Cascade
- Defined in:
- lib/tuile/component/menu_bar/cascade.rb,
sig/tuile.rbs
Overview
The stack of open menu panels — one ListDropdown per level, the last deepest — and the drill/pop/activate logic driving them. Private machinery of Tuile::Component::MenuBar; an app never names it.
cascade.open_below(segment_rect, item) # Enter/Down on the strip
cascade.step_to(segment_rect, item) # LEFT/RIGHT along the strip
return true if cascade.handle_key?(key) # MenuBar#handle_key?, first
cascade.close # focus lost, or rect changed
It also holds the bar's menu mode (#browsing?), which outlives the panels: stepping onto a top-level item with no menu shows nothing and stays in the mode, so the next step opens its neighbour's menu again.
A panel is a non-modal overlay, not a child, so it never takes focus: focus stays on the Tuile::Component::MenuBar for the whole interaction and every key arrives via #handle_key?, which offers it here first. That is Select's architecture extended to N levels, and it is why nothing in the key-dispatch ladder changes.
Widths are measured here, per level — the panel is as wide as the level's
widest label — because ListDropdown deliberately measures nothing
itself (design/decisions.md D_select).
Implementation details
While open it consumes everything except the two keys that mean
"leave this menu sideways", which only the strip can answer: LEFT at
depth 1, and RIGHT with no submenu under the highlight — a panel opened
with highlight: false has none at all, so it declines RIGHT throughout.
An open menu is quasi-modal — firing an app's s-to-save behind a
visible panel is worse than a dead keystroke.
UI-thread-confined, like everything in the tree (see Screen).
Constant Summary collapse
- SUBMENU_ARROW =
The affordance painted on a row that opens a submenu. U+25B8 rather than the obvious
▶: like Select's▾it is East-Asian Neutral, so it measures one column even under ambiguous-as-wide and stays outsideD_ambiguous_width's bet, where▶and▼are Ambiguous and would need an ASCII opt-in. "▸"- ARROW_WIDTH =
Columns a submenu arrow occupies, the space before it included.
2
Instance Method Summary collapse
-
#activate(level, item) ⇒ void
Drills into
item, or fires it and closes the cascade. - #activate_highlighted ⇒ void
-
#browsing? ⇒ Boolean
@return — whether the bar is in menu mode — which is not #open?: stepping onto a top-level item with no menu keeps the mode with no panel to show for it.
-
#close ⇒ void
Closes every open panel, deepest first, and leaves menu mode.
-
#deepest ⇒ ListDropdown
@return — the deepest open panel.
-
#depth ⇒ Integer
@return — how many panels are open;
0when closed. -
#enter_panel?(key) ⇒ Boolean
Moves into a panel opened with no highlighted row — Down, Enter and Space onto its first row, Up onto its last.
-
#handle_key?(key) ⇒ Boolean
Offers a key to the deepest panel and to the cascade's own verbs.
-
#handle_mnemonic?(key) ⇒ Boolean
Activates the deepest level's item bound to
key— the drill-or-fire the mnemonic shares with Enter. -
#highlighted(level) ⇒ Item?
@param
level. -
#initialize ⇒ Cascade
constructor
A new instance of Cascade.
-
#label_width_of(items) ⇒ Integer
@param
items. -
#open? ⇒ Boolean
@return — whether any panel is open.
-
#open_below(anchor, item) ⇒ void
Opens
item's children directly beneathanchor, first row highlighted, closing anything already open first. -
#pop ⇒ void
Closes the deepest panel; at depth 1 that closes the cascade.
-
#push(item, highlight: true) ⇒ void
Builds a panel for
item's children and yields it to be anchored, which is what opens it. -
#push_beside(level, item) ⇒ void
Opens
item's children beside the row highlighted inlevel. -
#renderer_for(items) ⇒ Proc
@param
items. -
#show(anchor, item, highlight:) ⇒ void
The body both entry verbs share: drop what is open, then mount a panel for
item's children if it has any. -
#step_to(anchor, item) ⇒ void
Shows
item's children beneathanchorwith no row highlighted — the sideways step along the strip. -
#truncate(count) ⇒ void
@param
count— how many panels to keep. -
#width_for(items) ⇒ Integer
@param
items.
Constructor Details
#initialize ⇒ Cascade
Returns a new instance of Cascade.
51 52 53 54 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 51 def initialize @levels = [] @browsing = false end |
Instance Method Details
#activate(level, item) ⇒ void
This method returns an undefined value.
Drills into item, or fires it and closes the cascade.
@param level — the panel the item belongs to.
@param item — nil (an off-content cursor) does nothing.
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 197 def activate(level, item) # Truncate first, for the mouse: a click on a shallower panel that is # still visible routes to *that* panel, so anything deeper is stale. truncate(level + 1) return if item.nil? if item. push_beside(level, item) else # Closed before the listener runs, so an action that opens a dialog # doesn't paint it under a menu. A listener-less leaf still closes: # activation stays uniform. close item.on_click.fire(Item::ClickEvent.new(source: item)) end end |
#activate_highlighted ⇒ void
This method returns an undefined value.
190 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 190 def activate_highlighted = activate(depth - 1, highlighted(depth - 1)) |
#browsing? ⇒ Boolean
@return — whether the bar is in menu mode — which is not #open?: stepping onto a top-level item with no menu keeps the mode with no panel to show for it. Cleared with the last panel, however it went.
63 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 63 def browsing? = @browsing |
#close ⇒ void
This method returns an undefined value.
Closes every open panel, deepest first, and leaves menu mode.
The flag earns its own line: menu mode outlives the panels, so at the
panel-less stop truncate has nothing to close and nothing to notify.
93 94 95 96 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 93 def close truncate(0) @browsing = false end |
#deepest ⇒ ListDropdown
@return — the deepest open panel.
187 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 187 def deepest = @levels.last[1] |
#depth ⇒ Integer
@return — how many panels are open; 0 when closed.
66 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 66 def depth = @levels.size |
#enter_panel?(key) ⇒ Boolean
Moves into a panel opened with no highlighted row — Down, Enter and Space onto its first row, Up onto its last. Every other key leaves the empty highlight alone, RIGHT above all: it falls through to be declined, which is what lets a sideways step keep stepping.
Up is answered here rather than by ListDropdown#move, which would
clamp backwards onto the first row (List::Cursor#go floors at 0).
@param key
@return — whether it moved in.
174 175 176 177 178 179 180 181 182 183 184 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 174 def enter_panel?(key) level = depth - 1 return false unless highlighted(level).nil? item, drop = @levels[level] case key when *Keys::DOWN_ARROWS, Keys::ENTER, " " then drop.select(0) when *Keys::UP_ARROWS then drop.select(item.items.size - 1) else false end end |
#handle_key?(key) ⇒ Boolean
Offers a key to the deepest panel and to the cascade's own verbs.
@param key
@return — true when consumed — almost always, while open.
false when closed, and for the two sideways keys Tuile::Component::MenuBar answers
(see the class docs).
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 103 def handle_key?(key) return false unless open? return true if enter_panel?(key) return true if deepest.move(key) case key when Keys::ENTER, " " then activate_highlighted when Keys::RIGHT_ARROW return false unless highlighted(depth - 1)&. activate_highlighted when Keys::LEFT_ARROW return false if depth < 2 pop when Keys::ESC then pop else # Only a printable: an open menu also swallows HOME, function keys # and the five-byte junk Keys.getkey returns for an unrecognized # escape sequence, and ringing at terminal noise is worse than # silence. A printable is a deliberate, visible act. Screen.instance.beep if Keys.printable?(key) end true end |
#handle_mnemonic?(key) ⇒ Boolean
Activates the deepest level's item bound to key — the drill-or-fire
the mnemonic shares with Enter. The highlight moves there first, so a
submenu anchors beside the row that opened it rather than beside
wherever the cursor happened to be.
@param key — a single printable, already downcased.
@return — whether an item on the deepest level claimed it. A miss is never offered to a shallower level.
136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 136 def handle_mnemonic?(key) return false unless open? level = depth - 1 item, drop = @levels[level] index = item.items.index { |child| child.mnemonic == key } return false if index.nil? drop.select(index) activate(level, item.items[index]) true end |
#highlighted(level) ⇒ Item?
@param level
@return — the item under level's cursor; nil when it sits
off-content. The range guard matters: a cursor at -1 would
otherwise index the last child.
284 285 286 287 288 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 284 def highlighted(level) item, drop = @levels[level] position = drop.cursor.position position.between?(0, item.items.size - 1) ? item.items[position] : nil end |
#label_width_of(items) ⇒ Integer
@param items
321 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 321 def label_width_of(items) = items.map { _1.cued_caption.display_width }.max || 0 |
#open? ⇒ Boolean
@return — whether any panel is open.
57 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 57 def open? = !@levels.empty? |
#open_below(anchor, item) ⇒ void
This method returns an undefined value.
Opens item's children directly beneath anchor, first row
highlighted, closing anything already open first.
@param anchor — the strip segment the menu drops from.
@param item — a childless one opens nothing and leaves menu mode off: Enter on a top-level button is not menu navigation.
74 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 74 def open_below(anchor, item) = show(anchor, item, highlight: true) |
#pop ⇒ void
This method returns an undefined value.
Closes the deepest panel; at depth 1 that closes the cascade.
269 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 269 def pop = truncate(depth - 1) |
#push(item, highlight: true) ⇒ void
This method returns an undefined value.
Builds a panel for item's children and yields it to be anchored,
which is what opens it.
@param item
@param highlight — false parks the cursor off content, so nothing is highlighted.
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 233 def push(item, highlight: true) children = item.items drop = ListDropdown.new drop.renderer = renderer_for(children) drop.items = children drop.cursor = List::Cursor.new(position: highlight ? 0 : -1) level = @levels.size # Wired *after* the items and cursor: {List#items=} and {List#cursor=} # both fire on_cursor_changed, so wiring first would have the fresh # panel truncate itself away as it was built. drop.list.on_item_chosen { |e| activate(level, e.item) } drop.list.on_cursor_changed { truncate(level + 1) } # The cascade's own record of what is open is reconciled from the # popup's own closure, not maintained alongside it: an outside click # closes panels behind our back ({Overlay#close_on_outside_click?}), and # a level left in `@levels` after its panel is gone would have `depth`, # `deepest` and `highlighted` all lying. Identity-keyed and idempotent, # because the notice also arrives from `truncate` (which has already # popped the entry) and from teardown, in no guaranteed order. # Menu mode ends with the last panel, whoever took it: this notice is # what covers a dismissal {#close} never hears about. drop.on_close do @levels.delete_if { |(_i, d)| d.equal?(drop) } @browsing = false if @levels.empty? end # Chain each panel to the one it dropped out of, so a click on a # deeper panel is "inside" the shallower ones and doesn't dismiss # them. Level 0 owns nothing on purpose: a click on a dialog hosting # the bar *should* close the whole menu and keep the dialog. drop.owner = @levels.last&.last @levels << [item, drop] yield(drop, width_for(children)) end |
#push_beside(level, item) ⇒ void
This method returns an undefined value.
Opens item's children beside the row highlighted in level.
@param level
@param item
218 219 220 221 222 223 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 218 def push_beside(level, item) anchor = @levels[level][1].cursor_row_rect return if anchor.nil? push(item) { |drop, width| drop.anchor_beside(anchor, width: width) } end |
#renderer_for(items) ⇒ Proc
@param items
@return — item -> row: the label padded to the level's widest, plus an arrow column when any sibling has a submenu — so every arrow lands in the same column. The width List offers a renderer is no use here: #width_for derives the panel's width from these same labels, so laying out against it would be circular.
296 297 298 299 300 301 302 303 304 305 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 296 def renderer_for(items) label_width = label_width_of(items) arrows = items.any?(&:submenu?) lambda do |item, _text_width| row = item.cued_caption.ellipsize(label_width).ljust(label_width) next row unless arrows row + StyledString.plain(item. ? " #{SUBMENU_ARROW}" : " " * ARROW_WIDTH) end end |
#show(anchor, item, highlight:) ⇒ void
This method returns an undefined value.
The body both entry verbs share: drop what is open, then mount a panel
for item's children if it has any.
@param anchor
@param item
@param highlight — whether the panel's first row starts highlighted.
157 158 159 160 161 162 163 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 157 def show(anchor, item, highlight:) close return unless item. @browsing = true push(item, highlight: highlight) { |drop, width| drop.anchor_to(anchor, width: width) } end |
#step_to(anchor, item) ⇒ void
This method returns an undefined value.
Shows item's children beneath anchor with no row highlighted — the
sideways step along the strip. Menu mode survives a childless item,
which is the whole difference from #open_below: walking past a
top-level button must not end the walk.
@param anchor — the strip segment the menu drops from.
@param item
83 84 85 86 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 83 def step_to(anchor, item) show(anchor, item, highlight: false) @browsing = true end |
#truncate(count) ⇒ void
This method returns an undefined value.
@param count — how many panels to keep.
273 274 275 276 277 278 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 273 def truncate(count) while depth > count _item, drop = @levels.pop drop.close end end |
#width_for(items) ⇒ Integer
@param items
@return — the panel width: the rendered row plus List's two row gutters, plus a scrollbar column when the rows can't all be shown. As in Select, the scrollbar is predicted from the item count rather than the final height — a panel the screen clamps shorter than ListDropdown::MAX_VISIBLE_ROWS scrolls without having bought that column, and ellipsizes one character early.
314 315 316 317 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 314 def width_for(items) row = label_width_of(items) + (items.any?(&:submenu?) ? ARROW_WIDTH : 0) row + 2 + (items.size > ListDropdown::MAX_VISIBLE_ROWS ? 1 : 0) end |