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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -36
- data/DECISIONS.md +2566 -0
- data/README.md +37 -24
- data/book/03-layout.md +153 -8
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +84 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +458 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +22 -14
- data/examples/sampler.rb +632 -67
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +118 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +134 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +263 -0
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -15
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +157 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +125 -86
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +2962 -680
- metadata +25 -7
data/README.md
CHANGED
|
@@ -49,6 +49,12 @@ gem "tuile", git: "https://github.com/mvysny/tuile.git"
|
|
|
49
49
|
|
|
50
50
|
Tuile requires Ruby 3.3+.
|
|
51
51
|
|
|
52
|
+
One component — `Component::BigDecimalField` — additionally needs the
|
|
53
|
+
`bigdecimal` gem, which Tuile deliberately does *not* depend on (it has been a
|
|
54
|
+
bundled gem since Ruby 3.4, so Bundler no longer puts it on the load path for
|
|
55
|
+
free). Add `gem "bigdecimal"` to your Gemfile if you use that field; nothing
|
|
56
|
+
else in Tuile loads it.
|
|
57
|
+
|
|
52
58
|
## Documentation
|
|
53
59
|
|
|
54
60
|
- **[The Tuile guide](book/README.md)** teaches Tuile cover to cover — the
|
|
@@ -98,9 +104,8 @@ Shift+Tab move focus between the list and the demo's widgets.
|
|
|
98
104
|
### Component tree
|
|
99
105
|
|
|
100
106
|
Everything on screen is a `Tuile::Component`. Components have a `parent`,
|
|
101
|
-
`children`, a `rect` (absolute position), an `active?` flag (true for
|
|
102
|
-
component on the focus chain root → focused)
|
|
103
|
-
that the framework will route keys to from anywhere in the tree.
|
|
107
|
+
`children`, a `rect` (absolute position), and an `active?` flag (true for
|
|
108
|
+
every component on the focus chain root → focused).
|
|
104
109
|
|
|
105
110
|
A single `Tuile::Screen` (process singleton) owns the tree. Under it sits a
|
|
106
111
|
structural `ScreenPane` with three slots: tiled `content` (your app's main
|
|
@@ -194,11 +199,13 @@ mechanism that handles it wins:
|
|
|
194
199
|
screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
|
|
195
200
|
```
|
|
196
201
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
|
202
209
|
|
|
203
210
|
Pass `hint:` to surface the shortcut in the status bar. It's a
|
|
204
211
|
preformatted string the caller fully owns (color it however the rest
|
|
@@ -207,23 +214,10 @@ mechanism that handles it wins:
|
|
|
207
214
|
`over_popups: true` hints show up, prepended before the popup's
|
|
208
215
|
`q Close`. Omit `hint:` to leave the shortcut silent in the status bar.
|
|
209
216
|
|
|
210
|
-
3. **`Component#
|
|
211
|
-
component. The framework walks the focused component's subtree for a
|
|
212
|
-
match and focuses the winner. Good fit for "press F to focus the filter
|
|
213
|
-
field" or one-key tab pickers. The lookup is suppressed while the
|
|
214
|
-
focused component owns the hardware cursor (e.g. a `TextField` the user
|
|
215
|
-
is typing into) so editing isn't interrupted:
|
|
216
|
-
|
|
217
|
-
```ruby
|
|
218
|
-
filter_field.key_shortcut = "f"
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
4. **`Component#handle_key`** — override this on your own component when
|
|
217
|
+
3. **`Component#handle_key`** — override this on your own component when
|
|
222
218
|
it needs to react to keys directly (a list reacting to arrows, a custom
|
|
223
219
|
widget handling Enter, …). Return `true` to mark the key handled,
|
|
224
|
-
`false` to let the
|
|
225
|
-
default `key_shortcut` subtree lookup; suppress it only when you
|
|
226
|
-
deliberately want this component to swallow everything:
|
|
220
|
+
`false` to let the key keep travelling:
|
|
227
221
|
|
|
228
222
|
```ruby
|
|
229
223
|
class Toggle < Tuile::Component
|
|
@@ -233,7 +227,26 @@ mechanism that handles it wins:
|
|
|
233
227
|
invalidate
|
|
234
228
|
true
|
|
235
229
|
else
|
|
236
|
-
|
|
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
|
|
237
250
|
end
|
|
238
251
|
end
|
|
239
252
|
end
|
data/book/03-layout.md
CHANGED
|
@@ -125,6 +125,15 @@ complex TUIs — tmux, neovim's splits, k9s, lazygit, htop — are all
|
|
|
125
125
|
them needs flex grow/shrink/wrap/basis or a constraint solve. The
|
|
126
126
|
hardest real terminal UIs already live comfortably inside "simple."
|
|
127
127
|
|
|
128
|
+
Be precise about what that validates, though: it's TUI *app architecture*,
|
|
129
|
+
not TUI *framework feature lists*. Several terminal frameworks do ship a
|
|
130
|
+
full engine — Textual has CSS, Ink embeds Yoga (the flexbox engine React
|
|
131
|
+
Native uses), ratatui runs a real Cassowary solver. The reason isn't that
|
|
132
|
+
terminals need one; it's that those frameworks never hand you a rectangle,
|
|
133
|
+
so an engine is the only way their users can lay anything out. Tuile hands
|
|
134
|
+
you coordinates, which is what makes richer layout *optional* here —
|
|
135
|
+
available where it helps, declinable everywhere else.
|
|
136
|
+
|
|
128
137
|
It's stronger than "simple happens to work," though. Importing a CSS-like
|
|
129
138
|
system would be *actively worse* on a terminal, for three concrete
|
|
130
139
|
reasons:
|
|
@@ -204,6 +213,146 @@ That's the whole "responsive" story: plain Ruby, recomputed on a
|
|
|
204
213
|
discrete resize event. No breakpoint DSL, no media queries — just the
|
|
205
214
|
arithmetic you'd write anyway.
|
|
206
215
|
|
|
216
|
+
## Stacks without the arithmetic: `Vertical` and `Horizontal`
|
|
217
|
+
|
|
218
|
+
`Absolute` is the right tool for genuinely two-dimensional geometry, and
|
|
219
|
+
tedious for the most common shape in any app: a stack. So Tuile ships two
|
|
220
|
+
*box* layouts that do that arithmetic for you. You declare what extent each
|
|
221
|
+
child should get, and the box hands down rectangles through the very same
|
|
222
|
+
`rect=`:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
form = Tuile::Component::Layout::Vertical.new(spacing: 1)
|
|
226
|
+
form.add(prompt, Tuile::Component::Layout::Fixed[3]) # 3 rows
|
|
227
|
+
form.add(field, Tuile::Component::Layout::Fixed[1]) # 1 row
|
|
228
|
+
form.add(log, Tuile::Component::Layout::Expand[1]) # …all that's left
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`Horizontal` is the same with the axes swapped — the constraint is a width,
|
|
232
|
+
and `Expand` claims the rest of the row:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
split = Tuile::Component::Layout::Horizontal.new
|
|
236
|
+
split.add(sidebar, Tuile::Component::Layout::Fixed[30])
|
|
237
|
+
split.add(main, Tuile::Component::Layout::Expand[1])
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Inside a subclass the constraint names need no prefix, since they live on
|
|
241
|
+
`Layout`, an ancestor:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
class LoginForm < Tuile::Component::Layout::Vertical
|
|
245
|
+
def initialize
|
|
246
|
+
super(spacing: 1, padding: Insets[top: 1])
|
|
247
|
+
add(@user = Tuile::Component::TextField.new, Fixed[1], cross: Fixed[30])
|
|
248
|
+
add(@log = Tuile::Component::TextView.new, Expand[1])
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### The three constraints
|
|
254
|
+
|
|
255
|
+
- **`Fixed[n]`** — exactly `n` cells, clamped to what's still unassigned.
|
|
256
|
+
- **`Percent[n]`** — `n`% of the space *available*, measured after padding
|
|
257
|
+
and the gaps between children come off. So two `Percent[50]` children fit
|
|
258
|
+
exactly instead of overflowing by the gap between them.
|
|
259
|
+
- **`Expand[weight]`** — a share of whatever is left once the `Fixed` and
|
|
260
|
+
`Percent` children have taken theirs, split in proportion to the weights.
|
|
261
|
+
|
|
262
|
+
That's the entire vocabulary, and the omission is the point: **there is no
|
|
263
|
+
`Auto`.** Nothing asks a child how big it would like to be. This is the same
|
|
264
|
+
rule as the rest of the chapter, wearing a friendlier face.
|
|
265
|
+
|
|
266
|
+
Two more knobs, both on the box rather than on each child: `spacing:` (blank
|
|
267
|
+
cells between adjacent children) and `padding:` (an inset from the box's own
|
|
268
|
+
rect — `Insets[top: 1, left: 2]`, or a plain integer for all four edges).
|
|
269
|
+
|
|
270
|
+
### The cross axis, and alignment
|
|
271
|
+
|
|
272
|
+
Each child also gets a `cross:` constraint — its width in a `Vertical`, its
|
|
273
|
+
height in a `Horizontal`. It defaults to `Percent[100]`, so children fill the
|
|
274
|
+
box across the axis, which is usually what you want. Narrow one when it isn't:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
form.add(field, Fixed[1], cross: Fixed[30]) # 30 columns
|
|
278
|
+
form.add(title, Fixed[1], cross: Percent[50], align: :center)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`align:` is `:start`, `:center` or `:end` — axis-agnostic on purpose, since
|
|
282
|
+
`:start` means the left edge in a `Vertical` and the top edge in a
|
|
283
|
+
`Horizontal`. It does something only when the child is narrower than the
|
|
284
|
+
space available.
|
|
285
|
+
|
|
286
|
+
Alignment might look like it contradicts the top-down rule — surely centering
|
|
287
|
+
needs to know how wide the child is? It doesn't. It needs *a* width, and the
|
|
288
|
+
`cross:` constraint is where that width came from. Nothing gets measured.
|
|
289
|
+
(`Expand` is main-axis only for a related reason: across the axis a child has
|
|
290
|
+
no siblings to compete with, so a weight would have nothing to mean. Passing
|
|
291
|
+
one as `cross:` raises.)
|
|
292
|
+
|
|
293
|
+
### Packing, starving, and remainders
|
|
294
|
+
|
|
295
|
+
Three behaviours worth knowing, because they are what you get *instead of* a
|
|
296
|
+
solver:
|
|
297
|
+
|
|
298
|
+
**Children pack from the start edge.** With no `Expand` among them the slack
|
|
299
|
+
is simply left at the end — there's no invisible filler to add, the way
|
|
300
|
+
Swing's `BoxLayout` needs glue.
|
|
301
|
+
|
|
302
|
+
**Over-subscription starves rather than raising.** If the children ask for
|
|
303
|
+
more than there is, they're satisfied in declaration order and whoever is
|
|
304
|
+
left over gets an empty rect — which, as chapter 2 established, paints
|
|
305
|
+
nothing. A pane too short for its content degrades quietly instead of
|
|
306
|
+
throwing or spilling outside its rect.
|
|
307
|
+
|
|
308
|
+
**A remainder goes to the earliest `Expand` children, one cell each.** Five
|
|
309
|
+
equal `Expand`s in 12 rows get `3, 3, 2, 2, 2` — never `2, 2, 2, 2, 4`, which
|
|
310
|
+
is what "give the leftover to the last one" produces. On a character grid a
|
|
311
|
+
doubled pane is plainly visible, so spare cells are spread rather than dumped.
|
|
312
|
+
One wrinkle, since this chapter showed you the hand-written version first: the
|
|
313
|
+
two-pane `Absolute` example above gives the odd column to the *right* pane,
|
|
314
|
+
while two `Expand[1]` children give it to the *left*. Both are deterministic;
|
|
315
|
+
they're just different code.
|
|
316
|
+
|
|
317
|
+
### Varying the gap: nest, don't configure
|
|
318
|
+
|
|
319
|
+
`spacing` belongs to the box rather than to individual children, deliberately.
|
|
320
|
+
A gap sits *between* two children, so "whose gap is it?" has no good answer —
|
|
321
|
+
and both possible conventions confuse readers.
|
|
322
|
+
|
|
323
|
+
When you want tighter grouping, nest a box. A `spacing: 0` stack inside a
|
|
324
|
+
`spacing: 1` stack keeps two rows flush while the rest of the form breathes:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
pair = Tuile::Component::Layout::Vertical.new # spacing: 0
|
|
328
|
+
pair.add(bar, Fixed[1])
|
|
329
|
+
pair.add(caption, Fixed[1]) # flush under the bar
|
|
330
|
+
|
|
331
|
+
form = Tuile::Component::Layout::Vertical.new(spacing: 1)
|
|
332
|
+
form.add(prompt, Fixed[4])
|
|
333
|
+
form.add(pair, Fixed[2]) # blank row around the pair
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
That *states* the grouping instead of faking it with a per-child gap — boxes
|
|
337
|
+
within boxes, which is how the rest of Tuile composes anyway.
|
|
338
|
+
|
|
339
|
+
### When to stay with `Absolute`
|
|
340
|
+
|
|
341
|
+
The boxes are sugar, not a replacement, and they can't say everything. A **cap
|
|
342
|
+
on a proportion** is the case to recognise:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
list_width = (rect.width / 3).clamp(20, 40) # a third, but never <20 or >40
|
|
346
|
+
group_width = [16, rect.width / 3].min # a third, but never more than 16
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Both of these are in `examples/sampler.rb`, and both keep a `rect=`
|
|
350
|
+
override. That's the intended division of labour rather than a gap to work
|
|
351
|
+
around: use a box for the stack, drop to `Absolute` for the region that
|
|
352
|
+
genuinely needs arithmetic — usually nesting one inside the other, so only the
|
|
353
|
+
awkward part carries any. The sampler does exactly that, and porting it to
|
|
354
|
+
these layouts took it from 59 hand-written rectangles down to 7.
|
|
355
|
+
|
|
207
356
|
## Geometry: `Point`, `Size`, `Rect`
|
|
208
357
|
|
|
209
358
|
The values you compute with are three small frozen types
|
|
@@ -369,11 +518,7 @@ own code and set the size top-down. Keep measurement opt-in and
|
|
|
369
518
|
caller-side; the moment the framework starts consulting children for
|
|
370
519
|
sizes automatically, it's on the road back to the constraint solver.
|
|
371
520
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
feeding results to the very same `rect=` setter you already use, with no
|
|
377
|
-
change to the foundation. Absolute-first is the base; a descriptive
|
|
378
|
-
split layer is an optional convenience on top, added if and when the
|
|
379
|
-
convenience pays for itself.
|
|
521
|
+
Note that the box layouts above are not an exception to any of this. They
|
|
522
|
+
compute rectangles *for* you, but they compute them from constraints you
|
|
523
|
+
supplied, and they hand them down through the same `rect=`. No child is ever
|
|
524
|
+
consulted.
|
data/book/04-event-loop.md
CHANGED
|
@@ -172,6 +172,92 @@ Reach for `tick` when you're pacing work ("check every two seconds"),
|
|
|
172
172
|
`tick_fps` when you're driving an animation ("spin at 8 fps"). Same
|
|
173
173
|
machinery underneath; pick the unit that matches how you're thinking.
|
|
174
174
|
|
|
175
|
+
## Owning a resource for as long as you're on screen
|
|
176
|
+
|
|
177
|
+
Both examples above end with a loose thread: *who calls `cancel`, and
|
|
178
|
+
when?* If the spinner lives in a popup the user can close, then someone
|
|
179
|
+
has to remember to stop the ticker at exactly the moment the popup goes
|
|
180
|
+
away — and "someone remembers" is how you end up with a timer firing
|
|
181
|
+
against a component nobody can see, forever.
|
|
182
|
+
|
|
183
|
+
The component itself is the only thing that knows. So it gets told:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
class Spinner < Tuile::Component::Label
|
|
187
|
+
FRAMES = %w[/ - \\ |]
|
|
188
|
+
|
|
189
|
+
protected
|
|
190
|
+
|
|
191
|
+
def on_attached
|
|
192
|
+
@ticker = screen.event_queue.tick_fps(8) { |n| self.text = FRAMES[n % FRAMES.size] }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def on_detached
|
|
196
|
+
@ticker&.cancel
|
|
197
|
+
@ticker = nil
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`on_attached` fires the moment this component's tree is mounted on the
|
|
203
|
+
screen; `on_detached` fires the moment it's unmounted. Add the spinner to a
|
|
204
|
+
popup and it starts; close the popup and it stops. Nothing at the call site
|
|
205
|
+
remembers anything — `popup.close` is the whole teardown.
|
|
206
|
+
|
|
207
|
+
The contract is a mirror: **`on_attached` starts what `on_detached` stops.**
|
|
208
|
+
Keep both cheap and idempotent, because a component *moved* from one parent
|
|
209
|
+
to another gets `on_detached` and then `on_attached` — between those two
|
|
210
|
+
calls it genuinely is off the screen, possibly for a long time, so stopping
|
|
211
|
+
and restarting is the honest thing to do. And whatever you acquire in
|
|
212
|
+
`on_attached` you must release in `on_detached`, because nothing else will.
|
|
213
|
+
|
|
214
|
+
This generalizes well beyond tickers, and the interesting case is
|
|
215
|
+
subscriptions. A component may depend on a service, but a service must never
|
|
216
|
+
reach back up into the UI — so when data has to flow *upward*, the component
|
|
217
|
+
subscribes and the service emits blind. That subscription is a resource with
|
|
218
|
+
exactly the lifetime the hooks describe:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
class BuildStatus < Tuile::Component::Label
|
|
222
|
+
def initialize(service)
|
|
223
|
+
super()
|
|
224
|
+
@service = service
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
protected
|
|
228
|
+
|
|
229
|
+
def on_attached
|
|
230
|
+
@subscription = @service.on_change { |s| screen.event_queue.submit { self.text = s } }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def on_detached
|
|
234
|
+
@subscription&.unsubscribe
|
|
235
|
+
@subscription = nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Note the `submit` inside the listener — the service emits from whatever
|
|
241
|
+
thread it likes, and marshalling onto the UI thread is the listener's job,
|
|
242
|
+
exactly as earlier in this chapter. What the hooks add is the other half:
|
|
243
|
+
the subscription exists for precisely as long as the component is on
|
|
244
|
+
screen, and no view-closing code path has to know that the subscription
|
|
245
|
+
exists at all.
|
|
246
|
+
|
|
247
|
+
`screen.close` counts as unmounting, so the `screen.close` at the end of
|
|
248
|
+
your `main` gives every component still on screen its `on_detached` — the
|
|
249
|
+
tickers stop, the subscriptions come off, and you didn't write any of that
|
|
250
|
+
teardown. What *doesn't* fire is a process that exits without closing the
|
|
251
|
+
screen at all: these are lifecycle hooks, not destructors, and Tuile
|
|
252
|
+
installs no `at_exit`. If your `on_detached` does something that matters
|
|
253
|
+
beyond the process — flushing a file, say — close the screen deliberately
|
|
254
|
+
rather than relying on exit.
|
|
255
|
+
|
|
256
|
+
The other thing the hooks are not is a place to do layout. When
|
|
257
|
+
`on_attached` runs, your parent hasn't assigned your `rect` yet. If you need
|
|
258
|
+
to paint, invalidate here and do the work in `repaint`, which is what
|
|
259
|
+
chapter 2 was about anyway.
|
|
260
|
+
|
|
175
261
|
## Resize is just another event
|
|
176
262
|
|
|
177
263
|
A terminal resize could have been handled off the `SIGWINCH` signal
|
data/book/05-focus.md
CHANGED
|
@@ -55,8 +55,8 @@ Shift+Tab land on this component while cycling. It's also `false` by
|
|
|
55
55
|
default, and it *implies* focusable — a tab stop is always a valid focus
|
|
56
56
|
target, but not every focus target is a tab stop. The distinction matters
|
|
57
57
|
for containers: a {Tuile::Component::Window} is focusable (so a click on
|
|
58
|
-
its chrome
|
|
59
|
-
|
|
58
|
+
its chrome can focus it) but is *not* a tab stop (Tab should skip the frame
|
|
59
|
+
and stop on the actual inputs inside it). So:
|
|
60
60
|
|
|
61
61
|
- **Label** — neither. Decoration.
|
|
62
62
|
- **Window, Popup** — focusable, not a tab stop. Clickable chrome,
|
|
@@ -80,8 +80,8 @@ earlier.
|
|
|
80
80
|
**1. Tab and Shift+Tab — focus navigation, first, always.** These are
|
|
81
81
|
intercepted before anything else and drive the cycling described above.
|
|
82
82
|
They're taken off the top deliberately: a focused text field swallows
|
|
83
|
-
almost every printable key
|
|
84
|
-
|
|
83
|
+
almost every printable key, and if Tab weren't reserved here, a field would
|
|
84
|
+
trap it too and you could never Tab out.
|
|
85
85
|
|
|
86
86
|
**2. Global shortcuts.** App-level shortcuts registered with
|
|
87
87
|
{Tuile::Screen#register_global_shortcut} fire next, before any component
|
|
@@ -95,32 +95,24 @@ screen.register_global_shortcut(Tuile::Keys::CTRL_L,
|
|
|
95
95
|
end
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
**4. `handle_key` on the focus chain.** Finally, if nothing above claimed
|
|
118
|
-
the key, it's delivered to the focused component's
|
|
119
|
-
{Tuile::Component#handle_key}, and if that returns `false` (didn't handle
|
|
120
|
-
it), it bubbles up the ancestor chain — the focused component, then its
|
|
121
|
-
parent, then *its* parent — until someone returns `true` or the scope
|
|
122
|
-
root is reached. This is how a list handles arrow keys itself but lets an
|
|
123
|
-
unhandled key rise to the window around it.
|
|
98
|
+
This registry is the only keyboard mechanism that sits *above* the
|
|
99
|
+
component tree, and nothing suppresses it — which is exactly why it's
|
|
100
|
+
picky about what it accepts. Printable keys are rejected at registration
|
|
101
|
+
time, because a global binding on `a` would hijack someone typing `a` into
|
|
102
|
+
a text field. So are Tab and Shift+Tab (step 1 already took them), and so
|
|
103
|
+
are `Screen::EDITING_KEYS` — Enter, Backspace, Delete and the arrows —
|
|
104
|
+
because every editable widget needs those, and a global binding would
|
|
105
|
+
break text entry app-wide with no way for a field to defend itself. What's
|
|
106
|
+
left is yours: control keys, ESC, PgUp/PgDn, function keys. A shortcut can
|
|
107
|
+
opt to fire even while a modal popup is open (`over_popups: true`); by
|
|
108
|
+
default it's suppressed while a popup is up, so the popup stays modal.
|
|
109
|
+
|
|
110
|
+
**3. `handle_key`, delivered to focus and bubbling up.** Everything else
|
|
111
|
+
goes to the focused component's {Tuile::Component#handle_key}, and if that
|
|
112
|
+
returns `false` (didn't handle it), the key bubbles up the ancestor chain —
|
|
113
|
+
the focused component, then its parent, then *its* parent — until someone
|
|
114
|
+
returns `true` or the scope root is reached. This is how a list handles
|
|
115
|
+
arrow keys itself but lets an unhandled key rise to the window around it.
|
|
124
116
|
|
|
125
117
|
A component only ever receives a key when it's on the focus chain, so
|
|
126
118
|
`handle_key` implementations act on the key alone — they never need to
|
|
@@ -128,32 +120,73 @@ check their own `active?` state. And if focus is `nil`, or sits outside
|
|
|
128
120
|
the current modal scope, delivery reaches no one: that's precisely what
|
|
129
121
|
makes an open modal popup modal.
|
|
130
122
|
|
|
131
|
-
|
|
123
|
+
Three rungs, and that's the whole ladder. Tuile used to have a fourth — a
|
|
124
|
+
scan of the scope for a component carrying a matching "shortcut key,"
|
|
125
|
+
which would jump focus to it. It's gone; the next section explains why the
|
|
126
|
+
bubble does that job better.
|
|
132
127
|
|
|
133
|
-
|
|
134
|
-
a search field, and elsewhere in the same window a list has `key_shortcut
|
|
135
|
-
= "d"` for "delete." You click into the field and type "add item." Should
|
|
136
|
-
the "d" in "add" trigger delete?
|
|
128
|
+
## Scope-wide keys live on an ancestor
|
|
137
129
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
the
|
|
130
|
+
Two things every app wants: `1`/`2`/`3` to jump between panes, and Enter to
|
|
131
|
+
submit a form. Neither is a *global* action — each belongs to one region of
|
|
132
|
+
the tree — and neither needs machinery, because bubbling already has the
|
|
133
|
+
right shape. **Put the key on the ancestor that owns the region.**
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
class AppLayout < Tuile::Component::Layout::Absolute
|
|
137
|
+
def handle_key(key)
|
|
138
|
+
case key
|
|
139
|
+
when "1" then @files.focus; true
|
|
140
|
+
when "2" then @log.focus; true
|
|
141
|
+
else false
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Now think about what happens when the user clicks into a search field
|
|
148
|
+
inside `@files` and types "add item". Should the `1` in a typed "item 1"
|
|
149
|
+
jump panes? Obviously not — and it doesn't, for a reason that requires no
|
|
150
|
+
special case at all: **the focused field consumes the key at step 3 and
|
|
151
|
+
returns `true`, so the layout never sees it.** An ancestor only hears the
|
|
152
|
+
keys its descendants declined. That's the whole protection.
|
|
153
|
+
|
|
154
|
+
The same mechanism gives you a form's default button, one form per popup:
|
|
155
|
+
|
|
156
|
+
| focused widget | Enter | outcome |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `TextArea` | consumes it (newline) | the form never sees it |
|
|
159
|
+
| `TextField` with an `on_enter` | consumes it | no double-submit |
|
|
160
|
+
| `TextField` without one | declines | bubbles up → submit |
|
|
161
|
+
| `Button` | consumes it | activates *itself*, not the default |
|
|
162
|
+
| `Checkbox` | consumes it (toggles) | the form never sees it |
|
|
163
|
+
| `Select` | consumes it (opens, then commits) | the form never sees it |
|
|
164
|
+
|
|
165
|
+
Because bubbling stops at the scope root, two forms in two popups each get
|
|
166
|
+
their own Enter — something a global registry structurally cannot do. This
|
|
167
|
+
is also why registering Enter globally is refused in step 2: the registry
|
|
168
|
+
would take it away from all of them at once.
|
|
169
|
+
|
|
170
|
+
The one thing this shape asks of you is that the *parent* holds the key →
|
|
171
|
+
child table, rather than each widget declaring its own mnemonic. That's a
|
|
172
|
+
fair trade: which key jumps where is a decision about the assembly, and it
|
|
173
|
+
reads well in one place.
|
|
174
|
+
|
|
175
|
+
## Where the cursor comes in — and where it doesn't
|
|
142
176
|
|
|
143
177
|
A component signals cursor ownership through
|
|
144
178
|
{Tuile::Component#cursor_position} — return a `Point` and the terminal
|
|
145
|
-
cursor is shown there; return `nil` (the default) and there's no cursor.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
otherwise pick them off.
|
|
179
|
+
cursor is shown there; return `nil` (the default) and there's no cursor. A
|
|
180
|
+
{Tuile::Component::TextField} being edited returns its caret position, so
|
|
181
|
+
the caret you see blinking is the focused component's answer to that one
|
|
182
|
+
question.
|
|
183
|
+
|
|
184
|
+
That's all it does. It positions the hardware cursor; it does not route
|
|
185
|
+
keys. Tuile briefly used it as a proxy for "this widget is in text-entry
|
|
186
|
+
mode, don't steal its printable keys" — a signal the deleted fourth rung
|
|
187
|
+
needed. With dispatch resting on nothing but "did you return `true`," the
|
|
188
|
+
proxy is gone, and a component's decision to consume a key is the only
|
|
189
|
+
declaration in the system.
|
|
157
190
|
|
|
158
191
|
## The status bar writes itself
|
|
159
192
|
|
data/book/06-theming.md
CHANGED
|
@@ -38,6 +38,55 @@ colors plus an app-extensible `custom` hash — and that's all. Two are
|
|
|
38
38
|
built in: {Tuile::Theme::DARK}, the colors Tuile has always used, and
|
|
39
39
|
{Tuile::Theme::LIGHT}, counterparts legible on a pale background.
|
|
40
40
|
|
|
41
|
+
## Backgrounds are opt-in
|
|
42
|
+
|
|
43
|
+
The stance above — paint accents, leave the rest to the terminal — is how
|
|
44
|
+
the *framework* paints. But sometimes your *app* wants a real background:
|
|
45
|
+
an overlay panel, a slash-command menu, a dropdown that has to read as one
|
|
46
|
+
solid tinted block floating over the content beneath it — filler rows
|
|
47
|
+
included, not a ragged half-shaded box. That is {Tuile::Component#bg_color}.
|
|
48
|
+
|
|
49
|
+
Set it on a component and that component paints a background behind
|
|
50
|
+
everything it draws; leave it unset (the default) and you get the
|
|
51
|
+
terminal default, exactly as before. The useful part is that it
|
|
52
|
+
**inherits**: set `bg_color` once on a container — a
|
|
53
|
+
{Tuile::Component::Popup}, a layout, a window — and every descendant that
|
|
54
|
+
hasn't set its own picks it up. You tint the panel, and the labels and
|
|
55
|
+
lists inside come out on the same tint without being told individually.
|
|
56
|
+
|
|
57
|
+
That inheritance has to be *manufactured*, because a terminal has no
|
|
58
|
+
transparency: every cell holds exactly one background, and painting a
|
|
59
|
+
glyph replaces the whole cell (chapter 2's opaque grid). So Tuile resolves
|
|
60
|
+
the effective background at paint time by walking up to the nearest
|
|
61
|
+
ancestor that set a `bg_color` — the same read-at-paint discipline the
|
|
62
|
+
theme uses, and for the same reason: change a container's background and
|
|
63
|
+
its subtree is invalidated and simply repaints in the new color. The
|
|
64
|
+
terminal default is just the root of that chain, which is why an unset
|
|
65
|
+
`bg_color` everywhere behaves exactly like it always has.
|
|
66
|
+
|
|
67
|
+
A widget with a background of its *own* keeps it. A text field paints its
|
|
68
|
+
well across its whole rect, so dropping one into a tinted panel shows the
|
|
69
|
+
field in its own well, not the panel tint — the explicit background wins
|
|
70
|
+
over the inherited one, the terminal equivalent of a CSS element that sets
|
|
71
|
+
its own `background`.
|
|
72
|
+
|
|
73
|
+
`bg_color` takes either a concrete {Tuile::Color} or a *live theme
|
|
74
|
+
reference* — `Theme.ref(:panel_bg)` — that names one of your app's custom
|
|
75
|
+
tokens and re-resolves it against the current theme on every paint. The
|
|
76
|
+
reference is the ergonomic path: assign it once and the panel follows
|
|
77
|
+
light and dark on its own, with no `on_theme_changed` handler. That works
|
|
78
|
+
precisely because a background — unlike the baked-in colors of your
|
|
79
|
+
*content* (below) — is resolved *live* at paint, exactly like the
|
|
80
|
+
framework's own accents; `bg_color` is a single value read late, so
|
|
81
|
+
late-binding it to a token costs nothing. It is the one place an app color
|
|
82
|
+
tracks the theme without the hook.
|
|
83
|
+
|
|
84
|
+
A reference reaches your *custom* tokens only, never a framework-imposed
|
|
85
|
+
global, so the "no global background token" line holds either way: the
|
|
86
|
+
framework still paints no background of its own. You opt in per
|
|
87
|
+
component — a concrete `Color` when you want it fixed, a `Theme.ref` when
|
|
88
|
+
you want it to follow the scheme.
|
|
89
|
+
|
|
41
90
|
## Read at paint time, never cached
|
|
42
91
|
|
|
43
92
|
There is one rule about *using* the theme that everything else depends
|
|
@@ -207,7 +256,10 @@ declaration site says nothing.
|
|
|
207
256
|
|
|
208
257
|
The built-in components restyle for free because they read the theme at
|
|
209
258
|
paint time. Your *content* can't always do that — and this is the one
|
|
210
|
-
theming responsibility that lands on the app.
|
|
259
|
+
theming responsibility that lands on the app. (A *background* is the
|
|
260
|
+
exception: `bg_color = Theme.ref(:token)` tracks the theme live with no
|
|
261
|
+
handler, because it's resolved at paint — see "Backgrounds are opt-in"
|
|
262
|
+
above. The hook below is for baked-in *content* colors.)
|
|
211
263
|
|
|
212
264
|
The problem: when you build a {Tuile::StyledString} for a
|
|
213
265
|
{Tuile::Component::Label} or a {Tuile::Component::List} row, its colors
|