tuile 0.11.0 → 0.13.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 +77 -0
- data/DECISIONS.md +1970 -14
- data/README.md +136 -491
- data/TERMINOLOGY.md +70 -0
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +19 -6
- data/book/03-layout.md +12 -11
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +6 -3
- data/book/07-components.md +498 -38
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +27 -20
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +422 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +16 -10
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- 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 +13 -8
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/list.rb +313 -216
- data/lib/tuile/component/list_dropdown.rb +100 -10
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +320 -0
- data/lib/tuile/component/picker_window.rb +3 -8
- data/lib/tuile/component/popup.rb +83 -19
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +10 -8
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +84 -277
- data/lib/tuile/component/text_field.rb +24 -7
- data/lib/tuile/component/text_view.rb +197 -180
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +43 -18
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +95 -78
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +52 -12
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/sig/tuile.rbs +2307 -516
- metadata +9 -3
- data/mise.toml +0 -2
|
@@ -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,28 +327,43 @@ 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: dispatch delivers keys only along
|
|
336
|
+
# the focus chain, so this is what a host with focus elsewhere — a chat
|
|
337
|
+
# transcript under an input field — calls instead of forwarding a
|
|
338
|
+
# synthetic keystroke that would lie about where focus is.
|
|
339
|
+
# @return [void]
|
|
340
|
+
def scroll_half_page_up = move_scroll_top_row_by(-half_page_rows)
|
|
341
|
+
|
|
342
|
+
# The `Ctrl+D` twin of {#scroll_half_page_up}, clamped at the last row —
|
|
343
|
+
# arriving there re-arms {#following?}.
|
|
344
|
+
# @return [void]
|
|
345
|
+
def scroll_half_page_down = move_scroll_top_row_by(half_page_rows)
|
|
346
|
+
|
|
334
347
|
def focusable? = true
|
|
335
348
|
|
|
336
349
|
def tab_stop? = true
|
|
337
350
|
|
|
351
|
+
# Claims the scroll ladder: the arrows and `j`/`k`, PageUp/PageDown,
|
|
352
|
+
# `Ctrl+U`/`Ctrl+D`, Home/`g`/End/`G`. Acts on the key alone — a clamped
|
|
353
|
+
# scroll at either edge is still a handled key, and hand-feeding a key to
|
|
354
|
+
# an unfocused view scrolls it (dispatch gates on focus, this doesn't).
|
|
338
355
|
# @param key [String]
|
|
339
356
|
# @return [Boolean]
|
|
340
357
|
def handle_key(key)
|
|
341
|
-
return false unless active?
|
|
342
|
-
return true if super
|
|
343
|
-
|
|
344
358
|
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
|
|
359
|
+
when *Keys::DOWN_ARROWS then move_scroll_top_row_by(1)
|
|
360
|
+
when *Keys::UP_ARROWS then move_scroll_top_row_by(-1)
|
|
361
|
+
when Keys::PAGE_DOWN then move_scroll_top_row_by(viewport_rows)
|
|
362
|
+
when Keys::PAGE_UP then move_scroll_top_row_by(-viewport_rows)
|
|
363
|
+
when Keys::CTRL_D then scroll_half_page_down
|
|
364
|
+
when Keys::CTRL_U then scroll_half_page_up
|
|
365
|
+
when *Keys::HOMES, "g" then move_scroll_top_row_to(0)
|
|
366
|
+
when *Keys::ENDS_, "G" then move_scroll_top_row_to(scroll_top_row_max)
|
|
353
367
|
else return false
|
|
354
368
|
end
|
|
355
369
|
true
|
|
@@ -360,8 +374,8 @@ module Tuile
|
|
|
360
374
|
def handle_mouse(event)
|
|
361
375
|
super
|
|
362
376
|
case event.button
|
|
363
|
-
when :scroll_down then
|
|
364
|
-
when :scroll_up then
|
|
377
|
+
when :scroll_down then move_scroll_top_row_by(4)
|
|
378
|
+
when :scroll_up then move_scroll_top_row_by(-4)
|
|
365
379
|
end
|
|
366
380
|
end
|
|
367
381
|
|
|
@@ -370,18 +384,18 @@ module Tuile
|
|
|
370
384
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
371
385
|
# painted explicitly (with padded blanks past the last line), so the
|
|
372
386
|
# "fully draw over your rect" contract is met without an upfront wipe.
|
|
373
|
-
# Rows go through {Component#
|
|
387
|
+
# Rows go through {Component#draw_text}, so content and blank rows inherit
|
|
374
388
|
# {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
|
|
375
389
|
# @return [void]
|
|
376
390
|
def repaint
|
|
377
391
|
return if rect.empty?
|
|
378
392
|
|
|
379
393
|
scrollbar = if scrollbar_visible?
|
|
380
|
-
VerticalScrollBar.new(rect.height,
|
|
394
|
+
VerticalScrollBar.new(rect.height, row_count: @rows.size, scroll_top_row: @scroll_top_row)
|
|
381
395
|
end
|
|
382
396
|
(0...rect.height).each do |row|
|
|
383
|
-
line =
|
|
384
|
-
|
|
397
|
+
line = paintable_row(row + @scroll_top_row, row, scrollbar)
|
|
398
|
+
draw_text(rect.left, rect.top + row, line)
|
|
385
399
|
end
|
|
386
400
|
end
|
|
387
401
|
|
|
@@ -411,7 +425,7 @@ module Tuile
|
|
|
411
425
|
# @param size [Integer]
|
|
412
426
|
# @param what [String]
|
|
413
427
|
# @return [Array(Integer, Integer)]
|
|
414
|
-
def normalize_replace_range(range, size = @
|
|
428
|
+
def normalize_replace_range(range, size = @lines.size, what = "the buffer")
|
|
415
429
|
case range
|
|
416
430
|
when Integer
|
|
417
431
|
from = to = range
|
|
@@ -435,7 +449,7 @@ module Tuile
|
|
|
435
449
|
[from, to]
|
|
436
450
|
end
|
|
437
451
|
|
|
438
|
-
# Hard-line index where `region` begins in {@
|
|
452
|
+
# Hard-line index where `region` begins in {@lines} — derived
|
|
439
453
|
# by summing the line counts of all regions that precede it.
|
|
440
454
|
# @param region [Region]
|
|
441
455
|
# @return [Integer]
|
|
@@ -456,13 +470,13 @@ module Tuile
|
|
|
456
470
|
start = region_start_index(region)
|
|
457
471
|
count = region.line_count
|
|
458
472
|
return StyledString::EMPTY if count.zero?
|
|
459
|
-
return @
|
|
473
|
+
return @lines[start] if count == 1
|
|
460
474
|
|
|
461
475
|
newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
|
|
462
476
|
spans = []
|
|
463
477
|
count.times do |i|
|
|
464
478
|
spans << newline if i.positive?
|
|
465
|
-
spans.concat(@
|
|
479
|
+
spans.concat(@lines[start + i].spans)
|
|
466
480
|
end
|
|
467
481
|
StyledString.new(spans)
|
|
468
482
|
end
|
|
@@ -480,13 +494,13 @@ module Tuile
|
|
|
480
494
|
new_lines = parsed.empty? ? [] : parsed.lines
|
|
481
495
|
start = region_start_index(region)
|
|
482
496
|
old_count = region.line_count
|
|
483
|
-
return if new_lines == @
|
|
497
|
+
return if new_lines == @lines[start, old_count]
|
|
484
498
|
|
|
485
|
-
|
|
499
|
+
splice_lines(start, old_count, new_lines)
|
|
486
500
|
region.send(:line_count=, new_lines.size)
|
|
487
501
|
@text = nil
|
|
488
|
-
@
|
|
489
|
-
|
|
502
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
503
|
+
update_scroll_top_row_if_auto_scroll
|
|
490
504
|
invalidate
|
|
491
505
|
end
|
|
492
506
|
|
|
@@ -501,17 +515,17 @@ module Tuile
|
|
|
501
515
|
screen.check_locked
|
|
502
516
|
from, to = normalize_replace_range(range, region.line_count, "the region")
|
|
503
517
|
parsed = StyledString.parse(str)
|
|
504
|
-
|
|
518
|
+
new_lines = parsed.empty? ? [] : parsed.lines
|
|
505
519
|
start = region_start_index(region)
|
|
506
520
|
abs_from = start + from
|
|
507
521
|
length = to - from + 1
|
|
508
|
-
return if
|
|
522
|
+
return if new_lines == @lines[abs_from, length]
|
|
509
523
|
|
|
510
|
-
|
|
511
|
-
region.send(:line_count=, region.line_count - length +
|
|
524
|
+
splice_lines(abs_from, length, new_lines)
|
|
525
|
+
region.send(:line_count=, region.line_count - length + new_lines.size)
|
|
512
526
|
@text = nil
|
|
513
|
-
@
|
|
514
|
-
|
|
527
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
528
|
+
update_scroll_top_row_if_auto_scroll
|
|
515
529
|
invalidate
|
|
516
530
|
end
|
|
517
531
|
|
|
@@ -532,7 +546,7 @@ module Tuile
|
|
|
532
546
|
new_segments = parsed.lines
|
|
533
547
|
start = region_start_index(region)
|
|
534
548
|
if region.empty?
|
|
535
|
-
|
|
549
|
+
splice_lines(start, 0, new_segments)
|
|
536
550
|
region.send(:line_count=, new_segments.size)
|
|
537
551
|
else
|
|
538
552
|
last_idx = start + region.line_count - 1
|
|
@@ -541,21 +555,21 @@ module Tuile
|
|
|
541
555
|
if extension.empty?
|
|
542
556
|
return if rest.empty?
|
|
543
557
|
|
|
544
|
-
|
|
558
|
+
splice_lines(last_idx + 1, 0, rest)
|
|
545
559
|
else
|
|
546
|
-
extended = @
|
|
547
|
-
|
|
560
|
+
extended = @lines[last_idx] + extension
|
|
561
|
+
splice_lines(last_idx, 1, [extended, *rest])
|
|
548
562
|
end
|
|
549
563
|
region.send(:line_count=, region.line_count + rest.size)
|
|
550
564
|
end
|
|
551
565
|
@text = nil
|
|
552
|
-
@
|
|
553
|
-
|
|
566
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
567
|
+
update_scroll_top_row_if_auto_scroll
|
|
554
568
|
invalidate
|
|
555
569
|
end
|
|
556
570
|
|
|
557
571
|
# Drops the last `n` hard lines from `region`'s tail via
|
|
558
|
-
# {#
|
|
572
|
+
# {#splice_lines}. `n` is clamped to the region's current
|
|
559
573
|
# line count; callers guarantee `n > 0` and the region is
|
|
560
574
|
# non-empty (the {Region#remove_last_n_lines} guard handles the
|
|
561
575
|
# no-op cases).
|
|
@@ -567,16 +581,16 @@ module Tuile
|
|
|
567
581
|
to_drop = [n, region.line_count].min
|
|
568
582
|
start = region_start_index(region)
|
|
569
583
|
drop_from = start + region.line_count - to_drop
|
|
570
|
-
|
|
584
|
+
splice_lines(drop_from, to_drop, [])
|
|
571
585
|
region.send(:line_count=, region.line_count - to_drop)
|
|
572
586
|
@text = nil
|
|
573
|
-
@
|
|
574
|
-
|
|
587
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
588
|
+
update_scroll_top_row_if_auto_scroll
|
|
575
589
|
invalidate
|
|
576
590
|
end
|
|
577
591
|
|
|
578
592
|
# Drops `region` from {@regions}: its hard lines are removed via
|
|
579
|
-
# {#
|
|
593
|
+
# {#splice_lines}, the handle is detached, and the always-one
|
|
580
594
|
# default is restored if the removal would have left zero regions.
|
|
581
595
|
# Skips the rewrap / invalidate work when the region was empty
|
|
582
596
|
# (the buffer didn't change), but always detaches.
|
|
@@ -587,7 +601,7 @@ module Tuile
|
|
|
587
601
|
had_lines = region.line_count.positive?
|
|
588
602
|
if had_lines
|
|
589
603
|
start = region_start_index(region)
|
|
590
|
-
|
|
604
|
+
splice_lines(start, region.line_count, [])
|
|
591
605
|
end
|
|
592
606
|
@regions.delete(region)
|
|
593
607
|
region.send(:detach!)
|
|
@@ -595,12 +609,12 @@ module Tuile
|
|
|
595
609
|
return unless had_lines
|
|
596
610
|
|
|
597
611
|
@text = nil
|
|
598
|
-
@
|
|
599
|
-
|
|
612
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
613
|
+
update_scroll_top_row_if_auto_scroll
|
|
600
614
|
invalidate
|
|
601
615
|
end
|
|
602
616
|
|
|
603
|
-
# Adjusts region line counts after a {@
|
|
617
|
+
# Adjusts region line counts after a {@lines} splice that removed
|
|
604
618
|
# `removed_count` lines at `from` and inserted `added_count`. Subtracts
|
|
605
619
|
# each region's overlap with the removed range, then credits the added
|
|
606
620
|
# lines to the first region that lost lines. Pure insertions have no such
|
|
@@ -644,122 +658,125 @@ module Tuile
|
|
|
644
658
|
end
|
|
645
659
|
|
|
646
660
|
# @return [Integer] number of visible lines.
|
|
647
|
-
def
|
|
661
|
+
def viewport_rows = rect.height
|
|
662
|
+
|
|
663
|
+
# @return [Integer] the max value of {#scroll_top_row} for scroll-key clamping.
|
|
664
|
+
def scroll_top_row_max = (@rows.size - viewport_rows).clamp(0, nil)
|
|
648
665
|
|
|
649
|
-
# @return [Integer]
|
|
650
|
-
|
|
666
|
+
# @return [Integer] half a viewport, at least one row, for the half-page
|
|
667
|
+
# scroll verbs and their keys.
|
|
668
|
+
def half_page_rows = [viewport_rows / 2, 1].max
|
|
651
669
|
|
|
652
|
-
# Full rebuild of {@
|
|
653
|
-
# from {@
|
|
670
|
+
# Full rebuild of {@rows} and {@line_wrap_counts}
|
|
671
|
+
# from {@lines}. Called when wrap width changes (which
|
|
654
672
|
# invalidates every cached row count) and from {#text=} (which
|
|
655
673
|
# replaces the whole logical model). Mid-buffer mutators splice
|
|
656
|
-
# incrementally via {#
|
|
657
|
-
# here. Clamps {@
|
|
674
|
+
# incrementally via {#splice_lines} and do *not* go through
|
|
675
|
+
# here. Clamps {@scroll_top_row} if the new line count puts it out of
|
|
658
676
|
# range.
|
|
659
677
|
# @return [void]
|
|
660
678
|
def rewrap
|
|
661
679
|
width = wrap_width
|
|
662
|
-
@
|
|
663
|
-
@
|
|
664
|
-
@
|
|
665
|
-
@
|
|
666
|
-
|
|
667
|
-
@
|
|
668
|
-
@
|
|
680
|
+
@blank_row = pad_to(StyledString::EMPTY, width)
|
|
681
|
+
@rows = []
|
|
682
|
+
@line_wrap_counts = []
|
|
683
|
+
@lines.each do |line|
|
|
684
|
+
wrapped, n = wrap_line(line, width)
|
|
685
|
+
@rows.concat(wrapped)
|
|
686
|
+
@line_wrap_counts << n
|
|
669
687
|
end
|
|
670
|
-
@
|
|
688
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
671
689
|
end
|
|
672
690
|
|
|
673
|
-
# Wraps `
|
|
674
|
-
#
|
|
675
|
-
# run) and degenerate `width <= 0` both emit a single {@
|
|
691
|
+
# Wraps `line` at `width` and returns the padded rows alongside the
|
|
692
|
+
# row count. Empty lines (e.g. from a `"\n\n"`
|
|
693
|
+
# run) and degenerate `width <= 0` both emit a single {@blank_row}
|
|
676
694
|
# row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
|
|
677
695
|
# would have produced.
|
|
678
|
-
# @param
|
|
696
|
+
# @param line [StyledString]
|
|
679
697
|
# @param width [Integer]
|
|
680
698
|
# @return [Array(Array<StyledString>, Integer)]
|
|
681
|
-
def
|
|
682
|
-
return [[@
|
|
699
|
+
def wrap_line(line, width)
|
|
700
|
+
return [[@blank_row], 1] if line.empty? || width <= 0
|
|
683
701
|
|
|
684
|
-
wrapped =
|
|
685
|
-
[wrapped.map { |
|
|
702
|
+
wrapped = line.wrap(width)
|
|
703
|
+
[wrapped.map { |row| pad_to(row, width) }, wrapped.size]
|
|
686
704
|
end
|
|
687
705
|
|
|
688
|
-
# Appends `
|
|
689
|
-
# wrap-count cache and {@
|
|
690
|
-
# @param
|
|
706
|
+
# Appends `line` to the tail of {@lines}, updating the
|
|
707
|
+
# wrap-count cache and {@rows} in lockstep.
|
|
708
|
+
# @param line [StyledString]
|
|
691
709
|
# @param width [Integer]
|
|
692
710
|
# @return [void]
|
|
693
|
-
def
|
|
694
|
-
|
|
695
|
-
@
|
|
696
|
-
@
|
|
697
|
-
@
|
|
711
|
+
def push_line(line, width)
|
|
712
|
+
wrapped, n = wrap_line(line, width)
|
|
713
|
+
@lines << line
|
|
714
|
+
@line_wrap_counts << n
|
|
715
|
+
@rows.concat(wrapped)
|
|
698
716
|
end
|
|
699
717
|
|
|
700
|
-
# Pops the last
|
|
701
|
-
#
|
|
702
|
-
# hard line.
|
|
718
|
+
# Pops the last line, the corresponding cache entry, and the rows
|
|
719
|
+
# that line contributed. Returns the popped line.
|
|
703
720
|
# @return [StyledString]
|
|
704
|
-
def
|
|
705
|
-
n = @
|
|
706
|
-
n.times { @
|
|
707
|
-
@
|
|
721
|
+
def pop_line
|
|
722
|
+
n = @line_wrap_counts.pop
|
|
723
|
+
n.times { @rows.pop }
|
|
724
|
+
@lines.pop
|
|
708
725
|
end
|
|
709
726
|
|
|
710
|
-
# Splices `
|
|
711
|
-
#
|
|
712
|
-
# {@
|
|
713
|
-
# The starting
|
|
714
|
-
# adds via the cache — no wraps of preceding
|
|
727
|
+
# Splices `new_lines` into the buffer in place of the `count` lines
|
|
728
|
+
# starting at index `from`. Updates {@lines},
|
|
729
|
+
# {@line_wrap_counts}, and {@rows} consistently.
|
|
730
|
+
# The starting row offset is computed in O(`from`) integer
|
|
731
|
+
# adds via the cache — no wraps of preceding lines. Wraps are
|
|
715
732
|
# done only for the new content, so total cost is
|
|
716
|
-
# `O(from + count +
|
|
733
|
+
# `O(from + count + new_lines.sum(&:display_width))`.
|
|
717
734
|
# @param from [Integer]
|
|
718
|
-
# @param count [Integer] number of existing
|
|
719
|
-
# @param
|
|
735
|
+
# @param count [Integer] number of existing lines to remove.
|
|
736
|
+
# @param new_lines [Array<StyledString>]
|
|
720
737
|
# @return [void]
|
|
721
|
-
def
|
|
738
|
+
def splice_lines(from, count, new_lines)
|
|
722
739
|
width = wrap_width
|
|
723
|
-
|
|
724
|
-
|
|
740
|
+
row_start = row_offset_at(from)
|
|
741
|
+
old_row_count = @line_wrap_counts[from, count].sum
|
|
725
742
|
|
|
726
|
-
@
|
|
743
|
+
@lines[from, count] = new_lines
|
|
727
744
|
|
|
728
745
|
new_rows = []
|
|
729
746
|
new_counts = []
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
new_rows.concat(
|
|
747
|
+
new_lines.each do |line|
|
|
748
|
+
wrapped, n = wrap_line(line, width)
|
|
749
|
+
new_rows.concat(wrapped)
|
|
733
750
|
new_counts << n
|
|
734
751
|
end
|
|
735
752
|
|
|
736
|
-
@
|
|
737
|
-
@
|
|
753
|
+
@line_wrap_counts[from, count] = new_counts
|
|
754
|
+
@rows[row_start, old_row_count] = new_rows
|
|
738
755
|
end
|
|
739
756
|
|
|
740
757
|
# @param idx [Integer]
|
|
741
|
-
# @return [Integer] the {@
|
|
742
|
-
# at {@
|
|
758
|
+
# @return [Integer] the {@rows} index where the line
|
|
759
|
+
# at {@lines}`[idx]` starts. O(`idx`) integer adds via the
|
|
743
760
|
# wrap-count cache.
|
|
744
|
-
def
|
|
761
|
+
def row_offset_at(idx)
|
|
745
762
|
return 0 if idx.zero?
|
|
746
763
|
|
|
747
|
-
@
|
|
764
|
+
@line_wrap_counts[0, idx].sum
|
|
748
765
|
end
|
|
749
766
|
|
|
750
|
-
# Rebuilds the joined {StyledString} from {@
|
|
751
|
-
# default-styled `"\n"` between
|
|
767
|
+
# Rebuilds the joined {StyledString} from {@lines}, inserting a
|
|
768
|
+
# default-styled `"\n"` between lines. Called from the {#text}
|
|
752
769
|
# reader when the cache is cold. Cost is O(total spans).
|
|
753
770
|
# @return [StyledString]
|
|
754
771
|
def build_text
|
|
755
|
-
return StyledString::EMPTY if @
|
|
756
|
-
return @
|
|
772
|
+
return StyledString::EMPTY if @lines.empty?
|
|
773
|
+
return @lines.first if @lines.size == 1
|
|
757
774
|
|
|
758
775
|
newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
|
|
759
776
|
spans = []
|
|
760
|
-
@
|
|
777
|
+
@lines.each_with_index do |line, i|
|
|
761
778
|
spans << newline if i.positive?
|
|
762
|
-
spans.concat(
|
|
779
|
+
spans.concat(line.spans)
|
|
763
780
|
end
|
|
764
781
|
StyledString.new(spans)
|
|
765
782
|
end
|
|
@@ -775,32 +792,32 @@ module Tuile
|
|
|
775
792
|
|
|
776
793
|
# @param delta [Integer] negative scrolls up, positive scrolls down.
|
|
777
794
|
# @return [void]
|
|
778
|
-
def
|
|
779
|
-
|
|
795
|
+
def move_scroll_top_row_by(delta)
|
|
796
|
+
move_scroll_top_row_to(@scroll_top_row + delta)
|
|
780
797
|
end
|
|
781
798
|
|
|
782
|
-
# @param target [Integer] desired top line; clamped to `[0,
|
|
799
|
+
# @param target [Integer] desired top line; clamped to `[0, scroll_top_row_max]`.
|
|
783
800
|
# @return [void]
|
|
784
|
-
def
|
|
785
|
-
clamped = target.clamp(0,
|
|
786
|
-
self.
|
|
801
|
+
def move_scroll_top_row_to(target)
|
|
802
|
+
clamped = target.clamp(0, scroll_top_row_max)
|
|
803
|
+
self.scroll_top_row = clamped unless @scroll_top_row == clamped
|
|
787
804
|
end
|
|
788
805
|
|
|
789
806
|
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
790
807
|
# viewport pin is skipped, so reading older content is not interrupted
|
|
791
|
-
# by incoming lines. {#
|
|
808
|
+
# by incoming lines. {#scroll_top_row=} re-arms `@follow` when the viewport
|
|
792
809
|
# returns to the bottom.
|
|
793
810
|
# @return [void]
|
|
794
|
-
def
|
|
811
|
+
def update_scroll_top_row_if_auto_scroll
|
|
795
812
|
return unless @auto_scroll && @follow
|
|
796
813
|
|
|
797
|
-
target = (@
|
|
798
|
-
self.
|
|
814
|
+
target = (@rows.size - viewport_rows).clamp(0, nil)
|
|
815
|
+
self.scroll_top_row = target if @scroll_top_row != target
|
|
799
816
|
end
|
|
800
817
|
|
|
801
818
|
# @return [Boolean] whether the viewport is pinned to the last line.
|
|
802
|
-
# Drives {#following?}: re-evaluated on every {#
|
|
803
|
-
def at_bottom? = @
|
|
819
|
+
# Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
|
|
820
|
+
def at_bottom? = @scroll_top_row == scroll_top_row_max
|
|
804
821
|
|
|
805
822
|
# @return [Boolean]
|
|
806
823
|
def scrollbar_visible?
|
|
@@ -814,29 +831,29 @@ module Tuile
|
|
|
814
831
|
# constrained the line to `<= width`, so no truncation is performed.
|
|
815
832
|
# `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
|
|
816
833
|
# `wrap_width == 0` case (rect.width == 1 with scrollbar).
|
|
817
|
-
# @param
|
|
834
|
+
# @param row [StyledString]
|
|
818
835
|
# @param width [Integer]
|
|
819
836
|
# @return [StyledString]
|
|
820
|
-
def pad_to(
|
|
837
|
+
def pad_to(row, width)
|
|
821
838
|
return StyledString::EMPTY if width <= 0
|
|
822
839
|
|
|
823
|
-
diff = width -
|
|
824
|
-
return
|
|
840
|
+
diff = width - row.display_width
|
|
841
|
+
return row if diff <= 0
|
|
825
842
|
|
|
826
|
-
|
|
843
|
+
row + StyledString.plain(" " * diff)
|
|
827
844
|
end
|
|
828
845
|
|
|
829
|
-
# @param index [Integer] 0-based index into `@
|
|
846
|
+
# @param index [Integer] 0-based index into `@rows`.
|
|
830
847
|
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
831
848
|
# @param scrollbar [VerticalScrollBar, nil]
|
|
832
|
-
# @return [StyledString] paintable
|
|
833
|
-
# Body
|
|
849
|
+
# @return [StyledString] paintable row exactly `rect.width` columns wide.
|
|
850
|
+
# Body rows come pre-padded from {#rewrap}, so this reduces to a lookup
|
|
834
851
|
# plus a concat of the scrollbar glyph when one is present.
|
|
835
|
-
def
|
|
836
|
-
|
|
837
|
-
return
|
|
852
|
+
def paintable_row(index, row_in_viewport, scrollbar)
|
|
853
|
+
row = @rows[index] || @blank_row
|
|
854
|
+
return row unless scrollbar
|
|
838
855
|
|
|
839
|
-
|
|
856
|
+
row + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
|
|
840
857
|
end
|
|
841
858
|
|
|
842
859
|
# A logical section of a {TextView}'s text — a contiguous run of
|