tuile 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd5711addbd65a00c8d471204d50973da93ac9be65ba197114ac04c94cace526
4
- data.tar.gz: 4505d93153dc96fd439e5d69a7b1506e985fc9094f8428fe092ebc6247d62b8a
3
+ metadata.gz: bf132f33d9f0dfd061a502d0b974a9906436bbc585bf363eb0ec7d97a2b223ae
4
+ data.tar.gz: 2d91fe558079a4b5c43abd3818c3bb47cd50dc4ac3372c4afe011ef33dc97045
5
5
  SHA512:
6
- metadata.gz: 23c69343d8a0cc87143b12cd1a4a9a11862c770debf9c5381cabfcb59b55786f8be143ef362581e0a79bb1dc7f9ce512f47d755f603cb486bf4058b3487d4399
7
- data.tar.gz: '0974d322289b63b43bdd498e172f446d7cc56df656645b8e6826fed388154dda4a287110819d305572dbbf1c1ee23011fc4e0cb208f6459d67535e2df5c1d2b1'
6
+ metadata.gz: 8258e9e552143470a5642559f6db98e5292e14fa2fa52c30a06e26bf505e44ff1c889da73792bc262def396997861187d02d95d4189ced1f853ab91333e606b5
7
+ data.tar.gz: 99c03866f8d65ec2c6ca61381078b69616a3e23b3dada3cc11a3d4e2e1fc2973e1d15ac2ac0147e0e583edd20fae247e5be85ad974cf53e245a8cc109b3fe084
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-05-21
4
+
5
+ - Add `Tuile::Color` — a value type wrapping the four color forms ANSI understands (named Symbol, 256-color Integer, RGB Array, or `nil`). Pre-defined constants `Color::RED`, `Color::BRIGHT_BLUE`, … cover the 16 named ANSI colors; `Color.coerce` accepts raw forms transparently.
6
+ - `Component::Label`: add `bg` accessor — applies a background color uniformly across every painted row (text, trailing pad, and blank rows past the last line). Accepts anything `Color.coerce` accepts.
7
+ - Add `Component::TextView::Region` — opaque handle to a contiguous run of hard lines, so apps can stream into logical sections without tracking line indices across sibling mutations. Create with `view.create_region`; mutate via `region.append`/`#<<`/`#text=`/`#add_line`/`#remove_last_n_lines`/`#replace`/`#insert`/`#remove`. Detached handles raise on every reader / mutator (except `#remove`, which is idempotent). `view.text=` / `clear` detach all region handles and install a fresh internal default.
8
+ - Add `Component::TextView#replace(range, str)` and `#insert(at, str)` for mid-buffer hard-line splices (Integer or Range, inclusive/exclusive end, empty range == insertion, `begin == hard-line count` valid for end-insertion).
9
+ - `Component::TextView`: incremental wrap via a per-hard-line row-count cache — mid-buffer mutations now re-wrap only the affected slice instead of the whole buffer. Speeds up the LLM streaming path (mid-document `region.append`, tombstone-style `region.text=`, `view.replace`/`view.insert`). `view.append` on the spatial tail keeps its existing fast path; `view.text=` and `on_width_changed` still do a full rewrap (now rebuilding the cache too).
10
+ - Add `EventQueue#tick(fps) { |n| ... }` returning a `Ticker` backed by `Concurrent::TimerTask`; fires on the event-loop thread with a 0-based monotonic counter. Intended for spinner animations, periodic refresh, or surfacing background-task progress. Auto-cancels on raise.
11
+ - Add `FakeEventQueue#tick` and `FakeTicker` — synchronous test double that drives ticks deterministically.
12
+ - **Breaking:** `StyledString::Style#fg` and `#bg` now return `Color` (or `nil`) instead of the raw `Symbol`/`Integer`/`Array`. `Style.new` and `#merge` continue to accept the raw forms via `Color.coerce`.
13
+ - **Breaking:** Remove `StyledString::Style::COLOR_SYMBOLS` — moved to `Color::COLOR_SYMBOLS`.
14
+ - **Breaking:** `EventQueue#run_loop` now yields submitted `Proc` events to its consumer block instead of dispatching them inline, so a raise from a `submit{}` block is routed through `Screen#on_error` like any other event. Custom `run_loop` consumers must `call` Procs in their case statement.
15
+
16
+ ## [0.4.0] - 2026-05-20
17
+
18
+ - Add `Screen#register_global_shortcut` for app-level hotkeys; registered shortcuts surface in the status bar via `hint:`.
19
+ - Add `Keys::CTRL_A..CTRL_Z` constants and `Keys.printable?` (extracted from `TextField`/`TextArea`/`Screen`).
20
+ - Extract `Component::TextInput` as the shared base of `TextField` and `TextArea`; add `#empty?`.
21
+ - `TextField`/`TextArea`: default `on_escape` to clear focus.
22
+ - `Screen#run_event_loop` accepts `capture_mouse:` (default `true`); pass `false` to skip xterm mouse tracking so the terminal's native select-to-copy keeps working.
23
+ - `StyledString`: add `#with_fg`, mirroring `#with_bg`.
24
+ - `Component::TextView`: add `#<<`, `#add_line`, `#empty?`, and `#remove_last_n_lines` for streaming-tail retraction.
25
+ - `MouseEvent`: map buttons 66/67 to `:scroll_left`/`:scroll_right`.
26
+ - `Component::LogWindow`: extract `#log` helper.
27
+ - `Component::List`: skip `auto_scroll` when rect is empty; re-snap on width change; snap cursor to last line on `auto_scroll`.
28
+ - Document `Component#repaint`'s attached-only call contract.
29
+ - Document keyboard input dispatch order and testing (`FakeScreen`, PTY system tests) in the README.
30
+ - **Breaking:** `Component::TextView#append` is now verbatim — chunks are concatenated onto the current last hard line, embedded `\n` becomes hard breaks, no implicit newline is inserted. Designed for streaming use (e.g. an LLM chat window feeding partial messages straight in). Aliased as `<<` for chainability. The old "add a new entry" behavior is now `Component::TextView#add_line`.
31
+ - **Breaking:** `MouseEvent.parse` raises on malformed input instead of silently truncating.
32
+ - Fix: `Component` gates `invalidate` and `repaint` on `attached?`, dropping the negative-rect relic.
33
+ - Fix: `Popup` recomputes size from content on every `#open`.
34
+ - Fix: `Keys.getkey` reads 5 trailing bytes after ESC, not 6.
35
+ - Fix: `Component::List#add_line` rejects `nil`.
36
+
3
37
  ## [0.3.0] - 2026-05-18
