tuile 0.12.0 → 0.14.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. data/mise.toml +0 -2
@@ -294,6 +294,28 @@ module Tuile
294
294
  search_and_go(query, include_current: include_current, reverse: true)
295
295
  end
296
296
 
297
+ # Moves the cursor to the item at `index`, scrolling it into view and
298
+ # firing {#on_cursor_changed} — the positional member of the
299
+ # {#select_next} / {#select_prev} family, for a caller that already knows
300
+ # *which* item it wants:
301
+ #
302
+ # list.select(items.index(chosen))
303
+ #
304
+ # Refuses an index the current {#cursor} can't reach — out of range, or
305
+ # anything at all under {Cursor::None} — rather than stranding the cursor
306
+ # off-content.
307
+ # @param index [Integer]
308
+ # @return [Boolean] whether the cursor moved there.
309
+ def select(index)
310
+ return false unless @cursor.candidate_positions(@items.size).include?(index)
311
+
312
+ @cursor.go(index)
313
+ move_viewport_to_cursor
314
+ notify_cursor_changed
315
+ invalidate
316
+ true
317
+ end
318
+
297
319
  # @param event [MouseEvent]
298
320
  # @return [void]
299
321
  def handle_mouse(event)
@@ -4,7 +4,7 @@ module Tuile
4
4
  class Component
5
5
  # A borderless, tinted, non-focusable floating selection list — the dropdown
6
6
  # a *driver* drops open, drives by forwarding movement keys, and commits a
7
- # pick from: a non-modal {Popup} wrapping a {List} that never takes focus, so
7
+ # pick from: an {Overlay} wrapping a {List} that never takes focus, so
8
8
  # focus stays on the driver while the caller refills the rows, moves the
9
9
  # highlight, and reads the pick.
10
10
  #
@@ -19,9 +19,10 @@ module Tuile
19
19
  # drop.choose if key == Keys::ENTER # commit the highlight
20
20
  #
21
21
  # It owns only what every such dropdown shares — *placement* included, via
22
- # {#anchor_to}. What stays with the driver: the width **policy** ({#anchor_to}
23
- # measures nothing itself), filtering, row rendering, the commit action, and
24
- # ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
22
+ # {#anchor_to} (below a field) and {#anchor_beside} (beside a parent row, for
23
+ # a cascading submenu). What stays with the driver: the width **policy**
24
+ # (neither placement method measures anything itself), filtering, row
25
+ # rendering, the commit action, and ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
25
26
  # revert a query; Enter may commit via {#choose} *or* via a separate submit
26
27
  # path), so {#move} claims neither — the driver calls {#choose} and {#close}
27
28
  # from its own branches.
@@ -33,7 +34,7 @@ module Tuile
33
34
  # different tint (a `Theme.ref(:token)` keeps the flip-tracking).
34
35
  #
35
36
  # UI-thread-confined, like every component (see {Screen}).
36
- class ListDropdown < Popup
37
+ class ListDropdown < Overlay
37
38
  # The dropdown's {List}. Non-focusable on purpose: the driver forwards keys
38
39
  # while focus stays on it, and a mouse click selects an item without
39
40
  # stealing focus — so a driving text input never loses its caret
@@ -63,7 +64,7 @@ module Tuile
63
64
  @list = Menu.new
64
65
  @list.cursor = List::Cursor.new
65
66
  @list.show_cursor_when_inactive = true # highlight the selection though focus stays on the driver
66
- super(content: @list, modal: false)
67
+ super(content: @list)
67
68
  self.bg_color = Theme.ref(:input_bg_color)
68
69
  end
69
70
 
@@ -88,6 +89,14 @@ module Tuile
88
89
  @list.on_item_chosen = proc
89
90
  end
90
91
 
92
+ # @param proc [Proc, Method, nil] highlight-moved callback; see
93
+ # {List#on_cursor_changed}. A cascading driver needs it to drop the
94
+ # panels that belonged to the row the highlight just left.
95
+ # @return [void]
96
+ def on_cursor_changed=(proc)
97
+ @list.on_cursor_changed = proc
98
+ end
99
+
91
100
  # @param cursor [List::Cursor] the highlight; see {List#cursor=}.
