tuile 0.15.0 → 0.16.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +121 -80
  3. data/README.md +28 -12
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +95 -26
  6. data/book/06-theming.md +58 -26
  7. data/book/07-components.md +61 -6
  8. data/book/08-testing.md +24 -22
  9. data/book/10-locale.md +2 -2
  10. data/book/README.md +6 -5
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +392 -18
  14. data/lib/tuile/component/abstract_string_field.rb +16 -18
  15. data/lib/tuile/component/abstract_wrapping_field.rb +47 -11
  16. data/lib/tuile/component/button.rb +8 -8
  17. data/lib/tuile/component/checkbox.rb +9 -9
  18. data/lib/tuile/component/checkbox_group.rb +6 -5
  19. data/lib/tuile/component/combo_box.rb +50 -35
  20. data/lib/tuile/component/confirm_window.rb +7 -5
  21. data/lib/tuile/component/date_field.rb +28 -3
  22. data/lib/tuile/component/date_time_field.rb +275 -0
  23. data/lib/tuile/component/has_bad_input.rb +2 -2
  24. data/lib/tuile/component/has_content.rb +3 -3
  25. data/lib/tuile/component/has_placeholder.rb +1 -1
  26. data/lib/tuile/component/has_validation.rb +2 -2
  27. data/lib/tuile/component/has_value.rb +1 -1
  28. data/lib/tuile/component/label.rb +1 -1
  29. data/lib/tuile/component/layout/box.rb +4 -1
  30. data/lib/tuile/component/layout.rb +3 -3
  31. data/lib/tuile/component/list.rb +42 -32
  32. data/lib/tuile/component/list_dropdown.rb +3 -3
  33. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  34. data/lib/tuile/component/menu_bar.rb +18 -18
  35. data/lib/tuile/component/notification.rb +32 -18
  36. data/lib/tuile/component/overlay.rb +9 -8
  37. data/lib/tuile/component/picker_window.rb +27 -8
  38. data/lib/tuile/component/popup.rb +2 -2
  39. data/lib/tuile/component/progress_bar.rb +10 -4
  40. data/lib/tuile/component/radio_group.rb +6 -5
  41. data/lib/tuile/component/select.rb +11 -12
  42. data/lib/tuile/component/slot.rb +3 -3
  43. data/lib/tuile/component/tab_sheet.rb +6 -6
  44. data/lib/tuile/component/tabs.rb +11 -11
  45. data/lib/tuile/component/text_area.rb +12 -10
  46. data/lib/tuile/component/text_field.rb +14 -12
  47. data/lib/tuile/component/text_view.rb +15 -11
  48. data/lib/tuile/component/time_field.rb +29 -4
  49. data/lib/tuile/component.rb +201 -93
  50. data/lib/tuile/event_queue.rb +4 -4
  51. data/lib/tuile/fake_event_queue.rb +1 -1
  52. data/lib/tuile/fake_screen.rb +84 -2
  53. data/lib/tuile/mouse/router.rb +217 -0
  54. data/lib/tuile/mouse.rb +177 -0
  55. data/lib/tuile/screen.rb +98 -61
  56. data/lib/tuile/screen_pane.rb +41 -36
  57. data/lib/tuile/styled_string.rb +5 -5
  58. data/lib/tuile/testing.rb +8 -8
  59. data/lib/tuile/theme.rb +22 -34
  60. data/lib/tuile/version.rb +1 -1
  61. data/lib/tuile/vertical_scroll_bar.rb +1 -1
  62. data/sig/tuile.rbs +1211 -427
  63. metadata +4 -16
  64. data/COMPARISON.md +0 -101
  65. data/DECISIONS.md +0 -8562
  66. data/TERMINOLOGY.md +0 -85
  67. data/ideas/arrow-key-navigation.md +0 -221
  68. data/ideas/binder.md +0 -177
  69. data/ideas/composite-field.md +0 -77
  70. data/ideas/focus-accent.md +0 -116
  71. data/ideas/form-layout.md +0 -151
  72. data/ideas/hover/probe.rb +0 -241
  73. data/ideas/hover/probe_spec.rb +0 -82
  74. data/ideas/hover.md +0 -909
  75. data/ideas/modal-backdrop.md +0 -24
  76. data/ideas/new-components.md +0 -144
  77. data/ideas/per-component-buffers.md +0 -55
  78. data/lib/tuile/mouse_event.rb +0 -68
