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
@@ -61,6 +61,12 @@ module Tuile
61
61
  # default button still sees it; only an {#on_enter} of this field's own
62
62
  # consumes it, which is {TextField#on_enter}'s existing contract.
63
63
  #
64
+ # == When the value notice fires
65
+ # Per edit by default. A field whose grammar is not prefix-closed sets
66
+ # {#notify_on_edit?} to `false` and lets the notice settle onto those same
67
+ # two gestures, so a form is never handed a half-typed date that happens to
68
+ # parse ({DateField}, {TimeField}).
69
+ #
64
70
  # == Implementation details
65
71
  # - **{HasValue#value} and {#value=} raise until overridden.** The inherited
66
72
  # pair stores into `@value` and never touches the editor, so a subclass
@@ -71,7 +77,7 @@ module Tuile
71
77
  # - **The editor's `on_change` and `on_enter` slots are claimed** — for that
72
78
  # guard, and to commit before an app's ENTER handler runs. A slot cannot
73
79
  # be shared, so a subclass reacting to buffer edits overrides
74
- # {#on_editor_change} (every edit), {#value=} or {#commit} rather than
80
+ # {#handle_editor_change} (every edit), {#value=} or {#commit} rather than
75
81
  # reassigning either.
76
82
  # - **Not for a field whose editor is a *filter*.** This base assumes the
77
83
  # buffer is a rendering of the value, so an edit may change the value.
@@ -97,8 +103,8 @@ module Tuile
97
103
  # field's bg_color reaches the cells the editor paints.
98
104
  editor.bg_color = BG_INHERIT
99
105
  editor.on_change = lambda do |_text|
100
- on_editor_change
101
- fire_if_changed
106
+ handle_editor_change
107
+ fire_if_changed if notify_on_edit?
102
108
  end
103
109
  add_child(editor, at: 0)
104
110
  end
@@ -119,7 +125,13 @@ module Tuile
119
125
  # already reads {HasValue#empty_value}, so clearing through {#value=} could
120
126
  # leave the glyphs on screen ({HasBadInput}).
121
127
  # @return [void]
122
- def clear = editor.clear
128
+ def clear
129
+ editor.clear
130
+ # Announced here rather than through the editor's change, so a field
131
+ # holding its notice ({#notify_on_edit?}) still reports an emptying as
132
+ # it happens: emptying is not a half-typed prefix.
133
+ fire_if_changed
134
+ end
123
135
 
124
136
  # @return [String, nil] the hint the editor paints while empty
125
137
  # ({HasPlaceholder}).
@@ -142,9 +154,9 @@ module Tuile
142
154
  @on_enter = callback
143
155
  # Wrapped rather than forwarded, so an app's ENTER handler reads a
144
156
  # committed buffer. A nil callback leaves the editor's own slot nil,
145
- # which is what keeps ENTER *bubbling* — see {#handle_key}.
157
+ # which is what keeps ENTER *bubbling* — see {#handle_key?}.
146
158
  editor.on_enter = callback && lambda do
147
- commit
159
+ commit_and_notify
148
160
  callback.call
149
161
  end
150
162
  end
@@ -158,8 +170,8 @@ module Tuile
158
170
  # @param key [String]
159
171
  # @return [Boolean] whatever `super` returns — committing never consumes
160
172
  # the key.
161
- def handle_key(key)
162
- commit if key == Keys::ENTER
173
+ def handle_key?(key)
174
+ commit_and_notify if key == Keys::ENTER
163
175
  super
164
176
  end
165
177
 
@@ -175,11 +187,11 @@ module Tuile
175
187
  def active=(flag)
176
188
  was = active?
177
189
  super
178
- commit if was && !active?
190
+ commit_and_notify if was && !active?
179
191
  end
180
192
 
181
193
  # @return [void]
182
- def on_focus
194
+ def handle_focus
183
195
  super
184
196
  # The editor is what actually edits, so it takes the focus this field was
185
197
  # given — the field itself has no keys of its own.
@@ -204,6 +216,21 @@ module Tuile
204
216
  # @return [void]
205
217
  def commit = nil
206
218
 
