tuile 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,20 +31,36 @@ module Tuile
31
31
  class TextView < Component
32
32
  def initialize
33
33
  super
34
- # `@hard_lines` is the logical model one entry per `\n`-delimited
35
- # line of the original text, width-independent. `@physical_lines` is
36
- # the rendered view each hard line word-wrapped to `wrap_width`
37
- # and padded with trailing blanks, so painting a row is a lookup.
38
- # Resizing rebuilds `@physical_lines` from `@hard_lines`; `#append`
39
- # extends both.
34
+ # Three parallel structures, kept in lockstep by every mutator:
35
+ # `@hard_lines` is the logical model (one entry per `\n`-delimited
36
+ # line, width-independent); `@physical_lines` is the rendered view
37
+ # (each hard line word-wrapped to `wrap_width` and padded with
38
+ # trailing blanks, so painting a row is a lookup); and
39
+ # `@hard_line_wrap_counts` is an Integer-per-hard-line cache of
40
+ # how many physical rows each hard line occupies, so a mid-buffer
41
+ # splice can find its starting physical-row offset without
42
+ # re-wrapping every preceding hard line.
43
+ #
44
+ # Invariants:
45
+ # - `@hard_line_wrap_counts.size == @hard_lines.size`
46
+ # - `@hard_line_wrap_counts.sum == @physical_lines.size`
47
+ # A full rebuild ({#rewrap}) happens on {#text=} and width changes;
48
+ # other mutators splice incrementally.
40
49
  @hard_lines = []
41
50
  @physical_lines = []
51
+ @hard_line_wrap_counts = []
42
52
  @text = StyledString::EMPTY
43
53
  @content_size = Size::ZERO
44
54
  @blank_line = StyledString::EMPTY
45
55
  @top_line = 0
46
56
  @auto_scroll = false
47
57
  @scrollbar_visibility = :gone
58
+ # The view always has at least one region — an implicit default. It
59
+ # owns whatever hard lines exist that no later region claims. App
60
+ # code that never calls {#create_region} sees the same behavior as
61
+ # before (a single region containing everything); apps that do call
62
+ # {#create_region} stack additional regions at the spatial tail.
63
+ @regions = [Region.send(:new, self)]
48
64
  end
49
65
 
50
66
  # @return [StyledString] the current text. Defaults to an empty
@@ -72,20 +88,51 @@ module Tuile
72
88
  # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
73
89
  # honored); a `StyledString` is used as-is; `nil` is coerced to an
74
90
  # empty {StyledString}.
91
+ #
92
+ # Detaches every existing {Region} (including the original default)
93
+ # and installs a fresh internal default region that owns all the new
94
+ # hard lines. Any handle the caller was holding becomes detached and
95
+ # raises on use — see {Region#attached?}. The no-op short-circuit
96
+ # (matching value, same {StyledString}) preserves existing regions.
75
97
  # @param value [String, StyledString, nil]
76
98
  # @return [void]
77
99
  def text=(value)
78
100
  new_text = StyledString.parse(value)
79
- return if text == new_text
101
+ content_unchanged = text == new_text
80
102
 
103
+ # `text=` is a structural reset: even when the new content matches
104
+ # the old, existing region handles must die — the caller said "set
105
+ # the text," not "merge with what's there." The unchanged-content
106
+ # path still skips the expensive rewrap / invalidate work.
81
107
  @text = new_text
82
108
  @hard_lines = new_text.empty? ? [] : new_text.lines
109
+ @regions.each { |r| r.send(:detach!) }
110
+ @regions = [Region.send(:new, self, @hard_lines.size)]
111
+ return if content_unchanged
112
+
83
113
  @content_size = compute_content_size
84
114
  rewrap
85
115
  update_top_line_if_auto_scroll
86
116
  invalidate
87
117
  end
88
118
 
119
+ # Creates a new empty {Region} at the spatial tail of the document
120
+ # and returns its handle. Subsequent {#append} / {#<<} / {#add_line}
121
+ # calls route through this new region (since it is now the spatial
122
+ # tail). Earlier regions keep their content and their handles stay
123
+ # valid; their {Region#range} shifts as later regions grow.
124
+ #
125
+ # Apps streaming logically-distinct sections (e.g. an LLM's "thinking"
126
+ # vs. "assistant" output) create one region per section, hold the
127
+ # handles, and call `region.append` / `region.text=` directly when
128
+ # they need to grow or rewrite an earlier section.
129
+ # @return [Region]
130
+ def create_region
131
+ region = Region.send(:new, self)
132
+ @regions << region
133
+ region
134
+ end
135
+
89
136
  # @return [Boolean] true iff {#text} is empty (no hard lines).
90
137
  def empty? = @hard_lines.empty?
91
138
 
@@ -109,29 +156,29 @@ module Tuile
109
156
  appended = StyledString.parse(str)
