tuile 0.13.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +1095 -195
  5. data/README.md +19 -19
  6. data/TERMINOLOGY.md +6 -5
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +4 -1
  9. data/book/06-theming.md +98 -0
  10. data/book/07-components.md +169 -19
  11. data/book/08-testing.md +16 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/examples/file_commander.rb +1 -1
  14. data/examples/sampler.rb +143 -43
  15. data/ideas/arrow-key-navigation.md +2 -2
  16. data/ideas/modal-backdrop.md +24 -0
  17. data/ideas/new-components.md +24 -24
  18. data/lib/tuile/buffer.rb +51 -3
  19. data/lib/tuile/color.rb +143 -0
  20. data/lib/tuile/color_depth.rb +80 -0
  21. data/lib/tuile/component/button.rb +3 -3
  22. data/lib/tuile/component/checkbox.rb +3 -3
  23. data/lib/tuile/component/combo_box.rb +9 -2
  24. data/lib/tuile/component/confirm_window.rb +442 -0
  25. data/lib/tuile/component/has_content.rb +22 -9
  26. data/lib/tuile/component/has_value.rb +1 -1
  27. data/lib/tuile/component/info_window.rb +64 -16
  28. data/lib/tuile/component/layout.rb +0 -10
  29. data/lib/tuile/component/list_dropdown.rb +18 -10
  30. data/lib/tuile/component/log_text_view.rb +71 -0
  31. data/lib/tuile/component/log_window.rb +13 -48
  32. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  33. data/lib/tuile/component/menu_bar.rb +5 -5
  34. data/lib/tuile/component/notification.rb +16 -34
  35. data/lib/tuile/component/overlay.rb +192 -0
  36. data/lib/tuile/component/popup.rb +59 -187
  37. data/lib/tuile/component/progress_bar.rb +1 -1
  38. data/lib/tuile/component/select.rb +14 -6
  39. data/lib/tuile/component/slot.rb +54 -0
  40. data/lib/tuile/component/tab_sheet.rb +0 -11
  41. data/lib/tuile/component/tabs.rb +5 -5
  42. data/lib/tuile/component/window.rb +22 -46
  43. data/lib/tuile/component.rb +149 -19
  44. data/lib/tuile/event_queue.rb +21 -1
  45. data/lib/tuile/fake_screen.rb +26 -2
  46. data/lib/tuile/keys.rb +7 -0
  47. data/lib/tuile/screen.rb +120 -38
  48. data/lib/tuile/screen_pane.rb +37 -35
  49. data/lib/tuile/styled_string.rb +40 -7
  50. data/lib/tuile/terminal_background.rb +74 -16
  51. data/lib/tuile/version.rb +1 -1
  52. data/sig/tuile.rbs +1157 -368
  53. metadata +8 -1
data/README.md CHANGED
@@ -6,25 +6,16 @@ and Tuile runs a single-threaded event loop that dispatches keys and mouse
6
6
  events, then repaints everything that was invalidated since the last tick. The
7
7
  name is French for "roof tile": small pieces that compose into a larger whole.
8
8
 
9
- The design philosophy — "boxes within boxes" that talk via listeners and data
9
+ The design philosophy — "composing components in layouts to create something bigger" that talk via listeners and data
10
10
  providers — is described in
