tuile 0.5.0 → 0.7.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.
@@ -14,20 +14,16 @@ module Tuile
14
14
  #
15
15
  # Cursor is supported; call {#cursor=} to change cursor behavior. The
16
16
  # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
17
- # list automatically. The cursor highlight overlays a dark background
18
- # while preserving each span's foreground color.
17
+ # list automatically. The cursor highlight overlays
18
+ # {Theme#active_bg_color} while preserving each span's foreground color.
19
19
  class List < Component
20
- # 256-color SGR index for the cursor-row background highlight. Matches
21
- # what `Rainbow(...).bg(:darkslategray)` emits.
22
- # @return [Integer]
23
- CURSOR_BG = 59
24
-
25
20
  def initialize
26
21
  super
27
22
  @lines = []
28
23
  @padded_lines = []
29
24
  @blank_padded = StyledString::EMPTY
30
25
  @auto_scroll = false
26
+ @follow = true
31
27
  @top_line = 0
32
28
  @cursor = Cursor::None.new
33
29
  @scrollbar_visibility = :gone
@@ -55,9 +51,18 @@ module Tuile
55
51
  attr_accessor :on_cursor_changed
56
52
 
57
53
  # @return [Boolean] if true and a line is added or new content is set,
58
- # auto-scrolls to the bottom.
54
+ # auto-scrolls to the bottom — but only while the viewport is already
55
+ # pinned to the last line (see {#following?}). Scroll up to read older
56
+ # content and appends stop yanking you back down; scroll back to the
57
+ # bottom and tailing resumes.
59
58
  attr_reader :auto_scroll
60
59
 
60
+ # @return [Boolean] whether {#auto_scroll} is currently tailing. True
61
+ # while the viewport sits at the last line; flips to false the moment
62
+ # the user scrolls up, and back to true once they scroll to the bottom
63
+ # again. Only consulted when {#auto_scroll} is enabled.
64
+ def following? = @follow
65
+
61
66
  # @return [Integer] top line of the viewport. 0 or positive.
62
67
  attr_reader :top_line
63
68
 
@@ -92,10 +97,12 @@ module Tuile
92
97
  invalidate
93
98
  end
94
99
 
95
- # Sets the new auto_scroll. If true, immediately scrolls to the bottom.
100
+ # Sets the new auto_scroll. If true, re-engages tailing and immediately
101
+ # scrolls to the bottom.
96
102
  # @param new_auto_scroll [Boolean]
97
103
  def auto_scroll=(new_auto_scroll)
98
104
  @auto_scroll = new_auto_scroll
105
+ @follow = true if new_auto_scroll
99
106
  update_top_line_if_auto_scroll
100
107
  end
101
108
 
@@ -118,6 +125,7 @@ module Tuile
118
125
  return unless @top_line != new_top_line
119
126
 
120
127
  @top_line = new_top_line
128
+ @follow = at_bottom?
121
129
  invalidate
122
130
  end
123
131
 
@@ -135,11 +143,11 @@ module Tuile
135
143
  raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array
136
144
 
137
145
  @lines = parse_input_lines(lines)
138
- @content_size = nil
139
146
  rebuild_padded_lines
140
147
  update_top_line_if_auto_scroll
141
148
  notify_cursor_changed
142
149
  invalidate
150
+ self.content_size = compute_content_size
143
151
  end
144
152
 
145
153
  # Without a block, returns the current lines. With a block, fully
@@ -168,6 +176,7 @@ module Tuile
168
176
  # @return [void]
169
177
  def add_line(line)
170
178
  raise ArgumentError, "line is nil" if line.nil?
179
+
171
180
  add_lines [line]
172
181
  end
173
182
 
@@ -181,20 +190,11 @@ module Tuile
181
190
  screen.check_locked
182
191
  new_lines = parse_input_lines(lines)
183
192
  @lines += new_lines
184
- @content_size = nil
185
193
  @padded_lines += new_lines.map { |line| pad_to_row(line) }
186
194
  update_top_line_if_auto_scroll
187
195
  notify_cursor_changed
188
196
  invalidate
