tuile 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +4266 -226
  5. data/README.md +44 -24
  6. data/TERMINOLOGY.md +22 -7
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +67 -3
  9. data/book/06-theming.md +153 -7
  10. data/book/07-components.md +643 -67
  11. data/book/08-testing.md +94 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/book/10-locale.md +216 -0
  14. data/book/README.md +14 -5
  15. data/examples/file_commander.rb +1 -1
  16. data/examples/sampler.rb +402 -62
  17. data/ideas/arrow-key-navigation.md +2 -2
  18. data/ideas/binder.md +177 -0
  19. data/ideas/composite-field.md +77 -0
  20. data/ideas/focus-accent.md +116 -0
  21. data/ideas/form-layout.md +151 -0
  22. data/ideas/hover/probe.rb +241 -0
  23. data/ideas/hover/probe_spec.rb +82 -0
  24. data/ideas/hover.md +909 -0
  25. data/ideas/modal-backdrop.md +24 -0
  26. data/ideas/new-components.md +49 -29
  27. data/lib/tuile/buffer.rb +51 -3
  28. data/lib/tuile/color.rb +143 -0
  29. data/lib/tuile/color_depth.rb +80 -0
  30. data/lib/tuile/component/abstract_string_field.rb +106 -58
  31. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  32. data/lib/tuile/component/big_decimal_field.rb +52 -79
  33. data/lib/tuile/component/button.rb +3 -3
  34. data/lib/tuile/component/checkbox.rb +3 -3
  35. data/lib/tuile/component/checkbox_group.rb +36 -20
  36. data/lib/tuile/component/combo_box.rb +68 -33
  37. data/lib/tuile/component/confirm_window.rb +442 -0
  38. data/lib/tuile/component/date_field.rb +322 -0
  39. data/lib/tuile/component/float_field.rb +57 -82
  40. data/lib/tuile/component/has_bad_input.rb +88 -0
  41. data/lib/tuile/component/has_caption.rb +8 -0
  42. data/lib/tuile/component/has_content.rb +43 -11
  43. data/lib/tuile/component/has_placeholder.rb +62 -0
  44. data/lib/tuile/component/has_validation.rb +115 -0
  45. data/lib/tuile/component/has_value.rb +28 -1
  46. data/lib/tuile/component/info_window.rb +64 -16
  47. data/lib/tuile/component/integer_field.rb +51 -78
  48. data/lib/tuile/component/label.rb +6 -38
  49. data/lib/tuile/component/layout/box.rb +87 -19
  50. data/lib/tuile/component/layout.rb +13 -13
  51. data/lib/tuile/component/list.rb +11 -6
  52. data/lib/tuile/component/list_dropdown.rb +22 -10
  53. data/lib/tuile/component/log_text_view.rb +71 -0
  54. data/lib/tuile/component/log_window.rb +13 -48
  55. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  56. data/lib/tuile/component/menu_bar.rb +5 -5
  57. data/lib/tuile/component/notification.rb +16 -34
  58. data/lib/tuile/component/overlay.rb +209 -0
  59. data/lib/tuile/component/popup.rb +59 -187
  60. data/lib/tuile/component/progress_bar.rb +1 -1
  61. data/lib/tuile/component/radio_group.rb +39 -22
  62. data/lib/tuile/component/select.rb +26 -10
  63. data/lib/tuile/component/slot.rb +54 -0
  64. data/lib/tuile/component/tab_sheet.rb +0 -11
  65. data/lib/tuile/component/tabs.rb +5 -5
  66. data/lib/tuile/component/text_area.rb +14 -8
  67. data/lib/tuile/component/text_field.rb +42 -15
  68. data/lib/tuile/component/text_view.rb +25 -8
  69. data/lib/tuile/component/time_field.rb +454 -0
  70. data/lib/tuile/component/window.rb +48 -59
  71. data/lib/tuile/component.rb +580 -54
  72. data/lib/tuile/event_queue.rb +21 -1
  73. data/lib/tuile/fake_screen.rb +37 -3
  74. data/lib/tuile/final.rb +75 -0
  75. data/lib/tuile/keys.rb +7 -0
  76. data/lib/tuile/locale.rb +851 -0
  77. data/lib/tuile/screen.rb +251 -55
  78. data/lib/tuile/screen_pane.rb +50 -44
  79. data/lib/tuile/styled_string.rb +40 -7
  80. data/lib/tuile/terminal_background.rb +74 -16
  81. data/lib/tuile/testing.rb +198 -0
  82. data/lib/tuile/theme.rb +100 -10
  83. data/lib/tuile/version.rb +1 -1
  84. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  85. data/lib/tuile.rb +1 -0
  86. data/sig/tuile.rbs +4545 -770
  87. metadata +25 -1