92
101
  # @return [void]
93
102
  def cursor=(cursor)
@@ -97,6 +106,13 @@ module Tuile
97
106
  # @return [List::Cursor] the list's cursor (the current highlight).
98
107
  def cursor = @list.cursor
99
108
 
109
+ # Moves the highlight to the item at `index`, scrolling it into view; see
110
+ # {List#select}. The positional counterpart of {#move}, for a driver that
111
+ # picked a row by something other than a key — a mnemonic letter, say.
112
+ # @param index [Integer]
113
+ # @return [Boolean] whether the highlight moved there.
114
+ def select(index) = @list.select(index)
115
+
100
116
  # Sizes and places the dropdown against `anchor`: directly beneath it,
101
117
  # flipped above when `rows` won't fit below, clamped — with the list
102
118
  # scrolling — when neither side has room. Horizontally the left edges line
@@ -108,7 +124,16 @@ module Tuile
108
124
  # Vertical flips but horizontal slides because covering the driver would
109
125
  # hide what is being chosen, while sharing its columns is the point.
110
126
  #
111
- # @param anchor [Rect] the driver's rect; the dropdown never covers it.
127
+ # **`anchor` is the region actually occupied, and may be taller than one
128
+ # row** — "beneath" means the row *after* it, so a multi-row driver (a
129
+ # {Component::TextArea} carrying an autocomplete menu) is cleared entirely
130
+ # rather than overdrawn from its second row down. A widget that paints one
131
+ # row but may be *assigned* more height passes its face, not its rect:
132
+ # {ComboBox} and {Select} both do, since a {Window} content slot hands them
133
+ # the full inner height.
134
+ #
135
+ # @param anchor [Rect] the region the driver occupies, of any height; the
136
+ # dropdown never covers it.
112
137
  # @param rows [Integer] how many rows there are to show — the content
113
138
  # count, not the height: more than fits turns the scrollbar on. `0`
114
139
  # collapses the dropdown to an empty rect (drivers close instead).
@@ -120,26 +145,92 @@ module Tuile
120
145
  # @return [void]
121
146
  def anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS)
122
147
  desired = [rows, max_rows].min
123
- below = screen.size.height - (anchor.top + 1)
148
+ beneath = anchor.top + anchor.height
149
+ below = screen.size.height - beneath
124
150
  above = anchor.top
125
151
  if desired <= below
126
- top = anchor.top + 1
152
+ top = beneath
127
153
  height = desired
128
154
  elsif above >= below
129
155
  height = [desired, above].min
130
156
  top = anchor.top - height
131
157
  else
132
158
  height = below
133
- top = anchor.top + 1
159
+ top = beneath
134
160
  end
135
161
  width = [width, screen.size.width].min
136
- self.size = Size.new(width, height)
137
162
  self.rect = Rect.new([anchor.left, screen.size.width - width].min.clamp(0, nil), top, width, height)
138
163
  # After the geometry: the setter rebuilds the list's padded rows against
139
164
  # the width it can see, and the gutter takes a column off it.
140
165
  @list.scrollbar_visibility = rows > height ? :visible : :gone
141
166
  end
142
167
 
