tuile 0.9.0 → 0.11.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -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/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. metadata +25 -7
@@ -56,14 +56,36 @@ decision.
56
56
  ## Editing text
57
57
 
58
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.
59
+ a base — {Tuile::Component::AbstractStringField} — and differ only in shape.
60
60
 
61
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.
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.
67
89
 
68
90
  {Tuile::Component::TextArea} is the multi-line counterpart: a word-wrapping
69
91
  editor that scrolls vertically to keep the caret's line visible, with
@@ -71,6 +93,29 @@ Enter inserting a newline as in any text editor. Like everything else,
71
93
  it's sized by its parent — it does not grow to fit its content; text that
72
94
  overflows the rect is reached by scrolling.
73
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
+
74
119
  Both inherit the same event hooks from the base, and this is where the
75
120
  design pays off: you customize an input by assigning callbacks, not by
76
121
  subclassing. `on_change` fires whenever the text changes; `on_escape`
@@ -92,6 +137,86 @@ left `nil`, let those keys *fall through* to the parent — that's how Enter
92
137
  in a search field can trigger the surrounding window's action while the
93
138
  field still handles ordinary typing.
94
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
+ {Tuile::Component::FloatField} is the same field one type over — it also
179
+ accepts a single decimal point, and its value is a `Float`. That naming is
180
+ a small rule worth knowing, because it tells you what you're getting: a
181
+ typed field is named after the Ruby class of its value, so `IntegerField`
182
+ hands back an `Integer` and `FloatField` a `Float` — a binary double, which
183
+ makes it exactly the wrong field for money. It parses generously while you
184
+ type: a buffer of `1.` already reads as `1.0`, so reaching for the decimal
185
+ point doesn't blink the value to `nil` and back in the listener you wired.
186
+
187
+ Money gets {Tuile::Component::BigDecimalField}, the same field once more with
188
+ an exact decimal inside — type `0.1` and it is `0.1`, not the `0.1000…0055`
189
+ a binary double stores. It is strict about how that exactness is preserved:
190
+ assigning a `Float` raises rather than quietly converting, because by the time
191
+ `19.99` reaches the setter it is already not `19.99`. This is the one
192
+ component with a dependency Tuile itself doesn't carry — `bigdecimal` has
193
+ been a *bundled* gem since Ruby 3.4, so an app that uses this field names it
194
+ in its own `Gemfile`, and an app that doesn't never loads it.
195
+
196
+ The combo box and the two numeric fields are built the same way, and it's
197
+ worth seeing why: each *wraps* a text field rather than *being* one. A
198
+ subclass would inherit the text field's `String`-typed value and wear it
199
+ on its face right next to the real typed one — two conflicting answers to
200
+ "what's your value?". Composing sidesteps that: the wrapper holds a text
201
+ field privately, does its own filtering and parsing, and exposes only the
202
+ value that makes sense for it. (This is the "configure a generic component
203
+ to make a domain one" idea from the architecture the whole library is
204
+ built on.)
205
+
206
+ The password field is the same rule read the other way. Its value *is* its
207
+ text — same type, same vocabulary — so there's no second seam to collide
208
+ with, and it simply *is* a text field, subclassed to paint differently.
209
+ That's the test when you build your own input: if what you hand back
210
+ differs in type from what the user types, wrap a field; if it's the same
211
+ thing shown another way, extend one.
212
+
213
+ Turning a field's value into a domain model — parsing, validation, the
214
+ box-holds-a-`String` ⟷ bean-holds-an-`Integer` conversion — is
215
+ deliberately *not* the field's job; it belongs to a forms/binder layer
216
+ that will one day sit above these components. So the seam is kept thin on
217
+ purpose: `on_value_change` carries just the new value, and there's no
218
+ read-only or required flag yet. Room left for that layer to grow into.
219
+
95
220
  ## Choosing from a set
96
221
 
97
222
  {Tuile::Component::List} is the workhorse: a scrollable column of
@@ -126,11 +251,335 @@ list.cursor = Component::List::Cursor.new
126
251
  list.on_item_chosen = ->(index, line) { open(entries[index]) }
