tuile 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +4266 -226
  5. data/README.md +44 -24
  6. data/TERMINOLOGY.md +22 -7
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +67 -3
  9. data/book/06-theming.md +153 -7
  10. data/book/07-components.md +643 -67
  11. data/book/08-testing.md +94 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/book/10-locale.md +216 -0
  14. data/book/README.md +14 -5
  15. data/examples/file_commander.rb +1 -1
  16. data/examples/sampler.rb +402 -62
  17. data/ideas/arrow-key-navigation.md +2 -2
  18. data/ideas/binder.md +177 -0
  19. data/ideas/composite-field.md +77 -0
  20. data/ideas/focus-accent.md +116 -0
  21. data/ideas/form-layout.md +151 -0
  22. data/ideas/hover/probe.rb +241 -0
  23. data/ideas/hover/probe_spec.rb +82 -0
  24. data/ideas/hover.md +909 -0
  25. data/ideas/modal-backdrop.md +24 -0
  26. data/ideas/new-components.md +49 -29
  27. data/lib/tuile/buffer.rb +51 -3
  28. data/lib/tuile/color.rb +143 -0
  29. data/lib/tuile/color_depth.rb +80 -0
  30. data/lib/tuile/component/abstract_string_field.rb +106 -58
  31. data/lib/tuile/component/abstract_wrapping_field.rb +243 -0
  32. data/lib/tuile/component/big_decimal_field.rb +52 -79
  33. data/lib/tuile/component/button.rb +3 -3
  34. data/lib/tuile/component/checkbox.rb +3 -3
  35. data/lib/tuile/component/checkbox_group.rb +36 -20
  36. data/lib/tuile/component/combo_box.rb +68 -33
  37. data/lib/tuile/component/confirm_window.rb +442 -0
  38. data/lib/tuile/component/date_field.rb +322 -0
  39. data/lib/tuile/component/float_field.rb +57 -82
  40. data/lib/tuile/component/has_bad_input.rb +88 -0
  41. data/lib/tuile/component/has_caption.rb +8 -0
  42. data/lib/tuile/component/has_content.rb +43 -11
  43. data/lib/tuile/component/has_placeholder.rb +62 -0
  44. data/lib/tuile/component/has_validation.rb +115 -0
  45. data/lib/tuile/component/has_value.rb +28 -1
  46. data/lib/tuile/component/info_window.rb +64 -16
  47. data/lib/tuile/component/integer_field.rb +51 -78
  48. data/lib/tuile/component/label.rb +6 -38
  49. data/lib/tuile/component/layout/box.rb +87 -19
  50. data/lib/tuile/component/layout.rb +13 -13
  51. data/lib/tuile/component/list.rb +11 -6
  52. data/lib/tuile/component/list_dropdown.rb +22 -10
  53. data/lib/tuile/component/log_text_view.rb +71 -0
  54. data/lib/tuile/component/log_window.rb +13 -48
  55. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  56. data/lib/tuile/component/menu_bar.rb +5 -5
  57. data/lib/tuile/component/notification.rb +16 -34
  58. data/lib/tuile/component/overlay.rb +209 -0
  59. data/lib/tuile/component/popup.rb +59 -187
  60. data/lib/tuile/component/progress_bar.rb +1 -1
  61. data/lib/tuile/component/radio_group.rb +39 -22
  62. data/lib/tuile/component/select.rb +26 -10
  63. data/lib/tuile/component/slot.rb +54 -0
  64. data/lib/tuile/component/tab_sheet.rb +0 -11
  65. data/lib/tuile/component/tabs.rb +5 -5
  66. data/lib/tuile/component/text_area.rb +14 -8
  67. data/lib/tuile/component/text_field.rb +42 -15
  68. data/lib/tuile/component/text_view.rb +25 -8
  69. data/lib/tuile/component/time_field.rb +454 -0
  70. data/lib/tuile/component/window.rb +48 -59
  71. data/lib/tuile/component.rb +580 -54
  72. data/lib/tuile/event_queue.rb +21 -1
  73. data/lib/tuile/fake_screen.rb +37 -3
  74. data/lib/tuile/final.rb +75 -0
  75. data/lib/tuile/keys.rb +7 -0
  76. data/lib/tuile/locale.rb +851 -0
  77. data/lib/tuile/screen.rb +251 -55
  78. data/lib/tuile/screen_pane.rb +50 -44
  79. data/lib/tuile/styled_string.rb +40 -7
  80. data/lib/tuile/terminal_background.rb +74 -16
  81. data/lib/tuile/testing.rb +198 -0
  82. data/lib/tuile/theme.rb +100 -10
  83. data/lib/tuile/version.rb +1 -1
  84. data/lib/tuile/vertical_scroll_bar.rb +88 -12
  85. data/lib/tuile.rb +1 -0
  86. data/sig/tuile.rbs +4545 -770
  87. metadata +25 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5eb20428f8e05c04c1a1ba4b56313cdd0250cf7b002a3f3e6e7a6e74273950db
