tuile 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -27
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +2342 -158
- data/README.md +151 -505
- data/TERMINOLOGY.md +15 -5
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +28 -20
- data/book/05-focus.md +137 -19
- data/book/06-theming.md +103 -2
- data/book/07-components.md +567 -27
- data/book/08-testing.md +34 -4
- data/book/09-styled-text.md +3 -3
- data/book/README.md +7 -5
- data/examples/file_commander.rb +23 -17
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +527 -108
- data/ideas/arrow-key-navigation.md +17 -1
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +28 -27
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +12 -3
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +102 -11
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +24 -39
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +61 -123
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +17 -7
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +231 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +186 -31
- data/lib/tuile/event_queue.rb +45 -1
- data/lib/tuile/fake_screen.rb +40 -2
- data/lib/tuile/keys.rb +72 -0
- data/lib/tuile/screen.rb +212 -113
- data/lib/tuile/screen_pane.rb +125 -41
- data/lib/tuile/styled_string.rb +80 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2527 -358
- metadata +13 -3
- data/mise.toml +0 -2
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)
|
|
@@ -4,7 +4,7 @@ module Tuile
|
|
|
4
4
|
class Component
|
|
5
5
|
# A borderless, tinted, non-focusable floating selection list — the dropdown
|
|
6
6
|
# a *driver* drops open, drives by forwarding movement keys, and commits a
|
|
7
|
-
# pick from:
|
|
7
|
+
# pick from: an {Overlay} wrapping a {List} that never takes focus, so
|
|
8
8
|
# focus stays on the driver while the caller refills the rows, moves the
|
|
9
9
|
# highlight, and reads the pick.
|
|
10
10
|
#
|
|
@@ -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.
|
|
@@ -33,7 +34,7 @@ module Tuile
|
|
|
33
34
|
# different tint (a `Theme.ref(:token)` keeps the flip-tracking).
|
|
34
35
|
#
|
|
35
36
|
# UI-thread-confined, like every component (see {Screen}).
|
|
36
|
-
class ListDropdown <
|
|
37
|
+
class ListDropdown < Overlay
|
|
37
38
|
# The dropdown's {List}. Non-focusable on purpose: the driver forwards keys
|
|
38
39
|
# while focus stays on it, and a mouse click selects an item without
|
|
39
40
|
# stealing focus — so a driving text input never loses its caret
|
|
@@ -63,7 +64,7 @@ module Tuile
|
|
|
63
64
|
@list = Menu.new
|
|
64
65
|
@list.cursor = List::Cursor.new
|
|
65
66
|
@list.show_cursor_when_inactive = true # highlight the selection though focus stays on the driver
|
|
66
|
-
super(content: @list
|
|
67
|
+
super(content: @list)
|
|
67
68
|
self.bg_color = Theme.ref(:input_bg_color)
|
|
68
69
|
end
|
|
69
70
|
|
|
@@ -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
|
|
@@ -108,7 +124,16 @@ module Tuile
|
|
|
108
124
|
# Vertical flips but horizontal slides because covering the driver would
|
|
109
125
|
# hide what is being chosen, while sharing its columns is the point.
|
|
110
126
|
#
|
|
111
|
-
#
|
|
127
|
+
# **`anchor` is the region actually occupied, and may be taller than one
|
|
128
|
+
# row** — "beneath" means the row *after* it, so a multi-row driver (a
|
|
129
|
+
# {Component::TextArea} carrying an autocomplete menu) is cleared entirely
|
|
130
|
+
# rather than overdrawn from its second row down. A widget that paints one
|
|
131
|
+
# row but may be *assigned* more height passes its face, not its rect:
|
|
132
|
+
# {ComboBox} and {Select} both do, since a {Window} content slot hands them
|
|
133
|
+
# the full inner height.
|
|
134
|
+
#
|
|
135
|
+
# @param anchor [Rect] the region the driver occupies, of any height; the
|
|
136
|
+
# dropdown never covers it.
|
|
112
137
|
# @param rows [Integer] how many rows there are to show — the content
|
|
113
138
|
# count, not the height: more than fits turns the scrollbar on. `0`
|
|
114
139
|
# collapses the dropdown to an empty rect (drivers close instead).
|
|
@@ -120,26 +145,92 @@ module Tuile
|
|
|
120
145
|
# @return [void]
|
|
121
146
|
def anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS)
|
|
122
147
|
desired = [rows, max_rows].min
|
|
123
|
-
|
|
148
|
+
beneath = anchor.top + anchor.height
|
|
149
|
+
below = screen.size.height - beneath
|
|
124
150
|
above = anchor.top
|
|
125
151
|
if desired <= below
|
|
126
|
-
top =
|
|
152
|
+
top = beneath
|
|
127
153
|
height = desired
|
|
128
154
|
elsif above >= below
|
|
129
155
|
height = [desired, above].min
|
|
130
156
|
top = anchor.top - height
|
|
131
157
|
else
|
|
132
158
|
height = below
|
|
133
|
-
top =
|
|
159
|
+
top = beneath
|
|
134
160
|
end
|
|
135
161
|
width = [width, screen.size.width].min
|
|
136
|
-
self.size = Size.new(width, height)
|
|
137
162
|
self.rect = Rect.new([anchor.left, screen.size.width - width].min.clamp(0, nil), top, width, height)
|
|
138
163
|
# After the geometry: the setter rebuilds the list's padded rows against
|
|
139
164
|
# the width it can see, and the gutter takes a column off it.
|
|
140
165
|
@list.scrollbar_visibility = rows > height ? :visible : :gone
|
|
141
166
|
end
|
|
142
167
|
|
|
168
|
+
# Sizes and places the dropdown *beside* `anchor` — the placement a
|
|
169
|
+
# cascading submenu wants, where {#anchor_to} is the placement a field's
|
|
170
|
+
# dropdown wants.
|
|
171
|
+
#
|
|
172
|
+
# sub.anchor_beside(parent.cursor_row_rect, rows: kids.size, width: measured)
|
|
173
|
+
#
|
|
174
|
+
# Horizontally it sits against `anchor`'s right edge, **flipping** to its
|
|
175
|
+
# left when the right has no room (and clamping to the screen when neither
|
|
176
|
+
# side does). Vertically it **slides**: the panel's first row lines up with
|
|
177
|
+
# the anchored row, sliding up only far enough to keep the panel on screen.
|
|
178
|
+
#
|
|
179
|
+
# The two axes are the mirror image of {#anchor_to}'s, for the same reason:
|
|
180
|
+
# never cover the thing being chosen from. A field's dropdown must not
|
|
181
|
+
# cover the field, so it flips *vertically* and shares its columns; a
|
|
182
|
+
# submenu must not cover its parent panel, so it flips *horizontally* and
|
|
183
|
+
# shares its rows.
|
|
184
|
+
#
|
|
185
|
+
# @param anchor [Rect] the row the submenu belongs to — typically the
|
|
186
|
+
# parent dropdown's {#cursor_row_rect}. Its width is the parent panel's,
|
|
187
|
+
# which is what the submenu clears.
|
|
188
|
+
# @param rows [Integer] how many rows there are to show — the content
|
|
189
|
+
# count, not the height; more than fits turns the scrollbar on. `0`
|
|
190
|
+
# collapses the dropdown to an empty rect (drivers close instead).
|
|
191
|
+
# @param width [Integer] the panel's width in columns, clamped to the
|
|
192
|
+
# screen. **Required, with no default:** `anchor.width` is the *parent's*
|
|
193
|
+
# width and would be meaningless here, so the caller measures (see
|
|
194
|
+
# `DECISIONS.md` `D_select` on why the width policy stays with the
|
|
195
|
+
# driver).
|
|
196
|
+
# @param max_rows [Integer] rows shown before the list scrolls.
|
|
197
|
+
# @return [void]
|
|
198
|
+
def anchor_beside(anchor, rows:, width:, max_rows: MAX_VISIBLE_ROWS)
|
|
199
|
+
height = [rows, max_rows, screen.size.height].min
|
|
200
|
+
width = [width, screen.size.width].min
|
|
201
|
+
right = anchor.left + anchor.width
|
|
202
|
+
left = if right + width <= screen.size.width || (anchor.left - width).negative?
|
|
203
|
+
right
|
|
204
|
+
else
|
|
205
|
+
anchor.left - width
|
|
206
|
+
end
|
|
207
|
+
left = left.clamp(0, [screen.size.width - width, 0].max)
|
|
208
|
+
top = [anchor.top, screen.size.height - height].min.clamp(0, nil)
|
|
209
|
+
self.rect = Rect.new(left, top, width, height)
|
|
210
|
+
# After the geometry, as in {#anchor_to}: the setter rebuilds the list's
|
|
211
|
+
# padded rows against the width it can see.
|
|
212
|
+
@list.scrollbar_visibility = rows > height ? :visible : :gone
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The highlighted row's rect on screen — what a cascading submenu anchors
|
|
216
|
+
# against, via {#anchor_beside}.
|
|
217
|
+
#
|
|
218
|
+
# It lives here rather than in the driver because {ListDropdown} owns the
|
|
219
|
+
# list's geometry: a driver computing `top + position - scroll_top_row`
|
|
220
|
+
# itself would have to reach through to the private list.
|
|
221
|
+
# @return [Rect, nil] one row spanning the panel's width, or `nil` when
|
|
222
|
+
# the cursor is off-content ({List::Cursor::None}, an empty list) or its
|
|
223
|
+
# row is scrolled out of the viewport.
|
|
224
|
+
def cursor_row_rect
|
|
225
|
+
return nil if @list.rect.empty?
|
|
226
|
+
return nil unless @list.cursor.position.between?(0, @list.items.size - 1)
|
|
227
|
+
|
|
228
|
+
row = @list.cursor.position - @list.scroll_top_row
|
|
229
|
+
return nil unless row.between?(0, @list.rect.height - 1)
|
|
230
|
+
|
|
231
|
+
Rect.new(@list.rect.left, @list.rect.top + row, @list.rect.width, 1)
|
|
232
|
+
end
|
|
233
|
+
|
|
143
234
|
# Forwards a cursor-movement key to the list. The driver calls this from
|
|
144
235
|
# its own key handler; a truthy return means "consumed — stop here", falsy
|
|
145
236
|
# means "not mine — proceed with normal editing/dispatch". Only {MOVE_KEYS}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A {TextView} purpose-built for log output: auto-scroll is on, the
|
|
6
|
+
# scrollbar is visible, and lines arrive via {#log} — from any thread.
|
|
7
|
+
# Point a logger at a {LogTextView::IO} to route its lines here:
|
|
8
|
+
#
|
|
9
|
+
# view = Tuile::Component::LogTextView.new
|
|
10
|
+
# logger = Logger.new(Tuile::Component::LogTextView::IO.new(view))
|
|
11
|
+
# logger.info("started") # appears in the view, from any thread
|
|
12
|
+
#
|
|
13
|
+
# Any logger that writes formatted lines to an IO works the same way —
|
|
14
|
+
# for example `TTY::Logger` configured with the `:console` handler and
|
|
15
|
+
# `output: LogTextView::IO.new(view)`.
|
|
16
|
+
#
|
|
17
|
+
# Add it to a layout as-is for a frameless log pane, or use {LogWindow}
|
|
18
|
+
# for the framed assembly. A {TextView} rather than a {List}: long lines
|
|
19
|
+
# (stacktraces, wide log records) word-wrap rather than ellipsize — a
|
|
20
|
+
# truncated log line hides the very detail you opened the log to read.
|
|
21
|
+
class LogTextView < TextView
|
|
22
|
+
def initialize
|
|
23
|
+
super
|
|
24
|
+
self.auto_scroll = true
|
|
25
|
+
self.scrollbar_visibility = :visible
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Appends the given line to the log. Safe to call from any thread —
|
|
29
|
+
# the one exception to the UI-thread confinement every component
|
|
30
|
+
# carries: the append is marshalled through `event_queue.submit`, so
|
|
31
|
+
# it lands once a loop drains the queue (deferred before the first
|
|
32
|
+
# loop; silently dropped after the loop has returned).
|
|
33
|
+
# @param string [String, nil] the line (or multiple lines) to log;
|
|
34
|
+
# `nil` is a no-op.
|
|
35
|
+
# @return [void]
|
|
36
|
+
def log(string)
|
|
37
|
+
return if string.nil?
|
|
38
|
+
|
|
39
|
+
screen.event_queue.submit { add_line(string) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# IO-shaped adapter that forwards each log line to its sink's `#log`.
|
|
43
|
+
# Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
|
|
44
|
+
# call `output.puts`, e.g. `TTY::Logger`).
|
|
45
|
+
class IO
|
|
46
|
+
# @param sink [LogTextView, LogWindow] anything responding to `#log`.
|
|
47
|
+
def initialize(sink)
|
|
48
|
+
@sink = sink
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param string [String]
|
|
52
|
+
# @return [void]
|
|
53
|
+
def write(string)
|
|
54
|
+
@sink.log(string.chomp)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param string [String]
|
|
58
|
+
# @return [void]
|
|
59
|
+
def puts(string)
|
|
60
|
+
@sink.log(string)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Stdlib `Logger` only treats an object as an IO target when it
|
|
64
|
+
# responds to both {#write} and {#close}; otherwise it tries to
|
|
65
|
+
# interpret it as a filename. This is a no-op.
|
|
66
|
+
# @return [void]
|
|
67
|
+
def close; end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -2,65 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# A {Window} framing a {LogTextView} — the framed log pane. All the log
|
|
6
|
+
# behavior lives on the view (see {LogTextView} for the adapter and the
|
|
7
|
+
# wrap rationale); the window adds the border, caption and delegation:
|
|
7
8
|
#
|
|
8
9
|
# log_window = Tuile::Component::LogWindow.new
|
|
9
10
|
# logger = Logger.new(Tuile::Component::LogWindow::IO.new(log_window))
|
|
10
|
-
#
|
|
11
|
-
# Any logger that writes formatted lines to an IO works the same way —
|
|
12
|
-
# for example `TTY::Logger` configured with the `:console` handler and
|
|
13
|
-
# `output: LogWindow::IO.new(window)`.
|
|
14
11
|
class LogWindow < Window
|
|
12
|
+
# Alias of {LogTextView::IO}, which accepts the window itself as its sink.
|
|
13
|
+
# @return [Class]
|
|
14
|
+
IO = LogTextView::IO
|
|
15
|
+
|
|
15
16
|
# @param caption [String]
|
|
16
17
|
def initialize(caption = "Log")
|
|
17
18
|
super
|
|
18
|
-
|
|
19
|
-
# Word-wrap long lines (stacktraces, wide log records) rather than
|
|
20
|
-
# ellipsizing them as a {List} would — a truncated log line hides the
|
|
21
|
-
# very detail you opened the log to read.
|
|
22
|
-
view.auto_scroll = true
|
|
23
|
-
self.content = view
|
|
24
|
-
self.scrollbar = true
|
|
19
|
+
self.content = LogTextView.new
|
|
25
20
|
end
|
|
26
21
|
|
|
27
|
-
# Appends given line to the log
|
|
28
|
-
#
|
|
22
|
+
# Appends the given line to the log, from any thread — delegates to
|
|
23
|
+
# {LogTextView#log}.
|
|
24
|
+
# @param string [String, nil] the line (or multiple lines) to log;
|
|
25
|
+
# `nil` is a no-op.
|
|
29
26
|
# @return [void]
|
|
30
27
|
def log(string)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
screen.event_queue.submit do
|
|
34
|
-
content.add_line(string)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# IO-shaped adapter that forwards each log line to the owning {LogWindow}.
|
|
39
|
-
# Implements both {#write} (stdlib `Logger`) and {#puts} (loggers that
|
|
40
|
-
# call `output.puts`, e.g. `TTY::Logger`).
|
|
41
|
-
class IO
|
|
42
|
-
# @param window [LogWindow]
|
|
43
|
-
def initialize(window)
|
|
44
|
-
@window = window
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# @param string [String]
|
|
48
|
-
# @return [void]
|
|
49
|
-
def write(string)
|
|
50
|
-
@window.log(string.chomp)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# @param string [String]
|
|
54
|
-
# @return [void]
|
|
55
|
-
def puts(string)
|
|
56
|
-
@window.log(string)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Stdlib `Logger` only treats an object as an IO target when it
|
|
60
|
-
# responds to both {#write} and {#close}; otherwise it tries to
|
|
61
|
-
# interpret it as a filename. This is a no-op.
|
|
62
|
-
# @return [void]
|
|
63
|
-
def close; end
|
|
28
|
+
content.log(string)
|
|
64
29
|
end
|
|
65
30
|
end
|
|
66
31
|
end
|
|
@@ -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 ({Overlay#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
|