tuile 0.12.0 → 0.14.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 +116 -27
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +2342 -158
- data/README.md +151 -505
- data/TERMINOLOGY.md +15 -5
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +28 -20
- data/book/05-focus.md +137 -19
- data/book/06-theming.md +103 -2
- data/book/07-components.md +567 -27
- data/book/08-testing.md +34 -4
- data/book/09-styled-text.md +3 -3
- data/book/README.md +7 -5
- data/examples/file_commander.rb +23 -17
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +527 -108
- data/ideas/arrow-key-navigation.md +17 -1
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +28 -27
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +12 -3
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +102 -11
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- 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 +24 -39
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +61 -123
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +17 -7
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +231 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +186 -31
- data/lib/tuile/event_queue.rb +45 -1
- data/lib/tuile/fake_screen.rb +40 -2
- data/lib/tuile/keys.rb +72 -0
- data/lib/tuile/screen.rb +212 -113
- data/lib/tuile/screen_pane.rb +125 -41
- data/lib/tuile/styled_string.rb +80 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2527 -358
- metadata +13 -3
- data/mise.toml +0 -2
|
@@ -28,19 +28,23 @@ module Tuile
|
|
|
28
28
|
# (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
|
|
29
29
|
# shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
|
|
30
30
|
# rows and is then ellipsized, and entries past the height cap wait unpainted.
|
|
31
|
-
# `DECISIONS.md` `
|
|
31
|
+
# `DECISIONS.md` `D_notification` has why each of those is what it is.
|
|
32
32
|
#
|
|
33
33
|
# Three things it deliberately doesn't do:
|
|
34
34
|
#
|
|
35
|
-
# - **Take focus, or receive keys.**
|
|
36
|
-
# key-dispatch scope ({ScreenPane#handle_key}), so
|
|
37
|
-
#
|
|
38
|
-
# wanting a key registers a global shortcut and calls {#close}.
|
|
35
|
+
# - **Take focus, or receive keys.** An {Overlay} sits off the
|
|
36
|
+
# key-dispatch scope ({ScreenPane#handle_key}), so no key arrives here at
|
|
37
|
+
# all. A left click *on the box* dismisses ({#handle_mouse}); an app
|
|
38
|
+
# wanting a key registers a global shortcut and calls {Overlay#close}. A
|
|
39
|
+
# click *elsewhere* does not — this is the one overlay with
|
|
40
|
+
# {Overlay#close_on_outside_click?} false, since a toast is timed and an
|
|
41
|
+
# unrelated click is not about it.
|
|
39
42
|
# - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
|
|
40
43
|
# the message is added — a toast lives seconds, so there is no
|
|
41
44
|
# {Component#on_theme_changed} rebuild.
|
|
42
|
-
# - **Take a size.** {
|
|
43
|
-
|
|
45
|
+
# - **Take a size.** An {Overlay} has no declared box; the messages decide
|
|
46
|
+
# this one's, in {#reposition}.
|
|
47
|
+
class Notification < Overlay
|
|
44
48
|
# Most messages held at once, counting both the painted ones and any
|
|
45
49
|
# waiting for room. Chosen from reading time rather than geometry: the
|
|
46
50
|
# drain rate is one message per {DISPLAY_SECONDS}, so the queue length *is*
|
|
@@ -113,32 +117,17 @@ module Tuile
|
|
|
113
117
|
private_class_method :new
|
|
114
118
|
|
|
115
119
|
def initialize
|
|
116
|
-
# Built before `super`, because
|
|
117
|
-
#
|
|
120
|
+
# Built before `super`, because Overlay#initialize assigns the content
|
|
121
|
+
# and our #reposition override reads every one of these.
|
|
118
122
|
@messages = []
|
|
119
123
|
@high_water = 0
|
|
120
124
|
@ticker = nil
|
|
121
125
|
@view = TextView.new
|
|
122
126
|
@window = Window.new
|
|
123
127
|
@window.content = @view
|
|
124
|
-
super(content: @window,
|
|
128
|
+
super(content: @window, close_on_outside_click: false)
|
|
125
129
|
end
|
|
126
130
|
|
|
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
131
|
# Appends a message, dropping it (with a {Tuile.logger} warning) once
|
|
143
132
|
# {MAX_MESSAGES} are held. Public so a caller holding the instance can
|
|
144
133
|
# append without repeating {show}'s lookup.
|
|
@@ -169,7 +158,7 @@ module Tuile
|
|
|
169
158
|
|
|
170
159
|
# Recomputes the box from its messages and re-anchors it to the screen's
|
|
171
160
|
# top-right corner — so a SIGWINCH re-wraps and re-anchors, where
|
|
172
|
-
# {
|
|
161
|
+
# {Overlay#reposition} would have kept the stale left column of a *derived*
|
|
173
162
|
# position (off-screen entirely if the terminal narrowed).
|
|
174
163
|
#
|
|
175
164
|
# Rebuilds the {TextView}'s text too, and every mutation routes through
|
|
@@ -186,27 +175,17 @@ module Tuile
|
|
|
186
175
|
width = box_width
|
|
187
176
|
rows = @messages.flat_map { |message| wrap_message(message, width - 2) }
|
|
188
177
|
height = [rows.size + 2, cap_height].min
|
|
189
|
-
@size = Size.new(width, height)
|
|
190
178
|
@view.text = join_rows(rows)
|
|
191
179
|
self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
|
|
192
180
|
end
|
|
193
181
|
|
|
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
182
|
# A left click dismisses the whole box, every message with it. Other buttons
|
|
204
183
|
# are consumed and inert — including the scroll wheel, which would otherwise
|
|
205
184
|
# nuke the box on a stray spin.
|
|
206
185
|
#
|
|
207
186
|
# Deliberately *replaces* rather than augments: neither `super` nor
|
|
208
187
|
# {HasContent#handle_mouse} may run, since both end at a
|
|
209
|
-
# `screen.focused = …` inside this subtree (see {#focusable?}).
|
|
188
|
+
# `screen.focused = …` inside this subtree (see {Overlay#focusable?}).
|
|
210
189
|
# @param event [MouseEvent]
|
|
211
190
|
# @return [void]
|
|
212
191
|
def handle_mouse(event)
|
|
@@ -214,10 +193,16 @@ module Tuile
|
|
|
214
193
|
end
|
|
215
194
|
|
|
216
195
|
# @return [void]
|
|
217
|
-
def on_attached
|
|
196
|
+
def on_attached
|
|
197
|
+
super
|
|
198
|
+
sync_ticker
|
|
199
|
+
end
|
|
218
200
|
|
|
219
201
|
# @return [void]
|
|
220
|
-
def on_detached
|
|
202
|
+
def on_detached
|
|
203
|
+
super
|
|
204
|
+
sync_ticker
|
|
205
|
+
end
|
|
221
206
|
|
|
222
207
|
private
|
|
223
208
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A component mounted on the {Screen}'s overlay stack rather than in the
|
|
6
|
+
# tiled tree: it floats above the content at a rect the caller assigns, and
|
|
7
|
+
# has an open/close lifecycle instead of a parent that lays it out.
|
|
8
|
+
#
|
|
9
|
+
# overlay = Component::Overlay.new(content: Component::Label.new("saved"))
|
|
10
|
+
# overlay.rect = Rect.new(10, 4, 20, 1) # you place it — nothing else does
|
|
11
|
+
# overlay.open # mounts it on the Screen
|
|
12
|
+
# overlay.close
|
|
13
|
+
#
|
|
14
|
+
# That is the whole of it — floating, plus the lifecycle, {#on_close},
|
|
15
|
+
# outside-click dismissal and {#owner}. {Component::Popup} is the subclass
|
|
16
|
+
# that adds a declared size, self-centering, focus and key handling.
|
|
17
|
+
#
|
|
18
|
+
# The wrapped content fills the overlay's whole {#rect}; for a frame and a
|
|
19
|
+
# caption, wrap a {Component::Window} and let it draw its own border.
|
|
20
|
+
#
|
|
21
|
+
# == Implementation details
|
|
22
|
+
#
|
|
23
|
+
# **{#focusable?} and {#modal?} move together — flip both or neither.** The
|
|
24
|
+
# defaults here are inert (`false`, `false`): a bare overlay floats without
|
|
25
|
+
# disturbing focus or key dispatch. {Component::Popup} flips both. What must
|
|
26
|
+
# not appear is a *focusable non-modal* overlay: {ScreenPane#handle_key}
|
|
27
|
+
# scopes delivery to the topmost modal popup or else the tiled content, so
|
|
28
|
+
# such an overlay would hold focus outside the key scope, where delivery
|
|
29
|
+
# reaches nobody and *every* keystroke goes dead until Tab recovers. A
|
|
30
|
+
# non-modal overlay is therefore driven from its owner's key handler
|
|
31
|
+
# ({Component::Select} forwarding to its dropdown) instead of claiming focus.
|
|
32
|
+
#
|
|
33
|
+
# **A derived position needs a {#reposition} override.** The default is a
|
|
34
|
+
# no-op: the rect is whatever the caller last assigned. An overlay whose
|
|
35
|
+
# position is computed — from the screen, or from an anchor — must recompute
|
|
36
|
+
# it there, or it keeps a stale rect after a SIGWINCH and sits off-screen
|
|
37
|
+
# entirely if the terminal narrowed. Closing on resize is equally legal
|
|
38
|
+
# ({Component::MenuBar} drops its cascade from `rect=` rather than walking
|
|
39
|
+
# every level to re-anchor it).
|
|
40
|
+
#
|
|
41
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
42
|
+
class Overlay < Component
|
|
43
|
+
include Component::HasContent
|
|
44
|
+
|
|
45
|
+
# @param content [Component, nil] initial content; can be set later via
|
|
46
|
+
# {#content=}. It fills the overlay's {#rect} and does not determine it.
|
|
47
|
+
# @param close_on_outside_click [Boolean] true (default) to dismiss on a
|
|
48
|
+
# left click that misses this overlay. See {#close_on_outside_click?}.
|
|
49
|
+
def initialize(content: nil, close_on_outside_click: true)
|
|
50
|
+
super()
|
|
51
|
+
@close_on_outside_click = close_on_outside_click
|
|
52
|
+
@owner = nil
|
|
53
|
+
@on_close = nil
|
|
54
|
+
@content = nil
|
|
55
|
+
self.content = content unless content.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Boolean] false — a bare overlay scopes no keys and blocks no
|
|
59
|
+
# clicks. {Component::Popup} overrides it, together with {#focusable?}.
|
|
60
|
+
def modal? = false
|
|
61
|
+
|
|
62
|
+
# @return [Boolean] false — a bare overlay leaves focus where it was. See
|
|
63
|
+
# the class docs: override it only together with {#modal?}.
|
|
64
|
+
def focusable? = false
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] false — Tab never lands on the overlay wrapper itself
|
|
67
|
+
# (its content may still carry stops).
|
|
68
|
+
def tab_stop? = false
|
|
69
|
+
|
|
70
|
+
# Whether a left click outside this overlay closes it (default true). The
|
|
71
|
+
# pane does the closing — {ScreenPane#handle_mouse} snapshots the open
|
|
72
|
+
# overlays *before* routing the click and closes the dismissable ones
|
|
73
|
+
# *after*, so a widget that toggles its own overlay from a click on its
|
|
74
|
+
# face (a {Component::Select}, a {Component::MenuBar} title) still toggles
|
|
75
|
+
# correctly: the delivered click closes the overlay and the dismissal then
|
|
76
|
+
# no-ops on it, rather than closing and reopening it. Only `:left`
|
|
77
|
+
# dismisses; scroll and right clicks never do.
|
|
78
|
+
#
|
|
79
|
+
# **"Outside" spans the {#owner} chain, not just this rect.** A click
|
|
80
|
+
# counts as inside this overlay when it lands in its rect *or* in any
|
|
81
|
+
# overlay that belongs to it — so a dialog is not dismissed by a click on a
|
|
82
|
+
# dropdown its own field opened, and a menu cascade is not dismissed by a
|
|
83
|
+
# click on one of its deeper panels. Overlays with no owner relationship
|
|
84
|
+
# are independent: clicking one dismisses the other, which is what a
|
|
85
|
+
# window-like overlay should do. One that must survive unrelated clicks
|
|
86
|
+
# entirely ({Component::Notification}) sets this false.
|
|
87
|
+
#
|
|
88
|
+
# Every dismissable overlay closes, not just the topmost, and stacking
|
|
89
|
+
# order plays no part: a {Component::MenuBar} cascade must vanish whole on
|
|
90
|
+
# one click on the background, not peel one panel per click.
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def close_on_outside_click? = @close_on_outside_click
|
|
93
|
+
|
|
94
|
+
# @return [Boolean] see {#close_on_outside_click?}.
|
|
95
|
+
attr_writer :close_on_outside_click
|
|
96
|
+
|
|
97
|
+
# The component this overlay is *part of*, or `nil` (the default) when it
|
|
98
|
+
# is an overlay in its own right. It exists for outside-click dismissal: a
|
|
99
|
+
# click inside this overlay also counts as inside whatever overlay encloses
|
|
100
|
+
# its owner, so the host is not dismissed by a click on a panel it put
|
|
101
|
+
# there. See {#close_on_outside_click?}.
|
|
102
|
+
#
|
|
103
|
+
# Set it to the *driver* — {Component::ComboBox} hands its dropdown `self`
|
|
104
|
+
# — rather than to the enclosing overlay: the driver knows what it is,
|
|
105
|
+
# while the overlay above it is a tree relationship the pane resolves at
|
|
106
|
+
# click time (so it cannot go stale). Any {Component} is accepted, and an
|
|
107
|
+
# `Overlay` resolves to itself, which is how a
|
|
108
|
+
# {Component::MenuBar::Cascade} chains each panel to the one it dropped out
|
|
109
|
+
# of.
|
|
110
|
+
# @return [Component, nil]
|
|
111
|
+
attr_accessor :owner
|
|
112
|
+
|
|
113
|
+
# A callback taking no arguments, fired once this overlay has left the
|
|
114
|
+
# screen — **however it left**: {#close}, a direct {Screen#remove_popup},
|
|
115
|
+
# an outside click, or teardown via {Screen#close}. That unconditionality
|
|
116
|
+
# is the point, so it hangs off {#on_detached} rather than {#close}; a
|
|
117
|
+
# driver keeping its own record of open overlays reconciles it here and
|
|
118
|
+
# cannot drift ({Component::MenuBar::Cascade} is the worked example).
|
|
119
|
+
#
|
|
120
|
+
# It fires *after* the overlay is detached, so {#open?} is already false
|
|
121
|
+
# and the usual {Component#on_detached} caveats apply: release state, don't
|
|
122
|
+
# inspect the tree, keep it trivial (it may run while the pane is mid-way
|
|
123
|
+
# through closing a batch of overlays, and a raise propagates).
|
|
124
|
+
# @return [Proc, nil]
|
|
125
|
+
attr_accessor :on_close
|
|
126
|
+
|
|
127
|
+
# Reassigns the overlay's rect, escalating to a full scene repaint when an
|
|
128
|
+
# open overlay shrinks or moves so its new rect no longer covers the cells
|
|
129
|
+
# it previously painted. An overlay overdraws the scene without clipping
|
|
130
|
+
# and nothing clears underneath it, so {Screen#repaint}'s overlay-only fast
|
|
131
|
+
# path would repaint into the new rect and leave the vacated cells showing
|
|
132
|
+
# stale content. When the new rect fully covers the old one (the overlay
|
|
133
|
+
# only grew), the fast path is correct and the full repaint is skipped.
|
|
134
|
+
# @param new_rect [Rect]
|
|
135
|
+
# @return [void]
|
|
136
|
+
def rect=(new_rect)
|
|
137
|
+
old_rect = rect
|
|
138
|
+
super
|
|
139
|
+
screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Mounts this overlay on the {Screen}.
|
|
143
|
+
#
|
|
144
|
+
# overlay = Component::Overlay.new(content: label).open # construct and mount
|
|
145
|
+
#
|
|
146
|
+
# There is deliberately no class-level `Overlay.open` factory — see
|
|
147
|
+
# `DECISIONS.md` `D_popup_open`; returning `self` is what keeps the
|
|
148
|
+
# one-liner above available without one.
|
|
149
|
+
# @return [self]
|
|
150
|
+
def open
|
|
151
|
+
reposition
|
|
152
|
+
screen.add_popup(self)
|
|
153
|
+
self
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Removes this overlay from the {Screen}. No-op if not currently open.
|
|
157
|
+
# @return [void]
|
|
158
|
+
def close
|
|
159
|
+
screen.remove_popup(self)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @return [Boolean] true if this overlay is currently mounted on the screen.
|
|
163
|
+
def open?
|
|
164
|
+
screen.has_popup?(self)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Recomputes this overlay's own rect (not its content's layout). A no-op
|
|
168
|
+
# here — the rect is whatever the caller assigned — and the hook a subclass
|
|
169
|
+
# with a *derived* position overrides; see the class docs. Called on
|
|
170
|
+
# {#open} and by the screen's layout pass, so an override tracks SIGWINCH.
|
|
171
|
+
# @return [void]
|
|
172
|
+
def reposition; end
|
|
173
|
+
|
|
174
|
+
# Fires {#on_close}. A subclass overriding this **must** call `super`, or
|
|
175
|
+
# the overlay's driver never hears that it closed.
|
|
176
|
+
# @return [void]
|
|
177
|
+
def on_detached
|
|
178
|
+
@on_close&.call
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
protected
|
|
182
|
+
|
|
183
|
+
# Content fills the overlay's full rect — an Overlay has no border to
|
|
184
|
+
# subtract.
|
|
185
|
+
# @param content [Component]
|
|
186
|
+
# @return [void]
|
|
187
|
+
def layout(content)
|
|
188
|
+
content.rect = rect
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -66,11 +66,6 @@ module Tuile
|
|
|
66
66
|
end
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
# @return [String]
|
|
70
|
-
def keyboard_hint
|
|
71
|
-
@options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }.join(" ")
|
|
72
|
-
end
|
|
73
|
-
|
|
74
69
|
# Opens a picker as a popup. Picking an option fires `block`, then
|
|
75
70
|
# closes the popup; ESC / `q` close without firing `block`.
|
|
76
71
|
# @param caption [String]
|
|
@@ -2,143 +2,98 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# {#size} the {Screen} applies.
|
|
9
|
-
#
|
|
10
|
-
# The popup does *not* size itself to its content. Its box is declared by
|
|
11
|
-
# {#size} — a {Fraction} (resolved against the screen every layout pass, so
|
|
12
|
-
# it tracks resize) or an absolute {Size} (clamped to the screen). The
|
|
13
|
-
# default is {Fraction::HALF}: half the screen, centered. The wrapped
|
|
14
|
-
# content then fills that box and handles its own overflow by wrapping and
|
|
15
|
-
# scrolling, so use content that can — a {Component::TextView} or
|
|
16
|
-
# {Component::TextArea} — for anything longer than fits. A
|
|
17
|
-
# {Component::Label} only truncates.
|
|
18
|
-
#
|
|
19
|
-
# Modal by default: it centers on the screen, grabs focus, eats keys, and
|
|
20
|
-
# blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
|
|
21
|
-
# that floats above the content without taking focus or capturing input —
|
|
22
|
-
# the caller positions it (via {#rect=}), sizes it, and drives it from app
|
|
23
|
-
# code. That's the building block for an autocomplete/slash-command list
|
|
24
|
-
# anchored to a text field's caret: typing keeps focus in the input while
|
|
25
|
-
# the caller refills and drives the overlay.
|
|
26
|
-
#
|
|
27
|
-
# The wrapped content fills the popup's full {#rect}; if you want a frame
|
|
28
|
-
# and caption, wrap a {Component::Window} (or any subclass — including
|
|
29
|
-
# {Component::LogWindow}) and let it draw its own border:
|
|
5
|
+
# A modal dialog: an {Component::Overlay} that centers itself on the screen,
|
|
6
|
+
# grabs focus, scopes keys to its own subtree, blocks clicks beneath it, and
|
|
7
|
+
# closes on ESC or `q`.
|
|
30
8
|
#
|
|
31
9
|
# window = Component::Window.new("Help")
|
|
32
10
|
# window.content = Component::List.new.tap { _1.lines = lines }
|
|
33
11
|
# Component::Popup.new(content: window).open
|
|
34
12
|
#
|
|
35
13
|
# Bare content also works (a {Component::Label}, a {Component::List}…), in
|
|
36
|
-
# which case the popup is borderless.
|
|
14
|
+
# which case the popup is borderless. For a floating layer that does *not*
|
|
15
|
+
# take focus or capture input — an autocomplete list anchored to a field, a
|
|
16
|
+
# toast — use {Component::Overlay} directly.
|
|
17
|
+
#
|
|
18
|
+
# The popup does *not* size itself to its content. Its box is set by
|
|
19
|
+
# {#declared_size} — a {Fraction} (resolved against the screen every layout
|
|
20
|
+
# pass, so it tracks resize) or an absolute {Size} (clamped to the screen).
|
|
21
|
+
# The default is {Fraction::HALF}: half the screen, centered. The wrapped content
|
|
22
|
+
# then fills that box and handles its own overflow by wrapping and scrolling,
|
|
23
|
+
# so use content that can — a {Component::TextView} or
|
|
24
|
+
# {Component::TextArea} — for anything longer than fits. A
|
|
25
|
+
# {Component::Label} only truncates.
|
|
26
|
+
#
|
|
27
|
+
# == Implementation details
|
|
37
28
|
#
|
|
38
29
|
# `q` and ESC close the popup — handled here, at the top of the popup's own
|
|
39
30
|
# subtree, so the key only arrives after every component on the focus chain
|
|
40
31
|
# declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
|
|
41
|
-
# nested {Component::TextField} doesn't dismiss the popup: the field
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
32
|
+
# nested {Component::TextField} doesn't dismiss the popup: the field consumes
|
|
33
|
+
# it first.
|
|
34
|
+
#
|
|
35
|
+
# A left click *outside* the popup closes it too — see
|
|
36
|
+
# {Overlay#close_on_outside_click?} for the exact contract and
|
|
37
|
+
# {Overlay#on_close=} for the notice a driver hears when it happens.
|
|
38
|
+
#
|
|
39
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
40
|
+
class Popup < Overlay
|
|
46
41
|
# @param content [Component, nil] initial content; can be set later via
|
|
47
42
|
# {#content=}. The content fills the popup's {#rect}; it does not
|
|
48
43
|
# determine the popup's size.
|
|
49
|
-
# @param
|
|
50
|
-
#
|
|
51
|
-
# positions and drives (see the class docs).
|
|
52
|
-
# @param size [Size, Fraction] the popup's size, applied top-down. A
|
|
53
|
-
# {Fraction} is resolved against the screen each layout pass; a {Size}
|
|
44
|
+
# @param declared_size [Size, Fraction] the popup's box, applied top-down.
|
|
45
|
+
# A {Fraction} is resolved against the screen each layout pass; a {Size}
|
|
54
46
|
# is clamped to the screen. Defaults to {Fraction::HALF}.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
47
|
+
# @param close_on_outside_click [Boolean] true (default) to dismiss on a
|
|
48
|
+
# left click that misses this popup. See
|
|
49
|
+
# {Overlay#close_on_outside_click?}.
|
|
50
|
+
def initialize(content: nil, declared_size: Fraction::HALF, close_on_outside_click: true)
|
|
51
|
+
super(content: content, close_on_outside_click: close_on_outside_click)
|
|
52
|
+
@declared_size = declared_size
|
|
61
53
|
reposition
|
|
62
54
|
end
|
|
63
55
|
|
|
64
|
-
#
|
|
65
|
-
|
|
56
|
+
# The box this popup asks for, *as set* — a {Fraction} comes back
|
|
57
|
+
# unresolved. {#rect} is the resolved answer. Named apart from `size`
|
|
58
|
+
# because a component's `size` is its `rect.size`, which this is not: it
|
|
59
|
+
# is an input re-read on every layout pass, not a report of the current
|
|
60
|
+
# geometry.
|
|
61
|
+
# @return [Size, Fraction] see {#declared_size=}.
|
|
62
|
+
attr_reader :declared_size
|
|
66
63
|
|
|
67
|
-
# @return [Boolean]
|
|
68
|
-
|
|
64
|
+
# @return [Boolean] true — a Popup scopes key dispatch, grabs focus on
|
|
65
|
+
# open, and blocks clicks on the content beneath it.
|
|
66
|
+
def modal? = true
|
|
69
67
|
|
|
68
|
+
# @return [Boolean] true — {ScreenPane#add_popup} focuses a popup on open,
|
|
69
|
+
# and focus repair falls back to it when its subtree has no tab stop.
|
|
70
70
|
def focusable? = true
|
|
71
71
|
|
|
72
|
-
# Sets the popup's
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
72
|
+
# Sets the popup's box and repositions it. Accepts a {Fraction} (resolved
|
|
73
|
+
# against the screen every layout pass, so it tracks resize) or an
|
|
74
|
+
# absolute {Size} (clamped to the screen). This is **authoritative**, not
|
|
75
|
+
# a preference: the screen applies exactly what you ask for (clamped),
|
|
76
76
|
# with no negotiation — a popup has no siblings to compete with.
|
|
77
77
|
# @param new_size [Size, Fraction]
|
|
78
78
|
# @return [void]
|
|
79
|
-
def
|
|
80
|
-
@
|
|
79
|
+
def declared_size=(new_size)
|
|
80
|
+
@declared_size = new_size
|
|
81
81
|
reposition
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
# would repaint into the new rect and leave the vacated cells showing
|
|
89
|
-
# stale content. When the new rect fully covers the old one (the popup
|
|
90
|
-
# only grew), the fast path is correct and the full repaint is skipped.
|
|
91
|
-
# @param new_rect [Rect]
|
|
92
|
-
# @return [void]
|
|
93
|
-
def rect=(new_rect)
|
|
94
|
-
old_rect = rect
|
|
95
|
-
super
|
|
96
|
-
screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# Mounts this popup on the {Screen}, re-resolving its {#size} against the
|
|
100
|
-
# current screen first.
|
|
101
|
-
#
|
|
102
|
-
# popup = Component::Popup.new(content: window).open # construct and mount
|
|
84
|
+
# Re-resolves {#declared_size} against the current screen and recenters the popup
|
|
85
|
+
# *itself* (this is not laying out content — the popup's own rect). Called
|
|
86
|
+
# on {Overlay#open}, on {#declared_size=}, and by the screen's layout pass,
|
|
87
|
+
# so a {Fraction} tracks SIGWINCH.
|
|
103
88
|
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
# one
|
|
107
|
-
# @return [self]
|
|
108
|
-
def open
|
|
109
|
-
reposition
|
|
110
|
-
screen.add_popup(self)
|
|
111
|
-
self
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Removes this popup from the {Screen}. No-op if not currently open.
|
|
115
|
-
# @return [void]
|
|
116
|
-
def close
|
|
117
|
-
screen.remove_popup(self)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# @return [Boolean] true if this popup is currently mounted on the screen.
|
|
121
|
-
def open?
|
|
122
|
-
screen.has_popup?(self)
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# Re-resolves {#size} against the current screen and repositions the popup
|
|
126
|
-
# *itself* (this is not laying out content — the popup's own rect): a
|
|
127
|
-
# modal popup recenters; a non-modal overlay keeps its caller-assigned
|
|
128
|
-
# top-left (only its size follows the screen). Called on {#open}, on
|
|
129
|
-
# {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
|
|
130
|
-
# SIGWINCH).
|
|
131
|
-
#
|
|
132
|
-
# The final rect is computed and assigned in one step rather than sizing
|
|
133
|
-
# at the origin and then centering: the intermediate origin rect rarely
|
|
134
|
-
# covers the previous one, which would make {#rect=}'s shrink/move
|
|
89
|
+
# The final rect is computed and assigned in one step rather than sizing at
|
|
90
|
+
# the origin and then centering: the intermediate origin rect rarely covers
|
|
91
|
+
# the previous one, which would make {Overlay#rect=}'s shrink/move
|
|
135
92
|
# detection fire a full repaint on every resize.
|
|
136
93
|
# @return [void]
|
|
137
94
|
def reposition
|
|
138
|
-
size = @
|
|
139
|
-
|
|
140
|
-
r = r.centered(screen.size) if modal?
|
|
141
|
-
self.rect = r
|
|
95
|
+
size = @declared_size.is_a?(Fraction) ? @declared_size.resolve(screen.size) : @declared_size.clamp(screen.size)
|
|
96
|
+
self.rect = Rect.new(rect.left, rect.top, size.width, size.height).centered(screen.size)
|
|
142
97
|
end
|
|
143
98
|
|
|
144
99
|
# Recenters the popup on the screen, preserving its current width/height.
|
|
@@ -147,14 +102,6 @@ module Tuile
|
|
|
147
102
|
self.rect = rect.centered(screen.size)
|
|
148
103
|
end
|
|
149
104
|
|
|
150
|
-
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
151
|
-
# @return [String]
|
|
152
|
-
def keyboard_hint
|
|
153
|
-
prefix = "q #{screen.theme.hint("Close")}"
|
|
154
|
-
child_hint = @content&.keyboard_hint.to_s
|
|
155
|
-
child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
|
|
156
|
-
end
|
|
157
|
-
|
|
158
105
|
# `q` and ESC close the popup. The popup sits on the focus chain of
|
|
159
106
|
# whatever it wraps, so the key reaches here by bubbling up from the
|
|
160
107
|
# focused content after that content declined to handle it.
|
|
@@ -168,15 +115,6 @@ module Tuile
|
|
|
168
115
|
false
|
|
169
116
|
end
|
|
170
117
|
end
|
|
171
|
-
|
|
172
|
-
protected
|
|
173
|
-
|
|
174
|
-
# Content fills the popup's full rect — Popup has no border to subtract.
|
|
175
|
-
# @param content [Component]
|
|
176
|
-
# @return [void]
|
|
177
|
-
def layout(content)
|
|
178
|
-
content.rect = rect
|
|
179
|
-
end
|
|
180
118
|
end
|
|
181
119
|
end
|
|
182
120
|
end
|
|
@@ -38,7 +38,7 @@ module Tuile
|
|
|
38
38
|
# The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
|
|
39
39
|
# Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
|
|
40
40
|
# the rendered length would vary with the fill level. Shipped anyway, per
|
|
41
|
-
# `DECISIONS.md` `
|
|
41
|
+
# `DECISIONS.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
|
|
42
42
|
# beats a third convention, and if that bet is ever reversed both swap
|
|
43
43
|
# together.
|
|
44
44
|
class ProgressBar < Component
|
|
@@ -42,7 +42,7 @@ module Tuile
|
|
|
42
42
|
# Home/End are declined too, so they stay available app-wide.
|
|
43
43
|
#
|
|
44
44
|
# There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
|
|
45
|
-
# the feedback removed (`DECISIONS.md` `
|
|
45
|
+
# the feedback removed (`DECISIONS.md` `D_select`). Which is also why labels
|
|
46
46
|
# need no prefix-disambiguation.
|
|
47
47
|
#
|
|
48
48
|
# == Implementation details
|
|
@@ -72,6 +72,9 @@ module Tuile
|
|
|
72
72
|
@value = value
|
|
73
73
|
@on_value_change = nil
|
|
74
74
|
@overlay = ListDropdown.new
|
|
75
|
+
# Outside-click dismissal spans the owner chain, so a click on this
|
|
76
|
+
# select's dropdown must not dismiss a dialog the select sits in.
|
|
77
|
+
@overlay.owner = self
|
|
75
78
|
@overlay.renderer = method(:label_for)
|
|
76
79
|
@overlay.on_item_chosen = ->(_index, item) { commit(item) }
|
|
77
80
|
end
|
|
@@ -108,7 +111,6 @@ module Tuile
|
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
# @return [String]
|
|
111
|
-
def keyboard_hint = "⏎ #{screen.theme.hint("open")} ↑↓ #{screen.theme.hint("select")}"
|
|
112
114
|
|
|
113
115
|
# Re-anchors the (open) dropdown after a move or resize.
|
|
114
116
|
# @param new_rect [Rect]
|
|
@@ -154,24 +156,32 @@ module Tuile
|
|
|
154
156
|
end
|
|
155
157
|
end
|
|
156
158
|
|
|
157
|
-
#
|
|
159
|
+
# The one row this Select paints — the full width, at the top of {#rect}.
|
|
160
|
+
# A single-slot container ({Component::Window}, {Component::Popup}) hands
|
|
161
|
+
# its content the whole inner rect, so a Select is routinely assigned more
|
|
162
|
+
# height than it uses; {#repaint} clears that tail, {#handle_mouse} refuses
|
|
163
|
+
# clicks in it, and the dropdown hangs under this rather than under the
|
|
164
|
+
# unused space.
|
|
165
|
+
# @return [Size]
|
|
166
|
+
def extent = Size.new(rect.width, 1)
|
|
167
|
+
|
|
168
|
+
# Toggles the dropdown on a left click anywhere in {#extent} — a field's
|
|
158
169
|
# affordance is its whole row, as the well advertises; `super` runs first,
|
|
159
170
|
# so the click also focuses.
|
|
160
171
|
# @param event [MouseEvent]
|
|
161
172
|
# @return [void]
|
|
162
173
|
def handle_mouse(event)
|
|
163
174
|
super
|
|
164
|
-
return unless event.button == :left &&
|
|
175
|
+
return unless event.button == :left && extent_rect.contains?(event.point)
|
|
165
176
|
|
|
166
177
|
@overlay.open? ? close_menu : open_menu
|
|
167
178
|
end
|
|
168
179
|
|
|
169
180
|
# @return [void]
|
|
170
181
|
def repaint
|
|
182
|
+
super
|
|
171
183
|
return if rect.empty?
|
|
172
184
|
|
|
173
|
-
tail = Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)
|
|
174
|
-
clear_background(tail) unless tail.empty?
|
|
175
185
|
draw_text(rect.left, rect.top, face_row)
|
|
176
186
|
end
|
|
177
187
|
|
|
@@ -218,7 +228,7 @@ module Tuile
|
|
|
218
228
|
end
|
|
219
229
|
|
|
220
230
|
# @return [void]
|
|
221
|
-
def anchor = @overlay.anchor_to(
|
|
231
|
+
def anchor = @overlay.anchor_to(extent_rect, rows: @items.size, width: menu_width)
|
|
222
232
|
|
|
223
233
|
# The dropdown's width: the widest label plus {List}'s two row gutters, plus
|
|
224
234
|
# the scrollbar column when the rows can't all be shown at once — but never
|