110
157
  return if appended.empty?
111
158
 
159
+ tail_region = @regions.last
160
+ tail_was_empty = tail_region.empty?
112
161
  new_segments = appended.lines
113
162
  width = wrap_width
114
163
 
115
- if empty?
116
- new_segments.each do |hl|
117
- @hard_lines << hl
118
- append_physical_lines(hl, width)
119
- end
164
+ if tail_was_empty
165
+ # An empty spatial-tail region (either a fresh buffer, or an empty
166
+ # region the app created at the tail) means new content starts on
167
+ # a fresh hard line — we must not extend the previous region's
168
+ # last line.
169
+ new_segments.each { |hl| push_hard_line(hl, width) }
170
+ added = new_segments.size
120
171
  else
121
172
  extension = new_segments.first
122
173
  unless extension.empty?
123
- old_last = @hard_lines.pop
124
- drop_physical_rows_for(old_last, width)
125
- extended = old_last + extension
126
- @hard_lines << extended
127
- append_physical_lines(extended, width)
128
- end
129
- new_segments[1..].each do |hl|
130
- @hard_lines << hl
131
- append_physical_lines(hl, width)
174
+ old_last = pop_hard_line
175
+ push_hard_line(old_last + extension, width)
132
176
  end
177
+ new_segments[1..].each { |hl| push_hard_line(hl, width) }
178
+ added = new_segments.size - 1
133
179
  end
134
180
 
181
+ tail_region.send(:line_count=, tail_region.line_count + added)
135
182
  @text = nil
136
183
  @content_size = compute_content_size
137
184
  update_top_line_if_auto_scroll
@@ -156,7 +203,11 @@ module Tuile
156
203
  # @return [void]
157
204
  def add_line(str)
158
205
  parsed = StyledString.parse(str)
159
- if empty?
206
+ if empty? || @regions.last.empty?
207
+ # No previous line in the tail region to break away from — just
208
+ # append. (If the tail region is empty but earlier regions have
209
+ # content, the verbatim {#append} path already starts a fresh
210
+ # hard line in the tail.)
160
211
  append(parsed)
161
212
  else
162
213
  append(StyledString.plain("\n") + parsed)
@@ -187,11 +238,19 @@ module Tuile
187
238
  screen.check_locked
188
239
  return if n.zero? || empty?
189
240
 
190
- width = wrap_width
191
241
  to_drop = [n, @hard_lines.size].min
192
- to_drop.times do
193
- popped = @hard_lines.pop
194
- drop_physical_rows_for(popped, width)
242
+ to_drop.times { pop_hard_line }
243
+
244
+ # Cascade-shrink regions from the spatial tail. The tail region
245
+ # gives up lines first; if more are still owed (because the tail
246
+ # was shorter than `to_drop`), earlier regions shrink in turn.
247
+ remaining = to_drop
248
+ @regions.reverse_each do |region|
249
+ break if remaining.zero?
250
+
251
+ take = [remaining, region.line_count].min
252
+ region.send(:line_count=, region.line_count - take)
253
+ remaining -= take
195
254
  end
196
255
 
197
256
  @text = nil
@@ -201,6 +260,79 @@ module Tuile
201
260
  invalidate
202
261
  end
203
262
 
