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