168
+ # Sizes and places the dropdown *beside* `anchor` — the placement a
169
+ # cascading submenu wants, where {#anchor_to} is the placement a field's
170
+ # dropdown wants.
171
+ #
172
+ # sub.anchor_beside(parent.cursor_row_rect, rows: kids.size, width: measured)
173
+ #
174
+ # Horizontally it sits against `anchor`'s right edge, **flipping** to its
175
+ # left when the right has no room (and clamping to the screen when neither
176
+ # side does). Vertically it **slides**: the panel's first row lines up with
177
+ # the anchored row, sliding up only far enough to keep the panel on screen.
178
+ #
179
+ # The two axes are the mirror image of {#anchor_to}'s, for the same reason:
180
+ # never cover the thing being chosen from. A field's dropdown must not
181
+ # cover the field, so it flips *vertically* and shares its columns; a
182
+ # submenu must not cover its parent panel, so it flips *horizontally* and
183
+ # shares its rows.
184
+ #
185
+ # @param anchor [Rect] the row the submenu belongs to — typically the
186
+ # parent dropdown's {#cursor_row_rect}. Its width is the parent panel's,
187
+ # which is what the submenu clears.
188
+ # @param rows [Integer] how many rows there are to show — the content
189
+ # count, not the height; more than fits turns the scrollbar on. `0`
190
+ # collapses the dropdown to an empty rect (drivers close instead).
191
+ # @param width [Integer] the panel's width in columns, clamped to the
192
+ # screen. **Required, with no default:** `anchor.width` is the *parent's*
193
+ # width and would be meaningless here, so the caller measures (see
194
+ # `DECISIONS.md` `D_select` on why the width policy stays with the
195
+ # driver).
196
+ # @param max_rows [Integer] rows shown before the list scrolls.
197
+ # @return [void]
198
+ def anchor_beside(anchor, rows:, width:, max_rows: MAX_VISIBLE_ROWS)
199
+ height = [rows, max_rows, screen.size.height].min
200
+ width = [width, screen.size.width].min
201
+ right = anchor.left + anchor.width
202
+ left = if right + width <= screen.size.width || (anchor.left - width).negative?
203
+ right
204
+ else
205
+ anchor.left - width
206
+ end
207
+ left = left.clamp(0, [screen.size.width - width, 0].max)
208
+ top = [anchor.top, screen.size.height - height].min.clamp(0, nil)
209
+ self.rect = Rect.new(left, top, width, height)
210
+ # After the geometry, as in {#anchor_to}: the setter rebuilds the list's
211
+ # padded rows against the width it can see.
212
+ @list.scrollbar_visibility = rows > height ? :visible : :gone
213
+ end
214
+
215
+ # The highlighted row's rect on screen — what a cascading submenu anchors
216
+ # against, via {#anchor_beside}.
217
+ #
218
+ # It lives here rather than in the driver because {ListDropdown} owns the
219
+ # list's geometry: a driver computing `top + position - scroll_top_row`
220
+ # itself would have to reach through to the private list.
221
+ # @return [Rect, nil] one row spanning the panel's width, or `nil` when
222
+ # the cursor is off-content ({List::Cursor::None}, an empty list) or its
223
+ # row is scrolled out of the viewport.
224
+ def cursor_row_rect
225
+ return nil if @list.rect.empty?
226
+ return nil unless @list.cursor.position.between?(0, @list.items.size - 1)
227
+
228
+ row = @list.cursor.position - @list.scroll_top_row
229
+ return nil unless row.between?(0, @list.rect.height - 1)
230
+
231
+ Rect.new(@list.rect.left, @list.rect.top + row, @list.rect.width, 1)
232
+ end
233
+
143
234
  # Forwards a cursor-movement key to the list. The driver calls this from
144
235
  # its own key handler; a truthy return means "consumed — stop here", falsy
145
236
  # means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A {TextView} purpose-built for log output: auto-scroll is on, the