263
+ # Replaces a contiguous range of hard lines with the parsed content
264
+ # of `str`. The replacement is parsed exactly like {#text=} and
265
+ # {#append}: a {String} is run through {StyledString.parse} (so
266
+ # embedded ANSI is honored), a {StyledString} is used as-is, `nil`
267
+ # behaves like an empty replacement (the range is deleted). Embedded
268
+ # `"\n"` in the replacement produces multiple hard lines, so a single
269
+ # `replace` can grow or shrink the buffer.
270
+ #
271
+ # `range` selects which hard lines to swap out:
272
+ #
273
+ # - an `Integer` `n` is shorthand for `n..n` (replace one existing
274
+ # line — `n` must be in `[0, hard-line count)`);
275
+ # - a non-empty `Range` of hard-line indices replaces those lines;
276
+ # - an empty `Range` (e.g. `2...2`, or the canonical end-insertion
277
+ # `hard_lines.size...hard_lines.size`) is *insertion* at that
278
+ # position — no lines are removed. {#insert} is a thin alias for
279
+ # this case.
280
+ #
281
+ # Endpoints must be non-negative integers; `begin` may equal
282
+ # `hard-line count` (insertion at the end), `end` may not exceed
283
+ # `hard-line count - 1`. `nil` endpoints (beginless / endless ranges)
284
+ # are not accepted.
285
+ #
286
+ # Cost is roughly `O(from + length + new content)`: the splice
287
+ # updates only the affected slice of the physical-row buffer, using
288
+ # the per-hard-line wrap-count cache to locate the starting offset
289
+ # without re-wrapping preceding lines. Lines outside the splice are
290
+ # never re-wrapped. {#top_line} is clamped if the new line count
291
+ # puts it past the end; {#auto_scroll} pins it to the bottom as
292
+ # usual. The call is a no-op (no invalidation) when the parsed
293
+ # replacement equals the covered range (vacuously true for an empty
294
+ # range plus empty replacement, so `replace(n...n, "")` is a cheap
295
+ # no-op).
296
+ #
297
+ # @param range [Range, Integer] hard-line indices to replace.
298
+ # @param str [String, StyledString, nil] replacement content.
299
+ # @raise [TypeError] if `range` is neither an `Integer` nor a `Range`,
300
+ # or if a `Range` endpoint is not an `Integer`, or if `str` is not
301
+ # a `String`, `StyledString`, or `nil`.
302
+ # @raise [ArgumentError] if `range` has a negative endpoint, extends
303
+ # past the last hard line, or is malformed (`end` more than one
304
+ # below `begin`).
305
+ # @return [void]
306
+ def replace(range, str)
307
+ screen.check_locked
308
+ from, to = normalize_replace_range(range)
309
+
310
+ parsed = StyledString.parse(str)
311
+ new_hard_lines = parsed.empty? ? [] : parsed.lines
312
+ length = to - from + 1
313
+ return if new_hard_lines == @hard_lines[from, length]
314
+
315
+ splice_hard_lines(from, length, new_hard_lines)
316
+ update_region_counts(from, length, new_hard_lines.size)
317
+ @text = nil
318
+ @content_size = compute_content_size
319
+ @top_line = top_line_max if @top_line > top_line_max
320
+ update_top_line_if_auto_scroll
321
+ invalidate
322
+ end
323
+
324
+ # Inserts `str` at hard-line index `at`. Equivalent to
325
+ # `replace(at...at, str)` — a no-removal splice that grows the buffer
326
+ # by the parsed line count. `at == hard-line count` is allowed and
327
+ # appends at the end; for that case {#append} / {#add_line} are
328
+ # usually more idiomatic.
329
+ # @param at [Integer] 0-based hard-line index in `[0, hard-line count]`.
330
+ # @param str [String, StyledString, nil] content to insert.
331
+ # @return [void]
332
+ def insert(at, str)
333
+ replace(at...at, str)
334
+ end
335
+
204
336
  # Clears the text. Equivalent to `text = ""`.
205
337
  # @return [void]
206
338
  def clear
@@ -310,54 +442,365 @@ module Tuile
310
442
 
311
443
  private
312
444
 
