tuile 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/DECISIONS.md +3181 -41
  4. data/README.md +25 -5
  5. data/TERMINOLOGY.md +17 -3
  6. data/book/05-focus.md +63 -2
  7. data/book/06-theming.md +55 -7
  8. data/book/07-components.md +474 -48
  9. data/book/08-testing.md +78 -0
  10. data/book/10-locale.md +216 -0
  11. data/book/README.md +14 -5
  12. data/examples/sampler.rb +265 -25
  13. data/ideas/binder.md +177 -0
  14. data/ideas/composite-field.md +77 -0
  15. data/ideas/focus-accent.md +116 -0
  16. data/ideas/form-layout.md +151 -0
  17. data/ideas/hover/probe.rb +241 -0
  18. data/ideas/hover/probe_spec.rb +82 -0
  19. data/ideas/hover.md +909 -0
  20. data/ideas/new-components.md +26 -6
  21. data/lib/tuile/component/abstract_string_field.rb +106 -58
  22. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  23. data/lib/tuile/component/big_decimal_field.rb +52 -79
  24. data/lib/tuile/component/checkbox_group.rb +36 -20
  25. data/lib/tuile/component/combo_box.rb +59 -31
  26. data/lib/tuile/component/date_field.rb +322 -0
  27. data/lib/tuile/component/float_field.rb +57 -82
  28. data/lib/tuile/component/has_bad_input.rb +88 -0
  29. data/lib/tuile/component/has_caption.rb +8 -0
  30. data/lib/tuile/component/has_content.rb +29 -10
  31. data/lib/tuile/component/has_placeholder.rb +62 -0
  32. data/lib/tuile/component/has_validation.rb +115 -0
  33. data/lib/tuile/component/has_value.rb +27 -0
  34. data/lib/tuile/component/integer_field.rb +51 -78
  35. data/lib/tuile/component/label.rb +6 -38
  36. data/lib/tuile/component/layout/box.rb +87 -19
  37. data/lib/tuile/component/layout.rb +13 -3
  38. data/lib/tuile/component/list.rb +11 -6
  39. data/lib/tuile/component/list_dropdown.rb +4 -0
  40. data/lib/tuile/component/overlay.rb +17 -0
  41. data/lib/tuile/component/radio_group.rb +39 -22
  42. data/lib/tuile/component/select.rb +12 -4
  43. data/lib/tuile/component/text_area.rb +14 -8
  44. data/lib/tuile/component/text_field.rb +42 -15
  45. data/lib/tuile/component/text_view.rb +25 -8
  46. data/lib/tuile/component/time_field.rb +454 -0
  47. data/lib/tuile/component/window.rb +26 -13
  48. data/lib/tuile/component.rb +469 -73
  49. data/lib/tuile/fake_screen.rb +11 -1
  50. data/lib/tuile/final.rb +75 -0
  51. data/lib/tuile/locale.rb +851 -0
  52. data/lib/tuile/screen.rb +131 -17
  53. data/lib/tuile/screen_pane.rb +13 -9
  54. data/lib/tuile/testing.rb +198 -0
  55. data/lib/tuile/theme.rb +100 -10
  56. data/lib/tuile/version.rb +1 -1
  57. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  58. data/lib/tuile.rb +1 -0
  59. data/sig/tuile.rbs +3398 -412
  60. metadata +18 -1
@@ -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.
@@ -802,44 +1202,70 @@ on anything it would have to ask the strip — better than quietly answering
802
1202
  about a tab that is no longer there. Its caption stays readable, so an
803
1203
  error message can still name it.
804
1204
 
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.
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.
839
1262
 
840
1263
  The `TabSheet` pane in `examples/sampler.rb` demonstrates the state part
841
1264
  directly: scroll the prose tab, switch away, come back, and the status line
842
- 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.
843
1269
 
844
1270
  ### When the strip is too narrow
845
1271
 
