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-shaped content via #text=) and List (scroll keys, optional scrollbar, auto-scroll).

Text is modeled as a StyledString: embedded \n are hard line breaks, lines wider than the viewport are word-wrapped via StyledString#wrap (style spans are preserved across wrap boundaries — unlike the older ANSI-as-bytes wrapping, color does not get dropped on continuation rows). #text= accepts a String (parsed via StyledString.parse, so embedded ANSI is honored) or a StyledString directly; #text always returns the StyledString.

For incremental updates pick the right primitive: #append (aliased as <<) is verbatim and stream-friendly — chunks are concatenated straight onto the buffer, with embedded \n becoming hard breaks. #add_line is the "log entry" convenience — it starts the content on a fresh line by inserting a leading \n when the buffer is non-empty. #remove_last_n_lines pops hard lines back off the tail — the inverse of building up a region with #append / #add_line, so a caller streaming reformattable content (e.g. partially-rendered Markdown that may need to retract its last paragraph) can replace the tail without rewriting the whole text. Turn on #auto_scroll to keep the latest content in view.

TextView is meant to be the content of a Window — focus indication and keyboard-hint surfacing rely on the surrounding window chrome.

Defined Under Namespace

Classes: Region

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeTextView

Returns a new instance of TextView.



32
33
34
35
36
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
# File 'lib/tuile/component/text_view.rb', line 32

def initialize
  super
  # Three parallel structures, kept in lockstep by every mutator:
  # `@hard_lines` is the logical model (one entry per `\n`-delimited
  # line, width-independent); `@physical_lines` is the rendered view
  # (each hard line word-wrapped to `wrap_width` and padded with
  # trailing blanks, so painting a row is a lookup); and
  # `@hard_line_wrap_counts` is an Integer-per-hard-line cache of
  # how many physical rows each hard line occupies, so a mid-buffer
  # splice can find its starting physical-row offset without
  # re-wrapping every preceding hard line.
  #
  # Invariants:
  # - `@hard_line_wrap_counts.size == @hard_lines.size`
  # - `@hard_line_wrap_counts.sum == @physical_lines.size`
  # A full rebuild ({#rewrap}) happens on {#text=} and width changes;
  # other mutators splice incrementally.
  @hard_lines = []
  @physical_lines = []
  @hard_line_wrap_counts = []
  @text = StyledString::EMPTY
  @blank_line = StyledString::EMPTY
  @top_line = 0
  @auto_scroll = false
  @follow = true
  @scrollbar_visibility = :gone
  # The view always has at least one region — an implicit default. It
  # owns whatever hard lines exist that no later region claims. App
  # code that never calls {#create_region} sees the same behavior as
  # before (a single region containing everything); apps that do call
  # {#create_region} stack additional regions at the spatial tail.
  @regions = [Region.send(:new, self)]
end

Instance Attribute Details

#auto_scrollBoolean

@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)


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

def auto_scroll
  @auto_scroll
end

#scrollbar_visibilitySymbol

@return:gone or :visible.

Returns:

  • (Symbol)


81
82
83
# File 'lib/tuile/component/text_view.rb', line 81

def scrollbar_visibility
  @scrollbar_visibility
end

#top_lineInteger

@return — index of the first visible physical line.

Returns:

  • (Integer)


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

def top_line
  @top_line
end

Instance Method Details

#<<(str) ⇒ self

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

@param str

Parameters:

Returns:

  • (self)


198
199
200
201
# File 'lib/tuile/component/text_view.rb', line 198

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:



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

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 characters become hard line breaks; otherwise the text is concatenated onto the current last hard line. Designed for streaming use (e.g. an LLM chat window receiving partial messages — feed each chunk straight in). Accepts the same input forms as #text=; empty/nil input is a no-op.

For the "add an entry on a new line" pattern use #add_line.

Cost is O(appended + width-of-current-last-hard-line) — the previously last hard line is re-wrapped (because the extension may cause it to wrap differently), any additional hard lines created by embedded \n are wrapped fresh. The cached #text is invalidated and rebuilt on demand.

@param str

Parameters:



