tuile 0.16.0 → 0.17.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -9,12 +9,11 @@ module Tuile
9
9
  # highlight, and reads the pick.
10
10
  #
11
11
  # drop = Component::ListDropdown.new
12
- # drop.renderer = method(:label_for) # caller renders
13
- # drop.on_item_chosen = ->(_index, item) { commit(item) } # caller commits
12
+ # drop.renderer = ->(item, _w) { label_for(item) } # caller renders
13
+ # drop.list.on_item_chosen { |e| commit(e.item) } # caller commits
14
14
  # # …then, from the driver's key handler:
15
15
  # drop.items = matches # caller filters
16
- # drop.anchor_to(rect, rows: matches.size) # below the driver, or flipped
17
- # drop.open
16
+ # drop.anchor_to(self) # opens it below the driver, or flipped
18
17
  # return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
19
18
  # drop.choose if key == Keys::ENTER # commit the highlight
20
19
  #
@@ -64,42 +63,120 @@ module Tuile
64
63
  # @return [Integer]
65
64
  MAX_VISIBLE_ROWS = 10
66
65
 
66
+ # The placement {#anchor_to} and {#anchor_beside} open the dropdown with:
67
+ # hung off `anchor`, `side` `:below` or `:beside` it, as tall as its rows
68
+ # up to `max_rows`. The pane re-reads the anchor on every pass and again
69
+ # once the content has settled, so the panel follows a field that moves.
70
+ #
71
+ # @!attribute [r] anchor
72
+ # @return [Component, Rect] {Overlay::Placement#anchor}: a component,
73
+ # followed for as long as it is on screen, or a fixed rect in screen
74
+ # coordinates.
75
+ # @!attribute [r] side
76
+ # @return [Symbol] `:below` or `:beside`.
77
+ # @!attribute [r] width
78
+ # @return [Integer, #call, nil] columns, or something answering them when
79
+ # called; `nil` takes the anchor's width.
80
+ # @!attribute [r] max_rows
81
+ # @return [Integer] rows shown before the list scrolls.
82
+ Anchored = Data.define(:anchor, :side, :width, :max_rows) do
83
+ include Overlay::Placement
84
+
85
+ # @param drop [ListDropdown]
86
+ # @param screen_size [Size]
87
+ # @param anchor_rect [Rect] {#anchor} in screen coordinates, resolved by the pane.
88
+ # @return [Rect]
89
+ def rect_for(drop, screen_size, anchor_rect)
90
+ columns = width.nil? ? anchor_rect.width : width
91
+ columns = [columns.respond_to?(:call) ? columns.call : columns, screen_size.width].min
92
+ if side == :below
93
+ below(anchor_rect, drop.items.size, columns, screen_size)
94
+ else
95
+ beside(anchor_rect, drop.items.size, columns, screen_size)
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ # Beneath the anchor, flipped above when the rows won't fit below,
102
+ # clamped — with the list scrolling — when neither side has room; the
103
+ # left edges line up, sliding left only far enough to stay on screen.
104
+ # @param anchor [Rect]
105
+ # @param rows [Integer]
106
+ # @param width [Integer]
107
+ # @param screen_size [Size]
108
+ # @return [Rect]
109
+ def below(anchor, rows, width, screen_size)
110
+ desired = [rows, max_rows].min
111
+ beneath = anchor.top + anchor.height
112
+ room_below = screen_size.height - beneath
113
+ if desired <= room_below
114
+ top = beneath
115
+ height = desired
116
+ elsif anchor.top >= room_below
117
+ height = [desired, anchor.top].min
118
+ top = anchor.top - height
119
+ else
120
+ height = room_below
121
+ top = beneath
122
+ end
123
+ Rect.new([anchor.left, screen_size.width - width].min.clamp(0, nil), top, width, height)
124
+ end
125
+
126
+ # Against the anchor's right edge, flipped to its left when the right
127
+ # has no room; the first row lines up with the anchor, sliding up only
128
+ # far enough to stay on screen.
129
+ # @param anchor [Rect]
130
+ # @param rows [Integer]
131
+ # @param width [Integer]
132
+ # @param screen_size [Size]
133
+ # @return [Rect]
134
+ def beside(anchor, rows, width, screen_size)
135
+ height = [rows, max_rows, screen_size.height].min
136
+ right = anchor.left + anchor.width
137
+ left = if right + width <= screen_size.width || (anchor.left - width).negative?
138
+ right
139
+ else
140
+ anchor.left - width
141
+ end
142
+ left = left.clamp(0, [screen_size.width - width, 0].max)
143
+ top = [anchor.top, screen_size.height - height].min.clamp(0, nil)
144
+ Rect.new(left, top, width, height)
145
+ end
146
+ end
147
+
67
148
  def initialize