4
38
 
5
39
  - Add `Component::TextView` — read-only scrollable wrapped prose with word wrap, incremental append, and a lazy text reader.
data/README.md CHANGED
@@ -146,7 +146,7 @@ posts a size event, runs layout, and invalidates the entire tree. Components
146
146
  react by reassigning their child rectangles inside `rect=` — do not install
147
147
  your own WINCH handler.
148
148
 
149
- ### Focus and shortcuts
149
+ ### Focus and keyboard input
150
150
 
151
151
  `screen.focused = component` walks parent pointers up to the root, marks the
152
152
  whole chain `active?`, and deactivates everything else. Click-to-focus and
@@ -154,10 +154,86 @@ whole chain `active?`, and deactivates everything else. Click-to-focus and
154
154
  returns true, so clicking a `Label` inside a `Window` does not pull focus
155
155
  away from the window's content.
156
156
 
157
- `key_shortcut` is matched against the focused component's whole subtree
158
- *unless* the focused component owns the hardware cursor (e.g. a `TextField`
159
- the user is typing into) — that suppression is what lets text fields swallow
160
- printable keys without sibling shortcuts hijacking them.
157
+ When a key arrives, the screen dispatches it in this order — the first
158
+ mechanism that handles it wins:
159
+
160
+ 1. **Tab / Shift+Tab** advance focus through `tab_stop?` components in the
161
+ current modal scope (the topmost popup if one is open, otherwise the
162
+ tiled content). They are intercepted at the screen level before anything
163
+ else sees them, so a focused `TextField` cannot swallow them.
164
+
165
+ 2. **Global shortcuts** registered via `Screen#register_global_shortcut`.
166
+ These are app-level hotkeys for actions that don't belong to any
167
+ specific component — opening a log window, toggling help, etc.:
168
+
169
+ ```ruby
170
+ screen.register_global_shortcut(Tuile::Keys::CTRL_L,
171
+ over_popups: true,
172
+ hint: "^L #{Rainbow('log').cadetblue}") do
173
+ log_popup.open
174
+ end
175
+ screen.unregister_global_shortcut(Tuile::Keys::CTRL_L)
176
+ ```
177
+
178
+ Only unprintable keys are accepted (control characters, ESC, BACKSPACE,
179
+ arrows, F-keys); printable keys raise so they can't hijack typing into
180
+ a `TextField`. By default, the shortcut is suppressed while any popup
181
+ is open and the popup receives the key; pass `over_popups: true` to
182
+ pre-empt the popup.
183
+
184
+ Pass `hint:` to surface the shortcut in the status bar. It's a
185
+ preformatted string the caller fully owns (color it however the rest
186
+ of your app does). In the tiled case it appears right after `q quit`
187
+ and before the active window's hint; while a popup is open, only
188
+ `over_popups: true` hints show up, prepended before the popup's
189
+ `q Close`. Omit `hint:` to leave the shortcut silent in the status bar.
190
+
191
+ 3. **`Component#key_shortcut`** — a declarative hotkey attached to a
192
+ component. The framework walks the focused component's subtree for a
193
+ match and focuses the winner. Good fit for "press F to focus the filter
194
+ field" or one-key tab pickers. The lookup is suppressed while the
195
+ focused component owns the hardware cursor (e.g. a `TextField` the user
196
+ is typing into) so editing isn't interrupted:
197
+
198
+ ```ruby
199
+ filter_field.key_shortcut = "f"
200
+ ```
201
+
202
+ 4. **`Component#handle_key`** — override this on your own component when
203
+ it needs to react to keys directly (a list reacting to arrows, a custom
204
+ widget handling Enter, …). Return `true` to mark the key handled,
205
+ `false` to let the dispatcher keep walking. Call `super` to keep the
206
+ default `key_shortcut` subtree lookup; suppress it only when you
207
+ deliberately want this component to swallow everything:
208
+
209
+ ```ruby
210
+ class Toggle < Tuile::Component
211
+ def handle_key(key)
212
+ if key == " "
213
+ @on = !@on
214
+ invalidate
215
+ true
216
+ else
217
+ super
218
+ end
219
+ end
220
+ end
221
+ ```
222
+
223
+ If nothing handles the key and it's `q` or `ESC`, the event loop exits.
224
+
225
+ A component can advertise the keys it responds to by overriding
226
+ `keyboard_hint`. The status bar shows the active window's hint alongside
227
+ the global `q quit` prompt; while a popup is open, the popup's own hint
228
+ replaces it, prefixed with `q Close`:
229
+
230
+ ```ruby
231
+ class FilterWindow < Tuile::Component::Window
232
+ def keyboard_hint
233
+ "f #{Rainbow('filter').cadetblue} Enter #{Rainbow('open').cadetblue}"
234
+ end
235
+ end
236
+ ```
161
237
 