445
+ # Validates and unpacks a {#replace}-style range argument into
446
+ # inclusive `[from, to]` line indices. An `Integer` `n` becomes
447
+ # `[n, n]` (which must point at an existing line — `Integer` is
448
+ # never insertion sugar). A `Range` is normalized for
449
+ # `exclude_end?`; `to == from - 1` is a valid empty range
450
+ # (insertion at `from`), and `from` may equal `size` for
451
+ # end-insertion. Shared by {#replace} and {Region#replace};
452
+ # `size` is the buffer or region line count, and `what` is the
453
+ # entity name woven into error messages.
454
+ # @param range [Range, Integer]
455
+ # @param size [Integer]
456
+ # @param what [String]
457
+ # @return [Array(Integer, Integer)]
458
+ def normalize_replace_range(range, size = @hard_lines.size, what = "the buffer")
459
+ case range
460
+ when Integer
461
+ from = to = range
462
+ when Range
463
+ from = range.begin
464
+ raw_end = range.end
465
+ unless from.is_a?(Integer) && raw_end.is_a?(Integer)
466
+ raise TypeError, "range endpoints must be Integers, got #{range.inspect}"
467
+ end
468
+
469
+ to = range.exclude_end? ? raw_end - 1 : raw_end
470
+ else
471
+ raise TypeError, "expected Range or Integer, got #{range.inspect}"
472
+ end
473
+ raise ArgumentError, "range endpoints must not be negative, got #{range.inspect}" if from.negative?
474
+ if from > size || to >= size
475
+ raise ArgumentError, "range #{range.inspect} out of bounds for #{what} (#{size} hard line(s))"
476
+ end
477
+ raise ArgumentError, "range #{range.inspect} is malformed (end more than one below begin)" if to < from - 1
478
+
479
+ [from, to]
480
+ end
481
+
482
+ # Hard-line index where `region` begins in {@hard_lines} — derived
483
+ # by summing the line counts of all regions that precede it.
484
+ # @param region [Region]
485
+ # @return [Integer]
486
+ def region_start_index(region)
487
+ idx = @regions.index(region)
488
+ raise "region not found in view" unless idx
489
+
490
+ sum = 0
491
+ idx.times { |i| sum += @regions[i].line_count }
492
+ sum
493
+ end
494
+
495
+ # Joined {StyledString} of the hard lines that `region` owns. Mirrors
496
+ # {#text} but scoped to one region.
497
+ # @param region [Region]
498
+ # @return [StyledString]
499
+ def text_for_region(region)
500
+ start = region_start_index(region)
501
+ count = region.line_count
502
+ return StyledString::EMPTY if count.zero?
503
+ return @hard_lines[start] if count == 1
504
+
505
+ newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
506
+ spans = []
507
+ count.times do |i|
508
+ spans << newline if i.positive?
509
+ spans.concat(@hard_lines[start + i].spans)
510
+ end
511
+ StyledString.new(spans)
512
+ end
513
+
514
+ # Replaces all of `region`'s hard lines with the parsed content of
515
+ # `value`. Symmetric with {#text=}, scoped to one region. Empty/nil
516
+ # content empties the region (no visible blank line). Works on
517
+ # already-empty regions (insertion at the region's position).
518
+ # @param region [Region]
519
+ # @param value [String, StyledString, nil]
520
+ # @return [void]
521
+ def set_region_text(region, value)
522
+ screen.check_locked
523
+ parsed = StyledString.parse(value)
524
+ new_lines = parsed.empty? ? [] : parsed.lines
525
+ start = region_start_index(region)
526
+ old_count = region.line_count
527
+ return if new_lines == @hard_lines[start, old_count]
528
+
529
+ splice_hard_lines(start, old_count, new_lines)
530
+ region.send(:line_count=, new_lines.size)
531
+ @text = nil
532
+ @content_size = compute_content_size
533
+ @top_line = top_line_max if @top_line > top_line_max
534
+ update_top_line_if_auto_scroll
535
+ invalidate
536
+ end
537
+
538
+ # Region-scoped {#replace}. Validates `range` against
539
+ # `region.line_count`, translates region-relative indices to
540
+ # absolute buffer indices, splices, and updates the region's count.
541
+ # @param region [Region]
542
+ # @param range [Range, Integer]
543
+ # @param str [String, StyledString, nil]
544
+ # @return [void]
545
+ def replace_in_region(region, range, str)
546
+ screen.check_locked
547
+ from, to = normalize_replace_range(range, region.line_count, "the region")
548
+ parsed = StyledString.parse(str)
549
+ new_hard_lines = parsed.empty? ? [] : parsed.lines
550
+ start = region_start_index(region)
551
+ abs_from = start + from
552
+ length = to - from + 1
553
+ return if new_hard_lines == @hard_lines[abs_from, length]
554
+
555
+ splice_hard_lines(abs_from, length, new_hard_lines)
556
+ region.send(:line_count=, region.line_count - length + new_hard_lines.size)
557
+ @text = nil
558
+ @content_size = compute_content_size
559
+ @top_line = top_line_max if @top_line > top_line_max
560
+ update_top_line_if_auto_scroll
561
+ invalidate
562
+ end
563
+
564
+ # Verbatim append into `region`.
565
+ # @param region [Region]
566
+ # @param str [String, StyledString, nil]
567
+ # @return [void]
568
+ def append_to_region(region, str)
569
+ screen.check_locked
570
+ parsed = StyledString.parse(str)
571
+ return if parsed.empty?
572
+
573
+ if region.equal?(@regions.last)
574
+ append(parsed)
575
+ return
576
+ end
577
+
578
+ new_segments = parsed.lines
579
+ start = region_start_index(region)
580
+ if region.empty?
581
+ splice_hard_lines(start, 0, new_segments)
582
+ region.send(:line_count=, new_segments.size)
583
+ else
584
+ last_idx = start + region.line_count - 1
585
+ extension = new_segments.first
586
+ rest = new_segments[1..]
587
+ if extension.empty?
588
+ return if rest.empty?
589
+
590
+ splice_hard_lines(last_idx + 1, 0, rest)
591
+ else
592
+ extended = @hard_lines[last_idx] + extension
593
+ splice_hard_lines(last_idx, 1, [extended, *rest])
594
+ end
595
+ region.send(:line_count=, region.line_count + rest.size)
596
+ end
597
+ @text = nil
598
+ @content_size = compute_content_size
599
+ @top_line = top_line_max if @top_line > top_line_max
600
+ update_top_line_if_auto_scroll
601
+ invalidate
602
+ end
603
+
604
+ # Drops the last `n` hard lines from `region`'s tail via
605
+ # {#splice_hard_lines}. `n` is clamped to the region's current
606
+ # line count; callers guarantee `n > 0` and the region is
607
+ # non-empty (the {Region#remove_last_n_lines} guard handles the
608
+ # no-op cases).
609
+ # @param region [Region]
610
+ # @param n [Integer]
611
+ # @return [void]
612
+ def remove_last_n_from_region(region, n)
613
+ screen.check_locked
614
+ to_drop = [n, region.line_count].min
615
+ start = region_start_index(region)
616
+ drop_from = start + region.line_count - to_drop
617
+ splice_hard_lines(drop_from, to_drop, [])
618
+ region.send(:line_count=, region.line_count - to_drop)
619
+ @text = nil
620
+ @content_size = compute_content_size
621
+ @top_line = top_line_max if @top_line > top_line_max
622
+ update_top_line_if_auto_scroll
623
+ invalidate
624
+ end
625
+
626
+ # Drops `region` from {@regions}: its hard lines are removed via
627
+ # {#splice_hard_lines}, the handle is detached, and the always-one
628
+ # default is restored if the removal would have left zero regions.
629
+ # Skips the rewrap / invalidate work when the region was empty
630
+ # (the buffer didn't change), but always detaches.
631
+ # @param region [Region]
632
+ # @return [void]
633
+ def remove_region(region)
634
+ screen.check_locked
635
+ had_lines = region.line_count.positive?
636
+ if had_lines
637
+ start = region_start_index(region)
638
+ splice_hard_lines(start, region.line_count, [])
639
+ end
640
+ @regions.delete(region)
641
+ region.send(:detach!)
642
+ @regions << Region.send(:new, self) if @regions.empty?
643
+ return unless had_lines
644
+
645
+ @text = nil
646
+ @content_size = compute_content_size
647
+ @top_line = top_line_max if @top_line > top_line_max
648
+ update_top_line_if_auto_scroll
649
+ invalidate
650
+ end
651
+
652
+ # Adjusts region line counts after a {@hard_lines} splice that
653
+ # removed `removed_count` lines at index `from` and inserted
654
+ # `added_count` in their place. Two passes:
655
+ #
656
+ # 1. Subtract each region's overlap with the removed range (uses
657
+ # the original counts to compute positions). Remember the first
658
+ # region that lost lines — that's the natural home for the
659
+ # replacement content.
660
+ # 2. Credit `added_count` to that region. For pure insertions (no
661
+ # removal), there's no "first overlapping region" to pick from;
662
+ # walk regions and credit the latest one starting at `from` (the
663
+ # boundary tiebreaker matches the spatial-tail-routing of
664
+ # {#append}). Past-the-end inserts fall back to the tail region.
665
+ # @param from [Integer]
666
+ # @param removed_count [Integer]
667
+ # @param added_count [Integer]
668
+ # @return [void]
669
+ def update_region_counts(from, removed_count, added_count)
670
+ target = nil
671
+ pos = 0
672
+ @regions.each do |region|
673
+ original_count = region.line_count
674
+ overlap_start = [from, pos].max
675
+ overlap_end = [from + removed_count, pos + original_count].min
676
+ overlap = overlap_end - overlap_start
677
+ if overlap.positive?
678
+ region.send(:line_count=, original_count - overlap)
679
+ target ||= region
680
+ end
681
+ pos += original_count
682
+ end
683
+ return if added_count.zero?
684
+
685
+ if target.nil?
686
+ pos = 0
687
+ @regions.each do |region|
688
+ region_end_exclusive = pos + region.line_count
689
+ if from == pos
690
+ target = region
691
+ elsif from < region_end_exclusive
692
+ target = region
693
+ break
694
+ end
695
+ pos = region_end_exclusive
696
+ end
697
+ target ||= @regions.last
698
+ end
699
+ target.send(:line_count=, target.line_count + added_count)
700
+ end
701
+
313
702
  # @return [Integer] number of visible lines.