data/book/08-testing.md CHANGED
@@ -95,6 +95,84 @@ lives in the buffer; cursor behavior lives in `prints`. Keeping the two
95
95
  apart is deliberate, and mixing them up is the most common way a first
96
96
  Tuile test goes wrong.
97
97
 
98
+ ## Finding the component to drive
99
+
100
+ Asserting on the buffer needs a `rect`; driving a component needs the
101
+ component itself. For a two-line test you have it already — you just built
102
+ it. The awkward case is the one that shows up as soon as an app grows: the
103
+ widget you want to poke is four levels down inside something a *builder
104
+ method* assembled, and the test never held a reference to it.
105
+
106
+ {Tuile::Testing} is the answer. `Testing.get` walks the tree and returns the
107
+ one component matching a spec:
108
+
109
+ ```ruby
110
+ Testing.get(Component::Button, caption: "Save").handle_key(Keys::ENTER)
111
+ Testing.get(id: :amount).value = 42
112
+ ```
113
+
114
+ The spec is a class, an `id`, a caption, a block, or any combination of
115
+ them — never a path through the hierarchy, which would break every time you
116
+ nested one more layout. The class slot also takes a *mixin*, which is where
117
+ the `Has*` family from chapter 7 pays off a second time:
118
+ `Testing.find(Component::HasBadInput)` finds every field in the tree whose
119
+ parse can fail, whatever their classes.
120
+
121
+ The `id` in that second line is a plain `Symbol` tag you set on any
122
+ component, purely so a test can ask for it back:
123
+
124
+ ```ruby
125
+ amount = Component::IntegerField.new
126
+ amount.id = :amount # nothing paints this, and nothing reads it
127
+ ```
128
+
129
+ `get` is strict on purpose: it raises unless *exactly one* component
130
+ matches. That is the whole feature. The obvious thing to write by hand is a
131
+ walk that takes the first match —
132
+
133
+ ```ruby
134
+ combo = nil
135
+ window.on_tree { |c| combo ||= c if c.is_a?(Component::ComboBox) }
136
+ ```
137
+
138
+ — and the day the pane grows a second `ComboBox`, that silently re-points
139
+ your test at a different widget. Nothing fails; the assertions just start
140
+ describing something else. `get` calls that ambiguity what it is, and the
141
+ failure comes with a dump of the tree it searched:
142
+
143
+ ```
144
+ expected 1 Component::ComboBox, found 2
145
+ searched:
146
+ #<ScreenPane rect=(0,0 160x50)>
147
+ #<Window rect=(0,0 40x10) caption="Settings">
148
+ → #<ComboBox rect=(1,1 38x1) value=nil>
149
+ → #<ComboBox rect=(1,2 38x1) value="dark">
150
+ ```
151
+
152
+ Which usually tells you the fix immediately: narrow the search. Every
153
+ lookup takes `in:` to scope it to a subtree, and by default searches the
154
+ whole screen — popups included, since chapter 1's pane holds them under the
155
+ same root as the content.
156
+
157
+ ```ruby
158
+ Testing.get(Component::ComboBox, in: sampler.demo_window)
159
+ ```
160
+
161
+ Its sibling `Testing.find` returns *all* matches as an array, and takes an
162
+ optional `count:` — an Integer for exactly, a Range for a bound — so an
163
+ assertion about how many of something exists is one call:
164
+
165
+ ```ruby
166
+ Testing.find(Component::Checkbox, in: form, count: 3) # raises unless 3
167
+ ```
168
+
169
+ Two habits worth forming. Call these qualified, as `Testing.get(...)`:
170
+ `find` and `get` are the most collision-prone names in a spec suite, so
171
+ Tuile deliberately neither installs them on `Component` nor asks you to mix
172
+ the module in. And remember this is *additive* — it makes driving a tree
173
+ terser, and changes nothing about the assertion channel. What a component
174
+ *shows* is still asserted on the buffer.
175
+
98
176
  ## Driving the system
99
177
 
100
178
  There are two altitudes at which you feed input, and picking the right one