162
238
  ## Components
163
239
 
@@ -352,6 +428,62 @@ Tuile.logger = TTY::Logger.new # duck-typed, works directly
352
428
  Tuile.logger = Logger.new(Tuile::Component::LogWindow::IO.new(window))
353
429
  ```
354
430
 
431
+ ## Testing
432
+
433
+ Tuile ships with a `Tuile::FakeScreen` that you install in place of the real
434
+ screen for unit tests. It fixes the viewport at 160×50, disables the UI lock,
435
+ collects every string the framework "would have printed" into an array, and
436
+ uses a synchronous `FakeEventQueue` (submitted blocks run inline; posted
437
+ events are discarded). No terminal IO happens, so the TTY running the tests
438
+ is never painted over.
439
+
440
+ The standard setup is `Screen.fake` / `Screen.close` as a before/after pair —
441
+ this resets the singleton between examples, so state can't leak across
442
+ tests:
443
+
444
+ ```ruby
445
+ require "tuile"
446
+
447
+ module Tuile
448
+ describe Component::Label do
449
+ before { Screen.fake }
450
+ after { Screen.close }
451
+
452
+ it "renders text into its rect" do
453
+ label = Component::Label.new
454
+ label.rect = Rect.new(0, 0, 5, 1)
455
+ label.text = "hi"
456
+ label.repaint
457
+ assert_equal [TTY::Cursor.move_to(0, 0), "hi "], Screen.instance.prints
458
+ end
459
+ end
460
+ end
461
+ ```
462
+
463
+ Key hooks:
464
+
465
+ - `Screen.instance.prints` — array of strings the screen would have written
466
+ to the terminal. Assert against it (or `.join`) for repaint output.
467
+ - `Screen.instance.repaint` — drive a repaint synchronously; production code
468
+ must not call this, but specs use it to flush the invalidated set after a
469
+ mutation.
470
+ - `Screen.instance.invalidated?(component)` / `invalidated_clear` — verify
471
+ that a mutation did (or did not) invalidate something. Setting a property
472
+ to its current value should typically *not* invalidate.
473
+ - `Screen.instance.clear` — drops accumulated `prints` without resetting
474
+ invalidation.
475
+
476
+ Because `FakeEventQueue#submit` runs the block immediately on the calling
477
+ thread, code paths that marshal work back via `screen.event_queue.submit { … }`
478
+ just work in tests. Posted events (`#post`) are dropped — if your test needs
479
+ to drive a real event loop, you are in system-test territory.
480
+
481
+ For end-to-end tests of a runnable script, spawn it in a pseudo-TTY with
482
+ `PTY.spawn`, wait for a known glyph to confirm the first paint landed, send
483
+ a key, and assert the exit status. `spec/examples/hello_world_spec.rb` is the
484
+ canonical template; PTY-based tests are Linux/macOS only since Ruby's stdlib
485
+ `PTY` isn't on Windows.
486
+
355
487
  ## Development
