tuile 0.7.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +51 -24
  4. data/book/01-first-app.md +186 -0
  5. data/book/02-repaint.md +177 -0
  6. data/book/03-layout.md +379 -0
  7. data/book/04-event-loop.md +209 -0
  8. data/book/05-focus.md +188 -0
  9. data/book/06-theming.md +250 -0
  10. data/book/07-components.md +231 -0
  11. data/book/08-testing.md +186 -0
  12. data/book/README.md +79 -0
  13. data/examples/hello_world.rb +1 -2
  14. data/examples/sampler.rb +109 -0
  15. data/lib/tuile/ansi.rb +16 -0
  16. data/lib/tuile/buffer.rb +481 -0
  17. data/lib/tuile/component/button.rb +3 -14
  18. data/lib/tuile/component/has_content.rb +0 -6
  19. data/lib/tuile/component/info_window.rb +4 -2
  20. data/lib/tuile/component/label.rb +15 -23
  21. data/lib/tuile/component/layout.rb +0 -21
  22. data/lib/tuile/component/list.rb +10 -37
  23. data/lib/tuile/component/log_window.rb +6 -5
  24. data/lib/tuile/component/picker_window.rb +4 -2
  25. data/lib/tuile/component/popup.rb +85 -55
  26. data/lib/tuile/component/text_area.rb +1 -1
  27. data/lib/tuile/component/text_field.rb +1 -1
  28. data/lib/tuile/component/text_input.rb +25 -9
  29. data/lib/tuile/component/text_view.rb +6 -30
  30. data/lib/tuile/component/window.rb +77 -112
  31. data/lib/tuile/component.rb +16 -71
  32. data/lib/tuile/event_queue.rb +31 -10
  33. data/lib/tuile/fake_event_queue.rb +21 -5
  34. data/lib/tuile/fake_screen.rb +14 -1
  35. data/lib/tuile/fraction.rb +46 -0
  36. data/lib/tuile/screen.rb +98 -105
  37. data/lib/tuile/screen_pane.rb +82 -19
  38. data/lib/tuile/styled_string.rb +40 -30
  39. data/lib/tuile/version.rb +1 -1
  40. data/sig/tuile.rbs +653 -282
  41. metadata +13 -3
  42. data/lib/tuile/sizing.rb +0 -59
