tuile 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -36
- data/DECISIONS.md +2566 -0
- data/README.md +37 -24
- data/book/03-layout.md +153 -8
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +84 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +458 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +22 -14
- data/examples/sampler.rb +632 -67
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +118 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +134 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +263 -0
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -15
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +157 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +125 -86
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +2962 -680
- metadata +25 -7
|
@@ -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
CHANGED
|
@@ -29,7 +29,9 @@ theming (including live OS light/dark flips). Chapters 7–8 close out
|
|
|
29
29
|
**narratively** — a tour of the shipped component toolbox framed around
|
|
30
30
|
when and why to reach for each, and how to test a Tuile app end to end.
|
|
31
31
|
Those two lean on the rdoc for the exact APIs; the guide keeps to the
|
|
32
|
-
walkthroughs and use-cases.
|
|
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.
|
|
33
35
|
|
|
34
36
|
The book grows organically — a chapter exists when a concept has earned
|
|
35
37
|
one, not to fill an outline.
|
|
@@ -50,7 +52,9 @@ one, not to fill an outline.
|
|
|
50
52
|
the design. Top-down, absolute, integer coordinates; a parent
|
|
51
53
|
assigns its children's `rect` and components never negotiate a size.
|
|
52
54
|
The C64 argument for *why simple layouting is enough* on a character
|
|
53
|
-
grid, `Layout::Absolute` and the `rect=` override, `
|
|
55
|
+
grid, `Layout::Absolute` and the `rect=` override, the `Vertical` /
|
|
56
|
+
`Horizontal` box layouts and their three constraints (`Fixed` /
|
|
57
|
+
`Percent` / `Expand`) as sugar over that same rule, `Fraction` for
|
|
54
58
|
sizing a popup against the screen, and resize as a discrete
|
|
55
59
|
recompute. Geometry primitives (`Point` / `Size` / `Rect`) live here.
|
|
56
60
|
4. **[The event loop and background work](04-event-loop.md).** The
|
|
@@ -59,21 +63,25 @@ one, not to fill an outline.
|
|
|
59
63
|
`submit`, and how terminal resize (`SIGWINCH`) is plumbed through the
|
|
60
64
|
same queue rather than handled off the signal.
|
|
61
65
|
5. **[Focus and the keyboard](05-focus.md).** The focus chain and
|
|
62
|
-
`focusable?`, and the order in which a keystroke is offered
|
|
63
|
-
tree — Tab, global shortcuts,
|
|
64
|
-
|
|
65
|
-
how `keyboard_hint`
|
|
66
|
+
`focusable?`, and the three-rung order in which a keystroke is offered
|
|
67
|
+
to the tree — Tab, global shortcuts, then `handle_key` delivered to
|
|
68
|
+
focus and bubbling up its ancestors. Why scope-wide keys (pane jumps, a
|
|
69
|
+
form's default button) belong on an ancestor, and how `keyboard_hint`
|
|
70
|
+
drives the status bar.
|
|
66
71
|
6. **[Theming](06-theming.md).** Semantic color tokens read at paint
|
|
67
|
-
time,
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
time, opt-in component backgrounds that inherit down the tree
|
|
73
|
+
(`bg_color`), light/dark auto-detection at startup and live OS
|
|
74
|
+
appearance flips, pairing variants in a `ThemeDef`, app-specific custom
|
|
75
|
+
tokens, and rebuilding theme-derived content in `on_theme_changed`.
|
|
70
76
|
7. **[The component library](07-components.md).** A narrative tour of
|
|
71
77
|
the shipped toolbox — Window, List, the text inputs and views,
|
|
72
|
-
Popup, and the window conveniences — framed around
|
|
73
|
-
you reach for each. Signatures stay in the rdoc.
|
|
78
|
+
ProgressBar, Popup, and the window conveniences — framed around
|
|
79
|
+
*when and why* you reach for each. Signatures stay in the rdoc.
|
|
74
80
|
8. **[Testing a Tuile app](08-testing.md).** The testing approach:
|
|
75
81
|
`FakeScreen`, asserting against the painted buffer, driving
|
|
76
82
|
invalidation, and PTY-based end-to-end tests of runnable scripts.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
9. **[Styled text](09-styled-text.md).** A deep dive on
|
|
84
|
+
`Tuile::StyledString`, the span-based "text plus styling" value type
|
|
85
|
+
under everything Tuile draws: why spans instead of a `String` full of
|
|
86
|
+
escape codes, the style-aware algebra (slice/wrap/concat by display
|
|
87
|
+
column), minimal-diff rendering, and the strict-vs-lenient parser.
|