tuile 0.6.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.
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # An in-memory grid of styled cells mirroring the terminal screen. This is
5
+ # the back buffer behind flicker-free rendering: components paint into it
6
+ # (via {#set_line} / {#set_char} / {#fill}) instead of writing escape
7
+ # sequences straight to the terminal, and {#flush} emits the minimal escape
8
+ # string needed to bring a terminal — one that already matches the buffer's
9
+ # state as of the previous flush — up to date. Only cells that actually
10
+ # changed are emitted, so nothing flickers regardless of terminal/multiplexer
11
+ # synchronized-output support. See `ideas/back-buffer.md`.
12
+ #
13
+ # Coordinates are 0-based `(x, y)` = `(column, row)`, matching
14
+ # {Component#rect} and `TTY::Cursor.move_to`.
15
+ #
16
+ # ## Dirty tracking
17
+ #
18
+ # Every mutator compares the incoming grapheme+style against what's already
19
+ # there and records the cell dirty only when it differs — so both mutation
20
+ # and {#flush} cost scale with what actually changed, never with the buffer
21
+ # size. There is deliberately no per-frame whole-buffer clear or copy;
22
+ # un-touched cells retain the previous frame's value.
23
+ #
24
+ # The bookkeeping avoids hashing and full-grid scans: a dirty flag **on each
25
+ # cell** (O(1) set, no `Set` bucket math, no separate array), a per-row
26
+ # boolean so {#flush} scans only the rows that changed, and one global flag
27
+ # so {#dirty?} and the "nothing changed" early-out are O(1). {#flush} clears
28
+ # every flag it consumes.
29
+ #
30
+ # Cells are **mutable and pre-allocated**: the grid builds its {Cell}s once
31
+ # (at construction and {#resize}) and rewrites them in place, so a normal
32
+ # paint allocates nothing per cell. That is why {Cell} is a plain mutable
33
+ # object rather than a frozen value type. The empty state of a cell is a
34
+ # space in the default style.
35
+ #
36
+ # ## Wide characters
37
+ #
38
+ # A 2-column glyph (fullwidth CJK, most emoji) occupies its origin cell plus a
39
+ # **continuation** cell to its right (an empty-grapheme {Cell} the flush emits
40
+ # nothing for, since the glyph itself advances the cursor two columns).
41
+ # Overwriting either half of a wide glyph blanks the orphaned half, so the
42
+ # grid never holds a dangling continuation or a headless one.
43
+ class Buffer
44
+ # One screen cell: a single grapheme cluster, the {StyledString::Style} it's
45
+ # drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
46
+ # tracking") — the grid rewrites cells in place. A continuation cell (right
47
+ # half of a wide glyph) carries an empty grapheme — see {#continuation?}.
48
+ class Cell
49
+ # Read-only: mutate content through {#set} so dirty tracking stays correct.
50
+ # @return [String] one grapheme cluster, `" "` for blank, or `""` for a
51
+ # wide-glyph continuation.
52
+ attr_reader :grapheme
53
+
54
+ # @return [StyledString::Style]
55
+ attr_reader :style
56
+
57
+ # @return [Boolean] true if this cell changed since the last {Buffer#flush}.
58
+ # {Buffer} flips it (off as it flushes, on via {Buffer#mark_all_dirty}).
59
+ attr_accessor :dirty
60
+
61
+ # @param grapheme [String]
62
+ # @param style [StyledString::Style]
63
+ def initialize(grapheme, style)
64
+ @grapheme = grapheme
65
+ @style = style
66
+ @dirty = false
67
+ end
68
+
69
+ # @return [Boolean] true if this is the right half of a wide glyph, which
70
+ # {Buffer#flush} skips (the glyph to the left already moved the cursor
71
+ # past it).
72
+ def continuation? = @grapheme.empty?
73
+
74
+ # Sets the cell's content, flipping {#dirty} on when grapheme or style
75
+ # actually changes (an already-dirty cell stays dirty). Returns the
76
+ # resulting dirty flag, so callers can aggregate row/buffer dirty state in
77
+ # one step. The single mutation path behind {Buffer#set_char} / {#fill} /
78
+ # {#clear}.
79
+ # @param grapheme [String]
80
+ # @param style [StyledString::Style]
81
+ # @return [Boolean] {#dirty} after the write.
82
+ def set(grapheme, style)
83
+ return @dirty if @grapheme == grapheme && @style == style
84
+
85
+ @grapheme = grapheme
86
+ @style = style
87
+ @dirty = true
88
+ end
89
+
90
+ # Content equality (grapheme + style); the dirty flag is bookkeeping and
91
+ # is deliberately excluded.
92
+ # @param other [Object]
93
+ # @return [Boolean]
94
+ def ==(other)
95
+ other.is_a?(Cell) && @grapheme == other.grapheme && @style == other.style
96
+ end
97
+ end
98
+
99
+ # @return [StyledString::Style] the unstyled default.
100
+ DEFAULT_STYLE = StyledString::Style::DEFAULT
101
+ private_constant :DEFAULT_STYLE
102
+
103
+ # @param size [Size] grid dimensions in columns × rows.
104
+ def initialize(size)
105
+ allocate_grid(size)
106
+ # A fresh buffer never matches the terminal yet — the screen holds
107
+ # whatever was there at startup — so it begins fully dirty and the first
108
+ # flush paints the whole grid (gaps included). Same reasoning as {#resize}.
109
+ mark_all_dirty
110
+ end
111
+
112
+ # @return [Size] grid dimensions.
113
+ def size = Size.new(@width, @height)
114
+
115
+ # @return [Integer]
116
+ attr_reader :width, :height
117
+
118
+ # @param x [Integer] column.
119
+ # @param y [Integer] row.
120
+ # @return [Cell, nil] the live cell at `(x, y)` (do not mutate — paint via
121
+ # {#set_char} / {#set_line} so dirty tracking stays correct), or nil when
122
+ # out of bounds.
123
+ def cell(x, y)
124
+ return nil unless in_bounds?(x, y)
125
+
126
+ @cells[index(x, y)]
127
+ end
128
+
129
+ # @return [Boolean] true if any cell has changed since the last {#flush}.
130
+ def dirty? = @any_dirty
131
+
132
+ # Writes one grapheme cluster at `(x, y)`. A 2-column glyph also writes a
133
+ # continuation cell at `(x + 1, y)`; a wide glyph that would overflow the
134
+ # last column is replaced by a blank (terminals can't render a half-clipped
135
+ # wide glyph). Zero-width input (a lone combining mark) is ignored — it has
136
+ # no cell of its own. Out-of-bounds writes are dropped.
137
+ # @param x [Integer] column.
138
+ # @param y [Integer] row.
139
+ # @param grapheme [String] one grapheme cluster.
140
+ # @param style [StyledString::Style]
141
+ # @return [void]
142
+ def set_char(x, y, grapheme, style = DEFAULT_STYLE)
143
+ return unless in_bounds?(x, y)
144
+
145
+ w = Unicode::DisplayWidth.of(grapheme)
146
+ return if w <= 0
147
+
148
+ if w == 2 && !in_bounds?(x + 1, y)
149
+ repair_orphans(x, y)
150
+ return write_cell(x, y, " ", style)
151
+ end
152
+
153
+ repair_orphans(x, y)
154
+ repair_orphans(x + 1, y) if w == 2
155
+ write_cell(x, y, grapheme, style)
156
+ write_cell(x + 1, y, "", style) if w == 2
157
+ end
158
+
159
+ # Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
160
+ # display width and clipping at the right edge. The workhorse that replaces
161
+ # the old `screen.print(TTY::Cursor.move_to(x, y), styled.to_ansi)` per-row
162
+ # paint. Newlines in the string are not handled — pass one physical line.
163
+ # @param x [Integer] starting column.
164
+ # @param y [Integer] row.
165
+ # @param styled [StyledString]
166
+ # @return [void]
167
+ def set_line(x, y, styled)
168
+ col = x
169
+ styled.spans.each do |span|
170
+ span.text.grapheme_clusters.each do |g|
171
+ w = Unicode::DisplayWidth.of(g)
172
+ next if w <= 0 # combining mark with no base in this run: skip
173
+
174
+ break if col >= @width # rest of the line is clipped
175
+
176
+ set_char(col, y, g, span.style)
177
+ col += w
178
+ end
179
+ end
180
+ end
181
+
182
+ # Fills the intersection of `rect` and the buffer with blank cells in
183
+ # `style` — the cell-grid equivalent of clearing a background. Only `bg`
184
+ # shows; the grapheme is a space.
185
+ # @param rect [Rect]
186
+ # @param style [StyledString::Style]
187
+ # @return [void]
188
+ def fill(rect, style = DEFAULT_STYLE)
189
+ top = [rect.top, 0].max
190
+ bottom = [rect.top + rect.height, @height].min
191
+ left = [rect.left, 0].max
192
+ right = [rect.left + rect.width, @width].min
193
+ y = top
194
+ while y < bottom
195
+ x = left
196
+ while x < right
197
+ write_cell(x, y, " ", style)
198
+ x += 1
199
+ end
200
+ y += 1
201
+ end
202
+ end
203
+
204
+ # Blanks the entire buffer in `style`. A flat pass over every cell — no
205
+ # rect math or nested loops, since it covers the whole grid. Only cells
206
+ # that actually change are marked dirty (and their rows), so a {#flush}
207
+ # after clearing an already-blank buffer emits nothing.
208
+ # @param style [StyledString::Style]
209
+ # @return [void]
210
+ def clear(style = DEFAULT_STYLE)
211
+ @cells.each_with_index do |c, i|
212
+ next unless c.set(" ", style)
213
+
214
+ @dirty_rows[i / @width] = true
215
+ @any_dirty = true
216
+ end
217
+ end
218
+
219
+ # Marks every cell dirty, so the next {#flush} re-emits the whole grid.
220
+ # Used after a resize and whenever the terminal contents become unknown
221
+ # (e.g. the screen was cleared underneath us).
222
+ # @return [void]
223
+ def mark_all_dirty
224
+ @cells.each { |c| c.dirty = true }
225
+ @dirty_rows.fill(true)
226
+ @any_dirty = true
227
+ end
228
+
229
+ # Resizes the grid to `size`, reallocating blank cells and marking the
230
+ # whole buffer dirty — after a resize the terminal contents are undefined,
231
+ # so the next flush redraws from scratch.
232
+ # @param size [Size]
233
+ # @return [void]
234
+ def resize(size)
235
+ allocate_grid(size)
236
+ mark_all_dirty
237
+ end
238
+
239
+ # Emits the minimal escape sequence that updates a terminal — already
240
+ # matching this buffer as of the previous flush — to the current contents,
241
+ # then clears the dirty flags. Returns `""` when nothing changed.
242
+ #
243
+ # Scans only dirty rows; within a row, consecutive dirty cells form one run
244
+ # (one `TTY::Cursor.move_to` followed by their graphemes), with a running
245
+ # {StyledString::Style#sgr_to} diff so only changed attributes are sent
246
+ # (continuation cells emit nothing). The sequence always ends in the default
247
+ # style ({Ansi::RESET} when needed), the invariant the next flush relies on:
248
+ # the terminal's SGR state is default at flush boundaries.
249
+ # @return [String] the escape sequence to write to the terminal.
250
+ def flush
251
+ return "" unless @any_dirty
252
+
253
+ out = +""
254
+ style = DEFAULT_STYLE
255
+ y = 0
256
+ while y < @height
257
+ if @dirty_rows[y]
258
+ @dirty_rows[y] = false
259
+ style = flush_row(out, y, style)
260
+ end
261
+ y += 1
262
+ end
263
+ out << Ansi::RESET unless style.default?
264
+ @any_dirty = false
265
+ out
266
+ end
267
+
268
+ # @param y [Integer] row.
269
+ # @return [String] the plain text of row `y` (continuation cells contribute
270
+ # nothing, so wide glyphs read as their single cluster). Intended for
271
+ # tests; see {FakeScreen}.
272
+ def row_text(y)
273
+ return "" unless y >= 0 && y < @height
274
+
275
+ base = y * @width
276
+ (0...@width).map { |x| @cells[base + x].grapheme }.join
277
+ end
278
+
279
+ # @param y [Integer] row.
280
+ # @return [String] row `y` rendered to ANSI across its full width — the
281
+ # minimal-SGR encoding of its cells, equivalent to what a component's
282
+ # `set_line` of the whole row would have printed. Intended for tests that
283
+ # assert on styled output (see {FakeScreen}); empty for an out-of-range row.
284
+ def row_ansi(y)
285
+ return "" unless y >= 0 && y < @height
286
+
287
+ base = y * @width
288
+ spans = (0...@width).map do |x|
289
+ c = @cells[base + x]
290
+ StyledString::Span.new(text: c.grapheme, style: c.style)
291
+ end
292
+ StyledString.new(spans).to_ansi
293
+ end
294
+
295
+ # @param rect [Rect]
296
+ # @return [Array<String>] the plain text of each row within `rect`'s column
297
+ # range, top to bottom. The region equivalent of {#row_text}, for asserting
298
+ # what a component painted into its own rect. Intended for tests.
299
+ def region_text(rect)
300
+ region_cells(rect).map { |row| row.map(&:grapheme).join }
301
+ end
302
+
303
+ # @param rect [Rect]
304
+ # @return [Array<String>] each row within `rect` rendered to ANSI, top to
305
+ # bottom — byte-identical to what a component's per-row `set_line` over
306
+ # that rect emitted. The region equivalent of {#row_ansi}. Intended for
307
+ # tests asserting styled output.
308
+ def region_ansi(rect)
309
+ region_cells(rect).map do |row|
310
+ StyledString.new(row.map { |c| StyledString::Span.new(text: c.grapheme, style: c.style) }).to_ansi
311
+ end
312
+ end
313
+
314
+ private
315
+
316
+ # (Re)allocates a blank grid of `size` with clean dirty state. Callers
317
+ # follow with {#mark_all_dirty} when the terminal doesn't match the new
318
+ # grid — construction and {#resize} both do.
319
+ # @param size [Size]
320
+ # @return [void]
321
+ def allocate_grid(size)
322
+ raise TypeError, "expected Size, got #{size.inspect}" unless size.is_a?(Size)
323
+
324
+ @width = size.width
325
+ @height = size.height
326
+ @cells = Array.new(@width * @height) { Cell.new(" ", DEFAULT_STYLE) }
327
+ @dirty_rows = Array.new(@height, false)
328
+ @any_dirty = false
329
+ end
330
+
331
+ # Emits the dirty cells of row `y` into `out`, breaking a run at each clean
332
+ # cell, and returns the running style at the end of the row.
333
+ # @param out [String] accumulator.
334
+ # @param y [Integer]
335
+ # @param style [StyledString::Style] style the terminal currently holds.
336
+ # @return [StyledString::Style]
337
+ def flush_row(out, y, style)
338
+ base = y * @width
339
+ run_open = false
340
+ x = 0
341
+ while x < @width
342
+ c = @cells[base + x]
343
+ if c.dirty
344
+ c.dirty = false
345
+ unless run_open
346
+ out << TTY::Cursor.move_to(x, y)
347
+ run_open = true
348
+ end
349
+ unless c.continuation?
350
+ out << style.sgr_to(c.style) << c.grapheme
351
+ style = c.style
352
+ end
353
+ else
354
+ run_open = false
355
+ end
356
+ x += 1
357
+ end
358
+ style
359
+ end
360
+
361
+ # @param rect [Rect]
362
+ # @return [Array<Array<Cell>>] cells within `rect`, row-major, clamped to
363
+ # the grid (out-of-bounds positions yield a blank cell).
364
+ def region_cells(rect)
365
+ blank = Cell.new(" ", DEFAULT_STYLE)
366
+ (rect.top...(rect.top + rect.height)).map do |y|
367
+ (rect.left...(rect.left + rect.width)).map { |x| cell(x, y) || blank }
368
+ end
369
+ end
370
+
371
+ # @param x [Integer] column
372
+ # @param y [Integer] row
373
+ # @return [Integer] flat-array index for `(x, y)`.
374
+ def index(x, y) = (y * @width) + x
375
+
376
+ # @param x [Integer] column
377
+ # @param y [Integer] row
378
+ # @return [Boolean] true when `(x, y)` falls within the grid.
379
+ def in_bounds?(x, y) = x >= 0 && x < @width && y >= 0 && y < @height
380
+
381
+ # Rewrites the cell at `(x, y)` in place, marking it (and its row) dirty
382
+ # only when grapheme or style actually changes. Caller guarantees `(x, y)`
383
+ # is in bounds.
384
+ # @param x [Integer] column
385
+ # @param y [Integer] row
386
+ # @param grapheme [String] the new grapheme cluster
387
+ # @param style [StyledString::Style] the new style
388
+ # @return [void]
389
+ def write_cell(x, y, grapheme, style)
390
+ return unless @cells[index(x, y)].set(grapheme, style)
391
+
392
+ @dirty_rows[y] = true
393
+ @any_dirty = true
394
+ end
395
+
396
+ # If `(x, y)` is half of a wide glyph, blanks the *other* half, so a write
397
+ # that lands on either half doesn't strand the remaining one.
398
+ # @param x [Integer] column
399
+ # @param y [Integer] row
400
+ # @return [void]
401
+ def repair_orphans(x, y)
402
+ return unless in_bounds?(x, y)
403
+
404
+ c = @cells[index(x, y)]
405
+ if c.continuation?
406
+ write_cell(x - 1, y, " ", DEFAULT_STYLE) if in_bounds?(x - 1, y)
407
+ elsif Unicode::DisplayWidth.of(c.grapheme) == 2 && in_bounds?(x + 1, y)
408
+ write_cell(x + 1, y, " ", DEFAULT_STYLE)
409
+ end
410
+ end
411
+ end
412
+ end
@@ -49,9 +49,6 @@ module Tuile
49
49
  # @param key [String]