@@ -42,7 +42,7 @@ module Tuile
42
42
  #
43
43
  # {Item} handles are minted by {#add_item} and nest via the *same* method, so
44
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`
45
+ # rebuilding: a menu is built once, at construction. See `design/decisions.md`
46
46
  # `D_menu_bar`.
47
47
  #
48
48
  # == Sizing
@@ -242,7 +242,7 @@ module Tuile
242
242
  #
243
243
  # Both the highlight and the click hit test use it, so a click on the blank
244
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
245
+ # nothing. It still *focuses*: {Mouse::Router}'s click-to-focus is
246
246
  # ungated by geometry.
247
247
  # @return [Size]
248
248
  def extent
@@ -275,7 +275,7 @@ module Tuile
275
275
  # panels on the pane — they are the {ScreenPane}'s children, not the bar's,
276
276
  # so nothing else would take them down.
277
277
  # @return [void]
278
- def on_detached
278
+ def handle_detached
279
279
  super
280
280
  @cascade.close
281
281
  end
@@ -288,11 +288,11 @@ module Tuile
288
288
  # submenu — and both step to the sibling menu.
289
289
  # @param key [String]
290
290
  # @return [Boolean]
291
- def handle_key(key)
291
+ def handle_key?(key)
292
292
  # Ahead of the cascade: an open one swallows every printable it doesn't
293
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)
294
+ return true if handle_mnemonic?(key)
295
+ return true if @cascade.handle_key?(key)
296
296
 
297
297
  if @cascade.open?
298
298
  case key
@@ -310,17 +310,16 @@ module Tuile
310
310
  end
311
311
  end
312
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
313
+ # Opens the menu under a left press, or closes it when it is already the
314
+ # open one; a press on the strip's padding claims the press and does
315
+ # nothing else.
316
+ # @param event [Mouse::DownEvent]
317
+ # @return [Boolean]
318
+ def handle_mouse_down?(event)
319
+ return false unless event.button == :left
321
320
 
322
321
  index = index_at(event.point)
323
- return if index.nil?
322
+ return true if index.nil?
324
323
 
325
324
  if @cascade.open? && index == @highlighted_index
326
325
  @cascade.close
@@ -328,6 +327,7 @@ module Tuile
328
327
  self.highlight = index
329
328
  open_highlighted
330
329
  end
330
+ true
331
331
  end
332
332
 
333
333
  # @return [void]
@@ -372,7 +372,7 @@ module Tuile
372
372
  # hook is the whole geometry story; {Component#rect=} invalidates for us,
373
373
  # and {#rect=} closes the cascade rather than re-anchoring it.
374
374
  # @return [void]
375
- def on_width_changed
375
+ def handle_width_changed
376
376
  super
377
377
  adjust_left_column
378
378
  end
@@ -512,11 +512,11 @@ module Tuile
512
512
  # not offered to any other level.
513
513
  # @param key [String]
514
514
  # @return [Boolean] whether a mnemonic claimed the key.
515
- def handle_mnemonic(key)
515
+ def handle_mnemonic?(key)
516
516
  return false unless Keys.printable?(key)
517
517
 
518
518
  down = key.downcase
519
- return @cascade.handle_mnemonic(down) if @cascade.open?
519
+ return @cascade.handle_mnemonic?(down) if @cascade.open?
520
520
 
521
521
  index = items.index { |item| item.mnemonic == down }
522
522
  return false if index.nil?
@@ -28,20 +28,20 @@ module Tuile
28
28
  # (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
29
29
  # shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
30
30
  # rows and is then ellipsized, and entries past the height cap wait unpainted.
31
- # `DECISIONS.md` `D_notification` has why each of those is what it is.
31
+ # `design/decisions.md` `D_notification` has why each of those is what it is.
32
32
  #
33
33
  # Three things it deliberately doesn't do:
34
34
  #
35
35
  # - **Take focus, or receive keys.** An {Overlay} sits off the
36
- # key-dispatch scope ({ScreenPane#handle_key}), so no key arrives here at
37
- # all. A left click *on the box* dismisses ({#handle_mouse}); an app
36
+ # key-dispatch scope ({ScreenPane#handle_key?}), so no key arrives here at
37
+ # all. A left click *on the box* dismisses ({#handle_mouse_down?}); an app
38
38
  # wanting a key registers a global shortcut and calls {Overlay#close}. A
39
39
  # click *elsewhere* does not — this is the one overlay with
40
40
  # {Overlay#close_on_outside_click?} false, since a toast is timed and an
41
41
  # unrelated click is not about it.
42
42
  # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
43
43
  # the message is added — a toast lives seconds, so there is no
44
- # {Component#on_theme_changed} rebuild.
44
+ # {Component#handle_theme_changed} rebuild.
45
45
  # - **Take a size.** An {Overlay} has no declared box; the messages decide
46
46
  # this one's, in {#reposition}.
47
47
  class Notification < Overlay
@@ -122,7 +122,7 @@ module Tuile
122
122
  @messages = []
123
123
  @high_water = 0
124
124
  @ticker = nil
125
- @view = TextView.new
125
+ @view = View.new
126
126
  @window = Window.new
127
127
  @window.content = @view
128
128
  super(content: @window, close_on_outside_click: false)
@@ -179,27 +179,41 @@ module Tuile
179
179
  self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
180
180
  end
181
181
 
182
- # A left click dismisses the whole box, every message with it. Other buttons
183
- # are consumed and inert — including the scroll wheel, which would otherwise
184
- # nuke the box on a stray spin.
185
- #
186
- # Deliberately *replaces* rather than augments: neither `super` nor
187
- # {HasContent#handle_mouse} may run, since both end at a
188
- # `screen.focused = …` inside this subtree (see {Overlay#focusable?}).
189
- # @param event [MouseEvent]
190
- # @return [void]
191
- def handle_mouse(event)
182
+ # A left press dismisses the whole box, every message with it. Every other
183
+ # press is claimed and inert, so nothing beneath the toast acts on it.
184
+ # @param event [Mouse::DownEvent]
185
+ # @return [Boolean]
186
+ def handle_mouse_down?(event)
192
187
  close if event.button == :left
188
+ true
189
+ end
190
+
191
+ # Claimed and inert: a stray wheel spin over the toast must neither nuke it
192
+ # nor reach whatever it covers.
193
+ # @param _event [Mouse::ScrollEvent]
194
+ # @return [Boolean]
195
+ def handle_mouse_scroll?(_event) = true
196
+
197
+ # The message rows, minus the wheel — the one thing a plain
198
+ # {Component::TextView} would add here. On a short terminal the queued
199
+ # messages are taller than the box, and the box is unfocusable, so
200
+ # scrolling them would be a capability the keyboard cannot reach
201
+ # (`D_mouse`); they are meant to *wait* until the ticker retires the ones
202
+ # above.
203
+ class View < TextView
204
+ # @param _event [Mouse::ScrollEvent]
205
+ # @return [Boolean] true — claimed on the box's behalf, and inert.
206
+ def handle_mouse_scroll?(_event) = true
193
207
  end
194
208
 
195
209
  # @return [void]
196
- def on_attached
210
+ def handle_attached
197
211
  super
198
212
  sync_ticker
199
213
  end
200
214
 
201
215
  # @return [void]
202
- def on_detached
216
+ def handle_detached
203
217
  super
204
218
  sync_ticker
205
219
  end
@@ -218,7 +232,7 @@ module Tuile
218
232
  # Syncs the retirement clock from the invariant "something to retire, and on
219
233
  # screen" — the sole writer of `@ticker`. Four sites change whether it is
220
234
  # wanted (append, a retirement that empties the queue, {#close}, detach),
221
- # which is the 2×2 a start-in-{#on_attached} / cancel-in-{#on_detached} pair
235
+ # which is the 2×2 a start-in-{#handle_attached} / cancel-in-{#handle_detached} pair
222
236
  # gets half wrong. The early return is also what keeps an append from
223
237
  # *restarting* the clock and extending the oldest message's life.
224
238
  # @return [void]
@@ -23,7 +23,7 @@ module Tuile
23
23
  # **{#focusable?} and {#modal?} move together — flip both or neither.** The
24
24
  # defaults here are inert (`false`, `false`): a bare overlay floats without
25
25
  # disturbing focus or key dispatch. {Component::Popup} flips both. What must
26
- # not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key}
26
+ # not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key?}
27
27
  # scopes delivery to the topmost modal popup or else the tiled content, so
28
28
  # such an overlay would hold focus outside the key scope, where delivery
29
29
  # reaches nobody and *every* keystroke goes dead until Tab recovers. A
@@ -85,9 +85,9 @@ module Tuile
85
85
  end
86
86
 
87
87
  # Whether a left click outside this overlay closes it (default true). The
88
- # pane does the closing — {ScreenPane#handle_mouse} snapshots the open
89
- # overlays *before* routing the click and closes the dismissable ones
90
- # *after*, so a widget that toggles its own overlay from a click on its
88
+ # pane does the closing — {ScreenPane#dismissing_popups_outside} snapshots
89
+ # the open overlays *before* the press is routed and closes the
90
+ # dismissable ones *after*, so a widget that toggles its own overlay from a click on its
91
91
  # face (a {Component::Select}, a {Component::MenuBar} title) still toggles
92
92
  # correctly: the delivered click closes the overlay and the dismissal then
93
93
  # no-ops on it, rather than closing and reopening it. Only `:left`
@@ -130,12 +130,12 @@ module Tuile
130
130
  # A callback taking no arguments, fired once this overlay has left the
131
131
  # screen — **however it left**: {#close}, a direct {Screen#remove_popup},
132
132
  # an outside click, or teardown via {Screen#close}. That unconditionality
133
- # is the point, so it hangs off {#on_detached} rather than {#close}; a
133
+ # is the point, so it hangs off {#handle_detached} rather than {#close}; a
134
134
  # driver keeping its own record of open overlays reconciles it here and
135
135
  # cannot drift ({Component::MenuBar::Cascade} is the worked example).
136
136
  #
137
137
  # It fires *after* the overlay is detached, so {#open?} is already false
138
- # and the usual {Component#on_detached} caveats apply: release state, don't
138
+ # and the usual {Component#handle_detached} caveats apply: release state, don't
139
139
  # inspect the tree, keep it trivial (it may run while the pane is mid-way
140
140
  # through closing a batch of overlays, and a raise propagates).
141
141
  # @return [Proc, nil]
@@ -161,7 +161,7 @@ module Tuile
161
161
  # overlay = Component::Overlay.new(content: label).open # construct and mount
162
162
  #
163
163
  # There is deliberately no class-level `Overlay.open` factory — see
164
- # `DECISIONS.md` `D_popup_open`; returning `self` is what keeps the
164
+ # `design/decisions.md` `D_popup_open`; returning `self` is what keeps the
165
165
  # one-liner above available without one.
166
166
  # @return [self]
167
167
  def open
@@ -191,7 +191,8 @@ module Tuile
191
191
  # Fires {#on_close}. A subclass overriding this **must** call `super`, or
192
192
  # the overlay's driver never hears that it closed.
193
193
  # @return [void]
194
- def on_detached
194
+ def handle_detached
195
+ super
195
196
  @on_close&.call
196
197
  end
197
198
 
@@ -3,11 +3,26 @@
3
3
  module Tuile
4
4
  class Component
5
5
  # A {Window} that lists options identified by single keyboard keys, asks
6
- # the user to pick one, and fires a callback with the picked key.
6
+ # the user to pick one, and fires a callback with the picked key. Each row
7
+ # is `"<key> <caption>"`:
8
+ #
9
+ # PickerWindow.open("Sort by", [%w[n name], %w[s size]]) { sort_by(_1) }
10
+ #
11
+ # ┌Sort by───────┐
12
+ # │n name │
13
+ # │s size │
14
+ # └──────────────┘
7
15
  #
8
16
  # Usable tiled (just add to a {Layout} and read picks via the block) or
9
17
  # as a popup via {.open}, which wraps it in a {Popup} that closes itself
10
18
  # after a pick. ESC / `q` close without firing the callback.
19
+ #
20
+ # Captions paint in the terminal's own foreground. To color them, hand in
21
+ # {StyledString} captions — the picker styles nothing itself, so an app's
22
+ # own token applies per option:
23
+ #
24
+ # PickerWindow.open("File", [["o", StyledString.plain("Open")],
25
+ # ["d", theme.fg(:danger, "Delete")]]) { … }
11
26
  class PickerWindow < Window
12
27
  # Scrolls the window when more items.
13
28
  # @return [Integer]
@@ -18,26 +33,30 @@ module Tuile
18
33
  # @!attribute [r] key
19
34
  # @return [String] the keyboard key that picks this option.
20
35
  # @!attribute [r] caption
21
- # @return [String] the option caption.
36
+ # @return [StyledString] the option caption, coerced from whatever the
37
+ # constructor was handed.
22
38
  class Option < Data.define(:key, :caption)
23
39
  end
24
40
 
25
41
  # @param caption [String] the window caption.
26
- # @param options [Array<Array(String, String)>] pairs of keyboard key and
27
- # option caption. No Rainbow formatting must be used.
42
+ # @param options [Array<Array(String, StyledString)>] pairs of
43
+ # keyboard key and option caption. A caption goes through
44
+ # {StyledString.parse}, so a plain String, an ANSI-coded one (what
45
+ # {Theme#fg} returns) and a {StyledString} are all accepted.
28
46
  # @yield [key] called with the option key once one is selected by the
29
47
  # user. Not called if the picker is dismissed without picking.
30
48
  # @yieldparam key [String] the picked option key.
31
49
  # @yieldreturn [void]
50
+ # @raise [ArgumentError] when `block` is missing or `options` is empty.
32
51
  def initialize(caption, options, &block)
33
52
  raise ArgumentError, "block required" unless block
34
53
  raise ArgumentError, "options must not be empty" if options.empty?
35
54
 
36
55
  super(caption)
37
- @options = options.map { Option.new(_1[0], _1[1]) }
56
+ @options = options.map { Option.new(_1[0], StyledString.parse(_1[1])) }
38
57
  @block = block
39
58
  list = Component::List.new
40
- list.renderer = ->(option) { "#{option.key} #{screen.theme.hint(option.caption)}" }
59
+ list.renderer = ->(option) { StyledString.plain("#{option.key} ") + option.caption }
41
60
  list.items = @options
42
61
  list.cursor = Component::List::Cursor.new
43
62
  list.on_item_chosen = ->(_index, option) { select_option(option.key) }
@@ -57,7 +76,7 @@ module Tuile
57
76
  # option's `key` picks that option.
58
77
  # @param key [String]
59
78
  # @return [Boolean]
60
- def handle_key(key)
79
+ def handle_key?(key)
61
80
  if @options.any? { _1.key == key }
62
81
  select_option(key)
63
82
  true
@@ -69,7 +88,7 @@ module Tuile
69
88
  # Opens a picker as a popup. Picking an option fires `block`, then
70
89
  # closes the popup; ESC / `q` close without firing `block`.
71
90
  # @param caption [String]
72
- # @param options [Array<Array(String, String)>]
91
+ # @param options [Array<Array(String, StyledString)>]
73
92
  # @yield [key]
74
93
  # @yieldparam key [String]
75
94
  # @yieldreturn [void]
@@ -28,7 +28,7 @@ module Tuile
28
28
  #
29
29
  # `q` and ESC close the popup — handled here, at the top of the popup's own
30
30
  # subtree, so the key only arrives after every component on the focus chain
31
- # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
31
+ # declined it (see {ScreenPane#handle_key?}). That's why typing `q` into a
32
32
  # nested {Component::TextField} doesn't dismiss the popup: the field consumes
33
33
  # it first.
34
34
  #
@@ -107,7 +107,7 @@ module Tuile
107
107
  # focused content after that content declined to handle it.
108
108
  # @param key [String]
109
109
  # @return [Boolean] true if the key was handled.
110
- def handle_key(key)
110
+ def handle_key?(key)
111
111
  if [Keys::ESC, "q"].include?(key)
112
112
  close
113
113
  true
@@ -38,7 +38,7 @@ module Tuile
38
38
  # The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
39
39
  # Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
40
40
  # the rendered length would vary with the fill level. Shipped anyway, per
41
- # `DECISIONS.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
41
+ # `design/decisions.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
42
42
  # beats a third convention, and if that bet is ever reversed both swap