356
488
 
357
489
  After checking out the repo, run `bin/setup` to install dependencies. Then,
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ # An immutable terminal color. Accepts the three forms ANSI/SGR understands:
5
+ #
6
+ # - a Symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright named colors
7
+ # (SGR 30..37 / 90..97 for fg, 40..47 / 100..107 for bg)
8
+ # - an Integer 0..255 — the 256-color palette (SGR 38;5;N / 48;5;N)
9
+ # - an Array of three Integers 0..255 — 24-bit RGB (SGR 38;2;R;G;B / 48;2;R;G;B)
10
+ #
11
+ # A constant per named color is pre-defined (`Color::RED`, `Color::BRIGHT_BLUE`,
12
+ # …) so callers can reach for `Color::RED` instead of building one each time.
13
+ # {.coerce} accepts anything {.new} accepts plus `nil` (terminal default) and
14
+ # an existing {Color} (returned as-is), so APIs that accept colors typically
15
+ # take `[Color, nil]` and pass through {.coerce}.
16
+ #
17
+ # ```ruby
18
+ # Color.new(:red) # named
19
+ # Color.new(42) # 256-color palette
20
+ # Color.new([255, 100, 0]) # RGB
21
+ # Color::RED # constant
22
+ # Color.coerce(:red) # accepts raw forms, returns Color
23
+ # Color.coerce(nil) # nil → nil
24
+ # ```
25
+ #
26
+ # {#to_ansi} renders a full SGR escape (`"\e[31m"`); {#sgr_codes} returns the
27
+ # raw numeric codes so callers (notably {StyledString}) can combine them with
28
+ # other SGR attributes in a single sequence.
29
+ class Color
30
+ # Symbolic color names. Order is significant: indices 0..7 map to the
31
+ # standard ANSI colors (SGR 30..37 fg / 40..47 bg); indices 8..15 map to
32
+ # bright variants (SGR 90..97 / 100..107).
33
+ # @return [Array<Symbol>]
34
+ COLOR_SYMBOLS = %i[
35
+ black red green yellow blue magenta cyan white
36
+ bright_black bright_red bright_green bright_yellow
37
+ bright_blue bright_magenta bright_cyan bright_white
38
+ ].freeze
39
+
40
+ # Coerces the input to a {Color}. `nil` passes through unchanged (callers
41
+ # use `nil` for the terminal default); an existing {Color} is returned
42
+ # as-is; otherwise the value is fed to {.new}.
43
+ #
44
+ # @param value [Color, Symbol, Integer, Array<Integer>, nil]
45
+ # @return [Color, nil]
46
+ # @raise [ArgumentError] when `value` is not one of the accepted forms.
47
+ def self.coerce(value)
48
+ case value
49
+ when nil, Color then value
50
+ else new(value)
51
+ end
52
+ end
53
+
54
+ # @param value [Symbol, Integer, Array<Integer>] see class-level docs for
55
+ # the three accepted forms.
56
+ # @raise [ArgumentError] when `value` is not one of the accepted forms.
57
+ def initialize(value)
58
+ unless COLOR_SYMBOLS.include?(value) ||
59
+ (value.is_a?(Integer) && value.between?(0, 255)) ||
60
+ (value.is_a?(Array) && value.length == 3 &&
61
+ value.all? { |v| v.is_a?(Integer) && v.between?(0, 255) })
62
+ raise ArgumentError, "invalid color: #{value.inspect}"
63
+ end
64
+
65
+ @value = value.is_a?(Array) ? value.dup.freeze : value
66
+ freeze
67
+ end
68
+
69
+ # The underlying raw representation — a Symbol, Integer, or frozen
70
+ # Array<Integer>.
71
+ # @return [Symbol, Integer, Array<Integer>]
72
+ attr_reader :value
73
+
74
+ # SGR parameter codes for emitting this color as either a foreground
75
+ # (`target: :fg`) or background (`target: :bg`). Returned as an array so
76
+ # callers can splice them into a multi-attribute SGR (e.g. bold + color).
77
+ #
78
+ # @param target [Symbol] `:fg` or `:bg`.
79
+ # @return [Array<Integer>]
80
+ # @raise [ArgumentError] when `target` is neither `:fg` nor `:bg`.
81
+ def sgr_codes(target = :fg)
82
+ base, ext = case target
83
+ when :fg then [30, 38]
84
+ when :bg then [40, 48]
85
+ else raise ArgumentError, "target must be :fg or :bg, got #{target.inspect}"
86
+ end
87
+ case @value
88
+ when Symbol
89
+ idx = COLOR_SYMBOLS.index(@value)
90
+ idx < 8 ? [base + idx] : [base + 60 + (idx - 8)]
91
+ when Integer then [ext, 5, @value]
92
+ when Array then [ext, 2, *@value]
93
+ end
94
+ end
95
+
96
+ # Full SGR escape sequence for this color (e.g. `"\e[31m"`). Useful for
97
+ # `print`-style direct emission; for composing with other attributes use
98
+ # {#sgr_codes} instead.
99
+ #
100
+ # @param target [Symbol] `:fg` or `:bg`.
101
+ # @return [String]
102
+ def to_ansi(target = :fg)
103
+ "\e[#{sgr_codes(target).join(";")}m"
104
+ end
105
+
106
+ # @param other [Object]
107
+ # @return [Boolean]
108
+ def ==(other)
109
+ other.is_a?(Color) && @value == other.value
110
+ end
111
+ alias eql? ==
112
+
113
+ # @return [Integer]
114
+ def hash
115
+ [self.class, @value].hash
116
+ end
117
+
118
+ # @return [String]
119
+ def inspect
120
+ "#<#{self.class.name} #{@value.inspect}>"
121
+ end
122
+
123
+ COLOR_SYMBOLS.each do |sym|
124
+ const_set(sym.upcase, new(sym))
125
+ end
126
+ end
127
+ end
@@ -11,6 +11,7 @@ module Tuile
11
11
  def initialize
