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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18944d23fd95ada3802f613eb6ff6847e845a082e1b155f2ed9d72e324181179
4
- data.tar.gz: 33c476d0d194ae365a95e0ec65f9652fb8da44ba566e34e4a0c99a043747080a
3
+ metadata.gz: 442d863affeb579ca0fdc7d99531c0d255a9c6129c82c09e0a91ebbb1404e651
4
+ data.tar.gz: a502cb86f7c2c9fc0c871699838c223b6076ec98e9099ef1771f8e0f3ef973f1
5
5
  SHA512:
6
- metadata.gz: 5e7a9b67f3530b86123933ccfaab6116b0aa0c4031f03e81c6b60094f6196aaa8f8adbc86634f4cc052048a1b15078ed5230983a3fff0ba7186d7d8b3d1e2d71
7
- data.tar.gz: ae43fd49b5217606f88d3b7d631c89c8f3c86b82882c0850b6c6980ce100cdbaefd9237da9ea148680775d1c3a980b708934fad0ab71616de3560ea35804602f
6
+ metadata.gz: cc042252f3248883da046ad9328417fe7bc609314b1633a56751bbf62608c0c5e1142dd943bd33027b2d1da0661dadea4306442db8d8ea800bd96a5e882765f5
7
+ data.tar.gz: '09e3aecaedcf12cfdb0da848fc92baec87c393c990a927f58f7c2796c19734032fc7fe8ab2f3678b9433c98579dbc6c1953e08040b7316c75c353e49a9434f15'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,115 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.16.0] - 2026-09-18
4
+
5
+ 0.16.0 settles two vocabularies. Every override point is now `handle_foo` and
6
+ every listener slot `on_foo=`, with a trailing `?` marking exactly the handlers a
7
+ dispatcher routes, so a name says what it is and what it owes. The mouse is
8
+ rebuilt on that footing: `Tuile::Mouse` gives each wire event its own class, and
9
+ `Mouse::Router` owns every walk — resolving the path under the pointer, focusing,
10
+ bubbling a press to one claimant and holding the grab until the release — so no
11
+ widget hit-tests or `super`s a walk of its own again.
12
+
13
+ - Add `Tuile::Mouse` — one class per mouse event, `DownEvent` / `UpEvent` / `ScrollEvent` / `MoveEvent` off the wire plus the router-made `DragEvent` (which `Screen#handle_mouse` refuses, naming the `MoveEvent` to post instead), each including the `Mouse::Event` marker and its `point`; `Mouse.parse` replaces `MouseEvent.parse`. See `design/decisions.md` `D_mouse_dispatch`.
14
+ - Add `Tuile::Mouse::Router` — the dispatcher that owns every mouse walk: it resolves the path under the pointer, focuses, bubbles a press to one claimant, holds the grab and diffs the hovered chain. `Screen#handle_mouse` is its public door. See `design/decisions.md` `D_mouse_dispatch`.
15
+ - Add `Screen#grabbed` and `Screen#hovered` — the component holding the mouse grab, and the innermost one under the pointer.
16
+ - Add `capture_mouse: :drag` / `:hover` to `Screen#run_event_loop` — the rungs unlocking `handle_mouse_drag` (mode 1002) and `handle_mouse_move?` with the enter/exit hooks (1003). `true` still means `:clicks`, and an unknown level now raises. See `design/research.md` `R_mouse_reporting`.
17
+ - Add `FakeScreen#click` / `#press` / `#release` / `#scroll` / `#move` / `#drag` — a spec posts the gesture the terminal would, at screen coordinates, instead of calling a handler; `#drag` plays press-moves-release over the points handed it and interpolates none.
18
+ - Add a *Mouse* pane to `examples/sampler.rb` — a canvas that strokes `X` on a drag, trails on a hover and lifts on a right-drag, beside a log of the discrete events and a live pointer row; it takes the sampler to `capture_mouse: :hover`.
19
+ - Add `Component::DateTimeField` — a `DateField` and a `TimeField` side by side on one row behind a single `DateTime` value, with the two halves exposed read-only as `date_field` / `time_field` for tuning. See `design/decisions.md` `D_date_time_field`.
20
+ - Add `Component#clear_inside_extent` — the protected hook a container overrides to decline the default's blank of its extent, so ink it paints into a face cell no child covers stays off the wire. See `design/decisions.md` `D_extent`.
21
+ - Add `Component::AbstractWrappingField#notify_on_edit?` — the protected hook a field whose grammar is not prefix-closed answers `false`, settling `on_value_change` onto the commit gestures instead of firing it per edit. See `design/decisions.md` `D_date_field`.
22
+ - Add `Component#on_theme_changed` and `#on_locale_changed` readers — every listener slot is now a full `attr_accessor`, the two dual names that forced `attr_writer` having been renamed away. See `design/decisions.md` `D_handler_naming`.
23
+ - Add `Component::Notification::View` — the toast's own `TextView`, which refuses the wheel so queued messages wait for the ticker rather than becoming mouse-only scrollable text. See `design/decisions.md` `D_notification`.
24
+ - Fix `Component#repaint` leaving the cells of a declared `extent` that no child covers — a `Layout::Box`'s `spacing` column, its trailing slack, a hidden child's abandoned span — unblanked, so they kept stale glyphs across a resize. See `design/decisions.md` `D_extent`.
25
+ - **Breaking:** `Component#handle_mouse` is gone, and with it the walk every override had to `super` into: a component overrides one handler per event, three routed (`handle_mouse_down?`, `handle_mouse_scroll?`, `handle_mouse_move?`) and four not (`handle_mouse_up`, `handle_mouse_drag`, `handle_mouse_enter`, `handle_mouse_exit`). Move an old body into `handle_mouse_down?`, drop the `super` and the hit test the router now does, and return `true` when you acted. See `design/decisions.md` `D_mouse_dispatch`.
26
+ - **Breaking:** `Tuile::MouseEvent` is gone, replaced by one class per wire event under `Tuile::Mouse`. Build a press as `Mouse::DownEvent.new(:left, x, y)`, a release as `Mouse::UpEvent.new(x, y)` — it carries no button — and read `direction` on a `Mouse::ScrollEvent` where you read `button: :scroll_up`.
27
+ - **Breaking:** Click-to-focus moved into `Mouse::Router`, which focuses the innermost `focusable?` under the pointer before any handler runs. Delete a widget's own `extent_rect` hit test: a press only reaches it where the extent contains the point.
28
+ - **Breaking:** A press is claimed by exactly one component and stops there, where it used to reach every level containing the point, and the claimant holds the mouse grab until the release, any key or the next press. A container acting on presses aimed at its children must claim them itself.
29
+ - **Breaking:** `ScreenPane#handle_mouse` is gone; the pane answers the router's two tree questions instead, `#mouse_root_at` and `#dismissing_popups_outside`. Outside-click dismissal is unchanged.
30
+ - **Breaking:** `List::Cursor#handle_mouse` is now `#handle_mouse_down?`, so a custom cursor strategy renames its override. `List` and `TextView` now decline a wheel notch they cannot act on, which bubbles it to an ancestor scroller instead of swallowing it.
31
+ - **Breaking:** Every override hook is now `handle_*`, so `on_` names a listener slot and nothing else: `on_attached`, `on_detached`, `on_focus`, `on_blur`, `on_child_removed`, `on_child_visibility_changed`, `on_width_changed`, `on_text_mutated`, `on_caret_mutated`, `on_editor_change`, `on_half_change`, `on_theme_changed`, `on_locale_changed`, and `Screen`'s `on_color_scheme` / `on_background_color`. Rename each override and call `super` from it; every `on_foo=` slot keeps its name, so `label.on_theme_changed = …` is untouched. See `design/decisions.md` `D_handler_naming`.
32
+ - **Breaking:** The routed handlers that return a verdict now say so with a `?` — `Component#handle_key?`, `Screen#handle_key?`, `ScreenPane#handle_key?`, `AbstractStringField#handle_text_input_key?` and `MenuBar#handle_mnemonic?`. Rename each override and each direct call, `Testing.get(…).handle_key?(Keys::ENTER)` included; the return values are unchanged. See `design/decisions.md` `D_handler_naming`.
33
+ - **Breaking:** `Component#handle_paste` returns `void` rather than a Boolean. Drop the trailing `true` from an override; nothing read the answer. See `design/decisions.md` `D_bracketed_paste`.
34
+ - **Breaking:** `Component#on_tree` and `#on_shown_tree` are now `#walk_tree` and `#walk_shown_tree` — the `on_` prefix is reserved for listener slots, and these are traversals, not events. Rename the call; nothing else about either walk changed.
35
+ - **Breaking:** `EventQueue#on_loop_thread?` is now `#in_loop_thread?`, for the same reserved prefix. Same answer, same semantics — only the spelling moved.
36
+ - **Breaking:** `Component::DateField` and `Component::TimeField` fire `on_value_change` at the commit gestures — leaving the field, or ENTER — rather than per keystroke. A listener wanting the live reading polls `value`; a `value=`, an arrow-key step and a `clear` still announce themselves as they happen. See `design/decisions.md` `D_date_field`.
37
+ - **Breaking:** The contributor docs moved under `design/` and no longer ship in the packaged gem — `DECISIONS.md`, `TERMINOLOGY.md`, `RELEASING.md` and `ideas/` are now `design/decisions.md`, `terminology.md`, `releasing.md` and `design/ideas/`. Update any link you kept and read them on GitHub; nothing under `lib/`, `book/`, `examples/` or `sig/` changed.
38
+ - **Breaking:** `COMPARISON.md` and `design/requirements.md` are retired — the toolkit survey is now `design/research.md`'s `R_ruby_tui_toolkits` / `R_ratatui` / `R_charm_ruby` / `R_curses_bindings` entries, and the one requirement is the **Promises** section of `AGENTS.md`.
39
+ - **Breaking:** Remove `Theme#hint_color` and `Theme#hint` — a "de-emphasized text" shade is the global fg token Tuile declines to carry. Carry it as a `custom` token instead and render it with `Theme#fg(:hint, text)`, pairing it in a `ThemeDef` so it survives an appearance flip. See `design/decisions.md` `D_no_hint_color`.
40
+ - **Breaking:** `lib/tuile/AGENTS.md` and `lib/tuile/component/layout/AGENTS.md` are retired, and the two that remain trade their per-file lists for a sub-directory map. Their rules already lived in each symbol's rdoc and `design/decisions.md` `D_box_layouts`; `ls` is the file index.
41
+ - **Breaking:** `Component::ComboBox` no longer includes `Component::HasContent` — its inner `TextField` holds a transient query it owns, not content you populate — so `combo.content` and `combo.content=` are gone. A spec reaches the field with `Testing.get(Component::TextField, in: combo)`. See `design/decisions.md` `D_has_content`.
42
+ - **Breaking:** `Component::PickerWindow` no longer colors option captions; they paint in the terminal's own foreground. `Option#caption` is now a `StyledString`, coerced through `StyledString.parse`, so hand in a `StyledString` (or the ANSI String `Theme#fg` returns) to color one — per option.
43
+
44
+ ## [0.15.0] - 2026-09-05
45
+
46
+ 0.15.0 is about the form. A field can now report input its type cannot
47
+ represent (`HasBadInput`), hold an outside validator's verdict
48
+ (`HasValidation`), paint either as a red well, hint at what it wants
49
+ (`HasPlaceholder`), and spell dates and times the way the session's new
50
+ `Tuile::Locale` says — `DateField` and `TimeField` are the two new ones, over a
51
+ shared `AbstractWrappingField`. Under them, two seams the widgets had been
52
+ improvising become framework: a background that resolves down the tree through
53
+ `default_bg_color` and `BG_INHERIT`, and `Component#visible=`, which hides a
54
+ component as if it were detached while it stays in the tree. And `Tuile::Testing`
55
+ lets a spec drive that UI by looking components up instead of reading the
56
+ painted buffer.
57
+
58
+ - Add `Component#visible=` / `#visible?` — hides a component as if it were detached while it stays in the tree: it paints nothing, takes no space in a `Layout::Box`, and is unreachable by focus, Tab, keys, the mouse and `Testing.find`, while keeping its parent, rect, state and any running resource. See `design/decisions.md` `D_visibility` and book ch7.
59
+ - Add `Component#on_shown_tree` — `on_tree` with hidden subtrees pruned; the walk every "can the user reach it" question takes, and the one a new focus traversal must use.
60
+ - Add `Component#on_child_visibility_changed` — the protected notice a container with layout arithmetic overrides to re-divide space when a direct child is hidden or shown; `Layout::Box` relayouts from it.
61
+ - Add a visibility rule to `Testing.find` / `.get`: they simulate a user, so a hidden component is never returned, and a failed lookup counts the hidden matches it excluded. `Screen#focused=` likewise raises on a hidden target, as it already did on a detached one.
62
+ - Add `Tuile::Testing` — `Testing.get` / `.find`, which locate a component in the tree by class, mixin, `id`, caption or a block, so a spec can drive the UI instead of only reading the painted buffer. A failed lookup dumps the tree it searched. See `design/decisions.md` `D_component_lookup`.
63
+ - Add `Component#id` — a `Symbol` tag for finding a component again; nothing paints it and nothing enforces uniqueness, since `Testing.get` raising on two matches is the enforcement.
64
+ - Add `Component#inspect` — one line naming the class, `id`, rect and each mixin's contribution via the protected `inspect_details` hook, replacing an `Object#inspect` that walked `parent`, `children` and the `Screen`.
65
+ - Add `Tuile::Final` — the `final` keyword Ruby lacks: a class `extend`s it, marks the methods a subclass may not redefine, and calls `verify_final!` from its own `initialize`. See `design/decisions.md` `D_final_tree`.
66
+ - Add `Component#default_bg_color` — a protected hook where a widget declares the background it paints itself; the level the resolution chain was missing, sitting under `bg_color` and over the parent's. See `design/decisions.md` `D_bg_surface`.
67
+ - Add `Component::BG_INHERIT` — assign it to `bg_color` to contribute no background of your own and take what surrounds you, skipping this component's `default_bg_color`; it is how a widget owned by a bigger one drops its well. See `design/decisions.md` `D_bg_surface`.
68
+ - Add a state-keyed form of `Component#bg_color`: `{ normal: …, active: … }` over `Component::BG_STATES`, so an app can keep a focus shade of its own rather than flattening one. An absent key falls through to the next level of the chain.
69
+ - Add `Component::HasBadInput` — `bad_input?` / `bad_input_message`, the report a field owes when its input is something its value cannot represent (a lone `-` in an `IntegerField` reads `nil`, exactly like an empty one). Included by the three numeric fields; ask it before `empty?`. See `design/decisions.md` `D_bad_input` and book ch7.
70
+ - Add `Component::HasValidation` — `error_message` plus `on_error_message_change`, the verdict slot a validator writes and the field shows as a red well; included by `HasValue`, so every field has it, and `bad_input?` reddens the well too. See `design/decisions.md` `D_has_validation` and book ch7.
71
+ - Add `Component#error_bg_color` — a protected hook for the background a component paints while signalling an error, resolving *above* `bg_color` so tinting a panel cannot switch the signal off on the fields inside it.
72
+ - Add `VerticalScrollBar.handle_char=` / `.track_char=` — the two bar glyphs as an app-global look-and-feel knob (`▐` over `│` for a lazygit-style bar); a glyph that is not exactly one cluster one column wide is rejected at assignment.
73
+ - Add `Component::HasPlaceholder` — `placeholder=` paints a hint into a field's empty well (`dd.mm.yyyy`) in the new barely-visible `Theme#placeholder_color`, never touching the value; carried by `TextField`, forwarded by the four composed fields. See `design/decisions.md` `D_placeholder` and book ch7.
74
+ - Add `Component::AbstractWrappingField` — the base for a field that carries a typed value but paints nothing itself, owning and hiding one inner editor, committing on both commit gestures and offering subclasses an `on_editor_change` hook; `IntegerField`, `FloatField` and `BigDecimalField` are built on it. See `design/decisions.md` `D_wrapping_field`.
75
+ - Add `Component::DateField` — a typed `Date`/`nil` field over a list of strftime `formats` taken from `Screen#locale`: parsing tries them in order, leaving the field rewrites the buffer in the first, Up/Down step a day, and unparseable input is reported through `bad_input?` rather than filtered. See `design/decisions.md` `D_date_field` and book ch7.
76
+ - Add `Component::TimeField` — a typed `Time`/`nil` time of day on a fixed epoch date, spelled the way `Screen#locale` says: `step` is both the Up/Down stride and the precision, PageUp/PageDown step an hour, and unparseable input is reported through `bad_input?`. See `design/decisions.md` `D_time_field` and book ch7.
77
+ - Add `Locale#time_formats` — the strftime patterns a time field accepts, detected from glibc's `t_fmt` and carrying its full precision, since dropping the seconds is a form field's policy and a clock display wants them. See `design/decisions.md` `D_time_field` and book ch10.
78
+ - Add `Component::HasBadInput#bad_input_settled?` — the protected gate deciding whether bad input may paint the invalid well *yet*; `true` by default (the numeric fields redden as you type), latched to the commit gestures by `DateField`, whose every prefix is bad input. The `bad_input?` pull is unaffected. See `design/decisions.md` `D_has_validation`.
79
+ - Add `Tuile::Locale` — a frozen value type of formatting conventions (date formats, calendar, month/weekday names, decimal separator), detected from `locale -k` by `Locale.system` and never holding prose. `Locale::ISO` is the only preset and the fallback. See `design/decisions.md` `D_locale` and book ch10.
80
+ - Add `Screen#locale` / `#locale=` — the session's conventions, seeded at construction; assigning fires `Component#on_locale_changed` across the tree and repaints it, so a `DateField` respells the dates it holds. See `design/decisions.md` `D_locale`.
81
+ - Add `Component#on_locale_changed` — the protected hook (plus an assignable listener) for state derived from the old conventions and *pushed* somewhere; anything read at paint or parse time needs nothing, since the locale change repaints everything.
82
+ - Add `Screen.instance?` — whether `Screen.instance` would answer rather than raise, for code that must work with no screen in the process, as a detached component tree legitimately is.
83
+ - Add `Component::DateField#calendar_start` / `#calendar_start=` — the calendar the field parses and formats against, inheriting `Screen#locale` until you set one and again when set back to `nil`; proleptic Gregorian by default, not Ruby's `Date::ITALY`. See `design/decisions.md` `D_date_field`.
84
+ - Add a `DateField` pane to `examples/sampler.rb` under Input → Typed, where typing `4.9.2026` and Tabbing away shows the buffer rewrite.
85
+ - Add readline's kill keys to both text inputs (`AbstractStringField`): `Ctrl+W` deletes the word behind the caret, `Ctrl+U` everything before it — back to index 0 in a `TextField`, to the caret's row start in a `TextArea`. See `design/decisions.md` `D_kill_keys` and book ch7.
86
+ - Add `Component#invalidate_children` — the protected repaint cascade as a named call, so a container that paints its own rect can skip the default's blanket clear without silently dropping the one line it may not.
87
+ - Add an `at:` keyword to `Component::Layout::Box#add`, placing the child at an index instead of appending, so a pane hidden with `#remove` goes back where it was. See `design/decisions.md` `D_empty_ancestor`.
88
+ - Add `Component::Layout::Box#constrain` — re-constrains a child already added, a `nil` axis keeping whatever it had, so a pane can be collapsed with `Fixed[0]` without removing and re-adding it.
89
+ - Add `Component#on_blur` — the protected mirror of `on_focus`, fired on the component that just lost focus, so a field can commit or canonicalize what the user typed even when they Tab away. See `design/decisions.md` `D_on_blur` and book ch5.
90
+ - Fix `Component::Window` and its subclasses re-emitting their whole border on every unchanged repaint — 925 bytes for an 80x25 window, paid on each focus change — by no longer blanking the rect it is about to repaint. See `design/decisions.md` `D_component_contract`.
91
+ - Fix a `Component::Window` with `scrollbar = true` painting its right border under the content's scrollbar, dirtying that column into every frame; the border now leaves the column it gave away alone.
92
+ - Fix `Component::Layout::Box` stranding its children at their old rects when its own rect went empty, so the next full repaint — which any popup close runs — painted the hidden subtree back at stale coordinates. See `design/decisions.md` `D_empty_ancestor`.
93
+ - Fix `Screen#repaint` painting a component that sits under an empty-rect ancestor: the drain filter now drops it, as it already drops a detached one, so a container that forgets to zero its children is inert rather than wrong.
94
+ - Fix `Screen`'s pane carrying no rect until the first `#layout`, which runs from the event loop; it is sized at construction from `#size`, so the tree is not filtered out of the repaint as sitting under an empty ancestor.
95
+ - Fix `Component::IntegerField`, `FloatField` and `BigDecimalField` accepting *pasted* text their own filter forbids — `"abc"` pasted into an integer field left it showing `abc42` while `value` silently read `nil`. Each now filters in its inner field's `insert_text`, which typing and pasting both pass through. See `design/decisions.md` `D_input_filters`.
96
+ - Fix the numeric fields occupying their inner field's `on_key` slot for Up/Down stepping, so an app that set `field.content.on_key` silently killed the spinner. They use `on_key_up` / `on_key_down` now, and `on_key` is free.
97
+ - Fix `Component#bg_color` being inert on `Component::TextField`, `TextArea`, `Select` and `ComboBox`, which reached past it to the theme — contradicting what `bg_color=` and `effective_bg_color` documented. Setting one on a field now wins over its well.
98
+ - Fix `Component::TextView` wrapping text right up against a visible scrollbar (`…to show the█`): the bar now reserves a blank column beside it, as `List` rows always have. See `design/decisions.md` `D_scrollbar_reserve`.
99
+ - Fix the scrollbar painting a solid full-height `█` when the content fits: with nothing to scroll the bar now shows only its track, keeping its column and its content width. See `design/decisions.md` `D_scrollbar_ink`.
100
+ - **Breaking:** `Locale::DateFormats::DIRECTIVE`, `::LOCALE_LOOKALIKES` and `.each_directive` moved to the new `Locale::Formats`, the strftime lexer `DateFormats` and `TimeFormats` now share. Reference them under `Locale::Formats`; `REF`, `HINTS`, `.validate`, `.humanize` and `.widen` are unmoved.
101
+ - **Breaking:** `Component::AbstractStringField#on_key` is gone — the pre-dispatch interceptor that could claim any key. Subclass and override `handle_text_input_key` instead, calling `super` for keys you don't claim; a key you decline still bubbles to an ancestor. See `design/decisions.md` `D_no_key_interceptor` and book ch7.
102
+ - **Breaking:** A paste is delivered to `Screen#focused` alone and no longer bubbles up the focus chain; `ScreenPane#handle_paste` returns false when the focused component declines. A container that relied on catching a paste its child refused must move that `handle_paste` onto the focusable component. See `design/decisions.md` `D_bracketed_paste`.
103
+ - **Breaking:** `Component::TextField#preprocess_paste` keeps the paste's first line instead of flattening every newline to a space, so a copied whole line no longer arrives with an invisible trailing space. Override it to restore the old behavior. See `design/decisions.md` `D_paste_newlines`.
104
+ - **Breaking:** `Theme` carries a further token, `placeholder_color`, the ink `Component::HasPlaceholder` paints an empty field's hint in. A hand-rolled `Theme.new(...)` must now pass it; `Theme::DARK.with(...)` is unaffected. See `design/decisions.md` `D_placeholder`.
105
+ - **Breaking:** `Theme` carries four further tokens — `scrollbar_color`, plus `error_color` / `error_bg_color` / `error_active_bg_color` — so a scrollbar is themed rather than painted in the terminal's default foreground, and an invalid field shows a well that still reads as focused. A hand-rolled `Theme.new(...)` must now pass all four; `Theme::DARK.with(...)` is unaffected. See `design/decisions.md` `D_scrollbar_ink` and `D_has_validation`.
106
+ - **Breaking:** `Component::Label#bg` / `#bg=` are gone. Use `bg_color`, which now covers the text, the trailing padding and the blank rows alike; to override a span's own background as `#bg` did, restyle the text with `label.text = text.with_bg(c)`.
107
+ - **Breaking:** `Component#effective_bg_color` is protected and final. A widget declares its own background by overriding `default_bg_color`; nothing else needs to read the resolved value, since `draw_text` / `draw_char` / `clear_background` apply it.
108
+ - **Breaking:** `Component::AbstractStringField#background` is gone. A subclass painting its own row calls `draw_text`, which applies the well; to change the well, override `default_bg_color`.
109
+ - **Breaking:** `Component::FINAL_METHODS` is gone, replaced by the `Tuile::Final` declaration it became. Read `Component.final_methods` instead; `Component.verify_final!` is unchanged.
110
+ - **Breaking:** `content` / `content=` are gone from `Component::IntegerField`, `FloatField` and `BigDecimalField`, whose inner editor is private machinery. Use the field's own `placeholder` / `on_enter` / `cursor_position` / `clear`, write a forwarder for anything else, and reach the editor from a *spec* with `Testing.get(Component::TextField, in: field)`. See `design/decisions.md` `D_wrapping_field`.
111
+ - **Breaking:** `content` is now `list` on `Component::CheckboxGroup` and `RadioGroup`, and read-only — an app tunes the composed `List` but must not swap it, since the group's renderer and selection are wired into that one. Rename `group.content` to `group.list`; there is no `content=` replacement. See `design/decisions.md` `D_has_content`.
112
+
3
113
  ## [0.14.0] - 2026-08-31
