tuile 0.6.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.
@@ -23,6 +23,7 @@ module Tuile
23
23
  @padded_lines = []
24
24
  @blank_padded = StyledString::EMPTY
25
25
  @auto_scroll = false
26
+ @follow = true
26
27
  @top_line = 0
27
28
  @cursor = Cursor::None.new
28
29
  @scrollbar_visibility = :gone
@@ -50,9 +51,18 @@ module Tuile
50
51
  attr_accessor :on_cursor_changed
51
52
 
52
53
  # @return [Boolean] if true and a line is added or new content is set,
53
- # 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.
54
58
  attr_reader :auto_scroll
55
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
+
56
66
  # @return [Integer] top line of the viewport. 0 or positive.
57
67
  attr_reader :top_line
58
68
 
@@ -87,10 +97,12 @@ module Tuile
87
97
  invalidate
88
98
  end
89
99
 
90
- # 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.
91
102
  # @param new_auto_scroll [Boolean]
92
103
  def auto_scroll=(new_auto_scroll)
93
104
  @auto_scroll = new_auto_scroll
105
+ @follow = true if new_auto_scroll
94
106
  update_top_line_if_auto_scroll
95
107
  end
96
108
 
@@ -113,6 +125,7 @@ module Tuile
113
125
  return unless @top_line != new_top_line
114
126
 
115
127
  @top_line = new_top_line
128
+ @follow = at_bottom?
116
129
  invalidate
117
130
  end
118
131
 
@@ -163,6 +176,7 @@ module Tuile
163
176
  # @return [void]
164
177
  def add_line(line)
165
178
  raise ArgumentError, "line is nil" if line.nil?
179
+
166
180
  add_lines [line]
167
181
  end
168
182
 
@@ -190,11 +204,7 @@ module Tuile
190
204
  # @param key [String] a key.
191
205
  # @return [Boolean] true if the key was handled.
192
206
  def handle_key(key)
193
- if !active?
194
- false
195
- elsif super
196
- true
197
- elsif key == Keys::PAGE_UP
207
+ if key == Keys::PAGE_UP
198
208
  move_top_line_by(-viewport_lines)
199
209
  true
200
210
  elsif key == Keys::PAGE_DOWN
@@ -274,7 +284,7 @@ module Tuile
274
284
  end
275
285
  (0...rect.height).each do |row|
276
286
  line = paintable_line(row + @top_line, row, scrollbar)
277
- screen.print TTY::Cursor.move_to(rect.left, row + rect.top), line
287
+ screen.buffer.set_line(rect.left, row + rect.top, line)
278
288
  end
279
289
  end
280
290
 
@@ -282,6 +292,8 @@ module Tuile
282
292
  class Cursor
283
293
  # @param position [Integer] the initial cursor position.
284
294
  def initialize(position: 0)
295
+ raise "invalid position #{position}" unless position.is_a? Integer
296
+
285
297
  @position = position
286
298
  end
287
299
 
@@ -416,8 +428,10 @@ module Tuile
416
428
  # empty.
417
429
  # @param position [Integer] initial position.
418
430
  def initialize(positions, position: positions[0])
431
+ raise "positions are empty" if positions.empty?
432
+
419
433
  @positions = positions.sort
420
- position = @positions[@positions.rindex { it < position } || 0] unless @positions.include?(position)
434
+ position = @positions[@positions.rindex { _1 < position } || 0] unless @positions.include?(position)
421
435
  super(position: position)
422
436
  end
423
437
 
@@ -427,7 +441,7 @@ module Tuile
427
441
  # @return [Boolean]
428
442
  def handle_mouse(line, event, _line_count)
429
443
  if event.button == :left
430
- prev_pos = @positions.reverse_each.find { it <= line }
444
+ prev_pos = @positions.reverse_each.find { _1 <= line }
431
445
  return go_to_first if prev_pos.nil?
432
446
 
433
447
  go(prev_pos)
@@ -439,7 +453,7 @@ module Tuile
439
453
  # @param line_count [Integer]
440
454
  # @return [Array<Integer>]
441
455
  def candidate_positions(line_count)
442
- @positions.select { it < line_count }
456
+ @positions.select { _1 < line_count }
443
457
  end
444
458
 
445
459
  # @param _line_count [Integer]
@@ -454,7 +468,7 @@ module Tuile
454
468
  # @param line_count [Integer]
455
469
  # @return [Boolean]
456
470
  def go_down_by(lines, line_count)
457
- next_pos = @positions.find { it >= @position + lines }
471
+ next_pos = @positions.find { _1 >= @position + lines }
458
472
  return go_to_last(line_count) if next_pos.nil?
