tuile 0.3.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.
@@ -12,29 +12,55 @@ module Tuile
12
12
  # ANSI-as-bytes wrapping, color does *not* get dropped on continuation
13
13
  # rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
14
14
  # so embedded ANSI is honored) or a {StyledString} directly; {#text}
15
- # always returns the {StyledString}. Use {#append} for incremental "log
16
- # line" style updates; turn on {#auto_scroll} to keep the latest content
17
- # in view.
15
+ # always returns the {StyledString}.
16
+ #
17
+ # For incremental updates pick the right primitive: {#append} (aliased
18
+ # as `<<`) is verbatim and stream-friendly — chunks are concatenated
19
+ # straight onto the buffer, with embedded `\n` becoming hard breaks.
20
+ # {#add_line} is the "log entry" convenience — it starts the content on
21
+ # a fresh line by inserting a leading `\n` when the buffer is non-empty.
22
+ # {#remove_last_n_lines} pops hard lines back off the tail — the
23
+ # inverse of building up a region with {#append} / {#add_line}, so a
24
+ # caller streaming reformattable content (e.g. partially-rendered
25
+ # Markdown that may need to retract its last paragraph) can replace
26
+ # the tail without rewriting the whole text. Turn on {#auto_scroll}
27
+ # to keep the latest content in view.
18
28
  #
19
29
  # TextView is meant to be the content of a {Window} — focus indication and
20
30
  # keyboard-hint surfacing rely on the surrounding window chrome.
21
31
  class TextView < Component
22
32
  def initialize
23
33
  super
24
- # `@hard_lines` is the logical model one entry per `\n`-delimited
25
- # line of the original text, width-independent. `@physical_lines` is
26
- # the rendered view each hard line word-wrapped to `wrap_width`
27
- # and padded with trailing blanks, so painting a row is a lookup.
28
- # Resizing rebuilds `@physical_lines` from `@hard_lines`; `#append`
29
- # 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.
30
49
  @hard_lines = []
31
50
  @physical_lines = []
51
+ @hard_line_wrap_counts = []
32
52
  @text = StyledString::EMPTY
33
53
  @content_size = Size::ZERO
34
54
  @blank_line = StyledString::EMPTY
35
55
  @top_line = 0
36
56
  @auto_scroll = false
37
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)]
38
64
  end
39
65
 
40
66
  # @return [StyledString] the current text. Defaults to an empty
@@ -62,53 +88,251 @@ module Tuile
62
88
  # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
63
89
  # honored); a `StyledString` is used as-is; `nil` is coerced to an
64
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.
65
97
  # @param value [String, StyledString, nil]
66
98
  # @return [void]
67
99
  def text=(value)
68
100
  new_text = StyledString.parse(value)
69
- return if text == new_text
101
+ content_unchanged = text == new_text
70
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.
71
107
  @text = new_text
72
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
+
73
113
  @content_size = compute_content_size
74
114
  rewrap
75
115
  update_top_line_if_auto_scroll
76
116
  invalidate
77
117
  end
78
118
 
79
- # Appends `str` as a new physical line. If the current text is empty,
80
- # behaves like `text = str`; otherwise prepends a newline so the new
81
- # content lands on a fresh line. Accepts the same input forms as
82
- # {#text=}.
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
+
136
+ # @return [Boolean] true iff {#text} is empty (no hard lines).
137
+ def empty? = @hard_lines.empty?
138
+
139
+ # Appends `str` verbatim. Embedded `\n` characters become hard line
140
+ # breaks; otherwise the text is concatenated onto the current last
141
+ # hard line. Designed for streaming use (e.g. an LLM chat window
142
+ # receiving partial messages — feed each chunk straight in). Accepts
143
+ # the same input forms as {#text=}; empty/`nil` input is a no-op.
83
144
  #
84
- # Cost is O(appended) rather than O(total) the existing wrapped
85
- # buffer is reused, only the new hard line(s) are wrapped and padded,
86
- # and `@content_size` is updated incrementally. The cached
87
- # {#text} is invalidated and rebuilt on demand.
145
+ # For the "add an entry on a new line" pattern use {#add_line}.
146
+ #
147
+ # Cost is O(appended + width-of-current-last-hard-line) — the
148
+ # previously last hard line is re-wrapped (because the extension may
149
+ # cause it to wrap differently), any additional hard lines created by
150
+ # embedded `\n` are wrapped fresh. The cached {#text} is invalidated
151
+ # and rebuilt on demand.
88
152
  # @param str [String, StyledString, nil]
89
153
  # @return [void]
90
154
  def append(str)
91
155
  screen.check_locked
92
156
  appended = StyledString.parse(str)
93
- if @hard_lines.empty?
94
- self.text = appended
95
- return
157
+ return if appended.empty?
158
+
159
+ tail_region = @regions.last
160
+ tail_was_empty = tail_region.empty?
161
+ new_segments = appended.lines
162
+ width = wrap_width
163
+
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
171
+ else
172
+ extension = new_segments.first
173
+ unless extension.empty?
174
+ old_last = pop_hard_line
175
+ push_hard_line(old_last + extension, width)
176
+ end
177
+ new_segments[1..].each { |hl| push_hard_line(hl, width) }
178
+ added = new_segments.size - 1
96
179
  end
97
180
 
98
- new_hard_lines = appended.lines
181
+ tail_region.send(:line_count=, tail_region.line_count + added)
99
182
  @text = nil
