tuile 0.8.0 → 0.10.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +82 -48
  5. data/book/01-first-app.md +186 -0
  6. data/book/02-repaint.md +177 -0
  7. data/book/03-layout.md +379 -0
  8. data/book/04-event-loop.md +295 -0
  9. data/book/05-focus.md +219 -0
  10. data/book/06-theming.md +302 -0
  11. data/book/07-components.md +585 -0
  12. data/book/08-testing.md +199 -0
  13. data/book/09-styled-text.md +132 -0
  14. data/book/README.md +85 -0
  15. data/examples/hello_world.rb +1 -2
  16. data/examples/sampler.rb +435 -20
  17. data/ideas/new-components.md +109 -0
  18. data/ideas/per-component-buffers.md +55 -0
  19. data/lib/tuile/buffer.rb +113 -43
  20. data/lib/tuile/color.rb +4 -10
  21. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  22. data/lib/tuile/component/button.rb +25 -29
  23. data/lib/tuile/component/checkbox.rb +133 -0
  24. data/lib/tuile/component/checkbox_group.rb +188 -0
  25. data/lib/tuile/component/combo_box.rb +281 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/info_window.rb +4 -2
  30. data/lib/tuile/component/integer_field.rb +135 -0
  31. data/lib/tuile/component/label.rb +20 -25
  32. data/lib/tuile/component/layout.rb +3 -26
  33. data/lib/tuile/component/list.rb +8 -33
  34. data/lib/tuile/component/list_dropdown.rb +106 -0
  35. data/lib/tuile/component/log_window.rb +0 -14
  36. data/lib/tuile/component/password_field.rb +105 -0
  37. data/lib/tuile/component/popup.rb +70 -79
  38. data/lib/tuile/component/progress_bar.rb +278 -0
  39. data/lib/tuile/component/radio_group.rb +188 -0
  40. data/lib/tuile/component/text_area.rb +189 -65
  41. data/lib/tuile/component/text_field.rb +170 -32
  42. data/lib/tuile/component/text_view.rb +57 -137
  43. data/lib/tuile/component/window.rb +88 -121
  44. data/lib/tuile/component.rb +246 -142
  45. data/lib/tuile/event_queue.rb +39 -21
  46. data/lib/tuile/fake_event_queue.rb +32 -7
  47. data/lib/tuile/fake_screen.rb +4 -5
  48. data/lib/tuile/fraction.rb +42 -0
  49. data/lib/tuile/screen.rb +210 -109
  50. data/lib/tuile/screen_pane.rb +56 -44
  51. data/lib/tuile/styled_string.rb +112 -83
  52. data/lib/tuile/theme.rb +78 -41
  53. data/lib/tuile/version.rb +1 -1
  54. data/sig/tuile.rbs +2291 -890
  55. metadata +28 -9
  56. data/ideas/back-buffer.md +0 -217
  57. data/lib/tuile/sizing.rb +0 -59