43
43
  # together.
44
44
  class ProgressBar < Component
@@ -148,7 +148,7 @@ module Tuile
148
148
 
149
149
  # Sets the color of both glyphs, live-resolved at paint time when given a
150
150
  # {Theme::Ref} (so it follows a {Screen#theme=} with no
151
- # {Component#on_theme_changed} hook).
151
+ # {Component#handle_theme_changed} hook).
152
152
  #
153
153
  # bar.bar_color = Color::GREEN
154
154
  # bar.bar_color = Theme.ref(:brand_ok) # an app #custom token
@@ -192,10 +192,16 @@ module Tuile
192
192
  end
193
193
 
194
194
  # @return [void]
195
- def on_attached = sync_ticker
195
+ def handle_attached
196
+ super
197
+ sync_ticker
198
+ end
196
199
 
197
200
  # @return [void]
198
- def on_detached = sync_ticker
201
+ def handle_detached
202
+ super
203
+ sync_ticker
204
+ end
199
205
 
200
206
  # Paints the bar on the first row of {#rect} and blanks the rest.
201
207
  #
@@ -86,9 +86,10 @@ module Tuile
86
86
 
87
87
  # The composed {List}: an app may *tune* it — its scrollbar, its cursor,
88
88
  # `show_cursor_when_inactive` — but never replace it, since this group's
