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?).
-
#scroll_top_row ⇒ Integer
@return — index of the first visible row.
-
#scrollbar_visibility ⇒ Symbol
@return —
:goneor:visible.
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 @lines, inserting a default-styled
"\n"between 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.
-
#half_page_rows ⇒ Integer
@return — half a viewport, at least one row, for the half-page scroll verbs and their keys.
-
#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_scroll_top_row_by(delta) ⇒ void
@param
delta— negative scrolls up, positive scrolls down. -
#move_scroll_top_row_to(target) ⇒ void
@param
target— desired top line; clamped to[0, scroll_top_row_max]. -
#normalize_replace_range(range, size = @lines.size, what = "the buffer") ⇒ [Integer, Integer]
Validates and unpacks a #replace-style range argument into inclusive
[from, to]line indices. -
#on_width_changed ⇒ void
Rewraps the text on width changes.
-
#pad_to(row, width) ⇒ StyledString
Pads
linewith trailing default-styled spaces out towidthdisplay columns. -
#paintable_row(index, row_in_viewport, scrollbar) ⇒ StyledString
@param
index— 0-based index into@rows. -
#pop_line ⇒ StyledString
Pops the last line, the corresponding cache entry, and the rows that line contributed.
-
#push_line(line, width) ⇒ void
Appends
lineto the tail of @lines, updating the wrap-count cache and @rows in lockstep. -
#region_start_index(region) ⇒ Integer
Hard-line index where
regionbegins in @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_lines. - #remove_last_n_lines(n) ⇒ void
-
#remove_region(region) ⇒ void
Drops
regionfrom @regions: its hard lines are removed via #splice_lines, the handle is detached, and the always-one default is restored if the removal would have left zero regions. -
#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 @rows and @line_wrap_counts from @lines.
-
#row_offset_at(idx) ⇒ Integer
@param
idx. -
#scroll_half_page_down ⇒ void
The
Ctrl+Dtwin of #scroll_half_page_up, clamped at the last row — arriving there re-arms #following?. -
#scroll_half_page_up ⇒ void
Scrolls up half a viewport (
rect.height / 2, at least one row), clamped at the top — unlike #scroll_top_row=, which raises below0. -
#scroll_top_row_max ⇒ Integer
@return — the max value of #scroll_top_row for scroll-key clamping.
- #scrollbar_visible? ⇒ Boolean
-
#set_region_text(region, value) ⇒ void
Replaces all of
region's hard lines with the parsed content ofvalue. -
#splice_lines(from, count, new_lines) ⇒ void
Splices
new_linesinto the buffer in place of thecountlines 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. -
#update_region_counts(from, removed_count, added_count) ⇒ void
Adjusts region line counts after a @lines splice that removed
removed_countlines atfromand insertedadded_count. -
#update_scroll_top_row_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_rows ⇒ Integer
@return — number of visible lines.
-
#wrap_line(line, width) ⇒ [::Array[StyledString], Integer]
Wraps
lineatwidthand returns the padded 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 |
# File 'lib/tuile/component/text_view.rb', line 26 def initialize super # Three parallel structures, kept in lockstep by every mutator: # `@lines` is the logical model (one entry per `\n`-delimited # line, width-independent); `@rows` is the rendered view # (each line word-wrapped to `wrap_width` and padded with # trailing blanks, so painting a row is a lookup); and # `@line_wrap_counts` is an Integer-per-line cache of how many # rows each line occupies, so a mid-buffer splice can find its # starting row offset without re-wrapping every preceding line. # # Invariants: # - `@line_wrap_counts.size == @lines.size` # - `@line_wrap_counts.sum == @rows.size` # A full rebuild ({#rewrap}) happens on {#text=} and width changes; # other mutators splice incrementally. @lines = [] @rows = [] @line_wrap_counts = [] @text = StyledString::EMPTY @blank_row = StyledString::EMPTY @scroll_top_row = 0 @auto_scroll = false @follow = true @scrollbar_visibility = :gone # Always ≥1 region; the implicit default owns any hard lines no # app-created region claims. See {Region}. @regions = [Region.send(:new, self)] end |
Instance Attribute Details
#auto_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.
74 75 76 |
# File 'lib/tuile/component/text_view.rb', line 74 def auto_scroll @auto_scroll end |
#scroll_top_row ⇒ Integer
@return — index of the first visible row.
64 65 66 |
# File 'lib/tuile/component/text_view.rb', line 64 def scroll_top_row @scroll_top_row end |
#scrollbar_visibility ⇒ Symbol
@return — :gone or :visible.
67 68 69 |
# File 'lib/tuile/component/text_view.rb', line 67 def @scrollbar_visibility end |
Instance Method Details
#<<(str) ⇒ self
Verbatim append, returning self for chainability (view << a << b).
@param str
177 178 179 180 |
# File 'lib/tuile/component/text_view.rb', line 177 def <<(str) append(str) self end |
#add_line(str) ⇒ void
This method returns an undefined value.
Appends str as a new entry: starts a fresh hard line first (when
the buffer is non-empty) and then appends str. Equivalent to
append("\n" + str) on a non-empty buffer, or append(str) on an
empty one. nil and "" produce a blank entry on a non-empty
buffer and a no-op on an empty buffer (matches the old append
semantics for "log line" callers).
@param str
190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/tuile/component/text_view.rb', line 190 def add_line(str) parsed = StyledString.parse(str) if empty? || @regions.last.empty? # No previous line in the tail region to break away from — just # append. (If the tail region is empty but earlier regions have # content, the verbatim {#append} path already starts a fresh # hard line in the tail.) append(parsed) else append(StyledString.plain("\n") + parsed) end end |
#append(str) ⇒ void
This method returns an undefined value.
Appends str verbatim. Embedded \n become hard line breaks; otherwise
the text is concatenated onto the current last hard line. Designed for
streaming use (feed each partial chunk straight in). Accepts the same
input forms as #text=; empty/nil is a no-op. For the "entry on a new
line" pattern use #add_line. Cost is O(appended + width of the last
hard line), which is re-wrapped since the extension may wrap differently.
@param str
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/tuile/component/text_view.rb', line 141 def append(str) screen.check_locked appended = StyledString.parse(str) return if appended.empty? tail_region = @regions.last tail_was_empty = tail_region.empty? new_segments = appended.lines width = wrap_width if tail_was_empty # An empty spatial-tail region (either a fresh buffer, or an empty # region the app created at the tail) means new content starts on # a fresh hard line — we must not extend the previous region's # last line. new_segments.each { |line| push_line(line, width) } added = new_segments.size else extension = new_segments.first unless extension.empty? old_last = pop_line push_line(old_last + extension, width) end new_segments[1..].each { |line| push_line(line, width) } added = new_segments.size - 1 end tail_region.send(:line_count=, tail_region.line_count + added) @text = nil update_scroll_top_row_if_auto_scroll invalidate end |
#append_to_region(region, str) ⇒ void
This method returns an undefined value.
Verbatim append into region.
@param region
@param str
533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 |
# File 'lib/tuile/component/text_view.rb', line 533 def append_to_region(region, str) screen.check_locked parsed = StyledString.parse(str) return if parsed.empty? if region.equal?(@regions.last) append(parsed) return end new_segments = parsed.lines start = region_start_index(region) if region.empty? splice_lines(start, 0, new_segments) region.send(:line_count=, new_segments.size) else last_idx = start + region.line_count - 1 extension = new_segments.first rest = new_segments[1..] if extension.empty? return if rest.empty? splice_lines(last_idx + 1, 0, rest) else extended = @lines[last_idx] + extension splice_lines(last_idx, 1, [extended, *rest]) end region.send(:line_count=, region.line_count + rest.size) end @text = nil @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max update_scroll_top_row_if_auto_scroll invalidate end |
#at_bottom? ⇒ Boolean
@return — whether the viewport is pinned to the last line. Drives #following?: re-evaluated on every #scroll_top_row=.
817 |
# File 'lib/tuile/component/text_view.rb', line 817 def at_bottom? = @scroll_top_row == scroll_top_row_max |
#build_text ⇒ StyledString
Rebuilds the joined StyledString from @lines, inserting a
default-styled "\n" between lines. Called from the #text
reader when the cache is cold. Cost is O(total spans).
768 769 770 771 772 773 774 775 776 777 778 779 |
# File 'lib/tuile/component/text_view.rb', line 768 def build_text return StyledString::EMPTY if @lines.empty? return @lines.first if @lines.size == 1 newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT) spans = [] @lines.each_with_index do |line, i| spans << newline if i.positive? spans.concat(line.spans) end StyledString.new(spans) end |
#clear ⇒ void
This method returns an undefined value.
Clears the text. Equivalent to text = "".
295 296 297 |
# File 'lib/tuile/component/text_view.rb', line 295 def clear self.text = StyledString::EMPTY end |
#create_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.
124 125 126 127 128 |
# File 'lib/tuile/component/text_view.rb', line 124 def create_region region = Region.send(:new, self) @regions << region region end |
#empty? ⇒ Boolean
@return — true iff #text is empty (no hard lines).
131 |
# File 'lib/tuile/component/text_view.rb', line 131 def empty? = @lines.empty? |
#focusable? ⇒ Boolean
345 |
# File 'lib/tuile/component/text_view.rb', line 345 def focusable? = true |
#following? ⇒ Boolean
@return — whether #auto_scroll is currently tailing. True while the viewport sits at the last line; flips to false the moment the user scrolls up, and back to true once they scroll to the bottom again. Only consulted when #auto_scroll is enabled.
80 |
# File 'lib/tuile/component/text_view.rb', line 80 def following? = @follow |
#half_page_rows ⇒ Integer
@return — half a viewport, at least one row, for the half-page scroll verbs and their keys.
665 |
# File 'lib/tuile/component/text_view.rb', line 665 def half_page_rows = [ / 2, 1].max |
#handle_key(key) ⇒ Boolean
@param key
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 |
# File 'lib/tuile/component/text_view.rb', line 351 def handle_key(key) return false unless active? return true if super case key when *Keys::DOWN_ARROWS then move_scroll_top_row_by(1) when *Keys::UP_ARROWS then move_scroll_top_row_by(-1) when Keys::PAGE_DOWN then move_scroll_top_row_by() when Keys::PAGE_UP then move_scroll_top_row_by(-) when Keys::CTRL_D then scroll_half_page_down when Keys::CTRL_U then scroll_half_page_up when *Keys::HOMES, "g" then move_scroll_top_row_to(0) when *Keys::ENDS_, "G" then move_scroll_top_row_to(scroll_top_row_max) else return false end true end |
#handle_mouse(event) ⇒ void
This method returns an undefined value.
@param event
371 372 373 374 375 376 377 |
# File 'lib/tuile/component/text_view.rb', line 371 def handle_mouse(event) super case event. when :scroll_down then move_scroll_top_row_by(4) when :scroll_up then move_scroll_top_row_by(-4) end end |
#insert(at, str) ⇒ void
This method returns an undefined value.
Inserts str at hard-line index at. Equivalent to
replace(at...at, str) — a no-removal splice that grows the buffer
by the parsed line count. at == hard-line count is allowed and
appends at the end; for that case #append / #add_line are
usually more idiomatic.
@param at — 0-based hard-line index in [0, hard-line count].
@param str — content to insert.
289 290 291 |
# File 'lib/tuile/component/text_view.rb', line 289 def insert(at, str) replace(at...at, str) end |
#move_scroll_top_row_by(delta) ⇒ void
This method returns an undefined value.
@param delta — negative scrolls up, positive scrolls down.
792 793 794 |
# File 'lib/tuile/component/text_view.rb', line 792 def move_scroll_top_row_by(delta) move_scroll_top_row_to(@scroll_top_row + delta) end |
#move_scroll_top_row_to(target) ⇒ void
This method returns an undefined value.
@param target — desired top line; clamped to [0, scroll_top_row_max].
798 799 800 801 |
# File 'lib/tuile/component/text_view.rb', line 798 def move_scroll_top_row_to(target) clamped = target.clamp(0, scroll_top_row_max) self.scroll_top_row = clamped unless @scroll_top_row == clamped end |
#normalize_replace_range(range, size = @lines.size, what = "the buffer") ⇒ [Integer, Integer]
Validates and unpacks a #replace-style range argument into
inclusive [from, to] line indices. An Integer n becomes
[n, n] (which must point at an existing line — Integer is
never insertion sugar). A Range is normalized for
exclude_end?; to == from - 1 is a valid empty range
(insertion at from), and from may equal size for
end-insertion. Shared by #replace and Tuile::Component::TextView::Region#replace;
size is the buffer or region line count, and what is the
entity name woven into error messages.
@param range
@param size
@param what
425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 |
# File 'lib/tuile/component/text_view.rb', line 425 def normalize_replace_range(range, size = @lines.size, what = "the buffer") case range when Integer from = to = range when Range from = range.begin raw_end = range.end unless from.is_a?(Integer) && raw_end.is_a?(Integer) raise TypeError, "range endpoints must be Integers, got #{range.inspect}" end to = range.exclude_end? ? raw_end - 1 : raw_end else raise TypeError, "expected Range or Integer, got #{range.inspect}" end raise ArgumentError, "range endpoints must not be negative, got #{range.inspect}" if from.negative? if from > size || to >= size raise ArgumentError, "range #{range.inspect} out of bounds for #{what} (#{size} hard line(s))" end raise ArgumentError, "range #{range.inspect} is malformed (end more than one below begin)" if to < from - 1 [from, to] end |
#on_width_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.
405 406 407 408 |
# File 'lib/tuile/component/text_view.rb', line 405 def on_width_changed super rewrap end |
#pad_to(row, width) ⇒ StyledString
Pads line with trailing default-styled spaces out to width display
columns. Callers rely on StyledString#wrap having already
constrained the line to <= width, so no truncation is performed.
width <= 0 returns StyledString::EMPTY to handle the degenerate
wrap_width == 0 case (rect.width == 1 with scrollbar).
@param row
@param width
834 835 836 837 838 839 840 841 |
# File 'lib/tuile/component/text_view.rb', line 834 def pad_to(row, width) return StyledString::EMPTY if width <= 0 diff = width - row.display_width return row if diff <= 0 row + StyledString.plain(" " * diff) end |
#paintable_row(index, row_in_viewport, scrollbar) ⇒ StyledString
@param index — 0-based index into @rows.
@param row_in_viewport — 0-based row within the viewport.
@param scrollbar
@return — paintable row exactly rect.width columns wide.
Body rows come pre-padded from #rewrap, so this reduces to a lookup
plus a concat of the scrollbar glyph when one is present.
849 850 851 852 853 854 |
# File 'lib/tuile/component/text_view.rb', line 849 def paintable_row(index, , ) row = @rows[index] || @blank_row return row unless row + StyledString.plain(.()) end |
#pop_line ⇒ StyledString
Pops the last line, the corresponding cache entry, and the rows that line contributed. Returns the popped line.
718 719 720 721 722 |
# File 'lib/tuile/component/text_view.rb', line 718 def pop_line n = @line_wrap_counts.pop n.times { @rows.pop } @lines.pop end |
#push_line(line, width) ⇒ void
This method returns an undefined value.
Appends line to the tail of @lines, updating the
wrap-count cache and @rows in lockstep.
@param line
@param width
708 709 710 711 712 713 |
# File 'lib/tuile/component/text_view.rb', line 708 def push_line(line, width) wrapped, n = wrap_line(line, width) @lines << line @line_wrap_counts << n @rows.concat(wrapped) end |
#region_start_index(region) ⇒ Integer
Hard-line index where region begins in @lines — derived
by summing the line counts of all regions that precede it.
@param region
453 454 455 456 457 458 459 460 |
# File 'lib/tuile/component/text_view.rb', line 453 def region_start_index(region) idx = @regions.index(region) raise "region not found in view" unless idx sum = 0 idx.times { |i| sum += @regions[i].line_count } sum end |
#remove_last_n_from_region(region, n) ⇒ void
This method returns an undefined value.
Drops the last n hard lines from region's tail via
#splice_lines. n is clamped to the region's current
line count; callers guarantee n > 0 and the region is
non-empty (the Tuile::Component::TextView::Region#remove_last_n_lines guard handles the
no-op cases).
@param region
@param n
576 577 578 579 580 581 582 583 584 585 586 587 |
# File 'lib/tuile/component/text_view.rb', line 576 def remove_last_n_from_region(region, n) screen.check_locked to_drop = [n, region.line_count].min start = region_start_index(region) drop_from = start + region.line_count - to_drop splice_lines(drop_from, to_drop, []) region.send(:line_count=, region.line_count - to_drop) @text = nil @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max update_scroll_top_row_if_auto_scroll invalidate end |
#remove_last_n_lines(n) ⇒ void
This method returns an undefined value.
Drops the last n hard lines from the buffer — the inverse of building
up a tail with #append / #add_line, so a caller can remove then
append to rewrite a damaged tail in place. Operates on hard lines
(the \n-delimited entries), not wrapped rows. n == 0 and the
empty buffer are no-ops; n >= hard-line count empties the buffer.
@param n — number of hard lines to drop; must be >= 0.
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'lib/tuile/component/text_view.rb', line 212 def remove_last_n_lines(n) raise TypeError, "expected Integer, got #{n.inspect}" unless n.is_a?(Integer) raise ArgumentError, "n must not be negative, got #{n}" if n.negative? screen.check_locked return if n.zero? || empty? to_drop = [n, @lines.size].min to_drop.times { pop_line } # Cascade-shrink regions from the spatial tail. The tail region # gives up lines first; if more are still owed (because the tail # was shorter than `to_drop`), earlier regions shrink in turn. remaining = to_drop @regions.reverse_each do |region| break if remaining.zero? take = [remaining, region.line_count].min region.send(:line_count=, region.line_count - take) remaining -= take end @text = nil @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max update_scroll_top_row_if_auto_scroll invalidate end |
#remove_region(region) ⇒ void
This method returns an undefined value.
Drops region from @regions: its hard lines are removed via
#splice_lines, the handle is detached, and the always-one
default is restored if the removal would have left zero regions.
Skips the rewrap / invalidate work when the region was empty
(the buffer didn't change), but always detaches.
@param region
596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 |
# File 'lib/tuile/component/text_view.rb', line 596 def remove_region(region) screen.check_locked had_lines = region.line_count.positive? if had_lines start = region_start_index(region) splice_lines(start, region.line_count, []) end @regions.delete(region) region.send(:detach!) @regions << Region.send(:new, self) if @regions.empty? return unless had_lines @text = nil @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max update_scroll_top_row_if_auto_scroll invalidate end |
#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_text, so content and blank rows inherit Tuile::Component#effective_bg_color (a Tuile::Component#bg_color set here or on an ancestor).
387 388 389 390 391 392 393 394 395 396 397 |
# File 'lib/tuile/component/text_view.rb', line 387 def repaint return if rect.empty? = if VerticalScrollBar.new(rect.height, row_count: @rows.size, scroll_top_row: @scroll_top_row) end (0...rect.height).each do |row| line = paintable_row(row + @scroll_top_row, row, ) draw_text(rect.left, rect.top + row, line) end end |
#replace(range, str) ⇒ void
This method returns an undefined value.
Replaces a contiguous range of hard lines with the parsed content of
str (parsed like #text=: 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 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.
264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/tuile/component/text_view.rb', line 264 def replace(range, str) screen.check_locked from, to = normalize_replace_range(range) parsed = StyledString.parse(str) new_lines = parsed.empty? ? [] : parsed.lines length = to - from + 1 return if new_lines == @lines[from, length] splice_lines(from, length, new_lines) update_region_counts(from, length, new_lines.size) @text = nil @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max update_scroll_top_row_if_auto_scroll invalidate end |
#replace_in_region(region, range, str) ⇒ void
This method returns an undefined value.
Region-scoped #replace. Validates range against
region.line_count, translates region-relative indices to
absolute buffer indices, splices, and updates the region's count.
@param region
@param range
@param str
511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 |
# File 'lib/tuile/component/text_view.rb', line 511 def replace_in_region(region, range, str) screen.check_locked from, to = normalize_replace_range(range, region.line_count, "the region") parsed = StyledString.parse(str) new_lines = parsed.empty? ? [] : parsed.lines start = region_start_index(region) abs_from = start + from length = to - from + 1 return if new_lines == @lines[abs_from, length] splice_lines(abs_from, length, new_lines) region.send(:line_count=, region.line_count - length + new_lines.size) @text = nil @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max update_scroll_top_row_if_auto_scroll invalidate end |
#rewrap ⇒ void
This method returns an undefined value.
Full rebuild of @rows and @line_wrap_counts from @lines. Called when wrap width changes (which invalidates every cached row count) and from #text= (which replaces the whole logical model). Mid-buffer mutators splice incrementally via #splice_lines and do not go through here. Clamps @scroll_top_row if the new line count puts it out of range.
675 676 677 678 679 680 681 682 683 684 685 686 |
# File 'lib/tuile/component/text_view.rb', line 675 def rewrap width = wrap_width @blank_row = pad_to(StyledString::EMPTY, width) @rows = [] @line_wrap_counts = [] @lines.each do |line| wrapped, n = wrap_line(line, width) @rows.concat(wrapped) @line_wrap_counts << n end @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max end |
#row_offset_at(idx) ⇒ Integer
@param idx
@return — the @rows index where the line
at @lines[idx] starts. O(idx) integer adds via the
wrap-count cache.
758 759 760 761 762 |
# File 'lib/tuile/component/text_view.rb', line 758 def row_offset_at(idx) return 0 if idx.zero? @line_wrap_counts[0, idx].sum end |
#scroll_half_page_down ⇒ void
This method returns an undefined value.
The Ctrl+D twin of #scroll_half_page_up, clamped at the last row —
arriving there re-arms #following?.
343 |
# File 'lib/tuile/component/text_view.rb', line 343 def scroll_half_page_down = move_scroll_top_row_by(half_page_rows) |
#scroll_half_page_up ⇒ void
This method returns an undefined value.
Scrolls up half a viewport (rect.height / 2, at least one row),
clamped at the top — unlike #scroll_top_row=, which raises below 0.
What Ctrl+U does, minus the focus: #handle_key ignores every key
while the view is inactive, this works whoever holds focus.
338 |
# File 'lib/tuile/component/text_view.rb', line 338 def scroll_half_page_up = move_scroll_top_row_by(-half_page_rows) |
#scroll_top_row_max ⇒ Integer
@return — the max value of #scroll_top_row for scroll-key clamping.
661 |
# File 'lib/tuile/component/text_view.rb', line 661 def scroll_top_row_max = (@rows.size - ).clamp(0, nil) |
#scrollbar_visible? ⇒ Boolean
820 821 822 823 824 |
# File 'lib/tuile/component/text_view.rb', line 820 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
488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 |
# File 'lib/tuile/component/text_view.rb', line 488 def set_region_text(region, value) screen.check_locked parsed = StyledString.parse(value) new_lines = parsed.empty? ? [] : parsed.lines start = region_start_index(region) old_count = region.line_count return if new_lines == @lines[start, old_count] splice_lines(start, old_count, new_lines) region.send(:line_count=, new_lines.size) @text = nil @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max update_scroll_top_row_if_auto_scroll invalidate end |
#splice_lines(from, count, new_lines) ⇒ void
This method returns an undefined value.
Splices new_lines into the buffer in place of the count lines
starting at index from. Updates @lines,
@line_wrap_counts, and @rows consistently.
The starting row offset is computed in O(from) integer
adds via the cache — no wraps of preceding lines. Wraps are
done only for the new content, so total cost is
O(from + count + new_lines.sum(&:display_width)).
@param from
@param count — number of existing lines to remove.
@param new_lines
735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 |
# File 'lib/tuile/component/text_view.rb', line 735 def splice_lines(from, count, new_lines) width = wrap_width row_start = row_offset_at(from) old_row_count = @line_wrap_counts[from, count].sum @lines[from, count] = new_lines new_rows = [] new_counts = [] new_lines.each do |line| wrapped, n = wrap_line(line, width) new_rows.concat(wrapped) new_counts << n end @line_wrap_counts[from, count] = new_counts @rows[row_start, old_row_count] = new_rows end |
#tab_stop? ⇒ Boolean
347 |
# File 'lib/tuile/component/text_view.rb', line 347 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).
59 60 61 |
# File 'lib/tuile/component/text_view.rb', line 59 def text @text ||= build_text end |
#text=(value) ⇒ void
This method returns an undefined value.
Replaces the text. Embedded \n characters become hard line breaks.
A String is parsed via StyledString.parse (so embedded ANSI is
honored); a StyledString is used as-is; nil is coerced to an
empty StyledString.
Detaches every existing Region (including the original default) and installs a fresh internal default region that owns all the new hard lines. Any handle the caller was holding becomes detached and raises on use — see Tuile::Component::TextView::Region#attached?. The no-op short-circuit (matching value, same StyledString) preserves existing regions.
@param value
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/tuile/component/text_view.rb', line 94 def text=(value) new_text = StyledString.parse(value) content_unchanged = text == new_text # `text=` is a structural reset: even when the new content matches # the old, existing region handles must die — the caller said "set # the text," not "merge with what's there." The unchanged-content # path still skips the expensive rewrap / invalidate work. @text = new_text @lines = new_text.empty? ? [] : new_text.lines @regions.each { |r| r.send(:detach!) } @regions = [Region.send(:new, self, @lines.size)] return if content_unchanged rewrap update_scroll_top_row_if_auto_scroll invalidate end |
#text_for_region(region) ⇒ StyledString
Joined StyledString of the hard lines that region owns. Mirrors
#text but scoped to one region.
@param region
466 467 468 469 470 471 472 473 474 475 476 477 478 479 |
# File 'lib/tuile/component/text_view.rb', line 466 def text_for_region(region) start = region_start_index(region) count = region.line_count return StyledString::EMPTY if count.zero? return @lines[start] if count == 1 newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT) spans = [] count.times do |i| spans << newline if i.positive? spans.concat(@lines[start + i].spans) end StyledString.new(spans) end |
#update_region_counts(from, removed_count, added_count) ⇒ void
This method returns an undefined value.
Adjusts region line counts after a @lines splice that removed
removed_count lines at from and inserted added_count. Subtracts
each region's overlap with the removed range, then credits the added
lines to the first region that lost lines. Pure insertions have no such
region — they credit the latest region starting at from, matching
#append's spatial-tail routing (past-the-end falls back to the tail).
@param from
@param removed_count
@param added_count
624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 |
# File 'lib/tuile/component/text_view.rb', line 624 def update_region_counts(from, removed_count, added_count) target = nil pos = 0 @regions.each do |region| original_count = region.line_count overlap_start = [from, pos].max overlap_end = [from + removed_count, pos + original_count].min overlap = overlap_end - overlap_start if overlap.positive? region.send(:line_count=, original_count - overlap) target ||= region end pos += original_count end return if added_count.zero? if target.nil? pos = 0 @regions.each do |region| region_end_exclusive = pos + region.line_count if from == pos target = region elsif from < region_end_exclusive target = region break end pos = region_end_exclusive end target ||= @regions.last end target.send(:line_count=, target.line_count + added_count) end |
#update_scroll_top_row_if_auto_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. #scroll_top_row= re-arms @follow when the viewport
returns to the bottom.
808 809 810 811 812 813 |
# File 'lib/tuile/component/text_view.rb', line 808 def update_scroll_top_row_if_auto_scroll return unless @auto_scroll && @follow target = (@rows.size - ).clamp(0, nil) self.scroll_top_row = target if @scroll_top_row != target end |
#viewport_rows ⇒ Integer
@return — number of visible lines.
658 |
# File 'lib/tuile/component/text_view.rb', line 658 def = rect.height |
#wrap_line(line, width) ⇒ [::Array[StyledString], Integer]
Wraps line at width and returns the padded rows alongside the
row count. Empty lines (e.g. from a "\n\n"
run) and degenerate width <= 0 both emit a single @blank_row
row, matching what @text.wrap(width).map { |l| pad_to(l, width) }
would have produced.
@param line
@param width
696 697 698 699 700 701 |
# File 'lib/tuile/component/text_view.rb', line 696 def wrap_line(line, width) return [[@blank_row], 1] if line.empty? || width <= 0 wrapped = line.wrap(width) [wrapped.map { |row| pad_to(row, width) }, wrapped.size] end |
#wrap_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.
784 785 786 787 788 |
# File 'lib/tuile/component/text_view.rb', line 784 def wrap_width return 0 if rect.width <= 0 rect.width - ( ? 1 : 0) end |