219
+ # Whether an edit of the buffer fires {HasValue#on_value_change} as it
220
+ # happens. `true` here, which is right wherever every buffer state is a
221
+ # value the user might mean: an {IntegerField} passing through `4` on the
222
+ # way to `42` really does hold 4 for that keystroke. A field whose
223
+ # grammar is **not prefix-closed** answers `false` and lets the notice
224
+ # settle onto the commit gestures instead ({DateField}, `D_date_field`).
225
+ #
226
+ # Only the *push* settles: {HasValue#value} stays a live parse of the
227
+ # buffer either way. And overriding this is half the job — {#commit} is
228
+ # covered here, but the field must fire from its own `value=` too, or a
229
+ # programmatic write and an Up/Down step go unannounced until the next
230
+ # commit.
231
+ # @return [Boolean]
232
+ def notify_on_edit? = true
233
+
207
234
  # Called whenever the editor's buffer changes, however the characters
208
235
  # arrived — a typed key, a paste, or a {#value=} of this field's own. It
209
236
  # is named for the *editor*, not for the user, because those last two are
@@ -211,7 +238,7 @@ module Tuile
211
238
  # the *previous* buffer, as a field latching whether its input has settled
212
239
  # must ({HasBadInput}).
213
240
  # @return [void]
214
- def on_editor_change = nil
241
+ def handle_editor_change; end
215
242
 
216
243
  # Places the editor across the whole rect; override to reserve cells for a
217
244
  # face of your own.
@@ -227,6 +254,15 @@ module Tuile
227
254
 
228
255
  private
229
256
 
257
+ # Every commit gesture runs through here, so a field holding its notice
258
+ # ({#notify_on_edit?}) announces from one place rather than three; the
259
+ # diff guard makes the call free for a field that fired on the way in.
260
+ # @return [void]
261
+ def commit_and_notify
262
+ commit
263
+ fire_if_changed
264
+ end
265
+
230
266
  # Re-emits {HasValue#on_value_change}, but only when {#value} differs from
231
267
  # the last one fired — so a buffer edit that leaves the value alone
232
268
  # (`"7"`→`"07"`) stays silent.
@@ -9,7 +9,7 @@ module Tuile
9
9
  #
10
10
  # Buttons are tab stops — Tab and Shift+Tab will land on them as part of
11
11
  # the standard focus cycle. Click-to-focus also works via the inherited
12
- # {Component#handle_mouse}.
12
+ # {Component#handle_mouse_down?}.
13
13
  #
14
14
  # Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
15
15
  # show `[ caption ]` — that natural width is `caption.display_width + 4`.
@@ -38,7 +38,7 @@ module Tuile
38
38
 
39
39
  # @param key [String]
40
40
  # @return [Boolean]
41
- def handle_key(key)
41
+ def handle_key?(key)
42
42
  case key
43
43
  when Keys::ENTER, " "
44
44
  @on_click&.call
@@ -52,7 +52,7 @@ module Tuile
52
52
  # columns, clipped to {#rect}. Both the focus highlight and the click hit
53
53
  # test use it, so a click on the blank tail of an over-wide rect — or on a
54
54
  # lower row, when the rect is taller than one — does not fire {#on_click}.
55
- # It still *focuses*: {Component#handle_mouse}'s click-to-focus is ungated
55
+ # It still *focuses*: {Mouse::Router}'s click-to-focus is ungated
56
56
  # by geometry. Same rule as {Checkbox#extent}, which documents the two
57
57
  # traps behind it.
58
58
  # @return [Size]
@@ -60,13 +60,13 @@ module Tuile
60
60
 
61
61
  # Fires {#on_click} on a left click within {#extent}; `super` runs first, so
62
62
  # a click anywhere in {#rect} still focuses.
63
- # @param event [MouseEvent]
64
- # @return [void]
65
- def handle_mouse(event)
66
- super
67
- return unless event.button == :left && extent_rect.contains?(event.point)
63
+ # @param event [Mouse::DownEvent]
64
+ # @return [Boolean]
65
+ def handle_mouse_down?(event)
66
+ return false unless event.button == :left
68
67
 
69
68
  @on_click&.call
