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
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A one-row field pairing a {DateField} and a {TimeField} behind a single
6
+ # `DateTime`. Give it a single-row {#rect}, 16 columns or wider:
7
+ #
8
+ # [2026-09-14] [13:45]
9
+ # ↑ the blank column is {Layout::Box#spacing}, not a component
10
+ #
11
+ # f = Component::DateTimeField.new
12
+ # f.on_value_change = ->(dt) { puts dt.inspect } # DateTime or nil, per commit
13
+ # f.value = DateTime.new(2026, 9, 14, 13, 45) # "2026-09-14" / "13:45"
14
+ # f.clear # empties both halves
15
+ #
16
+ # Neither half is labelled: each paints the hint derived from its own format
17
+ # (`yyyy-mm-dd`, `hh:mm`), which names it while it is empty — the moment
18
+ # naming matters. The *caption* ("Starts at") belongs to the layout around
19
+ # the field, as it does for every field (`design/decisions.md`
20
+ # `D_caption_ownership`).
21
+ #
22
+ # == Tune the halves; don't replace them
23
+ # They are exposed read-only, so everything they configure is reached
24
+ # directly rather than forwarded through a second set of names:
25
+ #
26
+ # f.date_field.formats = "%d.%m.%Y" # ambiguous if it were `f.formats=`
27
+ # f.date_field.calendar_start = Date::ITALY
28
+ # f.time_field.step = 900 # Up/Down walk a quarter hour
29
+ #
30
+ # Two of their knobs are **claimed** by this field and must not be
31
+ # reassigned: each half's {HasValue#on_value_change} (that is how the
32
+ # composite hears them) and each half's {Component#bg_color} (see the well
33
+ # rule below).
34
+ #
35
+ # == The value is a `DateTime` at +00:00
36
+ # Both halves feed it with no adapter, and the offset is a placeholder
37
+ # rather than a zone — {TimeField}'s epoch cost, taken the same way: a value
38
+ # that is visibly wrong where an instant was meant beats one that is subtly
39
+ # wrong. Combine it with a zone at your own boundary (`f.value&.to_time`).
40
+ #
41
+ # Lenient in, strict out, so an input carrying more than the halves can hold
42
+ # does not round-trip:
43
+ #
44
+ # f.value = Time.now # takes today's date and the wall clock
45
+ # f.value == DateTime.now # => false — the zone went, and the seconds with it
46
+ #
47
+ # == Three states, and only one of them is this field's own fault
48
+ # {HasValue#value} is non-nil **iff both halves parse**, so a half going bad
49
+ # nils the whole value ({HasBadInput}: a field holds bad input *or* a value,
50
+ # never both). Who reddens follows from whether the fault is attributable:
51
+ #
52
+ # date half time half value bad_input? red
53
+ # 2026-09-14 13:45 DateTime no nobody
54
+ # (empty) (empty) nil no — empty is not bad input nobody
55
+ # 2026-99-99 13:45 nil "not a valid date" the date half
56
+ # 2026-09-14 (empty) nil "needs both a date and a time" this field
57
+ #
58
+ # A half's bad input is the half's to paint, on its own latch, and this
59
+ # field paints nothing. Half-filled is nobody else's, so this field reddens
60
+ # whole — but **only while it is not active**: it judges you when you leave
61
+ # and goes quiet when you come back to fix it. A validator's verdict
62
+ # ({HasValidation#error_message=}) is by definition not attributable either,
63
+ # and reddens whole with no latch at all.
64
+ #
65
+ # The one cost: **ENTER does not redden this field**, where it reddens a
66
+ # half. A save gate on ENTER over a date with no time still reads
67
+ # {HasBadInput#bad_input?} true and gets the message; only the ink waits for
68
+ # the blur.
69
+ #
70
+ # == Implementation details
71
+ # - **The halves keep their own wells, and this field's ink is *synced* onto
72
+ # them.** `error_bg_color` sits at the top of the background chain, so a
73
+ # child answering {Component#default_bg_color} — every field does — never
74
+ # inherits an ancestor's error level, so marking only this field would
75
+ # leave the halves untouched and reach no cell at all. So the halves are
76
+ # marked {Component::BG_INHERIT} exactly while this field inks, and `nil`
77
+ # otherwise. A guilty half's *own* error well still beats the mark, which
78
+ # is what keeps the ink rule free of arithmetic.
79
+ # - **The spacing column is nobody's surface** — {Component#clear_inside_extent}
80
+ # blanks it in the ambient background, so the two wells read as two fields
81
+ # rather than one long one and each half keeps its own focus highlight.
82
+ # - **A half announces from its own `value=` and its Up/Down step** — the
83
+ # other half of {AbstractWrappingField#notify_on_edit?}'s contract — so
84
+ # writing a value into both halves would announce a half-assembled
85
+ # `DateTime`. Suppressed while applying, and announced once from this
86
+ # field's own diff.
87
+ # - **Nothing else is wired.** Focus forwards through {Layout#handle_focus},
88
+ # the mouse routes down through {Mouse::Router}, each half commits
89
+ # on its own blur (Tab between them canonicalizes the date and leaves this
90
+ # field active), and ENTER commits inside the half and keeps bubbling to
91
+ # the scope's default button.
92
+ #
93
+ # UI-thread-confined, like every component (see {Screen}).
94
+ class DateTimeField < Layout::Horizontal
95
+ include HasValue
96
+ include HasBadInput
97
+
98
+ # @return [String] what {HasBadInput#bad_input_message} reports when one
99
+ # half holds a value and the other is empty.
100
+ HALF_FILLED_MESSAGE = "needs both a date and a time"
101
+ private_constant :HALF_FILLED_MESSAGE
102
+
103
+ # What {#value=} needs off whatever it is handed — the two halves' own
104
+ # leniencies, checked together so a rejected value writes neither.
105
+ # @return [Array<Symbol>]
106
+ CIVIL_PARTS = %i[strftime hour min sec].freeze
107
+ private_constant :CIVIL_PARTS
108
+
109
+ # The content ratio, which decides this field's minimum width rather than
110
+ # merely its looks: `2026-09-14` is 10 columns and `13:45` is 5, so at 16
111
+ # the 2:1 split lands exactly 10 / 5. A constant rather than a measurement,
112
+ # so a locale spelling dates longer simply reaches its own minimum later
113
+ # (`design/decisions.md` `D_date_time_field`).
114
+ # @return [Integer]
115
+ DATE_WEIGHT = 2
116
+ private_constant :DATE_WEIGHT
117
+
118
+ # @return [Integer]
119
+ TIME_WEIGHT = 1
120
+ private_constant :TIME_WEIGHT
121
+
122
+ def initialize
123
+ super(spacing: 1)
124
+ @date_field = DateField.new
125
+ @time_field = TimeField.new
126
+ @last_value = empty_value
127
+ @applying = false
128
+ # cross: Fixed[1] is load-bearing — neither half declares an extent, so
129
+ # one handed a three-row rect paints a three-row well.
130
+ add(@date_field, Expand[DATE_WEIGHT], cross: Fixed[1])
131
+ add(@time_field, Expand[TIME_WEIGHT], cross: Fixed[1])
132
+ [@date_field, @time_field].each { _1.on_value_change = ->(_) { handle_half_change } }
133
+ end
134
+
135
+ # @return [DateField] the left half; tune it, never replace it.
136
+ attr_reader :date_field
137
+
138
+ # @return [TimeField] the right half; tune it, never replace it.
139
+ attr_reader :time_field
140
+
141
+ # @return [DateTime, nil] the two halves assembled, on the calendar
142
+ # {DateField#calendar_start} parsed the date in; `nil` unless both parse.
143
+ def value
144
+ date = date_field.value
145
+ time = time_field.value
146
+ return nil if date.nil? || time.nil?
147
+
148
+ DateTime.new(date.year, date.month, date.day, time.hour, time.min, time.sec, 0, date.start)
149
+ end
150
+
151
+ # Writes the date into one half and the time of day into the other, firing
152
+ # {HasValue#on_value_change} once if the value actually changed.
153
+ #
154
+ # @param new_value [DateTime, Time, nil] anything carrying both a civil
155
+ # date and a time of day; `nil` empties both halves.
156
+ # @return [void]
157
+ # @raise [TypeError] on a `Date` (it has no hour, and midnight would be
158
+ # invented) or anything else missing one of the two — checked before
159
+ # either half is written, so a rejected value leaves the field as it was.
160
+ def value=(new_value)
161
+ unless new_value.nil? || CIVIL_PARTS.all? { new_value.respond_to?(_1) }
162
+ raise TypeError,
163
+ "expected a date and time of day answering #{CIVIL_PARTS.join("/")}, got #{new_value.inspect}"
164
+ end
165
+
166
+ applying do
167
+ date_field.value = new_value
168
+ time_field.value = new_value
169
+ end
170
+ fire_if_changed
171
+ end
172
+
173
+ # `nil`, not a pair of nils: a field with no parseable date *and* time is
174
+ # empty.
175
+ # @return [nil]
176
+ def empty_value = nil
177
+
178
+ # Empties the *input* of both halves, not just the value — either may be
179
+ # holding glyphs no parse could use ({HasBadInput}).
180
+ # @return [void]
181
+ def clear
182
+ applying { [date_field, time_field].each(&:clear) }
183
+ # Announced even though the halves hold their own notice: emptying is
184
+ # not a half-typed prefix.
185
+ fire_if_changed
186
+ end
187
+
188
+ # The guilty half's own report, the date's first when both are bad; else
189
+ # the one fault no half can wear, a half-filled pair.
190
+ # @return [String, nil]
191
+ def bad_input_message
192
+ attributed = date_field.bad_input_message || time_field.bad_input_message
193
+ return attributed unless attributed.nil?
194
+
195
+ date_field.empty? ^ time_field.empty? ? HALF_FILLED_MESSAGE : nil
196
+ end
197
+
198
+ # Sets the verdict and syncs the halves' wells onto it.
199
+ # @param new_message [String, StyledString, nil]
200
+ # @return [void]
201
+ def error_message=(new_message)
202
+ super
203
+ sync_half_wells
204
+ end
205
+
206
+ # Syncs the halves' wells on both focus edges — this field inks its
207
+ # half-filled fault only once you have left it.
208
+ # @param flag [Boolean]
209
+ # @return [void]
210
+ def active=(flag)
211
+ was = active?
212
+ super
213
+ sync_half_wells unless was == active?
214
+ end
215
+
216
+ # @return [Size] the full width, one row — so a taller rect gets the
217
+ # ambient background rather than this field's well ({Component#extent}).
218
+ def extent = Size.new(rect.width, 1)
219
+
220
+ protected
221
+
222
+ # The ink rule in the class doc, as an expression.
223
+ #
224
+ # No latch ivar, deliberately: every input here is a fact something
225
+ # announces, which is what lets the well sync have a complete call list. A
226
+ # half's `bad_input?` moves with every keystroke and announces nothing at
227
+ # all by design, so a latch of this field's own could not follow it.
228
+ # @return [Boolean]
229
+ def bad_input_settled? = !attributable? && !active?
230
+
231
+ private
232
+
233
+ # @return [Boolean] whether a half is holding input its own value cannot
234
+ # represent, and so wears the error itself.
235
+ def attributable? = date_field.bad_input? || time_field.bad_input?
236
+
237
+ # One idempotent sync over one condition, this field the sole writer of
238
+ # its halves' {Component#bg_color} — the shape a hook-owned resource takes.
239
+ # Called from the three places {HasValidation#error_ink?} can change: a
240
+ # verdict, a focus edge, and a half's announcement. Leave one out and this
241
+ # field stops inking while its halves stay marked, i.e. both halves flat
242
+ # with their wells gone.
243
+ # @return [void]
244
+ def sync_half_wells
245
+ ink = error_ink?
246
+ [date_field, time_field].each { _1.bg_color = ink ? BG_INHERIT : nil }
247
+ end
248
+
249
+ # @return [void]
250
+ def handle_half_change
251
+ sync_half_wells
252
+ fire_if_changed unless @applying
253
+ end
254
+
255
+ # Runs `block` with the halves' notices suppressed, so a value written
256
+ # into both is announced once rather than half-assembled.
257
+ # @return [void]
258
+ def applying
259
+ @applying = true
260
+ yield
261
+ ensure
262
+ @applying = false
263
+ end
264
+
265
+ # @return [void]
266
+ def fire_if_changed
267
+ v = value
268
+ return if v == @last_value
269
+
270
+ @last_value = v
271
+ on_value_change&.call(v)
272
+ end
273
+ end
274
+ end
275
+ end
@@ -25,7 +25,7 @@ module Tuile
25
25
  #