4
- data.tar.gz: dc18582bfb77d159b2181c5f946160c2ffa72232f22e977b37d75288756b4e8b
3
+ metadata.gz: b22fdf0be0fec94a9e4fdfd3b7875549bdae21c05268633c698584506d867c42
4
+ data.tar.gz: 58ceb7bb8ef81351d5f1f0a7c63413111c32fd21974b591446712444ea61ab3e
5
5
  SHA512:
6
- metadata.gz: 27d48291bf7cb57d7cfea72da131cbf5b44ffd85ce3b49d772b62b3944898b741038ce39da2d9999cc56b483b1eecd5c444f86c6fe19def878f8098957706031
7
- data.tar.gz: 473586d2424cd5f2d398b450aa10ee38e1b23560b427f99edbf7cef462c31fc32bf52e3cb450ff356e4c185ab3594060ace0bc743a5da95b06c4ae29fc3f928c
6
+ metadata.gz: 0a565f876fd6918d9303145a9aa545e3892b346bbc43a3213ccec579346a9f71e61e2646e1af080d5ab6906d72b0817f85ca116604e67c057243813c05902b80
7
+ data.tar.gz: 553527746d69b5e2f078d7edfb16941288252f850a5fa4b326bec0f0ae814400ad568c509a7080e1d1c5e75e3b7479650204be809a39926ef7f9ce2e9fd27984
data/CHANGELOG.md CHANGED
@@ -1,5 +1,118 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.15.0] - 2026-09-05
4
+
5
+ 0.15.0 is about the form. A field can now report input its type cannot
6
+ represent (`HasBadInput`), hold an outside validator's verdict
7
+ (`HasValidation`), paint either as a red well, hint at what it wants
8
+ (`HasPlaceholder`), and spell dates and times the way the session's new
9
+ `Tuile::Locale` says — `DateField` and `TimeField` are the two new ones, over a
10
+ shared `AbstractWrappingField`. Under them, two seams the widgets had been
11
+ improvising become framework: a background that resolves down the tree through
12
+ `default_bg_color` and `BG_INHERIT`, and `Component#visible=`, which hides a
13
+ component as if it were detached while it stays in the tree. And `Tuile::Testing`
14
+ lets a spec drive that UI by looking components up instead of reading the
15
+ painted buffer.
16
+
17
+ - 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 `DECISIONS.md` `D_visibility` and book ch7.
18
+ - 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.
19
+ - 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.
20
+ - 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.
21
+ - 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 `DECISIONS.md` `D_component_lookup`.
22
+ - 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.
23
+ - 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`.
24
+ - 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 `DECISIONS.md` `D_final_tree`.
25
+ - 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 `DECISIONS.md` `D_bg_surface`.
26
+ - 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 `DECISIONS.md` `D_bg_surface`.
27
+ - 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.
28
+ - 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 `DECISIONS.md` `D_bad_input` and book ch7.
29
+ - 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 `DECISIONS.md` `D_has_validation` and book ch7.
30
+ - 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.
31
+ - 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.
32
+ - 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 `DECISIONS.md` `D_placeholder` and book ch7.
33
+ - 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 `DECISIONS.md` `D_wrapping_field`.
34
+ - 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 `DECISIONS.md` `D_date_field` and book ch7.
35
+ - 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 `DECISIONS.md` `D_time_field` and book ch7.
36
+ - 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 `DECISIONS.md` `D_time_field` and book ch10.
37
+ - 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 `DECISIONS.md` `D_has_validation`.
38
+ - 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 `DECISIONS.md` `D_locale` and book ch10.
39
+ - 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 `DECISIONS.md` `D_locale`.
40
+ - 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.
41
+ - 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.
42
+ - 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 `DECISIONS.md` `D_date_field`.
43
+ - Add a `DateField` pane to `examples/sampler.rb` under Input → Typed, where typing `4.9.2026` and Tabbing away shows the buffer rewrite.
44
+ - 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 `DECISIONS.md` `D_kill_keys` and book ch7.
45
+ - 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.
46
+ - 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 `DECISIONS.md` `D_empty_ancestor`.
47
+ - 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.
48
+ - 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 `DECISIONS.md` `D_on_blur` and book ch5.
49
+ - 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 `DECISIONS.md` `D_component_contract`.
50
+ - 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.
51
+ - 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 `DECISIONS.md` `D_empty_ancestor`.
52
+ - 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.
53
+ - 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.
54
+ - 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 `DECISIONS.md` `D_input_filters`.
55
+ - 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.
56
+ - 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.
57
+ - 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 `DECISIONS.md` `D_scrollbar_reserve`.
58
+ - 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 `DECISIONS.md` `D_scrollbar_ink`.
59
+ - **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.
60
+ - **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 `DECISIONS.md` `D_no_key_interceptor` and book ch7.
61
+ - **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 `DECISIONS.md` `D_bracketed_paste`.
62
+ - **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 `DECISIONS.md` `D_paste_newlines`.
63
+ - **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 `DECISIONS.md` `D_placeholder`.
64
+ - **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 `DECISIONS.md` `D_scrollbar_ink` and `D_has_validation`.
65
+ - **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)`.
66
+ - **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.
67
+ - **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`.
68
+ - **Breaking:** `Component::FINAL_METHODS` is gone, replaced by the `Tuile::Final` declaration it became. Read `Component.final_methods` instead; `Component.verify_final!` is unchanged.
69
+ - **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 `DECISIONS.md` `D_wrapping_field`.
70
+ - **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 `DECISIONS.md` `D_wrapping_field`.
71
+
72
+ ## [0.14.0] - 2026-08-31
73
+
74
+ Tuile names the foundations it had been improvising: the floating layer becomes
75
+ `Component::Overlay` with `Popup` as its always-modal subclass, a swappable
76
+ region becomes a `Component::Slot` over a now-final tree API, and the gap
77
+ between the rect a parent assigns and the cells a widget paints becomes
78
+ `Component#extent`. Color learns the terminal's limits — a depth probe
79
+ quantizes every color at the wire, so a computed RGB tint survives a 256-color
80
+ terminal untouched by the app — and the terminal's own background becomes
81
+ readable as a `Color`. On top of that sit the dialogs: `ConfirmWindow`, an
82
+ `InfoWindow` prose body, and `LogTextView` for a frameless log.
83
+
84
+ - 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.
85
+ - 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`.
86
+ - 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`.
87
+ - 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.
88
+ - 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`.
89
+ - 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`.
90
+ - 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.
91
+ - 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`.
92
+ - 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).
93
+ - 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.
94
+ - 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.
95
+ - 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.
96
+ - 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.
97
+ - 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.
98
+ - Add `FakeScreen#background_color=` — plays the terminal answering the re-probe, so a spec can drive app code that derives colors from it.
99
+ - 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`.
100
+ - 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.
101
+ - 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.
102
+ - Fix a click on `Component::Window` chrome not landing focus on the window, which its `focusable?` has claimed all along.
103
+ - Fix `Component::ListDropdown#anchor_to` placing the panel over a multi-row driver's second row: "beneath" is now the row after the anchor rather than `anchor.top + 1`. `ComboBox` and `Select` pass the one row they paint.
104
+ - 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.
105
+ - 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.
106
+ - 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.
107
+ - 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`.
108
+ - **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`.
109
+ - **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.
110
+ - **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.
111
+ - **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`.
112
+ - **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=`.
113
+ - **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)`.
114
+ - **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.
115
+
3
116
  ## [0.13.0] - 2026-08-25
4
117
 
5
118
  Tuile grows the navigation chrome an app builds its shell from — a `MenuBar`
@@ -9,38 +122,38 @@ fills the terminal and an app builds its own status line. A paste also stops
9
122
  being a burst of keystrokes, arriving as one `Component#handle_paste` instead