12
12
  super
13
13
  @text = StyledString::EMPTY
14
+ @bg = nil
14
15
  @clipped_lines = []
15
16
  @blank_line = ""
16
17
  end
@@ -19,6 +20,11 @@ module Tuile
19
20
  # {StyledString}.
20
21
  attr_reader :text
21
22
 
23
+ # @return [Color, nil] background color applied uniformly across every
24
+ # painted row (including padding past the text). `nil` (default)
25
+ # leaves whatever bg the text's own styling carries.
26
+ attr_reader :bg
27
+
22
28
  # Replaces the text. A `String` is parsed via {StyledString.parse}
23
29
  # (embedded ANSI is honored); a `StyledString` is used as-is; `nil` is
24
30
  # coerced to an empty {StyledString}. Lines wider than {#rect} are
@@ -35,6 +41,23 @@ module Tuile
35
41
  invalidate
36
42
  end
37
43
 
44
+ # Sets the background color. Coerced via {Color.coerce}, so a Symbol,
45
+ # Integer, Array, {Color}, or `nil` all work. `nil` clears the override
46
+ # — the label paints with whatever bg the text's own styling provides.
47
+ # Otherwise the bg overlays every span (including the trailing pad and
48
+ # blank rows past the last text line).
49
+ #
50
+ # @param value [Color, Symbol, Integer, Array<Integer>, nil]
51
+ # @return [void]
52
+ def bg=(value)
53
+ new_bg = Color.coerce(value)
54
+ return if @bg == new_bg
55
+
56
+ @bg = new_bg
57
+ update_clipped_lines
58
+ invalidate
59
+ end
60
+
38
61
  # @return [Size] longest hard-line's display width × number of hard
