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.

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.



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/tuile/component/text_view.rb', line 26

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 width changes;
  # other mutators splice incrementally.
  @lines = []
  @rows = []
  @line_wrap_counts = []
  @text = StyledString::EMPTY
  @blank_row = StyledString::EMPTY
  @scroll_top_row = 0
  @auto_scroll = false
  @follow = true
  @scrollbar_visibility = :gone
  # Always ≥1 region; the implicit default owns any hard lines no
  # app-created region claims. See {Region}.
  @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)


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

def auto_scroll
  @auto_scroll
end

#scroll_top_rowInteger

@return — index of the first visible row.

Returns:

  • (Integer)


64
65
66
# File 'lib/tuile/component/text_view.rb', line 64

def scroll_top_row
  @scroll_top_row
end

#scrollbar_visibilitySymbol

@return:gone or :visible.

Returns:

  • (Symbol)


67
68
69
# File 'lib/tuile/component/text_view.rb', line 67

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)


177
178
179
180
# File 'lib/tuile/component/text_view.rb', line 177

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:



190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/tuile/component/text_view.rb', line 190

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:



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/tuile/component/text_view.rb', line 141

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
end

#append_to_region(region, str) ⇒ void

This method returns an undefined value.

Verbatim append into region.

@param region

@param str

Parameters:



533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
# File 'lib/tuile/component/text_view.rb', line 533

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


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

def at_bottom? = @scroll_top_row == scroll_top_row_max

#build_textStyledString

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:



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

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

#clearvoid

This method returns an undefined value.

Clears the text. Equivalent to text = "".



295
296
297
# File 'lib/tuile/component/text_view.rb', line 295

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:



124
125
126
127
128
# File 'lib/tuile/component/text_view.rb', line 124

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)


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

def empty? = @lines.empty?

#focusable?Boolean

Returns:

  • (Boolean)


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

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)


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

def following? = @follow

#half_page_rowsInteger

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

Returns:

  • (Integer)


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

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

#handle_key(key) ⇒ Boolean

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/tuile/component/text_view.rb', line 351

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

  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(event) ⇒ void

This method returns an undefined value.

@param event

Parameters:



371
372
373
374
375
376
377
# File 'lib/tuile/component/text_view.rb', line 371

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



289
290
291
# File 'lib/tuile/component/text_view.rb', line 289

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)


792
793
794
# File 'lib/tuile/component/text_view.rb', line 792

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)


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

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


425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
# File 'lib/tuile/component/text_view.rb', line 425

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

#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.



405
406
407
408
# File 'lib/tuile/component/text_view.rb', line 405

def on_width_changed
  super
  rewrap
end

#pad_to(row, 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 row

@param width

Parameters:

Returns:



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

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

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

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

#paintable_row(index, row_in_viewport, scrollbar) ⇒ StyledString

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

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

@param scrollbar

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

Parameters:

Returns:



849
850
851
852
853
854
# File 'lib/tuile/component/text_view.rb', line 849

def paintable_row(index, row_in_viewport, scrollbar)
  row = @rows[index] || @blank_row
  return row unless scrollbar

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

#pop_lineStyledString

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

Returns:



718
719
720
721
722
# File 'lib/tuile/component/text_view.rb', line 718

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:



708
709
710
711
712
713
# File 'lib/tuile/component/text_view.rb', line 708

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)


453
454
455
456
457
458
459
460
# File 'lib/tuile/component/text_view.rb', line 453

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_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:



576
577
578
579
580
581
582
583
584
585
586
587
# File 'lib/tuile/component/text_view.rb', line 576

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


212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/tuile/component/text_view.rb', line 212

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

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



596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
# File 'lib/tuile/component/text_view.rb', line 596

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

  @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
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. Rows go through Tuile::Component#draw_text, so content and blank rows inherit Tuile::Component#effective_bg_color (a Tuile::Component#bg_color set here or on an ancestor).



387
388
389
390
391
392
393
394
395
396
397
# File 'lib/tuile/component/text_view.rb', line 387

def repaint
  return if rect.empty?

  scrollbar = if scrollbar_visible?
                VerticalScrollBar.new(rect.height, row_count: @rows.size, scroll_top_row: @scroll_top_row)
              end
  (0...rect.height).each do |row|
    line = paintable_row(row + @scroll_top_row, row, scrollbar)
    draw_text(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 (parsed like #text=: StringStyledString.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)


264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# File 'lib/tuile/component/text_view.rb', line 264

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



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

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

#rewrapvoid

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.



675
676
677
678
679
680
681
682
683
684
685
686
# File 'lib/tuile/component/text_view.rb', line 675

def rewrap
  width = wrap_width
  @blank_row = pad_to(StyledString::EMPTY, 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)


758
759
760
761
762
# File 'lib/tuile/component/text_view.rb', line 758

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

  @line_wrap_counts[0, idx].sum
end

#scroll_half_page_downvoid

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?.



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

def scroll_half_page_down = move_scroll_top_row_by(half_page_rows)

#scroll_half_page_upvoid

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: #handle_key ignores every key while the view is inactive, this works whoever holds focus.



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

def scroll_half_page_up = move_scroll_top_row_by(-half_page_rows)

#scroll_top_row_maxInteger

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

Returns:

  • (Integer)


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

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

#scrollbar_visible?Boolean

Returns:

  • (Boolean)


820
821
822
823
824
# File 'lib/tuile/component/text_view.rb', line 820

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:



488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
# File 'lib/tuile/component/text_view.rb', line 488

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


735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
# File 'lib/tuile/component/text_view.rb', line 735

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

#tab_stop?Boolean

Returns:

  • (Boolean)


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

def tab_stop? = true

#textStyledString

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



59
60
61
# File 'lib/tuile/component/text_view.rb', line 59

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:



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/tuile/component/text_view.rb', line 94

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



466
467
468
469
470
471
472
473
474
475
476
477
478
479
# File 'lib/tuile/component/text_view.rb', line 466

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)


624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
# File 'lib/tuile/component/text_view.rb', line 624

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



808
809
810
811
812
813
# File 'lib/tuile/component/text_view.rb', line 808

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_rowsInteger

@return — number of visible lines.

Returns:

  • (Integer)


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

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| pad_to(l, width) } would have produced.

@param line

@param width

Parameters:

Returns:



696
697
698
699
700
701
# File 'lib/tuile/component/text_view.rb', line 696

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

  wrapped = line.wrap(width)
  [wrapped.map { |row| pad_to(row, 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)


784
785
786
787
788
# File 'lib/tuile/component/text_view.rb', line 784

def wrap_width
  return 0 if rect.width <= 0

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