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}