39
62
  # lines. Reported on the *unclipped* text — sizing is intrinsic to
40
63
  # the content, not the viewport. Empty text returns `Size.new(0, 0)`.
@@ -57,7 +80,7 @@ module Tuile
57
80
  # wipe.
58
81
  # @return [void]
59
82
  def repaint
60
- return if rect.empty? || rect.left.negative? || rect.top.negative?
83
+ return if rect.empty?
61
84
 
62
85
  (0...rect.height).each do |row|
63
86
  line = @clipped_lines[row] || @blank_line
@@ -79,12 +102,19 @@ module Tuile
79
102
  # Each line is ellipsized to fit, padded with trailing spaces out to
80
103
  # the full width, and pre-rendered to ANSI so {#repaint} is just a
81
104
  # lookup + screen.print per row. {@blank_line} covers rows past the
82
- # last text line.
105
+ # last text line. When {#bg} is set, every produced line (and the
106
+ # blank row) has the bg applied uniformly.
83
107
  # @return [void]
84
108
  def update_clipped_lines
85
109
  width = rect.width.clamp(0, nil)
86
- @blank_line = " " * width
87
- @clipped_lines = @text.lines.map { |line| pad_to(line.ellipsize(width), width).to_ansi }
110
+ @blank_line = apply_bg(StyledString.plain(" " * width)).to_ansi
111
+ @clipped_lines = @text.lines.map { |line| apply_bg(pad_to(line.ellipsize(width), width)).to_ansi }
112
+ end
113
+
114
+ # @param line [StyledString]
115
+ # @return [StyledString]
116
+ def apply_bg(line)
117
+ @bg ? line.with_bg(@bg) : line
88
118
  end