4
114
 
5
115
  Tuile names the foundations it had been improvising: the floating layer becomes
@@ -12,22 +122,22 @@ terminal untouched by the app — and the terminal's own background becomes
12
122
  readable as a `Color`. On top of that sit the dialogs: `ConfirmWindow`, an
13
123
  `InfoWindow` prose body, and `LogTextView` for a frameless log.
14
124
 
15
- - Add `Component::Overlay` — the bare floating layer every overlay is built on: a mount/dismiss lifecycle, `owner`, `on_close` and outside-click dismissal, at a rect the caller assigns. See `DECISIONS.md` `D_overlay` and book ch7.
16
- - Add `Component::Slot` — a one-child region for content that may be absent, arrive late, or be swapped; a container gives each of its regions one, wired at construction, so a swap never has to compute an insert index. See `DECISIONS.md` `D_slots`.
17
- - Add `Component#extent`, `#extent_rect` and `#clear_outside_extent` — the size a widget actually paints inside the rect it was assigned (`nil` by default, meaning undeclared), and what it hit-tests, anchors and blanks against. See `DECISIONS.md` `D_extent`.
125
+ - Add `Component::Overlay` — the bare floating layer every overlay is built on: a mount/dismiss lifecycle, `owner`, `on_close` and outside-click dismissal, at a rect the caller assigns. See `design/decisions.md` `D_overlay` and book ch7.
126
+ - Add `Component::Slot` — a one-child region for content that may be absent, arrive late, or be swapped; a container gives each of its regions one, wired at construction, so a swap never has to compute an insert index. See `design/decisions.md` `D_slots`.
127
+ - Add `Component#extent`, `#extent_rect` and `#clear_outside_extent` — the size a widget actually paints inside the rect it was assigned (`nil` by default, meaning undeclared), and what it hit-tests, anchors and blanks against. See `design/decisions.md` `D_extent`.
18
128
  - Add `Component::Select#extent` and `Component::ComboBox#extent` — the one row the widget paints, which is what its dropdown anchors to; a single-slot container assigns these widgets far more height than they use.