11
11
  [component-oriented programming](https://mvysny.github.io/component-oriented-programming/).
12
12
  Tuile is that approach applied to a terminal.
13
13
 
14
- If you have looked at the alternatives:
15
-
16
- - [tty-toolkit](https://ttytoolkit.org/) (`tty-prompt`, `tty-cursor`, …) is a
17
- set of low-level building blocks rather than a framework: there is no
18
- component tree, no event loop, no invalidation. Tuile sits on top of
19
- `tty-cursor`/`tty-screen` and adds the framework layer.
20
- - [vedeu](https://github.com/gavinlaking/vedeu) is the closest Ruby comparable
21
- but is no longer maintained (last release 2017).
22
- - [ratatui](https://github.com/ratatui/ratatui) is the popular TUI framework
23
- in the Rust ecosystem; its immediate-mode API is closer to `tty-prompt` than
24
- to Tuile's retained component tree.
25
-
26
14
  Tuile is the only actively maintained component-oriented TUI framework for
27
- Ruby that we are aware of.
15
+ Ruby that we are aware of. If you have looked at the alternatives —
16
+ tty-toolkit, vedeu, ratatui, or the curses bindings your distro packages —
17
+ [COMPARISON.md](COMPARISON.md) sizes each one up and says which of them you
18
+ can actually reach from Ruby.
28
19
 
29
20
  ## Installation
30
21
 
@@ -64,6 +55,9 @@ else in Tuile loads it.
64
55
  - **API reference:** every public class and method carries YARD headers —
65
56
  browse them at <https://rubydoc.info/gems/tuile>, or run
66
57
  `bundle exec rake yard` for a local site.
58
+ - **[COMPARISON.md](COMPARISON.md)** places Tuile among the neighbouring
59
+ toolkits, and answers what a Ruby program can reach without writing
60
+ bindings first.
67
61
 
68
62
  ## Hello world
69
63
 
@@ -149,7 +143,9 @@ status-bar hints — plus whatever `custom` tokens your app adds. Everything
149
143
  else inherits the terminal's own foreground and background, so Tuile looks at
150
144
  home in the user's palette instead of fighting it. Tuile probes the terminal
151
145
  background at startup, pairs a dark and a light theme in a `ThemeDef`, and
152
- re-picks on a live OS appearance flip.
146
+ re-picks on a live OS appearance flip. The probed background is also yours to
147
+ read — `Screen#background_color` — for panes tinted a few percent off the
148
+ terminal's own, LazyVim-style.
153
149
  → [chapter 6](book/06-theming.md)
154
150
 
155
151
  ## Components
@@ -174,6 +170,7 @@ carries the per-method reference: `bundle exec rake yard`, or
174
170
  | component | what it is |
175
171
  |---|---|
176
172
  | `Window` | A frame with a `caption`, one content slot, and a border that lights up while the window is on the focus chain. `footer_text=` decorates the bottom border; `footer=` mounts a real component in it; `scrollbar=` reclaims the right border column. |
173
+ | `Slot` | A one-child region for content that may be absent, arrive late, or be swapped. Give a multi-region container one per region and the tree stays honest — the occupant fills the slot's rect, and an empty slot holds its place rather than collapsing. |
177
174
  | `MenuBar` | A one-row strip of menu captions, each dropping a cascade of submenus that nests without limit. Items are handles from `#add_item`, each with its own `on_click`. See [Menus](book/07-components.md#menus). |
178
175
  | `Tabs` | A one-row strip of captions with one selected, Left/Right switching immediately. Knows nothing about content — pair it with `TabSheet`, or drive your own view swap from `on_tab_selected`. |
179
176
  | `TabSheet` | A `Tabs` strip plus the pane belonging to the selected tab. Unselected panes are *detached*, so they keep their state and stay out of the Tab cycle. See [Switching between views](book/07-components.md#switching-between-views). |
@@ -224,11 +221,14 @@ carries the per-method reference: `bundle exec rake yard`, or
224
221
 
225
222
  | component | what it is |
226
223
  |---|---|
227
- | `Popup` | The modal overlay host: it wraps any component, paints nothing itself, and is sized by `size=` (a `Size` or a `Fraction` of the screen) rather than by its content. ESC or `q` dismisses. |
224
+ | `Overlay` | The bare floating layer: it wraps any component, paints nothing itself, and sits at the rect you assign it. Takes no focus and no keys — the building block for anchored panels and toasts. |
225
+ | `Popup` | The modal dialog: an `Overlay` that centers itself, grabs focus, scopes keys to its own subtree and blocks clicks beneath it. Sized by `declared_size=` (a `Size` or a `Fraction` of the screen) rather than by its content; ESC or `q` dismisses. |
228
226
  | `Notification` | A transient corner toast — `Notification.show("Saved")` — stacking messages in one box that a single ticker drains. Non-modal, and it never takes focus. |
229
- | `InfoWindow` | A `Window` of static lines, tiled or popped up. For read-only information you don't want to assemble by hand. |
227
+ | `ConfirmWindow` | The confirm dialog: a message and a row of buttons in a popup sized to fit. `alert` / `confirm` / `yes_no` cover the common shapes; `#button` builds any other. Every button closes; ESC, `q` or an outside click fire `on_dismiss`. See [The confirm dialog](book/07-components.md#the-confirm-dialog). |
228
+ | `InfoWindow` | A `Window` with a read-only body, tiled or popped up: prose that wraps (`message=`), or rows that don't (`lines=`). |
230
229
  | `PickerWindow` | A `Window` of options identified by single keystrokes, firing a callback with the key that was pressed. |
231
- | `LogWindow` | A scrolling log view. Point your logger at a `LogWindow::IO` and lines land here from any thread, marshalled through the event queue. |
230
+ | `LogTextView` | An auto-scrolling `TextView` for log output. Point your logger at a `LogTextView::IO` and lines land here from any thread, marshalled through the event queue. |
231
+ | `LogWindow` | A `Window` framing a `LogTextView` — the framed log pane. |
232
232
 
233
233
  The mixins those share — `HasValue` (the `value` / `empty?` / `clear` /
234
234
  `on_value_change` seam every input speaks), `HasContent` (one-child
@@ -252,7 +252,7 @@ interface:
252
252
  ```ruby
253
253
  Tuile.logger = Logger.new($stderr) # or:
254
254
  Tuile.logger = TTY::Logger.new # duck-typed, works directly
255
- Tuile.logger = Logger.new(Tuile::Component::LogWindow::IO.new(window))
255
+ Tuile.logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
256
256
  ```
257
257
 
258
258
  ## Testing
data/TERMINOLOGY.md CHANGED
@@ -4,7 +4,7 @@ Tuile's house vocabulary — one line per term, looked up by word.
4
4
 
5
5
  This file owns **definitions only**. The *rules that bite* live in AGENTS.md
6
6
  ("Nomenclature" and the sections each word belongs to); the *why we chose a word
7
- and not its synonym* lives in DECISIONS.md (`D-scroll-nomenclature` for the
7
+ and not its synonym* lives in DECISIONS.md (`D_scroll_nomenclature` for the
8
8
  row/line/item split); the *concepts* live in the book. When a definition here
9
9
  needs a paragraph of justification, that paragraph belongs in one of those three.
10
10
 
@@ -15,7 +15,7 @@ needs a paragraph of justification, that paragraph belongs in one of those three
15
15
  | **row** | one row of the terminal grid — the framework's only word for it. A wrapped unit of text *is* a row; wrapping is what turns text into rows. |
16
16
  | **column** | one cell-column of the terminal grid; the unit `display_width` counts. |
17
17
  | **cell** | one grid position: a grapheme plus a {Tuile::StyledString::Style}, in {Tuile::Buffer}. |
18
- | **glyph** | what the terminal draws in one or more cells. Ambiguous-width glyphs count as **one** column (the bet in `D-ambiguous-width`). |
18
+ | **glyph** | what the terminal draws in one or more cells. Ambiguous-width glyphs count as **one** column (the bet in `D_ambiguous_width`). |
19
19
  | **cluster** | a grapheme cluster — the unit measurement, slicing, caret motion and deletion all work in. Never `each_char`. |
20
20
  | **row_in_viewport** | a row measured `0...rect.height`, i.e. relative to a component's own rect. |
21
21
  | **scroll_top_row** | the content row currently sitting at the top of the viewport. |
@@ -23,7 +23,7 @@ needs a paragraph of justification, that paragraph belongs in one of those three
23
23
  | **viewport_rows** | how many rows of content are visible — always `rect.height`; kept private, since `rect.height` is the public form. |
24
24
  | **row_count** | how many rows the wrapped content occupies. Public on `TextArea` (with `caret_row`, its companion); also on the private `WrappedText` and as `VerticalScrollBar.new(row_count:)`. Not on `TextView` / `List`, which have no caller for it. |
25
25
  | **caret_row** | the row a text input's caret sits in, counted from the content's first row. `TextArea` only. |
26
- | **extent** | the sub-rect a one-row widget actually paints, used for its highlight and hit test — narrower than the `rect` it was given. The arithmetic is each widget's own (a `Checkbox`'s glyph plus caption; a `Tabs` strip's segments and separators), never a `Component` method. |
26
+ | **extent** | the `Size` a widget actually paints inside the `rect` it was given — `Component#extent`, `nil` unless declared, always at the rect's top-left (`Component#extent_rect` positions it). What the widget clears outside of, hit-tests, highlights and anchors its dropdown to. The arithmetic is each widget's own (a `Checkbox`'s glyph plus caption; a `Tabs` strip's segments and separators). Distinct from a *slot extent*. |
27
27
  | **segment** | one tab's span on a {Tuile::Component::Tabs} strip: its caption plus a padding column either side. The unit a click resolves to; the separator column between two segments belongs to neither. |
28
28
 
29
29
  **Space rule 1.** An object with only one row space leaves `row` unqualified:
@@ -42,7 +42,7 @@ content-space.
42
42
  | **line_count** | a count of `\n` units (`TextView::Region#line_count`). Never a row count. |
43
43
  | **item** | a domain object a widget holds and renders — `List#items`, and the enum widgets above it. |
44
44
  | **renderer** | the `item -> row` proc a generic component uses to render an item it knows nothing about. |
45
- | **selection** | which item or tab a selector currently points at. *View state* when nothing would save it ({Tuile::Component::Tabs}`#selected`), a *value* when a form would (`RadioGroup#value`) — the split `D-tabs` calls the "would a form save it?" test. |
45
+ | **selection** | which item or tab a selector currently points at. *View state* when nothing would save it ({Tuile::Component::Tabs}`#selected`), a *value* when a form would (`RadioGroup#value`) — the split `D_tabs` calls the "would a form save it?" test. |
46
46
  | **item_count** / **item_index** | how a `List::Cursor` counts and addresses; equal to a row count in a `List`, but the cursor indexes *items*. |
47
47
  | **text** | the user-editable **value** of an input ({Tuile::Component::HasValue}, aliased as `text` on `AbstractStringField`). |
48
48
  | **caption** | app-authored **chrome** text ({Tuile::Component::HasCaption}) — a `Window` title, a `Button` label. Never a value. |
@@ -54,9 +54,10 @@ content-space.
54
54
  | term | means |
55
55
  |---|---|
56
56
  | **component** | a node of the UI tree ({Tuile::Component}); the only thing that paints. |
57
+ | **slot extent** | in a `Layout::Box`, the size a parent *allocates* a child along an axis — what `Fixed` / `Percent` / `Expand` declare, and what `main_extent` / `cross_extent` measure. The parent's allocation, where a component's *extent* is the child's own painted region; `D_extent` turns on the two being different. Here `slot` is the box's allocation for one child and has **nothing** to do with {Tuile::Component::Slot} — the phrase is glossary-only (the code says `main_extent` / `cross_extent`), so read it as one term, never as "the extent of a `Slot`". |
57
58
  | **tile** / **tiled** | the non-popup part of the tree — `ScreenPane#content` and its descendants. Also *to tile*: to cover a rect completely. |
58
59
  | **attached** | reachable from a {Tuile::ScreenPane} via the parent chain — the one axis `attached?` consults. |
59
- | **slot** | a named child a container holds by identity (`content`, `footer`) as well as in `children`. |
60
+ | **slot** | a named region of a container, reached by identity (`content`, `footer`) as well as through `children`. Two forms: a plain named child, when the occupant is permanent and integral (`HasContent#content`); or a {Tuile::Component::Slot}, the one-child region component, when the occupant may be absent or swapped (`Window#footer`). Capital-`S` `Slot` always means the class. |
60
61
  | **cascade** | the stack of open {Tuile::Component::ListDropdown} panels a {Tuile::Component::MenuBar} drives, one per level, the last deepest. Each is an overlay on the pane, not a child of the bar. |
61
62
  | **submenu** | a menu item that opens a further panel instead of doing something — `MenuBar::Item#submenu?`, true iff the item has children. Painted with a trailing `▸`. |
62
63
  | **mnemonic** | a letter that activates one {Tuile::Component::MenuBar} item, underlined in its caption. Always *level-scoped*: matched against the top-level items while the cascade is closed and the deepest open panel while it is open, never across the two. |
data/book/03-layout.md CHANGED
@@ -160,8 +160,9 @@ common case, debuggability, and auditability all at once.
160
160
 
161
161
  The place you actually write layout code is a `rect=` override. The base
162
162
  class for this is `Tuile::Component::Layout::Absolute`: it inherits all
163
- the focus and key-dispatch wiring, paints nothing itself, and asks only
164
- that you position your children whenever your own rectangle is assigned —
163
+ the focus, key-dispatch and mouse-routing wiring, paints nothing itself,
164
+ and asks only that you position your children whenever your own rectangle
165
+ is assigned —
165
166
  which happens once at startup and again on every resize.
166
167
 
167
168
  ```ruby
@@ -396,24 +397,24 @@ class Fraction < Data.define(:width, :height) # each a float in 0.0..1.0
396
397
  end
397
398
  ```
398
399
 
399
- A popup's size is set with `Popup#size=`, which accepts either a
400
+ A popup's box is set with `Popup#declared_size=`, which accepts either a
400
401
  `Fraction` (resolved against the screen at layout time, so it tracks
401
402
  resize) or an absolute `Size` (clamped to the screen):
402
403
 
403
404
  ```ruby
404
405
  popup = Tuile::Component::Popup.new(content: some_window)
405
- popup.size = Tuile::Fraction::HALF # the default — half the screen, centered
406
- popup.size = Tuile::Fraction::FULL # fullscreen
407
- popup.size = Tuile::Fraction.new(0.8, 0.5) # 80% wide, half tall
408
- popup.size = Tuile::Size.new(50, 12) # exact, clamped to screen
406
+ popup.declared_size = Tuile::Fraction::HALF # the default — half the screen, centered
407
+ popup.declared_size = Tuile::Fraction::FULL # fullscreen
408
+ popup.declared_size = Tuile::Fraction.new(0.8, 0.5) # 80% wide, half tall
409
+ popup.declared_size = Tuile::Size.new(50, 12) # exact, clamped to screen
409
410
  ```
410
411
 
411
412
  The default is `Fraction::HALF`, resolved on every layout pass, so a
412
413
  popup you never size at all is half-screen and follows the terminal as
413
414
  it resizes. `Fraction::FULL` is the fullscreen shorthand.
414
415
 
415
- A subtle but important point: `size=` is **authoritative, not a
416
- preference**. The name is `size`, not `preferred_size`, on purpose.
416
+ A subtle but important point: `declared_size=` is **authoritative, not a
417
+ preference**. The name says *declared*, not *preferred*, on purpose.
417
418
  There is no parent that might negotiate it downward — the screen simply
418
419
  *applies* what you asked for (clamping an oversized absolute `Size` to
419
420
  fit). Calling it a preference would invite a future "well, the parent
@@ -434,7 +435,7 @@ wrong for a paragraph.
434
435
  > height) and it isn't even reliably pretty — a single long line
435
436
  > collapses the popup to one row. Half-screen-and-wrap sidesteps all of
436
437
  > it. When you genuinely know the right size — an autocomplete dropdown
437
- > whose items you own — you set it yourself: `popup.size =
438
+ > whose items you own — you set it yourself: `popup.declared_size =
438
439
  > Tuile::Size.new(longest_item, [items.size, 8].min)`. That's still
439
440
  > caller-decides, top-down.
440
441
 
@@ -482,6 +483,12 @@ A component in the footer slot always fills the width — there is no
482
483
  sizing policy to configure, because the window already knows its inner
483
484
  width and that's the only dimension a bottom-row widget needs.
484
485
 
486
+ The word *slot* is literal here: the footer is a
487
+ {Tuile::Component::Slot}, the one-child region you'd use for the same job
488
+ in a container of your own (chapter 7). That's why `footer=` needs no
489
+ sizing argument and why setting it to `nil` restores the border cleanly —
490
+ the region stays in the tree either way, occupied or not.
491
+
485
492
  The two are mutually exclusive by precedence: if a `footer=` component
486
493
  is present it occupies the bottom row and `footer_text` is hidden;
487
494
  otherwise `footer_text` embeds into the border. No window needs both at
data/book/05-focus.md CHANGED
@@ -45,7 +45,10 @@ decoration; clicking one shouldn't yank focus away from the window around
45
45
  it. Controls that accept input (a text field, a list, a button) override
46
46
  it to `true`. This gate is what makes click-to-focus sane: clicking lands
47
47
  focus on the component under the cursor *only if it's focusable*,
48
- otherwise the click is ignored for focus purposes. The same rule governs
48
+ otherwise the click is ignored for focus purposes. A click descends the
49
+ tree — every component whose rectangle contains the point sees it, outermost
50
+ first — so "the component under the cursor" is really all of them, and focus
51
+ settles on the deepest focusable one. The same rule governs
49
52
  the automatic focus-forwarding a container does when it's focused — a
50
53
  window handed focus passes it down to its content, but only if that
51
54
  content is focusable.
data/book/06-theming.md CHANGED
@@ -170,6 +170,104 @@ theme. From your code's perspective a live appearance flip and a startup
170
170
  detection are the same thing arriving through the same channel — which is
171
171
  exactly the single-threaded-loop payoff chapter 4 promised.
172
172
 
173
+ ## Building on the terminal's own background
174
+
175
+ Everything so far picks colors to sit *against* the background. Some
176
+ designs want the opposite: a color derived *from* it. The borderless-pane
177
+ idiom — LazyVim's editor-versus-explorer split is the one most people
178
+ have seen — leaves the primary pane at the terminal's own background and
179
+ tints the secondary panes a few percent off it. No borders, no boxes; the
180
+ panes separate because one is very slightly lighter than the other.
181
+
182
+ You cannot do that with a fixed color. A tint tuned against `#1e1e2e`
183
+ looks like a deliberate panel against `#000000` and disappears entirely
184
+ against `#282c34`. What the effect needs is the terminal's *actual*
185
+ background, and Tuile has it: the OSC 11 reply carries the RGB, and
186
+ {Tuile::Screen}`#background_color` hands it to you as a
187
+ {Tuile::Color}.
188
+
189
+ ```ruby
190
+ bg = Tuile::Screen.instance.background_color
191
+ sidebar.bg_color =
192
+ bg ? Tuile::Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
193
+ ```
194
+
195
+ That `FALLBACK_TINT` is not defensive padding — it's the branch you
196
+ should expect to hit. Plenty of terminals answer neither probe, and the
197
+ `COLORFGBG` fallback reports a palette *index* with no RGB behind it, so
198
+ `background_color` is nil for every one of them. The fixed near-neutral
199
+ you would have shipped anyway becomes the fallback; the reported color is
200
+ the upgrade for terminals that can support it.
201
+
202
+ The value stays honest across an appearance flip, and doing so takes one
203
+ more round trip than you might expect. The mode-2031 report says only
204
+ "the OS is light now" — it carries no RGB — so when the screen sees one,
205
+ it writes the OSC 11 query again, and the reply comes back through the
206
+ key thread as another event. The new color therefore lands a frame after
207
+ the new theme. When it does, Tuile fires
208
+ {Tuile::Component}`#on_theme_changed` across the tree exactly as a theme
209
+ swap does, on the reasoning that a tint derived from the background *is*
210
+ a theme-derived color, and that hook is already where you rebuild those.
211
+ So the same override handles both halves of a flip, and you don't need to
212
+ know which one woke you.
213
+
214
+ ## Not every terminal can show what you computed
215
+
216
+ There is a catch hiding in that last section, and it is worth seeing
217
+ clearly because it applies to every color you *compute* rather than
218
+ declare.
219
+
220
+ A 24-bit color goes out as `\e[48;2;30;30;34m`. That sequence assumes the
221
+ terminal on the other end understands 24-bit color — and plenty don't.
222
+ A `TERM=xterm-256color` session understands only the 256-color palette; a
223
+ Linux console understands sixteen colors; tmux without
224
+ `terminal-features "*:RGB"` mangles or approximates whatever passes
225
+ through it. When you *declared* your colors, this was somebody else's
226
+ problem: you picked them by eye, in a terminal you were looking at, and
227
+ if they came out wrong you picked different ones. A tint computed at
228
+ runtime from the reported background has nobody to eyeball it.
229
+
230
+ So Tuile detects what the terminal can show, and degrades on the way out.
231
+
232
+ ```ruby
233
+ Tuile::Screen.instance.color_depth # => :truecolor, :palette256, or :ansi16
234
+ ```
235
+
236
+ Detection reads the environment — `COLORTERM`, then `TERM` — and never
237
+ asks the terminal anything, so unlike the background probe there is no
238
+ timing to respect and no staleness to worry about: the depth is settled
239
+ at construction and stays put. Terminals do lie, in both directions, and
240
+ `COLORTERM` in particular tends not to survive ssh or tmux. Two things
241
+ make that survivable. Misdetection lands *conservatively* — a truecolor
242
+ tmux advertising only `tmux-256color` reads as `:palette256`, which
243
+ renders coarser but never garbled — and `TUILE_COLOR_DEPTH` overrides the
244
+ detection outright, which is what you reach for when a terminal reports
245
+ itself wrong.
246
+
247
+ The part that matters for your code is that **you don't have to do
248
+ anything about it**. The degradation happens inside
249
+ {Tuile::Buffer}`#flush`, at the moment cells become bytes: every color is
250
+ mapped to the nearest one the terminal can actually show, and the RGB
251
+ you computed is what stays in the component. Paint `Color.rgb(30, 30, 34)`
252
+ on a 256-color terminal and the wire carries palette cell 234; read the
253
+ component back and it still holds your RGB. Nothing you store is ever
254
+ quantized — which is the point, because a stored palette cell has
255
+ forgotten what it was derived from, and the next tint you compute from it
256
+ would compound the error.
257
+
258
+ That leaves one thing worth doing deliberately, and only sometimes. If
259
+ you want to know what a color will *become* — checking that a computed
260
+ tint still contrasts with the background after both round to the same
261
+ coarse palette — ask it:
262
+
263
+ ```ruby
264
+ tint.quantize(Tuile::Screen.instance.color_depth) # => the color the terminal will show
265
+ ```
266
+
267
+ This is a question, not a step you owe the framework. It returns the
268
+ receiver unchanged whenever the depth can show the color as-is, so it is
269
+ also the cheapest way to ask "would this degrade at all?".
270
+
173
271
  ## Theming an app durably
174
272
 
175
273
  Detection picks between *Tuile's* two themes. To give your app its own
@@ -674,6 +674,39 @@ window.content = Component::List.new.tap { _1.lines = entries }
674
674
  window.scrollbar = true
675
675
  ```
676
676
 
677
+ ### Reserving a region: `Slot`
678
+
679
+ A `Window` has one content region. When *you* build a container with
680
+ several — a dialog with a message, a button row and maybe a header — give
681
+ each region a {Tuile::Component::Slot}: a component whose whole job is to
682
+ hold one child and size it to itself.
683
+
684
+ ```ruby
685
+ @message = Component::Slot.new
686
+ add(@message, Expand[1]) # the region, wired once at construction
687
+ @message.content = Component::Label.new("Delete this file?") # the occupant
688
+ ```
689
+
690
+ The reason to bother is an arithmetic problem you'd otherwise have to
691
+ solve. Children are ordered, and order decides paint order and Tab order —
692
+ so if you held the message and the buttons as direct children, "where does
693
+ the message get inserted?" would depend on whether the header happens to be
694
+ present right now. Inside a slot the answer is always index 0, because the
695
+ slot itself never leaves the tree. Add regions, reorder them, leave some
696
+ empty: none of it changes a swap.
697
+
698
+ Which leads to the one thing that surprises people: **an empty slot doesn't
699
+ collapse.** It keeps the rectangle its parent gave it and clears it, so a
700
+ dialog with no message shows the hole — exactly as it would with an *empty*
701
+ message. If you want the gap closed, that's the parent's arithmetic (give
702
+ the slot a zero extent), which is the same top-down rule as everything else
703
+ in chapter 3. Don't detach the slot to make it go away; that hands you back
704
+ the insert-index problem it exists to remove.
705
+
706
+ A slot is invisible to input: it can't take focus, clicks pass straight
707
+ through to the occupant, and when an occupant leaves, the focus repair is
708
+ handed up to your container rather than stranding focus on the slot.
709
+
677
710
  ## Switching between views
678
711
 
679
712
  When a screen has more content than fits and the parts are *alternatives*
@@ -1008,22 +1041,24 @@ every GUI dismisses its menus on a window resize too.
1008
1041
  The popup itself paints nothing — it's a transparent host that wraps any
1009
1042
  component as its content and manages the lifecycle (`open` / `close`,
1010
1043
  ESC/`q` to dismiss). Crucially, and per chapter 3, **it does not size
1011
- itself to its content**: its box is declared by `size` — a `Fraction`
1044
+ itself to its content**: its box is set by `declared_size` — a `Fraction`
1012
1045
  (default `Fraction::HALF`, half the screen, re-resolved on every resize)
1013
1046
  or an absolute `Size`. The content then fills that box, so use content
1014
1047
  that can cope with overflow — a TextView or TextArea that scrolls, not a
1015
1048
  bare Label that only truncates.
1016
1049
 
1017
- A popup is **modal by default**: centered, it grabs focus, eats keys, and
1050
+ A popup is **always modal**: centered, it grabs focus, eats keys, and
1018
1051
  blocks clicks beneath it — that's what makes an open dialog trap Tab and
1019
- input inside itself. Pass `modal: false` for a non-modal overlay that
1020
- floats above the content without taking focus — the autocomplete-list case
1021
- from earlier, where the caller positions it against a field's caret and
1022
- drives it from app code.
1023
-
1024
- **A left click outside a popup closes it**, modal or not — the same light
1025
- dismissal a desktop dialog gives you. It's a per-popup switch,
1026
- `close_on_outside_click`, on by default; a popup that must survive stray
1052
+ input inside itself. For a layer that floats *without* taking focus — the
1053
+ autocomplete-list case from earlier, where the caller positions it against a
1054
+ field's caret and drives it from app code — use its base class, `Overlay`,
1055
+ directly. An `Overlay` is a Popup minus the modality: same open/close
1056
+ lifecycle, same outside-click dismissal, but it sits at the rect you assign
1057
+ and never disturbs focus or key dispatch.
1058
+
1059
+ **A left click outside an overlay closes it**, modal or not — the same light
1060
+ dismissal a desktop dialog gives you. It's a per-overlay switch,
1061
+ `close_on_outside_click`, on by default; one that must survive stray
1027
1062
  clicks turns it off, as a Notification does. The click still reaches
1028
1063
  whatever was beneath it, unless an open modal swallowed it — in which case
1029
1064
  the first click dismisses and a second one acts.
@@ -1050,6 +1085,116 @@ A nested TextField still swallows printable keys first, so typing `q` into
1050
1085
  a field inside a popup doesn't dismiss it — the popup's own `q` handler sits
1051
1086
  on the ancestor, and only sees keys the field declined.
1052
1087
 
1088
+ ## The confirm dialog
1089
+
1090
+ That Window-in-a-Popup assembly is how you build any dialog. One dialog is
1091
+ so common it comes pre-assembled: {Tuile::Component::ConfirmWindow}, the
1092
+ "are you sure?" box — a caption, a short message, a row of buttons.
1093
+
1094
+ ```ruby
1095
+ Component::ConfirmWindow.confirm("Delete Report Q4?", "This cannot be undone.",
1096
+ confirm: "Delete") { delete! }
1097
+ ```
1098
+
1099
+ ```
1100
+ ┌Delete Report Q4?───────────┐
1101
+ │ This cannot be undone. │
1102
+ │ │
1103
+ │ [ Delete ] [ Cancel ] │
1104
+ └────────────────────────────┘
1105
+ ```
1106
+
1107
+ Three factories cover the shapes you'll actually write: `alert(caption,
1108
+ message)` is the one-button acknowledgement, `confirm` the two-button
1109
+ question — its labels are keywords, so it is also your OK/Cancel and
1110
+ Delete/Cancel — and `yes_no` the other canonical phrasing. That's
1111
+ deliberately the whole list: Windows' `MessageBoxButtons` enum grew six
1112
+ values by naming every label pair, and any set the factories don't cover is
1113
+ a few lines of the builder below.
1114
+
1115
+ ### Why a block, and not an answer
1116
+
1117
+ Everyone's first instinct here is the blocking call — `if confirm?("Delete?")`
1118
+ — because that's what Swing's `JOptionPane`, tkinter's `askyesno` and GTK's
1119
+ `dialog.run` all offer. Tuile can't, and it's worth understanding why: the
1120
+ whole UI runs on one thread (chapter 4), so a call that *waits* for the
1121
+ answer would have to nest a second event loop inside the first, re-entering
1122
+ raw mode under a key thread that's already reading stdin. The dialog
1123
+ therefore takes callbacks: the block is the action, and the dialog returns
1124
+ immediately.
1125
+
1126
+ ### One kind of way out
1127
+
1128
+ Every button closes the dialog. A button *with* a block then fires it; a
1129
+ button *without* one is a Cancel. And ESC, `q`, a click outside the box and
1130
+ that Cancel button are all the same event — `on_dismiss`, fired exactly
1131
+ once, and only when no action button was chosen. You never write a `case`
1132
+ over outcomes, and you never have to enumerate the ways out: there is the
1133
+ action you asked about, and there is "do nothing", however the user spells
1134
+ it.
1135
+
1136
+ There is deliberately no way to keep the dialog open after a press. A
1137
+ dialog that leads somewhere — "Copy files" showing a progress window —
1138
+ opens the next window *from its callback*, which is safe because the block
1139
+ fires after the dialog has already closed and focus has been repaired.
1140
+
1141
+ For any other button set, the component is its own builder — buttons are
1142
+ declared one at a time, each a caption and an optional block:
1143
+
1144
+ ```ruby
1145
+ dialog = Component::ConfirmWindow.new("Unsaved changes")
1146
+ dialog.message = "Save your changes before leaving?"
1147
+ dialog.button("Save") { save! }
1148
+ dialog.button("Discard") { discard! }
1149
+ dialog.button("Cancel") # no block: pressing it dismisses
1150
+ dialog.on_dismiss = -> { stay_put }
1151
+ dialog.open
1152
+ ```
1153
+
1154
+ ### Keys, and the underlined letters
1155
+
1156
+ Focus opens on the first button — which, since Enter presses the *focused*
1157
+ button, makes it the default. Left/Right and Tab walk the row; Enter or
1158
+ Space press.
1159
+
1160
+ Each button also answers to a **mnemonic**: a letter, underlined in its
1161
+ caption, that presses the button from anywhere in the dialog. By default
1162
+ it's the caption's first letter (Save gets `s`, Discard `d`), matched
1163
+ case-insensitively; pass `mnemonic:` to pick another letter — the case you
1164
+ give chooses which occurrence gets the underline — or `nil` for none. The
1165
+ underline isn't decoration: Tuile draws no status bar to advertise keys in,
1166
+ so the caption *is* the advertisement.
1167
+
1168
+ Three letters are never mnemonics. `q` is unconditionally the do-nothing
1169
+ route out — a dialog that forces a choice only thinks it does, since the
1170
+ user can always Ctrl+C, and pretending there's no escape route just trains
1171
+ them to reach for it. And `g`/`G` belong to the message: the body scrolls
1172
+ *without taking focus* — Up/Down, PgUp/PgDn, Ctrl+U/D, Home/End and the
1173
+ less-style `g`/`G` are handed to it while a button keeps focus, so a long
1174
+ message reads without any focus gymnastics. The body is also a tab stop:
1175
+ Shift+Tab reaches it, so overflowing prose is visibly reachable, not
1176
+ secretly scrollable.
1177
+
1178
+ ### The popup that sizes itself
1179
+
1180
+ Chapter 3 was firm that nothing in Tuile sizes itself to its content, and a
1181
+ plain Popup takes half the screen whatever it wraps — absurd around a
1182
+ one-line "Delete?". The confirm dialog is the sanctioned exception *shape*:
1183
+ it measures **content it owns** — its caption, its message, its buttons —
1184
+ and asks the screen for exactly that box, still capped at half the screen
1185
+ (a message longer than the cap wraps and scrolls). Assign a `Component` as
1186
+ the message and the measuring honestly gives up: injected content is not
1187
+ the dialog's to measure, so the popup takes the full half-screen box.
1188
+
1189
+ ### What it deliberately isn't
1190
+
1191
+ The message is prose — a `String` or {Tuile::StyledString}, which on a TTY
1192
+ already covers color, emphasis and iconography — not a content slot. A
1193
+ dialog collecting *input* is not a confirm dialog: the moment you want a
1194
+ form, a picker or a diff view in there, you've outgrown the sugar, and the
1195
+ general mechanism is one line away — `Popup.new(content: your_layout)`,
1196
+ exactly as in the previous section.
1197
+
1053
1198
  ## Notifications
1054
1199
 
1055
1200
  {Tuile::Component::Notification} is the one overlay you don't assemble at
@@ -1105,18 +1250,23 @@ The last three components are conveniences: common Window-plus-content
1105
1250
  assemblies you'd otherwise build by hand. Each works tiled (add it to a
1106
1251
  layout) *or* as a popup (via a class-level `open`).
1107
1252
 
1108
- - {Tuile::Component::InfoWindow} — a Window preloaded with a List of
1109
- static lines. The read-only "here's some information" box;
1110
- `InfoWindow.open(caption, lines)` pops it up.
1253
+ - {Tuile::Component::InfoWindow} — a Window with a read-only body, in one
1254
+ of two presentations: `message=` is *prose*, wrapped by a scrollable
1255
+ TextView; `lines=` is *rows*, a List keeping one item per row and
1256
+ truncating — the choice for columnar output, where a wrap would destroy
1257
+ the alignment. `InfoWindow.open(caption, body)` pops it up, picking the
1258
+ presentation from the body's type (an Array is rows, text is prose).
1111
1259
  - {Tuile::Component::PickerWindow} — a menu of options each bound to a
1112
1260
  single key, firing your block with the picked key. Popped up via `open`,
1113
1261
  it closes itself after a pick; ESC/`q` cancels without firing.
1114
- - {Tuile::Component::LogWindow} — a Window wrapping an auto-scrolling,
1115
- scrollbar-equipped TextView, purpose-built for log output. Its `log`
1116
- method is **thread-safe** — it marshals the append back onto the UI
1117
- thread via the event queue (chapter 4), so background work can log
1118
- freely. And it carries an `IO`-shaped adapter so you can point a stdlib
1119
- `Logger` (or a `TTY::Logger`) straight at it:
1262
+ - {Tuile::Component::LogWindow} — a Window framing a
1263
+ {Tuile::Component::LogTextView}: an auto-scrolling, scrollbar-equipped
1264
+ TextView purpose-built for log output. The view is where the behavior
1265
+ lives — compose it bare into a layout when you don't want the frame.
1266
+ Its `log` method is **thread-safe** — it marshals the append back onto
1267
+ the UI thread via the event queue (chapter 4), so background work can
1268
+ log freely. And the view carries an `IO`-shaped adapter so you can point
1269
+ a stdlib `Logger` (or a `TTY::Logger`) straight at either of them:
1120
1270
 
1121
1271
  ```ruby
1122
1272
  window = Component::LogWindow.new
data/book/08-testing.md CHANGED
@@ -108,9 +108,25 @@ key, so you assert on that too:
108
108
 
109
109
  ```ruby
110
110
  list.handle_key(Keys::DOWN_ARROW) # exercises the cursor directly
111
+ ```
112
+
113
+ **A mouse test needs the component mounted, where a key test doesn't.** A
114
+ click doesn't only *do* something, it also *focuses* — and
115
+ {Tuile::Screen#focused=} refuses a component that isn't on the pane, so
116
+ `handle_mouse` on a component you never attached raises "is not attached to
117
+ this screen". Give it a tree first:
118
+
119
+ ```ruby
120
+ screen.content = list # a click focuses; focus needs a tree
121
+ list.rect = Rect.new(0, 0, 10, 5)
111
122
  list.handle_mouse(MouseEvent.new(:left, 5, 2))
112
123
  ```
113
124
 
125
+ That applies to containers too, and to more of them than you might expect:
126
+ a click descends to every child whose rect contains the point, so testing a
127
+ window's footer by clicking it exercises the window, the footer's slot and
128
+ the footer, all of which want to be attached.
129
+
114
130
  **High: go through the pane.** {Tuile::ScreenPane#handle_key} runs the
115
131
  dispatch rung from chapter 5 that routing is actually about: delivery to
116
132
  {Tuile::Screen#focused}, then the bubble up its ancestor chain to the scope
@@ -29,7 +29,7 @@ tangle has to be re-untangled on every edit.
29
29
  {Tuile::StyledString} untangles it once, structurally. A styled string is
30
30
  a sequence of **spans**, each a maximal run of characters that share one
31
31
  complete {Tuile::StyledString::Style} — foreground, background, bold,
32
- italic, underline, strikethrough. The spans are non-overlapping and tile
32
+ italic, underline, strikethrough, inverse. The spans are non-overlapping and tile
33
33
  the whole string: every character belongs to exactly one span, and that
34
34
  span's `style` *is* the character's style. There are no overlay layers to
35
35
  merge, no running state to reconstruct. "What's the style at column 5?"
@@ -100,8 +100,8 @@ by default.**
100
100
 
101
101
  Strict means it recognizes exactly the SGR codes that map to a
102
102
  {Tuile::StyledString::Style}'s attributes — the foreground and background
103
- colors, bold, italic, underline, strikethrough — and *raises* on anything
104
- else. An unmodeled attribute like blink or reverse video, an unknown SGR
103
+ colors, bold, italic, underline, strikethrough, inverse — and *raises* on
104
+ anything else. An unmodeled attribute like blink or conceal, an unknown SGR
105
105
  code, a non-SGR escape like a cursor move or an OSC sequence: all of them
106
106
  are a {Tuile::StyledString::ParseError}, not a shrug. The reason is a
107
107
  contract worth protecting: `parse(to_ansi(x)) == x`. If parsing silently
@@ -82,7 +82,7 @@ module FileCommanderExample
82
82
  @on_cwd_changed&.call
83
83
  rescue SystemCallError => e
84
84
  @cwd = previous
85
- Tuile::Component::InfoWindow.open("Cannot open", [path, e.message])
85
+ Tuile::Component::InfoWindow.open("Cannot open", "#{path}\n#{e.message}")
86
86
  end
87
87
 
88
88
  def load_entries