89
119
 
90
120
  # @param line [StyledString]
@@ -167,6 +167,7 @@ module Tuile
167
167
  # @param line [String, StyledString, #to_s]
168
168
  # @return [void]
169
169
  def add_line(line)
170
+ raise ArgumentError, "line is nil" if line.nil?
170
171
  add_lines [line]
171
172
  end
172
173
 
@@ -326,6 +327,16 @@ module Tuile
326
327
  def candidate_positions(_line_count)
327
328
  []
328
329
  end
330
+
331
+ # Overridden so all movement funnels — base {Cursor#go_to_last},
332
+ # {Cursor#go_to_first}, etc., which all call {#go} — become safe
333
+ # no-ops on a disabled cursor. The instance is frozen, so a default
334
+ # mutating {#go} would raise.
335
+ # @param _new_position [Integer]
336
+ # @return [Boolean] always false.
337
+ def go(_new_position)
338
+ false
339
+ end
329
340
  end
330
341
 
331
342
  # @return [Integer] 0-based line index of the current cursor position.
@@ -384,6 +395,15 @@ module Tuile
384
395
  true
385
396
  end
386
397
 
398
+ # Moves the cursor to the last reachable position. For base {Cursor},
399
+ # the last line; {Limited} clamps to the last allowed position; {None}
400
+ # is a no-op.
401
+ # @param line_count [Integer] number of lines in the list.
402
+ # @return [Boolean] true if the position changed.
403
+ def go_to_last(line_count)
404
+ go(line_count - 1)
405
+ end
406
+
387
407
  protected
388
408
 
389
409
  # @param lines [Integer]
@@ -404,12 +424,6 @@ module Tuile
404
424
  go(0)
405
425
  end
406
426
 
407
- # @param line_count [Integer]
408
- # @return [Boolean]
409
- def go_to_last(line_count)
410
- go(line_count - 1)
411
- end
412
-
413
427
  # Cursor which can only land on specific allowed lines.
414
428
  class Limited < Cursor
415
429
  # @param positions [Array<Integer>] allowed positions. Must not be
@@ -442,6 +456,12 @@ module Tuile
442
456
  @positions.select { it < line_count }
443
457
  end
444
458
 
459
+ # @param _line_count [Integer]
460
+ # @return [Boolean]
461
+ def go_to_last(_line_count)
462
+ go(@positions.last)
463
+ end
464
+
445
465
  protected
446
466
 
447
467
  # @param lines [Integer]
@@ -467,12 +487,6 @@ module Tuile
467
487
  def go_to_first
468
488
  go(@positions.first)
469
489
  end
470
-
471
- # @param _line_count [Integer]
472
- # @return [Boolean]
473
- def go_to_last(_line_count)
474
- go(@positions.last)
475
- end
476
490
  end
477
491
  end
478
492
 
@@ -480,11 +494,16 @@ module Tuile
480
494
 
481
495
  # Rebuilds pre-padded lines when the wrap width changes. The wrap width
482
496
  # depends on {#rect}`.width` and the scrollbar gutter, both of which
483
- # trigger this hook.
497
+ # trigger this hook. Also re-evaluates {#auto_scroll}: if items were
498
+ # appended while the rect was empty (e.g. a {Popup}-wrapped list got
499
+ # `add_line` calls before the popup was opened), the auto-scroll update
500
+ # was skipped because there was no viewport — re-run it now that there
501
+ # is one, so the list snaps to the bottom on first paint.
484
502
  # @return [void]