@@ -0,0 +1,481 @@
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.
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
+ #
44
+ # ## Future direction
45
+ #
46
+ # Components paint through this drawing surface ({#set_line} / {#set_char})
47
+ # without knowing whether it is the one global buffer or a private one — that
48
+ # indirection is deliberate, so per-component back buffers plus a z-order
49
+ # compositor could drop in without touching component code. It is not worth
50
+ # doing yet: the diff already drops unchanged cells from the wire, and an
51
+ # occluded component that didn't change is never repainted at all, so a
52
+ # compositor would only save residual `repaint` CPU. It pays off in exactly
53
+ # one regime — high repeat-rate scroll (held arrow / mouse wheel) of a large
54
+ # component on a large screen, where re-rendering the content each repeat is
55
+ # the dominant cost.
56
+ class Buffer
57
+ # One screen cell: a single grapheme cluster, the {StyledString::Style} it's
58
+ # drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
59
+ # tracking") — the grid rewrites cells in place. A continuation cell (right
60
+ # half of a wide glyph) carries an empty grapheme — see {#continuation?}.
61
+ class Cell
62
+ # Read-only: mutate content through {#set} so dirty tracking stays correct.
63
+ # @return [String] one grapheme cluster, `" "` for blank, or `""` for a
64
+ # wide-glyph continuation.
65
+ attr_reader :grapheme
66
+
67
+ # @return [StyledString::Style]
68
+ attr_reader :style
69
+
70
+ # @return [Boolean] true if this cell changed since the last {Buffer#flush}.
71
+ # {Buffer} flips it (off as it flushes, on via {Buffer#mark_all_dirty}).
72
+ attr_accessor :dirty
73
+
74
+ # @param grapheme [String]
75
+ # @param style [StyledString::Style]
76
+ def initialize(grapheme, style)
77
+ @grapheme = grapheme
78
+ @style = style
79
+ @dirty = false
80
+ end
81
+
82
+ # @return [Boolean] true if this is the right half of a wide glyph, which
83
+ # {Buffer#flush} skips (the glyph to the left already moved the cursor
84
+ # past it).
85
+ def continuation? = @grapheme.empty?
86
+
87
+ # Sets the cell's content, flipping {#dirty} on when grapheme or style
88
+ # actually changes (an already-dirty cell stays dirty). Returns the
89
+ # resulting dirty flag, so callers can aggregate row/buffer dirty state in
90
+ # one step. The single mutation path behind {Buffer#set_char} / {#fill} /
91
+ # {#clear}.
92
+ # @param grapheme [String]
93
+ # @param style [StyledString::Style]
94
+ # @return [Boolean] {#dirty} after the write.
95
+ def set(grapheme, style)
96
+ return @dirty if @grapheme == grapheme && @style == style
97
+
98
+ @grapheme = grapheme
99
+ @style = style
100
+ @dirty = true
101
+ end
102
+
103
+ # Content equality (grapheme + style); the dirty flag is bookkeeping and
104
+ # is deliberately excluded.
105
+ # @param other [Object]
106
+ # @return [Boolean]
107
+ def ==(other)
108
+ other.is_a?(Cell) && @grapheme == other.grapheme && @style == other.style
109
+ end
110
+ end
111
+
112
+ # @return [StyledString::Style] the unstyled default.
113
+ DEFAULT_STYLE = StyledString::Style::DEFAULT
114
+ private_constant :DEFAULT_STYLE
115
+
116
+ # Memo for {.display_width}: a grapheme's display width is fixed, and a TTY
117
+ # paints from a small, recurring alphabet (ASCII, box-drawing rules, a few
118
+ # emoji), so the per-grapheme width lookup — the dominant cost of a repaint
119
+ # (see `benchmark/display_width.rb`) — collapses to a Hash read after the
120
+ # first sighting. Shared across all buffers and unbounded, but bounded in
121
+ # practice by the font's glyph set; safe to share because all painting runs
122
+ # on the single UI thread (see AGENTS.md "Threading rule").
123
+ # @return [Hash{String => Integer}]
124
+ WIDTH_CACHE = Hash.new { |h, g| h[g] = Unicode::DisplayWidth.of(g) }
125
+ private_constant :WIDTH_CACHE
126
+
127
+ # Memoized {Unicode::DisplayWidth.of}. Use this for every paint-path width
128
+ # lookup instead of calling the gem directly.
129
+ # @param grapheme [String] one grapheme cluster.
130
+ # @return [Integer] its display width in columns (0 for combining marks).
131
+ def self.display_width(grapheme) = WIDTH_CACHE[grapheme]
132
+
133
+ # @param size [Size] grid dimensions in columns × rows.
134
+ def initialize(size)
135
+ allocate_grid(size)
136
+ # A fresh buffer never matches the terminal yet — the screen holds
137
+ # whatever was there at startup — so it begins fully dirty and the first
138
+ # flush paints the whole grid (gaps included). Same reasoning as {#resize}.
139
+ mark_all_dirty
140
+ end
141
+
142
+ # @return [Size] grid dimensions.
143
+ def size = Size.new(@width, @height)
144
+
145
+ # @return [Integer]
146
+ attr_reader :width, :height
147
+
148
+ # @param x [Integer] column.
149
+ # @param y [Integer] row.
150
+ # @return [Cell, nil] the live cell at `(x, y)` (do not mutate — paint via
151
+ # {#set_char} / {#set_line} so dirty tracking stays correct), or nil when
152
+ # out of bounds.
153
+ def cell(x, y)
154
+ return nil unless in_bounds?(x, y)
155
+
156
+ @cells[index(x, y)]
157
+ end
158
+
159
+ # @return [Boolean] true if any cell has changed since the last {#flush}.
160
+ def dirty? = @any_dirty
161
+
162
+ # Writes one grapheme cluster at `(x, y)`. A 2-column glyph also writes a
163
+ # continuation cell at `(x + 1, y)`; a wide glyph that would overflow the
164
+ # last column is replaced by a blank (terminals can't render a half-clipped
165
+ # wide glyph). Zero-width input (a lone combining mark) is ignored — it has
166
+ # no cell of its own. Out-of-bounds writes are dropped.
167
+ # @param x [Integer] column.
168
+ # @param y [Integer] row.
169
+ # @param grapheme [String] one grapheme cluster.
170
+ # @param style [StyledString::Style]
171
+ # @return [void]
172
+ def set_char(x, y, grapheme, style = DEFAULT_STYLE)
173
+ put_char(x, y, grapheme, Buffer.display_width(grapheme), style)
174
+ end
175
+
176
+ # Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
177
+ # display width and clipping at the right edge. The workhorse that replaces
178
+ # the old `screen.print(TTY::Cursor.move_to(x, y), styled.to_ansi)` per-row
179
+ # paint. Newlines in the string are not handled — pass one physical line.
180
+ # @param x [Integer] starting column.
181
+ # @param y [Integer] row.
182
+ # @param styled [StyledString]
183
+ # @return [void]
184
+ def set_line(x, y, styled)
185
+ col = x
186
+ styled.spans.each do |span|
187
+ span.text.grapheme_clusters.each do |g|
188
+ w = Buffer.display_width(g)
189
+ next if w <= 0 # combining mark with no base in this run: skip
190
+
191
+ break if col >= @width # rest of the line is clipped
192
+
193
+ # Reuse the width we just computed: put_char skips re-measuring `g`.
194
+ put_char(col, y, g, w, span.style)
195
+ col += w
196
+ end
197
+ end
198
+ end
199
+
200
+ # Fills the intersection of `rect` and the buffer with blank cells in
201
+ # `style` — the cell-grid equivalent of clearing a background. Only `bg`
202
+ # shows; the grapheme is a space.
203
+ # @param rect [Rect]
204
+ # @param style [StyledString::Style]
205
+ # @return [void]
206
+ def fill(rect, style = DEFAULT_STYLE)
207
+ top = [rect.top, 0].max
208
+ bottom = [rect.top + rect.height, @height].min
209
+ left = [rect.left, 0].max
210
+ right = [rect.left + rect.width, @width].min
211
+ y = top
212
+ while y < bottom
213
+ x = left
214
+ while x < right
215
+ write_cell(x, y, " ", style)
216
+ x += 1
217
+ end
218
+ y += 1
219
+ end
220
+ end
221
+
222
+ # Blanks the entire buffer in `style`. A flat pass over every cell — no
223
+ # rect math or nested loops, since it covers the whole grid. Only cells
224
+ # that actually change are marked dirty (and their rows), so a {#flush}
225
+ # after clearing an already-blank buffer emits nothing.
226
+ # @param style [StyledString::Style]
227
+ # @return [void]
228
+ def clear(style = DEFAULT_STYLE)
229
+ @cells.each_with_index do |c, i|
230
+ next unless c.set(" ", style)
231
+
232
+ @dirty_rows[i / @width] = true
233
+ @any_dirty = true
234
+ end
235
+ end
236
+
237
+ # Marks every cell dirty, so the next {#flush} re-emits the whole grid.
238
+ # Used after a resize and whenever the terminal contents become unknown
239
+ # (e.g. the screen was cleared underneath us).
240
+ # @return [void]
241
+ def mark_all_dirty
242
+ @cells.each { |c| c.dirty = true }
243
+ @dirty_rows.fill(true)
244
+ @any_dirty = true
245
+ end
246
+
247
+ # Resizes the grid to `size`, reallocating blank cells and marking the
248
+ # whole buffer dirty — after a resize the terminal contents are undefined,
249
+ # so the next flush redraws from scratch.
250
+ # @param size [Size]
251
+ # @return [void]
252
+ def resize(size)
253
+ allocate_grid(size)
254
+ mark_all_dirty
255
+ end
256
+
257
+ # Emits the minimal escape sequence that updates a terminal — already
258
+ # matching this buffer as of the previous flush — to the current contents,
259
+ # then clears the dirty flags. Returns `""` when nothing changed.
260
+ #
261
+ # Scans only dirty rows; within a row, consecutive dirty cells form one run
262
+ # (one `TTY::Cursor.move_to` followed by their graphemes), with a running
263
+ # {StyledString::Style#sgr_to} diff so only changed attributes are sent
264
+ # (continuation cells emit nothing). The sequence always ends in the default
265
+ # style ({Ansi::RESET} when needed), the invariant the next flush relies on:
266
+ # the terminal's SGR state is default at flush boundaries.
267
+ # @return [String] the escape sequence to write to the terminal.
268
+ def flush
269
+ return "" unless @any_dirty
270
+
271
+ out = +""
272
+ style = DEFAULT_STYLE
273
+ y = 0
274
+ while y < @height
275
+ if @dirty_rows[y]
276
+ @dirty_rows[y] = false
277
+ style = flush_row(out, y, style)
278
+ end
279
+ y += 1
280
+ end
281
+ out << Ansi::RESET unless style.default?
282
+ @any_dirty = false
283
+ out
284
+ end
285
+
286
+ # @param y [Integer] row.
287
+ # @return [String] the plain text of row `y` (continuation cells contribute
288
+ # nothing, so wide glyphs read as their single cluster). Intended for
289
+ # tests; see {FakeScreen}.
290
+ def row_text(y)
291
+ return "" unless y >= 0 && y < @height
292
+
293
+ base = y * @width
294
+ (0...@width).map { |x| @cells[base + x].grapheme }.join
295
+ end
296
+
297
+ # @param y [Integer] row.
298
+ # @return [String] row `y` rendered to ANSI across its full width — the
299
+ # minimal-SGR encoding of its cells, equivalent to what a component's
300
+ # `set_line` of the whole row would have printed. Intended for tests that
301
+ # assert on styled output (see {FakeScreen}); empty for an out-of-range row.
302
+ def row_ansi(y)
303
+ return "" unless y >= 0 && y < @height
304
+
305
+ base = y * @width
306
+ spans = (0...@width).map do |x|
307
+ c = @cells[base + x]
308
+ StyledString::Span.new(text: c.grapheme, style: c.style)
309
+ end
310
+ StyledString.new(spans).to_ansi
311
+ end
312
+
313
+ # @param rect [Rect]
314
+ # @return [Array<String>] the plain text of each row within `rect`'s column
315
+ # range, top to bottom. The region equivalent of {#row_text}, for asserting
316
+ # what a component painted into its own rect. Intended for tests.
317
+ def region_text(rect)
318
+ region_cells(rect).map { |row| row.map(&:grapheme).join }
319
+ end
320
+
321
+ # @param rect [Rect]
322
+ # @return [Array<String>] each row within `rect` rendered to ANSI, top to
323
+ # bottom — byte-identical to what a component's per-row `set_line` over
324
+ # that rect emitted. The region equivalent of {#row_ansi}. Intended for
325
+ # tests asserting styled output.
326
+ def region_ansi(rect)
327
+ region_cells(rect).map do |row|
328
+ StyledString.new(row.map { |c| StyledString::Span.new(text: c.grapheme, style: c.style) }).to_ansi
329
+ end
330
+ end
331
+
332
+ private
333
+
334
+ # Core of {#set_char} with the grapheme's display width already known.
335
+ # {#set_line} computes each width once while advancing the column and passes
336
+ # it straight through, so the paint hot path measures every grapheme exactly
337
+ # once (and that once is a {.display_width} memo read). See {#set_char} for
338
+ # the wide-glyph / clipping / out-of-bounds contract.
339
+ # @param x [Integer] column.
340
+ # @param y [Integer] row.
341
+ # @param grapheme [String] one grapheme cluster.
342
+ # @param w [Integer] `grapheme`'s display width (0, 1, or 2).
343
+ # @param style [StyledString::Style]
344
+ # @return [void]
345
+ def put_char(x, y, grapheme, w, style)
346
+ return unless in_bounds?(x, y)
347
+ return if w <= 0
348
+
349
+ if w == 2 && !in_bounds?(x + 1, y)
350
+ blank_left_partner(x, y)
351
+ return write_cell(x, y, " ", style)
352
+ end
353
+
354
+ # Repair only the glyphs we'd leave half-overwritten on our flanks: a wide
355
+ # glyph whose right half sits at `x`, or one whose left half sits at the
356
+ # last cell we write. The cells we fully rewrite need no pre-blanking —
357
+ # pre-blanking the continuation only to re-empty it would churn it
358
+ # spuriously dirty, which misplaces the next flush onto the glyph's right
359
+ # half (see bug/, balloon corruption).
360
+ blank_left_partner(x, y)
361
+ blank_right_partner(x + w - 1, y)
362
+ write_cell(x, y, grapheme, style)
363
+ write_cell(x + 1, y, "", style) if w == 2
364
+ end
365
+
366
+ # (Re)allocates a blank grid of `size` with clean dirty state. Callers
367
+ # follow with {#mark_all_dirty} when the terminal doesn't match the new
368
+ # grid — construction and {#resize} both do.
369
+ # @param size [Size]
370
+ # @return [void]
371
+ def allocate_grid(size)
372
+ raise TypeError, "expected Size, got #{size.inspect}" unless size.is_a?(Size)
373
+
374
+ @width = size.width
375
+ @height = size.height
376
+ @cells = Array.new(@width * @height) { Cell.new(" ", DEFAULT_STYLE) }
377
+ @dirty_rows = Array.new(@height, false)
378
+ @any_dirty = false
379
+ end
380
+
381
+ # Emits the dirty cells of row `y` into `out`, breaking a run at each clean
382
+ # cell, and returns the running style at the end of the row.
383
+ # @param out [String] accumulator.
384
+ # @param y [Integer]
385
+ # @param style [StyledString::Style] style the terminal currently holds.
386
+ # @return [StyledString::Style]
387
+ def flush_row(out, y, style)
388
+ base = y * @width
389
+ run_open = false
390
+ x = 0
391
+ while x < @width
392
+ c = @cells[base + x]
393
+ if c.dirty
394
+ c.dirty = false
395
+ # A continuation cell (right half of a wide glyph) renders nothing of
396
+ # its own and must never open a run: positioning the cursor onto its
397
+ # column would land the next glyph on the wide glyph's left half,
398
+ # corrupting it. When the wide glyph itself is dirty it is emitted from
399
+ # its origin cell and advances the cursor across this column; when the
400
+ # glyph is intact this column needs no output at all. (A continuation
401
+ # can be left spuriously dirty by an in-place wide-glyph repaint, so we
402
+ # can't assume its origin was emitted in this same run.)
403
+ unless c.continuation?
404
+ unless run_open
405
+ out << TTY::Cursor.move_to(x, y)
406
+ run_open = true
407
+ end
408
+ out << style.sgr_to(c.style) << c.grapheme
409
+ style = c.style
410
+ end
411
+ else
412
+ run_open = false
413
+ end
414
+ x += 1
415
+ end
416
+ style
417
+ end
418
+
419
+ # @param rect [Rect]
420
+ # @return [Array<Array<Cell>>] cells within `rect`, row-major, clamped to
421
+ # the grid (out-of-bounds positions yield a blank cell).
422
+ def region_cells(rect)
423
+ blank = Cell.new(" ", DEFAULT_STYLE)
424
+ (rect.top...(rect.top + rect.height)).map do |y|
425
+ (rect.left...(rect.left + rect.width)).map { |x| cell(x, y) || blank }
426
+ end
427
+ end
428
+
429
+ # @param x [Integer] column
430
+ # @param y [Integer] row
431
+ # @return [Integer] flat-array index for `(x, y)`.
432
+ def index(x, y) = (y * @width) + x
433
+
434
+ # @param x [Integer] column
435
+ # @param y [Integer] row
436
+ # @return [Boolean] true when `(x, y)` falls within the grid.
437
+ def in_bounds?(x, y) = x >= 0 && x < @width && y >= 0 && y < @height
438
+
439
+ # Rewrites the cell at `(x, y)` in place, marking it (and its row) dirty
440
+ # only when grapheme or style actually changes. Caller guarantees `(x, y)`
441
+ # is in bounds.
442
+ # @param x [Integer] column
443
+ # @param y [Integer] row
444
+ # @param grapheme [String] the new grapheme cluster
445
+ # @param style [StyledString::Style] the new style
446
+ # @return [void]
447
+ def write_cell(x, y, grapheme, style)
448
+ return unless @cells[index(x, y)].set(grapheme, style)
449
+
450
+ @dirty_rows[y] = true
451
+ @any_dirty = true
452
+ end
453
+
454
+ # If `(x, y)` holds the right half (continuation) of a wide glyph, blanks the
455
+ # orphaned left half at `x - 1`. Called before a write lands on `x`, so the
456
+ # wide glyph to the left isn't left headless.
457
+ # @param x [Integer] column
458
+ # @param y [Integer] row
459
+ # @return [void]
460
+ def blank_left_partner(x, y)
461
+ return unless in_bounds?(x, y) && @cells[index(x, y)].continuation? && in_bounds?(x - 1, y)
462
+
463
+ write_cell(x - 1, y, " ", DEFAULT_STYLE)
464
+ end
465
+
466
+ # If the cell just right of `(x, y)` is a continuation (the right half of a
467
+ # wide glyph whose origin is `(x, y)`), blanks it. Called before a write
468
+ # lands on `x`, so overwriting a wide origin doesn't strand its continuation.
469
+ # A continuation can only ever belong to the wide glyph immediately to its
470
+ # left, so the empty-grapheme test is exact — and cheaper than re-measuring
471
+ # the origin's width.
472
+ # @param x [Integer] column
473
+ # @param y [Integer] row
474
+ # @return [void]
475
+ def blank_right_partner(x, y)
476
+ return unless in_bounds?(x + 1, y) && @cells[index(x + 1, y)].continuation?
477
+
478
+ write_cell(x + 1, y, " ", DEFAULT_STYLE)
479
+ end
480
+ end
481
+ end
@@ -12,7 +12,7 @@ module Tuile
12
12
  # {Component#handle_mouse}.