26
26
  # == Implementation details
27
27
  # An includer overrides {#bad_input_message} and nothing else. Two rules
28
- # bind that override, and `DECISIONS.md` `D_bad_input` has the why:
28
+ # bind that override, and `design/decisions.md` `D_bad_input` has the why:
29
29
  #
30
30
  # - **Empty input is not bad input.** Return `nil` for an empty buffer even
31
31
  # though it parses to nothing, or every blank *optional* field blocks a
@@ -79,7 +79,7 @@ module Tuile
79
79
  # def bad_input_settled? = @settled # set on commit, cleared on an edit
80
80
  #
81
81
  # It gates the **ink only**: {#bad_input?} is a pull, and a save gate
82
- # asking at a click must get the answer settled or not (`DECISIONS.md`
82
+ # asking at a click must get the answer settled or not (`design/decisions.md`
83
83
  # `D_bad_input`).
84
84
  # @return [Boolean]
85
85
  def bad_input_settled? = true
@@ -62,7 +62,7 @@ module Tuile
62
62
 
63
63
  old = self.content
64
64
  # Detached without notifying, and notified at the very end: the focus
65
- # repair in on_child_removed cascades into whatever occupies the slot
65
+ # repair in handle_child_removed cascades into whatever occupies the slot
66
66
  # *now*, so it has to see the new content (window_spec pins it).
