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. A menu stepped to that way is only shown: it opens with no row highlighted, so RIGHT steps on again, and Down, Enter or Space moves onto its first row, Up onto its last. Book ch7 has the table.
The walk is governed by menu mode, not by whether a panel is up: a
top-level item with no menu shows nothing when you step onto it, and the
step after it still opens the next menu. ESC leaves the mode there — the
one place the strip claims ESC, an unclaimed one being what stops the loop
(D_quit_key). Enter on such an item fires it and enters no mode.
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 design/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(canvas, row, column, glyph) ⇒ Object
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(canvas, 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 ⇒ Size
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_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.
-
#handle_key?(key) ⇒ Boolean
Offers the key to the open cascade first, then to the strip's own LEFT/RIGHT/Enter/Space/Down/ESC.
-
#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_down?(event) ⇒ Boolean
Opens the menu under a left press, or closes it when it is already the open one; a press on the strip's padding claims the press and does nothing else.
-
#handle_rect_changed(old_rect) ⇒ void
Closes the cascade — only on a changed rect, which is all this hook hears: a layout re-assigning the same rect (which Layout::Box does on any child mutation) must not.
-
#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.
-
#leave_menus ⇒ Boolean
Leaves menu mode — the ESC that gets you out where stepping has landed on a top-level item with no menu, so there is no panel left to close.
-
#move_highlight(delta) ⇒ Boolean
Moves the highlight along the strip, clamping at both ends.
-
#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.
-
#refresh ⇒ void
Re-syncs the scroll offset and repaints — what every change to the items or the highlight ends in.
-
#relayout ⇒ void
The offset follows the width; #rect= invalidates for us, and #handle_rect_changed closes the cascade rather than re-anchoring it.
-
#repaint(canvas) ⇒ void
@param
canvas— see #repaint. -
#segment_rect(index) ⇒ Rect
The cascade hangs off ScreenPane, so it shares no offset with this strip and has to be given screen coordinates (
D_relative_rect). -
#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. -
#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 neighbour and shows its menu; a top-level button shows nothing and keeps menu mode.
-
#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.
239 240 241 |
# File 'lib/tuile/component/menu_bar.rb', line 239 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.
361 362 363 |
# File 'lib/tuile/component/menu_bar.rb', line 361 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
287 288 289 290 291 |
# File 'lib/tuile/component/menu_bar.rb', line 287 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.
255 256 257 |
# File 'lib/tuile/component/menu_bar.rb', line 255 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.
400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 |
# File 'lib/tuile/component/menu_bar.rb', line 400 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(canvas, row, column, glyph) ⇒ Object
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 canvas — the surface to paint onto.
@param row — the windowed row.
@param column — relative to Tuile::Component#rect.left.
@param glyph
458 459 460 461 |
# File 'lib/tuile/component/menu_bar.rb', line 458 def draw_cue(canvas, row, column, glyph) style = row.slice(column, 1).spans.first&.style || StyledString::Style::DEFAULT canvas.set_char(column, 0, glyph, style) end |
#draw_cues(canvas, 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 canvas — the surface to paint onto.
@param row — the windowed row, as painted.
445 446 447 448 |
# File 'lib/tuile/component/menu_bar.rb', line 445 def draw_cues(canvas, row) draw_cue(canvas, row, 0, "<") if @left_column.positive? draw_cue(canvas, row, rect.width - 1, ">") if @left_column + rect.width < painted_width end |
#extent ⇒ Size
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: Mouse::Router's click-to-focus is ungated by geometry.
267 268 269 270 271 |
# File 'lib/tuile/component/menu_bar.rb', line 267 def extent return Size.new(0, 1) if rect.empty? Size.new([painted_width - @left_column, rect.width].min, 1) end |
#focusable? ⇒ Boolean
@return — true — the strip takes focus, so its keys work.
228 |
# File 'lib/tuile/component/menu_bar.rb', line 228 def focusable? = true |
#handle_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.
297 298 299 300 |
# File 'lib/tuile/component/menu_bar.rb', line 297 def handle_detached super @cascade.close end |
#handle_key?(key) ⇒ Boolean
Offers the key to the open cascade first, then to the strip's own LEFT/RIGHT/Enter/Space/Down/ESC.
With a cascade open, the only keys reaching the strip are the two the cascade declines — LEFT at the first level, RIGHT with no submenu under the highlight — and both step to the sibling menu.
@param key
310 311 312 313 314 315 316 317 318 319 320 321 322 323 |
# File 'lib/tuile/component/menu_bar.rb', line 310 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) case key when Keys::LEFT_ARROW then @cascade.browsing? ? (-1) : move_highlight(-1) when Keys::RIGHT_ARROW then @cascade.browsing? ? (1) : move_highlight(1) when Keys::ENTER, " ", Keys::DOWN_ARROW then open_highlighted when Keys::ESC then else false 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.
531 532 533 534 535 536 537 538 539 540 541 542 |
# File 'lib/tuile/component/menu_bar.rb', line 531 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_down?(event) ⇒ Boolean
Opens the menu under a left press, or closes it when it is already the open one; a press on the strip's padding claims the press and does nothing else.
@param event
330 331 332 333 334 335 336 337 338 339 340 341 342 343 |
# File 'lib/tuile/component/menu_bar.rb', line 330 def handle_mouse_down?(event) return false unless event. == :left index = index_at(event.point) return true if index.nil? if @cascade.open? && index == @highlighted_index @cascade.close else self.highlight = index open_highlighted end true end |
#handle_rect_changed(old_rect) ⇒ void
This method returns an undefined value.
Closes the cascade — only on a changed rect, which is all this hook hears: a layout re-assigning the same rect (which Layout::Box does on any child mutation) must not.
@param old_rect
278 279 280 281 |
# File 'lib/tuile/component/menu_bar.rb', line 278 def handle_rect_changed(old_rect) super @cascade.close 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
369 370 371 372 373 374 |
# File 'lib/tuile/component/menu_bar.rb', line 369 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.
486 487 488 489 490 491 |
# File 'lib/tuile/component/menu_bar.rb', line 486 def index_at(point) return nil unless local_extent_rect.contains?(point) column = point.x + @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.
235 |
# File 'lib/tuile/component/menu_bar.rb', line 235 def items = @root.items |
#leave_menus ⇒ Boolean
Leaves menu mode — the ESC that gets you out where stepping has landed
on a top-level item with no menu, so there is no panel left to close.
Declined otherwise, so ESC keeps its app-level meaning (D_quit_key).
578 579 580 581 582 583 |
# File 'lib/tuile/component/menu_bar.rb', line 578 def return false unless @cascade.browsing? @cascade.close true end |
#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.
548 549 550 551 552 553 |
# File 'lib/tuile/component/menu_bar.rb', line 548 def move_highlight(delta) return false if items.empty? self.highlight = (@highlighted_index + delta).clamp(0, items.size - 1) true 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.
589 590 591 592 593 594 595 596 597 598 599 |
# File 'lib/tuile/component/menu_bar.rb', line 589 def open_highlighted return false if items.empty? item = items[@highlighted_index] @cascade.open_below(segment_rect(@highlighted_index), item) # Fired after the close inside `open_below`, exactly as {Cascade} # activates a leaf: an action that opens a dialog must not paint it # under a menu. item.on_click.fire(Item::ClickEvent.new(source: item)) unless item. true end |
#painted_width ⇒ Integer
@return — columns the strip would paint given an unlimited rect.
478 479 480 481 |
# File 'lib/tuile/component/menu_bar.rb', line 478 def painted_width _item, start, width = segments.last start.nil? ? 0 : start + width 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.
379 380 381 382 |
# File 'lib/tuile/component/menu_bar.rb', line 379 def refresh adjust_left_column invalidate end |
#relayout ⇒ void
This method returns an undefined value.
The offset follows the width; Tuile::Component#rect= invalidates for us, and #handle_rect_changed closes the cascade rather than re-anchoring it.
387 388 389 390 |
# File 'lib/tuile/component/menu_bar.rb', line 387 def relayout super adjust_left_column end |
#repaint(canvas) ⇒ void
This method returns an undefined value.
@param canvas — see Tuile::Component#repaint.
347 348 349 350 351 352 353 354 |
# File 'lib/tuile/component/menu_bar.rb', line 347 def repaint(canvas) super return if rect.empty? row = strip_row.slice(@left_column, rect.width) canvas.set_text(0, 0, row) draw_cues(canvas, row) end |
#segment_rect(index) ⇒ Rect
The cascade hangs off ScreenPane, so it shares no offset with this
strip and has to be given screen coordinates (D_relative_rect).
@param index
@return — the segment's cells on screen — the cascade's anchor.
497 498 499 500 |
# File 'lib/tuile/component/menu_bar.rb', line 497 def segment_rect(index) _item, start, width = segments[index] Rect.new(0, 0, width, 1).at(to_screen(Point.new(start - @left_column, 0))) 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.
517 518 519 520 521 522 523 |
# File 'lib/tuile/component/menu_bar.rb', line 517 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.
469 470 471 472 473 474 475 |
# File 'lib/tuile/component/menu_bar.rb', line 469 def segments column = 0 items.map do |item| width = item.cued_caption.display_width + 2 [item, column, width].tap { column += width } end 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.
425 426 427 428 429 430 431 432 433 |
# File 'lib/tuile/component/menu_bar.rb', line 425 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 neighbour and shows its menu; a top-level button shows nothing and keeps menu mode. 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: menu mode swallows the key either way.
565 566 567 568 569 570 571 572 |
# File 'lib/tuile/component/menu_bar.rb', line 565 def (delta) was = @highlighted_index move_highlight(delta) return true if @highlighted_index == was @cascade.step_to(segment_rect(@highlighted_index), items[@highlighted_index]) 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.
505 506 507 508 509 |
# File 'lib/tuile/component/menu_bar.rb', line 505 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.
231 |
# File 'lib/tuile/component/menu_bar.rb', line 231 def tab_stop? = true |