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.
@@ -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]
@@ -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
@@ -204,11 +204,7 @@ module Tuile
204
204
  # @param key [String] a key.
205
205
  # @return [Boolean] true if the key was handled.
206
206
  def handle_key(key)
207
- if !active?
208
- false
209
- elsif super
210
- true
211
- elsif key == Keys::PAGE_UP
207
+ if key == Keys::PAGE_UP
212
208
  move_top_line_by(-viewport_lines)
213
209
  true
214
210
  elsif key == Keys::PAGE_DOWN
@@ -288,7 +284,7 @@ module Tuile
288
284
  end
289
285
  (0...rect.height).each do |row|
290
286
  line = paintable_line(row + @top_line, row, scrollbar)
291
- screen.print TTY::Cursor.move_to(rect.left, row + rect.top), line
287
+ screen.buffer.set_line(rect.left, row + rect.top, line)
292
288
  end
293
289
  end
294
290
 
@@ -296,6 +292,8 @@ module Tuile
296
292
  class Cursor
297
293
  # @param position [Integer] the initial cursor position.
298
294
  def initialize(position: 0)
295
+ raise "invalid position #{position}" unless position.is_a? Integer
296
+
299
297
  @position = position
300
298
  end
301
299
 
@@ -430,6 +428,8 @@ module Tuile
430
428
  # empty.
431
429
  # @param position [Integer] initial position.
432
430
  def initialize(positions, position: positions[0])
431
+ raise "positions are empty" if positions.empty?
432
+
433
433
  @positions = positions.sort
434
434
  position = @positions[@positions.rindex { _1 < position } || 0] unless @positions.include?(position)
435
435
  super(position: position)
@@ -759,15 +759,14 @@ module Tuile
759
759
  # @param row_in_viewport [Integer] 0-based row within the viewport.
760
760
  # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
761
761
  # if not shown.
762
- # @return [String] paintable ANSI-encoded line exactly `rect.width`
763
- # columns wide; highlighted if cursor is here.
762
+ # @return [StyledString] paintable line exactly `rect.width` columns wide;
763
+ # highlighted if cursor is here.
764
764
  def paintable_line(index, row_in_viewport, scrollbar)
765
765
  base = index < @lines.size ? @padded_lines[index] : @blank_padded
766
766
  is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
767
767
  styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
768
- out = styled.to_ansi
769
- out += scrollbar.scrollbar_char(row_in_viewport) if scrollbar
770
- out
768
+ styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
769
+ styled
771
770
  end
772
771
  end
773
772
  end
@@ -15,14 +15,29 @@ module Tuile
15
15
  # @param caption [String]
16
16
  def initialize(caption = "Log")
17
17
  super
18
- list = Component::List.new
19
- list.auto_scroll = true
20
- # Allow scrolling when a long stacktrace is logged.
21
- list.cursor = Component::List::Cursor.new
22
- self.content = list
18
+ view = Component::TextView.new
19
+ # Word-wrap long lines (stacktraces, wide log records) rather than
20
+ # ellipsizing them as a {List} would a truncated log line hides the
21
+ # very detail you opened the log to read.
22
+ view.auto_scroll = true
23
+ self.content = view
23
24
  self.scrollbar = true
24
25
  end
25
26
 
27
+ # Keep the log pane at least half the screen tall even when only a few
28
+ # lines have been logged: a {Component::Popup} sizes to its content, which
29
+ # would collapse a near-empty log to two or three rows. Advice consulted
30
+ # by {Component::Popup#min_height} when this window is a popup's content.
31
+ # @return [Integer]
32
+ def popup_min_height = screen.size.height / 2
33
+
34
+ # Let a busy log grow past the popup's base 12-row cap (up to the
35
+ # 4/5-of-screen ceiling {Component::Popup#update_rect} applies) so the
36
+ # diagnostic stream stays scrollable in a tall window. Advice consulted
37
+ # by {Component::Popup#max_height} when this window is a popup's content.
38
+ # @return [Integer]
39
+ def popup_max_height = screen.size.height
40
+
26
41
  # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
27
42
  # @param string [String, nil] the line (or multiple lines) to log.
28
43
  # @return [void]
@@ -50,11 +50,13 @@ module Tuile
50
50
  # @return [Proc, nil]
51
51
  attr_accessor :on_pick
52
52
 
53
+ # Handles an option-key press. Reached by bubbling: the inner {List}
54
+ # (the focused component) sees the key first and handles cursor/Enter
55
+ # picks; anything it declines bubbles up here, where a key matching an
56
+ # option's `key` picks that option.
53
57
  # @param key [String]
54
58
  # @return [Boolean]
55
59
  def handle_key(key)
56
- return true if super
57
-
58
60
  if @options.any? { _1.key == key }
59
61
  select_option(key)
60
62
  true