68
149
  @list = Menu.new
69
150
  @list.cursor = List::Cursor.new
70
151
  @list.show_cursor_when_inactive = true # highlight the selection though focus stays on the driver
152
+ @list.scrollbar_visibility = :auto # the list fills the panel, so the bar is on exactly when the rows outrun it
71
153
  super(content: @list)
72
154
  self.bg_color = Theme.ref(:input_bg_color)
73
155
  end
74
156
 
75
157
  # @param items [Array] the items to show, one row each; see {List#items=}.
158
+ # An open dropdown resizes to the new count on the next settle.
76
159
  # @return [void]
77
160
  def items=(items)
78
161
  @list.items = items
162
+ reposition
79
163
  end
80
164
 
81
165
  # @return [Array] the items currently shown.
82
166
  def items = @list.items
83
167
 
84
- # @param proc [Proc, Method] item -> row; see {List#renderer}.
168
+ # @param proc [Proc, Method] `(item, text_width) -> row`; see {List#renderer}.
85
169
  # @return [void]
86
170
  def renderer=(proc)
87
171
  @list.renderer = proc
88
172
  end
89
173
 
90
- # @param proc [Proc, Method, nil] commit callback; see {List#on_item_chosen}.
91
- # @return [void]
92
- def on_item_chosen=(proc)
93
- @list.on_item_chosen = proc
94
- end
95
-
96
- # @param proc [Proc, Method, nil] highlight-moved callback; see
97
- # {List#on_cursor_changed}. A cascading driver needs it to drop the
98
- # panels that belonged to the row the highlight just left.
99
- # @return [void]
100
- def on_cursor_changed=(proc)
101
- @list.on_cursor_changed = proc
102
- end
174
+ # The wrapped list, exposed so a driver can register on {List#on_item_chosen}
175
+ # (its commit) and {List#on_cursor_changed} (a cascading driver drops the
176
+ # panels belonging to the row the highlight just left). A driver tunes it
177
+ # but never supplies it (`D_has_content`).
178
+ # @return [List]
179
+ attr_reader :list
103
180
 
104
181
  # @param cursor [List::Cursor] the highlight; see {List#cursor=}.
105
182
  # @return [void]
@@ -117,13 +194,14 @@ module Tuile
117
194
  # @return [Boolean] whether the highlight moved there.
118
195
  def select(index) = @list.select(index)
119
196
 
120
- # Sizes and places the dropdown against `anchor`: directly beneath it,
121
- # flipped above when `rows` won't fit below, clamped — with the list
122
- # scrolling — when neither side has room. Horizontally the left edges line
123
- # up, sliding left only far enough to keep the panel on screen.
197
+ # Opens the dropdown against `anchor` — directly beneath it, flipped above
198
+ # when its rows won't fit below, clamped (with the list scrolling) when
199
+ # neither side has room — or moves it there if it is open already.
200
+ # Horizontally the left edges line up, sliding left only far enough to
201
+ # keep the panel on screen.
124
202
  #
125
- # drop.anchor_to(field.rect, rows: matches.size) # field width
126
- # drop.anchor_to(rect, rows: items.size, width: measured) # own width
203
+ # drop.anchor_to(self) # follows the field, its width
204
+ # drop.anchor_to(self, width: method(:menu_width))
127
205
  #
128
206
  # Vertical flips but horizontal slides because covering the driver would
129
207
  # hide what is being chosen, while sharing its columns is the point.
@@ -131,49 +209,34 @@ module Tuile
131
209
  # **`anchor` is the region actually occupied, and may be taller than one
132
210
  # row** — "beneath" means the row *after* it, so a multi-row driver (a
133
211
  # {Component::TextArea} carrying an autocomplete menu) is cleared entirely
134
- # rather than overdrawn from its second row down. A widget that paints one
135
- # row but may be *assigned* more height passes its face, not its rect:
136
- # {ComboBox} and {Select} both do, since a {Window} content slot hands them
137
- # the full inner height.
212
+ # rather than overdrawn from its second row down. A component anchor is
213
+ # read through its {Component#absolute_extent_rect}, so a widget that
214
+ # paints one row but is *assigned* more height ({ComboBox}, {Select})
215
+ # hangs the panel off its face.
216
+ #
217
+ # The height is the item count, capped at `max_rows`, and follows
218
+ # {#items=}. Settles before it returns, so a driver can forward a key to
219
+ # the list, or read {#cursor_row_rect}, in the same handler.
138
220
  #