69
+ true
70
70
  end
71
71
 
72
72
  # @return [void]
@@ -89,7 +89,7 @@ module Tuile
89
89
  #
90
90
  # Both the focus highlight and the click hit test use it, so a click on the
91
91
  # blank tail — or on a lower row, when the rect is taller than one — does
92
- # not toggle. It still *focuses*: {Component#handle_mouse}'s click-to-focus
92
+ # not toggle. It still *focuses*: {Mouse::Router}'s click-to-focus
93
93
  # is ungated by geometry, and the tail is the field's own row.
94
94
  #
95
95
  # The extent ignores {Component#bg_color}: an inherited tint paints the dead
@@ -102,22 +102,22 @@ module Tuile
102
102
  # to an ancestor.
103
103
  # @param key [String]
104
104
  # @return [Boolean]
105
- def handle_key(key)
105
+ def handle_key?(key)
106
106
  return false unless [" ", Keys::ENTER].include?(key)
107
107
 
108
108
  toggle
109
109
  true
110
110
  end
111
111
 
112
- # Toggles on a left click within {#extent}; `super` runs first, so a click
113
- # anywhere in {#rect} still focuses.
114
- # @param event [MouseEvent]
115
- # @return [void]
116
- def handle_mouse(event)
117
- super
118
- return unless event.button == :left && extent_rect.contains?(event.point)
112
+ # Toggles on a left press; a press on the dead tail past {#extent} focuses
113
+ # the checkbox without reaching here.
114
+ # @param event [Mouse::DownEvent]
115
+ # @return [Boolean]
116
+ def handle_mouse_down?(event)
117
+ return false unless event.button == :left
119
118
 
120
119
  toggle
120
+ true
121
121
  end
122
122
 
123
123
  # @return [void]
@@ -83,9 +83,10 @@ module Tuile
83
83
 
84
84
  # The composed {List}: an app may *tune* it — its scrollbar, its cursor,
85
85
  # `show_cursor_when_inactive` — but never replace it, since this group's
86
- # renderer and selection are wired into this one. Those knobs are {List}
87
- # concepts rather than group concepts, which is why they are reached here
88
- # instead of forwarded (`DECISIONS.md` `D_wrapping_field`).
86
+ # renderer and selection are wired into this one (`design/decisions.md`
87
+ # `D_has_content`). Those knobs are {List} concepts rather than group
88
+ # concepts, which is why they are reached here instead of forwarded
89
+ # (`D_wrapping_field`).
89
90
  # @return [List]
90
91
  attr_reader :list
91
92
 
@@ -97,7 +98,7 @@ module Tuile
97
98
  end
98
99
 
99
100
  # @return [void]
100
- def on_focus
101
+ def handle_focus
101
102
  super
102
103
  # The list is what the arrows drive, so it takes the focus this group
103
104
  # was given; the group itself claims only Space.
@@ -152,7 +153,7 @@ module Tuile
152
153
  # neither of us wants bubbles on to an ancestor.
153
154
  # @param key [String]
154
155
  # @return [Boolean]
155
- def handle_key(key)
156
+ def handle_key?(key)
156
157
  return false unless key == " "
157
158
 
158
159
  toggle_at(list.cursor.position)
@@ -30,9 +30,16 @@ module Tuile
30
30
  # The dropdown is a {ListDropdown}, tinted to read as a floating panel; see
31
31
  # it for the theming knob.
32
32
  #
33
+ # == The inner field is private machinery
34
+ # It has no public accessor: its buffer is the *query*, so swapping the field
35
+ # would break the filtering. What is worth reaching is re-exposed here
36
+ # ({#placeholder}, {#cursor_position}); a **spec** reaches the field itself:
37
+ #
38
+ # field = Testing.get(Component::TextField, in: combo)
39
+ # field.text = "ap" # type a query without a real loop
40
+ #
33
41
  # UI-thread-confined, like every component (see {Screen}).
34
42
  class ComboBox < Component
35
- include HasContent
36
43
  include HasValue
37
44
  include HasPlaceholder
38
45
 
@@ -47,16 +54,16 @@ module Tuile
47
54
  @filtered = []