162
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
# File 'lib/tuile/component/text_view.rb', line 162

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 { |hl| push_hard_line(hl, width) }
    added = new_segments.size
  else
    extension = new_segments.first
    unless extension.empty?
      old_last = pop_hard_line
      push_hard_line(old_last + extension, width)
    end
    new_segments[1..].each { |hl| push_hard_line(hl, width) }
    added = new_segments.size - 1
  end

  tail_region.send(:line_count=, tail_region.line_count + added)
  @text = nil
  update_top_line_if_auto_scroll
  invalidate
end

#append_to_region(region, str) ⇒ void

This method returns an undefined value.

Verbatim append into region.

@param region

@param str

Parameters:



567
568
569
570
571
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
# File 'lib/tuile/component/text_view.rb', line 567

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_hard_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_hard_lines(last_idx + 1, 0, rest)
    else
      extended = @hard_lines[last_idx] + extension
      splice_hard_lines(last_idx, 1, [extended, *rest])
    end
    region.send(:line_count=, region.line_count + rest.size)
  end
  @text = nil
  @top_line = top_line_max if @top_line > top_line_max
  update_top_line_if_auto_scroll
  invalidate
end

#at_bottom?Boolean

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

Returns:

  • (Boolean)


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

def at_bottom? = @top_line == top_line_max

#build_textStyledString

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

Returns:



806
807
808
809
810
811
812
813
814
815
816
817
# File 'lib/tuile/component/text_view.rb', line 806

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

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

#clearvoid

This method returns an undefined value.

Clears the text. Equivalent to text = "".



343
344
345
# File 'lib/tuile/component/text_view.rb', line 343

def clear
  self.text = StyledString::EMPTY
end

#create_regionRegion

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:



138
139
140
141
142
# File 'lib/tuile/component/text_view.rb', line 138

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)


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

def empty? = @hard_lines.empty?

#focusable?Boolean

Returns:

  • (Boolean)


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

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)


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

def following? = @follow

#handle_key(key) ⇒ Boolean

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'lib/tuile/component/text_view.rb', line 387

def handle_key(key)
  return false unless active?
  return true if super

  case key
  when *Keys::DOWN_ARROWS then move_top_line_by(1)
  when *Keys::UP_ARROWS   then move_top_line_by(-1)
  when Keys::PAGE_DOWN    then move_top_line_by(viewport_lines)
  when Keys::PAGE_UP      then move_top_line_by(-viewport_lines)
  when Keys::CTRL_D       then move_top_line_by(viewport_lines / 2)
  when Keys::CTRL_U       then move_top_line_by(-viewport_lines / 2)
  when *Keys::HOMES, "g"  then move_top_line_to(0)
  when *Keys::ENDS_, "G"  then move_top_line_to(top_line_max)
  else return false
  end
  true
end

#handle_mouse(event) ⇒ void

This method returns an undefined value.

@param event

Parameters:



407
408
409
410
411
412
413
# File 'lib/tuile/component/text_view.rb', line 407

def handle_mouse(event)
  super
  case event.button
  when :scroll_down then move_top_line_by(4)
  when :scroll_up   then move_top_line_by(-4)
  end
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:



337
338
339
# File 'lib/tuile/component/text_view.rb', line 337

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

#move_top_line_by(delta) ⇒ void

This method returns an undefined value.

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

Parameters:

  • delta (Integer)


830
831
832
# File 'lib/tuile/component/text_view.rb', line 830

def move_top_line_by(delta)
  move_top_line_to(@top_line + delta)
end

#move_top_line_to(target) ⇒ void

This method returns an undefined value.

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

Parameters:

  • target (Integer)


836
837
838
839
# File 'lib/tuile/component/text_view.rb', line 836

def move_top_line_to(target)
  clamped = target.clamp(0, top_line_max)
  self.top_line = clamped unless @top_line == clamped
end

#normalize_replace_range(range, size = @hard_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: @hard_lines.size)
  • what (String) (defaults to: "the buffer")

Returns:

  • ([Integer, Integer])


459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
# File 'lib/tuile/component/text_view.rb', line 459