67
67
  detach_child(old) unless old.nil?
68
68
  @content = content
@@ -71,7 +71,7 @@ module Tuile
71
71
  content.invalidate
72
72
  layout(content)
73
73
  end
74
- on_child_removed(old) unless old.nil?
74
+ handle_child_removed(old) unless old.nil?
75
75
  end
76
76
 
77
77
  # @param rect [Rect]
@@ -82,7 +82,7 @@ module Tuile
82
82
  end
83
83
 
84
84
  # @return [void]
85
- def on_focus
85
+ def handle_focus
86
86
  super
87
87
  # Let the content component receive focus, so that it can immediately
88
88
  # start responding to key presses. Hidden content is left alone, so
@@ -21,7 +21,7 @@ module Tuile
21
21
  # Include it in a field whose *input shape* is unguessable from an empty
22
22
  # well. Not in {Select}, the near miss: a blank face plus `▾` already reads
23
23
  # as "nothing picked", so an absent enum *value* needs no hint the way an
24
- # unguessable input *format* does (`DECISIONS.md` `D_select`).
24
+ # unguessable input *format* does (`design/decisions.md` `D_select`).
25
25
  #
26
26
  # == Implementation details
27
27
  # The ink is {Theme#placeholder_color}, calibrated to be *barely* visible —
