Class: Tuile::Component::MenuBar

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

Classes: Cascade, Item

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeMenuBar

Returns a new instance of MenuBar.



200
201
202
203
204
205
206
# File 'lib/tuile/component/menu_bar.rb', line 200

def initialize
  super()
  @root = Item.send(:new, StyledString::EMPTY, nil, nil)
  @highlighted_index = 0
  @left_column = 0
  @cascade = Cascade.new
end

Instance Attribute Details

#highlighted_indexInteger (readonly)

@return — which top-level item the strip highlights while focused, and which menu Enter opens. 0 until the user moves.

Returns:

  • (Integer)


220
221
222
# File 'lib/tuile/component/menu_bar.rb', line 220

def highlighted_index
  @highlighted_index
end

#left_columnInteger (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.

Returns:

  • (Integer)


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

Parameters:

  • flag (Boolean)


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.

Parameters:

  • caption (?(String | StyledString), nil) (defaults to: nil)
  • mnemonic: (String, nil) (defaults to: nil)

Returns:



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_columnvoid

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

Parameters:



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.

Parameters:



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

#extentRect

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.

Returns:



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

@returntrue — the strip takes focus, so its keys work.

Returns:

  • (Boolean)


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

Parameters:

  • key (String)

Returns:

  • (Boolean)


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 step_menu(-1)
    when Keys::RIGHT_ARROW then step_menu(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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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

Parameters:



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.button == :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

Parameters:

  • index (Integer)


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.

Parameters:

Returns:

  • (Integer, nil)


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.

Returns:



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.

@returnfalse only when there are no items.

Parameters:

  • delta (Integer)

Returns:

  • (Boolean)


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_detachedvoid

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_changedvoid

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_highlightedBoolean

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.

@returnfalse only when there are no items.

Returns:

  • (Boolean)


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]
  show_highlighted_menu
  # 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.submenu?
  true
end

#painted_widthInteger

@return — columns the strip would paint given an unlimited rect.

Returns:

  • (Integer)


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

Parameters:



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

#refreshvoid

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

#repaintvoid

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.

Parameters:

  • index (Integer)

Returns:



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.

Parameters:

  • item (Item)
  • index (Integer)

Returns:



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.

Returns:

  • (::Array[[Item, Integer, Integer]])


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_menuvoid

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 show_highlighted_menu
  item = items[@highlighted_index]
  return @cascade.close unless item.submenu?

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

Parameters:

  • column (Integer)

Returns:

  • (Integer)


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.

Parameters:

  • delta (Integer)

Returns:

  • (Boolean)


550
551
552
553
554
555
# File 'lib/tuile/component/menu_bar.rb', line 550

def step_menu(delta)
  was = @highlighted_index
  move_highlight(delta)
  show_highlighted_menu unless @highlighted_index == was
  true
end

#strip_rowStyledString

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

Returns:



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

@returntrue — one stop for the whole strip, as on Tabs.

Returns:

  • (Boolean)


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

def tab_stop? = true