50
50
  # @return [Boolean]
51
51
  def handle_key(key)
52
- return false unless active?
53
- return true if super
54
-
55
52
  case key
56
53
  when Keys::ENTER, " "
57
54
  @on_click&.call
@@ -76,8 +73,8 @@ module Tuile
76
73
  return if rect.empty?
77
74
 
78
75
  label = "[ #{@caption} ]"[0, rect.width]
79
- styled = active? ? screen.theme.active_bg(label) : label
80
- screen.print TTY::Cursor.move_to(rect.left, rect.top), styled
76
+ styled = active? ? StyledString.styled(label, bg: screen.theme.active_bg_color) : StyledString.plain(label)
77
+ screen.buffer.set_line(rect.left, rect.top, styled)
81
78
  end
82
79
 
83
80
  private
@@ -9,12 +9,6 @@ module Tuile
9
9
  # @return [Component, nil] the current content component.
10
10
  attr_reader :content
11
11
 
12
- # @param key [String] a key.
13
- # @return [Boolean] true if the key was handled, false if not.
14
- def handle_key(key)
15
- content.nil? || !content.active? ? false : content.handle_key(key)
16
- end
17
-
18
12
  # @param event [MouseEvent]
19
13
  # @return [void]
20
14
  def handle_mouse(event)