189
- end
190
-
191
- # @return [Size]
192
- def content_size
193
- @content_size ||= begin
194
- content_w = @lines.map(&:display_width).max || 0
195
- width = @lines.empty? ? 0 : content_w + 2
196
- Size.new(width, @lines.size)
197
- end
197
+ grow_content_size(new_lines)
198
198
  end
199
199
 
200
200
  def focusable? = true
@@ -431,7 +431,7 @@ module Tuile
431
431
  # @param position [Integer] initial position.
432
432
  def initialize(positions, position: positions[0])
433
433
  @positions = positions.sort
434
- position = @positions[@positions.rindex { it < position } || 0] unless @positions.include?(position)
434
+ position = @positions[@positions.rindex { _1 < position } || 0] unless @positions.include?(position)
435
435
  super(position: position)
436
436
  end
437
437
 
@@ -441,7 +441,7 @@ module Tuile
441
441
  # @return [Boolean]
442
442
  def handle_mouse(line, event, _line_count)
443
443
  if event.button == :left
444
- prev_pos = @positions.reverse_each.find { it <= line }
444
+ prev_pos = @positions.reverse_each.find { _1 <= line }
445
445
  return go_to_first if prev_pos.nil?
446
446
 
447
447
  go(prev_pos)
@@ -453,7 +453,7 @@ module Tuile
453
453
  # @param line_count [Integer]
454
454
  # @return [Array<Integer>]
455
455
  def candidate_positions(line_count)
456
- @positions.select { it < line_count }
456
+ @positions.select { _1 < line_count }
457
457
  end
458
458
 
459
459
  # @param _line_count [Integer]
@@ -468,7 +468,7 @@ module Tuile
468
468
  # @param line_count [Integer]
469
469
  # @return [Boolean]
470
470
  def go_down_by(lines, line_count)
471
- next_pos = @positions.find { it >= @position + lines }
471
+ next_pos = @positions.find { _1 >= @position + lines }
472
472
  return go_to_last(line_count) if next_pos.nil?
473
473
 
474
474
  go(next_pos)
@@ -477,7 +477,7 @@ module Tuile
477
477
  # @param lines [Integer]
478
478
  # @return [Boolean]
479
479
  def go_up_by(lines)
480
- prev_pos = @positions.reverse_each.find { it <= @position - lines }
480
+ prev_pos = @positions.reverse_each.find { _1 <= @position - lines }
481
481
  return go_to_first if prev_pos.nil?
482
482
 
483
483
  go(prev_pos)
@@ -508,6 +508,30 @@ module Tuile
508
508
 
509
509
  private
510
510
 
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
+
511
535
  # Coerces and flattens a list of input entries into trimmed
512
536
  # {StyledString} lines. Each entry becomes a {StyledString} (String
513
537
  # via {StyledString.parse}, StyledString passed through, anything else
@@ -612,16 +636,16 @@ module Tuile
612
636
  def order_for_search(candidates, current, include_current:, reverse:)
613
637
  if reverse
614
638
  before, after = if include_current
615
- [candidates.select { it <= current }, candidates.select { it > current }]
639
+ [candidates.select { _1 <= current }, candidates.select { _1 > current }]
616
640
  else
617
- [candidates.select { it < current }, candidates.select { it >= current }]
641
+ [candidates.select { _1 < current }, candidates.select { _1 >= current }]
618
642
  end
619
643
  before.reverse + after.reverse
620
644
  else
621
645
  after, before = if include_current
622
- [candidates.select { it >= current }, candidates.select { it < current }]
646
+ [candidates.select { _1 >= current }, candidates.select { _1 < current }]
623
647
  else
624
- [candidates.select { it > current }, candidates.select { it <= current }]
648
+ [candidates.select { _1 > current }, candidates.select { _1 <= current }]
625
649
  end
626
650
  after + before
627
651
  end
@@ -643,6 +667,10 @@ module Tuile
643
667
  # @return [Integer] the max value of {#top_line}.
644
668
  def top_line_max = (@lines.size - rect.height).clamp(0, nil)
645
669
 
670
+ # @return [Boolean] whether the viewport is pinned to the last line.
671
+ # Drives {#following?}: re-evaluated on every {#top_line=}.
672
+ def at_bottom? = @top_line == top_line_max
673
+
646
674
  # @return [Integer] the number of visible lines.
647
675
  def viewport_lines = rect.height
648
676
 
