tuile 0.9.0 → 0.10.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 +1961 -0
- data/README.md +31 -24
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +82 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +363 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +19 -13
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout.rb +3 -17
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2043 -614
- metadata +18 -7
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A one-row progress bar: a run of `█` growing left to right across {#rect},
|
|
6
|
+
# over a `░` track.
|
|
7
|
+
#
|
|
8
|
+
# ████████░░░░░░░░░░░░
|
|
9
|
+
#
|
|
10
|
+
# bar = Component::ProgressBar.new(range: 0..files.size)
|
|
11
|
+
# label = Component::Label.new
|
|
12
|
+
# add(bar)
|
|
13
|
+
# add(label)
|
|
14
|
+
#
|
|
15
|
+
# def rect=(new_rect) # the enclosing Layout positions both
|
|
16
|
+
# super
|
|
17
|
+
# bar.rect = Rect.new(rect.left, rect.top, rect.width, 1)
|
|
18
|
+
# label.rect = Rect.new(rect.left, rect.top + 1, rect.width, 1)
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# bar.value = done
|
|
22
|
+
# label.text = "#{bar.percent}% — #{done}/#{files.size}"
|
|
23
|
+
#
|
|
24
|
+
# The bar paints no text of its own: put a {Label} beside it and feed it
|
|
25
|
+
# {#percent} or {#fraction}, so the app words it ("42% — 3/7 files") and
|
|
26
|
+
# places it freely. Display-only — not focusable, no keys, no mouse.
|
|
27
|
+
#
|
|
28
|
+
# While the total is still unknown, {#indeterminate=} swaps the fill for a
|
|
29
|
+
# block sliding across the bar:
|
|
30
|
+
#
|
|
31
|
+
# ░░░░░░░████░░░░░░░░░
|
|
32
|
+
#
|
|
33
|
+
# Both endpoints are exact: the bar is full only at {#max} and empty only at
|
|
34
|
+
# {#min}, so a full bar always means done. Assign a one-row {#rect}; a taller
|
|
35
|
+
# one paints the bar on its first row and leaves the rest to the background.
|
|
36
|
+
#
|
|
37
|
+
# == Implementation details
|
|
38
|
+
# The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
|
|
39
|
+
# Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
|
|
40
|
+
# the rendered length would vary with the fill level. Shipped anyway, per
|
|
41
|
+
# `DECISIONS.md` `D-ambiguous-width`: a bar that rhymes with the scrollbar
|
|
42
|
+
# beats a third convention, and if that bet is ever reversed both swap
|
|
43
|
+
# together.
|
|
44
|
+
class ProgressBar < Component
|
|
45
|
+
# Range covering the whole bar when none is given.
|
|
46
|
+
# @return [Range]
|
|
47
|
+
DEFAULT_RANGE = (0.0..1.0)
|
|
48
|
+
|
|
49
|
+
# Frames per second of the indeterminate animation. The block advances one
|
|
50
|
+
# cell per frame, so this is also its speed in cells/second.
|
|
51
|
+
# @return [Integer]
|
|
52
|
+
INDETERMINATE_FPS = 5
|
|
53
|
+
|
|
54
|
+
# The indeterminate block is this fraction of the bar, at least one cell.
|
|
55
|
+
# @return [Integer]
|
|
56
|
+
BLOCK_DIVISOR = 5
|
|
57
|
+
|
|
58
|
+
# @param range [Range] initial {#range=}.
|
|
59
|
+
# @param value [Numeric, nil] initial {#value=}; `nil` starts at the range's
|
|
60
|
+
# lower bound.
|
|
61
|
+
# @param indeterminate [Boolean] initial {#indeterminate=}.
|
|
62
|
+
def initialize(range: DEFAULT_RANGE, value: nil, indeterminate: false)
|
|
63
|
+
super()
|
|
64
|
+
@value = 0.0
|
|
65
|
+
@min = 0.0
|
|
66
|
+
@max = 1.0
|
|
67
|
+
@phase = 0
|
|
68
|
+
@ticker = nil
|
|
69
|
+
@indeterminate = false
|
|
70
|
+
@bar_color = nil
|
|
71
|
+
self.range = range
|
|
72
|
+
self.value = value unless value.nil?
|
|
73
|
+
self.indeterminate = indeterminate
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Float] lower bound of {#range}.
|
|
77
|
+
attr_reader :min
|
|
78
|
+
|
|
79
|
+
# @return [Float] upper bound of {#range}.
|
|
80
|
+
attr_reader :max
|
|
81
|
+
|
|
82
|
+
# @return [Color, nil] the value as set, so a {Theme::Ref} comes back
|
|
83
|
+
# unresolved. Both glyphs paint in it; `nil` (the default) is the
|
|
84
|
+
# terminal's default foreground.
|
|
85
|
+
attr_reader :bar_color
|
|
86
|
+
|
|
87
|
+
# @return [Range] the scale {#value} is measured against.
|
|
88
|
+
def range = @min..@max
|
|
89
|
+
|
|
90
|
+
# Replaces the scale, re-clamping {#value} into it. `min == max` is legal
|
|
91
|
+
# and reads as complete — a zero-length job has nothing outstanding — so
|
|
92
|
+
# `bar.range = 0..files.size` needs no special case for an empty list.
|
|
93
|
+
#
|
|
94
|
+
# @param new_range [Range] inclusive; endpoints Numeric and finite.
|
|
95
|
+
# @return [void]
|
|
96
|
+
# @raise [ArgumentError] on an exclusive, inverted or non-finite range, or
|
|
97
|
+
# an endpoint `Float()` cannot parse.
|
|
98
|
+
# @raise [TypeError] on a beginless or endless range — its `nil` endpoint
|
|
99
|
+
# is what `Float()` refuses — or any other type it refuses outright.
|
|
100
|
+
def range=(new_range)
|
|
101
|
+
raise ArgumentError, "range must be inclusive, got #{new_range.inspect}" if new_range.exclude_end?
|
|
102
|
+
|
|
103
|
+
min = Float(new_range.begin)
|
|
104
|
+
max = Float(new_range.end)
|
|
105
|
+
raise ArgumentError, "range end #{max} is below its start #{min}" if max < min
|
|
106
|
+
|
|
107
|
+
unless min.finite? && max.finite?
|
|
108
|
+
raise ArgumentError, "range endpoints must be finite (use indeterminate = true)"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@min = min
|
|
112
|
+
@max = max
|
|
113
|
+
self.value = @value
|
|
114
|
+
invalidate # the scale moved even when the clamped value did not
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [Float] the progress, clamped into {#range} when assigned — so
|
|
118
|
+
# `bar.value = 999` on a `0..250` bar reads back as `250.0`.
|
|
119
|
+
attr_reader :value
|
|
120
|
+
|
|
121
|
+
# @param new_value [Numeric] clamped into {#range}.
|
|
122
|
+
# @return [void]
|
|
123
|
+
# @raise [ArgumentError] on NaN — typically `done.to_f / total` with a zero
|
|
124
|
+
# total, which wants {#indeterminate=} instead — or on a String `Float()`
|
|
125
|
+
# cannot parse.
|
|
126
|
+
# @raise [TypeError] on `nil` and other types `Float()` refuses outright.
|
|
127
|
+
def value=(new_value)
|
|
128
|
+
new_value = Float(new_value)
|
|
129
|
+
raise ArgumentError, "value must be a number, got NaN" if new_value.nan?
|
|
130
|
+
|
|
131
|
+
new_value = new_value.clamp(@min, @max)
|
|
132
|
+
return if @value == new_value
|
|
133
|
+
|
|
134
|
+
@value = new_value
|
|
135
|
+
invalidate
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Float] {#value} as `0.0..1.0`. `1.0` when the range is empty.
|
|
139
|
+
def fraction
|
|
140
|
+
return 1.0 if @max == @min
|
|
141
|
+
|
|
142
|
+
(@value - @min) / (@max - @min)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Integer] {#fraction} as `0..100`, floored — `100` means done and
|
|
146
|
+
# nothing else does, matching the painted bar exactly.
|
|
147
|
+
def percent = scale(100)
|
|
148
|
+
|
|
149
|
+
# Sets the color of both glyphs, live-resolved at paint time when given a
|
|
150
|
+
# {Theme::Ref} (so it follows a {Screen#theme=} with no
|
|
151
|
+
# {Component#on_theme_changed} hook).
|
|
152
|
+
#
|
|
153
|
+
# bar.bar_color = Color::GREEN
|
|
154
|
+
# bar.bar_color = Theme.ref(:brand_ok) # an app #custom token
|
|
155
|
+
#
|
|
156
|
+
# @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
|
|
157
|
+
# coerced via {Color.coerce} unless it is a {Theme::Ref}; `nil` is the
|
|
158
|
+
# terminal default.
|
|
159
|
+
# @return [void]
|
|
160
|
+
# @raise [KeyError] when a {Theme::Ref} names a token the current theme
|
|
161
|
+
# lacks.
|
|
162
|
+
def bar_color=(color)
|
|
163
|
+
color = Color.coerce(color) unless color.is_a?(Theme::Ref)
|
|
164
|
+
return if @bar_color == color
|
|
165
|
+
|
|
166
|
+
color.resolve(screen.theme) if color.is_a?(Theme::Ref) # fail fast on a bad token
|
|
167
|
+
|
|
168
|
+
@bar_color = color
|
|
169
|
+
invalidate
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @return [Boolean] whether the sliding-block animation is showing.
|
|
173
|
+
def indeterminate? = @indeterminate
|
|
174
|
+
|
|
175
|
+
# Switches between the fill and the sliding block. {#value} keeps working
|
|
176
|
+
# while indeterminate — it is simply not painted — so switching back shows
|
|
177
|
+
# the progress that accumulated meanwhile.
|
|
178
|
+
#
|
|
179
|
+
# The animation only runs while the bar is {Component#attached? attached},
|
|
180
|
+
# and stops on detach. It also keeps the event loop awake at
|
|
181
|
+
# {INDETERMINATE_FPS}, so turn it off (or remove the bar) when the job ends.
|
|
182
|
+
#
|
|
183
|
+
# @param flag [Boolean] coerced; truthiness decides.
|
|
184
|
+
# @return [void]
|
|
185
|
+
def indeterminate=(flag)
|
|
186
|
+
flag = flag ? true : false
|
|
187
|
+
return if @indeterminate == flag
|
|
188
|
+
|
|
189
|
+
@indeterminate = flag
|
|
190
|
+
sync_ticker
|
|
191
|
+
invalidate # the picture changes now, not on the next frame
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @return [void]
|
|
195
|
+
def on_attached = sync_ticker
|
|
196
|
+
|
|
197
|
+
# @return [void]
|
|
198
|
+
def on_detached = sync_ticker
|
|
199
|
+
|
|
200
|
+
# Paints the bar on the first row of {#rect} and blanks the rest.
|
|
201
|
+
#
|
|
202
|
+
# Deliberately not `super`: {Component#repaint}'s default blanks the
|
|
203
|
+
# *whole* rect, which dirties every cell of the bar's own row before it is
|
|
204
|
+
# painted over — so {Buffer#flush} re-emits the entire row every frame
|
|
205
|
+
# instead of the one or two cells that actually moved.
|
|
206
|
+
# @return [void]
|
|
207
|
+
def repaint
|
|
208
|
+
return if rect.empty?
|
|
209
|
+
|
|
210
|
+
draw_line(rect.left, rect.top, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
|
|
211
|
+
clear_background(Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)) if rect.height > 1
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# Filled cells out of `steps` — the rect width when painting, 100 for
|
|
217
|
+
# {#percent}, so the bar and a {Label} showing the percentage can never
|
|
218
|
+
# disagree about being done.
|
|
219
|
+
# @param steps [Integer]
|
|
220
|
+
# @return [Integer]
|
|
221
|
+
def scale(steps)
|
|
222
|
+
return 0 if fraction <= 0.0
|
|
223
|
+
return steps if fraction >= 1.0
|
|
224
|
+
return 0 if steps < 2 # no interior to land in; fills only when done
|
|
225
|
+
|
|
226
|
+
(fraction * steps).floor.clamp(1, steps - 1)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# @param width [Integer] columns available.
|
|
230
|
+
# @return [String] the row, `width` glyphs wide.
|
|
231
|
+
def glyphs(width)
|
|
232
|
+
start, length = @indeterminate ? block_at(width) : [0, scale(width)]
|
|
233
|
+
[("░" * start), ("█" * length), ("░" * (width - start - length))].join
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Where the sliding block sits this frame: it enters at the left edge and
|
|
237
|
+
# leaves at the right, one cell per frame, then loops. The period is one
|
|
238
|
+
# short of `width + block` so at least one cell is always lit — a full
|
|
239
|
+
# `width + block` blanks the bar for exactly one frame per cycle.
|
|
240
|
+
# @param width [Integer] columns available.
|
|
241
|
+
# @return [Array(Integer, Integer)] start column and length, clipped.
|
|
242
|
+
def block_at(width)
|
|
243
|
+
block = [width / BLOCK_DIVISOR, 1].max
|
|
244
|
+
start = (@phase % (width + block - 1)) - (block - 1)
|
|
245
|
+
first = [start, 0].max
|
|
246
|
+
last = [start + block, width].min
|
|
247
|
+
[first, last - first]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# @return [Color, nil]
|
|
251
|
+
def resolved_bar_color
|
|
252
|
+
@bar_color.is_a?(Theme::Ref) ? @bar_color.resolve(screen.theme) : @bar_color
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Brings the ticker in line with "animating and on screen". The sole writer
|
|
256
|
+
# of `@ticker`, and idempotent, so the attach/detach hooks and
|
|
257
|
+
# {#indeterminate=} are all the same call and a repeated `indeterminate =
|
|
258
|
+
# true` cannot start a second one.
|
|
259
|
+
# @return [void]
|
|
260
|
+
def sync_ticker
|
|
261
|
+
want = attached? && @indeterminate
|
|
262
|
+
return if want == !@ticker.nil?
|
|
263
|
+
|
|
264
|
+
if want
|
|
265
|
+
@ticker = screen.event_queue.tick_fps(INDETERMINATE_FPS) do |tick|
|
|
266
|
+
# The paint that already happened is frame 0; a ticker's first
|
|
267
|
+
# firing is one interval later, so it is frame 1.
|
|
268
|
+
@phase = tick + 1
|
|
269
|
+
invalidate
|
|
270
|
+
end
|
|
271
|
+
else
|
|
272
|
+
@ticker.cancel
|
|
273
|
+
@ticker = nil
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# Single-select from a set of typed items, one row each. Arrows move a
|
|
6
|
+
# cursor; Space, Enter or a left click selects the row under it:
|
|
7
|
+
#
|
|
8
|
+
# (*) Ascending
|
|
9
|
+
# ( ) Descending <- cursor row, highlighted across the full width
|
|
10
|
+
# ( ) Unsorted
|
|
11
|
+
# ^ the composed {List}'s one-column gutter
|
|
12
|
+
#
|
|
13
|
+
# rg = Component::RadioGroup.new(items: %w[Ascending Descending Unsorted])
|
|
14
|
+
# rg.value = "Descending" # or seed it via the ctor
|
|
15
|
+
# rg.on_value_change = ->(order) { resort(order) }
|
|
16
|
+
# rg.value # => "Descending"
|
|
17
|
+
# rg.item_label = ->(o) { o.title } # default :to_s
|
|
18
|
+
#
|
|
19
|
+
# {#value} is **the selected item itself** — of whatever type {#items}
|
|
20
|
+
# holds, never its label. `nil` means nothing is selected: that is the
|
|
21
|
+
# initial state, and assigning it is the only way back, since Space on the
|
|
22
|
+
# already-selected row is a no-op rather than a deselect.
|
|
23
|
+
#
|
|
24
|
+
# Composes rather than subclasses, like {ComboBox}: a {List} is its single
|
|
25
|
+
# {HasContent} child, which is where the cursor, scrolling, the scrollbar
|
|
26
|
+
# and per-row mouse hit-testing come from. `content` is that list, so an app
|
|
27
|
+
# can tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
|
|
28
|
+
# beyond {#rect}'s height scroll; the inner list is the tab stop, not the
|
|
29
|
+
# group.
|
|
30
|
+
#
|
|
31
|
+
# == The cursor is chrome
|
|
32
|
+
# The cursor and the selection are two independent things, as in
|
|
33
|
+
# {CheckboxGroup} — arrows roam without changing {#value}, so a listener
|
|
34
|
+
# that resorts a pane fires once on intent instead of once per row crossed.
|
|
35
|
+
# {#value=} therefore does *not* move the cursor. An app that wants it
|
|
36
|
+
# parked on the selection parks it:
|
|
37
|
+
#
|
|
38
|
+
# rg.content.cursor = List::Cursor.new(position: rg.items.index(rg.value))
|
|
39
|
+
#
|
|
40
|
+
# {#items=} is the one thing that moves it, clamping it back into range.
|
|
41
|
+
#
|
|
42
|
+
# == +items+ is chrome; +value+ is authoritative
|
|
43
|
+
# {#items=} changes only what is *presented*. It never touches {#value} and
|
|
44
|
+
# never fires {HasValue#on_value_change}, and a selected item absent from
|
|
45
|
+
# {#items} renders no marked row while surviving intact — so a form saved
|
|
46
|
+
# without the user editing anything changes nothing silently. Keeping the
|
|
47
|
+
# two in sync is the app's job. Same contract as {ComboBox#value} and
|
|
48
|
+
# {CheckboxGroup#value}.
|
|
49
|
+
#
|
|
50
|
+
# == Implementation details
|
|
51
|
+
# Two `==`-equal items share one selection, so selecting either marks both
|
|
52
|
+
# rows; two *distinct* items that merely render the same label stay
|
|
53
|
+
# independent, because a row resolves to an item by index.
|
|
54
|
+
#
|
|
55
|
+
# Rows are `(*) `/`( ) ` literals, mirroring {Checkbox}'s convention rather
|
|
56
|
+
# than importing constants from it. ASCII deliberately: `(•)` would measure
|
|
57
|
+
# two columns in a terminal configured for East-Asian-Ambiguous glyphs and
|
|
58
|
+
# shift every row's text, which no test would catch.
|
|
59
|
+
#
|
|
60
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
61
|
+
class RadioGroup < Component
|
|
62
|
+
include HasContent
|
|
63
|
+
include HasValue
|
|
64
|
+
|
|
65
|
+
# @param items [Array] the items to present, one row each; also settable
|
|
66
|
+
# via {#items=}.
|
|
67
|
+
# @param value [Object, nil] the initially selected item. Seeds the
|
|
68
|
+
# backing ivar directly, so no listener fires and assignment order
|
|
69
|
+
# doesn't matter to a form helper.
|
|
70
|
+
def initialize(items: [], value: nil)
|
|
71
|
+
super()
|
|
72
|
+
@items = items.to_a
|
|
73
|
+
@item_label = :to_s.to_proc
|
|
74
|
+
@value = value
|
|
75
|
+
@on_value_change = nil
|
|
76
|
+
|
|
77
|
+
list = List.new
|
|
78
|
+
# A List has no cursor at all by default (Cursor::None, position -1).
|
|
79
|
+
list.cursor = List::Cursor.new
|
|
80
|
+
list.on_item_chosen = ->(index, _line) { select_at(index) }
|
|
81
|
+
self.content = list
|
|
82
|
+
rebuild_rows
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Array] the presented items.
|
|
86
|
+
attr_reader :items
|
|
87
|
+
|
|
88
|
+
# @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
|
|
89
|
+
# anything with `#to_s`); `:to_s` by default.
|
|
90
|
+
attr_reader :item_label
|
|
91
|
+
|
|
92
|
+
# Replaces the presented rows, leaving {#value} untouched and clamping the
|
|
93
|
+
# cursor back into range.
|
|
94
|
+
# @param new_items [Array]
|
|
95
|
+
# @raise [TypeError] unless `new_items` is an `Array`.
|
|
96
|
+
# @return [void]
|
|
97
|
+
def items=(new_items)
|
|
98
|
+
raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
|
|
99
|
+
|
|
100
|
+
@items = new_items
|
|
101
|
+
# Before the rebuild, so the single {List#on_cursor_changed} that
|
|
102
|
+
# {List#lines=} fires reports the final row rather than a stale one.
|
|
103
|
+
clamp_cursor
|
|
104
|
+
rebuild_rows
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @param proc [Proc, Method] item -> row label.
|
|
108
|
+
# @return [void]
|
|
109
|
+
def item_label=(proc)
|
|
110
|
+
@item_label = proc
|
|
111
|
+
rebuild_rows
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Selects `new_value`, firing {HasValue#on_value_change} when it really
|
|
115
|
+
# changed. The cursor stays where it is.
|
|
116
|
+
# @param new_value [Object, nil] `nil` selects nothing; an item outside
|
|
117
|
+
# {#items} is kept but renders no marked row.
|
|
118
|
+
# @return [void]
|
|
119
|
+
def value=(new_value)
|
|
120
|
+
# HasValue#value= no-ops on an unchanged value; this guard is what also
|
|
121
|
+
# skips the row rebuild.
|
|
122
|
+
return if value == new_value
|
|
123
|
+
|
|
124
|
+
super
|
|
125
|
+
rebuild_rows
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Selects the cursor row on Space. Nothing else is claimed: the composed
|
|
129
|
+
# {List} — being the focused component — has already had its chance at the
|
|
130
|
+
# key (its arrows, Home/End, PgUp/PgDn, ^U/^D and Enter), and whatever
|
|
131
|
+
# neither of us wants bubbles on to an ancestor.
|
|
132
|
+
# @param key [String]
|
|
133
|
+
# @return [Boolean]
|
|
134
|
+
def handle_key(key)
|
|
135
|
+
return false unless key == " "
|
|
136
|
+
|
|
137
|
+
select_at(content.cursor.position)
|
|
138
|
+
true
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
protected
|
|
142
|
+
|
|
143
|
+
# Places the composed list across the whole rect ({HasContent} hook).
|
|
144
|
+
# @param list [Component]
|
|
145
|
+
# @return [void]
|
|
146
|
+
def layout(list) = (list.rect = rect)
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# Selects the item on row `index`; an index outside {#items} is ignored.
|
|
151
|
+
# @param index [Integer]
|
|
152
|
+
# @return [void]
|
|
153
|
+
def select_at(index)
|
|
154
|
+
return unless index.between?(0, @items.size - 1)
|
|
155
|
+
|
|
156
|
+
self.value = @items[index]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Re-renders every row from the current items, labels and selection.
|
|
160
|
+
# @return [void]
|
|
161
|
+
def rebuild_rows
|
|
162
|
+
content.lines = @items.map do |item|
|
|
163
|
+
StyledString.plain(item == value ? "(*) " : "( ) ") + label_for(item)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Pulls an over-range cursor back onto the last row (row 0 when there are
|
|
168
|
+
# none). {List#lines=} leaves a stale cursor alone, which would strand it
|
|
169
|
+
# off-content: no highlight, a dead Enter, and a Space that resolves to
|
|
170
|
+
# `nil` and silently clears the selection.
|
|
171
|
+
# @return [void]
|
|
172
|
+
def clamp_cursor
|
|
173
|
+
cursor = content.cursor
|
|
174
|
+
# go_to_last funnels through Cursor#go's clamp(0, nil), so an empty
|
|
175
|
+
# items list floors at 0 instead of going negative.
|
|
176
|
+
cursor.go_to_last(@items.size) if cursor.position >= @items.size
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @param item [Object]
|
|
180
|
+
# @return [StyledString, String] whichever {StyledString#+} accepts on the
|
|
181
|
+
# right — so a styled label keeps its spans and a plain one is parsed.
|
|
182
|
+
def label_for(item)
|
|
183
|
+
label = @item_label.call(item)
|
|
184
|
+
label.is_a?(StyledString) ? label : label.to_s
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|