@@ -30,7 +30,7 @@ module Tuile
30
30
  # nothing — where red *text* is invisible on the empty field that is the
31
31
  # required-field case, and invisible again on content carrying colors of its
32
32
  # own. It takes two tokens rather than one because a focused invalid field
33
- # still has to look focused (`DECISIONS.md` `D_has_validation`).
33
+ # still has to look focused (`design/decisions.md` `D_has_validation`).
34
34
  #
35
35
  # The well reaches the whole widget with nothing forwarding it: a composed
36
36
  # field's inner face is marked {Component::BG_INHERIT} and a group's {List}
@@ -53,7 +53,7 @@ module Tuile
53
53
  #
54
54
  # Unlike `bad_input?`, this fact is *discrete* — asserted at a click or a
55
55
  # binder pass, not recomputed per keystroke — which is why it carries a
56
- # change notice where `bad_input?` deliberately doesn't (`DECISIONS.md`
56
+ # change notice where `bad_input?` deliberately doesn't (`design/decisions.md`
57
57
  # `D_bad_input`, `D_has_validation`).
58
58
  module HasValidation
59
59
  # @return [Proc, Method, nil] one-arg callable fired with the new message
@@ -68,7 +68,7 @@ module Tuile
68
68
  # Input fields are focusable by default (overrides {Component#focusable?});