459
473
 
460
474
  go(next_pos)
@@ -463,7 +477,7 @@ module Tuile
463
477
  # @param lines [Integer]
464
478
  # @return [Boolean]
465
479
  def go_up_by(lines)
466
- prev_pos = @positions.reverse_each.find { it <= @position - lines }
480
+ prev_pos = @positions.reverse_each.find { _1 <= @position - lines }
467
481
  return go_to_first if prev_pos.nil?
468
482
 
469
483
  go(prev_pos)
@@ -622,16 +636,16 @@ module Tuile
622
636
  def order_for_search(candidates, current, include_current:, reverse:)
623
637
  if reverse
624
638
  before, after = if include_current
625
- [candidates.select { it <= current }, candidates.select { it > current }]
639
+ [candidates.select { _1 <= current }, candidates.select { _1 > current }]
626
640
  else
627
- [candidates.select { it < current }, candidates.select { it >= current }]
641
+ [candidates.select { _1 < current }, candidates.select { _1 >= current }]
628
642
  end
629
643
  before.reverse + after.reverse
630
644
  else
631
645
  after, before = if include_current
632
- [candidates.select { it >= current }, candidates.select { it < current }]
646
+ [candidates.select { _1 >= current }, candidates.select { _1 < current }]
633
647
  else
634
- [candidates.select { it > current }, candidates.select { it <= current }]
648
+ [candidates.select { _1 > current }, candidates.select { _1 <= current }]
635
649
  end
636
650
  after + before
637
651
  end
@@ -653,6 +667,10 @@ module Tuile
653
667
  # @return [Integer] the max value of {#top_line}.
654
668
  def top_line_max = (@lines.size - rect.height).clamp(0, nil)
655
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
+
656
674
  # @return [Integer] the number of visible lines.
657
675
  def viewport_lines = rect.height
658
676
 
@@ -675,9 +693,14 @@ module Tuile
675
693
  # which would leave `top_line` past the last item once a real rect
676
694
  # arrives. {#on_width_changed} re-runs this hook when the rect grows so
677
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.
678
701
  # @return [void]
679
702
  def update_top_line_if_auto_scroll
680
- return unless @auto_scroll
703
+ return unless @auto_scroll && @follow
681
704
  return if rect.empty?
682
705
 
683
706
  notify_cursor_changed if @cursor.go_to_last(@lines.size)
@@ -736,15 +759,14 @@ module Tuile
736
759
  # @param row_in_viewport [Integer] 0-based row within the viewport.
737
760
  # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
738
761
  # if not shown.
739
- # @return [String] paintable ANSI-encoded line exactly `rect.width`
740
- # columns wide; highlighted if cursor is here.
762
+ # @return [StyledString] paintable line exactly `rect.width` columns wide;
763
+ # highlighted if cursor is here.
741
764
  def paintable_line(index, row_in_viewport, scrollbar)
742
765
  base = index < @lines.size ? @padded_lines[index] : @blank_padded
743
766
  is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
744
767
  styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
745
- out = styled.to_ansi
746
- out += scrollbar.scrollbar_char(row_in_viewport) if scrollbar
747
- out
768
+ styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
769
+ styled
748
770
  end
749
771
  end
750
772
  end
@@ -15,19 +15,35 @@ 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]
29
44
  def log(string)
30
45
  return if string.nil?
46
+
31
47
  screen.event_queue.submit do
32
48
  content.add_line(string)
33
49
  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} #{screen.theme.hint(it.caption)}" }
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
@@ -50,12 +50,14 @@ 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
- if @options.any? { it.key == key }
60
+ if @options.any? { _1.key == key }
59
61
  select_option(key)
60
62
  true
61
63
  else
@@ -65,7 +67,7 @@ module Tuile
65
67
 
66
68
  # @return [String]
67
69
  def keyboard_hint
68
- @options.map { "#{it.key} #{screen.theme.hint(it.caption)}" }.join(" ")
70
+ @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }.join(" ")
69
71
  end
70
72
 
71
73
  # Opens a picker as a popup. Picking an option fires `block`, then