127
252
  ```
128
253
 
254
+ When the set is long and the user roughly knows what they want, a plain
255
+ list makes them scroll for it. {Tuile::Component::ComboBox} is the answer:
256
+ a text field with a dropdown that filters as you type. Hand it `items` (of
257
+ any type) and, when their `to_s` isn't what you want shown, an
258
+ `item_label` strategy to render each one; type to narrow, arrow to move,
259
+ Enter or click to accept. It's the value seam doing real work — its
260
+ `value` is the selected *item*, the object and not its label, so a combo
261
+ over `User`s hands back a `User`. The field's text is merely a transient
262
+ query: it reverts to the selection's label when you dismiss the dropdown,
263
+ and only a real commit fires `on_value_change`. The dropdown itself is a
264
+ borderless popup tinted apart from the content beneath it (chapter 6's
265
+ background inheritance again), floating below the field or flipping above
266
+ when it's near the bottom of the screen.
267
+
268
+ ```ruby
269
+ combo = Component::ComboBox.new
270
+ combo.items = User.all
271
+ combo.item_label = ->(u) { u.full_name }
272
+ combo.on_value_change = ->(u) { show(u) }
273
+ ```
274
+
275
+ When the choice is simply yes-or-no, {Tuile::Component::Checkbox} is a
276
+ single row — `[x] Enable syslog forwarding` — that Space or a left-click
277
+ flips. Its `value` is the value seam again, at its simplest: always `true`
278
+ or `false`, never `nil`. Unchecked is the *empty* value, so a fresh
279
+ checkbox reports `empty?` and `clear` unchecks it. Because `value` reads a
280
+ little colorlessly in application code, the same state answers to
281
+ `checked?`, `checked=` and `toggle` — four names, one piece of state, and
282
+ one write path, so your `on_value_change` listener fires exactly once
283
+ however you flip it.
284
+
285
+ ```ruby
286
+ cb = Component::Checkbox.new("Enable syslog forwarding", value: true)
287
+ cb.on_value_change = ->(on) { config.syslog = on }
288
+ cb.toggle # unchecks it, firing the listener with false
289
+ ```
290
+
291
+ Two of its choices are worth understanding, because they're really
292
+ statements about how Tuile widgets behave in general. The first: **Enter
293
+ toggles it, just as Space does.** Space is the native gesture for flipping
294
+ a checkbox, and for a while Enter was deliberately left alone — a checkbox
295
+ has no action to confirm. What settled it is the checkbox group further
296
+ down this chapter: a checkable row inside a list flips on Enter, because
297
+ Enter is how a list chooses the row under its cursor. Had the standalone
298
+ widget stayed silent, the same `[ ] Verbose` would have responded to Enter
299
+ in a group and ignored it in a form, which is a distinction the person at
300
+ the keyboard has no way to see.
301
+
302
+ The consequence is worth stating plainly, because it's the general rule
303
+ hiding behind the specific choice: a focused checkbox *consumes* Enter, so
304
+ a form's submit button on an ancestor won't see it. That's not a
305
+ regression from some guarantee — the framework never kept Enter clear, and
306
+ chapter 5's table shows why it can't: a text area claims Enter for a
307
+ newline, a button claims it to activate itself. Whether Enter reaches your
308
+ form always depends on the widget that has focus.
309
+
310
+ The second is about *where the widget actually is*. A form column will
311
+ happily hand a checkbox forty columns for a caption that needs twenty-two,
312
+ and the extra eighteen are blank. Both the focus highlight and the click
313
+ target stop at the end of the caption rather than filling the row — the
314
+ painted glyph is the affordance, so a click that visibly lands on nothing
315
+ must not toggle anything, and a full-width highlight band would read as a
316
+ selected *row*, which is the wrong signal for one field among ten.
317
+ (Clicking the blank tail still moves *focus* there; it's the field's row,
318
+ after all.) {Tuile::Component::Button} follows the identical rule, which is
319
+ why both expose that painted region as `extent`.
320
+
321
+ The glyphs are plain ASCII — `[x] ` and `[ ] `, three columns and a space
322
+ — rather than the prettier `☑`/`☐`. Not for the column-width reason you
323
+ might expect: those box characters genuinely measure one cell everywhere. They're simply missing from most monospace fonts, and missing
324
+ *asymmetrically* — `☐` is the worse-covered of the two, so the unchecked
325
+ state can degrade to tofu while the checked one renders, which reads as a
326
+ bug rather than a fallback.
327
+
328
+ When the user should pick *several* things from a handful,
329
+ {Tuile::Component::CheckboxGroup} stacks those rows into one widget: a
330
+ cursor moves with the arrows, Space toggles the row it sits on, and `value`
331
+ is the `Set` of items you selected.
332
+
333
+ ```ruby
334
+ levels = Component::CheckboxGroup.new(items: LogLevel.all)
335
+ levels.item_label = ->(l) { l.name }
336
+ levels.on_value_change = ->(set) { refilter(set) } # a Set of LogLevels
337
+ ```
338
+
339
+ Notice what `value` holds: the *items*, exactly as the combo box does — a
340
+ group over `LogLevel`s hands back `LogLevel`s, so filtering is
341
+ `selected.include?(level)` and never a lookup from a label back to the
342
+ thing it named. A `Set` rather than an array, because the selection has no
343
+ inherent order — which brings a wrinkle worth knowing up front. The set
344
+ iterates in the order things were *toggled*, so if you need the order the
345
+ rows are shown in, ask for it: `items & value.to_a`. Treat the set as
346
+ unordered and you'll never be surprised.
347
+
348
+ The set is also **frozen**. That's deliberate, and it's the one thing that
349
+ can bite you if you don't expect it: `group.value << item` raises rather
350
+ than quietly working. It has to, because a listener that fires on change
351
+ can only notice a change if the value is *replaced* rather than edited in
352
+ place — mutate the set you were handed and the group would have no way to
353
+ tell anyone. So assign a new selection instead (an array is fine, it's
354
+ coerced), and let the widget's own toggling build the new sets for you.
355
+
356
+ Here the cursor and the selection are genuinely two different things — the
357
+ cursor says *where you are*, the checkmarks say *what you picked* — and
358
+ that shape is exactly what a list already provides. So a checkbox group
359
+ doesn't paint rows itself; it holds a {Tuile::Component::List} and gets the
360
+ cursor, the scrolling, the scrollbar and the per-row mouse handling for
361
+ free, in the same "wrap a generic component to make a domain one" way the
362
+ combo box wraps a text field. That inheritance goes further than
363
+ convenience: a click anywhere on a row toggles it, and Enter toggles the
364
+ cursor's row, because those are the list's own gestures for choosing an
365
+ item.
366
+
367
+ Which is worth pausing on, because it looks like a contradiction of what
368
+ you just read about the standalone checkbox, where a click on the blank
369
+ space past the caption pointedly does *not* toggle. Both are right, and the
370
+ difference is what the user is aiming at. A lone checkbox in a form column
371
+ is a small painted thing surrounded by emptiness — the glyph is the
372
+ target. A row in a list is a *row*: it highlights across its full width, so
373
+ its full width is what you can click. The rule didn't bend; the thing being
374
+ clicked changed.
375
+
376
+ One thing the group deliberately does *not* do is reconcile `items` against
377
+ `value`. Replacing the items changes only what's on screen — the selection
378
+ is left exactly as it was, even if some of it is now invisible, and no
379
+ change event fires. It sounds careless until you picture a form: a user
380
+ ticks three boxes, some code refreshes the item list, and a selection
381
+ silently narrows itself. The user saves without touching anything and has
382
+ just changed data they never edited. Keeping `value` authoritative means
383
+ that can't happen, and reconciling — when you actually want it — is a line
384
+ of your own: `group.value &= group.items.to_set`. The combo box makes the
385
+ identical promise for its single value.
386
+
387
+ When exactly one of a handful will do, {Tuile::Component::RadioGroup} is
388
+ the same widget with a single answer.
389
+
390
+ ```ruby
391
+ sort = Component::RadioGroup.new(items: SORT_ORDERS)
392
+ sort.item_label = ->(order) { order.label }
393
+ sort.on_value_change = ->(order) { resort(order) }
394
+ ```
395
+
396
+ Its `value` is the selected item — the object, not its label, as always —
397
+ and `nil` when nothing is selected, which is where a fresh group starts.
398
+ That `nil` is also the only way back out: Space on the row that's already
399
+ selected does nothing, because a radio group has no deselect gesture. If
400
+ "none of these" is a legitimate answer, give it a row of its own. Items
401
+ are chrome here too, with the same reasoning as above: replacing them
402
+ never touches `value`, and a selection that's no longer among the rows
403
+ simply shows nothing marked.
404
+
405
+ The interaction is worth dwelling on, because it deliberately breaks with
406
+ the desktop convention. In a graphical radio group the arrow keys move the
407
+ *selection*: press Down and you have chosen the next option. Tuile splits
408
+ the two. The arrows move a cursor, and you select with Space, Enter or a
409
+ click — the same gestures as the checkbox group above.
410
+
411
+ Two reasons, the second of which decides it. First, consistency: "a cursor
412
+ roams, Enter chooses" is how every list-shaped thing in Tuile behaves, and
413
+ two group widgets sitting one Tab apart in the same form must not answer
414
+ Down differently. Second, and more practically, selection-follows-arrows
415
+ fires your listener once per row you cross. Arrow from the first option to
416
+ the fifth, and a listener that re-sorts a table, refetches a page or
417
+ rewrites a config file does that work four times — three of them for
418
+ choices the user never made. Committing on a keystroke means it happens
419
+ once, when it was meant.
420
+
421
+ So the cursor is *chrome*: presentation state, like `items`, rather than
422
+ part of the value. Assigning `value` doesn't move it, and the two
423
+ indicators say two different things — the `(*)` marks what's selected and
424
+ is always visible, while the highlighted row marks where you are and fades
425
+ when focus leaves. The one thing that *does* move the cursor is `items=`,
426
+ which pulls it back into range when the row set shrinks beneath it.
427
+
428
+ The glyphs are `(*) ` and `( ) `, and this time the reason is the column
429
+ width the checkbox section set aside. A filled bullet — `(•)` — is the
430
+ nicer mark, but U+2022 is one of Unicode's East-Asian *ambiguous* width
431
+ characters: a terminal configured for CJK text draws it two cells wide, a
432
+ Western one draws it in a single cell, and a program cannot ask which it's
433
+ talking to. Guess wrong and every row's text sits one column off — not a
434
+ cosmetic blemish but a coordinate error, since Tuile computes every rect
435
+ and clip from the width it believes each character has. Tuile bets on
436
+ one cell, and keeps the set of characters riding on that bet small enough
437
+ to enumerate, so a new widget reaches for ASCII and offers the pretty
438
+ glyph only where someone can opt in knowing their terminal.
439
+
440
+ A radio group spends a row per option, permanently. When the form has six
441
+ of these and a terminal has twenty-four rows, that arithmetic stops
442
+ working, and {Tuile::Component::Select} is the same single answer on *one*
443
+ row: the selected label plus a `▾`, with the options appearing only while
444
+ you're choosing between them.
445
+
446
+ ```ruby
447
+ level = Component::Select.new(items: %w[debug info warn error], value: "warn")
448
+ level.on_value_change = ->(l) { logger.level = l }
449
+ ```
450
+
451
+ Enter, Space or Down opens the dropdown, the arrows move the highlight,
452
+ Enter or Space commits, ESC closes it having changed nothing. `value` is
453
+ the selected item as always, `nil` while nothing is selected — and that
454
+ `nil` is a perfectly ordinary state here, which is why there's no
455
+ placeholder text: an optional enum field simply shows a blank face.
456
+
457
+ So when do you reach for which? The temptation is to decide by item count,
458
+ and that's the wrong axis. Ask instead **who wrote the labels**:
459
+
460
+ | The options are… | Widget | Why |
461
+ |---|---|---|
462
+ | a developer-authored enum, on one form row | `Select` | one row; borrows *n* transiently |
463
+ | the same enum, worth comparing side by side | `RadioGroup` | spends *n* rows permanently |
464
+ | supplied by the app, open-ended, labels you don't control | `ComboBox` | filtering *is* the navigation |
465
+ | an enum, several of which apply | `CheckboxGroup` | a frozen `Set` value |
466
+
467
+ A select is for a closed set you knew when you wrote the code — log level,
468
+ sort order, line endings, Yes/No/Ask. A combo box is for countries, users,
469
+ branches: data. A twelve-value enum is still a select, and a three-row
470
+ country list loaded from a database is still a combo box, because next
471
+ release it's two hundred rows and the widget you chose shouldn't have to
472
+ change. Count is a symptom; authorship is the criterion.
473
+
474
+ Which brings up the property that really separates the two, and it's not
475
+ the filtering. **A select claims no printable key but Space.** Every other
476
+ letter and digit bubbles straight past it, up the focus chain, to your
477
+ application — so a form's `s`-to-save, or a layout's `1`/`2`/`3` jumps
478
+ between panes, keep working while focus sits in a select. A combo box can
479
+ never offer that: its field must eat every printable, because every
480
+ printable is potentially part of the query. Add the fact that a select has
481
+ no caret, and the two together are the whole case for the component. A
482
+ caret is the strongest promise a terminal can make about what a widget
483
+ does, and spending it on "you may type free text here" over a four-value
484
+ enum is a lie the user then has to discover.
485
+
486
+ Space is the one exception, and it's a safe one precisely because Space was
487
+ never yours to begin with: every activatable widget in Tuile already claims
488
+ it — a button, a checkbox, a radio group. Home and End, by contrast, are
489
+ declined, so they stay available for you to bind app-wide.
490
+
491
+ You may be waiting for type-ahead — press `f` and jump to the first item
492
+ starting with `f`, the way desktop lists do. It isn't there, deliberately.
493
+ The single-key version is silently wrong: with Finland, Fiji and Jamaica in
494
+ the list, typing `fij` selects *Jamaica*, because each key is a fresh
495
+ one-character match. The fix everyone reaches for next is a small
496
+ accumulating buffer that clears after a second of idleness — and that
497
+ buffer *is* the combo box's query with the display removed. If you're
498
+ holding query state, showing it is strictly better than hiding it, and
499
+ showing it is a combo box. On a terminal it's worse still: the timer leans
500
+ on inter-keystroke gaps, and gaps are exactly what a laggy SSH link or a
501
+ paste destroys.
502
+
503
+ One small nicety worth noticing: the dropdown is never narrower than the
504
+ select itself, and grows past it when a label needs the room — so its edges
505
+ line up with the face you clicked, and the labels are never the thing that
506
+ gets ellipsized. It opens below the select, flips above near the bottom of
507
+ the screen, slides left rather than running off the right edge, and grows a
508
+ scrollbar when there are more options than it can show.
509
+
129
510
  For a discrete action rather than a selection, {Tuile::Component::Button}
130
511
  is a one-row `[ caption ]` that fires `on_click` on Enter, Space, or a
131
512
  left-click, highlighting its background while focused. It's a tab stop, so
132
513
  it joins the normal Tab cycle.
133
514
 
515
+ ## Reporting progress
516
+
517
+ Everything so far either shows text or captures input.
518
+ {Tuile::Component::ProgressBar} does neither: it reports, and it is the
519
+ first component in this tour you never focus and never type into. A run of
520
+ `█` grows left to right over a `░` track, measured against a range you set:
521
+
522
+ ```ruby
523
+ bar = Component::ProgressBar.new(range: 0..files.size)
524
+ bar.value = done
525
+ ```
526
+
527
+ The first thing to notice is what it *doesn't* have — text. No percentage
528
+ sits on the bar, and there is no slot to put one there. That looks like an
529
+ omission until you try to write the alternative: centering a string over a
530
+ fill boundary means slicing it in two and restyling each half so it stays
531
+ legible against both, and the result can only ever be one centered line
532
+ clipped to the bar's width. A {Tuile::Component::Label} underneath is
533
+ strictly more capable and costs one line:
534
+
535
+ ```ruby
536
+ label.text = "#{bar.percent}% — #{done}/#{files.size} files"
537
+ ```
538
+
539
+ Now the app words it. "Scanning…", a filename, two lines, a count — none of
540
+ which a formatting knob on the bar could have produced. This is the
541
+ composition argument from chapter 1 in miniature, and the frameworks Tuile
542
+ takes after land in the same place: Vaadin's `ProgressBar` has no text API
543
+ either, and its own docs tell you to put a label beside it.
544
+
545
+ That leaves `fraction` and `percent` as real API rather than conveniences,
546
+ since they're what the label reads. Both scale the same way, and it's worth
547
+ knowing the rule: **the endpoints are exact.** A full bar means done and
548
+ `percent` returns 100 only at the maximum — 99.9 % floors to 99 and paints
549
+ one empty cell. The alternative, rounding, paints a *full* bar at 97.5 % on
550
+ a 20-cell rect, and a progress bar that says "finished" before it is has
551
+ told you the one lie it exists to avoid. At the other end the rule is
552
+ mirrored: anything above zero lights at least one cell, because a job that
553
+ has started and shows nothing reads as a job that has hung.
554
+
555
+ When you don't know the total, say so:
556
+
557
+ ```ruby
558
+ bar.indeterminate = true
559
+ ```
560
+
561
+ and the fill is replaced by a block sliding across the bar. It animates
562
+ itself — the bar starts a ticker when it's added to the tree and cancels it
563
+ when it's removed, so there is nothing to remember and nothing to leak.
564
+ That is the attach-hook idiom from chapter 4, and this is the first
565
+ component to use it. The cost is that an animating bar keeps the event loop
566
+ awake, so switch it off (or take the bar off screen) when the work ends.
567
+
568
+ One consequence of measuring against a range is worth calling out because
569
+ it looks like an edge case and isn't: `range = 0..0` is legal, and reads as
570
+ complete. An empty file list is a job with nothing outstanding, so
571
+ `bar.range = 0..files.size` needs no special case for the empty run — and
572
+ an app that reaches that state because it hasn't counted yet wanted
573
+ `indeterminate` anyway.
574
+
575
+ The bar takes its color from `bar_color`, which is `nil` by default — the
576
+ terminal's own foreground, the same choice chapter 6 makes for every
577
+ non-accent cell. Assign a {Tuile::Color} for a branded or threshold color
578
+ (green under 50 %, red over 90 %), or a `Theme.ref` to have it track the
579
+ light/dark scheme. Both glyphs take that one color: what distinguishes
580
+ filled from empty is the *density* of the character, not its hue, so the
581
+ bar still reads on a terminal with no color at all.
582
+
134
583
  ## Framing content
135
584
 
136
585
  {Tuile::Component::Window} is the frame: a bordered box with a `caption`
@@ -186,9 +635,9 @@ window.content = Component::TextView.new.tap { _1.text = help_text }
186
635
  Component::Popup.new(content: window).open
187
636
  ```