89
- # renderer and selection are wired into this one. Those knobs are {List}
90
- # concepts rather than group concepts, which is why they are reached here
91
- # instead of forwarded (`DECISIONS.md` `D_wrapping_field`).
89
+ # renderer and selection are wired into this one (`design/decisions.md`
90
+ # `D_has_content`). Those knobs are {List} concepts rather than group
91
+ # concepts, which is why they are reached here instead of forwarded
92
+ # (`D_wrapping_field`).
92
93
  # @return [List]
93
94
  attr_reader :list
94
95
 
@@ -100,7 +101,7 @@ module Tuile
100
101
  end
101
102
 
102
103
  # @return [void]
103
- def on_focus
104
+ def handle_focus
104
105
  super
105
106
  # The list is what the arrows drive, so it takes the focus this group
106
107
  # was given; the group itself claims only Space.
@@ -155,7 +156,7 @@ module Tuile
155
156
  # neither of us wants bubbles on to an ancestor.
156
157
  # @param key [String]
157
158
  # @return [Boolean]
158
- def handle_key(key)
159
+ def handle_key?(key)
159
160
  return false unless key == " "
160
161
 
161
162
  select_at(list.cursor.position)
@@ -42,7 +42,7 @@ module Tuile
42
42
  # Home/End are declined too, so they stay available app-wide.
