tuile 0.16.0 → 0.17.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 +108 -0
- data/README.md +21 -12
- data/book/02-repaint.md +47 -19
- data/book/03-layout.md +98 -49
- data/book/04-event-loop.md +5 -4
- data/book/05-focus.md +12 -9
- data/book/06-theming.md +55 -17
- data/book/07-components.md +188 -40
- data/book/08-testing.md +115 -15
- data/book/10-locale.md +1 -1
- data/book/README.md +5 -5
- data/examples/file_commander.rb +38 -27
- data/examples/hello_world.rb +1 -1
- data/examples/sampler.rb +225 -169
- data/lib/tuile/buffer.rb +12 -1
- data/lib/tuile/canvas/backend.rb +46 -0
- data/lib/tuile/canvas.rb +212 -0
- data/lib/tuile/color.rb +38 -9
- data/lib/tuile/component/abstract_string_field.rb +81 -80
- data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
- data/lib/tuile/component/big_decimal_field.rb +7 -6
- data/lib/tuile/component/button.rb +19 -11
- data/lib/tuile/component/checkbox.rb +12 -10
- data/lib/tuile/component/checkbox_group.rb +11 -13
- data/lib/tuile/component/combo_box.rb +30 -40
- data/lib/tuile/component/confirm_window.rb +27 -22
- data/lib/tuile/component/date_field.rb +27 -20
- data/lib/tuile/component/date_time_field.rb +75 -31
- data/lib/tuile/component/fill.rb +93 -0
- data/lib/tuile/component/float_field.rb +7 -6
- data/lib/tuile/component/form_item.rb +250 -0
- data/lib/tuile/component/form_layout.rb +206 -0
- data/lib/tuile/component/has_bad_input.rb +98 -27
- data/lib/tuile/component/has_caption.rb +14 -5
- data/lib/tuile/component/has_content.rb +5 -12
- data/lib/tuile/component/has_validation.rb +39 -13
- data/lib/tuile/component/has_value.rb +70 -16
- data/lib/tuile/component/integer_field.rb +7 -6
- data/lib/tuile/component/label.rb +8 -15
- data/lib/tuile/component/layout/absolute.rb +86 -0
- data/lib/tuile/component/layout/box.rb +38 -63
- data/lib/tuile/component/layout.rb +124 -10
- data/lib/tuile/component/list.rb +197 -94
- data/lib/tuile/component/list_dropdown.rb +148 -88
- data/lib/tuile/component/menu_bar/cascade.rb +97 -27
- data/lib/tuile/component/menu_bar.rb +84 -64
- data/lib/tuile/component/notification.rb +44 -31
- data/lib/tuile/component/overlay.rb +210 -52
- data/lib/tuile/component/password_field.rb +1 -8
- data/lib/tuile/component/picker_window.rb +15 -10
- data/lib/tuile/component/popup.rb +13 -24
- data/lib/tuile/component/progress_bar.rb +7 -7
- data/lib/tuile/component/radio_group.rb +10 -12
- data/lib/tuile/component/scroller.rb +266 -0
- data/lib/tuile/component/select.rb +15 -31
- data/lib/tuile/component/slot.rb +1 -2
- data/lib/tuile/component/tab_sheet.rb +21 -28
- data/lib/tuile/component/tabs.rb +39 -24
- data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
- data/lib/tuile/component/text_area.rb +21 -19
- data/lib/tuile/component/text_field.rb +55 -39
- data/lib/tuile/component/text_view.rb +143 -79
- data/lib/tuile/component/time_field.rb +26 -21
- data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
- data/lib/tuile/component/window.rb +27 -26
- data/lib/tuile/component.rb +481 -259
- data/lib/tuile/component_background.rb +177 -0
- data/lib/tuile/component_util.rb +43 -0
- data/lib/tuile/event.rb +29 -0
- data/lib/tuile/event_queue.rb +14 -0
- data/lib/tuile/fake_screen.rb +41 -10
- data/lib/tuile/keys.rb +15 -6
- data/lib/tuile/layout_pass.rb +180 -0
- data/lib/tuile/listeners.rb +219 -0
- data/lib/tuile/mouse/router.rb +51 -35
- data/lib/tuile/mouse.rb +96 -29
- data/lib/tuile/point.rb +6 -0
- data/lib/tuile/rect.rb +33 -0
- data/lib/tuile/screen.rb +419 -84
- data/lib/tuile/screen_pane.rb +144 -31
- data/lib/tuile/strict_layout.rb +127 -0
- data/lib/tuile/styled_string.rb +139 -9
- data/lib/tuile/testing/gestures.rb +35 -0
- data/lib/tuile/testing.rb +310 -36
- data/lib/tuile/theme.rb +170 -19
- data/lib/tuile/theme_def.rb +4 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +53 -0
- data/sig/tuile.rbs +4951 -1158
- metadata +16 -2
- data/lib/tuile/vertical_scroll_bar.rb +0 -122
|
@@ -3,17 +3,23 @@
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
5
|
# A component mounted on the {Screen}'s overlay stack rather than in the
|
|
6
|
-
# tiled tree: it floats above the content
|
|
7
|
-
#
|
|
6
|
+
# tiled tree: it floats above the content, and has an open/close lifecycle.
|
|
7
|
+
# It opens with a *placement* — where it wants to be — and the
|
|
8
|
+
# {ScreenPane}'s layout pass turns that into its rect, again on every
|
|
9
|
+
# resize:
|
|
8
10
|
#
|
|
9
11
|
# overlay = Component::Overlay.new(content: Component::Label.new("saved"))
|
|
10
|
-
# overlay.
|
|
11
|
-
# overlay.
|
|
12
|
+
# overlay.open(Overlay::At[Rect.new(10, 4, 20, 1)]) # mounts it on the Screen
|
|
13
|
+
# overlay.placement = Overlay::At[Rect.new(10, 6, 20, 1)] # moves it
|
|
12
14
|
# overlay.close
|
|
13
15
|
#
|
|
14
16
|
# That is the whole of it — floating, plus the lifecycle, {#on_close},
|
|
15
17
|
# outside-click dismissal and {#owner}. {Component::Popup} is the subclass
|
|
16
|
-
# that adds a declared size,
|
|
18
|
+
# that adds a declared size, centering, focus and key handling.
|
|
19
|
+
#
|
|
20
|
+
# Where it goes is a {Placement}: {At} a fixed rect, {Centered} and
|
|
21
|
+
# {TopRight} an overlay that declares its size ({#declared_size_in}),
|
|
22
|
+
# {ListDropdown::Anchored} a dropdown hanging off a field.
|
|
17
23
|
#
|
|
18
24
|
# The wrapped content fills the overlay's whole {#rect}; for a frame and a
|
|
19
25
|
# caption, wrap a {Component::Window} and let it draw its own border.
|
|
@@ -30,18 +36,127 @@ module Tuile
|
|
|
30
36
|
# non-modal overlay is therefore driven from its owner's key handler
|
|
31
37
|
# ({Component::Select} forwarding to its dropdown) instead of claiming focus.
|
|
32
38
|
#
|
|
33
|
-
# **
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
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).
|
|
39
|
+
# **An overlay whose size changes calls {#reposition}**, which asks the pane
|
|
40
|
+
# to place it again; the placement itself stays put. That is how a growing
|
|
41
|
+
# {Component::Notification} or a re-measured {Component::ConfirmWindow}
|
|
42
|
+
# gets its new rect.
|
|
40
43
|
#
|
|
41
44
|
# UI-thread-confined, like every component (see {Screen}).
|
|
42
45
|
class Overlay < Component
|
|
43
46
|
include Component::HasContent
|
|
44
47
|
|
|
48
|
+
# Where an overlay wants to be. Include it to *be* a placement — the pane
|
|
49
|
+
# asks for a rect on every layout pass, and an app supplies its own by
|
|
50
|
+
# answering {#rect_for}:
|
|
51
|
+
#
|
|
52
|
+
# Bottom = Data.define do
|
|
53
|
+
# include Overlay::Placement
|
|
54
|
+
#
|
|
55
|
+
# def rect_for(overlay, screen_size, _anchor_rect)
|
|
56
|
+
# size = overlay.declared_size_in(screen_size)
|
|
57
|
+
# Rect.new(0, screen_size.height - size.height, size.width, size.height)
|
|
58
|
+
# end
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
# overlay.open(Bottom[])
|
|
62
|
+
#
|
|
63
|
+
# A placement is a **rule, not a rect**: it is re-read on every resize and
|
|
64
|
+
# every settle, so re-running it moves nothing that stood still. Built in
|
|
65
|
+
# are {At}, {Centered}, {TopRight} and {ListDropdown::Anchored}.
|
|
66
|
+
#
|
|
67
|
+
# == Anchors
|
|
68
|
+
#
|
|
69
|
+
# A placement that hangs off something on screen — a field, a menu row —
|
|
70
|
+
# says so by answering {#anchor}; the pane resolves it and hands the rect
|
|
71
|
+
# to {#rect_for}:
|
|
72
|
+
#
|
|
73
|
+
# def anchor = field
|
|
74
|
+
#
|
|
75
|
+
# Resolving is deliberately the framework's job: a component becomes a
|
|
76
|
+
# screen rect through an ancestor-inclusive visibility walk, an
|
|
77
|
+
# {Component#attached?} test and {Component#absolute_extent_rect} (not
|
|
78
|
+
# {Component#absolute_rect}) — three invariants, one of them the trap
|
|
79
|
+
# {Component#walk_shown_tree} exists to prevent. {#anchor} therefore
|
|
80
|
+
# answers one of three things:
|
|
81
|
+
#
|
|
82
|
+
# - a {Component} — resolved while it is reachable, and **losable**: once
|
|
83
|
+
# detached or hidden there is no rect to compute, so the pane leaves the
|
|
84
|
+
# overlay where it was and warns its owner once rather than placing it
|
|
85
|
+
# against nothing. Closing it is the owner's job.
|
|
86
|
+
# - a {Rect} in screen coordinates — always resolves, never lost. Anchors to an absolute position.
|
|
87
|
+
# - `nil`, the default — not anchored; {#rect_for} is handed `nil`.
|
|
88
|
+
#
|
|
89
|
+
# An anchor also earns a **second settle pass**: the pane lays out before
|
|
90
|
+
# the content its anchor sits in does, so {Screen#flush_layout} re-reads
|
|
91
|
+
# every {#anchor} once the queue is empty and runs the pane again if any
|
|
92
|
+
# now resolves elsewhere.
|
|
93
|
+
module Placement
|
|
94
|
+
# What this placement hangs off, re-read on every pass — see the
|
|
95
|
+
# *Anchors* section above.
|
|
96
|
+
# @return [Component, Rect, nil] `nil`, the default, is not anchored.
|
|
97
|
+
def anchor = nil
|
|
98
|
+
|
|
99
|
+
# The rect this placement wants for `overlay`, in screen coordinates.
|
|
100
|
+
# The pane assigns it as-is — clamping it on screen is the placement's
|
|
101
|
+
# own job.
|
|
102
|
+
# @param overlay [Overlay] the overlay being placed.
|
|
103
|
+
# @param screen_size [Size] the whole terminal.
|
|
104
|
+
# @param anchor_rect [Rect, nil] {#anchor} resolved to screen
|
|
105
|
+
# coordinates; `nil` only for an unanchored placement, since a lost
|
|
106
|
+
# anchor is not placed at all.
|
|
107
|
+
# @raise [NotImplementedError] unless the includer overrides it.
|
|
108
|
+
# @return [Rect]
|
|
109
|
+
def rect_for(overlay, screen_size, anchor_rect)
|
|
110
|
+
raise(NotImplementedError, "#{self.class}#rect_for")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Places the overlay at exactly `rect`, in screen coordinates.
|
|
115
|
+
#
|
|
116
|
+
# overlay.open(Overlay::At[Rect.new(10, 4, 20, 1)])
|
|
117
|
+
#
|
|
118
|
+
# @!attribute [r] rect
|
|
119
|
+
# @return [Rect]
|
|
120
|
+
At = Data.define(:rect) do
|
|
121
|
+
include Placement
|
|
122
|
+
|
|
123
|
+
# @param _overlay [Overlay]
|
|
124
|
+
# @param _screen_size [Size]
|
|
125
|
+
# @param _anchor_rect [Rect, nil] unused: this placement declares no {#anchor}.
|
|
126
|
+
# @return [Rect]
|
|
127
|
+
def rect_for(_overlay, _screen_size, _anchor_rect) = rect
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Centers the overlay on the screen at the size it declares
|
|
131
|
+
# ({Overlay#declared_size_in}) — {Popup}'s default.
|
|
132
|
+
Centered = Data.define do
|
|
133
|
+
include Placement
|
|
134
|
+
|
|
135
|
+
# @param overlay [Overlay]
|
|
136
|
+
# @param screen_size [Size]
|
|
137
|
+
# @param _anchor_rect [Rect, nil] unused: this placement declares no {#anchor}.
|
|
138
|
+
# @return [Rect]
|
|
139
|
+
def rect_for(overlay, screen_size, _anchor_rect)
|
|
140
|
+
size = overlay.declared_size_in(screen_size)
|
|
141
|
+
Rect.new(0, 0, size.width, size.height).centered(screen_size)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Puts the overlay in the screen's top-right corner at the size it
|
|
146
|
+
# declares ({Overlay#declared_size_in}) — {Notification}'s default.
|
|
147
|
+
TopRight = Data.define do
|
|
148
|
+
include Placement
|
|
149
|
+
|
|
150
|
+
# @param overlay [Overlay]
|
|
151
|
+
# @param screen_size [Size]
|
|
152
|
+
# @param _anchor_rect [Rect, nil] unused: this placement declares no {#anchor}.
|
|
153
|
+
# @return [Rect]
|
|
154
|
+
def rect_for(overlay, screen_size, _anchor_rect)
|
|
155
|
+
size = overlay.declared_size_in(screen_size)
|
|
156
|
+
Rect.new([screen_size.width - size.width, 0].max, 0, size.width, size.height)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
45
160
|
# @param content [Component, nil] initial content; can be set later via
|
|
46
161
|
# {#content=}. It fills the overlay's {#rect} and does not determine it.
|
|
47
162
|
# @param close_on_outside_click [Boolean] true (default) to dismiss on a
|
|
@@ -50,7 +165,6 @@ module Tuile
|
|
|
50
165
|
super()
|
|
51
166
|
@close_on_outside_click = close_on_outside_click
|
|
52
167
|
@owner = nil
|
|
53
|
-
@on_close = nil
|
|
54
168
|
@content = nil
|
|
55
169
|
self.content = content unless content.nil?
|
|
56
170
|
end
|
|
@@ -127,46 +241,74 @@ module Tuile
|
|
|
127
241
|
# @return [Component, nil]
|
|
128
242
|
attr_accessor :owner
|
|
129
243
|
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
# it
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
#
|
|
244
|
+
# What {#on_close} fires.
|
|
245
|
+
#
|
|
246
|
+
# @!attribute [r] source
|
|
247
|
+
# @return [Overlay] the overlay that left the screen.
|
|
248
|
+
CloseEvent = Data.define(:source) { include Tuile::Event }
|
|
249
|
+
|
|
250
|
+
# @!method on_close
|
|
251
|
+
# Fired with a {CloseEvent} once this overlay has left the screen —
|
|
252
|
+
# **however it left**: {#close}, a direct {Screen#remove_popup}, an
|
|
253
|
+
# outside click, or teardown via {Screen#close}. That unconditionality is
|
|
254
|
+
# the point, so it hangs off {#handle_detached} rather than {#close}; a
|
|
255
|
+
# driver keeping its own record of open overlays reconciles it here and
|
|
256
|
+
# cannot drift ({Component::MenuBar::Cascade} is the worked example).
|
|
257
|
+
#
|
|
258
|
+
# It fires *after* the overlay is detached, so {#open?} is already false
|
|
259
|
+
# and the usual {Component#handle_detached} caveats apply: release state,
|
|
260
|
+
# don't inspect the tree, keep it trivial (it may run while the pane is
|
|
261
|
+
# mid-way through closing a batch of overlays, and a raise propagates).
|
|
262
|
+
# @return [Listeners]
|
|
263
|
+
listener :on_close
|
|
264
|
+
|
|
265
|
+
# The box this overlay asks for on a screen of `screen_size` — what
|
|
266
|
+
# {Centered} and {TopRight} place. A bare overlay declares none.
|
|
267
|
+
# @param _screen_size [Size]
|
|
268
|
+
# @raise [Tuile::Error] always, here; {Popup} and {Notification} answer.
|
|
269
|
+
# @return [Size]
|
|
270
|
+
def declared_size_in(_screen_size)
|
|
271
|
+
raise Tuile::Error, "#{self.class} declares no size — place it with Overlay::At[rect]"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The placement a bare {#open} uses. A bare overlay has none, because
|
|
275
|
+
# nothing about it says where it goes.
|
|
276
|
+
# @raise [ArgumentError] always, here; {Popup} and {Notification} answer.
|
|
277
|
+
# @return [Placement]
|
|
278
|
+
def default_placement
|
|
279
|
+
raise ArgumentError, "#{self.class} has no default placement — open(Overlay::At[rect])"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Escalates to a full scene repaint when an open overlay shrinks or moves
|
|
283
|
+
# so its new rect no longer covers the cells it previously painted. An
|
|
284
|
+
# overlay overdraws the scene without clipping and nothing clears
|
|
285
|
+
# underneath it, so {Screen#repaint}'s overlay-only fast path would repaint
|
|
286
|
+
# into the new rect and leave the vacated cells showing stale content.
|
|
287
|
+
# When the new rect fully covers the old one (the overlay only grew), the
|
|
288
|
+
# fast path is correct and the full repaint is skipped.
|
|
289
|
+
# @param old_rect [Rect]
|
|
152
290
|
# @return [void]
|
|
153
|
-
def
|
|
154
|
-
old_rect = rect
|
|
291
|
+
def handle_rect_changed(old_rect)
|
|
155
292
|
super
|
|
156
|
-
screen.needs_full_repaint if open? && !
|
|
293
|
+
screen.needs_full_repaint if open? && !rect.contains_rect?(old_rect)
|
|
157
294
|
end
|
|
158
295
|
|
|
159
|
-
# Mounts this overlay on the {Screen}
|
|
296
|
+
# Mounts this overlay on the {Screen} at `placement`; the pane assigns the
|
|
297
|
+
# rect on the next settle.
|
|
160
298
|
#
|
|
161
|
-
#
|
|
299
|
+
# Component::Popup.new(content: window).open # its default: centered
|
|
300
|
+
# overlay.open(Overlay::At[Rect.new(10, 4, 20, 1)])
|
|
162
301
|
#
|
|
163
302
|
# There is deliberately no class-level `Overlay.open` factory — see
|
|
164
303
|
# `design/decisions.md` `D_popup_open`; returning `self` is what keeps the
|
|
165
304
|
# one-liner above available without one.
|
|
305
|
+
# @param placement [Placement] where it wants to be.
|
|
306
|
+
# @raise [ArgumentError] with no placement, for an overlay that has no
|
|
307
|
+
# {#default_placement}.
|
|
308
|
+
# @raise [TypeError] if `placement` does not include {Placement}.
|
|
166
309
|
# @return [self]
|
|
167
|
-
def open
|
|
168
|
-
|
|
169
|
-
screen.add_popup(self)
|
|
310
|
+
def open(placement = default_placement)
|
|
311
|
+
screen.add_popup(self, placement)
|
|
170
312
|
self
|
|
171
313
|
end
|
|
172
314
|
|
|
@@ -181,29 +323,45 @@ module Tuile
|
|
|
181
323
|
screen.has_popup?(self)
|
|
182
324
|
end
|
|
183
325
|
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
|
|
326
|
+
# Where this overlay wants to be, or `nil` while closed.
|
|
327
|
+
# @return [Placement, nil] the placement it was opened or moved with.
|
|
328
|
+
def placement = open? ? screen.pane.placement(self) : nil
|
|
329
|
+
|
|
330
|
+
# Moves the open overlay; it takes the new rect on the next settle.
|
|
331
|
+
# @param placement [Placement] see the class docs.
|
|
332
|
+
# @raise [Tuile::Error] unless open — a closed overlay takes its
|
|
333
|
+
# placement from {#open}.
|
|
334
|
+
# @raise [TypeError] if `placement` does not include {Placement}.
|
|
188
335
|
# @return [void]
|
|
189
|
-
def
|
|
336
|
+
def placement=(placement)
|
|
337
|
+
raise Tuile::Error, "#{self} is not open — pass the placement to #open" unless open?
|
|
338
|
+
|
|
339
|
+
screen.pane.constrain(self, placement)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Asks the pane to place this overlay again, because something its
|
|
343
|
+
# placement reads — its declared size, its row count — changed. A no-op
|
|
344
|
+
# while closed: {#open} places it anyway.
|
|
345
|
+
# @return [void]
|
|
346
|
+
def reposition
|
|
347
|
+
parent&.invalidate_layout
|
|
348
|
+
end
|
|
190
349
|
|
|
191
350
|
# Fires {#on_close}. A subclass overriding this **must** call `super`, or
|
|
192
351
|
# the overlay's driver never hears that it closed.
|
|
193
352
|
# @return [void]
|
|
194
353
|
def handle_detached
|
|
195
354
|
super
|
|
196
|
-
|
|
355
|
+
on_close.fire(CloseEvent.new(source: self))
|
|
197
356
|
end
|
|
198
357
|
|
|
199
358
|
protected
|
|
200
359
|
|
|
201
360
|
# Content fills the overlay's full rect — an Overlay has no border to
|
|
202
361
|
# subtract.
|
|
203
|
-
# @param content [Component]
|
|
204
362
|
# @return [void]
|
|
205
|
-
def
|
|
206
|
-
content
|
|
363
|
+
def relayout
|
|
364
|
+
content&.rect = local_rect
|
|
207
365
|
end
|
|
208
366
|
end
|
|
209
367
|
end
|
|
@@ -51,10 +51,7 @@ module Tuile
|
|
|
51
51
|
# one-glyph-per-character contract {TextField#display_text} rests on,
|
|
52
52
|
# a wide one the column axis.
|
|
53
53
|
def mask_char=(char)
|
|
54
|
-
|
|
55
|
-
raise ArgumentError, "expected one grapheme cluster, got #{char.inspect}" unless single_cluster?(char)
|
|
56
|
-
raise ArgumentError, "expected a 1-column glyph, got #{char.inspect}" unless Buffer.display_width(char) == 1
|
|
57
|
-
|
|
54
|
+
char = StyledString.validate_glyph(char, :mask_char)
|
|
58
55
|
return if @mask_char == char
|
|
59
56
|
|
|
60
57
|
@mask_char = char
|
|
@@ -91,10 +88,6 @@ module Tuile
|
|
|
91
88
|
|
|
92
89
|
private
|
|
93
90
|
|
|
94
|
-
# @param char [String]
|
|
95
|
-
# @return [Boolean]
|
|
96
|
-
def single_cluster?(char) = char.each_grapheme_cluster.take(2).size == 1
|
|
97
|
-
|
|
98
91
|
# @return [Integer] caret target for CTRL+LEFT: the start, while masked.
|
|
99
92
|
def word_left = revealed? ? super : 0
|
|
100
93
|
|
|
@@ -56,19 +56,24 @@ module Tuile
|
|
|
56
56
|
@options = options.map { Option.new(_1[0], StyledString.parse(_1[1])) }
|
|
57
57
|
@block = block
|
|
58
58
|
list = Component::List.new
|
|
59
|
-
list.renderer = ->(option) { StyledString.plain("#{option.key} ") + option.caption }
|
|
59
|
+
list.renderer = ->(option, _text_width) { StyledString.plain("#{option.key} ") + option.caption }
|
|
60
60
|
list.items = @options
|
|
61
61
|
list.cursor = Component::List::Cursor.new
|
|
62
|
-
list.on_item_chosen
|
|
62
|
+
list.on_item_chosen { |e| select_option(e.item.key) }
|
|
63
63
|
self.content = list
|
|
64
|
-
# Optional hook for a containing Popup to dismiss itself after a pick.
|
|
65
|
-
@on_pick = nil
|
|
66
64
|
end
|
|
67
65
|
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
66
|
+
# What {#on_pick} fires.
|
|
67
|
+
#
|
|
68
|
+
# @!attribute [r] source
|
|
69
|
+
# @return [PickerWindow] the picker whose option was chosen.
|
|
70
|
+
PickEvent = Data.define(:source) { include Tuile::Event }
|
|
71
|
+
|
|
72
|
+
# @!method on_pick
|
|
73
|
+
# Fired with a {PickEvent} after the user picks an option, after the
|
|
74
|
+
# block fires. The {Popup} returned by {.open} registers its own `close`.
|
|
75
|
+
# @return [Listeners]
|
|
76
|
+
listener :on_pick
|
|
72
77
|
|
|
73
78
|
# Handles an option-key press. Reached by bubbling: the inner {List}
|
|
74
79
|
# (the focused component) sees the key first and handles cursor/Enter
|
|
@@ -96,7 +101,7 @@ module Tuile
|
|
|
96
101
|
def self.open(caption, options, &block)
|
|
97
102
|
picker = PickerWindow.new(caption, options, &block)
|
|
98
103
|
popup = Popup.new(content: picker)
|
|
99
|
-
picker.on_pick
|
|
104
|
+
picker.on_pick { popup.close }
|
|
100
105
|
popup.open
|
|
101
106
|
end
|
|
102
107
|
|
|
@@ -106,7 +111,7 @@ module Tuile
|
|
|
106
111
|
# @return [void]
|
|
107
112
|
def select_option(key)
|
|
108
113
|
@block.call(key)
|
|
109
|
-
|
|
114
|
+
on_pick.fire(PickEvent.new(source: self))
|
|
110
115
|
end
|
|
111
116
|
end
|
|
112
117
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
# A modal dialog: an {Component::Overlay}
|
|
5
|
+
# A modal dialog: an {Component::Overlay} centered on the screen, which
|
|
6
6
|
# grabs focus, scopes keys to its own subtree, blocks clicks beneath it, and
|
|
7
7
|
# closes on ESC or `q`.
|
|
8
8
|
#
|
|
@@ -34,7 +34,7 @@ module Tuile
|
|
|
34
34
|
#
|
|
35
35
|
# A left click *outside* the popup closes it too — see
|
|
36
36
|
# {Overlay#close_on_outside_click?} for the exact contract and
|
|
37
|
-
# {Overlay#on_close
|
|
37
|
+
# {Overlay#on_close} for the notice a driver hears when it happens.
|
|
38
38
|
#
|
|
39
39
|
# UI-thread-confined, like every component (see {Screen}).
|
|
40
40
|
class Popup < Overlay
|
|
@@ -50,7 +50,6 @@ module Tuile
|
|
|
50
50
|
def initialize(content: nil, declared_size: Fraction::HALF, close_on_outside_click: true)
|
|
51
51
|
super(content: content, close_on_outside_click: close_on_outside_click)
|
|
52
52
|
@declared_size = declared_size
|
|
53
|
-
reposition
|
|
54
53
|
end
|
|
55
54
|
|
|
56
55
|
# The box this popup asks for, *as set* — a {Fraction} comes back
|
|
@@ -69,9 +68,9 @@ module Tuile
|
|
|
69
68
|
# and focus repair falls back to it when its subtree has no tab stop.
|
|
70
69
|
def focusable? = true
|
|
71
70
|
|
|
72
|
-
# Sets the popup's box
|
|
73
|
-
# against the screen every layout pass, so
|
|
74
|
-
# absolute {Size} (clamped to the screen). This is **authoritative**, not
|
|
71
|
+
# Sets the popup's box; an open popup takes it on the next settle.
|
|
72
|
+
# Accepts a {Fraction} (resolved against the screen every layout pass, so
|
|
73
|
+
# it tracks resize) or an absolute {Size} (clamped to the screen). This is **authoritative**, not
|
|
75
74
|
# a preference: the screen applies exactly what you ask for (clamped),
|
|
76
75
|
# with no negotiation — a popup has no siblings to compete with.
|
|
77
76
|
# @param new_size [Size, Fraction]
|
|
@@ -81,26 +80,16 @@ module Tuile
|
|
|
81
80
|
reposition
|
|
82
81
|
end
|
|
83
82
|
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
# the origin and then centering: the intermediate origin rect rarely covers
|
|
91
|
-
# the previous one, which would make {Overlay#rect=}'s shrink/move
|
|
92
|
-
# detection fire a full repaint on every resize.
|
|
93
|
-
# @return [void]
|
|
94
|
-
def reposition
|
|
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)
|
|
83
|
+
# {#declared_size} resolved against `screen_size`: a {Fraction} scaled, a
|
|
84
|
+
# {Size} clamped.
|
|
85
|
+
# @param screen_size [Size]
|
|
86
|
+
# @return [Size]
|
|
87
|
+
def declared_size_in(screen_size)
|
|
88
|
+
@declared_size.is_a?(Fraction) ? @declared_size.resolve(screen_size) : @declared_size.clamp(screen_size)
|
|
97
89
|
end
|
|
98
90
|
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
def center
|
|
102
|
-
self.rect = rect.centered(screen.size)
|
|
103
|
-
end
|
|
91
|
+
# @return [Overlay::Centered] a popup opens centered.
|
|
92
|
+
def default_placement = Centered[]
|
|
104
93
|
|
|
105
94
|
# `q` and ESC close the popup. The popup sits on the focus chain of
|
|
106
95
|
# whatever it wraps, so the key reaches here by bubbling up from the
|
|
@@ -12,10 +12,9 @@ module Tuile
|
|
|
12
12
|
# add(bar)
|
|
13
13
|
# add(label)
|
|
14
14
|
#
|
|
15
|
-
# def
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# label.rect = Rect.new(rect.left, rect.top + 1, rect.width, 1)
|
|
15
|
+
# private def relayout # the enclosing Layout positions both
|
|
16
|
+
# bar.rect = Rect.new(0, 0, width, 1)
|
|
17
|
+
# label.rect = Rect.new(0, 1, width, 1)
|
|
19
18
|
# end
|
|
20
19
|
#
|
|
21
20
|
# bar.value = done
|
|
@@ -209,12 +208,13 @@ module Tuile
|
|
|
209
208
|
# *whole* rect, which dirties every cell of the bar's own row before it is
|
|
210
209
|
# painted over — so {Buffer#flush} re-emits the entire row every frame
|
|
211
210
|
# instead of the one or two cells that actually moved.
|
|
211
|
+
# @param canvas [Canvas] see {Component#repaint}.
|
|
212
212
|
# @return [void]
|
|
213
|
-
def repaint
|
|
213
|
+
def repaint(canvas)
|
|
214
214
|
return if rect.empty?
|
|
215
215
|
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
canvas.set_text(0, 0, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
|
|
217
|
+
canvas.fill(Rect.new(0, 1, rect.width, rect.height - 1)) if rect.height > 1
|
|
218
218
|
end
|
|
219
219
|
|
|
220
220
|
private
|
|
@@ -12,7 +12,7 @@ module Tuile
|
|
|
12
12
|
#
|
|
13
13
|
# rg = Component::RadioGroup.new(items: %w[Ascending Descending Unsorted])
|
|
14
14
|
# rg.value = "Descending" # or seed it via the ctor
|
|
15
|
-
# rg.on_value_change
|
|
15
|
+
# rg.on_value_change { |e| resort(e.value) }
|
|
16
16
|
# rg.value # => "Descending"
|
|
17
17
|
# rg.item_label = ->(o) { o.title } # default :to_s
|
|
18
18
|
#
|
|
@@ -72,13 +72,12 @@ module Tuile
|
|
|
72
72
|
super()
|
|
73
73
|
@item_label = :to_s.to_proc
|
|
74
74
|
@value = value
|
|
75
|
-
@on_value_change = nil
|
|
76
75
|
|
|
77
76
|
list = List.new
|
|
78
77
|
# A List has no cursor at all by default (Cursor::None, position -1).
|
|
79
78
|
list.cursor = List::Cursor.new
|
|
80
79
|
list.renderer = method(:render_row)
|
|
81
|
-
list.on_item_chosen
|
|
80
|
+
list.on_item_chosen { |e| set_value(e.item, from_user: true) }
|
|
82
81
|
list.items = items.to_a
|
|
83
82
|
@list = list
|
|
84
83
|
add_child(list, at: 0)
|
|
@@ -93,12 +92,8 @@ module Tuile
|
|
|
93
92
|
# @return [List]
|
|
94
93
|
attr_reader :list
|
|
95
94
|
|
|
96
|
-
# @param new_rect [Rect]
|
|
97
95
|
# @return [void]
|
|
98
|
-
def rect=
|
|
99
|
-
super
|
|
100
|
-
list.rect = rect
|
|
101
|
-
end
|
|
96
|
+
def relayout = list.rect = local_rect
|
|
102
97
|
|
|
103
98
|
# @return [void]
|
|
104
99
|
def handle_focus
|
|
@@ -140,9 +135,10 @@ module Tuile
|
|
|
140
135
|
# changed. The cursor stays where it is.
|
|
141
136
|
# @param new_value [Object, nil] `nil` selects nothing; an item outside
|
|
142
137
|
# {#items} is kept but renders no marked row.
|
|
138
|
+
# @param from_user [Boolean] see {HasValue#set_value}.
|
|
143
139
|
# @return [void]
|
|
144
|
-
def
|
|
145
|
-
# HasValue#
|
|
140
|
+
def set_value(new_value, from_user:)
|
|
141
|
+
# HasValue#set_value no-ops on an unchanged value; this guard is what also
|
|
146
142
|
# skips the row rebuild.
|
|
147
143
|
return if value == new_value
|
|
148
144
|
|
|
@@ -172,14 +168,16 @@ module Tuile
|
|
|
172
168
|
def select_at(index)
|
|
173
169
|
return unless index.between?(0, items.size - 1)
|
|
174
170
|
|
|
175
|
-
|
|
171
|
+
set_value(items[index], from_user: true)
|
|
176
172
|
end
|
|
177
173
|
|
|
178
174
|
# @param item [Object]
|
|
175
|
+
# @param _text_width [Integer] unused: a marker plus a label is as wide as
|
|
176
|
+
# it is, and {List} ellipsizes what will not fit.
|
|
179
177
|
# @return [StyledString] the item's row: its label behind a selection
|
|
180
178
|
# marker. The {List} calls this at paint time, so the marker tracks
|
|
181
179
|
# {#value} without re-rendering anything but the visible rows.
|
|
182
|
-
def render_row(item)
|
|
180
|
+
def render_row(item, _text_width)
|
|
183
181
|
StyledString.plain(item == value ? "(*) " : "( ) ") + label_for(item)
|
|
184
182
|
end
|
|
185
183
|
|