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
return true if cascade.handle_key(key) # MenuBar#handle_key, first
cascade.close # focus lost, or rect changed
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 (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 on a row with no submenu. 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
-
#close ⇒ void
Closes every open panel, deepest first.
-
#deepest ⇒ ListDropdown
@return — the deepest open panel.
-
#depth ⇒ Integer
@return — how many panels are open;
0when closed. -
#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, closing anything already open first. -
#pop ⇒ void
Closes the deepest panel; at depth 1 that closes the cascade.
-
#push(item) ⇒ void
Mounts a panel for
item's children and yields it for geometry. -
#push_beside(level, item) ⇒ void
Opens
item's children beside the row highlighted inlevel. -
#renderer_for(items) ⇒ Proc
@param
items. -
#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.
45 46 47 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 45 def initialize @levels = [] 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.
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 134 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&.call end end |
#activate_highlighted ⇒ void
This method returns an undefined value.
127 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 127 def activate_highlighted = activate(depth - 1, highlighted(depth - 1)) |
#close ⇒ void
This method returns an undefined value.
Closes every open panel, deepest first.
69 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 69 def close = truncate(0) |
#deepest ⇒ ListDropdown
@return — the deepest open panel.
124 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 124 def deepest = @levels.last[1] |
#depth ⇒ Integer
@return — how many panels are open; 0 when closed.
53 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 53 def depth = @levels.size |
#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).
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 76 def handle_key(key) return false unless open? 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.
108 109 110 111 112 113 114 115 116 117 118 119 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 108 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.
215 216 217 218 219 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 215 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
251 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 251 def label_width_of(items) = items.map { _1.cued_caption.display_width }.max || 0 |
#open? ⇒ Boolean
@return — whether any panel is open.
50 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 50 def open? = !@levels.empty? |
#open_below(anchor, item) ⇒ void
This method returns an undefined value.
Opens item's children directly beneath anchor, closing anything
already open first.
@param anchor — the strip segment the menu drops from.
@param item — a childless one opens nothing.
60 61 62 63 64 65 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 60 def open_below(anchor, item) close return unless item. push(item) { |drop, rows, width| drop.anchor_to(anchor, rows: rows, width: width) } end |
#pop ⇒ void
This method returns an undefined value.
Closes the deepest panel; at depth 1 that closes the cascade.
200 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 200 def pop = truncate(depth - 1) |
#push(item) ⇒ void
This method returns an undefined value.
Mounts a panel for item's children and yields it for geometry.
@param item
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 168 def push(item) children = item.items drop = ListDropdown.new drop.renderer = renderer_for(children) drop.items = children drop.cursor = List::Cursor.new 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.on_item_chosen = ->(_index, child) { activate(level, child) } drop.on_cursor_changed = ->(_index, _child) { 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 ({Popup#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. drop.on_close = -> { @levels.delete_if { |(_i, d)| d.equal?(drop) } } # 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] drop.open yield(drop, children.size, 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
155 156 157 158 159 160 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 155 def push_beside(level, item) anchor = @levels[level][1].cursor_row_rect return if anchor.nil? push(item) { |drop, rows, width| drop.anchor_beside(anchor, rows: rows, 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 without asking the List how wide it ended up.
225 226 227 228 229 230 231 232 233 234 235 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 225 def renderer_for(items) label_width = label_width_of(items) arrows = items.any?(&:submenu?) lambda do |item| row = item.cued_caption.ellipsize(label_width) row += StyledString.plain(" " * (label_width - row.display_width)) next row unless arrows row + StyledString.plain(item. ? " #{SUBMENU_ARROW}" : " " * ARROW_WIDTH) end end |
#truncate(count) ⇒ void
This method returns an undefined value.
@param count — how many panels to keep.
204 205 206 207 208 209 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 204 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.
244 245 246 247 |
# File 'lib/tuile/component/menu_bar/cascade.rb', line 244 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 |