tuile 0.7.0 → 0.8.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 +11 -0
- data/examples/sampler.rb +109 -0
- data/ideas/back-buffer.md +217 -0
- data/lib/tuile/ansi.rb +16 -0
- data/lib/tuile/buffer.rb +412 -0
- data/lib/tuile/component/button.rb +2 -5
- data/lib/tuile/component/has_content.rb +0 -6
- data/lib/tuile/component/label.rb +8 -8
- data/lib/tuile/component/layout.rb +0 -12
- data/lib/tuile/component/list.rb +10 -11
- data/lib/tuile/component/log_window.rb +20 -5
- data/lib/tuile/component/picker_window.rb +4 -2
- data/lib/tuile/component/popup.rb +48 -13
- 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 -7
- data/lib/tuile/component/window.rb +21 -38
- data/lib/tuile/component.rb +29 -25
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/screen.rb +90 -100
- data/lib/tuile/screen_pane.rb +80 -19
- data/lib/tuile/styled_string.rb +40 -30
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +511 -112
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3e4cd91c879f7eea941fd0a60a453a69de1f672a4907269282a0964645d919f8
|
|
4
|
+
data.tar.gz: c5f6b3124a64904cf606cfd4bc8a28409e7335b2b52c45bc99c67844f2b7de41
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c2d866c691d81685c3db892f933ce094fa9f770c8b7394a652f4344975595ec53acea525f05f1528ddcc934ce9a966ff631be9bf1292d58651c2606dd22fdaa
|
|
7
|
+
data.tar.gz: 87889c8181d1da7c477678456a3eee02dcd3abcf553b162c9cc1b560856228a8e65f062dc46c6d0c0a1edff5a892dd3b295e506f509698bc5a5142925b5c9663
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.8.0] - 2026-06-11
|
|
4
|
+
|
|
5
|
+
- Render through a back buffer: `Screen#buffer` is now a `Tuile::Buffer` cell grid sized to the viewport. Components paint into it; `Screen#repaint` flushes only the cells that changed since the last frame, wrapped with the cursor move in one synchronized-output batch (DEC mode 2026). Repaint is flicker-free on **any** terminal regardless of mode-2026 support, because an unchanged cell is never rewritten — the full-scene overdraw a shrinking popup forces no longer reaches the wire. `FakeScreen` exposes the populated buffer for content assertions (`row_text`/`row_ansi`/`region_text`/`region_ansi`/`cell`); `prints` now holds only the assembled frame and cursor housekeeping.
|
|
6
|
+
- Add non-modal popups: `Component::Popup.new(modal: false)` still paints on top and auto-sizes to its content, but does not center, grab focus, capture keys, or block clicks — the focused component keeps the cursor and keeps receiving keys. This is the building block for autocomplete / slash-command menus anchored to a text input's caret. `Popup#modal?` exposes the mode; `ScreenPane#modal_popup` is the topmost *modal* popup (or nil), through which all "modal owner" reads (key scope, mouse fall-through, focus repair, Tab scope, global-shortcut gate) now route.
|
|
7
|
+
- Add `Component::TextInput#on_key` — an interceptor consulted before the input's own key handling (a truthy return consumes the key); the keyboard analog of `on_change`, for both `TextField` and `TextArea`. Lets app code layer Up/Down/Enter/ESC handling onto a field without subclassing.
|
|
8
|
+
- Add `Component#popup_min_height` / `popup_max_height` — content components can advise a wrapping `Popup` of preferred height bounds. `Component::LogWindow` uses them to stay readable at half-screen height when nearly empty and grow to full-screen for busy logs.
|
|
9
|
+
- `Component::LogWindow` now renders through a `TextView`, so long log lines wrap instead of being ellipsized.
|
|
10
|
+
- Add `examples/sampler.rb` "Slash menu" demo: a `TextArea` whose `on_change` refills a non-modal `Popup`-wrapped `List` anchored to the caret, with `on_key` forwarding navigation and ESC dismissing — focus and caret stay in the field throughout.
|
|
11
|
+
- **Breaking:** components paint into `Screen#buffer` via `set_line` / `fill` / `set_char` (handing the surface a `StyledString`) instead of writing escape sequences through `screen.print`. Custom components that drew via `screen.print(move_to, ansi)` must migrate to the buffer API.
|
|
12
|
+
- **Breaking:** key dispatch is centralized into a capture + bubble model in `ScreenPane#handle_key` (a `key_shortcut` match anywhere in scope focuses that component; the key is then delivered to `Screen#focused` and bubbles up its ancestor chain). A component's `handle_key` now acts on the key alone and never gates on its own `active?` state. `Layout#handle_key`, `Window#handle_key`, and `HasContent#handle_key` are removed (routing is the dispatcher's job), and the `active?` guards in `TextInput` / `List` / `Button` are dropped.
|
|
13
|
+
|
|
3
14
|
## [0.7.0] - 2026-06-09
|
|
4
15
|
|
|
5
16
|
- Lower the Ruby floor to 3.3 (was 3.4): replaced the `it` implicit block parameter (3.4+) with `_1` throughout, and added 3.3 to the CI matrix.
|
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)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Back-buffer cell diff (flicker-free rendering)
|
|
2
|
+
|
|
3
|
+
Status: **proposal, for review**. Supersedes the stop-gap synchronized-output
|
|
4
|
+
commit (`23b4e71`), which we keep as a bonus but which only works where the
|
|
5
|
+
whole terminal stack implements DEC mode 2026 (notably *not* under tmux < 3.4).
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
Components paint by emitting escape sequences straight to the terminal:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
screen.print TTY::Cursor.move_to(x, y), styled_string.to_ansi
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
When a frame redraws a large region — e.g. the full-scene repaint a shrinking
|
|
16
|
+
non-modal popup forces via `Screen#needs_full_repaint` — the terminal shows the
|
|
17
|
+
clear-then-redraw in progress. On the slash-command demo this flickers on every
|
|
18
|
+
keystroke. Mode 2026 hides it on capable stacks but is best-effort: one old
|
|
19
|
+
layer (tmux < 3.4, Alacritty < 0.13) silently swallows the private-mode set and
|
|
20
|
+
the flicker returns.
|
|
21
|
+
|
|
22
|
+
The durable fix is stack-independent: **never write a cell whose final value
|
|
23
|
+
equals what's already on screen.** Flicker comes from overwriting a cell with a
|
|
24
|
+
space and then the glyph; if the result is unchanged, emit nothing. This is why
|
|
25
|
+
ncurses, tmux, and Ratatui never flicker on any terminal.
|
|
26
|
+
|
|
27
|
+
## Approach P — single cell buffer + dirty-cell diff
|
|
28
|
+
|
|
29
|
+
Two stages, kept distinct:
|
|
30
|
+
|
|
31
|
+
- **Composite** (in memory): components write into a back buffer. Driven by the
|
|
32
|
+
*existing* invalidation engine — only invalidated components repaint, so cost
|
|
33
|
+
stays proportional to what changed.
|
|
34
|
+
- **Flush** (to terminal): walk the cells that actually changed, group them into
|
|
35
|
+
runs, and emit `move_to` + minimal SGR per run. Only changed cells reach the
|
|
36
|
+
wire.
|
|
37
|
+
|
|
38
|
+
Crucially this **keeps almost the entire current architecture** and swaps only
|
|
39
|
+
the output sink. We do *not* rip out invalidation or the z-order overdraw rule.
|
|
40
|
+
|
|
41
|
+
### What stays (deliberately)
|
|
42
|
+
|
|
43
|
+
- **`invalidate` / the `@invalidated` set.** Dedup, paint-at-most-once-per-frame,
|
|
44
|
+
decides *who* repaints. Unchanged.
|
|
45
|
+
- **The z-order overdraw rule** (`repaint` partitioning tiled vs popup,
|
|
46
|
+
`collect_subtree`, "repaint occluders on top in stacking order"). It orders
|
|
47
|
+
writes into the shared buffer so a popup wins where it overlaps content.
|
|
48
|
+
Unchanged.
|
|
49
|
+
- **`needs_full_repaint`.** Still called on popup close / shrink / move. It stops
|
|
50
|
+
being a flicker source *because the diff filters it*: every component rewrites
|
|
51
|
+
its cells, the vast majority equal what's already there → not marked dirty →
|
|
52
|
+
not emitted. Only the newly-exposed region differs and gets flushed. So
|
|
53
|
+
"invalidate everything" becomes cheap without deleting it.
|
|
54
|
+
|
|
55
|
+
### What changes
|
|
56
|
+
|
|
57
|
+
- New `Tuile::Buffer` (cell grid) — the screen mirror that components paint into.
|
|
58
|
+
- `Component#repaint` methods call `buffer.set_line(x, y, styled)` instead of
|
|
59
|
+
`screen.print(move_to(x, y), styled.to_ansi)`. Mechanical, ~8 files.
|
|
60
|
+
- `Screen` flushes by diffing dirty cells instead of accumulating a
|
|
61
|
+
`@frame_buffer` of escape strings.
|
|
62
|
+
|
|
63
|
+
### What it kills
|
|
64
|
+
|
|
65
|
+
- The whole class of clear-then-redraw flicker, on **every** terminal,
|
|
66
|
+
independent of mode-2026 support.
|
|
67
|
+
|
|
68
|
+
## Cell & Buffer model
|
|
69
|
+
|
|
70
|
+
Reuse `StyledString::Style` as the per-cell style — it is already a frozen value
|
|
71
|
+
type (`fg/bg/bold/italic/underline/strikethrough`) and already knows how to diff
|
|
72
|
+
itself into minimal SGR (`StyledString#sgr_diff`, lifted into a shared helper).
|
|
73
|
+
This is the single biggest reason the refactor is tractable rather than a
|
|
74
|
+
from-scratch styling layer.
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Cell = (grapheme: String, style: Style)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- Normal cell = one display column.
|
|
81
|
+
- A 2-column glyph (CJK/emoji) occupies cell `x` (glyph) and `x+1` (a
|
|
82
|
+
continuation sentinel; the flush skips it since the glyph already advanced the
|
|
83
|
+
cursor two columns, and overwriting either half clears both). `StyledString`
|
|
84
|
+
already computes correct widths via `Unicode::DisplayWidth` and already drops
|
|
85
|
+
half-overlapping wide chars on `slice` boundaries, so the hard Unicode work is
|
|
86
|
+
done.
|
|
87
|
+
|
|
88
|
+
### `Buffer` API (new file `lib/tuile/buffer.rb`)
|
|
89
|
+
|
|
90
|
+
One top-level constant per file per the Zeitwerk rule; `Buffer::Cell` nested.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
buffer.set_line(x, y, styled_string) # write a row, clipped to bounds — workhorse
|
|
94
|
+
buffer.set_char(x, y, grapheme, style) # primitive; marks the cell dirty iff it changed
|
|
95
|
+
buffer.fill(rect, style) # clear_background's replacement
|
|
96
|
+
buffer.resize(size) # on WINCH; forces full redraw
|
|
97
|
+
buffer.flush -> String # emit minimal escapes for dirty cells, clear dirty
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`set_line` is a near-mechanical replacement for today's
|
|
101
|
+
`screen.print(move_to(x, y), styled.to_ansi)`: walk the styled string with
|
|
102
|
+
`each_char_with_style`, place graphemes at successive columns, handle wide-char
|
|
103
|
+
continuations + clipping.
|
|
104
|
+
|
|
105
|
+
### Dirty tracking — proportional, no whole-buffer sweep
|
|
106
|
+
|
|
107
|
+
`set_char` compares the incoming `(grapheme, style)` against the current cell; on
|
|
108
|
+
a difference it overwrites and records the cell (or its row range) as dirty.
|
|
109
|
+
There is **no per-frame whole-buffer clear or copy** — un-repainted cells simply
|
|
110
|
+
retain last frame's value. So both composite and flush cost scale with what
|
|
111
|
+
actually changed, which is the efficiency property we want at large sizes
|
|
112
|
+
(210×79 ≈ 16.6k cells).
|
|
113
|
+
|
|
114
|
+
Minor accepted imperfection: a component that writes a cell away from and back to
|
|
115
|
+
its original value within one frame leaves it marked dirty, so the flush
|
|
116
|
+
re-emits an identical glyph to that one cell — imperceptible (same value, no
|
|
117
|
+
flash), and rare. Avoiding it would need a second buffer + full diff; not worth
|
|
118
|
+
the per-frame whole-buffer touch.
|
|
119
|
+
|
|
120
|
+
### Flush — reuse `StyledString` for the run emitter
|
|
121
|
+
|
|
122
|
+
Walk dirty cells in row order, group maximal horizontal runs, build a
|
|
123
|
+
`StyledString` from each run's `(grapheme, style)` cells, and emit
|
|
124
|
+
`move_to(run.x, run.y)` + `run.to_ansi`. `StyledString` already collapses
|
|
125
|
+
adjacent same-styled characters and emits minimal-diff SGR, so the run encoder is
|
|
126
|
+
nearly free. Wrap the whole flush in `Ansi::SYNC_BEGIN`/`SYNC_END` (belt and
|
|
127
|
+
suspenders where supported).
|
|
128
|
+
|
|
129
|
+
## Performance
|
|
130
|
+
|
|
131
|
+
With composite proportional to invalidation, a keystroke that changes one widget
|
|
132
|
+
touches a few hundred `set_char` and emits a few runs — sub-millisecond. The only
|
|
133
|
+
potentially full-width operation is the dirty scan on flush; track dirty as row
|
|
134
|
+
ranges (or a dirty-row set) so it stays proportional. No C sidecar now; if the
|
|
135
|
+
diff scan ever shows in a profile it is the most mechanical thing to drop into C
|
|
136
|
+
later, but it is unlikely to matter at keystroke cadence.
|
|
137
|
+
|
|
138
|
+
## Deferred: per-component back buffers + compositor
|
|
139
|
+
|
|
140
|
+
A later optimization, **not** in this plan. Each component would own a buffer;
|
|
141
|
+
the screen is composited by walking a z-stack over dirty regions. Honest
|
|
142
|
+
assessment of why it waits:
|
|
143
|
+
|
|
144
|
+
- It does **not** avoid clipping — it adopts clipping's tamer cousin
|
|
145
|
+
(z-compositing) and forces a **transparency model** (components don't tile
|
|
146
|
+
their rect — see the "not required to fully tile" invariant — so cells need
|
|
147
|
+
set-vs-transparent so lower layers show through). That's *more* model
|
|
148
|
+
complexity than Approach P, where the parent's clear-fill + shared buffer
|
|
149
|
+
already handles gaps.
|
|
150
|
+
- The waste it would save is mostly already gone. Obscured-component-under-dialog
|
|
151
|
+
splits two ways under Approach P:
|
|
152
|
+
- *Only the dialog changes* (the actual slash-menu case): only the dialog is
|
|
153
|
+
invalidated, so the obscured component is **not repainted at all**.
|
|
154
|
+
- *The obscured component changes*: the overdraw rule repaints the dialog on
|
|
155
|
+
top, but only its `repaint()` **CPU** — the diff drops its unchanged cells
|
|
156
|
+
from the wire.
|
|
157
|
+
Per-component buffers would shave only that residual CPU, by copying the
|
|
158
|
+
dialog's buffer cells instead of re-running its `repaint`.
|
|
159
|
+
|
|
160
|
+
It genuinely pays in exactly one regime: high repeat-rate scroll (held arrow /
|
|
161
|
+
mouse wheel) of a large component on a large screen, where re-rendering content
|
|
162
|
+
each repeat is the cost. Keep the door open: design `Buffer`'s API so components
|
|
163
|
+
paint through a drawing surface (`set_line`/`set_char`) without knowing whether
|
|
164
|
+
it is the global buffer or their own — then a compositor becomes a drop-in if
|
|
165
|
+
profiling ever demands it.
|
|
166
|
+
|
|
167
|
+
## Migration surface
|
|
168
|
+
|
|
169
|
+
Eight files emit paint output today: `component.rb` (base `clear_background`),
|
|
170
|
+
`label`, `button`, `list`, `text_field`, `text_area`, `text_view`, `window`.
|
|
171
|
+
Each has 1–3 `screen.print(move_to, ansi)` sites. Rewrite is mechanical:
|
|
172
|
+
`screen.print(move_to(x, y), styled.to_ansi)` → `buffer.set_line(x, y, styled)`.
|
|
173
|
+
Borders (Window) and row highlights (List `with_bg`) remain per-row styled
|
|
174
|
+
strings, so they compose unchanged.
|
|
175
|
+
|
|
176
|
+
## Test-suite impact (the bulk of the human effort)
|
|
177
|
+
|
|
178
|
+
`FakeScreen` captures emitted bytes in `@prints`; many specs assert
|
|
179
|
+
`screen.prints.join.include?("hi")`. After the change components write to a
|
|
180
|
+
buffer, not `print`. So:
|
|
181
|
+
|
|
182
|
+
- `FakeScreen` exposes the back buffer; new idiom
|
|
183
|
+
`assert_includes screen.buffer.row_text(2), "hi"` — cleaner than scanning
|
|
184
|
+
escape soup.
|
|
185
|
+
- Specs asserting raw `prints` (a meaningful fraction across the 8 component
|
|
186
|
+
specs) migrate to buffer queries. Add `Buffer#row_text(y)` / `#cell(x, y)`.
|
|
187
|
+
- New `spec/tuile/buffer_spec.rb` covers the cell model, wide-char
|
|
188
|
+
continuations, clipping, and the diff — including the load-bearing property:
|
|
189
|
+
**an unchanged cell emits nothing**.
|
|
190
|
+
|
|
191
|
+
This test migration is larger than the production rewrite.
|
|
192
|
+
|
|
193
|
+
## Phasing (keep `rake spec` green at each step)
|
|
194
|
+
|
|
195
|
+
1. **`Buffer` + `Cell` + flush, standalone.** New files + full unit spec. Zero
|
|
196
|
+
integration. Lift `StyledString#sgr_diff` into a shared SGR helper both use.
|
|
197
|
+
*Lands green, touches nothing else.*
|
|
198
|
+
2. **Wire `Screen` to composite into the buffer and flush by diff.** Keep
|
|
199
|
+
`Component#repaint` writing through a thin `set_line` shim so the change is
|
|
200
|
+
one layer. Switch `FakeScreen` to expose the buffer.
|
|
201
|
+
3. **Migrate component `repaint`s** to `set_line`, one file at a time, migrating
|
|
202
|
+
each mirrored spec alongside.
|
|
203
|
+
4. **Simplify `Screen#repaint`'s output path** (drop `@frame_buffer`
|
|
204
|
+
accumulation; the partition/overdraw logic and `needs_full_repaint` stay).
|
|
205
|
+
Update the AGENTS.md "Invalidation + repaint" section to describe the
|
|
206
|
+
buffer + diff sink.
|
|
207
|
+
5. Re-profile; confirm no flicker on Alacritty **and under tmux** (the acceptance
|
|
208
|
+
test the mode-2026 stop-gap fails).
|
|
209
|
+
|
|
210
|
+
## Open decisions (resolved during design discussion)
|
|
211
|
+
|
|
212
|
+
- **Keep `invalidate`** — yes. Composite proportional to change; lower-risk
|
|
213
|
+
refactor than full recomposite.
|
|
214
|
+
- **Flush emits only changed runs** — yes; reuse `StyledString.to_ansi`.
|
|
215
|
+
- **Per-component buffers** — deferred; API kept open (see above).
|
|
216
|
+
- **`Style` location** — reuse `StyledString::Style` as the cell style, one
|
|
217
|
+
styling vocabulary across the framework.
|
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
|