43
43
  #
44
44
  # There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
45
- # the feedback removed (`DECISIONS.md` `D_select`). Which is also why labels
45
+ # the feedback removed (`design/decisions.md` `D_select`). Which is also why labels
46
46
  # need no prefix-disambiguation.
47
47
  #
48
48
  # == Implementation details
@@ -138,7 +138,7 @@ module Tuile
138
138
  # is left unhandled so it bubbles to an ancestor.
139
139
  # @param key [String]
140
140
  # @return [Boolean]
141
- def handle_key(key)
141
+ def handle_key?(key)
142
142
  if @overlay.open?
143
143
  return true if @overlay.move(key)
144
144
 
@@ -159,22 +159,21 @@ module Tuile
159
159
  # The one row this Select paints — the full width, at the top of {#rect}.
160
160
  # A single-slot container ({Component::Window}, {Component::Popup}) hands
161
161
  # its content the whole inner rect, so a Select is routinely assigned more
162
- # height than it uses; {#repaint} clears that tail, {#handle_mouse} refuses
163
- # clicks in it, and the dropdown hangs under this rather than under the
162
+ # height than it uses; {#repaint} clears that tail, a press in it never reaches
163
+ # {#handle_mouse_down?}, and the dropdown hangs under this rather than under the
164
164
  # unused space.
