tuile 0.7.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +51 -24
  4. data/book/01-first-app.md +186 -0
  5. data/book/02-repaint.md +177 -0
  6. data/book/03-layout.md +379 -0
  7. data/book/04-event-loop.md +209 -0
  8. data/book/05-focus.md +188 -0
  9. data/book/06-theming.md +250 -0
  10. data/book/07-components.md +231 -0
  11. data/book/08-testing.md +186 -0
  12. data/book/README.md +79 -0
  13. data/examples/hello_world.rb +1 -2
  14. data/examples/sampler.rb +109 -0
  15. data/lib/tuile/ansi.rb +16 -0
  16. data/lib/tuile/buffer.rb +481 -0
  17. data/lib/tuile/component/button.rb +3 -14
  18. data/lib/tuile/component/has_content.rb +0 -6
  19. data/lib/tuile/component/info_window.rb +4 -2
  20. data/lib/tuile/component/label.rb +15 -23
  21. data/lib/tuile/component/layout.rb +0 -21
  22. data/lib/tuile/component/list.rb +10 -37
  23. data/lib/tuile/component/log_window.rb +6 -5
  24. data/lib/tuile/component/picker_window.rb +4 -2
  25. data/lib/tuile/component/popup.rb +85 -55
  26. data/lib/tuile/component/text_area.rb +1 -1
  27. data/lib/tuile/component/text_field.rb +1 -1
  28. data/lib/tuile/component/text_input.rb +25 -9
  29. data/lib/tuile/component/text_view.rb +6 -30
  30. data/lib/tuile/component/window.rb +77 -112
  31. data/lib/tuile/component.rb +16 -71
  32. data/lib/tuile/event_queue.rb +31 -10
  33. data/lib/tuile/fake_event_queue.rb +21 -5
  34. data/lib/tuile/fake_screen.rb +14 -1
  35. data/lib/tuile/fraction.rb +46 -0
  36. data/lib/tuile/screen.rb +98 -105
  37. data/lib/tuile/screen_pane.rb +82 -19
  38. data/lib/tuile/styled_string.rb +40 -30
  39. data/lib/tuile/version.rb +1 -1
  40. data/sig/tuile.rbs +653 -282
  41. metadata +13 -3
  42. data/lib/tuile/sizing.rb +0 -59
@@ -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
@@ -204,11 +202,7 @@ module Tuile
204
202
  # @param key [String] a key.
205
203
  # @return [Boolean] true if the key was handled.
206
204
  def handle_key(key)
207
- if !active?
208
- false
209
- elsif super
210
- true
211
- elsif key == Keys::PAGE_UP
205
+ if key == Keys::PAGE_UP
212
206
  move_top_line_by(-viewport_lines)
213
207
  true
214
208
  elsif key == Keys::PAGE_DOWN
@@ -288,7 +282,7 @@ module Tuile
288
282
  end
289
283
  (0...rect.height).each do |row|
290
284
  line = paintable_line(row + @top_line, row, scrollbar)
291
- screen.print TTY::Cursor.move_to(rect.left, row + rect.top), line
285
+ screen.buffer.set_line(rect.left, row + rect.top, line)
292
286
  end
293
287
  end
294
288
 
@@ -296,6 +290,8 @@ module Tuile
296
290
  class Cursor
297
291
  # @param position [Integer] the initial cursor position.
298
292
  def initialize(position: 0)
293
+ raise "invalid position #{position}" unless position.is_a? Integer
294
+
299
295
  @position = position
300
296
  end
301
297
 
@@ -430,6 +426,8 @@ module Tuile
430
426
  # empty.
431
427
  # @param position [Integer] initial position.
432
428
  def initialize(positions, position: positions[0])
429
+ raise "positions are empty" if positions.empty?
430
+
433
431
  @positions = positions.sort
434
432
  position = @positions[@positions.rindex { _1 < position } || 0] unless @positions.include?(position)
