tuile 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/DECISIONS.md +1297 -13
  4. data/README.md +136 -490
  5. data/TERMINOLOGY.md +11 -2
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +18 -5
  8. data/book/03-layout.md +11 -10
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +5 -2
  11. data/book/07-components.md +402 -12
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +22 -16
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +385 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +7 -6
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/component/abstract_string_field.rb +36 -0
  21. data/lib/tuile/component/combo_box.rb +3 -1
  22. data/lib/tuile/component/list.rb +22 -0
  23. data/lib/tuile/component/list_dropdown.rb +86 -3
  24. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  25. data/lib/tuile/component/menu_bar.rb +582 -0
  26. data/lib/tuile/component/notification.rb +14 -11
  27. data/lib/tuile/component/picker_window.rb +0 -5
  28. data/lib/tuile/component/popup.rb +75 -9
  29. data/lib/tuile/component/select.rb +3 -1
  30. data/lib/tuile/component/tab_sheet.rb +242 -0
  31. data/lib/tuile/component/tabs.rb +528 -0
  32. data/lib/tuile/component/text_area.rb +5 -4
  33. data/lib/tuile/component/text_field.rb +23 -6
  34. data/lib/tuile/component/text_view.rb +8 -5
  35. data/lib/tuile/component.rb +38 -13
  36. data/lib/tuile/event_queue.rb +25 -1
  37. data/lib/tuile/fake_screen.rb +14 -0
  38. data/lib/tuile/keys.rb +65 -0
  39. data/lib/tuile/screen.rb +94 -77
  40. data/lib/tuile/screen_pane.rb +109 -27
  41. data/lib/tuile/styled_string.rb +40 -0
  42. data/lib/tuile/version.rb +1 -1
  43. data/sig/tuile.rbs +1473 -93
  44. metadata +6 -3
  45. data/mise.toml +0 -2
