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
|
@@ -0,0 +1,320 @@
|
|
|
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 *on the box* dismisses
|
|
38
|
+
# ({#handle_mouse}); an app wanting a key registers a global shortcut and
|
|
39
|
+
# calls {#close}. A click *elsewhere* does not — this is the one popup
|
|
40
|
+
# with {Popup#close_on_outside_click?} false, since a toast is timed and
|
|
41
|
+
# an unrelated click is not about it.
|
|
42
|
+
# - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
|
|
43
|
+
# the message is added — a toast lives seconds, so there is no
|
|
44
|
+
# {Component#on_theme_changed} rebuild.
|
|
45
|
+
# - **Take a size.** {#size=} raises; the messages decide.
|
|
46
|
+
class Notification < Popup
|
|
47
|
+
# Most messages held at once, counting both the painted ones and any
|
|
48
|
+
# waiting for room. Chosen from reading time rather than geometry: the
|
|
49
|
+
# drain rate is one message per {DISPLAY_SECONDS}, so the queue length *is*
|
|
50
|
+
# a duration, and 5 × 3 s is about the longest a corner box should own the
|
|
51
|
+
# screen — and about as many short lines as anyone reads.
|
|
52
|
+
# @return [Integer]
|
|
53
|
+
MAX_MESSAGES = 5
|
|
54
|
+
|
|
55
|
+
# Rows a single message may occupy before it is ellipsized.
|
|
56
|
+
# @return [Integer]
|
|
57
|
+
MAX_ROWS_PER_MESSAGE = 3
|
|
58
|
+
|
|
59
|
+
# Seconds between retirements — how long the oldest message is held.
|
|
60
|
+
# @return [Float]
|
|
61
|
+
DISPLAY_SECONDS = 3.0
|
|
62
|
+
|
|
63
|
+
# Fraction of the screen width the box may not exceed (see {MIN_CAP_WIDTH}).
|
|
64
|
+
# @return [Float]
|
|
65
|
+
WIDTH_FRACTION = 0.4
|
|
66
|
+
|
|
67
|
+
# Fraction of the screen height the box may not exceed.
|
|
68
|
+
# @return [Float]
|
|
69
|
+
HEIGHT_FRACTION = 0.4
|
|
70
|
+
|
|
71
|
+
# Floor under the width cap, so 40 % of an 80-column terminal doesn't
|
|
72
|
+
# ellipsize every message down to five words.
|
|
73
|
+
# @return [Integer]
|
|
74
|
+
MIN_CAP_WIDTH = 34
|
|
75
|
+
|
|
76
|
+
# Separator for re-joining wrapped rows before ellipsizing.
|
|
77
|
+
# @return [StyledString]
|
|
78
|
+
SPACE = StyledString.parse(" ")
|
|
79
|
+
|
|
80
|
+
# Hard-line separator handed to {TextView#text=}.
|
|
81
|
+
# @return [StyledString]
|
|
82
|
+
ROW_BREAK = StyledString.parse("\n")
|
|
83
|
+
private_constant :SPACE, :ROW_BREAK
|
|
84
|
+
|
|
85
|
+
# Shows `text` in the corner, creating the box if none is open and
|
|
86
|
+
# appending to it if one is.
|
|
87
|
+
#
|
|
88
|
+
# @param text [String, StyledString, nil] the message. A `String` is parsed
|
|
89
|
+
# via {StyledString.parse}, so embedded ANSI is honored. `nil` and the
|
|
90
|
+
# empty string are no-ops (nothing is shown, nothing is created).
|
|
91
|
+
# @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
|
|
92
|
+
# applied to every span of the message via {StyledString#with_fg}. A
|
|
93
|
+
# {Theme::Ref} is resolved against the current theme *now* — see the
|
|
94
|
+
# class docs on theme following. `nil` leaves the message's own colors
|
|
95
|
+
# alone.
|
|
96
|
+
# @return [Notification, nil] the live notification, or `nil` when `text`
|
|
97
|
+
# was empty.
|
|
98
|
+
# @raise [Tuile::Error] when the screen is closed, or when called from a
|
|
99
|
+
# thread that doesn't currently own the UI — a background job raising a
|
|
100
|
+
# notification must marshal it: `screen.event_queue.submit { ... }`.
|
|
101
|
+
def self.show(text, color: nil)
|
|
102
|
+
Screen.instance.check_locked
|
|
103
|
+
return nil if StyledString.parse(text).empty?
|
|
104
|
+
|
|
105
|
+
live = Screen.instance.pane.popups.find { _1.is_a?(Notification) }
|
|
106
|
+
return live.tap { _1.add_message(text, color: color) } unless live.nil?
|
|
107
|
+
|
|
108
|
+
# Message first, so the box is sized before it is mounted: opening an
|
|
109
|
+
# empty 0×0 popup and then growing it would paint a frame of nothing.
|
|
110
|
+
new.tap do |notification|
|
|
111
|
+
notification.add_message(text, color: color)
|
|
112
|
+
notification.open
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private_class_method :new
|
|
117
|
+
|
|
118
|
+
def initialize
|
|
119
|
+
# Built before `super`, because Popup#initialize assigns the content and
|
|
120
|
+
# calls #reposition, and our override reads every one of these.
|
|
121
|
+
@messages = []
|
|
122
|
+
@high_water = 0
|
|
123
|
+
@ticker = nil
|
|
124
|
+
@view = TextView.new
|
|
125
|
+
@window = Window.new
|
|
126
|
+
@window.content = @view
|
|
127
|
+
super(content: @window, modal: false, close_on_outside_click: false)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
|
|
131
|
+
# outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
|
|
132
|
+
# nobody — every keystroke would go dead until the user pressed Tab.
|
|
133
|
+
# @return [Boolean] false.
|
|
134
|
+
def focusable? = false
|
|
135
|
+
|
|
136
|
+
# @return [Boolean] false — see {#focusable?}.
|
|
137
|
+
def tab_stop? = false
|
|
138
|
+
|
|
139
|
+
# Appends a message, dropping it (with a {Tuile.logger} warning) once
|
|
140
|
+
# {MAX_MESSAGES} are held. Public so a caller holding the instance can
|
|
141
|
+
# append without repeating {show}'s lookup.
|
|
142
|
+
# @param text [String, StyledString, nil] see {show}. Empty is a no-op.
|
|
143
|
+
# @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
|
|
144
|
+
# see {show}.
|
|
145
|
+
# @return [void]
|
|
146
|
+
# @raise [Tuile::Error] see {show}.
|
|
147
|
+
def add_message(text, color: nil)
|
|
148
|
+
# Explicit rather than inherited-through-invalidate: this appends to
|
|
149
|
+
# @messages before anything repaints, so a wrong-thread call has to fail
|
|
150
|
+
# before the message is recorded, not after.
|
|
151
|
+
screen.check_locked
|
|
152
|
+
message = build_message(text, color)
|
|
153
|
+
return if message.empty?
|
|
154
|
+
|
|
155
|
+
if @messages.size >= MAX_MESSAGES
|
|
156
|
+
Tuile.logger.warn("Notification: dropping #{message.to_s.inspect}, " \
|
|
157
|
+
"#{MAX_MESSAGES} messages already queued")
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
@messages << message
|
|
162
|
+
@high_water = [@high_water, natural_width(message)].max
|
|
163
|
+
reposition
|
|
164
|
+
sync_ticker
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Recomputes the box from its messages and re-anchors it to the screen's
|
|
168
|
+
# top-right corner — so a SIGWINCH re-wraps and re-anchors, where
|
|
169
|
+
# {Popup#reposition} would have kept the stale left column of a *derived*
|
|
170
|
+
# position (off-screen entirely if the terminal narrowed).
|
|
171
|
+
#
|
|
172
|
+
# Rebuilds the {TextView}'s text too, and every mutation routes through
|
|
173
|
+
# here, because the four are one computation: the wrap width *is* the box
|
|
174
|
+
# width, the height *is* the wrapped row count, the left edge *is* derived
|
|
175
|
+
# from the width.
|
|
176
|
+
# @return [void]
|
|
177
|
+
def reposition
|
|
178
|
+
if @messages.empty?
|
|
179
|
+
self.rect = Rect.new(0, 0, 0, 0)
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
width = box_width
|
|
184
|
+
rows = @messages.flat_map { |message| wrap_message(message, width - 2) }
|
|
185
|
+
height = [rows.size + 2, cap_height].min
|
|
186
|
+
@size = Size.new(width, height)
|
|
187
|
+
@view.text = join_rows(rows)
|
|
188
|
+
self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# A notification is sized by its messages, so this always raises. Failing
|
|
192
|
+
# loudly beats accepting a size the next {#reposition} would discard.
|
|
193
|
+
# @param _new_size [Size, Fraction]
|
|
194
|
+
# @raise [Tuile::Error] always.
|
|
195
|
+
# @return [void]
|
|
196
|
+
def size=(_new_size)
|
|
197
|
+
raise Tuile::Error, "Notification sizes itself from its messages; #{self.class}#size= is not settable"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# A left click dismisses the whole box, every message with it. Other buttons
|
|
201
|
+
# are consumed and inert — including the scroll wheel, which would otherwise
|
|
202
|
+
# nuke the box on a stray spin.
|
|
203
|
+
#
|
|
204
|
+
# Deliberately *replaces* rather than augments: neither `super` nor
|
|
205
|
+
# {HasContent#handle_mouse} may run, since both end at a
|
|
206
|
+
# `screen.focused = …` inside this subtree (see {#focusable?}).
|
|
207
|
+
# @param event [MouseEvent]
|
|
208
|
+
# @return [void]
|
|
209
|
+
def handle_mouse(event)
|
|
210
|
+
close if event.button == :left
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @return [void]
|
|
214
|
+
def on_attached
|
|
215
|
+
super
|
|
216
|
+
sync_ticker
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @return [void]
|
|
220
|
+
def on_detached
|
|
221
|
+
super
|
|
222
|
+
sync_ticker
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
# Retires the oldest message, closing the box when it was the last. Runs on
|
|
228
|
+
# the event-loop thread, from the ticker.
|
|
229
|
+
# @return [void]
|
|
230
|
+
def retire_oldest
|
|
231
|
+
@messages.shift
|
|
232
|
+
@messages.empty? ? close : reposition
|
|
233
|
+
sync_ticker
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Syncs the retirement clock from the invariant "something to retire, and on
|
|
237
|
+
# screen" — the sole writer of `@ticker`. Four sites change whether it is
|
|
238
|
+
# wanted (append, a retirement that empties the queue, {#close}, detach),
|
|
239
|
+
# which is the 2×2 a start-in-{#on_attached} / cancel-in-{#on_detached} pair
|
|
240
|
+
# gets half wrong. The early return is also what keeps an append from
|
|
241
|
+
# *restarting* the clock and extending the oldest message's life.
|
|
242
|
+
# @return [void]
|
|
243
|
+
def sync_ticker
|
|
244
|
+
want = attached? && !@messages.empty?
|
|
245
|
+
return if want == !@ticker.nil?
|
|
246
|
+
|
|
247
|
+
if want
|
|
248
|
+
@ticker = screen.event_queue.tick(DISPLAY_SECONDS) { retire_oldest }
|
|
249
|
+
else
|
|
250
|
+
@ticker.cancel
|
|
251
|
+
@ticker = nil
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# @param text [String, StyledString, nil]
|
|
256
|
+
# @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
|
|
257
|
+
# @return [StyledString]
|
|
258
|
+
def build_message(text, color)
|
|
259
|
+
message = StyledString.parse(text)
|
|
260
|
+
return message if color.nil? || message.empty?
|
|
261
|
+
|
|
262
|
+
message.with_fg(color.is_a?(Theme::Ref) ? color.resolve(screen.theme) : color)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Columns the message would like, ignoring wrapping — the *widest* of its
|
|
266
|
+
# hard lines, not the sum of its spans (which would add every line
|
|
267
|
+
# together for a message carrying `\n`).
|
|
268
|
+
# @param message [StyledString]
|
|
269
|
+
# @return [Integer]
|
|
270
|
+
def natural_width(message) = message.lines.map(&:display_width).max || 0
|
|
271
|
+
|
|
272
|
+
# Grow-only: the high-water mark is kept in *desired* columns and the cap
|
|
273
|
+
# is applied here, last. Storing the clamped value instead would let a
|
|
274
|
+
# SIGWINCH that narrows the terminal ratchet the box permanently down to
|
|
275
|
+
# the narrow cap, with nothing to restore it when the terminal widens.
|
|
276
|
+
# @return [Integer]
|
|
277
|
+
def box_width = [@high_water + 2, cap_width].min
|
|
278
|
+
|
|
279
|
+
# @return [Integer]
|
|
280
|
+
def cap_width
|
|
281
|
+
[[(screen.size.width * WIDTH_FRACTION).to_i, MIN_CAP_WIDTH].max, screen.size.width].min
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# @return [Integer] at least 3: two border rows plus one row of message.
|
|
285
|
+
def cap_height
|
|
286
|
+
[[(screen.size.height * HEIGHT_FRACTION).to_i, 3].max, screen.size.height].min
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Wraps one message to `width` columns, capped at {MAX_ROWS_PER_MESSAGE}
|
|
290
|
+
# rows.
|
|
291
|
+
#
|
|
292
|
+
# The overflow is ellipsized from the *joined remainder*, not by
|
|
293
|
+
# ellipsizing the last kept row: that row usually already fits `width`, so
|
|
294
|
+
# {StyledString#ellipsize} would be a no-op and the message would be
|
|
295
|
+
# truncated with no `…` to say so.
|
|
296
|
+
# @param message [StyledString]
|
|
297
|
+
# @param width [Integer]
|
|
298
|
+
# @return [Array<StyledString>]
|
|
299
|
+
def wrap_message(message, width)
|
|
300
|
+
rows = message.wrap(width)
|
|
301
|
+
return rows if rows.size <= MAX_ROWS_PER_MESSAGE
|
|
302
|
+
|
|
303
|
+
kept = rows.take(MAX_ROWS_PER_MESSAGE - 1)
|
|
304
|
+
rest = rows[(MAX_ROWS_PER_MESSAGE - 1)..].inject { |joined, row| joined + SPACE + row }
|
|
305
|
+
kept + [rest.ellipsize(width)]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Joins pre-wrapped rows into one {StyledString} with `\n` separators, so
|
|
309
|
+
# {TextView} takes them as hard lines and its own wrap is a no-op over
|
|
310
|
+
# them (each row already fits the width it will be painted at).
|
|
311
|
+
# @param rows [Array<StyledString>]
|
|
312
|
+
# @return [StyledString]
|
|
313
|
+
def join_rows(rows)
|
|
314
|
+
return StyledString::EMPTY if rows.empty?
|
|
315
|
+
|
|
316
|
+
rows.inject { |joined, row| joined + ROW_BREAK + row }
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
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
|
|
@@ -65,11 +66,6 @@ module Tuile
|
|
|
65
66
|
end
|
|
66
67
|
end
|
|
67
68
|
|
|
68
|
-
# @return [String]
|
|
69
|
-
def keyboard_hint
|
|
70
|
-
@options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }.join(" ")
|
|
71
|
-
end
|
|
72
|
-
|
|
73
69
|
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
74
70
|
# closes the popup; ESC / `q` close without firing `block`.
|
|
75
71
|
# @param caption [String]
|
|
@@ -83,7 +79,6 @@ module Tuile
|
|
|
83
79
|
popup = Popup.new(content: picker)
|
|
84
80
|
picker.on_pick = -> { popup.close }
|
|
85
81
|
popup.open
|
|
86
|
-
popup
|
|
87
82
|
end
|
|
88
83
|
|
|
89
84
|
protected
|
|
@@ -40,6 +40,10 @@ module Tuile
|
|
|
40
40
|
# declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
|
|
41
41
|
# nested {Component::TextField} doesn't dismiss the popup: the field
|
|
42
42
|
# consumes it first.
|
|
43
|
+
#
|
|
44
|
+
# A left click *outside* the popup closes it too, modal or not — see
|
|
45
|
+
# {#close_on_outside_click?} for the exact contract and {#on_close=} for
|
|
46
|
+
# the notice a driver hears when it happens.
|
|
43
47
|
class Popup < Component
|
|
44
48
|
include Component::HasContent
|
|
45
49
|
|
|
@@ -52,10 +56,16 @@ module Tuile
|
|
|
52
56
|
# @param size [Size, Fraction] the popup's size, applied top-down. A
|
|
53
57
|
# {Fraction} is resolved against the screen each layout pass; a {Size}
|
|
54
58
|
# is clamped to the screen. Defaults to {Fraction::HALF}.
|
|
55
|
-
|
|
59
|
+
# @param close_on_outside_click [Boolean] true (default) to dismiss on a
|
|
60
|
+
# left click that misses this popup. See {#close_on_outside_click?}.
|
|
61
|
+
def initialize(content: nil, modal: true, size: Fraction::HALF,
|
|
62
|
+
close_on_outside_click: true)
|
|
56
63
|
super()
|
|
57
64
|
@modal = modal
|
|
58
65
|
@size = size
|
|
66
|
+
@close_on_outside_click = close_on_outside_click
|
|
67
|
+
@owner = nil
|
|
68
|
+
@on_close = nil
|
|
59
69
|
@content = nil
|
|
60
70
|
self.content = content unless content.nil?
|
|
61
71
|
reposition
|
|
@@ -67,6 +77,63 @@ module Tuile
|
|
|
67
77
|
# @return [Boolean] whether this popup is modal. See {#initialize}.
|
|
68
78
|
def modal? = @modal
|
|
69
79
|
|
|
80
|
+
# Whether a left click outside this popup closes it (default true, modal or
|
|
81
|
+
# not). The pane does the closing — {ScreenPane#handle_mouse} snapshots
|
|
82
|
+
# the open popups *before* routing the click and closes the dismissable
|
|
83
|
+
# ones *after*, so a widget that toggles its own overlay from a click on
|
|
84
|
+
# its face (a {Component::Select}, a {Component::MenuBar} title) still
|
|
85
|
+
# toggles correctly: the delivered click closes the overlay and the
|
|
86
|
+
# dismissal then no-ops on it, rather than closing and reopening it. Only
|
|
87
|
+
# `:left` dismisses; scroll and right clicks never do.
|
|
88
|
+
#
|
|
89
|
+
# **"Outside" spans the {#owner} chain, not just this rect.** A click
|
|
90
|
+
# counts as inside this popup when it lands in its rect *or* in any popup
|
|
91
|
+
# that belongs to it — so a dialog is not dismissed by a click on a
|
|
92
|
+
# dropdown its own field opened, and a menu cascade is not dismissed by a
|
|
93
|
+
# click on one of its deeper panels. Popups with no owner relationship are
|
|
94
|
+
# independent: clicking one dismisses the other, which is what a
|
|
95
|
+
# window-like overlay should do. A popup that must survive unrelated
|
|
96
|
+
# clicks entirely ({Component::Notification}) sets this false.
|
|
97
|
+
#
|
|
98
|
+
# Every dismissable popup closes, not just the topmost, and stacking order
|
|
99
|
+
# plays no part: a {Component::MenuBar} cascade must vanish whole on one
|
|
100
|
+
# click on the background, not peel one panel per click.
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def close_on_outside_click? = @close_on_outside_click
|
|
103
|
+
|
|
104
|
+
# @return [Boolean] see {#close_on_outside_click?}.
|
|
105
|
+
attr_writer :close_on_outside_click
|
|
106
|
+
|
|
107
|
+
# The component this overlay is *part of*, or `nil` (the default) when it
|
|
108
|
+
# is an overlay in its own right. It exists for outside-click dismissal:
|
|
109
|
+
# a click inside this popup also counts as inside whatever popup encloses
|
|
110
|
+
# its owner, so the host is not dismissed by a click on a panel it put
|
|
111
|
+
# there. See {#close_on_outside_click?}.
|
|
112
|
+
#
|
|
113
|
+
# Set it to the *driver* — {Component::ComboBox} hands its dropdown
|
|
114
|
+
# `self` — rather than to the enclosing popup: the driver knows what it
|
|
115
|
+
# is, while the popup above it is a tree relationship the pane resolves
|
|
116
|
+
# at click time (so it cannot go stale). Any {Component} is accepted, and
|
|
117
|
+
# a `Popup` resolves to itself, which is how a
|
|
118
|
+
# {Component::MenuBar::Cascade} chains each panel to the one it dropped
|
|
119
|
+
# out of.
|
|
120
|
+
# @return [Component, nil]
|
|
121
|
+
attr_accessor :owner
|
|
122
|
+
|
|
123
|
+
# A callback taking no arguments, fired once this popup has left the
|
|
124
|
+
# screen — **however it left**: {#close}, a direct {Screen#remove_popup},
|
|
125
|
+
# an outside click, or teardown via {Screen#close}. That unconditionality
|
|
126
|
+
# is the point, so it hangs off {#on_detached} rather than {#close}; a
|
|
127
|
+
# driver keeping its own record of open popups reconciles it here and
|
|
128
|
+
# cannot drift ({Component::MenuBar::Cascade} is the worked example).
|
|
129
|
+
#
|
|
130
|
+
# It fires *after* the popup is detached, so {#open?} is already false and
|
|
131
|
+
# the usual {Component#on_detached} caveats apply: release state, don't
|
|
132
|
+
# inspect the tree, keep it trivial (it may run while the pane is mid-way
|
|
133
|
+
# through closing a batch of popups, and a raise propagates).
|
|
134
|
+
# @return [Proc, nil]
|
|
135
|
+
attr_accessor :on_close
|
|
136
|
+
|
|
70
137
|
def focusable? = true
|
|
71
138
|
|
|
72
139
|
# Sets the popup's size and repositions it. Accepts a {Fraction}
|
|
@@ -98,19 +165,17 @@ module Tuile
|
|
|
98
165
|
|
|
99
166
|
# Mounts this popup on the {Screen}, re-resolving its {#size} against the
|
|
100
167
|
# current screen first.
|
|
101
|
-
#
|
|
168
|
+
#
|
|
169
|
+
# popup = Component::Popup.new(content: window).open # construct and mount
|
|
170
|
+
#
|
|
171
|
+
# There is deliberately no class-level `Popup.open` factory — see
|
|
172
|
+
# `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
|
|
173
|
+
# one-liner above available without one.
|
|
174
|
+
# @return [self]
|
|
102
175
|
def open
|
|
103
176
|
reposition
|
|
104
177
|
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)
|
|
178
|
+
self
|
|
114
179
|
end
|
|
115
180
|
|
|
116
181
|
# Removes this popup from the {Screen}. No-op if not currently open.
|
|
@@ -149,14 +214,6 @@ module Tuile
|
|
|
149
214
|
self.rect = rect.centered(screen.size)
|
|
150
215
|
end
|
|
151
216
|
|
|
152
|
-
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
153
|
-
# @return [String]
|
|
154
|
-
def keyboard_hint
|
|
155
|
-
prefix = "q #{screen.theme.hint("Close")}"
|
|
156
|
-
child_hint = @content&.keyboard_hint.to_s
|
|
157
|
-
child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
|
|
158
|
-
end
|
|
159
|
-
|
|
160
217
|
# `q` and ESC close the popup. The popup sits on the focus chain of
|
|
161
218
|
# whatever it wraps, so the key reaches here by bubbling up from the
|
|
162
219
|
# focused content after that content declined to handle it.
|
|
@@ -171,6 +228,13 @@ module Tuile
|
|
|
171
228
|
end
|
|
172
229
|
end
|
|
173
230
|
|
|
231
|
+
# Fires {#on_close}. A subclass overriding this **must** call `super`, or
|
|
232
|
+
# the popup's driver never hears that it closed.
|
|
233
|
+
# @return [void]
|
|
234
|
+
def on_detached
|
|
235
|
+
@on_close&.call
|
|
236
|
+
end
|
|
237
|
+
|
|
174
238
|
protected
|
|
175
239
|
|
|
176
240
|
# Content fills the popup's full rect — Popup has no border to subtract.
|
|
@@ -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]
|