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
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,14 +15,16 @@ 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. |
22
+ | **left_column** | the content column currently painted in a widget's leftmost cell — the horizontal counterpart of `scroll_top_row`. Private wherever it exists (`TextField`, `Tabs`, `MenuBar`): what a caller relies on is the invariant it maintains — the caret, or the selected segment, is in view — not the number. |
22
23
  | **viewport_rows** | how many rows of content are visible — always `rect.height`; kept private, since `rect.height` is the public form. |
23
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. |
24
25
  | **caret_row** | the row a text input's caret sits in, counted from the content's first row. `TextArea` only. |
25
- | **extent** | the sub-rect a one-row caption widget actually occupies (`min(caption width + 4, rect.width)`), used for its highlight and hit test — narrower than its `rect`. |
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
+ | **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. |
26
28
 
27
29
  **Space rule 1.** An object with only one row space leaves `row` unqualified:
28
30
  {Tuile::Buffer} *is* the grid, so its rows are screen rows;
@@ -40,10 +42,11 @@ content-space.
40
42
  | **line_count** | a count of `\n` units (`TextView::Region#line_count`). Never a row count. |
41
43
  | **item** | a domain object a widget holds and renders — `List#items`, and the enum widgets above it. |
42
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. |
43
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*. |
44
47
  | **text** | the user-editable **value** of an input ({Tuile::Component::HasValue}, aliased as `text` on `AbstractStringField`). |
45
48
  | **caption** | app-authored **chrome** text ({Tuile::Component::HasCaption}) — a `Window` title, a `Button` label. Never a value. |
46
- | **chrome** | framework- or app-authored decoration around content: captions, borders, footers, the status bar. |
49
+ | **chrome** | framework- or app-authored decoration around content: captions, borders, footers, an app's status line. |
47
50
  | **caret** | the index into an input's `text` where editing happens; always on a cluster boundary. Distinct from the *cursor*. |
48
51
 
49
52
  ## Tree, paint and theme
@@ -51,9 +54,16 @@ content-space.
51
54
  | term | means |
52
55
  |---|---|
53
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`". |
54
58
  | **tile** / **tiled** | the non-popup part of the tree — `ScreenPane#content` and its descendants. Also *to tile*: to cover a rect completely. |
55
59
  | **attached** | reachable from a {Tuile::ScreenPane} via the parent chain — the one axis `attached?` consults. |
56
- | **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. |
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. |
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 `▸`. |
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. |
64
+ | **strip** | the one-row {Tuile::Component::Tabs} component: captions, one selected, no content of its own. A {Tuile::Component::MenuBar} has one too — same word, and the same extent-based hit testing, deliberately not the same look. |
65
+ | **tab** | a {Tuile::Component::Tabs::Tab} — a caption plus an identity, minted and owned by the strip. Not a component (it never paints itself) and not an *item* (it holds per-element state, and the set is never assigned whole). Say "a tab" and "the Tab key"; never let the two words touch. |
66
+ | **pane** | the component a {Tuile::Component::TabSheet} shows for the selected tab. The unselected ones are *detached*, which is how Tuile hides a component. |
57
67
  | **invalidate** | record a component as needing repaint; the loop coalesces and repaints once per tick. |
58
68
  | **cursor** | *(two senses, both live)* the hardware terminal cursor (`Screen#cursor_position`), and a `List::Cursor` — the selection position within a list. |
59
69
  | **well** | the explicit background an input paints over its whole rect (`Theme#input_bg_color` / `#active_bg_color`), which opts it out of `bg_color` inheritance. |
data/book/01-first-app.md CHANGED
@@ -104,11 +104,10 @@ window.focus
104
104
 
105
105
  `screen.content = window` makes the window the screen's **tiled content**
106
106
  — the component that fills the terminal. Setting it triggers a layout