6
+ # scrollbar is visible, and lines arrive via {#log} — from any thread.
7
+ # Point a logger at a {LogTextView::IO} to route its lines here:
8
+ #
9
+ # view = Tuile::Component::LogTextView.new
10
+ # logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
11
+ # logger.info("started") # appears in the view, from any thread
12
+ #
13
+ # Any logger that writes formatted lines to an IO works the same way —
14
+ # for example `TTY::Logger` configured with the `:console` handler and
15
+ # `output: LogTextView::IO.new(view)`.
16
+ #
17
+ # Add it to a layout as-is for a frameless log pane, or use {LogWindow}
18
+ # for the framed assembly. A {TextView} rather than a {List}: long lines
19
+ # (stacktraces, wide log records) word-wrap rather than ellipsize — a
20
+ # truncated log line hides the very detail you opened the log to read.
21
+ class LogTextView < TextView
22
+ def initialize
23
+ super
24
+ self.auto_scroll = true
25
+ self.scrollbar_visibility = :visible
26
+ end
27
+
28
+ # Appends the given line to the log. Safe to call from any thread —
29
+ # the one exception to the UI-thread confinement every component
30
+ # carries: the append is marshalled through `event_queue.submit`, so
31
+ # it lands once a loop drains the queue (deferred before the first
32
+ # loop; silently dropped after the loop has returned).
33
+ # @param string [String, nil] the line (or multiple lines) to log;
34
+ # `nil` is a no-op.
35
+ # @return [void]
36
+ def log(string)
37
+ return if string.nil?
38
+
39
+ screen.event_queue.submit { add_line(string) }
40
+ end
41
+
42
+ # IO-shaped adapter that forwards each log line to its sink's `#log`.
43
+ # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
44
+ # call `output.puts`, e.g. `TTY::Logger`).
45
+ class IO
46
+ # @param sink [LogTextView, LogWindow] anything responding to `#log`.
47
+ def initialize(sink)
48
+ @sink = sink
49
+ end
50
+
51
+ # @param string [String]
52
+ # @return [void]
53
+ def write(string)
54
+ @sink.log(string.chomp)
55
+ end
56
+
57
+ # @param string [String]
58
+ # @return [void]
59
+ def puts(string)
60
+ @sink.log(string)
61
+ end
62
+
63
+ # Stdlib `Logger` only treats an object as an IO target when it
64
+ # responds to both {#write} and {#close}; otherwise it tries to
65
+ # interpret it as a filename. This is a no-op.
66
+ # @return [void]
67
+ def close; end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -2,65 +2,30 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # Shows a log. Construct your logger pointed at a {LogWindow::IO} to route
6
- # log lines into this window:
5
+ # A {Window} framing a {LogTextView} — the framed log pane. All the log
6
+ # behavior lives on the view (see {LogTextView} for the adapter and the
7
+ # wrap rationale); the window adds the border, caption and delegation:
7
8
  #
8
9
  # log_window = Tuile::Component::LogWindow.new
9
10
  # logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
10
- #
11
- # Any logger that writes formatted lines to an IO works the same way —
12
- # for example `TTY::Logger` configured with the `:console` handler and
13
- # `output: LogWindow::IO.new(window)`.
14
11
  class LogWindow < Window
12
+ # Alias of {LogTextView::IO}, which accepts the window itself as its sink.
13
+ # @return [Class]
14
+ IO = LogTextView::IO
15
+
15
16
  # @param caption [String]
16
17
  def initialize(caption = "Log")
17
18
  super
18
- view = Component::TextView.new
19
- # Word-wrap long lines (stacktraces, wide log records) rather than
20
- # ellipsizing them as a {List} would — a truncated log line hides the
21
- # very detail you opened the log to read.
22
- view.auto_scroll = true
23
- self.content = view
24
- self.scrollbar = true
19
+ self.content = LogTextView.new
25
20
  end
26
21
 
27
- # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
28
- # @param string [String, nil] the line (or multiple lines) to log.
22
+ # Appends the given line to the log, from any thread — delegates to
23
+ # {LogTextView#log}.
24
+ # @param string [String, nil] the line (or multiple lines) to log;
25
+ # `nil` is a no-op.
29
26
  # @return [void]
30
27
  def log(string)