@@ -0,0 +1,582 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A one-row strip of menu captions, each dropping open a cascade of submenus
6
+ # that nests as deep as you build it.
7
+ #
8
+ # ␣File␣␣Edit␣␣View␣ <- the strip; highlighted while focused
9
+ # ␣New␣␣␣␣␣␣␣␣␣ <- the open menu, measured to its widest label
10
+ # ␣Recent␣␣␣␣▸␣ <- a row that opens a submenu
11
+ # ␣Quit␣␣␣␣␣␣␣␣ (the outer gutters are {List}'s)
12
+ #
13
+ # bar = Component::MenuBar.new
14
+ # file = bar.add_item("File", mnemonic: "f")
15
+ # file.add_item("New", mnemonic: "n") { new_document }
16
+ # recent = file.add_item("Recent") # no block ⇒ a submenu holder
17
+ # recent.add_item("notes.txt") { open("notes.txt") }
18
+ # bar.add_item("Quit") { screen.close } # a top-level leaf: a button
19
+ #
20
+ # LEFT / RIGHT move along the strip; Enter, Space or Down opens the
21
+ # highlighted menu. Inside a menu: Up / Down (and PgUp/PgDn, Ctrl+U/D) move
22
+ # the highlight, Enter or Space activates a row or opens its submenu, RIGHT
23
+ # opens a submenu, LEFT returns to the previous menu, ESC closes one level.
24
+ # LEFT at the first level and RIGHT on a row with no submenu step to the
25
+ # sibling menu, as they do in every menu bar. Book ch7 has the table.
26
+ #
27
+ # == Mnemonics
28
+ # An item given a `mnemonic:` answers to that letter, underlined in its
29
+ # caption wherever it occurs — on the strip and in the panels, focused or
30
+ # not (there is no Alt key to reveal them with). Matching is **level-scoped
31
+ # with no fallback**: the top-level items while the cascade is closed, the
32
+ # deepest open panel's items while it is open, and nothing else is ever
33
+ # consulted. So `f` then `q` walks File ▸ Quit as two ordinary keystrokes,
34
+ # two items on *different* levels may share a letter with nothing to
35
+ # arbitrate, and only siblings compete — a duplicate among them raises at
36
+ # {#add_item}. A letter matching nothing on the live level is swallowed and
37
+ # rings {Screen#beep}; it never falls out to a shallower level and switches
38
+ # menus. A mnemonic shadows what the app (or an ancestor, including a
39
+ # {Popup}'s `q`-to-close) would do with that key while the bar has focus.
40
+ # A paste can never fire one — pasted text rides its own path off the key
41
+ # ladder.
42
+ #
43
+ # {Item} handles are minted by {#add_item} and nest via the *same* method, so
44
+ # depth is unlimited. There is no removal, no reordering and no dynamic
45
+ # rebuilding: a menu is built once, at construction. See `DECISIONS.md`
46
+ # `D-menu-bar`.
47
+ #
48
+ # == Sizing
49
+ # Assign a {#rect} (typically one {Layout::Fixed}`[1]` row at the top of a
50
+ # {Layout::Vertical}). One wider than {#extent}`.width` leaves a dead tail; a
51
+ # narrower one **scrolls** to keep the highlighted segment whole, cueing the
52
+ # hidden captions with a `<` or `>` over an edge column, exactly as {Tabs}
53
+ # does — so a bar wider than its terminal stays wholly reachable by arrow,
54
+ # mnemonic and click. Reassigning the rect
55
+ # **closes** an open cascade: every panel position is derived from a segment
56
+ # or a parent row, so after a resize they would all sit at stale columns, and
57
+ # a resize with a menu open is rare enough that closing beats re-anchoring
58
+ # every level.
59
+ #
60
+ # == Implementation details
61
+ # Deliberately painted *unlike* {Tabs}, whose picture it would otherwise
62
+ # share: no separator between segments, no bold, and no highlight at all
63
+ # while unfocused — a menu bar has no persistent selection to show, and a
64
+ # reader should not have to work out which of the two controls they are
65
+ # looking at. Hit testing *is* {Tabs}': one private `segments` method feeds
66
+ # both the paint and the click and both offset it by the same scroll column,
67
+ # so a click cannot land on a caption other than the one drawn under it, and it is derived from the captions on each
68
+ # call so a hit test is correct before the first paint.
69
+ #
70
+ # The open panels are overlays owned by a private {Cascade}, not children:
71
+ # focus stays here for the whole interaction, so the strip receives every key
72
+ # and forwards it. An open cascade swallows keys the cascade doesn't
73
+ # recognize; a *closed* strip lets every printable bubble, so an app's
74
+ # `s`-to-save keeps working while the bar has focus.
75
+ #
76
+ # A click outside an open cascade is not blocked — non-modal overlays block
77
+ # nothing — but any click on a focusable component moves focus, and losing
78
+ # focus closes the cascade.
79
+ #
80
+ # UI-thread-confined, like every component (see {Screen}).
81
+ class MenuBar < Component
82
+ # One menu item: a caption, an optional click listener, and its children.
83
+ #
84
+ # file = bar.add_item("File") # minted by the bar
85
+ # file.add_item("New") { create } # …and nested by the same method
86
+ # file.items.size # => 1
87
+ #
88
+ # An item with children is a submenu and its own listener is dead
89
+ # ({#submenu?} decides). An item with **neither** children nor a listener is
90
+ # legal and inert: it highlights, Enter closes the menu, nothing happens —
91
+ # an item that looks live but does nothing is the app's error to fix, not
92
+ # the framework's to raise on.
93
+ #
94
+ # Apps don't construct items; {MenuBar#add_item} and {#add_item} do.
95
+ class Item
96
+ # @param caption [StyledString] already coerced by the caller.
97
+ # @param mnemonic [String, nil] already validated by the caller, in the
98
+ # case it was given in.
99
+ # @param on_click [Proc, Method, nil]
100
+ def initialize(caption, mnemonic, on_click)
101
+ @caption = caption
102
+ @mnemonic = mnemonic&.downcase
103
+ @cued_caption = build_cued_caption(caption, mnemonic)
104
+ @on_click = on_click
105
+ @items = []
106
+ end
107
+
108
+ private_class_method :new
109
+
110
+ # @return [StyledString] the label painted on the strip or the row.
111
+ attr_reader :caption
112
+
113
+ # @return [String, nil] the downcased letter that activates this item
114
+ # while its own level is the live one; `nil` when it has none.
115
+ attr_reader :mnemonic
116
+
117
+ # @return [StyledString] {#caption} with the {#mnemonic} underlined —
118
+ # what both paint sites draw. Equal to {#caption} when there is no
119
+ # mnemonic or the caption doesn't contain it. Computed once, at
120
+ # construction: caption and mnemonic are both fixed there, and
121
+ # underline is a plain attribute with no theme or `bg_color` input, so
122
+ # this is not a cached theme value.
123
+ attr_reader :cued_caption
124
+
125
+ # @return [Array<Item>] this item's children, in menu order. Read-only by
126
+ # convention, like {Component#children} — grow it through {#add_item}.
127
+ attr_reader :items
128
+
129
+ # @return [Proc, Method, nil] no-arg callable fired when the item is
130
+ # activated (Enter, Space or a left click), exactly as
131
+ # {Button#on_click}. Never fired on an item with children.
132
+ attr_accessor :on_click
133
+
134
+ # @return [Boolean] whether this item opens a submenu, i.e. has children.
135
+ def submenu? = !@items.empty?
136
+
137
+ # Appends a child and returns its handle.
138
+ # @param caption [String, StyledString, nil] parsed as
139
+ # {StyledString.parse} parses it.
140
+ # @param mnemonic [String, nil] the letter that activates this child
141
+ # while *this* item's children are the live level; see
142
+ # {MenuBar#add_item}.
143
+ # @yield optional `on_click` callback; same as assigning {#on_click=}.
144
+ # @raise [ArgumentError] see {MenuBar#add_item}.
145
+ # @return [Item]
146
+ def add_item(caption = nil, mnemonic: nil, &on_click)
147
+ validate_mnemonic(mnemonic)
148
+ Item.send(:new, StyledString.parse(caption), mnemonic, on_click).tap { @items << _1 }
149
+ end
150
+
151
+ # @return [String]
152
+ def inspect
153
+ mn = @mnemonic.nil? ? "" : " [#{@mnemonic}]"
154
+ "#<#{self.class.name} #{caption.to_s.inspect}#{mn}#{submenu? ? " (#{@items.size} items)" : ""}>"
155
+ end
156
+
157
+ private
158
+
159
+ # Rejects a mnemonic that couldn't work, or that would make two siblings
160
+ # ambiguous — all three at *registration*, since none has a sane answer
161
+ # at keypress time.
162
+ # @param mnemonic [String, nil]
163
+ # @raise [ArgumentError]
164
+ # @return [void]
165
+ def validate_mnemonic(mnemonic)
166
+ return if mnemonic.nil?
167
+
168
+ # Not implied by printable?, which accepts " ".
169
+ raise ArgumentError, "mnemonic must not be a space: Space activates the highlighted item" if mnemonic == " "
170
+ unless Keys.printable?(mnemonic) && StyledString.plain(mnemonic).display_width == 1
171
+ raise ArgumentError, "mnemonic must be a single one-column printable character; got #{mnemonic.inspect}"
172
+ end
173
+
174
+ down = mnemonic.downcase
175
+ return unless @items.any? { |item| item.mnemonic == down }
176
+
177
+ raise ArgumentError, "duplicate mnemonic #{down.inspect} among these menu items"
178
+ end
179
+
180
+ # {StyledString#slice} counts **columns** while a caption search yields a
181
+ # **character** index, so the prefix is measured, never counted.
182
+ # @param caption [StyledString]
183
+ # @param mnemonic [String, nil] in the case it was given in.
184
+ # @return [StyledString]
185
+ def build_cued_caption(caption, mnemonic)
186
+ return caption if mnemonic.nil?
187
+
188
+ text = caption.to_s
189
+ # Exact case first, so "Save As" can underline either "a" via the case
190
+ # it was given in.
191
+ index = text.index(mnemonic) || text.downcase.index(mnemonic.downcase)
192
+ return caption if index.nil?
193
+
194
+ start = StyledString.plain(text[0, index]).display_width
195
+ caption.slice(0, start) + caption.slice(start, 1).with_underline +
196
+ caption.slice(start + 1, caption.display_width - start - 1)
197
+ end
198
+ end
199
+
200
+ def initialize
201
+ super()
202
+ @root = Item.send(:new, StyledString::EMPTY, nil, nil)
203
+ @highlighted_index = 0
204
+ @left_column = 0
205
+ @cascade = Cascade.new
206
+ end
207
+
208
+ # @return [Boolean] `true` — the strip takes focus, so its keys work.
209
+ def focusable? = true
210
+
211
+ # @return [Boolean] `true` — one stop for the whole strip, as on {Tabs}.
212
+ def tab_stop? = true
213
+
214
+ # @return [Array<Item>] the top-level items, in strip order. Read-only by
215
+ # convention; grow it through {#add_item}.
216
+ def items = @root.items
217
+
218
+ # @return [Integer] which top-level item the strip highlights while
219
+ # focused, and which menu Enter opens. `0` until the user moves.
220
+ attr_reader :highlighted_index
221
+
222
+ # Appends a top-level item and returns its handle; nest submenus into it
223
+ # with {Item#add_item}.
224
+ # @param caption [String, StyledString, nil] parsed as
225
+ # {StyledString.parse} parses it.
226
+ # @param mnemonic [String, nil] a single one-column printable character
227
+ # that activates this item while the strip is focused and *closed*,
228
+ # underlined in the caption where it occurs. Matched case-insensitively;
229
+ # it shadows whatever the app would otherwise do with that key while the
230
+ # bar has focus.
231
+ # @yield optional `on_click` callback, for a top-level item that acts as a
232
+ # button rather than opening a menu.
233
+ # @raise [ArgumentError] if `mnemonic` is a space, is not a single
234
+ # one-column printable character, or duplicates a sibling's.
235
+ # @return [Item]
236
+ def add_item(caption = nil, mnemonic: nil, &on_click)
237
+ @root.add_item(caption, mnemonic: mnemonic, &on_click).tap { refresh }
238
+ end
239
+
240
+ # The cells the strip actually paints: one row, as wide as its segments
241
+ # need, clipped to {#rect}.
242
+ #
243
+ # Both the highlight and the click hit test use it, so a click on the blank
244
+ # tail — or on a lower row, when the rect is taller than one — opens
245
+ # nothing. It still *focuses*: {Component#handle_mouse}'s click-to-focus is
246
+ # ungated by geometry.
247
+ # @return [Rect]
248
+ def extent
249
+ return Rect.new(rect.left, rect.top, 0, 1) if rect.empty?
250
+
251
+ Rect.new(rect.left, rect.top, [painted_width - @left_column, rect.width].min, 1)
252
+ end
253
+
254
+ # @param new_rect [Rect]
255
+ # @return [void]
256
+ def rect=(new_rect)
257
+ # Only a *changed* rect closes the menu: a layout re-assigning the same
258
+ # rect (which {Layout::Box} does on any child mutation) must not.
259
+ changed = rect != new_rect
260
+ super
261
+ @cascade.close if changed
262
+ end
263
+
264
+ # Closes the cascade when the strip leaves the focus chain, so tabbing (or
265
+ # clicking) away doesn't strand an open menu.
266
+ # @param flag [Boolean]
267
+ # @return [void]
268
+ def active=(flag)
269
+ was = active?
270
+ super
271
+ @cascade.close if was && !active?
272
+ end
273
+
274
+ # Closes the cascade, so a bar removed from the tree can't strand its
275
+ # panels on the pane — they are the {ScreenPane}'s children, not the bar's,
276
+ # so nothing else would take them down.
277
+ # @return [void]
278
+ def on_detached
279
+ super
280
+ @cascade.close
281
+ end
282
+
283
+ # Offers the key to the open cascade first, then to the strip's own
284
+ # LEFT/RIGHT/Enter/Space/Down.
285
+ #
286
+ # With a cascade open, the only keys reaching the strip are the two the
287
+ # cascade declines — LEFT at the first level, RIGHT on a row with no
288
+ # submenu — and both step to the sibling menu.
289
+ # @param key [String]
290
+ # @return [Boolean]
291
+ def handle_key(key)
292
+ # Ahead of the cascade: an open one swallows every printable it doesn't
293
+ # recognize, so a letter would never reach the strip otherwise.
294
+ return true if handle_mnemonic(key)
295
+ return true if @cascade.handle_key(key)
296
+
297
+ if @cascade.open?
298
+ case key
299
+ when Keys::LEFT_ARROW then step_menu(-1)
300
+ when Keys::RIGHT_ARROW then step_menu(1)
301
+ else false
302
+ end
303
+ else
304
+ case key
305
+ when Keys::LEFT_ARROW then move_highlight(-1)
306
+ when Keys::RIGHT_ARROW then move_highlight(1)
307
+ when Keys::ENTER, " ", Keys::DOWN_ARROW then open_highlighted
308
+ else false
309
+ end
310
+ end
311
+ end
312
+
313
+ # Opens the menu under a left click, or closes it when it is already the
314
+ # open one; `super` runs first, so a click anywhere in {#rect} still
315
+ # focuses.
316
+ # @param event [MouseEvent]
317
+ # @return [void]
318
+ def handle_mouse(event)
319
+ super
320
+ return unless event.button == :left
321
+
322
+ index = index_at(event.point)
323
+ return if index.nil?
324
+
325
+ if @cascade.open? && index == @highlighted_index
326
+ @cascade.close
327
+ else
328
+ self.highlight = index
329
+ open_highlighted
330
+ end
331
+ end
332
+
333
+ # @return [void]
334
+ def repaint
335
+ super
336
+ return if rect.empty?
337
+
338
+ row = strip_row.slice(@left_column, rect.width)
339
+ draw_text(rect.left, rect.top, row)
340
+ draw_cues(row)
341
+ end
342
+
343
+ private
344
+
345
+ # @return [Integer] the strip column painted in {#rect}'s leftmost cell —
346
+ # the horizontal scroll offset. `0` unless the strip overflows its rect;
347
+ # {#adjust_left_column} is its sole writer.
348
+ attr_reader :left_column
349
+
350
+ # The sole writer of {#highlighted_index}: assigns, re-syncs the scroll
351
+ # offset and repaints. Every path that moves the highlight — arrow,
352
+ # mnemonic, click — goes through it, so the highlighted segment is on
353
+ # screen *before* {Cascade} anchors a panel to it.
354
+ # @param index [Integer]
355
+ # @return [void]
356
+ def highlight=(index)
357
+ return if index == @highlighted_index
358
+
359
+ @highlighted_index = index
360
+ refresh
361
+ end
362
+
363
+ # Re-syncs the scroll offset and repaints — what every change to the items
364
+ # or the highlight ends in.
365
+ # @return [void]
366
+ def refresh
367
+ adjust_left_column
368
+ invalidate
369
+ end
370
+
371
+ # The rect's *width* is the only part of it the offset depends on, so this
372
+ # hook is the whole geometry story; {Component#rect=} invalidates for us,
373
+ # and {#rect=} closes the cascade rather than re-anchoring it.
374
+ # @return [void]
375
+ def on_width_changed
376
+ super
377
+ adjust_left_column
378
+ end
379
+
380
+ # Scrolls the minimum needed to show the highlighted segment whole, and is
381
+ # the sole writer of {#left_column}. Idempotent, so every mutation site can
382
+ # call it blindly; it returns the offset to `0` on its own once the strip
383
+ # fits again, which is why no mutator owes a scroll-back branch.
384
+ #
385
+ # A segment wider than the whole rect cannot be shown whole: its head wins,
386
+ # being the half of a caption that identifies it.
387
+ # @return [void]
388
+ def adjust_left_column
389
+ if rect.empty? || painted_width <= rect.width || items.empty?
390
+ @left_column = 0
391
+ return
392
+ end
393
+
394
+ _item, start, width = segments[@highlighted_index]
395
+ if width >= rect.width
396
+ @left_column = start
397
+ else
398
+ @left_column = start if start < @left_column
399
+ @left_column = start + width - rect.width if start + width > @left_column + rect.width
400
+ end
401
+ @left_column = snap_to_glyph_start(@left_column.clamp(0, painted_width - rect.width))
402
+ end
403
+
404
+ # {StyledString#slice} *drops* a cluster straddling the window's edge
405
+ # rather than half-painting it, which would leave the painted row a column
406
+ # short and shift everything past the hole one column left — paint and hit
407
+ # test would then disagree, silently and only for wide glyphs. So the
408
+ # offset only ever lands on a cluster boundary. Snapping *forward* is the
409
+ # safe direction: it gives up at most one column of the segment to the left
410
+ # of the window, never of the one being revealed.
411
+ # @param column [Integer]
412
+ # @return [Integer] the smallest cluster-boundary column `>= column`.
413
+ def snap_to_glyph_start(column)
414
+ boundary = 0
415
+ strip_row.to_s.each_grapheme_cluster do |glyph|
416
+ return boundary if boundary >= column
417
+
418
+ boundary += Buffer.display_width(glyph)
419
+ end
420
+ boundary
421
+ end
422
+
423
+ # Paints the overflow cues over the windowed row's edge columns: `<` when
424
+ # segments sit to the left of the window, `>` when more sit to the right.
425
+ # ASCII by convention rather than by constant, as {Checkbox}'s brackets
426
+ # are, and *overlaid* rather than given reserved columns — reserving would
427
+ # make the window width a function of the offset computed from it. Painted
428
+ # focused or not: overflow is a fact about the captions and the rect, not
429
+ # about focus.
430
+ # @param row [StyledString] the windowed row, as painted.
431
+ # @return [void]
432
+ def draw_cues(row)
433
+ draw_cue(row, 0, "<") if @left_column.positive?
434
+ draw_cue(row, rect.width - 1, ">") if @left_column + rect.width < painted_width
435
+ end
436
+
437
+ # The cue keeps the style of the cell it covers, so one landing on the
438
+ # highlighted segment doesn't punch a default-background hole in its
439
+ # highlight.
440
+ # @param row [StyledString] the windowed row.
441
+ # @param column [Integer] relative to {#rect}`.left`.
442
+ # @param glyph [String]
443
+ # @return [void]
444
+ def draw_cue(row, column, glyph)
445
+ style = row.slice(column, 1).spans.first&.style || StyledString::Style::DEFAULT
446
+ draw_char(rect.left + column, rect.top, glyph, style)
447
+ end
448
+
449
+ # One `[item, start_column, width]` triple per top-level item, in strip
450
+ # order, in columns relative to {#rect}`.left`. A segment is its caption
451
+ # between two padding columns, and neighbours abut — the two blank columns
452
+ # between captions are the segments' own padding, so a click on either
453
+ # opens the menu it belongs to.
454
+ # @return [Array<Array(Item, Integer, Integer)>]
455
+ def segments
456
+ column = 0
457
+ items.map do |item|
458
+ width = item.cued_caption.display_width + 2
459
+ [item, column, width].tap { column += width }
460
+ end
461
+ end
462
+
463
+ # @return [Integer] columns the strip would paint given an unlimited rect.
464
+ def painted_width
465
+ _item, start, width = segments.last
466
+ start.nil? ? 0 : start + width
467
+ end
468
+
469
+ # @param point [Point]
470
+ # @return [Integer, nil] the index of the item painted at `point`; `nil`
471
+ # for the blank tail or a row the strip doesn't paint.
472
+ def index_at(point)
473
+ return nil unless extent.contains?(point)
474
+
475
+ column = point.x - rect.left + @left_column
476
+ segments.index { |_item, start, width| column >= start && column < start + width }
477
+ end
478
+
479
+ # @param index [Integer]
480
+ # @return [Rect] the segment's cells on screen — the cascade's anchor.
481
+ def segment_rect(index)
482
+ _item, start, width = segments[index]
483
+ Rect.new(rect.left + start - @left_column, rect.top, width, 1)
484
+ end
485
+
486
+ # @return [StyledString] the whole strip as one row, unclipped. {#repaint}
487
+ # windows it to the rect; nothing else may, since the window's own
488
+ # arithmetic is {#adjust_left_column}'s.
489
+ def strip_row
490
+ row = StyledString::EMPTY
491
+ items.each_with_index { |item, index| row += segment_text(item, index) }
492
+ row
493
+ end
494
+
495
+ # @param item [Item]
496
+ # @param index [Integer]
497
+ # @return [StyledString] the caption between its padding columns,
498
+ # highlighted when it is the one Enter would open *and* the strip has
499
+ # focus. An unfocused strip shows no highlight at all: there is no
500
+ # persistent selection to report.
501
+ def segment_text(item, index)
502
+ pad = StyledString.plain(" ")
503
+ segment = pad + item.cued_caption + pad
504
+ return segment unless index == @highlighted_index && active?
505
+
506
+ segment.with_bg(screen.theme.active_bg_color)
507
+ end
508
+
509
+ # Activates the item bound to `key` on the *live* level — the deepest open
510
+ # panel while the cascade is open, the top-level strip while it is closed.
511
+ # No fallback between the two: a letter matching nothing in the live set is
512
+ # not offered to any other level.
513
+ # @param key [String]
514
+ # @return [Boolean] whether a mnemonic claimed the key.
515
+ def handle_mnemonic(key)
516
+ return false unless Keys.printable?(key)
517
+
518
+ down = key.downcase
519
+ return @cascade.handle_mnemonic(down) if @cascade.open?
520
+
521
+ index = items.index { |item| item.mnemonic == down }
522
+ return false if index.nil?
523
+
524
+ self.highlight = index
525
+ open_highlighted
526
+ end
527
+
528
+ # Moves the highlight along the strip, clamping at both ends. Consumes the
529
+ # key even at an end, as {Tabs} does.
530
+ # @param delta [Integer] `+1` / `-1`.
531
+ # @return [Boolean] `false` only when there are no items.
532
+ def move_highlight(delta)
533
+ return false if items.empty?
534
+
535
+ self.highlight = (@highlighted_index + delta).clamp(0, items.size - 1)
536
+ true
537
+ end
538
+
539
+ # Steps to the neighbouring menu, showing *its* menu instead — or closing
540
+ # the cascade, when the neighbour is a top-level button with no menu to
541
+ # show. The cascade is left alone when the highlight is already at an end:
542
+ # reopening the same menu would throw away the submenu the user is standing
543
+ # in.
544
+ #
545
+ # It deliberately never *activates*. An item arrowed past is highlighted,
546
+ # not pressed, so a top-level button waits for Enter or Space — otherwise
547
+ # walking the strip would fire every button on it.
548
+ # @param delta [Integer] `+1` / `-1`.
549
+ # @return [Boolean] always `true`: an open menu swallows the key either way.
550
+ def step_menu(delta)
551
+ was = @highlighted_index
552
+ move_highlight(delta)
553
+ show_highlighted_menu unless @highlighted_index == was
554
+ true
555
+ end
556
+
557
+ # Opens the highlighted item's menu, or fires it when it is a top-level
558
+ # button — the Enter/Space/Down/click path, and the only one that fires a
559
+ # listener.
560
+ # @return [Boolean] `false` only when there are no items.
561
+ def open_highlighted
562
+ return false if items.empty?
563
+
564
+ item = items[@highlighted_index]
565
+ show_highlighted_menu
566
+ # Fired after the close above, exactly as {Cascade} activates a leaf: an
567
+ # action that opens a dialog must not paint it under a menu.
568
+ item.on_click&.call unless item.submenu?
569
+ true
570
+ end
571
+
572
+ # Shows the highlighted item's menu, closing the cascade when it has none.
573
+ # @return [void]
574
+ def show_highlighted_menu
575
+ item = items[@highlighted_index]
576
+ return @cascade.close unless item.submenu?
577
+
578
+ @cascade.open_below(segment_rect(@highlighted_index), item)
579
+ end
580
+ end
581
+ end
582
+ end
@@ -34,8 +34,11 @@ module Tuile
34
34
  #
35
35
  # - **Take focus, or receive keys.** A non-modal popup sits off the
36
36
  # key-dispatch scope ({ScreenPane#handle_key}), so not even {Popup}'s
37
- # `q`/ESC arrives here. A left click dismisses ({#handle_mouse}); an app
38
- # wanting a key registers a global shortcut and calls {#close}.
37
+ # `q`/ESC arrives here. A left click *on the box* dismisses
38
+ # ({#handle_mouse}); an app wanting a key registers a global shortcut and
39
+ # calls {#close}. A click *elsewhere* does not — this is the one popup
40
+ # with {Popup#close_on_outside_click?} false, since a toast is timed and
41
+ # an unrelated click is not about it.
39
42
  # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
40
43
  # the message is added — a toast lives seconds, so there is no
41
44
  # {Component#on_theme_changed} rebuild.
@@ -121,7 +124,7 @@ module Tuile
121
124
  @view = TextView.new
122
125
  @window = Window.new
123
126
  @window.content = @view
124
- super(content: @window, modal: false)
127
+ super(content: @window, modal: false, close_on_outside_click: false)
125
128
  end
126
129
 
127
130
  # Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
@@ -133,12 +136,6 @@ module Tuile
133
136
  # @return [Boolean] false — see {#focusable?}.
134
137
  def tab_stop? = false
135
138
 
136
- # Empty: a non-modal popup never owns the status bar, and {Popup}'s
137
- # inherited `q Close` hint would be a lie here — no key ever reaches a
138
- # notification.
139
- # @return [String]
140
- def keyboard_hint = ""
141
-
142
139
  # Appends a message, dropping it (with a {Tuile.logger} warning) once
143
140
  # {MAX_MESSAGES} are held. Public so a caller holding the instance can
144
141
  # append without repeating {show}'s lookup.
@@ -214,10 +211,16 @@ module Tuile
214
211
  end
215
212
 
216
213
  # @return [void]
217
- def on_attached = sync_ticker
214
+ def on_attached
215
+ super
216
+ sync_ticker
217
+ end
218
218
 
219
219
  # @return [void]
220
- def on_detached = sync_ticker
220
+ def on_detached
221
+ super
222
+ sync_ticker
223
+ end
221
224
 
222
225
  private
223
226
 
@@ -66,11 +66,6 @@ module Tuile
66
66
  end
67
67
  end
68
68
 
69
- # @return [String]
70
- def keyboard_hint
71
- @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }.join(" ")
72
- end
73
-
74
69
  # Opens a picker as a popup. Picking an option fires `block`, then
75
70
  # closes the popup; ESC / `q` close without firing `block`.
76
71
  # @param caption [String]