48
55
  @suppressing_filter = false
49
56
 
50
- field = TextField.new
57
+ @field = TextField.new
51
58
  # One widget, one surface: this field paints no well of its own, so the
52
59
  # composed field's own bg_color reaches the cells the field paints.
53
- field.bg_color = BG_INHERIT
54
- field.on_change = ->(_text) { refill unless @suppressing_filter }
60
+ @field.bg_color = BG_INHERIT
61
+ @field.on_change = ->(_text) { refill unless @suppressing_filter }
55
62
  # ESC is the one key this combo wants that the field consumes itself, so
56
- # it cannot arrive by bubbling the way {#handle_key}'s do. With no menu
63
+ # it cannot arrive by bubbling the way {#handle_key?}'s do. With no menu
57
64
  # open it keeps the field's own meaning: cancel text entry.
58
- field.on_escape = -> { @overlay.open? ? dismiss_menu : screen.focused = nil }
59
- self.content = field
65
+ @field.on_escape = -> { @overlay.open? ? dismiss_menu : screen.focused = nil }
66
+ add_child(@field, at: 0)
60
67
 
61
68
  @overlay = ListDropdown.new
62
69
  # Outside-click dismissal spans the owner chain, so a click on this
@@ -105,30 +112,39 @@ module Tuile
105
112
 
106
113
  # @return [Point, nil] the field's caret position (the combo delegates the
107
114
  # hardware cursor to its field).
108
- def cursor_position = content.cursor_position
115
+ def cursor_position = field.cursor_position
109
116
 
110
117
  # The hint the inner field paints while empty ({HasPlaceholder}) — for a
111
118
  # combo that means while nothing is selected *and* nothing is typed, so it
112
119
  # reads as a prompt for the query: `"type to filter"`.
113
120
  # @return [String, nil]
114
- def placeholder = content.placeholder
121
+ def placeholder = field.placeholder
115
122
 
116
123
  # @param text [String, nil]
117
124
  # @return [void]
118
125
  # @raise [TypeError] unless `text` is a String or nil.
119
126
  def placeholder=(text)
120
- content.placeholder = text
127
+ field.placeholder = text
121
128
  end
122
129
 
123
- # Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
124
- # field via {#layout}.
130
+ # Resizes the field and re-anchors the dropdown if it is open.
125
131
  # @param new_rect [Rect]
126
132
  # @return [void]
127
133
  def rect=(new_rect)
128
134
  super
135
+ # One row, or none at all when the combo itself was given none — a
136
+ # starved parent must not hand out a rect it doesn't own.
137
+ field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
129
138
  anchor if @overlay.open?
130
139
  end
131
140
 
141
+ # @return [void]
142
+ def handle_focus
143
+ super
144
+ # The field is what edits, so it takes the focus the combo was given.
145
+ screen.focused = field if field.focusable?
146
+ end
147
+
132
148
  # Closes the dropdown and reverts an uncommitted query when the combo
133
149
  # leaves the focus chain — so tabbing away doesn't strand an open menu or
134
150
  # a half-typed filter. Safe against re-entrancy: focus never sits inside
@@ -156,7 +172,7 @@ module Tuile
156
172
  # {AbstractStringField#on_escape} instead.
157
173
  # @param key [String]
158
174
  # @return [Boolean] true if consumed.
159
- def handle_key(key)
175
+ def handle_key?(key)
160
176
  if @overlay.open?
161
177
  return true if @overlay.move(key)
162
178
  return false unless key == Keys::ENTER
@@ -170,15 +186,16 @@ module Tuile
170
186
  true
171
187
  end
172
188
 
173
- # @param event [MouseEvent]
174
- # @return [void]
175
- def handle_mouse(event)
176
- if content.rect.contains?(event.point)
177
- content.handle_mouse(event)
178
- elsif event.button == :left && rect.contains?(event.point) # the ▾ cell
179
- content.focus
180
- @overlay.open? ? close_menu : open_menu
181
- end
189
+ # Toggles the dropdown on a left press on the ▾ cell — the only cell of
190
+ # this component's own that is not the field's.
191
+ # @param event [Mouse::DownEvent]
192
+ # @return [Boolean]
193
+ def handle_mouse_down?(event)
194
+ return false unless event.button == :left
195
+
196
+ field.focus
197
+ @overlay.open? ? close_menu : open_menu
198
+ true
182
199
  end