@@ -665,9 +693,14 @@ module Tuile
665
693
  # which would leave `top_line` past the last item once a real rect
666
694
  # arrives. {#on_width_changed} re-runs this hook when the rect grows so
667
695
  # the snap-to-bottom intent is preserved.
696
+ #
697
+ # Gated on {#following?}: once the user scrolls up off the bottom the
698
+ # cursor snap and viewport pin are both skipped, so reading older
699
+ # content is not interrupted by incoming lines. {#top_line=} re-arms
700
+ # `@follow` when the viewport returns to the bottom.
668
701
  # @return [void]
669
702
  def update_top_line_if_auto_scroll
670
- return unless @auto_scroll
703
+ return unless @auto_scroll && @follow
671
704
  return if rect.empty?
672
705
 
673
706
  notify_cursor_changed if @cursor.go_to_last(@lines.size)
@@ -731,7 +764,7 @@ module Tuile
731
764
  def paintable_line(index, row_in_viewport, scrollbar)
732
765
  base = index < @lines.size ? @padded_lines[index] : @blank_padded
733
766
  is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
734
- styled = is_cursor ? base.with_bg(CURSOR_BG) : base
767
+ styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
735
768
  out = styled.to_ansi
736
769
  out += scrollbar.scrollbar_char(row_in_viewport) if scrollbar
737
770
  out
@@ -28,6 +28,7 @@ module Tuile
28
28
  # @return [void]
29
29
  def log(string)
30
30
  return if string.nil?
31
+
31
32
  screen.event_queue.submit do
32
33
  content.add_line(string)
33
34
  end
@@ -34,10 +34,10 @@ module Tuile
34
34
  raise ArgumentError, "options must not be empty" if options.empty?
35
35
 
36
36
  super(caption)
37
- @options = options.map { Option.new(it[0], it[1]) }
37
+ @options = options.map { Option.new(_1[0], _1[1]) }
38
38
  @block = block
39
39
  list = Component::List.new
40
- list.lines = @options.map { "#{it.key} #{Rainbow(it.caption).cadetblue}" }
40
+ list.lines = @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }
41
41
  list.cursor = Component::List::Cursor.new
42
42
  list.on_item_chosen = ->(index, _line) { select_option(@options[index].key) }
43
43
  self.content = list
@@ -55,7 +55,7 @@ module Tuile
55
55
  def handle_key(key)
56
56
  return true if super
57
57
 
58
- if @options.any? { it.key == key }
58
+ if @options.any? { _1.key == key }
59
59
  select_option(key)
60
60
  true
61
61
  else
@@ -65,7 +65,7 @@ module Tuile
65
65
 
66
66
  # @return [String]
67
67
  def keyboard_hint
68
- @options.map { "#{it.key} #{Rainbow(it.caption).cadetblue}" }.join(" ")
68
+ @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }.join(" ")
69
69
  end
70
70
 
71
71
  # Opens a picker as a popup. Picking an option fires `block`, then
@@ -35,6 +35,21 @@ module Tuile
35
35
 
36
36
  def focusable? = true
37
37
 
38
+ # Reassigns the popup's rect, escalating to a full scene repaint when an
39
+ # open popup shrinks or moves so its new rect no longer covers the cells
40
+ # it previously painted. A popup overdraws the scene without clipping and
41
+ # nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
42
+ # would repaint into the new rect and leave the vacated cells showing
43
+ # stale content. When the new rect fully covers the old one (the popup
44
+ # only grew), the fast path is correct and the full repaint is skipped.
45
+ # @param new_rect [Rect]
46
+ # @return [void]
47
+ def rect=(new_rect)
48
+ old_rect = rect
49
+ super
50
+ screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
51
+ end
52
+
38
53
  # Mounts this popup on the {Screen}. Recomputes the popup's size from
39
54
  # the current content first, so reopening a popup whose content has
40
55
  # grown or shrunk while closed picks up the new size.
@@ -81,10 +96,20 @@ module Tuile
81
96
  update_rect unless new_content.nil?
82
97
  end
83
98
 
