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
|
@@ -9,12 +9,11 @@ module Tuile
|
|
|
9
9
|
# highlight, and reads the pick.
|
|
10
10
|
#
|
|
11
11
|
# drop = Component::ListDropdown.new
|
|
12
|
-
# drop.renderer =
|
|
13
|
-
# drop.on_item_chosen
|
|
12
|
+
# drop.renderer = ->(item, _w) { label_for(item) } # caller renders
|
|
13
|
+
# drop.list.on_item_chosen { |e| commit(e.item) } # caller commits
|
|
14
14
|
# # …then, from the driver's key handler:
|
|
15
15
|
# drop.items = matches # caller filters
|
|
16
|
-
# drop.anchor_to(
|
|
17
|
-
# drop.open
|
|
16
|
+
# drop.anchor_to(self) # opens it below the driver, or flipped
|
|
18
17
|
# return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
|
|
19
18
|
# drop.choose if key == Keys::ENTER # commit the highlight
|
|
20
19
|
#
|
|
@@ -64,42 +63,120 @@ module Tuile
|
|
|
64
63
|
# @return [Integer]
|
|
65
64
|
MAX_VISIBLE_ROWS = 10
|
|
66
65
|
|
|
66
|
+
# The placement {#anchor_to} and {#anchor_beside} open the dropdown with:
|
|
67
|
+
# hung off `anchor`, `side` `:below` or `:beside` it, as tall as its rows
|
|
68
|
+
# up to `max_rows`. The pane re-reads the anchor on every pass and again
|
|
69
|
+
# once the content has settled, so the panel follows a field that moves.
|
|
70
|
+
#
|
|
71
|
+
# @!attribute [r] anchor
|
|
72
|
+
# @return [Component, Rect] {Overlay::Placement#anchor}: a component,
|
|
73
|
+
# followed for as long as it is on screen, or a fixed rect in screen
|
|
74
|
+
# coordinates.
|
|
75
|
+
# @!attribute [r] side
|
|
76
|
+
# @return [Symbol] `:below` or `:beside`.
|
|
77
|
+
# @!attribute [r] width
|
|
78
|
+
# @return [Integer, #call, nil] columns, or something answering them when
|
|
79
|
+
# called; `nil` takes the anchor's width.
|
|
80
|
+
# @!attribute [r] max_rows
|
|
81
|
+
# @return [Integer] rows shown before the list scrolls.
|
|
82
|
+
Anchored = Data.define(:anchor, :side, :width, :max_rows) do
|
|
83
|
+
include Overlay::Placement
|
|
84
|
+
|
|
85
|
+
# @param drop [ListDropdown]
|
|
86
|
+
# @param screen_size [Size]
|
|
87
|
+
# @param anchor_rect [Rect] {#anchor} in screen coordinates, resolved by the pane.
|
|
88
|
+
# @return [Rect]
|
|
89
|
+
def rect_for(drop, screen_size, anchor_rect)
|
|
90
|
+
columns = width.nil? ? anchor_rect.width : width
|
|
91
|
+
columns = [columns.respond_to?(:call) ? columns.call : columns, screen_size.width].min
|
|
92
|
+
if side == :below
|
|
93
|
+
below(anchor_rect, drop.items.size, columns, screen_size)
|
|
94
|
+
else
|
|
95
|
+
beside(anchor_rect, drop.items.size, columns, screen_size)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Beneath the anchor, flipped above when the rows won't fit below,
|
|
102
|
+
# clamped — with the list scrolling — when neither side has room; the
|
|
103
|
+
# left edges line up, sliding left only far enough to stay on screen.
|
|
104
|
+
# @param anchor [Rect]
|
|
105
|
+
# @param rows [Integer]
|
|
106
|
+
# @param width [Integer]
|
|
107
|
+
# @param screen_size [Size]
|
|
108
|
+
# @return [Rect]
|
|
109
|
+
def below(anchor, rows, width, screen_size)
|
|
110
|
+
desired = [rows, max_rows].min
|
|
111
|
+
beneath = anchor.top + anchor.height
|
|
112
|
+
room_below = screen_size.height - beneath
|
|
113
|
+
if desired <= room_below
|
|
114
|
+
top = beneath
|
|
115
|
+
height = desired
|
|
116
|
+
elsif anchor.top >= room_below
|
|
117
|
+
height = [desired, anchor.top].min
|
|
118
|
+
top = anchor.top - height
|
|
119
|
+
else
|
|
120
|
+
height = room_below
|
|
121
|
+
top = beneath
|
|
122
|
+
end
|
|
123
|
+
Rect.new([anchor.left, screen_size.width - width].min.clamp(0, nil), top, width, height)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Against the anchor's right edge, flipped to its left when the right
|
|
127
|
+
# has no room; the first row lines up with the anchor, sliding up only
|
|
128
|
+
# far enough to stay on screen.
|
|
129
|
+
# @param anchor [Rect]
|
|
130
|
+
# @param rows [Integer]
|
|
131
|
+
# @param width [Integer]
|
|
132
|
+
# @param screen_size [Size]
|
|
133
|
+
# @return [Rect]
|
|
134
|
+
def beside(anchor, rows, width, screen_size)
|
|
135
|
+
height = [rows, max_rows, screen_size.height].min
|
|
136
|
+
right = anchor.left + anchor.width
|
|
137
|
+
left = if right + width <= screen_size.width || (anchor.left - width).negative?
|
|
138
|
+
right
|
|
139
|
+
else
|
|
140
|
+
anchor.left - width
|
|
141
|
+
end
|
|
142
|
+
left = left.clamp(0, [screen_size.width - width, 0].max)
|
|
143
|
+
top = [anchor.top, screen_size.height - height].min.clamp(0, nil)
|
|
144
|
+
Rect.new(left, top, width, height)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
67
148
|
def initialize
|
|
68
149
|
@list = Menu.new
|
|
69
150
|
@list.cursor = List::Cursor.new
|
|
70
151
|
@list.show_cursor_when_inactive = true # highlight the selection though focus stays on the driver
|
|
152
|
+
@list.scrollbar_visibility = :auto # the list fills the panel, so the bar is on exactly when the rows outrun it
|
|
71
153
|
super(content: @list)
|
|
72
154
|
self.bg_color = Theme.ref(:input_bg_color)
|
|
73
155
|
end
|
|
74
156
|
|
|
75
157
|
# @param items [Array] the items to show, one row each; see {List#items=}.
|
|
158
|
+
# An open dropdown resizes to the new count on the next settle.
|
|
76
159
|
# @return [void]
|
|
77
160
|
def items=(items)
|
|
78
161
|
@list.items = items
|
|
162
|
+
reposition
|
|
79
163
|
end
|
|
80
164
|
|
|
81
165
|
# @return [Array] the items currently shown.
|
|
82
166
|
def items = @list.items
|
|
83
167
|
|
|
84
|
-
# @param proc [Proc, Method] item -> row
|
|
168
|
+
# @param proc [Proc, Method] `(item, text_width) -> row`; see {List#renderer}.
|
|
85
169
|
# @return [void]
|
|
86
170
|
def renderer=(proc)
|
|
87
171
|
@list.renderer = proc
|
|
88
172
|
end
|
|
89
173
|
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# @param proc [Proc, Method, nil] highlight-moved callback; see
|
|
97
|
-
# {List#on_cursor_changed}. A cascading driver needs it to drop the
|
|
98
|
-
# panels that belonged to the row the highlight just left.
|
|
99
|
-
# @return [void]
|
|
100
|
-
def on_cursor_changed=(proc)
|
|
101
|
-
@list.on_cursor_changed = proc
|
|
102
|
-
end
|
|
174
|
+
# The wrapped list, exposed so a driver can register on {List#on_item_chosen}
|
|
175
|
+
# (its commit) and {List#on_cursor_changed} (a cascading driver drops the
|
|
176
|
+
# panels belonging to the row the highlight just left). A driver tunes it
|
|
177
|
+
# but never supplies it (`D_has_content`).
|
|
178
|
+
# @return [List]
|
|
179
|
+
attr_reader :list
|
|
103
180
|
|
|
104
181
|
# @param cursor [List::Cursor] the highlight; see {List#cursor=}.
|
|
105
182
|
# @return [void]
|
|
@@ -117,13 +194,14 @@ module Tuile
|
|
|
117
194
|
# @return [Boolean] whether the highlight moved there.
|
|
118
195
|
def select(index) = @list.select(index)
|
|
119
196
|
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
# up, sliding left only far enough to
|
|
197
|
+
# Opens the dropdown against `anchor` — directly beneath it, flipped above
|
|
198
|
+
# when its rows won't fit below, clamped (with the list scrolling) when
|
|
199
|
+
# neither side has room — or moves it there if it is open already.
|
|
200
|
+
# Horizontally the left edges line up, sliding left only far enough to
|
|
201
|
+
# keep the panel on screen.
|
|
124
202
|
#
|
|
125
|
-
# drop.anchor_to(
|
|
126
|
-
# drop.anchor_to(
|
|
203
|
+
# drop.anchor_to(self) # follows the field, its width
|
|
204
|
+
# drop.anchor_to(self, width: method(:menu_width))
|
|
127
205
|
#
|
|
128
206
|
# Vertical flips but horizontal slides because covering the driver would
|
|
129
207
|
# hide what is being chosen, while sharing its columns is the point.
|
|
@@ -131,49 +209,34 @@ module Tuile
|
|
|
131
209
|
# **`anchor` is the region actually occupied, and may be taller than one
|
|
132
210
|
# row** — "beneath" means the row *after* it, so a multi-row driver (a
|
|
133
211
|
# {Component::TextArea} carrying an autocomplete menu) is cleared entirely
|
|
134
|
-
# rather than overdrawn from its second row down. A
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
# the
|
|
212
|
+
# rather than overdrawn from its second row down. A component anchor is
|
|
213
|
+
# read through its {Component#absolute_extent_rect}, so a widget that
|
|
214
|
+
# paints one row but is *assigned* more height ({ComboBox}, {Select})
|
|
215
|
+
# hangs the panel off its face.
|
|
216
|
+
#
|
|
217
|
+
# The height is the item count, capped at `max_rows`, and follows
|
|
218
|
+
# {#items=}. Settles before it returns, so a driver can forward a key to
|
|
219
|
+
# the list, or read {#cursor_row_rect}, in the same handler.
|
|
138
220
|
#
|
|
139
|
-
# @param anchor [Rect] the
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
# than the screen clips — {List} has no horizontal scrolling.
|
|
221
|
+
# @param anchor [Component, Rect] the driver, followed wherever it moves,
|
|
222
|
+
# or a fixed region in screen coordinates. Of any height; the dropdown
|
|
223
|
+
# never covers it.
|
|
224
|
+
# @param width [Integer, #call, nil] the panel's width in columns, clamped
|
|
225
|
+
# to the screen, or something answering it; `nil` (the default) takes
|
|
226
|
+
# the anchor's, which lines both edges up with a field. A driver that
|
|
227
|
+
# measures its labels passes its own. A label wider than the screen
|
|
228
|
+
# clips — {List} has no horizontal scrolling.
|
|
148
229
|
# @param max_rows [Integer] rows shown before the list scrolls.
|
|
149
230
|
# @return [void]
|
|
150
|
-
def anchor_to(anchor,
|
|
151
|
-
|
|
152
|
-
beneath = anchor.top + anchor.height
|
|
153
|
-
below = screen.size.height - beneath
|
|
154
|
-
above = anchor.top
|
|
155
|
-
if desired <= below
|
|
156
|
-
top = beneath
|
|
157
|
-
height = desired
|
|
158
|
-
elsif above >= below
|
|
159
|
-
height = [desired, above].min
|
|
160
|
-
top = anchor.top - height
|
|
161
|
-
else
|
|
162
|
-
height = below
|
|
163
|
-
top = beneath
|
|
164
|
-
end
|
|
165
|
-
width = [width, screen.size.width].min
|
|
166
|
-
self.rect = Rect.new([anchor.left, screen.size.width - width].min.clamp(0, nil), top, width, height)
|
|
167
|
-
# After the geometry: the setter rebuilds the list's padded rows against
|
|
168
|
-
# the width it can see, and the gutter takes a column off it.
|
|
169
|
-
@list.scrollbar_visibility = rows > height ? :visible : :gone
|
|
231
|
+
def anchor_to(anchor, width: nil, max_rows: MAX_VISIBLE_ROWS)
|
|
232
|
+
anchor_with(Anchored.new(anchor:, side: :below, width:, max_rows:))
|
|
170
233
|
end
|
|
171
234
|
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
235
|
+
# Opens the dropdown *beside* `anchor` — the placement a cascading submenu
|
|
236
|
+
# wants, where {#anchor_to} is the placement a field's dropdown wants —
|
|
237
|
+
# or moves it there if it is open already.
|
|
175
238
|
#
|
|
176
|
-
# sub.anchor_beside(parent.cursor_row_rect,
|
|
239
|
+
# sub.anchor_beside(parent.cursor_row_rect, width: measured)
|
|
177
240
|
#
|
|
178
241
|
# Horizontally it sits against `anchor`'s right edge, **flipping** to its
|
|
179
242
|
# left when the right has no room (and clamping to the screen when neither
|
|
@@ -186,38 +249,22 @@ module Tuile
|
|
|
186
249
|
# submenu must not cover its parent panel, so it flips *horizontally* and
|
|
187
250
|
# shares its rows.
|
|
188
251
|
#
|
|
189
|
-
# @param anchor [Rect] the row the submenu belongs to
|
|
190
|
-
# parent dropdown's {#cursor_row_rect}.
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
# collapses the dropdown to an empty rect (drivers close instead).
|
|
195
|
-
# @param width [Integer] the panel's width in columns, clamped to the
|
|
196
|
-
# screen. **Required, with no default:** `anchor.width` is the *parent's*
|
|
197
|
-
# width and would be meaningless here, so the caller measures (see
|
|
252
|
+
# @param anchor [Rect, Component] the row the submenu belongs to, in screen
|
|
253
|
+
# coordinates — typically the parent dropdown's {#cursor_row_rect}.
|
|
254
|
+
# @param width [Integer, #call] the panel's width in columns, clamped to
|
|
255
|
+
# the screen. **Required, with no default:** the anchor's width is the
|
|
256
|
+
# *parent's* and would be meaningless here, so the caller measures (see
|
|
198
257
|
# `design/decisions.md` `D_select` on why the width policy stays with the
|
|
199
258
|
# driver).
|
|
200
259
|
# @param max_rows [Integer] rows shown before the list scrolls.
|
|
201
260
|
# @return [void]
|
|
202
|
-
def anchor_beside(anchor,
|
|
203
|
-
|
|
204
|
-
width = [width, screen.size.width].min
|
|
205
|
-
right = anchor.left + anchor.width
|
|
206
|
-
left = if right + width <= screen.size.width || (anchor.left - width).negative?
|
|
207
|
-
right
|
|
208
|
-
else
|
|
209
|
-
anchor.left - width
|
|
210
|
-
end
|
|
211
|
-
left = left.clamp(0, [screen.size.width - width, 0].max)
|
|
212
|
-
top = [anchor.top, screen.size.height - height].min.clamp(0, nil)
|
|
213
|
-
self.rect = Rect.new(left, top, width, height)
|
|
214
|
-
# After the geometry, as in {#anchor_to}: the setter rebuilds the list's
|
|
215
|
-
# padded rows against the width it can see.
|
|
216
|
-
@list.scrollbar_visibility = rows > height ? :visible : :gone
|
|
261
|
+
def anchor_beside(anchor, width:, max_rows: MAX_VISIBLE_ROWS)
|
|
262
|
+
anchor_with(Anchored.new(anchor:, side: :beside, width:, max_rows:))
|
|
217
263
|
end
|
|
218
264
|
|
|
219
|
-
# The highlighted row's rect on screen — what a cascading submenu
|
|
220
|
-
# against, via {#anchor_beside}.
|
|
265
|
+
# The highlighted row's rect **on screen** — what a cascading submenu
|
|
266
|
+
# anchors against, via {#anchor_beside}. Screen coordinates because the
|
|
267
|
+
# submenu is a sibling overlay rather than a child (`D_relative_rect`).
|
|
221
268
|
#
|
|
222
269
|
# It lives here rather than in the driver because {ListDropdown} owns the
|
|
223
270
|
# list's geometry: a driver computing `top + position - scroll_top_row`
|
|
@@ -232,7 +279,7 @@ module Tuile
|
|
|
232
279
|
row = @list.cursor.position - @list.scroll_top_row
|
|
233
280
|
return nil unless row.between?(0, @list.rect.height - 1)
|
|
234
281
|
|
|
235
|
-
Rect.new(
|
|
282
|
+
Rect.new(0, 0, @list.rect.width, 1).at(@list.to_screen(Point.new(0, row)))
|
|
236
283
|
end
|
|
237
284
|
|
|
238
285
|
# Forwards a cursor-movement key to the list. The driver calls this from
|
|
@@ -254,6 +301,19 @@ module Tuile
|
|
|
254
301
|
# @return [Boolean] true iff a row was chosen (false when the cursor is
|
|
255
302
|
# off-content).
|
|
256
303
|
def choose = @list.handle_key?(Keys::ENTER)
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
# Opens or moves the panel and settles the pass, because both anchor
|
|
308
|
+
# methods promise a panel that *is* placed: a driver reads
|
|
309
|
+
# {#cursor_row_rect} or forwards a key to the list in the same handler,
|
|
310
|
+
# and both measure rects this just assigned.
|
|
311
|
+
# @param placement [Anchored]
|
|
312
|
+
# @return [void]
|
|
313
|
+
def anchor_with(placement)
|
|
314
|
+
open? ? self.placement = placement : self.open(placement)
|
|
315
|
+
flush_layout
|
|
316
|
+
end
|
|
257
317
|
end
|
|
258
318
|
end
|
|
259
319
|
end
|
|
@@ -8,9 +8,14 @@ module Tuile
|
|
|
8
8
|
# machinery of {MenuBar}; an app never names it.
|
|
9
9
|
#
|
|
10
10
|
# cascade.open_below(segment_rect, item) # Enter/Down on the strip
|
|
11
|
+
# cascade.step_to(segment_rect, item) # LEFT/RIGHT along the strip
|
|
11
12
|
# return true if cascade.handle_key?(key) # MenuBar#handle_key?, first
|
|
12
13
|
# cascade.close # focus lost, or rect changed
|
|
13
14
|
#
|
|
15
|
+
# It also holds the bar's *menu mode* ({#browsing?}), which outlives the
|
|
16
|
+
# panels: stepping onto a top-level item with no menu shows nothing and
|
|
17
|
+
# stays in the mode, so the next step opens its neighbour's menu again.
|
|
18
|
+
#
|
|
14
19
|
# A panel is a **non-modal overlay, not a child**, so it never takes focus:
|
|
15
20
|
# focus stays on the {MenuBar} for the whole interaction and every key
|
|
16
21
|
# arrives via {MenuBar#handle_key?}, which offers it here first. That is
|
|
@@ -24,9 +29,10 @@ module Tuile
|
|
|
24
29
|
# == Implementation details
|
|
25
30
|
# While open it consumes **everything** except the two keys that mean
|
|
26
31
|
# "leave this menu sideways", which only the strip can answer: LEFT at
|
|
27
|
-
# depth 1, and RIGHT
|
|
28
|
-
#
|
|
29
|
-
#
|
|
32
|
+
# depth 1, and RIGHT with no submenu under the highlight — a panel opened
|
|
33
|
+
# with `highlight: false` has none at all, so it declines RIGHT throughout.
|
|
34
|
+
# An open menu is quasi-modal — firing an app's `s`-to-save behind a
|
|
35
|
+
# visible panel is worse than a dead keystroke.
|
|
30
36
|
#
|
|
31
37
|
# UI-thread-confined, like everything in the tree (see {Screen}).
|
|
32
38
|
class Cascade
|
|
@@ -44,29 +50,50 @@ module Tuile
|
|
|
44
50
|
|
|
45
51
|
def initialize
|
|
46
52
|
@levels = []
|
|
53
|
+
@browsing = false
|
|
47
54
|
end
|
|
48
55
|
|
|
49
56
|
# @return [Boolean] whether any panel is open.
|
|
50
57
|
def open? = !@levels.empty?
|
|
51
58
|
|
|
59
|
+
# @return [Boolean] whether the bar is in *menu mode* — which is not
|
|
60
|
+
# {#open?}: stepping onto a top-level item with no menu keeps the mode
|
|
61
|
+
# with no panel to show for it. Cleared with the last panel, however
|
|
62
|
+
# it went.
|
|
63
|
+
def browsing? = @browsing
|
|
64
|
+
|
|
52
65
|
# @return [Integer] how many panels are open; `0` when closed.
|
|
53
66
|
def depth = @levels.size
|
|
54
67
|
|
|
55
|
-
# Opens `item`'s children directly beneath `anchor`,
|
|
56
|
-
# already open first.
|
|
68
|
+
# Opens `item`'s children directly beneath `anchor`, first row
|
|
69
|
+
# highlighted, closing anything already open first.
|
|
57
70
|
# @param anchor [Rect] the strip segment the menu drops from.
|
|
58
|
-
# @param item [Item] a childless one opens nothing
|
|
71
|
+
# @param item [Item] a childless one opens nothing and leaves menu mode
|
|
72
|
+
# off: Enter on a top-level button is not menu navigation.
|
|
59
73
|
# @return [void]
|
|
60
|
-
def open_below(anchor, item)
|
|
61
|
-
close
|
|
62
|
-
return unless item.submenu?
|
|
74
|
+
def open_below(anchor, item) = show(anchor, item, highlight: true)
|
|
63
75
|
|
|
64
|
-
|
|
76
|
+
# Shows `item`'s children beneath `anchor` with no row highlighted — the
|
|
77
|
+
# sideways step along the strip. Menu mode survives a childless `item`,
|
|
78
|
+
# which is the whole difference from {#open_below}: walking past a
|
|
79
|
+
# top-level button must not end the walk.
|
|
80
|
+
# @param anchor [Rect] the strip segment the menu drops from.
|
|
81
|
+
# @param item [Item]
|
|
82
|
+
# @return [void]
|
|
83
|
+
def step_to(anchor, item)
|
|
84
|
+
show(anchor, item, highlight: false)
|
|
85
|
+
@browsing = true
|
|
65
86
|
end
|
|
66
87
|
|
|
67
|
-
# Closes every open panel, deepest first.
|
|
88
|
+
# Closes every open panel, deepest first, and leaves menu mode.
|
|
89
|
+
#
|
|
90
|
+
# The flag earns its own line: menu mode outlives the panels, so at the
|
|
91
|
+
# panel-less stop `truncate` has nothing to close and nothing to notify.
|
|
68
92
|
# @return [void]
|
|
69
|
-
def close
|
|
93
|
+
def close
|
|
94
|
+
truncate(0)
|
|
95
|
+
@browsing = false
|
|
96
|
+
end
|
|
70
97
|
|
|
71
98
|
# Offers a key to the deepest panel and to the cascade's own verbs.
|
|
72
99
|
# @param key [String]
|
|
@@ -75,6 +102,7 @@ module Tuile
|
|
|
75
102
|
# (see the class docs).
|
|
76
103
|
def handle_key?(key)
|
|
77
104
|
return false unless open?
|
|
105
|
+
return true if enter_panel?(key)
|
|
78
106
|
return true if deepest.move(key)
|
|
79
107
|
|
|
80
108
|
case key
|
|
@@ -120,6 +148,41 @@ module Tuile
|
|
|
120
148
|
|
|
121
149
|
private
|
|
122
150
|
|
|
151
|
+
# The body both entry verbs share: drop what is open, then mount a panel
|
|
152
|
+
# for `item`'s children if it has any.
|
|
153
|
+
# @param anchor [Rect]
|
|
154
|
+
# @param item [Item]
|
|
155
|
+
# @param highlight [Boolean] whether the panel's first row starts highlighted.
|
|
156
|
+
# @return [void]
|
|
157
|
+
def show(anchor, item, highlight:)
|
|
158
|
+
close
|
|
159
|
+
return unless item.submenu?
|
|
160
|
+
|
|
161
|
+
@browsing = true
|
|
162
|
+
push(item, highlight: highlight) { |drop, width| drop.anchor_to(anchor, width: width) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Moves into a panel opened with no highlighted row — Down, Enter and
|
|
166
|
+
# Space onto its first row, Up onto its last. Every other key leaves the
|
|
167
|
+
# empty highlight alone, RIGHT above all: it falls through to be
|
|
168
|
+
# declined, which is what lets a sideways step keep stepping.
|
|
169
|
+
#
|
|
170
|
+
# Up is answered here rather than by {ListDropdown#move}, which would
|
|
171
|
+
# clamp backwards onto the *first* row ({List::Cursor#go} floors at `0`).
|
|
172
|
+
# @param key [String]
|
|
173
|
+
# @return [Boolean] whether it moved in.
|
|
174
|
+
def enter_panel?(key)
|
|
175
|
+
level = depth - 1
|
|
176
|
+
return false unless highlighted(level).nil?
|
|
177
|
+
|
|
178
|
+
item, drop = @levels[level]
|
|
179
|
+
case key
|
|
180
|
+
when *Keys::DOWN_ARROWS, Keys::ENTER, " " then drop.select(0)
|
|
181
|
+
when *Keys::UP_ARROWS then drop.select(item.items.size - 1)
|
|
182
|
+
else false
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
123
186
|
# @return [ListDropdown] the deepest open panel.
|
|
124
187
|
def deepest = @levels.last[1]
|
|
125
188
|
|
|
@@ -144,7 +207,7 @@ module Tuile
|
|
|
144
207
|
# doesn't paint it under a menu. A listener-less leaf still closes:
|
|
145
208
|
# activation stays uniform.
|
|
146
209
|
close
|
|
147
|
-
item.on_click
|
|
210
|
+
item.on_click.fire(Item::ClickEvent.new(source: item))
|
|
148
211
|
end
|
|
149
212
|
end
|
|
150
213
|
|
|
@@ -156,27 +219,29 @@ module Tuile
|
|
|
156
219
|
anchor = @levels[level][1].cursor_row_rect
|
|
157
220
|
return if anchor.nil?
|
|
158
221
|
|
|
159
|
-
push(item) { |drop,
|
|
222
|
+
push(item) { |drop, width| drop.anchor_beside(anchor, width: width) }
|
|
160
223
|
end
|
|
161
224
|
|
|
162
|
-
#
|
|
225
|
+
# Builds a panel for `item`'s children and yields it to be anchored,
|
|
226
|
+
# which is what opens it.
|
|
163
227
|
# @param item [Item]
|
|
228
|
+
# @param highlight [Boolean] `false` parks the cursor off content, so
|
|
229
|
+
# nothing is highlighted.
|
|
164
230
|
# @yieldparam drop [ListDropdown]
|
|
165
|
-
# @yieldparam rows [Integer]
|
|
166
231
|
# @yieldparam width [Integer]
|
|
167
232
|
# @return [void]
|
|
168
|
-
def push(item)
|
|
233
|
+
def push(item, highlight: true)
|
|
169
234
|
children = item.items
|
|
170
235
|
drop = ListDropdown.new
|
|
171
236
|
drop.renderer = renderer_for(children)
|
|
172
237
|
drop.items = children
|
|
173
|
-
drop.cursor = List::Cursor.new
|
|
238
|
+
drop.cursor = List::Cursor.new(position: highlight ? 0 : -1)
|
|
174
239
|
level = @levels.size
|
|
175
240
|
# Wired *after* the items and cursor: {List#items=} and {List#cursor=}
|
|
176
241
|
# both fire on_cursor_changed, so wiring first would have the fresh
|
|
177
242
|
# panel truncate itself away as it was built.
|
|
178
|
-
drop.on_item_chosen
|
|
179
|
-
drop.on_cursor_changed
|
|
243
|
+
drop.list.on_item_chosen { |e| activate(level, e.item) }
|
|
244
|
+
drop.list.on_cursor_changed { truncate(level + 1) }
|
|
180
245
|
# The cascade's own record of what is open is reconciled from the
|
|
181
246
|
# popup's own closure, not maintained alongside it: an outside click
|
|
182
247
|
# closes panels behind our back ({Overlay#close_on_outside_click?}), and
|
|
@@ -184,15 +249,19 @@ module Tuile
|
|
|
184
249
|
# `deepest` and `highlighted` all lying. Identity-keyed and idempotent,
|
|
185
250
|
# because the notice also arrives from `truncate` (which has already
|
|
186
251
|
# popped the entry) and from teardown, in no guaranteed order.
|
|
187
|
-
|
|
252
|
+
# Menu mode ends with the last panel, whoever took it: this notice is
|
|
253
|
+
# what covers a dismissal {#close} never hears about.
|
|
254
|
+
drop.on_close do
|
|
255
|
+
@levels.delete_if { |(_i, d)| d.equal?(drop) }
|
|
256
|
+
@browsing = false if @levels.empty?
|
|
257
|
+
end
|
|
188
258
|
# Chain each panel to the one it dropped out of, so a click on a
|
|
189
259
|
# deeper panel is "inside" the shallower ones and doesn't dismiss
|
|
190
260
|
# them. Level 0 owns nothing on purpose: a click on a dialog hosting
|
|
191
261
|
# the bar *should* close the whole menu and keep the dialog.
|
|
192
262
|
drop.owner = @levels.last&.last
|
|
193
263
|
@levels << [item, drop]
|
|
194
|
-
drop
|
|
195
|
-
yield(drop, children.size, width_for(children))
|
|
264
|
+
yield(drop, width_for(children))
|
|
196
265
|
end
|
|
197
266
|
|
|
198
267
|
# Closes the deepest panel; at depth 1 that closes the cascade.
|
|
@@ -221,13 +290,14 @@ module Tuile
|
|
|
221
290
|
# @param items [Array<Item>]
|
|
222
291
|
# @return [Proc] item -> row: the label padded to the level's widest, plus
|
|
223
292
|
# an arrow column when any sibling has a submenu — so every arrow lands
|
|
224
|
-
# in the same column
|
|
293
|
+
# in the same column. The width {List} offers a renderer is no use
|
|
294
|
+
# here: {#width_for} derives the panel's width from these same labels,
|
|
295
|
+
# so laying out against it would be circular.
|
|
225
296
|
def renderer_for(items)
|
|
226
297
|
label_width = label_width_of(items)
|
|
227
298
|
arrows = items.any?(&:submenu?)
|
|
228
|
-
lambda do |item|
|
|
229
|
-
row = item.cued_caption.ellipsize(label_width)
|
|
230
|
-
row += StyledString.plain(" " * (label_width - row.display_width))
|
|
299
|
+
lambda do |item, _text_width|
|
|
300
|
+
row = item.cued_caption.ellipsize(label_width).ljust(label_width)
|
|
231
301
|
next row unless arrows
|
|
232
302
|
|
|
233
303
|
row + StyledString.plain(item.submenu? ? " #{SUBMENU_ARROW}" : " " * ARROW_WIDTH)
|