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}
627
628impl Core {
629 fn submenus(&mut self, s: MenuSurface) -> &mut Submenus {
630 match s {
631 MenuSurface::Context => &mut self.menu_sub,
632 MenuSurface::Bar => &mut self.menu_bar_sub,
633 }
634 }
635
636 /// The rows of the menu `s` has open: the context menu's, or the bar's
637 /// open menu's.
638 fn surface_items(&self, s: MenuSurface) -> Option<&[MenuItem]> {
639 match s {
640 MenuSurface::Context => self.menu.as_ref().map(|m| m.items.as_slice()),
641 MenuSurface::Bar => {
642 let open = self.menu_bar_open?;
643 Some(self.menu_bar.as_ref()?.menus.get(open)?.items.as_slice())
644 }
645 }
646 }
647
648 /// Opens the submenu of the row at `path` — and with it the ones on the
649 /// way, closing any other — the keyboard's way when `keyboard`, which
650 /// moves focus into it once it is drawn.
651 pub(crate) fn open_submenu(&mut self, s: MenuSurface, path: Vec<usize>, keyboard: bool) {
652 let sub = self.submenus(s);
653 sub.open = path;
654 sub.focus_first = keyboard;
655 }
656
657 /// The row in the submenu at `path` that is open, if one is: what the
658 /// panel at that level draws its submenu beside.
659 pub(crate) fn submenu_open_at(&mut self, s: MenuSurface, path: &[usize]) -> Option<usize> {
660 let open = &self.submenus(s).open;
661 (open.len() > path.len() && open[..path.len()] == *path).then(|| open[path.len()])
662 }
663
664 /// The pointer is on the row at `row` (its whole path) this frame. On a
665 /// change of row: a row with a submenu opens it, closing whatever was
666 /// open beside it, and any other row closes the submenus below its own
667 /// level — the way every platform's menus follow the pointer.
668 pub(crate) fn submenu_hovered(&mut self, s: MenuSurface, row: &[usize], opens: bool) {
669 let sub = self.submenus(s);
670 if sub.hovered.as_deref() == Some(row) {
671 return;
672 }
673 sub.hovered = Some(row.to_vec());
674 sub.focus_first = false;
675 let level = row.len() - 1;
676 if opens {
677 // Kept when it is already the open one, deeper submenus and all:
678 // the pointer coming back to its row from inside it.
679 if !sub.open.starts_with(row) {
680 sub.open = row.to_vec();
681 }
682 } else if sub.open.len() > level && sub.open[..level] == row[..level] {
683 sub.open.truncate(level);
684 }
685 }
686
687 /// The submenu at `path` was drawn this frame, its first row that can
688 /// take focus at `first`: where focus lands when the keyboard opened it.
689 pub(crate) fn submenu_drawn(&mut self, s: MenuSurface, path: &[usize], first: Key) {
690 let sub = self.submenus(s);
691 if sub.focus_first && sub.open == path {
692 sub.focus_first = false;
693 self.move_focus(Some(first));
694 self.focus_visible = true;
695 }
696 }
697
698 /// The keyboard inside one of the core's drawn menus, where a submenu
699 /// changes what a key means: the Right arrow on a row with a submenu
700 /// opens it, focus on its first row; the Left arrow inside a submenu
701 /// closes it, focus back on its row; Escape closes the innermost open
702 /// submenu rather than the whole menu. Returns whether the key was
703 /// taken; every other key goes on as before (the arrows walk the rows
704 /// of whichever panel holds focus, `composite_step`).
705 pub(crate) fn submenu_key(&mut self, ek: crate::input::EditKey) -> bool {
706 use crate::input::EditKey;
707 if !matches!(ek, EditKey::Left | EditKey::Right | EditKey::Escape) {
708 return false;
709 }
710 // No menu of the core's open, no submenu to work: an editor's
711 // arrows skip the walk below, and an app drawing `context_menu`
712 // itself — whose hovers still note a submenu — cannot leave one
713 // behind for a later Escape to be spent on.
714 if self.menu.is_none() && self.menu_bar_open.is_none() {
715 return false;
716 }
717 // The focused row, if it is one of the core's: which menu, and
718 // where in it.
719 let focused = self.focus.and_then(|k| {
720 let i = self.tree.keys.iter().position(|key| *key == k)?;
721 let s = MenuSurface::of(self.tree.origins[i])?;
722 let tag = self.tree.specs[i].events().on_click.as_ref()?;
723 let row = tag.get("row").and_then(Value::as_int)? as usize;
724 let mut path = row_path_of(tag);
725 path.push(row);
726 Some((s, path))
727 });
728 match (ek, focused) {
729 (EditKey::Right, Some((s, path))) => {
730 let opens = self
731 .surface_items(s)
732 .and_then(|items| MenuItem::at_path(items, &path))
733 .is_some_and(|item| item.enabled && item.has_submenu());
734 if opens {
735 self.open_submenu(s, path, true);
736 }
737 opens
738 }
739 // A row at `[a, b, c]` is in the submenu `[a, b]` opened; what
740 // closes is that one, down to `[a]`.
741 (EditKey::Left, Some((s, path))) if path.len() > 1 => {
742 self.close_submenu(s, path.len() - 2);
743 true
744 }
745 (EditKey::Escape, focused) => {
746 // The menu the focus is in, or else whichever has a submenu
747 // open: the pointer may have opened one the keyboard is not in.
748 let s = focused.map(|(s, _)| s).or_else(|| {
749 [MenuSurface::Context, MenuSurface::Bar]
750 .into_iter()
751 .find(|&s| !self.submenus(s).open.is_empty())
752 });
753 let Some(s) = s else { return false };
754 let depth = self.submenus(s).open.len();
755 if depth == 0 {
756 return false;
757 }
758 self.close_submenu(s, depth - 1);
759 true
760 }
761 _ => false,
762 }
763 }
764
765 /// Closes the submenus of `s` past the first `keep` — the one the row
766 /// at `open[..=keep]` opened and every one inside it — and puts focus
767 /// on that row, which is where the closed one hung.
768 fn close_submenu(&mut self, s: MenuSurface, keep: usize) {
769 let sub = self.submenus(s);
770 if sub.open.len() <= keep {
771 return;
772 }
773 let row = sub.open[..=keep].to_vec();
774 sub.open.truncate(keep);
775 sub.focus_first = false;
776 // The row the closed submenu hangs from, found by the tag its click
777 // carries; it is in the last frame's tree, since its submenu was.
778 let (parent, i) = row.split_at(keep);
779 let origin = s.origin();
780 let at = (0..self.tree.len()).find(|&n| {
781 self.tree.origins[n] == origin
782 && self.tree.specs[n]
783 .events()
784 .on_click
785 .as_ref()
786 .is_some_and(|tag| {
787 tag.get("row").and_then(Value::as_int) == Some(i[0] as i64)
788 && row_path_of(tag) == parent
789 })
790 });
791 if let Some(n) = at {
792 self.land_focus(n);
793 }
794 }
795}