435
433
  super(position: position)
@@ -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
@@ -759,15 +733,14 @@ module Tuile
759
733
  # @param row_in_viewport [Integer] 0-based row within the viewport.
760
734
  # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
761
735
  # if not shown.
762
- # @return [String] paintable ANSI-encoded line exactly `rect.width`
763
- # columns wide; highlighted if cursor is here.
736
+ # @return [StyledString] paintable line exactly `rect.width` columns wide;
737
+ # highlighted if cursor is here.
764
738
  def paintable_line(index, row_in_viewport, scrollbar)
765
739
  base = index < @lines.size ? @padded_lines[index] : @blank_padded
766
740
  is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
767
741
  styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
768
- out = styled.to_ansi
769
- out += scrollbar.scrollbar_char(row_in_viewport) if scrollbar
770
- out
742
+ styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
743
+ styled
771
744
  end
772
745
  end
773
746
  end
@@ -15,11 +15,12 @@ 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
 
@@ -50,11 +50,13 @@ module Tuile
50
50
  # @return [Proc, nil]
51
51
  attr_accessor :on_pick
52
52
 
53
+ # Handles an option-key press. Reached by bubbling: the inner {List}
54
+ # (the focused component) sees the key first and handles cursor/Enter
55
+ # picks; anything it declines bubbles up here, where a key matching an
56
+ # option's `key` picks that option.
53
57
  # @param key [String]
54
58
  # @return [Boolean]
55
59
  def handle_key(key)
56
- return true if super
57
-
58
60
  if @options.any? { _1.key == key }
59
61
  select_option(key)
60
62
  true
@@ -2,10 +2,32 @@
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 holds a top-down
8
+ # {#size} the {Screen} applies.
9
+ #
10
+ # The popup does *not* size itself to its content. Its box is declared by
11
+ # {#size} — a {Fraction} (resolved against the screen every layout pass, so
12
+ # it tracks resize) or an absolute {Size} (clamped to the screen). The
13
+ # default is {Fraction::HALF}: half the screen, centered. The wrapped
14
+ # content then fills that box and handles its own overflow by wrapping and
15
+ # scrolling, so use content that can — a {Component::TextView} or
16
+ # {Component::TextArea} — for anything longer than fits. A
17
+ # {Component::Label} only truncates.
18
+ #
19
+ # Modal by default: it centers on the screen, grabs focus, eats keys, and
20
+ # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
21
+ # that floats above the content (still painted on top) without taking focus
22
+ # or capturing input — the caller positions it (via {#rect=}) and drives it
23
+ # from app code. That is the building block for an autocomplete/slash-command
24
+ # list anchored to a {Component::TextField} or {Component::TextArea} caret:
25
+ # typing keeps focus (and the cursor) in the input, an
26
+ # {Component::TextInput#on_change} listener refills the list, and an
27
+ # {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
28
+ # Such a caller owns the list data, so it sizes the overlay itself
29
+ # (`overlay.size = Size.new(longest, [items.size, 8].min)`) — still
30
+ # caller-decides, top-down.
9
31
  #
10
32
  # The wrapped content fills the popup's full {#rect}; if you want a frame
11
33
  # and caption, wrap a {Component::Window} (or any subclass — including
@@ -26,15 +48,43 @@ module Tuile
26
48
  include Component::HasContent
27
49
 
28
50
  # @param content [Component, nil] initial content; can be set later via
29
- # {#content=}. When provided here, the popup auto-sizes to fit.
30
- def initialize(content: nil)
51
+ # {#content=}. The content fills the popup's {#rect}; it does not
52
+ # determine the popup's size.
53
+ # @param modal [Boolean] true (default) for a centered, focus-grabbing,
54
+ # input-capturing modal; false for a non-modal overlay the caller
55
+ # positions and drives (see the class docs).
56
+ # @param size [Size, Fraction] the popup's size, applied top-down. A
57
+ # {Fraction} is resolved against the screen each layout pass; a {Size}
58
+ # is clamped to the screen. Defaults to {Fraction::HALF}.
59
+ def initialize(content: nil, modal: true, size: Fraction::HALF)
31
60
  super()
