tuile 0.16.0 → 0.17.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 +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
|
@@ -17,7 +17,7 @@ module Tuile
|
|
|
17
17
|
# start of the next row in nearly all cases).
|
|
18
18
|
#
|
|
19
19
|
# Enter inserts a newline, as in a plain `<textarea>` or text editor; only
|
|
20
|
-
# {#
|
|
20
|
+
# {#on_value_change} is wired. {Keys::CTRL_J} does the same, since that is the
|
|
21
21
|
# byte a terminal sends for a typed Ctrl+J. A *pasted* line break arrives
|
|
22
22
|
# through {AbstractStringField#handle_paste} instead and never as a key at
|
|
23
23
|
# all — so a subclass rebinding Enter to submit keeps working under a
|
|
@@ -44,18 +44,18 @@ module Tuile
|
|
|
44
44
|
# == Implementation details
|
|
45
45
|
#
|
|
46
46
|
# The wrap itself — and with it every conversion between a character
|
|
47
|
-
# **index** and a **row/column** — lives in {WrappedText}, a
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# the {#scroll_top_row} viewport, which {WrappedText}
|
|
51
|
-
# nothing about (it is a pure function of text and
|
|
52
|
-
# stateful and needs {Rect#height}).
|
|
47
|
+
# **index** and a **row/column** — lives in {WrappedText}, a snapshot of
|
|
48
|
+
# `(text, rect.width)` this class caches: a text mutation drops it, and a
|
|
49
|
+
# read at another width rebuilds it. What stays here is the widget: keys,
|
|
50
|
+
# mouse, painting, and the {#scroll_top_row} viewport, which {WrappedText}
|
|
51
|
+
# deliberately knows nothing about (it is a pure function of text and
|
|
52
|
+
# width; the viewport is stateful and needs {Rect#height}).
|
|
53
53
|
class TextArea < AbstractStringField
|
|
54
54
|
def initialize
|
|
55
55
|
super
|
|
56
56
|
@scroll_top_row = 0
|
|
57
57
|
# Lazy cache; nil means "stale, rebuild on next read". Reset whenever
|
|
58
|
-
# {#text} mutates
|
|
58
|
+
# {#text} mutates; {#wrap} notices a width change itself.
|
|
59
59
|
@wrap = nil
|
|
60
60
|
end
|
|
61
61
|
|
|
@@ -80,11 +80,10 @@ module Tuile
|
|
|
80
80
|
row_in_viewport = row - @scroll_top_row
|
|
81
81
|
return nil if row_in_viewport.negative? || row_in_viewport >= rect.height
|
|
82
82
|
|
|
83
|
-
# Cap so the hardware cursor never lands
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + row_in_viewport)
|
|
83
|
+
# Cap so the hardware cursor never lands one past the last column.
|
|
84
|
+
# Terminals with auto-wrap interpret that as column 0 of the row below;
|
|
85
|
+
# capping pins the cursor on the last visible cell instead.
|
|
86
|
+
Point.new(col.clamp(0, rect.width - 1), row_in_viewport)
|
|
88
87
|
end
|
|
89
88
|
|
|
90
89
|
# @param event [Mouse::DownEvent]
|
|
@@ -92,22 +91,23 @@ module Tuile
|
|
|
92
91
|
def handle_mouse_down?(event)
|
|
93
92
|
return false unless event.button == :left
|
|
94
93
|
|
|
95
|
-
target_row =
|
|
94
|
+
target_row = event.y + @scroll_top_row
|
|
96
95
|
self.caret = if target_row >= wrap.row_count
|
|
97
96
|
@text.length
|
|
98
97
|
else
|
|
99
|
-
wrap.index_at(target_row, event.x
|
|
98
|
+
wrap.index_at(target_row, event.x)
|
|
100
99
|
end
|
|
101
100
|
true
|
|
102
101
|
end
|
|
103
102
|
|
|
103
|
+
# @param canvas [Canvas] see {Component#repaint}.
|
|
104
104
|
# @return [void]
|
|
105
|
-
def repaint
|
|
105
|
+
def repaint(canvas)
|
|
106
106
|
return if rect.empty?
|
|
107
107
|
|
|
108
108
|
(0...rect.height).each do |row_in_viewport|
|
|
109
109
|
line = wrap.row_text(row_in_viewport + @scroll_top_row)
|
|
110
|
-
|
|
110
|
+
canvas.set_text(0, row_in_viewport, StyledString.plain(line))
|
|
111
111
|
end
|
|
112
112
|
end
|
|
113
113
|
|
|
@@ -148,10 +148,11 @@ module Tuile
|
|
|
148
148
|
true
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
+
# Re-clamps the viewport to the caret, since either axis of a new rect can
|
|
152
|
+
# scroll it away.
|
|
151
153
|
# @return [void]
|
|
152
|
-
def
|
|
154
|
+
def relayout
|
|
153
155
|
super
|
|
154
|
-
@wrap = nil
|
|
155
156
|
adjust_scroll_top_row
|
|
156
157
|
end
|
|
157
158
|
|
|
@@ -159,6 +160,7 @@ module Tuile
|
|
|
159
160
|
|
|
160
161
|
# @return [WrappedText] the current wrap of {#text} at {Rect#width}.
|
|
161
162
|
def wrap
|
|
163
|
+
@wrap = nil unless @wrap&.width == rect.width
|
|
162
164
|
@wrap ||= WrappedText.new(@text, rect.width)
|
|
163
165
|
end
|
|
164
166
|
|
|
@@ -48,15 +48,12 @@ module Tuile
|
|
|
48
48
|
@placeholder = nil
|
|
49
49
|
@left_column = 0
|
|
50
50
|
@max_text_length = nil
|
|
51
|
-
@on_key_up = nil
|
|
52
|
-
@on_key_down = nil
|
|
53
|
-
@on_enter = nil
|
|
54
51
|
end
|
|
55
52
|
|
|
56
53
|
# Optional cap on {#text}'s length **in characters** — a wide glyph counts
|
|
57
54
|
# once. Typing into a field already at the cap does nothing.
|
|
58
55
|
#
|
|
59
|
-
# Deliberately does not police {#
|
|
56
|
+
# Deliberately does not police {#value=}: lowering the cap under an existing
|
|
60
57
|
# value leaves that value intact rather than silently trimming it.
|
|
61
58
|
# @return [Integer, nil] maximum characters, or nil for unbounded (default).
|
|
62
59
|
attr_reader :max_text_length
|
|
@@ -72,35 +69,54 @@ module Tuile
|
|
|
72
69
|
@max_text_length = max
|
|
73
70
|
end
|
|
74
71
|
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
72
|
+
# What {#on_key_up} fires.
|
|
73
|
+
#
|
|
74
|
+
# @!attribute [r] source
|
|
75
|
+
# @return [TextField] the field the key reached.
|
|
76
|
+
KeyUpEvent = Data.define(:source) { include Tuile::Event }
|
|
77
|
+
|
|
78
|
+
# What {#on_key_down} fires.
|
|
79
|
+
#
|
|
80
|
+
# @!attribute [r] source
|
|
81
|
+
# @return [TextField] the field the key reached.
|
|
82
|
+
KeyDownEvent = Data.define(:source) { include Tuile::Event }
|
|
83
|
+
|
|
84
|
+
# What {#on_enter} fires.
|
|
85
|
+
#
|
|
86
|
+
# @!attribute [r] source
|
|
87
|
+
# @return [TextField] the field the key reached.
|
|
88
|
+
EnterEvent = Data.define(:source) { include Tuile::Event }
|
|
89
|
+
|
|
90
|
+
# @!method on_key_up
|
|
91
|
+
# Fired with a {KeyUpEvent} when the UP arrow key is pressed. **Empty means
|
|
92
|
+
# the field declines UP**, which then falls through to the parent; a
|
|
93
|
+
# registered listener consumes it. Only triggered by {Keys::UP_ARROW},
|
|
94
|
+
# not by `k`, which is a printable character inserted into {#text}.
|
|
95
|
+
# @return [Listeners]
|
|
96
|
+
listener :on_key_up
|
|
97
|
+
|
|
98
|
+
# @!method on_key_down
|
|
99
|
+
# Fired with a {KeyDownEvent} when the DOWN arrow key is pressed. **Empty
|
|
100
|
+
# means the field declines DOWN**, which then falls through to the
|
|
101
|
+
# parent. Only triggered by {Keys::DOWN_ARROW}, not by `j`.
|
|
102
|
+
# @return [Listeners]
|
|
103
|
+
listener :on_key_down
|
|
104
|
+
|
|
105
|
+
# @!method on_enter
|
|
106
|
+
# Fired with an {EnterEvent} when ENTER is pressed. **Empty means the field
|
|
107
|
+
# declines ENTER**, which then falls through to the parent — which is how
|
|
108
|
+
# a scope's default button keeps working.
|
|
109
|
+
# @return [Listeners]
|
|
110
|
+
listener :on_enter
|
|
94
111
|
|
|
95
112
|
# @return [Point, nil]
|
|
96
113
|
def cursor_position
|
|
97
114
|
return nil unless rect.width.positive?
|
|
98
115
|
|
|
99
116
|
# Scrolling already keeps the caret inside the rect, so the cap is a
|
|
100
|
-
# guard rather than a policy: a cursor parked
|
|
117
|
+
# guard rather than a policy: a cursor parked one past the last column
|
|
101
118
|
# reads as column 0 of the next row on an auto-wrapping terminal.
|
|
102
|
-
|
|
103
|
-
Point.new(rect.left + offset, rect.top)
|
|
119
|
+
Point.new((column_at(@caret) - @left_column).clamp(0, rect.width - 1), 0)
|
|
104
120
|
end
|
|
105
121
|
|
|
106
122
|
# Places the caret at the pressed column. A press on the right half of a
|
|
@@ -110,17 +126,18 @@ module Tuile
|
|
|
110
126
|
def handle_mouse_down?(event)
|
|
111
127
|
return false unless event.button == :left
|
|
112
128
|
|
|
113
|
-
self.caret = index_at(event.x
|
|
129
|
+
self.caret = index_at(event.x + @left_column)
|
|
114
130
|
true
|
|
115
131
|
end
|
|
116
132
|
|
|
133
|
+
# @param canvas [Canvas] see {Component#repaint}.
|
|
117
134
|
# @return [void]
|
|
118
|
-
def repaint
|
|
135
|
+
def repaint(canvas)
|
|
119
136
|
return if rect.empty?
|
|
120
137
|
|
|
121
|
-
return
|
|
138
|
+
return canvas.set_text(0, 0, placeholder_row) if show_placeholder?
|
|
122
139
|
|
|
123
|
-
|
|
140
|
+
canvas.set_text(0, 0, StyledString.plain(visible_text))
|
|
124
141
|
end
|
|
125
142
|
|
|
126
143
|
protected
|
|
@@ -137,17 +154,17 @@ module Tuile
|
|
|
137
154
|
when *Keys::BACKSPACES then delete_before_caret
|
|
138
155
|
when Keys::DELETE then delete_at_caret
|
|
139
156
|
when Keys::UP_ARROW
|
|
140
|
-
return false if
|
|
157
|
+
return false if on_key_up.empty?
|
|
141
158
|
|
|
142
|
-
|
|
159
|
+
on_key_up.fire(KeyUpEvent.new(source: self))
|
|
143
160
|
when Keys::DOWN_ARROW
|
|
144
|
-
return false if
|
|
161
|
+
return false if on_key_down.empty?
|
|
145
162
|
|
|
146
|
-
|
|
163
|
+
on_key_down.fire(KeyDownEvent.new(source: self))
|
|
147
164
|
when Keys::ENTER
|
|
148
|
-
return false if
|
|
165
|
+
return false if on_enter.empty?
|
|
149
166
|
|
|
150
|
-
|
|
167
|
+
on_enter.fire(EnterEvent.new(source: self))
|
|
151
168
|
else
|
|
152
169
|
return insert(key) if Keys.printable?(key)
|
|
153
170
|
|
|
@@ -187,7 +204,7 @@ module Tuile
|
|
|
187
204
|
end
|
|
188
205
|
|
|
189
206
|
# @return [void]
|
|
190
|
-
def
|
|
207
|
+
def relayout
|
|
191
208
|
super
|
|
192
209
|
adjust_left_column
|
|
193
210
|
end
|
|
@@ -246,8 +263,7 @@ module Tuile
|
|
|
246
263
|
# @return [StyledString] the hint, ellipsized to `rect.width` and padded
|
|
247
264
|
# back out to it.
|
|
248
265
|
def placeholder_row
|
|
249
|
-
|
|
250
|
-
hint + StyledString.plain(" " * [rect.width - hint.display_width, 0].max)
|
|
266
|
+
StyledString.styled(placeholder, fg: screen.theme.placeholder_color).ellipsize(rect.width).ljust(rect.width)
|
|
251
267
|
end
|
|
252
268
|
|
|
253
269
|
# @return [Integer] total display width of {#text}.
|
|
@@ -20,6 +20,17 @@ module Tuile
|
|
|
20
20
|
# splice a range in place. Turn on {#auto_scroll} to keep the latest content
|
|
21
21
|
# in view.
|
|
22
22
|
#
|
|
23
|
+
# {#scrollbar_visibility} turns on a {VerticalScrollBar} in the rightmost
|
|
24
|
+
# column — a real child component, so the user can drag its handle
|
|
25
|
+
# (wanting `run_event_loop(capture_mouse: :drag)`) and press its track to
|
|
26
|
+
# page. Its column and the blank one beside it are reserved whenever it is
|
|
27
|
+
# `:visible`, whether anything scrolls or not, so the text never rewraps
|
|
28
|
+
# behind a growing buffer (`D_scrollbar_reserve`, `D_scrollbar_ink`).
|
|
29
|
+
# Under `:auto` the bar shows only while the text, wrapped at the full
|
|
30
|
+
# width, has more rows than the viewport — and then the text rewraps once,
|
|
31
|
+
# two columns narrower, which is the price of the columns it hands back
|
|
32
|
+
# while everything fits.
|
|
33
|
+
#
|
|
23
34
|
# Meant to be the content of a {Window} — focus indication relies on the
|
|
24
35
|
# surrounding window chrome.
|
|
25
36
|
class TextView < Component
|
|
@@ -37,20 +48,29 @@ module Tuile
|
|
|
37
48
|
# Invariants:
|
|
38
49
|
# - `@line_wrap_counts.size == @lines.size`
|
|
39
50
|
# - `@line_wrap_counts.sum == @rows.size`
|
|
40
|
-
# A full rebuild ({#rewrap}) happens on {#text=} and
|
|
41
|
-
# other mutators splice
|
|
51
|
+
# A full rebuild ({#rewrap}) happens on {#text=} and when
|
|
52
|
+
# {#wrap_width} moves off `@wrapped_at`; other mutators splice
|
|
53
|
+
# incrementally.
|
|
42
54
|
@lines = []
|
|
43
55
|
@rows = []
|
|
44
56
|
@line_wrap_counts = []
|
|
57
|
+
@wrapped_at = 0
|
|
45
58
|
@text = StyledString::EMPTY
|
|
46
59
|
@blank_row = StyledString::EMPTY
|
|
47
60
|
@scroll_top_row = 0
|
|
48
61
|
@auto_scroll = false
|
|
49
62
|
@follow = true
|
|
50
63
|
@scrollbar_visibility = :gone
|
|
64
|
+
# Whether an `:auto` bar is showing — state, not derived on read,
|
|
65
|
+
# because `@rows` is wrapped at the width it leaves; only
|
|
66
|
+
# {#sync_auto_scrollbar} flips it.
|
|
67
|
+
@auto_scrollbar_shown = false
|
|
51
68
|
# Always ≥1 region; the implicit default owns any hard lines no
|
|
52
69
|
# app-created region claims. See {Region}.
|
|
53
70
|
@regions = [Region.send(:new, self)]
|
|
71
|
+
@scrollbar = VerticalScrollBar.new
|
|
72
|
+
@scrollbar.on_scroll_request { self.scroll_top_row = _1.scroll_top_row }
|
|
73
|
+
add_child(@scrollbar) # chrome, appended: a TextView has no content children
|
|
54
74
|
end
|
|
55
75
|
|
|
56
76
|
# @return [StyledString] the current text (empty by default). Rebuilt
|
|
@@ -63,7 +83,8 @@ module Tuile
|
|
|
63
83
|
# @return [Integer] index of the first visible row.
|
|
64
84
|
attr_reader :scroll_top_row
|
|
65
85
|
|
|
66
|
-
# @return [Symbol] `:gone
|
|
86
|
+
# @return [Symbol] `:gone`, `:visible`, or `:auto` — shown only while the
|
|
87
|
+
# text overflows the viewport at the full width. `:gone` by default.
|
|
67
88
|
attr_reader :scrollbar_visibility
|
|
68
89
|
|
|
69
90
|
# @return [Boolean] if true, mutating the text scrolls the viewport so
|
|
@@ -108,6 +129,7 @@ module Tuile
|
|
|
108
129
|
rewrap
|
|
109
130
|
update_scroll_top_row_if_auto_scroll
|
|
110
131
|
invalidate
|
|
132
|
+
invalidate_layout
|
|
111
133
|
end
|
|
112
134
|
|
|
113
135
|
# Creates a new empty {Region} at the spatial tail of the document
|
|
@@ -169,6 +191,7 @@ module Tuile
|
|
|
169
191
|
@text = nil
|
|
170
192
|
update_scroll_top_row_if_auto_scroll
|
|
171
193
|
invalidate
|
|
194
|
+
invalidate_layout # `@rows` grew, and the bar's row_count follows it
|
|
172
195
|
end
|
|
173
196
|
|
|
174
197
|
# Verbatim append, returning `self` for chainability (`view << a << b`).
|
|
@@ -231,10 +254,7 @@ module Tuile
|
|
|
231
254
|
remaining -= take
|
|
232
255
|
end
|
|
233
256
|
|
|
234
|
-
|
|
235
|
-
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
236
|
-
update_scroll_top_row_if_auto_scroll
|
|
237
|
-
invalidate
|
|
257
|
+
content_changed
|
|
238
258
|
end
|
|
239
259
|
|
|
240
260
|
# Replaces a contiguous range of hard lines with the parsed content of
|
|
@@ -272,10 +292,7 @@ module Tuile
|
|
|
272
292
|
|
|
273
293
|
splice_lines(from, length, new_lines)
|
|
274
294
|
update_region_counts(from, length, new_lines.size)
|
|
275
|
-
|
|
276
|
-
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
277
|
-
update_scroll_top_row_if_auto_scroll
|
|
278
|
-
invalidate
|
|
295
|
+
content_changed
|
|
279
296
|
end
|
|
280
297
|
|
|
281
298
|
# Inserts `str` at hard-line index `at`. Equivalent to
|
|
@@ -307,17 +324,23 @@ module Tuile
|
|
|
307
324
|
@scroll_top_row = new_row
|
|
308
325
|
@follow = at_bottom?
|
|
309
326
|
invalidate
|
|
327
|
+
invalidate_layout # {#relayout} pushes the row to the bar
|
|
310
328
|
end
|
|
311
329
|
|
|
312
|
-
# @param value [Symbol] `:gone` or `:
|
|
330
|
+
# @param value [Symbol] `:gone`, `:visible` or `:auto`.
|
|
331
|
+
# @raise [ArgumentError] on any other value.
|
|
313
332
|
# @return [void]
|
|
314
333
|
def scrollbar_visibility=(value)
|
|
315
|
-
|
|
334
|
+
unless %i[gone visible auto].include?(value)
|
|
335
|
+
raise ArgumentError, "expected :gone, :visible or :auto, got #{value.inspect}"
|
|
336
|
+
end
|
|
316
337
|
return if @scrollbar_visibility == value
|
|
317
338
|
|
|
318
339
|
@scrollbar_visibility = value
|
|
340
|
+
@auto_scrollbar_shown = false # starts at the full width; the settle decides
|
|
319
341
|
rewrap
|
|
320
342
|
invalidate
|
|
343
|
+
invalidate_layout # the bar's rect appears or collapses
|
|
321
344
|
end
|
|
322
345
|
|
|
323
346
|
# Sets `auto_scroll`. If true, re-engages tailing and immediately
|
|
@@ -383,35 +406,50 @@ module Tuile
|
|
|
383
406
|
scroll_top_row != before
|
|
384
407
|
end
|
|
385
408
|
|
|
386
|
-
# Paints the text into {#rect}
|
|
409
|
+
# Paints the text into {#rect}, out to the blank column that
|
|
410
|
+
# {#scrollbar_columns} reserves. The bar's own column is not painted
|
|
411
|
+
# here — it is the child {VerticalScrollBar}'s, placed by {#relayout}.
|
|
387
412
|
#
|
|
388
413
|
# Skips the {Component#repaint} default's auto-clear: every row is
|
|
389
414
|
# painted explicitly (with padded blanks past the last line), so the
|
|
390
415
|
# "fully draw over your rect" contract is met without an upfront wipe.
|
|
391
|
-
#
|
|
392
|
-
#
|
|
416
|
+
# {Component#invalidate_children} is the half that cannot be skipped
|
|
417
|
+
# with it, or the bar goes stale under an ancestor's clear
|
|
418
|
+
# (`D_repaint_cascade`). Rows go through {Canvas#set_text}, so content
|
|
419
|
+
# and blank rows inherit {ComponentBackground#effective} (a {#bg_color}
|
|
420
|
+
# set here or on an ancestor).
|
|
421
|
+
# @param canvas [Canvas] see {Component#repaint}.
|
|
393
422
|
# @return [void]
|
|
394
|
-
def repaint
|
|
423
|
+
def repaint(canvas)
|
|
395
424
|
return if rect.empty?
|
|
396
425
|
|
|
397
|
-
|
|
398
|
-
VerticalScrollBar.new(rect.height, row_count: @rows.size, scroll_top_row: @scroll_top_row)
|
|
399
|
-
end
|
|
426
|
+
invalidate_children
|
|
400
427
|
(0...rect.height).each do |row|
|
|
401
|
-
|
|
402
|
-
draw_text(rect.left, rect.top + row, line)
|
|
428
|
+
canvas.set_text(0, row, paintable_row(row + @scroll_top_row))
|
|
403
429
|
end
|
|
404
430
|
end
|
|
405
431
|
|
|
406
432
|
protected
|
|
407
433
|
|
|
408
|
-
#
|
|
409
|
-
#
|
|
410
|
-
#
|
|
434
|
+
# Places the scrollbar, the view's one child, and pushes the two numbers
|
|
435
|
+
# it paints a handle from. No `rect.empty?` guard: a container assigns
|
|
436
|
+
# every child a rect on every pass (`D_empty_ancestor`), and
|
|
437
|
+
# {#scrollbar_visible?} already answers false for an empty rect.
|
|
438
|
+
#
|
|
439
|
+
# The rows are settled first, before the bar is placed, so the layout mark
|
|
440
|
+
# a scroll clamp makes is dropped rather than costing a second pass: a
|
|
441
|
+
# rewrap if {#wrap_width} moved — on a height change too, since an empty
|
|
442
|
+
# rect hides a `:visible` bar — then the `:auto` bar, then the
|
|
443
|
+
# {#auto_scroll} pin, which a taller viewport moves. Every mutator already
|
|
444
|
+
# marks, for the bar's `row_count`, so this runs after each one.
|
|
411
445
|
# @return [void]
|
|
412
|
-
def
|
|
413
|
-
|
|
414
|
-
|
|
446
|
+
def relayout
|
|
447
|
+
rewrap unless @wrapped_at == wrap_width
|
|
448
|
+
sync_auto_scrollbar
|
|
449
|
+
update_scroll_top_row_if_auto_scroll
|
|
450
|
+
@scrollbar.rect = scrollbar_visible? ? Rect.new(rect.width - 1, 0, 1, rect.height) : Rect.new(0, 0, 0, 0)
|
|
451
|
+
@scrollbar.row_count = @rows.size
|
|
452
|
+
@scrollbar.scroll_top_row = @scroll_top_row
|
|
415
453
|
end
|
|
416
454
|
|
|
417
455
|
private
|
|
@@ -502,10 +540,7 @@ module Tuile
|
|
|
502
540
|
|
|
503
541
|
splice_lines(start, old_count, new_lines)
|
|
504
542
|
region.send(:line_count=, new_lines.size)
|
|
505
|
-
|
|
506
|
-
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
507
|
-
update_scroll_top_row_if_auto_scroll
|
|
508
|
-
invalidate
|
|
543
|
+
content_changed
|
|
509
544
|
end
|
|
510
545
|
|
|
511
546
|
# Region-scoped {#replace}. Validates `range` against
|
|
@@ -527,10 +562,7 @@ module Tuile
|
|
|
527
562
|
|
|
528
563
|
splice_lines(abs_from, length, new_lines)
|
|
529
564
|
region.send(:line_count=, region.line_count - length + new_lines.size)
|
|
530
|
-
|
|
531
|
-
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
532
|
-
update_scroll_top_row_if_auto_scroll
|
|
533
|
-
invalidate
|
|
565
|
+
content_changed
|
|
534
566
|
end
|
|
535
567
|
|
|
536
568
|
# Verbatim append into `region`.
|
|
@@ -566,10 +598,7 @@ module Tuile
|
|
|
566
598
|
end
|
|
567
599
|
region.send(:line_count=, region.line_count + rest.size)
|
|
568
600
|
end
|
|
569
|
-
|
|
570
|
-
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
571
|
-
update_scroll_top_row_if_auto_scroll
|
|
572
|
-
invalidate
|
|
601
|
+
content_changed
|
|
573
602
|
end
|
|
574
603
|
|
|
575
604
|
# Drops the last `n` hard lines from `region`'s tail via
|
|
@@ -587,10 +616,7 @@ module Tuile
|
|
|
587
616
|
drop_from = start + region.line_count - to_drop
|
|
588
617
|
splice_lines(drop_from, to_drop, [])
|
|
589
618
|
region.send(:line_count=, region.line_count - to_drop)
|
|
590
|
-
|
|
591
|
-
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
592
|
-
update_scroll_top_row_if_auto_scroll
|
|
593
|
-
invalidate
|
|
619
|
+
content_changed
|
|
594
620
|
end
|
|
595
621
|
|
|
596
622
|
# Drops `region` from {@regions}: its hard lines are removed via
|
|
@@ -612,10 +638,7 @@ module Tuile
|
|
|
612
638
|
@regions << Region.send(:new, self) if @regions.empty?
|
|
613
639
|
return unless had_lines
|
|
614
640
|
|
|
615
|
-
|
|
616
|
-
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
617
|
-
update_scroll_top_row_if_auto_scroll
|
|
618
|
-
invalidate
|
|
641
|
+
content_changed
|
|
619
642
|
end
|
|
620
643
|
|
|
621
644
|
# Adjusts region line counts after a {@lines} splice that removed
|
|
@@ -681,7 +704,8 @@ module Tuile
|
|
|
681
704
|
# @return [void]
|
|
682
705
|
def rewrap
|
|
683
706
|
width = wrap_width
|
|
684
|
-
@
|
|
707
|
+
@wrapped_at = width
|
|
708
|
+
@blank_row = StyledString::EMPTY.ljust(width)
|
|
685
709
|
@rows = []
|
|
686
710
|
@line_wrap_counts = []
|
|
687
711
|
@lines.each do |line|
|
|
@@ -695,7 +719,7 @@ module Tuile
|
|
|
695
719
|
# Wraps `line` at `width` and returns the padded rows alongside the
|
|
696
720
|
# row count. Empty lines (e.g. from a `"\n\n"`
|
|
697
721
|
# run) and degenerate `width <= 0` both emit a single {@blank_row}
|
|
698
|
-
# row, matching what `@text.wrap(width).map { |l|
|
|
722
|
+
# row, matching what `@text.wrap(width).map { |l| l.ljust(width) }`
|
|
699
723
|
# would have produced.
|
|
700
724
|
# @param line [StyledString]
|
|
701
725
|
# @param width [Integer]
|
|
@@ -704,7 +728,7 @@ module Tuile
|
|
|
704
728
|
return [[@blank_row], 1] if line.empty? || width <= 0
|
|
705
729
|
|
|
706
730
|
wrapped = line.wrap(width)
|
|
707
|
-
[wrapped.map { |row|
|
|
731
|
+
[wrapped.map { |row| row.ljust(width) }, wrapped.size]
|
|
708
732
|
end
|
|
709
733
|
|
|
710
734
|
# Appends `line` to the tail of {@lines}, updating the
|
|
@@ -796,11 +820,13 @@ module Tuile
|
|
|
796
820
|
|
|
797
821
|
# Columns the scrollbar claims off the right edge: the bar itself plus one
|
|
798
822
|
# blank column, so a row wrapping at the full width doesn't run into `█`
|
|
799
|
-
# (`…to show the█`). `0` when the bar is hidden.
|
|
823
|
+
# (`…to show the█`). `0` when the bar is hidden. Only the blank is this
|
|
824
|
+
# view's to paint — {#paintable_row} emits it, the child
|
|
825
|
+
# {VerticalScrollBar} owns the column beyond it.
|
|
800
826
|
#
|
|
801
827
|
# The blank is dropped below width 3, where reserving it would leave no
|
|
802
|
-
# column for text at all — that keeps
|
|
803
|
-
# {#rect}`.width` columns
|
|
828
|
+
# column for text at all — that keeps row and bar together covering
|
|
829
|
+
# exactly {#rect}`.width` columns at every width.
|
|
804
830
|
# @return [Integer] `0`, `1` or `2`.
|
|
805
831
|
def scrollbar_columns
|
|
806
832
|
return 0 unless scrollbar_visible?
|
|
@@ -821,6 +847,24 @@ module Tuile
|
|
|
821
847
|
self.scroll_top_row = clamped unless @scroll_top_row == clamped
|
|
822
848
|
end
|
|
823
849
|
|
|
850
|
+
# Every splice's tail. {#invalidate_layout} rides along because
|
|
851
|
+
# {#relayout} pushes the bar's `row_count`: a splice that changed
|
|
852
|
+
# `@rows.size` without the mark leaves the handle sized for the old
|
|
853
|
+
# buffer.
|
|
854
|
+
#
|
|
855
|
+
# Not for {#append} or {#text=}, which mark by hand — appending must not
|
|
856
|
+
# clamp, or a viewport deliberately parked past the end (which
|
|
857
|
+
# {#scroll_top_row=} allows) is yanked back by the next line, and
|
|
858
|
+
# {#text=} clamps inside {#rewrap} already.
|
|
859
|
+
# @return [void]
|
|
860
|
+
def content_changed
|
|
861
|
+
@text = nil
|
|
862
|
+
@scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
|
|
863
|
+
update_scroll_top_row_if_auto_scroll
|
|
864
|
+
invalidate
|
|
865
|
+
invalidate_layout
|
|
866
|
+
end
|
|
867
|
+
|
|
824
868
|
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
825
869
|
# viewport pin is skipped, so reading older content is not interrupted
|
|
826
870
|
# by incoming lines. {#scroll_top_row=} re-arms `@follow` when the viewport
|
|
@@ -841,40 +885,60 @@ module Tuile
|
|
|
841
885
|
def scrollbar_visible?
|
|
842
886
|
return false if rect.empty?
|
|
843
887
|
|
|
844
|
-
@scrollbar_visibility
|
|
888
|
+
case @scrollbar_visibility
|
|
889
|
+
when :visible then true
|
|
890
|
+
when :auto then @auto_scrollbar_shown
|
|
891
|
+
else false
|
|
892
|
+
end
|
|
845
893
|
end
|
|
846
894
|
|
|
847
|
-
#
|
|
848
|
-
#
|
|
849
|
-
#
|
|
850
|
-
#
|
|
851
|
-
#
|
|
852
|
-
# @
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
def pad_to(row, width)
|
|
856
|
-
return StyledString::EMPTY if width <= 0
|
|
895
|
+
# The sole writer of `@auto_scrollbar_shown`: shows the bar iff the text
|
|
896
|
+
# overflows the viewport at the *full* width, and rewraps when that
|
|
897
|
+
# flips. Deciding at the full width alone is what settles it in one
|
|
898
|
+
# step — the answer does not depend on the width the bar leaves, so
|
|
899
|
+
# showing the bar can't flip it back. Idempotent.
|
|
900
|
+
# @return [void]
|
|
901
|
+
def sync_auto_scrollbar
|
|
902
|
+
return unless @scrollbar_visibility == :auto
|
|
857
903
|
|
|
858
|
-
|
|
859
|
-
return
|
|
904
|
+
shown = !rect.empty? && overflows_at_full_width?
|
|
905
|
+
return if shown == @auto_scrollbar_shown
|
|
860
906
|
|
|
861
|
-
|
|
907
|
+
@auto_scrollbar_shown = shown
|
|
908
|
+
rewrap
|
|
909
|
+
invalidate
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
# Whether the text, wrapped at {#rect}`.width`, has more rows than the
|
|
913
|
+
# viewport. With the bar hidden `@rows` already *is* that wrap; with it
|
|
914
|
+
# shown the lines are re-wrapped at the full width, stopping at the first
|
|
915
|
+
# row past the viewport — so the cost is bounded by what fits on screen,
|
|
916
|
+
# and a buffer with more hard lines than rows costs nothing at all.
|
|
917
|
+
# @return [Boolean]
|
|
918
|
+
def overflows_at_full_width?
|
|
919
|
+
return @rows.size > viewport_rows unless @auto_scrollbar_shown
|
|
920
|
+
return true if @lines.size > viewport_rows
|
|
921
|
+
|
|
922
|
+
width = rect.width
|
|
923
|
+
rows = 0
|
|
924
|
+
@lines.each do |line|
|
|
925
|
+
rows += line.empty? ? 1 : line.wrap(width).size
|
|
926
|
+
return true if rows > viewport_rows
|
|
927
|
+
end
|
|
928
|
+
false
|
|
862
929
|
end
|
|
863
930
|
|
|
864
931
|
# @param index [Integer] 0-based index into `@rows`.
|
|
865
|
-
# @
|
|
866
|
-
#
|
|
867
|
-
#
|
|
868
|
-
#
|
|
869
|
-
|
|
870
|
-
# is present (see {#scrollbar_columns}).
|
|
871
|
-
def paintable_row(index, row_in_viewport, scrollbar)
|
|
932
|
+
# @return [StyledString] every column of {#rect} the child bar does not
|
|
933
|
+
# own: all of them with the bar hidden, one fewer with it visible.
|
|
934
|
+
# Rows arrive padded to {#wrap_width} from {#rewrap}, so only the blank
|
|
935
|
+
# {#scrollbar_columns} reserves is added here.
|
|
936
|
+
def paintable_row(index)
|
|
872
937
|
row = @rows[index] || @blank_row
|
|
873
|
-
|
|
938
|
+
blanks = scrollbar_columns - 1
|
|
939
|
+
return row unless blanks.positive?
|
|
874
940
|
|
|
875
|
-
|
|
876
|
-
bar = StyledString.styled(scrollbar.scrollbar_char(row_in_viewport), fg: screen.theme.scrollbar_color)
|
|
877
|
-
row + StyledString.plain(blanks) + bar
|
|
941
|
+
row + StyledString.plain(" " * blanks)
|
|
878
942
|
end
|
|
879
943
|
|
|
880
944
|
# A logical section of a {TextView}'s text — a contiguous run of
|