485
503
  def on_width_changed
486
504
  super
487
505
  rebuild_padded_lines
506
+ update_top_line_if_auto_scroll
488
507
  end
489
508
 
490
509
  private
@@ -638,10 +657,20 @@ module Tuile
638
657
  invalidate
639
658
  end
640
659
 
641
- # If auto-scrolling, recalculate the top line.
660
+ # If auto-scrolling, recalculate the top line and snap the cursor to the
661
+ # last reachable position. Without the cursor snap the viewport gets
662
+ # yanked back to wherever the cursor sat on the next arrow press,
663
+ # negating the auto-scroll. Skipped when {#rect} is empty: without a
664
+ # viewport the "lines minus viewport" formula yields `@lines.size`,
665
+ # which would leave `top_line` past the last item once a real rect
666
+ # arrives. {#on_width_changed} re-runs this hook when the rect grows so
667
+ # the snap-to-bottom intent is preserved.
642
668
  # @return [void]
643
669
  def update_top_line_if_auto_scroll
644
670
  return unless @auto_scroll
671
+ return if rect.empty?
672
+
673
+ notify_cursor_changed if @cursor.go_to_last(@lines.size)
645
674
 
646
675
  new_top_line = (@lines.size - viewport_lines).clamp(0, nil)
647
676
  return unless @top_line != new_top_line
@@ -23,6 +23,16 @@ module Tuile
23
23
  self.scrollbar = true
24
24
  end
25
25
 
26
+ # Appends given line to the log. Can be called from any thread. Does nothing if nil is passed in.
27
+ # @param string [String, nil] the line (or multiple lines) to log.
28
+ # @return [void]
29
+ def log(string)
30
+ return if string.nil?
31
+ screen.event_queue.submit do
32
+ content.add_line(string)
33
+ end
34
+ end
35
+
26
36
  # IO-shaped adapter that forwards each log line to the owning {LogWindow}.
27
37
  # Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
28
38
  # call `output.puts`, e.g. `TTY::Logger`).
@@ -35,17 +45,13 @@ module Tuile
35
45
  # @param string [String]
36
46
  # @return [void]
37
47
  def write(string)
38
- @window.screen.event_queue.submit do
39
- @window.content.add_line(string.chomp)
40
- end
48
+ @window.log(string.chomp)
41
49
  end
42
50
 
43
51
  # @param string [String]
44
52
  # @return [void]
45
53
  def puts(string)
46
- @window.screen.event_queue.submit do
47
- @window.content.add_line(string)
48
- end
54
+ @window.log(string)
49
55
  end
50
56
 
51
57
  # Stdlib `Logger` only treats an object as an IO target when it
@@ -30,17 +30,17 @@ module Tuile
30
30
  def initialize(content: nil)
31
31
  super()
32
32
  @content = nil
33
- # Off-screen sentinel until the content sets a real size and the popup
34
- # is centered on open.
35
- @rect = Rect.new(-1, -1, 0, 0)
36
33
  self.content = content unless content.nil?
37
34
  end
38
35
 
39
36
  def focusable? = true
40
37
 
41
- # Mounts this popup on the {Screen}.
38
+ # Mounts this popup on the {Screen}. Recomputes the popup's size from
39
+ # the current content first, so reopening a popup whose content has
40
+ # grown or shrunk while closed picks up the new size.
42
41
  # @return [void]
43
42
  def open
43
+ update_rect unless @content.nil?
44
44
  screen.add_popup(self)
45
45
  end
46
46
 
@@ -119,7 +119,7 @@ module Tuile
119
119
  def update_rect
120
120
  size = @content.content_size.clamp_height(max_height)
121
121
  size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
122
- self.rect = Rect.new(-1, -1, size.width, size.height)
122
+ self.rect = Rect.new(0, 0, size.width, size.height)
123
123
  center if open?
124
124
  end
125
125
  end