107
- pass: the window is handed a rectangle spanning the whole screen (minus
108
- one bottom row, which we'll get to), and it in turn sizes the label
109
- inside its border. This is the top-down cascade in miniature — the screen
110
- sizes the window, the window sizes its content — and it re-runs, top
111
- down, every time the terminal is resized.
107
+ pass: the window is handed a rectangle spanning the whole screen, and it
108
+ in turn sizes the label inside its border. This is the top-down cascade in
109
+ miniature — the screen sizes the window, the window sizes its content —
110
+ and it re-runs, top down, every time the terminal is resized.
112
111
 
113
112
  `window.focus` marks the window as the **focused** component: the one
114
113
  that receives keystrokes. Focus flows down toward interactive content
@@ -117,30 +116,36 @@ the active thing." In a one-window app it's mostly cosmetic (it draws the
117
116
  border in the active color); in a real app, focus is what routes the
118
117
  keyboard, and it gets its own chapter (chapter 5).
119
118
 
120
- You may have noticed you never created a status bar, yet the app has one
121
- — the bottom row showing `q quit`. That's because your window isn't the
122
- whole story of what's on screen.
119
+ Run it and you'll notice what *isn't* there: no status bar, no menu, no
120
+ title chrome beyond the border you asked for. **Tuile paints nothing you
121
+ didn't build.** Other TUI toolkits hand you a status row and a hint line
122
+ for free; Tuile gives your content the whole terminal and lets you decide
123
+ whether a bottom row is worth one of your rows. A status line is three
124
+ lines of layout when you want one — `examples/hello_world.rb` adds one,
125
+ and chapter 3 shows the mechanism.
126
+
127
+ That is the same instinct as top-down layout: the framework declines to
128
+ make sizing decisions on your behalf, here by declining to spend a row.
123
129
 
124
130
  ## The tree you didn't build
125
131
 
126
132
  Your `window` isn't actually the root of the tree. The real root is a
127
133
  structural node called the {Tuile::ScreenPane}, owned by the screen, and
128
- it holds three things:
134
+ it holds two things:
129
135
 
130
136
  ```
131
137
  ScreenPane (structural root — paints nothing itself)
132
138
  ├── content your window (the tiled UI)
133
- ├── popups modal overlays, when you open them (none yet)
134
- └── status_bar the bottom row (that "q quit" hint)
139
+ └── popups modal overlays, when you open them (none yet)
135
140
  ```
136
141
 
137
142
  You only ever manage the `content` slot directly (via `screen.content=`);
138
- the pane, the popup stack, and the status bar are the framework's. The
139
- reason everything — including popups — lives under one parent is
140
- uniformity: focus traversal, "is this component still on screen?", and
141
- cleanup when a component is removed all work the same way for every node,
142
- with no special cases. You'll meet popups in chapter 7; for now it's
143
- enough to know the pane is up there, quietly being the root.
143
+ the pane and the popup stack are the framework's. The reason everything —
144
+ including popups — lives under one parent is uniformity: focus traversal,
145
+ "is this component still on screen?", and cleanup when a component is
146
+ removed all work the same way for every node, with no special cases.
147
+ You'll meet popups in chapter 7; for now it's enough to know the pane is
148
+ up there, quietly being the root.
144
149
 
145
150
  ## Run the loop, and always close
146
151
 
data/book/02-repaint.md CHANGED
@@ -136,14 +136,27 @@ Meeting that second half is easy, because the default
136
136
  - A **leaf** component (no children) gets its background cleared
137
137
  automatically, so you can paint your content and trust the rest is
138
138
  blanked.
139
- - A **container whose children exactly tile its rect** skips the clear —
140
- the children will cover everything anyway.
141
139
  - A **container with gaps** between its children (a form with
142
- mixed-width fields, say) gets the background cleared *and* its children
143
- re-invalidated, so they repaint cleanly on top. This is what makes
144
- gappy layouts safe without every container writing its own
140
+ mixed-width fields, say) gets the background cleared, because those gap
141
+ cells are ones no child will paint over.
142
+ - A **container whose children exactly tile its rect** skips the clear —
143
+ the children cover every cell anyway, and blanking a cell you are about
144
+ to repaint would only mark it dirty for the flush.
145
+ - **Either way, a container re-invalidates its children.** That is what
146
+ makes gappy layouts safe without every container writing its own
145
147
  damage-tracking pass.
