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        let menu = self.menu.as_ref()?;
88        let mut out = Vec::new();
89        match menu.items.get(i) {
90            Some(item) if !item.selectable() => return None,
91            Some(_) => self.choose_menu_item(i, &mut out),
92            None => {
93                self.close_menu();
94            }
95        }
96        // The same way out an input's events take: a row of the devtools'
97        // own select is the panel's whichever menu showed it.
98        self.outbound(&mut out);
99        Some(out)
100    }
101
102    /// What the selection would be looked *up* as, or `None` when it is
103    /// not something to look up.
104    ///
105    /// A definition panel answers a word or a short phrase. Handed a
106    /// paragraph it draws the whole thing back over the page as one
107    /// enormous highlighted strip and then says "No Results Found" — seen
108    /// in the field, 2026-09-09 — so a selection that spans lines, or runs
109    /// past 100 characters, is neither offered nor asked about.
110    /// A force click always passes: it selects one word.
111    pub fn lookup_text(&self) -> Option<String> {
112        let text = self.copy_selection().filter(|t| !t.trim().is_empty())?;
113        let one_line = !text.contains('\n');
114        (one_line && text.chars().count() <= LOOKUP_MAX).then_some(text)
115    }
116
117    /// A definition-panel request for whatever is selected: the text, and
118    /// the baseline origin of its first line to anchor the panel at.
119    pub(crate) fn lookup_action(&self) -> Option<MenuAction> {
120        if !self.lookup_available {
121            return None;
122        }
123        let text = self.lookup_text()?;
124        let at = self.selection_anchor()?;
125        Some(MenuAction::LookUp { text, at })
126    }
127
128    /// Opens one. A second replaces the first: a window has one menu, the
129    /// way it has one selection and one focus.
130    ///
131    /// Nothing is drawn here. The next frame draws it, because the menu is
132    /// state and the frame is a function of state — which is also what
133    /// makes an app that never pumps another frame after a right-click a
134    /// bug the app can see rather than a menu that appears out of turn.
135    pub fn open_menu(&mut self, mut menu: Menu) {
136        // `at` arrives in the host's coordinates (ADR 0024); the menu is
137        // kept in the window's, which the drawn one and the native one
138        // both place by.
139        menu.at = menu.at.plus(self.dt_shift());
140        self.open_menu_raw(menu);
141    }
142
143    /// `open_menu` for a point already in window coordinates — the
144    /// right-click's own.
145    pub(crate) fn open_menu_raw(&mut self, mut menu: Menu) {
146        // Whose menu it is, unless the caller said: the node it is about.
147        // An extension that opens a menu over its own node hears the rows
148        // come back, the way it hears every other event it declared (ADR
149        // 0014 decision 6).
150        //
151        // The node and not `self.origin()`, which is the *builder's*: it
152        // is right during a view and stale afterwards — whatever ran last
153        // in the frame that finished, an extension in any app that loads
154        // one — so a host opening a menu from its own event handler would
155        // have the rows answered to somebody else.
156        if menu.origin == crate::tree::OriginId::HOST {
157            menu.origin = self
158                .tree
159                .keys
160                .iter()
161                .position(|k| *k == menu.target)
162                .map_or_else(|| self.origin(), |i| self.tree.origins[i]);
163        }
164        // Which editor the menu is about, remembered now rather than
165        // looked up when a row is chosen: the menu's rows are focusable
166        // (they have to be — the arrow keys are the composite's), so by
167        // the time Copy runs, focus has moved off the field the user
168        // right-clicked. The window's *selection* survives the press
169        // (a press under `OriginId::MENU` is spared), but an editor's
170        // lives with its focus.
171        self.menu_editor = self.edit.focused();
172        self.menu = Some(menu);
173    }
174
175    /// A select field built this frame (`widgets::select`): its key and
176    /// the rows its menu will have. Read back by
177    /// [`Self::consume_select_events`] when the field is clicked.
178    pub(crate) fn declare_select(&mut self, key: Key, items: Vec<MenuItem>) {
179        self.selects.push((key, items));
180    }
181
182    /// A click on a select field is not the app's: taken back here and
183    /// answered with the field's menu, opened under its bottom-left
184    /// corner — the core's own menu, so what follows (the rows, a choice,
185    /// a dismissal) is `consume_menu_events`' as for any other. The
186    /// field's node is the target, so the choice is posted on it.
187    pub(crate) fn consume_select_events(&mut self, out: &mut Vec<UiEvent>) {
188        if self.selects.is_empty() {
189            return;
190        }
191        let mut clicked: Option<Key> = None;
192        out.retain(|ev| {
193            let is = ev.payload.get_bool("select") == Some(true)
194                && self.selects.iter().any(|(k, _)| *k == ev.key);
195            if is {
196                clicked = Some(ev.key);
197            }
198            !is
199        });
200        let Some(key) = clicked else {
201            return;
202        };
203        let Some((_, items)) = self.selects.iter().find(|(k, _)| *k == key) else {
204            return;
205        };
206        let items = items.clone();
207        // Under the field, in window coordinates: the last frame laid the
208        // field out, which is the frame the click was made against.
209        let at = self
210            .tree
211            .keys
212            .iter()
213            .position(|k| *k == key)
214            .map(|i| {
215                let (p, s) = (self.tree.pos[i], self.tree.size[i]);
216                Vec2::new(p.x, p.y + s.h)
217            })
218            .unwrap_or_default();
219        self.open_menu_raw(Menu::new(key, at, items));
220    }
221
222    /// Closes it. Returns whether one was open.
223    pub fn close_menu(&mut self) -> bool {
224        self.menu.take().is_some()
225    }
226
227    /// What choosing an item left for the host: clipboard work, which is
228    /// the host's in this library. Drained like the window and audio
229    /// commands, and empty on every frame of an app whose menus are all
230    /// its own.
231    pub fn take_menu_actions(&mut self) -> Vec<MenuAction> {
232        std::mem::take(&mut self.menu_actions)
233    }
234
235    /// Draws the open menu into the frame being built, if there is one.
236    /// Called by `Ui::finish` after the extensions have filled their
237    /// slots, so the menu is the last thing declared and therefore the
238    /// frame's modal scope and its topmost float.
239    pub(crate) fn build_menu(ui: &mut Ui<'_>) {
240        if ui.core().native_menus {
241            // The host is showing it. Nothing is drawn, so there are no
242            // rows to click and `consume_menu_events` finds nothing to
243            // take back.
244            return;
245        }
246        let Some(menu) = ui.core().menu.clone() else {
247            return;
248        };
249        // The widget floats against the host's viewport, whose origin is
250        // the dock's edge under a left dock (ADR 0024): the window point
251        // becomes a host one. Its nodes are opened under `OriginId::MENU`,
252        // which is how `consume_menu_events` knows them.
253        let at = menu.at.minus(ui.core().dt_shift());
254        crate::widgets::context_menu(ui, at, &menu.items);
255    }
256
257    /// The stock items for a right-click on `region`, or none when there
258    /// is nothing standard to offer there. An editor gets the four every
259    /// platform's field has; a selection scope gets the two that mean
260    /// anything without a text model behind them — nothing owns the text
261    /// under a label, so it cannot be cut into or pasted over.
262    ///
263    /// Every item is always present and only its *enabling* moves: a menu
264    /// whose rows shuffle depending on what happens to be possible is one
265    /// nobody can use without reading it every time.
266    pub(crate) fn default_menu_items(
267        &self,
268        editor: Option<Key>,
269        scope: Option<Key>,
270    ) -> Vec<MenuItem> {
271        if let Some(key) = editor {
272            let has = self.edit.copy_selection(key).is_some_and(|t| !t.is_empty());
273            return vec![
274                MenuItem::role(MenuRole::Cut).enabled(has),
275                MenuItem::role(MenuRole::Copy).enabled(has),
276                // The core cannot see a clipboard, so it cannot know
277                // whether there is anything to paste; the host that can
278                // will say so when it renders this natively (step 3).
279                MenuItem::role(MenuRole::Paste),
280                MenuItem::separator(),
281                MenuItem::role(MenuRole::SelectAll),
282            ];
283        }
284        if scope.is_some() {
285            // `copy_selection`, not `selection_text`: a `cells` grid is a
286            // scope too, and its selection is in cells rather than in
287            // runs — reading only the text one left Copy dimmed over a
288            // terminal with half its screen selected.
289            let has = self.copy_selection().is_some_and(|t| !t.is_empty());
290            let mut items = vec![
291                MenuItem::role(MenuRole::Copy).enabled(has),
292                MenuItem::role(MenuRole::SelectAll),
293            ];
294            // Only where the host can show one, and only with something
295            // to look up: the row is the platform's, and a dead one would
296            // be a promise this library cannot keep.
297            if self.lookup_available {
298                // Enabled only for something a dictionary can answer —
299                // dimmed for a passage, and dimmed rather than missing, so
300                // the rows a reader reaches for stay where they were.
301                let can = self.lookup_text().is_some();
302                items.insert(0, MenuItem::role(MenuRole::LookUp).enabled(has && can));
303                items.insert(1, MenuItem::separator());
304            }
305            return items;
306        }
307        Vec::new()
308    }
309
310    /// A secondary press the app did not claim: opens the stock menu when
311    /// the press landed on something with standard items, and does nothing
312    /// at all otherwise — a right-click on a plain box has never opened a
313    /// menu and does not start now.
314    ///
315    /// `claimed` is whether the node under the pointer declared
316    /// `onContextMenu`. That declaration wins: the app asked to own the
317    /// menu there, and one of the two has to win by declaration rather
318    /// than by luck.
319    pub(crate) fn auto_menu(&mut self, at: Vec2, claimed: bool) {
320        if claimed {
321            return;
322        }
323        let Some(region) = self.interaction.hit_at(at) else {
324            return;
325        };
326        let (key, origin) = (region.key, region.origin);
327        let editor = region.edit_origin.map(|_| key);
328        let scope = region.select_scope;
329        let items = self.default_menu_items(editor, scope);
330        if items.is_empty() {
331            return;
332        }
333        let target = editor.or(scope).unwrap_or(key);
334        self.open_menu_raw(Menu::new(target, at, items).origin(origin));
335        // The press told us which editor this is about, which is better
336        // than what held focus: a right-click moves no focus (it must
337        // leave a selection alone), so the field under the pointer is not
338        // necessarily the focused one.
339        self.menu_editor = editor;
340    }
341
342    /// Filters the events one input produced: anything belonging to the
343    /// core's own menu is consumed and acted on, and what the app hears
344    /// instead is the item's payload on the node the menu was about.
345    ///
346    /// Runs on the way out of `handle_input`, so a menu row cannot leak a
347    /// click into an app that never declared one.
348    pub(crate) fn consume_menu_events(&mut self, out: &mut Vec<UiEvent>) {
349        if self.menu.is_none() {
350            return;
351        }
352        let taken = Self::take_surface_events(out, OriginId::MENU);
353        if let Some(i) = taken.row {
354            self.choose_menu_item(i, out);
355        } else if taken.dismissed {
356            self.close_menu();
357        }
358    }
359
360    /// Takes every event of one of the core's own surfaces out of `out`
361    /// and reads what they said: a `dismiss` on the surface's modal root,
362    /// a click on the `row`th item (its payload is `{row}`, the message
363    /// `widgets::menu_row_tag` gave it, whether the pointer or Enter
364    /// clicked it), a click on the `title`th menu of the bar (`{title}`).
365    /// The one filter both menus consume through, so what a surface's
366    /// nodes post is read in one place. Hover, focus and the rest of a
367    /// surface's own events are taken with them: none of it is the app's.
368    pub(crate) fn take_surface_events(out: &mut Vec<UiEvent>, origin: OriginId) -> Taken {
369        let mut taken = Taken::default();
370        let index = |v: &Value, name: &str| v.get(name).and_then(Value::as_int).map(|i| i as usize);
371        out.retain(|ev| {
372            if ev.origin != origin {
373                return true;
374            }
375            if ev.kind() == Some("dismiss") {
376                taken.dismissed = true;
377            } else if let Some(i) = index(&ev.payload, "row") {
378                taken.row = Some(i);
379            } else if let Some(i) = index(&ev.payload, "title") {
380                taken.title = Some(i);
381            }
382            false
383        });
384        taken
385    }
386
387    /// Performs one item and closes the menu. A standard role the core can
388    /// finish it finishes; the clipboard three become a [`MenuAction`] for
389    /// the host; everything else is the app's, and reaches it as an event
390    /// on the node the menu was opened over.
391    fn choose_menu_item(&mut self, i: usize, out: &mut Vec<UiEvent>) {
392        let Some(menu) = self.menu.clone() else {
393            return;
394        };
395        let Some(item) = menu.items.get(i).cloned() else {
396            return;
397        };
398        self.close_menu();
399        self.perform_menu_item(&item, menu.target, menu.origin, out);
400    }
401
402    /// One item, performed and posted, wherever it was chosen from: the
403    /// open context menu's row, a host's native menu, or a menu of the
404    /// application menu bar. The two callers differ only in what they close first
405    /// and what node the event lands on, so everything after that is here
406    /// and cannot drift between them.
407    ///
408    /// `target` is the node the event is posted on and `origin` who hears
409    /// it; the standard roles act on [`Core::menu_editor`], which each
410    /// caller sets when its menu opens — an editor's selection lives with
411    /// its focus, and a menu's rows take that focus.
412    pub(crate) fn perform_menu_item(
413        &mut self,
414        item: &MenuItem,
415        target: Key,
416        origin: crate::tree::OriginId,
417        out: &mut Vec<UiEvent>,
418    ) {
419        match item.role {
420            MenuRole::Separator => return,
421            MenuRole::SelectAll => {
422                // The one standard item that needs nobody: an editor
423                // selects its own text, a scope selects its runs.
424                match self.menu_editor {
425                    Some(key) => {
426                        self.move_focus(Some(key));
427                        // The editor directly, not back through
428                        // `handle_input`: this runs *inside* one already,
429                        // and the events the nested call returned were
430                        // dropped on the floor.
431                        self.edit_with_fonts(|edit, fs| {
432                            edit.apply_key(
433                                key,
434                                crate::input::EditKey::SelectAll,
435                                crate::input::Mods::default(),
436                                fs,
437                            )
438                        });
439                    }
440                    None => {
441                        let scope = self.selection().map_or(target, |s| s.scope);
442                        self.select_all_in(scope);
443                    }
444                }
445            }
446            MenuRole::Copy => {
447                // `copy_selection` again, for the reason it is used to
448                // enable the row: a `cells` grid's selection is in cells,
449                // and reading only the text one left Copy lit over a
450                // terminal and then copied nothing when it was chosen.
451                let text = match self.menu_editor {
452                    Some(key) => self.edit.copy_selection(key),
453                    None => self.copy_selection().filter(|t| !t.is_empty()),
454                };
455                if let Some(text) = text {
456                    let html = self.selection_html();
457                    self.menu_actions
458                        .push(MenuAction::SetClipboard { text, html });
459                }
460            }
461            MenuRole::Cut => {
462                // Only an editor can be cut from: nothing owns the text
463                // behind a static selection, so there is nothing to take
464                // it out of. The item is simply not offered there.
465                if let Some(key) = self.menu_editor
466                    && let Some(text) = self.cut_editor(key)
467                {
468                    self.move_focus(Some(key));
469                    // No `html`: an editor's text is one style, and what
470                    // was cut is gone anyway.
471                    self.menu_actions
472                        .push(MenuAction::SetClipboard { text, html: None });
473                    // And the edit it is: every other mutation posts one
474                    // (`apply_text`, `apply_key`, a reader's `setValue`),
475                    // so an app mirroring the field hears this one too
476                    // (AR15).
477                    self.push_edit_event(key, "changed", out);
478                }
479            }
480            MenuRole::Paste => self.queue_paste(),
481            MenuRole::LookUp => {
482                if let Some(action) = self.lookup_action() {
483                    self.menu_actions.push(action);
484                }
485            }
486            MenuRole::Custom => {}
487        }
488        // Every chosen item posts, including the standard ones: an app
489        // that wants to know its editor was cut from does not have to
490        // guess, and one that does not simply ignores the event.
491        let payload = item.id.clone().unwrap_or_else(|| Value::str(item.text()));
492        out.push(UiEvent {
493            origin,
494            window: WindowId::MAIN,
495            key: target,
496            payload: Value::map([
497                ("kind", Value::str("menu")),
498                ("role", Value::str(item.role.name())),
499                ("item", payload),
500            ]),
501            slot: None,
502        });
503    }
504}
505
506/// What one input said to one of the core's surfaces (`Core::take_surface_events`).
507#[derive(Default)]
508pub(crate) struct Taken {
509    pub dismissed: bool,
510    pub row: Option<usize>,
511    pub title: Option<usize>,
512}