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
|
@@ -52,6 +52,9 @@ module Tuile
|
|
|
52
52
|
self.content = field
|
|
53
53
|
|
|
54
54
|
@overlay = ListDropdown.new
|
|
55
|
+
# Outside-click dismissal spans the owner chain, so a click on this
|
|
56
|
+
# combo's dropdown must not dismiss a dialog the combo sits in.
|
|
57
|
+
@overlay.owner = self
|
|
55
58
|
@overlay.renderer = ->(item) { @item_label.call(item) }
|
|
56
59
|
@overlay.on_item_chosen = ->(_index, item) { commit(item) }
|
|
57
60
|
end
|
|
@@ -98,7 +101,6 @@ module Tuile
|
|
|
98
101
|
def cursor_position = content.cursor_position
|
|
99
102
|
|
|
100
103
|
# @return [String]
|
|
101
|
-
def keyboard_hint = "↑↓ #{screen.theme.hint("select")} ⏎ #{screen.theme.hint("accept")}"
|
|
102
104
|
|
|
103
105
|
# Re-anchors the (open) dropdown after {HasContent#rect=} has resized the
|
|
104
106
|
# field via {#layout}.
|
data/lib/tuile/component/list.rb
CHANGED
|
@@ -294,6 +294,28 @@ module Tuile
|
|
|
294
294
|
search_and_go(query, include_current: include_current, reverse: true)
|
|
295
295
|
end
|
|
296
296
|
|
|
297
|
+
# Moves the cursor to the item at `index`, scrolling it into view and
|
|
298
|
+
# firing {#on_cursor_changed} — the positional member of the
|
|
299
|
+
# {#select_next} / {#select_prev} family, for a caller that already knows
|
|
300
|
+
# *which* item it wants:
|
|
301
|
+
#
|
|
302
|
+
# list.select(items.index(chosen))
|
|
303
|
+
#
|
|
304
|
+
# Refuses an index the current {#cursor} can't reach — out of range, or
|
|
305
|
+
# anything at all under {Cursor::None} — rather than stranding the cursor
|
|
306
|
+
# off-content.
|
|
307
|
+
# @param index [Integer]
|
|
308
|
+
# @return [Boolean] whether the cursor moved there.
|
|
309
|
+
def select(index)
|
|
310
|
+
return false unless @cursor.candidate_positions(@items.size).include?(index)
|
|
311
|
+
|
|
312
|
+
@cursor.go(index)
|
|
313
|
+
move_viewport_to_cursor
|
|
314
|
+
notify_cursor_changed
|
|
315
|
+
invalidate
|
|
316
|
+
true
|
|
317
|
+
end
|
|
318
|
+
|
|
297
319
|
# @param event [MouseEvent]
|
|
298
320
|
# @return [void]
|
|
299
321
|
def handle_mouse(event)
|
|
@@ -19,9 +19,10 @@ module Tuile
|
|
|
19
19
|
# drop.choose if key == Keys::ENTER # commit the highlight
|
|
20
20
|
#
|
|
21
21
|
# It owns only what every such dropdown shares — *placement* included, via
|
|
22
|
-
# {#anchor_to}
|
|
23
|
-
#
|
|
24
|
-
#
|
|
22
|
+
# {#anchor_to} (below a field) and {#anchor_beside} (beside a parent row, for
|
|
23
|
+
# a cascading submenu). What stays with the driver: the width **policy**
|
|
24
|
+
# (neither placement method measures anything itself), filtering, row
|
|
25
|
+
# rendering, the commit action, and ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may
|
|
25
26
|
# revert a query; Enter may commit via {#choose} *or* via a separate submit
|
|
26
27
|
# path), so {#move} claims neither — the driver calls {#choose} and {#close}
|
|
27
28
|
# from its own branches.
|
|
@@ -88,6 +89,14 @@ module Tuile
|
|
|
88
89
|
@list.on_item_chosen = proc
|
|
89
90
|
end
|
|
90
91
|
|
|
92
|
+
# @param proc [Proc, Method, nil] highlight-moved callback; see
|
|
93
|
+
# {List#on_cursor_changed}. A cascading driver needs it to drop the
|
|
94
|
+
# panels that belonged to the row the highlight just left.
|
|
95
|
+
# @return [void]
|
|
96
|
+
def on_cursor_changed=(proc)
|
|
97
|
+
@list.on_cursor_changed = proc
|
|
98
|
+
end
|
|
99
|
+
|
|
91
100
|
# @param cursor [List::Cursor] the highlight; see {List#cursor=}.
|
|
92
101
|
# @return [void]
|
|
93
102
|
def cursor=(cursor)
|
|
@@ -97,6 +106,13 @@ module Tuile
|
|
|
97
106
|
# @return [List::Cursor] the list's cursor (the current highlight).
|
|
98
107
|
def cursor = @list.cursor
|
|
99
108
|
|
|
109
|
+
# Moves the highlight to the item at `index`, scrolling it into view; see
|
|
110
|
+
# {List#select}. The positional counterpart of {#move}, for a driver that
|
|
111
|
+
# picked a row by something other than a key — a mnemonic letter, say.
|
|
112
|
+
# @param index [Integer]
|
|
113
|
+
# @return [Boolean] whether the highlight moved there.
|
|
114
|
+
def select(index) = @list.select(index)
|
|
115
|
+
|
|
100
116
|
# Sizes and places the dropdown against `anchor`: directly beneath it,
|
|
101
117
|
# flipped above when `rows` won't fit below, clamped — with the list
|
|
102
118
|
# scrolling — when neither side has room. Horizontally the left edges line
|
|
@@ -140,6 +156,73 @@ module Tuile
|
|
|
140
156
|
@list.scrollbar_visibility = rows > height ? :visible : :gone
|
|
141
157
|
end
|
|
142
158
|
|
|
159
|
+
# Sizes and places the dropdown *beside* `anchor` — the placement a
|
|
160
|
+
# cascading submenu wants, where {#anchor_to} is the placement a field's
|
|
161
|
+
# dropdown wants.
|
|
162
|
+
#
|
|
163
|
+
# sub.anchor_beside(parent.cursor_row_rect, rows: kids.size, width: measured)
|
|
164
|
+
#
|
|
165
|
+
# Horizontally it sits against `anchor`'s right edge, **flipping** to its
|
|
166
|
+
# left when the right has no room (and clamping to the screen when neither
|
|
167
|
+
# side does). Vertically it **slides**: the panel's first row lines up with
|
|
168
|
+
# the anchored row, sliding up only far enough to keep the panel on screen.
|
|
169
|
+
#
|
|
170
|
+
# The two axes are the mirror image of {#anchor_to}'s, for the same reason:
|
|
171
|
+
# never cover the thing being chosen from. A field's dropdown must not
|
|
172
|
+
# cover the field, so it flips *vertically* and shares its columns; a
|
|
173
|
+
# submenu must not cover its parent panel, so it flips *horizontally* and
|
|
174
|
+
# shares its rows.
|
|
175
|
+
#
|
|
176
|
+
# @param anchor [Rect] the row the submenu belongs to — typically the
|
|
177
|
+
# parent dropdown's {#cursor_row_rect}. Its width is the parent panel's,
|
|
178
|
+
# which is what the submenu clears.
|
|
179
|
+
# @param rows [Integer] how many rows there are to show — the content
|
|
180
|
+
# count, not the height; more than fits turns the scrollbar on. `0`
|
|
181
|
+
# collapses the dropdown to an empty rect (drivers close instead).
|
|
182
|
+
# @param width [Integer] the panel's width in columns, clamped to the
|
|
183
|
+
# screen. **Required, with no default:** `anchor.width` is the *parent's*
|
|
184
|
+
# width and would be meaningless here, so the caller measures (see
|
|
185
|
+
# `DECISIONS.md` `D-select` on why the width policy stays with the
|
|
186
|
+
# driver).
|
|
187
|
+
# @param max_rows [Integer] rows shown before the list scrolls.
|
|
188
|
+
# @return [void]
|
|
189
|
+
def anchor_beside(anchor, rows:, width:, max_rows: MAX_VISIBLE_ROWS)
|
|
190
|
+
height = [rows, max_rows, screen.size.height].min
|
|
191
|
+
width = [width, screen.size.width].min
|
|
192
|
+
right = anchor.left + anchor.width
|
|
193
|
+
left = if right + width <= screen.size.width || (anchor.left - width).negative?
|
|
194
|
+
right
|
|
195
|
+
else
|
|
196
|
+
anchor.left - width
|
|
197
|
+
end
|
|
198
|
+
left = left.clamp(0, [screen.size.width - width, 0].max)
|
|
199
|
+
top = [anchor.top, screen.size.height - height].min.clamp(0, nil)
|
|
200
|
+
self.size = Size.new(width, height)
|
|
201
|
+
self.rect = Rect.new(left, top, width, height)
|
|
202
|
+
# After the geometry, as in {#anchor_to}: the setter rebuilds the list's
|
|
203
|
+
# padded rows against the width it can see.
|
|
204
|
+
@list.scrollbar_visibility = rows > height ? :visible : :gone
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The highlighted row's rect on screen — what a cascading submenu anchors
|
|
208
|
+
# against, via {#anchor_beside}.
|
|
209
|
+
#
|
|
210
|
+
# It lives here rather than in the driver because {ListDropdown} owns the
|
|
211
|
+
# list's geometry: a driver computing `top + position - scroll_top_row`
|
|
212
|
+
# itself would have to reach through to the private list.
|
|
213
|
+
# @return [Rect, nil] one row spanning the panel's width, or `nil` when
|
|
214
|
+
# the cursor is off-content ({List::Cursor::None}, an empty list) or its
|
|
215
|
+
# row is scrolled out of the viewport.
|
|
216
|
+
def cursor_row_rect
|
|
217
|
+
return nil if @list.rect.empty?
|
|
218
|
+
return nil unless @list.cursor.position.between?(0, @list.items.size - 1)
|
|
219
|
+
|
|
220
|
+
row = @list.cursor.position - @list.scroll_top_row
|
|
221
|
+
return nil unless row.between?(0, @list.rect.height - 1)
|
|
222
|
+
|
|
223
|
+
Rect.new(@list.rect.left, @list.rect.top + row, @list.rect.width, 1)
|
|
224
|
+
end
|
|
225
|
+
|
|
143
226
|
# Forwards a cursor-movement key to the list. The driver calls this from
|
|
144
227
|
# its own key handler; a truthy return means "consumed — stop here", falsy
|
|
145
228
|
# means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
class MenuBar
|
|
6
|
+
# The stack of open menu panels — one {ListDropdown} per level, the last
|
|
7
|
+
# deepest — and the drill/pop/activate logic driving them. Private
|
|
8
|
+
# machinery of {MenuBar}; an app never names it.
|
|
9
|
+
#
|
|
10
|
+
# cascade.open_below(segment_rect, item) # Enter/Down on the strip
|
|
11
|
+
# return true if cascade.handle_key(key) # MenuBar#handle_key, first
|
|
12
|
+
# cascade.close # focus lost, or rect changed
|
|
13
|
+
#
|
|
14
|
+
# A panel is a **non-modal overlay, not a child**, so it never takes focus:
|
|
15
|
+
# focus stays on the {MenuBar} for the whole interaction and every key
|
|
16
|
+
# arrives via {MenuBar#handle_key}, which offers it here first. That is
|
|
17
|
+
# {Component::Select}'s architecture extended to N levels, and it is why
|
|
18
|
+
# nothing in the key-dispatch ladder changes.
|
|
19
|
+
#
|
|
20
|
+
# Widths are measured here, per level — the panel is as wide as the level's
|
|
21
|
+
# widest label — because {ListDropdown} deliberately measures nothing
|
|
22
|
+
# itself (`DECISIONS.md` `D-select`).
|
|
23
|
+
#
|
|
24
|
+
# == Implementation details
|
|
25
|
+
# While open it consumes **everything** except the two keys that mean
|
|
26
|
+
# "leave this menu sideways", which only the strip can answer: LEFT at
|
|
27
|
+
# depth 1, and RIGHT on a row with no submenu. An open menu is quasi-modal
|
|
28
|
+
# — firing an app's `s`-to-save behind a visible panel is worse than a dead
|
|
29
|
+
# keystroke.
|
|
30
|
+
#
|
|
31
|
+
# UI-thread-confined, like everything in the tree (see {Screen}).
|
|
32
|
+
class Cascade
|
|
33
|
+
# The affordance painted on a row that opens a submenu. U+25B8 rather
|
|
34
|
+
# than the obvious `▶`: like {Component::Select}'s `▾` it is East-Asian
|
|
35
|
+
# **Neutral**, so it measures one column even under ambiguous-as-wide and
|
|
36
|
+
# stays outside `D-ambiguous-width`'s bet, where `▶` and `▼` are
|
|
37
|
+
# Ambiguous and would need an ASCII opt-in.
|
|
38
|
+
# @return [String]
|
|
39
|
+
SUBMENU_ARROW = "▸"
|
|
40
|
+
|
|
41
|
+
# Columns a submenu arrow occupies, the space before it included.
|
|
42
|
+
# @return [Integer]
|
|
43
|
+
ARROW_WIDTH = 2
|
|
44
|
+
|
|
45
|
+
def initialize
|
|
46
|
+
@levels = []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] whether any panel is open.
|
|
50
|
+
def open? = !@levels.empty?
|
|
51
|
+
|
|
52
|
+
# @return [Integer] how many panels are open; `0` when closed.
|
|
53
|
+
def depth = @levels.size
|
|
54
|
+
|
|
55
|
+
# Opens `item`'s children directly beneath `anchor`, closing anything
|
|
56
|
+
# already open first.
|
|
57
|
+
# @param anchor [Rect] the strip segment the menu drops from.
|
|
58
|
+
# @param item [Item] a childless one opens nothing.
|
|
59
|
+
# @return [void]
|
|
60
|
+
def open_below(anchor, item)
|
|
61
|
+
close
|
|
62
|
+
return unless item.submenu?
|
|
63
|
+
|
|
64
|
+
push(item) { |drop, rows, width| drop.anchor_to(anchor, rows: rows, width: width) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Closes every open panel, deepest first.
|
|
68
|
+
# @return [void]
|
|
69
|
+
def close = truncate(0)
|
|
70
|
+
|
|
71
|
+
# Offers a key to the deepest panel and to the cascade's own verbs.
|
|
72
|
+
# @param key [String]
|
|
73
|
+
# @return [Boolean] `true` when consumed — almost always, while open.
|
|
74
|
+
# `false` when closed, and for the two sideways keys {MenuBar} answers
|
|
75
|
+
# (see the class docs).
|
|
76
|
+
def handle_key(key)
|
|
77
|
+
return false unless open?
|
|
78
|
+
return true if deepest.move(key)
|
|
79
|
+
|
|
80
|
+
case key
|
|
81
|
+
when Keys::ENTER, " " then activate_highlighted
|
|
82
|
+
when Keys::RIGHT_ARROW
|
|
83
|
+
return false unless highlighted(depth - 1)&.submenu?
|
|
84
|
+
|
|
85
|
+
activate_highlighted
|
|
86
|
+
when Keys::LEFT_ARROW
|
|
87
|
+
return false if depth < 2
|
|
88
|
+
|
|
89
|
+
pop
|
|
90
|
+
when Keys::ESC then pop
|
|
91
|
+
else
|
|
92
|
+
# Only a printable: an open menu also swallows HOME, function keys
|
|
93
|
+
# and the five-byte junk Keys.getkey returns for an unrecognized
|
|
94
|
+
# escape sequence, and ringing at terminal noise is worse than
|
|
95
|
+
# silence. A printable is a deliberate, visible act.
|
|
96
|
+
Screen.instance.beep if Keys.printable?(key)
|
|
97
|
+
end
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Activates the deepest level's item bound to `key` — the drill-or-fire
|
|
102
|
+
# the mnemonic shares with Enter. The highlight moves there *first*, so a
|
|
103
|
+
# submenu anchors beside the row that opened it rather than beside
|
|
104
|
+
# wherever the cursor happened to be.
|
|
105
|
+
# @param key [String] a single printable, already downcased.
|
|
106
|
+
# @return [Boolean] whether an item on the deepest level claimed it. A
|
|
107
|
+
# miss is never offered to a shallower level.
|
|
108
|
+
def handle_mnemonic(key)
|
|
109
|
+
return false unless open?
|
|
110
|
+
|
|
111
|
+
level = depth - 1
|
|
112
|
+
item, drop = @levels[level]
|
|
113
|
+
index = item.items.index { |child| child.mnemonic == key }
|
|
114
|
+
return false if index.nil?
|
|
115
|
+
|
|
116
|
+
drop.select(index)
|
|
117
|
+
activate(level, item.items[index])
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# @return [ListDropdown] the deepest open panel.
|
|
124
|
+
def deepest = @levels.last[1]
|
|
125
|
+
|
|
126
|
+
# @return [void]
|
|
127
|
+
def activate_highlighted = activate(depth - 1, highlighted(depth - 1))
|
|
128
|
+
|
|
129
|
+
# Drills into `item`, or fires it and closes the cascade.
|
|
130
|
+
#
|
|
131
|
+
# @param level [Integer] the panel the item belongs to.
|
|
132
|
+
# @param item [Item, nil] `nil` (an off-content cursor) does nothing.
|
|
133
|
+
# @return [void]
|
|
134
|
+
def activate(level, item)
|
|
135
|
+
# Truncate first, for the mouse: a click on a shallower panel that is
|
|
136
|
+
# still visible routes to *that* panel, so anything deeper is stale.
|
|
137
|
+
truncate(level + 1)
|
|
138
|
+
return if item.nil?
|
|
139
|
+
|
|
140
|
+
if item.submenu?
|
|
141
|
+
push_beside(level, item)
|
|
142
|
+
else
|
|
143
|
+
# Closed before the listener runs, so an action that opens a dialog
|
|
144
|
+
# doesn't paint it under a menu. A listener-less leaf still closes:
|
|
145
|
+
# activation stays uniform.
|
|
146
|
+
close
|
|
147
|
+
item.on_click&.call
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Opens `item`'s children beside the row highlighted in `level`.
|
|
152
|
+
# @param level [Integer]
|
|
153
|
+
# @param item [Item]
|
|
154
|
+
# @return [void]
|
|
155
|
+
def push_beside(level, item)
|
|
156
|
+
anchor = @levels[level][1].cursor_row_rect
|
|
157
|
+
return if anchor.nil?
|
|
158
|
+
|
|
159
|
+
push(item) { |drop, rows, width| drop.anchor_beside(anchor, rows: rows, width: width) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Mounts a panel for `item`'s children and yields it for geometry.
|
|
163
|
+
# @param item [Item]
|
|
164
|
+
# @yieldparam drop [ListDropdown]
|
|
165
|
+
# @yieldparam rows [Integer]
|
|
166
|
+
# @yieldparam width [Integer]
|
|
167
|
+
# @return [void]
|
|
168
|
+
def push(item)
|
|
169
|
+
children = item.items
|
|
170
|
+
drop = ListDropdown.new
|
|
171
|
+
drop.renderer = renderer_for(children)
|
|
172
|
+
drop.items = children
|
|
173
|
+
drop.cursor = List::Cursor.new
|
|
174
|
+
level = @levels.size
|
|
175
|
+
# Wired *after* the items and cursor: {List#items=} and {List#cursor=}
|
|
176
|
+
# both fire on_cursor_changed, so wiring first would have the fresh
|
|
177
|
+
# panel truncate itself away as it was built.
|
|
178
|
+
drop.on_item_chosen = ->(_index, child) { activate(level, child) }
|
|
179
|
+
drop.on_cursor_changed = ->(_index, _child) { truncate(level + 1) }
|
|
180
|
+
# The cascade's own record of what is open is reconciled from the
|
|
181
|
+
# popup's own closure, not maintained alongside it: an outside click
|
|
182
|
+
# closes panels behind our back ({Popup#close_on_outside_click?}), and
|
|
183
|
+
# a level left in `@levels` after its panel is gone would have `depth`,
|
|
184
|
+
# `deepest` and `highlighted` all lying. Identity-keyed and idempotent,
|
|
185
|
+
# because the notice also arrives from `truncate` (which has already
|
|
186
|
+
# popped the entry) and from teardown, in no guaranteed order.
|
|
187
|
+
drop.on_close = -> { @levels.delete_if { |(_i, d)| d.equal?(drop) } }
|
|
188
|
+
# Chain each panel to the one it dropped out of, so a click on a
|
|
189
|
+
# deeper panel is "inside" the shallower ones and doesn't dismiss
|
|
190
|
+
# them. Level 0 owns nothing on purpose: a click on a dialog hosting
|
|
191
|
+
# the bar *should* close the whole menu and keep the dialog.
|
|
192
|
+
drop.owner = @levels.last&.last
|
|
193
|
+
@levels << [item, drop]
|
|
194
|
+
drop.open
|
|
195
|
+
yield(drop, children.size, width_for(children))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Closes the deepest panel; at depth 1 that closes the cascade.
|
|
199
|
+
# @return [void]
|
|
200
|
+
def pop = truncate(depth - 1)
|
|
201
|
+
|
|
202
|
+
# @param count [Integer] how many panels to keep.
|
|
203
|
+
# @return [void]
|
|
204
|
+
def truncate(count)
|
|
205
|
+
while depth > count
|
|
206
|
+
_item, drop = @levels.pop
|
|
207
|
+
drop.close
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# @param level [Integer]
|
|
212
|
+
# @return [Item, nil] the item under `level`'s cursor; `nil` when it sits
|
|
213
|
+
# off-content. The range guard matters: a cursor at `-1` would
|
|
214
|
+
# otherwise index the *last* child.
|
|
215
|
+
def highlighted(level)
|
|
216
|
+
item, drop = @levels[level]
|
|
217
|
+
position = drop.cursor.position
|
|
218
|
+
position.between?(0, item.items.size - 1) ? item.items[position] : nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @param items [Array<Item>]
|
|
222
|
+
# @return [Proc] item -> row: the label padded to the level's widest, plus
|
|
223
|
+
# an arrow column when any sibling has a submenu — so every arrow lands
|
|
224
|
+
# in the same column without asking the {List} how wide it ended up.
|
|
225
|
+
def renderer_for(items)
|
|
226
|
+
label_width = label_width_of(items)
|
|
227
|
+
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))
|
|
231
|
+
next row unless arrows
|
|
232
|
+
|
|
233
|
+
row + StyledString.plain(item.submenu? ? " #{SUBMENU_ARROW}" : " " * ARROW_WIDTH)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @param items [Array<Item>]
|
|
238
|
+
# @return [Integer] the panel width: the rendered row plus {List}'s two
|
|
239
|
+
# row gutters, plus a scrollbar column when the rows can't all be shown.
|
|
240
|
+
# As in {Component::Select}, the scrollbar is predicted from the item
|
|
241
|
+
# count rather than the final height — a panel the screen clamps
|
|
242
|
+
# shorter than {ListDropdown::MAX_VISIBLE_ROWS} scrolls without having
|
|
243
|
+
# bought that column, and ellipsizes one character early.
|
|
244
|
+
def width_for(items)
|
|
245
|
+
row = label_width_of(items) + (items.any?(&:submenu?) ? ARROW_WIDTH : 0)
|
|
246
|
+
row + 2 + (items.size > ListDropdown::MAX_VISIBLE_ROWS ? 1 : 0)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# @param items [Array<Item>]
|
|
250
|
+
# @return [Integer]
|
|
251
|
+
def label_width_of(items) = items.map { _1.cued_caption.display_width }.max || 0
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|