@@ -0,0 +1,585 @@
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::AbstractStringField} — and differ only in shape.
60
+
61
+ {Tuile::Component::TextField} is a single line with a real hardware caret.
62
+ Its width bounds what you can *see*, not what it can hold: the text scrolls
63
+ horizontally, moving by the minimum needed to keep the caret in view. If you
64
+ want an actual limit, set `max_text_length` — a cap in characters, after
65
+ which typing quietly does nothing. Because it consumes every printable key
66
+ while focused (including the ones it ignores at the cap), it's also what
67
+ keeps a scope-wide key binding from firing while you type: as chapter 5
68
+ explains, an ancestor only hears the keys its descendants declined.
69
+
70
+ That cap counts *characters*, and the distinction matters more than it
71
+ looks. A field position is either an index into the text or a column on the
72
+ terminal, and the two coincide only while every glyph is one column wide —
73
+ a fullwidth CJK character is two columns, a combining mark zero. So `caret`
74
+ and `max_text_length` speak indices, while `rect`, `left_column` and a mouse
75
+ click speak columns, and the field converts between them rather than
76
+ assuming they're the same number. You don't need to think about this to use
77
+ a TextField; you do the moment you write a component that paints text.
78
+
79
+ There's a third unit hiding in there, and it's the one your *user* thinks
80
+ in: the glyph they see. A single visible character can be several characters
81
+ of storage — an `e` with a combining accent, a flag, an emoji family — and
82
+ editing a field one storage character at a time is how you get a Backspace
83
+ that strips the accent and leaves a bare `e`. So while `caret` counts
84
+ characters, it may only ever sit *between* glyphs, and the editing keys work
85
+ in glyphs too: one arrow press moves over one, one Backspace deletes one,
86
+ however many characters that turns out to be. Assign a caret into the middle
87
+ of a glyph and the field quietly moves it to that glyph's far edge — where
88
+ it was already being drawn anyway.
89
+
90
+ {Tuile::Component::TextArea} is the multi-line counterpart: a word-wrapping
91
+ editor that scrolls vertically to keep the caret's line visible, with
92
+ Enter inserting a newline as in any text editor. Like everything else,
93
+ it's sized by its parent — it does not grow to fit its content; text that
94
+ overflows the rect is reached by scrolling.
95
+
96
+ {Tuile::Component::PasswordField} is a text field that paints a mask —
97
+ one `*` per character — instead of its text. Everything else is the text
98
+ field's, unchanged: you edit it, click into it, and scroll it exactly the
99
+ same way, and `value` hands back the plaintext whenever you ask. Setting
100
+ `revealed = true` shows the real text; there's no in-field reveal button,
101
+ because a terminal row has nowhere to put one, so apps wire that to a
102
+ "show password" checkbox or a key of their own.
103
+
104
+ Two of its details are worth knowing, because both come straight from the
105
+ index-versus-column distinction above. The mask is one *single-column*
106
+ glyph per character, which is why the default is a plain `*` rather than a
107
+ prettier `•`: a bullet is one of those characters whose width depends on
108
+ how the terminal is configured, and a mask that's occasionally two columns
109
+ wide would put the caret in the wrong place. (You can still set
110
+ `mask_char` yourself if you know your terminal.) And because the mask
111
+ replaces each character with exactly one column, a masked CJK passphrase
112
+ takes *fewer* columns than the plaintext would — which is fine, and the
113
+ field's caret, scrolling and click handling all measure the mask rather
114
+ than the hidden text. The one thing it deliberately does *not* do is
115
+ protect the plaintext in memory: it's an ordinary Ruby string, and
116
+ anything stronger is a job for a type the whole application cooperates
117
+ with.
118
+
119
+ Both inherit the same event hooks from the base, and this is where the
120
+ design pays off: you customize an input by assigning callbacks, not by
121
+ subclassing. `on_change` fires whenever the text changes; `on_escape`
122
+ handles ESC (with a sensible default). The subtle one is `on_key` — an
123
+ interceptor consulted *before* the input's own key handling, which is the
124
+ building block for an autocomplete or slash-command overlay: while the
125
+ overlay is open, `on_key` claims Up/Down/Enter/ESC and forwards them to
126
+ the list, so the caret stays in the field and typing keeps refilling the
127
+ suggestions.
128
+
129
+ ```ruby
130
+ field = Component::TextField.new
131
+ field.on_change = ->(text) { filter_results(text) }
132
+ field.on_enter = -> { submit } # nil (default) → Enter bubbles to the parent
133
+ ```
134
+
135
+ Note that `on_enter` / `on_key_up` / `on_key_down` on a TextField, when
136
+ left `nil`, let those keys *fall through* to the parent — that's how Enter
137
+ in a search field can trigger the surrounding window's action while the
138
+ field still handles ordinary typing.
139
+
140
+ ## The value seam
141
+
142
+ Every input component — a text field today, a combo box or a date field
143
+ tomorrow — answers the same handful of questions, so Tuile gives them a
144
+ shared vocabulary: the {Tuile::Component::HasValue} mixin. Read or set
145
+ `value`, ask `empty?`, `clear` it, and subscribe to `on_value_change`.
146
+ Write code against that seam and it doesn't care which kind of input each
147
+ field is.
148
+
149
+ The idea worth internalizing is that **a component's `value` is of its own
150
+ natural type, not a string**. A text field's value *is* its text (a
151
+ `String` — `value` and `text` are two names for the one buffer, `text`
152
+ reading better while you're editing prose). But a combo box's value is the
153
+ *object you picked*, not the text shown for it — pick a `User` and you get
154
+ the `User` back, even when two users render to the same name. That typing
155
+ is free in Ruby: a "value" is just whatever you stored, there's no generic
156
+ to declare, so Tuile leans into it rather than making everything a string
157
+ you map back by hand.
158
+
159
+ A typed value can relate to the text on screen in two different ways, and
160
+ the input components show both. A combo box's value is kept *quite apart*
161
+ from the text — you type a query, but the value is the object you pick. An
162
+ {Tuile::Component::IntegerField}'s value is instead *derived from* the
163
+ text: it holds an `Integer` (or `nil`), parsed from the buffer on demand.
164
+ You may type only digits and at most one leading `-`; anything else is
165
+ quietly refused without so much as nudging the caret, and Up/Down step the
166
+ number by one (an empty field counting as zero). Read `value` and you
167
+ get an `Integer`, or `nil` when the buffer is blank or only half a number
168
+ (a lone `-`). It reports changes as you type, but only when the number
169
+ *itself* changes — padding `7` out to `07` moves the text without moving
170
+ the value, and stays silent.
171
+
172
+ ```ruby
173
+ qty = Component::IntegerField.new
174
+ qty.on_value_change = ->(n) { recompute(n) } # n is an Integer, or nil
175
+ qty.value = 3
176
+ ```
177
+
178
+ Both the combo box and the integer field are built the same way, and it's
179
+ worth seeing why: each *wraps* a text field rather than *being* one. A
180
+ subclass would inherit the text field's `String`-typed value and wear it
181
+ on its face right next to the real typed one — two conflicting answers to
182
+ "what's your value?". Composing sidesteps that: the wrapper holds a text
183
+ field privately, does its own filtering and parsing, and exposes only the
184
+ value that makes sense for it. (This is the "configure a generic component
185
+ to make a domain one" idea from the architecture the whole library is
186
+ built on.)
187
+
188
+ The password field is the same rule read the other way. Its value *is* its
189
+ text — same type, same vocabulary — so there's no second seam to collide
190
+ with, and it simply *is* a text field, subclassed to paint differently.
191
+ That's the test when you build your own input: if what you hand back
192
+ differs in type from what the user types, wrap a field; if it's the same
193
+ thing shown another way, extend one.
194
+
195
+ Turning a field's value into a domain model — parsing, validation, the
196
+ box-holds-a-`String` ⟷ bean-holds-an-`Integer` conversion — is
197
+ deliberately *not* the field's job; it belongs to a forms/binder layer
198
+ that will one day sit above these components. So the seam is kept thin on
199
+ purpose: `on_value_change` carries just the new value, and there's no
200
+ read-only or required flag yet. Room left for that layer to grow into.
201
+
202
+ ## Choosing from a set
203
+
204
+ {Tuile::Component::List} is the workhorse: a scrollable column of
205
+ {Tuile::StyledString} lines, ellipsized (spans preserved) when too wide.
206
+ What makes it flexible is that its *cursor behavior is a pluggable object*
207
+ rather than a boolean. Assign one of three {Tuile::Component::List::Cursor}
208
+ variants to fit the interaction:
209
+
210
+ - **`Cursor::None`** (the default) — no cursor at all. The list is a
211
+ read-only scroll region: a log, a static report.
212
+ - **`Cursor`** — a moving cursor that lands on any line. Arrows, `jk`,
213
+ Home/End, and Ctrl+U/D move it, and the list scrolls to follow. This is
214
+ the ordinary selectable list.
215
+ - **`Cursor::Limited`** — a cursor confined to a fixed set of allowed
216
+ lines. For a list where only some rows are selectable (headers
217
+ interspersed with items, say), it skips the rest.
218
+
219
+ Two callbacks cover the events you care about. `on_item_chosen` fires when
220
+ the user commits to the cursor's row — Enter or a left-click — and is the
221
+ "open this" signal. `on_cursor_changed` fires when the highlighted row
222
+ *changes*, which is exactly what you wire to keep a details pane in sync
223
+ with the selection. For a tailing list — a live log — set `auto_scroll`;
224
+ it pins to the bottom as lines arrive, but politely stops yanking you down
225
+ the moment you scroll up to read history, and resumes once you scroll back
226
+ (`following?` tells you which). A scrollbar is one assignment
227
+ (`scrollbar_visibility`).
228
+
229
+ ```ruby
230
+ list = Component::List.new
231
+ list.lines = entries
232
+ list.cursor = Component::List::Cursor.new
233
+ list.on_item_chosen = ->(index, line) { open(entries[index]) }
234
+ ```
235
+
236
+ When the set is long and the user roughly knows what they want, a plain
237
+ list makes them scroll for it. {Tuile::Component::ComboBox} is the answer:
238
+ a text field with a dropdown that filters as you type. Hand it `items` (of
239
+ any type) and, when their `to_s` isn't what you want shown, an
240
+ `item_label` strategy to render each one; type to narrow, arrow to move,
241
+ Enter or click to accept. It's the value seam doing real work — its
242
+ `value` is the selected *item*, the object and not its label, so a combo
243
+ over `User`s hands back a `User`. The field's text is merely a transient
244
+ query: it reverts to the selection's label when you dismiss the dropdown,
245
+ and only a real commit fires `on_value_change`. The dropdown itself is a
246
+ borderless popup tinted apart from the content beneath it (chapter 6's
247
+ background inheritance again), floating below the field or flipping above
248
+ when it's near the bottom of the screen.
249
+
250
+ ```ruby
251
+ combo = Component::ComboBox.new
252
+ combo.items = User.all
253
+ combo.item_label = ->(u) { u.full_name }
254
+ combo.on_value_change = ->(u) { show(u) }
255
+ ```
256
+
257
+ When the choice is simply yes-or-no, {Tuile::Component::Checkbox} is a
258
+ single row — `[x] Enable syslog forwarding` — that Space or a left-click
259
+ flips. Its `value` is the value seam again, at its simplest: always `true`
260
+ or `false`, never `nil`. Unchecked is the *empty* value, so a fresh
261
+ checkbox reports `empty?` and `clear` unchecks it. Because `value` reads a
262
+ little colorlessly in application code, the same state answers to
263
+ `checked?`, `checked=` and `toggle` — four names, one piece of state, and
264
+ one write path, so your `on_value_change` listener fires exactly once
265
+ however you flip it.
266
+
267
+ ```ruby
268
+ cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
269
+ cb.on_value_change = ->(on) { config.syslog = on }
270
+ cb.toggle # unchecks it, firing the listener with false
271
+ ```
272
+
273
+ Two of its choices are worth understanding, because they're really
274
+ statements about how Tuile widgets behave in general. The first: **Enter
275
+ does nothing.** A checkbox has no action to confirm — Space is the native
276
+ gesture for flipping one — so Enter is left unhandled, and by chapter 5's
277
+ rules it bubbles up to an ancestor. It's tempting to read that as a
278
+ guarantee, as though the framework kept Enter clear for a form's
279
+ submit button. It doesn't, and chapter 5's table shows why: a text area
280
+ claims Enter for a newline, a button claims it to activate itself. Whether
281
+ Enter reaches your form depends on the widget that has focus. A checkbox
282
+ declines it because it has nothing to do with it — which is a fact about
283
+ this widget, not a promise about all of them.
284
+
285
+ The second is about *where the widget actually is*. A form column will
286
+ happily hand a checkbox forty columns for a caption that needs twenty-two,
287
+ and the extra eighteen are blank. Both the focus highlight and the click
288
+ target stop at the end of the caption rather than filling the row — the
289
+ painted glyph is the affordance, so a click that visibly lands on nothing
290
+ must not toggle anything, and a full-width highlight band would read as a
291
+ selected *row*, which is the wrong signal for one field among ten.
292
+ (Clicking the blank tail still moves *focus* there; it's the field's row,
293
+ after all.) {Tuile::Component::Button} follows the identical rule, which is
294
+ why both expose that painted region as `extent`.
295
+
296
+ The glyphs are plain ASCII — `[x] ` and `[ ] `, three columns and a space
297
+ — rather than the prettier `☑`/`☐`. Not for the column-width reason you
298
+ might expect: those box characters genuinely measure one cell everywhere. They're simply missing from most monospace fonts, and missing
299
+ *asymmetrically* — `☐` is the worse-covered of the two, so the unchecked
300
+ state can degrade to tofu while the checked one renders, which reads as a
301
+ bug rather than a fallback.
302
+
303
+ When the user should pick *several* things from a handful,
304
+ {Tuile::Component::CheckboxGroup} stacks those rows into one widget: a
305
+ cursor moves with the arrows, Space toggles the row it sits on, and `value`
306
+ is the `Set` of items you selected.
307
+
308
+ ```ruby
309
+ levels = Component::CheckboxGroup.new(items: LogLevel.all)
310
+ levels.item_label = ->(l) { l.name }
311
+ levels.on_value_change = ->(set) { refilter(set) } # a Set of LogLevels
312
+ ```
313
+
314
+ Notice what `value` holds: the *items*, exactly as the combo box does — a
315
+ group over `LogLevel`s hands back `LogLevel`s, so filtering is
316
+ `selected.include?(level)` and never a lookup from a label back to the
317
+ thing it named. A `Set` rather than an array, because the selection has no
318
+ inherent order — which brings a wrinkle worth knowing up front. The set
319
+ iterates in the order things were *toggled*, so if you need the order the
320
+ rows are shown in, ask for it: `items & value.to_a`. Treat the set as
321
+ unordered and you'll never be surprised.
322
+
323
+ The set is also **frozen**. That's deliberate, and it's the one thing that
324
+ can bite you if you don't expect it: `group.value << item` raises rather
325
+ than quietly working. It has to, because a listener that fires on change
326
+ can only notice a change if the value is *replaced* rather than edited in
327
+ place — mutate the set you were handed and the group would have no way to
328
+ tell anyone. So assign a new selection instead (an array is fine, it's
329
+ coerced), and let the widget's own toggling build the new sets for you.
330
+
331
+ Here the cursor and the selection are genuinely two different things — the
332
+ cursor says *where you are*, the checkmarks say *what you picked* — and
333
+ that shape is exactly what a list already provides. So a checkbox group
334
+ doesn't paint rows itself; it holds a {Tuile::Component::List} and gets the
335
+ cursor, the scrolling, the scrollbar and the per-row mouse handling for
336
+ free, in the same "wrap a generic component to make a domain one" way the
337
+ combo box wraps a text field. That inheritance goes further than
338
+ convenience: a click anywhere on a row toggles it, and Enter toggles the
339
+ cursor's row, because those are the list's own gestures for choosing an
340
+ item.
341
+
342
+ Which is worth pausing on, because it looks like a contradiction of what
343
+ you just read about the standalone checkbox, where a click on the blank
344
+ space past the caption pointedly does *not* toggle. Both are right, and the
345
+ difference is what the user is aiming at. A lone checkbox in a form column
346
+ is a small painted thing surrounded by emptiness — the glyph is the
347
+ target. A row in a list is a *row*: it highlights across its full width, so
348
+ its full width is what you can click. The rule didn't bend; the thing being
349
+ clicked changed.
350
+
351
+ One thing the group deliberately does *not* do is reconcile `items` against
352
+ `value`. Replacing the items changes only what's on screen — the selection
353
+ is left exactly as it was, even if some of it is now invisible, and no
354
+ change event fires. It sounds careless until you picture a form: a user
355
+ ticks three boxes, some code refreshes the item list, and a selection
356
+ silently narrows itself. The user saves without touching anything and has
357
+ just changed data they never edited. Keeping `value` authoritative means
358
+ that can't happen, and reconciling — when you actually want it — is a line
359
+ of your own: `group.value &= group.items.to_set`. The combo box makes the
360
+ identical promise for its single value.
361
+
362
+ When exactly one of a handful will do, {Tuile::Component::RadioGroup} is
363
+ the same widget with a single answer.
364
+
365
+ ```ruby
366
+ sort = Component::RadioGroup.new(items: SORT_ORDERS)
367
+ sort.item_label = ->(order) { order.label }
368
+ sort.on_value_change = ->(order) { resort(order) }
369
+ ```
370
+
371
+ Its `value` is the selected item — the object, not its label, as always —
372
+ and `nil` when nothing is selected, which is where a fresh group starts.
373
+ That `nil` is also the only way back out: Space on the row that's already
374
+ selected does nothing, because a radio group has no deselect gesture. If
375
+ "none of these" is a legitimate answer, give it a row of its own. Items
376
+ are chrome here too, with the same reasoning as above: replacing them
377
+ never touches `value`, and a selection that's no longer among the rows
378
+ simply shows nothing marked.
379
+
380
+ The interaction is worth dwelling on, because it deliberately breaks with
381
+ the desktop convention. In a graphical radio group the arrow keys move the
382
+ *selection*: press Down and you have chosen the next option. Tuile splits
383
+ the two. The arrows move a cursor, and you select with Space, Enter or a
384
+ click — the same gestures as the checkbox group above.
385
+
386
+ Two reasons, the second of which decides it. First, consistency: "a cursor
387
+ roams, Enter chooses" is how every list-shaped thing in Tuile behaves, and
388
+ two group widgets sitting one Tab apart in the same form must not answer
389
+ Down differently. Second, and more practically, selection-follows-arrows
390
+ fires your listener once per row you cross. Arrow from the first option to
391
+ the fifth, and a listener that re-sorts a table, refetches a page or
392
+ rewrites a config file does that work four times — three of them for
393
+ choices the user never made. Committing on a keystroke means it happens
394
+ once, when it was meant.
395
+
396
+ So the cursor is *chrome*: presentation state, like `items`, rather than
397
+ part of the value. Assigning `value` doesn't move it, and the two
398
+ indicators say two different things — the `(*)` marks what's selected and
399
+ is always visible, while the highlighted row marks where you are and fades
400
+ when focus leaves. The one thing that *does* move the cursor is `items=`,
401
+ which pulls it back into range when the row set shrinks beneath it.
402
+
403
+ The glyphs are `(*) ` and `( ) `, and this time the reason is the column
404
+ width the checkbox section set aside. A filled bullet — `(•)` — is the
405
+ nicer mark, but U+2022 is one of Unicode's East-Asian *ambiguous* width
406
+ characters: a terminal configured for CJK text draws it two cells wide, a
407
+ Western one draws it in a single cell, and a program cannot ask which it's
408
+ talking to. Guess wrong and every row's text sits one column off — not a
409
+ cosmetic blemish but a coordinate error, since Tuile computes every rect
410
+ and clip from the width it believes each character has. Tuile bets on
411
+ one cell, and keeps the set of characters riding on that bet small enough
412
+ to enumerate, so a new widget reaches for ASCII and offers the pretty
413
+ glyph only where someone can opt in knowing their terminal.
414
+
415
+ For a discrete action rather than a selection, {Tuile::Component::Button}
416
+ is a one-row `[ caption ]` that fires `on_click` on Enter, Space, or a
417
+ left-click, highlighting its background while focused. It's a tab stop, so
418
+ it joins the normal Tab cycle.
419
+
420
+ ## Reporting progress
421
+
422
+ Everything so far either shows text or captures input.
423
+ {Tuile::Component::ProgressBar} does neither: it reports, and it is the
424
+ first component in this tour you never focus and never type into. A run of
425
+ `█` grows left to right over a `░` track, measured against a range you set:
426
+
427
+ ```ruby
428
+ bar = Component::ProgressBar.new(range: 0..files.size)
429
+ bar.value = done
430
+ ```
431
+
432
+ The first thing to notice is what it *doesn't* have — text. No percentage
433
+ sits on the bar, and there is no slot to put one there. That looks like an
434
+ omission until you try to write the alternative: centering a string over a
435
+ fill boundary means slicing it in two and restyling each half so it stays
436
+ legible against both, and the result can only ever be one centered line
437
+ clipped to the bar's width. A {Tuile::Component::Label} underneath is
438
+ strictly more capable and costs one line:
439
+
440
+ ```ruby
441
+ label.text = "#{bar.percent}% — #{done}/#{files.size} files"
442
+ ```
443
+
444
+ Now the app words it. "Scanning…", a filename, two lines, a count — none of
445
+ which a formatting knob on the bar could have produced. This is the
446
+ composition argument from chapter 1 in miniature, and the frameworks Tuile
447
+ takes after land in the same place: Vaadin's `ProgressBar` has no text API
448
+ either, and its own docs tell you to put a label beside it.
449
+
450
+ That leaves `fraction` and `percent` as real API rather than conveniences,
451
+ since they're what the label reads. Both scale the same way, and it's worth
452
+ knowing the rule: **the endpoints are exact.** A full bar means done and
453
+ `percent` returns 100 only at the maximum — 99.9 % floors to 99 and paints
454
+ one empty cell. The alternative, rounding, paints a *full* bar at 97.5 % on
455
+ a 20-cell rect, and a progress bar that says "finished" before it is has
456
+ told you the one lie it exists to avoid. At the other end the rule is
457
+ mirrored: anything above zero lights at least one cell, because a job that
458
+ has started and shows nothing reads as a job that has hung.
459
+
460
+ When you don't know the total, say so:
461
+
462
+ ```ruby
463
+ bar.indeterminate = true
464
+ ```
465
+
466
+ and the fill is replaced by a block sliding across the bar. It animates
467
+ itself — the bar starts a ticker when it's added to the tree and cancels it
468
+ when it's removed, so there is nothing to remember and nothing to leak.
469
+ That is the attach-hook idiom from chapter 4, and this is the first
470
+ component to use it. The cost is that an animating bar keeps the event loop
471
+ awake, so switch it off (or take the bar off screen) when the work ends.
472
+
473
+ One consequence of measuring against a range is worth calling out because
474
+ it looks like an edge case and isn't: `range = 0..0` is legal, and reads as
475
+ complete. An empty file list is a job with nothing outstanding, so
476
+ `bar.range = 0..files.size` needs no special case for the empty run — and
477
+ an app that reaches that state because it hasn't counted yet wanted
478
+ `indeterminate` anyway.
479
+
480
+ The bar takes its color from `bar_color`, which is `nil` by default — the
481
+ terminal's own foreground, the same choice chapter 6 makes for every
482
+ non-accent cell. Assign a {Tuile::Color} for a branded or threshold color
483
+ (green under 50 %, red over 90 %), or a `Theme.ref` to have it track the
484
+ light/dark scheme. Both glyphs take that one color: what distinguishes
485
+ filled from empty is the *density* of the character, not its hue, so the
486
+ bar still reads on a terminal with no color at all.
487
+
488
+ ## Framing content
489
+
490
+ {Tuile::Component::Window} is the frame: a bordered box with a `caption`
491
+ and a single content slot you fill via `content=`. Its border lights up in
492
+ the theme's accent color when the window is on the focus chain (chapter 5
493
+ + 6), which is what makes the active pane visually obvious in a multi-pane
494
+ layout. A window paints its whole rect and does not clip against
495
+ neighbors, so windows are meant to tile, not overlap — overlapping is what
496
+ popups are for.
497
+
498
+ The bottom border has two mutually exclusive uses, and the distinction is
499
+ the top-down-layout principle from chapter 3 made concrete. `footer_text=`
500
+ embeds decoration into the border line — chrome, mirroring the caption on
501
+ top, not focusable. `footer=` mounts a *real focusable component* spanning
502
+ the full inner width — the search-field-in-the-border case. A footer
503
+ component present takes the row and hides the text; neither drives the
504
+ window's size (the window was sized by its parent), so a footer that
505
+ doesn't fit is clipped rather than growing the frame. And if the content
506
+ supports scrolling, `scrollbar=` turns on a scrollbar by reclaiming the
507
+ right border column.
508
+
509
+ ```ruby
510
+ window = Component::Window.new("Files")
511
+ window.content = Component::List.new.tap { _1.lines = entries }
512
+ window.scrollbar = true
513
+ ```
514
+
515
+ ## Overlays
516
+
517
+ {Tuile::Component::Popup} is how you float something above the tiled UI.
518
+ The popup itself paints nothing — it's a transparent host that wraps any
519
+ component as its content and manages the lifecycle (`open` / `close`,
520
+ ESC/`q` to dismiss). Crucially, and per chapter 3, **it does not size
521
+ itself to its content**: its box is declared by `size` — a `Fraction`
522
+ (default `Fraction::HALF`, half the screen, re-resolved on every resize)
523
+ or an absolute `Size`. The content then fills that box, so use content
524
+ that can cope with overflow — a TextView or TextArea that scrolls, not a
525
+ bare Label that only truncates.
526
+
527
+ A popup is **modal by default**: centered, it grabs focus, eats keys, and
528
+ blocks clicks beneath it — that's what makes an open dialog trap Tab and
529
+ input inside itself. Pass `modal: false` for a non-modal overlay that
530
+ floats above the content without taking focus — the autocomplete-list case
531
+ from earlier, where the caller positions it against a field's caret and
532
+ drives it from app code.
533
+
534
+ Because the popup is just a transparent host, you get a bordered dialog by
535
+ wrapping a Window:
536
+
537
+ ```ruby
538
+ window = Component::Window.new("Help")
539
+ window.content = Component::TextView.new.tap { _1.text = help_text }
540
+ Component::Popup.new(content: window).open
541
+ ```
542
+
543
+ A nested TextField still swallows printable keys first, so typing `q` into
544
+ a field inside a popup doesn't dismiss it — the popup's own `q` handler sits
545
+ on the ancestor, and only sees keys the field declined.
546
+
547
+ ## Batteries-included windows
548
+
549
+ The last three components are conveniences: common Window-plus-content
550
+ assemblies you'd otherwise build by hand. Each works tiled (add it to a
551
+ layout) *or* as a popup (via a class-level `open`).
552
+
553
+ - {Tuile::Component::InfoWindow} — a Window preloaded with a List of
554
+ static lines. The read-only "here's some information" box;
555
+ `InfoWindow.open(caption, lines)` pops it up.
556
+ - {Tuile::Component::PickerWindow} — a menu of options each bound to a
557
+ single key, firing your block with the picked key. Popped up via `open`,
558
+ it closes itself after a pick; ESC/`q` cancels without firing.
559
+ - {Tuile::Component::LogWindow} — a Window wrapping an auto-scrolling,
560
+ scrollbar-equipped TextView, purpose-built for log output. Its `log`
561
+ method is **thread-safe** — it marshals the append back onto the UI
562
+ thread via the event queue (chapter 4), so background work can log
563
+ freely. And it carries an `IO`-shaped adapter so you can point a stdlib
564
+ `Logger` (or a `TTY::Logger`) straight at it:
565
+
566
+ ```ruby
567
+ window = Component::LogWindow.new
568
+ logger = Logger.new(Component::LogWindow::IO.new(window))
569
+ logger.info("started") # appears in the window, from any thread
570
+ ```
571
+
572
+ That LogWindow adapter is the tidy end of the thread-safety story chapter
573
+ 4 opened: a background thread doesn't know or care that its log line has
574
+ to reach the UI on the loop thread — it writes to a `Logger` as it always
575
+ would, and the plumbing routes it through `submit` for you.
576
+
577
+ ---
578
+
579
+ That's the toolbox. None of it is large, because the framework underneath
580
+ is small and the components inherit most of their behavior from it — which
581
+ is the recurring theme of this whole book. What remains is proving that a
582
+ UI built this way actually works, without a terminal in the loop. The
583
+ fakes the design has been quietly setting up since chapter 2 — the buffer
584
+ you can read back, the synchronous event queue, the in-memory screen — are
585
+ what make that possible, and chapter 8 puts them to work.