@@ -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,327 @@ 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
+ `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
+ The one discipline the writer owes is visible in those `: nil` branches: **set
544
+ or clear on every pass.** Only assign the message where you validate, and a
545
+ field that has been fixed goes back to normal on its own. Forget the clear and
546
+ a corrected field stays red forever.
547
+
548
+ To show the text, subscribe — and put it in cells you own:
549
+
550
+ ```ruby
551
+ error = Component::Label.new
552
+ username.on_error_message_change = ->(msg) { error.text = msg || StyledString::EMPTY }
553
+ ```
554
+
555
+ That listener is not decoration. The field repaints *itself* when its verdict
556
+ changes, but it knows nothing about your label, so without the notice your
557
+ message would go stale.
558
+
559
+ Two things follow from the split, both worth knowing before they surprise you.
560
+ An **empty** invalid field has no glyphs to tint — which is exactly the
561
+ required-field case — so on a bare pane the message *is* the whole signal; put
562
+ it somewhere. And the ink is **inherited**, so a composed field like an
563
+ `IntegerField` needs no help: the `TextField` inside it picks the color up from
564
+ its parent, as do the rows of a `RadioGroup`'s list.
565
+
566
+ Where does the caption go, then? The same rule answers it, in the other
567
+ direction: a field can tint the row it has, but it cannot *add* a row for a
568
+ label without displacing the value — so a field carries no caption at all. The
569
+ `Label` beside it is yours (or, one day, a form layout's), which is why every
570
+ form in this book builds its own captions.
571
+
572
+ The sibling seam, one level up, is **which keys the field acts on at all**:
573
+ override `handle_text_input_key` and call `super` for everything you don't
574
+ claim.
575
+
576
+ ```ruby
577
+ class SubmitField < Tuile::Component::TextArea
578
+ protected
579
+
580
+ def handle_text_input_key(key)
581
+ return super unless key == Tuile::Keys::ENTER
582
+
583
+ submit(text) # Enter submits instead of inserting a newline
584
+ true
585
+ end
586
+ end
587
+ ```
588
+
589
+ Both seams are overrides rather than callbacks on purpose, and the reason is
590
+ the same one that runs through this whole chapter: they *compose*. Two
591
+ behaviors can stack through `super`, where one callback slot can only be held
592
+ by one owner — and a key you decline still bubbles to an ancestor
593
+ (chapter 5), which a callback that returned `false` could not arrange.
594
+
197
595
  The combo box and the two numeric fields are built the same way, and it's
198
596
  worth seeing why: each *wraps* a text field rather than *being* one. A
199
597
  subclass would inherit the text field's `String`-typed value and wear it
@@ -283,7 +681,9 @@ widget then decorates, a row is the whole rendering.) It's the value seam doing
283
681
  `value` is the selected *item*, the object and not its label, so a combo
284
682
  over `User`s hands back a `User`. The field's text is merely a transient
285
683
  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
684
+ and only a real commit fires `on_value_change`. Being a text field, it takes
685
+ the editing keys above — Ctrl+U empties a query you want to start over in one
686
+ press, rather than holding Backspace down. The dropdown itself is a
287
687
  borderless popup tinted apart from the content beneath it (chapter 6's
288
688
  background inheritance again), floating below the field or flipping above
289
689
  when it's near the bottom of the screen.
@@ -674,6 +1074,39 @@ window.content = Component::List.new.tap { _1.lines = entries }
674
1074
  window.scrollbar = true
675
1075
  ```