69
69
  # a read-only display field could override back to `false`. Only
70
70
  # `focusable?` lives here — `tab_stop?` diverges between leaf fields and
71
- # composing wrappers, so it stays per-class (`DECISIONS.md`
71
+ # composing wrappers, so it stays per-class (`design/decisions.md`
72
72
  # `D_integer_field`).
73
73
  # @return [Boolean]
74
74
  def focusable? = true
@@ -60,7 +60,7 @@ module Tuile
60
60
  protected
61
61
 
62
62
  # @return [void]
63
- def on_width_changed
63
+ def handle_width_changed
64
64
  super
65
65
  update_rows
66
66
  end
@@ -190,7 +190,10 @@ module Tuile
190
190
  # {Component#visible=} buys over `remove` plus `add(…, at:)`.
191
191
  # @param _child [Component]
192
192
  # @return [void]
193
- def on_child_visibility_changed(_child) = relayout
193
+ def handle_child_visibility_changed(_child)
194
+ super
195
+ relayout
196
+ end
194
197
 
195
198
  private
196
199
 
@@ -173,7 +173,7 @@ module Tuile
173
173
  # the popup. Layouts don't paint any visible chrome of their own
174
174
  # (the auto-cleared background is just blank space), so this has no
175
175
  # mouse-routing consequences — clicks on a gap area land back on the
176
- # Layout itself and the on_focus cascade forwards to a tab stop.
176
+ # Layout itself and the handle_focus cascade forwards to a tab stop.
177
177
  def focusable? = true
178
178
 
179
179
  # Adds a child component to this layout.
@@ -198,7 +198,7 @@ module Tuile
198
198
  end
199
199
 
200
200
  # @return [void]
201
- def on_focus
201
+ def handle_focus
202
202
  super
203
203
  # Forward focus to the first interactive widget in the subtree so the
204
204
  # user can start typing / cursoring immediately. Prefer a {#tab_stop?}
@@ -210,7 +210,7 @@ module Tuile
210
210
  # Both halves skip hidden subtrees — this is the cascade that would
211
211
  # otherwise walk straight back into the pane just hidden.
212
212
  first_tab_stop = nil
213
- on_shown_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
213
+ walk_shown_tree { |c| first_tab_stop ||= c if !c.equal?(self) && c.tab_stop? }
214
214
  if first_tab_stop
215
215
  screen.focused = first_tab_stop
216
216
  else
@@ -6,7 +6,7 @@ module Tuile
6
6
  #
7
7
  # list = Component::List.new
8
8
  # list.items = people
9
- # list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.hint(" #{p.email}") }
9
+ # list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.fg(:muted, " #{p.email}") }
10
10
  # list.cursor = List::Cursor.new # a bare list has none
11
11
  # list.on_item_chosen = ->(index, person) { open(person) }
12
12
  #
@@ -249,7 +249,7 @@ module Tuile
249
249
 
250
250
  # @param key [String] a key.
251
251
  # @return [Boolean] true if the key was handled.
252
- def handle_key(key)
252
+ def handle_key?(key)
253
253
  if key == Keys::PAGE_UP
254
254
  move_scroll_top_row_by(-viewport_rows)
255
255
  true
@@ -259,7 +259,7 @@ module Tuile
259
259
  elsif key == Keys::ENTER && cursor_on_item?
260
260
  fire_item_chosen
261
261
  true
262
- elsif @cursor.handle_key(key, @items.size, viewport_rows)
262
+ elsif @cursor.handle_key?(key, @items.size, viewport_rows)
263
263
  move_viewport_to_cursor
264
264
  notify_cursor_changed
265
265
  invalidate
@@ -316,25 +316,35 @@ module Tuile
316
316
  true
317
317
  end
318
318
 
319
- # @param event [MouseEvent]
320
- # @return [void]
321
- def handle_mouse(event)
322
- super
323
- if event.button == :scroll_down
324
- move_scroll_top_row_by(4)
325
- elsif event.button == :scroll_up
326
- move_scroll_top_row_by(-4)
327
- else
328
- return unless rect.contains?(event.point)
319
+ # Moves the cursor to the pressed row and fires {#on_item_chosen}; what
320
+ # each {Cursor} does with a press is its own.
321
+ # @param event [Mouse::DownEvent]
322
+ # @return [Boolean]
323
+ def handle_mouse_down?(event)
324
+ return false unless event.button == :left
329
325
 