314
703
  def viewport_lines = rect.height
315
704
 
316
705
  # @return [Integer] the max value of {#top_line} for scroll-key clamping.
317
706
  def top_line_max = (@physical_lines.size - viewport_lines).clamp(0, nil)
318
707
 
319
- # Recomputes {@physical_lines} for the current text and wrap width,
320
- # pre-padding every line to `wrap_width` so {#paintable_line} is just
321
- # a lookup + optional scrollbar-char append at paint time (and the
322
- # rendered ANSI is cached on each line via {StyledString#to_ansi}'s
323
- # memoization, so re-painting on scroll is near-free). Clamps
324
- # {@top_line} if the new line count puts it out of range.
708
+ # Full rebuild of {@physical_lines} and {@hard_line_wrap_counts}
709
+ # from {@hard_lines}. Called when wrap width changes (which
710
+ # invalidates every cached row count) and from {#text=} (which
711
+ # replaces the whole logical model). Mid-buffer mutators splice
712
+ # incrementally via {#splice_hard_lines} and do *not* go through
713
+ # here. Clamps {@top_line} if the new line count puts it out of
714
+ # range.
325
715
  # @return [void]
326
716
  def rewrap
327
717
  width = wrap_width
328
718
  @blank_line = pad_to(StyledString::EMPTY, width)
