Class: Tuile::Component::TextView

Inherits:
Component
  • Object
show all
Defined in:
lib/tuile/component/text_view.rb,
sig/tuile.rbs

Overview

A read-only viewer for prose: chunks of formatted text that scroll vertically. Shape-wise a hybrid between Label (string content via #text=) and List (scroll keys, optional scrollbar, auto-scroll).

Text is a StyledString: embedded \n are hard line breaks, longer lines are word-wrapped via StyledString#wrap with style spans preserved across wrap boundaries. #text= takes a String (parsed via StyledString.parse, honoring embedded ANSI) or a StyledString; #text always returns the StyledString.

Pick the right incremental primitive: #append (aliased <<) concatenates a chunk verbatim onto the buffer (stream-friendly, \n → hard breaks); #add_line starts the chunk on a fresh line (the "log entry" convenience); #remove_last_n_lines pops hard lines off the tail, so a caller streaming reformattable content can retract and rewrite it; #replace / #insert splice a range in place. Turn on #auto_scroll to keep the latest content in view.

#scrollbar_visibility turns on a VerticalScrollBar in the rightmost column — a real child component, so the user can drag its handle (wanting run_event_loop(capture_mouse: :drag)) and press its track to page. Its column and the blank one beside it are reserved whenever it is :visible, whether anything scrolls or not, so the text never rewraps behind a growing buffer (D_scrollbar_reserve, D_scrollbar_ink). Under :auto the bar shows only while the text, wrapped at the full width, has more rows than the viewport — and then the text rewraps once, two columns narrower, which is the price of the columns it hands back while everything fits.

Meant to be the content of a Window — focus indication relies on the surrounding window chrome.

Direct Known Subclasses

LogTextView, Notification::View

Defined Under Namespace

Classes: Region

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize ⇒ TextView

Returns a new instance of TextView.



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/tuile/component/text_view.rb', line 37

def initialize
  super
  # Three parallel structures, kept in lockstep by every mutator:
  # `@lines` is the logical model (one entry per `\n`-delimited
  # line, width-independent); `@rows` is the rendered view
  # (each line word-wrapped to `wrap_width` and padded with
  # trailing blanks, so painting a row is a lookup); and
  # `@line_wrap_counts` is an Integer-per-line cache of how many
  # rows each line occupies, so a mid-buffer splice can find its
  # starting row offset without re-wrapping every preceding line.
  #
  # Invariants:
  # - `@line_wrap_counts.size == @lines.size`
  # - `@line_wrap_counts.sum == @rows.size`
  # A full rebuild ({#rewrap}) happens on {#text=} and when
  # {#wrap_width} moves off `@wrapped_at`; other mutators splice
  # incrementally.
  @lines = []
  @rows = []
  @line_wrap_counts = []
  @wrapped_at = 0
  @text = StyledString::EMPTY
  @blank_row = StyledString::EMPTY
  @scroll_top_row = 0
  @auto_scroll = false
  @follow = true
  @scrollbar_visibility = :gone
  # Whether an `:auto` bar is showing — state, not derived on read,
  # because `@rows` is wrapped at the width it leaves; only
  # {#sync_auto_scrollbar} flips it.
  @auto_scrollbar_shown = false
  # Always ≥1 region; the implicit default owns any hard lines no
  # app-created region claims. See {Region}.
  @regions = [Region.send(:new, self)]
  @scrollbar = VerticalScrollBar.new
  @scrollbar.on_scroll_request { self.scroll_top_row = _1.scroll_top_row }
  add_child(@scrollbar) # chrome, appended: a TextView has no content children
end

Instance Attribute Details

#auto_scroll ⇒ Boolean

@return — if true, mutating the text scrolls the viewport so the last line stays in view — but only while the viewport is already pinned to the last line (see #following?). Scroll up to read older content and appends stop yanking you back down; scroll back to the bottom and tailing resumes. Default false.

Returns:

  • (Boolean)


95
96
97
# File 'lib/tuile/component/text_view.rb', line 95

def auto_scroll
  @auto_scroll
end

#scroll_top_row ⇒ Integer

@return — index of the first visible row.

Returns:

  • (Integer)


84
85
86
# File 'lib/tuile/component/text_view.rb', line 84

def scroll_top_row
  @scroll_top_row
end

#scrollbar_visibility ⇒ Symbol

@return — :gone, :visible, or :auto — shown only while the text overflows the viewport at the full width. :gone by default.

Returns:

  • (Symbol)


88
89
90
# File 'lib/tuile/component/text_view.rb', line 88

def scrollbar_visibility
  @scrollbar_visibility
end

Instance Method Details

#<<(str) ⇒ self

Verbatim append, returning self for chainability (view << a << b).

@param str

Parameters:

Returns:

  • (self)


200
201
202
203
# File 'lib/tuile/component/text_view.rb', line 200

def <<(str)
  append(str)
  self
end

#add_line(str) ⇒ void

This method returns an undefined value.

Appends str as a new entry: starts a fresh hard line first (when the buffer is non-empty) and then appends str. Equivalent to append("\n" + str) on a non-empty buffer, or append(str) on an empty one. nil and "" produce a blank entry on a non-empty buffer and a no-op on an empty buffer (matches the old append semantics for "log line" callers).

@param str

Parameters:



213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/tuile/component/text_view.rb', line 213

def add_line(str)
  parsed = StyledString.parse(str)
  if empty? || @regions.last.empty?
    # No previous line in the tail region to break away from — just
    # append. (If the tail region is empty but earlier regions have
    # content, the verbatim {#append} path already starts a fresh
    # hard line in the tail.)
    append(parsed)
  else
    append(StyledString.plain("\n") + parsed)
  end
end

#append(str) ⇒ void

This method returns an undefined value.

Appends str verbatim. Embedded \n become hard line breaks; otherwise the text is concatenated onto the current last hard line. Designed for streaming use (feed each partial chunk straight in). Accepts the same input forms as #text=; empty/nil is a no-op. For the "entry on a new line" pattern use #add_line. Cost is O(appended + width of the last hard line), which is re-wrapped since the extension may wrap differently.

@param str

Parameters:



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/tuile/component/text_view.rb', line 163

def append(str)
  screen.check_locked
  appended = StyledString.parse(str)
  return if appended.empty?

  tail_region = @regions.last
  tail_was_empty = tail_region.empty?
  new_segments = appended.lines
  width = wrap_width

  if tail_was_empty
    # An empty spatial-tail region (either a fresh buffer, or an empty
    # region the app created at the tail) means new content starts on
    # a fresh hard line — we must not extend the previous region's
    # last line.
    new_segments.each { |line| push_line(line, width) }
    added = new_segments.size
  else
    extension = new_segments.first
    unless extension.empty?
      old_last = pop_line
      push_line(old_last + extension, width)
    end
    new_segments[1..].each { |line| push_line(line, width) }
    added = new_segments.size - 1
  end

  tail_region.send(:line_count=, tail_region.line_count + added)
  @text = nil
  update_scroll_top_row_if_auto_scroll
  invalidate
  invalidate_layout # `@rows` grew, and the bar's row_count follows it
end

#append_to_region(region, str) ⇒ void

This method returns an undefined value.

Verbatim append into region.

@param region

@param str

Parameters:



572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
# File 'lib/tuile/component/text_view.rb', line 572

def append_to_region(region, str)
  screen.check_locked
  parsed = StyledString.parse(str)
  return if parsed.empty?

  if region.equal?(@regions.last)
    append(parsed)
    return
  end

  new_segments = parsed.lines
  start = region_start_index(region)
  if region.empty?
    splice_lines(start, 0, new_segments)
    region.send(:line_count=, new_segments.size)
  else
    last_idx = start + region.line_count - 1
    extension = new_segments.first
    rest = new_segments[1..]
    if extension.empty?
      return if rest.empty?

      splice_lines(last_idx + 1, 0, rest)
    else
      extended = @lines[last_idx] + extension
      splice_lines(last_idx, 1, [extended, *rest])
    end
    region.send(:line_count=, region.line_count + rest.size)
  end
  content_changed
end

#at_bottom? ⇒ Boolean

@return — whether the viewport is pinned to the last line. Drives #following?: re-evaluated on every #scroll_top_row=.

Returns:

  • (Boolean)


882
# File 'lib/tuile/component/text_view.rb', line 882

def at_bottom? = @scroll_top_row == scroll_top_row_max

#build_text ⇒ StyledString

Rebuilds the joined StyledString from @lines, inserting a default-styled "\n" between lines. Called from the #text reader when the cache is cold. Cost is O(total spans).

Returns:



799
800
801
802
803
804
805
806
807
808
809
810
# File 'lib/tuile/component/text_view.rb', line 799

def build_text
  return StyledString::EMPTY if @lines.empty?
  return @lines.first if @lines.size == 1

  newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
  spans = []
  @lines.each_with_index do |line, i|
    spans << newline if i.positive?
    spans.concat(line.spans)
  end
  StyledString.new(spans)
end

#clear ⇒ void

This method returns an undefined value.

Clears the text. Equivalent to text = "".



312
313
314
# File 'lib/tuile/component/text_view.rb', line 312

def clear
  self.text = StyledString::EMPTY
end

#content_changed ⇒ void

This method returns an undefined value.

Every splice's tail. Tuile::Component#invalidate_layout rides along because #relayout pushes the bar's row_count: a splice that changed @rows.size without the mark leaves the handle sized for the old buffer.

Not for #append or #text=, which mark by hand — appending must not clamp, or a viewport deliberately parked past the end (which #scroll_top_row= allows) is yanked back by the next line, and #text= clamps inside #rewrap already.



860
861
862
863
864
865
866
# File 'lib/tuile/component/text_view.rb', line 860

def content_changed
  @text = nil
  @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
  update_scroll_top_row_if_auto_scroll
  invalidate
  invalidate_layout
end

#create_region ⇒ Region

Creates a new empty Region at the spatial tail of the document and returns its handle. Subsequent #append / #<< / #add_line calls route through this new region (since it is now the spatial tail). Earlier regions keep their content and their handles stay valid; their Tuile::Component::TextView::Region#range shifts as later regions grow.

Apps streaming logically-distinct sections (e.g. an LLM's "thinking" vs. "assistant" output) create one region per section, hold the handles, and call region.append / region.text= directly when they need to grow or rewrite an earlier section.

Returns:



146
147
148
149
150
# File 'lib/tuile/component/text_view.rb', line 146

def create_region
  region = Region.send(:new, self)
  @regions << region
  region
end

#empty? ⇒ Boolean

@return — true iff #text is empty (no hard lines).

Returns:

  • (Boolean)


153
# File 'lib/tuile/component/text_view.rb', line 153

def empty? = @lines.empty?

#focusable? ⇒ Boolean

Returns:

  • (Boolean)


370
# File 'lib/tuile/component/text_view.rb', line 370

def focusable? = true

#following? ⇒ Boolean

@return — whether #auto_scroll is currently tailing. True while the viewport sits at the last line; flips to false the moment the user scrolls up, and back to true once they scroll to the bottom again. Only consulted when #auto_scroll is enabled.

Returns:

  • (Boolean)


101
# File 'lib/tuile/component/text_view.rb', line 101

def following? = @follow

#half_page_rows ⇒ Integer

@return — half a viewport, at least one row, for the half-page scroll verbs and their keys.

Returns:

  • (Integer)


695
# File 'lib/tuile/component/text_view.rb', line 695

def half_page_rows = [viewport_rows / 2, 1].max

#handle_key?(key) ⇒ Boolean

Claims the scroll ladder: the arrows and j/k, PageUp/PageDown, Ctrl+U/Ctrl+D, Home/g/End/G. Acts on the key alone — a clamped scroll at either edge is still a handled key, and hand-feeding a key to an unfocused view scrolls it (dispatch gates on focus, this doesn't).

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


380
381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'lib/tuile/component/text_view.rb', line 380

def handle_key?(key)
  case key
  when *Keys::DOWN_ARROWS then move_scroll_top_row_by(1)
  when *Keys::UP_ARROWS   then move_scroll_top_row_by(-1)
  when Keys::PAGE_DOWN    then move_scroll_top_row_by(viewport_rows)
  when Keys::PAGE_UP      then move_scroll_top_row_by(-viewport_rows)
  when Keys::CTRL_D       then scroll_half_page_down
  when Keys::CTRL_U       then scroll_half_page_up
  when *Keys::HOMES, "g"  then move_scroll_top_row_to(0)
  when *Keys::ENDS_, "G"  then move_scroll_top_row_to(scroll_top_row_max)
  else return false
  end
  true
end

#handle_mouse_scroll?(event) ⇒ Boolean

Scrolls four rows a notch, and declines — so the notch bubbles to an ancestor scroller — once this view is at that end of its text.

@param event

Parameters:

Returns:

  • (Boolean)


399
400
401
402
403
404
405
406
407
# File 'lib/tuile/component/text_view.rb', line 399

def handle_mouse_scroll?(event)
  before = scroll_top_row
  case event.direction
  when :down then move_scroll_top_row_by(4)
  when :up   then move_scroll_top_row_by(-4)
  else return false
  end
  scroll_top_row != before
end

#insert(at, str) ⇒ void

This method returns an undefined value.

Inserts str at hard-line index at. Equivalent to replace(at...at, str) — a no-removal splice that grows the buffer by the parsed line count. at == hard-line count is allowed and appends at the end; for that case #append / #add_line are usually more idiomatic.

@param at — 0-based hard-line index in [0, hard-line count].

@param str — content to insert.

Parameters:



306
307
308
# File 'lib/tuile/component/text_view.rb', line 306

def insert(at, str)
  replace(at...at, str)
end

#move_scroll_top_row_by(delta) ⇒ void

This method returns an undefined value.

@param delta — negative scrolls up, positive scrolls down.

Parameters:

  • delta (Integer)


839
840
841
# File 'lib/tuile/component/text_view.rb', line 839

def move_scroll_top_row_by(delta)
  move_scroll_top_row_to(@scroll_top_row + delta)
end

#move_scroll_top_row_to(target) ⇒ void

This method returns an undefined value.

@param target — desired top line; clamped to [0, scroll_top_row_max].

Parameters:

  • target (Integer)


845
846
847
848
# File 'lib/tuile/component/text_view.rb', line 845

def move_scroll_top_row_to(target)
  clamped = target.clamp(0, scroll_top_row_max)
  self.scroll_top_row = clamped unless @scroll_top_row == clamped
end

#normalize_replace_range(range, size = @lines.size, what = "the buffer") ⇒ [Integer, Integer]

Validates and unpacks a #replace-style range argument into inclusive [from, to] line indices. An Integer n becomes [n, n] (which must point at an existing line — Integer is never insertion sugar). A Range is normalized for exclude_end?; to == from - 1 is a valid empty range (insertion at from), and from may equal size for end-insertion. Shared by #replace and Tuile::Component::TextView::Region#replace; size is the buffer or region line count, and what is the entity name woven into error messages.

@param range

@param size

@param what

Parameters:

  • range (::Range[untyped], Integer)
  • size (Integer) (defaults to: @lines.size)
  • what (String) (defaults to: "the buffer")

Returns:

  • ([Integer, Integer])


470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# File 'lib/tuile/component/text_view.rb', line 470

def normalize_replace_range(range, size = @lines.size, what = "the buffer")
  case range
  when Integer
    from = to = range
  when Range
    from = range.begin
    raw_end = range.end
    unless from.is_a?(Integer) && raw_end.is_a?(Integer)
      raise TypeError, "range endpoints must be Integers, got #{range.inspect}"
    end

    to = range.exclude_end? ? raw_end - 1 : raw_end
  else
    raise TypeError, "expected Range or Integer, got #{range.inspect}"
  end
  raise ArgumentError, "range endpoints must not be negative, got #{range.inspect}" if from.negative?
  if from > size || to >= size
    raise ArgumentError, "range #{range.inspect} out of bounds for #{what} (#{size} hard line(s))"
  end
  raise ArgumentError, "range #{range.inspect} is malformed (end more than one below begin)" if to < from - 1

  [from, to]
end

#overflows_at_full_width? ⇒ Boolean

Whether the text, wrapped at Tuile::Component#rect.width, has more rows than the viewport. With the bar hidden @rows already is that wrap; with it shown the lines are re-wrapped at the full width, stopping at the first row past the viewport — so the cost is bounded by what fits on screen, and a buffer with more hard lines than rows costs nothing at all.

Returns:

  • (Boolean)


918
919
920
921
922
923
924
925
926
927
928
929
# File 'lib/tuile/component/text_view.rb', line 918

def overflows_at_full_width?
  return @rows.size > viewport_rows unless @auto_scrollbar_shown
  return true if @lines.size > viewport_rows

  width = rect.width
  rows = 0
  @lines.each do |line|
    rows += line.empty? ? 1 : line.wrap(width).size
    return true if rows > viewport_rows
  end
  false
end

#paintable_row(index) ⇒ StyledString

@param index — 0-based index into @rows.

@return — every column of Tuile::Component#rect the child bar does not own: all of them with the bar hidden, one fewer with it visible. Rows arrive padded to #wrap_width from #rewrap, so only the blank #scrollbar_columns reserves is added here.

Parameters:

  • index (Integer)

Returns:



936
937
938
939
940
941
942
# File 'lib/tuile/component/text_view.rb', line 936

def paintable_row(index)
  row = @rows[index] || @blank_row
  blanks = scrollbar_columns - 1
  return row unless blanks.positive?

  row + StyledString.plain(" " * blanks)
end

#pop_line ⇒ StyledString

Pops the last line, the corresponding cache entry, and the rows that line contributed. Returns the popped line.

Returns:



749
750
751
752
753
# File 'lib/tuile/component/text_view.rb', line 749

def pop_line
  n = @line_wrap_counts.pop
  n.times { @rows.pop }
  @lines.pop
end

#push_line(line, width) ⇒ void

This method returns an undefined value.

Appends line to the tail of @lines, updating the wrap-count cache and @rows in lockstep.

@param line

@param width

Parameters:



739
740
741
742
743
744
# File 'lib/tuile/component/text_view.rb', line 739

def push_line(line, width)
  wrapped, n = wrap_line(line, width)
  @lines << line
  @line_wrap_counts << n
  @rows.concat(wrapped)
end

#region_start_index(region) ⇒ Integer

Hard-line index where region begins in @lines — derived by summing the line counts of all regions that precede it.

@param region

Parameters:

Returns:

  • (Integer)


498
499
500
501
502
503
504
505
# File 'lib/tuile/component/text_view.rb', line 498

def region_start_index(region)
  idx = @regions.index(region)
  raise "region not found in view" unless idx

  sum = 0
  idx.times { |i| sum += @regions[i].line_count }
  sum
end

#relayout ⇒ void

This method returns an undefined value.

Places the scrollbar, the view's one child, and pushes the two numbers it paints a handle from. No rect.empty? guard: a container assigns every child a rect on every pass (D_empty_ancestor), and #scrollbar_visible? already answers false for an empty rect.

The rows are settled first, before the bar is placed, so the layout mark a scroll clamp makes is dropped rather than costing a second pass: a rewrap if #wrap_width moved — on a height change too, since an empty rect hides a :visible bar — then the :auto bar, then the #auto_scroll pin, which a taller viewport moves. Every mutator already marks, for the bar's row_count, so this runs after each one.



446
447
448
449
450
451
452
453
# File 'lib/tuile/component/text_view.rb', line 446

def relayout
  rewrap unless @wrapped_at == wrap_width
  sync_auto_scrollbar
  update_scroll_top_row_if_auto_scroll
  @scrollbar.rect = scrollbar_visible? ? Rect.new(rect.width - 1, 0, 1, rect.height) : Rect.new(0, 0, 0, 0)
  @scrollbar.row_count = @rows.size
  @scrollbar.scroll_top_row = @scroll_top_row
end

#remove_last_n_from_region(region, n) ⇒ void

This method returns an undefined value.

Drops the last n hard lines from region's tail via #splice_lines. n is clamped to the region's current line count; callers guarantee n > 0 and the region is non-empty (the Tuile::Component::TextView::Region#remove_last_n_lines guard handles the no-op cases).

@param region

@param n

Parameters:



612
613
614
615
616
617
618
619
620
# File 'lib/tuile/component/text_view.rb', line 612

def remove_last_n_from_region(region, n)
  screen.check_locked
  to_drop = [n, region.line_count].min
  start = region_start_index(region)
  drop_from = start + region.line_count - to_drop
  splice_lines(drop_from, to_drop, [])
  region.send(:line_count=, region.line_count - to_drop)
  content_changed
end

#remove_last_n_lines(n) ⇒ void

This method returns an undefined value.

Drops the last n hard lines from the buffer — the inverse of building up a tail with #append / #add_line, so a caller can remove then append to rewrite a damaged tail in place. Operates on hard lines (the \n-delimited entries), not wrapped rows. n == 0 and the empty buffer are no-ops; n >= hard-line count empties the buffer.

@param n — number of hard lines to drop; must be >= 0.

Parameters:

  • n (Integer)


235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/tuile/component/text_view.rb', line 235

def remove_last_n_lines(n)
  raise TypeError, "expected Integer, got #{n.inspect}" unless n.is_a?(Integer)
  raise ArgumentError, "n must not be negative, got #{n}" if n.negative?

  screen.check_locked
  return if n.zero? || empty?

  to_drop = [n, @lines.size].min
  to_drop.times { pop_line }

  # Cascade-shrink regions from the spatial tail. The tail region
  # gives up lines first; if more are still owed (because the tail
  # was shorter than `to_drop`), earlier regions shrink in turn.
  remaining = to_drop
  @regions.reverse_each do |region|
    break if remaining.zero?

    take = [remaining, region.line_count].min
    region.send(:line_count=, region.line_count - take)
    remaining -= take
  end

  content_changed
end

#remove_region(region) ⇒ void

This method returns an undefined value.

Drops region from @regions: its hard lines are removed via #splice_lines, the handle is detached, and the always-one default is restored if the removal would have left zero regions. Skips the rewrap / invalidate work when the region was empty (the buffer didn't change), but always detaches.

@param region

Parameters:



629
630
631
632
633
634
635
636
637
638
639
640
641
642
# File 'lib/tuile/component/text_view.rb', line 629

def remove_region(region)
  screen.check_locked
  had_lines = region.line_count.positive?
  if had_lines
    start = region_start_index(region)
    splice_lines(start, region.line_count, [])
  end
  @regions.delete(region)
  region.send(:detach!)
  @regions << Region.send(:new, self) if @regions.empty?
  return unless had_lines

  content_changed
end

#repaint(canvas) ⇒ void

This method returns an undefined value.

Paints the text into Tuile::Component#rect, out to the blank column that #scrollbar_columns reserves. The bar's own column is not painted here — it is the child VerticalScrollBar's, placed by #relayout.

Skips the Tuile::Component#repaint default's auto-clear: every row is painted explicitly (with padded blanks past the last line), so the "fully draw over your rect" contract is met without an upfront wipe. Tuile::Component#invalidate_children is the half that cannot be skipped with it, or the bar goes stale under an ancestor's clear (D_repaint_cascade). Rows go through Tuile::Canvas#set_text, so content and blank rows inherit Tuile::ComponentBackground#effective (a Tuile::Component#bg_color set here or on an ancestor).

@param canvas — see Tuile::Component#repaint.

Parameters:



423
424
425
426
427
428
429
430
# File 'lib/tuile/component/text_view.rb', line 423

def repaint(canvas)
  return if rect.empty?

  invalidate_children
  (0...rect.height).each do |row|
    canvas.set_text(0, row, paintable_row(row + @scroll_top_row))
  end
end

#replace(range, str) ⇒ void

This method returns an undefined value.

Replaces a contiguous range of hard lines with the parsed content of str (parsed like #text=: String → StyledString.parse, nil → empty, so nil deletes the range). Embedded "\n" yields multiple hard lines, so one replace can grow or shrink the buffer. range selects which hard lines to swap out:

  • an Integer n is shorthand for n..n (replace one existing line);
  • a non-empty Range replaces those lines;
  • an empty Range (e.g. 2...2, or size...size at the end) is insertion at that position — nothing removed. #insert aliases this.

Splices in place — only the affected slice of the row buffer is touched, no preceding lines re-wrapped (cost O(from + length + new content)). A no-op when the replacement equals the covered range, so replace(n...n, "") is cheap.

@param range — hard-line indices to replace.

@param str — replacement content.

Parameters:

  • range (::Range[untyped], Integer)
  • str (String, StyledString, nil)


284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/tuile/component/text_view.rb', line 284

def replace(range, str)
  screen.check_locked
  from, to = normalize_replace_range(range)

  parsed = StyledString.parse(str)
  new_lines = parsed.empty? ? [] : parsed.lines
  length = to - from + 1
  return if new_lines == @lines[from, length]

  splice_lines(from, length, new_lines)
  update_region_counts(from, length, new_lines.size)
  content_changed
end

#replace_in_region(region, range, str) ⇒ void

This method returns an undefined value.

Region-scoped #replace. Validates range against region.line_count, translates region-relative indices to absolute buffer indices, splices, and updates the region's count.

@param region

@param range

@param str

Parameters:



553
554
555
556
557
558
559
560
561
562
563
564
565
566
# File 'lib/tuile/component/text_view.rb', line 553

def replace_in_region(region, range, str)
  screen.check_locked
  from, to = normalize_replace_range(range, region.line_count, "the region")
  parsed = StyledString.parse(str)
  new_lines = parsed.empty? ? [] : parsed.lines
  start = region_start_index(region)
  abs_from = start + from
  length = to - from + 1
  return if new_lines == @lines[abs_from, length]

  splice_lines(abs_from, length, new_lines)
  region.send(:line_count=, region.line_count - length + new_lines.size)
  content_changed
end

#rewrap ⇒ void

This method returns an undefined value.

Full rebuild of @rows and @line_wrap_counts from @lines. Called when wrap width changes (which invalidates every cached row count) and from #text= (which replaces the whole logical model). Mid-buffer mutators splice incrementally via #splice_lines and do not go through here. Clamps @scroll_top_row if the new line count puts it out of range.



705
706
707
708
709
710
711
712
713
714
715
716
717
# File 'lib/tuile/component/text_view.rb', line 705

def rewrap
  width = wrap_width
  @wrapped_at = width
  @blank_row = StyledString::EMPTY.ljust(width)
  @rows = []
  @line_wrap_counts = []
  @lines.each do |line|
    wrapped, n = wrap_line(line, width)
    @rows.concat(wrapped)
    @line_wrap_counts << n
  end
  @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
end

#row_offset_at(idx) ⇒ Integer

@param idx

@return — the @rows index where the line at @lines[idx] starts. O(idx) integer adds via the wrap-count cache.

Parameters:

  • idx (Integer)

Returns:

  • (Integer)


789
790
791
792
793
# File 'lib/tuile/component/text_view.rb', line 789

def row_offset_at(idx)
  return 0 if idx.zero?

  @line_wrap_counts[0, idx].sum
end

#scroll_half_page_down ⇒ void

This method returns an undefined value.

The Ctrl+D twin of #scroll_half_page_up, clamped at the last row — arriving there re-arms #following?.



368
# File 'lib/tuile/component/text_view.rb', line 368

def scroll_half_page_down = move_scroll_top_row_by(half_page_rows)

#scroll_half_page_up ⇒ void

This method returns an undefined value.

Scrolls up half a viewport (rect.height / 2, at least one row), clamped at the top — unlike #scroll_top_row=, which raises below 0. What Ctrl+U does, minus the focus: dispatch delivers keys only along the focus chain, so this is what a host with focus elsewhere — a chat transcript under an input field — calls instead of forwarding a synthetic keystroke that would lie about where focus is.



363
# File 'lib/tuile/component/text_view.rb', line 363

def scroll_half_page_up = move_scroll_top_row_by(-half_page_rows)

#scroll_top_row_max ⇒ Integer

@return — the max value of #scroll_top_row for scroll-key clamping.

Returns:

  • (Integer)


691
# File 'lib/tuile/component/text_view.rb', line 691

def scroll_top_row_max = (@rows.size - viewport_rows).clamp(0, nil)

#scrollbar_columns ⇒ Integer

Columns the scrollbar claims off the right edge: the bar itself plus one blank column, so a row wrapping at the full width doesn't run into █ (…to show the█). 0 when the bar is hidden. Only the blank is this view's to paint — #paintable_row emits it, the child VerticalScrollBar owns the column beyond it.

The blank is dropped below width 3, where reserving it would leave no column for text at all — that keeps row and bar together covering exactly Tuile::Component#rect.width columns at every width.

@return — 0, 1 or 2.

Returns:

  • (Integer)


831
832
833
834
835
# File 'lib/tuile/component/text_view.rb', line 831

def scrollbar_columns
  return 0 unless scrollbar_visible?

  rect.width >= 3 ? 2 : 1
end

#scrollbar_visible? ⇒ Boolean

Returns:

  • (Boolean)


885
886
887
888
889
890
891
892
893
# File 'lib/tuile/component/text_view.rb', line 885

def scrollbar_visible?
  return false if rect.empty?

  case @scrollbar_visibility
  when :visible then true
  when :auto then @auto_scrollbar_shown
  else false
  end
end

#set_region_text(region, value) ⇒ void

This method returns an undefined value.

Replaces all of region's hard lines with the parsed content of value. Symmetric with #text=, scoped to one region. Empty/nil content empties the region (no visible blank line). Works on already-empty regions (insertion at the region's position).

@param region

@param value

Parameters:



533
534
535
536
537
538
539
540
541
542
543
544
# File 'lib/tuile/component/text_view.rb', line 533

def set_region_text(region, value)
  screen.check_locked
  parsed = StyledString.parse(value)
  new_lines = parsed.empty? ? [] : parsed.lines
  start = region_start_index(region)
  old_count = region.line_count
  return if new_lines == @lines[start, old_count]

  splice_lines(start, old_count, new_lines)
  region.send(:line_count=, new_lines.size)
  content_changed
end

#splice_lines(from, count, new_lines) ⇒ void

This method returns an undefined value.

Splices new_lines into the buffer in place of the count lines starting at index from. Updates @lines, @line_wrap_counts, and @rows consistently. The starting row offset is computed in O(from) integer adds via the cache — no wraps of preceding lines. Wraps are done only for the new content, so total cost is O(from + count + new_lines.sum(&:display_width)).

@param from

@param count — number of existing lines to remove.

@param new_lines

Parameters:

  • from (Integer)
  • count (Integer)
  • new_lines (::Array[StyledString])


766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
# File 'lib/tuile/component/text_view.rb', line 766

def splice_lines(from, count, new_lines)
  width = wrap_width
  row_start = row_offset_at(from)
  old_row_count = @line_wrap_counts[from, count].sum

  @lines[from, count] = new_lines

  new_rows = []
  new_counts = []
  new_lines.each do |line|
    wrapped, n = wrap_line(line, width)
    new_rows.concat(wrapped)
    new_counts << n
  end

  @line_wrap_counts[from, count] = new_counts
  @rows[row_start, old_row_count] = new_rows
end

#sync_auto_scrollbar ⇒ void

This method returns an undefined value.

The sole writer of @auto_scrollbar_shown: shows the bar iff the text overflows the viewport at the full width, and rewraps when that flips. Deciding at the full width alone is what settles it in one step — the answer does not depend on the width the bar leaves, so showing the bar can't flip it back. Idempotent.



901
902
903
904
905
906
907
908
909
910
# File 'lib/tuile/component/text_view.rb', line 901

def sync_auto_scrollbar
  return unless @scrollbar_visibility == :auto

  shown = !rect.empty? && overflows_at_full_width?
  return if shown == @auto_scrollbar_shown

  @auto_scrollbar_shown = shown
  rewrap
  invalidate
end

#tab_stop? ⇒ Boolean

Returns:

  • (Boolean)


372
# File 'lib/tuile/component/text_view.rb', line 372

def tab_stop? = true

#text ⇒ StyledString

@return — the current text (empty by default). Rebuilt lazily on the first read after a mutation (O(total spans)), then cached — repeated reads are O(1).

Returns:



79
80
81
# File 'lib/tuile/component/text_view.rb', line 79

def text
  @text ||= build_text
end

#text=(value) ⇒ void

This method returns an undefined value.

Replaces the text. Embedded \n characters become hard line breaks. A String is parsed via StyledString.parse (so embedded ANSI is honored); a StyledString is used as-is; nil is coerced to an empty StyledString.

Detaches every existing Region (including the original default) and installs a fresh internal default region that owns all the new hard lines. Any handle the caller was holding becomes detached and raises on use — see Tuile::Component::TextView::Region#attached?. The no-op short-circuit (matching value, same StyledString) preserves existing regions.

@param value

Parameters:



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/tuile/component/text_view.rb', line 115

def text=(value)
  new_text = StyledString.parse(value)
  content_unchanged = text == new_text

  # `text=` is a structural reset: even when the new content matches
  # the old, existing region handles must die — the caller said "set
  # the text," not "merge with what's there." The unchanged-content
  # path still skips the expensive rewrap / invalidate work.
  @text = new_text
  @lines = new_text.empty? ? [] : new_text.lines
  @regions.each { |r| r.send(:detach!) }
  @regions = [Region.send(:new, self, @lines.size)]
  return if content_unchanged

  rewrap
  update_scroll_top_row_if_auto_scroll
  invalidate
  invalidate_layout
end

#text_for_region(region) ⇒ StyledString

Joined StyledString of the hard lines that region owns. Mirrors #text but scoped to one region.

@param region

Parameters:

Returns:



511
512
513
514
515
516
517
518
519
520
521
522
523
524
# File 'lib/tuile/component/text_view.rb', line 511

def text_for_region(region)
  start = region_start_index(region)
  count = region.line_count
  return StyledString::EMPTY if count.zero?
  return @lines[start] if count == 1

  newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
  spans = []
  count.times do |i|
    spans << newline if i.positive?
    spans.concat(@lines[start + i].spans)
  end
  StyledString.new(spans)
end

#update_region_counts(from, removed_count, added_count) ⇒ void

This method returns an undefined value.

Adjusts region line counts after a @lines splice that removed removed_count lines at from and inserted added_count. Subtracts each region's overlap with the removed range, then credits the added lines to the first region that lost lines. Pure insertions have no such region — they credit the latest region starting at from, matching #append's spatial-tail routing (past-the-end falls back to the tail).

@param from

@param removed_count

@param added_count

Parameters:

  • from (Integer)
  • removed_count (Integer)
  • added_count (Integer)


654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
# File 'lib/tuile/component/text_view.rb', line 654

def update_region_counts(from, removed_count, added_count)
  target = nil
  pos = 0
  @regions.each do |region|
    original_count = region.line_count
    overlap_start = [from, pos].max
    overlap_end = [from + removed_count, pos + original_count].min
    overlap = overlap_end - overlap_start
    if overlap.positive?
      region.send(:line_count=, original_count - overlap)
      target ||= region
    end
    pos += original_count
  end
  return if added_count.zero?

  if target.nil?
    pos = 0
    @regions.each do |region|
      region_end_exclusive = pos + region.line_count
      if from == pos
        target = region
      elsif from < region_end_exclusive
        target = region
        break
      end
      pos = region_end_exclusive
    end
    target ||= @regions.last
  end
  target.send(:line_count=, target.line_count + added_count)
end

#update_scroll_top_row_if_auto_scroll ⇒ void

This method returns an undefined value.

Gated on #following?: once the user scrolls up off the bottom the viewport pin is skipped, so reading older content is not interrupted by incoming lines. #scroll_top_row= re-arms @follow when the viewport returns to the bottom.



873
874
875
876
877
878
# File 'lib/tuile/component/text_view.rb', line 873

def update_scroll_top_row_if_auto_scroll
  return unless @auto_scroll && @follow

  target = (@rows.size - viewport_rows).clamp(0, nil)
  self.scroll_top_row = target if @scroll_top_row != target
end

#viewport_rows ⇒ Integer

@return — number of visible lines.

Returns:

  • (Integer)


688
# File 'lib/tuile/component/text_view.rb', line 688

def viewport_rows = rect.height

#wrap_line(line, width) ⇒ [::Array[StyledString], Integer]

Wraps line at width and returns the padded rows alongside the row count. Empty lines (e.g. from a "\n\n" run) and degenerate width <= 0 both emit a single @blank_row row, matching what @text.wrap(width).map { |l| l.ljust(width) } would have produced.

@param line

@param width

Parameters:

Returns:



727
728
729
730
731
732
# File 'lib/tuile/component/text_view.rb', line 727

def wrap_line(line, width)
  return [[@blank_row], 1] if line.empty? || width <= 0

  wrapped = line.wrap(width)
  [wrapped.map { |row| row.ljust(width) }, wrapped.size]
end

#wrap_width ⇒ Integer

@return — column width available for wrapped text — viewport width minus #scrollbar_columns. 0 when Tuile::Component#rect's width is non-positive, which yields a degenerate "no wrap" result.

Returns:

  • (Integer)


815
816
817
818
819
# File 'lib/tuile/component/text_view.rb', line 815

def wrap_width
  return 0 if rect.width <= 0

  rect.width - scrollbar_columns
end