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

""
ARROW_WIDTH =

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

Returns:

  • (Integer)
2

Instance Method Summary collapse

Constructor Details

#initializeCascade

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 itemnil (an off-content cursor) does nothing.

Parameters:

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


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.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&.call
  end
end

#activate_highlightedvoid

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

#closevoid

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)

#deepestListDropdown

@return — the deepest open panel.

Returns:



124
# File 'lib/tuile/component/menu_bar/cascade.rb', line 124

def deepest = @levels.last[1]

#depthInteger

@return — how many panels are open; 0 when closed.

Returns:

  • (Integer)


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

@returntrue 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)


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


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.

Parameters:

  • level (Integer)

Returns:



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

Parameters:

  • items (::Array[Item])

Returns:

  • (Integer)


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.

Returns:

  • (Boolean)


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.

Parameters:



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

  push(item) { |drop, rows, width| drop.anchor_to(anchor, rows: rows, width: width) }
end

#popvoid

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

Parameters:



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

Parameters:

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

Parameters:

  • items (::Array[Item])

Returns:

  • (Proc)


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? ? " #{SUBMENU_ARROW}" : " " * ARROW_WIDTH)
  end
end

#truncate(count) ⇒ void

This method returns an undefined value.

@param count — how many panels to keep.

Parameters:

  • count (Integer)


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.

Parameters:

  • items (::Array[Item])

Returns:

  • (Integer)


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