100
- @hard_lines.concat(new_hard_lines)
101
- new_width = new_hard_lines.map(&:display_width).max || 0
102
- @content_size = Size.new(
103
- [@content_size.width, new_width].max,
104
- @content_size.height + new_hard_lines.size
105
- )
106
- width = wrap_width
107
- new_hard_lines.each { |hl| append_physical_lines(hl, width) }
183
+ @content_size = compute_content_size
184
+ update_top_line_if_auto_scroll
185
+ invalidate
186
+ end
187
+
188
+ # Verbatim append, returning `self` for chainability (`view << a << b`).
189
+ # @param str [String, StyledString, nil]
190
+ # @return [self]
191
+ def <<(str)
192
+ append(str)
193
+ self
194
+ end
195
+
196
+ # Appends `str` as a new entry: starts a fresh hard line first (when
197
+ # the buffer is non-empty) and then appends `str`. Equivalent to
198
+ # `append("\n" + str)` on a non-empty buffer, or `append(str)` on an
199
+ # empty one. `nil` and `""` produce a blank entry on a non-empty
200
+ # buffer and a no-op on an empty buffer (matches the old `append`
201
+ # semantics for "log line" callers).
202
+ # @param str [String, StyledString, nil]
203
+ # @return [void]
204
+ def add_line(str)
205
+ parsed = StyledString.parse(str)
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.)
211
+ append(parsed)
212
+ else
213
+ append(StyledString.plain("\n") + parsed)
214
+ end
215
+ end
216
+
217
+ # Drops the last `n` hard lines from the buffer. The inverse of
218
+ # building up a tail region with {#append} / {#add_line}: a caller
219
+ # streaming partially-rendered content whose tail must occasionally
220
+ # be retracted (e.g. Markdown-to-ANSI where a new token reformats
221
+ # the table being built) can call `remove_last_n_lines(k)` followed
222
+ # by `append(new_tail)` to replace the damaged region in place.
223
+ #
224
+ # `n == 0` and the empty-buffer case are no-ops (no invalidation).
225
+ # `n >= hard-line count` empties the buffer.
226
+ #
227
+ # Operates on **hard lines** (the `\n`-delimited entries the
228
+ # buffer stores), not on wrapped physical rows — same granularity
229
+ # as {#add_line}. Cost is O(rendered-rows of the popped lines).
230
+ # @param n [Integer] number of hard lines to drop; must be >= 0.
231
+ # @raise [TypeError] if `n` isn't an `Integer`.
232
+ # @raise [ArgumentError] if `n` is negative.
233
+ # @return [void]
234
+ def remove_last_n_lines(n)
235
+ raise TypeError, "expected Integer, got #{n.inspect}" unless n.is_a?(Integer)
236
+ raise ArgumentError, "n must not be negative, got #{n}" if n.negative?
237
+
238
+ screen.check_locked
239
+ return if n.zero? || empty?
240
+
241
+ to_drop = [n, @hard_lines.size].min
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
254
+ end
255
+
256
+ @text = nil
257
+ @content_size = compute_content_size
258
+ @top_line = top_line_max if @top_line > top_line_max
259
+ update_top_line_if_auto_scroll
260
+ invalidate
261
+ end
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
108
320
  update_top_line_if_auto_scroll
109
321
  invalidate
110
322
  end
111
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
+
112
336
  # Clears the text. Equivalent to `text = ""`.
113
337
  # @return [void]
114
338
  def clear
@@ -218,41 +442,365 @@ module Tuile
218
442
 
219
443
  private
220
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
+
221
702
  # @return [Integer] number of visible lines.
222
703
  def viewport_lines = rect.height
223
704
 
224
705
  # @return [Integer] the max value of {#top_line} for scroll-key clamping.
225
706
  def top_line_max = (@physical_lines.size - viewport_lines).clamp(0, nil)
226
707
 
227
- # Recomputes {@physical_lines} for the current text and wrap width,
228
- # pre-padding every line to `wrap_width` so {#paintable_line} is just
229
- # a lookup + optional scrollbar-char append at paint time (and the
230
- # rendered ANSI is cached on each line via {StyledString#to_ansi}'s
231
- # memoization, so re-painting on scroll is near-free). Clamps
232
- # {@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.
233
715
  # @return [void]
234
716
  def rewrap
235
717
  width = wrap_width
236
718
  @blank_line = pad_to(StyledString::EMPTY, width)
237
719
  @physical_lines = []
238
- @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
239
726
  @top_line = top_line_max if @top_line > top_line_max
240
727
  end
241
728
 
242
- # Wraps `hard_line` at `width` and appends the padded physical lines
243
- # to {@physical_lines}. Empty hard lines (e.g. from a `"\n\n"` run)
244
- # and degenerate `width <= 0` both emit a single {@blank_line} row,
245
- # matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
246
- # would have produced for those cases.
247
- # @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]
735
+ # @param width [Integer]
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]
742
+ end
743
+
744
+ # Appends `hard_line` to the tail of {@hard_lines}, updating the
745
+ # wrap-count cache and {@physical_lines} in lockstep.
746
+ # @param hard_line [StyledString]
248
747
  # @param width [Integer]
249
748
  # @return [void]
250
- def append_physical_lines(hard_line, width)
251
- if hard_line.empty? || width <= 0
252
- @physical_lines << @blank_line
253
- else
254
- hard_line.wrap(width).each { |line| @physical_lines << pad_to(line, width) }
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
255
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
256
804
  end
257
805
 
258
806
  # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
@@ -346,6 +894,202 @@ module Tuile
346
894
 
347
895
  line.to_ansi + scrollbar.scrollbar_char(row_in_viewport)
348
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
349
1093
  end
350
1094
  end
351
1095
  end