99
+ # Re-sizes (and recenters, when open) whenever the wrapped content's
100
+ # natural size changes — e.g. a {Label}'s `text=`, a {List}'s
101
+ # `add_line`, or a nested {Window} whose own content grew (the window
102
+ # recomputes its {Component#content_size} and the change bubbles here).
103
+ # @param _child [Component]
104
+ # @return [void]
105
+ def on_child_content_size_changed(_child)
106
+ update_rect
107
+ end
108
+
84
109
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
85
110
  # @return [String]
86
111
  def keyboard_hint
87
- prefix = "q #{Rainbow("Close").cadetblue}"
112
+ prefix = "q #{screen.theme.hint("Close")}"
88
113
  child_hint = @content&.keyboard_hint.to_s
89
114
  child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
90
115
  end
@@ -115,12 +140,18 @@ module Tuile
115
140
 
116
141
  # Recompute width/height from {#content}'s natural size and recenter
117
142
  # if currently open. Called whenever content is (re)assigned.
143
+ #
144
+ # Computes the final (centered) rect and assigns it in one step rather
145
+ # than positioning at the origin and then centering: the intermediate
146
+ # origin rect rarely covers the previous one, which would make
147
+ # {#rect=}'s shrink/move detection fire a full repaint on every resize.
118
148
  # @return [void]
119
149
  def update_rect
120
150
  size = @content.content_size.clamp_height(max_height)
121
151
  size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
122
- self.rect = Rect.new(0, 0, size.width, size.height)
123
- center if open?
152
+ r = Rect.new(0, 0, size.width, size.height)
153
+ r = r.centered(screen.size) if open?
154
+ self.rect = r
124
155
  end
125
156
  end
126
157
  end
@@ -70,7 +70,6 @@ module Tuile
70
70
  def repaint
71
71
  return if rect.empty?
72
72
 
73
- bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
74
73
  rows = display_rows
75
74
  (0...rect.height).each do |screen_row|
76
75
  row_idx = screen_row + @top_display_row
@@ -81,7 +80,7 @@ module Tuile
81
80
  chunk = @text[r[:start], r[:length]] || ""
82
81
  chunk + (" " * (rect.width - r[:length]))
83
82
  end
84
- screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), bg, line, Ansi::RESET
83
+ screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), background(line)
85
84
  end
86
85
  end
87
86
 
@@ -59,9 +59,8 @@ module Tuile
59
59
  def repaint
60
60
  return if rect.empty?
61
61
 
62
- bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
63
62
  padded = @text + (" " * (rect.width - @text.length))
64
- screen.print TTY::Cursor.move_to(rect.left, rect.top), bg, padded, Ansi::RESET
63
+ screen.print TTY::Cursor.move_to(rect.left, rect.top), background(padded)
65
64
  end
66
65
 
67
66
  protected
@@ -88,21 +88,6 @@ module Tuile
88
88
  invalidate
89
89
  end
90
90
 
91
- # 256-color SGR for the focused-button highlight (matches what
92
- # `Rainbow(...).bg(:darkslategray)` emits, which is what
93
- # {Component::Button#repaint} uses for its focused state).
94
- # @return [String]
95
- ACTIVE_BG_SGR = "\e[48;5;59m"
96
- # 256-color SGR for the unfocused field's "well": index 238 sits in
97
- # the grayscale ramp (~#444444), bright enough to stand out against
98
- # non-pure-black terminal themes (Gruvbox/Solarized/OneDark base
99
- # backgrounds sit in the #1d–#2d range), and still distinctly darker
100
- # than the active highlight at index 59 (~#5f5f5f). Rainbow's
101
- # RGB-to-256 mapping snaps everything dark to palette index 16
102
- # (terminal black), so we emit the escape directly to reach the ramp.
103
- # @return [String]
104
- INACTIVE_BG_SGR = "\e[48;5;238m"
105
-
106
91
  # Handles a key. Returns false when the component is inactive. Otherwise
107
92
  # first runs the {Component#handle_key} shortcut search via `super`, then
108
93
  # delegates to {#handle_text_input_key}.
@@ -117,6 +102,16 @@ module Tuile
117
102
 
118
103
  protected
119
104
 