10
123
  of one keystroke per character.
11
124
 
12
- - 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.
13
- - 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.
125
+ - 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.
126
+ - 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.
14
127
  - 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.
15
- - 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.
128
+ - 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.
16
129
  - 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.
17
130
  - 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.
18
131
  - 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`.
19
132
  - 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.
20
- - 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.
133
+ - 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.
21
134
  - 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.
22
135
  - 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.
23
- - 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`.
136
+ - 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`.
24
137
  - 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.
25
138
  - 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.
26
139
  - 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.
27
- - 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.
140
+ - 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.
28
141
  - Add `Screen#run_event_loop(bracketed_paste:)` — on by default, mirroring `capture_mouse:`; pass false for a terminal that mishandles mode 2004.
29
142
  - 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.
30
143
  - Add `EventQueue::PasteEvent` — the whole clipboard as one frozen event, posted by the key thread.
31
144
  - Add `FakeScreen#paste` — normalizes and dispatches like the real key thread, so a spec can hand it the CR line endings terminals actually send.
32
145
  - 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`.
33
146
  - 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).
34
- - 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`.
147
+ - 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`.
35
148
  - 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.
36
149
  - 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.
37
150
  - **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.
38
- - **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`.
151
+ - **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`.
39
152
  - **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)).
40
153
  - **Fix:** `Component::TextArea`'s rdoc had the two line-break bytes backwards — a pasted break arrived as `\r`, not `\n`.
41
- - **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`.
154
+ - **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`.
42
155
  - **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)`.