61
+ @modal = modal
62
+ @size = size
32
63
  @content = nil
33
64
  self.content = content unless content.nil?
65
+ reposition
34
66
  end
35
67
 
68
+ # @return [Size, Fraction] the popup's declared size. See {#size=}.
69
+ attr_reader :size
70
+
71
+ # @return [Boolean] whether this popup is modal. See {#initialize}.
72
+ def modal? = @modal
73
+
36
74
  def focusable? = true
37
75
 
76
+ # Sets the popup's size and repositions it. Accepts a {Fraction}
77
+ # (resolved against the screen every layout pass, so it tracks resize) or
78
+ # an absolute {Size} (clamped to the screen). This is **authoritative**,
79
+ # not a preference: the screen applies exactly what you ask for (clamped),
80
+ # with no negotiation — a popup has no siblings to compete with.
81
+ # @param new_size [Size, Fraction]
82
+ # @return [void]
83
+ def size=(new_size)
84
+ @size = new_size
85
+ reposition
86
+ end
87
+
38
88
  # Reassigns the popup's rect, escalating to a full scene repaint when an
39
89
  # open popup shrinks or moves so its new rect no longer covers the cells
40
90
  # it previously painted. A popup overdraws the scene without clipping and
@@ -50,20 +100,21 @@ module Tuile
50
100
  screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
51
101
  end
52
102
 
53
- # Mounts this popup on the {Screen}. Recomputes the popup's size from
54
- # the current content first, so reopening a popup whose content has
55
- # grown or shrunk while closed picks up the new size.
103
+ # Mounts this popup on the {Screen}, re-resolving its {#size} against the
104
+ # current screen first.
56
105
  # @return [void]
57
106
  def open
58
- update_rect unless @content.nil?
107
+ reposition
59
108
  screen.add_popup(self)
60
109
  end
61
110
 
62
111
  # Constructs and opens a popup in one call.
63
112
  # @param content [Component, nil]
113
+ # @param modal [Boolean] see {#initialize}.
114
+ # @param size [Size, Fraction] see {#initialize}.
64
115
  # @return [Popup] the opened popup.
65
- def self.open(content: nil)
66
- Popup.new(content: content).tap(&:open)
116
+ def self.open(content: nil, modal: true, size: Fraction::HALF)
117
+ Popup.new(content: content, modal: modal, size: size).tap(&:open)
67
118
  end
68
119
 
69
120
  # Removes this popup from the {Screen}. No-op if not currently open.
@@ -77,33 +128,29 @@ module Tuile
77
128
  screen.has_popup?(self)
78
129
  end
79
130
 
80
- # Recenters the popup on the screen, preserving its current width/height.
81
- # Called automatically by the screen's layout pass and by {#content=}
82
- # when the popup is open.
131
+ # Re-resolves {#size} against the current screen and repositions the popup
132
+ # *itself* (this is not laying out content — the popup's own rect): a
133
+ # modal popup recenters; a non-modal overlay keeps its caller-assigned
134
+ # top-left (only its size follows the screen). Called on {#open}, on
135
+ # {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
136
+ # SIGWINCH).
137
+ #
138
+ # The final rect is computed and assigned in one step rather than sizing
139
+ # at the origin and then centering: the intermediate origin rect rarely
140
+ # covers the previous one, which would make {#rect=}'s shrink/move
141
+ # detection fire a full repaint on every resize.
83
142
  # @return [void]
