Class: Tuile::Component::MenuBar::Cascade

Inherits:
Object
  • Object
show all
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

"▸"
ARROW_WIDTH =

Columns a submenu arrow occupies, the space before it included.

Returns:

  • (Integer)
2

Instance Method Summary collapse

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.

Parameters:

  • level (Integer)
  • item (Item, nil)


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.submenu?
    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.

Returns:

  • (Boolean)


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.

Returns:



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.

Returns:

  • (Integer)


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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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).

Parameters:

  • key (String)

Returns:

  • (Boolean)


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)&.submenu?

    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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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.

Parameters:

  • level (Integer)

Returns:



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

Parameters:

  • items (::Array[Item])

Returns:

  • (Integer)


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.

Returns:

  • (Boolean)


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.

Parameters:



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.

Parameters:

  • item (Item)
  • highlight: (Boolean) (defaults to: true)


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

Parameters:

  • level (Integer)
  • item (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.

Parameters:

  • items (::Array[Item])

Returns:

  • (Proc)


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? ? " #{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.

Parameters:

  • anchor (Rect)
  • item (Item)
  • highlight: (Boolean)


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.submenu?

  @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

Parameters:



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.

Parameters:

  • count (Integer)


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.

Parameters:

  • items (::Array[Item])

Returns:

  • (Integer)


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