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.
#scrollbar_visibility turns on a VerticalScrollBar in the rightmost
column — a real child component, so the user can drag its handle
(wanting run_event_loop(capture_mouse: :drag)) and press its track to
page. Its column and the blank one beside it are reserved whenever it is
:visible, whether anything scrolls or not, so the text never rewraps
behind a growing buffer (D_scrollbar_reserve, D_scrollbar_ink).
Under :auto the bar shows only while the text, wrapped at the full
width, has more rows than the viewport — and then the text rewraps once,
two columns narrower, which is the price of the columns it hands back
while everything fits.
Meant to be the content of a Window — focus indication relies on the surrounding window chrome.
Direct Known Subclasses
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 —
:gone,:visible, or:auto— shown only while the text overflows the viewport at the full width.
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.
-
#content_changed ⇒ void
Every splice's tail.
-
#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
Claims the scroll ladder: the arrows and
j/k, PageUp/PageDown,Ctrl+U/Ctrl+D, Home/g/End/G. -
#handle_mouse_scroll?(event) ⇒ Boolean
Scrolls four rows a notch, and declines — so the notch bubbles to an ancestor scroller — once this view is at that end of its text.
-
#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. -
#overflows_at_full_width? ⇒ Boolean
Whether the text, wrapped at #rect
.width, has more rows than the viewport. -
#paintable_row(index) ⇒ 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. -
#relayout ⇒ void
Places the scrollbar, the view's one child, and pushes the two numbers it paints a handle from.
-
#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(canvas) ⇒ void
Paints the text into #rect, out to the blank column that #scrollbar_columns reserves.
-
#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_columns ⇒ Integer
Columns the scrollbar claims off the right edge: the bar itself plus one blank column, so a row wrapping at the full width doesn't run into
█(…to show the█). - #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. -
#sync_auto_scrollbar ⇒ void
The sole writer of
@auto_scrollbar_shown: shows the bar iff the text overflows the viewport at the full width, and rewraps when that flips. - #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 #scrollbar_columns.
Constructor Details
#initialize ⇒ TextView
Returns a new instance of TextView.
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 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/tuile/component/text_view.rb', line 37 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 when # {#wrap_width} moves off `@wrapped_at`; other mutators splice # incrementally. @lines = [] @rows = [] @line_wrap_counts = [] @wrapped_at = 0 @text = StyledString::EMPTY @blank_row = StyledString::EMPTY @scroll_top_row = 0 @auto_scroll = false @follow = true @scrollbar_visibility = :gone # Whether an `:auto` bar is showing — state, not derived on read, # because `@rows` is wrapped at the width it leaves; only # {#sync_auto_scrollbar} flips it. @auto_scrollbar_shown = false # Always ≥1 region; the implicit default owns any hard lines no # app-created region claims. See {Region}. @regions = [Region.send(:new, self)] @scrollbar = VerticalScrollBar.new @scrollbar.on_scroll_request { self.scroll_top_row = _1.scroll_top_row } add_child(@scrollbar) # chrome, appended: a TextView has no content children 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.
95 96 97 |
# File 'lib/tuile/component/text_view.rb', line 95 def auto_scroll @auto_scroll end |
#scroll_top_row ⇒ Integer
@return — index of the first visible row.
84 85 86 |
# File 'lib/tuile/component/text_view.rb', line 84 def scroll_top_row @scroll_top_row end |
#scrollbar_visibility ⇒ Symbol
@return — :gone, :visible, or :auto — shown only while the
text overflows the viewport at the full width. :gone by default.
88 89 90 |
# File 'lib/tuile/component/text_view.rb', line 88 def @scrollbar_visibility end |
Instance Method Details
#<<(str) ⇒ self
Verbatim append, returning self for chainability (view << a << b).
@param str
200 201 202 203 |
# File 'lib/tuile/component/text_view.rb', line 200 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
213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/tuile/component/text_view.rb', line 213 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
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 194 195 |
# File 'lib/tuile/component/text_view.rb', line 163 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 invalidate_layout # `@rows` grew, and the bar's row_count follows it end |
#append_to_region(region, str) ⇒ void
This method returns an undefined value.
Verbatim append into region.
@param region
@param str
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 601 602 |
# File 'lib/tuile/component/text_view.rb', line 572 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 content_changed end |
#at_bottom? ⇒ Boolean
@return — whether the viewport is pinned to the last line. Drives #following?: re-evaluated on every #scroll_top_row=.
882 |
# File 'lib/tuile/component/text_view.rb', line 882 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).
799 800 801 802 803 804 805 806 807 808 809 810 |
# File 'lib/tuile/component/text_view.rb', line 799 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 = "".
312 313 314 |
# File 'lib/tuile/component/text_view.rb', line 312 def clear self.text = StyledString::EMPTY end |
#content_changed ⇒ void
This method returns an undefined value.
Every splice's tail. Tuile::Component#invalidate_layout rides along because
#relayout pushes the bar's row_count: a splice that changed
@rows.size without the mark leaves the handle sized for the old
buffer.
Not for #append or #text=, which mark by hand — appending must not clamp, or a viewport deliberately parked past the end (which #scroll_top_row= allows) is yanked back by the next line, and #text= clamps inside #rewrap already.
860 861 862 863 864 865 866 |
# File 'lib/tuile/component/text_view.rb', line 860 def content_changed @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 invalidate_layout 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.
146 147 148 149 150 |
# File 'lib/tuile/component/text_view.rb', line 146 def create_region region = Region.send(:new, self) @regions << region region end |
#empty? ⇒ Boolean
@return — true iff #text is empty (no hard lines).
153 |
# File 'lib/tuile/component/text_view.rb', line 153 def empty? = @lines.empty? |
#focusable? ⇒ Boolean
370 |
# File 'lib/tuile/component/text_view.rb', line 370 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.
101 |
# File 'lib/tuile/component/text_view.rb', line 101 def following? = @follow |
#half_page_rows ⇒ Integer
@return — half a viewport, at least one row, for the half-page scroll verbs and their keys.
695 |
# File 'lib/tuile/component/text_view.rb', line 695 def half_page_rows = [ / 2, 1].max |
#handle_key?(key) ⇒ Boolean
Claims the scroll ladder: the arrows and j/k, PageUp/PageDown,
Ctrl+U/Ctrl+D, Home/g/End/G. Acts on the key alone — a clamped
scroll at either edge is still a handled key, and hand-feeding a key to
an unfocused view scrolls it (dispatch gates on focus, this doesn't).
@param key
380 381 382 383 384 385 386 387 388 389 390 391 392 393 |
# File 'lib/tuile/component/text_view.rb', line 380 def handle_key?(key) 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_scroll?(event) ⇒ Boolean
Scrolls four rows a notch, and declines — so the notch bubbles to an ancestor scroller — once this view is at that end of its text.
@param event
399 400 401 402 403 404 405 406 407 |
# File 'lib/tuile/component/text_view.rb', line 399 def handle_mouse_scroll?(event) before = scroll_top_row case event.direction when :down then move_scroll_top_row_by(4) when :up then move_scroll_top_row_by(-4) else return false end scroll_top_row != before 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.
306 307 308 |
# File 'lib/tuile/component/text_view.rb', line 306 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.
839 840 841 |
# File 'lib/tuile/component/text_view.rb', line 839 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].
845 846 847 848 |
# File 'lib/tuile/component/text_view.rb', line 845 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
470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 |
# File 'lib/tuile/component/text_view.rb', line 470 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 |
#overflows_at_full_width? ⇒ Boolean
Whether the text, wrapped at Tuile::Component#rect.width, has more rows than the
viewport. With the bar hidden @rows already is that wrap; with it
shown the lines are re-wrapped at the full width, stopping at the first
row past the viewport — so the cost is bounded by what fits on screen,
and a buffer with more hard lines than rows costs nothing at all.
918 919 920 921 922 923 924 925 926 927 928 929 |
# File 'lib/tuile/component/text_view.rb', line 918 def overflows_at_full_width? return @rows.size > unless @auto_scrollbar_shown return true if @lines.size > width = rect.width rows = 0 @lines.each do |line| rows += line.empty? ? 1 : line.wrap(width).size return true if rows > end false end |
#paintable_row(index) ⇒ StyledString
@param index — 0-based index into @rows.
@return — every column of Tuile::Component#rect the child bar does not own: all of them with the bar hidden, one fewer with it visible. Rows arrive padded to #wrap_width from #rewrap, so only the blank #scrollbar_columns reserves is added here.
936 937 938 939 940 941 942 |
# File 'lib/tuile/component/text_view.rb', line 936 def paintable_row(index) row = @rows[index] || @blank_row blanks = - 1 return row unless blanks.positive? row + StyledString.plain(" " * blanks) end |
#pop_line ⇒ StyledString
Pops the last line, the corresponding cache entry, and the rows that line contributed. Returns the popped line.
749 750 751 752 753 |
# File 'lib/tuile/component/text_view.rb', line 749 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
739 740 741 742 743 744 |
# File 'lib/tuile/component/text_view.rb', line 739 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
498 499 500 501 502 503 504 505 |
# File 'lib/tuile/component/text_view.rb', line 498 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 |
#relayout ⇒ void
This method returns an undefined value.
Places the scrollbar, the view's one child, and pushes the two numbers
it paints a handle from. No rect.empty? guard: a container assigns
every child a rect on every pass (D_empty_ancestor), and
#scrollbar_visible? already answers false for an empty rect.
The rows are settled first, before the bar is placed, so the layout mark
a scroll clamp makes is dropped rather than costing a second pass: a
rewrap if #wrap_width moved — on a height change too, since an empty
rect hides a :visible bar — then the :auto bar, then the
#auto_scroll pin, which a taller viewport moves. Every mutator already
marks, for the bar's row_count, so this runs after each one.
446 447 448 449 450 451 452 453 |
# File 'lib/tuile/component/text_view.rb', line 446 def relayout rewrap unless @wrapped_at == wrap_width update_scroll_top_row_if_auto_scroll @scrollbar.rect = ? Rect.new(rect.width - 1, 0, 1, rect.height) : Rect.new(0, 0, 0, 0) @scrollbar.row_count = @rows.size @scrollbar.scroll_top_row = @scroll_top_row 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
612 613 614 615 616 617 618 619 620 |
# File 'lib/tuile/component/text_view.rb', line 612 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) content_changed 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.
235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/tuile/component/text_view.rb', line 235 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 content_changed 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
629 630 631 632 633 634 635 636 637 638 639 640 641 642 |
# File 'lib/tuile/component/text_view.rb', line 629 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 content_changed end |
#repaint(canvas) ⇒ void
This method returns an undefined value.
Paints the text into Tuile::Component#rect, out to the blank column that #scrollbar_columns reserves. The bar's own column is not painted here — it is the child VerticalScrollBar's, placed by #relayout.
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.
Tuile::Component#invalidate_children is the half that cannot be skipped
with it, or the bar goes stale under an ancestor's clear
(D_repaint_cascade). Rows go through Tuile::Canvas#set_text, so content
and blank rows inherit Tuile::ComponentBackground#effective (a Tuile::Component#bg_color
set here or on an ancestor).
@param canvas — see Tuile::Component#repaint.
423 424 425 426 427 428 429 430 |
# File 'lib/tuile/component/text_view.rb', line 423 def repaint(canvas) return if rect.empty? invalidate_children (0...rect.height).each do |row| canvas.set_text(0, row, paintable_row(row + @scroll_top_row)) 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.
284 285 286 287 288 289 290 291 292 293 294 295 296 |
# File 'lib/tuile/component/text_view.rb', line 284 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) content_changed 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
553 554 555 556 557 558 559 560 561 562 563 564 565 566 |
# File 'lib/tuile/component/text_view.rb', line 553 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) content_changed 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.
705 706 707 708 709 710 711 712 713 714 715 716 717 |
# File 'lib/tuile/component/text_view.rb', line 705 def rewrap width = wrap_width @wrapped_at = width @blank_row = StyledString::EMPTY.ljust(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.
789 790 791 792 793 |
# File 'lib/tuile/component/text_view.rb', line 789 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?.
368 |
# File 'lib/tuile/component/text_view.rb', line 368 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: dispatch delivers keys only along
the focus chain, so this is what a host with focus elsewhere — a chat
transcript under an input field — calls instead of forwarding a
synthetic keystroke that would lie about where focus is.
363 |
# File 'lib/tuile/component/text_view.rb', line 363 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.
691 |
# File 'lib/tuile/component/text_view.rb', line 691 def scroll_top_row_max = (@rows.size - ).clamp(0, nil) |
#scrollbar_columns ⇒ Integer
Columns the scrollbar claims off the right edge: the bar itself plus one
blank column, so a row wrapping at the full width doesn't run into █
(…to show the█). 0 when the bar is hidden. Only the blank is this
view's to paint — #paintable_row emits it, the child
VerticalScrollBar owns the column beyond it.
The blank is dropped below width 3, where reserving it would leave no
column for text at all — that keeps row and bar together covering
exactly Tuile::Component#rect.width columns at every width.
@return — 0, 1 or 2.
831 832 833 834 835 |
# File 'lib/tuile/component/text_view.rb', line 831 def return 0 unless rect.width >= 3 ? 2 : 1 end |
#scrollbar_visible? ⇒ Boolean
885 886 887 888 889 890 891 892 893 |
# File 'lib/tuile/component/text_view.rb', line 885 def return false if rect.empty? case @scrollbar_visibility when :visible then true when :auto then @auto_scrollbar_shown else false end 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
533 534 535 536 537 538 539 540 541 542 543 544 |
# File 'lib/tuile/component/text_view.rb', line 533 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) content_changed 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
766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 |
# File 'lib/tuile/component/text_view.rb', line 766 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 |
#sync_auto_scrollbar ⇒ void
This method returns an undefined value.
The sole writer of @auto_scrollbar_shown: shows the bar iff the text
overflows the viewport at the full width, and rewraps when that
flips. Deciding at the full width alone is what settles it in one
step — the answer does not depend on the width the bar leaves, so
showing the bar can't flip it back. Idempotent.
901 902 903 904 905 906 907 908 909 910 |
# File 'lib/tuile/component/text_view.rb', line 901 def return unless @scrollbar_visibility == :auto shown = !rect.empty? && overflows_at_full_width? return if shown == @auto_scrollbar_shown @auto_scrollbar_shown = shown rewrap invalidate end |
#tab_stop? ⇒ Boolean
372 |
# File 'lib/tuile/component/text_view.rb', line 372 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).
79 80 81 |
# File 'lib/tuile/component/text_view.rb', line 79 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
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/tuile/component/text_view.rb', line 115 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 invalidate_layout end |
#text_for_region(region) ⇒ StyledString
Joined StyledString of the hard lines that region owns. Mirrors
#text but scoped to one region.
@param region
511 512 513 514 515 516 517 518 519 520 521 522 523 524 |
# File 'lib/tuile/component/text_view.rb', line 511 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
654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 |
# File 'lib/tuile/component/text_view.rb', line 654 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.
873 874 875 876 877 878 |
# File 'lib/tuile/component/text_view.rb', line 873 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.
688 |
# File 'lib/tuile/component/text_view.rb', line 688 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| l.ljust(width) }
would have produced.
@param line
@param width
727 728 729 730 731 732 |
# File 'lib/tuile/component/text_view.rb', line 727 def wrap_line(line, width) return [[@blank_row], 1] if line.empty? || width <= 0 wrapped = line.wrap(width) [wrapped.map { |row| row.ljust(width) }, wrapped.size] end |
#wrap_width ⇒ Integer
@return — column width available for wrapped text — viewport
width minus #scrollbar_columns. 0 when Tuile::Component#rect's width is
non-positive, which yields a degenerate "no wrap" result.
815 816 817 818 819 |
# File 'lib/tuile/component/text_view.rb', line 815 def wrap_width return 0 if rect.width <= 0 rect.width - end |