329
719
  @physical_lines = []
330
- @hard_lines.each { |hl| append_physical_lines(hl, width) }
720
+ @hard_line_wrap_counts = []
721
+ @hard_lines.each do |hl|
722
+ rows, n = wrap_hard_line(hl, width)
723
+ @physical_lines.concat(rows)
724
+ @hard_line_wrap_counts << n
725
+ end
331
726
  @top_line = top_line_max if @top_line > top_line_max
332
727
  end
333
728
 
334
- # Wraps `hard_line` at `width` and appends the padded physical lines
335
- # to {@physical_lines}. Empty hard lines (e.g. from a `"\n\n"` run)
336
- # and degenerate `width <= 0` both emit a single {@blank_line} row,
337
- # matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
338
- # would have produced for those cases.
339
- # @param hard_line [StyledString] one hard-broken line (no embedded `"\n"`).
729
+ # Wraps `hard_line` at `width` and returns the padded physical rows
730
+ # alongside the row count. Empty hard lines (e.g. from a `"\n\n"`
731
+ # run) and degenerate `width <= 0` both emit a single {@blank_line}
732
+ # row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
733
+ # would have produced.
734
+ # @param hard_line [StyledString]
340
735
  # @param width [Integer]
341
- # @return [void]
342
- def append_physical_lines(hard_line, width)
343
- if hard_line.empty? || width <= 0
344
- @physical_lines << @blank_line
345
- else
346
- hard_line.wrap(width).each { |line| @physical_lines << pad_to(line, width) }
347
- end
736
+ # @return [Array(Array<StyledString>, Integer)]
737
+ def wrap_hard_line(hard_line, width)
738
+ return [[@blank_line], 1] if hard_line.empty? || width <= 0
739
+
740
+ wrapped = hard_line.wrap(width)
741
+ [wrapped.map { |line| pad_to(line, width) }, wrapped.size]
348
742
  end
349
743
 
350
- # Pops from {@physical_lines} the rows that `hard_line` previously
351
- # contributed (the inverse of {#append_physical_lines} for the same
352
- # input). Used by {#append} when extending the last hard line: its
353
- # old wrapped rows are dropped, then the extended hard line is
354
- # re-wrapped and appended.
744
+ # Appends `hard_line` to the tail of {@hard_lines}, updating the
745
+ # wrap-count cache and {@physical_lines} in lockstep.
355
746
  # @param hard_line [StyledString]
356
747
  # @param width [Integer]
357
748
  # @return [void]
358
- def drop_physical_rows_for(hard_line, width)
359
- count = hard_line.empty? || width <= 0 ? 1 : hard_line.wrap(width).size
360
- count.times { @physical_lines.pop }
749
+ def push_hard_line(hard_line, width)
750
+ rows, n = wrap_hard_line(hard_line, width)
751
+ @hard_lines << hard_line
752
+ @hard_line_wrap_counts << n
753
+ @physical_lines.concat(rows)
754
+ end
755
+
756
+ # Pops the last hard line, the corresponding cache entry, and the
757
+ # physical rows that hard line contributed. Returns the popped
758
+ # hard line.
759
+ # @return [StyledString]
760
+ def pop_hard_line
761
+ n = @hard_line_wrap_counts.pop
762
+ n.times { @physical_lines.pop }
763
+ @hard_lines.pop
764
+ end
765
+
766
+ # Splices `new_hard_lines` into the buffer in place of the `count`
767
+ # hard lines starting at index `from`. Updates {@hard_lines},
768
+ # {@hard_line_wrap_counts}, and {@physical_lines} consistently.
769
+ # The starting physical-row offset is computed in O(`from`) integer
770
+ # adds via the cache — no wraps of preceding hard lines. Wraps are
771
+ # done only for the new content, so total cost is
772
+ # `O(from + count + new_hard_lines.sum(&:display_width))`.
773
+ # @param from [Integer]
774
+ # @param count [Integer] number of existing hard lines to remove.
775
+ # @param new_hard_lines [Array<StyledString>]
776
+ # @return [void]
777
+ def splice_hard_lines(from, count, new_hard_lines)
778
+ width = wrap_width
779
+ phys_start = phys_offset_at(from)
780
+ old_phys_count = @hard_line_wrap_counts[from, count].sum
781
+
782
+ @hard_lines[from, count] = new_hard_lines
783
+
784
+ new_rows = []
785
+ new_counts = []
786
+ new_hard_lines.each do |hl|
787
+ rows, n = wrap_hard_line(hl, width)
788
+ new_rows.concat(rows)
789
+ new_counts << n
790
+ end
791
+
792
+ @hard_line_wrap_counts[from, count] = new_counts
793
+ @physical_lines[phys_start, old_phys_count] = new_rows
794
+ end
795
+
796
+ # @param idx [Integer]
797
+ # @return [Integer] the {@physical_lines} index where the hard line
798
+ # at {@hard_lines}`[idx]` starts. O(`idx`) integer adds via the
799
+ # wrap-count cache.
800
+ def phys_offset_at(idx)
801
+ return 0 if idx.zero?
802
+
803
+ @hard_line_wrap_counts[0, idx].sum
361
804
  end