183
200
 
184
201
  # @return [void]
@@ -203,19 +220,17 @@ module Tuile
203
220
  # @return [Size]
204
221
  def extent = Size.new(rect.width, 1)
205
222
 
206
- protected
207
-
208
- # Field spans the row bar the last column, which the `▾` occupies
209
- # ({HasContent} layout hook). One row, or none at all when the combo itself
210
- # was given none — a starved parent must not hand out a rect it doesn't own.
211
- # @param field [Component]
223
+ # Declines the default's blank: `field` covers every column of the face but
224
+ # the last, and this combo paints the `▾` into that one, so blanking would
225
+ # only dirty a cell it is about to repaint (`D_progress_bar`).
212
226
  # @return [void]
213
- def layout(field)
214
- field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
215
- end
227
+ def clear_inside_extent = nil
216
228
 
217
229
  private
218
230
 
231
+ # @return [TextField] the inner field, holding the query.
232
+ attr_reader :field
233
+
219
234
  # Dismisses the dropdown and puts the current value's label back in the
220
235
  # field, undoing an uncommitted query.
221
236
  # @return [void]
@@ -229,7 +244,7 @@ module Tuile
229
244
  # when there are none.
230
245
  # @return [void]
231
246
  def refill
232
- @filtered = matching(content.text)
247
+ @filtered = matching(field.text)
233
248
  if @filtered.empty?
234
249
  close_menu
235
250
  else
@@ -273,7 +288,7 @@ module Tuile
273
288
  # Sets the field's text without triggering a refilter — for programmatic
274
289
  # value changes and query reverts, which must not spring the dropdown.
275
290
  # Every programmatic write to the field goes through here; a direct
276
- # `content.text =` reaches the field's `on_change` and pops the dropdown
291
+ # `field.text =` reaches the field's `on_change` and pops the dropdown
277
292
  # open on a {#value=} the user never asked to browse.
278
293
  # Parks the caret at the end: `text=` only *clamps* the caret, so a
279
294
  # shorter query replaced by a longer label would otherwise strand it
@@ -282,8 +297,8 @@ module Tuile
282
297
  # @return [void]
283
298
  def sync_field(text)
284
299
  @suppressing_filter = true
285
- content.text = text
286
- content.caret = content.text.length
300
+ field.text = text
301
+ field.caret = field.text.length
287
302
  ensure
288
303
  @suppressing_filter = false
289
304
  end
@@ -44,7 +44,7 @@ module Tuile
44
44
  # There is deliberately no content slot: the body is prose ({#message=}
45
45
  # takes a component for the rare rich body, but the dialog then cannot
46
46
  # measure it). A dialog collecting *input* is not a confirm dialog — build a
47
- # `Popup.new(content: your_layout)`. See `DECISIONS.md` `D_confirm_window`
47
+ # `Popup.new(content: your_layout)`. See `design/decisions.md` `D_confirm_window`
48
48
  # for the API rationale.
49
49
  class ConfirmWindow < Window
50
50
  # Keys handed to the message body from anywhere in the dialog, so it
@@ -221,9 +221,11 @@ module Tuile
221
221
  end
222
222
 
223
223
  # Focus lands on the first button rather than cascading into the message
224
- # body, which sits before the button row in the tree.
224
+ # body, which sits before the button row in the tree. `super` is reached
225
+ # only when there is no button to take it: {HasContent#handle_focus} *is*
226
+ # the cascade this override exists to skip.
225
227
  # @return [void]
226
- def on_focus
228
+ def handle_focus
227
229
  first = @actions.keys.first
228
230
  if first.nil?
229
231
  super
@@ -239,11 +241,11 @@ module Tuile
239
241
  # before this runs.
240
242
  # @param key [String]
241
243
  # @return [Boolean] true if the key was handled.
242
- def handle_key(key)
244
+ def handle_key?(key)
243
245
  case key