165
165
  # @return [Size]
166
166
  def extent = Size.new(rect.width, 1)
167
167
 
168
- # Toggles the dropdown on a left click anywhere in {#extent} — a field's
169
- # affordance is its whole row, as the well advertises; `super` runs first,
170
- # so the click also focuses.
171
- # @param event [MouseEvent]
172
- # @return [void]
173
- def handle_mouse(event)
174
- super
175
- return unless event.button == :left && extent_rect.contains?(event.point)
168
+ # Toggles the dropdown on a left press anywhere in {#extent} — a field's
169
+ # affordance is its whole row, as the well advertises.
170
+ # @param event [Mouse::DownEvent]
171
+ # @return [Boolean]
172
+ def handle_mouse_down?(event)
173
+ return false unless event.button == :left
176
174
 
177
175
  @overlay.open? ? close_menu : open_menu
176
+ true
178
177
  end
179
178
 
180
179
  # @return [void]
@@ -23,7 +23,7 @@ module Tuile
23
23
  # (what {Window} does with an absent footer); never detach it.
24
24
  #
25
25
  # Transparent to input: not {Component#focusable?},
26
- # {Component#handle_mouse} descends through it, and a departing occupant's
26
+ # the mouse routes straight through it, and a departing occupant's
27
27
  # focus repair is handed to the container.