146
148
 
149
+ That last point is worth a moment, because the obvious optimization is
150
+ wrong. A clear wipes the container's *whole* rect — every descendant's
151
+ cells, not just the gaps — but a container only ever notifies its own
152
+ direct children, so the notice has to keep travelling down on its own. A
153
+ tiling container that stayed quiet ("my children cover everything, nothing
154
+ to do") would be a dead end: its grandchildren would never learn their
155
+ cells had been blanked by an ancestor, and their content would vanish until
156
+ some unrelated event happened to invalidate them. Repainting more than
157
+ strictly necessary costs nothing here — identical glyphs leave a cell
158
+ unchanged, so the diff is still empty and nothing reaches the wire.
159
+
147
160
  The practical rule for writing a component: **call `super` in your
148
161
  `repaint`** to inherit that clearing, then paint your content. The only
149
162
  components that skip `super` are the few that paint every cell of their
data/book/03-layout.md CHANGED
@@ -40,9 +40,9 @@ rectangles they are given. If new content arrives that is too tall for
40
40
  its pane, the pane scrolls or clips — it does not push back on the
41
41
  parent to grow.
42
42
 
43
- This is already how Tuile's tiled UI works today: `ScreenPane` sizes
44
- your content and the status bar; every real layout you write positions
45
- its children the same way. The chapter's job is to convince you that
43
+ This is already how Tuile's tiled UI works today: `ScreenPane` hands your
44
+ content the whole terminal; every real layout you write positions its
45
+ children the same way. The chapter's job is to convince you that
46
46
  this is a feature, then show you the few pieces of vocabulary that make
47
47
  it comfortable.
48
48
 
@@ -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
@@ -342,16 +343,17 @@ The boxes are sugar, not a replacement, and they can't say everything. A **cap
342
343
  on a proportion** is the case to recognise:
343
344
 
344
345
  ```ruby
345
- list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
346
346
  group_width = [16, rect.width / 3].min # a third, but never more than 16
347
+ list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
347
348
  ```
348
349
 
349
- Both of these are in `examples/sampler.rb`, and both keep a `rect=`
350
- override. That's the intended division of labour rather than a gap to work
351
- around: use a box for the stack, drop to `Absolute` for the region that
352
- genuinely needs arithmetic — usually nesting one inside the other, so only the
353
- awkward part carries any. The sampler does exactly that, and porting it to
354
- these layouts took it from 59 hand-written rectangles down to 7.
350
+ The first is in `examples/sampler.rb` twice — the sidebar in its CheckboxGroup
351
+ pane and the one in its List pane — and both keep a `rect=` override. That's
352
+ the intended division of labour rather than a gap to work around: use a box for
353
+ the stack, drop to `Absolute` for the region that genuinely needs arithmetic —
354
+ usually nesting one inside the other, so only the awkward part carries any. The
355
+ sampler does exactly that, and porting it to these layouts took it from 59
356
+ hand-written rectangles down to a handful (5 today).
355
357
 
356
358
  ## Geometry: `Point`, `Size`, `Rect`
357
359
 
@@ -395,24 +397,24 @@ class Fraction < Data.define(:width, :height) # each a float in 0.0..1.0
395
397
  end
396
398
  ```
397
399
 
398
- 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
399
401
  `Fraction` (resolved against the screen at layout time, so it tracks
400
402
  resize) or an absolute `Size` (clamped to the screen):
401
403
 
402
404
  ```ruby
403
405
  popup = Tuile::Component::Popup.new(content: some_window)
404
- popup.size = Tuile::Fraction::HALF # the default — half the screen, centered
405
- popup.size = Tuile::Fraction::FULL # fullscreen
406
- popup.size = Tuile::Fraction.new(0.8, 0.5) # 80% wide, half tall
407
- 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
408
410
  ```
409
411
 
410
412
  The default is `Fraction::HALF`, resolved on every layout pass, so a
411
413
  popup you never size at all is half-screen and follows the terminal as
412
414
  it resizes. `Fraction::FULL` is the fullscreen shorthand.
413
415
 
414
- A subtle but important point: `size=` is **authoritative, not a
415
- 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.
416
418
  There is no parent that might negotiate it downward — the screen simply
417
419
  *applies* what you asked for (clamping an oversized absolute `Size` to
418
420
  fit). Calling it a preference would invite a future "well, the parent
@@ -433,7 +435,7 @@ wrong for a paragraph.
433
435
  > height) and it isn't even reliably pretty — a single long line
434
436
  > collapses the popup to one row. Half-screen-and-wrap sidesteps all of
435
437
  > it. When you genuinely know the right size — an autocomplete dropdown
436
- > whose items you own — you set it yourself: `popup.size =
438
+ > whose items you own — you set it yourself: `popup.declared_size =
437
439
  > Tuile::Size.new(longest_item, [items.size, 8].min)`. That's still
438
440
  > caller-decides, top-down.
439
441
 
@@ -481,6 +483,12 @@ A component in the footer slot always fills the width — there is no
481
483
  sizing policy to configure, because the window already knows its inner
482
484
  width and that's the only dimension a bottom-row widget needs.
483
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
+
484
492
  The two are mutually exclusive by precedence: if a `footer=` component
485
493
  is present it occupies the bottom row and `footer_text` is hidden;
486
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.
@@ -125,6 +128,17 @@ scan of the scope for a component carrying a matching "shortcut key,"
125
128
  which would jump focus to it. It's gone; the next section explains why the
126
129
  bubble does that job better.
127
130
 
131
+ One thing does happen *after* the ladder, and it lives in the loop rather
132
+ than in dispatch: if nothing handled the key and it was `q` or ESC, the
133
+ loop stops and your program exits. That is what a `q quit` hint in an app's
134
+ status line is describing — not a binding anyone registered, but the fate
135
+ of an unclaimed quit key. It also explains why ESC means different
136
+ things in different places: an open {Tuile::Component::Popup} handles ESC
137
+ itself (dismissing is its job), so the loop never sees it and the popup
138
+ closes instead of the app. And a widget keeps a stray `q` from quitting
139
+ simply by consuming it, which a focused {Tuile::Component::TextField} was
140
+ doing anyway — it's a printable character.
141
+
128
142
  ## Scope-wide keys live on an ancestor
129
143
 
130
144
  Two things every app wants: `1`/`2`/`3` to jump between panes, and Enter to
@@ -161,6 +175,7 @@ The same mechanism gives you a form's default button, one form per popup:
161
175
  | `Button` | consumes it | activates *itself*, not the default |
162
176
  | `Checkbox` | consumes it (toggles) | the form never sees it |
163
177
  | `Select` | consumes it (opens, then commits) | the form never sees it |
178
+ | `Tabs` | declines | bubbles up → submit |
164
179
 
165
180
  Because bubbling stops at the scope root, two forms in two popups each get
166
181
  their own Enter — something a global registry structurally cannot do. This
@@ -172,6 +187,76 @@ child table, rather than each widget declaring its own mnemonic. That's a
172
187
  fair trade: which key jumps where is a decision about the assembly, and it
173
188
  reads well in one place.
174
189
 
190
+ ## Paste is not a keystroke
191
+
192
+ Everything above is about keys. A paste looks like keys — and that
193
+ resemblance is a genuine problem, not a convenience.
194
+
195
+ Ask a terminal to paste eight lines and, by default, it types them at your
196
+ program: one byte at a time, with every line break converted to `\r`. That
197
+ `\r` is byte-identical to the Enter you press with your finger. So a prompt
198
+ that rebinds Enter to "submit" submits eight times, and no amount of
199
+ cleverness in `handle_key` can tell the two apart — by the time the key
200
+ arrives, the information is gone.
201
+
202
+ The fix has to happen one layer down, at the code that talks to the
203
+ terminal. {Tuile::Screen#run_event_loop} enables **bracketed paste** (DEC
204
+ private mode 2004), which asks the terminal to wrap pasted text in
205
+ `\e[200~` … `\e[201~` markers. Tuile's key thread recognizes the opening
206
+ marker, reads the payload raw up to the terminator, and posts it as a
207
+ single `PasteEvent` — which never enters the ladder at all:
208
+
209
+ - no Tab traversal, no global shortcuts, no `handle_key`;
210
+ - straight to {Tuile::Component#handle_paste}, delivered down the focus
211
+ chain and bubbling exactly like a key;
212
+ - the whole clipboard as one `String`, `\n`-normalized.
213
+
214
+ The default `handle_paste` returns `false` and the text is dropped.
215
+ {Tuile::Component::AbstractStringField} overrides it to insert at the caret
216
+ as **one** mutation — so `on_change` fires once for the paste rather than
217
+ once per character, and a subclass that claims Enter needs no paste code of
218
+ its own:
219
+
220
+ ```ruby
221
+ class PromptTextArea < Tuile::Component::TextArea
222
+ protected
223
+
224
+ def handle_text_input_key(key)
225
+ return super unless key == Tuile::Keys::ENTER
226
+
227
+ submit(text) # a typed Enter, and only ever a typed Enter
228
+ self.text = ""
229
+ true
230
+ end
231
+ end
232
+ ```
233
+
234
+ Override `handle_paste` yourself when a paste should mean something other
235
+ than "insert this": collapsing a huge clipboard to a `[Pasted 230 lines]`
236
+ placeholder, say, or pulling a file path out of it.
237
+
238
+ ```ruby
239
+ def handle_paste(text)
240
+ return super if text.lines.size < 20
241
+
242
+ attach_as_file(text)
243
+ self.text = "#{text.lines.size} lines attached"
244
+ true
245
+ end
246
+ ```
247
+
248
+ Two smaller consequences worth knowing. Because the payload is read raw
249
+ rather than through {Tuile::Keys.getkey}, a pasted ESC or Tab stays payload
250
+ — unbracketed, a pasted Tab moves focus and a pasted ESC swallows the five
251
+ bytes behind it. And the line endings are normalized for you: terminals
252
+ disagree about whether a bracketed line break is `\r`, `\r\n` or `\n`, so
253
+ Tuile settles on `\n` before the text reaches a component.
254
+
255
+ `run_event_loop(bracketed_paste: false)` turns the mode off, the same way
256
+ `capture_mouse: false` turns off mouse tracking. Then a paste is keystrokes
257
+ again, with the ambiguity that implies — reach for it only if a terminal
258
+ mishandles the mode.
259
+
175
260
  ## Where the cursor comes in — and where it doesn't
176
261
 
177
262
  A component signals cursor ownership through
@@ -188,28 +273,61 @@ needed. With dispatch resting on nothing but "did you return `true`," the
188
273
  proxy is gone, and a component's decision to consume a key is the only
189
274
  declaration in the system.
190
275
 
191
- ## The status bar writes itself
276
+ ## Writing a status line
277
+
278
+ Chapter 1 pointed out that Tuile draws no status bar. This is the chapter
279
+ where you find out that's a decision about *ownership*, not an omission —
280
+ and that most status lines don't need this chapter's machinery at all.
281
+
282
+ A status line is a `Label` in your layout. That's the whole idea:
192
283
 
193
- You've seen the bottom row showing hints like `q quit` since chapter 1.
194
- It's driven by focus. Whenever focus changes, the screen rebuilds the
195
- status bar from two sources: the currently-relevant shortcuts, and the
196
- focused context's own advertised hint.
284
+ ```ruby
285
+ status = Tuile::Component::Label.new
286
+ status.text = "q #{screen.theme.hint("quit")} Tab #{screen.theme.hint("Switch")}"
287
+
288
+ root = Tuile::Component::Layout::Vertical.new
289
+ root.add(main_ui, Tuile::Component::Layout::Expand[1])
290
+ root.add(status, Tuile::Component::Layout::Fixed[1])
291
+ ```
292
+
293
+ If the keys your app offers are the same wherever the user is, you are
294
+ done — set the text once and never touch it again. `examples/file_commander.rb`
295
+ is exactly this: Tab, Enter and Backspace work in both panes, so its row is
296
+ a constant. Reaching for a focus callback there would be machinery computing
297
+ a value that never changes.
197
298
 
198
- A component advertises its hint by overriding
199
- {Tuile::Component#keyboard_hint} to return a preformatted string
200
- (components build these with `theme.hint(...)` so the styling matches).
201
- The screen composes the bar differently depending on what's in front:
299
+ Two details about the text itself. `theme.hint(...)` styles the descriptive
300
+ half of a `key what` pair so hints look consistent (chapter 6), and it
301
+ **bakes the color in** — so a label built from it rebuilds itself from
302
+ `on_theme_changed` to follow a light/dark flip. And keys registered with
303
+ {Tuile::Screen#register_global_shortcut} don't advertise themselves: the
304
+ registry runs actions, it doesn't describe them, so a `^K menu` in your row
305
+ is text you write next to the registration.
202
306
 
203
- - **Tiled (no popup):** `q quit`, then any global-shortcut hints, then the
204
- active window's `keyboard_hint`.
205
- - **Popup open:** the over-popups global hints, then the popup's own hint
206
- (a popup owns its `q Close` prefix).
307
+ ### When the row does depend on focus
308
+
309
+ Some apps genuinely show different keys in different places — a window with
310
+ a search mode, or a pane whose commands only apply to it. For those,
311
+ {Tuile::Screen#on_focus_changed=} is the notification:
312
+
313
+ ```ruby
314
+ screen.on_focus_changed = -> { status.text = hint_for(screen.focused) }
315
+ ```
207
316
 
208
- You don't assemble the bar yourself; you override `keyboard_hint` on the
209
- components that have shortcuts worth advertising, register global
210
- shortcuts with a `hint:`, and the composition happens on every focus
211
- change. The status bar is a *view* of the focus state, not a thing you
212
- maintain.
317
+ It fires after every focus *change* — to and from `nil` included, and after
318
+ the repair that runs when a popup closes. It's edge-triggered, so
319
+ re-focusing what already has focus fires nothing and your callback can
320
+ rebuild the string unconditionally. Two things it must tolerate: `focused`
321
+ being `nil`, and firing during `screen.close`, which clears focus as it
322
+ unmounts.
323
+
324
+ What `hint_for` does is entirely yours — Tuile has no notion of a hint and
325
+ no method for one, so there is no interface here to conform to.
326
+ `examples/sampler.rb` names the focused component's class, which makes Tab
327
+ traversal visible as you walk a pane. An app with per-window keys usually
328
+ walks up the focus chain from `screen.focused` and takes the first answer,
329
+ because that mirrors the direction a key bubbles — but that's an app's
330
+ design decision, not a framework pattern.
213
331
 
214
332
  ---
215
333
 
data/book/06-theming.md CHANGED
@@ -24,7 +24,7 @@ defaults for free.
24
24
  What Tuile *does* color is the small set of cues that signal
25
25
  interaction: the highlight behind the focused list row, the border of the
26
26
  active window, the resting "well" of a text field, the shortcut captions
27
- in the status bar. Those are the accents, and they are exactly the tokens
27
+ in a status line you write. Those are the accents, and they are exactly the tokens
28
28
  a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
29
29
  `input_bg_color`, `hint_color`. There is no global `bg` or `fg` token,
30
30
  and that absence is intentional: adding one would mean painting over the
@@ -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
@@ -250,7 +348,10 @@ integer) that {Tuile::Color}.coerce accepts elsewhere. A theme is
250
348
  declared once per app, so the extra verbosity buys self-documentation —
251
349
  `Color.palette(130)` says "palette index," and the named constant
252
350
  `Color::DARK_ORANGE3` says even more, where a bare `130` at the
253
- declaration site says nothing.
351
+ declaration site says nothing. All 256 xterm palette names are there as
352
+ constants — `Color::DODGER_BLUE1`, `Color::GREY37` — and
353
+ `Color::PALETTE_NAMES` is the enumerable map behind them if you'd rather
354
+ browse than guess.
254
355
 
255
356
  ## When the theme changes under your content
256
357