tuile 0.11.0 → 0.12.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 +32 -0
- data/DECISIONS.md +680 -8
- data/README.md +12 -13
- data/TERMINOLOGY.md +61 -0
- data/book/02-repaint.md +1 -1
- data/book/03-layout.md +1 -1
- data/book/06-theming.md +1 -1
- data/book/07-components.md +97 -27
- data/examples/file_commander.rb +5 -4
- data/examples/sampler.rb +38 -1
- data/ideas/new-components.md +9 -4
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +1 -1
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +10 -7
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/list.rb +291 -216
- data/lib/tuile/component/list_dropdown.rb +14 -7
- data/lib/tuile/component/notification.rb +317 -0
- data/lib/tuile/component/picker_window.rb +3 -3
- data/lib/tuile/component/popup.rb +8 -10
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +7 -7
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +79 -273
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_view.rb +191 -177
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +5 -5
- data/lib/tuile/screen.rb +1 -1
- data/lib/tuile/styled_string.rb +12 -12
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/sig/tuile.rbs +788 -377
- metadata +4 -1
|
@@ -26,26 +26,25 @@ module Tuile
|
|
|
26
26
|
def initialize
|
|
27
27
|
super
|
|
28
28
|
# Three parallel structures, kept in lockstep by every mutator:
|
|
29
|
-
# `@
|
|
30
|
-
# line, width-independent); `@
|
|
31
|
-
# (each
|
|
29
|
+
# `@lines` is the logical model (one entry per `\n`-delimited
|
|
30
|
+
# line, width-independent); `@rows` is the rendered view
|
|
31
|
+
# (each line word-wrapped to `wrap_width` and padded with
|
|
32
32
|
# trailing blanks, so painting a row is a lookup); and
|
|
33
|
-
# `@
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# re-wrapping every preceding hard line.
|
|
33
|
+
# `@line_wrap_counts` is an Integer-per-line cache of how many
|
|
34
|
+
# rows each line occupies, so a mid-buffer splice can find its
|
|
35
|
+
# starting row offset without re-wrapping every preceding line.
|
|
37
36
|
#
|
|
38
37
|
# Invariants:
|
|
39
|
-
# - `@
|
|
40
|
-
# - `@
|
|
38
|
+
# - `@line_wrap_counts.size == @lines.size`
|
|
39
|
+
# - `@line_wrap_counts.sum == @rows.size`
|
|
41
40
|
# A full rebuild ({#rewrap}) happens on {#text=} and width changes;
|
|
42
41
|
# other mutators splice incrementally.
|
|
43
|
-
@
|
|
44
|
-
@
|
|
45
|
-
@
|
|
42
|
+
@lines = []
|
|
43
|
+
@rows = []
|
|
44
|
+
@line_wrap_counts = []
|
|
46
45
|
@text = StyledString::EMPTY
|
|
47
|
-
@
|
|
48
|
-
@
|
|
46
|
+
@blank_row = StyledString::EMPTY
|
|
47
|
+
@scroll_top_row = 0
|
|
49
48
|
@auto_scroll = false
|
|
50
49
|
@follow = true
|
|
51
50
|
@scrollbar_visibility = :gone
|
|
@@ -61,8 +60,8 @@ module Tuile
|
|
|
61
60
|
@text ||= build_text
|
|
62
61
|
end
|
|
63
62
|
|
|
64
|
-
# @return [Integer] index of the first visible
|
|
65
|
-
attr_reader :
|
|
63
|
+
# @return [Integer] index of the first visible row.
|
|
64
|
+
attr_reader :scroll_top_row
|
|
66
65
|
|
|
67
66
|
# @return [Symbol] `:gone` or `:visible`.
|
|
68
67
|
attr_reader :scrollbar_visibility
|
|
@@ -101,13 +100,13 @@ module Tuile
|
|
|
101
100
|
# the text," not "merge with what's there." The unchanged-content
|
|
102
101
|
# path still skips the expensive rewrap / invalidate work.
|
|
103
102
|
@text = new_text
|
|
104
|
-
@
|
|
103
|
+
@lines = new_text.empty? ? [] : new_text.lines
|
|
105
104
|
@regions.each { |r| r.send(:detach!) }
|
|
106
|
-
@regions = [Region.send(:new, self, @
|
|
105
|
+
@regions = [Region.send(:new, self, @lines.size)]
|
|
107
106
|
return if content_unchanged
|
|
108
107
|
|
|
109
108
|
rewrap
|
|
110
|
-
|
|
109
|
+
update_scroll_top_row_if_auto_scroll
|
|
111
110
|
invalidate
|
|
112
111
|
end
|
|
113
112
|
|
|
@@ -129,7 +128,7 @@ module Tuile
|
|
|
129
128
|
end
|
|
130
129
|
|
|
131
130
|
# @return [Boolean] true iff {#text} is empty (no hard lines).
|
|
132
|
-
def empty? = @
|
|
131
|
+
def empty? = @lines.empty?
|
|
133
132
|
|
|
134
133
|
# Appends `str` verbatim. Embedded `\n` become hard line breaks; otherwise
|
|
135
134
|
# the text is concatenated onto the current last hard line. Designed for
|
|
@@ -154,21 +153,21 @@ module Tuile
|
|
|
154
153
|
# region the app created at the tail) means new content starts on
|
|
155
154
|
# a fresh hard line — we must not extend the previous region's
|
|
156
155
|
# last line.
|
|
157
|
-
new_segments.each { |
|
|
156
|
+
new_segments.each { |line| push_line(line, width) }
|
|
158
157
|
added = new_segments.size
|
|
159
158
|
else
|
|
160
159
|
extension = new_segments.first
|
|
161
160
|
unless extension.empty?
|
|
162
|
-
old_last =
|
|
163
|
-
|
|
161
|
+
old_last = pop_line
|
|
162
|
+
push_line(old_last + extension, width)
|
|
164
163
|
end
|
|
165
|
-
new_segments[1..].each { |
|
|
164
|
+
new_segments[1..].each { |line| push_line(line, width) }
|
|
166
165
|
added = new_segments.size - 1
|
|
167
166
|
end
|
|
168
167
|
|
|
169
168
|
tail_region.send(:line_count=, tail_region.line_count + added)
|
|
170
169
|
@text = nil
|
|
171
|
-
|
|
170
|
+
update_scroll_top_row_if_auto_scroll
|
|
172
171
|
invalidate
|
|
173
172
|
end
|
|
174
173
|
|
|
@@ -204,7 +203,7 @@ module Tuile
|
|
|
204
203
|
# Drops the last `n` hard lines from the buffer — the inverse of building
|
|
205
204
|
# up a tail with {#append} / {#add_line}, so a caller can `remove` then
|
|
206
205
|
# `append` to rewrite a damaged tail in place. Operates on **hard lines**
|
|
207
|
-
# (the `\n`-delimited entries), not wrapped
|
|
206
|
+
# (the `\n`-delimited entries), not wrapped rows. `n == 0` and the
|
|
208
207
|
# empty buffer are no-ops; `n >= hard-line count` empties the buffer.
|
|
209
208
|
# @param n [Integer] number of hard lines to drop; must be >= 0.
|
|
210
209
|
# @raise [TypeError] if `n` isn't an `Integer`.
|
|
@@ -217,8 +216,8 @@ module Tuile
|
|
|
217
216
|
screen.check_locked
|
|
218
217
|
return if n.zero? || empty?
|
|
219
218
|
|
|
220
|
-
to_drop = [n, @
|
|
221
|
-
to_drop.times {
|
|
219
|
+
to_drop = [n, @lines.size].min
|
|
220
|
+
to_drop.times { pop_line }
|
|
222
221
|
|
|
223
222
|
# Cascade-shrink regions from the spatial tail. The tail region
|
|
224
223
|
# gives up lines first; if more are still owed (because the tail
|
|
@@ -233,8 +232,8 @@ module Tuile
|
|
|
233
232
|
end
|
|
234
233
|
|
|
235
234
|
@text = nil
|
|
236
|
-
@
|
|
237
|
-
|
|
235
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
236
|
+
update_scroll_top_row_if_auto_scroll
|
|
238
237
|
invalidate
|
|
239
238
|
end
|
|
240
239
|
|
|
@@ -249,7 +248,7 @@ module Tuile
|
|
|
249
248
|
# - an empty `Range` (e.g. `2...2`, or `size...size` at the end) is
|
|
250
249
|
# *insertion* at that position — nothing removed. {#insert} aliases this.
|
|
251
250
|
#
|
|
252
|
-
# Splices in place — only the affected slice of the
|
|
251
|
+
# Splices in place — only the affected slice of the row buffer is
|
|
253
252
|
# touched, no preceding lines re-wrapped (cost O(from + length + new
|
|
254
253
|
# content)). A no-op when the replacement equals the covered range, so
|
|
255
254
|
# `replace(n...n, "")` is cheap.
|
|
@@ -267,15 +266,15 @@ module Tuile
|
|
|
267
266
|
from, to = normalize_replace_range(range)
|
|
268
267
|
|
|
269
268
|
parsed = StyledString.parse(str)
|
|
270
|
-
|
|
269
|
+
new_lines = parsed.empty? ? [] : parsed.lines
|
|
271
270
|
length = to - from + 1
|
|
272
|
-
return if
|
|
271
|
+
return if new_lines == @lines[from, length]
|
|
273
272
|
|
|
274
|
-
|
|
275
|
-
update_region_counts(from, length,
|
|
273
|
+
splice_lines(from, length, new_lines)
|
|
274
|
+
update_region_counts(from, length, new_lines.size)
|
|
276
275
|
@text = nil
|
|
277
|
-
@
|
|
278
|
-
|
|
276
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
277
|
+
update_scroll_top_row_if_auto_scroll
|
|
279
278
|
invalidate
|
|
280
279
|
end
|
|
281
280
|
|
|
@@ -297,15 +296,15 @@ module Tuile
|
|
|
297
296
|
self.text = StyledString::EMPTY
|
|
298
297
|
end
|
|
299
298
|
|
|
300
|
-
# @param
|
|
301
|
-
# number of
|
|
299
|
+
# @param new_row [Integer] 0 or greater. Not clamped against the
|
|
300
|
+
# number of rows (matches {List#scroll_top_row=}).
|
|
302
301
|
# @return [void]
|
|
303
|
-
def
|
|
304
|
-
raise TypeError, "expected Integer, got #{
|
|
305
|
-
raise ArgumentError, "
|
|
306
|
-
return if @
|
|
302
|
+
def scroll_top_row=(new_row)
|
|
303
|
+
raise TypeError, "expected Integer, got #{new_row.inspect}" unless new_row.is_a? Integer
|
|
304
|
+
raise ArgumentError, "scroll_top_row must not be negative, got #{new_row}" if new_row.negative?
|
|
305
|
+
return if @scroll_top_row == new_row
|
|
307
306
|
|
|
308
|
-
@
|
|
307
|
+
@scroll_top_row = new_row
|
|
309
308
|
@follow = at_bottom?
|
|
310
309
|
invalidate
|
|
311
310
|
end
|
|
@@ -328,9 +327,21 @@ module Tuile
|
|
|
328
327
|
def auto_scroll=(value)
|
|
329
328
|
@auto_scroll = value ? true : false
|
|
330
329
|
@follow = true if @auto_scroll
|
|
331
|
-
|
|
330
|
+
update_scroll_top_row_if_auto_scroll
|
|
332
331
|
end
|
|
333
332
|
|
|
333
|
+
# Scrolls up half a viewport (`rect.height / 2`, at least one row),
|
|
334
|
+
# clamped at the top — unlike {#scroll_top_row=}, which raises below `0`.
|
|
335
|
+
# What `Ctrl+U` does, minus the focus: {#handle_key} ignores every key
|
|
336
|
+
# while the view is inactive, this works whoever holds focus.
|
|
337
|
+
# @return [void]
|
|
338
|
+
def scroll_half_page_up = move_scroll_top_row_by(-half_page_rows)
|
|
339
|
+
|
|
340
|
+
# The `Ctrl+D` twin of {#scroll_half_page_up}, clamped at the last row —
|
|
341
|
+
# arriving there re-arms {#following?}.
|
|
342
|
+
# @return [void]
|
|
343
|
+
def scroll_half_page_down = move_scroll_top_row_by(half_page_rows)
|
|
344
|
+
|
|
334
345
|
def focusable? = true
|
|
335
346
|
|
|
336
347
|
def tab_stop? = true
|
|
@@ -342,14 +353,14 @@ module Tuile
|
|
|
342
353
|
return true if super
|
|
343
354
|
|
|
344
355
|
case key
|
|
345
|
-
when *Keys::DOWN_ARROWS then
|
|
346
|
-
when *Keys::UP_ARROWS then
|
|
347
|
-
when Keys::PAGE_DOWN then
|
|
348
|
-
when Keys::PAGE_UP then
|
|
349
|
-
when Keys::CTRL_D then
|
|
350
|
-
when Keys::CTRL_U then
|
|
351
|
-
when *Keys::HOMES, "g" then
|
|
352
|
-
when *Keys::ENDS_, "G" then
|
|
356
|
+
when *Keys::DOWN_ARROWS then move_scroll_top_row_by(1)
|
|
357
|
+
when *Keys::UP_ARROWS then move_scroll_top_row_by(-1)
|
|
358
|
+
when Keys::PAGE_DOWN then move_scroll_top_row_by(viewport_rows)
|
|
359
|
+
when Keys::PAGE_UP then move_scroll_top_row_by(-viewport_rows)
|
|
360
|
+
when Keys::CTRL_D then scroll_half_page_down
|
|
361
|
+
when Keys::CTRL_U then scroll_half_page_up
|
|
362
|
+
when *Keys::HOMES, "g" then move_scroll_top_row_to(0)
|
|
363
|
+
when *Keys::ENDS_, "G" then move_scroll_top_row_to(scroll_top_row_max)
|
|
353
364
|
else return false
|
|
354
365
|
end
|
|
355
366
|
true
|
|
@@ -360,8 +371,8 @@ module Tuile
|
|
|
360
371
|
def handle_mouse(event)
|
|
361
372
|
super
|
|
362
373
|
case event.button
|
|
363
|
-
when :scroll_down then
|
|
364
|
-
when :scroll_up then
|
|
374
|
+
when :scroll_down then move_scroll_top_row_by(4)
|
|
375
|
+
when :scroll_up then move_scroll_top_row_by(-4)
|
|
365
376
|
end
|
|
366
377
|
end
|
|
367
378
|
|
|
@@ -370,18 +381,18 @@ module Tuile
|
|
|
370
381
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
371
382
|
# painted explicitly (with padded blanks past the last line), so the
|
|
372
383
|
# "fully draw over your rect" contract is met without an upfront wipe.
|
|
373
|
-
# Rows go through {Component#
|
|
384
|
+
# Rows go through {Component#draw_text}, so content and blank rows inherit
|
|
374
385
|
# {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
|
|
375
386
|
# @return [void]
|
|
376
387
|
def repaint
|
|
377
388
|
return if rect.empty?
|
|
378
389
|
|
|
379
390
|
scrollbar = if scrollbar_visible?
|
|
380
|
-
VerticalScrollBar.new(rect.height,
|
|
391
|
+
VerticalScrollBar.new(rect.height, row_count: @rows.size, scroll_top_row: @scroll_top_row)
|
|
381
392
|
end
|
|
382
393
|
(0...rect.height).each do |row|
|
|
383
|
-
line =
|
|
384
|
-
|
|
394
|
+
line = paintable_row(row + @scroll_top_row, row, scrollbar)
|
|
395
|
+
draw_text(rect.left, rect.top + row, line)
|
|
385
396
|
end
|
|
386
397
|
end
|
|
387
398
|
|
|
@@ -411,7 +422,7 @@ module Tuile
|
|
|
411
422
|
# @param size [Integer]
|
|
412
423
|
# @param what [String]
|
|
413
424
|
# @return [Array(Integer, Integer)]
|
|
414
|
-
def normalize_replace_range(range, size = @
|
|
425
|
+
def normalize_replace_range(range, size = @lines.size, what = "the buffer")
|
|
415
426
|
case range
|
|
416
427
|
when Integer
|
|
417
428
|
from = to = range
|
|
@@ -435,7 +446,7 @@ module Tuile
|
|
|
435
446
|
[from, to]
|
|
436
447
|
end
|
|
437
448
|
|
|
438
|
-
# Hard-line index where `region` begins in {@
|
|
449
|
+
# Hard-line index where `region` begins in {@lines} — derived
|
|
439
450
|
# by summing the line counts of all regions that precede it.
|
|
440
451
|
# @param region [Region]
|
|
441
452
|
# @return [Integer]
|
|
@@ -456,13 +467,13 @@ module Tuile
|
|
|
456
467
|
start = region_start_index(region)
|
|
457
468
|
count = region.line_count
|
|
458
469
|
return StyledString::EMPTY if count.zero?
|
|
459
|
-
return @
|
|
470
|
+
return @lines[start] if count == 1
|
|
460
471
|
|
|
461
472
|
newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
|
|
462
473
|
spans = []
|
|
463
474
|
count.times do |i|
|
|
464
475
|
spans << newline if i.positive?
|
|
465
|
-
spans.concat(@
|
|
476
|
+
spans.concat(@lines[start + i].spans)
|
|
466
477
|
end
|
|
467
478
|
StyledString.new(spans)
|
|
468
479
|
end
|
|
@@ -480,13 +491,13 @@ module Tuile
|
|
|
480
491
|
new_lines = parsed.empty? ? [] : parsed.lines
|
|
481
492
|
start = region_start_index(region)
|
|
482
493
|
old_count = region.line_count
|
|
483
|
-
return if new_lines == @
|
|
494
|
+
return if new_lines == @lines[start, old_count]
|
|
484
495
|
|
|
485
|
-
|
|
496
|
+
splice_lines(start, old_count, new_lines)
|
|
486
497
|
region.send(:line_count=, new_lines.size)
|
|
487
498
|
@text = nil
|
|
488
|
-
@
|
|
489
|
-
|
|
499
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
500
|
+
update_scroll_top_row_if_auto_scroll
|
|
490
501
|
invalidate
|
|
491
502
|
end
|
|
492
503
|
|
|
@@ -501,17 +512,17 @@ module Tuile
|
|
|
501
512
|
screen.check_locked
|
|
502
513
|
from, to = normalize_replace_range(range, region.line_count, "the region")
|
|
503
514
|
parsed = StyledString.parse(str)
|
|
504
|
-
|
|
515
|
+
new_lines = parsed.empty? ? [] : parsed.lines
|
|
505
516
|
start = region_start_index(region)
|
|
506
517
|
abs_from = start + from
|
|
507
518
|
length = to - from + 1
|
|
508
|
-
return if
|
|
519
|
+
return if new_lines == @lines[abs_from, length]
|
|
509
520
|
|
|
510
|
-
|
|
511
|
-
region.send(:line_count=, region.line_count - length +
|
|
521
|
+
splice_lines(abs_from, length, new_lines)
|
|
522
|
+
region.send(:line_count=, region.line_count - length + new_lines.size)
|
|
512
523
|
@text = nil
|
|
513
|
-
@
|
|
514
|
-
|
|
524
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
525
|
+
update_scroll_top_row_if_auto_scroll
|
|
515
526
|
invalidate
|
|
516
527
|
end
|
|
517
528
|
|
|
@@ -532,7 +543,7 @@ module Tuile
|
|
|
532
543
|
new_segments = parsed.lines
|
|
533
544
|
start = region_start_index(region)
|
|
534
545
|
if region.empty?
|
|
535
|
-
|
|
546
|
+
splice_lines(start, 0, new_segments)
|
|
536
547
|
region.send(:line_count=, new_segments.size)
|
|
537
548
|
else
|
|
538
549
|
last_idx = start + region.line_count - 1
|
|
@@ -541,21 +552,21 @@ module Tuile
|
|
|
541
552
|
if extension.empty?
|
|
542
553
|
return if rest.empty?
|
|
543
554
|
|
|
544
|
-
|
|
555
|
+
splice_lines(last_idx + 1, 0, rest)
|
|
545
556
|
else
|
|
546
|
-
extended = @
|
|
547
|
-
|
|
557
|
+
extended = @lines[last_idx] + extension
|
|
558
|
+
splice_lines(last_idx, 1, [extended, *rest])
|
|
548
559
|
end
|
|
549
560
|
region.send(:line_count=, region.line_count + rest.size)
|
|
550
561
|
end
|
|
551
562
|
@text = nil
|
|
552
|
-
@
|
|
553
|
-
|
|
563
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
564
|
+
update_scroll_top_row_if_auto_scroll
|
|
554
565
|
invalidate
|
|
555
566
|
end
|
|
556
567
|
|
|
557
568
|
# Drops the last `n` hard lines from `region`'s tail via
|
|
558
|
-
# {#
|
|
569
|
+
# {#splice_lines}. `n` is clamped to the region's current
|
|
559
570
|
# line count; callers guarantee `n > 0` and the region is
|
|
560
571
|
# non-empty (the {Region#remove_last_n_lines} guard handles the
|
|
561
572
|
# no-op cases).
|
|
@@ -567,16 +578,16 @@ module Tuile
|
|
|
567
578
|
to_drop = [n, region.line_count].min
|
|
568
579
|
start = region_start_index(region)
|
|
569
580
|
drop_from = start + region.line_count - to_drop
|
|
570
|
-
|
|
581
|
+
splice_lines(drop_from, to_drop, [])
|
|
571
582
|
region.send(:line_count=, region.line_count - to_drop)
|
|
572
583
|
@text = nil
|
|
573
|
-
@
|
|
574
|
-
|
|
584
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
585
|
+
update_scroll_top_row_if_auto_scroll
|
|
575
586
|
invalidate
|
|
576
587
|
end
|
|
577
588
|
|
|
578
589
|
# Drops `region` from {@regions}: its hard lines are removed via
|
|
579
|
-
# {#
|
|
590
|
+
# {#splice_lines}, the handle is detached, and the always-one
|
|
580
591
|
# default is restored if the removal would have left zero regions.
|
|
581
592
|
# Skips the rewrap / invalidate work when the region was empty
|
|
582
593
|
# (the buffer didn't change), but always detaches.
|
|
@@ -587,7 +598,7 @@ module Tuile
|
|
|
587
598
|
had_lines = region.line_count.positive?
|
|
588
599
|
if had_lines
|
|
589
600
|
start = region_start_index(region)
|
|
590
|
-
|
|
601
|
+
splice_lines(start, region.line_count, [])
|
|
591
602
|
end
|
|
592
603
|
@regions.delete(region)
|
|
593
604
|
region.send(:detach!)
|
|
@@ -595,12 +606,12 @@ module Tuile
|
|
|
595
606
|
return unless had_lines
|
|
596
607
|
|
|
597
608
|
@text = nil
|
|
598
|
-
@
|
|
599
|
-
|
|
609
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
610
|
+
update_scroll_top_row_if_auto_scroll
|
|
600
611
|
invalidate
|
|
601
612
|
end
|
|
602
613
|
|
|
603
|
-
# Adjusts region line counts after a {@
|
|
614
|
+
# Adjusts region line counts after a {@lines} splice that removed
|
|
604
615
|
# `removed_count` lines at `from` and inserted `added_count`. Subtracts
|
|
605
616
|
# each region's overlap with the removed range, then credits the added
|
|
606
617
|
# lines to the first region that lost lines. Pure insertions have no such
|
|
@@ -644,122 +655,125 @@ module Tuile
|
|
|
644
655
|
end
|
|
645
656
|
|
|
646
657
|
# @return [Integer] number of visible lines.
|
|
647
|
-
def
|
|
658
|
+
def viewport_rows = rect.height
|
|
659
|
+
|
|
660
|
+
# @return [Integer] the max value of {#scroll_top_row} for scroll-key clamping.
|
|
661
|
+
def scroll_top_row_max = (@rows.size - viewport_rows).clamp(0, nil)
|
|
648
662
|
|
|
649
|
-
# @return [Integer]
|
|
650
|
-
|
|
663
|
+
# @return [Integer] half a viewport, at least one row, for the half-page
|
|
664
|
+
# scroll verbs and their keys.
|
|
665
|
+
def half_page_rows = [viewport_rows / 2, 1].max
|
|
651
666
|
|
|
652
|
-
# Full rebuild of {@
|
|
653
|
-
# from {@
|
|
667
|
+
# Full rebuild of {@rows} and {@line_wrap_counts}
|
|
668
|
+
# from {@lines}. Called when wrap width changes (which
|
|
654
669
|
# invalidates every cached row count) and from {#text=} (which
|
|
655
670
|
# replaces the whole logical model). Mid-buffer mutators splice
|
|
656
|
-
# incrementally via {#
|
|
657
|
-
# here. Clamps {@
|
|
671
|
+
# incrementally via {#splice_lines} and do *not* go through
|
|
672
|
+
# here. Clamps {@scroll_top_row} if the new line count puts it out of
|
|
658
673
|
# range.
|
|
659
674
|
# @return [void]
|
|
660
675
|
def rewrap
|
|
661
676
|
width = wrap_width
|
|
662
|
-
@
|
|
663
|
-
@
|
|
664
|
-
@
|
|
665
|
-
@
|
|
666
|
-
|
|
667
|
-
@
|
|
668
|
-
@
|
|
677
|
+
@blank_row = pad_to(StyledString::EMPTY, width)
|
|
678
|
+
@rows = []
|
|
679
|
+
@line_wrap_counts = []
|
|
680
|
+
@lines.each do |line|
|
|
681
|
+
wrapped, n = wrap_line(line, width)
|
|
682
|
+
@rows.concat(wrapped)
|
|
683
|
+
@line_wrap_counts << n
|
|
669
684
|
end
|
|
670
|
-
@
|
|
685
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
671
686
|
end
|
|
672
687
|
|
|
673
|
-
# Wraps `
|
|
674
|
-
#
|
|
675
|
-
# run) and degenerate `width <= 0` both emit a single {@
|
|
688
|
+
# Wraps `line` at `width` and returns the padded rows alongside the
|
|
689
|
+
# row count. Empty lines (e.g. from a `"\n\n"`
|
|
690
|
+
# run) and degenerate `width <= 0` both emit a single {@blank_row}
|
|
676
691
|
# row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
|
|
677
692
|
# would have produced.
|
|
678
|
-
# @param
|
|
693
|
+
# @param line [StyledString]
|
|
679
694
|
# @param width [Integer]
|
|
680
695
|
# @return [Array(Array<StyledString>, Integer)]
|
|
681
|
-
def
|
|
682
|
-
return [[@
|
|
696
|
+
def wrap_line(line, width)
|
|
697
|
+
return [[@blank_row], 1] if line.empty? || width <= 0
|
|
683
698
|
|
|
684
|
-
wrapped =
|
|
685
|
-
[wrapped.map { |
|
|
699
|
+
wrapped = line.wrap(width)
|
|
700
|
+
[wrapped.map { |row| pad_to(row, width) }, wrapped.size]
|
|
686
701
|
end
|
|
687
702
|
|
|
688
|
-
# Appends `
|
|
689
|
-
# wrap-count cache and {@
|
|
690
|
-
# @param
|
|
703
|
+
# Appends `line` to the tail of {@lines}, updating the
|
|
704
|
+
# wrap-count cache and {@rows} in lockstep.
|
|
705
|
+
# @param line [StyledString]
|
|
691
706
|
# @param width [Integer]
|
|
692
707
|
# @return [void]
|
|
693
|
-
def
|
|
694
|
-
|
|
695
|
-
@
|
|
696
|
-
@
|
|
697
|
-
@
|
|
708
|
+
def push_line(line, width)
|
|
709
|
+
wrapped, n = wrap_line(line, width)
|
|
710
|
+
@lines << line
|
|
711
|
+
@line_wrap_counts << n
|
|
712
|
+
@rows.concat(wrapped)
|
|
698
713
|
end
|
|
699
714
|
|
|
700
|
-
# Pops the last
|
|
701
|
-
#
|
|
702
|
-
# hard line.
|
|
715
|
+
# Pops the last line, the corresponding cache entry, and the rows
|
|
716
|
+
# that line contributed. Returns the popped line.
|
|
703
717
|
# @return [StyledString]
|
|
704
|
-
def
|
|
705
|
-
n = @
|
|
706
|
-
n.times { @
|
|
707
|
-
@
|
|
718
|
+
def pop_line
|
|
719
|
+
n = @line_wrap_counts.pop
|
|
720
|
+
n.times { @rows.pop }
|
|
721
|
+
@lines.pop
|
|
708
722
|
end
|
|
709
723
|
|
|
710
|
-
# Splices `
|
|
711
|
-
#
|
|
712
|
-
# {@
|
|
713
|
-
# The starting
|
|
714
|
-
# adds via the cache — no wraps of preceding
|
|
724
|
+
# Splices `new_lines` into the buffer in place of the `count` lines
|
|
725
|
+
# starting at index `from`. Updates {@lines},
|
|
726
|
+
# {@line_wrap_counts}, and {@rows} consistently.
|
|
727
|
+
# The starting row offset is computed in O(`from`) integer
|
|
728
|
+
# adds via the cache — no wraps of preceding lines. Wraps are
|
|
715
729
|
# done only for the new content, so total cost is
|
|
716
|
-
# `O(from + count +
|
|
730
|
+
# `O(from + count + new_lines.sum(&:display_width))`.
|
|
717
731
|
# @param from [Integer]
|
|
718
|
-
# @param count [Integer] number of existing
|
|
719
|
-
# @param
|
|
732
|
+
# @param count [Integer] number of existing lines to remove.
|
|
733
|
+
# @param new_lines [Array<StyledString>]
|
|
720
734
|
# @return [void]
|
|
721
|
-
def
|
|
735
|
+
def splice_lines(from, count, new_lines)
|
|
722
736
|
width = wrap_width
|
|
723
|
-
|
|
724
|
-
|
|
737
|
+
row_start = row_offset_at(from)
|
|
738
|
+
old_row_count = @line_wrap_counts[from, count].sum
|
|
725
739
|
|
|
726
|
-
@
|
|
740
|
+
@lines[from, count] = new_lines
|
|
727
741
|
|
|
728
742
|
new_rows = []
|
|
729
743
|
new_counts = []
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
new_rows.concat(
|
|
744
|
+
new_lines.each do |line|
|
|
745
|
+
wrapped, n = wrap_line(line, width)
|
|
746
|
+
new_rows.concat(wrapped)
|
|
733
747
|
new_counts << n
|
|
734
748
|
end
|
|
735
749
|
|
|
736
|
-
@
|
|
737
|
-
@
|
|
750
|
+
@line_wrap_counts[from, count] = new_counts
|
|
751
|
+
@rows[row_start, old_row_count] = new_rows
|
|
738
752
|
end
|
|
739
753
|
|
|
740
754
|
# @param idx [Integer]
|
|
741
|
-
# @return [Integer] the {@
|
|
742
|
-
# at {@
|
|
755
|
+
# @return [Integer] the {@rows} index where the line
|
|
756
|
+
# at {@lines}`[idx]` starts. O(`idx`) integer adds via the
|
|
743
757
|
# wrap-count cache.
|
|
744
|
-
def
|
|
758
|
+
def row_offset_at(idx)
|
|
745
759
|
return 0 if idx.zero?
|
|
746
760
|
|
|
747
|
-
@
|
|
761
|
+
@line_wrap_counts[0, idx].sum
|
|
748
762
|
end
|
|
749
763
|
|
|
750
|
-
# Rebuilds the joined {StyledString} from {@
|
|
751
|
-
# default-styled `"\n"` between
|
|
764
|
+
# Rebuilds the joined {StyledString} from {@lines}, inserting a
|
|
765
|
+
# default-styled `"\n"` between lines. Called from the {#text}
|
|
752
766
|
# reader when the cache is cold. Cost is O(total spans).
|
|
753
767
|
# @return [StyledString]
|
|
754
768
|
def build_text
|
|
755
|
-
return StyledString::EMPTY if @
|
|
756
|
-
return @
|
|
769
|
+
return StyledString::EMPTY if @lines.empty?
|
|
770
|
+
return @lines.first if @lines.size == 1
|
|
757
771
|
|
|
758
772
|
newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
|
|
759
773
|
spans = []
|
|
760
|
-
@
|
|
774
|
+
@lines.each_with_index do |line, i|
|
|
761
775
|
spans << newline if i.positive?
|
|
762
|
-
spans.concat(
|
|
776
|
+
spans.concat(line.spans)
|
|
763
777
|
end
|
|
764
778
|
StyledString.new(spans)
|
|
765
779
|
end
|
|
@@ -775,32 +789,32 @@ module Tuile
|
|
|
775
789
|
|
|
776
790
|
# @param delta [Integer] negative scrolls up, positive scrolls down.
|
|
777
791
|
# @return [void]
|
|
778
|
-
def
|
|
779
|
-
|
|
792
|
+
def move_scroll_top_row_by(delta)
|
|
793
|
+
move_scroll_top_row_to(@scroll_top_row + delta)
|
|
780
794
|
end
|
|
781
795
|
|
|
782
|
-
# @param target [Integer] desired top line; clamped to `[0,
|
|
796
|
+
# @param target [Integer] desired top line; clamped to `[0, scroll_top_row_max]`.
|
|
783
797
|
# @return [void]
|
|
784
|
-
def
|
|
785
|
-
clamped = target.clamp(0,
|
|
786
|
-
self.
|
|
798
|
+
def move_scroll_top_row_to(target)
|
|
799
|
+
clamped = target.clamp(0, scroll_top_row_max)
|
|
800
|
+
self.scroll_top_row = clamped unless @scroll_top_row == clamped
|
|
787
801
|
end
|
|
788
802
|
|
|
789
803
|
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
790
804
|
# viewport pin is skipped, so reading older content is not interrupted
|
|
791
|
-
# by incoming lines. {#
|
|
805
|
+
# by incoming lines. {#scroll_top_row=} re-arms `@follow` when the viewport
|
|
792
806
|
# returns to the bottom.
|
|
793
807
|
# @return [void]
|
|
794
|
-
def
|
|
808
|
+
def update_scroll_top_row_if_auto_scroll
|
|
795
809
|
return unless @auto_scroll && @follow
|
|
796
810
|
|
|
797
|
-
target = (@
|
|
798
|
-
self.
|
|
811
|
+
target = (@rows.size - viewport_rows).clamp(0, nil)
|
|
812
|
+
self.scroll_top_row = target if @scroll_top_row != target
|
|
799
813
|
end
|
|
800
814
|
|
|
801
815
|
# @return [Boolean] whether the viewport is pinned to the last line.
|
|
802
|
-
# Drives {#following?}: re-evaluated on every {#
|
|
803
|
-
def at_bottom? = @
|
|
816
|
+
# Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
|
|
817
|
+
def at_bottom? = @scroll_top_row == scroll_top_row_max
|
|
804
818
|
|
|
805
819
|
# @return [Boolean]
|
|
806
820
|
def scrollbar_visible?
|
|
@@ -814,29 +828,29 @@ module Tuile
|
|
|
814
828
|
# constrained the line to `<= width`, so no truncation is performed.
|
|
815
829
|
# `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
|
|
816
830
|
# `wrap_width == 0` case (rect.width == 1 with scrollbar).
|
|
817
|
-
# @param
|
|
831
|
+
# @param row [StyledString]
|
|
818
832
|
# @param width [Integer]
|
|
819
833
|
# @return [StyledString]
|
|
820
|
-
def pad_to(
|
|
834
|
+
def pad_to(row, width)
|
|
821
835
|
return StyledString::EMPTY if width <= 0
|
|
822
836
|
|
|
823
|
-
diff = width -
|
|
824
|
-
return
|
|
837
|
+
diff = width - row.display_width
|
|
838
|
+
return row if diff <= 0
|
|
825
839
|
|
|
826
|
-
|
|
840
|
+
row + StyledString.plain(" " * diff)
|
|
827
841
|
end
|
|
828
842
|
|
|
829
|
-
# @param index [Integer] 0-based index into `@
|
|
843
|
+
# @param index [Integer] 0-based index into `@rows`.
|
|
830
844
|
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
831
845
|
# @param scrollbar [VerticalScrollBar, nil]
|
|
832
|
-
# @return [StyledString] paintable
|
|
833
|
-
# Body
|
|
846
|
+
# @return [StyledString] paintable row exactly `rect.width` columns wide.
|
|
847
|
+
# Body rows come pre-padded from {#rewrap}, so this reduces to a lookup
|
|
834
848
|
# plus a concat of the scrollbar glyph when one is present.
|
|
835
|
-
def
|
|
836
|
-
|
|
837
|
-
return
|
|
849
|
+
def paintable_row(index, row_in_viewport, scrollbar)
|
|
850
|
+
row = @rows[index] || @blank_row
|
|
851
|
+
return row unless scrollbar
|
|
838
852
|
|
|
839
|
-
|
|
853
|
+
row + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
|
|
840
854
|
end
|
|
841
855
|
|
|
842
856
|
# A logical section of a {TextView}'s text — a contiguous run of
|