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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/DECISIONS.md +1297 -13
- data/README.md +136 -490
- data/TERMINOLOGY.md +11 -2
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +11 -10
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +5 -2
- data/book/07-components.md +402 -12
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +22 -16
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +385 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +7 -6
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/combo_box.rb +3 -1
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +86 -3
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +14 -11
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +75 -9
- data/lib/tuile/component/select.rb +3 -1
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component.rb +38 -13
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +94 -77
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +40 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1473 -93
- metadata +6 -3
- 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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|