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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -49
  3. data/README.md +53 -17
  4. data/book/04-event-loop.md +12 -12
  5. data/book/05-focus.md +152 -22
  6. data/book/06-theming.md +105 -25
  7. data/book/07-components.md +531 -50
  8. data/book/08-testing.md +100 -20
  9. data/book/10-locale.md +216 -0
  10. data/book/README.md +19 -9
  11. data/examples/file_commander.rb +14 -5
  12. data/examples/hello_world.rb +17 -4
  13. data/examples/sampler.rb +654 -40
  14. data/lib/tuile/component/abstract_string_field.rb +114 -68
  15. data/lib/tuile/component/abstract_wrapping_field.rb +279 -0
  16. data/lib/tuile/component/big_decimal_field.rb +52 -79
  17. data/lib/tuile/component/button.rb +8 -8
  18. data/lib/tuile/component/checkbox.rb +9 -9
  19. data/lib/tuile/component/checkbox_group.rb +38 -21
  20. data/lib/tuile/component/combo_box.rb +102 -59
  21. data/lib/tuile/component/confirm_window.rb +7 -5
  22. data/lib/tuile/component/date_field.rb +347 -0
  23. data/lib/tuile/component/date_time_field.rb +275 -0
  24. data/lib/tuile/component/float_field.rb +57 -82
  25. data/lib/tuile/component/has_bad_input.rb +88 -0
  26. data/lib/tuile/component/has_caption.rb +8 -0
  27. data/lib/tuile/component/has_content.rb +32 -13
  28. data/lib/tuile/component/has_placeholder.rb +62 -0
  29. data/lib/tuile/component/has_validation.rb +115 -0
  30. data/lib/tuile/component/has_value.rb +28 -1
  31. data/lib/tuile/component/integer_field.rb +51 -78
  32. data/lib/tuile/component/label.rb +7 -39
  33. data/lib/tuile/component/layout/box.rb +90 -19
  34. data/lib/tuile/component/layout.rb +15 -5
  35. data/lib/tuile/component/list.rb +53 -38
  36. data/lib/tuile/component/list_dropdown.rb +7 -3
  37. data/lib/tuile/component/menu_bar/cascade.rb +5 -5
  38. data/lib/tuile/component/menu_bar.rb +18 -18
  39. data/lib/tuile/component/notification.rb +32 -18
  40. data/lib/tuile/component/overlay.rb +26 -8
  41. data/lib/tuile/component/picker_window.rb +27 -8
  42. data/lib/tuile/component/popup.rb +2 -2
  43. data/lib/tuile/component/progress_bar.rb +10 -4
  44. data/lib/tuile/component/radio_group.rb +41 -23
  45. data/lib/tuile/component/select.rb +23 -16
  46. data/lib/tuile/component/slot.rb +3 -3
  47. data/lib/tuile/component/tab_sheet.rb +6 -6
  48. data/lib/tuile/component/tabs.rb +11 -11
  49. data/lib/tuile/component/text_area.rb +26 -18
  50. data/lib/tuile/component/text_field.rb +55 -26
  51. data/lib/tuile/component/text_view.rb +40 -19
  52. data/lib/tuile/component/time_field.rb +479 -0
  53. data/lib/tuile/component/window.rb +26 -13
  54. data/lib/tuile/component.rb +635 -131
  55. data/lib/tuile/event_queue.rb +4 -4
  56. data/lib/tuile/fake_event_queue.rb +1 -1
  57. data/lib/tuile/fake_screen.rb +95 -3
  58. data/lib/tuile/final.rb +75 -0
  59. data/lib/tuile/locale.rb +851 -0
  60. data/lib/tuile/mouse/router.rb +217 -0
  61. data/lib/tuile/mouse.rb +177 -0
  62. data/lib/tuile/screen.rb +219 -68
  63. data/lib/tuile/screen_pane.rb +51 -42
  64. data/lib/tuile/styled_string.rb +5 -5
  65. data/lib/tuile/testing.rb +198 -0
  66. data/lib/tuile/theme.rb +110 -32
  67. data/lib/tuile/version.rb +1 -1
  68. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  69. data/lib/tuile.rb +1 -0
  70. data/sig/tuile.rbs +4595 -825
  71. metadata +14 -9
  72. data/COMPARISON.md +0 -101
  73. data/DECISIONS.md +0 -5422
  74. data/TERMINOLOGY.md +0 -71
  75. data/ideas/arrow-key-navigation.md +0 -221
  76. data/ideas/modal-backdrop.md +0 -24
  77. data/ideas/new-components.md +0 -124
  78. data/ideas/per-component-buffers.md +0 -55
  79. data/lib/tuile/mouse_event.rb +0 -68
@@ -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, and this is where the
121
- design pays off: you customize an input by assigning callbacks, not by
122
- subclassing. `on_change` fires whenever the text changes; `on_escape`
123
- handles ESC (with a sensible default). The subtle one is `on_key` — an
124
- interceptor consulted *before* the input's own key handling, which is the
125
- building block for an autocomplete or slash-command overlay: while the
126
- overlay is open, `on_key` claims Up/Down/Enter/ESC and forwards them to
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
- You may type only digits and at most one leading `-`; anything else is
166
- quietly refused without so much as nudging the caret, and Up/Down step the
167
- number by one (an empty field counting as zero). Read `value` and you
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`. The dropdown itself is a
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` on the ancestor that owns the
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
- ### Hiding a component means detaching it
806
-
807
- Here is the part with consequences beyond this widget. **Tuile has no
808
- visibility flag.** There is no `visible?`, no `display`; the empty rect you
809
- met in chapter 2 gates *painting* and nothing else — an "invisible" widget
810
- with an empty rect is still in the Tab cycle, still a target of the focus
811
- cascades, still answering for the cursor. So hiding, in Tuile, means taking
812
- something out of the tree, and that is exactly what a `TabSheet` does: only
813
- the selected tab's pane is a child of the sheet, and the rest are detached.
814
-
815
- Three things fall out of that, and they're the reason it's the right
816
- mechanism rather than a workaround for a missing feature:
817
-
818
- - **A hidden pane is invisible to everything** — the Tab cycle, focus,
819
- repaint, the cursor, tree walks. Not because anything checks a flag, but
820
- because it isn't there. There is no gate to get wrong.
821
- - **Its state survives, because state is ivars.** Scroll position, caret,
822
- list cursor, typed text: all exactly as the user left them. You can go on
823
- mutating a hidden pane too, with no special handling and no "am I
824
- visible?" check — `invalidate` on a detached component is a silent no-op,
825
- and the sheet assigns it a rect and invalidates it when it comes back.
826
- - **The lifecycle hooks fire on every switch.** `on_detached` when a pane
827
- goes away, `on_attached` when it returns — so a
828
- {Tuile::Component::ProgressBar} in a hidden tab stops its ticker and
829
- restarts it on return, with no bookkeeping from you.
830
-
831
- The cost is the mirror image of that last point: a pane that must keep
832
- something *alive* while hidden can't, because chapter 4's
833
- `on_attached`/`on_detached` contract is exactly what detachment triggers. The
834
- way out is to move the thing that must not stop: give the resource to the
835
- model your pane renders rather than to the pane, and let the pane pick up
836
- its current state on return. That is usually the better shape anyway — if
837
- you're reluctant to let a hidden pane's poller or subscription die, it
838
- probably wanted to outlive the view all along.
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