105
+ # Renders `text` on the field's background well, looked up from the
106
+ # current {Screen#theme} at paint time: {Theme#active_bg} when this
107
+ # input is on the active (focus) chain, {Theme#input_bg} otherwise —
108
+ # visibly a field either way, distinctly highlighted when active.
109
+ # @param text [String]
110
+ # @return [String] ANSI-rendered text.
111
+ def background(text)
112
+ active? ? screen.theme.active_bg(text) : screen.theme.input_bg(text)
113
+ end
114
+
120
115
  # Input filter for {#text=}. Subclasses override to truncate or reject
121
116
  # invalid input. Default coerces to String.
122
117
  # @param new_text [String]
@@ -50,10 +50,10 @@ module Tuile
50
50
  @physical_lines = []
51
51
  @hard_line_wrap_counts = []
52
52
  @text = StyledString::EMPTY
53
- @content_size = Size::ZERO
54
53
  @blank_line = StyledString::EMPTY
55
54
  @top_line = 0
56
55
  @auto_scroll = false
56
+ @follow = true
57
57
  @scrollbar_visibility = :gone
58
58
  # The view always has at least one region — an implicit default. It
59
59
  # owns whatever hard lines exist that no later region claims. App
@@ -81,9 +81,18 @@ module Tuile
81
81
  attr_reader :scrollbar_visibility
82
82
 
83
83
  # @return [Boolean] if true, mutating the text scrolls the viewport so
84
- # the last line stays in view. Default `false`.
84
+ # the last line stays in view but only while the viewport is already
85
+ # pinned to the last line (see {#following?}). Scroll up to read older
86
+ # content and appends stop yanking you back down; scroll back to the
87
+ # bottom and tailing resumes. Default `false`.
85
88
  attr_reader :auto_scroll
86
89
 
90
+ # @return [Boolean] whether {#auto_scroll} is currently tailing. True
91
+ # while the viewport sits at the last line; flips to false the moment
92
+ # the user scrolls up, and back to true once they scroll to the bottom
93
+ # again. Only consulted when {#auto_scroll} is enabled.
94
+ def following? = @follow
95
+
87
96
  # Replaces the text. Embedded `\n` characters become hard line breaks.
88
97
  # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
89
98
  # honored); a `StyledString` is used as-is; `nil` is coerced to an
@@ -110,10 +119,10 @@ module Tuile
110
119
  @regions = [Region.send(:new, self, @hard_lines.size)]
111
120
  return if content_unchanged
112
121
 
113
- @content_size = compute_content_size
114
122
  rewrap
115
123
  update_top_line_if_auto_scroll
116
124
  invalidate
125
+ self.content_size = compute_content_size
117
126
  end
118
127
 
119
128
  # Creates a new empty {Region} at the spatial tail of the document
@@ -180,9 +189,9 @@ module Tuile
180
189
 
181
190
  tail_region.send(:line_count=, tail_region.line_count + added)
182
191
  @text = nil
183
- @content_size = compute_content_size
184
192
  update_top_line_if_auto_scroll
185
193
  invalidate
194
+ self.content_size = compute_content_size
186
195
  end
187
196
 
188
197
  # Verbatim append, returning `self` for chainability (`view << a << b`).
@@ -254,10 +263,10 @@ module Tuile
254
263
  end
255
264
 
256
265
  @text = nil
257
- @content_size = compute_content_size
258
266
  @top_line = top_line_max if @top_line > top_line_max
259
267
  update_top_line_if_auto_scroll
260
268
  invalidate
269
+ self.content_size = compute_content_size
261
270
  end
262
271
 
263
272
  # Replaces a contiguous range of hard lines with the parsed content
@@ -315,10 +324,10 @@ module Tuile
315
324
  splice_hard_lines(from, length, new_hard_lines)
316
325
  update_region_counts(from, length, new_hard_lines.size)
317
326
  @text = nil
318
- @content_size = compute_content_size
319
327
  @top_line = top_line_max if @top_line > top_line_max
320
328
  update_top_line_if_auto_scroll
321
329
  invalidate
330
+ self.content_size = compute_content_size
322
331
  end
323
332
 
324
333
  # Inserts `str` at hard-line index `at`. Equivalent to
@@ -348,6 +357,7 @@ module Tuile
348
357
  return if @top_line == new_top_line
349
358
 
350
359
  @top_line = new_top_line
360
+ @follow = at_bottom?
351
361
  invalidate
352
362
  end
353
363
 
@@ -362,11 +372,13 @@ module Tuile
362
372
  invalidate
