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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/tuile/color.rb +127 -0
- data/lib/tuile/component/label.rb +33 -3
- data/lib/tuile/component/text_view.rb +693 -54
- data/lib/tuile/event_queue.rb +104 -9
- data/lib/tuile/fake_event_queue.rb +69 -0
- data/lib/tuile/screen.rb +2 -0
- data/lib/tuile/styled_string.rb +28 -61
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +533 -52
- metadata +2 -1
|
@@ -31,20 +31,36 @@ module Tuile
|
|
|
31
31
|
class TextView < Component
|
|
32
32
|
def initialize
|
|
33
33
|
super
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
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
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 =
|
|
124
|
-
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
#
|
|
320
|
-
#
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
# {@top_line} if the new line count puts it out of
|
|
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
|
-
@
|
|
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
|
|
335
|
-
#
|
|
336
|
-
# and degenerate `width <= 0` both emit a single {@blank_line}
|
|
337
|
-
# matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
|
|
338
|
-
# would have produced
|
|
339
|
-
# @param hard_line [StyledString]
|
|
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 [
|
|
342
|
-
def
|
|
343
|
-
if hard_line.empty? || width <= 0
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
#
|
|
351
|
-
#
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
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
|