43
- - **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`.
156
+ - **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`.
44
157
  - **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.
45
158
  - **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.
46
159
  - **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.
@@ -54,14 +167,14 @@ folded onto it. The framework also settles its scrolling vocabulary in one
54
167
  pass: `row` is the terminal grid unit everywhere, `line` means exactly what
55
168
  `String#lines` returns, and `items` are the domain objects a widget renders.
56
169
 
57
- - 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.
170
+ - 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.
58
171
  - 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.
59
172
  - 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=`.
60
173
  - Add `Component::ListDropdown#items` / `#items=` / `#renderer=`, forwarding to its list.
61
- - 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.
62
- - 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`.
63
- - 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`.
64
- - 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`.
174
+ - 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.
175
+ - 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`.
176
+ - 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`.
177
+ - 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`.
65
178
  - `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.
66
179
  - `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.
67
180
  - `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.
@@ -70,38 +183,38 @@ pass: `row` is the terminal grid unit everywhere, `line` means exactly what
70
183
  - **Fix:** `examples/file_commander.rb` navigates again — Enter on a directory called `Rainbow.uncolor` on a `StyledString` and raised.
71
184
  - **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.
72
185
  - **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.
73
- - **Breaking:** `Component::ListDropdown#lines=` / `#lines` are removed — use `#items=` with a `#renderer=`. See `DECISIONS.md` `D-list-items`.
74
- - **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`.
75
- - **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`.
186
+ - **Breaking:** `Component::ListDropdown#lines=` / `#lines` are removed — use `#items=` with a `#renderer=`. See `DECISIONS.md` `D_list_items`.
187
+ - **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`.
188
+ - **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`.
76
189
  - **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.
77
190
  - **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.
78
191
  - **Breaking:** `VerticalScrollBar.new(line_count:, top_line:)` is now `.new(row_count:, scroll_top_row:)`. Rename the keywords.
79
192
 
80
193
  ## [0.11.0] - 2026-08-12
81
194
 
82
- - 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.
83
- - 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.
84
- - 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`.
85
- - 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`.
195
+ - 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.
196
+ - 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.
197
+ - 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`.
198
+ - 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`.
86
199
  - 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.
87
200
  - `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`.
