tuile 0.12.0 → 0.13.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/DECISIONS.md +1297 -13
  4. data/README.md +136 -490
  5. data/TERMINOLOGY.md +11 -2
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +18 -5
  8. data/book/03-layout.md +11 -10
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +5 -2
  11. data/book/07-components.md +402 -12
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +22 -16
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +385 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +7 -6
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/component/abstract_string_field.rb +36 -0
  21. data/lib/tuile/component/combo_box.rb +3 -1
  22. data/lib/tuile/component/list.rb +22 -0
  23. data/lib/tuile/component/list_dropdown.rb +86 -3
  24. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  25. data/lib/tuile/component/menu_bar.rb +582 -0
  26. data/lib/tuile/component/notification.rb +14 -11
  27. data/lib/tuile/component/picker_window.rb +0 -5
  28. data/lib/tuile/component/popup.rb +75 -9
  29. data/lib/tuile/component/select.rb +3 -1
  30. data/lib/tuile/component/tab_sheet.rb +242 -0
  31. data/lib/tuile/component/tabs.rb +528 -0
  32. data/lib/tuile/component/text_area.rb +5 -4
  33. data/lib/tuile/component/text_field.rb +23 -6
  34. data/lib/tuile/component/text_view.rb +8 -5
  35. data/lib/tuile/component.rb +38 -13
  36. data/lib/tuile/event_queue.rb +25 -1
  37. data/lib/tuile/fake_screen.rb +14 -0
  38. data/lib/tuile/keys.rb +65 -0
  39. data/lib/tuile/screen.rb +94 -77
  40. data/lib/tuile/screen_pane.rb +109 -27
  41. data/lib/tuile/styled_string.rb +40 -0
  42. data/lib/tuile/version.rb +1 -1
  43. data/sig/tuile.rbs +1473 -93
  44. metadata +6 -3
  45. data/mise.toml +0 -2
data/README.md CHANGED
@@ -57,11 +57,10 @@ else in Tuile loads it.
57
57
 
58
58
  ## Documentation
59
59
 
60
- - **[The Tuile guide](book/README.md)** teaches Tuile cover to cover — the
61
- component tree, the top-down layout model and the case for why it's
62
- enough, the single-threaded event loop and background work, focus, and
63
- theming. Start here to learn the concepts and the *why*. It grows a
64
- chapter at a time; the layout chapter is the heart of the design.
60
+ - **[The Tuile guide](book/README.md)** teaches Tuile cover to cover, in
61
+ order — the concepts and the *why*. Start here; the summary below links
62
+ into it chapter by chapter, and the layout chapter is the heart of the
63
+ design.
65
64
  - **API reference:** every public class and method carries YARD headers —
66
65
  browse them at <https://rubydoc.info/gems/tuile>, or run
67
66
  `bundle exec rake yard` for a local site.
@@ -101,494 +100,141 @@ Shift+Tab move focus between the list and the demo's widgets.
101
100
 
102
101
  ## How it works
103
102
 