362
805
 
363
806
  # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
@@ -451,6 +894,202 @@ module Tuile
451
894
 
452
895
  line.to_ansi + scrollbar.scrollbar_char(row_in_viewport)
453
896
  end
897
+
898
+ # A logical section of a {TextView}'s text — a contiguous run of
899
+ # hard lines the app wants to address as a unit (e.g. an LLM's
900
+ # "thinking" output vs. its assistant message). The view always
901
+ # has at least one region, an internal default that owns whatever
902
+ # hard lines aren't claimed by an app-created region.
903
+ #
904
+ # Apps don't construct regions directly; call {TextView#create_region}
905
+ # to get one. The handle stays valid as long as the region is
906
+ # attached — i.e. until {TextView#text=} (or {TextView#clear}) wipes
907
+ # the slate and installs a fresh internal default. Detached regions
908
+ # raise {RuntimeError} on every mutator and reader.
909
+ #
910
+ # A region's position is derived from its sibling order and counts,
911
+ # so growing or shrinking an earlier region implicitly shifts the
912
+ # ranges of all later regions. Empty regions occupy zero rows but
913
+ # still hold a position in the sequence; `region.text = ""` collapses
914
+ # a region's visible footprint without detaching it. Pre-creating
915
+ # empty placeholder regions is supported and is the natural pattern
916
+ # for "I'll fill this in later" layouts.
917
+ class Region
918
+ # @param view [TextView] the owning view (never `nil` at construction).
919
+ # @param line_count [Integer] number of hard lines this region owns.
920
+ def initialize(view, line_count = 0)
921
+ @view = view
922
+ @line_count = line_count
923
+ end
924
+
925
+ private_class_method :new
926
+
927
+ # @return [Integer] number of hard lines this region owns. Safe to
928
+ # read on a detached region (no error raised).
929
+ attr_reader :line_count
930
+
931
+ # @return [Boolean] `true` while the region is owned by its
932
+ # {TextView}. Becomes `false` permanently once detached
933
+ # (typically by {TextView#text=} / {TextView#clear}).
934
+ def attached?
935
+ !@view.nil?
936
+ end
937
+
938
+ # @return [Boolean] true iff the region owns zero hard lines.
939
+ # Empty regions render nothing — they still hold a position in
940
+ # the sequence, so subsequent mutations route to them as usual.
941
+ def empty? = @line_count.zero?
942
+
943
+ # @return [StyledString] the joined content of just this region's
944
+ # hard lines. Empty regions return {StyledString::EMPTY}.
945
+ # @raise [RuntimeError] when the region is detached.
946
+ def text
947
+ check_attached
948
+ @view.send(:text_for_region, self)
949
+ end
950
+
951
+ # Replaces all of this region's hard lines with the parsed content
952
+ # of `value`. Accepts the same inputs as {TextView#text=}; empty
953
+ # or `nil` content collapses the region to zero hard lines.
954
+ # @param value [String, StyledString, nil]
955
+ # @raise [RuntimeError] when the region is detached.
956
+ # @return [void]
957
+ def text=(value)
958
+ check_attached
959
+ @view.send(:set_region_text, self, value)
960
+ end
961
+
962
+ # Verbatim append into this region's tail. Same semantics as
963
+ # {TextView#append} but scoped to the region: embedded `"\n"`
964
+ # creates new hard lines within the region, no-leading-newline
965
+ # input extends the region's last hard line. Empty / `nil` input
966
+ # is a no-op (but still raises when detached). When the region is
967
+ # the spatial tail of the view, this uses the incremental
968
+ # {TextView#append} path; mid-document regions splice the affected
969
+ # slice of the physical-row buffer (lines outside the region are
970
+ # not re-wrapped).
971
+ # @param str [String, StyledString, nil]
972
+ # @raise [RuntimeError] when the region is detached.
973
+ # @return [void]
974
+ def append(str)
975
+ check_attached
976
+ @view.send(:append_to_region, self, str)
977
+ end
978
+ alias << append
979
+
980
+ # @return [Range] the hard-line indices this region currently
981
+ # occupies — `start...(start + line_count)`. Empty regions
982
+ # return a degenerate exclusive range at their position (e.g.
983
+ # `5...5`). The result is computed on each call and so always
984
+ # reflects sibling mutations.
985
+ # @raise [RuntimeError] when the region is detached.
986
+ def range
987
+ check_attached
988
+ start = @view.send(:region_start_index, self)
989
+ start...(start + @line_count)
990
+ end
991
+
992
+ # Removes this region from its view. The region's hard lines (if
993
+ # any) are deleted from the buffer — subsequent regions' ranges
994
+ # shift up by `line_count` — and the handle detaches permanently.
995
+ # The view keeps its always-≥1-region invariant: if this was the
996
+ # only remaining region, a fresh internal default is installed
997
+ # (the app doesn't get a handle to it; call
998
+ # {TextView#create_region} again to start tracking).
999
+ #
1000
+ # Idempotent: calling `remove` on an already-detached region is a
1001
+ # silent no-op (unlike the other mutators, which raise). This
1002
+ # lets cleanup paths blindly call `remove` without first checking
1003
+ # {#attached?}.
1004
+ # @return [void]
1005
+ def remove
1006
+ return unless attached?
1007
+
1008
+ @view.send(:remove_region, self)
1009
+ end
1010
+
1011
+ # Appends `str` as a new entry in this region: starts a fresh
1012
+ # hard line first (when the region is non-empty), then appends
1013
+ # `str`. Scoped equivalent of {TextView#add_line}. On an empty
1014
+ # region behaves like {#append}.
1015
+ # @param str [String, StyledString, nil]
1016
+ # @raise [RuntimeError] when the region is detached.
1017
+ # @return [void]
1018
+ def add_line(str)
1019
+ check_attached
1020
+ parsed = StyledString.parse(str)
1021
+ if empty?
1022
+ append(parsed)
1023
+ else
1024
+ append(StyledString.plain("\n") + parsed)
1025
+ end
1026
+ end
1027
+
1028
+ # Replaces a contiguous range of this region's hard lines with the
1029
+ # parsed content of `str`. Region-scoped counterpart of
1030
+ # {TextView#replace}: indices are 0-based **within the region**
1031
+ # (so `replace(0, "x")` rewrites the region's first line, not
1032
+ # the buffer's). Same range conventions apply — `Integer`,
1033
+ # inclusive/exclusive `Range`, empty range as insertion at
1034
+ # `begin`, and `begin == line_count` for end-insertion.
1035
+ # @param range [Range, Integer] region-relative hard-line indices.
1036
+ # @param str [String, StyledString, nil] replacement content.
1037
+ # @raise [RuntimeError] when the region is detached.
1038
+ # @raise [TypeError] when `range` or `str` has the wrong type.
1039
+ # @raise [ArgumentError] on negative, malformed, or out-of-bounds
1040
+ # ranges.
1041
+ # @return [void]
1042
+ def replace(range, str)
1043
+ check_attached
1044
+ @view.send(:replace_in_region, self, range, str)
1045
+ end
1046
+
1047
+ # Inserts `str` at region-relative hard-line index `at`.
1048
+ # Equivalent to `replace(at...at, str)`. Region-scoped counterpart
1049
+ # of {TextView#insert}; `at == line_count` is allowed and appends
1050
+ # at the region's tail.
1051
+ # @param at [Integer] region-relative index in `[0, line_count]`.
1052
+ # @param str [String, StyledString, nil]
1053
+ # @raise [RuntimeError] when the region is detached.
1054
+ # @return [void]
1055
+ def insert(at, str)
1056
+ replace(at...at, str)
1057
+ end
1058
+
1059
+ # Drops the last `n` hard lines from this region's tail.
1060
+ # Subsequent regions' ranges shift up by the number actually
1061
+ # dropped. `n` is clamped to {#line_count}, so passing a large
1062
+ # `n` empties the region — the handle stays attached (use
1063
+ # {#remove} when the goal is to drop the region itself).
1064
+ # `n == 0` and an already-empty region are no-ops.
1065
+ # @param n [Integer]
1066
+ # @raise [RuntimeError] when the region is detached.
1067
+ # @raise [TypeError] when `n` is not an `Integer`.
1068
+ # @raise [ArgumentError] when `n` is negative.
1069
+ # @return [void]
1070
+ def remove_last_n_lines(n)
1071
+ check_attached
1072
+ raise TypeError, "expected Integer, got #{n.inspect}" unless n.is_a?(Integer)
1073
+ raise ArgumentError, "n must not be negative, got #{n}" if n.negative?
1074
+ return if n.zero? || empty?
1075
+
1076
+ @view.send(:remove_last_n_from_region, self, n)
1077
+ end
1078
+
1079
+ private
1080
+
1081
+ attr_writer :line_count
1082
+
1083
+ # @return [void]
1084
+ def detach!
1085
+ @view = nil
1086
+ end
1087
+
1088
+ # @return [void]
1089
+ def check_attached
1090
+ raise "region is detached" unless attached?
1091
+ end
1092
+ end
454
1093
  end
455
1094
  end
456
1095
  end