676
1076
 
1077
+ ### Reserving a region: `Slot`
1078
+
1079
+ A `Window` has one content region. When *you* build a container with
1080
+ several — a dialog with a message, a button row and maybe a header — give
1081
+ each region a {Tuile::Component::Slot}: a component whose whole job is to
1082
+ hold one child and size it to itself.
1083
+
1084
+ ```ruby
1085
+ @message = Component::Slot.new
1086
+ add(@message, Expand[1]) # the region, wired once at construction
1087
+ @message.content = Component::Label.new("Delete this file?") # the occupant
1088
+ ```
1089
+
1090
+ The reason to bother is an arithmetic problem you'd otherwise have to
1091
+ solve. Children are ordered, and order decides paint order and Tab order —
1092
+ so if you held the message and the buttons as direct children, "where does
1093
+ the message get inserted?" would depend on whether the header happens to be
1094
+ present right now. Inside a slot the answer is always index 0, because the
1095
+ slot itself never leaves the tree. Add regions, reorder them, leave some
1096
+ empty: none of it changes a swap.
1097
+
1098
+ Which leads to the one thing that surprises people: **an empty slot doesn't
1099
+ collapse.** It keeps the rectangle its parent gave it and clears it, so a
1100
+ dialog with no message shows the hole — exactly as it would with an *empty*
1101
+ message. If you want the gap closed, that's the parent's arithmetic (give
1102
+ the slot a zero extent), which is the same top-down rule as everything else
1103
+ in chapter 3. Don't detach the slot to make it go away; that hands you back
1104
+ the insert-index problem it exists to remove.
1105
+
1106
+ A slot is invisible to input: it can't take focus, clicks pass straight
1107
+ through to the occupant, and when an occupant leaves, the focus repair is
1108
+ handed up to your container rather than stranding focus on the slot.
1109
+
677
1110
  ## Switching between views
678
1111
 
679
1112
  When a screen has more content than fits and the parts are *alternatives*
@@ -769,44 +1202,70 @@ on anything it would have to ask the strip — better than quietly answering
769
1202
  about a tab that is no longer there. Its caption stays readable, so an
770
1203
  error message can still name it.
771
1204
 
