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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/DECISIONS.md +3181 -41
- data/README.md +25 -5
- data/TERMINOLOGY.md +17 -3
- data/book/05-focus.md +63 -2
- data/book/06-theming.md +55 -7
- data/book/07-components.md +474 -48
- data/book/08-testing.md +78 -0
- data/book/10-locale.md +216 -0
- data/book/README.md +14 -5
- data/examples/sampler.rb +265 -25
- data/ideas/binder.md +177 -0
- data/ideas/composite-field.md +77 -0
- data/ideas/focus-accent.md +116 -0
- data/ideas/form-layout.md +151 -0
- data/ideas/hover/probe.rb +241 -0
- data/ideas/hover/probe_spec.rb +82 -0
- data/ideas/hover.md +909 -0
- data/ideas/new-components.md +26 -6
- data/lib/tuile/component/abstract_string_field.rb +106 -58
- data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/checkbox_group.rb +36 -20
- data/lib/tuile/component/combo_box.rb +59 -31
- data/lib/tuile/component/date_field.rb +322 -0
- data/lib/tuile/component/float_field.rb +57 -82
- data/lib/tuile/component/has_bad_input.rb +88 -0
- data/lib/tuile/component/has_caption.rb +8 -0
- data/lib/tuile/component/has_content.rb +29 -10
- data/lib/tuile/component/has_placeholder.rb +62 -0
- data/lib/tuile/component/has_validation.rb +115 -0
- data/lib/tuile/component/has_value.rb +27 -0
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +6 -38
- data/lib/tuile/component/layout/box.rb +87 -19
- data/lib/tuile/component/layout.rb +13 -3
- data/lib/tuile/component/list.rb +11 -6
- data/lib/tuile/component/list_dropdown.rb +4 -0
- data/lib/tuile/component/overlay.rb +17 -0
- data/lib/tuile/component/radio_group.rb +39 -22
- data/lib/tuile/component/select.rb +12 -4
- data/lib/tuile/component/text_area.rb +14 -8
- data/lib/tuile/component/text_field.rb +42 -15
- data/lib/tuile/component/text_view.rb +25 -8
- data/lib/tuile/component/time_field.rb +454 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +469 -73
- data/lib/tuile/fake_screen.rb +11 -1
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/screen.rb +131 -17
- data/lib/tuile/screen_pane.rb +13 -9
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +100 -10
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +88 -12
- data/lib/tuile.rb +1 -0
- data/sig/tuile.rbs +3398 -412
- metadata +18 -1
data/ideas/new-components.md
CHANGED
|
@@ -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`;
|
|
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 |
|
|
67
|
-
|
|
|
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
|
-
|
|
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,
|
|
16
|
-
# `tab_stop?` flag (`focusable?` comes from
|
|
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
|
|
71
|
+
# Subclasses inject their own behavior via four protected hooks:
|
|
42
72
|
#
|
|
43
|
-
# - {#
|
|
44
|
-
#
|
|
45
|
-
# - {#
|
|
46
|
-
#
|
|
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
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
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
|
|
165
|
-
# one
|
|
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.
|
|
184
|
-
#
|
|
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
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
#
|
|
207
|
-
#
|
|
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
|
|
234
|
-
#
|
|
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
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
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
|
-
|
|
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
|