tuile 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/DECISIONS.md +1297 -13
- data/README.md +136 -490
- data/TERMINOLOGY.md +11 -2
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +11 -10
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +5 -2
- data/book/07-components.md +402 -12
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +22 -16
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +385 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +7 -6
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/combo_box.rb +3 -1
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +86 -3
- 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 +14 -11
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +75 -9
- data/lib/tuile/component/select.rb +3 -1
- data/lib/tuile/component/tab_sheet.rb +242 -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.rb +38 -13
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +94 -77
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +40 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1473 -93
- metadata +6 -3
- data/mise.toml +0 -2
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A one-row strip of menu captions, each dropping open a cascade of submenus
|
|
6
|
+
# that nests as deep as you build it.
|
|
7
|
+
#
|
|
8
|
+
# ␣File␣␣Edit␣␣View␣ <- the strip; highlighted while focused
|
|
9
|
+
# ␣New␣␣␣␣␣␣␣␣␣ <- the open menu, measured to its widest label
|
|
10
|
+
# ␣Recent␣␣␣␣▸␣ <- a row that opens a submenu
|
|
11
|
+
# ␣Quit␣␣␣␣␣␣␣␣ (the outer gutters are {List}'s)
|
|
12
|
+
#
|
|
13
|
+
# bar = Component::MenuBar.new
|
|
14
|
+
# file = bar.add_item("File", mnemonic: "f")
|
|
15
|
+
# file.add_item("New", mnemonic: "n") { new_document }
|
|
16
|
+
# recent = file.add_item("Recent") # no block ⇒ a submenu holder
|
|
17
|
+
# recent.add_item("notes.txt") { open("notes.txt") }
|
|
18
|
+
# bar.add_item("Quit") { screen.close } # a top-level leaf: a button
|
|
19
|
+
#
|
|
20
|
+
# LEFT / RIGHT move along the strip; Enter, Space or Down opens the
|
|
21
|
+
# highlighted menu. Inside a menu: Up / Down (and PgUp/PgDn, Ctrl+U/D) move
|
|
22
|
+
# the highlight, Enter or Space activates a row or opens its submenu, RIGHT
|
|
23
|
+
# opens a submenu, LEFT returns to the previous menu, ESC closes one level.
|
|
24
|
+
# LEFT at the first level and RIGHT on a row with no submenu step to the
|
|
25
|
+
# sibling menu, as they do in every menu bar. Book ch7 has the table.
|
|
26
|
+
#
|
|
27
|
+
# == Mnemonics
|
|
28
|
+
# An item given a `mnemonic:` answers to that letter, underlined in its
|
|
29
|
+
# caption wherever it occurs — on the strip and in the panels, focused or
|
|
30
|
+
# not (there is no Alt key to reveal them with). Matching is **level-scoped
|
|
31
|
+
# with no fallback**: the top-level items while the cascade is closed, the
|
|
32
|
+
# deepest open panel's items while it is open, and nothing else is ever
|
|
33
|
+
# consulted. So `f` then `q` walks File ▸ Quit as two ordinary keystrokes,
|
|
34
|
+
# two items on *different* levels may share a letter with nothing to
|
|
35
|
+
# arbitrate, and only siblings compete — a duplicate among them raises at
|
|
36
|
+
# {#add_item}. A letter matching nothing on the live level is swallowed and
|
|
37
|
+
# rings {Screen#beep}; it never falls out to a shallower level and switches
|
|
38
|
+
# menus. A mnemonic shadows what the app (or an ancestor, including a
|
|
39
|
+
# {Popup}'s `q`-to-close) would do with that key while the bar has focus.
|
|
40
|
+
# A paste can never fire one — pasted text rides its own path off the key
|
|
41
|
+
# ladder.
|
|
42
|
+
#
|
|
43
|
+
# {Item} handles are minted by {#add_item} and nest via the *same* method, so
|
|
44
|
+
# depth is unlimited. There is no removal, no reordering and no dynamic
|
|
45
|
+
# rebuilding: a menu is built once, at construction. See `DECISIONS.md`
|
|
46
|
+
# `D-menu-bar`.
|
|
47
|
+
#
|
|
48
|
+
# == Sizing
|
|
49
|
+
# Assign a {#rect} (typically one {Layout::Fixed}`[1]` row at the top of a
|
|
50
|
+
# {Layout::Vertical}). One wider than {#extent}`.width` leaves a dead tail; a
|
|
51
|
+
# narrower one **scrolls** to keep the highlighted segment whole, cueing the
|
|
52
|
+
# hidden captions with a `<` or `>` over an edge column, exactly as {Tabs}
|
|
53
|
+
# does — so a bar wider than its terminal stays wholly reachable by arrow,
|
|
54
|
+
# mnemonic and click. Reassigning the rect
|
|
55
|
+
# **closes** an open cascade: every panel position is derived from a segment
|
|
56
|
+
# or a parent row, so after a resize they would all sit at stale columns, and
|
|
57
|
+
# a resize with a menu open is rare enough that closing beats re-anchoring
|
|
58
|
+
# every level.
|
|
59
|
+
#
|
|
60
|
+
# == Implementation details
|
|
61
|
+
# Deliberately painted *unlike* {Tabs}, whose picture it would otherwise
|
|
62
|
+
# share: no separator between segments, no bold, and no highlight at all
|
|
63
|
+
# while unfocused — a menu bar has no persistent selection to show, and a
|
|
64
|
+
# reader should not have to work out which of the two controls they are
|
|
65
|
+
# looking at. Hit testing *is* {Tabs}': one private `segments` method feeds
|
|
66
|
+
# both the paint and the click and both offset it by the same scroll column,
|
|
67
|
+
# so a click cannot land on a caption other than the one drawn under it, and it is derived from the captions on each
|
|
68
|
+
# call so a hit test is correct before the first paint.
|
|
69
|
+
#
|
|
70
|
+
# The open panels are overlays owned by a private {Cascade}, not children:
|
|
71
|
+
# focus stays here for the whole interaction, so the strip receives every key
|
|
72
|
+
# and forwards it. An open cascade swallows keys the cascade doesn't
|
|
73
|
+
# recognize; a *closed* strip lets every printable bubble, so an app's
|
|
74
|
+
# `s`-to-save keeps working while the bar has focus.
|
|
75
|
+
#
|
|
76
|
+
# A click outside an open cascade is not blocked — non-modal overlays block
|
|
77
|
+
# nothing — but any click on a focusable component moves focus, and losing
|
|
78
|
+
# focus closes the cascade.
|
|
79
|
+
#
|
|
80
|
+
# UI-thread-confined, like every component (see {Screen}).
|
|
81
|
+
class MenuBar < Component
|
|
82
|
+
# One menu item: a caption, an optional click listener, and its children.
|
|
83
|
+
#
|
|
84
|
+
# file = bar.add_item("File") # minted by the bar
|
|
85
|
+
# file.add_item("New") { create } # …and nested by the same method
|
|
86
|
+
# file.items.size # => 1
|
|
87
|
+
#
|
|
88
|
+
# An item with children is a submenu and its own listener is dead
|
|
89
|
+
# ({#submenu?} decides). An item with **neither** children nor a listener is
|
|
90
|
+
# legal and inert: it highlights, Enter closes the menu, nothing happens —
|
|
91
|
+
# an item that looks live but does nothing is the app's error to fix, not
|
|
92
|
+
# the framework's to raise on.
|
|
93
|
+
#
|
|
94
|
+
# Apps don't construct items; {MenuBar#add_item} and {#add_item} do.
|
|
95
|
+
class Item
|
|
96
|
+
# @param caption [StyledString] already coerced by the caller.
|
|
97
|
+
# @param mnemonic [String, nil] already validated by the caller, in the
|
|
98
|
+
# case it was given in.
|
|
99
|
+
# @param on_click [Proc, Method, nil]
|
|
100
|
+
def initialize(caption, mnemonic, on_click)
|
|
101
|
+
@caption = caption
|
|
102
|
+
@mnemonic = mnemonic&.downcase
|
|
103
|
+
@cued_caption = build_cued_caption(caption, mnemonic)
|
|
104
|
+
@on_click = on_click
|
|
105
|
+
@items = []
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private_class_method :new
|
|
109
|
+
|
|
110
|
+
# @return [StyledString] the label painted on the strip or the row.
|
|
111
|
+
attr_reader :caption
|
|
112
|
+
|
|
113
|
+
# @return [String, nil] the downcased letter that activates this item
|
|
114
|
+
# while its own level is the live one; `nil` when it has none.
|
|
115
|
+
attr_reader :mnemonic
|
|
116
|
+
|
|
117
|
+
# @return [StyledString] {#caption} with the {#mnemonic} underlined —
|
|
118
|
+
# what both paint sites draw. Equal to {#caption} when there is no
|
|
119
|
+
# mnemonic or the caption doesn't contain it. Computed once, at
|
|
120
|
+
# construction: caption and mnemonic are both fixed there, and
|
|
121
|
+
# underline is a plain attribute with no theme or `bg_color` input, so
|
|
122
|
+
# this is not a cached theme value.
|
|
123
|
+
attr_reader :cued_caption
|
|
124
|
+
|
|
125
|
+
# @return [Array<Item>] this item's children, in menu order. Read-only by
|
|
126
|
+
# convention, like {Component#children} — grow it through {#add_item}.
|
|
127
|
+
attr_reader :items
|
|
128
|
+
|
|
129
|
+
# @return [Proc, Method, nil] no-arg callable fired when the item is
|
|
130
|
+
# activated (Enter, Space or a left click), exactly as
|
|
131
|
+
# {Button#on_click}. Never fired on an item with children.
|
|
132
|
+
attr_accessor :on_click
|
|
133
|
+
|
|
134
|
+
# @return [Boolean] whether this item opens a submenu, i.e. has children.
|
|
135
|
+
def submenu? = !@items.empty?
|
|
136
|
+
|
|
137
|
+
# Appends a child and returns its handle.
|
|
138
|
+
# @param caption [String, StyledString, nil] parsed as
|
|
139
|
+
# {StyledString.parse} parses it.
|
|
140
|
+
# @param mnemonic [String, nil] the letter that activates this child
|
|
141
|
+
# while *this* item's children are the live level; see
|
|
142
|
+
# {MenuBar#add_item}.
|
|
143
|
+
# @yield optional `on_click` callback; same as assigning {#on_click=}.
|
|
144
|
+
# @raise [ArgumentError] see {MenuBar#add_item}.
|
|
145
|
+
# @return [Item]
|
|
146
|
+
def add_item(caption = nil, mnemonic: nil, &on_click)
|
|
147
|
+
validate_mnemonic(mnemonic)
|
|
148
|
+
Item.send(:new, StyledString.parse(caption), mnemonic, on_click).tap { @items << _1 }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [String]
|
|
152
|
+
def inspect
|
|
153
|
+
mn = @mnemonic.nil? ? "" : " [#{@mnemonic}]"
|
|
154
|
+
"#<#{self.class.name} #{caption.to_s.inspect}#{mn}#{submenu? ? " (#{@items.size} items)" : ""}>"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# Rejects a mnemonic that couldn't work, or that would make two siblings
|
|
160
|
+
# ambiguous — all three at *registration*, since none has a sane answer
|
|
161
|
+
# at keypress time.
|
|
162
|
+
# @param mnemonic [String, nil]
|
|
163
|
+
# @raise [ArgumentError]
|
|
164
|
+
# @return [void]
|
|
165
|
+
def validate_mnemonic(mnemonic)
|
|
166
|
+
return if mnemonic.nil?
|
|
167
|
+
|
|
168
|
+
# Not implied by printable?, which accepts " ".
|
|
169
|
+
raise ArgumentError, "mnemonic must not be a space: Space activates the highlighted item" if mnemonic == " "
|
|
170
|
+
unless Keys.printable?(mnemonic) && StyledString.plain(mnemonic).display_width == 1
|
|
171
|
+
raise ArgumentError, "mnemonic must be a single one-column printable character; got #{mnemonic.inspect}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
down = mnemonic.downcase
|
|
175
|
+
return unless @items.any? { |item| item.mnemonic == down }
|
|
176
|
+
|
|
177
|
+
raise ArgumentError, "duplicate mnemonic #{down.inspect} among these menu items"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# {StyledString#slice} counts **columns** while a caption search yields a
|
|
181
|
+
# **character** index, so the prefix is measured, never counted.
|
|
182
|
+
# @param caption [StyledString]
|
|
183
|
+
# @param mnemonic [String, nil] in the case it was given in.
|
|
184
|
+
# @return [StyledString]
|
|
185
|
+
def build_cued_caption(caption, mnemonic)
|
|
186
|
+
return caption if mnemonic.nil?
|
|
187
|
+
|
|
188
|
+
text = caption.to_s
|
|
189
|
+
# Exact case first, so "Save As" can underline either "a" via the case
|
|
190
|
+
# it was given in.
|
|
191
|
+
index = text.index(mnemonic) || text.downcase.index(mnemonic.downcase)
|
|
192
|
+
return caption if index.nil?
|
|
193
|
+
|
|
194
|
+
start = StyledString.plain(text[0, index]).display_width
|
|
195
|
+
caption.slice(0, start) + caption.slice(start, 1).with_underline +
|
|
196
|
+
caption.slice(start + 1, caption.display_width - start - 1)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def initialize
|
|
201
|
+
super()
|
|
202
|
+
@root = Item.send(:new, StyledString::EMPTY, nil, nil)
|
|
203
|
+
@highlighted_index = 0
|
|
204
|
+
@left_column = 0
|
|
205
|
+
@cascade = Cascade.new
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# @return [Boolean] `true` — the strip takes focus, so its keys work.
|
|
209
|
+
def focusable? = true
|
|
210
|
+
|
|
211
|
+
# @return [Boolean] `true` — one stop for the whole strip, as on {Tabs}.
|
|
212
|
+
def tab_stop? = true
|
|
213
|
+
|
|
214
|
+
# @return [Array<Item>] the top-level items, in strip order. Read-only by
|
|
215
|
+
# convention; grow it through {#add_item}.
|
|
216
|
+
def items = @root.items
|
|
217
|
+
|
|
218
|
+
# @return [Integer] which top-level item the strip highlights while
|
|
219
|
+
# focused, and which menu Enter opens. `0` until the user moves.
|
|
220
|
+
attr_reader :highlighted_index
|
|
221
|
+
|
|
222
|
+
# Appends a top-level item and returns its handle; nest submenus into it
|
|
223
|
+
# with {Item#add_item}.
|
|
224
|
+
# @param caption [String, StyledString, nil] parsed as
|
|
225
|
+
# {StyledString.parse} parses it.
|
|
226
|
+
# @param mnemonic [String, nil] a single one-column printable character
|
|
227
|
+
# that activates this item while the strip is focused and *closed*,
|
|
228
|
+
# underlined in the caption where it occurs. Matched case-insensitively;
|
|
229
|
+
# it shadows whatever the app would otherwise do with that key while the
|
|
230
|
+
# bar has focus.
|
|
231
|
+
# @yield optional `on_click` callback, for a top-level item that acts as a
|
|
232
|
+
# button rather than opening a menu.
|
|
233
|
+
# @raise [ArgumentError] if `mnemonic` is a space, is not a single
|
|
234
|
+
# one-column printable character, or duplicates a sibling's.
|
|
235
|
+
# @return [Item]
|
|
236
|
+
def add_item(caption = nil, mnemonic: nil, &on_click)
|
|
237
|
+
@root.add_item(caption, mnemonic: mnemonic, &on_click).tap { refresh }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The cells the strip actually paints: one row, as wide as its segments
|
|
241
|
+
# need, clipped to {#rect}.
|
|
242
|
+
#
|
|
243
|
+
# Both the highlight and the click hit test use it, so a click on the blank
|
|
244
|
+
# tail — or on a lower row, when the rect is taller than one — opens
|
|
245
|
+
# nothing. It still *focuses*: {Component#handle_mouse}'s click-to-focus is
|
|
246
|
+
# ungated by geometry.
|
|
247
|
+
# @return [Rect]
|
|
248
|
+
def extent
|
|
249
|
+
return Rect.new(rect.left, rect.top, 0, 1) if rect.empty?
|
|
250
|
+
|
|
251
|
+
Rect.new(rect.left, rect.top, [painted_width - @left_column, rect.width].min, 1)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# @param new_rect [Rect]
|
|
255
|
+
# @return [void]
|
|
256
|
+
def rect=(new_rect)
|
|
257
|
+
# Only a *changed* rect closes the menu: a layout re-assigning the same
|
|
258
|
+
# rect (which {Layout::Box} does on any child mutation) must not.
|
|
259
|
+
changed = rect != new_rect
|
|
260
|
+
super
|
|
261
|
+
@cascade.close if changed
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Closes the cascade when the strip leaves the focus chain, so tabbing (or
|
|
265
|
+
# clicking) away doesn't strand an open menu.
|
|
266
|
+
# @param flag [Boolean]
|
|
267
|
+
# @return [void]
|
|
268
|
+
def active=(flag)
|
|
269
|
+
was = active?
|
|
270
|
+
super
|
|
271
|
+
@cascade.close if was && !active?
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Closes the cascade, so a bar removed from the tree can't strand its
|
|
275
|
+
# panels on the pane — they are the {ScreenPane}'s children, not the bar's,
|
|
276
|
+
# so nothing else would take them down.
|
|
277
|
+
# @return [void]
|
|
278
|
+
def on_detached
|
|
279
|
+
super
|
|
280
|
+
@cascade.close
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Offers the key to the open cascade first, then to the strip's own
|
|
284
|
+
# LEFT/RIGHT/Enter/Space/Down.
|
|
285
|
+
#
|
|
286
|
+
# With a cascade open, the only keys reaching the strip are the two the
|
|
287
|
+
# cascade declines — LEFT at the first level, RIGHT on a row with no
|
|
288
|
+
# submenu — and both step to the sibling menu.
|
|
289
|
+
# @param key [String]
|
|
290
|
+
# @return [Boolean]
|
|
291
|
+
def handle_key(key)
|
|
292
|
+
# Ahead of the cascade: an open one swallows every printable it doesn't
|
|
293
|
+
# recognize, so a letter would never reach the strip otherwise.
|
|
294
|
+
return true if handle_mnemonic(key)
|
|
295
|
+
return true if @cascade.handle_key(key)
|
|
296
|
+
|
|
297
|
+
if @cascade.open?
|
|
298
|
+
case key
|
|
299
|
+
when Keys::LEFT_ARROW then step_menu(-1)
|
|
300
|
+
when Keys::RIGHT_ARROW then step_menu(1)
|
|
301
|
+
else false
|
|
302
|
+
end
|
|
303
|
+
else
|
|
304
|
+
case key
|
|
305
|
+
when Keys::LEFT_ARROW then move_highlight(-1)
|
|
306
|
+
when Keys::RIGHT_ARROW then move_highlight(1)
|
|
307
|
+
when Keys::ENTER, " ", Keys::DOWN_ARROW then open_highlighted
|
|
308
|
+
else false
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Opens the menu under a left click, or closes it when it is already the
|
|
314
|
+
# open one; `super` runs first, so a click anywhere in {#rect} still
|
|
315
|
+
# focuses.
|
|
316
|
+
# @param event [MouseEvent]
|
|
317
|
+
# @return [void]
|
|
318
|
+
def handle_mouse(event)
|
|
319
|
+
super
|
|
320
|
+
return unless event.button == :left
|
|
321
|
+
|
|
322
|
+
index = index_at(event.point)
|
|
323
|
+
return if index.nil?
|
|
324
|
+
|
|
325
|
+
if @cascade.open? && index == @highlighted_index
|
|
326
|
+
@cascade.close
|
|
327
|
+
else
|
|
328
|
+
self.highlight = index
|
|
329
|
+
open_highlighted
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# @return [void]
|
|
334
|
+
def repaint
|
|
335
|
+
super
|
|
336
|
+
return if rect.empty?
|
|
337
|
+
|
|
338
|
+
row = strip_row.slice(@left_column, rect.width)
|
|
339
|
+
draw_text(rect.left, rect.top, row)
|
|
340
|
+
draw_cues(row)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
private
|
|
344
|
+
|
|
345
|
+
# @return [Integer] the strip column painted in {#rect}'s leftmost cell —
|
|
346
|
+
# the horizontal scroll offset. `0` unless the strip overflows its rect;
|
|
347
|
+
# {#adjust_left_column} is its sole writer.
|
|
348
|
+
attr_reader :left_column
|
|
349
|
+
|
|
350
|
+
# The sole writer of {#highlighted_index}: assigns, re-syncs the scroll
|
|
351
|
+
# offset and repaints. Every path that moves the highlight — arrow,
|
|
352
|
+
# mnemonic, click — goes through it, so the highlighted segment is on
|
|
353
|
+
# screen *before* {Cascade} anchors a panel to it.
|
|
354
|
+
# @param index [Integer]
|
|
355
|
+
# @return [void]
|
|
356
|
+
def highlight=(index)
|
|
357
|
+
return if index == @highlighted_index
|
|
358
|
+
|
|
359
|
+
@highlighted_index = index
|
|
360
|
+
refresh
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Re-syncs the scroll offset and repaints — what every change to the items
|
|
364
|
+
# or the highlight ends in.
|
|
365
|
+
# @return [void]
|
|
366
|
+
def refresh
|
|
367
|
+
adjust_left_column
|
|
368
|
+
invalidate
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# The rect's *width* is the only part of it the offset depends on, so this
|
|
372
|
+
# hook is the whole geometry story; {Component#rect=} invalidates for us,
|
|
373
|
+
# and {#rect=} closes the cascade rather than re-anchoring it.
|
|
374
|
+
# @return [void]
|
|
375
|
+
def on_width_changed
|
|
376
|
+
super
|
|
377
|
+
adjust_left_column
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Scrolls the minimum needed to show the highlighted segment whole, and is
|
|
381
|
+
# the sole writer of {#left_column}. Idempotent, so every mutation site can
|
|
382
|
+
# call it blindly; it returns the offset to `0` on its own once the strip
|
|
383
|
+
# fits again, which is why no mutator owes a scroll-back branch.
|
|
384
|
+
#
|
|
385
|
+
# A segment wider than the whole rect cannot be shown whole: its head wins,
|
|
386
|
+
# being the half of a caption that identifies it.
|
|
387
|
+
# @return [void]
|
|
388
|
+
def adjust_left_column
|
|
389
|
+
if rect.empty? || painted_width <= rect.width || items.empty?
|
|
390
|
+
@left_column = 0
|
|
391
|
+
return
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
_item, start, width = segments[@highlighted_index]
|
|
395
|
+
if width >= rect.width
|
|
396
|
+
@left_column = start
|
|
397
|
+
else
|
|
398
|
+
@left_column = start if start < @left_column
|
|
399
|
+
@left_column = start + width - rect.width if start + width > @left_column + rect.width
|
|
400
|
+
end
|
|
401
|
+
@left_column = snap_to_glyph_start(@left_column.clamp(0, painted_width - rect.width))
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# {StyledString#slice} *drops* a cluster straddling the window's edge
|
|
405
|
+
# rather than half-painting it, which would leave the painted row a column
|
|
406
|
+
# short and shift everything past the hole one column left — paint and hit
|
|
407
|
+
# test would then disagree, silently and only for wide glyphs. So the
|
|
408
|
+
# offset only ever lands on a cluster boundary. Snapping *forward* is the
|
|
409
|
+
# safe direction: it gives up at most one column of the segment to the left
|
|
410
|
+
# of the window, never of the one being revealed.
|
|
411
|
+
# @param column [Integer]
|
|
412
|
+
# @return [Integer] the smallest cluster-boundary column `>= column`.
|
|
413
|
+
def snap_to_glyph_start(column)
|
|
414
|
+
boundary = 0
|
|
415
|
+
strip_row.to_s.each_grapheme_cluster do |glyph|
|
|
416
|
+
return boundary if boundary >= column
|
|
417
|
+
|
|
418
|
+
boundary += Buffer.display_width(glyph)
|
|
419
|
+
end
|
|
420
|
+
boundary
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Paints the overflow cues over the windowed row's edge columns: `<` when
|
|
424
|
+
# segments sit to the left of the window, `>` when more sit to the right.
|
|
425
|
+
# ASCII by convention rather than by constant, as {Checkbox}'s brackets
|
|
426
|
+
# are, and *overlaid* rather than given reserved columns — reserving would
|
|
427
|
+
# make the window width a function of the offset computed from it. Painted
|
|
428
|
+
# focused or not: overflow is a fact about the captions and the rect, not
|
|
429
|
+
# about focus.
|
|
430
|
+
# @param row [StyledString] the windowed row, as painted.
|
|
431
|
+
# @return [void]
|
|
432
|
+
def draw_cues(row)
|
|
433
|
+
draw_cue(row, 0, "<") if @left_column.positive?
|
|
434
|
+
draw_cue(row, rect.width - 1, ">") if @left_column + rect.width < painted_width
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# The cue keeps the style of the cell it covers, so one landing on the
|
|
438
|
+
# highlighted segment doesn't punch a default-background hole in its
|
|
439
|
+
# highlight.
|
|
440
|
+
# @param row [StyledString] the windowed row.
|
|
441
|
+
# @param column [Integer] relative to {#rect}`.left`.
|
|
442
|
+
# @param glyph [String]
|
|
443
|
+
# @return [void]
|
|
444
|
+
def draw_cue(row, column, glyph)
|
|
445
|
+
style = row.slice(column, 1).spans.first&.style || StyledString::Style::DEFAULT
|
|
446
|
+
draw_char(rect.left + column, rect.top, glyph, style)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# One `[item, start_column, width]` triple per top-level item, in strip
|
|
450
|
+
# order, in columns relative to {#rect}`.left`. A segment is its caption
|
|
451
|
+
# between two padding columns, and neighbours abut — the two blank columns
|
|
452
|
+
# between captions are the segments' own padding, so a click on either
|
|
453
|
+
# opens the menu it belongs to.
|
|
454
|
+
# @return [Array<Array(Item, Integer, Integer)>]
|
|
455
|
+
def segments
|
|
456
|
+
column = 0
|
|
457
|
+
items.map do |item|
|
|
458
|
+
width = item.cued_caption.display_width + 2
|
|
459
|
+
[item, column, width].tap { column += width }
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# @return [Integer] columns the strip would paint given an unlimited rect.
|
|
464
|
+
def painted_width
|
|
465
|
+
_item, start, width = segments.last
|
|
466
|
+
start.nil? ? 0 : start + width
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# @param point [Point]
|
|
470
|
+
# @return [Integer, nil] the index of the item painted at `point`; `nil`
|
|
471
|
+
# for the blank tail or a row the strip doesn't paint.
|
|
472
|
+
def index_at(point)
|
|
473
|
+
return nil unless extent.contains?(point)
|
|
474
|
+
|
|
475
|
+
column = point.x - rect.left + @left_column
|
|
476
|
+
segments.index { |_item, start, width| column >= start && column < start + width }
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# @param index [Integer]
|
|
480
|
+
# @return [Rect] the segment's cells on screen — the cascade's anchor.
|
|
481
|
+
def segment_rect(index)
|
|
482
|
+
_item, start, width = segments[index]
|
|
483
|
+
Rect.new(rect.left + start - @left_column, rect.top, width, 1)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# @return [StyledString] the whole strip as one row, unclipped. {#repaint}
|
|
487
|
+
# windows it to the rect; nothing else may, since the window's own
|
|
488
|
+
# arithmetic is {#adjust_left_column}'s.
|
|
489
|
+
def strip_row
|
|
490
|
+
row = StyledString::EMPTY
|
|
491
|
+
items.each_with_index { |item, index| row += segment_text(item, index) }
|
|
492
|
+
row
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# @param item [Item]
|
|
496
|
+
# @param index [Integer]
|
|
497
|
+
# @return [StyledString] the caption between its padding columns,
|
|
498
|
+
# highlighted when it is the one Enter would open *and* the strip has
|
|
499
|
+
# focus. An unfocused strip shows no highlight at all: there is no
|
|
500
|
+
# persistent selection to report.
|
|
501
|
+
def segment_text(item, index)
|
|
502
|
+
pad = StyledString.plain(" ")
|
|
503
|
+
segment = pad + item.cued_caption + pad
|
|
504
|
+
return segment unless index == @highlighted_index && active?
|
|
505
|
+
|
|
506
|
+
segment.with_bg(screen.theme.active_bg_color)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# Activates the item bound to `key` on the *live* level — the deepest open
|
|
510
|
+
# panel while the cascade is open, the top-level strip while it is closed.
|
|
511
|
+
# No fallback between the two: a letter matching nothing in the live set is
|
|
512
|
+
# not offered to any other level.
|
|
513
|
+
# @param key [String]
|
|
514
|
+
# @return [Boolean] whether a mnemonic claimed the key.
|
|
515
|
+
def handle_mnemonic(key)
|
|
516
|
+
return false unless Keys.printable?(key)
|
|
517
|
+
|
|
518
|
+
down = key.downcase
|
|
519
|
+
return @cascade.handle_mnemonic(down) if @cascade.open?
|
|
520
|
+
|
|
521
|
+
index = items.index { |item| item.mnemonic == down }
|
|
522
|
+
return false if index.nil?
|
|
523
|
+
|
|
524
|
+
self.highlight = index
|
|
525
|
+
open_highlighted
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Moves the highlight along the strip, clamping at both ends. Consumes the
|
|
529
|
+
# key even at an end, as {Tabs} does.
|
|
530
|
+
# @param delta [Integer] `+1` / `-1`.
|
|
531
|
+
# @return [Boolean] `false` only when there are no items.
|
|
532
|
+
def move_highlight(delta)
|
|
533
|
+
return false if items.empty?
|
|
534
|
+
|
|
535
|
+
self.highlight = (@highlighted_index + delta).clamp(0, items.size - 1)
|
|
536
|
+
true
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Steps to the neighbouring menu, showing *its* menu instead — or closing
|
|
540
|
+
# the cascade, when the neighbour is a top-level button with no menu to
|
|
541
|
+
# show. The cascade is left alone when the highlight is already at an end:
|
|
542
|
+
# reopening the same menu would throw away the submenu the user is standing
|
|
543
|
+
# in.
|
|
544
|
+
#
|
|
545
|
+
# It deliberately never *activates*. An item arrowed past is highlighted,
|
|
546
|
+
# not pressed, so a top-level button waits for Enter or Space — otherwise
|
|
547
|
+
# walking the strip would fire every button on it.
|
|
548
|
+
# @param delta [Integer] `+1` / `-1`.
|
|
549
|
+
# @return [Boolean] always `true`: an open menu swallows the key either way.
|
|
550
|
+
def step_menu(delta)
|
|
551
|
+
was = @highlighted_index
|
|
552
|
+
move_highlight(delta)
|
|
553
|
+
show_highlighted_menu unless @highlighted_index == was
|
|
554
|
+
true
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Opens the highlighted item's menu, or fires it when it is a top-level
|
|
558
|
+
# button — the Enter/Space/Down/click path, and the only one that fires a
|
|
559
|
+
# listener.
|
|
560
|
+
# @return [Boolean] `false` only when there are no items.
|
|
561
|
+
def open_highlighted
|
|
562
|
+
return false if items.empty?
|
|
563
|
+
|
|
564
|
+
item = items[@highlighted_index]
|
|
565
|
+
show_highlighted_menu
|
|
566
|
+
# Fired after the close above, exactly as {Cascade} activates a leaf: an
|
|
567
|
+
# action that opens a dialog must not paint it under a menu.
|
|
568
|
+
item.on_click&.call unless item.submenu?
|
|
569
|
+
true
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Shows the highlighted item's menu, closing the cascade when it has none.
|
|
573
|
+
# @return [void]
|
|
574
|
+
def show_highlighted_menu
|
|
575
|
+
item = items[@highlighted_index]
|
|
576
|
+
return @cascade.close unless item.submenu?
|
|
577
|
+
|
|
578
|
+
@cascade.open_below(segment_rect(@highlighted_index), item)
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
end
|
|
@@ -34,8 +34,11 @@ module Tuile
|
|
|
34
34
|
#
|
|
35
35
|
# - **Take focus, or receive keys.** A non-modal popup sits off the
|
|
36
36
|
# key-dispatch scope ({ScreenPane#handle_key}), so not even {Popup}'s
|
|
37
|
-
# `q`/ESC arrives here. A left click
|
|
38
|
-
# wanting a key registers a global shortcut and
|
|
37
|
+
# `q`/ESC arrives here. A left click *on the box* dismisses
|
|
38
|
+
# ({#handle_mouse}); an app wanting a key registers a global shortcut and
|
|
39
|
+
# calls {#close}. A click *elsewhere* does not — this is the one popup
|
|
40
|
+
# with {Popup#close_on_outside_click?} false, since a toast is timed and
|
|
41
|
+
# an unrelated click is not about it.
|
|
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.
|
|
@@ -121,7 +124,7 @@ module Tuile
|
|
|
121
124
|
@view = TextView.new
|
|
122
125
|
@window = Window.new
|
|
123
126
|
@window.content = @view
|
|
124
|
-
super(content: @window, modal: false)
|
|
127
|
+
super(content: @window, modal: false, close_on_outside_click: false)
|
|
125
128
|
end
|
|
126
129
|
|
|
127
130
|
# Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
|
|
@@ -133,12 +136,6 @@ module Tuile
|
|
|
133
136
|
# @return [Boolean] false — see {#focusable?}.
|
|
134
137
|
def tab_stop? = false
|
|
135
138
|
|
|
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
139
|
# Appends a message, dropping it (with a {Tuile.logger} warning) once
|
|
143
140
|
# {MAX_MESSAGES} are held. Public so a caller holding the instance can
|
|
144
141
|
# append without repeating {show}'s lookup.
|
|
@@ -214,10 +211,16 @@ module Tuile
|
|
|
214
211
|
end
|
|
215
212
|
|
|
216
213
|
# @return [void]
|
|
217
|
-
def on_attached
|
|
214
|
+
def on_attached
|
|
215
|
+
super
|
|
216
|
+
sync_ticker
|
|
217
|
+
end
|
|
218
218
|
|
|
219
219
|
# @return [void]
|
|
220
|
-
def on_detached
|
|
220
|
+
def on_detached
|
|
221
|
+
super
|
|
222
|
+
sync_ticker
|
|
223
|
+
end
|
|
221
224
|
|
|
222
225
|
private
|
|
223
226
|
|
|
@@ -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]
|