tuile 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/DECISIONS.md +1970 -14
- data/README.md +136 -491
- data/TERMINOLOGY.md +70 -0
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +19 -6
- data/book/03-layout.md +12 -11
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +6 -3
- data/book/07-components.md +498 -38
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +27 -20
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +422 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +16 -10
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +1 -1
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +13 -8
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/list.rb +313 -216
- data/lib/tuile/component/list_dropdown.rb +100 -10
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +320 -0
- data/lib/tuile/component/picker_window.rb +3 -8
- data/lib/tuile/component/popup.rb +83 -19
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +10 -8
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +84 -277
- data/lib/tuile/component/text_field.rb +24 -7
- data/lib/tuile/component/text_view.rb +197 -180
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +43 -18
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +95 -78
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +52 -12
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/sig/tuile.rbs +2307 -516
- metadata +9 -3
- data/mise.toml +0 -2
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.
|
|
@@ -244,33 +294,56 @@ module Tuile
|
|
|
244
294
|
search_and_go(query, include_current: include_current, reverse: true)
|
|
245
295
|
end
|
|
246
296
|
|
|
297
|
+
# Moves the cursor to the item at `index`, scrolling it into view and
|
|
298
|
+
# firing {#on_cursor_changed} — the positional member of the
|
|
299
|
+
# {#select_next} / {#select_prev} family, for a caller that already knows
|
|
300
|
+
# *which* item it wants:
|
|
301
|
+
#
|
|
302
|
+
# list.select(items.index(chosen))
|
|
303
|
+
#
|
|
304
|
+
# Refuses an index the current {#cursor} can't reach — out of range, or
|
|
305
|
+
# anything at all under {Cursor::None} — rather than stranding the cursor
|
|
306
|
+
# off-content.
|
|
307
|
+
# @param index [Integer]
|
|
308
|
+
# @return [Boolean] whether the cursor moved there.
|
|
309
|
+
def select(index)
|
|
310
|
+
return false unless @cursor.candidate_positions(@items.size).include?(index)
|
|
311
|
+
|
|
312
|
+
@cursor.go(index)
|
|
313
|
+
move_viewport_to_cursor
|
|
314
|
+
notify_cursor_changed
|
|
315
|
+
invalidate
|
|
316
|
+
true
|
|
317
|
+
end
|
|
318
|
+
|
|
247
319
|
# @param event [MouseEvent]
|
|
248
320
|
# @return [void]
|
|
249
321
|
def handle_mouse(event)
|
|
250
322
|
super
|
|
251
323
|
if event.button == :scroll_down
|
|
252
|
-
|
|
324
|
+
move_scroll_top_row_by(4)
|
|
253
325
|
elsif event.button == :scroll_up
|
|
254
|
-
|
|
326
|
+
move_scroll_top_row_by(-4)
|
|
255
327
|
else
|
|
256
328
|
return unless rect.contains?(event.point)
|
|
257
329
|
|
|
258
|
-
|
|
259
|
-
if @cursor.handle_mouse(
|
|
330
|
+
item_index = event.y - rect.top + scroll_top_row
|
|
331
|
+
if @cursor.handle_mouse(item_index, event, @items.size)
|
|
260
332
|
move_viewport_to_cursor
|
|
261
333
|
notify_cursor_changed
|
|
262
334
|
invalidate
|
|
263
335
|
end
|
|
264
|
-
fire_item_chosen if event.button == :left &&
|
|
336
|
+
fire_item_chosen if event.button == :left && item_index >= 0 && item_index < @items.size && cursor_on_item?
|
|
265
337
|
end
|
|
266
338
|
end
|
|
267
339
|
|
|
268
|
-
# Paints the
|
|
340
|
+
# Paints the visible items into {#rect}, rendering the ones not already
|
|
341
|
+
# cached.
|
|
269
342
|
#
|
|
270
343
|
# Skips the {Component#repaint} default's auto-clear: every row of
|
|
271
344
|
# {#rect} is painted below (with blank padding past the last item),
|
|
272
345
|
# so the parent contract — "fully draw over your rect" — is met
|
|
273
|
-
# without an upfront wipe. Rows go through {Component#
|
|
346
|
+
# without an upfront wipe. Rows go through {Component#draw_text}, so
|
|
274
347
|
# content *and* blank filler inherit {Component#effective_bg_color}
|
|
275
348
|
# (a {#bg_color} set here or on an ancestor); the cursor row's
|
|
276
349
|
# {Theme#active_bg_color} highlight composes on top of it.
|
|
@@ -279,11 +352,10 @@ module Tuile
|
|
|
279
352
|
return if rect.empty?
|
|
280
353
|
|
|
281
354
|
scrollbar = if scrollbar_visible?
|
|
282
|
-
VerticalScrollBar.new(rect.height,
|
|
355
|
+
VerticalScrollBar.new(rect.height, row_count: @items.size, scroll_top_row: @scroll_top_row)
|
|
283
356
|
end
|
|
284
357
|
(0...rect.height).each do |row|
|
|
285
|
-
|
|
286
|
-
draw_line(rect.left, row + rect.top, line)
|
|
358
|
+
draw_text(rect.left, row + rect.top, paintable_row(row + @scroll_top_row, row, scrollbar))
|
|
287
359
|
end
|
|
288
360
|
end
|
|
289
361
|
|
|
@@ -304,24 +376,24 @@ module Tuile
|
|
|
304
376
|
end
|
|
305
377
|
|
|
306
378
|
# @param _key [String]
|
|
307
|
-
# @param
|
|
308
|
-
# @param
|
|
379
|
+
# @param _item_count [Integer]
|
|
380
|
+
# @param _viewport_rows [Integer]
|
|
309
381
|
# @return [Boolean]
|
|
310
|
-
def handle_key(_key,
|
|
382
|
+
def handle_key(_key, _item_count, _viewport_rows)
|
|
311
383
|
false
|
|
312
384
|
end
|
|
313
385
|
|
|
314
|
-
# @param
|
|
386
|
+
# @param _item_index [Integer]
|
|
315
387
|
# @param _event [MouseEvent]
|
|
316
|
-
# @param
|
|
388
|
+
# @param _item_count [Integer]
|
|
317
389
|
# @return [Boolean]
|
|
318
|
-
def handle_mouse(
|
|
390
|
+
def handle_mouse(_item_index, _event, _item_count)
|
|
319
391
|
false
|
|
320
392
|
end
|
|
321
393
|
|
|
322
|
-
# @param
|
|
394
|
+
# @param _item_count [Integer]
|
|
323
395
|
# @return [Array<Integer>]
|
|
324
|
-
def candidate_positions(
|
|
396
|
+
def candidate_positions(_item_count)
|
|
325
397
|
[]
|
|
326
398
|
end
|
|
327
399
|
|
|
@@ -336,46 +408,46 @@ module Tuile
|
|
|
336
408
|
end
|
|
337
409
|
end
|
|
338
410
|
|
|
339
|
-
# @return [Integer] 0-based
|
|
411
|
+
# @return [Integer] 0-based item index of the current cursor position.
|
|
340
412
|
attr_reader :position
|
|
341
413
|
|
|
342
|
-
# @param
|
|
414
|
+
# @param item_count [Integer] number of items in the list.
|
|
343
415
|
# @return [Array<Integer>] positions the cursor can land on, in
|
|
344
416
|
# ascending order.
|
|
345
|
-
def candidate_positions(
|
|
346
|
-
(0...
|
|
417
|
+
def candidate_positions(item_count)
|
|
418
|
+
(0...item_count).to_a
|
|
347
419
|
end
|
|
348
420
|
|
|
349
421
|
# @param key [String] pressed keyboard key.
|
|
350
|
-
# @param
|
|
351
|
-
# @param
|
|
422
|
+
# @param item_count [Integer] number of items in the list.
|
|
423
|
+
# @param viewport_rows [Integer] number of visible rows.
|
|
352
424
|
# @return [Boolean] true if the cursor moved.
|
|
353
|
-
def handle_key(key,
|
|
425
|
+
def handle_key(key, item_count, viewport_rows)
|
|
354
426
|
case key
|
|
355
427
|
when *Keys::DOWN_ARROWS
|
|
356
|
-
go_down_by(1,
|
|
428
|
+
go_down_by(1, item_count)
|
|
357
429
|
when *Keys::UP_ARROWS
|
|
358
430
|
go_up_by(1)
|
|
359
431
|
when *Keys::HOMES
|
|
360
432
|
go_to_first
|
|
361
433
|
when *Keys::ENDS_
|
|
362
|
-
go_to_last(
|
|
434
|
+
go_to_last(item_count)
|
|
363
435
|
when Keys::CTRL_U
|
|
364
|
-
go_up_by(
|
|
436
|
+
go_up_by(viewport_rows / 2)
|
|
365
437
|
when Keys::CTRL_D
|
|
366
|
-
go_down_by(
|
|
438
|
+
go_down_by(viewport_rows / 2, item_count)
|
|
367
439
|
else
|
|
368
440
|
false
|
|
369
441
|
end
|
|
370
442
|
end
|
|
371
443
|
|
|
372
|
-
# @param
|
|
444
|
+
# @param item_index [Integer] the item the cursor is hovering over.
|
|
373
445
|
# @param event [MouseEvent] the event.
|
|
374
|
-
# @param
|
|
446
|
+
# @param item_count [Integer] number of items in the list.
|
|
375
447
|
# @return [Boolean] true if the event was handled.
|
|
376
|
-
def handle_mouse(
|
|
448
|
+
def handle_mouse(item_index, event, item_count)
|
|
377
449
|
if event.button == :left
|
|
378
|
-
go(
|
|
450
|
+
go(item_index.clamp(nil, item_count - 1))
|
|
379
451
|
else
|
|
380
452
|
false
|
|
381
453
|
end
|
|
@@ -393,27 +465,27 @@ module Tuile
|
|
|
393
465
|
end
|
|
394
466
|
|
|
395
467
|
# Moves the cursor to the last reachable position. For base {Cursor},
|
|
396
|
-
# the last
|
|
468
|
+
# the last item; {Limited} clamps to the last allowed position; {None}
|
|
397
469
|
# is a no-op.
|
|
398
|
-
# @param
|
|
470
|
+
# @param item_count [Integer] number of items in the list.
|
|
399
471
|
# @return [Boolean] true if the position changed.
|
|
400
|
-
def go_to_last(
|
|
401
|
-
go(
|
|
472
|
+
def go_to_last(item_count)
|
|
473
|
+
go(item_count - 1)
|
|
402
474
|
end
|
|
403
475
|
|
|
404
476
|
protected
|
|
405
477
|
|
|
406
|
-
# @param
|
|
407
|
-
# @param
|
|
478
|
+
# @param count [Integer]
|
|
479
|
+
# @param item_count [Integer]
|
|
408
480
|
# @return [Boolean]
|
|
409
|
-
def go_down_by(
|
|
410
|
-
go((@position +
|
|
481
|
+
def go_down_by(count, item_count)
|
|
482
|
+
go((@position + count).clamp(nil, item_count - 1))
|
|
411
483
|
end
|
|
412
484
|
|
|
413
|
-
# @param
|
|
485
|
+
# @param count [Integer]
|
|
414
486
|
# @return [Boolean]
|
|
415
|
-
def go_up_by(
|
|
416
|
-
go(@position -
|
|
487
|
+
def go_up_by(count)
|
|
488
|
+
go(@position - count)
|
|
417
489
|
end
|
|
418
490
|
|
|
419
491
|
# @return [Boolean]
|
|
@@ -421,7 +493,7 @@ module Tuile
|
|
|
421
493
|
go(0)
|
|
422
494
|
end
|
|
423
495
|
|
|
424
|
-
# Cursor which can only land on specific allowed
|
|
496
|
+
# Cursor which can only land on specific allowed items.
|
|
425
497
|
class Limited < Cursor
|
|
426
498
|
# @param positions [Array<Integer>] allowed positions. Must not be
|
|
427
499
|
# empty.
|
|
@@ -434,13 +506,13 @@ module Tuile
|
|
|
434
506
|
super(position: position)
|
|
435
507
|
end
|
|
436
508
|
|
|
437
|
-
# @param
|
|
509
|
+
# @param item_index [Integer]
|
|
438
510
|
# @param event [MouseEvent]
|
|
439
|
-
# @param
|
|
511
|
+
# @param _item_count [Integer]
|
|
440
512
|
# @return [Boolean]
|
|
441
|
-
def handle_mouse(
|
|
513
|
+
def handle_mouse(item_index, event, _item_count)
|
|
442
514
|
if event.button == :left
|
|
443
|
-
prev_pos = @positions.reverse_each.find { _1 <=
|
|
515
|
+
prev_pos = @positions.reverse_each.find { _1 <= item_index }
|
|
444
516
|
return go_to_first if prev_pos.nil?
|
|
445
517
|
|
|
446
518
|
go(prev_pos)
|
|
@@ -449,34 +521,34 @@ module Tuile
|
|
|
449
521
|
end
|
|
450
522
|
end
|
|
451
523
|
|
|
452
|
-
# @param
|
|
524
|
+
# @param item_count [Integer]
|
|
453
525
|
# @return [Array<Integer>]
|
|
454
|
-
def candidate_positions(
|
|
455
|
-
@positions.select { _1 <
|
|
526
|
+
def candidate_positions(item_count)
|
|
527
|
+
@positions.select { _1 < item_count }
|
|
456
528
|
end
|
|
457
529
|
|
|
458
|
-
# @param
|
|
530
|
+
# @param _item_count [Integer]
|
|
459
531
|
# @return [Boolean]
|
|
460
|
-
def go_to_last(
|
|
532
|
+
def go_to_last(_item_count)
|
|
461
533
|
go(@positions.last)
|
|
462
534
|
end
|
|
463
535
|
|
|
464
536
|
protected
|
|
465
537
|
|
|
466
|
-
# @param
|
|
467
|
-
# @param
|
|
538
|
+
# @param count [Integer]
|
|
539
|
+
# @param item_count [Integer]
|
|
468
540
|
# @return [Boolean]
|
|
469
|
-
def go_down_by(
|
|
470
|
-
next_pos = @positions.find { _1 >= @position +
|
|
471
|
-
return go_to_last(
|
|
541
|
+
def go_down_by(count, item_count)
|
|
542
|
+
next_pos = @positions.find { _1 >= @position + count }
|
|
543
|
+
return go_to_last(item_count) if next_pos.nil?
|
|
472
544
|
|
|
473
545
|
go(next_pos)
|
|
474
546
|
end
|
|
475
547
|
|
|
476
|
-
# @param
|
|
548
|
+
# @param count [Integer]
|
|
477
549
|
# @return [Boolean]
|
|
478
|
-
def go_up_by(
|
|
479
|
-
prev_pos = @positions.reverse_each.find { _1 <= @position -
|
|
550
|
+
def go_up_by(count)
|
|
551
|
+
prev_pos = @positions.reverse_each.find { _1 <= @position - count }
|
|
480
552
|
return go_to_first if prev_pos.nil?
|
|
481
553
|
|
|
482
554
|
go(prev_pos)
|
|
@@ -491,18 +563,18 @@ module Tuile
|
|
|
491
563
|
|
|
492
564
|
protected
|
|
493
565
|
|
|
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
|
-
#
|
|
566
|
+
# Drops the rendered-row cache when the wrap width changes. The wrap
|
|
567
|
+
# width depends on {#rect}`.width` and the scrollbar gutter, both of
|
|
568
|
+
# which trigger this hook. Also re-evaluates {#auto_scroll}: if items were
|
|
569
|
+
# assigned while the rect was empty (e.g. a {Popup}-wrapped list was
|
|
570
|
+
# populated before the popup was opened), the auto-scroll update
|
|
499
571
|
# was skipped because there was no viewport — re-run it now that there
|
|
500
572
|
# is one, so the list snaps to the bottom on first paint.
|
|
501
573
|
# @return [void]
|
|
502
574
|
def on_width_changed
|
|
503
575
|
super
|
|
504
|
-
|
|
505
|
-
|
|
576
|
+
drop_row_cache
|
|
577
|
+
update_scroll_top_row_if_auto_scroll
|
|
506
578
|
end
|
|
507
579
|
|
|
508
580
|
private
|
|
@@ -512,7 +584,7 @@ module Tuile
|
|
|
512
584
|
# via {StyledString.parse}, StyledString passed through, anything else
|
|
513
585
|
# via `#to_s`), then split on `\n` via {StyledString#lines} — with
|
|
514
586
|
# trailing empty pieces dropped (matching `String#split("\n")`'s
|
|
515
|
-
# default behavior, so `
|
|
587
|
+
# default behavior, so a lone `""` entry adds no row) — and trailing ASCII
|
|
516
588
|
# whitespace stripped on each resulting line.
|
|
517
589
|
# @param entries [Array]
|
|
518
590
|
# @return [Array<StyledString>]
|
|
@@ -544,27 +616,27 @@ module Tuile
|
|
|
544
616
|
line.slice(0, line.display_width - trailing)
|
|
545
617
|
end
|
|
546
618
|
|
|
547
|
-
# @return [Boolean] true if the cursor sits on a real
|
|
619
|
+
# @return [Boolean] true if the cursor sits on a real item.
|
|
548
620
|
def cursor_on_item?
|
|
549
621
|
pos = @cursor.position
|
|
550
|
-
pos >= 0 && pos < @
|
|
622
|
+
pos >= 0 && pos < @items.size
|
|
551
623
|
end
|
|
552
624
|
|
|
553
|
-
# Calls {#on_item_chosen} with the cursor's current `(index,
|
|
625
|
+
# Calls {#on_item_chosen} with the cursor's current `(index, item)`.
|
|
554
626
|
# Caller must ensure {#cursor_on_item?}.
|
|
555
627
|
# @return [void]
|
|
556
628
|
def fire_item_chosen
|
|
557
629
|
pos = @cursor.position
|
|
558
|
-
@on_item_chosen&.call(pos, @
|
|
630
|
+
@on_item_chosen&.call(pos, @items[pos])
|
|
559
631
|
end
|
|
560
632
|
|
|
561
|
-
# @return [Array((Integer,
|
|
562
|
-
# `[position,
|
|
633
|
+
# @return [Array((Integer, Object, nil))]
|
|
634
|
+
# `[position, item_at_position]`, with the item nil when the cursor is
|
|
563
635
|
# off-content.
|
|
564
636
|
def cursor_state
|
|
565
637
|
pos = @cursor.position
|
|
566
|
-
|
|
567
|
-
[pos,
|
|
638
|
+
item = pos >= 0 && pos < @items.size ? @items[pos] : nil
|
|
639
|
+
[pos, item]
|
|
568
640
|
end
|
|
569
641
|
|
|
570
642
|
# Fires {#on_cursor_changed} if {#cursor_state} differs from the last
|
|
@@ -585,12 +657,12 @@ module Tuile
|
|
|
585
657
|
def search_and_go(query, include_current:, reverse:)
|
|
586
658
|
return false if query.empty?
|
|
587
659
|
|
|
588
|
-
candidates = @cursor.candidate_positions(@
|
|
660
|
+
candidates = @cursor.candidate_positions(@items.size)
|
|
589
661
|
return false if candidates.empty?
|
|
590
662
|
|
|
591
663
|
ordered = order_for_search(candidates, @cursor.position, include_current: include_current, reverse: reverse)
|
|
592
664
|
query_lc = query.downcase
|
|
593
|
-
match = ordered.find { |idx| @
|
|
665
|
+
match = ordered.find { |idx| render(@items[idx]).to_s.downcase.include?(query_lc) }
|
|
594
666
|
return false unless match
|
|
595
667
|
|
|
596
668
|
@cursor.go(match)
|
|
@@ -632,58 +704,58 @@ module Tuile
|
|
|
632
704
|
pos = @cursor.position
|
|
633
705
|
return unless pos >= 0
|
|
634
706
|
|
|
635
|
-
if @
|
|
636
|
-
self.
|
|
637
|
-
elsif pos > @
|
|
638
|
-
self.
|
|
707
|
+
if @scroll_top_row > pos
|
|
708
|
+
self.scroll_top_row = pos
|
|
709
|
+
elsif pos > @scroll_top_row + rect.height - 1
|
|
710
|
+
self.scroll_top_row = pos - rect.height + 1
|
|
639
711
|
end
|
|
640
712
|
end
|
|
641
713
|
|
|
642
|
-
# @return [Integer] the max value of {#
|
|
643
|
-
def
|
|
714
|
+
# @return [Integer] the max value of {#scroll_top_row}.
|
|
715
|
+
def scroll_top_row_max = (@items.size - rect.height).clamp(0, nil)
|
|
644
716
|
|
|
645
|
-
# @return [Boolean] whether the viewport is pinned to the last
|
|
646
|
-
# Drives {#following?}: re-evaluated on every {#
|
|
647
|
-
def at_bottom? = @
|
|
717
|
+
# @return [Boolean] whether the viewport is pinned to the last row.
|
|
718
|
+
# Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
|
|
719
|
+
def at_bottom? = @scroll_top_row == scroll_top_row_max
|
|
648
720
|
|
|
649
|
-
# @return [Integer] the number of visible
|
|
650
|
-
def
|
|
721
|
+
# @return [Integer] the number of visible rows.
|
|
722
|
+
def viewport_rows = rect.height
|
|
651
723
|
|
|
652
724
|
# Scrolls the list.
|
|
653
725
|
# @param delta [Integer] negative scrolls up, positive scrolls down.
|
|
654
726
|
# @return [void]
|
|
655
|
-
def
|
|
656
|
-
|
|
657
|
-
return if @
|
|
727
|
+
def move_scroll_top_row_by(delta)
|
|
728
|
+
new_scroll_top_row = (@scroll_top_row + delta).clamp(0, scroll_top_row_max)
|
|
729
|
+
return if @scroll_top_row == new_scroll_top_row
|
|
658
730
|
|
|
659
|
-
@
|
|
731
|
+
@scroll_top_row = new_scroll_top_row
|
|
660
732
|
invalidate
|
|
661
733
|
end
|
|
662
734
|
|
|
663
|
-
# If auto-scrolling, recalculate the top
|
|
735
|
+
# If auto-scrolling, recalculate the top row and snap the cursor to the
|
|
664
736
|
# last reachable position. Without the cursor snap the viewport gets
|
|
665
737
|
# yanked back to wherever the cursor sat on the next arrow press,
|
|
666
738
|
# negating the auto-scroll. Skipped when {#rect} is empty: without a
|
|
667
|
-
# viewport the "
|
|
668
|
-
# which would leave `
|
|
739
|
+
# viewport the "items minus viewport" formula yields `@items.size`,
|
|
740
|
+
# which would leave `scroll_top_row` past the last item once a real rect
|
|
669
741
|
# arrives. {#on_width_changed} re-runs this hook when the rect grows so
|
|
670
742
|
# the snap-to-bottom intent is preserved.
|
|
671
743
|
#
|
|
672
744
|
# Gated on {#following?}: once the user scrolls up off the bottom the
|
|
673
745
|
# cursor snap and viewport pin are both skipped, so reading older
|
|
674
|
-
# content is not interrupted by incoming
|
|
746
|
+
# content is not interrupted by incoming items. {#scroll_top_row=} re-arms
|
|
675
747
|
# `@follow` when the viewport returns to the bottom.
|
|
676
748
|
# @return [void]
|
|
677
|
-
def
|
|
749
|
+
def update_scroll_top_row_if_auto_scroll
|
|
678
750
|
return unless @auto_scroll && @follow
|
|
679
751
|
return if rect.empty?
|
|
680
752
|
|
|
681
|
-
notify_cursor_changed if @cursor.go_to_last(@
|
|
753
|
+
notify_cursor_changed if @cursor.go_to_last(@items.size)
|
|
682
754
|
|
|
683
|
-
|
|
684
|
-
return unless @
|
|
755
|
+
new_scroll_top_row = (@items.size - viewport_rows).clamp(0, nil)
|
|
756
|
+
return unless @scroll_top_row != new_scroll_top_row
|
|
685
757
|
|
|
686
|
-
self.
|
|
758
|
+
self.scroll_top_row = new_scroll_top_row
|
|
687
759
|
end
|
|
688
760
|
|
|
689
761
|
# @return [Boolean] whether the scrollbar should be drawn right now.
|
|
@@ -693,7 +765,7 @@ module Tuile
|
|
|
693
765
|
@scrollbar_visibility == :visible
|
|
694
766
|
end
|
|
695
767
|
|
|
696
|
-
# @return [Integer] column width available for
|
|
768
|
+
# @return [Integer] column width available for row content (rect width
|
|
697
769
|
# minus the scrollbar gutter, when visible). `0` when {#rect}'s width
|
|
698
770
|
# is non-positive.
|
|
699
771
|
def content_width
|
|
@@ -702,43 +774,68 @@ module Tuile
|
|
|
702
774
|
rect.width - (scrollbar_visible? ? 1 : 0)
|
|
703
775
|
end
|
|
704
776
|
|
|
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.
|
|
777
|
+
# Discards every rendered row, so the next paint re-renders the viewport
|
|
778
|
+
# against the current items, renderer and width.
|
|
709
779
|
# @return [void]
|
|
710
|
-
def
|
|
711
|
-
@
|
|
712
|
-
@
|
|
780
|
+
def drop_row_cache
|
|
781
|
+
@row_cache.clear
|
|
782
|
+
@blank_row = nil
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# @param index [Integer] 0-based index into {#items}.
|
|
786
|
+
# @return [StyledString] the item's padded row, rendered on first use and
|
|
787
|
+
# memoized until {#drop_row_cache}.
|
|
788
|
+
def padded_row(index)
|
|
789
|
+
@row_cache[index] ||= pad_to_row(render(@items[index]))
|
|
713
790
|
end
|
|
714
791
|
|
|
715
|
-
#
|
|
716
|
-
|
|
792
|
+
# @return [StyledString] the blank row painted past the last item.
|
|
793
|
+
def blank_row
|
|
794
|
+
@blank_row ||= pad_to_row(StyledString::EMPTY)
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Renders one item, *without* populating the row cache — {#search_and_go}
|
|
798
|
+
# scans with this, and caching a failed scan would grow the cache to one
|
|
799
|
+
# row per item.
|
|
800
|
+
# @param item [Object]
|
|
801
|
+
# @return [StyledString] one row: the {#renderer}'s output coerced to a
|
|
802
|
+
# {StyledString}, cut to its first line since a `\n` reaching the buffer
|
|
803
|
+
# would corrupt the frame.
|
|
804
|
+
def render(item)
|
|
805
|
+
rendered = @renderer.call(item)
|
|
806
|
+
rendered = StyledString.parse(rendered.to_s) unless rendered.is_a?(StyledString)
|
|
807
|
+
return rendered unless rendered.spans.any? { _1.text.include?("\n") }
|
|
808
|
+
|
|
809
|
+
rendered.lines.first
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# Pads `row` to one full row of the viewport (scrollbar gutter
|
|
813
|
+
# excluded). Rows wider than the content area are ellipsized via
|
|
717
814
|
# {StyledString#ellipsize} (span styles survive the cut); shorter
|
|
718
|
-
#
|
|
719
|
-
# @param
|
|
815
|
+
# ones are padded with default-styled spaces.
|
|
816
|
+
# @param row [StyledString]
|
|
720
817
|
# @return [StyledString] exactly {#content_width} display columns wide
|
|
721
818
|
# (or {StyledString::EMPTY} when content_width is non-positive).
|
|
722
|
-
def pad_to_row(
|
|
819
|
+
def pad_to_row(row)
|
|
723
820
|
cw = content_width
|
|
724
821
|
return StyledString::EMPTY if cw <= 0
|
|
725
822
|
return StyledString.plain(" " * cw) if cw < 2
|
|
726
823
|
|
|
727
824
|
text_width = cw - 2
|
|
728
|
-
body =
|
|
825
|
+
body = row.ellipsize(text_width)
|
|
729
826
|
fill = cw - 2 - body.display_width
|
|
730
827
|
StyledString.plain(" ") + body + StyledString.plain(" " * (fill + 1))
|
|
731
828
|
end
|
|
732
829
|
|
|
733
|
-
# @param index [Integer] 0-based index into {#
|
|
830
|
+
# @param index [Integer] 0-based index into {#items}.
|
|
734
831
|
# @param row_in_viewport [Integer] 0-based row within the viewport.
|
|
735
832
|
# @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
|
|
736
833
|
# if not shown.
|
|
737
|
-
# @return [StyledString] paintable
|
|
834
|
+
# @return [StyledString] paintable row exactly `rect.width` columns wide;
|
|
738
835
|
# highlighted if cursor is here.
|
|
739
|
-
def
|
|
740
|
-
base = index < @
|
|
741
|
-
is_cursor = (active? || @show_cursor_when_inactive) && index < @
|
|
836
|
+
def paintable_row(index, row_in_viewport, scrollbar)
|
|
837
|
+
base = index < @items.size ? padded_row(index) : blank_row
|
|
838
|
+
is_cursor = (active? || @show_cursor_when_inactive) && index < @items.size && @cursor.position == index
|
|
742
839
|
styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
|
|
743
840
|
styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
|
|
744
841
|
styled
|