tuile 0.14.0 → 0.15.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/DECISIONS.md +3181 -41
  4. data/README.md +25 -5
  5. data/TERMINOLOGY.md +17 -3
  6. data/book/05-focus.md +63 -2
  7. data/book/06-theming.md +55 -7
  8. data/book/07-components.md +474 -48
  9. data/book/08-testing.md +78 -0
  10. data/book/10-locale.md +216 -0
  11. data/book/README.md +14 -5
  12. data/examples/sampler.rb +265 -25
  13. data/ideas/binder.md +177 -0
  14. data/ideas/composite-field.md +77 -0
  15. data/ideas/focus-accent.md +116 -0
  16. data/ideas/form-layout.md +151 -0
  17. data/ideas/hover/probe.rb +241 -0
  18. data/ideas/hover/probe_spec.rb +82 -0
  19. data/ideas/hover.md +909 -0
  20. data/ideas/new-components.md +26 -6
  21. data/lib/tuile/component/abstract_string_field.rb +106 -58
  22. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  23. data/lib/tuile/component/big_decimal_field.rb +52 -79
  24. data/lib/tuile/component/checkbox_group.rb +36 -20
  25. data/lib/tuile/component/combo_box.rb +59 -31
  26. data/lib/tuile/component/date_field.rb +322 -0
  27. data/lib/tuile/component/float_field.rb +57 -82
  28. data/lib/tuile/component/has_bad_input.rb +88 -0
  29. data/lib/tuile/component/has_caption.rb +8 -0
  30. data/lib/tuile/component/has_content.rb +29 -10
  31. data/lib/tuile/component/has_placeholder.rb +62 -0
  32. data/lib/tuile/component/has_validation.rb +115 -0
  33. data/lib/tuile/component/has_value.rb +27 -0
  34. data/lib/tuile/component/integer_field.rb +51 -78
  35. data/lib/tuile/component/label.rb +6 -38
  36. data/lib/tuile/component/layout/box.rb +87 -19
  37. data/lib/tuile/component/layout.rb +13 -3
  38. data/lib/tuile/component/list.rb +11 -6
  39. data/lib/tuile/component/list_dropdown.rb +4 -0
  40. data/lib/tuile/component/overlay.rb +17 -0
  41. data/lib/tuile/component/radio_group.rb +39 -22
  42. data/lib/tuile/component/select.rb +12 -4
  43. data/lib/tuile/component/text_area.rb +14 -8
  44. data/lib/tuile/component/text_field.rb +42 -15
  45. data/lib/tuile/component/text_view.rb +25 -8
  46. data/lib/tuile/component/time_field.rb +454 -0
  47. data/lib/tuile/component/window.rb +26 -13
  48. data/lib/tuile/component.rb +469 -73
  49. data/lib/tuile/fake_screen.rb +11 -1
  50. data/lib/tuile/final.rb +75 -0
  51. data/lib/tuile/locale.rb +851 -0
  52. data/lib/tuile/screen.rb +131 -17
  53. data/lib/tuile/screen_pane.rb +13 -9
  54. data/lib/tuile/testing.rb +198 -0
  55. data/lib/tuile/theme.rb +100 -10
  56. data/lib/tuile/version.rb +1 -1
  57. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  58. data/lib/tuile.rb +1 -0
  59. data/sig/tuile.rbs +3398 -412
  60. metadata +18 -1
@@ -50,9 +50,10 @@ That leaves ~46 gaps.
50
50
  | ~~Notification~~ | `Popup` + `Ticker` | **built** 2026-08-17 (`D_notification`, book ch7); one non-modal top-right box, N messages, one 3 s ticker retiring the oldest. Corner anchor is its own `reposition` override, so `Popup` was untouched — and the `Popover` extraction still waits for a second *kind* of anchoring |
51
51
  | ~~Confirm Dialog~~ | `Popup`+`Window`+`Button` | **built** 2026-08-31 as `ConfirmWindow` (`D_confirm_window`, book ch7); the component is the builder — `#button` plus the `alert`/`confirm`/`yes_no` factories — every button dismisses, MenuBar-shaped mnemonics with `q`/`g`/`G` reserved. The fold-`PickerWindow`-in idea is **rejected**: the two disagree on every semantic that matters (cursor, default, ESC, close-on-pick) and share only API shape |
52
52
  | Details → Accordion | `HasContent` | Details is the atom, Accordion the group |