139
- # @param anchor [Rect] the region the driver occupies, of any height; the
140
- # dropdown never covers it.
141
- # @param rows [Integer] how many rows there are to show — the content
142
- # count, not the height: more than fits turns the scrollbar on. `0`
143
- # collapses the dropdown to an empty rect (drivers close instead).
144
- # @param width [Integer] the panel's width in columns, clamped to the
145
- # screen. Defaults to the anchor's, which lines both edges up with a
146
- # field; a driver that measured its labels passes its own. A label wider
147
- # than the screen clips — {List} has no horizontal scrolling.
221
+ # @param anchor [Component, Rect] the driver, followed wherever it moves,
222
+ # or a fixed region in screen coordinates. Of any height; the dropdown
223
+ # never covers it.
224
+ # @param width [Integer, #call, nil] the panel's width in columns, clamped
225
+ # to the screen, or something answering it; `nil` (the default) takes
226
+ # the anchor's, which lines both edges up with a field. A driver that
227
+ # measures its labels passes its own. A label wider than the screen
228
+ # clips — {List} has no horizontal scrolling.
148
229
  # @param max_rows [Integer] rows shown before the list scrolls.
149
230
  # @return [void]
150
- def anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS)
151
- desired = [rows, max_rows].min
152
- beneath = anchor.top + anchor.height
153
- below = screen.size.height - beneath
154
- above = anchor.top
155
- if desired <= below
156
- top = beneath
157
- height = desired
158
- elsif above >= below
159
- height = [desired, above].min
160
- top = anchor.top - height
161
- else
162
- height = below
163
- top = beneath
164
- end
165
- width = [width, screen.size.width].min
166
- self.rect = Rect.new([anchor.left, screen.size.width - width].min.clamp(0, nil), top, width, height)
167
- # After the geometry: the setter rebuilds the list's padded rows against
168
- # the width it can see, and the gutter takes a column off it.
169
- @list.scrollbar_visibility = rows > height ? :visible : :gone
231
+ def anchor_to(anchor, width: nil, max_rows: MAX_VISIBLE_ROWS)
232
+ anchor_with(Anchored.new(anchor:, side: :below, width:, max_rows:))
170
233
  end
171
234
 
172
- # Sizes and places the dropdown *beside* `anchor` — the placement a
173
- # cascading submenu wants, where {#anchor_to} is the placement a field's
174
- # dropdown wants.
235
+ # Opens the dropdown *beside* `anchor` — the placement a cascading submenu
236
+ # wants, where {#anchor_to} is the placement a field's dropdown wants —
237
+ # or moves it there if it is open already.
175
238
  #
176
- # sub.anchor_beside(parent.cursor_row_rect, rows: kids.size, width: measured)
239
+ # sub.anchor_beside(parent.cursor_row_rect, width: measured)
177
240
  #
178
241
  # Horizontally it sits against `anchor`'s right edge, **flipping** to its
179
242
  # left when the right has no room (and clamping to the screen when neither
@@ -186,38 +249,22 @@ module Tuile
186
249
  # submenu must not cover its parent panel, so it flips *horizontally* and
187
250
  # shares its rows.
188
251
  #
189
- # @param anchor [Rect] the row the submenu belongs to — typically the
190
- # parent dropdown's {#cursor_row_rect}. Its width is the parent panel's,
191
- # which is what the submenu clears.
192
- # @param rows [Integer] how many rows there are to show — the content
193
- # count, not the height; more than fits turns the scrollbar on. `0`
194
- # collapses the dropdown to an empty rect (drivers close instead).
195
- # @param width [Integer] the panel's width in columns, clamped to the
196
- # screen. **Required, with no default:** `anchor.width` is the *parent's*
197
- # width and would be meaningless here, so the caller measures (see
252
+ # @param anchor [Rect, Component] the row the submenu belongs to, in screen
253
+ # coordinates — typically the parent dropdown's {#cursor_row_rect}.
254
+ # @param width [Integer, #call] the panel's width in columns, clamped to
255
+ # the screen. **Required, with no default:** the anchor's width is the
256
+ # *parent's* and would be meaningless here, so the caller measures (see
198
257
  # `design/decisions.md` `D_select` on why the width policy stays with the
199
258
  # driver).
