tuile 0.8.0 → 0.10.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 +47 -0
- data/DECISIONS.md +1961 -0
- data/README.md +82 -48
- 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 +295 -0
- data/book/05-focus.md +219 -0
- data/book/06-theming.md +302 -0
- data/book/07-components.md +585 -0
- data/book/08-testing.md +199 -0
- data/book/09-styled-text.md +132 -0
- data/book/README.md +85 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +113 -43
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -29
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -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/info_window.rb +4 -2
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +20 -25
- data/lib/tuile/component/layout.rb +3 -26
- data/lib/tuile/component/list.rb +8 -33
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/log_window.rb +0 -14
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +70 -79
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -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 -137
- data/lib/tuile/component/window.rb +88 -121
- data/lib/tuile/component.rb +246 -142
- data/lib/tuile/event_queue.rb +39 -21
- data/lib/tuile/fake_event_queue.rb +32 -7
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +42 -0
- data/lib/tuile/screen.rb +210 -109
- data/lib/tuile/screen_pane.rb +56 -44
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2291 -890
- metadata +28 -9
- data/ideas/back-buffer.md +0 -217
- data/lib/tuile/sizing.rb +0 -59
data/book/08-testing.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# 8. Testing a Tuile app
|
|
2
|
+
|
|
3
|
+
Every chapter so far has, quietly, also been about this one. When chapter
|
|
4
|
+
2 said components *invalidate* rather than paint, and write into a back
|
|
5
|
+
*buffer* rather than to the terminal — that was a testing decision as much
|
|
6
|
+
as a rendering one. When chapter 4 insisted everything runs on one thread
|
|
7
|
+
through a queue you could swap out — same. A Tuile app is testable without
|
|
8
|
+
a terminal not because someone bolted on a test mode, but because the
|
|
9
|
+
framework never actually needed the terminal. It needed a `Screen`, a
|
|
10
|
+
buffer, and an event queue, and each of those has an in-memory double that
|
|
11
|
+
behaves like the real thing minus the I/O.
|
|
12
|
+
|
|
13
|
+
So this chapter is the payoff. It shows how to exercise a UI in a plain
|
|
14
|
+
unit test — instantiate a component, poke it, and read back exactly what
|
|
15
|
+
it drew — and, when that isn't enough, how to drive the whole real script
|
|
16
|
+
through a pseudo-terminal.
|
|
17
|
+
|
|
18
|
+
## The fake screen, and why it resets
|
|
19
|
+
|
|
20
|
+
The singleton `Screen` from chapter 4 is the thing tests replace. Two
|
|
21
|
+
class methods bracket every example:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
before { Screen.fake }
|
|
25
|
+
after { Screen.close }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`Screen.fake` installs a {Tuile::FakeScreen} as the process singleton — a
|
|
29
|
+
`Screen` subclass with the terminal amputated. It has a fixed 160×50
|
|
30
|
+
viewport (so geometry is deterministic, independent of whoever's terminal
|
|
31
|
+
runs the suite), it writes nothing to any TTY, its `check_locked` is a
|
|
32
|
+
no-op so you can mutate the UI freely from the test thread without holding
|
|
33
|
+
the UI lock, and its event queue is the synchronous {Tuile::FakeEventQueue}
|
|
34
|
+
(more on that below). It also pins the color scheme to `:dark`, skipping
|
|
35
|
+
the OSC 11 probe from chapter 6 — a probe would otherwise write an escape
|
|
36
|
+
query to the test runner's terminal and swallow its input.
|
|
37
|
+
|
|
38
|
+
`Screen.close` matters as much as `fake` does, and the reason is the
|
|
39
|
+
singleton itself. Because there is exactly one `Screen` per process
|
|
40
|
+
(chapter 4), a screen left standing after one example is the *same* screen
|
|
41
|
+
the next example sees — its tree, its focus, its invalidation set, all
|
|
42
|
+
leaked forward. `close` tears the singleton down so each example starts
|
|
43
|
+
from nothing. Skip the `after` and you get the classic singleton test
|
|
44
|
+
smell: passes in isolation, fails in suite, order-dependent. The pair is
|
|
45
|
+
not boilerplate you can trim.
|
|
46
|
+
|
|
47
|
+
## Asserting what got painted
|
|
48
|
+
|
|
49
|
+
Here's where the back buffer earns its keep. Recall from chapter 2 that
|
|
50
|
+
components don't emit escape sequences — they write styled cells into
|
|
51
|
+
`Screen#buffer`, and only a flush turns that into wire bytes. In a test
|
|
52
|
+
that means the buffer *is* the rendered screen, sitting in memory, fully
|
|
53
|
+
inspectable, before any diffing or I/O. You assert against it directly.
|
|
54
|
+
|
|
55
|
+
The rhythm is: build the component, give it a `rect`, repaint, read the
|
|
56
|
+
buffer back over that rect.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
label = Component::Label.new
|
|
60
|
+
label.rect = Rect.new(0, 0, 10, 1)
|
|
61
|
+
label.text = "hi"
|
|
62
|
+
label.repaint
|
|
63
|
+
|
|
64
|
+
assert_equal ["hi "], Screen.instance.buffer.region_text(label.rect)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
{Tuile::Buffer#region_text} returns the plain text of each row in the
|
|
68
|
+
rect, one string per row (trailing pad included — the label fills its
|
|
69
|
+
width). Its sibling {Tuile::Buffer#region_ansi} returns the same rows *with
|
|
70
|
+
their SGR styling* rendered back into ANSI, which is what you assert
|
|
71
|
+
against when the test is about *color* — that a focused field painted its
|
|
72
|
+
active-background, say, or that a theme flip changed a hint's hue. And
|
|
73
|
+
{Tuile::Buffer#cell} gives you a single cell's grapheme and style for a
|
|
74
|
+
pinpoint check. Everything is scoped to a `rect`, so you assert about a
|
|
75
|
+
component's own region without caring what surrounds it.
|
|
76
|
+
|
|
77
|
+
What you do *not* assert content against is `prints`. On a FakeScreen,
|
|
78
|
+
`prints` captures only what actually went "to the wire" — cursor
|
|
79
|
+
positioning, housekeeping escapes, and the assembled frame string. Content
|
|
80
|
+
lives in the buffer; cursor behavior lives in `prints`. Keeping the two
|
|
81
|
+
apart is deliberate, and mixing them up is the most common way a first
|
|
82
|
+
Tuile test goes wrong.
|
|
83
|
+
|
|
84
|
+
## Driving the system
|
|
85
|
+
|
|
86
|
+
There are two altitudes at which you feed input, and picking the right one
|
|
87
|
+
is most of writing a good Tuile test.
|
|
88
|
+
|
|
89
|
+
**Low: call the component directly.** {Tuile::Component#handle_key} and
|
|
90
|
+
`handle_mouse` are public, and calling them straight tests a component's
|
|
91
|
+
own logic in isolation — no focus, no dispatch, just "given this key, does
|
|
92
|
+
the list move its cursor?" `handle_key` returns whether it consumed the
|
|
93
|
+
key, so you assert on that too:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
list.handle_key(Keys::DOWN_ARROW) # exercises the cursor directly
|
|
97
|
+
list.handle_mouse(MouseEvent.new(:left, 5, 2))
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**High: go through the pane.** {Tuile::ScreenPane#handle_key} runs the
|
|
101
|
+
dispatch rung from chapter 5 that routing is actually about: delivery to
|
|
102
|
+
{Tuile::Screen#focused}, then the bubble up its ancestor chain to the scope
|
|
103
|
+
root. So when your test is about routing — that a layout's one-key pane jump
|
|
104
|
+
fires, that a focused text field swallows a key its ancestor would otherwise
|
|
105
|
+
claim, that an open modal keeps the content beneath it from seeing keys — you
|
|
106
|
+
drive the pane and let the real machinery run:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
screen.focused = list # focus as production does — or list.focus
|
|
110
|
+
assert screen.pane.handle_key("1") # the layout's ancestor binding fires
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The two rungs *above* the pane have their own doors, because `Screen`'s own
|
|
114
|
+
`handle_key` — the top of the ladder — is private: it belongs to the key
|
|
115
|
+
thread, not to app code. Tab cycling is {Tuile::Screen#focus_next} /
|
|
116
|
+
`focus_previous`, both already scoped to the topmost modal popup, which is
|
|
117
|
+
what "a popup traps Tab" means. A global shortcut is a block you registered,
|
|
118
|
+
so test the action it calls; the registry itself is a lookup table `Screen`
|
|
119
|
+
consults before handing the key to the pane, and `register_global_shortcut`
|
|
120
|
+
is worth a test only for what it *rejects* (printables, Tab, `EDITING_KEYS`).
|
|
121
|
+
|
|
122
|
+
Two more test-only hooks close the loop. After you mutate something, call
|
|
123
|
+
`Screen.instance.repaint` to flush the pending invalidations into the
|
|
124
|
+
buffer — the coalesced repaint that chapter 2 said happens once per tick,
|
|
125
|
+
triggered by hand because there's no loop running to trigger it. (This is
|
|
126
|
+
a *test* affordance; production code never calls `repaint`, it just
|
|
127
|
+
invalidates and lets the loop coalesce.) And to check invalidation itself
|
|
128
|
+
— that a setter did, or deliberately didn't, mark its component dirty —
|
|
129
|
+
`Screen.instance.invalidated?(component)` and `invalidated_clear` let you
|
|
130
|
+
assert on the set directly.
|
|
131
|
+
|
|
132
|
+
## Why background code just works
|
|
133
|
+
|
|
134
|
+
Chapter 4's rule was that background threads marshal UI work back with
|
|
135
|
+
`screen.event_queue.submit { … }`. That rule has a happy consequence for
|
|
136
|
+
tests: under the {Tuile::FakeEventQueue}, `submit` **runs its block
|
|
137
|
+
synchronously, right now, on the calling thread.** There is no loop, no
|
|
138
|
+
thread, no waiting. So code that in production hands work across the
|
|
139
|
+
thread boundary — a `LogWindow#log`, a worker posting a result — executes
|
|
140
|
+
inline the moment the test calls it, and the effect is visible on the very
|
|
141
|
+
next line. Posted *events* are simply discarded (a test isn't running the
|
|
142
|
+
loop that would consume them), and `check_locked` passing for free means
|
|
143
|
+
none of this trips the lock guard.
|
|
144
|
+
|
|
145
|
+
The one thing with no clock is animation. `tick` on the fake returns a
|
|
146
|
+
timeless ticker that fires only when the test tells it to: call
|
|
147
|
+
`event_queue.tick_once` to pump every registered ticker one frame. A test
|
|
148
|
+
that wants to advance an animation five frames calls `tick_once` five
|
|
149
|
+
times — frame cadence is the test's to decide, since there's no real time
|
|
150
|
+
passing.
|
|
151
|
+
|
|
152
|
+
## End to end, through a real terminal
|
|
153
|
+
|
|
154
|
+
Unit tests with the fake cover component logic and rendering, which is
|
|
155
|
+
most of what you write. But some things only exist when the *real* app
|
|
156
|
+
runs: the event loop actually looping, raw-mode key decoding, the WINCH
|
|
157
|
+
trap, a live color-scheme flip. For those, Tuile's own suite spawns the
|
|
158
|
+
real example script in a pseudo-terminal and talks to it like a user
|
|
159
|
+
would. The `spec/examples/` tests are the template.
|
|
160
|
+
|
|
161
|
+
The shape is always the same. Spawn the script under `PTY.spawn`; read its
|
|
162
|
+
output until a glyph you know it paints appears — that single wait proves a
|
|
163
|
+
lot at once (the tree built, a repaint ran, and the loop is now parked in
|
|
164
|
+
the key wait); write a keystroke; assert the process exits cleanly.
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
PTY.spawn("bundle", "exec", "ruby", "-Ilib", "examples/hello_world.rb") do |reader, writer, pid|
|
|
168
|
+
buffer = String.new
|
|
169
|
+
Timeout.timeout(10) do
|
|
170
|
+
buffer << reader.readpartial(4096) until buffer.include?("Hello, world!")
|
|
171
|
+
end
|
|
172
|
+
writer.write("q")
|
|
173
|
+
Timeout.timeout(5) { Process.wait(pid) }
|
|
174
|
+
assert_equal 0, $CHILD_STATUS.exitstatus
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
This is the only place the whole stack is exercised together, and it's
|
|
179
|
+
where you'd test something like the mode-2031 flip from chapter 6:
|
|
180
|
+
wait for the dark-theme hint to paint, write the terminal's
|
|
181
|
+
`\e[?997;2n` light report into the PTY, then wait for the *light* hint —
|
|
182
|
+
which passes only if the entire chain (key-thread drain, event parse,
|
|
183
|
+
theme reassignment, full repaint) actually ran end to end. The cost is
|
|
184
|
+
that PTY tests are slower, terminal-dependent, and Linux/macOS only
|
|
185
|
+
(Ruby's stdlib `PTY` isn't on Windows), so they stay a thin top layer over
|
|
186
|
+
a broad base of fake-screen unit tests — the classic pyramid.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
That closes the book. You've followed Tuile from the outside in and back
|
|
191
|
+
out: a tree of components (chapter 1) that repaint through a back buffer
|
|
192
|
+
without flicker (chapter 2), sized top-down by their parents (chapter 3),
|
|
193
|
+
driven by a single-threaded event loop (chapter 4), with keys routed by
|
|
194
|
+
focus (chapter 5) and accents drawn from a terminal-following theme
|
|
195
|
+
(chapter 6); then the library you assemble from (chapter 7), and finally
|
|
196
|
+
the fakes that let you test all of it with no terminal in sight (this
|
|
197
|
+
one). The recurring lesson is the one the name promises: small pieces, each
|
|
198
|
+
doing an obvious thing, composed. The framework is small because the ideas
|
|
199
|
+
are few — and now they're all yours to build on.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# 9. Styled text
|
|
2
|
+
|
|
3
|
+
Everything Tuile draws is, eventually, text with colors on it — a
|
|
4
|
+
highlighted list row, a red error label, a border in the active accent.
|
|
5
|
+
The value type that carries "text plus styling" through the whole
|
|
6
|
+
framework is {Tuile::StyledString}, and it's worth one chapter of *why*,
|
|
7
|
+
because the obvious representation — a plain `String` with ANSI escape
|
|
8
|
+
codes threaded through it — is the one Tuile deliberately does *not* use.
|
|
9
|
+
|
|
10
|
+
## Why not just a String with escape codes in it
|
|
11
|
+
|
|
12
|
+
A terminal styles text with SGR escape sequences: `"\e[31mred\e[0m"` is
|
|
13
|
+
the word "red" in red. It's tempting to treat a styled string as exactly
|
|
14
|
+
that — a normal `String` that happens to contain those bytes — and let
|
|
15
|
+
the terminal sort it out.
|
|
16
|
+
|
|
17
|
+
The trouble shows up the moment you do anything *structural* to the text.
|
|
18
|
+
Slice out columns 5 through 10: which colors are active at column 5? To
|
|
19
|
+
answer, you have to scan every escape sequence from the start of the
|
|
20
|
+
string, tracking the running SGR state, because the color at column 5 was
|
|
21
|
+
set by some `\e[...m` that might be twenty characters earlier. Word-wrap
|
|
22
|
+
it across a narrow viewport: every break point needs that same running
|
|
23
|
+
state re-established on the next line, or the color bleeds or resets
|
|
24
|
+
wrong. Concatenate two of them: whose reset wins? Every operation becomes
|
|
25
|
+
"parse the SGR state machine, figure out what's active here, splice
|
|
26
|
+
carefully." The escape codes and the text are tangled together, and the
|
|
27
|
+
tangle has to be re-untangled on every edit.
|
|
28
|
+
|
|
29
|
+
{Tuile::StyledString} untangles it once, structurally. A styled string is
|
|
30
|
+
a sequence of **spans**, each a maximal run of characters that share one
|
|
31
|
+
complete {Tuile::StyledString::Style} — foreground, background, bold,
|
|
32
|
+
italic, underline, strikethrough. The spans are non-overlapping and tile
|
|
33
|
+
the whole string: every character belongs to exactly one span, and that
|
|
34
|
+
span's `style` *is* the character's style. There are no overlay layers to
|
|
35
|
+
merge, no running state to reconstruct. "What's the style at column 5?"
|
|
36
|
+
is just "which span contains column 5?" — a lookup, not a replay.
|
|
37
|
+
|
|
38
|
+
That's the trade. You pay one extra type — you construct or parse a
|
|
39
|
+
{Tuile::StyledString} instead of building a raw `String` — and in return
|
|
40
|
+
slicing, wrapping, and concatenation become ordinary operations on a list
|
|
41
|
+
of spans, each of which already knows its own style. For a framework that
|
|
42
|
+
slices and wraps text constantly, on every repaint, that's the right side
|
|
43
|
+
of the trade.
|
|
44
|
+
|
|
45
|
+
## The algebra
|
|
46
|
+
|
|
47
|
+
Once text is spans, the operations you'd want on a string come back, but
|
|
48
|
+
style-aware. You concatenate with `+` (a plain `String` operand is parsed
|
|
49
|
+
first, so embedded escapes round-trip). You take substrings by *display
|
|
50
|
+
column* with `slice` — display column, not byte offset, because a
|
|
51
|
+
fullwidth CJK character is two columns wide and a combining mark is zero,
|
|
52
|
+
and the terminal cares about columns. You split on newlines with `lines`,
|
|
53
|
+
word-wrap to a width with `wrap`, and truncate-with-ellipsis with
|
|
54
|
+
`ellipsize`. Every one of them returns a fresh {Tuile::StyledString} with
|
|
55
|
+
the spans carried across the cut intact — the value is immutable and its
|
|
56
|
+
spans are frozen and shared, so these are cheap.
|
|
57
|
+
|
|
58
|
+
Two details are worth knowing because they're choices, not accidents.
|
|
59
|
+
Slicing **never splits a glyph**: if a two-column character straddles the
|
|
60
|
+
boundary of your slice, it's dropped rather than rendered as half a
|
|
61
|
+
character, which the terminal couldn't do anyway. "Glyph" there means a
|
|
62
|
+
*grapheme cluster*, not a character, and the distinction is not pedantic —
|
|
63
|
+
a letter plus its combining accent is two characters and one glyph, and a
|
|
64
|
+
slice that kept the letter but dropped the mark would hand you back a
|
|
65
|
+
visibly different word. Emoji make the same point louder: a thumbs-up plus
|
|
66
|
+
a skin-tone modifier is two characters, one glyph, and two columns. Tuile
|
|
67
|
+
measures clusters throughout and credits a combined emoji its real width
|
|
68
|
+
rather than adding up its pieces, so text with emoji in it lays out and
|
|
69
|
+
paints at the same size. And wrapping guarantees
|
|
70
|
+
no output line exceeds the target width *whenever every character fits in
|
|
71
|
+
that width* — a single glyph wider than the whole viewport still lands on
|
|
72
|
+
its own line at its natural width, because there's nowhere narrower to put
|
|
73
|
+
it. The exact signatures live in the rdoc; this is the shape of the
|
|
74
|
+
toolbox.
|
|
75
|
+
|
|
76
|
+
## Rendering and the minimal diff
|
|
77
|
+
|
|
78
|
+
Two spans, both red, sitting next to each other, should not each re-emit
|
|
79
|
+
`\e[31m` — the terminal is already red. {Tuile::StyledString#to_ansi}
|
|
80
|
+
renders the spans to escape codes by **diffing** each span's style against
|
|
81
|
+
the one before it, emitting only the codes that actually changed. A
|
|
82
|
+
transition back to the plain default style emits a single `\e[0m` rather
|
|
83
|
+
than laboriously turning each attribute off. The rendered run always
|
|
84
|
+
closes with `\e[0m` if it ended non-default, so styling never bleeds into
|
|
85
|
+
whatever the terminal prints next.
|
|
86
|
+
|
|
87
|
+
This isn't just tidiness. The same style-diffing logic
|
|
88
|
+
({Tuile::StyledString::Style#sgr_to}) is what the back buffer uses when it
|
|
89
|
+
flushes changed cells to the terminal (chapter 2) — cell-to-cell there,
|
|
90
|
+
span-to-span here, identical minimal sequences. Styled text and the
|
|
91
|
+
flicker-free repaint model are the same idea at two scales: never rewrite
|
|
92
|
+
what's already correct.
|
|
93
|
+
|
|
94
|
+
## The parser: strict by default, lenient on request
|
|
95
|
+
|
|
96
|
+
You can go the other way too — parse an ANSI-coded `String` *into* spans
|
|
97
|
+
with {Tuile::StyledString.parse}. Here Tuile makes a sharp choice that's
|
|
98
|
+
easy to get wrong, so it's worth stating plainly: **the parser is strict
|
|
99
|
+
by default.**
|
|
100
|
+
|
|
101
|
+
Strict means it recognizes exactly the SGR codes that map to a
|
|
102
|
+
{Tuile::StyledString::Style}'s attributes — the foreground and background
|
|
103
|
+
colors, bold, italic, underline, strikethrough — and *raises* on anything
|
|
104
|
+
else. An unmodeled attribute like blink or reverse video, an unknown SGR
|
|
105
|
+
code, a non-SGR escape like a cursor move or an OSC sequence: all of them
|
|
106
|
+
are a {Tuile::StyledString::ParseError}, not a shrug. The reason is a
|
|
107
|
+
contract worth protecting: `parse(to_ansi(x)) == x`. If parsing silently
|
|
108
|
+
dropped what it didn't understand, that round-trip would quietly lie, and
|
|
109
|
+
a styled string that survived a save/load cycle might come back subtly
|
|
110
|
+
different. Strict parsing keeps the round-trip honest — anything the model
|
|
111
|
+
can't represent is refused at the door, not swallowed.
|
|
112
|
+
|
|
113
|
+
But strictness is wrong for one common job: piping in colored output you
|
|
114
|
+
didn't produce and don't control — `git --color` through a pager, a build
|
|
115
|
+
tool's output, anything that sprinkles cursor moves and exotic attributes
|
|
116
|
+
you have no intention of modeling. For that, pass `lenient: true`. Now the
|
|
117
|
+
parser keeps the colors and attributes it understands and **discards
|
|
118
|
+
everything else** — unmodeled codes, malformed extended colors, cursor
|
|
119
|
+
moves, OSC and other string sequences, stray escapes — instead of
|
|
120
|
+
raising. It's lossy by definition (`parse(x, lenient: true)` does not
|
|
121
|
+
round-trip back to `x`), and that's the point: "give me the colors, throw
|
|
122
|
+
the rest away." Strict for text you own and must preserve exactly; lenient
|
|
123
|
+
for text you're borrowing and only want the colors from.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
{Tuile::StyledString} is the quiet primitive under everything visible.
|
|
128
|
+
You rarely construct one by hand for simple cases — a {Tuile::Component::Label}
|
|
129
|
+
takes a plain `String` and wraps it for you — but the moment you render
|
|
130
|
+
your own content with per-span colors (a log line, a syntax-highlighted
|
|
131
|
+
snippet, a diff), this is the type you're building, and the theming hook
|
|
132
|
+
from chapter 6 is where you rebuild it when the palette changes.
|
data/book/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# The Tuile guide
|
|
2
|
+
|
|
3
|
+
A short, sequential guide to building terminal UIs with Tuile. Each
|
|
4
|
+
chapter builds on the vocabulary the previous one established, so
|
|
5
|
+
reading order matters — the layout chapter assumes the component tree
|
|
6
|
+
from chapter 1, focus assumes the event loop, and so on. Don't jump
|
|
7
|
+
into chapter 5 without chapters 1 and 2 first.
|
|
8
|
+
|
|
9
|
+
If you want the reference instead of the narrative, every public class
|
|
10
|
+
and module is documented with YARD headers in the sources. Run
|
|
11
|
+
`bundle exec rake yard` for a browsable local site, or — once the gem
|
|
12
|
+
is published — see <https://rubydoc.info/gems/tuile>. The division of
|
|
13
|
+
labour: this guide teaches concepts and the *why*; the rdoc carries the
|
|
14
|
+
precise per-method technical truth. When the guide needs a signature it
|
|
15
|
+
links to the rdoc rather than restating it.
|
|
16
|
+
|
|
17
|
+
## How the guide is shaped
|
|
18
|
+
|
|
19
|
+
Chapters 1–2 are the **base vocabulary** — the component tree and the
|
|
20
|
+
repaint model that every later chapter leans on. Chapter 3 is the heart
|
|
21
|
+
of Tuile's design: layout is top-down and absolute, and the chapter
|
|
22
|
+
argues *why that is enough* — the "C64" case for hand-placed
|
|
23
|
+
coordinates on a character grid — rather than reaching for the
|
|
24
|
+
negotiated min/pref/max machinery of desktop and web toolkits.
|
|
25
|
+
|
|
26
|
+
Chapters 4–6 are the **runtime**: the single-threaded event loop and
|
|
27
|
+
how to do background work safely, focus and keyboard dispatch, and
|
|
28
|
+
theming (including live OS light/dark flips). Chapters 7–8 close out
|
|
29
|
+
**narratively** — a tour of the shipped component toolbox framed around
|
|
30
|
+
when and why to reach for each, and how to test a Tuile app end to end.
|
|
31
|
+
Those two lean on the rdoc for the exact APIs; the guide keeps to the
|
|
32
|
+
walkthroughs and use-cases. Chapter 9 is a **deep dive** on
|
|
33
|
+
`Tuile::StyledString`, the text primitive under everything the framework
|
|
34
|
+
draws — read it when you start rendering your own styled content.
|
|
35
|
+
|
|
36
|
+
The book grows organically — a chapter exists when a concept has earned
|
|
37
|
+
one, not to fill an outline.
|
|
38
|
+
|
|
39
|
+
## Chapters
|
|
40
|
+
|
|
41
|
+
1. **[Your first Tuile app](01-first-app.md).** Install, then a
|
|
42
|
+
hello-world walkthrough. Establishes the base vocabulary the rest of
|
|
43
|
+
the guide leans on: the singleton `Tuile::Screen`, the tree of
|
|
44
|
+
`Tuile::Component`s under `ScreenPane`, and the "build a tree, run
|
|
45
|
+
the loop" shape of every Tuile program.
|
|
46
|
+
2. **[How the screen repaints](02-repaint.md).** Why components never
|
|
47
|
+
write to the terminal directly. `invalidate` → the back buffer →
|
|
48
|
+
a minimal diff → one synchronized flush per tick. The "cover your
|
|
49
|
+
own `rect`" contract, and why the whole model is flicker-free
|
|
50
|
+
without damage tracking or clipping.
|
|
51
|
+
3. **[Layout: the parent sets the size](03-layout.md).** The heart of
|
|
52
|
+
the design. Top-down, absolute, integer coordinates; a parent
|
|
53
|
+
assigns its children's `rect` and components never negotiate a size.
|
|
54
|
+
The C64 argument for *why simple layouting is enough* on a character
|
|
55
|
+
grid, `Layout::Absolute` and the `rect=` override, `Fraction` for
|
|
56
|
+
sizing a popup against the screen, and resize as a discrete
|
|
57
|
+
recompute. Geometry primitives (`Point` / `Size` / `Rect`) live here.
|
|
58
|
+
4. **[The event loop and background work](04-event-loop.md).** The
|
|
59
|
+
single-threaded rule: all UI mutation happens on the loop thread.
|
|
60
|
+
The event queue, marshalling work back from a background thread with
|
|
61
|
+
`submit`, and how terminal resize (`SIGWINCH`) is plumbed through the
|
|
62
|
+
same queue rather than handled off the signal.
|
|
63
|
+
5. **[Focus and the keyboard](05-focus.md).** The focus chain and
|
|
64
|
+
`focusable?`, and the three-rung order in which a keystroke is offered
|
|
65
|
+
to the tree — Tab, global shortcuts, then `handle_key` delivered to
|
|
66
|
+
focus and bubbling up its ancestors. Why scope-wide keys (pane jumps, a
|
|
67
|
+
form's default button) belong on an ancestor, and how `keyboard_hint`
|
|
68
|
+
drives the status bar.
|
|
69
|
+
6. **[Theming](06-theming.md).** Semantic color tokens read at paint
|
|
70
|
+
time, opt-in component backgrounds that inherit down the tree
|
|
71
|
+
(`bg_color`), light/dark auto-detection at startup and live OS
|
|
72
|
+
appearance flips, pairing variants in a `ThemeDef`, app-specific custom
|
|
73
|
+
tokens, and rebuilding theme-derived content in `on_theme_changed`.
|
|
74
|
+
7. **[The component library](07-components.md).** A narrative tour of
|
|
75
|
+
the shipped toolbox — Window, List, the text inputs and views,
|
|
76
|
+
ProgressBar, Popup, and the window conveniences — framed around
|
|
77
|
+
*when and why* you reach for each. Signatures stay in the rdoc.
|
|
78
|
+
8. **[Testing a Tuile app](08-testing.md).** The testing approach:
|
|
79
|
+
`FakeScreen`, asserting against the painted buffer, driving
|
|
80
|
+
invalidation, and PTY-based end-to-end tests of runnable scripts.
|
|
81
|
+
9. **[Styled text](09-styled-text.md).** A deep dive on
|
|
82
|
+
`Tuile::StyledString`, the span-based "text plus styling" value type
|
|
83
|
+
under everything Tuile draws: why spans instead of a `String` full of
|
|
84
|
+
escape codes, the style-aware algebra (slice/wrap/concat by display
|
|
85
|
+
column), minimal-diff rendering, and the strict-vs-lenient parser.
|
data/examples/hello_world.rb
CHANGED
|
@@ -14,8 +14,7 @@ require "tuile"
|
|
|
14
14
|
# Tuile::Screen.instance during invalidate/repaint hooks.
|
|
15
15
|
screen = Tuile::Screen.new
|
|
16
16
|
|
|
17
|
-
label = Tuile::Component::Label.new
|
|
18
|
-
label.text = "Hello, world!"
|
|
17
|
+
label = Tuile::Component::Label.new("Hello, world!")
|
|
19
18
|
|
|
20
19
|
window = Tuile::Component::Window.new("Tuile")
|
|
21
20
|
window.content = label
|