84
- def center
85
- self.rect = rect.centered(screen.size)
86
- end
87
-
88
- # @return [Integer] max height the popup will grow to fit its content,
89
- # defaults to 12. Override in a subclass to allow taller popups.
90
- def max_height = 12
91
-
92
- # Sets the popup's content and auto-sizes the popup to fit.
93
- # @param new_content [Component, nil]
94
- def content=(new_content)
95
- super
96
- update_rect unless new_content.nil?
143
+ def reposition
144
+ size = @size.is_a?(Fraction) ? @size.resolve(screen.size) : @size.clamp(screen.size)
145
+ r = Rect.new(rect.left, rect.top, size.width, size.height)
146
+ r = r.centered(screen.size) if modal?
147
+ self.rect = r
97
148
  end
98
149
 
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]
150
+ # Recenters the popup on the screen, preserving its current width/height.
104
151
  # @return [void]
105
- def on_child_content_size_changed(_child)
106
- update_rect
152
+ def center
153
+ self.rect = rect.centered(screen.size)
107
154
  end
108
155
 
109
156
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
@@ -114,11 +161,12 @@ module Tuile
114
161
  child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
115
162
  end
116
163
 
164
+ # `q` and ESC close the popup. The popup sits on the focus chain of
165
+ # whatever it wraps, so the key reaches here by bubbling up from the
166
+ # focused content after that content declined to handle it.
117
167
  # @param key [String]
118
168
  # @return [Boolean] true if the key was handled.
119
169
  def handle_key(key)
120
- return true if super
121
-
122
170
  if [Keys::ESC, "q"].include?(key)
123
171
  close
124
172
  true
@@ -135,24 +183,6 @@ module Tuile
135
183
  def layout(content)
136
184
  content.rect = rect
137
185
  end
138
-
139
- private
140
-
141
- # Recompute width/height from {#content}'s natural size and recenter
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.
148
- # @return [void]
149
- def update_rect
150
- size = @content.content_size.clamp_height(max_height)
151
- size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
152
- r = Rect.new(0, 0, size.width, size.height)
153
- r = r.centered(screen.size) if open?
154
- self.rect = r
155
- end
156
186
  end
157
187
  end
158
188
  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
@@ -122,7 +122,6 @@ module Tuile
122
122
  rewrap
123
123
  update_top_line_if_auto_scroll
124
124
  invalidate
125
- self.content_size = compute_content_size
126
125
  end
127
126
 
128
127
  # Creates a new empty {Region} at the spatial tail of the document
@@ -191,7 +190,6 @@ module Tuile
191
190
  @text = nil
192
191
  update_top_line_if_auto_scroll
193
192
  invalidate
194
- self.content_size = compute_content_size
195
193
  end
196
194
 
197
195
  # Verbatim append, returning `self` for chainability (`view << a << b`).
@@ -266,7 +264,6 @@ module Tuile
266
264
  @top_line = top_line_max if @top_line > top_line_max
267
265
  update_top_line_if_auto_scroll
268
266
  invalidate
269
- self.content_size = compute_content_size
270
267
  end
271
268
 
272
269
  # Replaces a contiguous range of hard lines with the parsed content
@@ -327,7 +324,6 @@ module Tuile
327
324
  @top_line = top_line_max if @top_line > top_line_max
328
325
  update_top_line_if_auto_scroll
329
326
  invalidate
330
- self.content_size = compute_content_size
331
327
  end
332
328
 
333
329
  # Inserts `str` at hard-line index `at`. Equivalent to
@@ -386,13 +382,6 @@ module Tuile
386
382
 
387
383
  def tab_stop? = true
388
384
 
389
- # @return [Size] longest hard-line's display width × number of hard
390
- # lines. Reported on the *unwrapped* text — wrap-aware sizing would
391
- # be circular (width depends on width). Empty text returns
392
- # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
393
- # {#append}, so reads are O(1).
394
- attr_reader :content_size
395
-
396
385
  # @param key [String]
397
386
  # @return [Boolean]
398
387
  def handle_key(key)
@@ -437,7 +426,7 @@ module Tuile
437
426
  end