53
- | ~~Tabs → TabSheet~~ | plain `Component` + the tree API | **built** 2026-08-23 (`D_tabs`, book ch7); a strip (one tab stop, Left/Right, immediate activation) plus a sheet whose `children` are `[strip, pane]`. Neither is `HasValue` — a tab selection is view state — and neither is `HasContent`; hiding a pane means *detaching* it, since Tuile has no visibility flag; the strip owns mutable `Tabs::Tab` handles rather than the `items`/`item_label` shell. Hidden/disabled/closeable tabs, lazy panes and a scrolling strip are deferred, each additive (`D_tabs`) |
53
+ | ~~Tabs → TabSheet~~ | plain `Component` + the tree API | **built** 2026-08-23 (`D_tabs`, book ch7); a strip (one tab stop, Left/Right, immediate activation) plus a sheet whose `children` are `[strip, pane]`. Neither is `HasValue` — a tab selection is view state — and neither is `HasContent`; a `TabSheet` hides a pane by *detaching* it, deliberately — that is what fires the lifecycle hooks on every switch, and it stays the choice now that `Component#visible=` exists (`D_visibility`); the strip owns mutable `Tabs::Tab` handles rather than the `items`/`item_label` shell. Hidden/disabled/closeable tabs, lazy panes and a scrolling strip are deferred, each additive (`D_tabs`) |
54
54
  | Popover | `ListDropdown#anchor_to` (extracted 2026-08-12) | generalize the anchored non-modal overlay: `anchor_to` moves down to it and `ListDropdown` inherits it. Build it when the second *kind* of anchoring appears (a point; a right edge that flips) — not the second caller of the same kind. Nothing gates on it today: Menu Bar shipped without it |
55
55
  | ~~Menu Bar~~ | `ListDropdown` (+ `anchor_beside`) | **built** 2026-08-24 (`D_menu_bar`, book ch7), mnemonics the same day. Turned out *not* to need the Popover extraction: a focused strip drives a cascade of non-modal `ListDropdown`s the way `Select` drives one, so the additions were a side-anchor method, a highlighted-row rect and a cursor pass-through. Unlimited submenu depth; checkable/disabled items and global-shortcut activation deferred indefinitely |
56
+ | ~~DateField~~ | `AbstractWrappingField` + `HasBadInput` | **built** 2026-09-04 (`D_date_field`, book ch7), after re-tiering out of Tier 2 the same day: the v1 is manual entry only, so the calendar popup it was blocked on became a phase 2 (still Tier 2 below). Value is stdlib `Date`; a per-instance list of strftime `formats`, first whole match winning and `formats.first` written back on blur/ENTER. It filters *nothing* — a date's grammar is not prefix-closed, so the residue is reported through `HasBadInput` — and its empty well derives its own `HasPlaceholder` hint from the primary format. `%y` is rejected outright and the calendar is proleptic Gregorian, not Ruby's `Date::ITALY` |
56
57
  | Slider | `draw_line` | arrows/PgUp; 25.2 also has a two-thumb *range* variant |
57
58
  | Breadcrumbs | `Label`/`StyledString` | clickable path segments |
58
59
  | Markdown | `TextView` + `StyledString` | Markdown subset → styled text; high value on a TTY |
@@ -62,9 +63,9 @@ That leaves ~46 gaps.
62
63
  | Component | Blocked on |
63
64
  |---|---|
64
65
  | **Grid** (the flagship gap) | column model + renderer strategies + typed items + horizontal scroll (L) |
