1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
//! Owned context-action menus.
//!
//! A [`ContextMenu`] is the reusable popup face for secondary-button
//! actions. It lives in `app`, not `widgets`, because it opens an owned
//! overlay; lower-layer controls such as [`List`](crate::widgets::List)
//! only report a screen-space context request. The owned [`Popup`]
//! substrate supplies modal input routing, viewport clamping/flipping,
//! Escape/outside dismissal, resize dismissal, anchor-scope teardown,
//! and correct top-of-stack z placement.
//!
//! Menus expose stable action keys rather than application-specific
//! commands. A caller captures the target object (preferably a stable id,
//! not a volatile list index), builds the actions that apply to it, and
//! handles the chosen key in [`ContextMenu::on_action`].
use std::cell::RefCell;
use std::rc::Rc;
use crate::base::{Point, Size};
use crate::layout::{Dimension, Style as LayoutStyle};
use crate::reactive::Scope;
use crate::render::Style;
use crate::ui::{Element, EventCtx, Key, Mods, Phase, Role, UiEvent, View};
use super::anchored::{place_panel, DismissReason, PanelAnchor, PanelWidth, Popup};
use super::overlays::Overlays;
use super::select::core::{
first_enabled, last_enabled, option_rows_view, page_highlight, resolve_overlays,
step_highlight, OptionRows,
};
use super::select::SelectOption;
use super::{current_theme, current_viewport};
const DEFAULT_MAX_VISIBLE: usize = 12;
const DEFAULT_MIN_WIDTH: i32 = 12;
type ActionBox = Box<dyn FnMut(&str)>;
type DismissBox = Box<dyn FnMut(DismissReason)>;
type ActionCallback = Rc<RefCell<Option<ActionBox>>>;
type DismissCallback = Rc<RefCell<Option<DismissBox>>>;
/// One context-menu action. `key` is the stable value delivered to
/// [`ContextMenu::on_action`]; `label` and optional `hint` are display
/// text. Disabled actions render faint and are skipped by keyboard
/// movement and activation.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ContextMenuItem {
pub key: String,
pub label: String,
pub hint: Option<String>,
pub disabled: bool,
}
impl ContextMenuItem {
pub fn new(key: impl Into<String>, label: impl Into<String>) -> ContextMenuItem {
ContextMenuItem {
key: key.into(),
label: label.into(),
hint: None,
disabled: false,
}
}
/// Muted, right-aligned supporting text such as a shortcut.
pub fn hint(mut self, hint: impl Into<String>) -> ContextMenuItem {
self.hint = Some(hint.into());
self
}
pub fn disabled(mut self, disabled: bool) -> ContextMenuItem {
self.disabled = disabled;
self
}
}
/// A keyboard-accessible action menu anchored at a screen cell.
///
/// `open` returns `None` for an empty menu, a menu with no enabled
/// action, an unavailable overlay context, or a viewport with no room.
/// Up/Down/Home/End/Page keys move the highlight, Enter or Space commits,
/// Escape abandons, and an outside press dismisses without acting below.
/// Mouse activation is left-button only.
pub struct ContextMenu {
items: Vec<ContextMenuItem>,
access_label: String,
max_visible: usize,
min_width: i32,
overlays: Option<Overlays>,
on_action: Option<ActionBox>,
on_dismiss: Option<DismissBox>,
}
impl ContextMenu {
pub fn new(items: impl IntoIterator<Item = ContextMenuItem>) -> ContextMenu {
ContextMenu {
items: items.into_iter().collect(),
access_label: "context actions".into(),
max_visible: DEFAULT_MAX_VISIBLE,
min_width: DEFAULT_MIN_WIDTH,
overlays: None,
on_action: None,
on_dismiss: None,
}
}
/// Accessible name for the menu, for example `"Alice actions"`.
pub fn access_label(mut self, label: impl Into<String>) -> ContextMenu {
self.access_label = label.into();
self
}
/// Maximum visible action rows before the menu windows around its
/// highlight (default 12, minimum 1).
pub fn max_visible(mut self, rows: usize) -> ContextMenu {
self.max_visible = rows.max(1);
self
}
/// Minimum menu width in terminal cells (default 12, minimum 4).
/// The viewport still clamps the result.
pub fn min_width(mut self, cells: i32) -> ContextMenu {
self.min_width = cells.max(4);
self
}
/// Explicit overlay store for a menu built outside [`App`](super::App).
/// Inside `App::mount`, the ambient store is used automatically.
pub fn overlays(mut self, overlays: &Overlays) -> ContextMenu {
self.overlays = Some(overlays.clone());
self
}
/// Fires once for a committed enabled action, after the popup has
/// already closed. The borrowed key is valid for the callback.
pub fn on_action(mut self, f: impl FnMut(&str) + 'static) -> ContextMenu {
self.on_action = Some(Box::new(f));
self
}
/// Observe any popup ending. Commit is delivered before
/// [`ContextMenu::on_action`] runs; other reasons never run the action.
pub fn on_dismiss(mut self, f: impl FnMut(DismissReason) + 'static) -> ContextMenu {
self.on_dismiss = Some(Box::new(f));
self
}
/// Open at `screen_position`, normally
/// [`ListContext::screen_position`](crate::widgets::ListContext::screen_position).
/// Placement prefers the row below the pointer, flips above when
/// cramped, and clamps horizontally into the viewport.
pub fn open(self, cx: Scope, screen_position: Point) -> Option<Popup> {
let overlays = resolve_overlays(cx, self.overlays)?;
let viewport = current_viewport();
if viewport.w <= 0 || viewport.h <= 0 || self.items.is_empty() {
return None;
}
let display: Vec<usize> = (0..self.items.len()).collect();
let select_options: Vec<SelectOption> = self
.items
.iter()
.map(|item| SelectOption {
key: item.key.clone(),
label: item.label.clone(),
hint: item.hint.clone(),
disabled: item.disabled,
})
.collect();
let options = Rc::new(select_options);
let seed = first_enabled(&options, &display)?;
let wanted_rows = self.max_visible.min(display.len()).max(1);
// Width is measured in display cells, not bytes or scalar chars.
// Hints reserve a two-cell gap when present; rows own one cell of
// inset on each edge.
let natural_width = self
.items
.iter()
.map(|item| {
let label = crate::text::width(&item.label);
let hint = item
.hint
.as_ref()
.map_or(0, |hint| 2 + crate::text::width(hint));
label + hint + 2
})
.max()
.unwrap_or(0)
.max(self.min_width)
.max(4);
let width = PanelWidth::Content {
min: self.min_width.min(viewport.w.max(1)),
max: natural_width,
};
let anchor = PanelAnchor::cell(screen_position);
// `Popup::open` may shorten a panel near a viewport edge. Feed
// that solved row count to the windowing logic so keyboard
// movement never highlights an off-panel row.
let placed = place_panel(
viewport,
anchor.rect,
Size::new(natural_width, wanted_rows as i32),
width,
);
if placed.is_empty() {
return None;
}
let visible = placed.h.max(1) as usize;
let items = Rc::new(self.items);
let action: ActionCallback = Rc::new(RefCell::new(self.on_action));
let dismiss: DismissCallback = Rc::new(RefCell::new(self.on_dismiss));
let session: Rc<RefCell<Option<Popup>>> = Rc::new(RefCell::new(None));
let access_label = self.access_label;
let theme = current_theme().tokens;
let build = {
let options = options.clone();
let display_values = display.clone();
let items = items.clone();
let session = session.clone();
let action = action.clone();
move |pcx: Scope, _flipped: bool| -> View {
let display = pcx.signal(display_values.clone());
let highlight = pcx.signal(seed);
let activate: Rc<dyn Fn(usize)> = Rc::new({
let items = items.clone();
let session = session.clone();
let action = action.clone();
move |position| {
let Some(item) = items.get(position) else {
return;
};
if item.disabled {
return;
}
let key = item.key.clone();
// Teardown precedes application code: the action
// may dispose its opener or open a replacement
// without overlapping modal layers.
let popup = session.borrow().clone();
if let Some(popup) = popup {
popup.dismiss(DismissReason::Commit);
}
if let Some(f) = action.borrow_mut().as_mut() {
f(&key);
}
}
});
let key_handler = {
let options = options.clone();
let activate = activate.clone();
move |ctx: &mut EventCtx, ev: &UiEvent| {
let UiEvent::Key(k) = ev else { return };
if k.mods != Mods::NONE {
return;
}
let current = highlight
.get_untracked()
.min(display_values.len().saturating_sub(1));
let target = match k.key {
Key::Down => {
Some(step_highlight(&options, &display_values, current, 1))
}
Key::Up => Some(step_highlight(&options, &display_values, current, -1)),
Key::Home => first_enabled(&options, &display_values),
Key::End => last_enabled(&options, &display_values),
Key::PageDown => Some(page_highlight(
&options,
&display_values,
current,
1,
visible,
)),
Key::PageUp => Some(page_highlight(
&options,
&display_values,
current,
-1,
visible,
)),
Key::Enter | Key::Char(' ') => {
activate(current);
ctx.stop_propagation();
return;
}
_ => return, // Escape belongs to Popup.
};
if let Some(target) = target {
highlight.set_if_changed(target);
}
ctx.stop_propagation();
}
};
let ink = theme.text;
let ground = theme.surface_raised;
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.role(Role::Menu)
.access_label(access_label.clone())
.draw(move |canvas, rect| {
canvas.fill_styled(rect, ' ', &Style::new().fg(ink).bg(ground));
})
.on(Phase::Bubble, key_handler)
.child(option_rows_view(
&theme,
OptionRows {
options: options.clone(),
display,
highlight,
checks: None,
max_visible: visible,
on_activate: activate,
},
))
.build()
}
};
let popup = Popup::open(
&overlays,
cx,
viewport,
anchor,
width,
Size::new(natural_width, visible as i32),
build,
)?;
popup.on_dismiss({
let session = session.clone();
move |reason| {
session.borrow_mut().take();
if let Some(f) = dismiss.borrow_mut().as_mut() {
f(reason);
}
}
});
*session.borrow_mut() = Some(popup.clone());
Some(popup)
}
}
#[cfg(test)]
#[path = "context_menu_tests.rs"]
mod tests;