104
- ### Component tree
105
-
106
- Everything on screen is a `Tuile::Component`. Components have a `parent`,
107
- `children`, a `rect` (absolute position), and an `active?` flag (true for
108
- every component on the focus chain root → focused).
109
-
110
- A single `Tuile::Screen` (process singleton) owns the tree. Under it sits a
111
- structural `ScreenPane` with three slots: tiled `content` (your app's main
112
- layout), a `popups` stack (modal overlays), and a one-row `status_bar`.
113
- Putting popups under the same parent as content means focus traversal,
114
- attachment checks and child-removed callbacks all work uniformly.
115
-
116
- ### Layout and repaint
117
-
118
- Repainting has two halves: an *invalidation* pass decides which components
119
- re-render, and a *back buffer* turns their output into the minimal set of
120
- bytes sent to the terminal. There is no clipping in between.
121
-
122
- 1. A component that needs to redraw calls `invalidate`. This just records the
123
- component in a set on the screen.
124
- 2. After the event loop drains the current batch of keyboard/mouse/posted
125
- events, the screen runs a single `repaint` pass:
126
- - Invalidated **tiled** components are sorted by tree depth (parents first)
127
- and each one repaints its `rect`.
128
- - If anything tiled was redrawn, **all popups** are drawn on top in
129
- stacking order. Popups deliberately overdraw content; there is no
130
- clipping — overdraw is free because it only touches the buffer.
131
- - The hardware cursor is moved to the focused component's
132
- `cursor_position` (e.g. into a focused text field).
133
-
134
- Components never write escape sequences to the terminal. They paint styled
135
- cells into a back buffer (`Tuile::Buffer`) via `set_text` / `fill` /
136
- `set_char`. When the pass finishes, `Buffer#flush` emits the **minimal diff**
137
- — only the cells that actually changed since the last flush — wrapped in one
138
- synchronized-output batch. That is what keeps repaint flicker-free on any
139
- terminal regardless of mode-2026 support: an unchanged cell is never
140
- rewritten, so a popup overdrawing content costs nothing on the wire unless it
141
- changes a visible cell.
142
-
143
- A component must fully cover its own `rect`, but it need not tile that rect
144
- with children: the default `repaint` clears the background behind any gaps and
145
- re-invalidates its children to paint on top, so a layout with mixed-width
146
- fields shows no stale characters. Components that paint their entire rect
147
- themselves (`Window`, `List`) opt out of that default. `Layout` paints nothing
148
- of its own and positions its children within its rect.
149
-
150
- ### Single-threaded event loop
151
-
152
- `Tuile::Screen#run_event_loop` reads keys and mouse events on a worker thread,
153
- funnels them through `Tuile::EventQueue`, and processes them on the main
154
- thread. **All** UI mutations — `rect=`, `content=`, `items=`, `invalidate`,
155
- `screen.focused=` — must run on that thread. Most UI methods will raise
156
- `"UI lock not held"` if you violate this.
157
-
158
- If you need to mutate the UI from a background thread (an HTTP poll, a file
159
- watcher, a worker), marshal the work back via the queue:
160
-
161
- ```ruby
162
- Thread.new do
163
- result = some_long_call
164
- screen.event_queue.submit { log_window.content.add_line(result) }
165
- end
166
- ```
167
-
168
- `SIGWINCH` (terminal resize) is plumbed through the same queue: the framework
169
- posts a size event, runs layout, and invalidates the entire tree. Components
170
- react by reassigning their child rectangles inside `rect=` — do not install
171
- your own WINCH handler.
172
-
173
- ### Focus and keyboard input
174
-
175
- `screen.focused = component` walks parent pointers up to the root, marks the
176
- whole chain `active?`, and deactivates everything else. Click-to-focus and
177
- `Layout#on_focus` only ever forward focus to components whose `focusable?`
178
- returns true, so clicking a `Label` inside a `Window` does not pull focus
179
- away from the window's content.
180
-
181
- When a key arrives, the screen dispatches it in this order — the first
182
- mechanism that handles it wins:
183
-
184
- 1. **Tab / Shift+Tab** advance focus through `tab_stop?` components in the
185
- current modal scope (the topmost popup if one is open, otherwise the
186
- tiled content). They are intercepted at the screen level before anything
187
- else sees them, so a focused `TextField` cannot swallow them.
188
-
189
- 2. **Global shortcuts** registered via `Screen#register_global_shortcut`.
190
- These are app-level hotkeys for actions that don't belong to any
191
- specific component — opening a log window, toggling help, etc.:
192
-
193
- ```ruby
194
- screen.register_global_shortcut(Tuile::Keys::CTRL_L,
195
- over_popups: true,
196
- hint: "^L #{screen.theme.hint('log')}") do
197
- log_popup.open
198
- end
199
- screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
200
- ```
201
-
202
- This registry sits above the component tree and nothing suppresses it,
203
- so it only accepts keys no widget can need: printable keys raise (they'd
204
- hijack typing), as do Tab/Shift+Tab and `Screen::EDITING_KEYS` (Enter,
205
- Backspace, Delete, the arrows). Control characters, ESC, `PgUp`/`PgDn`
206
- and F-keys are yours. By default, the shortcut is suppressed while any
207
- popup is open and the popup receives the key; pass `over_popups: true`
208
- to pre-empt the popup.
209
-
210
- Pass `hint:` to surface the shortcut in the status bar. It's a
211
- preformatted string the caller fully owns (color it however the rest
212
- of your app does). In the tiled case it appears right after `q quit`
213
- and before the active window's hint; while a popup is open, only
214
- `over_popups: true` hints show up, prepended before the popup's
215
- `q Close`. Omit `hint:` to leave the shortcut silent in the status bar.
216
-
217
- 3. **`Component#handle_key`** — override this on your own component when
218
- it needs to react to keys directly (a list reacting to arrows, a custom
219
- widget handling Enter, …). Return `true` to mark the key handled,
220
- `false` to let the key keep travelling:
221
-
222
- ```ruby
223
- class Toggle < Tuile::Component
224
- def handle_key(key)
225
- if key == " "
226
- @on = !@on
227
- invalidate
228
- true
229
- else
230
- false
231
- end
232
- end
233
- end
234
- ```
235
-
236
- The key goes to the focused component first, then **bubbles up its
237
- ancestors** to the scope root (the topmost popup, or the tiled content).
238
- That makes an ancestor the right home for scope-wide keys — a form's
239
- default button, or one-key jumps between panes — and it needs no special
240
- protection, because a focused `TextField` consumes the key before the
241
- ancestor ever sees it:
242
-
243
- ```ruby
244
- class AppLayout < Tuile::Component::Layout::Absolute
245
- def handle_key(key)
246
- case key
247
- when "1" then @files.focus; true
248
- when "2" then @log.focus; true
249
- else false
250
- end
251
- end
252
- end
253
- ```
254
-
255
- If nothing handles the key and it's `q` or `ESC`, the event loop exits.
256
-
257
- A component can advertise the keys it responds to by overriding
258
- `keyboard_hint`. The status bar shows the active window's hint alongside
259
- the global `q quit` prompt; while a popup is open, the popup's own hint
260
- replaces it, prefixed with `q Close`:
261
-
262
- ```ruby
263
- class FilterWindow < Tuile::Component::Window
264
- def keyboard_hint
265
- "f #{screen.theme.hint('filter')} Enter #{screen.theme.hint('open')}"
266
- end
267
- end
268
- ```
269
-
270
- ### Theming
271
-
272
- The accent colors built-in components paint with — the list-cursor /
273
- focused-input highlight, the inactive input "well", the active window
274
- border, the status-bar hint color — come from a `Tuile::Theme`, a frozen
275
- value type of semantic color tokens. The current theme lives at
276
- `screen.theme`.
277
-
278
- The theme is picked automatically when the screen is constructed:
279
- `Screen.new` queries the terminal's background color (OSC 11, with a
280
- `COLORFGBG` fallback) and selects `Theme::LIGHT` on light backgrounds,
281
- `Theme::DARK` (the colors Tuile has always used) otherwise. While the
282
- event loop runs, terminals supporting mode 2031 (kitty, foot, contour,
283
- ghostty, …) push appearance changes, and the screen follows OS
284
- light/dark flips live, repainting everything in the matching theme.
285
- Override it any time:
286
-
287
- ```ruby
288
- screen.theme = Tuile::Theme::LIGHT
289
- # or tweak a single token (tokens are strict: `Color` instances only):
290
- screen.theme = Tuile::Theme::DARK.with(active_border_color: Tuile::Color::CYAN)
291
- ```
292
-
293
- Note a bare `theme=` assignment is transient: the next OS appearance flip
294
- re-picks from the screen's `ThemeDef` and replaces it. To theme an app
295
- durably, see [App themes](#app-themes) below.
296
-
297
- The theme's primary API is its rendering helpers — `active_bg(text)`,
298
- `active_border(text)`, `input_bg(text)`, `hint(text)` — which return the
299
- text wrapped in the token's color:
300
-
301
- ```ruby
302
- screen.theme.hint("quit") # => "\e[38;5;109mquit\e[0m"
303
- screen.theme.active_bg("[ Ok ]") # => "\e[48;5;59m[ Ok ]\e[0m"
304
- ```
305
-
306
- The raw colors are also readable via the `*_color` counterparts
307
- (`active_bg_color`, …) for span-aware styling with `StyledString`.
308
-
309
- Assigning a theme invalidates every component, so the whole UI restyles on
310
- the next repaint. One caveat: strings with colors already baked in (global
311
- shortcut `hint:`s, `Theme#hint` output you cached) don't restyle —
312
- rebuild them in `Component#on_theme_changed` (see
313
- [Reacting to theme changes](#reacting-to-theme-changes)).
314
-
315
- Everything that isn't an accent deliberately inherits the terminal's own
316
- default foreground/background, which already matches the user's terminal
317
- theme — so there is no global `bg`/`fg` token to configure.
318
-
319
- ### App themes
320
-
321
- Your app's own colors belong in the theme too, so they restyle in the same
322
- invalidate-everything pass and stay legible on both terminal backgrounds.
323
- Beyond the built-in tokens, a theme carries app-specific tokens in
324
- `custom` — a `Hash{Symbol => Color}`. Look them up with `theme[:token]`
325
- (fail-fast: a typo raises `KeyError` instead of quietly painting a
326
- default) and render with the generic `fg` / `bg` helpers:
327
-
328
- ```ruby
329
- theme = Tuile::Theme::DARK.with(custom: { accent: Tuile::Color::DARK_ORANGE })
330
- theme[:accent] # => Color — e.g. for StyledString#with_fg
331
- theme.fg(:accent, "NEW") # => "\e[38;5;208mNEW\e[0m"
332
- ```
333
-
334
- `Color::DARK_ORANGE` is `Color.palette(208)` — the 256-color palette
335
- carries a constant per standard xterm chart name (`CADET_BLUE`,
336
- `DODGER_BLUE1`, `GREY37`, …; see `Color::PALETTE_NAMES`), so a theme
337
- declaration can say which color it means instead of citing a bare index.
338
-
339
- The recommended shape is a `Theme` subclass that implements one coloring
340
- function per custom token, mirroring the built-in helpers (`hint`,
341
- `active_bg`, …) — call sites then read `theme.added("+42")` instead of
342
- `theme.fg(:added, "+42")`. `Data#with` preserves the subclass, so an
343
- `AppTheme` stays an `AppTheme` through `with`:
344
-
345
- ```ruby
346
- class AppTheme < Tuile::Theme
347
- # one coloring function per custom token
348
- def added(text) = fg(:added, text)
349
- def removed(text) = fg(:removed, text)
350
- end
351
- ```
352
-
353
- Build both appearance variants and pair them in a `Tuile::ThemeDef`
354
- assigned to `screen.theme_def=`. This is the durable way to theme an app:
355
- the screen picks the member matching the detected background at startup
356
- and re-picks on every OS appearance flip, so your definition survives
357
- light/dark toggles where a bare `theme=` assignment would be replaced.
358
- `ThemeDef.new` enforces that both members declare the same custom key
359
- set — a token present in only one variant fails at construction instead
360
- of raising `KeyError` at the unpredictable moment the user flips
361
- appearance:
362
-
363
- ```ruby
364
- APP_THEME = Tuile::ThemeDef.new(
365
- dark: AppTheme.new(**Tuile::Theme::DARK.to_h,
366
- custom: { added: Tuile::Color::DARK_SEA_GREEN,
367
- removed: Tuile::Color::LIGHT_PINK3 }),
368
- light: AppTheme.new(**Tuile::Theme::LIGHT.to_h,
369
- custom: { added: Tuile::Color::SPRING_GREEN4,
370
- removed: Tuile::Color::INDIAN_RED })
371
- )
372
- screen.theme_def = APP_THEME
373
- ```
374
-
375
- In tests, a fresh `Screen.fake` per example starts from the built-in
376
- definition, so a component reading `theme[:added]` would `KeyError`.
377
- Instead of repeating `Screen.instance.theme_def = APP_THEME` in every
378
- `before` block, point the construction-time default at your definition
379
- once, in `spec_helper.rb`:
380
-
381
- ```ruby
382
- Tuile::ThemeDef.default = APP_THEME # every Screen.fake now carries it
383
- ```
384
-
385
- ### Reacting to theme changes
386
-
387
- Built-in components read `screen.theme` at paint time, so their accents
388
- restyle automatically. Content you rendered yourself does not: a
389
- `StyledString` stored in `Label#text` / `List#lines=` / `TextView#text`
390
- has its colors baked in at construction, and only your app knows which of
391
- those were theme-derived (as opposed to inherent to the data — log-level
392
- colors, say). `Component#on_theme_changed` fires on every attached
393
- component when the theme changes (assignment or appearance flip); rebuild
394
- theme-derived content there by re-running the code that rendered it
395
- initially. Consume it either way:
396
-
397
- ```ruby
398
- # composition style — assembling stock components:
399
- label.on_theme_changed = -> { label.text = render_status_line }
400
-
401
- # subclass style — call `super` so an assigned listener keeps firing:
402
- class DiffView < Tuile::Component::TextView
403
- def on_theme_changed
404
- super
405
- self.text = render_diff # screen.theme already returns the new theme
406
- end
407
- end
408
- ```
409
-
410
- The hook runs on the UI thread and repaint coalesces per tick, so
411
- mutating content inside it is safe. Don't assign `screen.theme=` from
412
- inside the hook.
103
+ **A retained tree, not a redraw loop.** Everything on screen is a
104
+ `Tuile::Component` with a `parent`, `children` and a `rect`. A singleton
105
+ `Tuile::Screen` owns the tree; under it a `ScreenPane` holds the tiled
106
+ content and a stack of popups. You build the tree once and mutate it —
107
+ there is no per-frame rebuild and no immediate-mode redraw. Tuile paints no
108
+ chrome of its own and reserves no row: your content gets the whole
109
+ terminal, and a status line is yours to build if you want one.
110
+ → [chapter 1](book/01-first-app.md)
111
+
112
+ **Repaint is automatic, and flicker-free without trying.** Components never
113
+ write escape sequences. They call `invalidate`, and paint styled cells into a
114
+ back buffer when the loop asks them to; one flush per tick emits the
115
+ **minimal diff** — only the cells that actually changed — inside a
116
+ synchronized-output batch. There is no damage tracking to maintain and no
117
+ clipping to think about: popups simply overdraw, because overdraw into a
118
+ buffer is free. → [chapter 2](book/02-repaint.md)
119
+
120
+ **Layout is top-down, and that is the whole model.** A parent computes its
121
+ children's rectangles in plain Ruby and assigns them; a component never
122
+ advertises a size it would like. No `min`/`preferred`/`max`, no negotiation
123
+ pass, no shrink-to-fit. Subclass `Layout::Absolute` when the arithmetic is
124
+ yours, or use `Layout::Vertical` / `Layout::Horizontal` to declare each
125
+ child's extent as `Fixed` / `Percent` / `Expand`.
126
+ → [chapter 3](book/03-layout.md)
127
+
128
+ **One thread owns the UI.** Keys and mouse are read on a worker thread but
129
+ dispatched on the loop's, and *every* UI mutation must happen there —
130
+ violating it raises `Tuile::Error` rather than corrupting the screen.
131
+ Background work marshals back through `screen.event_queue.submit { … }`, and
132
+ periodic work through `tick` / `tick_fps`. Resize isn't a callback either:
133
+ `SIGWINCH` becomes an event in the same queue.
134
+ → [chapter 4](book/04-event-loop.md)
135
+
136
+ **Keys are routed by focus, in three rungs.** Tab and Shift+Tab are claimed
137
+ above everything, so focus can never be trapped inside a widget; then an
138
+ app-level registry (`Screen#register_global_shortcut`, which refuses keys a
139
+ widget might need, printables included); then delivery to the focused
140
+ component, bubbling up its ancestors to the scope root — which is what makes
141
+ an ancestor the natural home for a form's Enter or a layout's one-key jumps
142
+ between panes. A paste is deliberately *not* a burst of keys: with bracketed
143
+ paste it arrives whole, as one `handle_paste`.
144
+ → [chapter 5](book/05-focus.md)
145
+
146
+ **Theming is accents-only, and follows the OS.** A `Theme` carries semantic
147
+ accent tokens — the list cursor, an input well, an active window border,
148
+ status-bar hints — plus whatever `custom` tokens your app adds. Everything
149
+ else inherits the terminal's own foreground and background, so Tuile looks at
150
+ home in the user's palette instead of fighting it. Tuile probes the terminal
151
+ background at startup, pairs a dark and a light theme in a `ThemeDef`, and
152
+ re-picks on a live OS appearance flip.
153
+ → [chapter 6](book/06-theming.md)
413
154
 
414
155
  ## Components
415
156
 
416
- All components live under `Tuile::Component::*`. Each one is documented below
417
- with the methods you are most likely to reach for; full API docs are in the
418
- YARD output (`bundle exec rake yard`).
419
-
420
- ### `Component::Label`
421
-
422
- Static text. No word-wrapping; long lines are clipped to `rect.width`. Lines
423
- may contain ANSI SGR formatting — theme helper output, a `StyledString`,
424
- or any SGR-emitting library (e.g. Rainbow, which is no longer a Tuile
425
- dependency — add it to your own Gemfile if you use it).
426
-
427
- ```ruby
428
- label = Tuile::Component::Label.new
429
- label.text = "Hello, #{screen.theme.hint('world')}!"
430
- ```
431
-
432
- Key API: `text=`, `bg=`.
433
-
434
- ### `Component::Layout`
435
-
436
- Positions children but paints nothing of its own — children must completely
437
- cover the layout's `rect`. Use `add(child)` and `remove(child)`. By default,
438
- focus forwards to the first focusable child.
439
-
440
- ```ruby
441
- class Header < Tuile::Component::Layout::Absolute
442
- def initialize
443
- super
444
- @left = Tuile::Component::Label.new
445
- @right = Tuile::Component::Label.new
446
- add(@left)
447
- add(@right)
448
- end
449
-
450
- def rect=(new_rect)
451
- super
452
- @left.rect = Tuile::Rect.new(rect.left, rect.top, rect.width / 2, 1)
453
- @right.rect = Tuile::Rect.new(rect.left + rect.width / 2, rect.top,
454
- rect.width - rect.width / 2, 1)
455
- end
456
- end
457
- ```
458
-
459
- `Layout::Absolute` is the recommended base when you want to position children
460
- manually; it inherits all the focus / key dispatch wiring and only asks you
461
- to override `rect=` to reposition children whenever the parent resizes.
462
-
463
- ### `Component::Window`
464
-
465
- A bordered frame with a caption and a single content slot. Optionally has a
466
- `footer` (a component that overlays the bottom border row, e.g. a search
467
- field) and a built-in scrollbar when the content is a `List`.
468
-
469
- ```ruby
470
- window = Tuile::Component::Window.new("Settings")
471
- window.content = some_list
472
- window.scrollbar = true # only valid when content is a Component::List
473
- window.footer = search_field
474
- ```
475
-
476
- Key API: `content=`, `footer=`, `caption=`, `scrollbar=`. Windows are
477
- focusable; focus delegates to content (or footer when active).
478
-
479
- ### `Component::List`
480
-
481
- A scrollable list of items — one row each, rendered by a `renderer` — with
482
- optional cursor and scrollbar. `lines=` is the shortcut for items that are
483
- their own rendering.
484
-
485
- ```ruby
486
- list = Tuile::Component::List.new
487
- list.lines = ["alpha", "beta", "gamma"]
488
- list.cursor = Tuile::Component::List::Cursor.new
489
- list.on_item_chosen = ->(index, item) { Tuile.logger.info("picked #{item}") }
490
- list.auto_scroll = true # auto-scroll to bottom as the list grows
491
- list.lines = list.items + ["delta"] # no appenders: assign the items whole
492
- ```
493
-
494
- Cursor variants:
495
-
496
- - `List::Cursor::None` — no cursor (default).
497
- - `List::Cursor` — lands on every line; arrows / `jk` / Home / End / Ctrl+U /
498
- Ctrl+D move it.
499
- - `List::Cursor::Limited` — restricts the cursor to a fixed set of line
500
- positions (useful for menus where only some rows are selectable).
501
-
502
- Pressing Enter or left-clicking an item fires `on_item_chosen(index, line)`.
503
-
504
- Key API: `items=`, `renderer=`, `lines=`, `build_lines`, `cursor=`,
505
- `scroll_top_row=`, `auto_scroll=`, `scrollbar_visibility=`, `on_item_chosen`,
506
- `select_next` / `select_prev` (search).
507
-
508
- ### `Component::TextField`
509
-
510
- A single-line input with a real terminal caret. The field does not scroll —
511
- keystrokes that would overflow `rect.width - 1` are rejected.
512
-
513
- ```ruby
514
- field = Tuile::Component::TextField.new
515
- field.text = "initial"
516
- field.on_change = ->(text) { filter_results(text) }
517
- field.on_enter = -> { submit(field.text) }
518
- field.on_escape = -> { popup.close }
519
- field.on_key_up = -> { results.cursor.go_up_by(1) }
520
- ```
521
-
522
- Optional callbacks: `on_change`, `on_enter`, `on_escape`, `on_key_up`,
523
- `on_key_down`. When set, the corresponding key is consumed by the field; when
524
- nil, the key falls through to the parent (e.g. ESC closes the surrounding
525
- popup by default).
526
-
527
- ### `Component::Popup`
528
-
529
- A modal overlay. It paints nothing itself: it wraps any component as
530
- `content`, sizes itself top-down from `size:` (a `Size`, or a `Fraction` of
531
- the screen resolved every layout pass — default `Fraction::HALF`), centres
532
- itself, and consumes `q` / `ESC` to close. The content fills that box and
533
- scrolls/wraps within it; it does *not* drive the popup's size. Popups are
534
- drawn on top of the tiled content; multiple popups stack.
535
-
536
- ```ruby
537
- window = Tuile::Component::Window.new("Help")
538
- window.content = help_list
539
- popup = Tuile::Component::Popup.new(content: window, size: Tuile::Fraction::HALF).open
540
- # popup.close, popup.open?
541
- ```
542
-
543
- Bare content also works (a `Label`, a `List`…) and yields a borderless popup;
544
- wrap in a `Window` if you want a frame. Pass `modal: false` for a non-modal
545
- overlay that floats above the content without grabbing focus — the caller
546
- positions and drives it.
547
-
548
- ### `Component::InfoWindow`
549
-
550
- A `Window` preconfigured with a `List` of static lines. Convenient for
551
- read-only information.
552
-
553
- ```ruby
554
- Tuile::Component::InfoWindow.open("Cannot open", [path, error.message])
555
- ```
556
-
557
- Usable tiled too — just `add` it to a layout.
558
-
559
- ### `Component::PickerWindow`
560
-
561
- A `Window` that lists single-keystroke options and fires a callback when one
562
- is picked. ESC / `q` cancel without firing.
563
-
564
- ```ruby
565
- Tuile::Component::PickerWindow.open("Choose action", [
566
- ["e", "Edit"],
567
- ["d", "Delete"],
568
- ["c", "Copy"]
569
- ]) do |key|
570
- perform(key)
571
- end
572
- ```
573
-
574
- The callback receives the picked option's key. The popup variant closes
575
- itself after the pick.
576
-
577
- ### `Component::LogWindow`
578
-
579
- A `Window` whose content is an auto-scrolling `List`. Wire your logger at it
580
- through `LogWindow::IO`:
581
-
582
- ```ruby
583
- log_window = Tuile::Component::LogWindow.new("Log")
584
- Tuile.logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
585
- Tuile.logger.info("started up")
586
- ```
587
-
588
- `LogWindow::IO` implements both `write` (stdlib `Logger`) and `puts`
589
- (`TTY::Logger` and similar), and marshals lines back through the event queue,
590
- so it is safe to log from any thread. Tuile itself is silent unless the host
591
- app sets `Tuile.logger`.
157
+ Every component lives under `Tuile::Component::*`, and every one of them is a
158
+ `Tuile::Component`: its parent sizes it top-down, it invalidates rather than
159
+ paints, and it draws its accents from the theme. This is the catalogue — one
160
+ line each, so you can find the right name. The **book** explains when and why
161
+ to reach for each (chapter 7 is a tour organized by the job), and the **rdoc**
162
+ carries the per-method reference: `bundle exec rake yard`, or
163
+ <https://rubydoc.info/gems/tuile>.
164
+
165
+ ### Laying out — [book ch3](book/03-layout.md)
166
+
167
+ | component | what it is |
168
+ |---|---|
169
+ | `Layout::Absolute` | Positions children by assigning their `rect` in a `rect=` override, and paints nothing itself. The base to subclass when the arithmetic is yours. |
170
+ | `Layout::Vertical`, `Layout::Horizontal` | Stack children along one axis from declared extents — `Fixed[n]`, `Percent[n]`, `Expand[weight]` — with box-global `spacing` and `padding`. Sugar over `Absolute`, not a new sizing model. |
171
+
172
+ ### Framing and switching — [book ch7](book/07-components.md#framing-content)
173
+
174
+ | component | what it is |
175
+ |---|---|
176
+ | `Window` | A frame with a `caption`, one content slot, and a border that lights up while the window is on the focus chain. `footer_text=` decorates the bottom border; `footer=` mounts a real component in it; `scrollbar=` reclaims the right border column. |
177
+ | `MenuBar` | A one-row strip of menu captions, each dropping a cascade of submenus that nests without limit. Items are handles from `#add_item`, each with its own `on_click`. See [Menus](book/07-components.md#menus). |
178
+ | `Tabs` | A one-row strip of captions with one selected, Left/Right switching immediately. Knows nothing about content — pair it with `TabSheet`, or drive your own view swap from `on_tab_selected`. |
179
+ | `TabSheet` | A `Tabs` strip plus the pane belonging to the selected tab. Unselected panes are *detached*, so they keep their state and stay out of the Tab cycle. See [Switching between views](book/07-components.md#switching-between-views). |
180
+
181
+ ### Showing text — [book ch7](book/07-components.md#showing-text)
182
+
183
+ | component | what it is |
184
+ |---|---|
185
+ | `Label` | Static text, one row per line, no wrapping — long lines are ellipsized. Content is a `StyledString`, so ANSI passes through. |
186
+ | `TextView` | A read-only viewer for prose: word-wrapped, scrollable, appendable, and addressable in named `Region`s when you want to rewrite one part of the text in place. |
187
+ | `ProgressBar` | A one-row bar — `█` over a `░` track — driven by `value` within a `Range`, or `indeterminate` for a bouncing sweep that owns its own ticker while on screen. |
188
+
189
+ ### Editing text — [book ch7](book/07-components.md#editing-text)
190
+
191
+ | component | what it is |
192
+ |---|---|
193
+ | `TextField` | A single-line input with a real hardware caret, scrolling horizontally to keep the caret in view. |
194
+ | `PasswordField` | A `TextField` that paints one mask glyph per character; editing, caret and clicks are unchanged. |
195
+ | `TextArea` | A multi-line, word-wrapping input with a caret that moves by grapheme cluster, word-jumps, and a viewport that scrolls to follow it. |
196
+
197
+ ### Typed values — [book ch7](book/07-components.md#the-value-seam)
198
+
199
+ | component | what it is |
200
+ |---|---|
201
+ | `IntegerField` | A one-row field whose `value` is an `Integer` or `nil`, filtering input to digits and one leading `-`. |
202
+ | `FloatField` | The same, one Ruby type over: `value` is a `Float` or `nil`. |
203
+ | `BigDecimalField` | The same for money, where a binary `Float` is the wrong answer. Tuile's one optional dependency — add `bigdecimal` yourself if you name this component. |
204
+
205
+ ### Choosing from a set — [book ch7](book/07-components.md#choosing-from-a-set)
206
+
207
+ | component | what it is |
208
+ |---|---|
209
+ | `Checkbox` | A one-row boolean: `[x]` / `[ ]` plus a caption, toggled by Space, Enter or a click on the glyph or label. |
210
+ | `List` | The workhorse: a scrollable column of *typed items*, one row each, rendered lazily by a `renderer` you supply and handing your callbacks the item itself. Add a `Cursor` (or `Cursor::Limited`) to make it navigable. |
211
+ | `RadioGroup` | Single-select over a set of items, one row each, with the marker painted in front of the label. Its `value` is the selected item. |
212
+ | `CheckboxGroup` | Multi-select over the same shape; its `value` is a frozen `Set` of the checked items. |
213
+ | `Select` | The enum field: a one-row face plus a `▾`, dropping open a list of options. Claims no printable key but Space, so your app's own keys keep working while it has focus. |
214
+ | `ComboBox` | A text field with a filtering dropdown — type to narrow, arrow to highlight, Enter to accept. Its `value` is the selected *item*, never the typed text. |
215
+ | `ListDropdown` | The floating, non-focusable list that `Select` and `ComboBox` drop open, and the `Menu` variant an app can drive itself. You rarely instantiate it directly. |
216
+
217
+ ### Taking an action — [book ch7](book/07-components.md#taking-an-action)
218
+
219
+ | component | what it is |
220
+ |---|---|
221
+ | `Button` | A one-row `[ caption ]` running a block on Enter, Space or a left click. Size it yourself: `caption.display_width + 4`. |
222
+
223
+ ### Overlays and windows — [book ch7](book/07-components.md#overlays)
224
+
225
+ | component | what it is |
226
+ |---|---|
227
+ | `Popup` | The modal overlay host: it wraps any component, paints nothing itself, and is sized by `size=` (a `Size` or a `Fraction` of the screen) rather than by its content. ESC or `q` dismisses. |
228
+ | `Notification` | A transient corner toast — `Notification.show("Saved")` — stacking messages in one box that a single ticker drains. Non-modal, and it never takes focus. |
229
+ | `InfoWindow` | A `Window` of static lines, tiled or popped up. For read-only information you don't want to assemble by hand. |
230
+ | `PickerWindow` | A `Window` of options identified by single keystrokes, firing a callback with the key that was pressed. |
231
+ | `LogWindow` | A scrolling log view. Point your logger at a `LogWindow::IO` and lines land here from any thread, marshalled through the event queue. |
232
+
233
+ The mixins those share — `HasValue` (the `value` / `empty?` / `clear` /
234
+ `on_value_change` seam every input speaks), `HasContent` (one-child
235
+ containers) and `HasCaption` (app-authored chrome text) — are the seams to
236
+ include when you write your own; chapter 7's "value seam" section is the
237
+ walkthrough.
592
238
 
593
239
  ## Geometry primitives
594
240