Class: Tuile::Component::MenuBar
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::MenuBar
- Defined in:
- lib/tuile/component/menu_bar.rb,
lib/tuile/component/menu_bar/cascade.rb,
sig/tuile.rbs
Overview
A one-row strip of menu captions, each dropping open a cascade of submenus that nests as deep as you build it.
␣File␣␣Edit␣␣View␣ <- the strip; highlighted while focused
␣New␣␣␣␣␣␣␣␣␣ <- the open menu, measured to its widest label
␣Recent␣␣␣␣▸␣ <- a row that opens a submenu
␣Quit␣␣␣␣␣␣␣␣ (the outer gutters are {List}'s)
bar = Component::MenuBar.new
file = bar.add_item("File", mnemonic: "f")
file.add_item("New", mnemonic: "n") { new_document }
recent = file.add_item("Recent") # no block ⇒ a submenu holder
recent.add_item("notes.txt") { open("notes.txt") }
bar.add_item("Quit") { screen.close } # a top-level leaf: a button
LEFT / RIGHT move along the strip; Enter, Space or Down opens the highlighted menu. Inside a menu: Up / Down (and PgUp/PgDn, Ctrl+U/D) move the highlight, Enter or Space activates a row or opens its submenu, RIGHT opens a submenu, LEFT returns to the previous menu, ESC closes one level. LEFT at the first level and RIGHT on a row with no submenu step to the sibling menu, as they do in every menu bar. Book ch7 has the table.
Mnemonics
An item given a mnemonic: answers to that letter, underlined in its
caption wherever it occurs — on the strip and in the panels, focused or
not (there is no Alt key to reveal them with). Matching is level-scoped
with no fallback: the top-level items while the cascade is closed, the
deepest open panel's items while it is open, and nothing else is ever
consulted. So f then q walks File ▸ Quit as two ordinary keystrokes,
two items on different levels may share a letter with nothing to
arbitrate, and only siblings compete — a duplicate among them raises at
#add_item. A letter matching nothing on the live level is swallowed and
rings Screen#beep; it never falls out to a shallower level and switches
menus. A mnemonic shadows what the app (or an ancestor, including a
Popup's q-to-close) would do with that key while the bar has focus.
A paste can never fire one — pasted text rides its own path off the key
ladder.
Item handles are minted by #add_item and nest via the same method, so
depth is unlimited. There is no removal, no reordering and no dynamic
rebuilding: a menu is built once, at construction. See DECISIONS.md
D-menu-bar.
Sizing
Assign a #rect (typically one Layout::Fixed[1] row at the top of a
Layout::Vertical). One wider than #extent.width leaves a dead tail; a
narrower one scrolls to keep the highlighted segment whole, cueing the
hidden captions with a < or > over an edge column, exactly as Tabs
does — so a bar wider than its terminal stays wholly reachable by arrow,
mnemonic and click. Reassigning the rect
closes an open cascade: every panel position is derived from a segment
or a parent row, so after a resize they would all sit at stale columns, and
a resize with a menu open is rare enough that closing beats re-anchoring
every level.
Implementation details
Deliberately painted unlike Tabs, whose picture it would otherwise
share: no separator between segments, no bold, and no highlight at all
while unfocused — a menu bar has no persistent selection to show, and a
reader should not have to work out which of the two controls they are
looking at. Hit testing is Tabs': one private segments method feeds
both the paint and the click and both offset it by the same scroll column,
so a click cannot land on a caption other than the one drawn under it, and it is derived from the captions on each
call so a hit test is correct before the first paint.
The open panels are overlays owned by a private Cascade, not children:
focus stays here for the whole interaction, so the strip receives every key
and forwards it. An open cascade swallows keys the cascade doesn't
recognize; a closed strip lets every printable bubble, so an app's
s-to-save keeps working while the bar has focus.
A click outside an open cascade is not blocked — non-modal overlays block nothing — but any click on a focusable component moves focus, and losing focus closes the cascade.
UI-thread-confined, like every component (see Screen).
Defined Under Namespace
Instance Attribute Summary collapse
-
#highlighted_index ⇒ Integer
readonly
@return — which top-level item the strip highlights while focused, and which menu Enter opens.
-
#left_column ⇒ Integer
readonly
@return — the strip column painted in #rect's leftmost cell — the horizontal scroll offset.
Instance Method Summary collapse
-
#active=(flag) ⇒ void
Closes the cascade when the strip leaves the focus chain, so tabbing (or clicking) away doesn't strand an open menu.
-
#add_item(caption = nil, mnemonic: nil, &on_click) ⇒ Item
Appends a top-level item and returns its handle; nest submenus into it with Item#add_item.
-
#adjust_left_column ⇒ void
Scrolls the minimum needed to show the highlighted segment whole, and is the sole writer of #left_column.
-
#draw_cue(row, column, glyph) ⇒ void
The cue keeps the style of the cell it covers, so one landing on the highlighted segment doesn't punch a default-background hole in its highlight.
-
#draw_cues(row) ⇒ void
Paints the overflow cues over the windowed row's edge columns:
<when segments sit to the left of the window,>when more sit to the right. -
#extent ⇒ Rect
The cells the strip actually paints: one row, as wide as its segments need, clipped to #rect.
-
#focusable? ⇒ Boolean
@return —
true— the strip takes focus, so its keys work. -
#handle_key(key) ⇒ Boolean
Offers the key to the open cascade first, then to the strip's own LEFT/RIGHT/Enter/Space/Down.
-
#handle_mnemonic(key) ⇒ Boolean
Activates the item bound to
keyon the live level — the deepest open panel while the cascade is open, the top-level strip while it is closed. -
#handle_mouse(event) ⇒ void
Opens the menu under a left click, or closes it when it is already the open one;
superruns first, so a click anywhere in #rect still focuses. -
#highlight=(index) ⇒ void
The sole writer of #highlighted_index: assigns, re-syncs the scroll offset and repaints.
-
#index_at(point) ⇒ Integer?
@param
point. -
#initialize ⇒ MenuBar
constructor
A new instance of MenuBar.
-
#items ⇒ ::Array[Item]
@return — the top-level items, in strip order.
-
#move_highlight(delta) ⇒ Boolean
Moves the highlight along the strip, clamping at both ends.
-
#on_detached ⇒ void
Closes the cascade, so a bar removed from the tree can't strand its panels on the pane — they are the ScreenPane's children, not the bar's, so nothing else would take them down.
- #on_width_changed ⇒ void
-
#open_highlighted ⇒ Boolean
Opens the highlighted item's menu, or fires it when it is a top-level button — the Enter/Space/Down/click path, and the only one that fires a listener.
-
#painted_width ⇒ Integer
@return — columns the strip would paint given an unlimited rect.
-
#rect=(new_rect) ⇒ void
@param
new_rect. -
#refresh ⇒ void
Re-syncs the scroll offset and repaints — what every change to the items or the highlight ends in.
- #repaint ⇒ void
-
#segment_rect(index) ⇒ Rect
@param
index. -
#segment_text(item, index) ⇒ StyledString
@param
item. -
#segments ⇒ ::Array[[Item, Integer, Integer]]
One
[item, start_column, width]triple per top-level item, in strip order, in columns relative to #rect.left. -
#show_highlighted_menu ⇒ void
Shows the highlighted item's menu, closing the cascade when it has none.
-
#snap_to_glyph_start(column) ⇒ Integer
StyledString#slice drops a cluster straddling the window's edge rather than half-painting it, which would leave the painted row a column short and shift everything past the hole one column left — paint and hit test would then disagree, silently and only for wide glyphs.
-
#step_menu(delta) ⇒ Boolean
Steps to the neighbouring menu, showing its menu instead — or closing the cascade, when the neighbour is a top-level button with no menu to show.
-
#strip_row ⇒ StyledString
@return — the whole strip as one row, unclipped.
-
#tab_stop? ⇒ Boolean
@return —
true— one stop for the whole strip, as on Tabs.
Constructor Details
Instance Attribute Details
#highlighted_index ⇒ Integer (readonly)
@return — which top-level item the strip highlights while
focused, and which menu Enter opens. 0 until the user moves.
220 221 222 |
# File 'lib/tuile/component/menu_bar.rb', line 220 def highlighted_index @highlighted_index end |
#left_column ⇒ Integer (readonly)
@return — the strip column painted in Tuile::Component#rect's leftmost cell —
the horizontal scroll offset. 0 unless the strip overflows its rect;
#adjust_left_column is its sole writer.
348 349 350 |
# File 'lib/tuile/component/menu_bar.rb', line 348 def left_column @left_column end |
Instance Method Details
#active=(flag) ⇒ void
This method returns an undefined value.
Closes the cascade when the strip leaves the focus chain, so tabbing (or clicking) away doesn't strand an open menu.
@param flag
268 269 270 271 272 |
# File 'lib/tuile/component/menu_bar.rb', line 268 def active=(flag) was = active? super @cascade.close if was && !active? end |
#add_item(caption = nil, mnemonic: nil, &on_click) ⇒ Item
Appends a top-level item and returns its handle; nest submenus into it with Tuile::Component::MenuBar::Item#add_item.
@param caption — parsed as StyledString.parse parses it.
@param mnemonic — a single one-column printable character that activates this item while the strip is focused and closed, underlined in the caption where it occurs. Matched case-insensitively; it shadows whatever the app would otherwise do with that key while the bar has focus.
236 237 238 |
# File 'lib/tuile/component/menu_bar.rb', line 236 def add_item(caption = nil, mnemonic: nil, &on_click) @root.add_item(caption, mnemonic: mnemonic, &on_click).tap { refresh } end |
#adjust_left_column ⇒ void
This method returns an undefined value.
Scrolls the minimum needed to show the highlighted segment whole, and is
the sole writer of #left_column. Idempotent, so every mutation site can
call it blindly; it returns the offset to 0 on its own once the strip
fits again, which is why no mutator owes a scroll-back branch.
A segment wider than the whole rect cannot be shown whole: its head wins, being the half of a caption that identifies it.
388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 |
# File 'lib/tuile/component/menu_bar.rb', line 388 def adjust_left_column if rect.empty? || painted_width <= rect.width || items.empty? @left_column = 0 return end _item, start, width = segments[@highlighted_index] if width >= rect.width @left_column = start else @left_column = start if start < @left_column @left_column = start + width - rect.width if start + width > @left_column + rect.width end @left_column = snap_to_glyph_start(@left_column.clamp(0, painted_width - rect.width)) end |
#draw_cue(row, column, glyph) ⇒ void
This method returns an undefined value.
The cue keeps the style of the cell it covers, so one landing on the highlighted segment doesn't punch a default-background hole in its highlight.
@param row — the windowed row.
@param column — relative to Tuile::Component#rect.left.
@param glyph
444 445 446 447 |
# File 'lib/tuile/component/menu_bar.rb', line 444 def draw_cue(row, column, glyph) style = row.slice(column, 1).spans.first&.style || StyledString::Style::DEFAULT draw_char(rect.left + column, rect.top, glyph, style) end |
#draw_cues(row) ⇒ void
This method returns an undefined value.
Paints the overflow cues over the windowed row's edge columns: < when
segments sit to the left of the window, > when more sit to the right.
ASCII by convention rather than by constant, as Checkbox's brackets
are, and overlaid rather than given reserved columns — reserving would
make the window width a function of the offset computed from it. Painted
focused or not: overflow is a fact about the captions and the rect, not
about focus.
@param row — the windowed row, as painted.
432 433 434 435 |
# File 'lib/tuile/component/menu_bar.rb', line 432 def draw_cues(row) draw_cue(row, 0, "<") if @left_column.positive? draw_cue(row, rect.width - 1, ">") if @left_column + rect.width < painted_width end |
#extent ⇒ Rect
The cells the strip actually paints: one row, as wide as its segments need, clipped to Tuile::Component#rect.
Both the highlight and the click hit test use it, so a click on the blank tail — or on a lower row, when the rect is taller than one — opens nothing. It still focuses: Tuile::Component#handle_mouse's click-to-focus is ungated by geometry.
248 249 250 251 252 |
# File 'lib/tuile/component/menu_bar.rb', line 248 def extent return Rect.new(rect.left, rect.top, 0, 1) if rect.empty? Rect.new(rect.left, rect.top, [painted_width - @left_column, rect.width].min, 1) end |
#focusable? ⇒ Boolean
@return — true — the strip takes focus, so its keys work.
209 |
# File 'lib/tuile/component/menu_bar.rb', line 209 def focusable? = true |
#handle_key(key) ⇒ Boolean
Offers the key to the open cascade first, then to the strip's own LEFT/RIGHT/Enter/Space/Down.
With a cascade open, the only keys reaching the strip are the two the cascade declines — LEFT at the first level, RIGHT on a row with no submenu — and both step to the sibling menu.
@param key
291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/tuile/component/menu_bar.rb', line 291 def handle_key(key) # Ahead of the cascade: an open one swallows every printable it doesn't # recognize, so a letter would never reach the strip otherwise. return true if handle_mnemonic(key) return true if @cascade.handle_key(key) if @cascade.open? case key when Keys::LEFT_ARROW then (-1) when Keys::RIGHT_ARROW then (1) else false end else case key when Keys::LEFT_ARROW then move_highlight(-1) when Keys::RIGHT_ARROW then move_highlight(1) when Keys::ENTER, " ", Keys::DOWN_ARROW then open_highlighted else false end end end |
#handle_mnemonic(key) ⇒ Boolean
Activates the item bound to key on the live level — the deepest open
panel while the cascade is open, the top-level strip while it is closed.
No fallback between the two: a letter matching nothing in the live set is
not offered to any other level.
@param key
@return — whether a mnemonic claimed the key.
515 516 517 518 519 520 521 522 523 524 525 526 |
# File 'lib/tuile/component/menu_bar.rb', line 515 def handle_mnemonic(key) return false unless Keys.printable?(key) down = key.downcase return @cascade.handle_mnemonic(down) if @cascade.open? index = items.index { |item| item.mnemonic == down } return false if index.nil? self.highlight = index open_highlighted end |
#handle_mouse(event) ⇒ void
This method returns an undefined value.
Opens the menu under a left click, or closes it when it is already the
open one; super runs first, so a click anywhere in Tuile::Component#rect still
focuses.
@param event
318 319 320 321 322 323 324 325 326 327 328 329 330 331 |
# File 'lib/tuile/component/menu_bar.rb', line 318 def handle_mouse(event) super return unless event. == :left index = index_at(event.point) return if index.nil? if @cascade.open? && index == @highlighted_index @cascade.close else self.highlight = index open_highlighted end end |
#highlight=(index) ⇒ void
This method returns an undefined value.
The sole writer of #highlighted_index: assigns, re-syncs the scroll offset and repaints. Every path that moves the highlight — arrow, mnemonic, click — goes through it, so the highlighted segment is on screen before Cascade anchors a panel to it.
@param index
356 357 358 359 360 361 |
# File 'lib/tuile/component/menu_bar.rb', line 356 def highlight=(index) return if index == @highlighted_index @highlighted_index = index refresh end |
#index_at(point) ⇒ Integer?
@param point
@return — the index of the item painted at point; nil
for the blank tail or a row the strip doesn't paint.
472 473 474 475 476 477 |
# File 'lib/tuile/component/menu_bar.rb', line 472 def index_at(point) return nil unless extent.contains?(point) column = point.x - rect.left + @left_column segments.index { |_item, start, width| column >= start && column < start + width } end |
#items ⇒ ::Array[Item]
@return — the top-level items, in strip order. Read-only by convention; grow it through #add_item.
216 |
# File 'lib/tuile/component/menu_bar.rb', line 216 def items = @root.items |
#move_highlight(delta) ⇒ Boolean
Moves the highlight along the strip, clamping at both ends. Consumes the key even at an end, as Tabs does.
@param delta — +1 / -1.
@return — false only when there are no items.
532 533 534 535 536 537 |
# File 'lib/tuile/component/menu_bar.rb', line 532 def move_highlight(delta) return false if items.empty? self.highlight = (@highlighted_index + delta).clamp(0, items.size - 1) true end |
#on_detached ⇒ void
This method returns an undefined value.
Closes the cascade, so a bar removed from the tree can't strand its panels on the pane — they are the ScreenPane's children, not the bar's, so nothing else would take them down.
278 279 280 281 |
# File 'lib/tuile/component/menu_bar.rb', line 278 def on_detached super @cascade.close end |
#on_width_changed ⇒ void
This method returns an undefined value.
The rect's width is the only part of it the offset depends on, so this hook is the whole geometry story; Tuile::Component#rect= invalidates for us, and #rect= closes the cascade rather than re-anchoring it.
375 376 377 378 |
# File 'lib/tuile/component/menu_bar.rb', line 375 def on_width_changed super adjust_left_column end |
#open_highlighted ⇒ Boolean
Opens the highlighted item's menu, or fires it when it is a top-level button — the Enter/Space/Down/click path, and the only one that fires a listener.
@return — false only when there are no items.
561 562 563 564 565 566 567 568 569 570 |
# File 'lib/tuile/component/menu_bar.rb', line 561 def open_highlighted return false if items.empty? item = items[@highlighted_index] # Fired after the close above, exactly as {Cascade} activates a leaf: an # action that opens a dialog must not paint it under a menu. item.on_click&.call unless item. true end |
#painted_width ⇒ Integer
@return — columns the strip would paint given an unlimited rect.
464 465 466 467 |
# File 'lib/tuile/component/menu_bar.rb', line 464 def painted_width _item, start, width = segments.last start.nil? ? 0 : start + width end |
#rect=(new_rect) ⇒ void
This method returns an undefined value.
@param new_rect
256 257 258 259 260 261 262 |
# File 'lib/tuile/component/menu_bar.rb', line 256 def rect=(new_rect) # Only a *changed* rect closes the menu: a layout re-assigning the same # rect (which {Layout::Box} does on any child mutation) must not. changed = rect != new_rect super @cascade.close if changed end |
#refresh ⇒ void
This method returns an undefined value.
Re-syncs the scroll offset and repaints — what every change to the items or the highlight ends in.
366 367 368 369 |
# File 'lib/tuile/component/menu_bar.rb', line 366 def refresh adjust_left_column invalidate end |
#repaint ⇒ void
This method returns an undefined value.
334 335 336 337 338 339 340 341 |
# File 'lib/tuile/component/menu_bar.rb', line 334 def repaint super return if rect.empty? row = strip_row.slice(@left_column, rect.width) draw_text(rect.left, rect.top, row) draw_cues(row) end |
#segment_rect(index) ⇒ Rect
@param index
@return — the segment's cells on screen — the cascade's anchor.
481 482 483 484 |
# File 'lib/tuile/component/menu_bar.rb', line 481 def segment_rect(index) _item, start, width = segments[index] Rect.new(rect.left + start - @left_column, rect.top, width, 1) end |
#segment_text(item, index) ⇒ StyledString
@param item
@param index
@return — the caption between its padding columns, highlighted when it is the one Enter would open and the strip has focus. An unfocused strip shows no highlight at all: there is no persistent selection to report.
501 502 503 504 505 506 507 |
# File 'lib/tuile/component/menu_bar.rb', line 501 def segment_text(item, index) pad = StyledString.plain(" ") segment = pad + item.cued_caption + pad return segment unless index == @highlighted_index && active? segment.with_bg(screen.theme.active_bg_color) end |
#segments ⇒ ::Array[[Item, Integer, Integer]]
One [item, start_column, width] triple per top-level item, in strip
order, in columns relative to Tuile::Component#rect.left. A segment is its caption
between two padding columns, and neighbours abut — the two blank columns
between captions are the segments' own padding, so a click on either
opens the menu it belongs to.
455 456 457 458 459 460 461 |
# File 'lib/tuile/component/menu_bar.rb', line 455 def segments column = 0 items.map do |item| width = item.cued_caption.display_width + 2 [item, column, width].tap { column += width } end end |
#show_highlighted_menu ⇒ void
This method returns an undefined value.
Shows the highlighted item's menu, closing the cascade when it has none.
574 575 576 577 578 579 |
# File 'lib/tuile/component/menu_bar.rb', line 574 def item = items[@highlighted_index] return @cascade.close unless item. @cascade.open_below(segment_rect(@highlighted_index), item) end |
#snap_to_glyph_start(column) ⇒ Integer
StyledString#slice drops a cluster straddling the window's edge rather than half-painting it, which would leave the painted row a column short and shift everything past the hole one column left — paint and hit test would then disagree, silently and only for wide glyphs. So the offset only ever lands on a cluster boundary. Snapping forward is the safe direction: it gives up at most one column of the segment to the left of the window, never of the one being revealed.
@param column
@return — the smallest cluster-boundary column >= column.
413 414 415 416 417 418 419 420 421 |
# File 'lib/tuile/component/menu_bar.rb', line 413 def snap_to_glyph_start(column) boundary = 0 strip_row.to_s.each_grapheme_cluster do |glyph| return boundary if boundary >= column boundary += Buffer.display_width(glyph) end boundary end |
#step_menu(delta) ⇒ Boolean
Steps to the neighbouring menu, showing its menu instead — or closing the cascade, when the neighbour is a top-level button with no menu to show. The cascade is left alone when the highlight is already at an end: reopening the same menu would throw away the submenu the user is standing in.
It deliberately never activates. An item arrowed past is highlighted, not pressed, so a top-level button waits for Enter or Space — otherwise walking the strip would fire every button on it.
@param delta — +1 / -1.
@return — always true: an open menu swallows the key either way.
550 551 552 553 554 555 |
# File 'lib/tuile/component/menu_bar.rb', line 550 def (delta) was = @highlighted_index move_highlight(delta) unless @highlighted_index == was true end |
#strip_row ⇒ StyledString
@return — the whole strip as one row, unclipped. #repaint windows it to the rect; nothing else may, since the window's own arithmetic is #adjust_left_column's.
489 490 491 492 493 |
# File 'lib/tuile/component/menu_bar.rb', line 489 def strip_row row = StyledString::EMPTY items.each_with_index { |item, index| row += segment_text(item, index) } row end |
#tab_stop? ⇒ Boolean
@return — true — one stop for the whole strip, as on Tabs.
212 |
# File 'lib/tuile/component/menu_bar.rb', line 212 def tab_stop? = true |