tuile 0.12.0 → 0.14.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -27
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +2342 -158
  5. data/README.md +151 -505
  6. data/TERMINOLOGY.md +15 -5
  7. data/book/01-first-app.md +22 -17
  8. data/book/02-repaint.md +18 -5
  9. data/book/03-layout.md +28 -20
  10. data/book/05-focus.md +137 -19
  11. data/book/06-theming.md +103 -2
  12. data/book/07-components.md +567 -27
  13. data/book/08-testing.md +34 -4
  14. data/book/09-styled-text.md +3 -3
  15. data/book/README.md +7 -5
  16. data/examples/file_commander.rb +23 -17
  17. data/examples/hello_world.rb +17 -5
  18. data/examples/sampler.rb +527 -108
  19. data/ideas/arrow-key-navigation.md +17 -1
  20. data/ideas/modal-backdrop.md +24 -0
  21. data/ideas/new-components.md +28 -27
  22. data/lib/tuile/ansi.rb +10 -0
  23. data/lib/tuile/buffer.rb +51 -3
  24. data/lib/tuile/color.rb +143 -0
  25. data/lib/tuile/color_depth.rb +80 -0
  26. data/lib/tuile/component/abstract_string_field.rb +36 -0
  27. data/lib/tuile/component/button.rb +3 -3
  28. data/lib/tuile/component/checkbox.rb +3 -3
  29. data/lib/tuile/component/combo_box.rb +12 -3
  30. data/lib/tuile/component/confirm_window.rb +442 -0
  31. data/lib/tuile/component/has_content.rb +22 -9
  32. data/lib/tuile/component/has_value.rb +1 -1
  33. data/lib/tuile/component/info_window.rb +64 -16
  34. data/lib/tuile/component/layout.rb +0 -10
  35. data/lib/tuile/component/list.rb +22 -0
  36. data/lib/tuile/component/list_dropdown.rb +102 -11
  37. data/lib/tuile/component/log_text_view.rb +71 -0
  38. data/lib/tuile/component/log_window.rb +13 -48
  39. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  40. data/lib/tuile/component/menu_bar.rb +582 -0
  41. data/lib/tuile/component/notification.rb +24 -39
  42. data/lib/tuile/component/overlay.rb +192 -0
  43. data/lib/tuile/component/picker_window.rb +0 -5
  44. data/lib/tuile/component/popup.rb +61 -123
  45. data/lib/tuile/component/progress_bar.rb +1 -1
  46. data/lib/tuile/component/select.rb +17 -7
  47. data/lib/tuile/component/slot.rb +54 -0
  48. data/lib/tuile/component/tab_sheet.rb +231 -0
  49. data/lib/tuile/component/tabs.rb +528 -0
  50. data/lib/tuile/component/text_area.rb +5 -4
  51. data/lib/tuile/component/text_field.rb +23 -6
  52. data/lib/tuile/component/text_view.rb +8 -5
  53. data/lib/tuile/component/window.rb +22 -46
  54. data/lib/tuile/component.rb +186 -31
  55. data/lib/tuile/event_queue.rb +45 -1
  56. data/lib/tuile/fake_screen.rb +40 -2
  57. data/lib/tuile/keys.rb +72 -0
  58. data/lib/tuile/screen.rb +212 -113
  59. data/lib/tuile/screen_pane.rb +125 -41
  60. data/lib/tuile/styled_string.rb +80 -7
  61. data/lib/tuile/terminal_background.rb +74 -16
  62. data/lib/tuile/version.rb +1 -1
  63. data/sig/tuile.rbs +2527 -358
  64. metadata +13 -3
  65. data/mise.toml +0 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58db31e0d0371c72be5ce4d7db2bdebfbbaa830114a54d2f841cc50738f6b671
4
- data.tar.gz: 58f2aa454427b6700513974c7d882e985e116c00ccd3a9bc55ec7a20ab529f1f
3
+ metadata.gz: 18944d23fd95ada3802f613eb6ff6847e845a082e1b155f2ed9d72e324181179
4
+ data.tar.gz: 33c476d0d194ae365a95e0ec65f9652fb8da44ba566e34e4a0c99a043747080a
5
5
  SHA512:
