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

Classes: Cascade, Item

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize ⇒ MenuBar

Returns a new instance of MenuBar.



219
220
221
222
223
224
225
# File 'lib/tuile/component/menu_bar.rb', line 219

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_index ⇒ Integer (readonly)

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

Returns:

  • (Integer)


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.

Returns:

  • (Integer)


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

Parameters:

  • flag (Boolean)


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.

Parameters:

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

Returns:



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.

Parameters:



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.

Returns:



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.

Returns:

  • (Boolean)


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

Parameters:

  • key (String)

Returns:

  • (Boolean)


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? ? step_menu(-1) : move_highlight(-1)
  when Keys::RIGHT_ARROW then @cascade.browsing? ? step_menu(1) : move_highlight(1)
  when Keys::ENTER, " ", Keys::DOWN_ARROW then open_highlighted
  when Keys::ESC then leave_menus
  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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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

Parameters:

Returns:

  • (Boolean)


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

Parameters:



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

Parameters:

  • index (Integer)


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.

Parameters:

Returns:

  • (Integer, nil)


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.

Returns:



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

Returns:

  • (Boolean)


578
579
580
581
582
583
# File 'lib/tuile/component/menu_bar.rb', line 578

def leave_menus
  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.

Parameters:

  • delta (Integer)

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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

#painted_width ⇒ Integer

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

Returns:

  • (Integer)


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.

Parameters:



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.

Parameters:

  • index (Integer)

Returns:



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.

Parameters:

  • item (Item)
  • index (Integer)

Returns:



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.

Returns:

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


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.

Parameters:

  • column (Integer)

Returns:

  • (Integer)


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.

Parameters:

  • delta (Integer)

Returns:

  • (Boolean)


565
566
567
568
569
570
571
572
# File 'lib/tuile/component/menu_bar.rb', line 565

def step_menu(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.

Returns:



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.

Returns:

  • (Boolean)


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

def tab_stop? = true