200
259
  # @param max_rows [Integer] rows shown before the list scrolls.
201
260
  # @return [void]
202
- def anchor_beside(anchor, rows:, width:, max_rows: MAX_VISIBLE_ROWS)
203
- height = [rows, max_rows, screen.size.height].min
204
- width = [width, screen.size.width].min
205
- right = anchor.left + anchor.width
206
- left = if right + width <= screen.size.width || (anchor.left - width).negative?
207
- right
208
- else
209
- anchor.left - width
210
- end
211
- left = left.clamp(0, [screen.size.width - width, 0].max)
212
- top = [anchor.top, screen.size.height - height].min.clamp(0, nil)
213
- self.rect = Rect.new(left, top, width, height)
214
- # After the geometry, as in {#anchor_to}: the setter rebuilds the list's
215
- # padded rows against the width it can see.
216
- @list.scrollbar_visibility = rows > height ? :visible : :gone
261
+ def anchor_beside(anchor, width:, max_rows: MAX_VISIBLE_ROWS)
262
+ anchor_with(Anchored.new(anchor:, side: :beside, width:, max_rows:))
217
263
  end
218
264
 
219
- # The highlighted row's rect on screen — what a cascading submenu anchors
220
- # against, via {#anchor_beside}.
265
+ # The highlighted row's rect **on screen** — what a cascading submenu
266
+ # anchors against, via {#anchor_beside}. Screen coordinates because the
267
+ # submenu is a sibling overlay rather than a child (`D_relative_rect`).
221
268
  #
222
269
  # It lives here rather than in the driver because {ListDropdown} owns the
223
270
  # list's geometry: a driver computing `top + position - scroll_top_row`
@@ -232,7 +279,7 @@ module Tuile
232
279
  row = @list.cursor.position - @list.scroll_top_row
233
280
  return nil unless row.between?(0, @list.rect.height - 1)
234
281
 
235
- Rect.new(@list.rect.left, @list.rect.top + row, @list.rect.width, 1)
282
+ Rect.new(0, 0, @list.rect.width, 1).at(@list.to_screen(Point.new(0, row)))
236
283
  end
237
284
 
238
285
  # Forwards a cursor-movement key to the list. The driver calls this from
@@ -254,6 +301,19 @@ module Tuile
254
301
  # @return [Boolean] true iff a row was chosen (false when the cursor is
255
302
  # off-content).
256
303
  def choose = @list.handle_key?(Keys::ENTER)
304
+
305
+ private
306
+
307
+ # Opens or moves the panel and settles the pass, because both anchor
308
+ # methods promise a panel that *is* placed: a driver reads
309
+ # {#cursor_row_rect} or forwards a key to the list in the same handler,
310
+ # and both measure rects this just assigned.
311
+ # @param placement [Anchored]
312
+ # @return [void]
313
+ def anchor_with(placement)
314
+ open? ? self.placement = placement : self.open(placement)
315
+ flush_layout
316
+ end
257
317
  end
258
318
  end
259
319
  end
@@ -8,9 +8,14 @@ module Tuile
8
8
  # machinery of {MenuBar}; an app never names it.
9
9
  #
10
10
  # cascade.open_below(segment_rect, item) # Enter/Down on the strip
11
+ # cascade.step_to(segment_rect, item) # LEFT/RIGHT along the strip
11
12
  # return true if cascade.handle_key?(key) # MenuBar#handle_key?, first
12
13
  # cascade.close # focus lost, or rect changed
13
14
  #
15
+ # It also holds the bar's *menu mode* ({#browsing?}), which outlives the
16
+ # panels: stepping onto a top-level item with no menu shows nothing and
17
+ # stays in the mode, so the next step opens its neighbour's menu again.
18
+ #
14
19
  # A panel is a **non-modal overlay, not a child**, so it never takes focus:
15
20
  # focus stays on the {MenuBar} for the whole interaction and every key
16
21
  # arrives via {MenuBar#handle_key?}, which offers it here first. That is
@@ -24,9 +29,10 @@ module Tuile
24
29
  # == Implementation details
25
30
  # While open it consumes **everything** except the two keys that mean
26
31
  # "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.
32
+ # depth 1, and RIGHT with no submenu under the highlight — a panel opened
33
+ # with `highlight: false` has none at all, so it declines RIGHT throughout.
34
+ # An open menu is quasi-modal — firing an app's `s`-to-save behind a
35
+ # visible panel is worse than a dead keystroke.
30
36
  #
31
37
  # UI-thread-confined, like everything in the tree (see {Screen}).
