tuile 0.8.0 → 0.9.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 +15 -0
- data/README.md +51 -24
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +209 -0
- data/book/05-focus.md +188 -0
- data/book/06-theming.md +250 -0
- data/book/07-components.md +231 -0
- data/book/08-testing.md +186 -0
- data/book/README.md +79 -0
- data/examples/hello_world.rb +1 -2
- data/lib/tuile/buffer.rb +100 -31
- data/lib/tuile/component/button.rb +1 -9
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/label.rb +7 -15
- data/lib/tuile/component/layout.rb +0 -9
- data/lib/tuile/component/list.rb +0 -26
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/popup.rb +70 -75
- data/lib/tuile/component/text_view.rb +0 -23
- data/lib/tuile/component/window.rb +57 -75
- data/lib/tuile/component.rb +1 -60
- data/lib/tuile/event_queue.rb +31 -10
- data/lib/tuile/fake_event_queue.rb +21 -5
- data/lib/tuile/fraction.rb +46 -0
- data/lib/tuile/screen.rb +8 -5
- data/lib/tuile/screen_pane.rb +5 -3
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +195 -223
- metadata +12 -4
- data/ideas/back-buffer.md +0 -217
- data/lib/tuile/sizing.rb +0 -59
data/book/05-focus.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# 5. Focus and the keyboard
|
|
2
|
+
|
|
3
|
+
Chapter 4 ended on a promise: because everything runs on one thread, this
|
|
4
|
+
chapter can describe keyboard handling without a single caveat about
|
|
5
|
+
concurrency. When a key is dispatched, nothing else is happening — no
|
|
6
|
+
repaint mid-flight, no background thread mutating the tree. So the only
|
|
7
|
+
question left is the interesting one: given a keystroke and a tree of
|
|
8
|
+
components, *who gets it?*
|
|
9
|
+
|
|
10
|
+
The answer has two halves. **Focus** decides which component is the
|
|
11
|
+
current target. **Dispatch** decides the order in which components are
|
|
12
|
+
offered a key — because focus is the common case, not the only case.
|
|
13
|
+
|
|
14
|
+
## The focus chain
|
|
15
|
+
|
|
16
|
+
At any moment, at most one component is **focused**. You set it directly:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
screen.focused = some_component # or: some_component.focus
|
|
20
|
+
screen.focused = nil # nothing focused
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Focus is not just a flag on one component, though — it's a *chain*.
|
|
24
|
+
Setting focus walks from the target up through its parents to the root,
|
|
25
|
+
and marks every component on that path **active**. Everything not on the
|
|
26
|
+
path is deactivated. So if you focus a text field inside a window inside
|
|
27
|
+
the content area, the field, the window, and the content are all active;
|
|
28
|
+
their siblings are not.
|
|
29
|
+
|
|
30
|
+
That active chain is what components paint against. A {Tuile::Component::Window}
|
|
31
|
+
draws its border in the accent color when it's active and a plain color
|
|
32
|
+
when it isn't — which is why, in a multi-window layout, exactly the
|
|
33
|
+
window containing the focused widget lights up. "Active" means "on the
|
|
34
|
+
path to what has focus," and it falls out of one assignment.
|
|
35
|
+
|
|
36
|
+
## Two gates: `focusable?` and `tab_stop?`
|
|
37
|
+
|
|
38
|
+
Not everything can be focused, and not everything focusable participates
|
|
39
|
+
in every way of *getting* focused. Two predicates draw those lines, and
|
|
40
|
+
they're independent on purpose.
|
|
41
|
+
|
|
42
|
+
{Tuile::Component#focusable?} gates whether a component can become a focus
|
|
43
|
+
target *at all*. It's `false` by default — a {Tuile::Component::Label} is
|
|
44
|
+
decoration; clicking one shouldn't yank focus away from the window around
|
|
45
|
+
it. Controls that accept input (a text field, a list, a button) override
|
|
46
|
+
it to `true`. This gate is what makes click-to-focus sane: clicking lands
|
|
47
|
+
focus on the component under the cursor *only if it's focusable*,
|
|
48
|
+
otherwise the click is ignored for focus purposes. The same rule governs
|
|
49
|
+
the automatic focus-forwarding a container does when it's focused — a
|
|
50
|
+
window handed focus passes it down to its content, but only if that
|
|
51
|
+
content is focusable.
|
|
52
|
+
|
|
53
|
+
{Tuile::Component#tab_stop?} gates something narrower: whether Tab and
|
|
54
|
+
Shift+Tab land on this component while cycling. It's also `false` by
|
|
55
|
+
default, and it *implies* focusable — a tab stop is always a valid focus
|
|
56
|
+
target, but not every focus target is a tab stop. The distinction matters
|
|
57
|
+
for containers: a {Tuile::Component::Window} is focusable (so a click on
|
|
58
|
+
its chrome, or a `key_shortcut`, can focus it) but is *not* a tab stop
|
|
59
|
+
(Tab should skip the frame and stop on the actual inputs inside it). So:
|
|
60
|
+
|
|
61
|
+
- **Label** — neither. Decoration.
|
|
62
|
+
- **Window, Popup** — focusable, not a tab stop. Clickable chrome,
|
|
63
|
+
skipped by Tab.
|
|
64
|
+
- **TextField, List, Button** — both. Real inputs you can click *and*
|
|
65
|
+
Tab to.
|
|
66
|
+
|
|
67
|
+
Tab cycling is confined to a **modal scope**: the topmost modal popup if
|
|
68
|
+
one is open, otherwise the tiled content. Tab collects the tab stops in
|
|
69
|
+
that scope in tree order and advances by one, wrapping around; Shift+Tab
|
|
70
|
+
walks backward. This is what keeps Tab from escaping an open dialog — the
|
|
71
|
+
scope is the dialog, so cycling stays inside it.
|
|
72
|
+
|
|
73
|
+
## The dispatch order
|
|
74
|
+
|
|
75
|
+
When a key arrives, it's offered to the tree in a fixed order, and the
|
|
76
|
+
first handler to claim it wins. Understanding this order is the whole
|
|
77
|
+
game, because a key you expected one component to get can be intercepted
|
|
78
|
+
earlier.
|
|
79
|
+
|
|
80
|
+
**1. Tab and Shift+Tab — focus navigation, first, always.** These are
|
|
81
|
+
intercepted before anything else and drive the cycling described above.
|
|
82
|
+
They're taken off the top deliberately: a focused text field swallows
|
|
83
|
+
almost every printable key (step 3 explains how), and if Tab weren't
|
|
84
|
+
reserved here, a field would trap it too and you could never Tab out.
|
|
85
|
+
|
|
86
|
+
**2. Global shortcuts.** App-level shortcuts registered with
|
|
87
|
+
{Tuile::Screen#register_global_shortcut} fire next, before any component
|
|
88
|
+
sees the key. These are for app-wide actions — "Ctrl+L opens the log,"
|
|
89
|
+
"F1 shows help" — that should work regardless of what's focused:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
screen.register_global_shortcut(Tuile::Keys::CTRL_L,
|
|
93
|
+
hint: "^L #{screen.theme.hint("log")}") do
|
|
94
|
+
log_popup.open
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Only *unprintable* keys are allowed here (control keys, function keys,
|
|
99
|
+
escape sequences). Printable keys are rejected at registration time,
|
|
100
|
+
because a global binding on `a` would hijack someone typing `a` into a
|
|
101
|
+
text field — that's what the per-component `key_shortcut` in step 3 is
|
|
102
|
+
for. A shortcut can opt to fire even while a modal popup is open
|
|
103
|
+
(`over_popups: true`); by default it's suppressed while a popup is up, so
|
|
104
|
+
the popup stays modal.
|
|
105
|
+
|
|
106
|
+
**3. A `key_shortcut` anywhere in the focused scope.** Every component can
|
|
107
|
+
carry a {Tuile::Component#key_shortcut} — a single key that, when pressed,
|
|
108
|
+
*jumps focus to that component*. The screen searches the current modal
|
|
109
|
+
scope's subtree for a component whose shortcut matches; if it finds one,
|
|
110
|
+
it focuses it and consumes the key. A window advertises its shortcut in
|
|
111
|
+
its caption (`[f]-Files`), so a whole pane can be reachable with one key.
|
|
112
|
+
|
|
113
|
+
But this search is **suppressed while a text widget is mid-edit** — and
|
|
114
|
+
that suppression is the subtle, important part, so it gets its own
|
|
115
|
+
section below.
|
|
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.
|
|
124
|
+
|
|
125
|
+
A component only ever receives a key when it's on the focus chain, so
|
|
126
|
+
`handle_key` implementations act on the key alone — they never need to
|
|
127
|
+
check their own `active?` state. And if focus is `nil`, or sits outside
|
|
128
|
+
the current modal scope, delivery reaches no one: that's precisely what
|
|
129
|
+
makes an open modal popup modal.
|
|
130
|
+
|
|
131
|
+
## Why a text field can just type
|
|
132
|
+
|
|
133
|
+
Here's the problem the cursor-ownership rule solves. Suppose a window has
|
|
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?
|
|
137
|
+
|
|
138
|
+
Obviously not — and step 3 is where it would go wrong, because "d" *is* a
|
|
139
|
+
registered `key_shortcut` in the scope. The rule that saves you: the
|
|
140
|
+
`key_shortcut` search in step 3 is skipped whenever a component **owns
|
|
141
|
+
the hardware cursor**.
|
|
142
|
+
|
|
143
|
+
A component signals cursor ownership through
|
|
144
|
+
{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
|
+
A {Tuile::Component::TextField} being edited returns its caret position,
|
|
147
|
+
which is non-`nil`, so the screen knows a text widget is mid-edit and
|
|
148
|
+
suppresses shortcut capture. The "d" flows straight through step 3 to
|
|
149
|
+
step 4, where the focused field's `handle_key` inserts it. Tab (step 1)
|
|
150
|
+
still works, because it's reserved above all this — so you can always Tab
|
|
151
|
+
out of the field, at which point the cursor goes away and shortcuts light
|
|
152
|
+
back up.
|
|
153
|
+
|
|
154
|
+
That's the entire mechanism: printable keys belong to whoever owns the
|
|
155
|
+
cursor, and owning the cursor mutes the sibling shortcuts that would
|
|
156
|
+
otherwise pick them off.
|
|
157
|
+
|
|
158
|
+
## The status bar writes itself
|
|
159
|
+
|
|
160
|
+
You've seen the bottom row showing hints like `q quit` since chapter 1.
|
|
161
|
+
It's driven by focus. Whenever focus changes, the screen rebuilds the
|
|
162
|
+
status bar from two sources: the currently-relevant shortcuts, and the
|
|
163
|
+
focused context's own advertised hint.
|
|
164
|
+
|
|
165
|
+
A component advertises its hint by overriding
|
|
166
|
+
{Tuile::Component#keyboard_hint} to return a preformatted string
|
|
167
|
+
(components build these with `theme.hint(...)` so the styling matches).
|
|
168
|
+
The screen composes the bar differently depending on what's in front:
|
|
169
|
+
|
|
170
|
+
- **Tiled (no popup):** `q quit`, then any global-shortcut hints, then the
|
|
171
|
+
active window's `keyboard_hint`.
|
|
172
|
+
- **Popup open:** the over-popups global hints, then the popup's own hint
|
|
173
|
+
(a popup owns its `q Close` prefix).
|
|
174
|
+
|
|
175
|
+
You don't assemble the bar yourself; you override `keyboard_hint` on the
|
|
176
|
+
components that have shortcuts worth advertising, register global
|
|
177
|
+
shortcuts with a `hint:`, and the composition happens on every focus
|
|
178
|
+
change. The status bar is a *view* of the focus state, not a thing you
|
|
179
|
+
maintain.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
Focus and dispatch are the last piece of the runtime. You now have the
|
|
184
|
+
whole loop: build a tree (chapter 1), it repaints without flicker
|
|
185
|
+
(chapter 2), sized top-down (chapter 3), driven by a single-threaded
|
|
186
|
+
event loop (chapter 4), with keys routed through focus (this chapter).
|
|
187
|
+
Chapter 6 adds the last cross-cutting concern — color — and then chapters
|
|
188
|
+
7 and 8 turn from *how the framework works* to *what you build with it*.
|
data/book/06-theming.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# 6. Theming
|
|
2
|
+
|
|
3
|
+
Every chapter so far has been about *structure* — the tree, the repaint,
|
|
4
|
+
the loop, focus. This one is about a single presentational concern that
|
|
5
|
+
cuts across all of them: color. It's the last cross-cutting piece of the
|
|
6
|
+
runtime, and it's small, because Tuile takes a deliberately narrow view
|
|
7
|
+
of what a "theme" is. A theme in Tuile is not a stylesheet. It does not
|
|
8
|
+
describe how your app looks. It describes the handful of *accents* the
|
|
9
|
+
framework itself paints — and nothing else.
|
|
10
|
+
|
|
11
|
+
That restraint is the whole design. Understand why the theme is small
|
|
12
|
+
and you understand theming.
|
|
13
|
+
|
|
14
|
+
## The theme colors only the accents
|
|
15
|
+
|
|
16
|
+
Look at any Tuile screen and most of what you see is the terminal's own
|
|
17
|
+
colors: the default foreground text on the default background. A label's
|
|
18
|
+
text, a window's interior, the body of a list — none of that is themed.
|
|
19
|
+
It inherits whatever the user's terminal is set to, which already matches
|
|
20
|
+
their preferences perfectly. Tuile writes no background fill and no
|
|
21
|
+
foreground color for those cells, so they come out in the terminal's
|
|
22
|
+
defaults for free.
|
|
23
|
+
|
|
24
|
+
What Tuile *does* color is the small set of cues that signal
|
|
25
|
+
interaction: the highlight behind the focused list row, the border of the
|
|
26
|
+
active window, the resting "well" of a text field, the shortcut captions
|
|
27
|
+
in the status bar. Those are the accents, and they are exactly the tokens
|
|
28
|
+
a {Tuile::Theme} carries — `active_bg_color`, `active_border_color`,
|
|
29
|
+
`input_bg_color`, `hint_color`. There is no global `bg` or `fg` token,
|
|
30
|
+
and that absence is intentional: adding one would mean painting over the
|
|
31
|
+
terminal's defaults everywhere, which is precisely the thing that makes a
|
|
32
|
+
TUI look wrong on someone else's color scheme. The theme touches only
|
|
33
|
+
what the framework must color to be legible, and leaves the rest to the
|
|
34
|
+
terminal.
|
|
35
|
+
|
|
36
|
+
So a {Tuile::Theme} is a frozen value type — a `Data.define` of four
|
|
37
|
+
colors plus an app-extensible `custom` hash — and that's all. Two are
|
|
38
|
+
built in: {Tuile::Theme::DARK}, the colors Tuile has always used, and
|
|
39
|
+
{Tuile::Theme::LIGHT}, counterparts legible on a pale background.
|
|
40
|
+
|
|
41
|
+
## Read at paint time, never cached
|
|
42
|
+
|
|
43
|
+
There is one rule about *using* the theme that everything else depends
|
|
44
|
+
on, and it's stated as an invariant in AGENTS.md because breaking it
|
|
45
|
+
breaks live theme switching: **a component reads `screen.theme` at paint
|
|
46
|
+
time, inside `repaint`, and never stores a theme color in an ivar.**
|
|
47
|
+
|
|
48
|
+
The reason is the repaint model from chapter 2. When the theme changes,
|
|
49
|
+
Tuile does not hunt down every component and tell it which colors to
|
|
50
|
+
update. It does the crude, correct thing: it invalidates the entire tree
|
|
51
|
+
and lets the normal repaint redraw everything. On that repaint each
|
|
52
|
+
component asks `screen.theme` for its accents afresh — so it simply comes
|
|
53
|
+
out in the new colors, with no per-component update logic anywhere. A
|
|
54
|
+
component that cached `theme.active_bg_color` in its constructor would
|
|
55
|
+
keep painting the old color after a switch, an island of stale palette in
|
|
56
|
+
an otherwise-restyled screen. Read it every time; the lookup is a hash
|
|
57
|
+
access, and the repaint that follows a theme change was going to redraw
|
|
58
|
+
you regardless.
|
|
59
|
+
|
|
60
|
+
This is why the built-in components need no theme-change handling at all.
|
|
61
|
+
They read at paint time, the tree is invalidated, they repaint in the new
|
|
62
|
+
colors. Done.
|
|
63
|
+
|
|
64
|
+
## Two ways to apply a token
|
|
65
|
+
|
|
66
|
+
When a component has a themed color in hand, it applies it in one of two
|
|
67
|
+
ways, and which one depends on the text.
|
|
68
|
+
|
|
69
|
+
For plain chrome — a border string, a status-bar hint — the theme's
|
|
70
|
+
**rendering helpers** wrap the text in the token's SGR color and a reset:
|
|
71
|
+
`theme.active_bg("[ Ok ]")`, `theme.hint("quit")`. The helper picks the
|
|
72
|
+
right channel for the token's role (a `*_bg` token wraps as a background,
|
|
73
|
+
a hint as a foreground) and passes the content through verbatim, so the
|
|
74
|
+
string may already contain other escape sequences — which is how
|
|
75
|
+
{Tuile::Component::Window} feeds its whole border line, cursor moves and
|
|
76
|
+
all, through `active_border`.
|
|
77
|
+
|
|
78
|
+
But chrome text is flat. Content is not. A list row or a label may be a
|
|
79
|
+
{Tuile::StyledString} with its own per-span colors, and wrapping that in
|
|
80
|
+
one blunt SGR color would flatten every span to a single hue. For those,
|
|
81
|
+
the theme exposes the raw color as a `*_color` reader
|
|
82
|
+
(`theme.active_bg_color`) and you hand it to the StyledString, which
|
|
83
|
+
composites it *underneath* the existing spans — {Tuile::Component::List}
|
|
84
|
+
highlights its cursor row with `base.with_bg(theme.active_bg_color)`,
|
|
85
|
+
preserving whatever foreground colors the row already carried. The rule
|
|
86
|
+
of thumb: **plain chrome text → helper; structured text → `*_color`
|
|
87
|
+
reader plus StyledString.**
|
|
88
|
+
|
|
89
|
+
## Following the terminal, automatically
|
|
90
|
+
|
|
91
|
+
An app never has to ask which theme to use. Tuile picks one by detecting
|
|
92
|
+
whether the terminal background is light or dark, through
|
|
93
|
+
{Tuile::TerminalBackground}.detect — two mechanisms in order of
|
|
94
|
+
reliability. First an **OSC 11 query**: Tuile writes an escape sequence
|
|
95
|
+
asking the terminal for its background color, and a modern terminal
|
|
96
|
+
replies with the RGB, whose luminance decides light versus dark.
|
|
97
|
+
Terminals that don't understand the query simply never answer, so the
|
|
98
|
+
read is bounded by a short timeout and falls through to the second
|
|
99
|
+
mechanism, the `COLORFGBG` environment variable that a few terminals
|
|
100
|
+
export. If both are inconclusive, Tuile assumes dark.
|
|
101
|
+
|
|
102
|
+
The timing of that detection is subtle enough to be a design constraint.
|
|
103
|
+
The OSC 11 *reply arrives on stdin* — the same stream the key thread will
|
|
104
|
+
own once the event loop is running. If detection ran after the loop
|
|
105
|
+
started, those reply bytes would land in the key thread and be consumed
|
|
106
|
+
as a garbage keystroke. So detection must happen *before* stdin is
|
|
107
|
+
claimed, which is why {Tuile::Screen} runs it in its constructor, seeding
|
|
108
|
+
`theme` before your app has built a single component. This is not an
|
|
109
|
+
implementation detail you can relocate — it's why the constructor, not
|
|
110
|
+
some later `setup` call, is where the scheme is decided.
|
|
111
|
+
|
|
112
|
+
Detection at startup handles the common case. But a user can also flip
|
|
113
|
+
their OS between light and dark *while your app is running*, and Tuile
|
|
114
|
+
follows that too, on terminals that support **mode 2031**. The event loop
|
|
115
|
+
enables the mode on startup; the terminal then pushes a small report
|
|
116
|
+
whenever the OS appearance changes; the key thread recognizes that report
|
|
117
|
+
(it's a private-mode CSI sequence, longer than an ordinary key, so it's
|
|
118
|
+
drained specially) and turns it into an {Tuile::EventQueue::ColorSchemeEvent};
|
|
119
|
+
and the loop, receiving that event like any other, re-picks the matching
|
|
120
|
+
theme. From your code's perspective a live appearance flip and a startup
|
|
121
|
+
detection are the same thing arriving through the same channel — which is
|
|
122
|
+
exactly the single-threaded-loop payoff chapter 4 promised.
|
|
123
|
+
|
|
124
|
+
## Theming an app durably
|
|
125
|
+
|
|
126
|
+
Detection picks between *Tuile's* two themes. To give your app its own
|
|
127
|
+
colors, you supply your own — and here the distinction between a
|
|
128
|
+
transient override and a durable definition matters, because it's easy to
|
|
129
|
+
reach for the wrong one.
|
|
130
|
+
|
|
131
|
+
You *can* assign `screen.theme = ...` directly, and it works: the whole
|
|
132
|
+
UI restyles immediately. But it's a **transient override**. The next time
|
|
133
|
+
the OS appearance flips, Tuile re-picks from its theme *definition* and
|
|
134
|
+
replaces whatever you set. A bare `theme=` is the right tool for a
|
|
135
|
+
one-shot experiment, not for how your app looks.
|
|
136
|
+
|
|
137
|
+
The durable tool is a {Tuile::ThemeDef} — a pair of themes, one for dark
|
|
138
|
+
backgrounds and one for light — assigned once to `screen.theme_def=`.
|
|
139
|
+
Now Tuile picks the matching member at startup *and* re-picks from your
|
|
140
|
+
pair on every appearance flip, so your app stays your app's colors
|
|
141
|
+
through a light/dark toggle. That's the durable path: define the pair,
|
|
142
|
+
assign it once, forget about it.
|
|
143
|
+
|
|
144
|
+
The pair is required to be a *pair* for a reason. A {Tuile::ThemeDef}
|
|
145
|
+
enforces at construction time that its dark and light members declare the
|
|
146
|
+
same set of custom tokens. Without that check, a token you defined only
|
|
147
|
+
on the dark side would raise `KeyError` the moment the user flipped to
|
|
148
|
+
light — at an unpredictable time, far from the mistake. Checking up front
|
|
149
|
+
turns a lurking runtime crash into an immediate, obvious construction
|
|
150
|
+
error.
|
|
151
|
+
|
|
152
|
+
### Custom tokens
|
|
153
|
+
|
|
154
|
+
Your app almost certainly paints colors the framework knows nothing
|
|
155
|
+
about — a "tool call" cyan, an "error" red, diff-line backgrounds. Those
|
|
156
|
+
live in the theme's `custom` hash, a `Hash{Symbol => Color}`. You read
|
|
157
|
+
one with `theme[:accent]`, which **fail-fasts**: a typo'd token raises
|
|
158
|
+
`KeyError` rather than silently painting a default, so a missing color is
|
|
159
|
+
a loud bug and not a mystery. And you render with the generic `fg` / `bg`
|
|
160
|
+
helpers — `theme.fg(:accent, "NEW")` — the custom-token counterparts of
|
|
161
|
+
the built-in `hint` / `active_bg` helpers.
|
|
162
|
+
|
|
163
|
+
For an app with more than a couple of custom tokens, the tidier move is
|
|
164
|
+
to **subclass** {Tuile::Theme} and give each token a named coloring
|
|
165
|
+
method:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
class AppTheme < Tuile::Theme
|
|
169
|
+
def error(text) = fg(:error, text)
|
|
170
|
+
def tool(text) = fg(:tool, text)
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Call sites then read `theme.error("...")` instead of the stringly-typed
|
|
175
|
+
`theme.fg(:error, "...")`, and because a `Data` subclass survives `with`,
|
|
176
|
+
your `AppTheme` stays an `AppTheme` through any `with` derivation. You
|
|
177
|
+
build the dark and light instances from the built-in themes plus your
|
|
178
|
+
custom hashes and pair them in a `ThemeDef` — the pattern is small enough
|
|
179
|
+
to state in full:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
class AppTheme < Tuile::Theme
|
|
183
|
+
def error(text) = fg(:error, text)
|
|
184
|
+
|
|
185
|
+
DARK = new(**Tuile::Theme::DARK.to_h.merge(custom: { error: Tuile::Color::RED }))
|
|
186
|
+
LIGHT = new(**Tuile::Theme::LIGHT.to_h.merge(custom: { error: Tuile::Color::RED3 }))
|
|
187
|
+
THEME_DEF = Tuile::ThemeDef.new(dark: DARK, light: LIGHT)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
screen.theme_def = AppTheme::THEME_DEF # once, at boot
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Note the light-side error color isn't the same red — bright ANSI accents
|
|
194
|
+
that pop on black often turn illegible on white, so a real light theme
|
|
195
|
+
steps its accents darker. That per-side tuning is the entire point of
|
|
196
|
+
carrying two themes instead of one.
|
|
197
|
+
|
|
198
|
+
One aside on declaring theme colors: a {Tuile::Theme} takes {Tuile::Color}
|
|
199
|
+
instances *only*, never the lenient coercions (`"red"`, a bare palette
|
|
200
|
+
integer) that {Tuile::Color}.coerce accepts elsewhere. A theme is
|
|
201
|
+
declared once per app, so the extra verbosity buys self-documentation —
|
|
202
|
+
`Color.palette(130)` says "palette index," and the named constant
|
|
203
|
+
`Color::DARK_ORANGE3` says even more, where a bare `130` at the
|
|
204
|
+
declaration site says nothing.
|
|
205
|
+
|
|
206
|
+
## When the theme changes under your content
|
|
207
|
+
|
|
208
|
+
The built-in components restyle for free because they read the theme at
|
|
209
|
+
paint time. Your *content* can't always do that — and this is the one
|
|
210
|
+
theming responsibility that lands on the app.
|
|
211
|
+
|
|
212
|
+
The problem: when you build a {Tuile::StyledString} for a
|
|
213
|
+
{Tuile::Component::Label} or a {Tuile::Component::List} row, its colors
|
|
214
|
+
are **baked in at construction**. The string is a frozen value with its
|
|
215
|
+
SGR bytes already computed; it does not consult the theme at paint time,
|
|
216
|
+
because {Tuile::StyledString} deliberately knows nothing about `Screen` at
|
|
217
|
+
all (that independence is what lets it be a pure, memoizable value type).
|
|
218
|
+
So if you colored a label with `theme[:accent]` and the theme later
|
|
219
|
+
changes, that label keeps its old accent — the framework can't fix it,
|
|
220
|
+
because only *you* know which of the string's colors came from the theme
|
|
221
|
+
versus which are inherent to the data (a log line's level color, say,
|
|
222
|
+
should *not* follow the theme).
|
|
223
|
+
|
|
224
|
+
The hook for this is {Tuile::Component#on_theme_changed}, fired on every
|
|
225
|
+
attached component whenever the theme changes. Your handler does exactly
|
|
226
|
+
one thing: **re-run the code that rendered the content**, so it rebuilds
|
|
227
|
+
the StyledString against the now-current theme.
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
label.on_theme_changed = -> { label.text = render_status_line }
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
There are two ways to consume it, matching how you built the component.
|
|
234
|
+
If you assembled stock components, assign the `on_theme_changed=` proc as
|
|
235
|
+
above. If you subclassed, override the method — and call `super`, so an
|
|
236
|
+
assigned listener still fires. Either way the rule is the same: the hook
|
|
237
|
+
is where theme-derived content gets rebuilt, and the framework handles
|
|
238
|
+
everything else.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
That closes the runtime. Across six chapters you've seen the whole
|
|
243
|
+
machine: a tree of components (chapter 1), repainting without flicker
|
|
244
|
+
(chapter 2), sized top-down by their parents (chapter 3), driven by a
|
|
245
|
+
single-threaded event loop (chapter 4), with keys routed through focus
|
|
246
|
+
(chapter 5) and accents drawn from a terminal-following theme (this one).
|
|
247
|
+
Everything from here is *application*: chapter 7 tours the component
|
|
248
|
+
library — what Tuile ships so you don't build it — and chapter 8 shows
|
|
249
|
+
how to test a UI built this way, using the fakes the design has been
|
|
250
|
+
quietly setting up all along.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# 7. The component library
|
|
2
|
+
|
|
3
|
+
The last six chapters were about the machine: the tree, the repaint, the
|
|
4
|
+
loop, focus, color. This one turns the other way and asks what you
|
|
5
|
+
actually assemble on top of it. Tuile ships a small toolbox of ready
|
|
6
|
+
components, and the point of this chapter is not to enumerate their
|
|
7
|
+
methods — the rdoc does that, per symbol, and links are scattered
|
|
8
|
+
throughout below — but to answer the question you have when you start a
|
|
9
|
+
screen: *given what I'm trying to show or capture, which component do I
|
|
10
|
+
reach for?*
|
|
11
|
+
|
|
12
|
+
So this is a tour organized by the job, not by the class name. And
|
|
13
|
+
because every one of these is a {Tuile::Component}, everything you already
|
|
14
|
+
know still holds: its parent sizes it top-down (chapter 3), it invalidates
|
|
15
|
+
rather than paints (chapter 2), focus decides whether it sees a key
|
|
16
|
+
(chapter 5), and it draws its accents from the theme at paint time
|
|
17
|
+
(chapter 6). The components don't reintroduce any of that; they just fill
|
|
18
|
+
in the leaves of the tree.
|
|
19
|
+
|
|
20
|
+
## Showing text
|
|
21
|
+
|
|
22
|
+
The simplest job is putting text on the screen, and the choice comes down
|
|
23
|
+
to one question: **does it need to wrap?**
|
|
24
|
+
|
|
25
|
+
If not — a title, a status line, a single field of data — reach for
|
|
26
|
+
{Tuile::Component::Label}. A label shows one or more lines of text and
|
|
27
|
+
does *not* word-wrap; a line too wide for its rect is truncated with an
|
|
28
|
+
ellipsis. That's a feature, not a limitation: a label is chrome, and
|
|
29
|
+
chrome that reflows unpredictably when the terminal narrows is worse than
|
|
30
|
+
chrome that clips. Its text is a {Tuile::StyledString}, so embedded color
|
|
31
|
+
survives, and you can hand it either a plain `String` (parsed for ANSI) or
|
|
32
|
+
a StyledString directly:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
label = Component::Label.new("Ready")
|
|
36
|
+
label.text = "#{files.size} files"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
When the text *is* prose — a help screen, a rendered Markdown reply, a log
|
|
40
|
+
of wrapped lines — reach for {Tuile::Component::TextView} instead. It's
|
|
41
|
+
the read-only counterpart to a label: string-shaped content in, but
|
|
42
|
+
word-wrapped to its width (preserving spans across the wrap, so color
|
|
43
|
+
isn't lost on continuation rows) and vertically scrollable, with the
|
|
44
|
+
scroll keys and optional scrollbar you'd expect. For a growing view it
|
|
45
|
+
gives you the right primitives for the right shape of update — `append`
|
|
46
|
+
(aliased `<<`) concatenates verbatim for streaming, `add_line` starts a
|
|
47
|
+
fresh line like a log entry, and `remove_last_n_lines` retracts the tail
|
|
48
|
+
when you're rebuilding reformattable content. Turn on `auto_scroll` to
|
|
49
|
+
keep the latest content in view. A TextView is meant to live inside a
|
|
50
|
+
{Tuile::Component::Window} — it leans on the surrounding chrome for focus
|
|
51
|
+
indication and keyboard hints.
|
|
52
|
+
|
|
53
|
+
So: **Label truncates, TextView wraps.** That single line is the whole
|
|
54
|
+
decision.
|
|
55
|
+
|
|
56
|
+
## Editing text
|
|
57
|
+
|
|
58
|
+
When you need input back from the user, the two editable components share
|
|
59
|
+
a base — {Tuile::Component::TextInput} — and differ only in shape.
|
|
60
|
+
|
|
61
|
+
{Tuile::Component::TextField} is a single line with a real hardware caret.
|
|
62
|
+
It does not scroll; a keystroke that would push the text past the field's
|
|
63
|
+
width is simply rejected, so the field always shows its whole contents.
|
|
64
|
+
Because it owns the hardware cursor while focused, it's the component that
|
|
65
|
+
triggers the cursor-ownership rule from chapter 5 — printable keys flow
|
|
66
|
+
straight to it and sibling shortcuts stay muted while you type.
|
|
67
|
+
|
|
68
|
+
{Tuile::Component::TextArea} is the multi-line counterpart: a word-wrapping
|
|
69
|
+
editor that scrolls vertically to keep the caret's line visible, with
|
|
70
|
+
Enter inserting a newline as in any text editor. Like everything else,
|
|
71
|
+
it's sized by its parent — it does not grow to fit its content; text that
|
|
72
|
+
overflows the rect is reached by scrolling.
|
|
73
|
+
|
|
74
|
+
Both inherit the same event hooks from the base, and this is where the
|
|
75
|
+
design pays off: you customize an input by assigning callbacks, not by
|
|
76
|
+
subclassing. `on_change` fires whenever the text changes; `on_escape`
|
|
77
|
+
handles ESC (with a sensible default). The subtle one is `on_key` — an
|
|
78
|
+
interceptor consulted *before* the input's own key handling, which is the
|
|
79
|
+
building block for an autocomplete or slash-command overlay: while the
|
|
80
|
+
overlay is open, `on_key` claims Up/Down/Enter/ESC and forwards them to
|
|
81
|
+
the list, so the caret stays in the field and typing keeps refilling the
|
|
82
|
+
suggestions.
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
field = Component::TextField.new
|
|
86
|
+
field.on_change = ->(text) { filter_results(text) }
|
|
87
|
+
field.on_enter = -> { submit } # nil (default) → Enter bubbles to the parent
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Note that `on_enter` / `on_key_up` / `on_key_down` on a TextField, when
|
|
91
|
+
left `nil`, let those keys *fall through* to the parent — that's how Enter
|
|
92
|
+
in a search field can trigger the surrounding window's action while the
|
|
93
|
+
field still handles ordinary typing.
|
|
94
|
+
|
|
95
|
+
## Choosing from a set
|
|
96
|
+
|
|
97
|
+
{Tuile::Component::List} is the workhorse: a scrollable column of
|
|
98
|
+
{Tuile::StyledString} lines, ellipsized (spans preserved) when too wide.
|
|
99
|
+
What makes it flexible is that its *cursor behavior is a pluggable object*
|
|
100
|
+
rather than a boolean. Assign one of three {Tuile::Component::List::Cursor}
|
|
101
|
+
variants to fit the interaction:
|
|
102
|
+
|
|
103
|
+
- **`Cursor::None`** (the default) — no cursor at all. The list is a
|
|
104
|
+
read-only scroll region: a log, a static report.
|
|
105
|
+
- **`Cursor`** — a moving cursor that lands on any line. Arrows, `jk`,
|
|
106
|
+
Home/End, and Ctrl+U/D move it, and the list scrolls to follow. This is
|
|
107
|
+
the ordinary selectable list.
|
|
108
|
+
- **`Cursor::Limited`** — a cursor confined to a fixed set of allowed
|
|
109
|
+
lines. For a list where only some rows are selectable (headers
|
|
110
|
+
interspersed with items, say), it skips the rest.
|
|
111
|
+
|
|
112
|
+
Two callbacks cover the events you care about. `on_item_chosen` fires when
|
|
113
|
+
the user commits to the cursor's row — Enter or a left-click — and is the
|
|
114
|
+
"open this" signal. `on_cursor_changed` fires when the highlighted row
|
|
115
|
+
*changes*, which is exactly what you wire to keep a details pane in sync
|
|
116
|
+
with the selection. For a tailing list — a live log — set `auto_scroll`;
|
|
117
|
+
it pins to the bottom as lines arrive, but politely stops yanking you down
|
|
118
|
+
the moment you scroll up to read history, and resumes once you scroll back
|
|
119
|
+
(`following?` tells you which). A scrollbar is one assignment
|
|
120
|
+
(`scrollbar_visibility`).
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
list = Component::List.new
|
|
124
|
+
list.lines = entries
|
|
125
|
+
list.cursor = Component::List::Cursor.new
|
|
126
|
+
list.on_item_chosen = ->(index, line) { open(entries[index]) }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
For a discrete action rather than a selection, {Tuile::Component::Button}
|
|
130
|
+
is a one-row `[ caption ]` that fires `on_click` on Enter, Space, or a
|
|
131
|
+
left-click, highlighting its background while focused. It's a tab stop, so
|
|
132
|
+
it joins the normal Tab cycle.
|
|
133
|
+
|
|
134
|
+
## Framing content
|
|
135
|
+
|
|
136
|
+
{Tuile::Component::Window} is the frame: a bordered box with a `caption`
|
|
137
|
+
and a single content slot you fill via `content=`. Its border lights up in
|
|
138
|
+
the theme's accent color when the window is on the focus chain (chapter 5
|
|
139
|
+
+ 6), which is what makes the active pane visually obvious in a multi-pane
|
|
140
|
+
layout. A window paints its whole rect and does not clip against
|
|
141
|
+
neighbors, so windows are meant to tile, not overlap — overlapping is what
|
|
142
|
+
popups are for.
|
|
143
|
+
|
|
144
|
+
The bottom border has two mutually exclusive uses, and the distinction is
|
|
145
|
+
the top-down-layout principle from chapter 3 made concrete. `footer_text=`
|
|
146
|
+
embeds decoration into the border line — chrome, mirroring the caption on
|
|
147
|
+
top, not focusable. `footer=` mounts a *real focusable component* spanning
|
|
148
|
+
the full inner width — the search-field-in-the-border case. A footer
|
|
149
|
+
component present takes the row and hides the text; neither drives the
|
|
150
|
+
window's size (the window was sized by its parent), so a footer that
|
|
151
|
+
doesn't fit is clipped rather than growing the frame. And if the content
|
|
152
|
+
supports scrolling, `scrollbar=` turns on a scrollbar by reclaiming the
|
|
153
|
+
right border column.
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
window = Component::Window.new("Files")
|
|
157
|
+
window.content = Component::List.new.tap { _1.lines = entries }
|
|
158
|
+
window.scrollbar = true
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Overlays
|
|
162
|
+
|
|
163
|
+
{Tuile::Component::Popup} is how you float something above the tiled UI.
|
|
164
|
+
The popup itself paints nothing — it's a transparent host that wraps any
|
|
165
|
+
component as its content and manages the lifecycle (`open` / `close`,
|
|
166
|
+
ESC/`q` to dismiss). Crucially, and per chapter 3, **it does not size
|
|
167
|
+
itself to its content**: its box is declared by `size` — a `Fraction`
|
|
168
|
+
(default `Fraction::HALF`, half the screen, re-resolved on every resize)
|
|
169
|
+
or an absolute `Size`. The content then fills that box, so use content
|
|
170
|
+
that can cope with overflow — a TextView or TextArea that scrolls, not a
|
|
171
|
+
bare Label that only truncates.
|
|
172
|
+
|
|
173
|
+
A popup is **modal by default**: centered, it grabs focus, eats keys, and
|
|
174
|
+
blocks clicks beneath it — that's what makes an open dialog trap Tab and
|
|
175
|
+
input inside itself. Pass `modal: false` for a non-modal overlay that
|
|
176
|
+
floats above the content without taking focus — the autocomplete-list case
|
|
177
|
+
from earlier, where the caller positions it against a field's caret and
|
|
178
|
+
drives it from app code.
|
|
179
|
+
|
|
180
|
+
Because the popup is just a transparent host, you get a bordered dialog by
|
|
181
|
+
wrapping a Window:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
window = Component::Window.new("Help")
|
|
185
|
+
window.content = Component::TextView.new.tap { _1.text = help_text }
|
|
186
|
+
Component::Popup.new(content: window).open
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
A nested TextField that owns the cursor still swallows printable keys
|
|
190
|
+
first, so typing `q` into a field inside a popup doesn't dismiss it — the
|
|
191
|
+
same cursor-ownership rule, working through the layers.
|
|
192
|
+
|
|
193
|
+
## Batteries-included windows
|
|
194
|
+
|
|
195
|
+
The last three components are conveniences: common Window-plus-content
|
|
196
|
+
assemblies you'd otherwise build by hand. Each works tiled (add it to a
|
|
197
|
+
layout) *or* as a popup (via a class-level `open`).
|
|
198
|
+
|
|
199
|
+
- {Tuile::Component::InfoWindow} — a Window preloaded with a List of
|
|
200
|
+
static lines. The read-only "here's some information" box;
|
|
201
|
+
`InfoWindow.open(caption, lines)` pops it up.
|
|
202
|
+
- {Tuile::Component::PickerWindow} — a menu of options each bound to a
|
|
203
|
+
single key, firing your block with the picked key. Popped up via `open`,
|
|
204
|
+
it closes itself after a pick; ESC/`q` cancels without firing.
|
|
205
|
+
- {Tuile::Component::LogWindow} — a Window wrapping an auto-scrolling,
|
|
206
|
+
scrollbar-equipped TextView, purpose-built for log output. Its `log`
|
|
207
|
+
method is **thread-safe** — it marshals the append back onto the UI
|
|
208
|
+
thread via the event queue (chapter 4), so background work can log
|
|
209
|
+
freely. And it carries an `IO`-shaped adapter so you can point a stdlib
|
|
210
|
+
`Logger` (or a `TTY::Logger`) straight at it:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
window = Component::LogWindow.new
|
|
214
|
+
logger = Logger.new(Component::LogWindow::IO.new(window))
|
|
215
|
+
logger.info("started") # appears in the window, from any thread
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
That LogWindow adapter is the tidy end of the thread-safety story chapter
|
|
219
|
+
4 opened: a background thread doesn't know or care that its log line has
|
|
220
|
+
to reach the UI on the loop thread — it writes to a `Logger` as it always
|
|
221
|
+
would, and the plumbing routes it through `submit` for you.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
That's the toolbox. None of it is large, because the framework underneath
|
|
226
|
+
is small and the components inherit most of their behavior from it — which
|
|
227
|
+
is the recurring theme of this whole book. What remains is proving that a
|
|
228
|
+
UI built this way actually works, without a terminal in the loop. The
|
|
229
|
+
fakes the design has been quietly setting up since chapter 2 — the buffer
|
|
230
|
+
you can read back, the synchronous event queue, the in-memory screen — are
|
|
231
|
+
what make that possible, and chapter 8 puts them to work.
|