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 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
-
#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
-
#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(parsed like #text=:String→ StyledString.parse,nil→ empty, sonildeletes the range). -
#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 (empty by default).
-
#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 atfromand insertedadded_count. -
#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.
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_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.
75 76 77 |
# File 'lib/tuile/component/text_view.rb', line 75 def auto_scroll @auto_scroll end |
#scrollbar_visibility ⇒ Symbol
@return — :gone or :visible.
68 69 70 |
# File 'lib/tuile/component/text_view.rb', line 68 def @scrollbar_visibility end |
#top_line ⇒ Integer
@return — index of the first visible physical line.
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
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
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
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
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=.
803 |
# File 'lib/tuile/component/text_view.rb', line 803 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).
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 |
#clear ⇒ void
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_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.
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).
132 |
# File 'lib/tuile/component/text_view.rb', line 132 def empty? = @hard_lines.empty? |
#focusable? ⇒ 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.
81 |
# File 'lib/tuile/component/text_view.rb', line 81 def following? = @follow |
#handle_key(key) ⇒ Boolean
@param key
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() 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
360 361 362 363 364 365 366 |
# File 'lib/tuile/component/text_view.rb', line 360 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.
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.
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].
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
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_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.
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
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.
835 836 837 838 839 840 |
# File 'lib/tuile/component/text_view.rb', line 835 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.
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_line ⇒ StyledString
Pops the last hard line, the corresponding cache entry, and the physical rows that hard line contributed. Returns the popped hard line.
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
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
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
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.
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
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 |
#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. 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? = 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, ) 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=: String → StyledString.parse, nil →
empty, so nil deletes the range). Embedded "\n" yields multiple hard
lines, so one replace can grow or shrink the buffer. range selects
which hard lines to swap out:
- an
Integernis shorthand forn..n(replace one existing line); - a non-empty
Rangereplaces those lines; - an empty
Range(e.g.2...2, orsize...sizeat 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.
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
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 |
#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.
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
806 807 808 809 810 |
# File 'lib/tuile/component/text_view.rb', line 806 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
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
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
336 |
# File 'lib/tuile/component/text_view.rb', line 336 def tab_stop? = true |
#text ⇒ StyledString
@return — the current text (empty by default). Rebuilt lazily on the first read after a mutation (O(total spans)), then cached — repeated reads are O(1).
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
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
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_max ⇒ Integer
@return — the max value of #top_line for scroll-key clamping.
650 |
# File 'lib/tuile/component/text_view.rb', line 650 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 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
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_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.
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 - ).clamp(0, nil) self.top_line = target if @top_line != target end |
#viewport_lines ⇒ Integer
@return — number of visible lines.
647 |
# File 'lib/tuile/component/text_view.rb', line 647 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
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_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.
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 - ( ? 1 : 0) end |