@@ -2,10 +2,20 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A modal overlay that wraps any {Component} as its content. Popup itself
6
- # paints nothing — it's a transparent host that handles modality
7
- # ({#open} / {#close} / {#open?}, ESC/q to close), centers itself on the
8
- # screen, and auto-sizes to the wrapped content.
5
+ # An overlay that wraps any {Component} as its content. Popup itself
6
+ # paints nothing — it's a transparent host that handles its lifecycle
7
+ # ({#open} / {#close} / {#open?}, ESC/q to close) and auto-sizes to the
8
+ # wrapped content.
9
+ #
10
+ # Modal by default: it centers on the screen, grabs focus, eats keys, and
11
+ # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
12
+ # that floats above the content (still painted on top, still auto-sized)
13
+ # without taking focus or capturing input — the caller positions it (via
14
+ # {#rect=}) and drives it from app code. That is the building block for an
15
+ # autocomplete/slash-command list anchored to a {Component::TextField} or
16
+ # {Component::TextArea} caret: typing keeps focus (and the cursor) in the
17
+ # input, an {Component::TextInput#on_change} listener refills the list, and
18
+ # an {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
9
19
  #
10
20
  # The wrapped content fills the popup's full {#rect}; if you want a frame
11
21
  # and caption, wrap a {Component::Window} (or any subclass — including
@@ -27,14 +37,36 @@ module Tuile
27
37
 
28
38
  # @param content [Component, nil] initial content; can be set later via
29
39
  # {#content=}. When provided here, the popup auto-sizes to fit.
30
- def initialize(content: nil)
40
+ # @param modal [Boolean] true (default) for a centered, focus-grabbing,
41
+ # input-capturing modal; false for a non-modal overlay the caller
42
+ # positions and drives (see the class docs).
43
+ def initialize(content: nil, modal: true)
31
44
  super()
45
+ @modal = modal
32
46
  @content = nil
33
47
  self.content = content unless content.nil?
34
48
  end
35
49
 
50
+ # @return [Boolean] whether this popup is modal. See {#initialize}.
51
+ def modal? = @modal
52
+
36
53
  def focusable? = true
37
54
 
55
+ # Reassigns the popup's rect, escalating to a full scene repaint when an
56
+ # open popup shrinks or moves so its new rect no longer covers the cells
57
+ # it previously painted. A popup overdraws the scene without clipping and
58
+ # nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
59
+ # would repaint into the new rect and leave the vacated cells showing
60
+ # stale content. When the new rect fully covers the old one (the popup
61
+ # only grew), the fast path is correct and the full repaint is skipped.
62
+ # @param new_rect [Rect]
63
+ # @return [void]
64
+ def rect=(new_rect)
65
+ old_rect = rect
66
+ super
67
+ screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
68
+ end
69
+
38
70
  # Mounts this popup on the {Screen}. Recomputes the popup's size from
39
71
  # the current content first, so reopening a popup whose content has
40
72
  # grown or shrunk while closed picks up the new size.
@@ -70,9 +102,20 @@ module Tuile
70
102
  self.rect = rect.centered(screen.size)
71
103
  end
72
104
 
73
- # @return [Integer] max height the popup will grow to fit its content,
74
- # defaults to 12. Override in a subclass to allow taller popups.
75
- def max_height = 12
105
+ # @return [Integer] max height the popup will grow to fit its content.
106
+ # Defers to the content's {Component#popup_max_height} advice when it
107
+ # gives one, else defaults to 12. Override in a subclass to allow
108
+ # taller popups regardless of content.
109
+ def max_height = @content&.popup_max_height || 12
110
+
111
+ # @return [Integer] min height the popup occupies even when its content
112
+ # is shorter. Defers to the content's {Component#popup_min_height}
113
+ # advice when it gives one, else defaults to 0 (size purely to
114
+ # content) — so a {Component::LogWindow} stays readable while only a
115
+ # few lines are in without callers wiring up a subclass. Override in a
116
+ # subclass to keep any popup from collapsing to a couple of rows.
117
+ # Capped at the same 4/5-of-screen ceiling {#update_rect} applies.
118
+ def min_height = @content&.popup_min_height || 0
76
119
 
77
120
  # Sets the popup's content and auto-sizes the popup to fit.
78
121
  # @param new_content [Component, nil]
@@ -99,11 +142,12 @@ module Tuile
99
142
  child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
100
143
  end
101
144
 
145
+ # `q` and ESC close the popup. The popup sits on the focus chain of
146
+ # whatever it wraps, so the key reaches here by bubbling up from the
147
+ # focused content after that content declined to handle it.
102
148
  # @param key [String]
103
149
  # @return [Boolean] true if the key was handled.
104
150
  def handle_key(key)
105
- return true if super
106
-
107
151
  if [Keys::ESC, "q"].include?(key)
108
152
  close
109
153
  true
@@ -125,12 +169,24 @@ module Tuile
125
169
 