65
- | Form Layout | a field label/helper seam (Vaadin's `HasLabel`) — Tuile fields carry no caption |
66
- | Email Field | a validation seam (`HasValidation`: invalid state + error line) |
67
- | Date / Time / DateTime Picker | calendar-grid popup over Popover (L) |
66
+ | Form Layout | a field label/helper seam (Vaadin's `HasLabel`) — Tuile fields carry no caption by decision (`D_caption_ownership`), so the seam is the layout's own cells: `ideas/form-layout.md` |
67
+ | Email Field | nothing, per `D_bad_input` — its value *is* its input, so it has no bad-input state and contributes only a packaged regex; **re-tiered toward reject** |
68
+ | Calendar grid for `DateField`, and the ~~Time~~ / DateTime twins | the grid needs the calendar popup over Popover (L). It is **phase 2** of a field that already ships (`D_date_field`, Tier 1 above), so nothing is blocked on it: a `DateField` is fully usable by typing, and the grid is additive — a second way to set the same `value`, placed with `ListDropdown#anchor_to`. Two things it inherits rather than re-decides: the month names it paints are the locale question of `ideas/locale.md`, and `PageUp`/`PageDown` stepping a month is deferred there too. The Time / DateTime twins no longer wait on that seam — it shipped (`D_locale`) — and `TimeField` shipped under `D_time_field` (2026-09-05) with **no** picker dropdown: a list of times carries no information a user lacks, so it fails the test the calendar grid passes (`D_mouse`). PageUp/PageDown step an hour instead, and its phase 2 is segment-aware Up/Down (the Qt / `dialog --timebox` model), shared with `DateField` |
68
69
  | Multi Select Combo Box | Checkbox Group + ComboBox |
69
70
  | Split Layout → Master Detail Layout | mouse **motion/drag**: Tuile runs X10 mode 1000 (press only, no release, no motion) |
70
71
  | Virtual List | a lazy data-provider strategy on `List` |
@@ -97,8 +98,27 @@ file when its cluster comes up:
97
98
  foundation. A future Grid should reuse its `Fixed`/`Percent`/`Expand`
98
99
  constraints per row and column rather than invent a second vocabulary.
99
100
  2. **Field label + helper text seam** → Form Layout. Note this is what Form
100
- Layout is actually blocked on — the layout half now exists.
101
- 3. **Validation seam** → Email Field, forms generally.
101
+ Layout is actually blocked on — the layout half now exists. Designed
102
+ 2026-09-03 and **half shipped the same day**: the caption goes to the layout
103
+ (fields do *not* include `HasCaption` — `D_caption_ownership`), and the error
104
+ *verdict* stays on the field as `Component::HasValidation` (one member,
105
+ `error_message`, `HasValue` includes it, painted as a red *well* —
106
+ `D_has_validation`). What is left is the container that reads the *message*
107
+ off the field and paints it inline-right, plus the required marker and helper
108
+ text: `ideas/form-layout.md`. Items 2 and 3 turn out to be the same seam cut
109
+ in two — this half is the *geometry*, item 3 is the *signals*.
110
+ 3. **Validation seam** → forms generally. Designed 2026-09-03 and split three
111
+ ways. The field-side channel **shipped the same day**: `HasBadInput`
112
+ (`D_bad_input`) holds the one fact only the field can know, because
113
+ `on_value_change` is a diff over values and every unrepresentable input
114
+ collapses onto the same one, and `on_blur` — the commit point it wanted —
115
+ shipped 2026-09-04 (`D_on_blur`); only the push notice is still deferred, for
116
+ want of a consumer. Still open: `ideas/binder.md`
117
+ (the consumer, and the four-layer model/transformations/value/input
118
+ vocabulary the whole cluster now uses). The `HasValidation` half **shipped
119
+ the same day** as well (`D_has_validation`), so what is left of item 2's note
120
+ is the container that paints the message. `Date Picker` is the component that
121
+ actually forces it; Email Field is re-tiered toward *reject*.
102
122
  4. **Anchored Popover extraction** → pickers, Tooltip. **No longer gates Menu
103
123
  Bar** — `D_menu_bar` argues the side-anchor is a sibling method on
104
124
  `ListDropdown`, since both callers still wrap a `List`; the extraction's
@@ -12,8 +12,9 @@ module Tuile
12
12
  # Holds the shared state — a mutable {#text} buffer, a {#caret} index,
13
13
  # {#on_change} and {#on_escape} callbacks — and the keyboard machinery
14
14
  # that single-line and multi-line inputs both need: ESC handling,
15
- # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, and the
16
- # `tab_stop?` flag (`focusable?` comes from {HasValue}).
15
+ # LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, CTRL+W
16
+ # word-delete, and the `tab_stop?` flag (`focusable?` comes from
17
+ # {HasValue}).
17
18
  #
18
19
  # {#caret} counts *characters* into {#text} but may only sit *between*
19
20
  # grapheme clusters — the glyphs a terminal draws. Both write sites snap it
@@ -36,14 +37,45 @@ module Tuile
36
37
  # {#handle_text_input_key} hook — `super` falls through to the common
37
38
  # navigation handling.
38
39
  #
40
+ # == Customizing a field is subclassing it, and there are two seams
41
+ # To change what **keys** do, override {#handle_text_input_key}; to
42
+ # constrain what the buffer may **hold**, override {#insert_text}, which
43
+ # every insertion runs through — typed, pasted, or the ENTER newline:
44
+ #
45
+ # class HexField < TextField
46
+ # protected
47
+ #
48
+ # # ENTER submits instead of falling through to the parent.
49
+ # def handle_text_input_key(key)
50
+ # return super unless key == Keys::ENTER
51
+ #
52
+ # submit(text)
53
+ # true
54
+ # end
55
+ #
56
+ # # Hex digits only — and a paste of "12zz" lands nothing, not "12".
57
+ # def insert_text(str)
58
+ # return false unless @text.dup.insert(@caret, str).match?(/\A\h*\z/)
59
+ #
60
+ # super
61
+ # end
62
+ # end
63
+ #
64
+ # Both compose through `super`, which is why they are overrides rather than
65
+ # the callback slot this class carried until 0.15.0: two behaviors could not
66
+ # share one slot, and a filter written on a *key* callback let the same
67
+ # characters in through a paste (`D_input_filters`, book ch7).
68
+ #
39
69
  # The mutation pipeline is a template method: {#text=} and {#caret=}
40
70
  # detect no-ops, mutate state, fire {#on_change}, and invalidate.
41
- # Subclasses inject their own behavior via two protected hooks:
71
+ # Subclasses inject their own behavior via four protected hooks:
42
72
  #
43
- # - {#preprocess_text} — input filter (e.g. {TextField} truncates to
44
- # fit `rect.width - 1`).
45
- # - {#preprocess_paste} — the same for {#handle_paste}, which lands a
46
- # whole clipboard at the caret in one mutation.
73
+ # - {#insert_text} — **the one filter seam**: every insertion runs through
74
+ # it, typed or pasted, so what the buffer may hold is decided here.
75
+ # - {#preprocess_text} — filter for a whole assignment to {#text=},
76
+ # which insertion does *not* pass through.
77
+ # - {#preprocess_paste} — sanitizer for {#handle_paste}, run before the
78
+ # clipboard reaches {#insert_text} ({TextField} keeps its first line).
47
79
  # - {#on_text_mutated} / {#on_caret_mutated} — post-mutation side
48
80
  # effects (e.g. {TextArea} invalidates its wrap cache and scrolls to
49
81
  # keep the caret visible).
@@ -56,7 +88,6 @@ module Tuile
56
88
  @caret = 0
57
89
  @on_change = nil
58
90
  @on_value_change = nil
59
- @on_key = nil
60
91
  @on_escape = method(:default_on_escape)
61
92
  end
62
93
 
@@ -89,20 +120,6 @@ module Tuile
89
120
  # @return [Proc, Method, nil] one-arg callable, or nil.
90
121
  attr_accessor :on_change
91
122
 
92
- # Optional interceptor consulted before the input's own key handling.
93
- # Receives the pressed key; return a truthy value to consume it (the
94
- # input then ignores that key), falsy to let normal editing proceed.
95
- #
96
- # The keyboard analog of {#on_change}: it lets app code layer behavior
97
- # onto an input without subclassing. The motivating case is an
98
- # autocomplete / slash-command overlay (a non-modal {Component::Popup}):
99
- # while it is open the interceptor claims Up/Down/Enter/ESC and forwards
100
- # them to the overlay's list, but lets ordinary characters fall through
101
- # so typing keeps editing the field (and {#on_change} keeps refilling the
102
- # list).
103
- # @return [Proc, Method, nil] one-arg callable, or nil.
104
- attr_accessor :on_key
105
-
106
123
  # Callback fired when ESC is pressed. Defaults to a closure that clears
107
124
  # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
108
125
  # of bubbling to the parent — and, in particular, instead of reaching the
@@ -144,25 +161,20 @@ module Tuile
144
161
  invalidate
145
162
  end
146
163
 
147
- # Handles a key. An {#on_key} interceptor (if set) gets first refusal —
148
- # a truthy return consumes the key — otherwise it delegates to
149
- # {#handle_text_input_key}. Dispatch ({ScreenPane#handle_key}) only routes
150
- # keys here when this input is on the focus chain, so there is no
151
- # {#active?} gate.
164
+ # Handles a key, by delegating to the {#handle_text_input_key} hook a
165
+ # subclass overrides. Dispatch ({ScreenPane#handle_key}) only routes keys
166
+ # here when this input is on the focus chain, so there is no {#active?}
167
+ # gate.
152
168
  # @param key [String]
153
169
  # @return [Boolean]
154
- def handle_key(key)
155
- return true if @on_key&.call(key)
156
-
157
- handle_text_input_key(key)
158
- end
170
+ def handle_key(key) = handle_text_input_key(key)
159
171
 
160
172
  # Inserts pasted text at the caret as **one** mutation, so {#on_change}
161
173
  # fires once for the whole paste rather than once per character.
162
174
  # {#preprocess_paste} filters it first.
163
175
  # @param text [String]
164
- # @return [Boolean] always true — a field consumes every paste, an empty
165
- # one included.
176
+ # @return [Boolean] always true — a field consumes every paste: an empty
177
+ # one, and one its {#insert_text} rejects wholesale.
166
178
  def handle_paste(text)
167
179
  insert_text(preprocess_paste(text))
168
180
  true
@@ -180,8 +192,28 @@ module Tuile
180
192
  # @return [String]
181
193
  def preprocess_paste(text) = text.tr("\t", " ").gsub(/[\x00-\x09\x0b-\x1f\x7f]/, "")
182
194
 
183
- # Inserts `str` at the caret, leaving the caret behind it. The bulk
184
- # counterpart of a subclass's per-key insert.
195
+ # Inserts `str` at the caret, leaving the caret behind it.
196
+ #
197
+ # **Every insertion lands here** — a typed character, the ENTER newline
198
+ # and a whole pasted clipboard alike — so a field constrains its contents
199
+ # by overriding this, and one override covers typing and pasting both:
200
+ #
201
+ # def insert_text(str) # hex digits only, in a TextField subclass
202
+ # return false unless @text.dup.insert(@caret, str).match?(/\A\h*\z/)
203
+ #
204
+ # super
205
+ # end
206
+ #
207
+ # Test the whole resulting buffer, as above, and not the fragment being
208
+ # inserted: sieving per character turns a pasted `"1,5"` into the
209
+ # plausible, wrong `"15"`, where an all-or-nothing test drops it — which
210
+ # is also what typing the comma does. Filtering at all works only for a
211
+ # grammar every valid value can be *typed through*; one where it can't
212
+ # (a date — `"2020-13-45"` is well-formed at every character) reports bad
213
+ # input rather than filtering it (`D_input_filters`, book ch7).
214
+ #
215
+ # {#text=} does *not* pass through here: only user input is filtered, so a
216
+ # programmatic {HasValue#value=} may still write what no key types.
185
217
  # @param str [String]
186
218
  # @return [Boolean] true if the text changed.
187
219
  def insert_text(str)
@@ -193,20 +225,25 @@ module Tuile
193
225
  true
194
226
  end
195
227
 
196
- # Renders `text` on the field's background well, looked up from the
197
- # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
198
- # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
199
- # visibly a field either way, distinctly highlighted when active.
200
- # @param text [String]
201
- # @return [StyledString] text on the field's background well.
202
- def background(text)
203
- StyledString.styled(text, bg: active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
204
- end
205
-
206
- # Input filter for {#text=}. Subclasses override to truncate or reject
207
- # invalid input. Default coerces to String.
228
+ # The field's background well, looked up from the current {Screen#theme}
229
+ # at paint time: {Theme#active_bg_color} while this input is on the active
230
+ # (focus) chain, {Theme#input_bg_color} otherwise — visibly a field either
231
+ # way, distinctly highlighted when focused. An app overrides the pair by
232
+ # setting {Component#bg_color}, which wins over this.
233
+ #
234
+ # Unconditional on purpose. A field used as the face of a composed one
235
+ # ({Component::ComboBox}, {Component::IntegerField} …) is *told* to drop
236
+ # its well — that widget assigns {Component::BG_INHERIT} at construction,
237
+ # since it owns the surface and a second well would make its own
238
+ # {Component#bg_color} inert over the very cells this field paints.
239
+ # @return [Color]
240
+ def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
241
+
242
+ # Input filter for a whole assignment to {#text=}. Nothing overrides it
243
+ # today; a subclass that does is filtering the *programmatic* setter, not
244
+ # user input — that is {#insert_text}.
208
245
  # @param new_text [String]
209
- # @return [String] possibly transformed text.
246
+ # @return [String] possibly transformed text; the default coerces to String.
210
247
  def preprocess_text(new_text) = new_text.to_s
211
248
 
212
249
  # The one measurement primitive both inputs share: a caret index counts
@@ -230,13 +267,14 @@ module Tuile
230
267
  # @return [void]
231
268
  def on_caret_mutated; end
232
269
 
233
- # Dispatch hook for {#handle_key}. Handles ESC and the navigation keys
234
- # that have identical semantics in single-line and multi-line inputs:
270
+ # Dispatch hook for {#handle_key}. Handles ESC and the editing keys that
271
+ # have identical semantics in single-line and multi-line inputs:
235
272
  # LEFT/RIGHT arrows (one grapheme cluster per press, so a press always
236
- # moves), CTRL+LEFT/CTRL+RIGHT for word jumps. Subclasses
237
- # override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
238
- # DELETE, printable insertion) and call `super` to fall back to the
239
- # common navigation handling.
273
+ # moves), CTRL+LEFT/CTRL+RIGHT for word jumps, and CTRL+W, which deletes
274
+ # exactly what CTRL+LEFT would have skipped over (readline's
275
+ # `unix-word-rubout`). Subclasses override to add their own keys (HOME/END,
276
+ # UP/DOWN, ENTER, CTRL+U, BACKSPACE/DELETE, printable insertion) and call
277
+ # `super` to fall back to the common handling.
240
278
  # @param key [String]
241
279
  # @return [Boolean] true if the key was handled.
242
280
  def handle_text_input_key(key)
@@ -245,6 +283,7 @@ module Tuile
245
283
  when Keys::RIGHT_ARROW then self.caret = cluster_boundary_after(@caret)
246
284
  when Keys::CTRL_LEFT_ARROW then self.caret = word_left
247
285
  when Keys::CTRL_RIGHT_ARROW then self.caret = word_right
286
+ when Keys::CTRL_W then delete_back_to(word_left)
248
287
  when Keys::ESC
249
288
  return false if @on_escape.nil?
250
289
 
@@ -259,10 +298,19 @@ module Tuile
259
298
  # glyph, whatever it is built from (a ZWJ emoji family and a three-jamo
260
299
  # Hangul syllable each go whole).
261
300
  # @return [void]
262
- def delete_before_caret
263
- return if @caret.zero?
301
+ def delete_before_caret = delete_back_to(cluster_boundary_before(@caret))
302
+
303
+ # Removes the text between `index` and the caret, leaving the caret at
304
+ # `index` — one mutation, so {#on_change} fires once.
305
+ #
306
+ # `index` is snapped forward onto a grapheme-cluster boundary, so a
307
+ # caller may compute it by counting characters.
308
+ # @param index [Integer] a {#text} index; clamped to `0..caret`.
309
+ # @return [void]
310
+ def delete_back_to(index)
311
+ start = snap_to_cluster(index.clamp(0, @caret))
312
+ return if start == @caret
264
313
 
265
- start = cluster_boundary_before(@caret)
266
314
  new_text = @text.dup
267
315
  new_text.slice!(start...@caret)
268
316
  @caret = start
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # Abstract base for a field that **wraps one editor completely**: it carries
6
+ # a typed {HasValue#value} but paints nothing itself, handing the whole UI to
7
+ # a single {AbstractStringField} it owns and hides. Subclass it by passing
8
+ # the editor to `super` and defining the conversion both ways:
9
+ #
10
+ # class IntegerField < Component::AbstractWrappingField
11
+ # def initialize = super(TextField.new)
12
+ #
13
+ # def value = Integer(editor.text, 10) rescue nil
14
+ #
15
+ # def value=(new_value)
16
+ # editor.text = new_value.nil? ? "" : new_value.to_s
17
+ # editor.caret = editor.text.length
18
+ # end
19
+ #
20
+ # def empty_value = nil
21
+ # end
22
+ #
23
+ # Everything else arrives already wired: the editor is added as the single
24
+ # child and positioned across {Component#rect}, focus forwards into it, it
25
+ # sits on this field's one background well, and {#placeholder} /
26
+ # {#on_enter} / {#cursor_position} / {#clear} are re-exposed here so an app
27
+ # never addresses it. Give the field a single-row rect.
28
+ #
29
+ # == The editor is private machinery
30
+ # There is no public accessor — {#editor} is protected, for subclasses — and
31
+ # that is the point: swapping it would break the conversion. **An app never
32
+ # addresses the editor**; what it needs is either already delegated here or
33
+ # earns a forwarder here. It is still in `children`, because the tree is
34
+ # reported honestly, but that is not an invitation.
35
+ #
36
+ # A **spec** is the exception, and it has a sanctioned path — driving the
37
+ # editor is how a test reaches a state no public setter produces:
38
+ #
39
+ # editor = Testing.get(Component::TextField, in: field)
40
+ # editor.text = "-" # bad input; field.value still reads nil
41
+ #
42
+ # A knob that is *editor-shaped* rather than a concept of this field's own
43
+ # domain is **not** forwarded, and the subclass sets it on its editor
44
+ # instead:
45
+ #
46
+ # def initialize
47
+ # super(TextField.new)
48
+ # editor.max_text_length = 20 # an internal cap, not part of my surface
49
+ # end
50
+ #
51
+ # == Committing: leaving the widget, and ENTER
52
+ # {#commit} fires on both commit gestures. Leaving the focus chain is one —
53
+ # the *field*'s, not its editor's, which is left on every hop within a
54
+ # widget. ENTER is the other, because a form whose default button is reached
55
+ # by ENTER never moves focus at all. Override it to canonicalize a buffer
56
+ # the user typed loosely:
57
+ #
58
+ # def commit = (self.value = value unless value.nil?) # rewrite in the canonical form
59
+ #
60
+ # ENTER is committed and then **left to keep bubbling**, so a scope's
61
+ # default button still sees it; only an {#on_enter} of this field's own
62
+ # consumes it, which is {TextField#on_enter}'s existing contract.
63
+ #
64
+ # == Implementation details
65
+ # - **{HasValue#value} and {#value=} raise until overridden.** The inherited
66
+ # pair stores into `@value` and never touches the editor, so a subclass
67
+ # that defined only one would silently half-work.
68
+ # - **{HasValue#empty_value} is called during construction**, to seed the
69
+ # change guard, so it must not depend on subclass state that `super` has
70
+ # not set yet. In practice it is a constant per class.
71
+ # - **The editor's `on_change` and `on_enter` slots are claimed** — for that
72
+ # guard, and to commit before an app's ENTER handler runs. A slot cannot
73
+ # be shared, so a subclass reacting to buffer edits overrides
74
+ # {#on_editor_change} (every edit), {#value=} or {#commit} rather than
75
+ # reassigning either.
76
+ # - **Not for a field whose editor is a *filter*.** This base assumes the
77
+ # buffer is a rendering of the value, so an edit may change the value.
78
+ # {ComboBox} breaks both halves — its text is a transient query and only a
79
+ # commit moves its value — which is the same line {HasBadInput} draws.
80
+ #
81
+ # UI-thread-confined, like every component (see {Screen}).
82
+ class AbstractWrappingField < Component
83
+ include HasValue
84
+ include HasPlaceholder
85
+
86
+ # @param editor [AbstractStringField] the editor to wrap; becomes this
87
+ # field's single child and is never swapped.
88
+ # @raise [TypeError] unless `editor` is an {AbstractStringField}.
89
+ def initialize(editor)
90
+ super()
91
+ raise TypeError, "expected AbstractStringField, got #{editor.inspect}" unless editor.is_a?(AbstractStringField)
92
+
93
+ @editor = editor
94
+ @last_value = empty_value
95
+ @on_enter = nil
96
+ # One widget, one surface: the editor paints no well of its own, so this
97
+ # field's bg_color reaches the cells the editor paints.
98
+ editor.bg_color = BG_INHERIT
99
+ editor.on_change = lambda do |_text|
100
+ on_editor_change
101
+ fire_if_changed
102
+ end
103
+ add_child(editor, at: 0)
104
+ end
105
+
106
+ # @return [Object] the typed value, parsed from the editor's buffer.
107
+ # @raise [NotImplementedError] unless the subclass overrides it.
108
+ def value = raise(NotImplementedError, "#{self.class} must implement value")
109
+
110
+ # Writes `new_value` into the editor's buffer.
111
+ # @param new_value [Object]
112
+ # @return [void]
113
+ # @raise [NotImplementedError] unless the subclass overrides it.
114
+ def value=(new_value)
115
+ raise(NotImplementedError, "#{self.class} must implement value=")
116
+ end
117
+
118
+ # Empties the *input*, not just the value — a field holding bad input
119
+ # already reads {HasValue#empty_value}, so clearing through {#value=} could
120
+ # leave the glyphs on screen ({HasBadInput}).
121
+ # @return [void]
122
+ def clear = editor.clear
123
+
124
+ # @return [String, nil] the hint the editor paints while empty
125
+ # ({HasPlaceholder}).
126
+ def placeholder = editor.placeholder
127
+
128
+ # @param text [String, nil]
129
+ # @return [void]
130
+ # @raise [TypeError] unless `text` is a String or nil.
131
+ def placeholder=(text)
132
+ editor.placeholder = text
133
+ end
134
+
135
+ # @return [Proc, Method, nil] fired when ENTER is pressed, *after*
136
+ # {#commit}; see {TextField#on_enter}.
137
+ attr_reader :on_enter
138
+
139
+ # @param callback [Proc, Method, nil]
140
+ # @return [void]
141
+ def on_enter=(callback)
142
+ @on_enter = callback
143
+ # Wrapped rather than forwarded, so an app's ENTER handler reads a
144
+ # committed buffer. A nil callback leaves the editor's own slot nil,
145
+ # which is what keeps ENTER *bubbling* — see {#handle_key}.
146
+ editor.on_enter = callback && lambda do
147
+ commit
148
+ callback.call
149
+ end
150
+ end
151
+
152
+ # Commits on ENTER, and leaves the key unconsumed so it keeps bubbling.
153
+ #
154
+ # The editor declines ENTER whenever {#on_enter} is nil, so the key
155
+ # reaches this field instead — and it must be committed on the way past,
156
+ # or the form default button it is bubbling towards acts on an
157
+ # uncommitted buffer.
158
+ # @param key [String]
159
+ # @return [Boolean] whatever `super` returns — committing never consumes
160
+ # the key.
161
+ def handle_key(key)
162
+ commit if key == Keys::ENTER
163
+ super
164
+ end
165
+
166
+ # @return [Point, nil] the editor's caret — the hardware cursor is
167
+ # delegated to it.
168
+ def cursor_position = editor.cursor_position
169
+
170
+ # Runs {#commit} on the falling edge, i.e. when this field leaves the focus
171
+ # chain. Moving focus *within* a widget keeps it active, so a future
172
+ # multi-editor field inherits the same semantics unchanged.
173
+ # @param flag [Boolean]
174
+ # @return [void]
175
+ def active=(flag)
176
+ was = active?
177
+ super
178
+ commit if was && !active?
179
+ end
180
+
181
+ # @return [void]
182
+ def on_focus
183
+ super
184
+ # The editor is what actually edits, so it takes the focus this field was
185
+ # given — the field itself has no keys of its own.
186
+ screen.focused = editor if editor.focusable?
187
+ end
188
+
189
+ # @param new_rect [Rect]
190
+ # @return [void]
191
+ def rect=(new_rect)
192
+ super
193
+ layout(editor)
194
+ end
195
+
196
+ protected
197
+
198
+ # @return [AbstractStringField] the wrapped editor.
199
+ attr_reader :editor
200
+
201
+ # Called on a commit gesture — the field leaving the focus chain, or
202
+ # ENTER; no-op by default. This is the commit point a canonicalizing
203
+ # field rewrites its buffer from.
204
+ # @return [void]
205
+ def commit = nil
206
+
207
+ # Called whenever the editor's buffer changes, however the characters
208
+ # arrived — a typed key, a paste, or a {#value=} of this field's own. It
209
+ # is named for the *editor*, not for the user, because those last two are
210
+ # not input. No-op by default; override it to drop state that describes
211
+ # the *previous* buffer, as a field latching whether its input has settled
212
+ # must ({HasBadInput}).
213
+ # @return [void]
214
+ def on_editor_change = nil
215
+
216
+ # Places the editor across the whole rect; override to reserve cells for a
217
+ # face of your own.
218
+ # @param editor [Component]
219
+ # @return [void]
220
+ def layout(editor) = (editor.rect = rect)
221
+
222
+ # The field well the face sits on — the editor is marked
223
+ # {Component::BG_INHERIT}, so this one covers it (exactly one well per
224
+ # widget) and {Component#bg_color} set here reaches the cells it paints.
225
+ # @return [Color]
226
+ def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color
227
+
228
+ private
229
+
230
+ # Re-emits {HasValue#on_value_change}, but only when {#value} differs from
231
+ # the last one fired — so a buffer edit that leaves the value alone
232
+ # (`"7"`→`"07"`) stays silent.
233
+ # @return [void]
234
+ def fire_if_changed
235
+ v = value
236
+ return if v == @last_value
237
+
238
+ @last_value = v
239
+ on_value_change&.call(v)
240
+ end
241
+ end
242
+ end
243
+ end