32
38
  class Cascade
@@ -44,29 +50,50 @@ module Tuile
44
50
 
45
51
  def initialize
46
52
  @levels = []
53
+ @browsing = false
47
54
  end
48
55
 
49
56
  # @return [Boolean] whether any panel is open.
50
57
  def open? = !@levels.empty?
51
58
 
59
+ # @return [Boolean] whether the bar is in *menu mode* — which is not
60
+ # {#open?}: stepping onto a top-level item with no menu keeps the mode
61
+ # with no panel to show for it. Cleared with the last panel, however
62
+ # it went.
63
+ def browsing? = @browsing
64
+
52
65
  # @return [Integer] how many panels are open; `0` when closed.
53
66
  def depth = @levels.size
54
67
 
55
- # Opens `item`'s children directly beneath `anchor`, closing anything
56
- # already open first.
68
+ # Opens `item`'s children directly beneath `anchor`, first row
69
+ # highlighted, closing anything already open first.
57
70
  # @param anchor [Rect] the strip segment the menu drops from.
58
- # @param item [Item] a childless one opens nothing.
71
+ # @param item [Item] a childless one opens nothing and leaves menu mode
72
+ # off: Enter on a top-level button is not menu navigation.
59
73
  # @return [void]
60
- def open_below(anchor, item)
61
- close
62
- return unless item.submenu?
74
+ def open_below(anchor, item) = show(anchor, item, highlight: true)
63
75
 
64
- push(item) { |drop, rows, width| drop.anchor_to(anchor, rows: rows, width: width) }
76
+ # Shows `item`'s children beneath `anchor` with no row highlighted — the
77
+ # sideways step along the strip. Menu mode survives a childless `item`,
78
+ # which is the whole difference from {#open_below}: walking past a
79
+ # top-level button must not end the walk.
80
+ # @param anchor [Rect] the strip segment the menu drops from.
81
+ # @param item [Item]
82
+ # @return [void]
83
+ def step_to(anchor, item)
84
+ show(anchor, item, highlight: false)
85
+ @browsing = true
65
86
  end
66
87
 
67
- # Closes every open panel, deepest first.
88
+ # Closes every open panel, deepest first, and leaves menu mode.
89
+ #
90
+ # The flag earns its own line: menu mode outlives the panels, so at the
91
+ # panel-less stop `truncate` has nothing to close and nothing to notify.
68
92
  # @return [void]
69
- def close = truncate(0)
93
+ def close
94
+ truncate(0)
95
+ @browsing = false
96
+ end
70
97
 
71
98
  # Offers a key to the deepest panel and to the cascade's own verbs.
72
99
  # @param key [String]
@@ -75,6 +102,7 @@ module Tuile
75
102
  # (see the class docs).
76
103
  def handle_key?(key)
77
104
  return false unless open?
105
+ return true if enter_panel?(key)
78
106
  return true if deepest.move(key)
79
107
 
80
108
  case key
@@ -120,6 +148,41 @@ module Tuile
120
148
 
121
149
  private
122
150
 
151
+ # The body both entry verbs share: drop what is open, then mount a panel
152
+ # for `item`'s children if it has any.
153
+ # @param anchor [Rect]
154
+ # @param item [Item]
155
+ # @param highlight [Boolean] whether the panel's first row starts highlighted.
156
+ # @return [void]
157
+ def show(anchor, item, highlight:)
158
+ close
159
+ return unless item.submenu?
160
+
161
+ @browsing = true
162
+ push(item, highlight: highlight) { |drop, width| drop.anchor_to(anchor, width: width) }
163
+ end
164
+
165
+ # Moves into a panel opened with no highlighted row — Down, Enter and
166
+ # Space onto its first row, Up onto its last. Every other key leaves the
167
+ # empty highlight alone, RIGHT above all: it falls through to be
168
+ # declined, which is what lets a sideways step keep stepping.
169
+ #
170
+ # Up is answered here rather than by {ListDropdown#move}, which would
171
+ # clamp backwards onto the *first* row ({List::Cursor#go} floors at `0`).
172
+ # @param key [String]
173
+ # @return [Boolean] whether it moved in.
174
+ def enter_panel?(key)
175
+ level = depth - 1
176
+ return false unless highlighted(level).nil?
177
+
178
+ item, drop = @levels[level]
179
+ case key
180
+ when *Keys::DOWN_ARROWS, Keys::ENTER, " " then drop.select(0)
181
+ when *Keys::UP_ARROWS then drop.select(item.items.size - 1)
182
+ else false
183
+ end
184
+ end
185
+
123
186
  # @return [ListDropdown] the deepest open panel.