772
- ### Hiding a component means detaching it
773
-
774
- Here is the part with consequences beyond this widget. **Tuile has no
775
- visibility flag.** There is no `visible?`, no `display`; the empty rect you
776
- met in chapter 2 gates *painting* and nothing else — an "invisible" widget
777
- with an empty rect is still in the Tab cycle, still a target of the focus
778
- cascades, still answering for the cursor. So hiding, in Tuile, means taking
779
- something out of the tree, and that is exactly what a `TabSheet` does: only
780
- the selected tab's pane is a child of the sheet, and the rest are detached.
781
-
782
- Three things fall out of that, and they're the reason it's the right
783
- mechanism rather than a workaround for a missing feature:
784
-
785
- - **A hidden pane is invisible to everything** — the Tab cycle, focus,
786
- repaint, the cursor, tree walks. Not because anything checks a flag, but
787
- because it isn't there. There is no gate to get wrong.
788
- - **Its state survives, because state is ivars.** Scroll position, caret,
789
- list cursor, typed text: all exactly as the user left them. You can go on
790
- mutating a hidden pane too, with no special handling and no "am I
791
- visible?" check — `invalidate` on a detached component is a silent no-op,
792
- and the sheet assigns it a rect and invalidates it when it comes back.
793
- - **The lifecycle hooks fire on every switch.** `on_detached` when a pane
794
- goes away, `on_attached` when it returns — so a
795
- {Tuile::Component::ProgressBar} in a hidden tab stops its ticker and
796
- restarts it on return, with no bookkeeping from you.
797
-
798
- The cost is the mirror image of that last point: a pane that must keep
799
- something *alive* while hidden can't, because chapter 4's
800
- `on_attached`/`on_detached` contract is exactly what detachment triggers. The
801
- way out is to move the thing that must not stop: give the resource to the
802
- model your pane renders rather than to the pane, and let the pane pick up
803
- its current state on return. That is usually the better shape anyway — if
804
- you're reluctant to let a hidden pane's poller or subscription die, it
805
- probably wanted to outlive the view all along.
1205
+ ### Two ways to hide something, and why a `TabSheet` picks the other one
1206
+
1207
+ Here is the part with consequences beyond this widget. Hiding a component
1208
+ is one line:
1209
+
1210
+ ```ruby
1211
+ company_name.visible = false # gone
1212
+ company_name.visible = true # back, exactly as it was
1213
+ ```
1214
+
1215
+ The flag means **gone**, in the sense Android's `GONE` means it: the
1216
+ component paints nothing, takes no space in a box layout, and cannot be
1217
+ reached by Tab, keys, the cursor, the mouse or `Testing.find`. Say it as
1218
+ *as if detached, but it stays in the tree* — because the one thing it keeps
1219
+ that a detached component loses is the whole point. It keeps its parent, its
1220
+ rect, its box constraints, its state, and anything it had running: **no
1221
+ lifecycle hook fires**. A pane holding a subscription goes on holding it
1222
+ while out of sight.
1223
+
1224
+ Two things it is worth being precise about. First, the flag is
1225
+ *ancestor-inclusive*: hide a panel and everything under it goes with it,
1226
+ whatever those components' own flags say — and their flags are remembered,
1227
+ so showing the panel brings back exactly the subtree that was showing
1228
+ before. Second, there is no "invisible but still occupying its space" state,
1229
+ because you already have one: a {Tuile::Component::Slot} with no content
1230
+ holds its rect open and paints nothing.
1231
+
1232
+ The other way to hide something is to take it out of the tree, and it is
1233
+ what a `TabSheet` does: only the selected tab's pane is a child of the
1234
+ sheet, the rest are detached. That is a deliberate choice rather than
1235
+ history, and the reason is the sentence above about hooks — inverted:
1236
+
1237
+ - **`on_detached` fires when a pane goes away, `on_attached` when it
1238
+ returns.** A {Tuile::Component::ProgressBar} in a background tab stops its
1239
+ ticker and restarts it on return, with no bookkeeping from you. Hiding
1240
+ would keep it ticking, unseen.
1241
+ - **State survives either way, because state is ivars.** Scroll position,
1242
+ caret, list cursor, typed text: all exactly as the user left them, hidden
1243
+ or detached. You can go on mutating either — `invalidate` is a silent
1244
+ no-op on a detached component, and a hidden one's repaint is dropped at
1245
+ the drain.
1246
+
1247
+ So the question to ask isn't "which is the real way to hide" but **should
1248
+ this thing keep running while nobody can see it?** If yes, hide it. If no —
1249
+ if you'd rather its ticker, its poller or its subscription stopped — detach
1250
+ it, and let the hooks do the work.
1251
+
1252
+ One rule at the seam, worth knowing before you meet it as a bug: focus
1253
+ never stays on something the user cannot see. Hide the subtree that holds
1254
+ focus and it moves to the hidden component's parent, which passes it on to
1255
+ the first reachable field the same way it would after a removal. It is not
1256
+ handed back when the component reappears.
1257
+
1258
+ An overlay is the exception: {Tuile::Component::Popup} and friends refuse
1259
+ `visible=` outright. An overlay is *closed*, not hidden — it already has
1260
+ `open` and `close` for exactly this, and a hidden-but-open modal would go on
1261
+ scoping every key and swallowing every click while painting nothing.
806
1262
 
