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