tuile 0.8.0 → 0.9.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.
data/book/05-focus.md ADDED
@@ -0,0 +1,188 @@
1
+ # 5. Focus and the keyboard
2
+
3
+ Chapter 4 ended on a promise: because everything runs on one thread, this
4
+ chapter can describe keyboard handling without a single caveat about
5
+ concurrency. When a key is dispatched, nothing else is happening — no
6
+ repaint mid-flight, no background thread mutating the tree. So the only
7
+ question left is the interesting one: given a keystroke and a tree of
8
+ components, *who gets it?*
9
+
10
+ The answer has two halves. **Focus** decides which component is the
11
+ current target. **Dispatch** decides the order in which components are
12
+ offered a key — because focus is the common case, not the only case.
13
+
14
+ ## The focus chain
15
+
16
+ At any moment, at most one component is **focused**. You set it directly:
17
+
18
+ ```ruby
19
+ screen.focused = some_component # or: some_component.focus
20
+ screen.focused = nil # nothing focused
21
+ ```
22
+
23
+ Focus is not just a flag on one component, though — it's a *chain*.
24
+ Setting focus walks from the target up through its parents to the root,
25
+ and marks every component on that path **active**. Everything not on the
26
+ path is deactivated. So if you focus a text field inside a window inside
27
+ the content area, the field, the window, and the content are all active;
28
+ their siblings are not.
29
+
30
+ That active chain is what components paint against. A {Tuile::Component::Window}
31
+ draws its border in the accent color when it's active and a plain color
32
+ when it isn't — which is why, in a multi-window layout, exactly the
33
+ window containing the focused widget lights up. "Active" means "on the
34
+ path to what has focus," and it falls out of one assignment.
35
+
36
+ ## Two gates: `focusable?` and `tab_stop?`
37
+
38
+ Not everything can be focused, and not everything focusable participates
39
+ in every way of *getting* focused. Two predicates draw those lines, and
40
+ they're independent on purpose.
41
+
42
+ {Tuile::Component#focusable?} gates whether a component can become a focus
43
+ target *at all*. It's `false` by default — a {Tuile::Component::Label} is
44
+ decoration; clicking one shouldn't yank focus away from the window around
45
+ it. Controls that accept input (a text field, a list, a button) override
46
+ it to `true`. This gate is what makes click-to-focus sane: clicking lands
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
49
+ the automatic focus-forwarding a container does when it's focused — a
50
+ window handed focus passes it down to its content, but only if that
51
+ content is focusable.
52
+
53
+ {Tuile::Component#tab_stop?} gates something narrower: whether Tab and
54
+ Shift+Tab land on this component while cycling. It's also `false` by
55
+ default, and it *implies* focusable — a tab stop is always a valid focus
56
+ target, but not every focus target is a tab stop. The distinction matters
57
+ for containers: a {Tuile::Component::Window} is focusable (so a click on
58
+ its chrome, or a `key_shortcut`, can focus it) but is *not* a tab stop
59
+ (Tab should skip the frame and stop on the actual inputs inside it). So:
60
+
61
+ - **Label** — neither. Decoration.
62
+ - **Window, Popup** — focusable, not a tab stop. Clickable chrome,
63
+ skipped by Tab.
64
+ - **TextField, List, Button** — both. Real inputs you can click *and*
65
+ Tab to.
66
+
67
+ Tab cycling is confined to a **modal scope**: the topmost modal popup if
68
+ one is open, otherwise the tiled content. Tab collects the tab stops in
69
+ that scope in tree order and advances by one, wrapping around; Shift+Tab
70
+ walks backward. This is what keeps Tab from escaping an open dialog — the
71
+ scope is the dialog, so cycling stays inside it.
72
+
73
+ ## The dispatch order
74
+
75
+ When a key arrives, it's offered to the tree in a fixed order, and the
76
+ first handler to claim it wins. Understanding this order is the whole
77
+ game, because a key you expected one component to get can be intercepted
78
+ earlier.
79
+
80
+ **1. Tab and Shift+Tab — focus navigation, first, always.** These are
81
+ intercepted before anything else and drive the cycling described above.
82
+ They're taken off the top deliberately: a focused text field swallows
83
+ almost every printable key (step 3 explains how), and if Tab weren't
84
+ reserved here, a field would trap it too and you could never Tab out.
85
+
86
+ **2. Global shortcuts.** App-level shortcuts registered with
87
+ {Tuile::Screen#register_global_shortcut} fire next, before any component
88
+ sees the key. These are for app-wide actions — "Ctrl+L opens the log,"
89
+ "F1 shows help" — that should work regardless of what's focused:
90
+
91
+ ```ruby
92
+ screen.register_global_shortcut(Tuile::Keys::CTRL_L,
93
+ hint: "^L #{screen.theme.hint("log")}") do
94
+ log_popup.open
95
+ end
96
+ ```
97
+
98
+ Only *unprintable* keys are allowed here (control keys, function keys,
99
+ escape sequences). Printable keys are rejected at registration time,
100
+ because a global binding on `a` would hijack someone typing `a` into a
101
+ text field — that's what the per-component `key_shortcut` in step 3 is
102
+ for. A shortcut can opt to fire even while a modal popup is open
103
+ (`over_popups: true`); by default it's suppressed while a popup is up, so
104
+ the popup stays modal.
105
+
106
+ **3. A `key_shortcut` anywhere in the focused scope.** Every component can
107
+ carry a {Tuile::Component#key_shortcut} — a single key that, when pressed,
108
+ *jumps focus to that component*. The screen searches the current modal
109
+ scope's subtree for a component whose shortcut matches; if it finds one,
110
+ it focuses it and consumes the key. A window advertises its shortcut in
111
+ its caption (`[f]-Files`), so a whole pane can be reachable with one key.
112
+
113
+ But this search is **suppressed while a text widget is mid-edit** — and
114
+ that suppression is the subtle, important part, so it gets its own
115
+ section below.
116
+
117
+ **4. `handle_key` on the focus chain.** Finally, if nothing above claimed
118
+ the key, it's delivered to the focused component's
119
+ {Tuile::Component#handle_key}, and if that returns `false` (didn't handle
120
+ it), it bubbles up the ancestor chain — the focused component, then its
121
+ parent, then *its* parent — until someone returns `true` or the scope
122
+ root is reached. This is how a list handles arrow keys itself but lets an
123
+ unhandled key rise to the window around it.
124
+
125
+ A component only ever receives a key when it's on the focus chain, so
126
+ `handle_key` implementations act on the key alone — they never need to
127
+ check their own `active?` state. And if focus is `nil`, or sits outside
128
+ the current modal scope, delivery reaches no one: that's precisely what
129
+ makes an open modal popup modal.
130
+
131
+ ## Why a text field can just type
132
+
133
+ Here's the problem the cursor-ownership rule solves. Suppose a window has
134
+ a search field, and elsewhere in the same window a list has `key_shortcut
135
+ = "d"` for "delete." You click into the field and type "add item." Should
136
+ the "d" in "add" trigger delete?
137
+
138
+ Obviously not — and step 3 is where it would go wrong, because "d" *is* a
139
+ registered `key_shortcut` in the scope. The rule that saves you: the
140
+ `key_shortcut` search in step 3 is skipped whenever a component **owns
141
+ the hardware cursor**.
142
+
143
+ A component signals cursor ownership through
144
+ {Tuile::Component#cursor_position} — return a `Point` and the terminal
145
+ cursor is shown there; return `nil` (the default) and there's no cursor.
146
+ A {Tuile::Component::TextField} being edited returns its caret position,
147
+ which is non-`nil`, so the screen knows a text widget is mid-edit and
148
+ suppresses shortcut capture. The "d" flows straight through step 3 to
149
+ step 4, where the focused field's `handle_key` inserts it. Tab (step 1)
150
+ still works, because it's reserved above all this — so you can always Tab
151
+ out of the field, at which point the cursor goes away and shortcuts light
152
+ back up.
153
+
154
+ That's the entire mechanism: printable keys belong to whoever owns the
155
+ cursor, and owning the cursor mutes the sibling shortcuts that would
156
+ otherwise pick them off.
157
+
158
+ ## The status bar writes itself
159
+
160
+ You've seen the bottom row showing hints like `q quit` since chapter 1.
161
+ It's driven by focus. Whenever focus changes, the screen rebuilds the
162
+ status bar from two sources: the currently-relevant shortcuts, and the
163
+ focused context's own advertised hint.
164
+
165
+ A component advertises its hint by overriding
166
+ {Tuile::Component#keyboard_hint} to return a preformatted string
167
+ (components build these with `theme.hint(...)` so the styling matches).
168
+ The screen composes the bar differently depending on what's in front:
169
+
170
+ - **Tiled (no popup):** `q quit`, then any global-shortcut hints, then the
171
+ active window's `keyboard_hint`.
172
+ - **Popup open:** the over-popups global hints, then the popup's own hint
173
+ (a popup owns its `q Close` prefix).
174
+
175
+ You don't assemble the bar yourself; you override `keyboard_hint` on the
176
+ components that have shortcuts worth advertising, register global
177
+ shortcuts with a `hint:`, and the composition happens on every focus
178
+ change. The status bar is a *view* of the focus state, not a thing you
179
+ maintain.
180
+
181
+ ---
182
+
183
+ Focus and dispatch are the last piece of the runtime. You now have the
184
+ whole loop: build a tree (chapter 1), it repaints without flicker
185
+ (chapter 2), sized top-down (chapter 3), driven by a single-threaded
186
+ event loop (chapter 4), with keys routed through focus (this chapter).
187
+ Chapter 6 adds the last cross-cutting concern — color — and then chapters
188
+ 7 and 8 turn from *how the framework works* to *what you build with it*.
@@ -0,0 +1,250 @@
1
+ # 6. Theming
2
+
3
+ Every chapter so far has been about *structure* — the tree, the repaint,
4
+ the loop, focus. This one is about a single presentational concern that
5
+ cuts across all of them: color. It's the last cross-cutting piece of the
6
+ runtime, and it's small, because Tuile takes a deliberately narrow view
7
+ of what a "theme" is. A theme in Tuile is not a stylesheet. It does not
8
+ describe how your app looks. It describes the handful of *accents* the
9
+ framework itself paints — and nothing else.
10
+
11
+ That restraint is the whole design. Understand why the theme is small
12
+ and you understand theming.
13
+
14
+ ## The theme colors only the accents
15
+
16
+ Look at any Tuile screen and most of what you see is the terminal's own
17
+ colors: the default foreground text on the default background. A label's
18
+ text, a window's interior, the body of a list — none of that is themed.
19
+ It inherits whatever the user's terminal is set to, which already matches
20
+ their preferences perfectly. Tuile writes no background fill and no
21
+ foreground color for those cells, so they come out in the terminal's
22
+ defaults for free.
23
+
24
+ What Tuile *does* color is the small set of cues that signal
25
+ interaction: the highlight behind the focused list row, the border of the
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
28
+ a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
29
+ `input_bg_color`, `hint_color`. There is no global `bg` or `fg` token,
30
+ and that absence is intentional: adding one would mean painting over the
31
+ terminal's defaults everywhere, which is precisely the thing that makes a
32
+ TUI look wrong on someone else's color scheme. The theme touches only
33
+ what the framework must color to be legible, and leaves the rest to the
34
+ terminal.
35
+
36
+ So a {Tuile::Theme} is a frozen value type — a `Data.define` of four
37
+ colors plus an app-extensible `custom` hash — and that's all. Two are
38
+ built in: {Tuile::Theme::DARK}, the colors Tuile has always used, and
39
+ {Tuile::Theme::LIGHT}, counterparts legible on a pale background.
40
+
41
+ ## Read at paint time, never cached
42
+
43
+ There is one rule about *using* the theme that everything else depends
44
+ on, and it's stated as an invariant in AGENTS.md because breaking it
45
+ breaks live theme switching: **a component reads `screen.theme` at paint
46
+ time, inside `repaint`, and never stores a theme color in an ivar.**
47
+
48
+ The reason is the repaint model from chapter 2. When the theme changes,
49
+ Tuile does not hunt down every component and tell it which colors to
50
+ update. It does the crude, correct thing: it invalidates the entire tree
51
+ and lets the normal repaint redraw everything. On that repaint each
52
+ component asks `screen.theme` for its accents afresh — so it simply comes
53
+ out in the new colors, with no per-component update logic anywhere. A
54
+ component that cached `theme.active_bg_color` in its constructor would
55
+ keep painting the old color after a switch, an island of stale palette in
56
+ an otherwise-restyled screen. Read it every time; the lookup is a hash
57
+ access, and the repaint that follows a theme change was going to redraw
58
+ you regardless.
59
+
60
+ This is why the built-in components need no theme-change handling at all.
61
+ They read at paint time, the tree is invalidated, they repaint in the new
62
+ colors. Done.
63
+
64
+ ## Two ways to apply a token
65
+
66
+ When a component has a themed color in hand, it applies it in one of two
67
+ ways, and which one depends on the text.
68
+
69
+ For plain chrome — a border string, a status-bar hint — the theme's
70
+ **rendering helpers** wrap the text in the token's SGR color and a reset:
71
+ `theme.active_bg("[ Ok ]")`, `theme.hint("quit")`. The helper picks the
72
+ right channel for the token's role (a `*_bg` token wraps as a background,
73
+ a hint as a foreground) and passes the content through verbatim, so the
74
+ string may already contain other escape sequences — which is how
75
+ {Tuile::Component::Window} feeds its whole border line, cursor moves and
76
+ all, through `active_border`.
77
+
78
+ But chrome text is flat. Content is not. A list row or a label may be a
79
+ {Tuile::StyledString} with its own per-span colors, and wrapping that in
80
+ one blunt SGR color would flatten every span to a single hue. For those,
81
+ the theme exposes the raw color as a `*_color` reader
82
+ (`theme.active_bg_color`) and you hand it to the StyledString, which
83
+ composites it *underneath* the existing spans — {Tuile::Component::List}
84
+ highlights its cursor row with `base.with_bg(theme.active_bg_color)`,
85
+ preserving whatever foreground colors the row already carried. The rule
86
+ of thumb: **plain chrome text → helper; structured text → `*_color`
87
+ reader plus StyledString.**
88
+
89
+ ## Following the terminal, automatically
90
+
91
+ An app never has to ask which theme to use. Tuile picks one by detecting
92
+ whether the terminal background is light or dark, through
93
+ {Tuile::TerminalBackground}.detect — two mechanisms in order of
94
+ reliability. First an **OSC 11 query**: Tuile writes an escape sequence
95
+ asking the terminal for its background color, and a modern terminal
96
+ replies with the RGB, whose luminance decides light versus dark.
97
+ Terminals that don't understand the query simply never answer, so the
98
+ read is bounded by a short timeout and falls through to the second
99
+ mechanism, the `COLORFGBG` environment variable that a few terminals
100
+ export. If both are inconclusive, Tuile assumes dark.
101
+
102
+ The timing of that detection is subtle enough to be a design constraint.
103
+ The OSC 11 *reply arrives on stdin* — the same stream the key thread will
104
+ own once the event loop is running. If detection ran after the loop
105
+ started, those reply bytes would land in the key thread and be consumed
106
+ as a garbage keystroke. So detection must happen *before* stdin is
107
+ claimed, which is why {Tuile::Screen} runs it in its constructor, seeding
108
+ `theme` before your app has built a single component. This is not an
109
+ implementation detail you can relocate — it's why the constructor, not
110
+ some later `setup` call, is where the scheme is decided.
111
+
112
+ Detection at startup handles the common case. But a user can also flip
113
+ their OS between light and dark *while your app is running*, and Tuile
114
+ follows that too, on terminals that support **mode 2031**. The event loop
115
+ enables the mode on startup; the terminal then pushes a small report
116
+ whenever the OS appearance changes; the key thread recognizes that report
117
+ (it's a private-mode CSI sequence, longer than an ordinary key, so it's
118
+ drained specially) and turns it into an {Tuile::EventQueue::ColorSchemeEvent};
119
+ and the loop, receiving that event like any other, re-picks the matching
120
+ theme. From your code's perspective a live appearance flip and a startup
121
+ detection are the same thing arriving through the same channel — which is
122
+ exactly the single-threaded-loop payoff chapter 4 promised.
123
+
124
+ ## Theming an app durably
125
+
126
+ Detection picks between *Tuile's* two themes. To give your app its own
127
+ colors, you supply your own — and here the distinction between a
128
+ transient override and a durable definition matters, because it's easy to
129
+ reach for the wrong one.
130
+
131
+ You *can* assign `screen.theme = ...` directly, and it works: the whole
132
+ UI restyles immediately. But it's a **transient override**. The next time
133
+ the OS appearance flips, Tuile re-picks from its theme *definition* and
134
+ replaces whatever you set. A bare `theme=` is the right tool for a
135
+ one-shot experiment, not for how your app looks.
136
+
137
+ The durable tool is a {Tuile::ThemeDef} — a pair of themes, one for dark
138
+ backgrounds and one for light — assigned once to `screen.theme_def=`.
139
+ Now Tuile picks the matching member at startup *and* re-picks from your
140
+ pair on every appearance flip, so your app stays your app's colors
141
+ through a light/dark toggle. That's the durable path: define the pair,
142
+ assign it once, forget about it.
143
+
144
+ The pair is required to be a *pair* for a reason. A {Tuile::ThemeDef}
145
+ enforces at construction time that its dark and light members declare the
146
+ same set of custom tokens. Without that check, a token you defined only
147
+ on the dark side would raise `KeyError` the moment the user flipped to
148
+ light — at an unpredictable time, far from the mistake. Checking up front
149
+ turns a lurking runtime crash into an immediate, obvious construction
150
+ error.
151
+
152
+ ### Custom tokens
153
+
154
+ Your app almost certainly paints colors the framework knows nothing
155
+ about — a "tool call" cyan, an "error" red, diff-line backgrounds. Those
156
+ live in the theme's `custom` hash, a `Hash{Symbol => Color}`. You read
157
+ one with `theme[:accent]`, which **fail-fasts**: a typo'd token raises
158
+ `KeyError` rather than silently painting a default, so a missing color is
159
+ a loud bug and not a mystery. And you render with the generic `fg` / `bg`
160
+ helpers — `theme.fg(:accent, "NEW")` — the custom-token counterparts of
161
+ the built-in `hint` / `active_bg` helpers.
162
+
163
+ For an app with more than a couple of custom tokens, the tidier move is
164
+ to **subclass** {Tuile::Theme} and give each token a named coloring
165
+ method:
166
+
167
+ ```ruby
168
+ class AppTheme < Tuile::Theme
169
+ def error(text) = fg(:error, text)
170
+ def tool(text) = fg(:tool, text)
171
+ end
172
+ ```
173
+
174
+ Call sites then read `theme.error("...")` instead of the stringly-typed
175
+ `theme.fg(:error, "...")`, and because a `Data` subclass survives `with`,
176
+ your `AppTheme` stays an `AppTheme` through any `with` derivation. You
177
+ build the dark and light instances from the built-in themes plus your
178
+ custom hashes and pair them in a `ThemeDef` — the pattern is small enough
179
+ to state in full:
180
+
181
+ ```ruby
182
+ class AppTheme < Tuile::Theme
183
+ def error(text) = fg(:error, text)
184
+
185
+ DARK = new(**Tuile::Theme::DARK.to_h.merge(custom: { error: Tuile::Color::RED }))
186
+ LIGHT = new(**Tuile::Theme::LIGHT.to_h.merge(custom: { error: Tuile::Color::RED3 }))
187
+ THEME_DEF = Tuile::ThemeDef.new(dark: DARK, light: LIGHT)
188
+ end
189
+
190
+ screen.theme_def = AppTheme::THEME_DEF # once, at boot
191
+ ```
192
+
193
+ Note the light-side error color isn't the same red — bright ANSI accents
194
+ that pop on black often turn illegible on white, so a real light theme
195
+ steps its accents darker. That per-side tuning is the entire point of
196
+ carrying two themes instead of one.
197
+
198
+ One aside on declaring theme colors: a {Tuile::Theme} takes {Tuile::Color}
199
+ instances *only*, never the lenient coercions (`"red"`, a bare palette
200
+ integer) that {Tuile::Color}.coerce accepts elsewhere. A theme is
201
+ declared once per app, so the extra verbosity buys self-documentation —
202
+ `Color.palette(130)` says "palette index," and the named constant
203
+ `Color::DARK_ORANGE3` says even more, where a bare `130` at the
204
+ declaration site says nothing.
205
+
206
+ ## When the theme changes under your content
207
+
208
+ The built-in components restyle for free because they read the theme at
209
+ paint time. Your *content* can't always do that — and this is the one
210
+ theming responsibility that lands on the app.
211
+
212
+ The problem: when you build a {Tuile::StyledString} for a
213
+ {Tuile::Component::Label} or a {Tuile::Component::List} row, its colors
214
+ are **baked in at construction**. The string is a frozen value with its
215
+ SGR bytes already computed; it does not consult the theme at paint time,
216
+ because {Tuile::StyledString} deliberately knows nothing about `Screen` at
217
+ all (that independence is what lets it be a pure, memoizable value type).
218
+ So if you colored a label with `theme[:accent]` and the theme later
219
+ changes, that label keeps its old accent — the framework can't fix it,
220
+ because only *you* know which of the string's colors came from the theme
221
+ versus which are inherent to the data (a log line's level color, say,
222
+ should *not* follow the theme).
223
+
224
+ The hook for this is {Tuile::Component#on_theme_changed}, fired on every
225
+ attached component whenever the theme changes. Your handler does exactly
226
+ one thing: **re-run the code that rendered the content**, so it rebuilds
227
+ the StyledString against the now-current theme.
228
+
229
+ ```ruby
230
+ label.on_theme_changed = -> { label.text = render_status_line }
231
+ ```
232
+
233
+ There are two ways to consume it, matching how you built the component.
234
+ If you assembled stock components, assign the `on_theme_changed=` proc as
235
+ above. If you subclassed, override the method — and call `super`, so an
236
+ assigned listener still fires. Either way the rule is the same: the hook
237
+ is where theme-derived content gets rebuilt, and the framework handles
238
+ everything else.
239
+
240
+ ---
241
+
242
+ That closes the runtime. Across six chapters you've seen the whole
243
+ machine: a tree of components (chapter 1), repainting without flicker
244
+ (chapter 2), sized top-down by their parents (chapter 3), driven by a
245
+ single-threaded event loop (chapter 4), with keys routed through focus
246
+ (chapter 5) and accents drawn from a terminal-following theme (this one).
247
+ Everything from here is *application*: chapter 7 tours the component
248
+ library — what Tuile ships so you don't build it — and chapter 8 shows
249
+ how to test a UI built this way, using the fakes the design has been
250
+ quietly setting up all along.
@@ -0,0 +1,231 @@
1
+ # 7. The component library
2
+
3
+ The last six chapters were about the machine: the tree, the repaint, the
4
+ loop, focus, color. This one turns the other way and asks what you
5
+ actually assemble on top of it. Tuile ships a small toolbox of ready
6
+ components, and the point of this chapter is not to enumerate their
7
+ methods — the rdoc does that, per symbol, and links are scattered
8
+ throughout below — but to answer the question you have when you start a
9
+ screen: *given what I'm trying to show or capture, which component do I
10
+ reach for?*
11
+
12
+ So this is a tour organized by the job, not by the class name. And
13
+ because every one of these is a {Tuile::Component}, everything you already
14
+ know still holds: its parent sizes it top-down (chapter 3), it invalidates
15
+ rather than paints (chapter 2), focus decides whether it sees a key
16
+ (chapter 5), and it draws its accents from the theme at paint time
17
+ (chapter 6). The components don't reintroduce any of that; they just fill
18
+ in the leaves of the tree.
19
+
20
+ ## Showing text
21
+
22
+ The simplest job is putting text on the screen, and the choice comes down
23
+ to one question: **does it need to wrap?**
24
+
25
+ If not — a title, a status line, a single field of data — reach for
26
+ {Tuile::Component::Label}. A label shows one or more lines of text and
27
+ does *not* word-wrap; a line too wide for its rect is truncated with an
28
+ ellipsis. That's a feature, not a limitation: a label is chrome, and
29
+ chrome that reflows unpredictably when the terminal narrows is worse than
30
+ chrome that clips. Its text is a {Tuile::StyledString}, so embedded color
31
+ survives, and you can hand it either a plain `String` (parsed for ANSI) or
32
+ a StyledString directly:
33
+
34
+ ```ruby
35
+ label = Component::Label.new("Ready")
36
+ label.text = "#{files.size} files"
37
+ ```
38
+
39
+ When the text *is* prose — a help screen, a rendered Markdown reply, a log
40
+ of wrapped lines — reach for {Tuile::Component::TextView} instead. It's
41
+ the read-only counterpart to a label: string-shaped content in, but
42
+ word-wrapped to its width (preserving spans across the wrap, so color
43
+ isn't lost on continuation rows) and vertically scrollable, with the
44
+ scroll keys and optional scrollbar you'd expect. For a growing view it
45
+ gives you the right primitives for the right shape of update — `append`
46
+ (aliased `<<`) concatenates verbatim for streaming, `add_line` starts a
47
+ fresh line like a log entry, and `remove_last_n_lines` retracts the tail
48
+ when you're rebuilding reformattable content. Turn on `auto_scroll` to
49
+ keep the latest content in view. A TextView is meant to live inside a
50
+ {Tuile::Component::Window} — it leans on the surrounding chrome for focus
51
+ indication and keyboard hints.
52
+
53
+ So: **Label truncates, TextView wraps.** That single line is the whole
54
+ decision.
55
+
56
+ ## Editing text
57
+
58
+ When you need input back from the user, the two editable components share
59
+ a base — {Tuile::Component::TextInput} — and differ only in shape.
60
+
61
+ {Tuile::Component::TextField} is a single line with a real hardware caret.
62
+ It does not scroll; a keystroke that would push the text past the field's
63
+ width is simply rejected, so the field always shows its whole contents.
64
+ Because it owns the hardware cursor while focused, it's the component that
65
+ triggers the cursor-ownership rule from chapter 5 — printable keys flow
66
+ straight to it and sibling shortcuts stay muted while you type.
67
+
68
+ {Tuile::Component::TextArea} is the multi-line counterpart: a word-wrapping
69
+ editor that scrolls vertically to keep the caret's line visible, with
70
+ Enter inserting a newline as in any text editor. Like everything else,
71
+ it's sized by its parent — it does not grow to fit its content; text that
72
+ overflows the rect is reached by scrolling.
73
+
74
+ Both inherit the same event hooks from the base, and this is where the
75
+ design pays off: you customize an input by assigning callbacks, not by
76
+ subclassing. `on_change` fires whenever the text changes; `on_escape`
77
+ handles ESC (with a sensible default). The subtle one is `on_key` — an
78
+ interceptor consulted *before* the input's own key handling, which is the
79
+ building block for an autocomplete or slash-command overlay: while the
80
+ overlay is open, `on_key` claims Up/Down/Enter/ESC and forwards them to
81
+ the list, so the caret stays in the field and typing keeps refilling the
82
+ suggestions.
83
+
84
+ ```ruby
85
+ field = Component::TextField.new
86
+ field.on_change = ->(text) { filter_results(text) }
87
+ field.on_enter = -> { submit } # nil (default) → Enter bubbles to the parent
88
+ ```
89
+
90
+ Note that `on_enter` / `on_key_up` / `on_key_down` on a TextField, when
91
+ left `nil`, let those keys *fall through* to the parent — that's how Enter
92
+ in a search field can trigger the surrounding window's action while the
93
+ field still handles ordinary typing.
94
+
95
+ ## Choosing from a set
96
+
97
+ {Tuile::Component::List} is the workhorse: a scrollable column of
98
+ {Tuile::StyledString} lines, ellipsized (spans preserved) when too wide.
99
+ What makes it flexible is that its *cursor behavior is a pluggable object*
100
+ rather than a boolean. Assign one of three {Tuile::Component::List::Cursor}
101
+ variants to fit the interaction:
102
+
103
+ - **`Cursor::None`** (the default) — no cursor at all. The list is a
104
+ read-only scroll region: a log, a static report.
105
+ - **`Cursor`** — a moving cursor that lands on any line. Arrows, `jk`,
106
+ Home/End, and Ctrl+U/D move it, and the list scrolls to follow. This is
107
+ the ordinary selectable list.
108
+ - **`Cursor::Limited`** — a cursor confined to a fixed set of allowed
109
+ lines. For a list where only some rows are selectable (headers
110
+ interspersed with items, say), it skips the rest.
111
+
112
+ Two callbacks cover the events you care about. `on_item_chosen` fires when
113
+ the user commits to the cursor's row — Enter or a left-click — and is the
114
+ "open this" signal. `on_cursor_changed` fires when the highlighted row
115
+ *changes*, which is exactly what you wire to keep a details pane in sync
116
+ with the selection. For a tailing list — a live log — set `auto_scroll`;
117
+ it pins to the bottom as lines arrive, but politely stops yanking you down
118
+ the moment you scroll up to read history, and resumes once you scroll back
119
+ (`following?` tells you which). A scrollbar is one assignment
120
+ (`scrollbar_visibility`).
121
+
122
+ ```ruby
123
+ list = Component::List.new
124
+ list.lines = entries
125
+ list.cursor = Component::List::Cursor.new
126
+ list.on_item_chosen = ->(index, line) { open(entries[index]) }
127
+ ```
128
+
129
+ For a discrete action rather than a selection, {Tuile::Component::Button}
130
+ is a one-row `[ caption ]` that fires `on_click` on Enter, Space, or a
131
+ left-click, highlighting its background while focused. It's a tab stop, so
132
+ it joins the normal Tab cycle.
133
+
134
+ ## Framing content
135
+
136
+ {Tuile::Component::Window} is the frame: a bordered box with a `caption`
137
+ and a single content slot you fill via `content=`. Its border lights up in
138
+ the theme's accent color when the window is on the focus chain (chapter 5
139
+ + 6), which is what makes the active pane visually obvious in a multi-pane
140
+ layout. A window paints its whole rect and does not clip against
141
+ neighbors, so windows are meant to tile, not overlap — overlapping is what
142
+ popups are for.
143
+
144
+ The bottom border has two mutually exclusive uses, and the distinction is
145
+ the top-down-layout principle from chapter 3 made concrete. `footer_text=`
146
+ embeds decoration into the border line — chrome, mirroring the caption on
147
+ top, not focusable. `footer=` mounts a *real focusable component* spanning
148
+ the full inner width — the search-field-in-the-border case. A footer
149
+ component present takes the row and hides the text; neither drives the
150
+ window's size (the window was sized by its parent), so a footer that
151
+ doesn't fit is clipped rather than growing the frame. And if the content
152
+ supports scrolling, `scrollbar=` turns on a scrollbar by reclaiming the
153
+ right border column.
154
+
155
+ ```ruby
156
+ window = Component::Window.new("Files")
157
+ window.content = Component::List.new.tap { _1.lines = entries }
158
+ window.scrollbar = true
159
+ ```
160
+
161
+ ## Overlays
162
+
163
+ {Tuile::Component::Popup} is how you float something above the tiled UI.
164
+ The popup itself paints nothing — it's a transparent host that wraps any
165
+ component as its content and manages the lifecycle (`open` / `close`,
166
+ ESC/`q` to dismiss). Crucially, and per chapter 3, **it does not size
167
+ itself to its content**: its box is declared by `size` — a `Fraction`
168
+ (default `Fraction::HALF`, half the screen, re-resolved on every resize)
169
+ or an absolute `Size`. The content then fills that box, so use content
170
+ that can cope with overflow — a TextView or TextArea that scrolls, not a
171
+ bare Label that only truncates.
172
+
173
+ A popup is **modal by default**: centered, it grabs focus, eats keys, and
174
+ blocks clicks beneath it — that's what makes an open dialog trap Tab and
175
+ input inside itself. Pass `modal: false` for a non-modal overlay that
176
+ floats above the content without taking focus — the autocomplete-list case
177
+ from earlier, where the caller positions it against a field's caret and
178
+ drives it from app code.
179
+
180
+ Because the popup is just a transparent host, you get a bordered dialog by
181
+ wrapping a Window:
182
+
183
+ ```ruby
184
+ window = Component::Window.new("Help")
185
+ window.content = Component::TextView.new.tap { _1.text = help_text }
186
+ Component::Popup.new(content: window).open
187
+ ```
188
+
189
+ A nested TextField that owns the cursor still swallows printable keys
190
+ first, so typing `q` into a field inside a popup doesn't dismiss it — the
191
+ same cursor-ownership rule, working through the layers.
192
+
193
+ ## Batteries-included windows
194
+
195
+ The last three components are conveniences: common Window-plus-content
196
+ assemblies you'd otherwise build by hand. Each works tiled (add it to a
197
+ layout) *or* as a popup (via a class-level `open`).
198
+
199
+ - {Tuile::Component::InfoWindow} — a Window preloaded with a List of
200
+ static lines. The read-only "here's some information" box;
201
+ `InfoWindow.open(caption, lines)` pops it up.
202
+ - {Tuile::Component::PickerWindow} — a menu of options each bound to a
203
+ single key, firing your block with the picked key. Popped up via `open`,
204
+ it closes itself after a pick; ESC/`q` cancels without firing.
205
+ - {Tuile::Component::LogWindow} — a Window wrapping an auto-scrolling,
206
+ scrollbar-equipped TextView, purpose-built for log output. Its `log`
207
+ method is **thread-safe** — it marshals the append back onto the UI
208
+ thread via the event queue (chapter 4), so background work can log
209
+ freely. And it carries an `IO`-shaped adapter so you can point a stdlib
210
+ `Logger` (or a `TTY::Logger`) straight at it:
211
+
212
+ ```ruby
213
+ window = Component::LogWindow.new
214
+ logger = Logger.new(Component::LogWindow::IO.new(window))
215
+ logger.info("started") # appears in the window, from any thread
216
+ ```
217
+
218
+ That LogWindow adapter is the tidy end of the thread-safety story chapter
219
+ 4 opened: a background thread doesn't know or care that its log line has
220
+ to reach the UI on the loop thread — it writes to a `Logger` as it always
221
+ would, and the plumbing routes it through `submit` for you.
222
+
223
+ ---
224
+
225
+ That's the toolbox. None of it is large, because the framework underneath
226
+ is small and the components inherit most of their behavior from it — which
227
+ is the recurring theme of this whole book. What remains is proving that a
228
+ UI built this way actually works, without a terminal in the loop. The
229
+ fakes the design has been quietly setting up since chapter 2 — the buffer
230
+ you can read back, the synchronous event queue, the in-memory screen — are
231
+ what make that possible, and chapter 8 puts them to work.