807
1263
  The `TabSheet` pane in `examples/sampler.rb` demonstrates the state part
808
1264
  directly: scroll the prose tab, switch away, come back, and the status line
809
- under the sheet reports the row you left it on.
1265
+ under the sheet reports the row you left it on. The Shell → Visibility pane
1266
+ demonstrates the flag: tick "Business customer" and two more fields appear
1267
+ in the form, with the rows closing up cleanly when they go and the text you
1268
+ typed into them still there when they come back.
810
1269
 
811
1270
  ### When the strip is too narrow
812
1271
 
@@ -1008,22 +1467,24 @@ every GUI dismisses its menus on a window resize too.
1008
1467
  The popup itself paints nothing — it's a transparent host that wraps any
1009
1468
  component as its content and manages the lifecycle (`open` / `close`,
1010
1469
  ESC/`q` to dismiss). Crucially, and per chapter 3, **it does not size
1011
- itself to its content**: its box is declared by `size` — a `Fraction`
1470
+ itself to its content**: its box is set by `declared_size` — a `Fraction`
1012
1471
  (default `Fraction::HALF`, half the screen, re-resolved on every resize)
1013
1472
  or an absolute `Size`. The content then fills that box, so use content
1014
1473
  that can cope with overflow — a TextView or TextArea that scrolls, not a
1015
1474
  bare Label that only truncates.
1016
1475
 
1017
- A popup is **modal by default**: centered, it grabs focus, eats keys, and
1476
+ A popup is **always modal**: centered, it grabs focus, eats keys, and
1018
1477
  blocks clicks beneath it — that's what makes an open dialog trap Tab and
1019
- input inside itself. Pass `modal: false` for a non-modal overlay that
1020
- floats above the content without taking focus — the autocomplete-list case
1021
- from earlier, where the caller positions it against a field's caret and
1022
- drives it from app code.
1023
-
1024
- **A left click outside a popup closes it**, modal or not — the same light
1025
- dismissal a desktop dialog gives you. It's a per-popup switch,
1026
- `close_on_outside_click`, on by default; a popup that must survive stray
1478
+ input inside itself. For a layer that floats *without* taking focus — the
1479
+ autocomplete-list case from earlier, where the caller positions it against a
1480
+ field's caret and drives it from app code — use its base class, `Overlay`,
1481
+ directly. An `Overlay` is a Popup minus the modality: same open/close
1482
+ lifecycle, same outside-click dismissal, but it sits at the rect you assign
1483
+ and never disturbs focus or key dispatch.
1484
+
1485
+ **A left click outside an overlay closes it**, modal or not — the same light
1486
+ dismissal a desktop dialog gives you. It's a per-overlay switch,
1487
+ `close_on_outside_click`, on by default; one that must survive stray
1027
1488
  clicks turns it off, as a Notification does. The click still reaches
1028
1489
  whatever was beneath it, unless an open modal swallowed it — in which case
1029
1490
  the first click dismisses and a second one acts.
@@ -1050,6 +1511,116 @@ A nested TextField still swallows printable keys first, so typing `q` into
1050
1511
  a field inside a popup doesn't dismiss it — the popup's own `q` handler sits
1051
1512
  on the ancestor, and only sees keys the field declined.
1052
1513
 
