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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -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/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. 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, `Fraction` for
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 to the
63
- tree — Tab, global shortcuts, a component's `key_shortcut`, then
64
- `handle_key`. How a focused text field swallows printable keys, and
65
- how `keyboard_hint` drives the status bar.
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, light/dark auto-detection at startup and live OS appearance
68
- flips, pairing variants in a `ThemeDef`, app-specific custom tokens,
69
- and rebuilding theme-derived content in `on_theme_changed`.
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 *when and why*
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
- All eight chapters are currently **stubs** — the skeleton is in place so
79
- the numbering is stable; prose lands chapter by chapter.
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.