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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +82 -48
  5. data/book/01-first-app.md +186 -0
  6. data/book/02-repaint.md +177 -0
  7. data/book/03-layout.md +379 -0
  8. data/book/04-event-loop.md +295 -0
  9. data/book/05-focus.md +219 -0
  10. data/book/06-theming.md +302 -0
  11. data/book/07-components.md +585 -0
  12. data/book/08-testing.md +199 -0
  13. data/book/09-styled-text.md +132 -0
  14. data/book/README.md +85 -0
  15. data/examples/hello_world.rb +1 -2
  16. data/examples/sampler.rb +435 -20
  17. data/ideas/new-components.md +109 -0
  18. data/ideas/per-component-buffers.md +55 -0
  19. data/lib/tuile/buffer.rb +113 -43
  20. data/lib/tuile/color.rb +4 -10
  21. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  22. data/lib/tuile/component/button.rb +25 -29
  23. data/lib/tuile/component/checkbox.rb +133 -0
  24. data/lib/tuile/component/checkbox_group.rb +188 -0
  25. data/lib/tuile/component/combo_box.rb +281 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/info_window.rb +4 -2
  30. data/lib/tuile/component/integer_field.rb +135 -0
  31. data/lib/tuile/component/label.rb +20 -25
  32. data/lib/tuile/component/layout.rb +3 -26
  33. data/lib/tuile/component/list.rb +8 -33
  34. data/lib/tuile/component/list_dropdown.rb +106 -0
  35. data/lib/tuile/component/log_window.rb +0 -14
  36. data/lib/tuile/component/password_field.rb +105 -0
  37. data/lib/tuile/component/popup.rb +70 -79
  38. data/lib/tuile/component/progress_bar.rb +278 -0
  39. data/lib/tuile/component/radio_group.rb +188 -0
  40. data/lib/tuile/component/text_area.rb +189 -65
  41. data/lib/tuile/component/text_field.rb +170 -32
  42. data/lib/tuile/component/text_view.rb +57 -137
  43. data/lib/tuile/component/window.rb +88 -121
  44. data/lib/tuile/component.rb +246 -142
  45. data/lib/tuile/event_queue.rb +39 -21
  46. data/lib/tuile/fake_event_queue.rb +32 -7
  47. data/lib/tuile/fake_screen.rb +4 -5
  48. data/lib/tuile/fraction.rb +42 -0
  49. data/lib/tuile/screen.rb +210 -109
  50. data/lib/tuile/screen_pane.rb +56 -44
  51. data/lib/tuile/styled_string.rb +112 -83
  52. data/lib/tuile/theme.rb +78 -41
  53. data/lib/tuile/version.rb +1 -1
  54. data/sig/tuile.rbs +2291 -890
  55. metadata +28 -9
  56. data/ideas/back-buffer.md +0 -217
  57. data/lib/tuile/sizing.rb +0 -59
@@ -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.
@@ -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