tuile 0.7.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 +26 -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/examples/sampler.rb +109 -0
- data/lib/tuile/ansi.rb +16 -0
- data/lib/tuile/buffer.rb +481 -0
- data/lib/tuile/component/button.rb +3 -14
- data/lib/tuile/component/has_content.rb +0 -6
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/label.rb +15 -23
- data/lib/tuile/component/layout.rb +0 -21
- data/lib/tuile/component/list.rb +10 -37
- data/lib/tuile/component/log_window.rb +6 -5
- data/lib/tuile/component/picker_window.rb +4 -2
- data/lib/tuile/component/popup.rb +85 -55
- data/lib/tuile/component/text_area.rb +1 -1
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_input.rb +25 -9
- data/lib/tuile/component/text_view.rb +6 -30
- data/lib/tuile/component/window.rb +77 -112
- data/lib/tuile/component.rb +16 -71
- data/lib/tuile/event_queue.rb +31 -10
- data/lib/tuile/fake_event_queue.rb +21 -5
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/fraction.rb +46 -0
- data/lib/tuile/screen.rb +98 -105
- data/lib/tuile/screen_pane.rb +82 -19
- data/lib/tuile/styled_string.rb +40 -30
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +653 -282
- metadata +13 -3
- 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/examples/sampler.rb
CHANGED
|
@@ -67,6 +67,7 @@ module SamplerExample
|
|
|
67
67
|
["Label", :build_label],
|
|
68
68
|
["TextField", :build_text_field],
|
|
69
69
|
["TextArea", :build_text_area],
|
|
70
|
+
["Slash menu", :build_slash_demo],
|
|
70
71
|
["TextView", :build_text_view],
|
|
71
72
|
["Button", :build_buttons],
|
|
72
73
|
["List", :build_list],
|
|
@@ -87,6 +88,11 @@ module SamplerExample
|
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
def load_entry(idx)
|
|
91
|
+
# The slash-menu demo parks a non-modal overlay on the pane (it lives
|
|
92
|
+
# outside the right pane's content tree), so close it before swapping
|
|
93
|
+
# demos or it would linger over the next one.
|
|
94
|
+
@slash_overlay.close if @slash_overlay&.open?
|
|
95
|
+
@slash_overlay = nil
|
|
90
96
|
caption, builder = ENTRIES[idx]
|
|
91
97
|
@right_window.caption = caption
|
|
92
98
|
@right_window.content = send(builder)
|
|
@@ -135,6 +141,64 @@ module SamplerExample
|
|
|
135
141
|
end
|
|
136
142
|
end
|
|
137
143
|
|
|
144
|
+
# Slash commands the demo offers; the menu filters these by what's typed.
|
|
145
|
+
SLASH_COMMANDS = %w[/help /list /open /save /clear /quit].freeze
|
|
146
|
+
|
|
147
|
+
# A non-modal Popup used as an autocomplete menu. Focus (and the caret)
|
|
148
|
+
# stays in the TextArea the whole time: an `on_change` listener refills the
|
|
149
|
+
# menu, an `on_key` interceptor forwards Up/Down/Enter/ESC to it while it's
|
|
150
|
+
# open, and the menu floats above the field, anchored to the caret. None of
|
|
151
|
+
# this is baked into TextArea — it's all assembled here from stock hooks.
|
|
152
|
+
def build_slash_demo
|
|
153
|
+
prompt = Tuile::Component::Label.new
|
|
154
|
+
prompt.text = "Non-modal Popup as an autocomplete menu. Type a slash command\n" \
|
|
155
|
+
"(try \"/\" or \"/s\"). The menu floats above the field without taking\n" \
|
|
156
|
+
"focus: Down/Up move the selection, Enter accepts, ESC dismisses, and\n" \
|
|
157
|
+
"ordinary typing keeps editing the field and refilters the menu."
|
|
158
|
+
area = Tuile::Component::TextArea.new
|
|
159
|
+
|
|
160
|
+
list = Tuile::Component::List.new
|
|
161
|
+
list.cursor = Tuile::Component::List::Cursor.new
|
|
162
|
+
list.show_cursor_when_inactive = true # highlight the selection though focus stays in the field
|
|
163
|
+
window = Tuile::Component::Window.new("Commands").tap { _1.content = list }
|
|
164
|
+
overlay = Tuile::Component::Popup.new(content: window, modal: false)
|
|
165
|
+
@slash_overlay = overlay
|
|
166
|
+
|
|
167
|
+
refill = lambda do
|
|
168
|
+
matches = slash_matches(area)
|
|
169
|
+
if matches.empty?
|
|
170
|
+
overlay.close if overlay.open?
|
|
171
|
+
else
|
|
172
|
+
overlay.open unless overlay.open?
|
|
173
|
+
list.lines = matches
|
|
174
|
+
anchor_overlay(overlay, area)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
area.on_change = ->(_text) { refill.call }
|
|
179
|
+
list.on_item_chosen = ->(_idx, line) { accept_slash_command(area, line.to_s) }
|
|
180
|
+
area.on_key = lambda do |key|
|
|
181
|
+
next false unless overlay.open?
|
|
182
|
+
|
|
183
|
+
case key
|
|
184
|
+
when Tuile::Keys::UP_ARROW, Tuile::Keys::DOWN_ARROW, Tuile::Keys::ENTER
|
|
185
|
+
list.handle_key(key) # works though the list is unfocused — dispatch gates on focus, not the list
|
|
186
|
+
when Tuile::Keys::ESC
|
|
187
|
+
overlay.close
|
|
188
|
+
true
|
|
189
|
+
else
|
|
190
|
+
false
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
panel(prompt, area) do |r|
|
|
195
|
+
inner = inner_rect(r)
|
|
196
|
+
prompt.rect = Tuile::Rect.new(inner.left, inner.top + 1, inner.width, 4)
|
|
197
|
+
area_height = [inner.height - 7, 4].max
|
|
198
|
+
area.rect = Tuile::Rect.new(inner.left, inner.top + 6, inner.width, area_height)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
138
202
|
def build_text_view
|
|
139
203
|
prompt = Tuile::Component::Label.new
|
|
140
204
|
prompt.text = "Read-only viewer for prose. Word-wraps to width; ANSI formatting passes through.\n" \
|
|
@@ -301,6 +365,51 @@ module SamplerExample
|
|
|
301
365
|
end
|
|
302
366
|
end
|
|
303
367
|
|
|
368
|
+
# The run of non-space characters ending at the caret, when it starts with
|
|
369
|
+
# "/" — i.e. the slash command being typed — or nil.
|
|
370
|
+
def slash_token(area)
|
|
371
|
+
text = area.text
|
|
372
|
+
caret = area.caret
|
|
373
|
+
start = caret
|
|
374
|
+
start -= 1 while start.positive? && !text[start - 1].match?(/\s/)
|
|
375
|
+
token = text[start...caret].to_s
|
|
376
|
+
token.start_with?("/") ? token : nil
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Commands matching the slash token at the caret (empty when not in one).
|
|
380
|
+
def slash_matches(area)
|
|
381
|
+
token = slash_token(area)
|
|
382
|
+
return [] if token.nil?
|
|
383
|
+
|
|
384
|
+
SLASH_COMMANDS.select { _1.start_with?(token) }
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Replaces the slash token at the caret with `command` plus a trailing
|
|
388
|
+
# space, then drops the caret after it (which re-fires on_change → refill,
|
|
389
|
+
# so the now-tokenless text closes the menu).
|
|
390
|
+
def accept_slash_command(area, command)
|
|
391
|
+
text = area.text
|
|
392
|
+
caret = area.caret
|
|
393
|
+
start = caret
|
|
394
|
+
start -= 1 while start.positive? && !text[start - 1].match?(/\s/)
|
|
395
|
+
area.text = "#{text[0...start]}#{command} #{text[caret..]}"
|
|
396
|
+
area.caret = start + command.length + 1
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Positions the overlay just below the caret, flipping above when there's
|
|
400
|
+
# no room beneath, and clamps it to the screen.
|
|
401
|
+
def anchor_overlay(overlay, area)
|
|
402
|
+
caret = area.cursor_position
|
|
403
|
+
return if caret.nil?
|
|
404
|
+
|
|
405
|
+
screen_size = Tuile::Screen.instance.size
|
|
406
|
+
size = overlay.rect
|
|
407
|
+
top = caret.y + 1
|
|
408
|
+
top = [caret.y - size.height, 0].max if top + size.height > screen_size.height - 1
|
|
409
|
+
left = caret.x.clamp(0, [screen_size.width - size.width, 0].max)
|
|
410
|
+
overlay.rect = Tuile::Rect.new(left, top, size.width, size.height)
|
|
411
|
+
end
|
|
412
|
+
|
|
304
413
|
# Carves a 2-column padding out of the panel rect so the demo content
|
|
305
414
|
# doesn't run flush to the window border.
|
|
306
415
|
def inner_rect(rect)
|
data/lib/tuile/ansi.rb
CHANGED
|
@@ -11,5 +11,21 @@ module Tuile
|
|
|
11
11
|
# background, and text attributes.
|
|
12
12
|
# @return [String]
|
|
13
13
|
RESET = "\e[0m"
|
|
14
|
+
|
|
15
|
+
# Begin Synchronized Update (DEC private mode 2026, "Synchronized
|
|
16
|
+
# Output"). The terminal stops refreshing its display and buffers every
|
|
17
|
+
# subsequent write until {SYNC_END}, then composites the whole batch
|
|
18
|
+
# atomically — so a multi-cell repaint is never shown half-drawn. This is
|
|
19
|
+
# what stops flicker when a frame redraws a large region (e.g. the
|
|
20
|
+
# full-scene repaint a shrinking popup forces). Terminals without support
|
|
21
|
+
# ignore the private-mode set, so it's a safe no-op there. {Screen#repaint}
|
|
22
|
+
# wraps its single frame-buffer flush in this pair.
|
|
23
|
+
# @return [String]
|
|
24
|
+
SYNC_BEGIN = "\e[?2026h"
|
|
25
|
+
|
|
26
|
+
# End Synchronized Update — see {SYNC_BEGIN}. Releases the buffered frame
|
|
27
|
+
# and lets the terminal repaint.
|
|
28
|
+
# @return [String]
|
|
29
|
+
SYNC_END = "\e[?2026l"
|
|
14
30
|
end
|
|
15
31
|
end
|