kui_core/runtime/menubar_api.rs
1//! `Core`'s application menu bar: the declaration, the one open menu the
2//! drawn bar keeps, and the two doors a chosen item comes back through
3//! (`docs/adr/0018-a-menu-bar-the-app-declares.md`).
4//!
5//! The declaration is data and sticky, the way the window title is: a frame
6//! that declares none leaves the last one in force and a frame that
7//! declares an empty bar takes it away, so the driver diffs
8//! [`Core::menu_bar_revision`] rather than a tree.
9//!
10//! Where the platform owns a bar the driver hands the declaration over and
11//! reports back through [`Core::activate_menu_bar_item`]. Where it does not,
12//! [`crate::widgets::menu_bar`] draws it: its titles and its rows are
13//! ordinary nodes with ordinary click payloads, and this module *takes
14//! those events back* by key — recorded while building, never guessed at
15//! from a payload — exactly as `menu_api` does for the open context menu's
16//! rows. What reaches the app is one `{kind:"menu", role, item}`, the same
17//! event both menus have always posted.
18
19use crate::input::UiEvent;
20use crate::key::Key;
21use crate::menu::{MenuBar, MenuItem};
22use crate::runtime::Core;
23use crate::tree::OriginId;
24
25impl Core {
26 /// Declares the application menu for this frame.
27 ///
28 /// Sticky, and diffed: declaring the same bar again costs one
29 /// comparison and changes nothing, a different one bumps
30 /// [`Self::menu_bar_revision`] for the driver to notice, and an empty
31 /// [`MenuBar`] is how an app takes the bar away. A frame that says
32 /// nothing leaves the last declaration standing — which is what lets a
33 /// palette window declare no menu and leave the document window's bar
34 /// alone (ADR 0018, decision 8).
35 ///
36 /// Every item's accelerator is normalized on the way in: a portable
37 /// `"mod+s"` becomes the platform's own spelling (`"⌘S"`, `"Ctrl+S"`),
38 /// so the drawn bar and the platform's read the same and the platform's
39 /// can bind the key. A spelling kui cannot parse is left exactly as
40 /// written and drawn as written — an app's own shortcut is the app's.
41 pub fn declare_menu_bar(&mut self, bar: MenuBar) {
42 let bar = normalize(bar);
43 if self.menu_bar.as_ref() == Some(&bar) {
44 return;
45 }
46 // An open menu describes a bar that may no longer exist. Identity
47 // is the label at that index: an item list that changed under an
48 // open menu is ordinary (a row enabling, a setting checking), but a
49 // *different menu* at that index leaves the open index naming
50 // something the user never opened — and the row keys are derived
51 // from the index, so the next click would perform the new menu's
52 // item at that position.
53 let open_moved = self.menu_bar_open.is_some_and(|i| {
54 fn label(b: &MenuBar, i: usize) -> Option<&str> {
55 b.menus.get(i).map(|m| m.label.as_str())
56 }
57 self.menu_bar.as_ref().and_then(|old| label(old, i)) != label(&bar, i)
58 });
59 if open_moved {
60 self.menu_bar_open = None;
61 }
62 self.menu_bar = Some(bar);
63 self.menu_bar_origin = self.origin();
64 self.menu_bar_rev += 1;
65 }
66
67 /// The declaration in force, if any frame has made one.
68 pub fn menu_bar(&self) -> Option<&MenuBar> {
69 self.menu_bar.as_ref()
70 }
71
72 /// Bumped whenever the declaration changes. A driver keeps the number
73 /// it last applied and touches the platform only when they differ —
74 /// the menu-bar analogue of the title diff, and the reason re-declaring
75 /// an unchanged bar every frame costs nothing.
76 pub fn menu_bar_revision(&self) -> u64 {
77 self.menu_bar_rev
78 }
79
80 /// Tells the core that the platform owns the menu bar — macOS's, which
81 /// is not in any window (ADR 0018, decision 4). The drawn bar then
82 /// draws nothing, and the driver is the one that hands the declaration
83 /// over and reports what was chosen.
84 ///
85 /// Off by default, like [`Self::set_native_menus`]: a host that says
86 /// nothing draws its own bar, which is what every headless test and
87 /// every binding without a runner sees.
88 pub fn set_native_menu_bar(&mut self, on: bool) {
89 self.native_menu_bar = on;
90 }
91
92 /// Whether the platform said the menu bar is its.
93 pub fn native_menu_bar(&self) -> bool {
94 self.native_menu_bar
95 }
96
97 /// Which menu of the drawn bar is open, if any. Retained by the core
98 /// because it is the one thing the widget cannot derive from the frame
99 /// (ADR 0018, decision 6) — and always `None` while the platform draws
100 /// the bar, since then the open menu is the platform's.
101 pub fn menu_bar_open(&self) -> Option<usize> {
102 self.menu_bar_open
103 }
104
105 /// Opens one of the drawn bar's menus, closes it (`None`), and is what
106 /// a press on a title goes through. Public because a keymap is as good
107 /// a reason to open the File menu as a click is; out of range closes.
108 pub fn set_menu_bar_open(&mut self, menu: Option<usize>) {
109 let menu = menu.filter(|i| {
110 self.menu_bar
111 .as_ref()
112 .and_then(|b| b.menus.get(*i))
113 .is_some_and(|m| m.enabled && !m.items.is_empty())
114 });
115 if menu == self.menu_bar_open {
116 return;
117 }
118 // Which editor the menu is about, remembered as the menu opens for
119 // the reason `open_menu` remembers it: the rows are focusable, so
120 // by the time Copy runs the field the user was in has lost focus.
121 //
122 // Only when there *is* one, and never `None` over an answer already
123 // recorded: a press on a title has moved focus to the title before
124 // this runs (`Core::note_menu_bar_editor` is where the field was
125 // caught), and hovering across to another title runs it again with
126 // focus inside the open menu. Either would otherwise erase the
127 // field the menu is about and leave Cut with nothing to cut.
128 if menu.is_some()
129 && let Some(editor) = self.edit.focused()
130 {
131 self.menu_editor = Some(editor);
132 }
133 self.menu_bar_open = menu;
134 }
135
136 /// The platform's menu bar reports that an item was chosen: the same
137 /// path a press on the drawn bar's row takes. The core performs the
138 /// standard roles it can, queues what the host must do
139 /// (`take_menu_actions`) and returns the events the app hears.
140 ///
141 /// An index past the end does nothing, which is what a host reporting a
142 /// row this build does not have should do.
143 pub fn activate_menu_bar_item(&mut self, menu: usize, item: usize) -> Vec<UiEvent> {
144 let mut out = Vec::new();
145 let Some(item) = self
146 .menu_bar
147 .as_ref()
148 .and_then(|b| b.item(menu, item))
149 .cloned()
150 else {
151 return out;
152 };
153 // Nothing displaced focus — the platform's menu bar is not in this
154 // window and takes none — so the editor is simply whichever one has
155 // it now.
156 self.menu_editor = self.edit.focused();
157 let (target, origin) = (self.menu_bar_target(), self.menu_bar_origin);
158 self.perform_menu_item(&item, target, origin, &mut out);
159 out
160 }
161
162 /// Where a menu-bar event lands: the bar's own root when one is drawn,
163 /// and the frame root when the platform draws it and there is no node.
164 fn menu_bar_target(&self) -> Key {
165 self.menu_bar_root.unwrap_or(Key::ROOT)
166 }
167
168 /// The drawn bar reports its root this frame (`widgets::menu_bar`):
169 /// where a menu-bar event lands (`menu_bar_target`). Its titles and
170 /// rows are known by their origin, not by key.
171 pub(crate) fn set_menu_bar_root(&mut self, root: Key) {
172 self.menu_bar_root = Some(root);
173 }
174
175 /// Filters the events one input produced: anything belonging to the
176 /// drawn bar is consumed and acted on — a title opens or closes its
177 /// menu, a row performs its item, a dismissal closes what is open — and
178 /// what the app hears instead is the item's own payload.
179 ///
180 /// Runs beside `consume_menu_events` on the way out of `handle_input`,
181 /// so a bar the app never declared can leak nothing and an app that
182 /// declared one never sees its plumbing.
183 pub(crate) fn consume_menu_bar_events(&mut self, out: &mut Vec<UiEvent>) {
184 if self.menu_bar_root.is_none() {
185 return;
186 }
187 // A `dismiss` is the bar's own: Escape, or a press outside it while
188 // a menu is open (the bar is the modal scope then, so its titles
189 // stay live and the app below does not).
190 let taken = Self::take_surface_events(out, OriginId::MENU_BAR);
191 let (title, row, dismissed) = (taken.title, taken.row, taken.dismissed);
192 if let Some(i) = title {
193 // A press on the open menu's own title closes it, which is
194 // what every menu bar does and what makes the title a toggle.
195 let next = (self.menu_bar_open != Some(i)).then_some(i);
196 self.set_menu_bar_open(next);
197 } else if let Some(i) = row {
198 let Some(menu) = self.menu_bar_open else {
199 return;
200 };
201 let item = self
202 .menu_bar
203 .as_ref()
204 .and_then(|b| b.item(menu, i))
205 .cloned();
206 self.set_menu_bar_open(None);
207 if let Some(item) = item {
208 let (target, origin) = (self.menu_bar_target(), self.menu_bar_origin);
209 self.perform_menu_item(&item, target, origin, out);
210 }
211 } else if dismissed {
212 self.set_menu_bar_open(None);
213 }
214 }
215
216 /// A press has landed on a node of the drawn bar: remember the editor
217 /// it is about *now*, because the press is what moves focus onto the
218 /// title and the menu does not open until the release.
219 ///
220 /// The truth at press time, `None` included — a menu opened with no
221 /// field focused is about no field, and must not inherit the one an
222 /// earlier menu was about.
223 pub(crate) fn note_menu_bar_editor(&mut self) {
224 self.menu_editor = self.edit.focused();
225 }
226}
227
228/// Rewrites every accelerator kui can parse into the platform's own
229/// spelling, and leaves the rest exactly as declared. Runs once per
230/// changed declaration and not per frame, which is why `declare_menu_bar`
231/// compares after normalizing rather than before.
232fn normalize(mut bar: MenuBar) -> MenuBar {
233 for menu in &mut bar.menus {
234 for item in &mut menu.items {
235 normalize_item(item);
236 }
237 }
238 bar
239}
240
241fn normalize_item(item: &mut MenuItem) {
242 let Some(accel) = &item.accel else { return };
243 if let Some(parsed) = crate::menu::Accel::parse(accel) {
244 item.accel = Some(parsed.display());
245 }
246}