6
- metadata.gz: 77d50e104051a812185b60228b17f41a697f4bacb2f0dd5fd32514f4d9e784a7330ca6b5a17e449203ac188280104a0eab0ff8c7670272d58aa7fb4056782c6e
7
- data.tar.gz: 7d1b853471b6bf636324666b2e40ed796be228b4462c92e805dbd7ebdd991b974ece2d2d18756ed3813b8bcaeeed9c6d2d423e0437b58e247fded9459a113087
6
+ metadata.gz: 5e7a9b67f3530b86123933ccfaab6116b0aa0c4031f03e81c6b60094f6196aaa8f8adbc86634f4cc052048a1b15078ed5230983a3fff0ba7186d7d8b3d1e2d71
7
+ data.tar.gz: ae43fd49b5217606f88d3b7d631c89c8f3c86b82882c0850b6c6980ce100cdbaefd9237da9ea148680775d1c3a980b708934fad0ab71616de3560ea35804602f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,94 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.14.0] - 2026-08-31
4
+
5
+ Tuile names the foundations it had been improvising: the floating layer becomes
6
+ `Component::Overlay` with `Popup` as its always-modal subclass, a swappable
7
+ region becomes a `Component::Slot` over a now-final tree API, and the gap
8
+ between the rect a parent assigns and the cells a widget paints becomes
9
+ `Component#extent`. Color learns the terminal's limits — a depth probe
10
+ quantizes every color at the wire, so a computed RGB tint survives a 256-color
11
+ terminal untouched by the app — and the terminal's own background becomes
12
+ readable as a `Color`. On top of that sit the dialogs: `ConfirmWindow`, an
13
+ `InfoWindow` prose body, and `LogTextView` for a frameless log.
14
+
15
+ - Add `Component::Overlay` — the bare floating layer every overlay is built on: a mount/dismiss lifecycle, `owner`, `on_close` and outside-click dismissal, at a rect the caller assigns. See `DECISIONS.md` `D_overlay` and book ch7.
16
+ - Add `Component::Slot` — a one-child region for content that may be absent, arrive late, or be swapped; a container gives each of its regions one, wired at construction, so a swap never has to compute an insert index. See `DECISIONS.md` `D_slots`.
17
+ - Add `Component#extent`, `#extent_rect` and `#clear_outside_extent` — the size a widget actually paints inside the rect it was assigned (`nil` by default, meaning undeclared), and what it hit-tests, anchors and blanks against. See `DECISIONS.md` `D_extent`.
18
+ - Add `Component::Select#extent` and `Component::ComboBox#extent` — the one row the widget paints, which is what its dropdown anchors to; a single-slot container assigns these widgets far more height than they use.
19
+ - Add `Component#size`, `#width` and `#height` — read-only shorthands for the matching `rect` field. Reports of the geometry a parent assigned, never requests: there is no writer and no container consults them. See `DECISIONS.md` `D_declared_size`.
20
+ - Add `Component#handle_mouse` routing to every child whose `rect` contains the point, so a container gets click delivery without writing one; a widget that resolves clicks itself still overrides without `super`.
21
+ - Add `Component::ConfirmWindow` — the confirm/alert dialog: a caption, a wrapping message and a row of buttons, opened by the `alert` / `confirm` / `yes_no` factories or built button-by-button via `#button`; every route out of it fires `on_dismiss` exactly once. See `DECISIONS.md` `D_confirm_window` and book ch7.
22
+ - Add `Component::InfoWindow#message=` — the prose body, wrapped by a scrollable `TextView` (`ConfirmWindow`'s seam); `#lines=` stays as the rows presentation, and the constructor and `.open` pick the presentation by the body's type. See `DECISIONS.md` `D_info_window_body`.
23
+ - Add `Component::LogTextView` — `LogWindow`'s innards as a standalone `TextView`, so a frameless pane composes the log view directly: the any-thread `#log` and the `IO` adapter live on the view, and `LogWindow` reduces to a `Window` framing one (`LogWindow::IO` stays as an alias).
24
+ - Add `Screen#color_depth` and `ColorDepth.detect` — how many colors the terminal can show (`:truecolor` / `:palette256` / `:ansi16`), detected from `COLORTERM` and `TERM` at construction and overridable with `TUILE_COLOR_DEPTH`. See `DECISIONS.md` `D_color_depth` and book ch6.
25
+ - Add `Color#quantize` — this color as the nearest one a depth can show, returning the receiver when nothing needs degrading. `Buffer#flush` applies it to every color it emits, so a computed RGB tint renders on a 256-color terminal without the app quantizing anything.
26
+ - Add a `color_depth:` keyword to `Buffer.new`, which degrades colors as it flushes them; cells keep whatever color a component painted, so `region_ansi` and friends still report it unchanged.
27
+ - Add a pinned `:truecolor` depth to `FakeScreen`, so a spec asserting frame bytes is unaffected by the runner's `COLORTERM`; a PTY spec asserting bytes passes `TUILE_COLOR_DEPTH` to the child instead.
28
+ - Add `Screen#background_color` — the terminal's own background as a `Color`, nil when it reported none; re-probed on every OS appearance flip, and a changed color fires `Component#on_theme_changed`. See `DECISIONS.md` `D_background_rgb` and book ch6.
29
+ - Add `FakeScreen#background_color=` — plays the terminal answering the re-probe, so a spec can drive app code that derives colors from it.
30
+ - Add `StyledString::Style#inverse` — SGR 7 reverse video, swapping whatever fg/bg are in effect at the cell: parsed and emitted like the other attributes, applied whole-string via `StyledString#with_inverse`, and skipped by `#under_bg` the way an explicit bg is. See `DECISIONS.md` `D_inverse`.
31
+ - Add a *ConfirmWindow* pane to `examples/sampler.rb` — the three factories, the layer-1 three-way builder and a scrolling Terms-of-Service dialog, with a status row naming which callback each route out of a dialog landed in.
32
+ - Add a terminal-derived tint to the *Background* pane of `examples/sampler.rb` — the borderless-pane step of +10 per channel off `Screen#background_color`, re-derived in `on_theme_changed` and degrading to a "none reported" entry.
33
+ - Fix a click on `Component::Window` chrome not landing focus on the window, which its `focusable?` has claimed all along.
34
+ - 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.
35
+ - Fix `Component::Select` opening its dropdown from a click anywhere in its rect, including the tail its own `repaint` clears — it now hit-tests its `extent`, the one row it paints, as `Checkbox` already did on the width axis.
36
+ - Fix a click on a `Component::ListDropdown` margin being able to land focus outside the key scope, killing every keystroke until Tab; the dropdown is now non-focusable by inheritance rather than by geometry.
37
+ - Fix a lower popup's content bleeding through a popup stacked over it: `Screen#repaint`'s drain loop now re-asserts every popup above any layer that repainted, so a repaint cascade spilling into a later iteration can no longer paint over the popups on top.
38
+ - Fix `Screen#theme=` dying with `NoMethodError` when a component overrode `on_theme_changed` under `protected`, aborting the restyle mid-walk. The hook is now protected plumbing fanned out with `__send__`, so an override may declare any visibility. See `DECISIONS.md` `D_hook_visibility`.
39
+ - **Breaking:** `Component#children`, `#parent`, `#parent=`, `#add_child`, `#remove_child` and `#detach_child` are final — overriding any of them raises `Tuile::Error` at construction. Derive nothing: reparent through `add_child` / `remove_child` / `detach_child`, and hold a `Component::Slot` for a swappable region. See `DECISIONS.md` `D_final_tree`.
40
+ - **Breaking:** a `Component::Window` footer now lives in a `Component::Slot`, so `window.children` always holds that slot and `footer.parent` is the slot rather than the window. Reach the footer through `window.footer`, which is unchanged.
41
+ - **Breaking:** `Component#extent` is a `Size` (or `nil`) rather than a `Rect`, and `Button`, `Checkbox`, `Tabs` and `MenuBar` narrowed with it — an extent always sits at the rect's top-left. Read `#extent_rect` where you need the positioned rectangle.
42
+ - **Breaking:** `Component::Popup#size` is renamed `#declared_size`, and the `Popup.new` / `Component::InfoWindow.open` keyword with it, freeing `size` to mean `rect.size` on any component. Rename the accessor and the keyword at your call sites. See `DECISIONS.md` `D_declared_size`.
43
+ - **Breaking:** `Component::Popup` is always modal — `Popup.new(modal: false)` now raises `ArgumentError`. Build a non-modal overlay with `Component::Overlay.new`, which carries the same lifecycle, `owner`, `on_close` and `close_on_outside_click` members, and place it with `rect=`.
44
+ - **Breaking:** `Component::Notification` and `Component::ListDropdown` subclass `Component::Overlay` rather than `Component::Popup`, neither ever having been modal. An `is_a?(Popup)` test over the popup stack becomes `is_a?(Overlay)`.
45
+ - **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.
46
+
47
+ ## [0.13.0] - 2026-08-25
48
+
49
+ Tuile grows the navigation chrome an app builds its shell from — a `MenuBar`
50
+ driving a cascade of submenus, and `Tabs` / `TabSheet` — and gives up the last
51
+ row it reserved for itself: the framework status bar is gone, so `content`
52
+ fills the terminal and an app builds its own status line. A paste also stops
53
+ being a burst of keystrokes, arriving as one `Component#handle_paste` instead
54
+ of one keystroke per character.
55
+
56
+ - Add `Screen#on_focus_changed=` — a no-arg callback fired after the focused component *changes*, including to and from `nil` and on the focus repair a closing popup runs. Edge-triggered, so re-focusing what already has focus fires nothing. See `DECISIONS.md` `D_status_bar` and book ch5.
57
+ - Add `mnemonic:` to `Component::MenuBar#add_item` and `MenuBar::Item#add_item` — a letter that activates the item, underlined in its caption, matched level-scoped with no fallback, so only siblings can clash (which raises). See `DECISIONS.md` `D_menu_bar` and book ch7.
58
+ - Add `Component::List#select` and `Component::ListDropdown#select` — moves the cursor to an item by index, scrolling it into view and firing `on_cursor_changed`; the positional member of the `select_next` / `select_prev` family.
59
+ - Add `Component::MenuBar` — a one-row strip of menu captions, each dropping a cascade of submenus that nests without limit, built from `MenuBar::Item` handles minted and nested by `#add_item`, each carrying an optional no-arg `on_click`. See `DECISIONS.md` `D_menu_bar` and book ch7.
60
+ - Add a `MenuBar` shell to `examples/sampler.rb` — the side nav list becomes a menu bar grouped like the README's Components table, plus a `ComboBox` jump box at its right end, and the demo window fills everything below.
61
+ - Add a *MenuBar* pane to `examples/sampler.rb` — a three-deep File menu, an Edit menu, a top-level leaf that acts as a button, and a status line naming the last activated item.
62
+ - Add `Component::ListDropdown#anchor_beside` — places a panel against a row's right edge, flipping left when there is no room and sliding vertically, which is the cascading-submenu counterpart of `#anchor_to`.
63
+ - Add `Component::ListDropdown#cursor_row_rect` and `#on_cursor_changed=` — the highlighted row's rect, and the highlight-moved pass-through a cascading driver needs to drop the panels below the row it left.
64
+ - Add `Component::Tabs` — a one-row strip of captions with one selected: Left/Right switch immediately, a click selects, `#on_tab_selected` reports every change (`nil, nil` once the last tab goes), and tabs are `Tabs::Tab` handles minted by `#add_tab`. See `DECISIONS.md` `D_tabs` and book ch7.
65
+ - Add horizontal scrolling to `Component::Tabs` and `Component::MenuBar` — a strip narrower than its captions now scrolls to keep the selected or highlighted caption whole in view, instead of clipping it.
66
+ - Add a *TabSheet* pane to `examples/sampler.rb` — three tabs over a form, a `List` and a `TextView`, with a status line reporting every pane's state on each switch, so a hidden pane keeping its scroll position is visible rather than asserted.
67
+ - Add `Component::TabSheet` — a `Tabs` strip on its top row plus the selected tab's pane below it, added with `#add_tab(caption, pane)`; unselected panes are *detached* rather than hidden by a flag, so they keep their state and stay out of the Tab cycle. See `DECISIONS.md` `D_tabs`.
68
+ - Add `Screen#beep` and `Ansi::BEL` — rings the terminal bell for a keystroke that went nowhere. It writes immediately rather than riding the next frame, since the keys worth beeping at are precisely the ones that invalidate nothing.
69
+ - Add `StyledString#with_underline` — applies underline to every span, preserving each span's colors and other attributes; the underline counterpart of `#with_bold`. Slice and rejoin to underline part of a string, as a one-character mnemonic cue does.
70
+ - Add `StyledString#with_bold` — applies bold to every span, preserving each span's colors and other attributes; the bold-attribute counterpart of `#with_fg` / `#with_bg`. There is no `under_bold`, since bold has no inherited-unset state.
71
+ - Add `Component#handle_paste` — pasted text, whole and `\n`-normalized, delivered down the focus chain like a key but off the key ladder; the default returns false and `AbstractStringField` inserts at the caret in one mutation. See `DECISIONS.md` `D_bracketed_paste` and book ch5.
72
+ - Add `Screen#run_event_loop(bracketed_paste:)` — on by default, mirroring `capture_mouse:`; pass false for a terminal that mishandles mode 2004.
73
+ - Add `Keys::BRACKETED_PASTE_ON` / `_OFF`, `Keys::PASTE_START` / `PASTE_END`, `Keys.read_paste` and `Keys.normalize_paste` — the terminal-layer half: markers, a raw drain to the terminator, and CR/CRLF-to-`\n` plus a UTF-8 scrub.
74
+ - Add `EventQueue::PasteEvent` — the whole clipboard as one frozen event, posted by the key thread.
75
+ - Add `FakeScreen#paste` — normalizes and dispatches like the real key thread, so a spec can hand it the CR line endings terminals actually send.
76
+ - Add `Component::AbstractStringField#preprocess_paste` — the paste-side input filter; the base drops the C0 controls a text buffer cannot hold and `TextField` also flattens newlines to spaces and trims to `max_text_length`.
77
+ - Add a *Paste* pane to `examples/sampler.rb` — a submit-on-Enter prompt with submit/paste counters, so the distinction is visible (and PTY-testable).
78
+ - Add `Component::Popup#close_on_outside_click?` — a left click outside an open popup now closes it, modal or not (default true; `Component::Notification` opts out). Fixes dropdowns and menu cascades stranded by a click on inert decoration. See `DECISIONS.md` `D_outside_click`.
79
+ - Add `Component::Popup#owner` — names the component an overlay is part of, so a click inside it doesn't dismiss the popup hosting it. `ComboBox`, `Select` and each `MenuBar` cascade panel set it; an overlay without one is independent.
80
+ - Add `Component::Popup#on_close` — a no-arg callback fired once the popup has left the screen, however it left; for a driver keeping its own record of what is open.
81
+ - **Fix:** `Component::TabSheet` no longer holds a removed tab's pane against re-use — `Tabs::Tab#remove` bypasses `TabSheet#remove_tab`, and the stale mapping made `add_tab` reject that pane as still in use.
82
+ - **Fix:** a container whose children exactly tile its rect no longer swallows the repaint cascade — content under it survives an ancestor's `clear_background` instead of vanishing until the next unrelated repaint (visible in the sampler as rows blanking when focus moved). See `DECISIONS.md` `D_repaint_cascade`.
83
+ - **Fix:** a multi-line paste into a `Component::TextArea` subclass that rebinds ENTER no longer fires that binding once per pasted line ([#4](https://github.com/mvysny/tuile/issues/4)).
84
+ - **Fix:** `Component::TextArea`'s rdoc had the two line-break bytes backwards — a pasted break arrived as `\r`, not `\n`.
85
+ - **Fix:** `Component::TextView#handle_key` no longer refuses every key while the view is unfocused — a vestigial `active?` guard that 0.8.0's dispatch overhaul dropped from every other widget — so hand-feeding it a scroll key scrolls, as with any other component. See `DECISIONS.md` `D_text_view_scroll_verbs`.
86
+ - **Breaking:** `Component::TextField#left_column` is now private — the horizontal scroll offset was internal state with no caller outside the field. A spec asserting the scrolling reads it through `send(:left_column)`.
87
+ - **Breaking:** the framework status bar is removed — `ScreenPane#status_bar` is gone and the pane no longer reserves the bottom row, so `content` now fills the whole terminal. Build a status line into your own layout and fill it from `Screen#on_focus_changed=`; `examples/hello_world.rb` and `examples/file_commander.rb` show the shape. See `DECISIONS.md` `D_status_bar`.
88
+ - **Breaking:** `Component#keyboard_hint` is removed, along with its overrides on `Popup`, `PickerWindow`, `MenuBar`, `Tabs`, `Select`, `ComboBox` and `Notification` — four of them were unreachable in every configuration. Keep the method on your own window classes and call it from your status line; nothing in Tuile consults it now.
89
+ - **Breaking:** `Screen#register_global_shortcut` no longer takes `hint:` — the registry runs actions, it does not describe them. Drop the argument and write the hint into your own status line, next to the registration.
90
+ - **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.
91
+
3
92
  ## [0.12.0] - 2026-08-17
4
93
 
5
94
  `Component::List` becomes a list of *items* rather than of pre-rendered rows: it
@@ -9,14 +98,14 @@ folded onto it. The framework also settles its scrolling vocabulary in one
9
98
  pass: `row` is the terminal grid unit everywhere, `line` means exactly what
10
99
  `String#lines` returns, and `items` are the domain objects a widget renders.
11
100
 
12
- - 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.
101
+ - Add `Component::List#items` / `#items=` and `#renderer` — the list holds typed items, one row each, and a renderer turns an item into its row. See `DECISIONS.md` `D_list_items` and book ch7.
13
102
  - 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.
14
103
  - 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=`.
15
104
  - Add `Component::ListDropdown#items` / `#items=` / `#renderer=`, forwarding to its list.
16
- - 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.
17
- - 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`.
18
- - 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`.
19
- - 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`.
105
+ - Add `Component::Notification` — the corner toast: `Notification.show("Saved")` floats a non-modal box in the top-right for three seconds, stacking a burst into one box that drains one message every tick. See `DECISIONS.md` `D_notification` and book ch7.
106
+ - Add `Component::TextArea#caret_row` and `#row_count` — the two readers that answer "has Up/Down anywhere left to go?", so a subclass can claim the key at the text's edge and delegate elsewhere. See `DECISIONS.md` `D_text_area_rows`.
107
+ - Add `Component::TextView#scroll_half_page_up` / `#scroll_half_page_down` — the programmatic form of `Ctrl+U` / `Ctrl+D`, for a host paging a view it does not focus. See `DECISIONS.md` `D_text_view_scroll_verbs`.
108
+ - Add `TERMINOLOGY.md` — a glossary of Tuile's house words, looked up by term; the rules live in `AGENTS.md`'s *Nomenclature* section and the reasoning in `DECISIONS.md` `D_scroll_nomenclature`.
20
109
  - `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.
21
110
  - `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.
22
111
  - `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.
@@ -25,38 +114,38 @@ pass: `row` is the terminal grid unit everywhere, `line` means exactly what
25
114
  - **Fix:** `examples/file_commander.rb` navigates again — Enter on a directory called `Rainbow.uncolor` on a `StyledString` and raised.
26
115
  - **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.
27
116
  - **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.
28
- - **Breaking:** `Component::ListDropdown#lines=` / `#lines` are removed — use `#items=` with a `#renderer=`. See `DECISIONS.md` `D-list-items`.
29
- - **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`.
30
- - **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`.
117
+ - **Breaking:** `Component::ListDropdown#lines=` / `#lines` are removed — use `#items=` with a `#renderer=`. See `DECISIONS.md` `D_list_items`.
118
+ - **Breaking:** `Component::List#add_line` and `#add_lines` are removed — an append is a statement about a collection the list owns, which a lazily-sourced provider has nothing to mutate. Keep your own array and assign it whole (`list.items = mine`); for incremental append use `Component::TextView`. See `DECISIONS.md` `D_list_items`.
119
+ - **Breaking:** `Component::Popup.open` (the class method) is removed — it hardcoded `Popup.new`, so every subclass inherited a factory that silently built a bare `Popup`. Write `Popup.new(...).open`, which now returns the popup. See `DECISIONS.md` `D_popup_open`.
31
120
  - **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.
32
121
  - **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.
33
122
  - **Breaking:** `VerticalScrollBar.new(line_count:, top_line:)` is now `.new(row_count:, scroll_top_row:)`. Rename the keywords.
34
123
 
35
124
  ## [0.11.0] - 2026-08-12
36
125
 
37
- - 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.
38
- - 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.
39
- - 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`.
40
- - 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`.
126
+ - Add `Component::Select` — a one-row enum field that drops open a `ListDropdown` of its typed `items`; `value` is the selected item, and Enter, Space or Down opens it. It claims no printable key but Space, so a form's own letter bindings keep working while it has focus. See `DECISIONS.md` `D_select` and book ch7.
127
+ - Add `Component::Layout::Vertical` and `Component::Layout::Horizontal` (on the abstract `Component::Layout::Box`) — declarative 1-D layouts where a caller *declares* each child's extent (`Fixed` / `Percent` / `Expand`, plus box-global `spacing`, `padding` and a per-child `align:`) instead of computing it. See `DECISIONS.md` `D_box_layouts` and book ch3.
128
+ - Add `Component::BigDecimalField` — the money field: the shape of `IntegerField`/`FloatField` with an exact `BigDecimal` (or `nil`) value, where assigning a `Float` raises rather than converting. See `DECISIONS.md` `D_bigdecimal_field`.
129
+ - Add `Component::FloatField` — the `IntegerField` twin whose `value` is a `Float` (or `nil`), accepting a single `.` and stepping by `1.0` on Up/Down; `value=` raises on a NaN or infinity. See `DECISIONS.md` `D_float_field`.
41
130
  - 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.
42
131
  - `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`.
43
132
  - `examples/sampler.rb` gains `Select`, `FloatField` and `BigDecimalField` panes, and its demo panes are ported to the box layouts.
44
- - **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`.
133
+ - **Fix (behavior):** `StyledString#wrap` no longer eats a **first-line** indent, so indented text is displayable at all (every `Component::TextView` line goes through `wrap`). `plain(" ").wrap(5)` is now `[" "]` rather than `[""]`; continuations still start at column 0. See `DECISIONS.md` `D_wrap_leading_space`.
45
134
  - **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.
46
135
  - **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.
47
136
  - **Breaking:** `Component::ComboBox::MAX_VISIBLE_ROWS` moved to `Component::ListDropdown::MAX_VISIBLE_ROWS`. Update the constant reference.
48
- - **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`.
137
+ - **Breaking (behavior):** `Component::Checkbox` now toggles on **Enter** as well as Space, matching a checkable row inside a `List`. A focused checkbox therefore consumes Enter, so an ancestor's Enter-to-submit must move to a key no focused field claims. See `DECISIONS.md` `D_boolean_fields`.
49
138
 
50
139
  ## [0.10.0] - 2026-08-02
51
140
 
52
- - 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`.
53
- - 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`.
54
- - 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`.
141
+ - Add `Component::Checkbox` — a one-row boolean input (`[x] Enable syslog forwarding`) toggled by Space or a left-click, whose `value` is always `true`/`false`, with `checked?`/`checked=`/`toggle` as the domain-word face over it. See `DECISIONS.md` `D_boolean_fields`.
142
+ - Add `Component::CheckboxGroup` — multi-select over typed `items` whose `value` is a **frozen** `Set` of the selected items, iterating in toggle order (use `items & value.to_a` when order matters). See `DECISIONS.md` `D_checkbox_group`.
143
+ - Add `Component::RadioGroup` — single-select over typed `items` whose `value` is the selected item; it composes a `List`, so the cursor roams without selecting and Space, Enter or a click commits the row under it. See `DECISIONS.md` `D_radio_group`.
55
144
  - 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.
56
145
  - 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.
57
146
  - 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.
58
- - 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`.
59
- - 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`.
147
+ - Add `Component::ProgressBar` — a display-only fill over a `Range` with `fraction`/`percent` readers and an `indeterminate` mode owning a 5 fps ticker; it stays deliberately outside `Component::HasValue`. See `DECISIONS.md` `D_progress_bar` and `D_color_slots`.
148
+ - Add `Component#on_attached` / `on_detached` — lifecycle hooks fired once per component per transition, letting a component own a mounted-lifetime resource. They are hooks, not destructors: a process exiting without `Screen#close` fires nothing. See `DECISIONS.md` `D_attach_hooks`.
60
149
  - 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.
61
150
  - `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.
62
151
  - `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.
@@ -64,17 +153,17 @@ pass: `row` is the terminal grid unit everywhere, `line` means exactly what
64
153
  - `Screen#close` now unmounts the component tree (via `ScreenPane#detach_all`), so teardown fires `on_detached` across it.
65
154
  - `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.
66
155
  - `examples/sampler.rb` gains panes for `ProgressBar`, `RadioGroup` and `CheckboxGroup`.
67
- - 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`.
68
- - 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`.
69
- - **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`.
70
- - **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`.
71
- - **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`.
72
- - **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`.
156
+ - Fix the caret stepping by *character* rather than by grapheme cluster: LEFT/RIGHT, BACKSPACE and DELETE now move over and delete exactly one cluster, and the caret is boundary-locked so a mid-cluster position is unrepresentable. See `DECISIONS.md` `D_cluster_caret`.
157
+ - Fix `Component::TextArea` hanging the UI thread on text containing `\r`, `\v` or `\f` — the word-scan measured zero and the wrap loop never advanced, so `area.text = File.read(crlf_file)` was enough to lock up an app. See `DECISIONS.md` `D_text_area_columns`.
158
+ - **Breaking:** `Component#children` is final, and reparenting goes through the protected `add_child(child, at:)` / `remove_child(child)` / `detach_child(child)`. A custom container must stop overriding `children` or hand-wiring `child.parent = …`; named slots become readers over the array. See `DECISIONS.md` `D_tree_api`.
159
+ - **Breaking:** `Component#attached?` is now the one-axis type test `root.is_a?(ScreenPane)` — it consults no `Screen`, so it never raises and a tree can be assembled with no screen in the process. See `DECISIONS.md` `D_tree_first`.
160
+ - **Breaking:** the UI-thread guard is rewritten — `EventQueue#locked?` becomes `#running?` plus `#on_loop_thread?`, the internal `@pretend_ui_lock` and `FakeScreen#check_locked`'s bypass are gone, and `Screen#state` is added. A spec mutating UI from a *spawned* thread now raises exactly as an app would. See `DECISIONS.md` `D_screen_lifecycle`.
161
+ - **Breaking:** `Component#key_shortcut` and `#find_shortcut_component` are removed, along with `ScreenPane#handle_key`'s capture phase and `Window`'s `[k]-Caption` border prefix. Migration: `w.key_shortcut = "1"` becomes a `case` in the containing layout's own `handle_key`. See `DECISIONS.md` `D_key_dispatch`.
73
162
  - **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.
74
163
  - **Breaking:** `Component::TextInput` is renamed `Component::AbstractStringField` (file `text_input.rb` → `abstract_string_field.rb`). Only code referencing the constant directly must update.
75
164
  - **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`.
76
- - **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`.
77
- - **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`.
165
+ - **Breaking (behavior):** all glyph measurement is now per grapheme cluster under one emoji policy (`:rgi`), so `"👍🏽"` measures 2 columns rather than 4 and no longer overruns its cell. Custom components measuring with `String#length` or iterating `each_char` must move to `StyledString#display_width` / `slice` / `ellipsize`. See `DECISIONS.md` `D_cluster_width`.
166
+ - **Breaking (behavior):** `Component::TextField` separates the caret's character index from its terminal column and **scrolls horizontally** instead of being capped by its own width. `max_text_length` is now an explicit, settable cap defaulting to `nil` (unbounded) rather than the derived `rect.width - 1`. See `DECISIONS.md` `D_text_field_axes`.
78
167
  - **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.
79
168
 
80
169
  ## [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.