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
data/lib/tuile/component/list.rb
CHANGED
|
@@ -2,27 +2,55 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
# A scrollable list of items with cursor support.
|
|
5
|
+
# A scrollable list of typed items, one row each, with cursor support.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
7
|
+
# list = Component::List.new
|
|
8
|
+
# list.items = people
|
|
9
|
+
# list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.hint(" #{p.email}") }
|
|
10
|
+
# list.cursor = List::Cursor.new # a bare list has none
|
|
11
|
+
# list.on_item_chosen = ->(index, person) { open(person) }
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
13
|
+
# The {#renderer} turns an item into one row; the default renders an item
|
|
14
|
+
# as itself, so a list of `String`s or {StyledString}s needs none — which
|
|
15
|
+
# is what {#lines=} and {#build_lines} are, items that are their own
|
|
16
|
+
# rendering (split on `\n`, one row per line).
|
|
17
|
+
#
|
|
18
|
+
# There are no appenders. The items are always assigned whole — an app that
|
|
19
|
+
# grows a list keeps its own array and re-assigns it (`list.items = mine`) —
|
|
20
|
+
# so a `List` stays a *snapshot of a collection*, which is the only shape a
|
|
21
|
+
# lazily-sourced provider could ever fill.
|
|
22
|
+
#
|
|
23
|
+
# Rows wider than the viewport are ellipsized via {StyledString#ellipsize}
|
|
24
|
+
# with span styles preserved across the cut. Vertical scrolling is via
|
|
25
|
+
# {#scroll_top_row}; enable {#auto_scroll} to keep the bottom in view. The cursor
|
|
26
|
+
# responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the list
|
|
27
|
+
# automatically; its highlight overlays {Theme#active_bg_color} while
|
|
28
|
+
# preserving each span's foreground color.
|
|
29
|
+
#
|
|
30
|
+
# == Implementation details
|
|
31
|
+
# Rendering is lazy: only the rows in the viewport are rendered, each
|
|
32
|
+
# memoized until {#items=}, {#renderer=} or a width change drops the cache.
|
|
33
|
+
# So a renderer runs *at paint time*, on any frame — keep it pure and
|
|
34
|
+
# cheap; work that reaches a service belongs in the item, not in the
|
|
35
|
+
# renderer. {#select_next} deliberately renders without memoizing: one
|
|
36
|
+
# failed scan would otherwise cache a row per item.
|
|
17
37
|
class List < Component
|
|
38
|
+
# The default {#renderer}: an item renders as itself. Every renderer's
|
|
39
|
+
# output is coerced the same way — a {StyledString} passes through, a
|
|
40
|
+
# `String` is parsed (so embedded ANSI is honored), anything else is
|
|
41
|
+
# `#to_s`'d first.
|
|
42
|
+
# @return [Proc]
|
|
43
|
+
DEFAULT_RENDERER = :itself.to_proc
|
|
44
|
+
|
|
18
45
|
def initialize
|
|
19
46
|
super
|
|
20
|
-
@
|
|
21
|
-
@
|
|
22
|
-
@
|
|
47
|
+
@items = []
|
|
48
|
+
@renderer = DEFAULT_RENDERER
|
|
49
|
+
@row_cache = {}
|
|
50
|
+
@blank_row = nil
|
|
23
51
|
@auto_scroll = false
|
|
24
52
|
@follow = true
|
|
25
|
-
@
|
|
53
|
+
@scroll_top_row = 0
|
|
26
54
|
@cursor = Cursor::None.new
|
|
27
55
|
@scrollbar_visibility = :gone
|
|
28
56
|
@show_cursor_when_inactive = false
|
|
@@ -32,37 +60,37 @@ module Tuile
|
|
|
32
60
|
end
|
|
33
61
|
|
|
34
62
|
# @return [Proc, nil] callback fired when an item is chosen — by pressing
|
|
35
|
-
# Enter on the cursor's item, or by left-clicking
|
|
36
|
-
# `proc.call(index,
|
|
37
|
-
#
|
|
38
|
-
#
|
|
63
|
+
# Enter on the cursor's item, or by left-clicking it. Called as
|
|
64
|
+
# `proc.call(index, item)` with the chosen 0-based index and the item
|
|
65
|
+
# itself. Never fires when the cursor's position is outside the content
|
|
66
|
+
# (e.g. {Cursor::None}, or empty content).
|
|
39
67
|
attr_accessor :on_item_chosen
|
|
40
68
|
|
|
41
|
-
# @return [Proc, nil] callback fired when the `(index,
|
|
42
|
-
# the cursor changes
|
|
43
|
-
#
|
|
69
|
+
# @return [Proc, nil] callback fired when the `(index, item)` tuple under
|
|
70
|
+
# the cursor changes (items compared with `==`). Called as
|
|
71
|
+
# `proc.call(index, item)`, with `item` `nil` when the cursor is
|
|
44
72
|
# off-content ({Cursor::None}, empty list, or `index` past the last
|
|
45
|
-
#
|
|
46
|
-
# and on {#
|
|
73
|
+
# item). Fires on cursor moves (key, mouse, search), on {#cursor=},
|
|
74
|
+
# and on {#items=} when the item at the cursor's index
|
|
47
75
|
# changes (or its in-range/out-of-range status flips). Useful for
|
|
48
76
|
# keeping a details pane in sync with the highlighted row.
|
|
49
77
|
attr_accessor :on_cursor_changed
|
|
50
78
|
|
|
51
|
-
# @return [Boolean] if true and
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
79
|
+
# @return [Boolean] if true and new content is set, auto-scrolls to the
|
|
80
|
+
# bottom — but only while the viewport is already pinned to the last
|
|
81
|
+
# row (see {#following?}). Scroll up to read older content and
|
|
82
|
+
# incoming rows stop yanking you back down; scroll back to the bottom
|
|
83
|
+
# and tailing resumes.
|
|
56
84
|
attr_reader :auto_scroll
|
|
57
85
|
|
|
58
86
|
# @return [Boolean] whether {#auto_scroll} is currently tailing. True
|
|
59
|
-
# while the viewport sits at the last
|
|
87
|
+
# while the viewport sits at the last row; flips to false the moment
|
|
60
88
|
# the user scrolls up, and back to true once they scroll to the bottom
|
|
61
89
|
# again. Only consulted when {#auto_scroll} is enabled.
|
|
62
90
|
def following? = @follow
|
|
63
91
|
|
|
64
|
-
# @return [Integer] top
|
|
65
|
-
attr_reader :
|
|
92
|
+
# @return [Integer] top row of the viewport. 0 or positive.
|
|
93
|
+
attr_reader :scroll_top_row
|
|
66
94
|
|
|
67
95
|
# @return [Cursor] the list's cursor.
|
|
68
96
|
attr_reader :cursor
|
|
@@ -91,7 +119,7 @@ module Tuile
|
|
|
91
119
|
return if @scrollbar_visibility == value
|
|
92
120
|
|
|
93
121
|
@scrollbar_visibility = value
|
|
94
|
-
|
|
122
|
+
drop_row_cache
|
|
95
123
|
invalidate
|
|
96
124
|
end
|
|
97
125
|
|
|
@@ -101,7 +129,7 @@ module Tuile
|
|
|
101
129
|
def auto_scroll=(new_auto_scroll)
|
|
102
130
|
@auto_scroll = new_auto_scroll
|
|
103
131
|
@follow = true if new_auto_scroll
|
|
104
|
-
|
|
132
|
+
update_scroll_top_row_if_auto_scroll
|
|
105
133
|
end
|
|
106
134
|
|
|
107
135
|
# Sets a new cursor.
|
|
@@ -115,84 +143,106 @@ module Tuile
|
|
|
115
143
|
notify_cursor_changed
|
|
116
144
|
end
|
|
117
145
|
|
|
118
|
-
# Sets the top
|
|
119
|
-
# @param
|
|
120
|
-
def
|
|
121
|
-
raise TypeError, "expected Integer, got #{
|
|
122
|
-
raise ArgumentError, "
|
|
123
|
-
return unless @
|
|
146
|
+
# Sets the top row.
|
|
147
|
+
# @param new_row [Integer] 0 or greater.
|
|
148
|
+
def scroll_top_row=(new_row)
|
|
149
|
+
raise TypeError, "expected Integer, got #{new_row.inspect}" unless new_row.is_a? Integer
|
|
150
|
+
raise ArgumentError, "scroll_top_row must not be negative, got #{new_row}" if new_row.negative?
|
|
151
|
+
return unless @scroll_top_row != new_row
|
|
124
152
|
|
|
125
|
-
@
|
|
153
|
+
@scroll_top_row = new_row
|
|
126
154
|
@follow = at_bottom?
|
|
127
155
|
invalidate
|
|
128
156
|
end
|
|
129
157
|
|
|
130
|
-
#
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
|
|
158
|
+
# @return [Array] the items, one row each.
|
|
159
|
+
attr_reader :items
|
|
160
|
+
|
|
161
|
+
# @return [Proc, Method] item -> row: a {StyledString}, a `String` (parsed,
|
|
162
|
+
# so embedded ANSI is honored), or anything with `#to_s`. Only the first
|
|
163
|
+
# line of a multi-line rendering is kept — one item is one row.
|
|
164
|
+
attr_reader :renderer
|
|
165
|
+
|
|
166
|
+
# Replaces the items, leaving the cursor where it is — a cursor left past
|
|
167
|
+
# the last item strands off-content: no highlight, a dead Enter, and a
|
|
168
|
+
# caller resolving `items[position]` gets `nil`. A caller that cares
|
|
169
|
+
# clamps it (`cursor.go_to_last(items.size)`).
|
|
170
|
+
# @param new_items [Array] one row each; rendered by {#renderer}.
|
|
171
|
+
# @raise [TypeError] unless `new_items` is an `Array`.
|
|
172
|
+
# @return [void]
|
|
173
|
+
def items=(new_items)
|
|
174
|
+
raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a? Array
|
|
175
|
+
|
|
176
|
+
@items = new_items
|
|
177
|
+
drop_row_cache
|
|
178
|
+
update_scroll_top_row_if_auto_scroll
|
|
179
|
+
notify_cursor_changed
|
|
180
|
+
invalidate
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @param proc [Proc, Method] item -> row; see {#renderer}.
|
|
184
|
+
# @return [void]
|
|
185
|
+
def renderer=(proc)
|
|
186
|
+
@renderer = proc
|
|
187
|
+
drop_row_cache
|
|
188
|
+
invalidate
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Re-renders every row — for a {#renderer} whose *inputs* changed while
|
|
192
|
+
# it and {#items} stayed the same, e.g. one prefixing a marker read from
|
|
193
|
+
# a selection it closes over:
|
|
194
|
+
#
|
|
195
|
+
# def value=(new_value) # RadioGroup: the marked row moved
|
|
196
|
+
# super
|
|
197
|
+
# content.refresh_rows
|
|
198
|
+
# end
|
|
199
|
+
#
|
|
200
|
+
# @return [void]
|
|
201
|
+
def refresh_rows
|
|
202
|
+
drop_row_cache
|
|
203
|
+
invalidate
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Sets the items from line-flavored input: each entry is coerced into a
|
|
207
|
+
# {StyledString} (a `String` is parsed via {StyledString.parse}, so
|
|
208
|
+
# embedded ANSI is honored; a {StyledString} is used as-is; anything else
|
|
209
|
+
# is stringified via `#to_s` first), then split on `\n` into separate
|
|
210
|
+
# lines via {StyledString#lines}, with trailing empty pieces dropped and
|
|
211
|
+
# trailing ASCII whitespace stripped. The resulting {StyledString}s
|
|
212
|
+
# *are* the {#items}, so under the
|
|
213
|
+
# {DEFAULT_RENDERER} each is its own row.
|
|
137
214
|
# @param lines [Array] entries are `String`, `StyledString`, or anything
|
|
138
215
|
# that responds to `#to_s`.
|
|
139
216
|
# @return [void]
|
|
140
217
|
def lines=(lines)
|
|
141
218
|
raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array
|
|
142
219
|
|
|
143
|
-
|
|
144
|
-
rebuild_padded_lines
|
|
145
|
-
update_top_line_if_auto_scroll
|
|
146
|
-
notify_cursor_changed
|
|
147
|
-
invalidate
|
|
220
|
+
self.items = parse_input_lines(lines)
|
|
148
221
|
end
|
|
149
222
|
|
|
150
|
-
#
|
|
151
|
-
#
|
|
223
|
+
# Fully re-populates the list, line-flavored: yields a fresh buffer and
|
|
224
|
+
# assigns it through {#lines=}, so each entry is coerced, split and
|
|
225
|
+
# rstripped exactly as there. The buffer is a plain `Array`, which a
|
|
226
|
+
# builder can read mid-build — recording the row a {Cursor::Limited} may
|
|
227
|
+
# land on is the case this exists for:
|
|
152
228
|
# ```ruby
|
|
153
|
-
# list.
|
|
154
|
-
#
|
|
229
|
+
# list.build_lines do |lines|
|
|
230
|
+
# cursor_positions << lines.size
|
|
231
|
+
# lines << format_overview(vm)
|
|
232
|
+
# lines << format_detail(vm)
|
|
155
233
|
# end
|
|
156
234
|
# ```
|
|
157
235
|
# @yield [buffer]
|
|
158
236
|
# @yieldparam buffer [Array] mutable buffer to push lines into. Each
|
|
159
237
|
# entry is parsed the same way as the items passed to {#lines=}.
|
|
160
238
|
# @yieldreturn [void]
|
|
161
|
-
# @return [
|
|
162
|
-
|
|
163
|
-
def lines
|
|
164
|
-
return @lines unless block_given?
|
|
165
|
-
|
|
239
|
+
# @return [void]
|
|
240
|
+
def build_lines
|
|
166
241
|
buffer = []
|
|
167
242
|
yield buffer
|
|
168
243
|
self.lines = buffer
|
|
169
244
|
end
|
|
170
245
|
|
|
171
|
-
# Adds a line.
|
|
172
|
-
# @param line [String, StyledString, #to_s]
|
|
173
|
-
# @return [void]
|
|
174
|
-
def add_line(line)
|
|
175
|
-
raise ArgumentError, "line is nil" if line.nil?
|
|
176
|
-
|
|
177
|
-
add_lines [line]
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# Appends given lines. Each entry is parsed the same way as in
|
|
181
|
-
# {#lines=}: coerced to a {StyledString}, split on `\n`, with trailing
|
|
182
|
-
# empty pieces dropped and trailing ASCII whitespace stripped.
|
|
183
|
-
# @param lines [Array] entries are `String`, `StyledString`, or anything
|
|
184
|
-
# that responds to `#to_s`.
|
|
185
|
-
# @return [void]
|
|
186
|
-
def add_lines(lines)
|
|
187
|
-
screen.check_locked
|
|
188
|
-
new_lines = parse_input_lines(lines)
|
|
189
|
-
@lines += new_lines
|
|
190
|
-
@padded_lines += new_lines.map { |line| pad_to_row(line) }
|
|
191
|
-
update_top_line_if_auto_scroll
|
|
192
|
-
notify_cursor_changed
|
|
193
|
-
invalidate
|
|
194
|
-
end
|
|
195
|
-
|
|
196
246
|
def focusable? = true
|
|
197
247
|
|
|
198
248
|
def tab_stop? = true
|
|
@@ -201,15 +251,15 @@ module Tuile
|
|
|
201
251
|
# @return [Boolean] true if the key was handled.
|
|
202
252
|
def handle_key(key)
|
|
203
253
|
if key == Keys::PAGE_UP
|
|
204
|
-
|
|
254
|
+
move_scroll_top_row_by(-viewport_rows)
|
|
205
255
|
true
|
|
206
256
|
elsif key == Keys::PAGE_DOWN
|
|
207
|
-
|
|
257
|
+
move_scroll_top_row_by(viewport_rows)
|
|
208
258
|
true
|
|
209
259
|
elsif key == Keys::ENTER && cursor_on_item?
|
|
210
260
|
fire_item_chosen
|
|
211
261
|
true
|
|
212
|
-
elsif @cursor.handle_key(key, @
|
|
262
|
+
elsif @cursor.handle_key(key, @items.size, viewport_rows)
|
|
213
263
|
move_viewport_to_cursor
|
|
214
264
|
notify_cursor_changed
|
|
215
265
|
invalidate
|
|
@@ -219,16 +269,16 @@ module Tuile
|
|
|
219
269
|
end
|
|
220
270
|
end
|
|
221
271
|
|
|
222
|
-
# Moves the cursor to the next
|
|
272
|
+
# Moves the cursor to the next item whose text contains `query`
|
|
223
273
|
# (case-insensitive substring match). Search wraps around the end of the
|
|
224
|
-
# list. Only
|
|
225
|
-
# Matching uses the
|
|
274
|
+
# list. Only items reachable by the current {#cursor} are considered.
|
|
275
|
+
# Matching uses the rendered row's plain text — span styles do not affect the
|
|
226
276
|
# match.
|
|
227
277
|
#
|
|
228
278
|
# @param query [String] substring to match. Empty query never matches.
|
|
229
279
|
# @param include_current [Boolean] when true, the current cursor position
|
|
230
280
|
# is eligible (useful when the query has just changed and the current
|
|
231
|
-
#
|
|
281
|
+
# item may still match); when false, the search starts after the
|
|
232
282
|
# current position (useful for "find next" key bindings that should
|
|
233
283
|
# advance past the current).
|
|
234
284
|
# @return [Boolean] true if a match was found.
|
|
@@ -249,28 +299,29 @@ module Tuile
|
|
|
249
299
|
def handle_mouse(event)
|
|
250
300
|
super
|
|
251
301
|
if event.button == :scroll_down
|
|
252
|
-
|
|
302
|
+
move_scroll_top_row_by(4)
|
|
253
303
|
elsif event.button == :scroll_up
|
|
254
|
-
|
|
304
|
+
move_scroll_top_row_by(-4)
|
|
255
305
|
else
|
|
256
306
|
return unless rect.contains?(event.point)
|
|
257
307
|
|
|
258
|
-
|
|
259
|
-
if @cursor.handle_mouse(
|
|
308
|
+
item_index = event.y - rect.top + scroll_top_row
|
|
309
|
+
if @cursor.handle_mouse(item_index, event, @items.size)
|
|
260
310
|
move_viewport_to_cursor
|
|
261
311
|
notify_cursor_changed
|
|
262
312
|
invalidate
|
|
263
313
|
end
|
|
264
|
-
fire_item_chosen if event.button == :left &&
|
|
314
|
+
fire_item_chosen if event.button == :left && item_index >= 0 && item_index < @items.size && cursor_on_item?
|
|
265
315
|
end
|
|
266
316
|
end
|
|
267
317
|
|
|
268
|
-
# Paints the
|
|
318
|
+
# Paints the visible items into {#rect}, rendering the ones not already
|
|
319
|
+
# cached.
|
|
269
320
|
#
|
|
270
321
|
# Skips the {Component#repaint} default's auto-clear: every row of
|
|
271
322
|
# {#rect} is painted below (with blank padding past the last item),
|
|
272
323
|
# so the parent contract — "fully draw over your rect" — is met
|
|
273
|
-
# without an upfront wipe. Rows go through {Component#
|
|
324
|
+
# without an upfront wipe. Rows go through {Component#draw_text}, so
|
|
274
325
|
# content *and* blank filler inherit {Component#effective_bg_color}
|
|
275
326
|
# (a {#bg_color} set here or on an ancestor); the cursor row's
|
|
276
327
|
# {Theme#active_bg_color} highlight composes on top of it.
|
|
@@ -279,11 +330,10 @@ module Tuile
|
|
|
279
330
|
return if rect.empty?
|
|
280
331
|
|
|
281
332
|
scrollbar = if scrollbar_visible?
|
|
282
|
-
VerticalScrollBar.new(rect.height,
|
|
333
|
+
VerticalScrollBar.new(rect.height, row_count: @items.size, scroll_top_row: @scroll_top_row)
|
|
283
334
|
end
|
|
284
335
|
(0...rect.height).each do |row|
|
|
285
|
-
|
|
286
|
-
draw_line(rect.left, row + rect.top, line)
|
|
336
|
+
draw_text(rect.left, row + rect.top, paintable_row(row + @scroll_top_row, row, scrollbar))
|
|
287
337
|
end
|
|
288
338
|
end
|
|
289
339
|
|
|
@@ -304,24 +354,24 @@ module Tuile
|
|
|
304
354
|
end
|
|
305
355
|
|
|
306
356
|
# @param _key [String]
|
|
307
|
-
# @param
|
|
308
|
-
# @param
|
|
357
|
+
# @param _item_count [Integer]
|
|
358
|
+
# @param _viewport_rows [Integer]
|
|
309
359
|
# @return [Boolean]
|
|
310
|
-
def handle_key(_key,
|
|
360
|
+
def handle_key(_key, _item_count, _viewport_rows)
|
|
311
361
|
false
|
|
312
362
|
end
|
|
313
363
|
|
|
314
|
-
# @param
|
|
364
|
+
# @param _item_index [Integer]
|
|
315
365
|
# @param _event [MouseEvent]
|
|
316
|
-
# @param
|
|
366
|
+
# @param _item_count [Integer]
|
|
317
367
|
# @return [Boolean]
|
|
318
|
-
def handle_mouse(
|
|
368
|
+
def handle_mouse(_item_index, _event, _item_count)
|
|
319
369
|
false
|
|
320
370
|
end
|
|
321
371
|
|
|
322
|
-
# @param
|
|
372
|
+
# @param _item_count [Integer]
|
|
323
373
|
# @return [Array<Integer>]
|
|
324
|
-
def candidate_positions(
|
|
374
|
+
def candidate_positions(_item_count)
|
|
325
375
|
[]
|
|
326
376
|
end
|
|
327
377
|
|
|
@@ -336,46 +386,46 @@ module Tuile
|
|
|
336
386
|
end
|
|
337
387
|
end
|
|
338
388
|
|
|
339
|
-
# @return [Integer] 0-based
|
|
389
|
+
# @return [Integer] 0-based item index of the current cursor position.
|
|
340
390
|
attr_reader :position
|
|
341
391
|
|
|
342
|
-
# @param
|
|
392
|
+
# @param item_count [Integer] number of items in the list.
|
|
343
393
|
# @return [Array<Integer>] positions the cursor can land on, in
|
|
344
394
|
# ascending order.
|
|
345
|
-
def candidate_positions(
|
|
346
|
-
(0...
|
|
395
|
+
def candidate_positions(item_count)
|
|
396
|
+
(0...item_count).to_a
|
|
347
397
|
end
|
|
348
398
|
|
|
349
399
|
# @param key [String] pressed keyboard key.
|
|
350
|
-
# @param
|
|
351
|
-
# @param
|
|
400
|
+
# @param item_count [Integer] number of items in the list.
|
|
401
|
+
# @param viewport_rows [Integer] number of visible rows.
|
|
352
402
|
# @return [Boolean] true if the cursor moved.
|
|
353
|
-
def handle_key(key,
|
|
403
|
+
def handle_key(key, item_count, viewport_rows)
|
|
354
404
|
case key
|
|
355
405
|
when *Keys::DOWN_ARROWS
|
|
356
|
-
go_down_by(1,
|
|
406
|
+
go_down_by(1, item_count)
|
|
357
407
|
when *Keys::UP_ARROWS
|
|
358
408
|
go_up_by(1)
|
|
359
409
|
when *Keys::HOMES
|
|
360
410
|
go_to_first
|
|
361
411
|
when *Keys::ENDS_
|
|
362
|
-
go_to_last(
|
|
412
|
+
go_to_last(item_count)
|
|
363
413
|
when Keys::CTRL_U
|
|
364
|
-
go_up_by(
|
|
414
|
+
go_up_by(viewport_rows / 2)
|
|
365
415
|
when Keys::CTRL_D
|
|
366
|
-
go_down_by(
|
|
416
|
+
go_down_by(viewport_rows / 2, item_count)
|
|
367
417
|
else
|
|
368
418
|
false
|
|
369
419
|
end
|
|
370
420
|
end
|
|
371
421
|
|
|
372
|
-
# @param
|
|
422
|
+
# @param item_index [Integer] the item the cursor is hovering over.
|
|
373
423
|
# @param event [MouseEvent] the event.
|
|
374
|
-
# @param
|
|
424
|
+
# @param item_count [Integer] number of items in the list.
|
|
375
425
|
# @return [Boolean] true if the event was handled.
|
|
376
|
-
def handle_mouse(
|
|
426
|
+
def handle_mouse(item_index, event, item_count)
|
|
377
427
|
if event.button == :left
|
|
378
|
-
go(
|
|
428
|
+
go(item_index.clamp(nil, item_count - 1))
|
|
379
429
|
else
|
|
380
430
|
false
|
|
381
431
|
end
|
|
@@ -393,27 +443,27 @@ module Tuile
|
|
|
393
443
|
end
|
|
394
444
|
|
|
395
445
|
# Moves the cursor to the last reachable position. For base {Cursor},
|
|
396
|
-
# the last
|
|
446
|
+
# the last item; {Limited} clamps to the last allowed position; {None}
|
|
397
447
|
# is a no-op.
|
|
398
|
-
# @param
|
|
448
|
+
# @param item_count [Integer] number of items in the list.
|
|
399
449
|
# @return [Boolean] true if the position changed.
|
|
400
|
-
def go_to_last(
|
|
401
|
-
go(
|
|
450
|
+
def go_to_last(item_count)
|
|
451
|
+
go(item_count - 1)
|
|
402
452
|
end
|
|
403
453
|
|
|
404
454
|
protected
|
|
405
455
|
|
|
406
|
-
# @param
|
|
407
|
-
# @param
|
|
456
|
+
# @param count [Integer]
|
|
457
|
+
# @param item_count [Integer]
|
|
408
458
|
# @return [Boolean]
|
|
409
|
-
def go_down_by(
|
|
410
|
-
go((@position +
|
|
459
|
+
def go_down_by(count, item_count)
|
|
460
|
+
go((@position + count).clamp(nil, item_count - 1))
|
|
411
461
|
end
|
|
412
462
|
|
|
413
|
-
# @param
|
|
463
|
+
# @param count [Integer]
|
|
414
464
|
# @return [Boolean]
|
|
415
|
-
def go_up_by(
|
|
416
|
-
go(@position -
|
|
465
|
+
def go_up_by(count)
|
|
466
|
+
go(@position - count)
|
|
417
467
|
end
|
|
418
468
|
|
|
419
469
|
# @return [Boolean]
|
|
@@ -421,7 +471,7 @@ module Tuile
|
|
|
421
471
|
go(0)
|
|
422
472
|
end
|
|
423
473
|
|
|
424
|
-
# Cursor which can only land on specific allowed
|
|
474
|
+
# Cursor which can only land on specific allowed items.
|
|
425
475
|
class Limited < Cursor
|
|
426
476
|
# @param positions [Array<Integer>] allowed positions. Must not be
|
|
427
477
|
# empty.
|
|
@@ -434,13 +484,13 @@ module Tuile
|
|
|
434
484
|
super(position: position)
|
|
435
485
|
end
|
|
436
486
|
|
|
437
|
-
# @param
|
|
487
|
+
# @param item_index [Integer]
|
|
438
488
|
# @param event [MouseEvent]
|
|
439
|
-
# @param
|
|
489
|
+
# @param _item_count [Integer]
|
|
440
490
|
# @return [Boolean]
|
|
441
|
-
def handle_mouse(
|
|
491
|
+
def handle_mouse(item_index, event, _item_count)
|
|
442
492
|
if event.button == :left
|
|
443
|
-
prev_pos = @positions.reverse_each.find { _1 <=
|
|
493
|
+
prev_pos = @positions.reverse_each.find { _1 <= item_index }
|
|
444
494
|
return go_to_first if prev_pos.nil?
|
|
445
495
|
|
|
446
496
|
go(prev_pos)
|
|
@@ -449,34 +499,34 @@ module Tuile
|
|
|
449
499
|
end
|
|
450
500
|
end
|
|
451
501
|
|
|
452
|
-
# @param
|
|
502
|
+
# @param item_count [Integer]
|
|
453
503
|
# @return [Array<Integer>]
|
|
454
|
-
def candidate_positions(
|
|
455
|
-
@positions.select { _1 <
|
|
504
|
+
def candidate_positions(item_count)
|
|
505
|
+
@positions.select { _1 < item_count }
|
|
456
506
|
end
|
|
457
507
|
|
|
458
|
-
# @param
|
|
508
|
+
# @param _item_count [Integer]
|
|
459
509
|
# @return [Boolean]
|
|
460
|
-
def go_to_last(
|
|
510
|
+
def go_to_last(_item_count)
|
|
461
511
|
go(@positions.last)
|
|
462
512
|
end
|
|
463
513
|
|
|
464
514
|
protected
|
|
465
515
|
|
|
466
|
-
# @param
|
|
467
|
-
# @param
|
|
516
|
+
# @param count [Integer]
|
|
517
|
+
# @param item_count [Integer]
|
|
468
518
|
# @return [Boolean]
|
|
469
|
-
def go_down_by(
|
|
470
|
-
next_pos = @positions.find { _1 >= @position +
|
|
471
|
-
return go_to_last(
|
|
519
|
+
def go_down_by(count, item_count)
|
|
520
|
+
next_pos = @positions.find { _1 >= @position + count }
|
|
521
|
+
return go_to_last(item_count) if next_pos.nil?
|
|
472
522
|
|
|
473
523
|
go(next_pos)
|
|
474
524
|
end
|
|
475
525
|
|
|
476
|
-
# @param
|
|
526
|
+
# @param count [Integer]
|
|
477
527
|
# @return [Boolean]
|
|
478
|
-
def go_up_by(
|
|
479
|
-
prev_pos = @positions.reverse_each.find { _1 <= @position -
|
|
528
|
+
def go_up_by(count)
|
|
529
|
+
prev_pos = @positions.reverse_each.find { _1 <= @position - count }
|
|
480
530
|
return go_to_first if prev_pos.nil?
|
|
481
531
|
|
|
482
532
|
go(prev_pos)
|
|
@@ -491,18 +541,18 @@ module Tuile
|
|
|
491
541
|
|
|
492
542
|
protected
|
|
493
543
|
|
|
494
|
-
#
|
|
495
|
-
# depends on {#rect}`.width` and the scrollbar gutter, both of
|
|
496
|
-
# trigger this hook. Also re-evaluates {#auto_scroll}: if items were
|
|
497
|
-
#
|
|
498
|
-
#
|
|
544
|
+
# Drops the rendered-row cache when the wrap width changes. The wrap
|
|
545
|
+
# width depends on {#rect}`.width` and the scrollbar gutter, both of
|
|
546
|
+
# which trigger this hook. Also re-evaluates {#auto_scroll}: if items were
|
|
547
|
+
# assigned while the rect was empty (e.g. a {Popup}-wrapped list was
|
|
548
|
+
# populated before the popup was opened), the auto-scroll update
|
|
499
549
|
# was skipped because there was no viewport — re-run it now that there
|
|
500
550
|
# is one, so the list snaps to the bottom on first paint.
|
|
501
551
|
# @return [void]
|
|
502
552
|
def on_width_changed
|
|
503
553
|
super
|
|
504
|
-
|
|
505
|
-
|
|
554
|
+
drop_row_cache
|
|
555
|
+
update_scroll_top_row_if_auto_scroll
|
|
506
556
|
end
|
|
507
557
|
|
|
508
558
|
private
|
|
@@ -512,7 +562,7 @@ module Tuile
|
|
|
512
562
|
# via {StyledString.parse}, StyledString passed through, anything else
|
|
513
563
|
# via `#to_s`), then split on `\n` via {StyledString#lines} — with
|
|
514
564
|
# trailing empty pieces dropped (matching `String#split("\n")`'s
|
|
515
|
-
# default behavior, so `
|
|
565
|
+
# default behavior, so a lone `""` entry adds no row) — and trailing ASCII
|
|
516
566
|
# whitespace stripped on each resulting line.
|
|
517
567
|
# @param entries [Array]
|
|
518
568
|
# @return [Array<StyledString>]
|
|
@@ -544,27 +594,27 @@ module Tuile
|
|
|
544
594
|
line.slice(0, line.display_width - trailing)
|
|
545
595
|
end
|
|
546
596
|
|
|
547
|
-
# @return [Boolean] true if the cursor sits on a real
|
|
597
|
+
# @return [Boolean] true if the cursor sits on a real item.
|
|
548
598
|
def cursor_on_item?
|
|
549
599
|
pos = @cursor.position
|
|
550
|
-
pos >= 0 && pos < @
|
|
600
|
+
pos >= 0 && pos < @items.size
|
|
551
601
|
end
|
|
552
602
|
|
|
553
|
-
# Calls {#on_item_chosen} with the cursor's current `(index,
|
|
603
|
+
# Calls {#on_item_chosen} with the cursor's current `(index, item)`.
|
|
554
604
|
# Caller must ensure {#cursor_on_item?}.
|
|
555
605
|
# @return [void]
|
|
556
606
|
def fire_item_chosen
|
|
557
607
|
pos = @cursor.position
|
|
558
|
-
@on_item_chosen&.call(pos, @
|
|
608
|
+
@on_item_chosen&.call(pos, @items[pos])
|
|
559
609
|
end
|
|
560
610
|
|
|
561
|
-
# @return [Array((Integer,
|
|
562
|
-
# `[position,
|
|
611
|
+
# @return [Array((Integer, Object, nil))]
|
|
612
|
+
# `[position, item_at_position]`, with the item nil when the cursor is
|
|
563
613
|
# off-content.
|
|
564
614
|
def cursor_state
|
|
565
615
|
pos = @cursor.position
|
|
566
|
-
|
|
567
|
-
[pos,
|
|
616
|
+
item = pos >= 0 && pos < @items.size ? @items[pos] : nil
|
|
617
|
+
[pos, item]
|
|
568
618
|
end
|
|
569
619
|
|
|
570
620
|
# Fires {#on_cursor_changed} if {#cursor_state} differs from the last
|
|
@@ -585,12 +635,12 @@ module Tuile
|
|
|
585
635
|
def search_and_go(query, include_current:, reverse:)
|
|
586
636
|
return false if query.empty?
|
|
587
637
|
|
|
588
|
-
candidates = @cursor.candidate_positions(@
|
|
638
|
+
candidates = @cursor.candidate_positions(@items.size)
|
|
589
639
|
return false if candidates.empty?
|
|
590
640
|
|
|
591
641
|
ordered = order_for_search(candidates, @cursor.position, include_current: include_current, reverse: reverse)
|
|
592
642
|
query_lc = query.downcase
|
|
593
|
-
match = ordered.find { |idx| @
|
|
643
|
+
match = ordered.find { |idx| render(@items[idx]).to_s.downcase.include?(query_lc) }
|
|
594
644
|
return false unless match
|
|
595
645
|
|
|
596
646
|
@cursor.go(match)
|
|
@@ -632,58 +682,58 @@ module Tuile
|
|
|
632
682
|
pos = @cursor.position
|
|
633
683
|
return unless pos >= 0
|
|
634
684
|
|
|
635
|
-
if @
|
|
636
|
-
self.
|
|
637
|
-
elsif pos > @
|
|
638
|
-
self.
|
|
685
|
+
if @scroll_top_row > pos
|
|
686
|
+
self.scroll_top_row = pos
|
|
687
|
+
elsif pos > @scroll_top_row + rect.height - 1
|
|
688
|
+
self.scroll_top_row = pos - rect.height + 1
|
|
639
689
|
end
|
|
640
690
|
end
|
|
641
691
|
|
|
642
|
-
# @return [Integer] the max value of {#
|
|
643
|
-
def
|
|
692
|
+
# @return [Integer] the max value of {#scroll_top_row}.
|
|
693
|
+
def scroll_top_row_max = (@items.size - rect.height).clamp(0, nil)
|
|
644
694
|
|
|
645
|
-
# @return [Boolean] whether the viewport is pinned to the last
|
|
646
|
-
# Drives {#following?}: re-evaluated on every {#
|
|
647
|
-
def at_bottom? = @
|
|
695
|
+
# @return [Boolean] whether the viewport is pinned to the last row.
|
|
696
|
+
# Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
|
|
697
|
+
def at_bottom? = @scroll_top_row == scroll_top_row_max
|
|
648
698
|
|
|
649
|
-
# @return [Integer] the number of visible
|
|
650
|
-
def
|
|
699
|
+
# @return [Integer] the number of visible rows.
|
|
700
|
+
def viewport_rows = rect.height
|
|
651
701
|
|
|
652
702
|
# Scrolls the list.
|
|
653
703
|
# @param delta [Integer] negative scrolls up, positive scrolls down.
|
|
654
704
|
# @return [void]
|
|
655
|
-
def
|
|
656
|
-
|
|
657
|
-
return if @
|
|
705
|
+
def move_scroll_top_row_by(delta)
|
|
706
|
+
new_scroll_top_row = (@scroll_top_row + delta).clamp(0, scroll_top_row_max)
|
|
707
|
+
return if @scroll_top_row == new_scroll_top_row
|
|
658
708
|
|
|
659
|
-
@
|
|
709
|
+
@scroll_top_row = new_scroll_top_row
|
|
660
710
|
invalidate
|
|
661
711
|
end
|
|
662
712
|
|
|
663
|
-
# If auto-scrolling, recalculate the top
|
|
713
|
+
# If auto-scrolling, recalculate the top row and snap the cursor to the
|
|
664
714
|
# last reachable position. Without the cursor snap the viewport gets
|
|
665
715
|
# yanked back to wherever the cursor sat on the next arrow press,
|
|
666
716
|
# negating the auto-scroll. Skipped when {#rect} is empty: without a
|
|
667
|
-
# viewport the "
|
|
668
|
-
# which would leave `
|
|
717
|
+
# viewport the "items minus viewport" formula yields `@items.size`,
|
|
718
|
+
# which would leave `scroll_top_row` past the last item once a real rect
|
|
669
719
|
# arrives. {#on_width_changed} re-runs this hook when the rect grows so
|
|
670
720
|
# the snap-to-bottom intent is preserved.
|
|
671
721
|
#
|
|
672
722
|
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
673
723
|
# cursor snap and viewport pin are both skipped, so reading older
|
|
674
|
-
# content is not interrupted by incoming
|
|
724
|
+
# content is not interrupted by incoming items. {#scroll_top_row=} re-arms
|
|
675
725
|
# `@follow` when the viewport returns to the bottom.
|
|
676
726
|
# @return [void]
|
|
677
|
-
def
|
|
727
|
+
def update_scroll_top_row_if_auto_scroll
|
|
678
728
|
return unless @auto_scroll && @follow
|
|
679
729
|
return if rect.empty?
|
|
680
730
|
|
|
681
|
-
notify_cursor_changed if @cursor.go_to_last(@
|
|
731
|
+
notify_cursor_changed if @cursor.go_to_last(@items.size)
|
|
682
732
|
|
|
683
|
-
|
|
684
|
-
return unless @
|
|
733
|
+
new_scroll_top_row = (@items.size - viewport_rows).clamp(0, nil)
|
|
734
|
+
return unless @scroll_top_row != new_scroll_top_row
|
|
685
735
|
|
|
686
|
-
self.
|
|
736
|
+
self.scroll_top_row = new_scroll_top_row
|
|
687
737
|
end
|
|
688
738
|
|
|
689
739
|
# @return [Boolean] whether the scrollbar should be drawn right now.
|
|
@@ -693,7 +743,7 @@ module Tuile
|
|
|
693
743
|
@scrollbar_visibility == :visible
|
|
694
744
|
end
|
|
695
745
|
|
|
696
|
-
# @return [Integer] column width available for
|
|
746
|
+
# @return [Integer] column width available for row content (rect width
|
|
697
747
|
# minus the scrollbar gutter, when visible). `0` when {#rect}'s width
|
|
698
748
|
# is non-positive.
|
|
699
749
|
def content_width
|
|
@@ -702,43 +752,68 @@ module Tuile
|
|
|
702
752
|
rect.width - (scrollbar_visible? ? 1 : 0)
|
|
703
753
|
end
|
|
704
754
|
|
|
705
|
-
#
|
|
706
|
-
#
|
|
707
|
-
# single-space gutters on each side, so {#paintable_line} only has to
|
|
708
|
-
# apply the cursor highlight (if any) and append the scrollbar glyph.
|
|
755
|
+
# Discards every rendered row, so the next paint re-renders the viewport
|
|
756
|
+
# against the current items, renderer and width.
|
|
709
757
|
# @return [void]
|
|
710
|
-
def
|
|
711
|
-
@
|
|
712
|
-
@
|
|
758
|
+
def drop_row_cache
|
|
759
|
+
@row_cache.clear
|
|
760
|
+
@blank_row = nil
|
|
713
761
|
end
|
|
714
762
|
|
|
715
|
-
#
|
|
716
|
-
#
|
|
763
|
+
# @param index [Integer] 0-based index into {#items}.
|
|
764
|
+
# @return [StyledString] the item's padded row, rendered on first use and
|
|
765
|
+
# memoized until {#drop_row_cache}.
|
|
766
|
+
def padded_row(index)
|
|
767
|
+
@row_cache[index] ||= pad_to_row(render(@items[index]))
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
# @return [StyledString] the blank row painted past the last item.
|
|
771
|
+
def blank_row
|
|
772
|
+
@blank_row ||= pad_to_row(StyledString::EMPTY)
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# Renders one item, *without* populating the row cache — {#search_and_go}
|
|
776
|
+
# scans with this, and caching a failed scan would grow the cache to one
|
|
777
|
+
# row per item.
|
|
778
|
+
# @param item [Object]
|
|
779
|
+
# @return [StyledString] one row: the {#renderer}'s output coerced to a
|
|
780
|
+
# {StyledString}, cut to its first line since a `\n` reaching the buffer
|
|
781
|
+
# would corrupt the frame.
|
|
782
|
+
def render(item)
|
|
783
|
+
rendered = @renderer.call(item)
|
|
784
|
+
rendered = StyledString.parse(rendered.to_s) unless rendered.is_a?(StyledString)
|
|
785
|
+
return rendered unless rendered.spans.any? { _1.text.include?("\n") }
|
|
786
|
+
|
|
787
|
+
rendered.lines.first
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
# Pads `row` to one full row of the viewport (scrollbar gutter
|
|
791
|
+
# excluded). Rows wider than the content area are ellipsized via
|
|
717
792
|
# {StyledString#ellipsize} (span styles survive the cut); shorter
|
|
718
|
-
#
|
|
719
|
-
# @param
|
|
793
|
+
# ones are padded with default-styled spaces.
|
|
794
|
+
# @param row [StyledString]
|
|
720
795
|
# @return [StyledString] exactly {#content_width} display columns wide
|
|
721
796
|
# (or {StyledString::EMPTY} when content_width is non-positive).
|
|
722
|
-
def pad_to_row(
|
|
797
|
+
def pad_to_row(row)
|
|
723
798
|
cw = content_width
|
|
724
799
|
return StyledString::EMPTY if cw <= 0
|
|
725
800
|
return StyledString.plain(" " * cw) if cw < 2
|
|
726
801
|
|
|
727
802
|
text_width = cw - 2
|
|
728
|
-
body =
|
|
803
|
+
body = row.ellipsize(text_width)
|
|
729
804
|
fill = cw - 2 - body.display_width
|
|
730
805
|
StyledString.plain(" ") + body + StyledString.plain(" " * (fill + 1))
|
|
731
806
|
end
|
|
732
807
|
|
|
733
|
-
# @param index [Integer] 0-based index into {#
|
|
808
|
+
# @param index [Integer] 0-based index into {#items}.
|
|
734
809
|
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
735
810
|
# @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
|
|
736
811
|
# if not shown.
|
|
737
|
-
# @return [StyledString] paintable
|
|
812
|
+
# @return [StyledString] paintable row exactly `rect.width` columns wide;
|
|
738
813
|
# highlighted if cursor is here.
|
|
739
|
-
def
|
|
740
|
-
base = index < @
|
|
741
|
-
is_cursor = (active? || @show_cursor_when_inactive) && index < @
|
|
814
|
+
def paintable_row(index, row_in_viewport, scrollbar)
|
|
815
|
+
base = index < @items.size ? padded_row(index) : blank_row
|
|
816
|
+
is_cursor = (active? || @show_cursor_when_inactive) && index < @items.size && @cursor.position == index
|
|
742
817
|
styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
|
|
743
818
|
styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
|
|
744
819
|
styled
|