363
373
  end
364
374
 
365
- # Sets `auto_scroll`. If true, immediately scrolls to the bottom.
375
+ # Sets `auto_scroll`. If true, re-engages tailing and immediately
376
+ # scrolls to the bottom.
366
377
  # @param value [Boolean]
367
378
  # @return [void]
368
379
  def auto_scroll=(value)
369
380
  @auto_scroll = value ? true : false
381
+ @follow = true if @auto_scroll
370
382
  update_top_line_if_auto_scroll
371
383
  end
372
384
 
@@ -529,10 +541,10 @@ module Tuile
529
541
  splice_hard_lines(start, old_count, new_lines)
530
542
  region.send(:line_count=, new_lines.size)
531
543
  @text = nil
532
- @content_size = compute_content_size
533
544
  @top_line = top_line_max if @top_line > top_line_max
534
545
  update_top_line_if_auto_scroll
535
546
  invalidate
547
+ self.content_size = compute_content_size
536
548
  end
537
549
 
538
550
  # Region-scoped {#replace}. Validates `range` against
@@ -555,10 +567,10 @@ module Tuile
555
567
  splice_hard_lines(abs_from, length, new_hard_lines)
556
568
  region.send(:line_count=, region.line_count - length + new_hard_lines.size)
557
569
  @text = nil
558
- @content_size = compute_content_size
559
570
  @top_line = top_line_max if @top_line > top_line_max
560
571
  update_top_line_if_auto_scroll
561
572
  invalidate
573
+ self.content_size = compute_content_size
562
574
  end
563
575
 
564
576
  # Verbatim append into `region`.
@@ -595,10 +607,10 @@ module Tuile
595
607
  region.send(:line_count=, region.line_count + rest.size)
596
608
  end
597
609
  @text = nil
598
- @content_size = compute_content_size
599
610
  @top_line = top_line_max if @top_line > top_line_max
600
611
  update_top_line_if_auto_scroll
601
612
  invalidate
613
+ self.content_size = compute_content_size
602
614
  end
603
615
 
604
616
  # Drops the last `n` hard lines from `region`'s tail via
@@ -617,10 +629,10 @@ module Tuile
617
629
  splice_hard_lines(drop_from, to_drop, [])
618
630
  region.send(:line_count=, region.line_count - to_drop)
619
631
  @text = nil
620
- @content_size = compute_content_size
621
632
  @top_line = top_line_max if @top_line > top_line_max
622
633
  update_top_line_if_auto_scroll
623
634
  invalidate
635
+ self.content_size = compute_content_size
624
636
  end
625
637
 
626
638
  # Drops `region` from {@regions}: its hard lines are removed via
@@ -643,10 +655,10 @@ module Tuile
643
655
  return unless had_lines
644
656
 
645
657
  @text = nil
646
- @content_size = compute_content_size
647
658
  @top_line = top_line_max if @top_line > top_line_max
648
659
  update_top_line_if_auto_scroll
649
660
  invalidate
661
+ self.content_size = compute_content_size
650
662
  end
651
663
 
652
664
  # Adjusts region line counts after a {@hard_lines} splice that
@@ -849,14 +861,22 @@ module Tuile
849
861
  self.top_line = clamped unless @top_line == clamped
850
862
  end
851
863
 
864
+ # Gated on {#following?}: once the user scrolls up off the bottom the
865
+ # viewport pin is skipped, so reading older content is not interrupted
866
+ # by incoming lines. {#top_line=} re-arms `@follow` when the viewport
867
+ # returns to the bottom.
852
868
  # @return [void]
853
869
  def update_top_line_if_auto_scroll
854
- return unless @auto_scroll
870
+ return unless @auto_scroll && @follow
855
871
 
856
872
  target = (@physical_lines.size - viewport_lines).clamp(0, nil)
857
873
  self.top_line = target if @top_line != target
858
874
  end
859
875
 
876
+ # @return [Boolean] whether the viewport is pinned to the last line.
877
+ # Drives {#following?}: re-evaluated on every {#top_line=}.
878
+ def at_bottom? = @top_line == top_line_max
879
+
860
880
  # @return [Boolean]
861
881
  def scrollbar_visible?
862
882
  return false if rect.empty?