tuile 0.14.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +159 -49
- data/README.md +53 -17
- data/book/04-event-loop.md +12 -12
- data/book/05-focus.md +152 -22
- data/book/06-theming.md +105 -25
- data/book/07-components.md +531 -50
- data/book/08-testing.md +100 -20
- data/book/10-locale.md +216 -0
- data/book/README.md +19 -9
- data/examples/file_commander.rb +14 -5
- data/examples/hello_world.rb +17 -4
- data/examples/sampler.rb +654 -40
- data/lib/tuile/component/abstract_string_field.rb +114 -68
- data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
- data/lib/tuile/component/big_decimal_field.rb +52 -79
- data/lib/tuile/component/button.rb +8 -8
- data/lib/tuile/component/checkbox.rb +9 -9
- data/lib/tuile/component/checkbox_group.rb +38 -21
- data/lib/tuile/component/combo_box.rb +102 -59
- data/lib/tuile/component/confirm_window.rb +7 -5
- data/lib/tuile/component/date_field.rb +347 -0
- data/lib/tuile/component/date_time_field.rb +275 -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 +32 -13
- 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 +28 -1
- data/lib/tuile/component/integer_field.rb +51 -78
- data/lib/tuile/component/label.rb +7 -39
- data/lib/tuile/component/layout/box.rb +90 -19
- data/lib/tuile/component/layout.rb +15 -5
- data/lib/tuile/component/list.rb +53 -38
- data/lib/tuile/component/list_dropdown.rb +7 -3
- data/lib/tuile/component/menu_bar/cascade.rb +5 -5
- data/lib/tuile/component/menu_bar.rb +18 -18
- data/lib/tuile/component/notification.rb +32 -18
- data/lib/tuile/component/overlay.rb +26 -8
- data/lib/tuile/component/picker_window.rb +27 -8
- data/lib/tuile/component/popup.rb +2 -2
- data/lib/tuile/component/progress_bar.rb +10 -4
- data/lib/tuile/component/radio_group.rb +41 -23
- data/lib/tuile/component/select.rb +23 -16
- data/lib/tuile/component/slot.rb +3 -3
- data/lib/tuile/component/tab_sheet.rb +6 -6
- data/lib/tuile/component/tabs.rb +11 -11
- data/lib/tuile/component/text_area.rb +26 -18
- data/lib/tuile/component/text_field.rb +55 -26
- data/lib/tuile/component/text_view.rb +40 -19
- data/lib/tuile/component/time_field.rb +479 -0
- data/lib/tuile/component/window.rb +26 -13
- data/lib/tuile/component.rb +635 -131
- data/lib/tuile/event_queue.rb +4 -4
- data/lib/tuile/fake_event_queue.rb +1 -1
- data/lib/tuile/fake_screen.rb +95 -3
- data/lib/tuile/final.rb +75 -0
- data/lib/tuile/locale.rb +851 -0
- data/lib/tuile/mouse/router.rb +217 -0
- data/lib/tuile/mouse.rb +177 -0
- data/lib/tuile/screen.rb +219 -68
- data/lib/tuile/screen_pane.rb +51 -42
- data/lib/tuile/styled_string.rb +5 -5
- data/lib/tuile/testing.rb +198 -0
- data/lib/tuile/theme.rb +110 -32
- 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 +4595 -825
- metadata +14 -9
- data/COMPARISON.md +0 -101
- data/DECISIONS.md +0 -5422
- data/TERMINOLOGY.md +0 -71
- data/ideas/arrow-key-navigation.md +0 -221
- data/ideas/modal-backdrop.md +0 -24
- data/ideas/new-components.md +0 -124
- data/ideas/per-component-buffers.md +0 -55
- data/lib/tuile/mouse_event.rb +0 -68
data/book/07-components.md
CHANGED
|
@@ -117,15 +117,13 @@ protect the plaintext in memory: it's an ordinary Ruby string, and
|
|
|
117
117
|
anything stronger is a job for a type the whole application cooperates
|
|
118
118
|
with.
|
|
119
119
|
|
|
120
|
-
Both inherit the same event hooks from the base
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
the list, so the caret stays in the field and typing keeps refilling the
|
|
128
|
-
suggestions.
|
|
120
|
+
Both inherit the same event hooks from the base. `on_change` fires whenever
|
|
121
|
+
the text changes; `on_escape` handles ESC (with a sensible default);
|
|
122
|
+
`on_enter`, `on_key_up` and `on_key_down` each claim one key. Notice what
|
|
123
|
+
they have in common: every one of them either *reports* something or takes a
|
|
124
|
+
**single named key** whose meaning the field itself has no use for. There is
|
|
125
|
+
deliberately no callback that intercepts keys in general — to change what
|
|
126
|
+
keys *do*, you subclass (see *Keeping input out of a field*, below).
|
|
129
127
|
|
|
130
128
|
```ruby
|
|
131
129
|
field = Component::TextField.new
|
|
@@ -138,6 +136,83 @@ left `nil`, let those keys *fall through* to the parent — that's how Enter
|
|
|
138
136
|
in a search field can trigger the surrounding window's action while the
|
|
139
137
|
field still handles ordinary typing.
|
|
140
138
|
|
|
139
|
+
Three editing keys are worth knowing because nothing on screen advertises
|
|
140
|
+
them. Ctrl+Left and Ctrl+Right jump by a word; **Ctrl+W** deletes the word
|
|
141
|
+
behind the caret — exactly what Ctrl+Left would have skipped over — and
|
|
142
|
+
**Ctrl+U** deletes everything before the caret, which with the caret at the
|
|
143
|
+
end is "clear this field". All three are readline's, so they already behave
|
|
144
|
+
the way they do in your shell and in every prompt you have typed into. A
|
|
145
|
+
TextArea has them too, and there Ctrl+U kills back to the start of the
|
|
146
|
+
caret's *row* — the same place Home goes.
|
|
147
|
+
|
|
148
|
+
What you cannot have is Shift+Backspace, and the reason is a useful window
|
|
149
|
+
into terminal keys in general. Backspace arrives as a single byte with
|
|
150
|
+
nowhere to carry a modifier, so a terminal sends Shift+Backspace as plain
|
|
151
|
+
Backspace; the shift only survives under newer opt-in protocols that not
|
|
152
|
+
every terminal speaks. That is why terminal software reaches for
|
|
153
|
+
Ctrl+*letter* as often as it does — those are among the few combinations the
|
|
154
|
+
wire can express at all.
|
|
155
|
+
|
|
156
|
+
### The hint in an empty field
|
|
157
|
+
|
|
158
|
+
A blank field tells the user nothing about what belongs in it. Usually that
|
|
159
|
+
is fine — a caption beside it says "Name" and there is nothing more to know.
|
|
160
|
+
But some fields want a *shape* rather than a label: a date field has to say
|
|
161
|
+
which of the several orderings it writes back, and no caption can carry that
|
|
162
|
+
without becoming a sentence.
|
|
163
|
+
|
|
164
|
+
That is what a placeholder is for. Give a field one and it paints the hint
|
|
165
|
+
into its own cells while it is empty:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
field = Component::TextField.new
|
|
169
|
+
field.placeholder = "dd.mm.yyyy"
|
|
170
|
+
field.text # => "" — the hint is not content
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The last line is the whole contract. A placeholder is paint and nothing
|
|
174
|
+
else: it never enters the value, never fires `on_value_change`, is never
|
|
175
|
+
touched by a paste, and never counts against `max_text_length`. That
|
|
176
|
+
asymmetry *is* the feature — a hint that lived in the buffer would be a
|
|
177
|
+
default value, and a form saving an untouched field would write
|
|
178
|
+
`"dd.mm.yyyy"` to your database.
|
|
179
|
+
|
|
180
|
+
It stays on screen while the field has focus, which is worth saying out
|
|
181
|
+
loud because browsers spent years doing the opposite. The reasoning is
|
|
182
|
+
simply that a format hint is wanted *most* while someone is typing into the
|
|
183
|
+
field — that is exactly the moment they need to know whether the month or
|
|
184
|
+
the day comes first.
|
|
185
|
+
|
|
186
|
+
The typed fields carry it too, and you set it on the field itself rather
|
|
187
|
+
than reaching for the text field inside:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
port = Component::IntegerField.new
|
|
191
|
+
port.placeholder = "1-65535"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Two smaller notes. A hint wider than its field is *ellipsized*, not cut, so
|
|
195
|
+
you see `dd.mm.y…` rather than `dd.mm.yyy` — the second reads as a complete
|
|
196
|
+
format that happens to be wrong, which is worse than obviously truncated.
|
|
197
|
+
And a field marked invalid keeps showing its placeholder: an empty required
|
|
198
|
+
field is the commonest invalid state there is, and the red well saying
|
|
199
|
+
*something is wrong* pairs naturally with a hint saying *what goes here*.
|
|
200
|
+
|
|
201
|
+
You will notice the ink is faint — deliberately so. A placeholder is a hint
|
|
202
|
+
the user is welcome to miss; nothing breaks if they never read it, so it is
|
|
203
|
+
tuned to sit just above invisible rather than to compete with the text they
|
|
204
|
+
type. It comes from the theme (`placeholder_color`), which is also why the
|
|
205
|
+
setter takes a plain `String` and refuses a `StyledString`: the colour is
|
|
206
|
+
the theme's to choose, and one baked in by hand would be stale the moment
|
|
207
|
+
the terminal flipped to light mode.
|
|
208
|
+
|
|
209
|
+
One thing to resist: a placeholder is not a caption in disguise. It is
|
|
210
|
+
tempting in a cramped form to drop the label and let the hint do that work,
|
|
211
|
+
but the hint vanishes the instant the user types a character — and a filled
|
|
212
|
+
form where every field has forgotten what it is called is a form nobody can
|
|
213
|
+
check before submitting. Chapter 5's rule still holds: the caption belongs
|
|
214
|
+
to the layout around the field.
|
|
215
|
+
|
|
141
216
|
## The value seam
|
|
142
217
|
|
|
143
218
|
Every input component — a text field today, a combo box or a date field
|
|
@@ -162,9 +237,11 @@ the input components show both. A combo box's value is kept *quite apart*
|
|
|
162
237
|
from the text — you type a query, but the value is the object you pick. An
|
|
163
238
|
{Tuile::Component::IntegerField}'s value is instead *derived from* the
|
|
164
239
|
text: it holds an `Integer` (or `nil`), parsed from the buffer on demand.
|
|
165
|
-
|
|
166
|
-
quietly refused without so much as nudging the caret
|
|
167
|
-
|
|
240
|
+
The buffer only ever holds digits and at most one leading `-`; anything
|
|
241
|
+
else is quietly refused without so much as nudging the caret — and that
|
|
242
|
+
holds for a *paste* too, which is less obvious than it sounds (see
|
|
243
|
+
*Keeping input out of a field*, below). Up/Down step the number by one (an
|
|
244
|
+
empty field counting as zero). Read `value` and you
|
|
168
245
|
get an `Integer`, or `nil` when the buffer is blank or only half a number
|
|
169
246
|
(a lone `-`). It reports changes as you type, but only when the number
|
|
170
247
|
*itself* changes — padding `7` out to `07` moves the text without moving
|
|
@@ -194,6 +271,345 @@ component with a dependency Tuile itself doesn't carry — `bigdecimal` has
|
|
|
194
271
|
been a *bundled* gem since Ruby 3.4, so an app that uses this field names it
|
|
195
272
|
in its own `Gemfile`, and an app that doesn't never loads it.
|
|
196
273
|
|
|
274
|
+
### A date, where the display is a choice
|
|
275
|
+
|
|
276
|
+
{Tuile::Component::DateField} follows the same naming rule — its value is a
|
|
277
|
+
stdlib `Date`, or `nil` — but it is the first field where *how the value looks*
|
|
278
|
+
is not settled by the type. `42` has one spelling; 4 September 2026 has a dozen.
|
|
279
|
+
So the field holds a list of strftime formats rather than one:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
due = Component::DateField.new
|
|
283
|
+
due.formats = ["%Y-%m-%d", "%d.%m.%Y"] # accepts both
|
|
284
|
+
due.value = Date.new(2026, 9, 4) # shows "2026-09-04", the first one
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Parsing tries them in order and the first whole match wins, while the *first*
|
|
288
|
+
format is also the one the field writes back. That single asymmetry is the whole
|
|
289
|
+
design: the field is lenient about what it accepts and strict about what it
|
|
290
|
+
shows, with no mode flag and no ambiguity about which format is "the" format.
|
|
291
|
+
Adding a format is therefore a UX decision as much as a parsing one — every
|
|
292
|
+
format you accept is a class of complaint that stops happening.
|
|
293
|
+
|
|
294
|
+
Which is why the list is *yours* and Tuile's default is a single ISO format
|
|
295
|
+
rather than a generous handful. It is tempting to ship the generous one, and it
|
|
296
|
+
cannot be done safely: `%m/%d/%Y` and `%d/%m/%Y` both match `04/09/2026` and
|
|
297
|
+
disagree about what it means, and nothing in the framework can tell which
|
|
298
|
+
reading you intended. Guessing would hand a European who typed 4 September a
|
|
299
|
+
field holding April 9 — a wrong value that saves cleanly, which is strictly
|
|
300
|
+
worse than input the user can see is bad. The order of your list is the
|
|
301
|
+
disambiguation, so it has to be your call. An app that wants two-digit-year
|
|
302
|
+
input is out of luck for the same class of reason: `%y` is rejected outright,
|
|
303
|
+
because two characters cannot carry a century and Ruby's fixed 1969–2068 window
|
|
304
|
+
silently misreads everything outside it.
|
|
305
|
+
|
|
306
|
+
The payoff for the reader arrives when they leave the field. Type `4.9.2026`
|
|
307
|
+
into the field above and Tab away, and it rewrites itself as `2026-09-04` —
|
|
308
|
+
**the user sees that the field understood what they typed**, which is what makes
|
|
309
|
+
a multi-format list legible rather than mysterious. Enter does the same, since a
|
|
310
|
+
form whose Save button is reached by Enter never moves focus at all. This is a
|
|
311
|
+
deliberate divergence from `IntegerField`, which leaves a typed `007` alone: a
|
|
312
|
+
format list is a statement that input and display are separate vocabularies,
|
|
313
|
+
which the integer field never made.
|
|
314
|
+
|
|
315
|
+
Two smaller things this field is the first to need. Its empty well paints a hint
|
|
316
|
+
— `yyyy-mm-dd` — *derived from the primary format*, because a date field is
|
|
317
|
+
unguessable in a way an integer field is not, and because the bad-input message
|
|
318
|
+
below deliberately cannot name the formats. And its calendar is proleptic
|
|
319
|
+
Gregorian rather than Ruby's `Date::ITALY` default, which is why `1582-10-10` is
|
|
320
|
+
an ordinary date here and a `Date::Error` in plain Ruby; `calendar_start` is
|
|
321
|
+
there for an app that means the Julian one.
|
|
322
|
+
|
|
323
|
+
### A time of day, where precision is the knob
|
|
324
|
+
|
|
325
|
+
{Tuile::Component::TimeField} is the date field's twin, and it diverges in
|
|
326
|
+
exactly two places worth knowing about.
|
|
327
|
+
|
|
328
|
+
The first is the value. Ruby has no civil-time class — no `LocalTime` — so
|
|
329
|
+
there is nothing to point at the way `DateField` points at `Date`. Tuile does
|
|
330
|
+
not invent one: it owns *UI* value types (`Point`, `Rect`, `Color`, `Theme`)
|
|
331
|
+
and no domain ones, because a time of day lands in your model, your column and
|
|
332
|
+
your serializer, and a Tuile-owned class would be a type none of them speak. So
|
|
333
|
+
the value is a plain `Time` pinned to a fixed epoch date in UTC — the shape
|
|
334
|
+
Rails' `time` column already hands you:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
alarm = Component::TimeField.new
|
|
338
|
+
alarm.set_to(7, 30) # shows "07:30"
|
|
339
|
+
alarm.value # => 2000-01-01 07:30:00 UTC
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
That epoch is the accepted cost, and it is stated rather than hidden: the value
|
|
343
|
+
is a real instant, so it is a perfectly good `Time` that is *wrong* anywhere an
|
|
344
|
+
instant was meant. Combine it with a date at your own boundary. An app that
|
|
345
|
+
forgets gets the year 2000 in its output — visible, which beats subtly wrong.
|
|
346
|
+
|
|
347
|
+
The second divergence is the interesting one. A date has a spelling; a time has
|
|
348
|
+
a spelling **and a precision**, and they are different questions. The spelling —
|
|
349
|
+
`13.45` or `1:45 PM` or `13:45` — is a convention, so it comes from
|
|
350
|
+
`Screen#locale` like the date formats do. The precision is the app's, and it
|
|
351
|
+
rides on `step`:
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
alarm.step = 1 # shows "07:30:00"; Up walks a second
|
|
355
|
+
alarm.step = 60 # shows "07:30"; Up walks a minute — the default
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
One knob doing both jobs looks like a shortcut and is the opposite. Precision is
|
|
359
|
+
a property of the format the buffer is written in, and the buffer is the single
|
|
360
|
+
source of truth for the value — so a second knob for it could disagree with the
|
|
361
|
+
first. Vaadin's `TimePicker` and the HTML `<input type="time">` both tie
|
|
362
|
+
precision to `step` for the same reason. The cost, since it is real: you cannot
|
|
363
|
+
ask for seconds *and* a minute-long stride.
|
|
364
|
+
|
|
365
|
+
Up and Down walk that stride; PageUp and PageDown walk an hour whatever the
|
|
366
|
+
stride is; either pair on an empty field lands on *now*. That is the whole
|
|
367
|
+
picker. There is deliberately no dropdown of times the way Vaadin's `TimePicker`
|
|
368
|
+
has one — a list of times tells you nothing you did not already know (a
|
|
369
|
+
calendar is different: it answers what weekday the 17th is), and with your hands
|
|
370
|
+
already on the keys, typing `1345` beats scrolling to it. Tuile is
|
|
371
|
+
keyboard-first: the mouse gets what falls out of click routing for free and never
|
|
372
|
+
motivates a widget on its own. The ranking behind that is `D_mouse` in
|
|
373
|
+
`design/decisions.md`.
|
|
374
|
+
|
|
375
|
+
What you get for it is that the two questions stay independent. Switching
|
|
376
|
+
precision never touches the spelling, so a Finnish user sees `13.45` and
|
|
377
|
+
`13.45.00` and never a stray colon — which is the failure mode a per-field
|
|
378
|
+
format setter would have, since overriding the format to add seconds would
|
|
379
|
+
throw the locale's separator away with it.
|
|
380
|
+
|
|
381
|
+
Two consequences that follow from the buffer being the truth. Stepping *adds*
|
|
382
|
+
rather than snapping to a grid, and wraps at midnight — `23:59` plus a minute is
|
|
383
|
+
`00:00`, because a clock has no day to carry into. And narrowing the precision
|
|
384
|
+
never silently truncates: `13:45:00` becomes `13:45`, since dropping a zero
|
|
385
|
+
discards nothing, but `13:45:30` stays exactly as typed and reads as bad input,
|
|
386
|
+
because the seconds are something you meant.
|
|
387
|
+
|
|
388
|
+
### Keeping input out of a field
|
|
389
|
+
|
|
390
|
+
Say you want a field that holds only hex digits. The tempting move is to
|
|
391
|
+
work at the keyboard: catch each keystroke, refuse the ones you don't
|
|
392
|
+
want. It seems to work, and it is wrong, for a reason worth
|
|
393
|
+
internalizing: **a paste is not a keystroke**. It arrives as one whole
|
|
394
|
+
`String` (chapter 5), nowhere near your key handling, so a filter written
|
|
395
|
+
there guards typing and waves the same characters through on Ctrl-V.
|
|
396
|
+
Tuile's own numeric fields shipped with exactly that bug — twice, in three
|
|
397
|
+
fields.
|
|
398
|
+
|
|
399
|
+
The lesson generalizes past this one API: filter at the altitude of the
|
|
400
|
+
thing you're constraining. You are constraining the *buffer*, so the seam
|
|
401
|
+
is `insert_text`, where *every* insertion goes — a typed character, a
|
|
402
|
+
pasted clipboard, the newline Enter puts in a text area:
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
class HexField < Tuile::Component::TextField
|
|
406
|
+
protected
|
|
407
|
+
|
|
408
|
+
def insert_text(str)
|
|
409
|
+
super if @text.dup.insert(@caret, str).match?(/\A\h*\z/)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Two things about that method are deliberate. It judges the **result**, not
|
|
415
|
+
the fragment being inserted — sieving a paste character by character would
|
|
416
|
+
turn a pasted `1,5` into `15`, a number the user never copied and can't see
|
|
417
|
+
is wrong, where testing the whole buffer simply drops it. And it filters
|
|
418
|
+
*user input* only: assigning `field.text =` still writes whatever you give
|
|
419
|
+
it, which is what lets a `FloatField` display the `1.0e-05` that
|
|
420
|
+
`value = 1e-5` produces.
|
|
421
|
+
|
|
422
|
+
This kind of filtering only works when the field's grammar is
|
|
423
|
+
**prefix-closed** — when every valid value can be reached through valid
|
|
424
|
+
intermediate states. An integer qualifies: `""`, `-`, `-1`. A *date* does
|
|
425
|
+
not. `2020-13-45` is well-formed at every single character and means
|
|
426
|
+
nothing, and month lengths and leap years are facts about the whole string,
|
|
427
|
+
so no filter over insertions can catch it. A field like that has to accept
|
|
428
|
+
the input and *report* it as bad instead — which is exactly what
|
|
429
|
+
{Tuile::Component::DateField} does: it filters nothing at all, typed or pasted.
|
|
430
|
+
Prevention where it can be total, reporting where it can't; a half-filter is
|
|
431
|
+
the one thing to avoid, because it reads like a guarantee.
|
|
432
|
+
|
|
433
|
+
### Reporting bad input
|
|
434
|
+
|
|
435
|
+
Even a total filter leaves a residue, because the buffers a value is typed
|
|
436
|
+
*through* have to be admitted: an integer field must let you type a lone
|
|
437
|
+
`-` on the way to `-1`. Read `value` at that moment and you get `nil` — and
|
|
438
|
+
`empty?` says `true`, on a field with a glyph in it. Nothing distinguishes
|
|
439
|
+
it from a field the user never touched, and a form that believes `empty?`
|
|
440
|
+
will save `nil` over a number they think they entered.
|
|
441
|
+
|
|
442
|
+
`on_value_change` cannot rescue you here, and it is worth seeing *why* it
|
|
443
|
+
structurally can't: it is a diff over **values**, and every input the value
|
|
444
|
+
can't represent collapses onto the same `nil`. The information was
|
|
445
|
+
destroyed by the parse before the diff ran. Typing `-` into a blank field
|
|
446
|
+
moves the buffer and not the value, so the listener stays silent — correctly.
|
|
447
|
+
|
|
448
|
+
So a field whose parse can fail carries a second, separate question, the
|
|
449
|
+
{Tuile::Component::HasBadInput} mixin:
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
amount = Component::IntegerField.new
|
|
453
|
+
# ... the user types a lone "-"
|
|
454
|
+
amount.value # => nil
|
|
455
|
+
amount.empty? # => true — empty of *value*
|
|
456
|
+
amount.bad_input? # => true — but there is input it could not use
|
|
457
|
+
amount.bad_input_message # => "not a whole number"
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Ask it before you ask `empty?`, and ask it whatever the value says. A form's
|
|
461
|
+
Save handler is the canonical consumer, walking a mixed bag of fields —
|
|
462
|
+
only some kinds can answer at all, which is what the `respond_to?` is for:
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
bad = fields.select { _1.respond_to?(:bad_input?) && _1.bad_input? }
|
|
466
|
+
if bad.any?
|
|
467
|
+
Component::ConfirmWindow.alert("Cannot save", bad.map(&:bad_input_message).join("\n"))
|
|
468
|
+
else
|
|
469
|
+
save!
|
|
470
|
+
end
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Two properties to hold onto. **An empty field is empty, not bad** — a blank
|
|
474
|
+
optional field has to save, so no field reports an empty buffer as bad
|
|
475
|
+
input. And **the answer is derived on read, never stored**, which matters
|
|
476
|
+
because the fact is *continuous*: every prefix of a valid date is bad input,
|
|
477
|
+
so typing `2026-05-01` walks nine bad states before one good one. That makes
|
|
478
|
+
it perfect for a gate you consult at the click and treacherous for anything
|
|
479
|
+
that reacts per keystroke — an error message wired straight to it would flash
|
|
480
|
+
red through the act of typing correctly. Ask at the moment you need the
|
|
481
|
+
answer, and you always get the current one.
|
|
482
|
+
|
|
483
|
+
Which fields carry it is decided by one question: *can my input be something
|
|
484
|
+
my value cannot represent?* The numeric fields say yes, and the date field says
|
|
485
|
+
yes so emphatically that it is the reason this mixin exists. A text field says no —
|
|
486
|
+
its value *is* its input, so nothing can fail. A checkbox or a select says no
|
|
487
|
+
for a different reason: there is nothing between the keystroke and the value
|
|
488
|
+
for a parse to fail in.
|
|
489
|
+
|
|
490
|
+
### Marking a field invalid
|
|
491
|
+
|
|
492
|
+
Bad input is the field's *own* verdict — it owns the format, so it knows. A
|
|
493
|
+
rule's verdict is a different animal: "at least 3 characters", "must be in the
|
|
494
|
+
past", "these two passwords differ". The field cannot compute any of those. It
|
|
495
|
+
cannot even see the sibling the last one compares against.
|
|
496
|
+
|
|
497
|
+
So Tuile splits the job by *who has the cells for it*. The **verdict** — this
|
|
498
|
+
field is invalid — fits the one row the field already owns, because it is a
|
|
499
|
+
restyle of cells the field paints anyway. The **message** does not: it needs
|
|
500
|
+
cells beside the field, which belong to whatever laid the field out. Hence
|
|
501
|
+
{Tuile::Component::HasValidation}, which every field has:
|
|
502
|
+
|
|
503
|
+
```ruby
|
|
504
|
+
login.on_click = lambda do
|
|
505
|
+
username.error_message = username.empty? ? "Username is required" : nil
|
|
506
|
+
password.error_message = password.empty? ? "Password is required" : nil
|
|
507
|
+
next if [username, password].any?(&:error_message)
|
|
508
|
+
|
|
509
|
+
authenticate(username.value, password.value)
|
|
510
|
+
end
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Assigning a message turns the field's **well** red — `Theme#error_bg_color`,
|
|
514
|
+
or `Theme#error_active_bg_color` while it has focus; assigning `nil` clears it.
|
|
515
|
+
Notice there is no layout in that handler — it works the same inside a form, a
|
|
516
|
+
`Layout::Vertical`, or a `Popup` — and no `invalid?` predicate: a non-nil
|
|
517
|
+
message *is* the verdict, so there is only ever one thing to ask, and no
|
|
518
|
+
ambiguity with `bad_input?` next door.
|
|
519
|
+
|
|
520
|
+
A background rather than red text, for a reason worth spelling out: red text
|
|
521
|
+
needs glyphs, and the commonest rule of all — *required* — fires on a field
|
|
522
|
+
that has none. The well is what shows a field's boundary in the first place, so
|
|
523
|
+
an invalid field gets a red one and loses nothing. It takes *two* colors
|
|
524
|
+
because a focused invalid field still has to look focused; a `Select` paints no
|
|
525
|
+
caret, so its well is the only focus indicator it has.
|
|
526
|
+
|
|
527
|
+
Bad input reddens the well too, so a field you cannot save from looks like one
|
|
528
|
+
whether the format or a rule is at fault. That does mean the well is *live*
|
|
529
|
+
where the verdict is discrete: a `FloatField` goes red at the half-typed `1.`
|
|
530
|
+
and back at `1.5`. Brief, and worth it — the well is telling you the field will
|
|
531
|
+
refuse to save in that state.
|
|
532
|
+
|
|
533
|
+
A `DateField` is where that stops being worth it, and the difference is
|
|
534
|
+
instructive. Its residue is not one buffer but *all of them*: `2`, `20`, `202`
|
|
535
|
+
are each bad input on the way to `2026-09-04`, so a live well would hold the
|
|
536
|
+
field red from the first keystroke to the last, saying "you are wrong" when the
|
|
537
|
+
truth is "you are not finished". So the date field **settles**: the well waits
|
|
538
|
+
for a commit gesture — you leave the field, or press Enter — reddens what did
|
|
539
|
+
not parse, and goes quiet again on your next edit. Only the *ink* waits.
|
|
540
|
+
`bad_input?` is still answered from the current buffer the instant you ask it,
|
|
541
|
+
which is what keeps the Save handler above correct with no change at all.
|
|
542
|
+
|
|
543
|
+
There is a second half to this, and it bites harder, because some prefixes of a
|
|
544
|
+
date do not merely fail to parse — they parse *cleanly*. In a `dd.mm.yyyy`
|
|
545
|
+
field, `1.1.2` on the way to `1.1.2024` is the first of January in the year 2:
|
|
546
|
+
a perfectly good `Date`, one `bad_input?` will never flag, and one your
|
|
547
|
+
listener would be handed while the user is still typing, along with whatever
|
|
548
|
+
recalculation hangs off it. So the date and time fields settle their *notice*
|
|
549
|
+
on those same two gestures: `on_value_change` fires when you leave the field or
|
|
550
|
+
press Enter, not as you type. Reading `value` is again unaffected, so a Save on
|
|
551
|
+
a keyboard shortcut that never moves focus still sees the date on screen — and
|
|
552
|
+
a value nobody had to type, a `value=` or an Up/Down step or a `clear`, is
|
|
553
|
+
announced the moment it happens.
|
|
554
|
+
|
|
555
|
+
The ink and the notice settle together because one question decides both, and
|
|
556
|
+
it is the prefix-closed question from a few pages back. Every buffer an
|
|
557
|
+
`IntegerField` passes through really is the number it shows, so `4` on the way
|
|
558
|
+
to `42` is worth announcing and worth reddening. A date's are neither. That one
|
|
559
|
+
property of the grammar settles the filter, the ink and the notice alike.
|
|
560
|
+
|
|
561
|
+
The one discipline the writer owes is visible in those `: nil` branches: **set
|
|
562
|
+
or clear on every pass.** Only assign the message where you validate, and a
|
|
563
|
+
field that has been fixed goes back to normal on its own. Forget the clear and
|
|
564
|
+
a corrected field stays red forever.
|
|
565
|
+
|
|
566
|
+
To show the text, subscribe — and put it in cells you own:
|
|
567
|
+
|
|
568
|
+
```ruby
|
|
569
|
+
error = Component::Label.new
|
|
570
|
+
username.on_error_message_change = ->(msg) { error.text = msg || StyledString::EMPTY }
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
That listener is not decoration. The field repaints *itself* when its verdict
|
|
574
|
+
changes, but it knows nothing about your label, so without the notice your
|
|
575
|
+
message would go stale.
|
|
576
|
+
|
|
577
|
+
Two things follow from the split, both worth knowing before they surprise you.
|
|
578
|
+
An **empty** invalid field has no glyphs to tint — which is exactly the
|
|
579
|
+
required-field case — so on a bare pane the message *is* the whole signal; put
|
|
580
|
+
it somewhere. And the ink is **inherited**, so a composed field like an
|
|
581
|
+
`IntegerField` needs no help: the `TextField` inside it picks the color up from
|
|
582
|
+
its parent, as do the rows of a `RadioGroup`'s list.
|
|
583
|
+
|
|
584
|
+
Where does the caption go, then? The same rule answers it, in the other
|
|
585
|
+
direction: a field can tint the row it has, but it cannot *add* a row for a
|
|
586
|
+
label without displacing the value — so a field carries no caption at all. The
|
|
587
|
+
`Label` beside it is yours (or, one day, a form layout's), which is why every
|
|
588
|
+
form in this book builds its own captions.
|
|
589
|
+
|
|
590
|
+
The sibling seam, one level up, is **which keys the field acts on at all**:
|
|
591
|
+
override `handle_text_input_key?` and call `super` for everything you don't
|
|
592
|
+
claim.
|
|
593
|
+
|
|
594
|
+
```ruby
|
|
595
|
+
class SubmitField < Tuile::Component::TextArea
|
|
596
|
+
protected
|
|
597
|
+
|
|
598
|
+
def handle_text_input_key?(key)
|
|
599
|
+
return super unless key == Tuile::Keys::ENTER
|
|
600
|
+
|
|
601
|
+
submit(text) # Enter submits instead of inserting a newline
|
|
602
|
+
true
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
Both seams are overrides rather than callbacks on purpose, and the reason is
|
|
608
|
+
the same one that runs through this whole chapter: they *compose*. Two
|
|
609
|
+
behaviors can stack through `super`, where one callback slot can only be held
|
|
610
|
+
by one owner — and a key you decline still bubbles to an ancestor
|
|
611
|
+
(chapter 5), which a callback that returned `false` could not arrange.
|
|
612
|
+
|
|
197
613
|
The combo box and the two numeric fields are built the same way, and it's
|
|
198
614
|
worth seeing why: each *wraps* a text field rather than *being* one. A
|
|
199
615
|
subclass would inherit the text field's `String`-typed value and wear it
|
|
@@ -218,6 +634,41 @@ that will one day sit above these components. So the seam is kept thin on
|
|
|
218
634
|
purpose: `on_value_change` carries just the new value, and there's no
|
|
219
635
|
read-only or required flag yet. Room left for that layer to grow into.
|
|
220
636
|
|
|
637
|
+
### Two fields, one value
|
|
638
|
+
|
|
639
|
+
{Tuile::Component::DateTimeField} is the first field made of *fields*: the date
|
|
640
|
+
field and the time field from earlier in this chapter, side by side on one row,
|
|
641
|
+
behind a single `DateTime`.
|
|
642
|
+
|
|
643
|
+
```ruby
|
|
644
|
+
starts = Component::DateTimeField.new
|
|
645
|
+
starts.value = DateTime.new(2026, 9, 14, 13, 45) # [2026-09-14] [13:45]
|
|
646
|
+
starts.date_field.formats = "%d.%m.%Y" # tune a half in place…
|
|
647
|
+
starts.time_field.step = 900 # …rather than through a forwarder
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
The halves are exposed read-only: a child you *tune* but never *supply* is
|
|
651
|
+
reached directly, so there is no second set of names to keep in step — and no
|
|
652
|
+
argument about whether `formats=` on the composite would mean the date's or the
|
|
653
|
+
time's.
|
|
654
|
+
|
|
655
|
+
The value is non-nil only when both halves parse, and a half going bad nils the
|
|
656
|
+
whole thing rather than holding the last good one: a field holds bad input **or**
|
|
657
|
+
a value, never both. What is genuinely new is the question of who goes red, and
|
|
658
|
+
the answer is one sentence — **the composite paints only the fault no half can
|
|
659
|
+
wear**. Garbage in the date half is attributable, so that half reddens itself on
|
|
660
|
+
the latch you saw a moment ago, and the composite paints nothing. A date with no
|
|
661
|
+
time is nobody else's fault, so the composite reddens *whole* — but only once you
|
|
662
|
+
leave it, so it judges you when you are done rather than while you are filling it
|
|
663
|
+
in. A rule's verdict is not attributable either, and reddens whole with no latch
|
|
664
|
+
at all.
|
|
665
|
+
|
|
666
|
+
One wrinkle follows from that. Pressing Enter over a half-filled field reports
|
|
667
|
+
`bad_input?` and its message but does not redden it; the ink waits for you to
|
|
668
|
+
leave. Latching on Enter would reopen exactly the window the rule closes — the
|
|
669
|
+
one where the field tells you that you are wrong when the truth is that you are
|
|
670
|
+
not finished.
|
|
671
|
+
|
|
221
672
|
## Choosing from a set
|
|
222
673
|
|
|
223
674
|
{Tuile::Component::List} is the workhorse: a scrollable column of *items*
|
|
@@ -283,7 +734,9 @@ widget then decorates, a row is the whole rendering.) It's the value seam doing
|
|
|
283
734
|
`value` is the selected *item*, the object and not its label, so a combo
|
|
284
735
|
over `User`s hands back a `User`. The field's text is merely a transient
|
|
285
736
|
query: it reverts to the selection's label when you dismiss the dropdown,
|
|
286
|
-
and only a real commit fires `on_value_change`.
|
|
737
|
+
and only a real commit fires `on_value_change`. Being a text field, it takes
|
|
738
|
+
the editing keys above — Ctrl+U empties a query you want to start over in one
|
|
739
|
+
press, rather than holding Backspace down. The dropdown itself is a
|
|
287
740
|
borderless popup tinted apart from the content beneath it (chapter 6's
|
|
288
741
|
background inheritance again), floating below the field or flipping above
|
|
289
742
|
when it's near the bottom of the screen.
|
|
@@ -567,7 +1020,7 @@ looks like in practice.)
|
|
|
567
1020
|
**A focused button consumes Enter**, and that matters the moment you have
|
|
568
1021
|
more than one. Enter on a focused `Save` activates *that* button — not some
|
|
569
1022
|
form-wide default, because Tuile has no notion of a default button at all.
|
|
570
|
-
The form's Enter-to-submit is a `handle_key
|
|
1023
|
+
The form's Enter-to-submit is a `handle_key?` on the ancestor that owns the
|
|
571
1024
|
form (chapter 5), and it only ever sees Enter when the focused widget
|
|
572
1025
|
declined it. So a dialog's two buttons are just two widgets, and which one
|
|
573
1026
|
Enter hits is simply which one has focus.
|
|
@@ -802,44 +1255,70 @@ on anything it would have to ask the strip — better than quietly answering
|
|
|
802
1255
|
about a tab that is no longer there. Its caption stays readable, so an
|
|
803
1256
|
error message can still name it.
|
|
804
1257
|
|
|
805
|
-
###
|
|
806
|
-
|
|
807
|
-
Here is the part with consequences beyond this widget.
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
something
|
|
833
|
-
`
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1258
|
+
### Two ways to hide something, and why a `TabSheet` picks the other one
|
|
1259
|
+
|
|
1260
|
+
Here is the part with consequences beyond this widget. Hiding a component
|
|
1261
|
+
is one line:
|
|
1262
|
+
|
|
1263
|
+
```ruby
|
|
1264
|
+
company_name.visible = false # gone
|
|
1265
|
+
company_name.visible = true # back, exactly as it was
|
|
1266
|
+
```
|
|
1267
|
+
|
|
1268
|
+
The flag means **gone**, in the sense Android's `GONE` means it: the
|
|
1269
|
+
component paints nothing, takes no space in a box layout, and cannot be
|
|
1270
|
+
reached by Tab, keys, the cursor, the mouse or `Testing.find`. Say it as
|
|
1271
|
+
*as if detached, but it stays in the tree* — because the one thing it keeps
|
|
1272
|
+
that a detached component loses is the whole point. It keeps its parent, its
|
|
1273
|
+
rect, its box constraints, its state, and anything it had running: **no
|
|
1274
|
+
lifecycle hook fires**. A pane holding a subscription goes on holding it
|
|
1275
|
+
while out of sight.
|
|
1276
|
+
|
|
1277
|
+
Two things it is worth being precise about. First, the flag is
|
|
1278
|
+
*ancestor-inclusive*: hide a panel and everything under it goes with it,
|
|
1279
|
+
whatever those components' own flags say — and their flags are remembered,
|
|
1280
|
+
so showing the panel brings back exactly the subtree that was showing
|
|
1281
|
+
before. Second, there is no "invisible but still occupying its space" state,
|
|
1282
|
+
because you already have one: a {Tuile::Component::Slot} with no content
|
|
1283
|
+
holds its rect open and paints nothing.
|
|
1284
|
+
|
|
1285
|
+
The other way to hide something is to take it out of the tree, and it is
|
|
1286
|
+
what a `TabSheet` does: only the selected tab's pane is a child of the
|
|
1287
|
+
sheet, the rest are detached. That is a deliberate choice rather than
|
|
1288
|
+
history, and the reason is the sentence above about hooks — inverted:
|
|
1289
|
+
|
|
1290
|
+
- **`handle_detached` fires when a pane goes away, `handle_attached` when it
|
|
1291
|
+
returns.** A {Tuile::Component::ProgressBar} in a background tab stops its
|
|
1292
|
+
ticker and restarts it on return, with no bookkeeping from you. Hiding
|
|
1293
|
+
would keep it ticking, unseen.
|
|
1294
|
+
- **State survives either way, because state is ivars.** Scroll position,
|
|
1295
|
+
caret, list cursor, typed text: all exactly as the user left them, hidden
|
|
1296
|
+
or detached. You can go on mutating either — `invalidate` is a silent
|
|
1297
|
+
no-op on a detached component, and a hidden one's repaint is dropped at
|
|
1298
|
+
the drain.
|
|
1299
|
+
|
|
1300
|
+
So the question to ask isn't "which is the real way to hide" but **should
|
|
1301
|
+
this thing keep running while nobody can see it?** If yes, hide it. If no —
|
|
1302
|
+
if you'd rather its ticker, its poller or its subscription stopped — detach
|
|
1303
|
+
it, and let the hooks do the work.
|
|
1304
|
+
|
|
1305
|
+
One rule at the seam, worth knowing before you meet it as a bug: focus
|
|
1306
|
+
never stays on something the user cannot see. Hide the subtree that holds
|
|
1307
|
+
focus and it moves to the hidden component's parent, which passes it on to
|
|
1308
|
+
the first reachable field the same way it would after a removal. It is not
|
|
1309
|
+
handed back when the component reappears.
|
|
1310
|
+
|
|
1311
|
+
An overlay is the exception: {Tuile::Component::Popup} and friends refuse
|
|
1312
|
+
`visible=` outright. An overlay is *closed*, not hidden — it already has
|
|
1313
|
+
`open` and `close` for exactly this, and a hidden-but-open modal would go on
|
|
1314
|
+
scoping every key and swallowing every click while painting nothing.
|
|
839
1315
|
|
|
840
1316
|
The `TabSheet` pane in `examples/sampler.rb` demonstrates the state part
|
|
841
1317
|
directly: scroll the prose tab, switch away, come back, and the status line
|
|
842
|
-
under the sheet reports the row you left it on.
|
|
1318
|
+
under the sheet reports the row you left it on. The Shell → Visibility pane
|
|
1319
|
+
demonstrates the flag: tick "Business customer" and two more fields appear
|
|
1320
|
+
in the form, with the rows closing up cleanly when they go and the text you
|
|
1321
|
+
typed into them still there when they come back.
|
|
843
1322
|
|
|
844
1323
|
### When the strip is too narrow
|
|
845
1324
|
|
|
@@ -1258,7 +1737,9 @@ layout) *or* as a popup (via a class-level `open`).
|
|
|
1258
1737
|
presentation from the body's type (an Array is rows, text is prose).
|
|
1259
1738
|
- {Tuile::Component::PickerWindow} — a menu of options each bound to a
|
|
1260
1739
|
single key, firing your block with the picked key. Popped up via `open`,
|
|
1261
|
-
it closes itself after a pick; ESC/`q` cancels without firing.
|
|
1740
|
+
it closes itself after a pick; ESC/`q` cancels without firing. Captions
|
|
1741
|
+
paint in the terminal's own foreground; hand in a {Tuile::StyledString}
|
|
1742
|
+
(or the ANSI string `theme.fg` returns) to color one, per option.
|
|
1262
1743
|
- {Tuile::Component::LogWindow} — a Window framing a
|
|
1263
1744
|
{Tuile::Component::LogTextView}: an auto-scrolling, scrollbar-equipped
|
|
1264
1745
|
TextView purpose-built for log output. The view is where the behavior
|