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

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


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

def auto_scroll
  @auto_scroll
end

#scrollbar_visibilitySymbol

@return:gone or :visible.

Returns:

  • (Symbol)


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

def scrollbar_visibility
  @scrollbar_visibility
end

#top_lineInteger

@return — index of the first visible physical line.

Returns:

  • (Integer)


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

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)


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

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:



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

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:



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

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:



522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
# File 'lib/tuile/component/text_view.rb', line 522

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)


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

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:



754
755
756
757
758
759
760
761
762
763
764
765
# File 'lib/tuile/component/text_view.rb', line 754

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 = "".



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

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:



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

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)


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

def empty? = @hard_lines.empty?

#focusable?Boolean

Returns:

  • (Boolean)


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

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)


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

def following? = @follow

#handle_key(key) ⇒ Boolean

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/tuile/component/text_view.rb', line 340

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:



360
361
362
363
364
365
366
# File 'lib/tuile/component/text_view.rb', line 360

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:



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

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)


778
779
780
# File 'lib/tuile/component/text_view.rb', line 778

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)


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

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


414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/tuile/component/text_view.rb', line 414

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.



394
395
396
397
# File 'lib/tuile/component/text_view.rb', line 394

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:



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

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:



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

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)


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

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:



704
705
706
707
708
# File 'lib/tuile/component/text_view.rb', line 704

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:



693
694
695
696
697
698
# File 'lib/tuile/component/text_view.rb', line 693

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)


442
443
444
445
446
447
448
449
# File 'lib/tuile/component/text_view.rb', line 442

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:



565
566
567
568
569
570
571
572
573
574
575
576
# File 'lib/tuile/component/text_view.rb', line 565

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


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

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:



585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
# File 'lib/tuile/component/text_view.rb', line 585

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



376
377
378
379
380
381
382
383
384
385
386
# File 'lib/tuile/component/text_view.rb', line 376

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)
    draw_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 (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 physical-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)


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

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:



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

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.



660
661
662
663
664
665
666
667
668
669
670
671
# File 'lib/tuile/component/text_view.rb', line 660

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)


806
807
808
809
810
# File 'lib/tuile/component/text_view.rb', line 806

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:



477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
# File 'lib/tuile/component/text_view.rb', line 477

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


721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
# File 'lib/tuile/component/text_view.rb', line 721

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)


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

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:



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

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:



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

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:



455
456
457
458
459
460
461
462
463
464
465
466
467
468
# File 'lib/tuile/component/text_view.rb', line 455

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)


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

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


613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
# File 'lib/tuile/component/text_view.rb', line 613

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.



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

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)


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

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:



681
682
683
684
685
686
# File 'lib/tuile/component/text_view.rb', line 681

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)


770
771
772
773
774
# File 'lib/tuile/component/text_view.rb', line 770

def wrap_width
  return 0 if rect.width <= 0

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