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/08-testing.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
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 screen.** {Tuile::Screen#handle_key} runs the *full*
|
|
101
|
+
dispatch pipeline from chapter 5 — Tab cycling, global shortcuts, the
|
|
102
|
+
subtree `key_shortcut` search, then the focus chain. When your test is
|
|
103
|
+
about routing — that a shortcut jumps focus, that a modal popup traps Tab,
|
|
104
|
+
that a focused text field swallows a key its sibling would otherwise claim
|
|
105
|
+
— you drive `Screen.instance.handle_key` and let the real machinery run.
|
|
106
|
+
Set focus the way production does, with `screen.focused = component` or
|
|
107
|
+
`component.focus`.
|
|
108
|
+
|
|
109
|
+
Two more test-only hooks close the loop. After you mutate something, call
|
|
110
|
+
`Screen.instance.repaint` to flush the pending invalidations into the
|
|
111
|
+
buffer — the coalesced repaint that chapter 2 said happens once per tick,
|
|
112
|
+
triggered by hand because there's no loop running to trigger it. (This is
|
|
113
|
+
a *test* affordance; production code never calls `repaint`, it just
|
|
114
|
+
invalidates and lets the loop coalesce.) And to check invalidation itself
|
|
115
|
+
— that a setter did, or deliberately didn't, mark its component dirty —
|
|
116
|
+
`Screen.instance.invalidated?(component)` and `invalidated_clear` let you
|
|
117
|
+
assert on the set directly.
|
|
118
|
+
|
|
119
|
+
## Why background code just works
|
|
120
|
+
|
|
121
|
+
Chapter 4's rule was that background threads marshal UI work back with
|
|
122
|
+
`screen.event_queue.submit { … }`. That rule has a happy consequence for
|
|
123
|
+
tests: under the {Tuile::FakeEventQueue}, `submit` **runs its block
|
|
124
|
+
synchronously, right now, on the calling thread.** There is no loop, no
|
|
125
|
+
thread, no waiting. So code that in production hands work across the
|
|
126
|
+
thread boundary — a `LogWindow#log`, a worker posting a result — executes
|
|
127
|
+
inline the moment the test calls it, and the effect is visible on the very
|
|
128
|
+
next line. Posted *events* are simply discarded (a test isn't running the
|
|
129
|
+
loop that would consume them), and `check_locked` passing for free means
|
|
130
|
+
none of this trips the lock guard.
|
|
131
|
+
|
|
132
|
+
The one thing with no clock is animation. `tick` on the fake returns a
|
|
133
|
+
timeless ticker that fires only when the test tells it to: call
|
|
134
|
+
`event_queue.tick_once` to pump every registered ticker one frame. A test
|
|
135
|
+
that wants to advance an animation five frames calls `tick_once` five
|
|
136
|
+
times — frame cadence is the test's to decide, since there's no real time
|
|
137
|
+
passing.
|
|
138
|
+
|
|
139
|
+
## End to end, through a real terminal
|
|
140
|
+
|
|
141
|
+
Unit tests with the fake cover component logic and rendering, which is
|
|
142
|
+
most of what you write. But some things only exist when the *real* app
|
|
143
|
+
runs: the event loop actually looping, raw-mode key decoding, the WINCH
|
|
144
|
+
trap, a live color-scheme flip. For those, Tuile's own suite spawns the
|
|
145
|
+
real example script in a pseudo-terminal and talks to it like a user
|
|
146
|
+
would. The `spec/examples/` tests are the template.
|
|
147
|
+
|
|
148
|
+
The shape is always the same. Spawn the script under `PTY.spawn`; read its
|
|
149
|
+
output until a glyph you know it paints appears — that single wait proves a
|
|
150
|
+
lot at once (the tree built, a repaint ran, and the loop is now parked in
|
|
151
|
+
the key wait); write a keystroke; assert the process exits cleanly.
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
PTY.spawn("bundle", "exec", "ruby", "-Ilib", "examples/hello_world.rb") do |reader, writer, pid|
|
|
155
|
+
buffer = String.new
|
|
156
|
+
Timeout.timeout(10) do
|
|
157
|
+
buffer << reader.readpartial(4096) until buffer.include?("Hello, world!")
|
|
158
|
+
end
|
|
159
|
+
writer.write("q")
|
|
160
|
+
Timeout.timeout(5) { Process.wait(pid) }
|
|
161
|
+
assert_equal 0, $CHILD_STATUS.exitstatus
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
This is the only place the whole stack is exercised together, and it's
|
|
166
|
+
where you'd test something like the mode-2031 flip from chapter 6:
|
|
167
|
+
wait for the dark-theme hint to paint, write the terminal's
|
|
168
|
+
`\e[?997;2n` light report into the PTY, then wait for the *light* hint —
|
|
169
|
+
which passes only if the entire chain (key-thread drain, event parse,
|
|
170
|
+
theme reassignment, full repaint) actually ran end to end. The cost is
|
|
171
|
+
that PTY tests are slower, terminal-dependent, and Linux/macOS only
|
|
172
|
+
(Ruby's stdlib `PTY` isn't on Windows), so they stay a thin top layer over
|
|
173
|
+
a broad base of fake-screen unit tests — the classic pyramid.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
That closes the book. You've followed Tuile from the outside in and back
|
|
178
|
+
out: a tree of components (chapter 1) that repaint through a back buffer
|
|
179
|
+
without flicker (chapter 2), sized top-down by their parents (chapter 3),
|
|
180
|
+
driven by a single-threaded event loop (chapter 4), with keys routed by
|
|
181
|
+
focus (chapter 5) and accents drawn from a terminal-following theme
|
|
182
|
+
(chapter 6); then the library you assemble from (chapter 7), and finally
|
|
183
|
+
the fakes that let you test all of it with no terminal in sight (this
|
|
184
|
+
one). The recurring lesson is the one the name promises: small pieces, each
|
|
185
|
+
doing an obvious thing, composed. The framework is small because the ideas
|
|
186
|
+
are few — and now they're all yours to build on.
|
data/book/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
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.
|
|
33
|
+
|
|
34
|
+
The book grows organically — a chapter exists when a concept has earned
|
|
35
|
+
one, not to fill an outline.
|
|
36
|
+
|
|
37
|
+
## Chapters
|
|
38
|
+
|
|
39
|
+
1. **[Your first Tuile app](01-first-app.md).** Install, then a
|
|
40
|
+
hello-world walkthrough. Establishes the base vocabulary the rest of
|
|
41
|
+
the guide leans on: the singleton `Tuile::Screen`, the tree of
|
|
42
|
+
`Tuile::Component`s under `ScreenPane`, and the "build a tree, run
|
|
43
|
+
the loop" shape of every Tuile program.
|
|
44
|
+
2. **[How the screen repaints](02-repaint.md).** Why components never
|
|
45
|
+
write to the terminal directly. `invalidate` → the back buffer →
|
|
46
|
+
a minimal diff → one synchronized flush per tick. The "cover your
|
|
47
|
+
own `rect`" contract, and why the whole model is flicker-free
|
|
48
|
+
without damage tracking or clipping.
|
|
49
|
+
3. **[Layout: the parent sets the size](03-layout.md).** The heart of
|
|
50
|
+
the design. Top-down, absolute, integer coordinates; a parent
|
|
51
|
+
assigns its children's `rect` and components never negotiate a size.
|
|
52
|
+
The C64 argument for *why simple layouting is enough* on a character
|
|
53
|
+
grid, `Layout::Absolute` and the `rect=` override, `Fraction` for
|
|
54
|
+
sizing a popup against the screen, and resize as a discrete
|
|
55
|
+
recompute. Geometry primitives (`Point` / `Size` / `Rect`) live here.
|
|
56
|
+
4. **[The event loop and background work](04-event-loop.md).** The
|
|
57
|
+
single-threaded rule: all UI mutation happens on the loop thread.
|
|
58
|
+
The event queue, marshalling work back from a background thread with
|
|
59
|
+
`submit`, and how terminal resize (`SIGWINCH`) is plumbed through the
|
|
60
|
+
same queue rather than handled off the signal.
|
|
61
|
+
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
|
+
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`.
|
|
70
|
+
7. **[The component library](07-components.md).** A narrative tour of
|
|
71
|
+
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.
|
|
74
|
+
8. **[Testing a Tuile app](08-testing.md).** The testing approach:
|
|
75
|
+
`FakeScreen`, asserting against the painted buffer, driving
|
|
76
|
+
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.
|
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
|
data/lib/tuile/buffer.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Tuile
|
|
|
8
8
|
# string needed to bring a terminal — one that already matches the buffer's
|
|
9
9
|
# state as of the previous flush — up to date. Only cells that actually
|
|
10
10
|
# changed are emitted, so nothing flickers regardless of terminal/multiplexer
|
|
11
|
-
# synchronized-output support.
|
|
11
|
+
# synchronized-output support.
|
|
12
12
|
#
|
|
13
13
|
# Coordinates are 0-based `(x, y)` = `(column, row)`, matching
|
|
14
14
|
# {Component#rect} and `TTY::Cursor.move_to`.
|
|
@@ -40,6 +40,19 @@ module Tuile
|
|
|
40
40
|
# nothing for, since the glyph itself advances the cursor two columns).
|
|
41
41
|
# Overwriting either half of a wide glyph blanks the orphaned half, so the
|
|
42
42
|
# grid never holds a dangling continuation or a headless one.
|
|
43
|
+
#
|
|
44
|
+
# ## Future direction
|
|
45
|
+
#
|
|
46
|
+
# Components paint through this drawing surface ({#set_line} / {#set_char})
|
|
47
|
+
# without knowing whether it is the one global buffer or a private one — that
|
|
48
|
+
# indirection is deliberate, so per-component back buffers plus a z-order
|
|
49
|
+
# compositor could drop in without touching component code. It is not worth
|
|
50
|
+
# doing yet: the diff already drops unchanged cells from the wire, and an
|
|
51
|
+
# occluded component that didn't change is never repainted at all, so a
|
|
52
|
+
# compositor would only save residual `repaint` CPU. It pays off in exactly
|
|
53
|
+
# one regime — high repeat-rate scroll (held arrow / mouse wheel) of a large
|
|
54
|
+
# component on a large screen, where re-rendering the content each repeat is
|
|
55
|
+
# the dominant cost.
|
|
43
56
|
class Buffer
|
|
44
57
|
# One screen cell: a single grapheme cluster, the {StyledString::Style} it's
|
|
45
58
|
# drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
|
|
@@ -100,6 +113,23 @@ module Tuile
|
|
|
100
113
|
DEFAULT_STYLE = StyledString::Style::DEFAULT
|
|
101
114
|
private_constant :DEFAULT_STYLE
|
|
102
115
|
|
|
116
|
+
# Memo for {.display_width}: a grapheme's display width is fixed, and a TTY
|
|
117
|
+
# paints from a small, recurring alphabet (ASCII, box-drawing rules, a few
|
|
118
|
+
# emoji), so the per-grapheme width lookup — the dominant cost of a repaint
|
|
119
|
+
# (see `benchmark/display_width.rb`) — collapses to a Hash read after the
|
|
120
|
+
# first sighting. Shared across all buffers and unbounded, but bounded in
|
|
121
|
+
# practice by the font's glyph set; safe to share because all painting runs
|
|
122
|
+
# on the single UI thread (see AGENTS.md "Threading rule").
|
|
123
|
+
# @return [Hash{String => Integer}]
|
|
124
|
+
WIDTH_CACHE = Hash.new { |h, g| h[g] = Unicode::DisplayWidth.of(g) }
|
|
125
|
+
private_constant :WIDTH_CACHE
|
|
126
|
+
|
|
127
|
+
# Memoized {Unicode::DisplayWidth.of}. Use this for every paint-path width
|
|
128
|
+
# lookup instead of calling the gem directly.
|
|
129
|
+
# @param grapheme [String] one grapheme cluster.
|
|
130
|
+
# @return [Integer] its display width in columns (0 for combining marks).
|
|
131
|
+
def self.display_width(grapheme) = WIDTH_CACHE[grapheme]
|
|
132
|
+
|
|
103
133
|
# @param size [Size] grid dimensions in columns × rows.
|
|
104
134
|
def initialize(size)
|
|
105
135
|
allocate_grid(size)
|
|
@@ -140,20 +170,7 @@ module Tuile
|
|
|
140
170
|
# @param style [StyledString::Style]
|
|
141
171
|
# @return [void]
|
|
142
172
|
def set_char(x, y, grapheme, style = DEFAULT_STYLE)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
w = Unicode::DisplayWidth.of(grapheme)
|
|
146
|
-
return if w <= 0
|
|
147
|
-
|
|
148
|
-
if w == 2 && !in_bounds?(x + 1, y)
|
|
149
|
-
repair_orphans(x, y)
|
|
150
|
-
return write_cell(x, y, " ", style)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
repair_orphans(x, y)
|
|
154
|
-
repair_orphans(x + 1, y) if w == 2
|
|
155
|
-
write_cell(x, y, grapheme, style)
|
|
156
|
-
write_cell(x + 1, y, "", style) if w == 2
|
|
173
|
+
put_char(x, y, grapheme, Buffer.display_width(grapheme), style)
|
|
157
174
|
end
|
|
158
175
|
|
|
159
176
|
# Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
|
|
@@ -168,12 +185,13 @@ module Tuile
|
|
|
168
185
|
col = x
|
|
169
186
|
styled.spans.each do |span|
|
|
170
187
|
span.text.grapheme_clusters.each do |g|
|
|
171
|
-
w =
|
|
188
|
+
w = Buffer.display_width(g)
|
|
172
189
|
next if w <= 0 # combining mark with no base in this run: skip
|
|
173
190
|
|
|
174
191
|
break if col >= @width # rest of the line is clipped
|
|
175
192
|
|
|
176
|
-
|
|
193
|
+
# Reuse the width we just computed: put_char skips re-measuring `g`.
|
|
194
|
+
put_char(col, y, g, w, span.style)
|
|
177
195
|
col += w
|
|
178
196
|
end
|
|
179
197
|
end
|
|
@@ -313,6 +331,38 @@ module Tuile
|
|
|
313
331
|
|
|
314
332
|
private
|
|
315
333
|
|
|
334
|
+
# Core of {#set_char} with the grapheme's display width already known.
|
|
335
|
+
# {#set_line} computes each width once while advancing the column and passes
|
|
336
|
+
# it straight through, so the paint hot path measures every grapheme exactly
|
|
337
|
+
# once (and that once is a {.display_width} memo read). See {#set_char} for
|
|
338
|
+
# the wide-glyph / clipping / out-of-bounds contract.
|
|
339
|
+
# @param x [Integer] column.
|
|
340
|
+
# @param y [Integer] row.
|
|
341
|
+
# @param grapheme [String] one grapheme cluster.
|
|
342
|
+
# @param w [Integer] `grapheme`'s display width (0, 1, or 2).
|
|
343
|
+
# @param style [StyledString::Style]
|
|
344
|
+
# @return [void]
|
|
345
|
+
def put_char(x, y, grapheme, w, style)
|
|
346
|
+
return unless in_bounds?(x, y)
|
|
347
|
+
return if w <= 0
|
|
348
|
+
|
|
349
|
+
if w == 2 && !in_bounds?(x + 1, y)
|
|
350
|
+
blank_left_partner(x, y)
|
|
351
|
+
return write_cell(x, y, " ", style)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Repair only the glyphs we'd leave half-overwritten on our flanks: a wide
|
|
355
|
+
# glyph whose right half sits at `x`, or one whose left half sits at the
|
|
356
|
+
# last cell we write. The cells we fully rewrite need no pre-blanking —
|
|
357
|
+
# pre-blanking the continuation only to re-empty it would churn it
|
|
358
|
+
# spuriously dirty, which misplaces the next flush onto the glyph's right
|
|
359
|
+
# half (see bug/, balloon corruption).
|
|
360
|
+
blank_left_partner(x, y)
|
|
361
|
+
blank_right_partner(x + w - 1, y)
|
|
362
|
+
write_cell(x, y, grapheme, style)
|
|
363
|
+
write_cell(x + 1, y, "", style) if w == 2
|
|
364
|
+
end
|
|
365
|
+
|
|
316
366
|
# (Re)allocates a blank grid of `size` with clean dirty state. Callers
|
|
317
367
|
# follow with {#mark_all_dirty} when the terminal doesn't match the new
|
|
318
368
|
# grid — construction and {#resize} both do.
|
|
@@ -342,11 +392,19 @@ module Tuile
|
|
|
342
392
|
c = @cells[base + x]
|
|
343
393
|
if c.dirty
|
|
344
394
|
c.dirty = false
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
395
|
+
# A continuation cell (right half of a wide glyph) renders nothing of
|
|
396
|
+
# its own and must never open a run: positioning the cursor onto its
|
|
397
|
+
# column would land the next glyph on the wide glyph's left half,
|
|
398
|
+
# corrupting it. When the wide glyph itself is dirty it is emitted from
|
|
399
|
+
# its origin cell and advances the cursor across this column; when the
|
|
400
|
+
# glyph is intact this column needs no output at all. (A continuation
|
|
401
|
+
# can be left spuriously dirty by an in-place wide-glyph repaint, so we
|
|
402
|
+
# can't assume its origin was emitted in this same run.)
|
|
349
403
|
unless c.continuation?
|
|
404
|
+
unless run_open
|
|
405
|
+
out << TTY::Cursor.move_to(x, y)
|
|
406
|
+
run_open = true
|
|
407
|
+
end
|
|
350
408
|
out << style.sgr_to(c.style) << c.grapheme
|
|
351
409
|
style = c.style
|
|
352
410
|
end
|
|
@@ -393,20 +451,31 @@ module Tuile
|
|
|
393
451
|
@any_dirty = true
|
|
394
452
|
end
|
|
395
453
|
|
|
396
|
-
# If `(x, y)`
|
|
397
|
-
#
|
|
454
|
+
# If `(x, y)` holds the right half (continuation) of a wide glyph, blanks the
|
|
455
|
+
# orphaned left half at `x - 1`. Called before a write lands on `x`, so the
|
|
456
|
+
# wide glyph to the left isn't left headless.
|
|
398
457
|
# @param x [Integer] column
|
|
399
458
|
# @param y [Integer] row
|
|
400
459
|
# @return [void]
|
|
401
|
-
def
|
|
402
|
-
return unless in_bounds?(x, y)
|
|
460
|
+
def blank_left_partner(x, y)
|
|
461
|
+
return unless in_bounds?(x, y) && @cells[index(x, y)].continuation? && in_bounds?(x - 1, y)
|
|
403
462
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
463
|
+
write_cell(x - 1, y, " ", DEFAULT_STYLE)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# If the cell just right of `(x, y)` is a continuation (the right half of a
|
|
467
|
+
# wide glyph whose origin is `(x, y)`), blanks it. Called before a write
|
|
468
|
+
# lands on `x`, so overwriting a wide origin doesn't strand its continuation.
|
|
469
|
+
# A continuation can only ever belong to the wide glyph immediately to its
|
|
470
|
+
# left, so the empty-grapheme test is exact — and cheaper than re-measuring
|
|
471
|
+
# the origin's width.
|
|
472
|
+
# @param x [Integer] column
|
|
473
|
+
# @param y [Integer] row
|
|
474
|
+
# @return [void]
|
|
475
|
+
def blank_right_partner(x, y)
|
|
476
|
+
return unless in_bounds?(x + 1, y) && @cells[index(x + 1, y)].continuation?
|
|
477
|
+
|
|
478
|
+
write_cell(x + 1, y, " ", DEFAULT_STYLE)
|
|
410
479
|
end
|
|
411
480
|
end
|
|
412
481
|
end
|
|
@@ -12,7 +12,7 @@ module Tuile
|
|
|
12
12
|
# {Component#handle_mouse}.
|
|
13
13
|
#
|
|
14
14
|
# Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
|
|
15
|
-
# show `[ caption ]
|
|
15
|
+
# show `[ caption ]` — that natural width is `caption.length + 4`.
|
|
16
16
|
class Button < Component
|
|
17
17
|
# @param caption [String] the button's label.
|
|
18
18
|
# @yield optional `on_click` callback; same as assigning {#on_click=}.
|
|
@@ -20,7 +20,6 @@ module Tuile
|
|
|
20
20
|
super()
|
|
21
21
|
@caption = caption.to_s
|
|
22
22
|
@on_click = on_click
|
|
23
|
-
self.content_size = natural_size
|
|
24
23
|
end
|
|
25
24
|
|
|
26
25
|
# @return [String] the button's label.
|
|
@@ -39,7 +38,6 @@ module Tuile
|
|
|
39
38
|
|
|
40
39
|
@caption = new_caption
|
|
41
40
|
invalidate
|
|
42
|
-
self.content_size = natural_size
|
|
43
41
|
end
|
|
44
42
|
|
|
45
43
|
def focusable? = true
|
|
@@ -76,12 +74,6 @@ module Tuile
|
|
|
76
74
|
styled = active? ? StyledString.styled(label, bg: screen.theme.active_bg_color) : StyledString.plain(label)
|
|
77
75
|
screen.buffer.set_line(rect.left, rect.top, styled)
|
|
78
76
|
end
|
|
79
|
-
|
|
80
|
-
private
|
|
81
|
-
|
|
82
|
-
# Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
|
|
83
|
-
# @return [Size]
|
|
84
|
-
def natural_size = Size.new(@caption.length + 4, 1)
|
|
85
77
|
end
|
|
86
78
|
end
|
|
87
79
|
end
|
|
@@ -21,9 +21,11 @@ module Tuile
|
|
|
21
21
|
# Opens the info window as a popup.
|
|
22
22
|
# @param caption [String]
|
|
23
23
|
# @param lines [Array<String>] the content, may contain formatting.
|
|
24
|
+
# @param size [Size, Fraction] the popup's size, applied top-down; the
|
|
25
|
+
# list wraps and scrolls within it. Defaults to {Fraction::HALF}.
|
|
24
26
|
# @return [Popup] the opened popup.
|
|
25
|
-
def self.open(caption, lines)
|
|
26
|
-
Popup.open(content: InfoWindow.new(caption, lines))
|
|
27
|
+
def self.open(caption, lines, size: Fraction::HALF)
|
|
28
|
+
Popup.open(content: InfoWindow.new(caption, lines), size: size)
|
|
27
29
|
end
|
|
28
30
|
end
|
|
29
31
|
end
|
|
@@ -8,12 +8,17 @@ module Tuile
|
|
|
8
8
|
# embedded ANSI is honored) or a {StyledString} directly. {#text}
|
|
9
9
|
# always returns the {StyledString}.
|
|
10
10
|
class Label < Component
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# @param text [String, StyledString, nil] initial text, coerced the same
|
|
12
|
+
# way {#text=} coerces it (a `String` is parsed via
|
|
13
|
+
# {StyledString.parse}; `nil` is an empty label). Equivalent to
|
|
14
|
+
# constructing empty and assigning {#text=}.
|
|
15
|
+
def initialize(text = nil)
|
|
16
|
+
super()
|
|
13
17
|
@text = StyledString::EMPTY
|
|
14
18
|
@bg = nil
|
|
15
19
|
@clipped_lines = []
|
|
16
20
|
@blank_line = ""
|
|
21
|
+
self.text = text unless text.nil?
|
|
17
22
|
end
|
|
18
23
|
|
|
19
24
|
# @return [StyledString] the current text. Defaults to an empty
|
|
@@ -38,7 +43,6 @@ module Tuile
|
|
|
38
43
|
@text = new_text
|
|
39
44
|
update_clipped_lines
|
|
40
45
|
invalidate
|
|
41
|
-
self.content_size = compute_content_size
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
# Sets the background color. Coerced via {Color.coerce}, so a Symbol,
|
|
@@ -84,18 +88,6 @@ module Tuile
|
|
|
84
88
|
|
|
85
89
|
private
|
|
86
90
|
|
|
87
|
-
# Natural size: longest hard-line's display width × number of hard
|
|
88
|
-
# lines. Computed on the *unclipped* text — sizing is intrinsic to the
|
|
89
|
-
# content, not the viewport. Empty text yields {Size::ZERO}.
|
|
90
|
-
# @return [Size]
|
|
91
|
-
def compute_content_size
|
|
92
|
-
return Size::ZERO if @text.empty?
|
|
93
|
-
|
|
94
|
-
hard_lines = @text.lines
|
|
95
|
-
width = hard_lines.map(&:display_width).max || 0
|
|
96
|
-
Size.new(width, hard_lines.size)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
91
|
# Recomputes {@clipped_lines} for the current text and rect width.
|
|
100
92
|
# Each line is ellipsized to fit and padded with trailing spaces out to
|
|
101
93
|
# the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
|
|
@@ -56,15 +56,6 @@ module Tuile
|
|
|
56
56
|
on_child_removed(child)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# @return [Size]
|
|
60
|
-
def content_size
|
|
61
|
-
return Size::ZERO if @children.empty?
|
|
62
|
-
|
|
63
|
-
right = @children.map { |c| c.rect.left + c.rect.width }.max
|
|
64
|
-
bottom = @children.map { |c| c.rect.top + c.rect.height }.max
|
|
65
|
-
Size.new(right - rect.left, bottom - rect.top)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
59
|
# Dispatches the event to the child under the mouse cursor.
|
|
69
60
|
# @param event [MouseEvent]
|
|
70
61
|
# @return [void]
|
data/lib/tuile/component/list.rb
CHANGED
|
@@ -147,7 +147,6 @@ module Tuile
|
|
|
147
147
|
update_top_line_if_auto_scroll
|
|
148
148
|
notify_cursor_changed
|
|
149
149
|
invalidate
|
|
150
|
-
self.content_size = compute_content_size
|
|
151
150
|
end
|
|
152
151
|
|
|
153
152
|
# Without a block, returns the current lines. With a block, fully
|
|
@@ -194,7 +193,6 @@ module Tuile
|
|
|
194
193
|
update_top_line_if_auto_scroll
|
|
195
194
|
notify_cursor_changed
|
|
196
195
|
invalidate
|
|
197
|
-
grow_content_size(new_lines)
|
|
198
196
|
end
|
|
199
197
|
|
|
200
198
|
def focusable? = true
|
|
@@ -508,30 +506,6 @@ module Tuile
|
|
|
508
506
|
|
|
509
507
|
private
|
|
510
508
|
|
|
511
|
-
# Natural size from scratch: longest line's display width plus the two
|
|
512
|
-
# single-space gutters {#pad_to_row} adds, × line count. An empty list
|
|
513
|
-
# is {Size::ZERO} (no gutters for no content).
|
|
514
|
-
# @return [Size]
|
|
515
|
-
def compute_content_size
|
|
516
|
-
content_w = @lines.map(&:display_width).max || 0
|
|
517
|
-
width = @lines.empty? ? 0 : content_w + 2
|
|
518
|
-
Size.new(width, @lines.size)
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
# Incremental {#content_size} update for appends: folds just the
|
|
522
|
-
# appended lines into the running maximum, keeping {#add_lines}
|
|
523
|
-
# O(appended) instead of re-scanning the whole list (LogWindow appends
|
|
524
|
-
# a line per log statement).
|
|
525
|
-
# @param appended [Array<StyledString>] the just-appended lines
|
|
526
|
-
# (already concatenated onto {@lines}).
|
|
527
|
-
# @return [void]
|
|
528
|
-
def grow_content_size(appended)
|
|
529
|
-
return if appended.empty?
|
|
530
|
-
|
|
531
|
-
appended_w = appended.map(&:display_width).max + 2
|
|
532
|
-
self.content_size = Size.new([content_size.width, appended_w].max, @lines.size)
|
|
533
|
-
end
|
|
534
|
-
|
|
535
509
|
# Coerces and flattens a list of input entries into trimmed
|
|
536
510
|
# {StyledString} lines. Each entry becomes a {StyledString} (String
|
|
537
511
|
# via {StyledString.parse}, StyledString passed through, anything else
|
|
@@ -24,20 +24,6 @@ module Tuile
|
|
|
24
24
|
self.scrollbar = true
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# Keep the log pane at least half the screen tall even when only a few
|
|
28
|
-
# lines have been logged: a {Component::Popup} sizes to its content, which
|
|
29
|
-
# would collapse a near-empty log to two or three rows. Advice consulted
|
|
30
|
-
# by {Component::Popup#min_height} when this window is a popup's content.
|
|
31
|
-
# @return [Integer]
|
|
32
|
-
def popup_min_height = screen.size.height / 2
|
|
33
|
-
|
|
34
|
-
# Let a busy log grow past the popup's base 12-row cap (up to the
|
|
35
|
-
# 4/5-of-screen ceiling {Component::Popup#update_rect} applies) so the
|
|
36
|
-
# diagnostic stream stays scrollable in a tall window. Advice consulted
|
|
37
|
-
# by {Component::Popup#max_height} when this window is a popup's content.
|
|
38
|
-
# @return [Integer]
|
|
39
|
-
def popup_max_height = screen.size.height
|
|
40
|
-
|
|
41
27
|
# Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
|
|
42
28
|
# @param string [String, nil] the line (or multiple lines) to log.
|
|
43
29
|
# @return [void]
|