438
427
  (0...rect.height).each do |row|
439
428
  line = paintable_line(row + @top_line, row, scrollbar)
440
- screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
429
+ screen.buffer.set_line(rect.left, rect.top + row, line)
441
430
  end
442
431
  end
443
432
 
@@ -544,7 +533,6 @@ module Tuile
544
533
  @top_line = top_line_max if @top_line > top_line_max
545
534
  update_top_line_if_auto_scroll
546
535
  invalidate
547
- self.content_size = compute_content_size
548
536
  end
549
537
 
550
538
  # Region-scoped {#replace}. Validates `range` against
@@ -570,7 +558,6 @@ module Tuile
570
558
  @top_line = top_line_max if @top_line > top_line_max
571
559
  update_top_line_if_auto_scroll
572
560
  invalidate
573
- self.content_size = compute_content_size
574
561
  end
575
562
 
576
563
  # Verbatim append into `region`.
@@ -610,7 +597,6 @@ module Tuile
610
597
  @top_line = top_line_max if @top_line > top_line_max
611
598
  update_top_line_if_auto_scroll
612
599
  invalidate
613
- self.content_size = compute_content_size
614
600
  end
615
601
 
616
602
  # Drops the last `n` hard lines from `region`'s tail via
@@ -632,7 +618,6 @@ module Tuile
632
618
  @top_line = top_line_max if @top_line > top_line_max
633
619
  update_top_line_if_auto_scroll
634
620
  invalidate
635
- self.content_size = compute_content_size
636
621
  end
637
622
 
638
623
  # Drops `region` from {@regions}: its hard lines are removed via
@@ -658,7 +643,6 @@ module Tuile
658
643
  @top_line = top_line_max if @top_line > top_line_max
659
644
  update_top_line_if_auto_scroll
660
645
  invalidate
661
- self.content_size = compute_content_size
662
646
  end
663
647
 
664
648
  # Adjusts region line counts after a {@hard_lines} splice that
@@ -832,13 +816,6 @@ module Tuile
832
816
  StyledString.new(spans)
833
817
  end
834
818
 
835
- # @return [Size] {#content_size} computed from {@hard_lines}.
836
- def compute_content_size
837
- return Size::ZERO if @hard_lines.empty?
838
-
839
- Size.new(@hard_lines.map(&:display_width).max || 0, @hard_lines.size)
840
- end
841
-
842
819
  # @return [Integer] column width available for wrapped text — viewport
843
820
  # width minus the scrollbar gutter (when visible). `0` when {#rect}'s
844
821
  # width is non-positive, which yields a degenerate "no wrap" result.
@@ -904,15 +881,14 @@ module Tuile
904
881
  # @param index [Integer] 0-based index into `@physical_lines`.
905
882
  # @param row_in_viewport [Integer] 0-based row within the viewport.
906
883
  # @param scrollbar [VerticalScrollBar, nil]
907
- # @return [String] paintable ANSI-encoded line exactly `rect.width`
908
- # columns wide. Body lines come pre-padded from {#rewrap}, so this
909
- # reduces to a memoized {StyledString#to_ansi} read plus an
910
- # ASCII-string concat of the scrollbar glyph when one is present.
884
+ # @return [StyledString] paintable line exactly `rect.width` columns wide.
885
+ # Body lines come pre-padded from {#rewrap}, so this reduces to a lookup
886
+ # plus a concat of the scrollbar glyph when one is present.
911
887
  def paintable_line(index, row_in_viewport, scrollbar)
912
888
  line = @physical_lines[index] || @blank_line
913
- return line.to_ansi unless scrollbar
889
+ return line unless scrollbar
914
890
 
915
- line.to_ansi + scrollbar.scrollbar_char(row_in_viewport)
891
+ line + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
916
892
  end
917
893
 
918
894
  # A logical section of a {TextView}'s text — a contiguous run of