28
28
  class Slot < Component
29
29
  include Component::HasContent
@@ -40,8 +40,8 @@ module Tuile
40
40
  # nothing to bubble from.
41
41
  # @param child [Component] the just-detached occupant.
42
42
  # @return [void]
43
- def on_child_removed(child)
44
- parent&.on_child_removed(child)
43
+ def handle_child_removed(child)
44
+ parent&.handle_child_removed(child)
45
45
  end
46
46
 
47
47
  protected
@@ -26,11 +26,11 @@ module Tuile
26
26
  # designing around:
27
27
  #
28
28
  # - A hidden pane is invisible to *everything*: the Tab cycle, focus
29
- # cascades, repaint, the cursor, `on_tree` walks. No gates anywhere.
29
+ # cascades, repaint, the cursor, `walk_tree` walks. No gates anywhere.
30
30
  # - Its state survives, because state is ivars — scroll position, caret,
31
31
  # list cursor, text are all exactly as the user left them, and mutating a
32
32
  # hidden pane is safe (`invalidate` while detached is a silent no-op).
33
- # - {Component#on_detached} / {Component#on_attached} fire on every switch,
33
+ # - {Component#handle_detached} / {Component#handle_attached} fire on every switch,
34
34
  # so a pane holding a mounted-lifetime resource — a {Component::ProgressBar}'s
35
35
  # ticker — releases it while hidden and re-acquires it on return. A pane
36
36
  # that must keep something alive while hidden can't; that something
@@ -40,7 +40,7 @@ module Tuile
40
40
  # `children` is `[strip, pane]`, the strip pinned at index 0, so pre-order
41
41
  # traversal gives the strip-then-pane Tab order for free. The swap follows
42
42
  # the slot-swap recipe {Component#detach_child} documents — detach, rewire,
43
- # `on_child_removed` last, so the focus repair sees the new occupant.
43
+ # `handle_child_removed` last, so the focus repair sees the new occupant.
44
44
  #
45
45
  # Panes live in an identity-keyed `Tab => Component` map here rather than in
46
46
  # a slot on {Tabs::Tab}: the strip's tab array stays the sole ordering
@@ -169,7 +169,7 @@ module Tuile
169
169
  # Sends focus to the strip: a sheet is a container, and the strip is where
170
170
  # a tab switch is driven from. The pane is a Tab press away.
171
171
  # @return [void]
172
- def on_focus
172
+ def handle_focus
173
173
  super
174
174
  screen.focused = @strip
175
175
  end
@@ -179,7 +179,7 @@ module Tuile
179
179
  # action was a tab switch.
180
180
  # @param child [Component]
181
181
  # @return [void]
182
- def on_child_removed(child)
182
+ def handle_child_removed(child)
183
183
  super
184
184
  screen.focused = @strip if attached? && screen.focused.equal?(self)
185
185
  end
@@ -205,7 +205,7 @@ module Tuile
205
205
  layout_pane
206
206
  end
207
207
  invalidate
208
- on_child_removed(old) unless old.nil?
208
+ handle_child_removed(old) unless old.nil?
209
209
  end
210
210
 
211
211
  # Drops entries whose tab is gone. {Tabs::Tab#remove} takes a tab off the
@@ -29,7 +29,7 @@ module Tuile
29
29
  #
30
30
  # {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
31
31
  # `items=`: a tab is identity plus its own state, so the set grows and
32
- # shrinks one tab at a time. See book ch7 and `DECISIONS.md` `D_tabs`.
32
+ # shrinks one tab at a time. See book ch7 and `design/decisions.md` `D_tabs`.
33
33
  #
34
34
  # == Sizing
35
35
  # Assign a {#rect} (typically from the surrounding {Layout}). One wider than
@@ -275,7 +275,7 @@ module Tuile
275
275
  #
276
276
  # Both the focus highlight and the click hit test use it, so a click on the
277
277
  # blank tail — or on a lower row, when the rect is taller than one —