126
170
  # Recompute width/height from {#content}'s natural size and recenter
127
171
  # if currently open. Called whenever content is (re)assigned.
172
+ #
173
+ # Computes the final (centered) rect and assigns it in one step rather
174
+ # than positioning at the origin and then centering: the intermediate
175
+ # origin rect rarely covers the previous one, which would make
176
+ # {#rect=}'s shrink/move detection fire a full repaint on every resize.
128
177
  # @return [void]
129
178
  def update_rect
179
+ ceiling = screen.size.height * 4 / 5
130
180
  size = @content.content_size.clamp_height(max_height)
131
- size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
132
- self.rect = Rect.new(0, 0, size.width, size.height)
133
- center if open?
181
+ size = size.clamp(Size.new(screen.size.width * 4 / 5, ceiling))
182
+ floor = min_height.clamp(0, ceiling)
183
+ size = Size.new(size.width, floor) if size.height < floor
184
+ # A non-modal overlay is positioned by the caller, so an open one keeps
185
+ # its current top-left when its content resizes; a modal popup recenters.
186
+ origin = open? && !modal? ? Point.new(rect.left, rect.top) : Point.new(0, 0)
187
+ r = Rect.new(origin.x, origin.y, size.width, size.height)
188
+ r = r.centered(screen.size) if open? && modal?
189
+ self.rect = r
134
190
  end
135
191
  end
136
192
  end
@@ -80,7 +80,7 @@ module Tuile
80
80
  chunk = @text[r[:start], r[:length]] || ""
81
81
  chunk + (" " * (rect.width - r[:length]))
82
82
  end
83
- screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), background(line)
83
+ screen.buffer.set_line(rect.left, rect.top + screen_row, background(line))
84
84
  end
85
85
  end
86
86
 
@@ -60,7 +60,7 @@ module Tuile
60
60
  return if rect.empty?
61
61
 
62
62
  padded = @text + (" " * (rect.width - @text.length))
63
- screen.print TTY::Cursor.move_to(rect.left, rect.top), background(padded)
63
+ screen.buffer.set_line(rect.left, rect.top, background(padded))
64
64
  end
65
65
 
66
66
  protected
@@ -31,6 +31,7 @@ module Tuile
31
31
  @text = +""
32
32
  @caret = 0
33
33
  @on_change = nil
34
+ @on_key = nil
34
35
  @on_escape = method(:default_on_escape)
35
36
  end
36
37
 
@@ -49,6 +50,20 @@ module Tuile
49
50
  # @return [Proc, Method, nil] one-arg callable, or nil.
50
51
  attr_accessor :on_change
51
52
 
53
+ # Optional interceptor consulted before the input's own key handling.
54
+ # Receives the pressed key; return a truthy value to consume it (the
55
+ # input then ignores that key), falsy to let normal editing proceed.
56
+ #
57
+ # The keyboard analog of {#on_change}: it lets app code layer behavior
58
+ # onto an input without subclassing. The motivating case is an
59
+ # autocomplete / slash-command overlay (a non-modal {Component::Popup}):
60
+ # while it is open the interceptor claims Up/Down/Enter/ESC and forwards
61
+ # them to the overlay's list, but lets ordinary characters fall through
62
+ # so typing keeps editing the field (and {#on_change} keeps refilling the
63
+ # list).
64
+ # @return [Proc, Method, nil] one-arg callable, or nil.
65
+ attr_accessor :on_key
66
+
52
67
  # Callback fired when ESC is pressed. Defaults to a closure that clears
53
68
  # focus (`screen.focused = nil`) so ESC visibly cancels text entry instead
54
69
  # of bubbling to the parent — and, in particular, instead of reaching the
@@ -88,14 +103,15 @@ module Tuile
88
103
  invalidate
89
104
  end
90
105
 
91
- # Handles a key. Returns false when the component is inactive. Otherwise
92
- # first runs the {Component#handle_key} shortcut search via `super`, then
93
- # delegates to {#handle_text_input_key}.
106
+ # Handles a key. An {#on_key} interceptor (if set) gets first refusal —
107
+ # a truthy return consumes the key otherwise it delegates to
108
+ # {#handle_text_input_key}. Dispatch ({ScreenPane#handle_key}) only routes
109
+ # keys here when this input is on the focus chain, so there is no
110
+ # {#active?} gate.
94
111
  # @param key [String]
95
112
  # @return [Boolean]
96
113
  def handle_key(key)
97
- return false unless active?
98
- return true if super
114
+ return true if @on_key&.call(key)
99
115
 
