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
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tuile
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Martin Vysny
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -116,10 +116,18 @@ files:
|
|
|
116
116
|
- CODE_OF_CONDUCT.md
|
|
117
117
|
- LICENSE.txt
|
|
118
118
|
- README.md
|
|
119
|
+
- book/01-first-app.md
|
|
120
|
+
- book/02-repaint.md
|
|
121
|
+
- book/03-layout.md
|
|
122
|
+
- book/04-event-loop.md
|
|
123
|
+
- book/05-focus.md
|
|
124
|
+
- book/06-theming.md
|
|
125
|
+
- book/07-components.md
|
|
126
|
+
- book/08-testing.md
|
|
127
|
+
- book/README.md
|
|
119
128
|
- examples/file_commander.rb
|
|
120
129
|
- examples/hello_world.rb
|
|
121
130
|
- examples/sampler.rb
|
|
122
|
-
- ideas/back-buffer.md
|
|
123
131
|
- lib/tuile.rb
|
|
124
132
|
- lib/tuile/ansi.rb
|
|
125
133
|
- lib/tuile/buffer.rb
|
|
@@ -142,6 +150,7 @@ files:
|
|
|
142
150
|
- lib/tuile/event_queue.rb
|
|
143
151
|
- lib/tuile/fake_event_queue.rb
|
|
144
152
|
- lib/tuile/fake_screen.rb
|
|
153
|
+
- lib/tuile/fraction.rb
|
|
145
154
|
- lib/tuile/keys.rb
|
|
146
155
|
- lib/tuile/mouse_event.rb
|
|
147
156
|
- lib/tuile/point.rb
|
|
@@ -149,7 +158,6 @@ files:
|
|
|
149
158
|
- lib/tuile/screen.rb
|
|
150
159
|
- lib/tuile/screen_pane.rb
|
|
151
160
|
- lib/tuile/size.rb
|
|
152
|
-
- lib/tuile/sizing.rb
|
|
153
161
|
- lib/tuile/styled_string.rb
|
|
154
162
|
- lib/tuile/terminal_background.rb
|
|
155
163
|
- lib/tuile/theme.rb
|
data/ideas/back-buffer.md
DELETED
|
@@ -1,217 +0,0 @@
|
|
|
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/sizing.rb
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Tuile
|
|
4
|
-
# A sizing policy for a slot whose position is managed by a parent
|
|
5
|
-
# component (e.g. {Component::Window#footer}). Resolves one dimension at a
|
|
6
|
-
# time via {#resolve}, so the same value works for widths and heights.
|
|
7
|
-
#
|
|
8
|
-
# Three policies exist:
|
|
9
|
-
#
|
|
10
|
-
# - {FILL} — take everything the slot offers;
|
|
11
|
-
# - {WRAP_CONTENT} — take the component's natural extent (its
|
|
12
|
-
# {Component#content_size}), clamped to the slot;
|
|
13
|
-
# - {.fixed} — take exactly the given number of cells, clamped to the slot.
|
|
14
|
-
#
|
|
15
|
-
# Note that {WRAP_CONTENT} only makes sense for components that report a
|
|
16
|
-
# natural {Component#content_size} ({Component::Label}, {Component::Button},
|
|
17
|
-
# {Component::List}, …). Input components ({Component::TextField} et al.)
|
|
18
|
-
# report {Size::ZERO}, so a wrap-content slot collapses to zero width —
|
|
19
|
-
# i.e. the component becomes invisible. Use {.fixed} or {FILL} for those.
|
|
20
|
-
#
|
|
21
|
-
# @!attribute [r] mode
|
|
22
|
-
# @return [Symbol] `:fill`, `:wrap_content` or `:fixed`.
|
|
23
|
-
# @!attribute [r] amount
|
|
24
|
-
# @return [Integer, nil] the cell count for `:fixed`; `nil` otherwise.
|
|
25
|
-
class Sizing < Data.define(:mode, :amount)
|
|
26
|
-
# @param amount [Integer] the number of cells to occupy; 0 or greater.
|
|
27
|
-
# @return [Sizing] a fixed-size policy.
|
|
28
|
-
def self.fixed(amount)
|
|
29
|
-
raise TypeError, "expected Integer, got #{amount.inspect}" unless amount.is_a?(Integer)
|
|
30
|
-
raise ArgumentError, "amount must not be negative, got #{amount}" if amount.negative?
|
|
31
|
-
|
|
32
|
-
new(mode: :fixed, amount: amount)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Resolves one dimension of a slot.
|
|
36
|
-
# @param available [Integer] cells the slot offers; 0 or greater.
|
|
37
|
-
# @param content [Integer] the component's natural extent on this axis
|
|
38
|
-
# (one dimension of its {Component#content_size}).
|
|
39
|
-
# @return [Integer] the resolved extent, always in `0..available`.
|
|
40
|
-
def resolve(available, content)
|
|
41
|
-
case mode
|
|
42
|
-
when :fill then available
|
|
43
|
-
when :fixed then amount.clamp(0, available)
|
|
44
|
-
when :wrap_content then content.clamp(0, available)
|
|
45
|
-
else raise ArgumentError, "unknown mode #{mode.inspect}"
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Occupy everything the slot offers.
|
|
50
|
-
# @return [Sizing]
|
|
51
|
-
FILL = new(mode: :fill, amount: nil)
|
|
52
|
-
|
|
53
|
-
# Occupy the component's natural {Component#content_size}, clamped to the
|
|
54
|
-
# slot. Components reporting {Size::ZERO} collapse to invisibility — see
|
|
55
|
-
# the class doc.
|
|
56
|
-
# @return [Sizing]
|
|
57
|
-
WRAP_CONTENT = new(mode: :wrap_content, amount: nil)
|
|
58
|
-
end
|
|
59
|
-
end
|