1use std::collections::hash_map::DefaultHasher;
17use std::hash::{Hash, Hasher};
18
19use serde::{Deserialize, Serialize};
20
21use crate::CommandInfo;
22use crate::shortcut::{self, Chord, Key};
23
24pub const MAX_DEPTH: usize = 8;
26pub const MAX_NODES: usize = 10_000;
28pub const MAX_TEXT: usize = 256;
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub enum Role {
38 About,
40 Services,
42 Hide,
43 HideOthers,
44 ShowAll,
45 Quit,
47 CloseWindow,
48 Minimize,
49 Zoom,
50 Fullscreen,
51 BringAllToFront,
52 Undo,
53 Redo,
54 Cut,
55 Copy,
56 Paste,
57 SelectAll,
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub enum SubmenuRole {
64 Window,
66 Help,
68}
69
70#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct Item {
74 pub id: String,
76 pub label: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub accelerator: Option<String>,
82 #[serde(default = "yes")]
83 pub enabled: bool,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub disabled_reason: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub checked: Option<bool>,
90}
91
92fn yes() -> bool {
93 true
94}
95
96#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct Submenu {
99 pub label: String,
100 #[serde(default = "yes")]
101 pub enabled: bool,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub role: Option<SubmenuRole>,
104 pub children: Vec<MenuNode>,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
109#[serde(tag = "type", rename_all = "camelCase")]
110pub enum MenuNode {
111 Item(Item),
112 Separator,
113 Submenu(Submenu),
114 Role {
116 role: Role,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 label: Option<String>,
119 },
120}
121
122#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
125pub struct MenuSpec {
126 pub menus: Vec<Submenu>,
127}
128
129impl Chord {
130 pub fn is_menu_safe(&self) -> bool {
134 self.mods.command || self.mods.ctrl || matches!(self.key, Key::F(_))
135 }
136}
137
138pub fn menu_chord(text: &str) -> Option<Chord> {
141 let first = text.split(" / ").next()?;
142 Chord::parse(first).ok().filter(Chord::is_menu_safe)
143}
144
145impl Item {
146 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
147 Self { id: id.into(), label: label.into(), accelerator: None, enabled: true, disabled_reason: None, checked: None }
148 }
149
150 pub fn accelerator(mut self, text: impl Into<String>) -> Self {
151 self.accelerator = Some(text.into());
152 self
153 }
154
155 pub fn enabled(mut self, enabled: bool) -> Self {
156 self.enabled = enabled;
157 self
158 }
159
160 pub fn checked(mut self, checked: bool) -> Self {
161 self.checked = Some(checked);
162 self
163 }
164
165 pub fn menu_chord(&self) -> Option<Chord> {
167 self.accelerator.as_deref().and_then(menu_chord)
168 }
169}
170
171impl From<Item> for MenuNode {
172 fn from(i: Item) -> Self {
173 MenuNode::Item(i)
174 }
175}
176
177impl From<Submenu> for MenuNode {
178 fn from(s: Submenu) -> Self {
179 MenuNode::Submenu(s)
180 }
181}
182
183impl MenuNode {
184 pub fn role(role: Role) -> Self {
185 MenuNode::Role { role, label: None }
186 }
187}
188
189impl Submenu {
190 pub fn new(label: impl Into<String>, children: Vec<MenuNode>) -> Self {
191 Self { label: label.into(), enabled: true, role: None, children }
192 }
193
194 pub fn with_role(mut self, role: SubmenuRole) -> Self {
195 self.role = Some(role);
196 self
197 }
198}
199
200pub type Path = Vec<usize>;
202
203impl MenuSpec {
204 pub fn new(menus: Vec<Submenu>) -> Self {
205 Self { menus }
206 }
207
208 pub fn from_commands(commands: &[CommandInfo], order: &[&str]) -> Self {
215 Self::from_items(
216 commands.iter().map(|c| {
217 let mut item = Item::new(c.id.clone(), c.label.clone()).enabled(c.enabled);
218 item.accelerator = c.shortcut.as_deref().map(|s| shortcut::normalize(s).unwrap_or_else(|| s.to_owned()));
219 item.disabled_reason = if c.enabled { None } else { c.disabled_reason.clone() };
220 (c.menu.as_slice(), item)
221 }),
222 order,
223 )
224 }
225
226 pub fn from_items<P: AsRef<[String]>>(entries: impl IntoIterator<Item = (P, Item)>, order: &[&str]) -> Self {
233 let mut spec = MenuSpec::default();
234 for (path, item) in entries {
235 let Some((top, rest)) = path.as_ref().split_first() else { continue };
236 let rest = rest.get(..MAX_DEPTH.saturating_sub(1)).unwrap_or(rest);
237 let idx = match spec.menus.iter().position(|m| &m.label == top) {
238 Some(i) => i,
239 None => {
240 spec.menus.push(Submenu::new(top.clone(), vec![]));
241 spec.menus.len() - 1
242 }
243 };
244 let Some(menu) = spec.menus.get_mut(idx) else { continue };
245 let Some(children) = submenu_children(&mut menu.children, rest) else { continue };
246 children.push(item.into());
247 }
248 let rank = |label: &str| order.iter().position(|o| *o == label).unwrap_or(order.len());
249 spec.menus.sort_by_key(|m| rank(&m.label));
250 spec
251 }
252
253 pub fn items(&self) -> Vec<&Item> {
255 let mut out = vec![];
256 self.walk(&mut |_, n| {
257 if let MenuNode::Item(i) = n {
258 out.push(i);
259 }
260 });
261 out
262 }
263
264 pub fn find(&self, id: &str) -> Option<&Item> {
266 self.items().into_iter().find(|i| i.id == id)
267 }
268
269 pub fn walk<'a>(&'a self, f: &mut dyn FnMut(&[usize], &'a MenuNode)) {
271 fn rec<'a>(nodes: &'a [MenuNode], path: &mut Vec<usize>, f: &mut dyn FnMut(&[usize], &'a MenuNode)) {
272 if path.len() > MAX_DEPTH {
273 return;
274 }
275 for (i, n) in nodes.iter().enumerate() {
276 path.push(i);
277 f(path, n);
278 if let MenuNode::Submenu(s) = n {
279 rec(&s.children, path, f);
280 }
281 path.pop();
282 }
283 }
284 for (i, m) in self.menus.iter().enumerate() {
285 rec(&m.children, &mut vec![i], f);
286 }
287 }
288
289 fn any_children_mut(&mut self, f: &mut dyn FnMut(&mut Vec<MenuNode>) -> bool) -> bool {
291 fn rec(nodes: &mut Vec<MenuNode>, depth: usize, f: &mut dyn FnMut(&mut Vec<MenuNode>) -> bool) -> bool {
292 if depth > MAX_DEPTH {
293 return false;
294 }
295 if f(nodes) {
296 return true;
297 }
298 nodes.iter_mut().any(|n| match n {
299 MenuNode::Submenu(s) => rec(&mut s.children, depth + 1, f),
300 _ => false,
301 })
302 }
303 self.menus.iter_mut().any(|m| rec(&mut m.children, 1, f))
304 }
305
306 pub fn separator_before(&mut self, id: &str) -> bool {
309 self.any_children_mut(&mut |nodes| {
310 let Some(i) = nodes.iter().position(|n| matches!(n, MenuNode::Item(it) if it.id == id)) else { return false };
311 if i > 0 && !matches!(nodes.get(i - 1), Some(MenuNode::Separator)) {
312 nodes.insert(i, MenuNode::Separator);
313 }
314 true
315 })
316 }
317
318 pub fn set_checked(&mut self, id: &str, checked: Option<bool>) -> bool {
321 self.any_children_mut(&mut |nodes| {
322 for n in nodes.iter_mut() {
323 if let MenuNode::Item(it) = n
324 && it.id == id
325 {
326 it.checked = checked;
327 return true;
328 }
329 }
330 false
331 })
332 }
333
334 pub fn structure_hash(&self) -> u64 {
338 let mut h = DefaultHasher::new();
339 fn rec(nodes: &[MenuNode], depth: usize, h: &mut DefaultHasher) {
340 if depth > MAX_DEPTH {
341 return;
342 }
343 for n in nodes {
344 match n {
345 MenuNode::Item(i) => {
346 0u8.hash(h);
347 i.id.hash(h);
348 i.checked.is_some().hash(h);
349 i.menu_chord().hash(h);
350 }
351 MenuNode::Separator => 1u8.hash(h),
352 MenuNode::Submenu(s) => {
353 2u8.hash(h);
354 s.role.hash(h);
355 rec(&s.children, depth + 1, h);
356 3u8.hash(h);
357 }
358 MenuNode::Role { role, label } => {
359 4u8.hash(h);
360 role.hash(h);
361 label.hash(h);
363 }
364 }
365 }
366 }
367 for m in &self.menus {
368 5u8.hash(&mut h);
369 m.role.hash(&mut h);
370 rec(&m.children, 1, &mut h);
371 }
372 h.finish()
373 }
374
375 pub fn state_hash(&self) -> u64 {
378 let mut h = DefaultHasher::new();
379 for m in &self.menus {
380 (&m.label, m.enabled).hash(&mut h);
381 }
382 self.walk(&mut |_, n| match n {
383 MenuNode::Item(i) => (&i.label, i.enabled, i.checked, &i.disabled_reason).hash(&mut h),
384 MenuNode::Submenu(s) => (&s.label, s.enabled).hash(&mut h),
385 _ => {}
386 });
387 h.finish()
388 }
389
390 pub fn validate(&self) -> Result<(), Vec<String>> {
393 let mut problems = vec![];
394 let mut ids = std::collections::HashSet::new();
395 let mut nodes = 0usize;
396 fn depth(nodes: &[MenuNode], d: usize) -> usize {
398 if d > MAX_DEPTH {
399 return d;
400 }
401 nodes.iter().map(|n| if let MenuNode::Submenu(s) = n { depth(&s.children, d + 1) } else { d }).max().unwrap_or(d)
402 }
403 for m in &self.menus {
404 if depth(&m.children, 1) > MAX_DEPTH {
405 problems.push(format!("menu `{}` nests deeper than {MAX_DEPTH}", clip(&m.label)));
406 }
407 if m.label.chars().count() > MAX_TEXT {
408 problems.push("a menu title is too long".into());
409 }
410 }
411 self.walk(&mut |_, n| {
412 nodes += 1;
413 match n {
414 MenuNode::Item(i) => {
415 if i.id.is_empty() || i.id.chars().count() > MAX_TEXT {
416 problems.push(format!("item `{}` has an empty or too long id", clip(&i.label)));
417 } else if !ids.insert(i.id.as_str()) {
418 problems.push(format!("id `{}` is used twice", clip(&i.id)));
419 }
420 if i.label.chars().count() > MAX_TEXT {
421 problems.push(format!("item `{}` has a too long label", clip(&i.id)));
422 }
423 if let Some(a) = &i.accelerator
424 && let Err(e) = shortcut::parse_list(a)
425 {
426 problems.push(format!("item `{}`: {e}", clip(&i.id)));
427 }
428 }
429 MenuNode::Submenu(s) if s.label.chars().count() > MAX_TEXT => problems.push("a submenu title is too long".into()),
430 _ => {}
431 }
432 });
433 if nodes > MAX_NODES {
434 problems.push(format!("{nodes} nodes; at most {MAX_NODES}"));
435 }
436 if problems.is_empty() { Ok(()) } else { Err(problems) }
437 }
438
439 pub fn node(&self, path: &[usize]) -> Option<&MenuNode> {
441 let (top, rest) = path.split_first()?;
442 let mut nodes = &self.menus.get(*top)?.children;
443 let (last, mid) = rest.split_last()?;
444 for i in mid {
445 match nodes.get(*i)? {
446 MenuNode::Submenu(s) => nodes = &s.children,
447 _ => return None,
448 }
449 }
450 nodes.get(*last)
451 }
452}
453
454fn submenu_children<'a>(children: &'a mut Vec<MenuNode>, path: &[String]) -> Option<&'a mut Vec<MenuNode>> {
456 let Some((name, rest)) = path.split_first() else { return Some(children) };
457 let pos = match children.iter().position(|n| matches!(n, MenuNode::Submenu(s) if &s.label == name)) {
458 Some(p) => p,
459 None => {
460 children.push(Submenu::new(name.clone(), vec![]).into());
461 children.len() - 1
462 }
463 };
464 match children.get_mut(pos)? {
465 MenuNode::Submenu(s) => submenu_children(&mut s.children, rest),
466 _ => None,
467 }
468}
469
470fn clip(s: &str) -> String {
471 s.chars().take(64).collect()
472}
473
474#[cfg(test)]
475mod tests;