124
187
  def deepest = @levels.last[1]
125
188
 
@@ -144,7 +207,7 @@ module Tuile
144
207
  # doesn't paint it under a menu. A listener-less leaf still closes:
145
208
  # activation stays uniform.
146
209
  close
147
- item.on_click&.call
210
+ item.on_click.fire(Item::ClickEvent.new(source: item))
148
211
  end
149
212
  end
150
213
 
@@ -156,27 +219,29 @@ module Tuile
156
219
  anchor = @levels[level][1].cursor_row_rect
157
220
  return if anchor.nil?
158
221
 
159
- push(item) { |drop, rows, width| drop.anchor_beside(anchor, rows: rows, width: width) }
222
+ push(item) { |drop, width| drop.anchor_beside(anchor, width: width) }
160
223
  end
161
224
 
162
- # Mounts a panel for `item`'s children and yields it for geometry.
225
+ # Builds a panel for `item`'s children and yields it to be anchored,
226
+ # which is what opens it.
163
227
  # @param item [Item]
228
+ # @param highlight [Boolean] `false` parks the cursor off content, so
229
+ # nothing is highlighted.
164
230
  # @yieldparam drop [ListDropdown]
165
- # @yieldparam rows [Integer]
166
231
  # @yieldparam width [Integer]
167
232
  # @return [void]
168
- def push(item)
233
+ def push(item, highlight: true)
169
234
  children = item.items
170
235
  drop = ListDropdown.new
171
236
  drop.renderer = renderer_for(children)
172
237
  drop.items = children
173
- drop.cursor = List::Cursor.new
238
+ drop.cursor = List::Cursor.new(position: highlight ? 0 : -1)
174
239
  level = @levels.size
175
240
  # Wired *after* the items and cursor: {List#items=} and {List#cursor=}
176
241
  # both fire on_cursor_changed, so wiring first would have the fresh
177
242
  # 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) }
243
+ drop.list.on_item_chosen { |e| activate(level, e.item) }
244
+ drop.list.on_cursor_changed { truncate(level + 1) }
180
245
  # The cascade's own record of what is open is reconciled from the
181
246
  # popup's own closure, not maintained alongside it: an outside click
182
247
  # closes panels behind our back ({Overlay#close_on_outside_click?}), and
@@ -184,15 +249,19 @@ module Tuile
184
249
  # `deepest` and `highlighted` all lying. Identity-keyed and idempotent,
185
250
  # because the notice also arrives from `truncate` (which has already
186
251
  # popped the entry) and from teardown, in no guaranteed order.
187
- drop.on_close = -> { @levels.delete_if { |(_i, d)| d.equal?(drop) } }
252
+ # Menu mode ends with the last panel, whoever took it: this notice is
253
+ # what covers a dismissal {#close} never hears about.
254
+ drop.on_close do
255
+ @levels.delete_if { |(_i, d)| d.equal?(drop) }
256
+ @browsing = false if @levels.empty?
257
+ end
188
258
  # Chain each panel to the one it dropped out of, so a click on a
189
259
  # deeper panel is "inside" the shallower ones and doesn't dismiss
190
260
  # them. Level 0 owns nothing on purpose: a click on a dialog hosting
191
261
  # the bar *should* close the whole menu and keep the dialog.
192
262
  drop.owner = @levels.last&.last
193
263
  @levels << [item, drop]
194
- drop.open
195
- yield(drop, children.size, width_for(children))
264
+ yield(drop, width_for(children))
196
265
  end
197
266
 
198
267
  # Closes the deepest panel; at depth 1 that closes the cascade.
@@ -221,13 +290,14 @@ module Tuile
221
290
  # @param items [Array<Item>]
222
291
  # @return [Proc] item -> row: the label padded to the level's widest, plus
223
292
  # 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.
293
+ # in the same column. The width {List} offers a renderer is no use
294
+ # here: {#width_for} derives the panel's width from these same labels,
295
+ # so laying out against it would be circular.
225
296
  def renderer_for(items)
226
297
  label_width = label_width_of(items)
227
298
  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))
299
+ lambda do |item, _text_width|
300
+ row = item.cued_caption.ellipsize(label_width).ljust(label_width)
231
301
  next row unless arrows
232
302
 
233
303
  row + StyledString.plain(item.submenu? ? " #{SUBMENU_ARROW}" : " " * ARROW_WIDTH)