88
201
  - `examples/sampler.rb` gains `Select`, `FloatField` and `BigDecimalField` panes, and its demo panes are ported to the box layouts.
89
- - **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`.
202
+ - **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`.
90
203
  - **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.
91
204
  - **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.
92
205
  - **Breaking:** `Component::ComboBox::MAX_VISIBLE_ROWS` moved to `Component::ListDropdown::MAX_VISIBLE_ROWS`. Update the constant reference.
93
- - **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`.
206
+ - **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`.
94
207
 
95
208
  ## [0.10.0] - 2026-08-02
96
209
 
97
- - 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`.
98
- - 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`.
99
- - 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`.
210
+ - 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`.
211
+ - 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`.
212
+ - 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`.
100
213
  - 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.
101
214
  - 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.
102
215
  - 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.
103
- - 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`.
104
- - 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`.
216
+ - 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`.
217
+ - 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`.
105
218
  - 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.
106
219
  - `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.
107
220
  - `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.
@@ -109,17 +222,17 @@ pass: `row` is the terminal grid unit everywhere, `line` means exactly what
109
222
  - `Screen#close` now unmounts the component tree (via `ScreenPane#detach_all`), so teardown fires `on_detached` across it.
110
223
  - `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.
111
224
  - `examples/sampler.rb` gains panes for `ProgressBar`, `RadioGroup` and `CheckboxGroup`.
112
- - 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`.
113
- - 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`.
114
- - **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`.
115
- - **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`.
116
- - **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`.
117
- - **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`.
225
+ - 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`.
226
+ - 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`.
227
+ - **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`.
228
+ - **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`.
229
+ - **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`.
230
+ - **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`.
118
231
  - **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.
119
232
  - **Breaking:** `Component::TextInput` is renamed `Component::AbstractStringField` (file `text_input.rb` → `abstract_string_field.rb`). Only code referencing the constant directly must update.
120
233
  - **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`.
121
- - **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`.
122
- - **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`.
234
+ - **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`.
235
+ - **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`.
123
236
  - **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.
124
237
 
125
238
  ## [0.9.0] - 2026-07-05