31
- return if string.nil?
32
-
33
- screen.event_queue.submit do
34
- content.add_line(string)
35
- end
36
- end
37
-
38
- # IO-shaped adapter that forwards each log line to the owning {LogWindow}.
39
- # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
40
- # call `output.puts`, e.g. `TTY::Logger`).
41
- class IO
42
- # @param window [LogWindow]
43
- def initialize(window)
44
- @window = window
45
- end
46
-
47
- # @param string [String]
48
- # @return [void]
49
- def write(string)
50
- @window.log(string.chomp)
51
- end
52
-
53
- # @param string [String]
54
- # @return [void]
55
- def puts(string)
56
- @window.log(string)
57
- end
58
-
59
- # Stdlib `Logger` only treats an object as an IO target when it
60
- # responds to both {#write} and {#close}; otherwise it tries to
61
- # interpret it as a filename. This is a no-op.
62
- # @return [void]
63
- def close; end
28
+ content.log(string)
64
29
  end
65
30
  end
66
31
  end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ class MenuBar
6
+ # The stack of open menu panels — one {ListDropdown} per level, the last
7
+ # deepest — and the drill/pop/activate logic driving them. Private
8
+ # machinery of {MenuBar}; an app never names it.
9
+ #
10
+ # cascade.open_below(segment_rect, item) # Enter/Down on the strip
11
+ # return true if cascade.handle_key(key) # MenuBar#handle_key, first
12
+ # cascade.close # focus lost, or rect changed
13
+ #
14
+ # A panel is a **non-modal overlay, not a child**, so it never takes focus:
15
+ # focus stays on the {MenuBar} for the whole interaction and every key
16
+ # arrives via {MenuBar#handle_key}, which offers it here first. That is
17
+ # {Component::Select}'s architecture extended to N levels, and it is why
18
+ # nothing in the key-dispatch ladder changes.
19
+ #
20
+ # Widths are measured here, per level — the panel is as wide as the level's
21
+ # widest label — because {ListDropdown} deliberately measures nothing
22
+ # itself (`DECISIONS.md` `D_select`).
23
+ #
24
+ # == Implementation details
25
+ # While open it consumes **everything** except the two keys that mean
26
+ # "leave this menu sideways", which only the strip can answer: LEFT at
27
+ # depth 1, and RIGHT on a row with no submenu. An open menu is quasi-modal
28
+ # — firing an app's `s`-to-save behind a visible panel is worse than a dead
29
+ # keystroke.
30
+ #
31
+ # UI-thread-confined, like everything in the tree (see {Screen}).
32
+ class Cascade
33
+ # The affordance painted on a row that opens a submenu. U+25B8 rather
34
+ # than the obvious `▶`: like {Component::Select}'s `▾` it is East-Asian
35
+ # **Neutral**, so it measures one column even under ambiguous-as-wide and
36
+ # stays outside `D_ambiguous_width`'s bet, where `▶` and `▼` are
37
+ # Ambiguous and would need an ASCII opt-in.
38
+ # @return [String]
39
+ SUBMENU_ARROW = "▸"
40
+
41
+ # Columns a submenu arrow occupies, the space before it included.
42
+ # @return [Integer]
43
+ ARROW_WIDTH = 2
44
+
45
+ def initialize
46
+ @levels = []
47
+ end
48
+
49
+ # @return [Boolean] whether any panel is open.
50
+ def open? = !@levels.empty?
51
+
52
+ # @return [Integer] how many panels are open; `0` when closed.
53
+ def depth = @levels.size
54
+
55
+ # Opens `item`'s children directly beneath `anchor`, closing anything
56
+ # already open first.
57
+ # @param anchor [Rect] the strip segment the menu drops from.
58
+ # @param item [Item] a childless one opens nothing.
59
+ # @return [void]
60
+ def open_below(anchor, item)
61
+ close
62
+ return unless item.submenu?
63
+
64
+ push(item) { |drop, rows, width| drop.anchor_to(anchor, rows: rows, width: width) }
65
+ end
66
+
67
+ # Closes every open panel, deepest first.
68
+ # @return [void]
69
+ def close = truncate(0)
70
+
71
+ # Offers a key to the deepest panel and to the cascade's own verbs.
72
+ # @param key [String]
73
+ # @return [Boolean] `true` when consumed — almost always, while open.
74
+ # `false` when closed, and for the two sideways keys {MenuBar} answers
75
+ # (see the class docs).
76
+ def handle_key(key)
77
+ return false unless open?
78
+ return true if deepest.move(key)
79
+
80
+ case key
81
+ when Keys::ENTER, " " then activate_highlighted
82
+ when Keys::RIGHT_ARROW
83
+ return false unless highlighted(depth - 1)&.submenu?
84
+
85
+ activate_highlighted
86
+ when Keys::LEFT_ARROW
87
+ return false if depth < 2
88
+
89
+ pop
90
+ when Keys::ESC then pop
91
+ else
92
+ # Only a printable: an open menu also swallows HOME, function keys
93
+ # and the five-byte junk Keys.getkey returns for an unrecognized
94
+ # escape sequence, and ringing at terminal noise is worse than
95
+ # silence. A printable is a deliberate, visible act.
96
+ Screen.instance.beep if Keys.printable?(key)
97
+ end
98
+ true
99
+ end
100
+
101
+ # Activates the deepest level's item bound to `key` — the drill-or-fire
102
+ # the mnemonic shares with Enter. The highlight moves there *first*, so a
103
+ # submenu anchors beside the row that opened it rather than beside
104
+ # wherever the cursor happened to be.
105
+ # @param key [String] a single printable, already downcased.
106
+ # @return [Boolean] whether an item on the deepest level claimed it. A
107
+ # miss is never offered to a shallower level.
108
+ def handle_mnemonic(key)
109
+ return false unless open?
110
+
111
+ level = depth - 1
112
+ item, drop = @levels[level]
113
+ index = item.items.index { |child| child.mnemonic == key }
114
+ return false if index.nil?
115
+
116
+ drop.select(index)
117
+ activate(level, item.items[index])
118
+ true
119
+ end
120
+
121
+ private
122
+
123
+ # @return [ListDropdown] the deepest open panel.
124
+ def deepest = @levels.last[1]
125
+
126
+ # @return [void]
127
+ def activate_highlighted = activate(depth - 1, highlighted(depth - 1))
128
+
129
+ # Drills into `item`, or fires it and closes the cascade.
130
+ #
131
+ # @param level [Integer] the panel the item belongs to.
132
+ # @param item [Item, nil] `nil` (an off-content cursor) does nothing.
133
+ # @return [void]
134
+ def activate(level, item)
135
+ # Truncate first, for the mouse: a click on a shallower panel that is
136
+ # still visible routes to *that* panel, so anything deeper is stale.
137
+ truncate(level + 1)
138
+ return if item.nil?
139
+
140
+ if item.submenu?
141
+ push_beside(level, item)
142
+ else
143
+ # Closed before the listener runs, so an action that opens a dialog
144
+ # doesn't paint it under a menu. A listener-less leaf still closes:
145
+ # activation stays uniform.
146
+ close
147
+ item.on_click&.call
148
+ end
149
+ end
150
+
151
+ # Opens `item`'s children beside the row highlighted in `level`.
152
+ # @param level [Integer]
153
+ # @param item [Item]
154
+ # @return [void]
155
+ def push_beside(level, item)
156
+ anchor = @levels[level][1].cursor_row_rect
157
+ return if anchor.nil?
158
+
159
+ push(item) { |drop, rows, width| drop.anchor_beside(anchor, rows: rows, width: width) }
160
+ end
161
+
162
+ # Mounts a panel for `item`'s children and yields it for geometry.
163
+ # @param item [Item]
164
+ # @yieldparam drop [ListDropdown]
165
+ # @yieldparam rows [Integer]
166
+ # @yieldparam width [Integer]
167
+ # @return [void]
168
+ def push(item)
169
+ children = item.items
170
+ drop = ListDropdown.new
171
+ drop.renderer = renderer_for(children)
172
+ drop.items = children
173
+ drop.cursor = List::Cursor.new
174
+ level = @levels.size
175
+ # Wired *after* the items and cursor: {List#items=} and {List#cursor=}
176
+ # both fire on_cursor_changed, so wiring first would have the fresh
177
+ # panel truncate itself away as it was built.
178
+ drop.on_item_chosen = ->(_index, child) { activate(level, child) }
179
+ drop.on_cursor_changed = ->(_index, _child) { truncate(level + 1) }
180
+ # The cascade's own record of what is open is reconciled from the
181
+ # popup's own closure, not maintained alongside it: an outside click
182
+ # closes panels behind our back ({Overlay#close_on_outside_click?}), and
183
+ # a level left in `@levels` after its panel is gone would have `depth`,
184
+ # `deepest` and `highlighted` all lying. Identity-keyed and idempotent,
185
+ # because the notice also arrives from `truncate` (which has already
186
+ # popped the entry) and from teardown, in no guaranteed order.
187
+ drop.on_close = -> { @levels.delete_if { |(_i, d)| d.equal?(drop) } }
188
+ # Chain each panel to the one it dropped out of, so a click on a
189
+ # deeper panel is "inside" the shallower ones and doesn't dismiss
190
+ # them. Level 0 owns nothing on purpose: a click on a dialog hosting
191
+ # the bar *should* close the whole menu and keep the dialog.
192
+ drop.owner = @levels.last&.last
193
+ @levels << [item, drop]
194
+ drop.open
195
+ yield(drop, children.size, width_for(children))
196
+ end
197
+
198
+ # Closes the deepest panel; at depth 1 that closes the cascade.
199
+ # @return [void]
200
+ def pop = truncate(depth - 1)
201
+
202
+ # @param count [Integer] how many panels to keep.
203
+ # @return [void]
204
+ def truncate(count)
205
+ while depth > count
206
+ _item, drop = @levels.pop
207
+ drop.close
208
+ end
209
+ end
210
+
211
+ # @param level [Integer]
212
+ # @return [Item, nil] the item under `level`'s cursor; `nil` when it sits
213
+ # off-content. The range guard matters: a cursor at `-1` would
214
+ # otherwise index the *last* child.
215
+ def highlighted(level)
216
+ item, drop = @levels[level]
217
+ position = drop.cursor.position
218
+ position.between?(0, item.items.size - 1) ? item.items[position] : nil
219
+ end
220
+
221
+ # @param items [Array<Item>]
222
+ # @return [Proc] item -> row: the label padded to the level's widest, plus
223
+ # an arrow column when any sibling has a submenu — so every arrow lands
224
+ # in the same column without asking the {List} how wide it ended up.
225
+ def renderer_for(items)
226
+ label_width = label_width_of(items)
227
+ arrows = items.any?(&:submenu?)
228
+ lambda do |item|
229
+ row = item.cued_caption.ellipsize(label_width)
230
+ row += StyledString.plain(" " * (label_width - row.display_width))
231
+ next row unless arrows
232
+
233
+ row + StyledString.plain(item.submenu? ? " #{SUBMENU_ARROW}" : " " * ARROW_WIDTH)
234
+ end
235
+ end
236
+
237
+ # @param items [Array<Item>]
238
+ # @return [Integer] the panel width: the rendered row plus {List}'s two
239
+ # row gutters, plus a scrollbar column when the rows can't all be shown.
240
+ # As in {Component::Select}, the scrollbar is predicted from the item
241
+ # count rather than the final height — a panel the screen clamps
242
+ # shorter than {ListDropdown::MAX_VISIBLE_ROWS} scrolls without having
243
+ # bought that column, and ellipsizes one character early.
244
+ def width_for(items)
245
+ row = label_width_of(items) + (items.any?(&:submenu?) ? ARROW_WIDTH : 0)
246
+ row + 2 + (items.size > ListDropdown::MAX_VISIBLE_ROWS ? 1 : 0)
247
+ end
248
+
249
+ # @param items [Array<Item>]
250
+ # @return [Integer]
251
+ def label_width_of(items) = items.map { _1.cued_caption.display_width }.max || 0
252
+ end
253
+ end
254
+ end
255
+ end