244
246
  when Keys::LEFT_ARROW then return focus_button_step(-1)
245
247
  when Keys::RIGHT_ARROW then return focus_button_step(1)
246
- when *BODY_SCROLL_KEYS then return @body_slot.content&.handle_key(key) || false
248
+ when *BODY_SCROLL_KEYS then return @body_slot.content&.handle_key?(key) || false
247
249
  end
248
250
 
249
251
  target = @mnemonics[key.downcase]
@@ -6,7 +6,7 @@ module Tuile
6
6
  # empty). Give it a single-row {#rect}:
7
7
  #
8
8
  # field = Component::DateField.new
9
- # field.on_value_change = ->(d) { puts d.inspect } # Date or nil, per change
9
+ # field.on_value_change = ->(d) { puts d.inspect } # Date or nil, per commit
10
10
  # field.value = Date.new(2026, 9, 4) # field shows "2026-09-04"
11
11
  # field.placeholder # => "yyyy-mm-dd"
12
12
  # field.clear # empties it; value => nil
@@ -63,6 +63,19 @@ module Tuile
63
63
  # `20`, `202` stay quiet, leaving the field (or pressing ENTER) reddens what
64
64
  # did not parse, and the next edit clears it again.
65
65
  #
66
+ # == The value notice waits for the same gesture
67
+ # A prefix of a date can also parse *cleanly*: typing `1.1.2024` into a
68
+ # `%d.%m.%Y` field passes through `1.1.2`, a perfectly good 1st of January
69
+ # in the year 2. So {HasValue#on_value_change} does not fire per keystroke,
70
+ # but when the user leaves the field or presses ENTER — and a form
71
+ # recalculating from it never sees that year 2.
72
+ #
73
+ # {#value} does *not* wait: it is a live parse of the buffer at every
74
+ # moment, so a save gate reached without leaving the field reads the date
75
+ # on screen. Nor does a change nobody had to type — a {#value=}, an Up/Down
76
+ # step, a {#clear} and a reparse under new {#formats} all fire as they
77
+ # happen.
78
+ #
66
79
  # == Implementation details
67
80
  # - **The buffer is the single source of truth.** {#value} is a parse of it,
68
81
  # recomputed on read — so {#formats=} and {#calendar_start=} can change the
@@ -135,6 +148,9 @@ module Tuile
135
148
  def value=(new_value)
136
149
  editor.text = new_value.nil? ? "" : new_value.strftime(formats.first)
137
150
  editor.caret = editor.text.length
151
+ # The edit above announced nothing ({#notify_on_edit?}); a date written
152
+ # rather than typed has no prefix to be mistaken for a value.
153
+ fire_if_changed
138
154
  end
139
155
 
140
156
  # `nil`, not `""`: a date field with no parseable date is empty.
@@ -244,6 +260,12 @@ module Tuile
244
260
  settle(true)
245
261
  end
246
262
 
263
+ # `false`: a prefix of a date can parse cleanly (`1.1.2` for `1.1.2024`),
264
+ # so the notice settles onto the commit gestures, exactly as the ink
265
+ # does. The class docs carry the case.
266
+ # @return [Boolean]
267
+ def notify_on_edit? = false
268
+
247
269
  # Every prefix of a date is bad input, so the well is latched to the
248
270
  # commit gestures instead of painted per keystroke: `2`, `20`, `202` on
249
271
  # the way to `2026-09-04` never redden, and a date the field cannot parse
@@ -255,12 +277,15 @@ module Tuile
255
277
  # An edit is the user having another go, so the well goes quiet again
256
278
  # until the next commit gesture.
257
279
  # @return [void]
258
- def on_editor_change = settle(false)
280
+ def handle_editor_change
281
+ super
282
+ settle(false)
283
+ end
259
284
 
260
285
  # Re-derives the hint (which was *pushed* into the editor, so a repaint
261
286
  # alone would keep the old one) and rewrites a buffer that still parses.
262
287
  # @return [void]
263
- def on_locale_changed
288
+ def handle_locale_changed
264
289
  super
265
290
  # Both overridden: this field follows no session convention.
266
291
  return if @formats && @calendar_start