def normalize_replace_range(range, size = @hard_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

#on_width_changedvoid

This method returns an undefined value.

Rewraps the text on width changes. Wrap width depends on Tuile::Component#rect.width and the scrollbar gutter, both of which trigger this hook.



439
440
441
442
# File 'lib/tuile/component/text_view.rb', line 439

def on_width_changed
  super
  rewrap
end

#pad_to(line, width) ⇒ StyledString

Pads line with trailing default-styled spaces out to width display columns. Callers rely on StyledString#wrap having already constrained the line to <= width, so no truncation is performed. width <= 0 returns StyledString::EMPTY to handle the degenerate wrap_width == 0 case (rect.width == 1 with scrollbar).

@param line

@param width

Parameters:

Returns:



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

def pad_to(line, width)
  return StyledString::EMPTY if width <= 0

  diff = width - line.display_width
  return line if diff <= 0

  line + StyledString.plain(" " * diff)
end

#paintable_line(index, row_in_viewport, scrollbar) ⇒ StyledString

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

@param row_in_viewport — 0-based row within the viewport.

@param scrollbar

@return — paintable line exactly rect.width columns wide. Body lines come pre-padded from #rewrap, so this reduces to a lookup plus a concat of the scrollbar glyph when one is present.

Parameters:

Returns:



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

def paintable_line(index, row_in_viewport, scrollbar)
  line = @physical_lines[index] || @blank_line
  return line unless scrollbar

  line + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
end

#phys_offset_at(idx) ⇒ Integer

@param idx

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

Parameters:

  • idx (Integer)

Returns:

  • (Integer)


796
797
798
799
800
# File 'lib/tuile/component/text_view.rb', line 796

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

  @hard_line_wrap_counts[0, idx].sum
end

#pop_hard_lineStyledString

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

Returns:



756
757
758
759
760
# File 'lib/tuile/component/text_view.rb', line 756

def pop_hard_line
  n = @hard_line_wrap_counts.pop
  n.times { @physical_lines.pop }
  @hard_lines.pop
end

#push_hard_line(hard_line, width) ⇒ void

This method returns an undefined value.

Appends hard_line to the tail of @hard_lines, updating the wrap-count cache and @physical_lines in lockstep.

@param hard_line

@param width

Parameters:



745
746
747
748
749
750
# File 'lib/tuile/component/text_view.rb', line 745

def push_hard_line(hard_line, width)
  rows, n = wrap_hard_line(hard_line, width)
  @hard_lines << hard_line
  @hard_line_wrap_counts << n
  @physical_lines.concat(rows)
end

#region_start_index(region) ⇒ Integer

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

@param region

Parameters:

Returns:

  • (Integer)


487
488
489
490
491
492
493
494
# File 'lib/tuile/component/text_view.rb', line 487

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

#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_hard_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:



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

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_hard_lines(drop_from, to_drop, [])
  region.send(:line_count=, region.line_count - to_drop)
  @text = nil
  @top_line = top_line_max if @top_line > top_line_max
  update_top_line_if_auto_scroll
  invalidate
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 region with #append / #add_line: a caller streaming partially-rendered content whose tail must occasionally be retracted (e.g. Markdown-to-ANSI where a new token reformats the table being built) can call remove_last_n_lines(k) followed by append(new_tail) to replace the damaged region in place.

n == 0 and the empty-buffer case are no-ops (no invalidation). n >= hard-line count empties the buffer.

Operates on hard lines (the \n-delimited entries the buffer stores), not on wrapped physical rows — same granularity as #add_line. Cost is O(rendered-rows of the popped lines).

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

Parameters:

  • n (Integer)


241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/tuile/component/text_view.rb', line 241

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, @hard_lines.size].min
  to_drop.times { pop_hard_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

  @text = nil
  @top_line = top_line_max if @top_line > top_line_max
  update_top_line_if_auto_scroll
  invalidate
end

#remove_region(region) ⇒ void

This method returns an undefined value.

Drops region from @regions: its hard lines are removed via #splice_hard_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:



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

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

  @text = nil
  @top_line = top_line_max if @top_line > top_line_max
  update_top_line_if_auto_scroll
  invalidate
end

#repaintvoid

This method returns an undefined value.

Paints the text into Tuile::Component#rect.

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.



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

def repaint
  return if rect.empty?

  scrollbar = if scrollbar_visible?
                VerticalScrollBar.new(rect.height, line_count: @physical_lines.size, top_line: @top_line)
              end
  (0...rect.height).each do |row|
    line = paintable_line(row + @top_line, row, scrollbar)
    screen.buffer.set_line(rect.left, rect.top + row, line)
  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. The replacement is parsed exactly like #text= and #append: a String is run through StyledString.parse (so embedded ANSI is honored), a StyledString is used as-is, nil behaves like an empty replacement (the range is deleted). Embedded "\n" in the replacement produces multiple hard lines, so a single 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 — n must be in [0, hard-line count));
  • a non-empty Range of hard-line indices replaces those lines;
  • an empty Range (e.g. 2...2, or the canonical end-insertion hard_lines.size...hard_lines.size) is insertion at that position — no lines are removed. #insert is a thin alias for this case.

Endpoints must be non-negative integers; begin may equal hard-line count (insertion at the end), end may not exceed hard-line count - 1. nil endpoints (beginless / endless ranges) are not accepted.

Cost is roughly O(from + length + new content): the splice updates only the affected slice of the physical-row buffer, using the per-hard-line wrap-count cache to locate the starting offset without re-wrapping preceding lines. Lines outside the splice are never re-wrapped. #top_line is clamped if the new line count puts it past the end; #auto_scroll pins it to the bottom as usual. The call is a no-op (no invalidation) when the parsed replacement equals the covered range (vacuously true for an empty range plus empty replacement, so replace(n...n, "") is a cheap no-op).

@param range — hard-line indices to replace.

@param str — replacement content.

Parameters:

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


312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/tuile/component/text_view.rb', line 312

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

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

  splice_hard_lines(from, length, new_hard_lines)
  update_region_counts(from, length, new_hard_lines.size)
  @text = nil
  @top_line = top_line_max if @top_line > top_line_max
  update_top_line_if_auto_scroll
  invalidate
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:



545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
# File 'lib/tuile/component/text_view.rb', line 545

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_hard_lines = parsed.empty? ? [] : parsed.lines
  start = region_start_index(region)
  abs_from = start + from
  length = to - from + 1
  return if new_hard_lines == @hard_lines[abs_from, length]

  splice_hard_lines(abs_from, length, new_hard_lines)
  region.send(:line_count=, region.line_count - length + new_hard_lines.size)
  @text = nil
  @top_line = top_line_max if @top_line > top_line_max
  update_top_line_if_auto_scroll
  invalidate
end

#rewrapvoid

This method returns an undefined value.

Full rebuild of @physical_lines and @hard_line_wrap_counts from @hard_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_hard_lines and do not go through here. Clamps @top_line if the new line count puts it out of range.



712
713
714
715
716
717
718
719
720
721
722
723
# File 'lib/tuile/component/text_view.rb', line 712

def rewrap
  width = wrap_width
  @blank_line = pad_to(StyledString::EMPTY, width)
  @physical_lines = []
  @hard_line_wrap_counts = []
  @hard_lines.each do |hl|
    rows, n = wrap_hard_line(hl, width)
    @physical_lines.concat(rows)
    @hard_line_wrap_counts << n
  end
  @top_line = top_line_max if @top_line > top_line_max
end

#scrollbar_visible?Boolean

Returns:

  • (Boolean)


858
859
860
861
862
# File 'lib/tuile/component/text_view.rb', line 858

def scrollbar_visible?
  return false if rect.empty?

  @scrollbar_visibility == :visible
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:



522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
# File 'lib/tuile/component/text_view.rb', line 522

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 == @hard_lines[start, old_count]

  splice_hard_lines(start, old_count, new_lines)
  region.send(:line_count=, new_lines.size)
  @text = nil
  @top_line = top_line_max if @top_line > top_line_max
  update_top_line_if_auto_scroll
  invalidate
end

#splice_hard_lines(from, count, new_hard_lines) ⇒ void

This method returns an undefined value.

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

@param from

@param count — number of existing hard lines to remove.

@param new_hard_lines

Parameters:

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


773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
# File 'lib/tuile/component/text_view.rb', line 773

def splice_hard_lines(from, count, new_hard_lines)
  width = wrap_width
  phys_start = phys_offset_at(from)
  old_phys_count = @hard_line_wrap_counts[from, count].sum

  @hard_lines[from, count] = new_hard_lines

  new_rows = []
  new_counts = []
  new_hard_lines.each do |hl|
    rows, n = wrap_hard_line(hl, width)
    new_rows.concat(rows)
    new_counts << n
  end

  @hard_line_wrap_counts[from, count] = new_counts
  @physical_lines[phys_start, old_phys_count] = new_rows
end

#tab_stop?Boolean

Returns:

  • (Boolean)


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

def tab_stop? = true

#textStyledString

@return — the current text. Defaults to an empty StyledString. Internally the text is stored as an array of hard lines so #append can stay O(appended) instead of re-scanning the whole buffer; the joined StyledString returned here is reconstructed on first read after a mutation and cached, so repeated reads are O(1) but the first read after #append pays O(total spans).

Returns:



73
74
75
# File 'lib/tuile/component/text_view.rb', line 73

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:



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/tuile/component/text_view.rb', line 108

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
  @hard_lines = new_text.empty? ? [] : new_text.lines
  @regions.each { |r| r.send(:detach!) }
  @regions = [Region.send(:new, self, @hard_lines.size)]
  return if content_unchanged

  rewrap
  update_top_line_if_auto_scroll
  invalidate
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:



500
501
502
503
504
505
506
507
508
509
510
511
512
513
# File 'lib/tuile/component/text_view.rb', line 500

def text_for_region(region)
  start = region_start_index(region)
  count = region.line_count
  return StyledString::EMPTY if count.zero?
  return @hard_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(@hard_lines[start + i].spans)
  end
  StyledString.new(spans)
end

#top_line_maxInteger

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

Returns:

  • (Integer)


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

def top_line_max = (@physical_lines.size - viewport_lines).clamp(0, nil)

#update_region_counts(from, removed_count, added_count) ⇒ void

This method returns an undefined value.

Adjusts region line counts after a @hard_lines splice that removed removed_count lines at index from and inserted added_count in their place. Two passes:

  1. Subtract each region's overlap with the removed range (uses the original counts to compute positions). Remember the first region that lost lines — that's the natural home for the replacement content.
  2. Credit added_count to that region. For pure insertions (no removal), there's no "first overlapping region" to pick from; walk regions and credit the latest one starting at from (the boundary tiebreaker matches the spatial-tail-routing of #append). Past-the-end inserts fall back to the tail region.

@param from

@param removed_count

@param added_count

Parameters:

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


665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
# File 'lib/tuile/component/text_view.rb', line 665

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_top_line_if_auto_scrollvoid

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. #top_line= re-arms @follow when the viewport returns to the bottom.



846
847
848
849
850
851
# File 'lib/tuile/component/text_view.rb', line 846

def update_top_line_if_auto_scroll
  return unless @auto_scroll && @follow

  target = (@physical_lines.size - viewport_lines).clamp(0, nil)
  self.top_line = target if @top_line != target
end

#viewport_linesInteger

@return — number of visible lines.

Returns:

  • (Integer)


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

def viewport_lines = rect.height

#wrap_hard_line(hard_line, width) ⇒ [::Array[StyledString], Integer]

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

@param hard_line

@param width

Parameters:

Returns:



733
734
735
736
737
738
# File 'lib/tuile/component/text_view.rb', line 733

def wrap_hard_line(hard_line, width)
  return [[@blank_line], 1] if hard_line.empty? || width <= 0

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

#wrap_widthInteger

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

Returns:

  • (Integer)


822
823
824
825
826
# File 'lib/tuile/component/text_view.rb', line 822

def wrap_width
  return 0 if rect.width <= 0

  rect.width - (scrollbar_visible? ? 1 : 0)
end