tuile 0.10.0 → 0.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 613c66d096b205b29545b61cb0e17be3a7372079d2e4711a06098016fd50cd65
4
- data.tar.gz: ca88dbc493cf0aba4ec46cfe99d786685c75fa579d722ad2582b7d52f42a1bbc
3
+ metadata.gz: 7560121c75afad2579449ec75052597e780749ecd56b9aca7169288bd5ffbbca
4
+ data.tar.gz: 2032440b0a0831027997ce8a193457ae64eb96c425866c2f40a123933b80f20f
5
5
  SHA512:
6
- metadata.gz: 4fec7e60c1c37f3ac4d0050a5f709444e0e6dd76eb61d96f6128da0136fe38bfa7a91e55c58982d2c16612099e72e248f34e11824ebfd1705b75594a0ca41a82
7
- data.tar.gz: e1696cf08df68aeb72ccdd13d628c958991ef59000489ac17722f2d7bebb52effccf368ff167db56617b65348113b0e7d1437d5a65ff967c707a932b303e24d9
6
+ metadata.gz: a9fc19844524d5c3762a80ec43315bc8e98408cf3b62adb427ae3b93361a4e7a7d415a92eeddb068d12ba28050b0c5dce7c1716a8d5245ac47ee6d86d2869507
7
+ data.tar.gz: d92742ed5181d75d1ae80c8552cf7c0112cd485bf2b613206227e907fb660d49a2a1e041b1b97f3c0ca76b6bd06a87b8259e3a118381f4d9b6b5cb854a053d13
data/CHANGELOG.md CHANGED
@@ -1,95 +1,108 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.11.0] - 2026-08-12
4
+
5
+ - 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.
6
+ - 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.
7
+ - 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`.
8
+ - 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`.
9
+ - 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.
10
+ - `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`.
11
+ - `examples/sampler.rb` gains `Select`, `FloatField` and `BigDecimalField` panes, and its demo panes are ported to the box layouts.
12
+ - **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`.
13
+ - **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.
14
+ - **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.
15
+ - **Breaking:** `Component::ComboBox::MAX_VISIBLE_ROWS` moved to `Component::ListDropdown::MAX_VISIBLE_ROWS`. Update the constant reference.
16
+ - **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`.
17
+
3
18
  ## [0.10.0] - 2026-08-02
4
19
 
