Skip to main content

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}