@@ -70,7 +70,7 @@ module Tuile
70
70
 
71
71
  (0...rect.height).each do |row|
72
72
  line = @clipped_lines[row] || @blank_line
73
- screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
73
+ screen.buffer.set_line(rect.left, rect.top + row, line)
74
74
  end
75
75
  end
76
76
 
@@ -97,16 +97,16 @@ module Tuile
97
97
  end
98
98
 
99
99
  # Recomputes {@clipped_lines} for the current text and rect width.
100
- # Each line is ellipsized to fit, padded with trailing spaces out to
101
- # the full width, and pre-rendered to ANSI so {#repaint} is just a
102
- # lookup + screen.print per row. {@blank_line} covers rows past the
103
- # last text line. When {#bg} is set, every produced line (and the
104
- # blank row) has the bg applied uniformly.
100
+ # Each line is ellipsized to fit and padded with trailing spaces out to
101
+ # the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
102
+ # row. {@blank_line} covers rows past the last text line. When {#bg} is
103
+ # set, every produced line (and the blank row) has the bg applied
104
+ # uniformly.
105
105
  # @return [void]
106
106
  def update_clipped_lines
107
107
  width = rect.width.clamp(0, nil)
108
- @blank_line = apply_bg(StyledString.plain(" " * width)).to_ansi
109
- @clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)).to_ansi }
108
+ @blank_line = apply_bg(StyledString.plain(" " * width))
109
+ @clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)) }
110
110
  end
111
111
 
112
112
  # @param line [StyledString]
@@ -34,7 +34,7 @@ module Tuile
34
34
  # @return [void]
35
35
  def add(child)
36
36
  if child.is_a? Enumerable
37
- child.each { add(it) }
37
+ child.each { add(_1) }
38
38
  else
39
39
  raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
40
40
  raise ArgumentError, "#{child} already has a parent #{child.parent}" unless child.parent.nil?
@@ -75,18 +75,6 @@ module Tuile
75
75
  end
76
76
  end
77
77
 
78
- # Called when a character is pressed on the keyboard.
79
- # @param key [String] a key.
80
- # @return [Boolean] true if the key was handled, false if not.
81
- def handle_key(key)
82
- return true if super
83
-
84
- sc = @children.find(&:active?)
85
- return false if sc.nil?
86
-
87
- sc.handle_key(key)
88
- end
89
-
90
78
  # @return [void]
91
79
  def on_focus
92
80
  super