5
- - Fix the caret stepping by *character* rather than by grapheme cluster: LEFT/RIGHT, BACKSPACE and DELETE now move over and delete exactly one cluster, so a decomposed `é`, a ZWJ family emoji and a three-jamo Hangul syllable each take one press, and a deletion can no longer strand a combining mark with no base. The caret still counts characters but is now *boundary-locked* — `caret=` and `text=`'s clamp both snap forward to the smallest cluster boundary `>= index`, which makes a mid-cluster caret unrepresentable. Insertion stays character-native so that typing a combining mark still merges into its base. See `DECISIONS.md` `D-cluster-caret`.
6
- - Add `Component::PasswordField` — a `TextField` painting one mask glyph per character (`mask_char=`, default `*`; rejects a wide or multi-cluster glyph), with a `revealed=` / `revealed?` toggle that shows the real text. While masked, word-jumps collapse to the ends of the buffer so the mask can't leak word boundaries. Constructor takes no arguments.
7
- - Add `Component::TextField#display_text` — the protected seam a subclass overrides when it paints something other than `text`. Overriding `repaint` alone is a bug: the caret, the horizontal scroll window and click-to-position all measure `display_text`, so painting a substitute behind their backs makes them drift. The contract is one display character per `text` character, in order.
8
- - Add `Component::ProgressBar` — a display-only fill over a `Range` (`range:`, default `0.0..1.0`), with `fraction` / `percent` readers and an `indeterminate` mode that owns a ticker (5 fps) for as long as the bar is attached. Its `bar_color` slot defaults to `nil` (the terminal's own foreground) and accepts a `Color` or a `Theme::Ref`, the latter live-resolved at paint time so it follows a theme change with no `on_theme_changed` hook. It deliberately stays *out* of `Component::HasValue`: a value that isn't a field shouldn't become focusable or enrol in the seam a forms layer iterates. There is no text on the bar — compose a `Label`. See `DECISIONS.md` `D-progress-bar` and `D-color-slots`.
9
- - Add `Component#on_attached` / `on_detached` lifecycle hooks fired once per component per transition as a subtree is mounted or unmounted, letting a component own a mounted-lifetime resource (`ProgressBar`'s ticker is the worked example). `attached?` is guaranteed `true` throughout the former and `false` throughout the latter, so an `invalidate` lands in one and no-ops in the other. They are hooks, not destructors: a process that exits without `Screen#close` fires nothing. See `DECISIONS.md` `D-attach-hooks`.
10
- - `Screen#close` now unmounts the component tree (via `ScreenPane#detach_all`), so teardown fires `on_detached` across it. The teardown flags are set in an `ensure`, so a raising hook still propagates loudly without leaving a half-closed screen behind.
11
- - **Breaking:** `Component#children` is final, and reparenting goes through the protected `add_child(child, at:)` / `remove_child(child)` / `detach_child(child)` — they write the array *and* the parent pointer in one call, and `parent=` is the sole firing site for the attach/detach hooks. A custom container must no longer override `children` or hand-wire `child.parent = …`: a derived `children` can disagree with the parent pointers, which `attached?` walks. Named slots (`Window#footer`, `HasContent#content`) are readers *over* the array, never a second copy. See `DECISIONS.md` `D-tree-api`.
12
- - **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. Previously it compared `root` against the singleton's current pane, which made `Screen#close` silently mass-detach every tree and made `Screen.instance` a prerequisite for asking the question. See `DECISIONS.md` `D-tree-first`.
13
- - **Breaking:** the UI-thread guard is rewritten and named. `EventQueue#locked?` is replaced by `#running?` (is a loop active anywhere) and `#on_loop_thread?` (is it mine); `Screen#check_locked` asks both and falls back to the creating thread only when no loop runs, so the loop need not run on the thread that built the tree. The internal `@pretend_ui_lock` — set once in `initialize`, cleared on the loop's first line and never restored, so every UI call during teardown raised "UI lock not held" — is gone, along with `FakeScreen#check_locked`'s bypass: a spec that mutates UI from a *spawned* thread now raises exactly as an app would. Adds `Screen#state` (`:idle` → `:running` → `:idle` → … → `:closed`), and `Screen#close` is now idempotent and refuses from `:running`. See `DECISIONS.md` `D-screen-lifecycle`.
14
- - **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. A cluster is *not* capped at two columns — a non-RGI ZWJ sequence measures 4 — and `Buffer#put_char` models an arbitrary continuation run for it. Custom components measuring with `String#length` or iterating `each_char` were already wrong and are now visibly so; use `StyledString#display_width` / `slice` / `ellipsize`. See `DECISIONS.md` `D-cluster-width`.
15
- - Fix `Component::TextArea` hanging the UI thread on text containing `\r`, `\v` or `\f` each matches `/\s/` but not `/[ \t]/` and isn't `"\n"`, so the word-scan measured zero and the wrap loop never advanced (`area.text = File.read(crlf_file)` was enough to lock up an app). The wrap now iterates grapheme clusters, consumes at least one on every branch, and tests `end_with?("\n")` for hard breaks because `"\r\n"` is a single cluster. See `DECISIONS.md` `D-text-area-columns`.
16
- - **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. Previously the two axes were one number: with `"日本語"` the hardware cursor landed mid-glyph, the background well overran the rect by a column per wide glyph, a click mapped its column straight onto a character index, and the capacity check counted characters against a column budget (a 10-wide field accepted 18 columns of CJK). `max_text_length` is now an explicit, settable cap defaulting to `nil` (unbounded) rather than the derived `rect.width - 1`, and it counts **characters** knowingly it is the one input measure that doesn't use the edit unit. Adds `left_column`. See `DECISIONS.md` `D-text-field-axes`.
17
- - Add `Component::RadioGroup` single-select over typed `items` (with an `item_label` proc), whose `value` is the selected item. Composes a `List`, so the cursor, scrolling, scrollbar and per-row hit-testing come free: the cursor **roams** without selecting (it is chrome, not the value), and Space, Enter or a click commits the row under it. `items=` clamps the cursor when the row set shrinks. See `DECISIONS.md` `D-radio-group`.
18
- - Add `Component::CheckboxGroup` multi-select over typed `items`, whose `value` is a **frozen** `Set` of the selected items (so `cg.value << item` raises rather than mutating state behind `on_value_change`'s back). `value=` coerces any `Enumerable` before the no-op guard and stores a copy; toggles go through `Set#+`/`#-`. The set iterates in toggle order, so the contract is unordered — use `items & value.to_a` when order matters. Space, Enter or a click toggles the row under the cursor. There is no select-all key. See `DECISIONS.md` `D-checkbox-group`.
19
- - For both group components — and for `ComboBox` before them — `items=` is chrome and never touches `value`: an absent value renders as nothing selected and survives intact, so a form saved without edits changes nothing silently. There is no reconcile step, clamp or silent drop.
20
+ - 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`.
21
+ - 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`.
22
+ - 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`.
23
+ - 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.
24
+ - 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.
25
+ - 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.
26
+ - 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`.
27
+ - 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`.
28
+ - 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.
29
+ - `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.
30
+ - `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.
31
+ - `Component::Window#caption` and `Component::Button#caption` accept a `String | StyledString | nil` and are built, clipped and ellipsized by **display** width, so a CJK/emoji caption no longer overruns the frame or the rect.
32
+ - `Screen#close` now unmounts the component tree (via `ScreenPane#detach_all`), so teardown fires `on_detached` across it.
33
+ - `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.
20
34
  - `examples/sampler.rb` gains panes for `ProgressBar`, `RadioGroup` and `CheckboxGroup`.
21
- - Add `Component::Checkbox` a one-row boolean input (`[x] Enable syslog forwarding`) toggled by Space or a left-click. Its `value` is always `true`/`false` (never `nil`, coerced on assignment) with unchecked as the empty value, and `checked?`/`checked=`/`toggle` are the domain-word face over that one piece of state. Enter is deliberately unhandled so a form's Enter-to-submit bubbles past a focused checkbox. Constructor is `Checkbox.new(caption = nil, value: false)`. See `DECISIONS.md` `D-boolean-fields`.
22
- - **Breaking (behavior):** `Component::Button#handle_mouse` now fires `on_click` only within the button's painted **extent** — `min(caption.display_width + 4, rect.width)` columns, one row rather than anywhere in its `rect`. A click on the blank tail of an over-wide rect, or on a lower row of a multi-row one, still *focuses* the button but no longer activates it. `Component::Button#extent` is public, and `Component::Checkbox` follows the identical rule; the extent deliberately does not widen when a `bg_color` paints that tail.
23
-
24
- - **Breaking:** `Component#key_shortcut` and `Component#find_shortcut_component` are **removed**, along with the *capture* phase of `ScreenPane#handle_key` that scanned the scope for a match, and `Window`'s `[k]-Caption` border prefix. Key dispatch is now three rungs — Tab, the global-shortcut registry, then delivery to `Screen#focused` bubbling up its ancestors and `Screen#cursor_position` is purely "where to park the hardware cursor," no longer a routing signal. A scope-wide one-key binding (jump-to-pane, a form's default button) belongs on the scope root's own `handle_key`, which gets better semantics for free: it's scoped per popup, a focused `TextField` consumes the key before the ancestor sees it, and there's no registration/lifecycle bookkeeping. Migration: `w.key_shortcut = "1"` becomes a `case` in the containing layout's `handle_key` (and the caption prefix, if you want it, becomes part of the caption you set). See `DECISIONS.md` `D-key-dispatch`.
25
- - **Breaking:** `Screen#register_global_shortcut` now also rejects `Screen::EDITING_KEYS` — `ENTER`, `BACKSPACE`, `DELETE` and the arrow keys. The registry sits above the component tree with nothing to suppress it, so `register_global_shortcut(Keys::ENTER) { submit }` silently broke `TextArea` newlines and `TextField#on_enter` app-wide; it now raises at registration and the message points at the ancestor-`handle_key` pattern instead. `HOME`/`END`/`PAGE_UP`/`PAGE_DOWN` stay legal (they navigate within a widget, and binding them app-wide is a real use case).
26
- - Add `Component::IntegerField` a single-line input whose `value` is an `Integer` (or `nil` when empty/un-parseable). Accepts only `0`–`9` and a single leading `-`; any other printable key is rejected without moving the caret. Up/Down step the value by one (an empty field counting as `0`). Composes a `TextField` (never subclasses one, so its face carries only the typed `HasValue` seam, not the widget's String `text`); `on_value_change` fires eagerly, once per real value change. No `min`/`max`, `+`, or grouping (a forms concern).
27
- - `Component::HasValue` now carries `focusable? = true` (overridable), the shared input-field default previously duplicated on `AbstractStringField` and `ComboBox`. `tab_stop?` is deliberately *not* folded in: it stays `true` on the leaf `AbstractStringField` and `false` on the composing wrappers (`ComboBox`/`IntegerField`), whose inner field carries the stop.
28
- - `Component::ComboBox` and `Component::IntegerField` compose their inner field via `Component::HasContent` (the one-child-container mixin) instead of hand-rolling `children`/`rect=`/`on_focus`; this also gives them click-to-position-caret on the field. Their `content`/`content=` are consequently public.
29
- - Add `Component::HasCaption` — the caption seam, included by `Button` and `Window`: a `StyledString` caption coerced from `String | StyledString | nil`, with no-op detection and invalidation in one place. It also coins the naming split (**caption** is app-authored chrome; **text** is the user-editable value, aliased to `HasValue#value`), and makes tree-walking lookups possible — `is_a?(Component::HasCaption)` plus a caption compare finds "the Button captioned Submit" without a hardcoded class list.
30
- - `Component::Window#caption` now accepts a `String | StyledString | nil` and the top border is built and clipped by **display** width: a CJK/emoji title no longer overruns the frame or mis-pads the dashes (the bottom border already did this for `footer_text`). An active window's border color claims the caption's own span colors, so the frame reads as one band. Both border lines are also clipped to `rect.width`, so a degenerate 1-column window no longer paints a second corner glyph outside its rect.
31
- - `Component::Button#caption` now accepts a `String | StyledString | nil` (coerced via `StyledString.parse`, as `Label#text=` does), and the label is ellipsized to `rect.width` by **display** width rather than sliced by character count — a double-width (CJK/emoji) caption no longer paints past `rect.right`. The focus highlight is applied with `with_bg`, so it reads as one band across a styled caption.
32
- - **Breaking:** `Component::Button#caption` and `Component::Window#caption` return a `StyledString`, not a `String`. Callers measuring one must use `caption.display_width` (the natural button width is `caption.display_width + 4`); `caption.to_s` recovers the plain text.
33
- - **Breaking:** `Component::TextInput` is renamed `Component::AbstractStringField` (file `text_input.rb` → `abstract_string_field.rb`), and its doc now scopes it as the *String-valued* base of `TextField`/`TextArea` — a field whose value isn't a `String` composes one rather than subclassing it. `TextField`/`TextArea` are unaffected; only code that referenced `TextInput` directly must update the constant.
35
+ - 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`.
36
+ - 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`.
37
+ - **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`.
38
+ - **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`.
39
+ - **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`.
40
+ - **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`.
41
+ - **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.
42
+ - **Breaking:** `Component::TextInput` is renamed `Component::AbstractStringField` (file `text_input.rb` `abstract_string_field.rb`). Only code referencing the constant directly must update.
43
+ - **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`.
44
+ - **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`.
45
+ - **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`.
46
+ - **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.
34
47
 
35
48
  ## [0.9.0] - 2026-07-05
36
49
 
37
50
  Layout is now strictly top-down: a parent assigns each child's `rect`, and components no longer advertise how big they want to be. The book's chapter 3 is the long-form rationale.
38
51
 
39
52
  - Add `Tuile::Fraction` (`HALF` / `FULL`, int-coercing, floor-at-1 `resolve`) — the one relational sizing primitive, scoped to `Popup#size=`.
40
- - `Component::Popup#size=` accepts a `Size` (clamped to the screen) or a `Fraction` (resolved against the screen on every layout pass, so it tracks resize); default `Fraction::HALF`. Adds a `size:` kwarg to `Popup.new` / `Popup.open`, forwarded through `InfoWindow.open`. Popups no longer auto-size to their content.
41
- - `Component::Window`: the single footer slot is split into two purpose-fit members — `footer_text=` (a `StyledString` embedded into the bottom border line at its own width with dashes filling the remainder, mirroring `caption` on top; not a component, not focusable) and `footer=` (a focusable component always spanning the full inner width of the bottom row). A `footer=` component present hides `footer_text`.
42
- - Add optional `Component::Label.new(text = nil)` — constructor symmetry with `Window.new(caption)`, reusing the `text=` coercion path (`String | StyledString | nil`). Purely additive; `Label.new` still works.
53
+ - Add optional `Component::Label.new(text = nil)` constructor symmetry with `Window.new(caption)`, reusing the `text=` coercion path. Purely additive; `Label.new` still works.
43
54
  - Add `EventQueue#tick_fps(fps)` = `tick(1.0 / fps)` for the frames-per-second animation idiom; lands on both the real and fake queues.
44
- - Fix `Buffer#flush` corrupting the left neighbour of a wide glyph: a dirty continuation cell (the right half of a wide glyph) no longer opens a flush run, so the next glyph is no longer shifted onto and blanked by — the wide glyph's right half (e.g. an emoji vanishing when an adjacent cell changed, persisting across tmux window switches).
45
- - Memoize display-width measurement in `Buffer` and measure each grapheme once per paint: a full-screen 160×50 repaint drops from ~29.5ms to ~5.8ms (~5×). Adds `rake benchmark` (excluded from the packaged gem).
46
- - **Breaking:** the eager bottom-up sizing channel is deleted — `Component#content_size` / `content_size=` / `on_child_content_size_changed`, and the `Component#popup_min_height` / `popup_max_height` height-advice hooks added in 0.8.0. Containers now compute their children's rectangles in plain Ruby (in a `rect=` override) and hand them down; content fills or scrolls within the rect it's given. Custom components that advertised a size must move that logic into the parent.
55
+ - `Component::Popup#size=` accepts a `Size` (clamped to the screen) or a `Fraction` (re-resolved on every layout pass, so it tracks resize), default `Fraction::HALF`; popups no longer auto-size to their content.
56
+ - `Component::Window`: the single footer slot splits into `footer_text=` (a `StyledString` embedded into the bottom border line, mirroring `caption` on top) and `footer=` (a focusable component spanning the full inner width of the bottom row), the latter hiding the former.
57
+ - Fix `Buffer#flush` corrupting the left neighbour of a wide glyph: a dirty continuation cell no longer opens a flush run, so an adjacent change no longer blanks the glyph (an emoji vanishing, persisting across tmux window switches).
58
+ - Memoize display-width measurement in `Buffer` and measure each grapheme once per paint: a full-screen 160×50 repaint drops from ~29.5ms to ~5.8ms (~5×). Adds `rake benchmark`.
59
+ - **Breaking:** the eager bottom-up sizing channel is deleted — `Component#content_size` / `content_size=` / `on_child_content_size_changed`, plus 0.8.0's `popup_min_height` / `popup_max_height`. A custom component that advertised a size must move that logic into its parent's `rect=`.
47
60
  - **Breaking:** `Tuile::Sizing` (`FILL` / `WRAP_CONTENT` / `Sizing.fixed`) and `Window#footer_sizing`, both added in 0.6.0, are removed — a bottom-row `footer=` widget is always FILL by construction.
48
- - **Breaking:** `EventQueue#tick(seconds)` now takes an interval in **seconds**, not frames-per-second. `tick(4)` used to mean "4 times a second"; it now means "every 4 seconds", matching `sleep` / `TimerTask` and every other Ruby scheduler. Use the new `tick_fps(fps)` for the animation mental model.
61
+ - **Breaking:** `EventQueue#tick(seconds)` now takes an interval in **seconds**, not frames-per-second. `tick(4)` used to mean "4 times a second" and now means "every 4 seconds"; use the new `tick_fps(fps)` for the animation mental model.
49
62
 
50
63
  ## [0.8.0] - 2026-06-11
51
64
 
52
- - Render through a back buffer: `Screen#buffer` is now a `Tuile::Buffer` cell grid sized to the viewport. Components paint into it; `Screen#repaint` flushes only the cells that changed since the last frame, wrapped with the cursor move in one synchronized-output batch (DEC mode 2026). Repaint is flicker-free on **any** terminal regardless of mode-2026 support, because an unchanged cell is never rewritten — the full-scene overdraw a shrinking popup forces no longer reaches the wire. `FakeScreen` exposes the populated buffer for content assertions (`row_text`/`row_ansi`/`region_text`/`region_ansi`/`cell`); `prints` now holds only the assembled frame and cursor housekeeping.
53
- - Add non-modal popups: `Component::Popup.new(modal: false)` still paints on top and auto-sizes to its content, but does not center, grab focus, capture keys, or block clicks the focused component keeps the cursor and keeps receiving keys. This is the building block for autocomplete / slash-command menus anchored to a text input's caret. `Popup#modal?` exposes the mode; `ScreenPane#modal_popup` is the topmost *modal* popup (or nil), through which all "modal owner" reads (key scope, mouse fall-through, focus repair, Tab scope, global-shortcut gate) now route.
54
- - Add `Component::TextInput#on_key` — an interceptor consulted before the input's own key handling (a truthy return consumes the key); the keyboard analog of `on_change`, for both `TextField` and `TextArea`. Lets app code layer Up/Down/Enter/ESC handling onto a field without subclassing.
55
- - Add `Component#popup_min_height` / `popup_max_height` — content components can advise a wrapping `Popup` of preferred height bounds. `Component::LogWindow` uses them to stay readable at half-screen height when nearly empty and grow to full-screen for busy logs.
65
+ - Render through a back buffer: `Screen#buffer` is now a `Tuile::Buffer` cell grid, and `Screen#repaint` flushes only the cells that changed since the last frame in one synchronized-output batch flicker-free on **any** terminal, since an unchanged cell is never rewritten.
66
+ - Add non-modal popups (`Component::Popup.new(modal: false)`) they paint on top but don't center, grab focus, capture keys or block clicks, which is the building block for autocomplete menus anchored to a caret. `ScreenPane#modal_popup` is the topmost *modal* popup, through which all modal-owner reads now route.
67
+ - Add `Component::TextInput#on_key` — an interceptor consulted before the input's own key handling (a truthy return consumes the key), letting app code layer Up/Down/Enter/ESC onto a field without subclassing.
68
+ - Add `Component#popup_min_height` / `popup_max_height` — content components can advise a wrapping `Popup` of preferred height bounds, which `Component::LogWindow` uses to grow from half-screen to full-screen as the log fills.
69
+ - Add an `examples/sampler.rb` "Slash menu" demo: a `TextArea` whose `on_change` refills a non-modal `Popup`-wrapped `List` anchored to the caret, with focus and caret staying in the field throughout.
56
70
  - `Component::LogWindow` now renders through a `TextView`, so long log lines wrap instead of being ellipsized.
57
- - Add `examples/sampler.rb` "Slash menu" demo: a `TextArea` whose `on_change` refills a non-modal `Popup`-wrapped `List` anchored to the caret, with `on_key` forwarding navigation and ESC dismissing focus and caret stay in the field throughout.
58
- - **Breaking:** components paint into `Screen#buffer` via `set_line` / `fill` / `set_char` (handing the surface a `StyledString`) instead of writing escape sequences through `screen.print`. Custom components that drew via `screen.print(move_to, ansi)` must migrate to the buffer API.
59
- - **Breaking:** key dispatch is centralized into a capture + bubble model in `ScreenPane#handle_key` (a `key_shortcut` match anywhere in scope focuses that component; the key is then delivered to `Screen#focused` and bubbles up its ancestor chain). A component's `handle_key` now acts on the key alone and never gates on its own `active?` state. `Layout#handle_key`, `Window#handle_key`, and `HasContent#handle_key` are removed (routing is the dispatcher's job), and the `active?` guards in `TextInput` / `List` / `Button` are dropped.
71
+ - **Breaking:** components paint into `Screen#buffer` via `set_line` / `fill` / `set_char` instead of writing escape sequences through `screen.print`. Custom components that drew via `screen.print(move_to, ansi)` must migrate to the buffer API.
72
+ - **Breaking:** key dispatch is centralized into a capture + bubble model in `ScreenPane#handle_key`, and a component's `handle_key` now acts on the key alone rather than gating on its own `active?` state. `Layout`/`Window`/`HasContent#handle_key` are removed and the `active?` guards in `TextInput`/`List`/`Button` are dropped.
60
73
 
61
74
  ## [0.7.0] - 2026-06-09
62
75
 
63
76
  - Lower the Ruby floor to 3.3 (was 3.4): replaced the `it` implicit block parameter (3.4+) with `_1` throughout, and added 3.3 to the CI matrix.
64
- - Fix `Component::Popup#close` raising `Tuile::Error` when the popup was not open — it is now the documented no-op (also covers calling `close` twice). `Screen#remove_popup` honors its "does nothing if not open" contract by guarding on `has_popup?`; `ScreenPane#remove_popup` keeps its strict internal assertion.
77
+ - Fix `Component::Popup#close` raising `Tuile::Error` when the popup was not open — it is now the documented no-op (also covering a double `close`). `Screen#remove_popup` guards on `has_popup?`; `ScreenPane#remove_popup` keeps its strict internal assertion.
65
78
 
66
79
  ## [0.6.0] - 2026-06-07
67
80
 
68
- - Add `Tuile::Theme` — semantic color tokens for the accents built-in components paint (the list-cursor/focused-input highlight `active_bg_color`, the inactive input well `input_bg_color`, the active window border `active_border_color`, the status-bar `hint_color`), with `DARK`/`LIGHT` presets and rendering helpers (`#active_bg`, `#active_border`, `#input_bg`, `#hint`). The current theme lives at `Screen#theme`; assigning restyles the whole UI in a single invalidate-everything pass. Everything that isn't an accent keeps inheriting the terminal's own default fg/bg.
69
- - Auto-detect the light/dark terminal background at startup: `Screen.new` queries the terminal via OSC 11 (`COLORFGBG` fallback, dark when inconclusive) and picks `Theme::LIGHT`/`Theme::DARK` to match.
81
+ - Add `Tuile::Theme` — semantic color tokens for the accents built-in components paint (`active_bg_color`, `input_bg_color`, `active_border_color`, `hint_color`) with `DARK`/`LIGHT` presets, living at `Screen#theme`; everything that isn't an accent keeps inheriting the terminal's own default fg/bg.
82
+ - Add `Tuile::ThemeDef` — an app's dark/light `Theme` pair. Assigning `Screen#theme_def=` is the durable way to theme an app, where a bare `theme=` is transient; construction validates that both members declare the same custom key set.
83
+ - Add `ThemeDef.default` — the definition newly-constructed screens start from. Reassign it once in `spec_helper.rb` and every `Screen.fake` carries the app's custom tokens, instead of repeating `theme_def=` in each `before` block.
84
+ - Add app-specific theme tokens: `Theme#custom` (`Hash{Symbol => Color}`), looked up fail-fast via `Theme#[]` and rendered via the generic `#fg`/`#bg` helpers. Subclass `Theme` to add one coloring function per custom token — `Data#with` preserves the subclass.
85
+ - Add `Component#on_theme_changed` — fired pre-order across the attached tree on every theme change, so apps can rebuild styled content whose colors came from the old theme. Override it (calling `super`) or assign the `on_theme_changed=` proc.
86
+ - Add `Color.hex` — a 24-bit RGB color from a CSS-style hex string (leading `#` optional, case-insensitive, 3-digit shorthand expands as in CSS). Alpha forms are rejected: SGR has no alpha channel.
87
+ - Add `Tuile::Sizing` (`FILL` / `WRAP_CONTENT` / `Sizing.fixed(n)`) and `Window#footer_sizing` — the footer slot is sized per policy against the inner width, and is excluded from `Window#content_size` since decoration must not drive window size.
88
+ - Name the 256-color palette: a constant per standard xterm chart name for indices 16..255 (`Color::CADET_BLUE`, `Color::GREY37`, …), listed in `Color::PALETTE_NAMES`; indices 0..15 keep the symbolic constants, which respect the terminal's own scheme.
89
+ - Auto-detect the light/dark terminal background at startup: `Screen.new` queries the terminal via OSC 11 (`COLORFGBG` fallback, dark when inconclusive) and picks the matching preset.
70
90
  - Follow OS light/dark appearance flips live via mode 2031 (kitty, foot, contour, ghostty, …): the screen re-picks the matching theme and repaints everything.
71
- - Add app-specific theme tokens: `Theme#custom` (`Hash{Symbol => Color}`), looked up fail-fast via `Theme#[]` (`KeyError` on typos) and rendered via the generic `#fg`/`#bg` helpers. Subclass `Theme` to add one semantic coloring function per custom token `Data#with` preserves the subclass. Theme tokens are strictly `Color` instances; `Color` gains the `Color.palette`/`Color.rgb` named constructors.
72
- - Add `Tuile::ThemeDef` — an app's dark/light `Theme` pair. Assigning `Screen#theme_def=` is the durable way to theme an app: the screen picks the member matching the detected background at startup and on every appearance flip, where a bare `theme=` assignment is transient. Construction validates that both members declare the same custom key set.
73
- - Add `ThemeDef.default` — the definition newly-constructed screens start from (initially `ThemeDef::DEFAULT`). Reassign it once in `spec_helper.rb` and every `Screen.fake` carries the app's custom tokens, instead of repeating `theme_def=` in each `before` block.
74
- - Name the 256-color palette: a constant per standard xterm chart name for palette indices 16..255 (`Color::CADET_BLUE` is `Color.palette(72)`; `Color::DODGER_BLUE1`, `Color::GREY37`, …) — exact palette cells, no quantization, listed in `Color::PALETTE_NAMES`. Where the chart names several cells identically, the first cell wins the constant; indices 0..15 keep the symbolic `Color::RED`/`Color::BRIGHT_BLUE`/… constants, which respect the terminal's own scheme.
75
- - Add `Color.hex` — a 24-bit RGB color from a CSS-style hex string (`Color.hex("#333333") == Color.rgb(51, 51, 51)`; leading `#` optional, case-insensitive, 3-digit shorthand expands as in CSS). Alpha forms (`#rgba`/`#rrggbbaa`) are rejected — SGR has no alpha channel. `Color.coerce` stays string-free; `.hex` is the explicit entry point.
76
- - Add `Component#on_theme_changed` — fired pre-order across the attached tree on every theme change, so apps can rebuild styled content whose colors were derived from the old theme. Override it (calling `super`) or assign the `on_theme_changed=` proc.
77
- - Add `Tuile::Sizing` (`FILL` / `WRAP_CONTENT` / `Sizing.fixed(n)`) and `Window#footer_sizing` — the footer slot is sized per policy against the inner width; a `WRAP_CONTENT` footer re-lays-out live as its content changes. The footer is excluded from `Window#content_size`: it is decoration overlaying the border and must not drive window size.
78
- - `Component#content_size` is now maintained eagerly: content mutators assign via the protected `content_size=` setter, which fires `parent.on_child_content_size_changed(self)` only when the value actually changed. Fixes a `Popup` staleness — an open popup now re-sizes and recenters when its content grows.
79
- - **Breaking:** `rainbow` is no longer a runtime dependency (nothing under `lib/` uses it — `Theme`/`StyledString`/`Color` produce all SGR output). Apps that style text with Rainbow must add it to their own Gemfile.
91
+ - `Component#content_size` is now maintained eagerly via the protected `content_size=` setter, which fires `parent.on_child_content_size_changed(self)` only on a real change; this fixes an open `Popup` not re-sizing when its content grew.
92
+ - **Breaking:** `rainbow` is no longer a runtime dependency (nothing under `lib/` uses it). Apps that style text with Rainbow must add it to their own Gemfile.
80
93
 
81
94
  ## [0.5.0] - 2026-05-21
82
95
 
83
- - Add `Tuile::Color` — a value type wrapping the four color forms ANSI understands (named Symbol, 256-color Integer, RGB Array, or `nil`). Pre-defined constants `Color::RED`, `Color::BRIGHT_BLUE`, … cover the 16 named ANSI colors; `Color.coerce` accepts raw forms transparently.
84
- - `Component::Label`: add `bg` accessor applies a background color uniformly across every painted row (text, trailing pad, and blank rows past the last line). Accepts anything `Color.coerce` accepts.
85
- - Add `Component::TextView::Region` — opaque handle to a contiguous run of hard lines, so apps can stream into logical sections without tracking line indices across sibling mutations. Create with `view.create_region`; mutate via `region.append`/`#<<`/`#text=`/`#add_line`/`#remove_last_n_lines`/`#replace`/`#insert`/`#remove`. Detached handles raise on every reader / mutator (except `#remove`, which is idempotent). `view.text=` / `clear` detach all region handles and install a fresh internal default.
86
- - Add `Component::TextView#replace(range, str)` and `#insert(at, str)` for mid-buffer hard-line splices (Integer or Range, inclusive/exclusive end, empty range == insertion, `begin == hard-line count` valid for end-insertion).
87
- - `Component::TextView`: incremental wrap via a per-hard-line row-count cache — mid-buffer mutations now re-wrap only the affected slice instead of the whole buffer. Speeds up the LLM streaming path (mid-document `region.append`, tombstone-style `region.text=`, `view.replace`/`view.insert`). `view.append` on the spatial tail keeps its existing fast path; `view.text=` and `on_width_changed` still do a full rewrap (now rebuilding the cache too).
88
- - Add `EventQueue#tick(fps) { |n| ... }` returning a `Ticker` backed by `Concurrent::TimerTask`; fires on the event-loop thread with a 0-based monotonic counter. Intended for spinner animations, periodic refresh, or surfacing background-task progress. Auto-cancels on raise.
89
- - Add `FakeEventQueue#tick` and `FakeTicker`synchronous test double that drives ticks deterministically.
96
+ - Add `Tuile::Color` — a value type wrapping the four color forms ANSI understands (named Symbol, 256-color Integer, RGB Array, or `nil`), with constants for the 16 named ANSI colors and a `Color.coerce` that accepts raw forms transparently.
97
+ - Add `Component::TextView::Region` — an opaque handle to a contiguous run of hard lines, so apps can stream into logical sections without tracking line indices across sibling mutations; create with `view.create_region`, and detached handles raise on every reader and mutator.
98
+ - Add `Component::TextView#replace(range, str)` and `#insert(at, str)` for mid-buffer hard-line splices (Integer or Range, empty range == insertion, `begin == hard-line count` valid for end-insertion).
99
+ - Add `EventQueue#tick(fps) { |n| ... }` returning a `Ticker` backed by `Concurrent::TimerTask`; it fires on the event-loop thread with a 0-based monotonic counter and auto-cancels on raise.
100
+ - Add `FakeEventQueue#tick` and `FakeTicker` a synchronous test double that drives ticks deterministically.
101
+ - `Component::Label`: add a `bg` accessor applying a background color uniformly across every painted row text, trailing pad, and blank rows past the last line.
102
+ - `Component::TextView`: incremental wrap via a per-hard-line row-count cache a mid-buffer mutation now re-wraps only the affected slice instead of the whole buffer, speeding up the LLM streaming path.
90
103
  - **Breaking:** `StyledString::Style#fg` and `#bg` now return `Color` (or `nil`) instead of the raw `Symbol`/`Integer`/`Array`. `Style.new` and `#merge` continue to accept the raw forms via `Color.coerce`.
91
- - **Breaking:** Remove `StyledString::Style::COLOR_SYMBOLS` — moved to `Color::COLOR_SYMBOLS`.
92
- - **Breaking:** `EventQueue#run_loop` now yields submitted `Proc` events to its consumer block instead of dispatching them inline, so a raise from a `submit{}` block is routed through `Screen#on_error` like any other event. Custom `run_loop` consumers must `call` Procs in their case statement.
104
+ - **Breaking:** `StyledString::Style::COLOR_SYMBOLS` is removed it moved to `Color::COLOR_SYMBOLS`.
105
+ - **Breaking:** `EventQueue#run_loop` now yields submitted `Proc` events to its consumer block instead of dispatching them inline, so a raise from a `submit{}` block routes through `Screen#on_error`. Custom `run_loop` consumers must `call` Procs in their case statement.
93
106
 
94
107
  ## [0.4.0] - 2026-05-20
95
108
 
@@ -105,7 +118,7 @@ Layout is now strictly top-down: a parent assigns each child's `rect`, and compo
105
118
  - `Component::List`: skip `auto_scroll` when rect is empty; re-snap on width change; snap cursor to last line on `auto_scroll`.
106
119
  - Document `Component#repaint`'s attached-only call contract.
107
120
  - Document keyboard input dispatch order and testing (`FakeScreen`, PTY system tests) in the README.
108
- - **Breaking:** `Component::TextView#append` is now verbatim — chunks are concatenated onto the current last hard line, embedded `\n` becomes hard breaks, no implicit newline is inserted. Designed for streaming use (e.g. an LLM chat window feeding partial messages straight in). Aliased as `<<` for chainability. The old "add a new entry" behavior is now `Component::TextView#add_line`.
121
+ - **Breaking:** `Component::TextView#append` is now verbatim — chunks concatenate onto the current last hard line, embedded `\n` becomes hard breaks, and no implicit newline is inserted (aliased as `<<`). The old "add a new entry" behavior is now `Component::TextView#add_line`.
109
122
  - **Breaking:** `MouseEvent.parse` raises on malformed input instead of silently truncating.
110
123
  - Fix: `Component` gates `invalidate` and `repaint` on `attached?`, dropping the negative-rect relic.
111
124
  - Fix: `Popup` recomputes size from content on every `#open`.