13
13
  #
14
14
  # Assign a {#rect} (typically by the surrounding {Layout}) wide enough to
15
- # show `[ caption ]`; {#content_size} reports that natural width.
15
+ # show `[ caption ]` that natural width is `caption.length + 4`.
16
16
  class Button < Component
17
17
  # @param caption [String] the button's label.
18
18
  # @yield optional `on_click` callback; same as assigning {#on_click=}.
@@ -20,7 +20,6 @@ module Tuile
20
20
  super()
21
21
  @caption = caption.to_s
22
22
  @on_click = on_click
23
- self.content_size = natural_size
24
23
  end
25
24
 
26
25
  # @return [String] the button's label.
@@ -39,7 +38,6 @@ module Tuile
39
38
 
40
39
  @caption = new_caption
41
40
  invalidate
42
- self.content_size = natural_size
43
41
  end
44
42
 
45
43
  def focusable? = true
@@ -49,9 +47,6 @@ module Tuile
49
47
  # @param key [String]
50
48
  # @return [Boolean]
51
49
  def handle_key(key)
52
- return false unless active?
53
- return true if super
54
-
55
50
  case key
56
51
  when Keys::ENTER, " "
57
52
  @on_click&.call
@@ -76,15 +71,9 @@ module Tuile
76
71
  return if rect.empty?