100
116
  handle_text_input_key(key)
101
117
  end
@@ -103,13 +119,13 @@ module Tuile
103
119
  protected
104
120
 
105
121
  # 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 —
122
+ # current {Screen#theme} at paint time: {Theme#active_bg_color} when this
123
+ # input is on the active (focus) chain, {Theme#input_bg_color} otherwise —
108
124
  # visibly a field either way, distinctly highlighted when active.
109
125
  # @param text [String]
110
- # @return [String] ANSI-rendered text.
126
+ # @return [StyledString] text on the field's background well.
111
127
  def background(text)
112
- active? ? screen.theme.active_bg(text) : screen.theme.input_bg(text)
128
+ StyledString.styled(text, bg: active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
113
129
  end
114
130
 
115
131
  # Input filter for {#text=}. Subclasses override to truncate or reject
@@ -53,6 +53,7 @@ module Tuile
53
53
  @blank_line = StyledString::EMPTY
54
54
  @top_line = 0
55
55
  @auto_scroll = false
56
+ @follow = true
56
57
  @scrollbar_visibility = :gone
57
58
  # The view always has at least one region — an implicit default. It
58
59
  # owns whatever hard lines exist that no later region claims. App
@@ -80,9 +81,18 @@ module Tuile
80
81
  attr_reader :scrollbar_visibility
81
82
 
82
83
  # @return [Boolean] if true, mutating the text scrolls the viewport so
83
- # 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`.
84
88
  attr_reader :auto_scroll
85
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
+
86
96
  # Replaces the text. Embedded `\n` characters become hard line breaks.
87
97
  # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
88
98
  # honored); a `StyledString` is used as-is; `nil` is coerced to an
@@ -347,6 +357,7 @@ module Tuile
347
357
  return if @top_line == new_top_line
348
358
 
349
359
  @top_line = new_top_line
360
+ @follow = at_bottom?
350
361
  invalidate
351
362
  end
352
363
 
@@ -361,11 +372,13 @@ module Tuile
361
372
  invalidate
362
373
  end
363
374
 
364
- # 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.
365
377
  # @param value [Boolean]
366
378
  # @return [void]
367
379
  def auto_scroll=(value)
368
380
  @auto_scroll = value ? true : false
381
+ @follow = true if @auto_scroll
369
382
  update_top_line_if_auto_scroll
370
383
  end
371
384
 
@@ -424,7 +437,7 @@ module Tuile
424
437
  end
425
438
  (0...rect.height).each do |row|
426
439
  line = paintable_line(row + @top_line, row, scrollbar)
427
- screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
440
+ screen.buffer.set_line(rect.left, rect.top + row, line)
428
441
  end
429
442
  end
430
443
 
@@ -848,14 +861,22 @@ module Tuile
848
861
  self.top_line = clamped unless @top_line == clamped
849
862
  end
850
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.
851
868
  # @return [void]
852
869
  def update_top_line_if_auto_scroll
853
- return unless @auto_scroll
870
+ return unless @auto_scroll && @follow
854
871
 
855
872
  target = (@physical_lines.size - viewport_lines).clamp(0, nil)
856
873
  self.top_line = target if @top_line != target
857
874
  end
858
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
+
859
880
  # @return [Boolean]
860
881
  def scrollbar_visible?
861
882
  return false if rect.empty?
@@ -883,15 +904,14 @@ module Tuile
883
904
  # @param index [Integer] 0-based index into `@physical_lines`.
884
905
  # @param row_in_viewport [Integer] 0-based row within the viewport.
885
906
  # @param scrollbar [VerticalScrollBar, nil]
886
- # @return [String] paintable ANSI-encoded line exactly `rect.width`
887
- # columns wide. Body lines come pre-padded from {#rewrap}, so this
888
- # reduces to a memoized {StyledString#to_ansi} read plus an
889
- # ASCII-string concat of the scrollbar glyph when one is present.
907
+ # @return [StyledString] paintable line exactly `rect.width` columns wide.
908
+ # Body lines come pre-padded from {#rewrap}, so this reduces to a lookup
909
+ # plus a concat of the scrollbar glyph when one is present.
890
910
  def paintable_line(index, row_in_viewport, scrollbar)
891
911
  line = @physical_lines[index] || @blank_line
892
- return line.to_ansi unless scrollbar
912
+ return line unless scrollbar
893
913
 
894
- line.to_ansi + scrollbar.scrollbar_char(row_in_viewport)
914
+ line + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
895
915
  end
896
916
 
897
917
  # A logical section of a {TextView}'s text — a contiguous run of