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
|
@@ -9,9 +9,10 @@ module Tuile
|
|
|
9
9
|
# highlight, and reads the pick.
|
|
10
10
|
#
|
|
11
11
|
# drop = Component::ListDropdown.new
|
|
12
|
-
# drop.
|
|
12
|
+
# drop.renderer = method(:label_for) # caller renders
|
|
13
|
+
# drop.on_item_chosen = ->(_index, item) { commit(item) } # caller commits
|
|
13
14
|
# # …then, from the driver's key handler:
|
|
14
|
-
# drop.
|
|
15
|
+
# drop.items = matches # caller filters
|
|
15
16
|
# drop.anchor_to(rect, rows: matches.size) # below the driver, or flipped
|
|
16
17
|
# drop.open
|
|
17
18
|
# return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
|
|
@@ -66,14 +67,20 @@ module Tuile
|
|
|
66
67
|
self.bg_color = Theme.ref(:input_bg_color)
|
|
67
68
|
end
|
|
68
69
|
|
|
69
|
-
# @param
|
|
70
|
+
# @param items [Array] the items to show, one row each; see {List#items=}.
|
|
70
71
|
# @return [void]
|
|
71
|
-
def
|
|
72
|
-
@list.
|
|
72
|
+
def items=(items)
|
|
73
|
+
@list.items = items
|
|
73
74
|
end
|
|
74
75
|
|
|
75
|
-
# @return [Array
|
|
76
|
-
def
|
|
76
|
+
# @return [Array] the items currently shown.
|
|
77
|
+
def items = @list.items
|
|
78
|
+
|
|
79
|
+
# @param proc [Proc, Method] item -> row; see {List#renderer}.
|
|
80
|
+
# @return [void]
|
|
81
|
+
def renderer=(proc)
|
|
82
|
+
@list.renderer = proc
|
|
83
|
+
end
|
|
77
84
|
|
|
78
85
|
# @param proc [Proc, Method, nil] commit callback; see {List#on_item_chosen}.
|
|
79
86
|
# @return [void]
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A transient message in the screen's top-right corner — the TTY toast:
|
|
6
|
+
#
|
|
7
|
+
# Component::Notification.show("Saved")
|
|
8
|
+
# Component::Notification.show("Disk full", color: Theme.ref(:error))
|
|
9
|
+
#
|
|
10
|
+
# ┌─────────┐ ← flush: row 0, right edge at the last column
|
|
11
|
+
# │Saved │ ← oldest on top, retires in 3 s
|
|
12
|
+
# │Disk full│ ← then this one, 3 s after that
|
|
13
|
+
# └─────────┘
|
|
14
|
+
#
|
|
15
|
+
# {show} is the only entry point ({new} is private): it finds the live
|
|
16
|
+
# notification and appends to it, so a burst stacks as entries in one box
|
|
17
|
+
# instead of opening five overlapping ones.
|
|
18
|
+
#
|
|
19
|
+
# One repeating ticker retires the **oldest** entry every {DISPLAY_SECONDS}
|
|
20
|
+
# and closes the box when the last one goes — five messages raised together
|
|
21
|
+
# appear at once and drain over fifteen seconds. A message arriving mid-cycle
|
|
22
|
+
# waits its turn and does *not* restart the clock, so the bottom entry of a
|
|
23
|
+
# full box is visible for about `N × DISPLAY_SECONDS`. Past {MAX_MESSAGES} a
|
|
24
|
+
# message is dropped and reported to {Tuile.logger}; an app notifying faster
|
|
25
|
+
# than that wants a {Component::LogWindow}.
|
|
26
|
+
#
|
|
27
|
+
# The box is flush to the corner, at most {WIDTH_FRACTION} of the screen wide
|
|
28
|
+
# (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
|
|
29
|
+
# shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
|
|
30
|
+
# rows and is then ellipsized, and entries past the height cap wait unpainted.
|
|
31
|
+
# `DECISIONS.md` `D-notification` has why each of those is what it is.
|
|
32
|
+
#
|
|
33
|
+
# Three things it deliberately doesn't do:
|
|
34
|
+
#
|
|
35
|
+
# - **Take focus, or receive keys.** A non-modal popup sits off the
|
|
36
|
+
# key-dispatch scope ({ScreenPane#handle_key}), so not even {Popup}'s
|
|
37
|
+
# `q`/ESC arrives here. A left click dismisses ({#handle_mouse}); an app
|
|
38
|
+
# wanting a key registers a global shortcut and calls {#close}.
|
|
39
|
+
# - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
|
|
40
|
+
# the message is added — a toast lives seconds, so there is no
|
|
41
|
+
# {Component#on_theme_changed} rebuild.
|
|
42
|
+
# - **Take a size.** {#size=} raises; the messages decide.
|
|
43
|
+
class Notification < Popup
|
|
44
|
+
# Most messages held at once, counting both the painted ones and any
|
|
45
|
+
# waiting for room. Chosen from reading time rather than geometry: the
|
|
46
|
+
# drain rate is one message per {DISPLAY_SECONDS}, so the queue length *is*
|
|
47
|
+
# a duration, and 5 × 3 s is about the longest a corner box should own the
|
|
48
|
+
# screen — and about as many short lines as anyone reads.
|
|
49
|
+
# @return [Integer]
|
|
50
|
+
MAX_MESSAGES = 5
|
|
51
|
+
|
|
52
|
+
# Rows a single message may occupy before it is ellipsized.
|
|
53
|
+
# @return [Integer]
|
|
54
|
+
MAX_ROWS_PER_MESSAGE = 3
|
|
55
|
+
|
|
56
|
+
# Seconds between retirements — how long the oldest message is held.
|
|
57
|
+
# @return [Float]
|
|
58
|
+
DISPLAY_SECONDS = 3.0
|
|
59
|
+
|
|
60
|
+
# Fraction of the screen width the box may not exceed (see {MIN_CAP_WIDTH}).
|
|
61
|
+
# @return [Float]
|
|
62
|
+
WIDTH_FRACTION = 0.4
|
|
63
|
+
|
|
64
|
+
# Fraction of the screen height the box may not exceed.
|
|
65
|
+
# @return [Float]
|
|
66
|
+
HEIGHT_FRACTION = 0.4
|
|
67
|
+
|
|
68
|
+
# Floor under the width cap, so 40 % of an 80-column terminal doesn't
|
|
69
|
+
# ellipsize every message down to five words.
|
|
70
|
+
# @return [Integer]
|
|
71
|
+
MIN_CAP_WIDTH = 34
|
|
72
|
+
|
|
73
|
+
# Separator for re-joining wrapped rows before ellipsizing.
|
|
74
|
+
# @return [StyledString]
|
|
75
|
+
SPACE = StyledString.parse(" ")
|
|
76
|
+
|
|
77
|
+
# Hard-line separator handed to {TextView#text=}.
|
|
78
|
+
# @return [StyledString]
|
|
79
|
+
ROW_BREAK = StyledString.parse("\n")
|
|
80
|
+
private_constant :SPACE, :ROW_BREAK
|
|
81
|
+
|
|
82
|
+
# Shows `text` in the corner, creating the box if none is open and
|
|
83
|
+
# appending to it if one is.
|
|
84
|
+
#
|
|
85
|
+
# @param text [String, StyledString, nil] the message. A `String` is parsed
|
|
86
|
+
# via {StyledString.parse}, so embedded ANSI is honored. `nil` and the
|
|
87
|
+
# empty string are no-ops (nothing is shown, nothing is created).
|
|
88
|
+
# @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
|
|
89
|
+
# applied to every span of the message via {StyledString#with_fg}. A
|
|
90
|
+
# {Theme::Ref} is resolved against the current theme *now* — see the
|
|
91
|
+
# class docs on theme following. `nil` leaves the message's own colors
|
|
92
|
+
# alone.
|
|
93
|
+
# @return [Notification, nil] the live notification, or `nil` when `text`
|
|
94
|
+
# was empty.
|
|
95
|
+
# @raise [Tuile::Error] when the screen is closed, or when called from a
|
|
96
|
+
# thread that doesn't currently own the UI — a background job raising a
|
|
97
|
+
# notification must marshal it: `screen.event_queue.submit { ... }`.
|
|
98
|
+
def self.show(text, color: nil)
|
|
99
|
+
Screen.instance.check_locked
|
|
100
|
+
return nil if StyledString.parse(text).empty?
|
|
101
|
+
|
|
102
|
+
live = Screen.instance.pane.popups.find { _1.is_a?(Notification) }
|
|
103
|
+
return live.tap { _1.add_message(text, color: color) } unless live.nil?
|
|
104
|
+
|
|
105
|
+
# Message first, so the box is sized before it is mounted: opening an
|
|
106
|
+
# empty 0×0 popup and then growing it would paint a frame of nothing.
|
|
107
|
+
new.tap do |notification|
|
|
108
|
+
notification.add_message(text, color: color)
|
|
109
|
+
notification.open
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private_class_method :new
|
|
114
|
+
|
|
115
|
+
def initialize
|
|
116
|
+
# Built before `super`, because Popup#initialize assigns the content and
|
|
117
|
+
# calls #reposition, and our override reads every one of these.
|
|
118
|
+
@messages = []
|
|
119
|
+
@high_water = 0
|
|
120
|
+
@ticker = nil
|
|
121
|
+
@view = TextView.new
|
|
122
|
+
@window = Window.new
|
|
123
|
+
@window.content = @view
|
|
124
|
+
super(content: @window, modal: false)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
|
|
128
|
+
# outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
|
|
129
|
+
# nobody — every keystroke would go dead until the user pressed Tab.
|
|
130
|
+
# @return [Boolean] false.
|
|
131
|
+
def focusable? = false
|
|
132
|
+
|
|
133
|
+
# @return [Boolean] false — see {#focusable?}.
|
|
134
|
+
def tab_stop? = false
|
|
135
|
+
|
|
136
|
+
# Empty: a non-modal popup never owns the status bar, and {Popup}'s
|
|
137
|
+
# inherited `q Close` hint would be a lie here — no key ever reaches a
|
|
138
|
+
# notification.
|
|
139
|
+
# @return [String]
|
|
140
|
+
def keyboard_hint = ""
|
|
141
|
+
|
|
142
|
+
# Appends a message, dropping it (with a {Tuile.logger} warning) once
|
|
143
|
+
# {MAX_MESSAGES} are held. Public so a caller holding the instance can
|
|
144
|
+
# append without repeating {show}'s lookup.
|
|
145
|
+
# @param text [String, StyledString, nil] see {show}. Empty is a no-op.
|
|
146
|
+
# @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
|
|
147
|
+
# see {show}.
|
|
148
|
+
# @return [void]
|
|
149
|
+
# @raise [Tuile::Error] see {show}.
|
|
150
|
+
def add_message(text, color: nil)
|
|
151
|
+
# Explicit rather than inherited-through-invalidate: this appends to
|
|
152
|
+
# @messages before anything repaints, so a wrong-thread call has to fail
|
|
153
|
+
# before the message is recorded, not after.
|
|
154
|
+
screen.check_locked
|
|
155
|
+
message = build_message(text, color)
|
|
156
|
+
return if message.empty?
|
|
157
|
+
|
|
158
|
+
if @messages.size >= MAX_MESSAGES
|
|
159
|
+
Tuile.logger.warn("Notification: dropping #{message.to_s.inspect}, " \
|
|
160
|
+
"#{MAX_MESSAGES} messages already queued")
|
|
161
|
+
return
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
@messages << message
|
|
165
|
+
@high_water = [@high_water, natural_width(message)].max
|
|
166
|
+
reposition
|
|
167
|
+
sync_ticker
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Recomputes the box from its messages and re-anchors it to the screen's
|
|
171
|
+
# top-right corner — so a SIGWINCH re-wraps and re-anchors, where
|
|
172
|
+
# {Popup#reposition} would have kept the stale left column of a *derived*
|
|
173
|
+
# position (off-screen entirely if the terminal narrowed).
|
|
174
|
+
#
|
|
175
|
+
# Rebuilds the {TextView}'s text too, and every mutation routes through
|
|
176
|
+
# here, because the four are one computation: the wrap width *is* the box
|
|
177
|
+
# width, the height *is* the wrapped row count, the left edge *is* derived
|
|
178
|
+
# from the width.
|
|
179
|
+
# @return [void]
|
|
180
|
+
def reposition
|
|
181
|
+
if @messages.empty?
|
|
182
|
+
self.rect = Rect.new(0, 0, 0, 0)
|
|
183
|
+
return
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
width = box_width
|
|
187
|
+
rows = @messages.flat_map { |message| wrap_message(message, width - 2) }
|
|
188
|
+
height = [rows.size + 2, cap_height].min
|
|
189
|
+
@size = Size.new(width, height)
|
|
190
|
+
@view.text = join_rows(rows)
|
|
191
|
+
self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A notification is sized by its messages, so this always raises. Failing
|
|
195
|
+
# loudly beats accepting a size the next {#reposition} would discard.
|
|
196
|
+
# @param _new_size [Size, Fraction]
|
|
197
|
+
# @raise [Tuile::Error] always.
|
|
198
|
+
# @return [void]
|
|
199
|
+
def size=(_new_size)
|
|
200
|
+
raise Tuile::Error, "Notification sizes itself from its messages; #{self.class}#size= is not settable"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# A left click dismisses the whole box, every message with it. Other buttons
|
|
204
|
+
# are consumed and inert — including the scroll wheel, which would otherwise
|
|
205
|
+
# nuke the box on a stray spin.
|
|
206
|
+
#
|
|
207
|
+
# Deliberately *replaces* rather than augments: neither `super` nor
|
|
208
|
+
# {HasContent#handle_mouse} may run, since both end at a
|
|
209
|
+
# `screen.focused = …` inside this subtree (see {#focusable?}).
|
|
210
|
+
# @param event [MouseEvent]
|
|
211
|
+
# @return [void]
|
|
212
|
+
def handle_mouse(event)
|
|
213
|
+
close if event.button == :left
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @return [void]
|
|
217
|
+
def on_attached = sync_ticker
|
|
218
|
+
|
|
219
|
+
# @return [void]
|
|
220
|
+
def on_detached = sync_ticker
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
# Retires the oldest message, closing the box when it was the last. Runs on
|
|
225
|
+
# the event-loop thread, from the ticker.
|
|
226
|
+
# @return [void]
|
|
227
|
+
def retire_oldest
|
|
228
|
+
@messages.shift
|
|
229
|
+
@messages.empty? ? close : reposition
|
|
230
|
+
sync_ticker
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Syncs the retirement clock from the invariant "something to retire, and on
|
|
234
|
+
# screen" — the sole writer of `@ticker`. Four sites change whether it is
|
|
235
|
+
# wanted (append, a retirement that empties the queue, {#close}, detach),
|
|
236
|
+
# which is the 2×2 a start-in-{#on_attached} / cancel-in-{#on_detached} pair
|
|
237
|
+
# gets half wrong. The early return is also what keeps an append from
|
|
238
|
+
# *restarting* the clock and extending the oldest message's life.
|
|
239
|
+
# @return [void]
|
|
240
|
+
def sync_ticker
|
|
241
|
+
want = attached? && !@messages.empty?
|
|
242
|
+
return if want == !@ticker.nil?
|
|
243
|
+
|
|
244
|
+
if want
|
|
245
|
+
@ticker = screen.event_queue.tick(DISPLAY_SECONDS) { retire_oldest }
|
|
246
|
+
else
|
|
247
|
+
@ticker.cancel
|
|
248
|
+
@ticker = nil
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# @param text [String, StyledString, nil]
|
|
253
|
+
# @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
|
|
254
|
+
# @return [StyledString]
|
|
255
|
+
def build_message(text, color)
|
|
256
|
+
message = StyledString.parse(text)
|
|
257
|
+
return message if color.nil? || message.empty?
|
|
258
|
+
|
|
259
|
+
message.with_fg(color.is_a?(Theme::Ref) ? color.resolve(screen.theme) : color)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Columns the message would like, ignoring wrapping — the *widest* of its
|
|
263
|
+
# hard lines, not the sum of its spans (which would add every line
|
|
264
|
+
# together for a message carrying `\n`).
|
|
265
|
+
# @param message [StyledString]
|
|
266
|
+
# @return [Integer]
|
|
267
|
+
def natural_width(message) = message.lines.map(&:display_width).max || 0
|
|
268
|
+
|
|
269
|
+
# Grow-only: the high-water mark is kept in *desired* columns and the cap
|
|
270
|
+
# is applied here, last. Storing the clamped value instead would let a
|
|
271
|
+
# SIGWINCH that narrows the terminal ratchet the box permanently down to
|
|
272
|
+
# the narrow cap, with nothing to restore it when the terminal widens.
|
|
273
|
+
# @return [Integer]
|
|
274
|
+
def box_width = [@high_water + 2, cap_width].min
|
|
275
|
+
|
|
276
|
+
# @return [Integer]
|
|
277
|
+
def cap_width
|
|
278
|
+
[[(screen.size.width * WIDTH_FRACTION).to_i, MIN_CAP_WIDTH].max, screen.size.width].min
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# @return [Integer] at least 3: two border rows plus one row of message.
|
|
282
|
+
def cap_height
|
|
283
|
+
[[(screen.size.height * HEIGHT_FRACTION).to_i, 3].max, screen.size.height].min
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Wraps one message to `width` columns, capped at {MAX_ROWS_PER_MESSAGE}
|
|
287
|
+
# rows.
|
|
288
|
+
#
|
|
289
|
+
# The overflow is ellipsized from the *joined remainder*, not by
|
|
290
|
+
# ellipsizing the last kept row: that row usually already fits `width`, so
|
|
291
|
+
# {StyledString#ellipsize} would be a no-op and the message would be
|
|
292
|
+
# truncated with no `…` to say so.
|
|
293
|
+
# @param message [StyledString]
|
|
294
|
+
# @param width [Integer]
|
|
295
|
+
# @return [Array<StyledString>]
|
|
296
|
+
def wrap_message(message, width)
|
|
297
|
+
rows = message.wrap(width)
|
|
298
|
+
return rows if rows.size <= MAX_ROWS_PER_MESSAGE
|
|
299
|
+
|
|
300
|
+
kept = rows.take(MAX_ROWS_PER_MESSAGE - 1)
|
|
301
|
+
rest = rows[(MAX_ROWS_PER_MESSAGE - 1)..].inject { |joined, row| joined + SPACE + row }
|
|
302
|
+
kept + [rest.ellipsize(width)]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Joins pre-wrapped rows into one {StyledString} with `\n` separators, so
|
|
306
|
+
# {TextView} takes them as hard lines and its own wrap is a no-op over
|
|
307
|
+
# them (each row already fits the width it will be painted at).
|
|
308
|
+
# @param rows [Array<StyledString>]
|
|
309
|
+
# @return [StyledString]
|
|
310
|
+
def join_rows(rows)
|
|
311
|
+
return StyledString::EMPTY if rows.empty?
|
|
312
|
+
|
|
313
|
+
rows.inject { |joined, row| joined + ROW_BREAK + row }
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
@@ -37,9 +37,10 @@ module Tuile
|
|
|
37
37
|
@options = options.map { Option.new(_1[0], _1[1]) }
|
|
38
38
|
@block = block
|
|
39
39
|
list = Component::List.new
|
|
40
|
-
list.
|
|
40
|
+
list.renderer = ->(option) { "#{option.key} #{screen.theme.hint(option.caption)}" }
|
|
41
|
+
list.items = @options
|
|
41
42
|
list.cursor = Component::List::Cursor.new
|
|
42
|
-
list.on_item_chosen = ->(
|
|
43
|
+
list.on_item_chosen = ->(_index, option) { select_option(option.key) }
|
|
43
44
|
self.content = list
|
|
44
45
|
# Optional hook for a containing Popup to dismiss itself after a pick.
|
|
45
46
|
@on_pick = nil
|
|
@@ -83,7 +84,6 @@ module Tuile
|
|
|
83
84
|
popup = Popup.new(content: picker)
|
|
84
85
|
picker.on_pick = -> { popup.close }
|
|
85
86
|
popup.open
|
|
86
|
-
popup
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
protected
|
|
@@ -98,19 +98,17 @@ module Tuile
|
|
|
98
98
|
|
|
99
99
|
# Mounts this popup on the {Screen}, re-resolving its {#size} against the
|
|
100
100
|
# current screen first.
|
|
101
|
-
#
|
|
101
|
+
#
|
|
102
|
+
# popup = Component::Popup.new(content: window).open # construct and mount
|
|
103
|
+
#
|
|
104
|
+
# There is deliberately no class-level `Popup.open` factory — see
|
|
105
|
+
# `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
|
|
106
|
+
# one-liner above available without one.
|
|
107
|
+
# @return [self]
|
|
102
108
|
def open
|
|
103
109
|
reposition
|
|
104
110
|
screen.add_popup(self)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
# Constructs and opens a popup in one call.
|
|
108
|
-
# @param content [Component, nil]
|
|
109
|
-
# @param modal [Boolean] see {#initialize}.
|
|
110
|
-
# @param size [Size, Fraction] see {#initialize}.
|
|
111
|
-
# @return [Popup] the opened popup.
|
|
112
|
-
def self.open(content: nil, modal: true, size: Fraction::HALF)
|
|
113
|
-
Popup.new(content: content, modal: modal, size: size).tap(&:open)
|
|
111
|
+
self
|
|
114
112
|
end
|
|
115
113
|
|
|
116
114
|
# Removes this popup from the {Screen}. No-op if not currently open.
|
|
@@ -207,7 +207,7 @@ module Tuile
|
|
|
207
207
|
def repaint
|
|
208
208
|
return if rect.empty?
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
draw_text(rect.left, rect.top, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
|
|
211
211
|
clear_background(Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)) if rect.height > 1
|
|
212
212
|
end
|
|
213
213
|
|
|
@@ -21,12 +21,13 @@ module Tuile
|
|
|
21
21
|
# initial state, and assigning it is the only way back, since Space on the
|
|
22
22
|
# already-selected row is a no-op rather than a deselect.
|
|
23
23
|
#
|
|
24
|
-
# Composes rather than subclasses, like {ComboBox}: a {List}
|
|
25
|
-
# {HasContent} child, which is where the cursor, scrolling,
|
|
26
|
-
# and per-row mouse hit-testing come from
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
24
|
+
# Composes rather than subclasses, like {ComboBox}: a {List} of the items
|
|
25
|
+
# is its single {HasContent} child, which is where the cursor, scrolling,
|
|
26
|
+
# the scrollbar and per-row mouse hit-testing come from — the group only
|
|
27
|
+
# supplies the {List#renderer} that puts the marker in front of the label.
|
|
28
|
+
# `content` is that list, so an app can tune it (`scrollbar_visibility`,
|
|
29
|
+
# `show_cursor_when_inactive`, …). Rows beyond {#rect}'s height scroll; the
|
|
30
|
+
# inner list is the tab stop, not the group.
|
|
30
31
|
#
|
|
31
32
|
# == The cursor is chrome
|
|
32
33
|
# The cursor and the selection are two independent things, as in
|
|
@@ -50,7 +51,7 @@ module Tuile
|
|
|
50
51
|
# == Implementation details
|
|
51
52
|
# Two `==`-equal items share one selection, so selecting either marks both
|
|
52
53
|
# rows; two *distinct* items that merely render the same label stay
|
|
53
|
-
# independent, because a row resolves to
|
|
54
|
+
# independent, because a row resolves to its own item, never to its label.
|
|
54
55
|
#
|
|
55
56
|
# Rows are `(*) `/`( ) ` literals, mirroring {Checkbox}'s convention rather
|
|
56
57
|
# than importing constants from it. ASCII deliberately: `(•)` would measure
|
|
@@ -69,7 +70,6 @@ module Tuile
|
|
|
69
70
|
# doesn't matter to a form helper.
|
|
70
71
|
def initialize(items: [], value: nil)
|
|
71
72
|
super()
|
|
72
|
-
@items = items.to_a
|
|
73
73
|
@item_label = :to_s.to_proc
|
|
74
74
|
@value = value
|
|
75
75
|
@on_value_change = nil
|
|
@@ -77,13 +77,14 @@ module Tuile
|
|
|
77
77
|
list = List.new
|
|
78
78
|
# A List has no cursor at all by default (Cursor::None, position -1).
|
|
79
79
|
list.cursor = List::Cursor.new
|
|
80
|
-
list.
|
|
80
|
+
list.renderer = method(:render_row)
|
|
81
|
+
list.on_item_chosen = ->(_index, item) { self.value = item }
|
|
82
|
+
list.items = items.to_a
|
|
81
83
|
self.content = list
|
|
82
|
-
rebuild_rows
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
# @return [Array] the presented items.
|
|
86
|
-
|
|
87
|
+
def items = content.items
|
|
87
88
|
|
|
88
89
|
# @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
|
|
89
90
|
# anything with `#to_s`); `:to_s` by default.
|
|
@@ -97,18 +98,17 @@ module Tuile
|
|
|
97
98
|
def items=(new_items)
|
|
98
99
|
raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
rebuild_rows
|
|
101
|
+
# Before the items land, so the single {List#on_cursor_changed} that
|
|
102
|
+
# {List#items=} fires reports the final row rather than a stale one.
|
|
103
|
+
clamp_cursor(new_items.size)
|
|
104
|
+
content.items = new_items
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
# @param proc [Proc, Method] item -> row label.
|
|
108
108
|
# @return [void]
|
|
109
109
|
def item_label=(proc)
|
|
110
110
|
@item_label = proc
|
|
111
|
-
|
|
111
|
+
content.refresh_rows
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
# Selects `new_value`, firing {HasValue#on_value_change} when it really
|
|
@@ -122,7 +122,7 @@ module Tuile
|
|
|
122
122
|
return if value == new_value
|
|
123
123
|
|
|
124
124
|
super
|
|
125
|
-
|
|
125
|
+
content.refresh_rows
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
# Selects the cursor row on Space. Nothing else is claimed: the composed
|
|
@@ -147,33 +147,35 @@ module Tuile
|
|
|
147
147
|
|
|
148
148
|
private
|
|
149
149
|
|
|
150
|
-
# Selects the item on row `index`; an index outside {#items} is ignored
|
|
150
|
+
# Selects the item on row `index`; an index outside {#items} is ignored —
|
|
151
|
+
# {List::Cursor::None}'s `-1` would otherwise select the *last* item.
|
|
151
152
|
# @param index [Integer]
|
|
152
153
|
# @return [void]
|
|
153
154
|
def select_at(index)
|
|
154
|
-
return unless index.between?(0,
|
|
155
|
+
return unless index.between?(0, items.size - 1)
|
|
155
156
|
|
|
156
|
-
self.value =
|
|
157
|
+
self.value = items[index]
|
|
157
158
|
end
|
|
158
159
|
|
|
159
|
-
#
|
|
160
|
-
# @return [
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
# @param item [Object]
|
|
161
|
+
# @return [StyledString] the item's row: its label behind a selection
|
|
162
|
+
# marker. The {List} calls this at paint time, so the marker tracks
|
|
163
|
+
# {#value} without re-rendering anything but the visible rows.
|
|
164
|
+
def render_row(item)
|
|
165
|
+
StyledString.plain(item == value ? "(*) " : "( ) ") + label_for(item)
|
|
165
166
|
end
|
|
166
167
|
|
|
167
168
|
# Pulls an over-range cursor back onto the last row (row 0 when there are
|
|
168
|
-
# none). {List#
|
|
169
|
+
# none). {List#items=} leaves a stale cursor alone, which would strand it
|
|
169
170
|
# off-content: no highlight, a dead Enter, and a Space that resolves to
|
|
170
171
|
# `nil` and silently clears the selection.
|
|
172
|
+
# @param item_count [Integer] size of the incoming item list.
|
|
171
173
|
# @return [void]
|
|
172
|
-
def clamp_cursor
|
|
174
|
+
def clamp_cursor(item_count)
|
|
173
175
|
cursor = content.cursor
|
|
174
176
|
# go_to_last funnels through Cursor#go's clamp(0, nil), so an empty
|
|
175
177
|
# items list floors at 0 instead of going negative.
|
|
176
|
-
cursor.go_to_last(
|
|
178
|
+
cursor.go_to_last(item_count) if cursor.position >= item_count
|
|
177
179
|
end
|
|
178
180
|
|
|
179
181
|
# @param item [Object]
|
|
@@ -72,7 +72,8 @@ module Tuile
|
|
|
72
72
|
@value = value
|
|
73
73
|
@on_value_change = nil
|
|
74
74
|
@overlay = ListDropdown.new
|
|
75
|
-
@overlay.
|
|
75
|
+
@overlay.renderer = method(:label_for)
|
|
76
|
+
@overlay.on_item_chosen = ->(_index, item) { commit(item) }
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
# @return [Array] the options.
|
|
@@ -171,7 +172,7 @@ module Tuile
|
|
|
171
172
|
|
|
172
173
|
tail = Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)
|
|
173
174
|
clear_background(tail) unless tail.empty?
|
|
174
|
-
|
|
175
|
+
draw_text(rect.left, rect.top, face_row)
|
|
175
176
|
end
|
|
176
177
|
|
|
177
178
|
private
|
|
@@ -196,7 +197,7 @@ module Tuile
|
|
|
196
197
|
return
|
|
197
198
|
end
|
|
198
199
|
|
|
199
|
-
@overlay.
|
|
200
|
+
@overlay.items = @items
|
|
200
201
|
@overlay.cursor = List::Cursor.new(position: @items.index(value) || 0)
|
|
201
202
|
@overlay.open unless @overlay.open?
|
|
202
203
|
anchor
|
|
@@ -208,11 +209,10 @@ module Tuile
|
|
|
208
209
|
# @return [void]
|
|
209
210
|
def close_menu = (@overlay.close if @overlay.open?)
|
|
210
211
|
|
|
211
|
-
# Adopts the item
|
|
212
|
-
# @param
|
|
212
|
+
# Adopts the chosen item as {#value} and closes the dropdown.
|
|
213
|
+
# @param item [Object]
|
|
213
214
|
# @return [void]
|
|
214
|
-
def commit(
|
|
215
|
-
item = @items[index]
|
|
215
|
+
def commit(item)
|
|
216
216
|
close_menu
|
|
217
217
|
self.value = item
|
|
218
218
|
end
|