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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffd96aaba12d84ccc9f3417db01f75b3b91f0f89cd7358864fdfd1b0dcaa778b
4
- data.tar.gz: fdbc08c48e4b908ee8b3cebc7bf949e88cf6cc2a1bb58260b53c8612bc6060cc
3
+ metadata.gz: 3e4cd91c879f7eea941fd0a60a453a69de1f672a4907269282a0964645d919f8
4
+ data.tar.gz: c5f6b3124a64904cf606cfd4bc8a28409e7335b2b52c45bc99c67844f2b7de41
5
5
  SHA512:
6
- metadata.gz: bc286448b580b3978de8652088f491e989618e0fb63c6063035f8f284ae8b57a2192a3fea7913cb26cfee1147e1bc7108e2288da53a1676acf874a069c8c0249
7
- data.tar.gz: 20aa07e2b6b53ed16e55211401277b1d97a87bc47e4fda07d7d0f8d642a36b3238ec46495262541f6b5e164d6fcde7bcf5b574a7cd22fc41f38eec9724a3e76c
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