330
- item_index = event.y - rect.top + scroll_top_row
331
- if @cursor.handle_mouse(item_index, event, @items.size)
332
- move_viewport_to_cursor
333
- notify_cursor_changed
334
- invalidate
335
- end
336
- fire_item_chosen if event.button == :left && item_index >= 0 && item_index < @items.size && cursor_on_item?
326
+ item_index = event.y - rect.top + scroll_top_row
327
+ if @cursor.handle_mouse_down?(item_index, event, @items.size)
328
+ move_viewport_to_cursor
329
+ notify_cursor_changed
330
+ invalidate
337
331
  end
332
+ fire_item_chosen if item_index >= 0 && item_index < @items.size && cursor_on_item?
333
+ true
334
+ end
335
+
336
+ # Scrolls four rows a notch, and declines — so the notch bubbles to an
337
+ # ancestor scroller — once this list is at that end of its items.
338
+ # @param event [Mouse::ScrollEvent]
339
+ # @return [Boolean]
340
+ def handle_mouse_scroll?(event)
341
+ before = scroll_top_row
342
+ case event.direction
343
+ when :down then move_scroll_top_row_by(4)
344
+ when :up then move_scroll_top_row_by(-4)
345
+ else return false
346
+ end
347
+ scroll_top_row != before
338
348
  end
339
349
 
340
350
  # Paints the visible items into {#rect}, rendering the ones not already
@@ -379,15 +389,15 @@ module Tuile
379
389
  # @param _item_count [Integer]
380
390
  # @param _viewport_rows [Integer]
381
391
  # @return [Boolean]
382
- def handle_key(_key, _item_count, _viewport_rows)
392
+ def handle_key?(_key, _item_count, _viewport_rows)
383
393
  false
384
394
  end
385
395
 
386
396
  # @param _item_index [Integer]
387
- # @param _event [MouseEvent]
397
+ # @param _event [Mouse::DownEvent]
388
398
  # @param _item_count [Integer]
389
399
  # @return [Boolean]
390
- def handle_mouse(_item_index, _event, _item_count)
400
+ def handle_mouse_down?(_item_index, _event, _item_count)
391
401
  false
392
402
  end
393
403
 
@@ -422,7 +432,7 @@ module Tuile
422
432
  # @param item_count [Integer] number of items in the list.
423
433
  # @param viewport_rows [Integer] number of visible rows.
424
434
  # @return [Boolean] true if the cursor moved.
425
- def handle_key(key, item_count, viewport_rows)
435
+ def handle_key?(key, item_count, viewport_rows)
426
436
  case key
427
437
  when *Keys::DOWN_ARROWS
428
438
  go_down_by(1, item_count)
@@ -441,11 +451,11 @@ module Tuile
441
451
  end
442
452
  end
443
453
 
444
- # @param item_index [Integer] the item the cursor is hovering over.
445
- # @param event [MouseEvent] the event.
454
+ # @param item_index [Integer] the item pressed on.
455
+ # @param event [Mouse::DownEvent] the event.
446
456
  # @param item_count [Integer] number of items in the list.
447
- # @return [Boolean] true if the event was handled.
448
- def handle_mouse(item_index, event, item_count)
457
+ # @return [Boolean] true if the cursor moved.
458
+ def handle_mouse_down?(item_index, event, item_count)
449
459
  if event.button == :left
450
460
  go(item_index.clamp(nil, item_count - 1))
451
461
  else
@@ -507,10 +517,10 @@ module Tuile
507
517
  end
508
518
 
509
519
  # @param item_index [Integer]
510
- # @param event [MouseEvent]
520
+ # @param event [Mouse::DownEvent]
511
521
  # @param _item_count [Integer]
512
522
  # @return [Boolean]
513
- def handle_mouse(item_index, event, _item_count)
523
+ def handle_mouse_down?(item_index, event, _item_count)
514
524
  if event.button == :left