data/COMPARISON.md ADDED
@@ -0,0 +1,101 @@
1
+ # COMPARISON.md
2
+
3
+ Tuile's neighbours: what else exists, and — the question this file exists to
4
+ answer — how much of it you can actually *reach from Ruby* on an ordinary Linux
5
+ box without building bindings first.
6
+
7
+ The short version: of the three alternatives the README names, **none** is
8
+ callable from Ruby via the distro package manager. What is callable is a pair
9
+ of curses bindings that sit *below* Tuile, and the tty-toolkit that Tuile is
10
+ already built on.
11
+
12
+ ## The alternatives
13
+
14
+ The three the README points here for:
15
+
16
+ - **[tty-toolkit](https://ttytoolkit.org/)** (`tty-prompt`, `tty-cursor`, …) —
17
+ low-level building blocks, not a framework: no component tree, no event loop,
18
+ no invalidation. Tuile sits on top of `tty-cursor` / `tty-screen` and adds
19
+ the framework layer.
20
+ - **[vedeu](https://github.com/gavinlaking/vedeu)** — the closest Ruby
21
+ comparable, unmaintained since 2017.
22
+ - **[ratatui](https://github.com/ratatui/ratatui)** — the popular Rust TUI
23
+ framework; its immediate-mode API is closer to `tty-prompt` than to Tuile's
24
+ retained component tree.
25
+
26
+ Beyond those, DECISIONS.md surveys **Textual**, **urwid**, **brick**,
27
+ **Lipgloss**, **notcurses**, **FTXUI** and **Ink** — but as per-decision
28
+ precedent inside `D_` entries (`D_bg_inherit`, `D_key_dispatch`,
29
+ `D_box_layouts`, `D_list_items` are the dense ones), not as a roster. Look
30
+ there for "what does Textual do about focus-first key dispatch", not for
31
+ "should I use Textual".
32
+
33
+ ## Reachable from Ruby, via apt
34
+
35
+ Surveyed on Ubuntu 26.04 (resolute) in 2026-08, against the distro's own
36
+ `/usr/bin/ruby` 3.3.8 — which matters, see the caveats below.
37
+
38
+ | Candidate | apt package | Callable from Ruby? |
39
+ |---|---|---|
40
+ | vedeu | *no package at all* | — |
41
+ | ratatui | `librust-ratatui-dev` (Rust source only) | no |
42
+ | Textual | `python3-textual` | no |
43
+ | urwid | `python3-urwid` | no |
44
+ | notcurses | `libnotcurses-dev`, `notcurses-bin` | no bindings — you would write the FFI |
45
+ | CDK, newt | `libcdk5-dev`, `libnewt-dev` | C and Python only |
46
+ | tty-toolkit | `ruby-tty-prompt`, `-cursor`, `-screen`, `-reader`, `-color`, `-pastel` | **yes** — but this is Tuile's own substrate |
47
+ | curses | `ruby-curses` | **yes** |
48
+ | ncurses + panel/form/menu | `ruby-ncurses` | **yes** — the closest of the lot |
49
+
50
+ `dialog` and `whiptail` are also packaged and can be shelled out to from Ruby,
51
+ but a subprocess that paints one dialog and exits is a different tool from a
52
+ framework that owns a running screen; they are out of scope here.
53
+
54
+ ### The two curses bindings
55
+
56
+ Neither is in the README's list, and between them they are the only way to
57
+ reach anything Tuile-shaped from Ruby without a compiler and a binding project.
58
+
59
+ - **`ruby-curses`** — the [ruby/curses](https://github.com/ruby/curses) gem,
60
+ wide-char, shipping `curses.so` plus a gemspec under
61
+ `rubygems-integration`, so `gem "curses"` resolves under Bundler with no
62
+ build step. Strictly low-level: windows, `addstr`, `getch`. No widgets, no
63
+ tree, no invalidation — *below* tty-toolkit, not beside it.
64
+ - **`ruby-ncurses`** — the `ncursesw` gem
65
+ ([sup-heliotrope fork](https://github.com/sup-heliotrope/ncursesw-ruby)), and
66
+ the interesting one. Its extension links `libpanelw`, `libformw` **and**
67
+ `libmenu`, and exports `new_form` / `new_menu` / `form_driver` /
68
+ `menu_driver`; `examples/form.rb` ships in the package. So you get
69
+ overlapping windows (Tuile's popup stack), field editing with validation, and
70
+ list selection, out of the box.
71
+
72
+ ### Why `ruby-ncurses` still is not a substitute
73
+
74
+ The gap is the framework layer, and the framework layer is the whole of Tuile.
75
+ ncurses forms and menus are a *fixed* widget set driven by a
76
+ `form_driver(request)` call. What is missing, in Tuile's own terms:
77
+
78
+ - no `parent` / `children` tree you compose your own components into — you
79
+ cannot write a component, only configure theirs;
80
+ - no top-down layout: nothing assigns a child its `rect`, so every rectangle is
81
+ arithmetic you keep by hand (book ch3 for why Tuile made that a rule rather
82
+ than an engine);
83
+ - no invalidate-and-batch-repaint, so no minimal-diff flush — you decide when
84
+ to `refresh` and pay for whatever you redraw;
85
+ - no theme or inherited `bg_color`, no `HasValue` value seam a forms layer can
86
+ iterate, no `FakeScreen` to assert painted cells against.
87
+
88
+ You would be building Tuile *on top of* it, not replacing Tuile with it — and
89
+ since `ncursesw-ruby` is a 1.4.x mirror of the C API, whatever you built would
90
+ sit directly on `WINDOW*` semantics.
91
+
92
+ ### Caveats on the apt route
93
+
94
+ - **`ruby-ncurses` and `ruby-curses` are built against the distro's Ruby** —
95
+ `Depends: libruby (<< 1:3.4~)` on resolute. They are invisible to any
96
+ rbenv/rvm/chruby Ruby, and they break on a distro Ruby upgrade. The gems
97
+ (`gem install curses` / `ncursesw`) compile against `libncurses-dev` and do
98
+ not have that problem.
99
+ - **The table above is a snapshot of one release.** Package availability is the
100
+ fastest-rotting fact in this file; re-run `apt-cache policy <pkg>` before
101
+ trusting a row.