77
72
 
78
73
  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
74
+ styled = active? ? StyledString.styled(label, bg: screen.theme.active_bg_color) : StyledString.plain(label)
75
+ screen.buffer.set_line(rect.left, rect.top, styled)
81
76
  end
82
-
83
- private
84
-
85
- # Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
86
- # @return [Size]
87
- def natural_size = Size.new(@caption.length + 4, 1)
88
77
  end
89
78
  end
90
79
  end
@@ -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)
@@ -21,9 +21,11 @@ module Tuile
21
21
  # Opens the info window as a popup.
22
22
  # @param caption [String]
23
23
  # @param lines [Array<String>] the content, may contain formatting.
24
+ # @param size [Size, Fraction] the popup's size, applied top-down; the
25
+ # list wraps and scrolls within it. Defaults to {Fraction::HALF}.
24
26
  # @return [Popup] the opened popup.
25
- def self.open(caption, lines)
26
- Popup.open(content: InfoWindow.new(caption, lines))
27
+ def self.open(caption, lines, size: Fraction::HALF)
28
+ Popup.open(content: InfoWindow.new(caption, lines), size: size)
27
29
  end
28
30
  end
29
31
  end
@@ -8,12 +8,17 @@ module Tuile
8
8
  # embedded ANSI is honored) or a {StyledString} directly. {#text}
