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.
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.8.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-06-11 00:00:00.000000000 Z
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