tuile 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ # 8. Testing a Tuile app
2
+
3
+ Every chapter so far has, quietly, also been about this one. When chapter
4
+ 2 said components *invalidate* rather than paint, and write into a back
5
+ *buffer* rather than to the terminal — that was a testing decision as much
6
+ as a rendering one. When chapter 4 insisted everything runs on one thread
7
+ through a queue you could swap out — same. A Tuile app is testable without
8
+ a terminal not because someone bolted on a test mode, but because the
9
+ framework never actually needed the terminal. It needed a `Screen`, a
10
+ buffer, and an event queue, and each of those has an in-memory double that
11
+ behaves like the real thing minus the I/O.
12
+
13
+ So this chapter is the payoff. It shows how to exercise a UI in a plain
14
+ unit test — instantiate a component, poke it, and read back exactly what
15
+ it drew — and, when that isn't enough, how to drive the whole real script
16
+ through a pseudo-terminal.
17
+
18
+ ## The fake screen, and why it resets
19
+
20
+ The singleton `Screen` from chapter 4 is the thing tests replace. Two
21
+ class methods bracket every example:
22
+
23
+ ```ruby
24
+ before { Screen.fake }
25
+ after { Screen.close }
26
+ ```
27
+
28
+ `Screen.fake` installs a {Tuile::FakeScreen} as the process singleton — a
29
+ `Screen` subclass with the terminal amputated. It has a fixed 160×50
30
+ viewport (so geometry is deterministic, independent of whoever's terminal
31
+ runs the suite), it writes nothing to any TTY, its `check_locked` is a
32
+ no-op so you can mutate the UI freely from the test thread without holding
33
+ the UI lock, and its event queue is the synchronous {Tuile::FakeEventQueue}
34
+ (more on that below). It also pins the color scheme to `:dark`, skipping
35
+ the OSC 11 probe from chapter 6 — a probe would otherwise write an escape
36
+ query to the test runner's terminal and swallow its input.
37
+
38
+ `Screen.close` matters as much as `fake` does, and the reason is the
39
+ singleton itself. Because there is exactly one `Screen` per process
40
+ (chapter 4), a screen left standing after one example is the *same* screen
41
+ the next example sees — its tree, its focus, its invalidation set, all
42
+ leaked forward. `close` tears the singleton down so each example starts
43
+ from nothing. Skip the `after` and you get the classic singleton test
44
+ smell: passes in isolation, fails in suite, order-dependent. The pair is
45
+ not boilerplate you can trim.
46
+
47
+ ## Asserting what got painted
48
+
49
+ Here's where the back buffer earns its keep. Recall from chapter 2 that
50
+ components don't emit escape sequences — they write styled cells into
51
+ `Screen#buffer`, and only a flush turns that into wire bytes. In a test
52
+ that means the buffer *is* the rendered screen, sitting in memory, fully
53
+ inspectable, before any diffing or I/O. You assert against it directly.
54
+
55
+ The rhythm is: build the component, give it a `rect`, repaint, read the
56
+ buffer back over that rect.
57
+
58
+ ```ruby
59
+ label = Component::Label.new
60
+ label.rect = Rect.new(0, 0, 10, 1)
61
+ label.text = "hi"
62
+ label.repaint
63
+
64
+ assert_equal ["hi "], Screen.instance.buffer.region_text(label.rect)
65
+ ```
66
+
67
+ {Tuile::Buffer#region_text} returns the plain text of each row in the
68
+ rect, one string per row (trailing pad included — the label fills its
69
+ width). Its sibling {Tuile::Buffer#region_ansi} returns the same rows *with
70
+ their SGR styling* rendered back into ANSI, which is what you assert
71
+ against when the test is about *color* — that a focused field painted its
72
+ active-background, say, or that a theme flip changed a hint's hue. And
73
+ {Tuile::Buffer#cell} gives you a single cell's grapheme and style for a
74
+ pinpoint check. Everything is scoped to a `rect`, so you assert about a
75
+ component's own region without caring what surrounds it.
76
+
77
+ What you do *not* assert content against is `prints`. On a FakeScreen,
78
+ `prints` captures only what actually went "to the wire" — cursor
79
+ positioning, housekeeping escapes, and the assembled frame string. Content
80
+ lives in the buffer; cursor behavior lives in `prints`. Keeping the two
81
+ apart is deliberate, and mixing them up is the most common way a first
82
+ Tuile test goes wrong.
83
+
84
+ ## Driving the system
85
+
86
+ There are two altitudes at which you feed input, and picking the right one
87
+ is most of writing a good Tuile test.
88
+
89
+ **Low: call the component directly.** {Tuile::Component#handle_key} and
90
+ `handle_mouse` are public, and calling them straight tests a component's
91
+ own logic in isolation — no focus, no dispatch, just "given this key, does
92
+ the list move its cursor?" `handle_key` returns whether it consumed the
93
+ key, so you assert on that too:
94
+
95
+ ```ruby
96
+ list.handle_key(Keys::DOWN_ARROW) # exercises the cursor directly
97
+ list.handle_mouse(MouseEvent.new(:left, 5, 2))
98
+ ```
99
+
100
+ **High: go through the screen.** {Tuile::Screen#handle_key} runs the *full*
101
+ dispatch pipeline from chapter 5 — Tab cycling, global shortcuts, the
102
+ subtree `key_shortcut` search, then the focus chain. When your test is
103
+ about routing — that a shortcut jumps focus, that a modal popup traps Tab,
104
+ that a focused text field swallows a key its sibling would otherwise claim
105
+ — you drive `Screen.instance.handle_key` and let the real machinery run.
106
+ Set focus the way production does, with `screen.focused = component` or
107
+ `component.focus`.
108
+
109
+ Two more test-only hooks close the loop. After you mutate something, call
110
+ `Screen.instance.repaint` to flush the pending invalidations into the
111
+ buffer — the coalesced repaint that chapter 2 said happens once per tick,
112
+ triggered by hand because there's no loop running to trigger it. (This is
113
+ a *test* affordance; production code never calls `repaint`, it just
114
+ invalidates and lets the loop coalesce.) And to check invalidation itself
115
+ — that a setter did, or deliberately didn't, mark its component dirty —
116
+ `Screen.instance.invalidated?(component)` and `invalidated_clear` let you
117
+ assert on the set directly.
118
+
119
+ ## Why background code just works
120
+
121
+ Chapter 4's rule was that background threads marshal UI work back with
122
+ `screen.event_queue.submit { … }`. That rule has a happy consequence for
123
+ tests: under the {Tuile::FakeEventQueue}, `submit` **runs its block
124
+ synchronously, right now, on the calling thread.** There is no loop, no
125
+ thread, no waiting. So code that in production hands work across the
126
+ thread boundary — a `LogWindow#log`, a worker posting a result — executes
127
+ inline the moment the test calls it, and the effect is visible on the very
128
+ next line. Posted *events* are simply discarded (a test isn't running the
129
+ loop that would consume them), and `check_locked` passing for free means
130
+ none of this trips the lock guard.
131
+
132
+ The one thing with no clock is animation. `tick` on the fake returns a
133
+ timeless ticker that fires only when the test tells it to: call
134
+ `event_queue.tick_once` to pump every registered ticker one frame. A test
135
+ that wants to advance an animation five frames calls `tick_once` five
136
+ times — frame cadence is the test's to decide, since there's no real time
137
+ passing.
138
+
139
+ ## End to end, through a real terminal
140
+
141
+ Unit tests with the fake cover component logic and rendering, which is
142
+ most of what you write. But some things only exist when the *real* app
143
+ runs: the event loop actually looping, raw-mode key decoding, the WINCH
144
+ trap, a live color-scheme flip. For those, Tuile's own suite spawns the
145
+ real example script in a pseudo-terminal and talks to it like a user
146
+ would. The `spec/examples/` tests are the template.
147
+
148
+ The shape is always the same. Spawn the script under `PTY.spawn`; read its
149
+ output until a glyph you know it paints appears — that single wait proves a
150
+ lot at once (the tree built, a repaint ran, and the loop is now parked in
151
+ the key wait); write a keystroke; assert the process exits cleanly.
152
+
153
+ ```ruby
154
+ PTY.spawn("bundle", "exec", "ruby", "-Ilib", "examples/hello_world.rb") do |reader, writer, pid|
155
+ buffer = String.new
156
+ Timeout.timeout(10) do
157
+ buffer << reader.readpartial(4096) until buffer.include?("Hello, world!")
158
+ end
159
+ writer.write("q")
160
+ Timeout.timeout(5) { Process.wait(pid) }
161
+ assert_equal 0, $CHILD_STATUS.exitstatus
162
+ end
163
+ ```
164
+
165
+ This is the only place the whole stack is exercised together, and it's
166
+ where you'd test something like the mode-2031 flip from chapter 6:
167
+ wait for the dark-theme hint to paint, write the terminal's
168
+ `\e[?997;2n` light report into the PTY, then wait for the *light* hint —
169
+ which passes only if the entire chain (key-thread drain, event parse,
170
+ theme reassignment, full repaint) actually ran end to end. The cost is
171
+ that PTY tests are slower, terminal-dependent, and Linux/macOS only
172
+ (Ruby's stdlib `PTY` isn't on Windows), so they stay a thin top layer over
173
+ a broad base of fake-screen unit tests — the classic pyramid.
174
+
175
+ ---
176
+
177
+ That closes the book. You've followed Tuile from the outside in and back
178
+ out: a tree of components (chapter 1) that repaint through a back buffer
179
+ without flicker (chapter 2), sized top-down by their parents (chapter 3),
180
+ driven by a single-threaded event loop (chapter 4), with keys routed by
181
+ focus (chapter 5) and accents drawn from a terminal-following theme
182
+ (chapter 6); then the library you assemble from (chapter 7), and finally
183
+ the fakes that let you test all of it with no terminal in sight (this
184
+ one). The recurring lesson is the one the name promises: small pieces, each
185
+ doing an obvious thing, composed. The framework is small because the ideas
186
+ are few — and now they're all yours to build on.
data/book/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # The Tuile guide
2
+
3
+ A short, sequential guide to building terminal UIs with Tuile. Each
4
+ chapter builds on the vocabulary the previous one established, so
5
+ reading order matters — the layout chapter assumes the component tree
6
+ from chapter 1, focus assumes the event loop, and so on. Don't jump
7
+ into chapter 5 without chapters 1 and 2 first.
8
+
9
+ If you want the reference instead of the narrative, every public class
10
+ and module is documented with YARD headers in the sources. Run
11
+ `bundle exec rake yard` for a browsable local site, or — once the gem
12
+ is published — see <https://rubydoc.info/gems/tuile>. The division of
13
+ labour: this guide teaches concepts and the *why*; the rdoc carries the
14
+ precise per-method technical truth. When the guide needs a signature it
15
+ links to the rdoc rather than restating it.
16
+
17
+ ## How the guide is shaped
18
+
19
+ Chapters 1–2 are the **base vocabulary** — the component tree and the
20
+ repaint model that every later chapter leans on. Chapter 3 is the heart
21
+ of Tuile's design: layout is top-down and absolute, and the chapter
22
+ argues *why that is enough* — the "C64" case for hand-placed
23
+ coordinates on a character grid — rather than reaching for the
24
+ negotiated min/pref/max machinery of desktop and web toolkits.
25
+
26
+ Chapters 4–6 are the **runtime**: the single-threaded event loop and
27
+ how to do background work safely, focus and keyboard dispatch, and
28
+ theming (including live OS light/dark flips). Chapters 7–8 close out
29
+ **narratively** — a tour of the shipped component toolbox framed around
30
+ when and why to reach for each, and how to test a Tuile app end to end.
31
+ Those two lean on the rdoc for the exact APIs; the guide keeps to the
32
+ walkthroughs and use-cases.
33
+
34
+ The book grows organically — a chapter exists when a concept has earned
35
+ one, not to fill an outline.
36
+
37
+ ## Chapters
38
+
39
+ 1. **[Your first Tuile app](01-first-app.md).** Install, then a
40
+ hello-world walkthrough. Establishes the base vocabulary the rest of
41
+ the guide leans on: the singleton `Tuile::Screen`, the tree of
42
+ `Tuile::Component`s under `ScreenPane`, and the "build a tree, run
43
+ the loop" shape of every Tuile program.
44
+ 2. **[How the screen repaints](02-repaint.md).** Why components never
45
+ write to the terminal directly. `invalidate` → the back buffer →
46
+ a minimal diff → one synchronized flush per tick. The "cover your
47
+ own `rect`" contract, and why the whole model is flicker-free
48
+ without damage tracking or clipping.
49
+ 3. **[Layout: the parent sets the size](03-layout.md).** The heart of
50
+ the design. Top-down, absolute, integer coordinates; a parent
51
+ assigns its children's `rect` and components never negotiate a size.
52
+ The C64 argument for *why simple layouting is enough* on a character
53
+ grid, `Layout::Absolute` and the `rect=` override, `Fraction` for
54
+ sizing a popup against the screen, and resize as a discrete
55
+ recompute. Geometry primitives (`Point` / `Size` / `Rect`) live here.
56
+ 4. **[The event loop and background work](04-event-loop.md).** The
57
+ single-threaded rule: all UI mutation happens on the loop thread.
58
+ The event queue, marshalling work back from a background thread with
59
+ `submit`, and how terminal resize (`SIGWINCH`) is plumbed through the
60
+ same queue rather than handled off the signal.
61
+ 5. **[Focus and the keyboard](05-focus.md).** The focus chain and
62
+ `focusable?`, and the order in which a keystroke is offered to the
63
+ tree — Tab, global shortcuts, a component's `key_shortcut`, then
64
+ `handle_key`. How a focused text field swallows printable keys, and
65
+ how `keyboard_hint` drives the status bar.
66
+ 6. **[Theming](06-theming.md).** Semantic color tokens read at paint
67
+ time, light/dark auto-detection at startup and live OS appearance
68
+ flips, pairing variants in a `ThemeDef`, app-specific custom tokens,
69
+ and rebuilding theme-derived content in `on_theme_changed`.
70
+ 7. **[The component library](07-components.md).** A narrative tour of
71
+ the shipped toolbox — Window, List, the text inputs and views,
72
+ Popup, and the window conveniences — framed around *when and why*
73
+ you reach for each. Signatures stay in the rdoc.
74
+ 8. **[Testing a Tuile app](08-testing.md).** The testing approach:
75
+ `FakeScreen`, asserting against the painted buffer, driving
76
+ invalidation, and PTY-based end-to-end tests of runnable scripts.
77
+
78
+ All eight chapters are currently **stubs** — the skeleton is in place so
79
+ the numbering is stable; prose lands chapter by chapter.
@@ -14,8 +14,7 @@ require "tuile"
14
14
  # Tuile::Screen.instance during invalidate/repaint hooks.
15
15
  screen = Tuile::Screen.new
16
16
 
17
- label = Tuile::Component::Label.new
18
- label.text = "Hello, world!"
17
+ label = Tuile::Component::Label.new("Hello, world!")
19
18
 
20
19
  window = Tuile::Component::Window.new("Tuile")
21
20
  window.content = label
data/lib/tuile/buffer.rb CHANGED
@@ -8,7 +8,7 @@ module Tuile
8
8
  # string needed to bring a terminal — one that already matches the buffer's
9
9
  # state as of the previous flush — up to date. Only cells that actually
10
10
  # changed are emitted, so nothing flickers regardless of terminal/multiplexer
11
- # synchronized-output support. See `ideas/back-buffer.md`.
11
+ # synchronized-output support.
12
12
  #
13
13
  # Coordinates are 0-based `(x, y)` = `(column, row)`, matching
14
14
  # {Component#rect} and `TTY::Cursor.move_to`.
@@ -40,6 +40,19 @@ module Tuile
40
40
  # nothing for, since the glyph itself advances the cursor two columns).
41
41
  # Overwriting either half of a wide glyph blanks the orphaned half, so the
42
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.
43
56
  class Buffer
44
57
  # One screen cell: a single grapheme cluster, the {StyledString::Style} it's
45
58
  # drawn in, and a dirty flag. Mutable by design (see {Buffer} "Dirty
@@ -100,6 +113,23 @@ module Tuile
100
113
  DEFAULT_STYLE = StyledString::Style::DEFAULT
101
114
  private_constant :DEFAULT_STYLE
102
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
+
103
133
  # @param size [Size] grid dimensions in columns × rows.
104
134
  def initialize(size)
105
135
  allocate_grid(size)
@@ -140,20 +170,7 @@ module Tuile
140
170
  # @param style [StyledString::Style]
141
171
  # @return [void]
142
172
  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
173
+ put_char(x, y, grapheme, Buffer.display_width(grapheme), style)
157
174
  end
158
175
 
159
176
  # Writes a {StyledString} starting at `(x, y)`, advancing by each grapheme's
@@ -168,12 +185,13 @@ module Tuile
168
185
  col = x
169
186
  styled.spans.each do |span|
170
187
  span.text.grapheme_clusters.each do |g|
171
- w = Unicode::DisplayWidth.of(g)
188
+ w = Buffer.display_width(g)
172
189
  next if w <= 0 # combining mark with no base in this run: skip
173
190
 
174
191
  break if col >= @width # rest of the line is clipped
175
192
 
176
- set_char(col, y, g, span.style)
193
+ # Reuse the width we just computed: put_char skips re-measuring `g`.
194
+ put_char(col, y, g, w, span.style)
177
195
  col += w
178
196
  end
179
197
  end
@@ -313,6 +331,38 @@ module Tuile
313
331
 
314
332
  private
315
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
+
316
366
  # (Re)allocates a blank grid of `size` with clean dirty state. Callers
317
367
  # follow with {#mark_all_dirty} when the terminal doesn't match the new
318
368
  # grid — construction and {#resize} both do.
@@ -342,11 +392,19 @@ module Tuile
342
392
  c = @cells[base + x]
343
393
  if c.dirty
344
394
  c.dirty = false
345
- unless run_open
346
- out << TTY::Cursor.move_to(x, y)
347
- run_open = true
348
- end
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.)
349
403
  unless c.continuation?
404
+ unless run_open
405
+ out << TTY::Cursor.move_to(x, y)
406
+ run_open = true
407
+ end
350
408
  out << style.sgr_to(c.style) << c.grapheme
351
409
  style = c.style
352
410
  end
@@ -393,20 +451,31 @@ module Tuile
393
451
  @any_dirty = true
394
452
  end
395
453
 
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.
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.
398
457
  # @param x [Integer] column
399
458
  # @param y [Integer] row
400
459
  # @return [void]
401
- def repair_orphans(x, y)
402
- return unless in_bounds?(x, y)
460
+ def blank_left_partner(x, y)
461
+ return unless in_bounds?(x, y) && @cells[index(x, y)].continuation? && in_bounds?(x - 1, y)
403
462
 
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
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)
410
479
  end
411
480
  end
412
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
@@ -76,12 +74,6 @@ module Tuile
76
74
  styled = active? ? StyledString.styled(label, bg: screen.theme.active_bg_color) : StyledString.plain(label)
77
75
  screen.buffer.set_line(rect.left, rect.top, styled)
78
76
  end
79
-
80
- private
81
-
82
- # Natural width is `caption.length + 4` to fit `[ caption ]`; height 1.
83
- # @return [Size]
84
- def natural_size = Size.new(@caption.length + 4, 1)
85
77
  end
86
78
  end
87
79
  end
@@ -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,
@@ -84,18 +88,6 @@ 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
92
  # Each line is ellipsized to fit and padded with trailing spaces out to
101
93
  # the full width, so {#repaint} is just a lookup + {Buffer#set_line} per
@@ -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]
@@ -147,7 +147,6 @@ module Tuile
147
147
  update_top_line_if_auto_scroll
148
148
  notify_cursor_changed
149
149
  invalidate
150
- self.content_size = compute_content_size
151
150
  end
152
151
 
153
152
  # Without a block, returns the current lines. With a block, fully
@@ -194,7 +193,6 @@ module Tuile
194
193
  update_top_line_if_auto_scroll
195
194
  notify_cursor_changed
196
195
  invalidate
197
- grow_content_size(new_lines)
198
196
  end
199
197
 
200
198
  def focusable? = true
@@ -508,30 +506,6 @@ module Tuile
508
506
 
509
507
  private
510
508
 
511
- # Natural size from scratch: longest line's display width plus the two
512
- # single-space gutters {#pad_to_row} adds, × line count. An empty list
513
- # is {Size::ZERO} (no gutters for no content).
514
- # @return [Size]
515
- def compute_content_size
516
- content_w = @lines.map(&:display_width).max || 0
517
- width = @lines.empty? ? 0 : content_w + 2
518
- Size.new(width, @lines.size)
519
- end
520
-
521
- # Incremental {#content_size} update for appends: folds just the
522
- # appended lines into the running maximum, keeping {#add_lines}
523
- # O(appended) instead of re-scanning the whole list (LogWindow appends
524
- # a line per log statement).
525
- # @param appended [Array<StyledString>] the just-appended lines
526
- # (already concatenated onto {@lines}).
527
- # @return [void]
528
- def grow_content_size(appended)
529
- return if appended.empty?
530
-
531
- appended_w = appended.map(&:display_width).max + 2
532
- self.content_size = Size.new([content_size.width, appended_w].max, @lines.size)
533
- end
534
-
535
509
  # Coerces and flattens a list of input entries into trimmed
536
510
  # {StyledString} lines. Each entry becomes a {StyledString} (String
537
511
  # via {StyledString.parse}, StyledString passed through, anything else
@@ -24,20 +24,6 @@ module Tuile
24
24
  self.scrollbar = true
25
25
  end
26
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
-
41
27
  # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
42
28
  # @param string [String, nil] the line (or multiple lines) to log.
43
29
  # @return [void]