9
9
  # always returns the {StyledString}.
10
10
  class Label < Component
11
- def initialize
12
- super
11
+ # @param text [String, StyledString, nil] initial text, coerced the same
12
+ # way {#text=} coerces it (a `String` is parsed via
13
+ # {StyledString.parse}; `nil` is an empty label). Equivalent to
14
+ # constructing empty and assigning {#text=}.
15
+ def initialize(text = nil)
16
+ super()
13
17
  @text = StyledString::EMPTY
14
18
  @bg = nil
15
19
  @clipped_lines = []
16
20
  @blank_line = ""
21
+ self.text = text unless text.nil?
17
22
  end
18
23
 
19
24
  # @return [StyledString] the current text. Defaults to an empty
@@ -38,7 +43,6 @@ module Tuile
38
43
  @text = new_text
39
44
  update_clipped_lines
40
45
  invalidate
41
- self.content_size = compute_content_size
42
46
  end
43
47
 
44
48
  # Sets the background color. Coerced via {Color.coerce}, so a Symbol,
@@ -70,7 +74,7 @@ module Tuile
70
74
 
71
75
  (0...rect.height).each do |row|
72
76
  line = @clipped_lines[row] || @blank_line
73
- screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
77
+ screen.buffer.set_line(rect.left, rect.top + row, line)
74
78
  end
