Class: Tuile::Component::TextView
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::TextView
- 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
-
#auto_scroll ⇒ Boolean
@return — if true, mutating the text scrolls the viewport so the last line stays in view — but only while the viewport is already pinned to the last line (see #following?).
-
#scrollbar_visibility ⇒ Symbol
@return —
:goneor:visible. -
#top_line ⇒ Integer
@return — index of the first visible physical line.
Instance Method Summary collapse
-
#<<(str) ⇒ self
Verbatim append, returning
selffor chainability (view << a << b). -
#add_line(str) ⇒ void
Appends
stras a new entry: starts a fresh hard line first (when the buffer is non-empty) and then appendsstr. -
#append(str) ⇒ void
Appends
strverbatim. -
#append_to_region(region, str) ⇒ void
Verbatim append into
region. -
#at_bottom? ⇒ Boolean
@return — whether the viewport is pinned to the last line.
-
#build_text ⇒ StyledString
Rebuilds the joined StyledString from @hard_lines, inserting a default-styled
"\n"between hard lines. -
#clear ⇒ void
Clears the text.
-
#create_region ⇒ Region
Creates a new empty Region at the spatial tail of the document and returns its handle.
-
#empty? ⇒ Boolean
@return — true iff #text is empty (no hard lines).
- #focusable? ⇒ Boolean
-
#following? ⇒ Boolean
@return — whether #auto_scroll is currently tailing.
-
#handle_key(key) ⇒ Boolean
@param
key. -
#handle_mouse(event) ⇒ void
@param
event. -
#initialize ⇒ TextView
constructor
A new instance of TextView.
-
#insert(at, str) ⇒ void
Inserts
strat hard-line indexat. -
#move_top_line_by(delta) ⇒ void
@param
delta— negative scrolls up, positive scrolls down. -
#move_top_line_to(target) ⇒ void
@param
target— desired top line; clamped to[0, top_line_max]. -
#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. -
#on_width_changed ⇒ void
Rewraps the text on width changes.
-
#pad_to(line, width) ⇒ StyledString
Pads
linewith trailing default-styled spaces out towidthdisplay columns. -
#paintable_line(index, row_in_viewport, scrollbar) ⇒ StyledString
@param
index— 0-based index into@physical_lines. -
#phys_offset_at(idx) ⇒ Integer
@param
idx. -
#pop_hard_line ⇒ StyledString
Pops the last hard line, the corresponding cache entry, and the physical rows that hard line contributed.
-
#push_hard_line(hard_line, width) ⇒ void
Appends
hard_lineto the tail of @hard_lines, updating the wrap-count cache and @physical_lines in lockstep. -
#region_start_index(region) ⇒ Integer
Hard-line index where
regionbegins in @hard_lines — derived by summing the line counts of all regions that precede it. -
#remove_last_n_from_region(region, n) ⇒ void
Drops the last
nhard lines fromregion's tail via #splice_hard_lines. -
#remove_last_n_lines(n) ⇒ void
Drops the last
nhard lines from the buffer. -
#remove_region(region) ⇒ void
Drops
regionfrom @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. -
#repaint ⇒ void
Paints the text into #rect.
-
#replace(range, str) ⇒ void
Replaces a contiguous range of hard lines with the parsed content of
str. -
#replace_in_region(region, range, str) ⇒ void
Region-scoped #replace.
-
#rewrap ⇒ void
Full rebuild of @physical_lines and @hard_line_wrap_counts from @hard_lines.
- #scrollbar_visible? ⇒ Boolean
-
#set_region_text(region, value) ⇒ void
Replaces all of
region's hard lines with the parsed content ofvalue. -
#splice_hard_lines(from, count, new_hard_lines) ⇒ void
Splices
new_hard_linesinto the buffer in place of thecounthard lines starting at indexfrom. - #tab_stop? ⇒ Boolean
-
#text ⇒ StyledString
@return — the current text.
-
#text=(value) ⇒ void
Replaces the text.
-
#text_for_region(region) ⇒ StyledString
Joined StyledString of the hard lines that
regionowns. -
#top_line_max ⇒ Integer
@return — the max value of #top_line for scroll-key clamping.
-
#update_region_counts(from, removed_count, added_count) ⇒ void
Adjusts region line counts after a @hard_lines splice that removed
removed_countlines at indexfromand insertedadded_countin their place. -
#update_top_line_if_auto_scroll ⇒ void
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.
-
#viewport_lines ⇒ Integer
@return — number of visible lines.
-
#wrap_hard_line(hard_line, width) ⇒ [::Array[StyledString], Integer]
Wraps
hard_lineatwidthand returns the padded physical rows alongside the row count. -
#wrap_width ⇒ Integer
@return — column width available for wrapped text — viewport width minus the scrollbar gutter (when visible).
Constructor Details
#initialize ⇒ TextView
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_scroll ⇒ Boolean
@return — if true, mutating the text scrolls the viewport so
the last line stays in view — but only while the viewport is already
pinned to the last line (see #following?). Scroll up to read older
content and appends stop yanking you back down; scroll back to the
bottom and tailing resumes. Default false.
88 89 90 |
# File 'lib/tuile/component/text_view.rb', line 88 def auto_scroll @auto_scroll end |
#scrollbar_visibility ⇒ Symbol
@return — :gone or :visible.
81 82 83 |
# File 'lib/tuile/component/text_view.rb', line 81 def @scrollbar_visibility end |
#top_line ⇒ Integer
@return — index of the first visible physical line.
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
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
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
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
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=.
855 |
# File 'lib/tuile/component/text_view.rb', line 855 def at_bottom? = @top_line == top_line_max |
#build_text ⇒ StyledString
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).
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 |
#clear ⇒ void
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_region ⇒ Region
Creates a new empty Region at the spatial tail of the document and returns its handle. Subsequent #append / #<< / #add_line calls route through this new region (since it is now the spatial tail). Earlier regions keep their content and their handles stay valid; their Tuile::Component::TextView::Region#range shifts as later regions grow.
Apps streaming logically-distinct sections (e.g. an LLM's "thinking"
vs. "assistant" output) create one region per section, hold the
handles, and call region.append / region.text= directly when
they need to grow or rewrite an earlier section.
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).
145 |
# File 'lib/tuile/component/text_view.rb', line 145 def empty? = @hard_lines.empty? |
#focusable? ⇒ 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.
94 |
# File 'lib/tuile/component/text_view.rb', line 94 def following? = @follow |
#handle_key(key) ⇒ Boolean
@param key
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() when Keys::PAGE_UP then move_top_line_by(-) when Keys::CTRL_D then move_top_line_by( / 2) when Keys::CTRL_U then move_top_line_by(- / 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
407 408 409 410 411 412 413 |
# File 'lib/tuile/component/text_view.rb', line 407 def handle_mouse(event) super case event. 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.
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.
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].
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
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_changed ⇒ void
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
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.
887 888 889 890 891 892 |
# File 'lib/tuile/component/text_view.rb', line 887 def paintable_line(index, , ) line = @physical_lines[index] || @blank_line return line unless line + StyledString.plain(.()) 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.
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_line ⇒ StyledString
Pops the last hard line, the corresponding cache entry, and the physical rows that hard line contributed. Returns the popped hard line.
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
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
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
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.
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
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 |
#repaint ⇒ void
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? = if 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, ) 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
Integernis shorthand forn..n(replace one existing line —nmust be in[0, hard-line count)); - a non-empty
Rangeof hard-line indices replaces those lines; - an empty
Range(e.g.2...2, or the canonical end-insertionhard_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.
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
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 |
#rewrap ⇒ void
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
858 859 860 861 862 |
# File 'lib/tuile/component/text_view.rb', line 858 def 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
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
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
383 |
# File 'lib/tuile/component/text_view.rb', line 383 def tab_stop? = true |
#text ⇒ StyledString
@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).
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
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
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_max ⇒ Integer
@return — the max value of #top_line for scroll-key clamping.
702 |
# File 'lib/tuile/component/text_view.rb', line 702 def top_line_max = (@physical_lines.size - ).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:
- 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.
- Credit
added_countto that region. For pure insertions (no removal), there's no "first overlapping region" to pick from; walk regions and credit the latest one starting atfrom(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
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_scroll ⇒ void
This method returns an undefined value.
Gated on #following?: once the user scrolls up off the bottom the
viewport pin is skipped, so reading older content is not interrupted
by incoming lines. #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 - ).clamp(0, nil) self.top_line = target if @top_line != target end |
#viewport_lines ⇒ Integer
@return — number of visible lines.
699 |
# File 'lib/tuile/component/text_view.rb', line 699 def = 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
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_width ⇒ Integer
@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.
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 - ( ? 1 : 0) end |