278
- # selects nothing. It still *focuses*: {Component#handle_mouse}'s
278
+ # selects nothing. It still *focuses*: {Mouse::Router}'s
279
279
  # click-to-focus is ungated by geometry.
280
280
  # @return [Size]
281
281
  def extent
@@ -291,7 +291,7 @@ module Tuile
291
291
  # bubbles to an ancestor; an empty strip handles nothing at all.
292
292
  # @param key [String]
293
293
  # @return [Boolean]
294
- def handle_key(key)
294
+ def handle_key?(key)
295
295
  case key
296
296
  when Keys::LEFT_ARROW then select_previous
297
297
  when Keys::RIGHT_ARROW then select_next
@@ -299,16 +299,16 @@ module Tuile
299
299
  end
300
300
  end
301
301
 
302
- # Selects the tab under a left click; `super` runs first, so a click
303
- # anywhere in {#rect} still focuses.
304
- # @param event [MouseEvent]
305
- # @return [void]
306
- def handle_mouse(event)
307
- super
308
- return unless event.button == :left
302
+ # Selects the tab under a left press; a press between tabs selects
303
+ # nothing and still claims the strip.
304
+ # @param event [Mouse::DownEvent]
305
+ # @return [Boolean]
306
+ def handle_mouse_down?(event)
307
+ return false unless event.button == :left
309
308
 
310
309
  tab = tab_at(event.point)
311
310
  self.selected = tab if tab
311
+ true
312
312
  end
313
313
 
314
314
  # @return [void]
@@ -339,7 +339,7 @@ module Tuile
339
339
  # The rect's *width* is the only part of it the offset depends on, so this
340
340
  # hook is the whole geometry story; {Component#rect=} invalidates for us.
341
341
  # @return [void]
342
- def on_width_changed
342
+ def handle_width_changed
343
343
  super
344
344
  adjust_left_column
345
345
  end
@@ -31,7 +31,7 @@ module Tuile
31
31
  # class PromptArea < Component::TextArea
32
32
  # protected
33
33
  #
34
- # def handle_text_input_key(key)
34
+ # def handle_text_input_key?(key)
35
35
  # return recall_previous if key == Keys::UP_ARROW && caret_row.zero?
36
36
  # return recall_next if key == Keys::DOWN_ARROW && caret_row == row_count - 1
37
37
  #
@@ -87,11 +87,10 @@ module Tuile
87
87
  Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + row_in_viewport)
88
88
  end
89
89
 
90
- # @param event [MouseEvent]
91
- # @return [void]
92
- def handle_mouse(event)
93
- super
94
- return unless event.button == :left && rect.contains?(event.point)
90
+ # @param event [Mouse::DownEvent]
91
+ # @return [Boolean]
92
+ def handle_mouse_down?(event)
93
+ return false unless event.button == :left
95
94
 
96
95
  target_row = (event.y - rect.top) + @scroll_top_row
97
96
  self.caret = if target_row >= wrap.row_count
@@ -99,6 +98,7 @@ module Tuile
99
98
  else
100
99
  wrap.index_at(target_row, event.x - rect.left)
101
100
  end
101
+ true
102
102
  end
103
103
 
104
104
  # @return [void]
@@ -114,13 +114,15 @@ module Tuile
114
114
  protected
115
115
 
116
116
  # @return [void]
117
- def on_text_mutated
117
+ def handle_text_mutated
118
+ super
118
119
  @wrap = nil
119
120
  adjust_scroll_top_row
120
121
  end
121
122
 
122
123
  # @return [void]
123
- def on_caret_mutated
124
+ def handle_caret_mutated
125
+ super
124
126
  adjust_scroll_top_row
125
127
  end
126
128
 
@@ -128,7 +130,7 @@ module Tuile
128
130
  # the `\n` line — so CTRL+U kills back to exactly where HOME would go.
129
131
  # @param key [String]
130
132
  # @return [Boolean]
131
- def handle_text_input_key(key)
133
+ def handle_text_input_key?(key)
132
134
  case key
133
135
  when Keys::UP_ARROW then move_caret_vertical(-1)
134
136
  when Keys::DOWN_ARROW then move_caret_vertical(1)
@@ -147,7 +149,7 @@ module Tuile
147
149
  end
148
150
 
149
151
  # @return [void]
150
- def on_width_changed
152
+ def handle_width_changed
151
153
  super
152
154
  @wrap = nil
153
155
  adjust_scroll_top_row