75
79
  end
76
80
 
@@ -84,29 +88,17 @@ module Tuile
84
88
 
85
89
  private
86
90
 
87
- # Natural size: longest hard-line's display width × number of hard
88
- # lines. Computed on the *unclipped* text — sizing is intrinsic to the
89
- # content, not the viewport. Empty text yields {Size::ZERO}.
90
- # @return [Size]
91
- def compute_content_size
92
- return Size::ZERO if @text.empty?
93
-
94
- hard_lines = @text.lines
95
- width = hard_lines.map(&:display_width).max || 0
96
- Size.new(width, hard_lines.size)
97
- end
98
-
99
91
  # 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.
92
+ # Each line is ellipsized to fit and padded with trailing spaces out to
93
+ # the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
94
+ # row. {@blank_line} covers rows past the last text line. When {#bg} is
95
+ # set, every produced line (and the blank row) has the bg applied
96
+ # uniformly.
105
97
  # @return [void]
106
98
  def update_clipped_lines
107
99
  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 }
100
+ @blank_line = apply_bg(StyledString.plain(" " * width))
101
+ @clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)) }
110
102
  end
111
103
 
112
104
  # @param line [StyledString]
@@ -56,15 +56,6 @@ module Tuile
56
56
  on_child_removed(child)
57
57
  end
58
58
 
59
- # @return [Size]
60
- def content_size
61
- return Size::ZERO if @children.empty?
62
-
63
- right = @children.map { |c| c.rect.left + c.rect.width }.max
64
- bottom = @children.map { |c| c.rect.top + c.rect.height }.max
65
- Size.new(right - rect.left, bottom - rect.top)
66
- end
67
-
68
59
  # Dispatches the event to the child under the mouse cursor.
69
60
  # @param event [MouseEvent]
70
61
  # @return [void]
@@ -75,18 +66,6 @@ module Tuile
75
66
  end
76
67
  end
77
68
 
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
69
  # @return [void]
91
70
  def on_focus
92
71
  super