kui_core/runtime/menu_api.rs
1//! `Core`'s menu surface: opening one, drawing the stock one into the
2//! frame, and consuming the events its own rows produce.
3//!
4//! The menu the core opens is drawn by [`crate::widgets::context_menu`] —
5//! the same public widget an app calls — through the moment `Ui::finish`
6//! already reserves for content the view did not build (the `"root"`
7//! fill). So there is no overlay layer here, no window of the core's own,
8//! and nothing the conformance corpus cannot see: the menu is ordinary
9//! nodes in an ordinary frame.
10//!
11//! Its rows post ordinary click events too, which is why this module
12//! *takes them back*. A row the core built is found by key — recorded
13//! while building, never guessed at from a payload — and the events those
14//! keys produce never reach the app; what reaches the app is the item's
15//! own payload, on the node the menu was opened over.
16
17use crate::geom::Vec2;
18/// The longest selection a definition panel is offered for. A word, a
19/// term, a short phrase — past this it is a passage, and a dictionary has
20/// nothing to say about a passage.
21pub const LOOKUP_MAX: usize = 100;
22
23use crate::input::UiEvent;
24use crate::key::Key;
25use crate::menu::{Menu, MenuAction, MenuItem, MenuRole};
26use crate::runtime::Core;
27use crate::tree::OriginId;
28use crate::ui::Ui;
29use crate::value::Value;
30use crate::window::WindowId;
31
32impl Core {
33 /// The menu this window has open, if any.
34 pub fn menu(&self) -> Option<&Menu> {
35 self.menu.as_ref()
36 }
37
38 /// Tells the core that this host shows menus itself — an `NSMenu`, a
39 /// `TrackPopupMenu`, whatever the platform has. The core then keeps the
40 /// open menu as state and **does not
41 /// draw it**: the host reads `menu()`, shows it, and reports back with
42 /// [`Self::activate_menu_item`] or [`Self::close_menu`].
43 ///
44 /// Declared once by the driver, not per menu, because it is a fact
45 /// about the host and not about any one menu. Off by default: a host
46 /// that says nothing gets the drawn menu, which is every binding's
47 /// starting point and the only thing a headless test can see.
48 pub fn set_native_menus(&mut self, on: bool) {
49 self.native_menus = on;
50 }
51
52 /// Tells the core that this host can show the platform's definition
53 /// panel — macOS's Look Up. The standard Look Up row is then offered
54 /// where it means something, and a force click over text asks for
55 /// one; without it the core neither offers nor asks, because an item
56 /// that does nothing is worse than an item that is not there.
57 pub fn set_lookup_available(&mut self, on: bool) {
58 self.lookup_available = on;
59 }
60
61 /// Whether the host said it can show a definition panel.
62 pub fn lookup_available(&self) -> bool {
63 self.lookup_available
64 }
65
66 /// Whether the host said it shows menus itself.
67 pub fn native_menus(&self) -> bool {
68 self.native_menus
69 }
70
71 /// The host's menu reports that item `i` was chosen: the same path a
72 /// press on the drawn menu's row takes — the core performs what it
73 /// can, queues what the host must do, and returns the events the app
74 /// hears (one `menu` event on the node the menu was about).
75 ///
76 /// `None` when nothing was taken: no menu is open, or row `i` cannot
77 /// be chosen — a disabled row, a separator — in which case the menu
78 /// stays open and nothing is posted, since a native menu never reports
79 /// such a row and the drawn one has no click on it, so a door that
80 /// names one (`activateMenuItem(1)` over a select whose second option
81 /// is disabled) should be answered the way the pointer would be,
82 /// rather than handing the app a choice it disabled.
83 /// An index past the end closes the menu and posts nothing
84 /// (`Some` and empty), which is what a host reporting a row this build
85 /// does not know should do.
86 pub fn activate_menu_item(&mut self, i: usize) -> Option<Vec<UiEvent>> {
87 self.activate_menu_path(&[i])
88 }
89
90 /// [`Self::activate_menu_item`] for a row inside a submenu, by its
91 /// path: `[2, 0]` is the first row of the third row's submenu
92 /// ([`MenuItem::at_path`]) — what a host whose own menu nests them
93 /// (an `NSMenu`'s submenus) reports (backlog F128). A row that opens a
94 /// submenu is refused like a disabled one: the platform opens it and
95 /// never reports it chosen. A path that names no row closes the menu
96 /// and posts nothing, as an index past the end does.
97 pub fn activate_menu_path(&mut self, path: &[usize]) -> Option<Vec<UiEvent>> {
98 let menu = self.menu.as_ref()?;
99 let mut out = Vec::new();
100 match MenuItem::at_path(&menu.items, path) {
101 Some(_) if !MenuItem::choosable_at(&menu.items, path) => return None,
102 Some(_) => self.choose_menu_path(path, &mut out),
103 None => {
104 self.close_menu();
105 }
106 }
107 // The same way out an input's events take: a row of the devtools'
108 // own select is the panel's whichever menu showed it.
109 self.outbound(&mut out);
110 Some(out)
111 }
112
113 /// What the selection would be looked *up* as, or `None` when it is
114 /// not something to look up.
115 ///
116 /// A definition panel answers a word or a short phrase. Handed a
117 /// paragraph it draws the whole thing back over the page as one
118 /// enormous highlighted strip and then says "No Results Found" — seen
119 /// in the field, 2026-09-09 — so a selection that spans lines, or runs
120 /// past 100 characters, is neither offered nor asked about.
121 /// A force click always passes: it selects one word.
122 pub fn lookup_text(&self) -> Option<String> {
123 let text = self.copy_selection().filter(|t| !t.trim().is_empty())?;
124 let one_line = !text.contains('\n');
125 (one_line && text.chars().count() <= LOOKUP_MAX).then_some(text)
126 }
127
128 /// A definition-panel request for whatever is selected: the text, and
129 /// the baseline origin of its first line to anchor the panel at.
130 pub(crate) fn lookup_action(&self) -> Option<MenuAction> {
131 if !self.lookup_available {
132 return None;
133 }
134 let text = self.lookup_text()?;
135 let at = self.selection_anchor()?;
136 Some(MenuAction::LookUp { text, at })
137 }
138
139 /// Opens one. A second replaces the first: a window has one menu, the
140 /// way it has one selection and one focus.
141 ///
142 /// Nothing is drawn here. The next frame draws it, because the menu is
143 /// state and the frame is a function of state — which is also what
144 /// makes an app that never pumps another frame after a right-click a
145 /// bug the app can see rather than a menu that appears out of turn.
146 pub fn open_menu(&mut self, mut menu: Menu) {
147 // `at` arrives in the host's coordinates (ADR 0024); the menu is
148 // kept in the window's, which the drawn one and the native one
149 // both place by.
150 menu.at = menu.at.plus(self.dt_shift());
151 self.open_menu_raw(menu);
152 }
153
154 /// `open_menu` for a point already in window coordinates — the
155 /// right-click's own.
156 pub(crate) fn open_menu_raw(&mut self, mut menu: Menu) {
157 // Whose menu it is, unless the caller said: the node it is about.
158 // An extension that opens a menu over its own node hears the rows
159 // come back, the way it hears every other event it declared (ADR
160 // 0014 decision 6).
161 //
162 // The node and not `self.origin()`, which is the *builder's*: it
163 // is right during a view and stale afterwards — whatever ran last
164 // in the frame that finished, an extension in any app that loads
165 // one — so a host opening a menu from its own event handler would
166 // have the rows answered to somebody else.
167 if menu.origin == crate::tree::OriginId::HOST {
168 menu.origin = self
169 .tree
170 .keys
171 .iter()
172 .position(|k| *k == menu.target)
173 .map_or_else(|| self.origin(), |i| self.tree.origins[i]);
174 }
175 // Which editor the menu is about, remembered now rather than
176 // looked up when a row is chosen: the menu's rows are focusable
177 // (they have to be — the arrow keys are the composite's), so by
178 // the time Copy runs, focus has moved off the field the user
179 // right-clicked. The window's *selection* survives the press
180 // (a press under `OriginId::MENU` is spared), but an editor's
181 // lives with its focus.
182 self.menu_editor = self.edit.focused();
183 // The accelerators in the platform's spelling, as the bar's are
184 // (`declare_menu_bar`): a portable `"mod+shift+n"` is drawn `⇧⌘N`
185 // or `Ctrl+Shift+N`, and a host that shows the menu itself reads
186 // the same string back (backlog F127).
187 MenuItem::normalize_accels(&mut menu.items);
188 self.menu = Some(menu);
189 // A new menu opens with none of its submenus open.
190 self.menu_sub = Submenus::default();
191 }
192
193 /// A select field built this frame (`widgets::select`): its key and
194 /// the rows its menu will have. Read back by
195 /// [`Self::consume_select_events`] when the field is clicked.
196 pub(crate) fn declare_select(&mut self, key: Key, items: Vec<MenuItem>) {
197 self.selects.push((key, items));
198 }
199
200 /// A click on a select field is not the app's: taken back here and
201 /// answered with the field's menu, opened under its bottom-left
202 /// corner — the core's own menu, so what follows (the rows, a choice,
203 /// a dismissal) is `consume_menu_events`' as for any other. The
204 /// field's node is the target, so the choice is posted on it.
205 pub(crate) fn consume_select_events(&mut self, out: &mut Vec<UiEvent>) {
206 if self.selects.is_empty() {
207 return;
208 }
209 let mut clicked: Option<Key> = None;
210 out.retain(|ev| {
211 let is = ev.payload.get_bool("select") == Some(true)
212 && self.selects.iter().any(|(k, _)| *k == ev.key);
213 if is {
214 clicked = Some(ev.key);
215 }
216 !is
217 });
218 let Some(key) = clicked else {
219 return;
220 };
221 let Some((_, items)) = self.selects.iter().find(|(k, _)| *k == key) else {
222 return;
223 };
224 let items = items.clone();
225 // Under the field, in window coordinates: the last frame laid the
226 // field out, which is the frame the click was made against.
227 let at = self
228 .tree
229 .keys
230 .iter()
231 .position(|k| *k == key)
232 .map(|i| {
233 let (p, s) = (self.tree.pos[i], self.tree.size[i]);
234 Vec2::new(p.x, p.y + s.h)
235 })
236 .unwrap_or_default();
237 self.open_menu_raw(Menu::new(key, at, items));
238 }
239
240 /// Closes it, and every submenu open in it. Returns whether one was
241 /// open.
242 pub fn close_menu(&mut self) -> bool {
243 self.menu_sub = Submenus::default();
244 self.menu.take().is_some()
245 }
246
247 /// The submenus open in the drawn context menu: the row opened at each
248 /// level, outermost first — `[2]` is the third row's submenu, `[2, 0]`
249 /// that and the submenu of its first row. Empty when none is, and
250 /// always while the host shows menus itself (backlog F128).
251 pub fn menu_submenus(&self) -> &[usize] {
252 &self.menu_sub.open
253 }
254
255 /// What choosing an item left for the host: clipboard work, which is
256 /// the host's in this library. Drained like the window and audio
257 /// commands, and empty on every frame of an app whose menus are all
258 /// its own.
259 pub fn take_menu_actions(&mut self) -> Vec<MenuAction> {
260 std::mem::take(&mut self.menu_actions)
261 }
262
263 /// Draws the open menu into the frame being built, if there is one.
264 /// Called by `Ui::finish` after the extensions have filled their
265 /// slots, so the menu is the last thing declared and therefore the
266 /// frame's modal scope and its topmost float.
267 pub(crate) fn build_menu(ui: &mut Ui<'_>) {
268 if ui.core().native_menus {
269 // The host is showing it. Nothing is drawn, so there are no
270 // rows to click and `consume_menu_events` finds nothing to
271 // take back.
272 return;
273 }
274 let Some(menu) = ui.core().menu.clone() else {
275 return;
276 };
277 // The widget floats against the host's viewport, whose origin is
278 // the dock's edge under a left dock (ADR 0024): the window point
279 // becomes a host one. Its nodes are opened under `OriginId::MENU`,
280 // which is how `consume_menu_events` knows them.
281 let at = menu.at.minus(ui.core().dt_shift());
282 crate::widgets::context_menu(ui, at, &menu.items);
283 }
284
285 /// The stock items for a right-click on `region`, or none when there
286 /// is nothing standard to offer there. An editor gets the four every
287 /// platform's field has; a selection scope gets the two that mean
288 /// anything without a text model behind them — nothing owns the text
289 /// under a label, so it cannot be cut into or pasted over.
290 ///
291 /// Every item is always present and only its *enabling* moves: a menu
292 /// whose rows shuffle depending on what happens to be possible is one
293 /// nobody can use without reading it every time.
294 pub(crate) fn default_menu_items(
295 &self,
296 editor: Option<Key>,
297 scope: Option<Key>,
298 ) -> Vec<MenuItem> {
299 if let Some(key) = editor {
300 let has = self.edit.copy_selection(key).is_some_and(|t| !t.is_empty());
301 return vec![
302 MenuItem::role(MenuRole::Cut).enabled(has),
303 MenuItem::role(MenuRole::Copy).enabled(has),
304 // The core cannot see a clipboard, so it cannot know
305 // whether there is anything to paste; the host that can
306 // will say so when it renders this natively (step 3).
307 MenuItem::role(MenuRole::Paste),
308 MenuItem::separator(),
309 MenuItem::role(MenuRole::SelectAll),
310 ];
311 }
312 if scope.is_some() {
313 // `copy_selection`, not `selection_text`: a `cells` grid is a
314 // scope too, and its selection is in cells rather than in
315 // runs — reading only the text one left Copy dimmed over a
316 // terminal with half its screen selected.
317 let has = self.copy_selection().is_some_and(|t| !t.is_empty());
318 let mut items = vec![
319 MenuItem::role(MenuRole::Copy).enabled(has),
320 MenuItem::role(MenuRole::SelectAll),
321 ];
322 // Only where the host can show one, and only with something
323 // to look up: the row is the platform's, and a dead one would
324 // be a promise this library cannot keep.
325 if self.lookup_available {
326 // Enabled only for something a dictionary can answer —
327 // dimmed for a passage, and dimmed rather than missing, so
328 // the rows a reader reaches for stay where they were.
329 let can = self.lookup_text().is_some();
330 items.insert(0, MenuItem::role(MenuRole::LookUp).enabled(has && can));
331 items.insert(1, MenuItem::separator());
332 }
333 return items;
334 }
335 Vec::new()
336 }
337
338 /// A secondary press the app did not claim: opens the stock menu when
339 /// the press landed on something with standard items, and does nothing
340 /// at all otherwise — a right-click on a plain box has never opened a
341 /// menu and does not start now.
342 ///
343 /// `claimed` is whether the node under the pointer declared
344 /// `onContextMenu`. That declaration wins: the app asked to own the
345 /// menu there, and one of the two has to win by declaration rather
346 /// than by luck.
347 pub(crate) fn auto_menu(&mut self, at: Vec2, claimed: bool) {
348 if claimed {
349 return;
350 }
351 let Some(region) = self.interaction.hit_at(at) else {
352 return;
353 };
354 let (key, origin) = (region.key, region.origin);
355 let editor = region.edit_origin.map(|_| key);
356 let scope = region.select_scope;
357 let items = self.default_menu_items(editor, scope);
358 if items.is_empty() {
359 return;
360 }
361 let target = editor.or(scope).unwrap_or(key);
362 self.open_menu_raw(Menu::new(target, at, items).origin(origin));
363 // The press told us which editor this is about, which is better
364 // than what held focus: a right-click moves no focus (it must
365 // leave a selection alone), so the field under the pointer is not
366 // necessarily the focused one.
367 self.menu_editor = editor;
368 }
369
370 /// Filters the events one input produced: anything belonging to the
371 /// core's own menu is consumed and acted on, and what the app hears
372 /// instead is the item's payload on the node the menu was about.
373 ///
374 /// Runs on the way out of `handle_input`, so a menu row cannot leak a
375 /// click into an app that never declared one.
376 pub(crate) fn consume_menu_events(&mut self, out: &mut Vec<UiEvent>) {
377 if self.menu.is_none() {
378 return;
379 }
380 let taken = Self::take_surface_events(out, OriginId::MENU);
381 if let Some(path) = taken.row_path() {
382 // A row that opens a submenu opens it — the click, Enter, a
383 // reader's press — and stays open; any other row is chosen.
384 let opens = self
385 .menu
386 .as_ref()
387 .and_then(|m| MenuItem::at_path(&m.items, &path))
388 .is_some_and(MenuItem::has_submenu);
389 if opens {
390 let keyboard = self.focus_visible;
391 self.open_submenu(MenuSurface::Context, path, keyboard);
392 } else {
393 self.choose_menu_path(&path, out);
394 }
395 } else if taken.dismissed {
396 self.close_menu();
397 }
398 }
399
400 /// Takes every event of one of the core's own surfaces out of `out`
401 /// and reads what they said: a `dismiss` on the surface's modal root,
402 /// a click on the `row`th item (its payload is `{row}`, the message
403 /// `widgets::menu_row_tag` gave it, whether the pointer or Enter
404 /// clicked it), a click on the `title`th menu of the bar (`{title}`).
405 /// The one filter both menus consume through, so what a surface's
406 /// nodes post is read in one place. Hover, focus and the rest of a
407 /// surface's own events are taken with them: none of it is the app's.
408 pub(crate) fn take_surface_events(out: &mut Vec<UiEvent>, origin: OriginId) -> Taken {
409 let mut taken = Taken::default();
410 let index = |v: &Value, name: &str| v.get(name).and_then(Value::as_int).map(|i| i as usize);
411 out.retain(|ev| {
412 if ev.origin != origin {
413 return true;
414 }
415 if ev.kind() == Some("dismiss") {
416 taken.dismissed = true;
417 } else if let Some(i) = index(&ev.payload, "row") {
418 taken.row = Some(i);
419 taken.path = row_path_of(&ev.payload);
420 } else if let Some(i) = index(&ev.payload, "title") {
421 taken.title = Some(i);
422 }
423 false
424 });
425 taken
426 }
427
428 /// Performs one item and closes the menu. A standard role the core can
429 /// finish it finishes; the clipboard three become a [`MenuAction`] for
430 /// the host; everything else is the app's, and reaches it as an event
431 /// on the node the menu was opened over.
432 fn choose_menu_path(&mut self, path: &[usize], out: &mut Vec<UiEvent>) {
433 let Some(menu) = self.menu.clone() else {
434 return;
435 };
436 let Some(item) = MenuItem::at_path(&menu.items, path).cloned() else {
437 return;
438 };
439 self.close_menu();
440 self.perform_menu_item(&item, menu.target, menu.origin, out);
441 }
442
443 /// One item, performed and posted, wherever it was chosen from: the
444 /// open context menu's row, a host's native menu, or a menu of the
445 /// application menu bar. The two callers differ only in what they close first
446 /// and what node the event lands on, so everything after that is here
447 /// and cannot drift between them.
448 ///
449 /// `target` is the node the event is posted on and `origin` who hears
450 /// it; the standard roles act on [`Core::menu_editor`], which each
451 /// caller sets when its menu opens — an editor's selection lives with
452 /// its focus, and a menu's rows take that focus.
453 pub(crate) fn perform_menu_item(
454 &mut self,
455 item: &MenuItem,
456 target: Key,
457 origin: crate::tree::OriginId,
458 out: &mut Vec<UiEvent>,
459 ) {
460 match item.role {
461 MenuRole::Separator => return,
462 MenuRole::SelectAll => {
463 // The one standard item that needs nobody: an editor
464 // selects its own text, a scope selects its runs.
465 match self.menu_editor {
466 Some(key) => {
467 self.move_focus(Some(key));
468 // The editor directly, not back through
469 // `handle_input`: this runs *inside* one already,
470 // and the events the nested call returned were
471 // dropped on the floor.
472 self.edit_with_fonts(|edit, fs| {
473 edit.apply_key(
474 key,
475 crate::input::EditKey::SelectAll,
476 crate::input::Mods::default(),
477 fs,
478 )
479 });
480 }
481 None => {
482 let scope = self.selection().map_or(target, |s| s.scope);
483 self.select_all_in(scope);
484 }
485 }
486 }
487 MenuRole::Copy => {
488 // `copy_selection` again, for the reason it is used to
489 // enable the row: a `cells` grid's selection is in cells,
490 // and reading only the text one left Copy lit over a
491 // terminal and then copied nothing when it was chosen.
492 let text = match self.menu_editor {
493 Some(key) => self.edit.copy_selection(key),
494 None => self.copy_selection().filter(|t| !t.is_empty()),
495 };
496 if let Some(text) = text {
497 let html = self.selection_html();
498 self.menu_actions
499 .push(MenuAction::SetClipboard { text, html });
500 }
501 }
502 MenuRole::Cut => {
503 // Only an editor can be cut from: nothing owns the text
504 // behind a static selection, so there is nothing to take
505 // it out of. The item is simply not offered there.
506 if let Some(key) = self.menu_editor
507 && let Some(text) = self.cut_editor(key)
508 {
509 self.move_focus(Some(key));
510 // No `html`: an editor's text is one style, and what
511 // was cut is gone anyway.
512 self.menu_actions
513 .push(MenuAction::SetClipboard { text, html: None });
514 // And the edit it is: every other mutation posts one
515 // (`apply_text`, `apply_key`, a reader's `setValue`),
516 // so an app mirroring the field hears this one too
517 // (AR15).
518 self.push_edit_event(key, "changed", out);
519 }
520 }
521 MenuRole::Paste => self.queue_paste(),
522 MenuRole::LookUp => {
523 if let Some(action) = self.lookup_action() {
524 self.menu_actions.push(action);
525 }
526 }
527 MenuRole::Custom => {}
528 }
529 // Every chosen item posts, including the standard ones: an app
530 // that wants to know its editor was cut from does not have to
531 // guess, and one that does not simply ignores the event.
532 let payload = item.id.clone().unwrap_or_else(|| Value::str(item.text()));
533 out.push(UiEvent {
534 origin,
535 window: WindowId::MAIN,
536 key: target,
537 payload: Value::map([
538 ("kind", Value::str("menu")),
539 ("role", Value::str(item.role.name())),
540 ("item", payload),
541 ]),
542 slot: None,
543 });
544 }
545}
546
547/// What one input said to one of the core's surfaces (`Core::take_surface_events`).
548#[derive(Default)]
549pub(crate) struct Taken {
550 pub dismissed: bool,
551 pub row: Option<usize>,
552 /// The rows opened on the way to `row`, for a row of a submenu.
553 pub path: Vec<usize>,
554 pub title: Option<usize>,
555}
556
557impl Taken {
558 /// The clicked row's whole path, `path` then `row`.
559 pub fn row_path(&self) -> Option<Vec<usize>> {
560 let row = self.row?;
561 Some(self.path.iter().copied().chain([row]).collect())
562 }
563}
564
565/// The `path` a submenu row's tag carries (`widgets::menu_row_tag`);
566/// empty for a top-level row, which carries none.
567fn row_path_of(payload: &Value) -> Vec<usize> {
568 match payload.get("path") {
569 Some(Value::List(ps)) => ps
570 .iter()
571 .filter_map(Value::as_int)
572 .map(|p| p as usize)
573 .collect(),
574 _ => Vec::new(),
575 }
576}
577
578// -- Submenus (backlog F128) --------------------------------------------------
579// A row with a submenu opens it beside itself; the rows inside are drawn by
580// the same `menu_panel`, under the same origin, so their clicks come back
581// through `take_surface_events` like any row's, carrying the path that
582// says which submenu they are in. What is open is the core's, per drawn
583// menu: the frame cannot derive which row the pointer last rested on, or
584// that the keyboard closed what the pointer opened.
585
586/// Which of the core's two drawn menus a row belongs to, told by the
587/// origin its nodes were opened under.
588#[derive(Clone, Copy, Debug, PartialEq, Eq)]
589pub(crate) enum MenuSurface {
590 /// The context menu (`open_menu`), and a select's list.
591 Context,
592 /// The drawn menu bar's open menu.
593 Bar,
594}
595
596impl MenuSurface {
597 pub(crate) fn of(origin: OriginId) -> Option<Self> {
598 match origin {
599 OriginId::MENU => Some(MenuSurface::Context),
600 OriginId::MENU_BAR => Some(MenuSurface::Bar),
601 _ => None,
602 }
603 }
604
605 fn origin(self) -> OriginId {
606 match self {
607 MenuSurface::Context => OriginId::MENU,
608 MenuSurface::Bar => OriginId::MENU_BAR,
609 }
610 }
611}
612
613/// The submenus open in one of the core's drawn menus.
614#[derive(Clone, Debug, Default)]
615pub(crate) struct Submenus {
616 /// The row opened at each level, outermost first: `[2, 0]` is the
617 /// third row's submenu and, inside it, its first row's. Empty: none.
618 pub open: Vec<usize>,
619 /// The row the pointer was last seen on, as a path. The pointer opens
620 /// and closes submenus when this *changes*, so a keyboard that moved on
621 /// is not undone by a pointer resting where it was.
622 pub hovered: Option<Vec<usize>>,
623 /// The keyboard opened the innermost submenu: its first row takes
624 /// focus on the frame that draws it (`submenu_drawn`).
625 pub focus_first: bool,
626 /// A switch away from an open submenu the pointer asked for, waiting
627 /// [`SUBMENU_SWITCH_DELAY`] in case it is only passing over the row on
628 /// its way into that submenu (backlog RG150).
629 pub pending: Option<PendingSwitch>,
630 /// Some row of this menu was under the pointer during the frame's
631 /// build: a pass that sees none forgets `hovered`, so coming back to
632 /// a row the keyboard closed opens it again.
633 pub seen: bool,
634}
635
636/// A row the pointer moved onto while a submenu beside another was open.
637#[derive(Clone, Debug)]
638pub(crate) struct PendingSwitch {
639 pub row: Vec<usize>,
640 pub opens: bool,
641 /// The frame clock's reading when the pointer arrived.
642 pub since: f64,
643}
644
645/// How long the pointer rests on another row before an open submenu gives
646/// way to it. Travelling diagonally from a row to a lower row of its
647/// submenu crosses the rows below it; without the wait each one closed
648/// the submenu the pointer was heading for.
649pub(crate) const SUBMENU_SWITCH_DELAY: f64 = 0.3;
650
651impl Submenus {
652 /// What the pointer on `row` means for what is open: a row with a
653 /// submenu opens it, closing whatever was open beside it; any other
654 /// row closes the submenus below its own level.
655 fn switch_to(&mut self, row: &[usize], opens: bool) {
656 let level = row.len() - 1;
657 if opens {
658 // Kept when it is already the open one, deeper submenus and all:
659 // the pointer coming back to its row from inside it.
660 if !self.open.starts_with(row) {
661 self.open = row.to_vec();
662 }
663 } else if self.open.len() > level && self.open[..level] == row[..level] {
664 self.open.truncate(level);
665 }
666 }
667}
668
669impl Core {
670 fn submenus(&mut self, s: MenuSurface) -> &mut Submenus {
671 match s {
672 MenuSurface::Context => &mut self.menu_sub,
673 MenuSurface::Bar => &mut self.menu_bar_sub,
674 }
675 }
676
677 /// The rows of the menu `s` has open: the context menu's, or the bar's
678 /// open menu's.
679 fn surface_items(&self, s: MenuSurface) -> Option<&[MenuItem]> {
680 match s {
681 MenuSurface::Context => self.menu.as_ref().map(|m| m.items.as_slice()),
682 MenuSurface::Bar => {
683 let open = self.menu_bar_open?;
684 Some(self.menu_bar.as_ref()?.menus.get(open)?.items.as_slice())
685 }
686 }
687 }
688
689 /// Opens the submenu of the row at `path` — and with it the ones on the
690 /// way, closing any other — the keyboard's way when `keyboard`, which
691 /// moves focus into it once it is drawn.
692 pub(crate) fn open_submenu(&mut self, s: MenuSurface, path: Vec<usize>, keyboard: bool) {
693 let sub = self.submenus(s);
694 sub.open = path;
695 sub.focus_first = keyboard;
696 sub.pending = None;
697 }
698
699 /// The row in the submenu at `path` that is open, if one is: what the
700 /// panel at that level draws its submenu beside.
701 pub(crate) fn submenu_open_at(&mut self, s: MenuSurface, path: &[usize]) -> Option<usize> {
702 let open = &self.submenus(s).open;
703 (open.len() > path.len() && open[..path.len()] == *path).then(|| open[path.len()])
704 }
705
706 /// The pointer is on the row at `row` (its whole path) this frame. On a
707 /// change of row: a row with a submenu opens it, closing whatever was
708 /// open beside it, and any other row closes the submenus below its own
709 /// level — the way every platform's menus follow the pointer.
710 ///
711 /// A row that would close a submenu open beside another — a sibling of
712 /// the row that opened it, or a row further out — waits
713 /// [`SUBMENU_SWITCH_DELAY`] on the frame clock first, and moving into
714 /// that submenu meanwhile cancels it. A driver that sets no clock
715 /// switches at once, as its transitions snap.
716 pub(crate) fn submenu_hovered(&mut self, s: MenuSurface, row: &[usize], opens: bool) {
717 let now = self.anim.time();
718 let sub = self.submenus(s);
719 sub.seen = true;
720 if sub.hovered.as_deref() == Some(row) {
721 // Still resting there: a switch it is waiting on ripens.
722 if let Some(p) = &sub.pending
723 && p.row == row
724 && now.is_none_or(|t| t - p.since >= SUBMENU_SWITCH_DELAY)
725 {
726 let p = sub.pending.take().unwrap();
727 sub.switch_to(&p.row, p.opens);
728 }
729 return;
730 }
731 sub.hovered = Some(row.to_vec());
732 sub.focus_first = false;
733 let level = row.len() - 1;
734 let away = sub.open.len() > level && !sub.open.starts_with(row);
735 match now {
736 Some(since) if away => {
737 sub.pending = Some(PendingSwitch {
738 row: row.to_vec(),
739 opens,
740 since,
741 });
742 }
743 _ => {
744 sub.pending = None;
745 sub.switch_to(row, opens);
746 }
747 }
748 }
749
750 /// The build of the menu on `s` begins (`begin`) or ends: a build in
751 /// which the pointer was on none of its rows forgets the row it was
752 /// last on, and any switch waiting on it.
753 pub(crate) fn submenu_pass(&mut self, s: MenuSurface, begin: bool) {
754 let sub = self.submenus(s);
755 if begin {
756 sub.seen = false;
757 } else if !sub.seen {
758 sub.hovered = None;
759 sub.pending = None;
760 }
761 }
762
763 /// Whether a switch is waiting on the clock: the driver owes the
764 /// frames that let it ripen.
765 pub(crate) fn submenu_waiting(&self) -> bool {
766 self.menu_sub.pending.is_some() || self.menu_bar_sub.pending.is_some()
767 }
768
769 /// The submenu at `path` was drawn this frame, its first row that can
770 /// take focus at `first`: where focus lands when the keyboard opened it.
771 pub(crate) fn submenu_drawn(&mut self, s: MenuSurface, path: &[usize], first: Key) {
772 let sub = self.submenus(s);
773 if sub.focus_first && sub.open == path {
774 sub.focus_first = false;
775 self.move_focus(Some(first));
776 self.focus_visible = true;
777 }
778 }
779
780 /// The keyboard inside one of the core's drawn menus, where a submenu
781 /// changes what a key means: the Right arrow on a row with a submenu
782 /// opens it, focus on its first row; the Left arrow inside a submenu
783 /// closes it, focus back on its row; Escape closes the innermost open
784 /// submenu rather than the whole menu. Returns whether the key was
785 /// taken; every other key goes on as before (the arrows walk the rows
786 /// of whichever panel holds focus, `composite_step`).
787 pub(crate) fn submenu_key(&mut self, ek: crate::input::EditKey) -> bool {
788 use crate::input::EditKey;
789 if !matches!(ek, EditKey::Left | EditKey::Right | EditKey::Escape) {
790 return false;
791 }
792 // No menu of the core's open, no submenu to work: an editor's
793 // arrows skip the walk below, and an app drawing `context_menu`
794 // itself — whose hovers still note a submenu — cannot leave one
795 // behind for a later Escape to be spent on.
796 if self.menu.is_none() && self.menu_bar_open.is_none() {
797 return false;
798 }
799 // The focused row, if it is one of the core's: which menu, and
800 // where in it.
801 let focused = self.focus.and_then(|k| {
802 let i = self.tree.keys.iter().position(|key| *key == k)?;
803 let s = MenuSurface::of(self.tree.origins[i])?;
804 let tag = self.tree.specs[i].events().on_click.as_ref()?;
805 let row = tag.get("row").and_then(Value::as_int)? as usize;
806 let mut path = row_path_of(tag);
807 path.push(row);
808 Some((s, path))
809 });
810 match (ek, focused) {
811 (EditKey::Right, Some((s, path))) => {
812 let opens = self
813 .surface_items(s)
814 .and_then(|items| MenuItem::at_path(items, &path))
815 .is_some_and(|item| item.enabled && item.has_submenu());
816 if opens {
817 self.open_submenu(s, path, true);
818 }
819 opens
820 }
821 // A row at `[a, b, c]` is in the submenu `[a, b]` opened; what
822 // closes is that one, down to `[a]`.
823 (EditKey::Left, Some((s, path))) if path.len() > 1 => {
824 self.close_submenu(s, path.len() - 2);
825 true
826 }
827 (EditKey::Escape, focused) => {
828 // The menu the focus is in, or else whichever has a submenu
829 // open: the pointer may have opened one the keyboard is not in.
830 let s = focused.map(|(s, _)| s).or_else(|| {
831 [MenuSurface::Context, MenuSurface::Bar]
832 .into_iter()
833 .find(|&s| !self.submenus(s).open.is_empty())
834 });
835 let Some(s) = s else { return false };
836 let depth = self.submenus(s).open.len();
837 if depth == 0 {
838 return false;
839 }
840 self.close_submenu(s, depth - 1);
841 true
842 }
843 _ => false,
844 }
845 }
846
847 /// Closes the submenus of `s` past the first `keep` — the one the row
848 /// at `open[..=keep]` opened and every one inside it — and puts focus
849 /// on that row, which is where the closed one hung.
850 fn close_submenu(&mut self, s: MenuSurface, keep: usize) {
851 let sub = self.submenus(s);
852 if sub.open.len() <= keep {
853 return;
854 }
855 let row = sub.open[..=keep].to_vec();
856 sub.open.truncate(keep);
857 sub.focus_first = false;
858 sub.pending = None;
859 // The row the closed submenu hangs from, found by the tag its click
860 // carries; it is in the last frame's tree, since its submenu was.
861 let (parent, i) = row.split_at(keep);
862 let origin = s.origin();
863 let at = (0..self.tree.len()).find(|&n| {
864 self.tree.origins[n] == origin
865 && self.tree.specs[n]
866 .events()
867 .on_click
868 .as_ref()
869 .is_some_and(|tag| {
870 tag.get("row").and_then(Value::as_int) == Some(i[0] as i64)
871 && row_path_of(tag) == parent
872 })
873 });
874 if let Some(n) = at {
875 self.land_focus(n);
876 }
877 }
878}