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}