515
525
  prev_pos = @positions.reverse_each.find { _1 <= item_index }
516
526
  return go_to_first if prev_pos.nil?
@@ -571,7 +581,7 @@ module Tuile
571
581
  # was skipped because there was no viewport — re-run it now that there
572
582
  # is one, so the list snaps to the bottom on first paint.
573
583
  # @return [void]
574
- def on_width_changed
584
+ def handle_width_changed
575
585
  super
576
586
  drop_row_cache
577
587
  update_scroll_top_row_if_auto_scroll
@@ -738,7 +748,7 @@ module Tuile
738
748
  # negating the auto-scroll. Skipped when {#rect} is empty: without a
739
749
  # viewport the "items minus viewport" formula yields `@items.size`,
740
750
  # which would leave `scroll_top_row` past the last item once a real rect
741
- # arrives. {#on_width_changed} re-runs this hook when the rect grows so
751
+ # arrives. {#handle_width_changed} re-runs this hook when the rect grows so
742
752
  # the snap-to-bottom intent is preserved.
743
753
  #
744
754
  # Gated on {#following?}: once the user scrolls up off the bottom the
@@ -195,7 +195,7 @@ module Tuile
195
195
  # @param width [Integer] the panel's width in columns, clamped to the
196
196
  # screen. **Required, with no default:** `anchor.width` is the *parent's*
197
197
  # width and would be meaningless here, so the caller measures (see
198
- # `DECISIONS.md` `D_select` on why the width policy stays with the
198
+ # `design/decisions.md` `D_select` on why the width policy stays with the
199
199
  # driver).
200
200
  # @param max_rows [Integer] rows shown before the list scrolls.
201
201
  # @return [void]
@@ -244,7 +244,7 @@ module Tuile
244
244
  def move(key)
245
245
  return false unless open? && MOVE_KEYS.include?(key)
246
246
 
247
- @list.handle_key(key)
247
+ @list.handle_key?(key)
248
248
  true
249
249
  end
250
250
 
@@ -253,7 +253,7 @@ module Tuile
253
253
  # own Enter branch.
254
254
  # @return [Boolean] true iff a row was chosen (false when the cursor is
255
255
  # off-content).
256
- def choose = @list.handle_key(Keys::ENTER)
256
+ def choose = @list.handle_key?(Keys::ENTER)
257
257
  end
258
258
  end
259
259
  end
@@ -8,18 +8,18 @@ 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
- # return true if cascade.handle_key(key) # MenuBar#handle_key, first
11
+ # return true if cascade.handle_key?(key) # MenuBar#handle_key?, first
12
12
  # cascade.close # focus lost, or rect changed
13
13
  #
14
14
  # A panel is a **non-modal overlay, not a child**, so it never takes focus:
15
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
16
+ # arrives via {MenuBar#handle_key?}, which offers it here first. That is
17
17
  # {Component::Select}'s architecture extended to N levels, and it is why
18
18
  # nothing in the key-dispatch ladder changes.
19
19
  #
20
20
  # Widths are measured here, per level — the panel is as wide as the level's
21
21
  # widest label — because {ListDropdown} deliberately measures nothing
22
- # itself (`DECISIONS.md` `D_select`).
22
+ # itself (`design/decisions.md` `D_select`).
23
23
  #
24
24
  # == Implementation details
25
25
  # While open it consumes **everything** except the two keys that mean
@@ -73,7 +73,7 @@ module Tuile
73
73
  # @return [Boolean] `true` when consumed — almost always, while open.
74
74
  # `false` when closed, and for the two sideways keys {MenuBar} answers
75
75
  # (see the class docs).
76
- def handle_key(key)
76
+ def handle_key?(key)
77
77
  return false unless open?
78
78
  return true if deepest.move(key)
79
79
 
@@ -105,7 +105,7 @@ module Tuile
105
105
  # @param key [String] a single printable, already downcased.
106
106
  # @return [Boolean] whether an item on the deepest level claimed it. A
107
107
  # miss is never offered to a shallower level.
108
- def handle_mnemonic(key)
108
+ def handle_mnemonic?(key)
109
109
  return false unless open?
110
110
 
111
111
  level = depth - 1