188
637
 
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.
638
+ A nested TextField still swallows printable keys first, so typing `q` into
639
+ a field inside a popup doesn't dismiss it — the popup's own `q` handler sits
640
+ on the ancestor, and only sees keys the field declined.
192
641
 
193
642
  ## Batteries-included windows
194
643
 
data/book/08-testing.md CHANGED
@@ -97,14 +97,27 @@ list.handle_key(Keys::DOWN_ARROW) # exercises the cursor directly
97
97
  list.handle_mouse(MouseEvent.new(:left, 5, 2))
98
98
  ```
99
99
 
100
- **High: go through the screen.** {Tuile::Screen#handle_key} runs the *full*
101
- dispatch pipeline from chapter 5 — Tab cycling, global shortcuts, the
102
- subtree `key_shortcut` search, then the focus chain. When your test is
103
- about routing — that a shortcut jumps focus, that a modal popup traps Tab,
104
- that a focused text field swallows a key its sibling would otherwise claim
105
- — you drive `Screen.instance.handle_key` and let the real machinery run.
106
- Set focus the way production does, with `screen.focused = component` or
107
- `component.focus`.
100
+ **High: go through the pane.** {Tuile::ScreenPane#handle_key} runs the
101
+ dispatch rung from chapter 5 that routing is actually about: delivery to
102
+ {Tuile::Screen#focused}, then the bubble up its ancestor chain to the scope
103
+ root. So when your test is about routing — that a layout's one-key pane jump
104
+ fires, that a focused text field swallows a key its ancestor would otherwise
105
+ claim, that an open modal keeps the content beneath it from seeing keys — you
106
+ drive the pane and let the real machinery run:
107
+
108
+ ```ruby
109
+ screen.focused = list # focus as production does — or list.focus
110
+ assert screen.pane.handle_key("1") # the layout's ancestor binding fires
111
+ ```
112
+
113
+ The two rungs *above* the pane have their own doors, because `Screen`'s own
114
+ `handle_key` — the top of the ladder — is private: it belongs to the key
115
+ thread, not to app code. Tab cycling is {Tuile::Screen#focus_next} /
116
+ `focus_previous`, both already scoped to the topmost modal popup, which is
117
+ what "a popup traps Tab" means. A global shortcut is a block you registered,
118
+ so test the action it calls; the registry itself is a lookup table `Screen`
119
+ consults before handing the key to the pane, and `register_global_shortcut`
120
+ is worth a test only for what it *rejects* (printables, Tab, `EDITING_KEYS`).
108
121
 
109
122
  Two more test-only hooks close the loop. After you mutate something, call
110
123
  `Screen.instance.repaint` to flush the pending invalidations into the