19
- - Add `Component#size`, `#width` and `#height` — read-only shorthands for the matching `rect` field. Reports of the geometry a parent assigned, never requests: there is no writer and no container consults them. See `DECISIONS.md` `D_declared_size`.
129
+ - Add `Component#size`, `#width` and `#height` — read-only shorthands for the matching `rect` field. Reports of the geometry a parent assigned, never requests: there is no writer and no container consults them. See `design/decisions.md` `D_declared_size`.
20
130
  - Add `Component#handle_mouse` routing to every child whose `rect` contains the point, so a container gets click delivery without writing one; a widget that resolves clicks itself still overrides without `super`.
21
- - Add `Component::ConfirmWindow` — the confirm/alert dialog: a caption, a wrapping message and a row of buttons, opened by the `alert` / `confirm` / `yes_no` factories or built button-by-button via `#button`; every route out of it fires `on_dismiss` exactly once. See `DECISIONS.md` `D_confirm_window` and book ch7.
22
- - Add `Component::InfoWindow#message=` — the prose body, wrapped by a scrollable `TextView` (`ConfirmWindow`'s seam); `#lines=` stays as the rows presentation, and the constructor and `.open` pick the presentation by the body's type. See `DECISIONS.md` `D_info_window_body`.
131
+ - Add `Component::ConfirmWindow` — the confirm/alert dialog: a caption, a wrapping message and a row of buttons, opened by the `alert` / `confirm` / `yes_no` factories or built button-by-button via `#button`; every route out of it fires `on_dismiss` exactly once. See `design/decisions.md` `D_confirm_window` and book ch7.
132
+ - Add `Component::InfoWindow#message=` — the prose body, wrapped by a scrollable `TextView` (`ConfirmWindow`'s seam); `#lines=` stays as the rows presentation, and the constructor and `.open` pick the presentation by the body's type. See `design/decisions.md` `D_info_window_body`.
23
133
  - Add `Component::LogTextView` — `LogWindow`'s innards as a standalone `TextView`, so a frameless pane composes the log view directly: the any-thread `#log` and the `IO` adapter live on the view, and `LogWindow` reduces to a `Window` framing one (`LogWindow::IO` stays as an alias).
24
- - Add `Screen#color_depth` and `ColorDepth.detect` — how many colors the terminal can show (`:truecolor` / `:palette256` / `:ansi16`), detected from `COLORTERM` and `TERM` at construction and overridable with `TUILE_COLOR_DEPTH`. See `DECISIONS.md` `D_color_depth` and book ch6.
134
+ - Add `Screen#color_depth` and `ColorDepth.detect` — how many colors the terminal can show (`:truecolor` / `:palette256` / `:ansi16`), detected from `COLORTERM` and `TERM` at construction and overridable with `TUILE_COLOR_DEPTH`. See `design/decisions.md` `D_color_depth` and book ch6.
25
135
  - Add `Color#quantize` — this color as the nearest one a depth can show, returning the receiver when nothing needs degrading. `Buffer#flush` applies it to every color it emits, so a computed RGB tint renders on a 256-color terminal without the app quantizing anything.
26
136
  - Add a `color_depth:` keyword to `Buffer.new`, which degrades colors as it flushes them; cells keep whatever color a component painted, so `region_ansi` and friends still report it unchanged.
27
137
  - Add a pinned `:truecolor` depth to `FakeScreen`, so a spec asserting frame bytes is unaffected by the runner's `COLORTERM`; a PTY spec asserting bytes passes `TUILE_COLOR_DEPTH` to the child instead.
28
- - Add `Screen#background_color` — the terminal's own background as a `Color`, nil when it reported none; re-probed on every OS appearance flip, and a changed color fires `Component#on_theme_changed`. See `DECISIONS.md` `D_background_rgb` and book ch6.
138
+ - Add `Screen#background_color` — the terminal's own background as a `Color`, nil when it reported none; re-probed on every OS appearance flip, and a changed color fires `Component#on_theme_changed`. See `design/decisions.md` `D_background_rgb` and book ch6.
29
139
  - Add `FakeScreen#background_color=` — plays the terminal answering the re-probe, so a spec can drive app code that derives colors from it.
30
- - Add `StyledString::Style#inverse` — SGR 7 reverse video, swapping whatever fg/bg are in effect at the cell: parsed and emitted like the other attributes, applied whole-string via `StyledString#with_inverse`, and skipped by `#under_bg` the way an explicit bg is. See `DECISIONS.md` `D_inverse`.
140
+ - Add `StyledString::Style#inverse` — SGR 7 reverse video, swapping whatever fg/bg are in effect at the cell: parsed and emitted like the other attributes, applied whole-string via `StyledString#with_inverse`, and skipped by `#under_bg` the way an explicit bg is. See `design/decisions.md` `D_inverse`.
31
141
  - Add a *ConfirmWindow* pane to `examples/sampler.rb` — the three factories, the layer-1 three-way builder and a scrolling Terms-of-Service dialog, with a status row naming which callback each route out of a dialog landed in.
32
142
  - Add a terminal-derived tint to the *Background* pane of `examples/sampler.rb` — the borderless-pane step of +10 per channel off `Screen#background_color`, re-derived in `on_theme_changed` and degrading to a "none reported" entry.
33
143
  - Fix a click on `Component::Window` chrome not landing focus on the window, which its `focusable?` has claimed all along.
@@ -35,11 +145,11 @@ readable as a `Color`. On top of that sit the dialogs: `ConfirmWindow`, an
35
145
  - Fix `Component::Select` opening its dropdown from a click anywhere in its rect, including the tail its own `repaint` clears — it now hit-tests its `extent`, the one row it paints, as `Checkbox` already did on the width axis.
36
146
  - Fix a click on a `Component::ListDropdown` margin being able to land focus outside the key scope, killing every keystroke until Tab; the dropdown is now non-focusable by inheritance rather than by geometry.
37
147
  - Fix a lower popup's content bleeding through a popup stacked over it: `Screen#repaint`'s drain loop now re-asserts every popup above any layer that repainted, so a repaint cascade spilling into a later iteration can no longer paint over the popups on top.
38
- - Fix `Screen#theme=` dying with `NoMethodError` when a component overrode `on_theme_changed` under `protected`, aborting the restyle mid-walk. The hook is now protected plumbing fanned out with `__send__`, so an override may declare any visibility. See `DECISIONS.md` `D_hook_visibility`.
39
- - **Breaking:** `Component#children`, `#parent`, `#parent=`, `#add_child`, `#remove_child` and `#detach_child` are final — overriding any of them raises `Tuile::Error` at construction. Derive nothing: reparent through `add_child` / `remove_child` / `detach_child`, and hold a `Component::Slot` for a swappable region. See `DECISIONS.md` `D_final_tree`.
148
+ - Fix `Screen#theme=` dying with `NoMethodError` when a component overrode `on_theme_changed` under `protected`, aborting the restyle mid-walk. The hook is now protected plumbing fanned out with `__send__`, so an override may declare any visibility. See `design/decisions.md` `D_hook_visibility`.
149
+ - **Breaking:** `Component#children`, `#parent`, `#parent=`, `#add_child`, `#remove_child` and `#detach_child` are final — overriding any of them raises `Tuile::Error` at construction. Derive nothing: reparent through `add_child` / `remove_child` / `detach_child`, and hold a `Component::Slot` for a swappable region. See `design/decisions.md` `D_final_tree`.
40
150
  - **Breaking:** a `Component::Window` footer now lives in a `Component::Slot`, so `window.children` always holds that slot and `footer.parent` is the slot rather than the window. Reach the footer through `window.footer`, which is unchanged.
41
151
  - **Breaking:** `Component#extent` is a `Size` (or `nil`) rather than a `Rect`, and `Button`, `Checkbox`, `Tabs` and `MenuBar` narrowed with it — an extent always sits at the rect's top-left. Read `#extent_rect` where you need the positioned rectangle.
42
- - **Breaking:** `Component::Popup#size` is renamed `#declared_size`, and the `Popup.new` / `Component::InfoWindow.open` keyword with it, freeing `size` to mean `rect.size` on any component. Rename the accessor and the keyword at your call sites. See `DECISIONS.md` `D_declared_size`.
152
+ - **Breaking:** `Component::Popup#size` is renamed `#declared_size`, and the `Popup.new` / `Component::InfoWindow.open` keyword with it, freeing `size` to mean `rect.size` on any component. Rename the accessor and the keyword at your call sites. See `design/decisions.md` `D_declared_size`.
43
153
  - **Breaking:** `Component::Popup` is always modal — `Popup.new(modal: false)` now raises `ArgumentError`. Build a non-modal overlay with `Component::Overlay.new`, which carries the same lifecycle, `owner`, `on_close` and `close_on_outside_click` members, and place it with `rect=`.
44
154
  - **Breaking:** `Component::Notification` and `Component::ListDropdown` subclass `Component::Overlay` rather than `Component::Popup`, neither ever having been modal. An `is_a?(Popup)` test over the popup stack becomes `is_a?(Overlay)`.
45
155
  - **Breaking:** `TerminalBackground.detect` returns a `TerminalBackground::Result` (`scheme` plus the reported `color`, nil under the `COLORFGBG` fallback) instead of a bare `Symbol`; nil still means undetectable. Call `.scheme` on the result where you read the symbol.
@@ -53,38 +163,38 @@ fills the terminal and an app builds its own status line. A paste also stops
53
163
  being a burst of keystrokes, arriving as one `Component#handle_paste` instead
54
164
  of one keystroke per character.
55
165
 
56
- - Add `Screen#on_focus_changed=` — a no-arg callback fired after the focused component *changes*, including to and from `nil` and on the focus repair a closing popup runs. Edge-triggered, so re-focusing what already has focus fires nothing. See `DECISIONS.md` `D_status_bar` and book ch5.
57
- - Add `mnemonic:` to `Component::MenuBar#add_item` and `MenuBar::Item#add_item` — a letter that activates the item, underlined in its caption, matched level-scoped with no fallback, so only siblings can clash (which raises). See `DECISIONS.md` `D_menu_bar` and book ch7.
166
+ - Add `Screen#on_focus_changed=` — a no-arg callback fired after the focused component *changes*, including to and from `nil` and on the focus repair a closing popup runs. Edge-triggered, so re-focusing what already has focus fires nothing. See `design/decisions.md` `D_status_bar` and book ch5.
167
+ - Add `mnemonic:` to `Component::MenuBar#add_item` and `MenuBar::Item#add_item` — a letter that activates the item, underlined in its caption, matched level-scoped with no fallback, so only siblings can clash (which raises). See `design/decisions.md` `D_menu_bar` and book ch7.
58
168
  - Add `Component::List#select` and `Component::ListDropdown#select` — moves the cursor to an item by index, scrolling it into view and firing `on_cursor_changed`; the positional member of the `select_next` / `select_prev` family.
59
- - Add `Component::MenuBar` — a one-row strip of menu captions, each dropping a cascade of submenus that nests without limit, built from `MenuBar::Item` handles minted and nested by `#add_item`, each carrying an optional no-arg `on_click`. See `DECISIONS.md` `D_menu_bar` and book ch7.
169
+ - Add `Component::MenuBar` — a one-row strip of menu captions, each dropping a cascade of submenus that nests without limit, built from `MenuBar::Item` handles minted and nested by `#add_item`, each carrying an optional no-arg `on_click`. See `design/decisions.md` `D_menu_bar` and book ch7.
60
170
  - Add a `MenuBar` shell to `examples/sampler.rb` — the side nav list becomes a menu bar grouped like the README's Components table, plus a `ComboBox` jump box at its right end, and the demo window fills everything below.
61
171
  - Add a *MenuBar* pane to `examples/sampler.rb` — a three-deep File menu, an Edit menu, a top-level leaf that acts as a button, and a status line naming the last activated item.
62
172
  - Add `Component::ListDropdown#anchor_beside` — places a panel against a row's right edge, flipping left when there is no room and sliding vertically, which is the cascading-submenu counterpart of `#anchor_to`.
63
173
  - Add `Component::ListDropdown#cursor_row_rect` and `#on_cursor_changed=` — the highlighted row's rect, and the highlight-moved pass-through a cascading driver needs to drop the panels below the row it left.
64
- - Add `Component::Tabs` — a one-row strip of captions with one selected: Left/Right switch immediately, a click selects, `#on_tab_selected` reports every change (`nil, nil` once the last tab goes), and tabs are `Tabs::Tab` handles minted by `#add_tab`. See `DECISIONS.md` `D_tabs` and book ch7.
174
+ - Add `Component::Tabs` — a one-row strip of captions with one selected: Left/Right switch immediately, a click selects, `#on_tab_selected` reports every change (`nil, nil` once the last tab goes), and tabs are `Tabs::Tab` handles minted by `#add_tab`. See `design/decisions.md` `D_tabs` and book ch7.
65
175
  - Add horizontal scrolling to `Component::Tabs` and `Component::MenuBar` — a strip narrower than its captions now scrolls to keep the selected or highlighted caption whole in view, instead of clipping it.
66
176
  - Add a *TabSheet* pane to `examples/sampler.rb` — three tabs over a form, a `List` and a `TextView`, with a status line reporting every pane's state on each switch, so a hidden pane keeping its scroll position is visible rather than asserted.
67
- - Add `Component::TabSheet` — a `Tabs` strip on its top row plus the selected tab's pane below it, added with `#add_tab(caption, pane)`; unselected panes are *detached* rather than hidden by a flag, so they keep their state and stay out of the Tab cycle. See `DECISIONS.md` `D_tabs`.
177
+ - Add `Component::TabSheet` — a `Tabs` strip on its top row plus the selected tab's pane below it, added with `#add_tab(caption, pane)`; unselected panes are *detached* rather than hidden by a flag, so they keep their state and stay out of the Tab cycle. See `design/decisions.md` `D_tabs`.
68
178
  - Add `Screen#beep` and `Ansi::BEL` — rings the terminal bell for a keystroke that went nowhere. It writes immediately rather than riding the next frame, since the keys worth beeping at are precisely the ones that invalidate nothing.
69
179
  - Add `StyledString#with_underline` — applies underline to every span, preserving each span's colors and other attributes; the underline counterpart of `#with_bold`. Slice and rejoin to underline part of a string, as a one-character mnemonic cue does.
70
180
  - Add `StyledString#with_bold` — applies bold to every span, preserving each span's colors and other attributes; the bold-attribute counterpart of `#with_fg` / `#with_bg`. There is no `under_bold`, since bold has no inherited-unset state.
71
- - Add `Component#handle_paste` — pasted text, whole and `\n`-normalized, delivered down the focus chain like a key but off the key ladder; the default returns false and `AbstractStringField` inserts at the caret in one mutation. See `DECISIONS.md` `D_bracketed_paste` and book ch5.
181
+ - Add `Component#handle_paste` — pasted text, whole and `\n`-normalized, delivered down the focus chain like a key but off the key ladder; the default returns false and `AbstractStringField` inserts at the caret in one mutation. See `design/decisions.md` `D_bracketed_paste` and book ch5.
72
182
  - Add `Screen#run_event_loop(bracketed_paste:)` — on by default, mirroring `capture_mouse:`; pass false for a terminal that mishandles mode 2004.
73
183
  - Add `Keys::BRACKETED_PASTE_ON` / `_OFF`, `Keys::PASTE_START` / `PASTE_END`, `Keys.read_paste` and `Keys.normalize_paste` — the terminal-layer half: markers, a raw drain to the terminator, and CR/CRLF-to-`\n` plus a UTF-8 scrub.
74
184
  - Add `EventQueue::PasteEvent` — the whole clipboard as one frozen event, posted by the key thread.
75
185
  - Add `FakeScreen#paste` — normalizes and dispatches like the real key thread, so a spec can hand it the CR line endings terminals actually send.
76
186
  - Add `Component::AbstractStringField#preprocess_paste` — the paste-side input filter; the base drops the C0 controls a text buffer cannot hold and `TextField` also flattens newlines to spaces and trims to `max_text_length`.
77
187
  - Add a *Paste* pane to `examples/sampler.rb` — a submit-on-Enter prompt with submit/paste counters, so the distinction is visible (and PTY-testable).
78
- - Add `Component::Popup#close_on_outside_click?` — a left click outside an open popup now closes it, modal or not (default true; `Component::Notification` opts out). Fixes dropdowns and menu cascades stranded by a click on inert decoration. See `DECISIONS.md` `D_outside_click`.
188
+ - Add `Component::Popup#close_on_outside_click?` — a left click outside an open popup now closes it, modal or not (default true; `Component::Notification` opts out). Fixes dropdowns and menu cascades stranded by a click on inert decoration. See `design/decisions.md` `D_outside_click`.
79
189
  - Add `Component::Popup#owner` — names the component an overlay is part of, so a click inside it doesn't dismiss the popup hosting it. `ComboBox`, `Select` and each `MenuBar` cascade panel set it; an overlay without one is independent.
80
190
  - Add `Component::Popup#on_close` — a no-arg callback fired once the popup has left the screen, however it left; for a driver keeping its own record of what is open.
81
191
  - **Fix:** `Component::TabSheet` no longer holds a removed tab's pane against re-use — `Tabs::Tab#remove` bypasses `TabSheet#remove_tab`, and the stale mapping made `add_tab` reject that pane as still in use.
82
- - **Fix:** a container whose children exactly tile its rect no longer swallows the repaint cascade — content under it survives an ancestor's `clear_background` instead of vanishing until the next unrelated repaint (visible in the sampler as rows blanking when focus moved). See `DECISIONS.md` `D_repaint_cascade`.
192
+ - **Fix:** a container whose children exactly tile its rect no longer swallows the repaint cascade — content under it survives an ancestor's `clear_background` instead of vanishing until the next unrelated repaint (visible in the sampler as rows blanking when focus moved). See `design/decisions.md` `D_repaint_cascade`.
83
193
  - **Fix:** a multi-line paste into a `Component::TextArea` subclass that rebinds ENTER no longer fires that binding once per pasted line ([#4](https://github.com/mvysny/tuile/issues/4)).
84
194
  - **Fix:** `Component::TextArea`'s rdoc had the two line-break bytes backwards — a pasted break arrived as `\r`, not `\n`.
85
- - **Fix:** `Component::TextView#handle_key` no longer refuses every key while the view is unfocused — a vestigial `active?` guard that 0.8.0's dispatch overhaul dropped from every other widget — so hand-feeding it a scroll key scrolls, as with any other component. See `DECISIONS.md` `D_text_view_scroll_verbs`.
195
+ - **Fix:** `Component::TextView#handle_key` no longer refuses every key while the view is unfocused — a vestigial `active?` guard that 0.8.0's dispatch overhaul dropped from every other widget — so hand-feeding it a scroll key scrolls, as with any other component. See `design/decisions.md` `D_text_view_scroll_verbs`.
86
196
  - **Breaking:** `Component::TextField#left_column` is now private — the horizontal scroll offset was internal state with no caller outside the field. A spec asserting the scrolling reads it through `send(:left_column)`.
87
- - **Breaking:** the framework status bar is removed — `ScreenPane#status_bar` is gone and the pane no longer reserves the bottom row, so `content` now fills the whole terminal. Build a status line into your own layout and fill it from `Screen#on_focus_changed=`; `examples/hello_world.rb` and `examples/file_commander.rb` show the shape. See `DECISIONS.md` `D_status_bar`.
197
+ - **Breaking:** the framework status bar is removed — `ScreenPane#status_bar` is gone and the pane no longer reserves the bottom row, so `content` now fills the whole terminal. Build a status line into your own layout and fill it from `Screen#on_focus_changed=`; `examples/hello_world.rb` and `examples/file_commander.rb` show the shape. See `design/decisions.md` `D_status_bar`.
88
198
  - **Breaking:** `Component#keyboard_hint` is removed, along with its overrides on `Popup`, `PickerWindow`, `MenuBar`, `Tabs`, `Select`, `ComboBox` and `Notification` — four of them were unreachable in every configuration. Keep the method on your own window classes and call it from your status line; nothing in Tuile consults it now.
89
199
  - **Breaking:** `Screen#register_global_shortcut` no longer takes `hint:` — the registry runs actions, it does not describe them. Drop the argument and write the hint into your own status line, next to the registration.
90
200
  - **Breaking:** `Screen#active_window` is removed — it existed only to pick the component the status bar asked for a hint. Walk up from `Screen#focused` instead, which is the direction a key actually bubbles.
@@ -98,14 +208,14 @@ folded onto it. The framework also settles its scrolling vocabulary in one
98
208
  pass: `row` is the terminal grid unit everywhere, `line` means exactly what
99
209
  `String#lines` returns, and `items` are the domain objects a widget renders.
100
210
 
101
- - Add `Component::List#items` / `#items=` and `#renderer` — the list holds typed items, one row each, and a renderer turns an item into its row. See `DECISIONS.md` `D_list_items` and book ch7.
211
+ - Add `Component::List#items` / `#items=` and `#renderer` — the list holds typed items, one row each, and a renderer turns an item into its row. See `design/decisions.md` `D_list_items` and book ch7.
102
212
  - Add `Component::List#refresh_rows` — re-renders every row when the renderer's *inputs* changed (a group's selection) while the items and the renderer did not.
103
213
  - Add `Component::List#build_lines` — the verb-named builder that `#lines`'s block form used to be: it yields a growing `Array` and assigns it through `#lines=`.
104
214
  - Add `Component::ListDropdown#items` / `#items=` / `#renderer=`, forwarding to its list.
105
- - Add `Component::Notification` — the corner toast: `Notification.show("Saved")` floats a non-modal box in the top-right for three seconds, stacking a burst into one box that drains one message every tick. See `DECISIONS.md` `D_notification` and book ch7.
106
- - Add `Component::TextArea#caret_row` and `#row_count` — the two readers that answer "has Up/Down anywhere left to go?", so a subclass can claim the key at the text's edge and delegate elsewhere. See `DECISIONS.md` `D_text_area_rows`.
107
- - Add `Component::TextView#scroll_half_page_up` / `#scroll_half_page_down` — the programmatic form of `Ctrl+U` / `Ctrl+D`, for a host paging a view it does not focus. See `DECISIONS.md` `D_text_view_scroll_verbs`.
108
- - Add `TERMINOLOGY.md` — a glossary of Tuile's house words, looked up by term; the rules live in `AGENTS.md`'s *Nomenclature* section and the reasoning in `DECISIONS.md` `D_scroll_nomenclature`.
215
+ - Add `Component::Notification` — the corner toast: `Notification.show("Saved")` floats a non-modal box in the top-right for three seconds, stacking a burst into one box that drains one message every tick. See `design/decisions.md` `D_notification` and book ch7.
216
+ - Add `Component::TextArea#caret_row` and `#row_count` — the two readers that answer "has Up/Down anywhere left to go?", so a subclass can claim the key at the text's edge and delegate elsewhere. See `design/decisions.md` `D_text_area_rows`.
217
+ - Add `Component::TextView#scroll_half_page_up` / `#scroll_half_page_down` — the programmatic form of `Ctrl+U` / `Ctrl+D`, for a host paging a view it does not focus. See `design/decisions.md` `D_text_view_scroll_verbs`.
218
+ - Add `design/terminology.md` — a glossary of Tuile's house words, looked up by term; the rules live in `AGENTS.md`'s *Nomenclature* section and the reasoning in `design/decisions.md` `D_scroll_nomenclature`.
109
219
  - `Component::List` now renders lazily: only the rows in the viewport, memoized until `items=`, `renderer=` or a width change. A renderer therefore runs at paint time and must stay pure and cheap.
110
220
  - `Component::List#lines=` is unchanged and stays supported: it splits on `\n` and stores the resulting `StyledString`s *as* the items, so a line-populated list behaves exactly as before.
111
221
  - `Component::List::Cursor`'s count parameters are renamed `item_count` and `viewport_rows` (a cursor indexes items; only the paging half counts rows). They are positional, so no caller changes — a `Cursor` subclass overrides against the new names.
@@ -114,38 +224,38 @@ pass: `row` is the terminal grid unit everywhere, `line` means exactly what
114
224
  - **Fix:** `examples/file_commander.rb` navigates again — Enter on a directory called `Rainbow.uncolor` on a `StyledString` and raised.
115
225
  - **Breaking:** `Component::List#on_item_chosen` and `#on_cursor_changed` now receive `(index, item)` rather than `(index, line)`. A list populated by `lines=` is unaffected (its items *are* the `StyledString` rows); one populated by `items=` must expect its own objects.
116
226
  - **Breaking:** `Component::List#lines` (the reader) is removed — it returned the items, and the name lies once a `renderer` is set. Read `#items`; for the block form call `#build_lines`; to assert what a list *shows*, assert the painted buffer.
117
- - **Breaking:** `Component::ListDropdown#lines=` / `#lines` are removed — use `#items=` with a `#renderer=`. See `DECISIONS.md` `D_list_items`.
118
- - **Breaking:** `Component::List#add_line` and `#add_lines` are removed — an append is a statement about a collection the list owns, which a lazily-sourced provider has nothing to mutate. Keep your own array and assign it whole (`list.items = mine`); for incremental append use `Component::TextView`. See `DECISIONS.md` `D_list_items`.
119
- - **Breaking:** `Component::Popup.open` (the class method) is removed — it hardcoded `Popup.new`, so every subclass inherited a factory that silently built a bare `Popup`. Write `Popup.new(...).open`, which now returns the popup. See `DECISIONS.md` `D_popup_open`.
227
+ - **Breaking:** `Component::ListDropdown#lines=` / `#lines` are removed — use `#items=` with a `#renderer=`. See `design/decisions.md` `D_list_items`.
228
+ - **Breaking:** `Component::List#add_line` and `#add_lines` are removed — an append is a statement about a collection the list owns, which a lazily-sourced provider has nothing to mutate. Keep your own array and assign it whole (`list.items = mine`); for incremental append use `Component::TextView`. See `design/decisions.md` `D_list_items`.
229
+ - **Breaking:** `Component::Popup.open` (the class method) is removed — it hardcoded `Popup.new`, so every subclass inherited a factory that silently built a bare `Popup`. Write `Popup.new(...).open`, which now returns the popup. See `design/decisions.md` `D_popup_open`.
120
230
  - **Breaking:** `Buffer#set_line` is now `#set_text` and `Component#draw_line` is now `#draw_text` — both write a `StyledString` starting at `(x, y)` and never filled a row. Rename the calls; behavior is unchanged.
121
231
  - **Breaking:** `Component::List#top_line`/`=` and `Component::TextView#top_line`/`=` are now `#scroll_top_row`/`=`, and `Component::TextArea#top_display_row` is now `#scroll_top_row`. Rename the accessors.
122
232
  - **Breaking:** `VerticalScrollBar.new(line_count:, top_line:)` is now `.new(row_count:, scroll_top_row:)`. Rename the keywords.
123
233
 
124
234
  ## [0.11.0] - 2026-08-12
125
235
 
126
- - Add `Component::Select` — a one-row enum field that drops open a `ListDropdown` of its typed `items`; `value` is the selected item, and Enter, Space or Down opens it. It claims no printable key but Space, so a form's own letter bindings keep working while it has focus. See `DECISIONS.md` `D_select` and book ch7.
127
- - Add `Component::Layout::Vertical` and `Component::Layout::Horizontal` (on the abstract `Component::Layout::Box`) — declarative 1-D layouts where a caller *declares* each child's extent (`Fixed` / `Percent` / `Expand`, plus box-global `spacing`, `padding` and a per-child `align:`) instead of computing it. See `DECISIONS.md` `D_box_layouts` and book ch3.
128
- - Add `Component::BigDecimalField` — the money field: the shape of `IntegerField`/`FloatField` with an exact `BigDecimal` (or `nil`) value, where assigning a `Float` raises rather than converting. See `DECISIONS.md` `D_bigdecimal_field`.
129
- - Add `Component::FloatField` — the `IntegerField` twin whose `value` is a `Float` (or `nil`), accepting a single `.` and stepping by `1.0` on Up/Down; `value=` raises on a NaN or infinity. See `DECISIONS.md` `D_float_field`.
236
+ - Add `Component::Select` — a one-row enum field that drops open a `ListDropdown` of its typed `items`; `value` is the selected item, and Enter, Space or Down opens it. It claims no printable key but Space, so a form's own letter bindings keep working while it has focus. See `design/decisions.md` `D_select` and book ch7.
237
+ - Add `Component::Layout::Vertical` and `Component::Layout::Horizontal` (on the abstract `Component::Layout::Box`) — declarative 1-D layouts where a caller *declares* each child's extent (`Fixed` / `Percent` / `Expand`, plus box-global `spacing`, `padding` and a per-child `align:`) instead of computing it. See `design/decisions.md` `D_box_layouts` and book ch3.
238
+ - Add `Component::BigDecimalField` — the money field: the shape of `IntegerField`/`FloatField` with an exact `BigDecimal` (or `nil`) value, where assigning a `Float` raises rather than converting. See `design/decisions.md` `D_bigdecimal_field`.
239
+ - Add `Component::FloatField` — the `IntegerField` twin whose `value` is a `Float` (or `nil`), accepting a single `.` and stepping by `1.0` on Up/Down; `value=` raises on a NaN or infinity. See `design/decisions.md` `D_float_field`.
130
240
  - Add `Component::ListDropdown#anchor_to(anchor, rows:, width:, max_rows:)` — placement now lives on the dropdown: below the driver, flipped above when the rows won't fit beneath, and slid left to stay on screen. Width stays a caller decision.
131
241
  - `bigdecimal` is Tuile's first optional dependency and is deliberately not in the gemspec: only an app naming `Component::BigDecimalField` loads it, and doing so without the gem raises `LoadError` with the fix in the message. A Bundler app on Ruby 3.4+ must name `bigdecimal` in its own `Gemfile`.
132
242
  - `examples/sampler.rb` gains `Select`, `FloatField` and `BigDecimalField` panes, and its demo panes are ported to the box layouts.
133
- - **Fix (behavior):** `StyledString#wrap` no longer eats a **first-line** indent, so indented text is displayable at all (every `Component::TextView` line goes through `wrap`). `plain(" ").wrap(5)` is now `[" "]` rather than `[""]`; continuations still start at column 0. See `DECISIONS.md` `D_wrap_leading_space`.
243
+ - **Fix (behavior):** `StyledString#wrap` no longer eats a **first-line** indent, so indented text is displayable at all (every `Component::TextView` line goes through `wrap`). `plain(" ").wrap(5)` is now `[" "]` rather than `[""]`; continuations still start at column 0. See `design/decisions.md` `D_wrap_leading_space`.
134
244
  - **Fix (behavior):** a `Component::ListDropdown` that scrolls now shows a scrollbar — `List`'s `scrollbar_visibility` defaults to `:gone` and the dropdown never changed it, so an 11-match `ComboBox` looked identical to a 10-match one.
135
245
  - **Fix:** `Component::ComboBox` no longer hands its inner `TextField` a one-row rect when its own rect is zero-height — a child painting outside its parent, which a box layout can provoke by starving an over-subscribed child.
136
246
  - **Breaking:** `Component::ComboBox::MAX_VISIBLE_ROWS` moved to `Component::ListDropdown::MAX_VISIBLE_ROWS`. Update the constant reference.
137
- - **Breaking (behavior):** `Component::Checkbox` now toggles on **Enter** as well as Space, matching a checkable row inside a `List`. A focused checkbox therefore consumes Enter, so an ancestor's Enter-to-submit must move to a key no focused field claims. See `DECISIONS.md` `D_boolean_fields`.
247
+ - **Breaking (behavior):** `Component::Checkbox` now toggles on **Enter** as well as Space, matching a checkable row inside a `List`. A focused checkbox therefore consumes Enter, so an ancestor's Enter-to-submit must move to a key no focused field claims. See `design/decisions.md` `D_boolean_fields`.
138
248
 
139
249
  ## [0.10.0] - 2026-08-02
140
250
 
141
- - Add `Component::Checkbox` — a one-row boolean input (`[x] Enable syslog forwarding`) toggled by Space or a left-click, whose `value` is always `true`/`false`, with `checked?`/`checked=`/`toggle` as the domain-word face over it. See `DECISIONS.md` `D_boolean_fields`.
142
- - Add `Component::CheckboxGroup` — multi-select over typed `items` whose `value` is a **frozen** `Set` of the selected items, iterating in toggle order (use `items & value.to_a` when order matters). See `DECISIONS.md` `D_checkbox_group`.
143
- - Add `Component::RadioGroup` — single-select over typed `items` whose `value` is the selected item; it composes a `List`, so the cursor roams without selecting and Space, Enter or a click commits the row under it. See `DECISIONS.md` `D_radio_group`.
251
+ - Add `Component::Checkbox` — a one-row boolean input (`[x] Enable syslog forwarding`) toggled by Space or a left-click, whose `value` is always `true`/`false`, with `checked?`/`checked=`/`toggle` as the domain-word face over it. See `design/decisions.md` `D_boolean_fields`.
252
+ - Add `Component::CheckboxGroup` — multi-select over typed `items` whose `value` is a **frozen** `Set` of the selected items, iterating in toggle order (use `items & value.to_a` when order matters). See `design/decisions.md` `D_checkbox_group`.
253
+ - Add `Component::RadioGroup` — single-select over typed `items` whose `value` is the selected item; it composes a `List`, so the cursor roams without selecting and Space, Enter or a click commits the row under it. See `design/decisions.md` `D_radio_group`.
144
254
  - Add `Component::IntegerField` — a single-line input whose `value` is an `Integer` (or `nil`), accepting only digits and a single leading `-`, with Up/Down stepping by one; it composes a `TextField` rather than subclassing one.
145
255
  - Add `Component::PasswordField` — a `TextField` painting one mask glyph per character (`mask_char=`, default `*`) with a `revealed=`/`revealed?` toggle; while masked, word-jumps collapse to the ends of the buffer so the mask can't leak word boundaries.
146
256
  - Add `Component::TextField#display_text` — the protected seam a subclass overrides when it paints something other than `text`, since the caret, the scroll window and click-to-position all measure it. The contract is one display character per `text` character, in order.
147
- - Add `Component::ProgressBar` — a display-only fill over a `Range` with `fraction`/`percent` readers and an `indeterminate` mode owning a 5 fps ticker; it stays deliberately outside `Component::HasValue`. See `DECISIONS.md` `D_progress_bar` and `D_color_slots`.
148
- - Add `Component#on_attached` / `on_detached` — lifecycle hooks fired once per component per transition, letting a component own a mounted-lifetime resource. They are hooks, not destructors: a process exiting without `Screen#close` fires nothing. See `DECISIONS.md` `D_attach_hooks`.
257
+ - Add `Component::ProgressBar` — a display-only fill over a `Range` with `fraction`/`percent` readers and an `indeterminate` mode owning a 5 fps ticker; it stays deliberately outside `Component::HasValue`. See `design/decisions.md` `D_progress_bar` and `D_color_slots`.
258
+ - Add `Component#on_attached` / `on_detached` — lifecycle hooks fired once per component per transition, letting a component own a mounted-lifetime resource. They are hooks, not destructors: a process exiting without `Screen#close` fires nothing. See `design/decisions.md` `D_attach_hooks`.
149
259
  - Add `Component::HasCaption` — the caption seam included by `Button` and `Window`, coining the naming split (**caption** is app-authored chrome, **text** is the user-editable value) and making `is_a?`-plus-caption tree lookups possible.
150
260
  - `Component::HasValue` now carries `focusable? = true` (overridable). `tab_stop?` is deliberately not folded in: it stays `true` on the leaf `AbstractStringField` and `false` on the composing wrappers, whose inner field carries the stop.
151
261
  - `Component::ComboBox` and `Component::IntegerField` compose their inner field via `Component::HasContent` instead of hand-rolling `children`/`rect=`/`on_focus`, so their `content`/`content=` are consequently public.
@@ -153,17 +263,17 @@ pass: `row` is the terminal grid unit everywhere, `line` means exactly what
153
263
  - `Screen#close` now unmounts the component tree (via `ScreenPane#detach_all`), so teardown fires `on_detached` across it.
154
264
  - `items=` is chrome on `ComboBox` and on both group components: it never touches `value`, so an absent value renders as nothing selected and survives intact, with no reconcile step, clamp or silent drop.
155
265
  - `examples/sampler.rb` gains panes for `ProgressBar`, `RadioGroup` and `CheckboxGroup`.
156
- - Fix the caret stepping by *character* rather than by grapheme cluster: LEFT/RIGHT, BACKSPACE and DELETE now move over and delete exactly one cluster, and the caret is boundary-locked so a mid-cluster position is unrepresentable. See `DECISIONS.md` `D_cluster_caret`.
157
- - Fix `Component::TextArea` hanging the UI thread on text containing `\r`, `\v` or `\f` — the word-scan measured zero and the wrap loop never advanced, so `area.text = File.read(crlf_file)` was enough to lock up an app. See `DECISIONS.md` `D_text_area_columns`.
158
- - **Breaking:** `Component#children` is final, and reparenting goes through the protected `add_child(child, at:)` / `remove_child(child)` / `detach_child(child)`. A custom container must stop overriding `children` or hand-wiring `child.parent = …`; named slots become readers over the array. See `DECISIONS.md` `D_tree_api`.
159
- - **Breaking:** `Component#attached?` is now the one-axis type test `root.is_a?(ScreenPane)` — it consults no `Screen`, so it never raises and a tree can be assembled with no screen in the process. See `DECISIONS.md` `D_tree_first`.
160
- - **Breaking:** the UI-thread guard is rewritten — `EventQueue#locked?` becomes `#running?` plus `#on_loop_thread?`, the internal `@pretend_ui_lock` and `FakeScreen#check_locked`'s bypass are gone, and `Screen#state` is added. A spec mutating UI from a *spawned* thread now raises exactly as an app would. See `DECISIONS.md` `D_screen_lifecycle`.
161
- - **Breaking:** `Component#key_shortcut` and `#find_shortcut_component` are removed, along with `ScreenPane#handle_key`'s capture phase and `Window`'s `[k]-Caption` border prefix. Migration: `w.key_shortcut = "1"` becomes a `case` in the containing layout's own `handle_key`. See `DECISIONS.md` `D_key_dispatch`.
266
+ - Fix the caret stepping by *character* rather than by grapheme cluster: LEFT/RIGHT, BACKSPACE and DELETE now move over and delete exactly one cluster, and the caret is boundary-locked so a mid-cluster position is unrepresentable. See `design/decisions.md` `D_cluster_caret`.
267
+ - Fix `Component::TextArea` hanging the UI thread on text containing `\r`, `\v` or `\f` — the word-scan measured zero and the wrap loop never advanced, so `area.text = File.read(crlf_file)` was enough to lock up an app. See `design/decisions.md` `D_text_area_columns`.
268
+ - **Breaking:** `Component#children` is final, and reparenting goes through the protected `add_child(child, at:)` / `remove_child(child)` / `detach_child(child)`. A custom container must stop overriding `children` or hand-wiring `child.parent = …`; named slots become readers over the array. See `design/decisions.md` `D_tree_api`.
269
+ - **Breaking:** `Component#attached?` is now the one-axis type test `root.is_a?(ScreenPane)` — it consults no `Screen`, so it never raises and a tree can be assembled with no screen in the process. See `design/decisions.md` `D_tree_first`.
270
+ - **Breaking:** the UI-thread guard is rewritten — `EventQueue#locked?` becomes `#running?` plus `#on_loop_thread?`, the internal `@pretend_ui_lock` and `FakeScreen#check_locked`'s bypass are gone, and `Screen#state` is added. A spec mutating UI from a *spawned* thread now raises exactly as an app would. See `design/decisions.md` `D_screen_lifecycle`.
271
+ - **Breaking:** `Component#key_shortcut` and `#find_shortcut_component` are removed, along with `ScreenPane#handle_key`'s capture phase and `Window`'s `[k]-Caption` border prefix. Migration: `w.key_shortcut = "1"` becomes a `case` in the containing layout's own `handle_key`. See `design/decisions.md` `D_key_dispatch`.
162
272
  - **Breaking:** `Screen#register_global_shortcut` now also rejects `Screen::EDITING_KEYS` — `ENTER`, `BACKSPACE`, `DELETE` and the arrows — which silently broke `TextArea` newlines app-wide. Bind those on an ancestor's `handle_key` instead; `HOME`/`END`/`PAGE_UP`/`PAGE_DOWN` stay legal.
163
273
  - **Breaking:** `Component::TextInput` is renamed `Component::AbstractStringField` (file `text_input.rb` → `abstract_string_field.rb`). Only code referencing the constant directly must update.
164
274
  - **Breaking:** `Component::Button#caption` and `Component::Window#caption` return a `StyledString`, not a `String`. Measure with `caption.display_width` and recover the plain text with `caption.to_s`.
165
- - **Breaking (behavior):** all glyph measurement is now per grapheme cluster under one emoji policy (`:rgi`), so `"👍🏽"` measures 2 columns rather than 4 and no longer overruns its cell. Custom components measuring with `String#length` or iterating `each_char` must move to `StyledString#display_width` / `slice` / `ellipsize`. See `DECISIONS.md` `D_cluster_width`.
166
- - **Breaking (behavior):** `Component::TextField` separates the caret's character index from its terminal column and **scrolls horizontally** instead of being capped by its own width. `max_text_length` is now an explicit, settable cap defaulting to `nil` (unbounded) rather than the derived `rect.width - 1`. See `DECISIONS.md` `D_text_field_axes`.
275
+ - **Breaking (behavior):** all glyph measurement is now per grapheme cluster under one emoji policy (`:rgi`), so `"👍🏽"` measures 2 columns rather than 4 and no longer overruns its cell. Custom components measuring with `String#length` or iterating `each_char` must move to `StyledString#display_width` / `slice` / `ellipsize`. See `design/decisions.md` `D_cluster_width`.
276
+ - **Breaking (behavior):** `Component::TextField` separates the caret's character index from its terminal column and **scrolls horizontally** instead of being capped by its own width. `max_text_length` is now an explicit, settable cap defaulting to `nil` (unbounded) rather than the derived `rect.width - 1`. See `design/decisions.md` `D_text_field_axes`.
167
277
  - **Breaking (behavior):** `Component::Button#handle_mouse` fires `on_click` only within the button's painted **extent**, not anywhere in its `rect`, and `Component::Checkbox` follows the identical rule. A click on the blank tail still focuses the button but no longer activates it.
168
278
 
169
279
  ## [0.9.0] - 2026-07-05
data/README.md CHANGED
@@ -12,10 +12,12 @@ providers — is described in
12
12
  Tuile is that approach applied to a terminal.
13
13
 
14
14
  Tuile is the only actively maintained component-oriented TUI framework for
15
- Ruby that we are aware of. If you have looked at the alternatives
16
- tty-toolkit, vedeu, ratatui, or the curses bindings your distro packages —
17
- [COMPARISON.md](COMPARISON.md) sizes each one up and says which of them you
18
- can actually reach from Ruby.
15
+ Ruby that we are aware of. Ruby does now reach two of the big non-Ruby
16
+ toolkits Rust's ratatui through `ratatui_ruby`, Go's Charm stack through
17
+ CharmRuby but both hand you a draw loop or an Elm-style model/update/view,
18
+ not a tree of components. [The research notes](design/research.md) size up each
19
+ neighbour, those two included, alongside tty-toolkit, vedeu and the curses
20
+ bindings your distro packages.
19
21
 
20
22
  ## Installation
21
23
 
@@ -55,8 +57,8 @@ else in Tuile loads it.
55
57
  - **API reference:** every public class and method carries YARD headers —
56
58
  browse them at <https://rubydoc.info/gems/tuile>, or run
57
59
  `bundle exec rake yard` for a local site.
58
- - **[COMPARISON.md](COMPARISON.md)** places Tuile among the neighbouring
59
- toolkits, and answers what a Ruby program can reach without writing
60
+ - **[The research notes](design/research.md)** size up the neighbouring
61
+ toolkits, and answer what a Ruby program can reach without writing
60
62
  bindings first.
61
63
 
62
64
  ## Hello world
@@ -137,9 +139,20 @@ between panes. A paste is deliberately *not* a burst of keys: with bracketed
137
139
  paste it arrives whole, as one `handle_paste`.
138
140
  → [chapter 5](book/05-focus.md)
139
141
 
142
+ **The mouse is routed by position, and claimed by one component.** A press
143
+ focuses the innermost focusable under the pointer before any handler runs,
144
+ then bubbles outward through `handle_mouse_down?` until someone answers
145
+ `true` — and that claimant is *grabbed*, so the drags and the release reach
146
+ it wherever the pointer goes. The wheel bubbles the same way, so a list
147
+ already at its top hands the notch up to whatever scrolls around it. `capture_mouse:` picks how
148
+ much the terminal reports: `:clicks`, `:drag`, or `:hover` with enter/exit
149
+ hooks.
150
+ → [chapter 5](book/05-focus.md)
151
+
140
152
  **Theming is accents-only, and follows the OS.** A `Theme` carries semantic
141
- accent tokens — the list cursor, an input well, an active window border,
142
- status-bar hints — plus whatever `custom` tokens your app adds. Everything
153
+ accent tokens for the chrome Tuile itself paints — the list cursor, an input
154
+ well, an active window border, a scrollbar — plus whatever `custom` tokens your
155
+ app adds for text of its own (a status row's shades live there). Everything
143
156
  else inherits the terminal's own foreground and background, so Tuile looks at
144
157
  home in the user's palette instead of fighting it. Tuile probes the terminal
145
158
  background at startup, pairs a dark and a light theme in a `ThemeDef`, and
@@ -148,6 +161,15 @@ read — `Screen#background_color` — for panes tinted a few percent off the
148
161
  terminal's own, LazyVim-style.
149
162
  → [chapter 6](book/06-theming.md)
150
163
 
164
+ **A `Locale` holds conventions, never prose.** Date formats, the calendar,
165
+ month and weekday names, the decimal separator — detected from `locale(1)` at
166
+ startup, but only when the environment actually asked for something, since the
167
+ POSIX default is American and "said nothing" is indistinguishable from "wants
168
+ American". Everything else falls back to `Locale::ISO`. Tuile ships no message
169
+ catalogue and no per-country presets: this is the formatting half of what POSIX
170
+ splits, and the wording half stays your app's.
171
+ → [chapter 10](book/10-locale.md)
172
+
151
173
  ## Components
152
174
 
153
175
  Every component lives under `Tuile::Component::*`, and every one of them is a
@@ -198,6 +220,9 @@ carries the per-method reference: `bundle exec rake yard`, or
198
220
  | `IntegerField` | A one-row field whose `value` is an `Integer` or `nil`, filtering input to digits and one leading `-`. |
199
221
  | `FloatField` | The same, one Ruby type over: `value` is a `Float` or `nil`. |
200
222
  | `BigDecimalField` | The same for money, where a binary `Float` is the wrong answer. Tuile's one optional dependency — add `bigdecimal` yourself if you name this component. |
223
+ | `DateField` | A one-row field whose `value` is a `Date` or `nil`, over a list of strftime formats taken from `Screen#locale`: it accepts any of them and writes the first one back when you leave the field. Manual entry — there is no calendar popup yet. |
224
+ | `TimeField` | A one-row field whose `value` is a time of day — a `Time` on a fixed epoch date, or `nil` — spelled the way `Screen#locale` says. `step` is both the Up/Down stride and the precision: it shows seconds only when set below a minute. |
225
+ | `DateTimeField` | The two above side by side on one row, behind a single `DateTime` at `+00:00`. Each half reddens its own bad input; the field reddens whole only for the fault no half can wear — one half filled and the other empty. |
201
226
 
202
227
  ### Choosing from a set — [book ch7](book/07-components.md#choosing-from-a-set)
203
228
 
@@ -223,18 +248,21 @@ carries the per-method reference: `bundle exec rake yard`, or
223
248
  |---|---|
224
249
  | `Overlay` | The bare floating layer: it wraps any component, paints nothing itself, and sits at the rect you assign it. Takes no focus and no keys — the building block for anchored panels and toasts. |
225
250
  | `Popup` | The modal dialog: an `Overlay` that centers itself, grabs focus, scopes keys to its own subtree and blocks clicks beneath it. Sized by `declared_size=` (a `Size` or a `Fraction` of the screen) rather than by its content; ESC or `q` dismisses. |
226
- | `Notification` | A transient corner toast — `Notification.show("Saved")` — stacking messages in one box that a single ticker drains. Non-modal, and it never takes focus. |
251
+ | `Notification` | A transient corner toast — `Notification.show("Saved")` — stacking messages in one box that a single ticker drains. Non-modal, it never takes focus, and its inner `View` refuses the wheel so queued messages wait for the ticker. |
227
252
  | `ConfirmWindow` | The confirm dialog: a message and a row of buttons in a popup sized to fit. `alert` / `confirm` / `yes_no` cover the common shapes; `#button` builds any other. Every button closes; ESC, `q` or an outside click fire `on_dismiss`. See [The confirm dialog](book/07-components.md#the-confirm-dialog). |
228
253
  | `InfoWindow` | A `Window` with a read-only body, tiled or popped up: prose that wraps (`message=`), or rows that don't (`lines=`). |
229
- | `PickerWindow` | A `Window` of options identified by single keystrokes, firing a callback with the key that was pressed. |
254
+ | `PickerWindow` | A `Window` of options identified by single keystrokes, firing a callback with the key that was pressed. Captions take a `StyledString` to color one. |
230
255
  | `LogTextView` | An auto-scrolling `TextView` for log output. Point your logger at a `LogTextView::IO` and lines land here from any thread, marshalled through the event queue. |
231
256
  | `LogWindow` | A `Window` framing a `LogTextView` — the framed log pane. |
232
257
 
233
258
  The mixins those share — `HasValue` (the `value` / `empty?` / `clear` /
234
- `on_value_change` seam every input speaks), `HasContent` (one-child
235
- containers) and `HasCaption` (app-authored chrome text) are the seams to
236
- include when you write your own; chapter 7's "value seam" section is the
237
- walkthrough.
259
+ `on_value_change` seam every input speaks), `HasValidation` (`error_message`,
260
+ the verdict a validator writes and the field shows as a red well), `HasBadInput`
261
+ (`bad_input?`, for a field whose input can be something its value cannot
262
+ represent — a lone `-` in a number), `HasContent` (a primary child the caller
263
+ populates, named `content`) and
264
+ `HasCaption` (app-authored chrome text) — are the seams to include when you
265
+ write your own; chapter 7's "value seam" section is the walkthrough.
238
266
 
239
267
  ## Geometry primitives
240
268
 
@@ -258,7 +286,7 @@ Tuile.logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
258
286
  ## Testing
259
287
 
260
288
  Tuile ships with a `Tuile::FakeScreen` that you install in place of the real
261
- screen for unit tests. It fixes the viewport at 160×50, disables the UI lock,
289
+ screen for unit tests. It fixes the viewport at 160×50,
262
290
  paints into an in-memory back buffer (assert on it for painted content) while
263
291
  capturing cursor/housekeeping escapes into an array, and uses a synchronous
264
292
  `FakeEventQueue` (submitted blocks run inline; posted events are discarded).
@@ -303,6 +331,12 @@ Key hooks:
303
331
  to its current value should typically *not* invalidate.
304
332
  - `Screen.instance.clear` — drops accumulated `prints` without resetting
305
333
  invalidation.
334
+ - `Tuile::Testing.get` / `.find` — locate a component to drive, by class,
335
+ mixin, `Component#id`, caption or a block:
336
+ `Testing.get(Component::Button, caption: "Save")`. `get` demands exactly
337
+ one match and prints the tree it searched when it doesn't get one; `find`
338
+ returns all matches and takes `count:`. Both take `in:` to scope the
339
+ search, and default to the whole screen. Call them qualified.
306
340
 
307
341
  Because `FakeEventQueue#submit` runs the block immediately on the calling
308
342
  thread, code paths that marshal work back via `screen.event_queue.submit { … }`
@@ -322,14 +356,16 @@ run `bundle exec rake spec` to run the tests. You can also run `bin/console`
322
356
  for an interactive prompt that will allow you to experiment.
323
357
 
324
358
  To install this gem onto your local machine, run `bundle exec rake install`.
325
- To release a new version, see [`RELEASING.md`](RELEASING.md).
359
+ To release a new version, see [`design/releasing.md`](design/releasing.md).
326
360
 
327
361
  ## Contributing
328
362
 
329
363
  Bug reports and pull requests are welcome on GitHub at
330
364
  <https://github.com/mvysny/tuile>. Please read [`AGENTS.md`](AGENTS.md) before
331
365
  opening a PR — it documents the architecture invariants (singleton screen,
332
- invalidation/repaint contract, threading rule) that the framework relies on.
366
+ invalidation/repaint contract, threading rule) that the framework relies on,
367
+ and routes you to [`design/`](design/), where
368
+ [`decisions.md`](design/decisions.md) answers "why is it like this?".
333
369
  This project is intended to be a safe, welcoming space for collaboration, and
334
370
  contributors are expected to adhere to the
335
371
  [code of conduct](https://github.com/mvysny/tuile/blob/master/CODE_OF_CONDUCT.md).