1514
+ ## The confirm dialog
1515
+
1516
+ That Window-in-a-Popup assembly is how you build any dialog. One dialog is
1517
+ so common it comes pre-assembled: {Tuile::Component::ConfirmWindow}, the
1518
+ "are you sure?" box — a caption, a short message, a row of buttons.
1519
+
1520
+ ```ruby
1521
+ Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
1522
+ confirm: "Delete") { delete! }
1523
+ ```
1524
+
1525
+ ```
1526
+ ┌Delete Report Q4?───────────┐
1527
+ │ This cannot be undone. │
1528
+ │ │
1529
+ │ [ Delete ] [ Cancel ] │
1530
+ └────────────────────────────┘
1531
+ ```
1532
+
1533
+ Three factories cover the shapes you'll actually write: `alert(caption,
1534
+ message)` is the one-button acknowledgement, `confirm` the two-button
1535
+ question — its labels are keywords, so it is also your OK/Cancel and
1536
+ Delete/Cancel — and `yes_no` the other canonical phrasing. That's
1537
+ deliberately the whole list: Windows' `MessageBoxButtons` enum grew six
1538
+ values by naming every label pair, and any set the factories don't cover is
1539
+ a few lines of the builder below.
1540
+
1541
+ ### Why a block, and not an answer
1542
+
1543
+ Everyone's first instinct here is the blocking call — `if confirm?("Delete?")`
1544
+ — because that's what Swing's `JOptionPane`, tkinter's `askyesno` and GTK's
1545
+ `dialog.run` all offer. Tuile can't, and it's worth understanding why: the
1546
+ whole UI runs on one thread (chapter 4), so a call that *waits* for the
1547
+ answer would have to nest a second event loop inside the first, re-entering
1548
+ raw mode under a key thread that's already reading stdin. The dialog
1549
+ therefore takes callbacks: the block is the action, and the dialog returns
1550
+ immediately.
1551
+
1552
+ ### One kind of way out
1553
+
1554
+ Every button closes the dialog. A button *with* a block then fires it; a
1555
+ button *without* one is a Cancel. And ESC, `q`, a click outside the box and
1556
+ that Cancel button are all the same event — `on_dismiss`, fired exactly
1557
+ once, and only when no action button was chosen. You never write a `case`
1558
+ over outcomes, and you never have to enumerate the ways out: there is the
1559
+ action you asked about, and there is "do nothing", however the user spells
1560
+ it.
1561
+
1562
+ There is deliberately no way to keep the dialog open after a press. A
1563
+ dialog that leads somewhere — "Copy files" showing a progress window —
1564
+ opens the next window *from its callback*, which is safe because the block
1565
+ fires after the dialog has already closed and focus has been repaired.
1566
+
1567
+ For any other button set, the component is its own builder — buttons are
1568
+ declared one at a time, each a caption and an optional block:
1569
+
1570
+ ```ruby
1571
+ dialog = Component::ConfirmWindow.new("Unsaved changes")
1572
+ dialog.message = "Save your changes before leaving?"
1573
+ dialog.button("Save") { save! }
1574
+ dialog.button("Discard") { discard! }
1575
+ dialog.button("Cancel") # no block: pressing it dismisses
1576
+ dialog.on_dismiss = -> { stay_put }
1577
+ dialog.open
1578
+ ```
1579
+
1580
+ ### Keys, and the underlined letters
1581
+
1582
+ Focus opens on the first button — which, since Enter presses the *focused*
1583
+ button, makes it the default. Left/Right and Tab walk the row; Enter or
1584
+ Space press.
1585
+
1586
+ Each button also answers to a **mnemonic**: a letter, underlined in its
1587
+ caption, that presses the button from anywhere in the dialog. By default
1588
+ it's the caption's first letter (Save gets `s`, Discard `d`), matched
1589
+ case-insensitively; pass `mnemonic:` to pick another letter — the case you
1590
+ give chooses which occurrence gets the underline — or `nil` for none. The
1591
+ underline isn't decoration: Tuile draws no status bar to advertise keys in,
1592
+ so the caption *is* the advertisement.
1593
+
1594
+ Three letters are never mnemonics. `q` is unconditionally the do-nothing
1595
+ route out — a dialog that forces a choice only thinks it does, since the
1596
+ user can always Ctrl+C, and pretending there's no escape route just trains
1597
+ them to reach for it. And `g`/`G` belong to the message: the body scrolls
1598
+ *without taking focus* — Up/Down, PgUp/PgDn, Ctrl+U/D, Home/End and the
1599
+ less-style `g`/`G` are handed to it while a button keeps focus, so a long
1600
+ message reads without any focus gymnastics. The body is also a tab stop:
1601
+ Shift+Tab reaches it, so overflowing prose is visibly reachable, not
1602
+ secretly scrollable.
1603
+
1604
+ ### The popup that sizes itself
1605
+
1606
+ Chapter 3 was firm that nothing in Tuile sizes itself to its content, and a
1607
+ plain Popup takes half the screen whatever it wraps — absurd around a
1608
+ one-line "Delete?". The confirm dialog is the sanctioned exception *shape*:
1609
+ it measures **content it owns** — its caption, its message, its buttons —
1610
+ and asks the screen for exactly that box, still capped at half the screen
1611
+ (a message longer than the cap wraps and scrolls). Assign a `Component` as
1612
+ the message and the measuring honestly gives up: injected content is not
1613
+ the dialog's to measure, so the popup takes the full half-screen box.
1614
+
1615
+ ### What it deliberately isn't
1616
+
1617
+ The message is prose — a `String` or {Tuile::StyledString}, which on a TTY
1618
+ already covers color, emphasis and iconography — not a content slot. A
1619
+ dialog collecting *input* is not a confirm dialog: the moment you want a
1620
+ form, a picker or a diff view in there, you've outgrown the sugar, and the
1621
+ general mechanism is one line away — `Popup.new(content: your_layout)`,
1622
+ exactly as in the previous section.
1623
+
1053
1624
  ## Notifications
1054
1625
 
1055
1626
  {Tuile::Component::Notification} is the one overlay you don't assemble at
@@ -1105,18 +1676,23 @@ The last three components are conveniences: common Window-plus-content
1105
1676
  assemblies you'd otherwise build by hand. Each works tiled (add it to a
1106
1677
  layout) *or* as a popup (via a class-level `open`).
1107
1678
 
1108
- - {Tuile::Component::InfoWindow} — a Window preloaded with a List of
1109
- static lines. The read-only "here's some information" box;
1110
- `InfoWindow.open(caption, lines)` pops it up.
1679
+ - {Tuile::Component::InfoWindow} — a Window with a read-only body, in one
1680
+ of two presentations: `message=` is *prose*, wrapped by a scrollable
1681
+ TextView; `lines=` is *rows*, a List keeping one item per row and
1682
+ truncating — the choice for columnar output, where a wrap would destroy
1683
+ the alignment. `InfoWindow.open(caption, body)` pops it up, picking the
1684
+ presentation from the body's type (an Array is rows, text is prose).
1111
1685
  - {Tuile::Component::PickerWindow} — a menu of options each bound to a
1112
1686
  single key, firing your block with the picked key. Popped up via `open`,
1113
1687
  it closes itself after a pick; ESC/`q` cancels without firing.
1114
- - {Tuile::Component::LogWindow} — a Window wrapping an auto-scrolling,
1115
- scrollbar-equipped TextView, purpose-built for log output. Its `log`
1116
- method is **thread-safe** — it marshals the append back onto the UI
1117
- thread via the event queue (chapter 4), so background work can log
1118
- freely. And it carries an `IO`-shaped adapter so you can point a stdlib
1119
- `Logger` (or a `TTY::Logger`) straight at it:
1688
+ - {Tuile::Component::LogWindow} — a Window framing a
1689
+ {Tuile::Component::LogTextView}: an auto-scrolling, scrollbar-equipped
1690
+ TextView purpose-built for log output. The view is where the behavior
1691
+ lives — compose it bare into a layout when you don't want the frame.
1692
+ Its `log` method is **thread-safe** — it marshals the append back onto
1693
+ the UI thread via the event queue (chapter 4), so background work can
1694
+ log freely. And the view carries an `IO`-shaped adapter so you can point
1695
+ a stdlib `Logger` (or a `TTY::Logger`) straight at either of them:
1120
1696
 
1121
1697
  ```ruby
1122
1698
  window = Component::LogWindow.new