tuile 0.5.0 → 0.7.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 +20 -0
- data/README.md +151 -5
- data/examples/file_commander.rb +4 -3
- data/examples/sampler.rb +4 -3
- data/lib/tuile/ansi.rb +4 -3
- data/lib/tuile/color.rb +122 -0
- data/lib/tuile/component/button.rb +9 -5
- data/lib/tuile/component/label.rb +13 -15
- data/lib/tuile/component/layout.rb +1 -1
- data/lib/tuile/component/list.rb +64 -31
- data/lib/tuile/component/log_window.rb +1 -0
- data/lib/tuile/component/picker_window.rb +4 -4
- data/lib/tuile/component/popup.rb +34 -3
- data/lib/tuile/component/text_area.rb +1 -2
- data/lib/tuile/component/text_field.rb +1 -2
- data/lib/tuile/component/text_input.rb +10 -15
- data/lib/tuile/component/text_view.rb +33 -13
- data/lib/tuile/component/window.rb +70 -16
- data/lib/tuile/component.rb +75 -6
- data/lib/tuile/event_queue.rb +26 -2
- data/lib/tuile/fake_screen.rb +8 -0
- data/lib/tuile/keys.rb +9 -3
- data/lib/tuile/rect.rb +12 -0
- data/lib/tuile/screen.rb +118 -20
- data/lib/tuile/screen_pane.rb +1 -1
- data/lib/tuile/sizing.rb +59 -0
- data/lib/tuile/styled_string.rb +125 -30
- data/lib/tuile/terminal_background.rb +137 -0
- data/lib/tuile/theme.rb +202 -0
- data/lib/tuile/theme_def.rb +85 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +0 -1
- data/mise.toml +2 -0
- data/sig/tuile.rbs +755 -62
- metadata +12 -18
data/lib/tuile/component/list.rb
CHANGED
|
@@ -14,20 +14,16 @@ module Tuile
|
|
|
14
14
|
#
|
|
15
15
|
# Cursor is supported; call {#cursor=} to change cursor behavior. The
|
|
16
16
|
# cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
|
|
17
|
-
# list automatically. The cursor highlight overlays
|
|
18
|
-
# while preserving each span's foreground color.
|
|
17
|
+
# list automatically. The cursor highlight overlays
|
|
18
|
+
# {Theme#active_bg_color} while preserving each span's foreground color.
|
|
19
19
|
class List < Component
|
|
20
|
-
# 256-color SGR index for the cursor-row background highlight. Matches
|
|
21
|
-
# what `Rainbow(...).bg(:darkslategray)` emits.
|
|
22
|
-
# @return [Integer]
|
|
23
|
-
CURSOR_BG = 59
|
|
24
|
-
|
|
25
20
|
def initialize
|
|
26
21
|
super
|
|
27
22
|
@lines = []
|
|
28
23
|
@padded_lines = []
|
|
29
24
|
@blank_padded = StyledString::EMPTY
|
|
30
25
|
@auto_scroll = false
|
|
26
|
+
@follow = true
|
|
31
27
|
@top_line = 0
|
|
32
28
|
@cursor = Cursor::None.new
|
|
33
29
|
@scrollbar_visibility = :gone
|
|
@@ -55,9 +51,18 @@ module Tuile
|
|
|
55
51
|
attr_accessor :on_cursor_changed
|
|
56
52
|
|
|
57
53
|
# @return [Boolean] if true and a line is added or new content is set,
|
|
58
|
-
# auto-scrolls to the bottom
|
|
54
|
+
# auto-scrolls to the bottom — but only while the viewport is already
|
|
55
|
+
# pinned to the last line (see {#following?}). Scroll up to read older
|
|
56
|
+
# content and appends stop yanking you back down; scroll back to the
|
|
57
|
+
# bottom and tailing resumes.
|
|
59
58
|
attr_reader :auto_scroll
|
|
60
59
|
|
|
60
|
+
# @return [Boolean] whether {#auto_scroll} is currently tailing. True
|
|
61
|
+
# while the viewport sits at the last line; flips to false the moment
|
|
62
|
+
# the user scrolls up, and back to true once they scroll to the bottom
|
|
63
|
+
# again. Only consulted when {#auto_scroll} is enabled.
|
|
64
|
+
def following? = @follow
|
|
65
|
+
|
|
61
66
|
# @return [Integer] top line of the viewport. 0 or positive.
|
|
62
67
|
attr_reader :top_line
|
|
63
68
|
|
|
@@ -92,10 +97,12 @@ module Tuile
|
|
|
92
97
|
invalidate
|
|
93
98
|
end
|
|
94
99
|
|
|
95
|
-
# Sets the new auto_scroll. If true,
|
|
100
|
+
# Sets the new auto_scroll. If true, re-engages tailing and immediately
|
|
101
|
+
# scrolls to the bottom.
|
|
96
102
|
# @param new_auto_scroll [Boolean]
|
|
97
103
|
def auto_scroll=(new_auto_scroll)
|
|
98
104
|
@auto_scroll = new_auto_scroll
|
|
105
|
+
@follow = true if new_auto_scroll
|
|
99
106
|
update_top_line_if_auto_scroll
|
|
100
107
|
end
|
|
101
108
|
|
|
@@ -118,6 +125,7 @@ module Tuile
|
|
|
118
125
|
return unless @top_line != new_top_line
|
|
119
126
|
|
|
120
127
|
@top_line = new_top_line
|
|
128
|
+
@follow = at_bottom?
|
|
121
129
|
invalidate
|
|
122
130
|
end
|
|
123
131
|
|
|
@@ -135,11 +143,11 @@ module Tuile
|
|
|
135
143
|
raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array
|
|
136
144
|
|
|
137
145
|
@lines = parse_input_lines(lines)
|
|
138
|
-
@content_size = nil
|
|
139
146
|
rebuild_padded_lines
|
|
140
147
|
update_top_line_if_auto_scroll
|
|
141
148
|
notify_cursor_changed
|
|
142
149
|
invalidate
|
|
150
|
+
self.content_size = compute_content_size
|
|
143
151
|
end
|
|
144
152
|
|
|
145
153
|
# Without a block, returns the current lines. With a block, fully
|
|
@@ -168,6 +176,7 @@ module Tuile
|
|
|
168
176
|
# @return [void]
|
|
169
177
|
def add_line(line)
|
|
170
178
|
raise ArgumentError, "line is nil" if line.nil?
|
|
179
|
+
|
|
171
180
|
add_lines [line]
|
|
172
181
|
end
|
|
173
182
|
|
|
@@ -181,20 +190,11 @@ module Tuile
|
|
|
181
190
|
screen.check_locked
|
|
182
191
|
new_lines = parse_input_lines(lines)
|
|
183
192
|
@lines += new_lines
|
|
184
|
-
@content_size = nil
|
|
185
193
|
@padded_lines += new_lines.map { |line| pad_to_row(line) }
|
|
186
194
|
update_top_line_if_auto_scroll
|
|
187
195
|
notify_cursor_changed
|
|
188
196
|
invalidate
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
# @return [Size]
|
|
192
|
-
def content_size
|
|
193
|
-
@content_size ||= begin
|
|
194
|
-
content_w = @lines.map(&:display_width).max || 0
|
|
195
|
-
width = @lines.empty? ? 0 : content_w + 2
|
|
196
|
-
Size.new(width, @lines.size)
|
|
197
|
-
end
|
|
197
|
+
grow_content_size(new_lines)
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
def focusable? = true
|
|
@@ -431,7 +431,7 @@ module Tuile
|
|
|
431
431
|
# @param position [Integer] initial position.
|
|
432
432
|
def initialize(positions, position: positions[0])
|
|
433
433
|
@positions = positions.sort
|
|
434
|
-
position = @positions[@positions.rindex {
|
|
434
|
+
position = @positions[@positions.rindex { _1 < position } || 0] unless @positions.include?(position)
|
|
435
435
|
super(position: position)
|
|
436
436
|
end
|
|
437
437
|
|
|
@@ -441,7 +441,7 @@ module Tuile
|
|
|
441
441
|
# @return [Boolean]
|
|
442
442
|
def handle_mouse(line, event, _line_count)
|
|
443
443
|
if event.button == :left
|
|
444
|
-
prev_pos = @positions.reverse_each.find {
|
|
444
|
+
prev_pos = @positions.reverse_each.find { _1 <= line }
|
|
445
445
|
return go_to_first if prev_pos.nil?
|
|
446
446
|
|
|
447
447
|
go(prev_pos)
|
|
@@ -453,7 +453,7 @@ module Tuile
|
|
|
453
453
|
# @param line_count [Integer]
|
|
454
454
|
# @return [Array<Integer>]
|
|
455
455
|
def candidate_positions(line_count)
|
|
456
|
-
@positions.select {
|
|
456
|
+
@positions.select { _1 < line_count }
|
|
457
457
|
end
|
|
458
458
|
|
|
459
459
|
# @param _line_count [Integer]
|
|
@@ -468,7 +468,7 @@ module Tuile
|
|
|
468
468
|
# @param line_count [Integer]
|
|
469
469
|
# @return [Boolean]
|
|
470
470
|
def go_down_by(lines, line_count)
|
|
471
|
-
next_pos = @positions.find {
|
|
471
|
+
next_pos = @positions.find { _1 >= @position + lines }
|
|
472
472
|
return go_to_last(line_count) if next_pos.nil?
|
|
473
473
|
|
|
474
474
|
go(next_pos)
|
|
@@ -477,7 +477,7 @@ module Tuile
|
|
|
477
477
|
# @param lines [Integer]
|
|
478
478
|
# @return [Boolean]
|
|
479
479
|
def go_up_by(lines)
|
|
480
|
-
prev_pos = @positions.reverse_each.find {
|
|
480
|
+
prev_pos = @positions.reverse_each.find { _1 <= @position - lines }
|
|
481
481
|
return go_to_first if prev_pos.nil?
|
|
482
482
|
|
|
483
483
|
go(prev_pos)
|
|
@@ -508,6 +508,30 @@ module Tuile
|
|
|
508
508
|
|
|
509
509
|
private
|
|
510
510
|
|
|
511
|
+
# Natural size from scratch: longest line's display width plus the two
|
|
512
|
+
# single-space gutters {#pad_to_row} adds, × line count. An empty list
|
|
513
|
+
# is {Size::ZERO} (no gutters for no content).
|
|
514
|
+
# @return [Size]
|
|
515
|
+
def compute_content_size
|
|
516
|
+
content_w = @lines.map(&:display_width).max || 0
|
|
517
|
+
width = @lines.empty? ? 0 : content_w + 2
|
|
518
|
+
Size.new(width, @lines.size)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Incremental {#content_size} update for appends: folds just the
|
|
522
|
+
# appended lines into the running maximum, keeping {#add_lines}
|
|
523
|
+
# O(appended) instead of re-scanning the whole list (LogWindow appends
|
|
524
|
+
# a line per log statement).
|
|
525
|
+
# @param appended [Array<StyledString>] the just-appended lines
|
|
526
|
+
# (already concatenated onto {@lines}).
|
|
527
|
+
# @return [void]
|
|
528
|
+
def grow_content_size(appended)
|
|
529
|
+
return if appended.empty?
|
|
530
|
+
|
|
531
|
+
appended_w = appended.map(&:display_width).max + 2
|
|
532
|
+
self.content_size = Size.new([content_size.width, appended_w].max, @lines.size)
|
|
533
|
+
end
|
|
534
|
+
|
|
511
535
|
# Coerces and flattens a list of input entries into trimmed
|
|
512
536
|
# {StyledString} lines. Each entry becomes a {StyledString} (String
|
|
513
537
|
# via {StyledString.parse}, StyledString passed through, anything else
|
|
@@ -612,16 +636,16 @@ module Tuile
|
|
|
612
636
|
def order_for_search(candidates, current, include_current:, reverse:)
|
|
613
637
|
if reverse
|
|
614
638
|
before, after = if include_current
|
|
615
|
-
[candidates.select {
|
|
639
|
+
[candidates.select { _1 <= current }, candidates.select { _1 > current }]
|
|
616
640
|
else
|
|
617
|
-
[candidates.select {
|
|
641
|
+
[candidates.select { _1 < current }, candidates.select { _1 >= current }]
|
|
618
642
|
end
|
|
619
643
|
before.reverse + after.reverse
|
|
620
644
|
else
|
|
621
645
|
after, before = if include_current
|
|
622
|
-
[candidates.select {
|
|
646
|
+
[candidates.select { _1 >= current }, candidates.select { _1 < current }]
|
|
623
647
|
else
|
|
624
|
-
[candidates.select {
|
|
648
|
+
[candidates.select { _1 > current }, candidates.select { _1 <= current }]
|
|
625
649
|
end
|
|
626
650
|
after + before
|
|
627
651
|
end
|
|
@@ -643,6 +667,10 @@ module Tuile
|
|
|
643
667
|
# @return [Integer] the max value of {#top_line}.
|
|
644
668
|
def top_line_max = (@lines.size - rect.height).clamp(0, nil)
|
|
645
669
|
|
|
670
|
+
# @return [Boolean] whether the viewport is pinned to the last line.
|
|
671
|
+
# Drives {#following?}: re-evaluated on every {#top_line=}.
|
|
672
|
+
def at_bottom? = @top_line == top_line_max
|
|
673
|
+
|
|
646
674
|
# @return [Integer] the number of visible lines.
|
|
647
675
|
def viewport_lines = rect.height
|
|
648
676
|
|
|
@@ -665,9 +693,14 @@ module Tuile
|
|
|
665
693
|
# which would leave `top_line` past the last item once a real rect
|
|
666
694
|
# arrives. {#on_width_changed} re-runs this hook when the rect grows so
|
|
667
695
|
# the snap-to-bottom intent is preserved.
|
|
696
|
+
#
|
|
697
|
+
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
698
|
+
# cursor snap and viewport pin are both skipped, so reading older
|
|
699
|
+
# content is not interrupted by incoming lines. {#top_line=} re-arms
|
|
700
|
+
# `@follow` when the viewport returns to the bottom.
|
|
668
701
|
# @return [void]
|
|
669
702
|
def update_top_line_if_auto_scroll
|
|
670
|
-
return unless @auto_scroll
|
|
703
|
+
return unless @auto_scroll && @follow
|
|
671
704
|
return if rect.empty?
|
|
672
705
|
|
|
673
706
|
notify_cursor_changed if @cursor.go_to_last(@lines.size)
|
|
@@ -731,7 +764,7 @@ module Tuile
|
|
|
731
764
|
def paintable_line(index, row_in_viewport, scrollbar)
|
|
732
765
|
base = index < @lines.size ? @padded_lines[index] : @blank_padded
|
|
733
766
|
is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
|
|
734
|
-
styled = is_cursor ? base.with_bg(
|
|
767
|
+
styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
|
|
735
768
|
out = styled.to_ansi
|
|
736
769
|
out += scrollbar.scrollbar_char(row_in_viewport) if scrollbar
|
|
737
770
|
out
|
|
@@ -34,10 +34,10 @@ module Tuile
|
|
|
34
34
|
raise ArgumentError, "options must not be empty" if options.empty?
|
|
35
35
|
|
|
36
36
|
super(caption)
|
|
37
|
-
@options = options.map { Option.new(
|
|
37
|
+
@options = options.map { Option.new(_1[0], _1[1]) }
|
|
38
38
|
@block = block
|
|
39
39
|
list = Component::List.new
|
|
40
|
-
list.lines = @options.map { "#{
|
|
40
|
+
list.lines = @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }
|
|
41
41
|
list.cursor = Component::List::Cursor.new
|
|
42
42
|
list.on_item_chosen = ->(index, _line) { select_option(@options[index].key) }
|
|
43
43
|
self.content = list
|
|
@@ -55,7 +55,7 @@ module Tuile
|
|
|
55
55
|
def handle_key(key)
|
|
56
56
|
return true if super
|
|
57
57
|
|
|
58
|
-
if @options.any? {
|
|
58
|
+
if @options.any? { _1.key == key }
|
|
59
59
|
select_option(key)
|
|
60
60
|
true
|
|
61
61
|
else
|
|
@@ -65,7 +65,7 @@ module Tuile
|
|
|
65
65
|
|
|
66
66
|
# @return [String]
|
|
67
67
|
def keyboard_hint
|
|
68
|
-
@options.map { "#{
|
|
68
|
+
@options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }.join(" ")
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
@@ -35,6 +35,21 @@ module Tuile
|
|
|
35
35
|
|
|
36
36
|
def focusable? = true
|
|
37
37
|
|
|
38
|
+
# Reassigns the popup's rect, escalating to a full scene repaint when an
|
|
39
|
+
# open popup shrinks or moves so its new rect no longer covers the cells
|
|
40
|
+
# it previously painted. A popup overdraws the scene without clipping and
|
|
41
|
+
# nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
|
|
42
|
+
# would repaint into the new rect and leave the vacated cells showing
|
|
43
|
+
# stale content. When the new rect fully covers the old one (the popup
|
|
44
|
+
# only grew), the fast path is correct and the full repaint is skipped.
|
|
45
|
+
# @param new_rect [Rect]
|
|
46
|
+
# @return [void]
|
|
47
|
+
def rect=(new_rect)
|
|
48
|
+
old_rect = rect
|
|
49
|
+
super
|
|
50
|
+
screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
|
|
51
|
+
end
|
|
52
|
+
|
|
38
53
|
# Mounts this popup on the {Screen}. Recomputes the popup's size from
|
|
39
54
|
# the current content first, so reopening a popup whose content has
|
|
40
55
|
# grown or shrunk while closed picks up the new size.
|
|
@@ -81,10 +96,20 @@ module Tuile
|
|
|
81
96
|
update_rect unless new_content.nil?
|
|
82
97
|
end
|
|
83
98
|
|
|
99
|
+
# Re-sizes (and recenters, when open) whenever the wrapped content's
|
|
100
|
+
# natural size changes — e.g. a {Label}'s `text=`, a {List}'s
|
|
101
|
+
# `add_line`, or a nested {Window} whose own content grew (the window
|
|
102
|
+
# recomputes its {Component#content_size} and the change bubbles here).
|
|
103
|
+
# @param _child [Component]
|
|
104
|
+
# @return [void]
|
|
105
|
+
def on_child_content_size_changed(_child)
|
|
106
|
+
update_rect
|
|
107
|
+
end
|
|
108
|
+
|
|
84
109
|
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
85
110
|
# @return [String]
|
|
86
111
|
def keyboard_hint
|
|
87
|
-
prefix = "q #{
|
|
112
|
+
prefix = "q #{screen.theme.hint("Close")}"
|
|
88
113
|
child_hint = @content&.keyboard_hint.to_s
|
|
89
114
|
child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
|
|
90
115
|
end
|
|
@@ -115,12 +140,18 @@ module Tuile
|
|
|
115
140
|
|
|
116
141
|
# Recompute width/height from {#content}'s natural size and recenter
|
|
117
142
|
# if currently open. Called whenever content is (re)assigned.
|
|
143
|
+
#
|
|
144
|
+
# Computes the final (centered) rect and assigns it in one step rather
|
|
145
|
+
# than positioning at the origin and then centering: the intermediate
|
|
146
|
+
# origin rect rarely covers the previous one, which would make
|
|
147
|
+
# {#rect=}'s shrink/move detection fire a full repaint on every resize.
|
|
118
148
|
# @return [void]
|
|
119
149
|
def update_rect
|
|
120
150
|
size = @content.content_size.clamp_height(max_height)
|
|
121
151
|
size = size.clamp(Size.new(screen.size.width * 4 / 5, screen.size.height * 4 / 5))
|
|
122
|
-
|
|
123
|
-
|
|
152
|
+
r = Rect.new(0, 0, size.width, size.height)
|
|
153
|
+
r = r.centered(screen.size) if open?
|
|
154
|
+
self.rect = r
|
|
124
155
|
end
|
|
125
156
|
end
|
|
126
157
|
end
|
|
@@ -70,7 +70,6 @@ module Tuile
|
|
|
70
70
|
def repaint
|
|
71
71
|
return if rect.empty?
|
|
72
72
|
|
|
73
|
-
bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
|
|
74
73
|
rows = display_rows
|
|
75
74
|
(0...rect.height).each do |screen_row|
|
|
76
75
|
row_idx = screen_row + @top_display_row
|
|
@@ -81,7 +80,7 @@ module Tuile
|
|
|
81
80
|
chunk = @text[r[:start], r[:length]] || ""
|
|
82
81
|
chunk + (" " * (rect.width - r[:length]))
|
|
83
82
|
end
|
|
84
|
-
screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row),
|
|
83
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top + screen_row), background(line)
|
|
85
84
|
end
|
|
86
85
|
end
|
|
87
86
|
|
|
@@ -59,9 +59,8 @@ module Tuile
|
|
|
59
59
|
def repaint
|
|
60
60
|
return if rect.empty?
|
|
61
61
|
|
|
62
|
-
bg = active? ? ACTIVE_BG_SGR : INACTIVE_BG_SGR
|
|
63
62
|
padded = @text + (" " * (rect.width - @text.length))
|
|
64
|
-
screen.print TTY::Cursor.move_to(rect.left, rect.top),
|
|
63
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top), background(padded)
|
|
65
64
|
end
|
|
66
65
|
|
|
67
66
|
protected
|
|
@@ -88,21 +88,6 @@ module Tuile
|
|
|
88
88
|
invalidate
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
-
# 256-color SGR for the focused-button highlight (matches what
|
|
92
|
-
# `Rainbow(...).bg(:darkslategray)` emits, which is what
|
|
93
|
-
# {Component::Button#repaint} uses for its focused state).
|
|
94
|
-
# @return [String]
|
|
95
|
-
ACTIVE_BG_SGR = "\e[48;5;59m"
|
|
96
|
-
# 256-color SGR for the unfocused field's "well": index 238 sits in
|
|
97
|
-
# the grayscale ramp (~#444444), bright enough to stand out against
|
|
98
|
-
# non-pure-black terminal themes (Gruvbox/Solarized/OneDark base
|
|
99
|
-
# backgrounds sit in the #1d–#2d range), and still distinctly darker
|
|
100
|
-
# than the active highlight at index 59 (~#5f5f5f). Rainbow's
|
|
101
|
-
# RGB-to-256 mapping snaps everything dark to palette index 16
|
|
102
|
-
# (terminal black), so we emit the escape directly to reach the ramp.
|
|
103
|
-
# @return [String]
|
|
104
|
-
INACTIVE_BG_SGR = "\e[48;5;238m"
|
|
105
|
-
|
|
106
91
|
# Handles a key. Returns false when the component is inactive. Otherwise
|
|
107
92
|
# first runs the {Component#handle_key} shortcut search via `super`, then
|
|
108
93
|
# delegates to {#handle_text_input_key}.
|
|
@@ -117,6 +102,16 @@ module Tuile
|
|
|
117
102
|
|
|
118
103
|
protected
|
|
119
104
|
|
|
105
|
+
# Renders `text` on the field's background well, looked up from the
|
|
106
|
+
# current {Screen#theme} at paint time: {Theme#active_bg} when this
|
|
107
|
+
# input is on the active (focus) chain, {Theme#input_bg} otherwise —
|
|
108
|
+
# visibly a field either way, distinctly highlighted when active.
|
|
109
|
+
# @param text [String]
|
|
110
|
+
# @return [String] ANSI-rendered text.
|
|
111
|
+
def background(text)
|
|
112
|
+
active? ? screen.theme.active_bg(text) : screen.theme.input_bg(text)
|
|
113
|
+
end
|
|
114
|
+
|
|
120
115
|
# Input filter for {#text=}. Subclasses override to truncate or reject
|
|
121
116
|
# invalid input. Default coerces to String.
|
|
122
117
|
# @param new_text [String]
|
|
@@ -50,10 +50,10 @@ module Tuile
|
|
|
50
50
|
@physical_lines = []
|
|
51
51
|
@hard_line_wrap_counts = []
|
|
52
52
|
@text = StyledString::EMPTY
|
|
53
|
-
@content_size = Size::ZERO
|
|
54
53
|
@blank_line = StyledString::EMPTY
|
|
55
54
|
@top_line = 0
|
|
56
55
|
@auto_scroll = false
|
|
56
|
+
@follow = true
|
|
57
57
|
@scrollbar_visibility = :gone
|
|
58
58
|
# The view always has at least one region — an implicit default. It
|
|
59
59
|
# owns whatever hard lines exist that no later region claims. App
|
|
@@ -81,9 +81,18 @@ module Tuile
|
|
|
81
81
|
attr_reader :scrollbar_visibility
|
|
82
82
|
|
|
83
83
|
# @return [Boolean] if true, mutating the text scrolls the viewport so
|
|
84
|
-
# the last line stays in view
|
|
84
|
+
# the last line stays in view — but only while the viewport is already
|
|
85
|
+
# pinned to the last line (see {#following?}). Scroll up to read older
|
|
86
|
+
# content and appends stop yanking you back down; scroll back to the
|
|
87
|
+
# bottom and tailing resumes. Default `false`.
|
|
85
88
|
attr_reader :auto_scroll
|
|
86
89
|
|
|
90
|
+
# @return [Boolean] whether {#auto_scroll} is currently tailing. True
|
|
91
|
+
# while the viewport sits at the last line; flips to false the moment
|
|
92
|
+
# the user scrolls up, and back to true once they scroll to the bottom
|
|
93
|
+
# again. Only consulted when {#auto_scroll} is enabled.
|
|
94
|
+
def following? = @follow
|
|
95
|
+
|
|
87
96
|
# Replaces the text. Embedded `\n` characters become hard line breaks.
|
|
88
97
|
# A `String` is parsed via {StyledString.parse} (so embedded ANSI is
|
|
89
98
|
# honored); a `StyledString` is used as-is; `nil` is coerced to an
|
|
@@ -110,10 +119,10 @@ module Tuile
|
|
|
110
119
|
@regions = [Region.send(:new, self, @hard_lines.size)]
|
|
111
120
|
return if content_unchanged
|
|
112
121
|
|
|
113
|
-
@content_size = compute_content_size
|
|
114
122
|
rewrap
|
|
115
123
|
update_top_line_if_auto_scroll
|
|
116
124
|
invalidate
|
|
125
|
+
self.content_size = compute_content_size
|
|
117
126
|
end
|
|
118
127
|
|
|
119
128
|
# Creates a new empty {Region} at the spatial tail of the document
|
|
@@ -180,9 +189,9 @@ module Tuile
|
|
|
180
189
|
|
|
181
190
|
tail_region.send(:line_count=, tail_region.line_count + added)
|
|
182
191
|
@text = nil
|
|
183
|
-
@content_size = compute_content_size
|
|
184
192
|
update_top_line_if_auto_scroll
|
|
185
193
|
invalidate
|
|
194
|
+
self.content_size = compute_content_size
|
|
186
195
|
end
|
|
187
196
|
|
|
188
197
|
# Verbatim append, returning `self` for chainability (`view << a << b`).
|
|
@@ -254,10 +263,10 @@ module Tuile
|
|
|
254
263
|
end
|
|
255
264
|
|
|
256
265
|
@text = nil
|
|
257
|
-
@content_size = compute_content_size
|
|
258
266
|
@top_line = top_line_max if @top_line > top_line_max
|
|
259
267
|
update_top_line_if_auto_scroll
|
|
260
268
|
invalidate
|
|
269
|
+
self.content_size = compute_content_size
|
|
261
270
|
end
|
|
262
271
|
|
|
263
272
|
# Replaces a contiguous range of hard lines with the parsed content
|
|
@@ -315,10 +324,10 @@ module Tuile
|
|
|
315
324
|
splice_hard_lines(from, length, new_hard_lines)
|
|
316
325
|
update_region_counts(from, length, new_hard_lines.size)
|
|
317
326
|
@text = nil
|
|
318
|
-
@content_size = compute_content_size
|
|
319
327
|
@top_line = top_line_max if @top_line > top_line_max
|
|
320
328
|
update_top_line_if_auto_scroll
|
|
321
329
|
invalidate
|
|
330
|
+
self.content_size = compute_content_size
|
|
322
331
|
end
|
|
323
332
|
|
|
324
333
|
# Inserts `str` at hard-line index `at`. Equivalent to
|
|
@@ -348,6 +357,7 @@ module Tuile
|
|
|
348
357
|
return if @top_line == new_top_line
|
|
349
358
|
|
|
350
359
|
@top_line = new_top_line
|
|
360
|
+
@follow = at_bottom?
|
|
351
361
|
invalidate
|
|
352
362
|
end
|
|
353
363
|
|
|
@@ -362,11 +372,13 @@ module Tuile
|
|
|
362
372
|
invalidate
|
|
363
373
|
end
|
|
364
374
|
|
|
365
|
-
# Sets `auto_scroll`. If true,
|
|
375
|
+
# Sets `auto_scroll`. If true, re-engages tailing and immediately
|
|
376
|
+
# scrolls to the bottom.
|
|
366
377
|
# @param value [Boolean]
|
|
367
378
|
# @return [void]
|
|
368
379
|
def auto_scroll=(value)
|
|
369
380
|
@auto_scroll = value ? true : false
|
|
381
|
+
@follow = true if @auto_scroll
|
|
370
382
|
update_top_line_if_auto_scroll
|
|
371
383
|
end
|
|
372
384
|
|
|
@@ -529,10 +541,10 @@ module Tuile
|
|
|
529
541
|
splice_hard_lines(start, old_count, new_lines)
|
|
530
542
|
region.send(:line_count=, new_lines.size)
|
|
531
543
|
@text = nil
|
|
532
|
-
@content_size = compute_content_size
|
|
533
544
|
@top_line = top_line_max if @top_line > top_line_max
|
|
534
545
|
update_top_line_if_auto_scroll
|
|
535
546
|
invalidate
|
|
547
|
+
self.content_size = compute_content_size
|
|
536
548
|
end
|
|
537
549
|
|
|
538
550
|
# Region-scoped {#replace}. Validates `range` against
|
|
@@ -555,10 +567,10 @@ module Tuile
|
|
|
555
567
|
splice_hard_lines(abs_from, length, new_hard_lines)
|
|
556
568
|
region.send(:line_count=, region.line_count - length + new_hard_lines.size)
|
|
557
569
|
@text = nil
|
|
558
|
-
@content_size = compute_content_size
|
|
559
570
|
@top_line = top_line_max if @top_line > top_line_max
|
|
560
571
|
update_top_line_if_auto_scroll
|
|
561
572
|
invalidate
|
|
573
|
+
self.content_size = compute_content_size
|
|
562
574
|
end
|
|
563
575
|
|
|
564
576
|
# Verbatim append into `region`.
|
|
@@ -595,10 +607,10 @@ module Tuile
|
|
|
595
607
|
region.send(:line_count=, region.line_count + rest.size)
|
|
596
608
|
end
|
|
597
609
|
@text = nil
|
|
598
|
-
@content_size = compute_content_size
|
|
599
610
|
@top_line = top_line_max if @top_line > top_line_max
|
|
600
611
|
update_top_line_if_auto_scroll
|
|
601
612
|
invalidate
|
|
613
|
+
self.content_size = compute_content_size
|
|
602
614
|
end
|
|
603
615
|
|
|
604
616
|
# Drops the last `n` hard lines from `region`'s tail via
|
|
@@ -617,10 +629,10 @@ module Tuile
|
|
|
617
629
|
splice_hard_lines(drop_from, to_drop, [])
|
|
618
630
|
region.send(:line_count=, region.line_count - to_drop)
|
|
619
631
|
@text = nil
|
|
620
|
-
@content_size = compute_content_size
|
|
621
632
|
@top_line = top_line_max if @top_line > top_line_max
|
|
622
633
|
update_top_line_if_auto_scroll
|
|
623
634
|
invalidate
|
|
635
|
+
self.content_size = compute_content_size
|
|
624
636
|
end
|
|
625
637
|
|
|
626
638
|
# Drops `region` from {@regions}: its hard lines are removed via
|
|
@@ -643,10 +655,10 @@ module Tuile
|
|
|
643
655
|
return unless had_lines
|
|
644
656
|
|
|
645
657
|
@text = nil
|
|
646
|
-
@content_size = compute_content_size
|
|
647
658
|
@top_line = top_line_max if @top_line > top_line_max
|
|
648
659
|
update_top_line_if_auto_scroll
|
|
649
660
|
invalidate
|
|
661
|
+
self.content_size = compute_content_size
|
|
650
662
|
end
|
|
651
663
|
|
|
652
664
|
# Adjusts region line counts after a {@hard_lines} splice that
|
|
@@ -849,14 +861,22 @@ module Tuile
|
|
|
849
861
|
self.top_line = clamped unless @top_line == clamped
|
|
850
862
|
end
|
|
851
863
|
|
|
864
|
+
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
865
|
+
# viewport pin is skipped, so reading older content is not interrupted
|
|
866
|
+
# by incoming lines. {#top_line=} re-arms `@follow` when the viewport
|
|
867
|
+
# returns to the bottom.
|
|
852
868
|
# @return [void]
|
|
853
869
|
def update_top_line_if_auto_scroll
|
|
854
|
-
return unless @auto_scroll
|
|
870
|
+
return unless @auto_scroll && @follow
|
|
855
871
|
|
|
856
872
|
target = (@physical_lines.size - viewport_lines).clamp(0, nil)
|
|
857
873
|
self.top_line = target if @top_line != target
|
|
858
874
|
end
|
|
859
875
|
|
|
876
|
+
# @return [Boolean] whether the viewport is pinned to the last line.
|
|
877
|
+
# Drives {#following?}: re-evaluated on every {#top_line=}.
|
|
878
|
+
def at_bottom? = @top_line == top_line_max
|
|
879
|
+
|
|
860
880
|
# @return [Boolean]
|
|
861
881
|
def scrollbar_visible?
|
|
862
882
|
return false if rect.empty?
|