Skip to main content

agg_gui/widgets/menu/
model.rs

1//! Data model for popup and menu-bar menus.
2//!
3//! Menus are application-owned item trees. The widget layer interprets this
4//! model for painting, hit testing, keyboard navigation, and action dispatch.
5
6use crate::event::{Key, Modifiers};
7use crate::platform;
8
9#[derive(Clone, Debug)]
10pub enum MenuEntry {
11    Item(MenuItem),
12    Separator,
13}
14
15#[derive(Clone, Debug)]
16pub struct MenuItem {
17    pub label: String,
18    pub icon: Option<char>,
19    pub shortcut: Option<String>,
20    pub accelerator: Option<MenuShortcut>,
21    pub enabled: bool,
22    pub selection: MenuSelection,
23    pub action: Option<String>,
24    pub submenu: Vec<MenuEntry>,
25    pub close_on_activate: bool,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum MenuSelection {
30    None,
31    Check { selected: bool },
32    Radio { selected: bool },
33}
34
35impl MenuItem {
36    pub fn action(label: impl Into<String>, action: impl Into<String>) -> Self {
37        Self {
38            label: label.into(),
39            icon: None,
40            shortcut: None,
41            accelerator: None,
42            enabled: true,
43            selection: MenuSelection::None,
44            action: Some(action.into()),
45            submenu: Vec::new(),
46            close_on_activate: true,
47        }
48    }
49
50    pub fn submenu(label: impl Into<String>, submenu: Vec<MenuEntry>) -> Self {
51        Self {
52            label: label.into(),
53            icon: None,
54            shortcut: None,
55            accelerator: None,
56            enabled: true,
57            selection: MenuSelection::None,
58            action: None,
59            submenu,
60            close_on_activate: false,
61        }
62    }
63
64    pub fn disabled(mut self) -> Self {
65        self.enabled = false;
66        self
67    }
68
69    pub fn checked(mut self, checked: bool) -> Self {
70        self.selection = MenuSelection::Check { selected: checked };
71        self
72    }
73
74    pub fn radio(mut self, selected: bool) -> Self {
75        self.selection = MenuSelection::Radio { selected };
76        self
77    }
78
79    pub fn keep_open(mut self) -> Self {
80        self.close_on_activate = false;
81        self
82    }
83
84    pub fn close_on_activate(mut self, close: bool) -> Self {
85        self.close_on_activate = close;
86        self
87    }
88
89    pub fn icon(mut self, icon: char) -> Self {
90        self.icon = Some(icon);
91        self
92    }
93
94    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
95        let shortcut = shortcut.into();
96        self.accelerator = MenuShortcut::parse(&shortcut);
97        self.shortcut = Some(shortcut);
98        self
99    }
100
101    pub fn accelerator(mut self, accelerator: MenuShortcut) -> Self {
102        self.shortcut = Some(accelerator.display_text());
103        self.accelerator = Some(accelerator);
104        self
105    }
106
107    pub fn has_submenu(&self) -> bool {
108        !self.submenu.is_empty()
109    }
110}
111
112impl From<MenuItem> for MenuEntry {
113    fn from(item: MenuItem) -> Self {
114        Self::Item(item)
115    }
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub struct MenuShortcut {
120    pub key: ShortcutKey,
121    /// Portable command modifier: Ctrl on Windows/Linux, Cmd on macOS.
122    pub command: bool,
123    pub shift: bool,
124    pub alt: bool,
125}
126
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum ShortcutKey {
129    Char(char),
130    Insert,
131    Delete,
132    Backspace,
133    Enter,
134    Escape,
135}
136
137impl MenuShortcut {
138    pub fn command_char(ch: char) -> Self {
139        Self {
140            key: ShortcutKey::Char(ch.to_ascii_uppercase()),
141            command: true,
142            shift: false,
143            alt: false,
144        }
145    }
146
147    pub fn parse(text: &str) -> Option<Self> {
148        let mut command = false;
149        let mut shift = false;
150        let mut alt = false;
151        let mut key = None;
152        for part in text.split('+') {
153            let token = part.trim();
154            match token.to_ascii_lowercase().as_str() {
155                "ctrl" | "control" | "cmd" | "command" | "meta" => command = true,
156                "shift" => shift = true,
157                "alt" | "option" => alt = true,
158                "insert" => key = Some(ShortcutKey::Insert),
159                "delete" | "del" => key = Some(ShortcutKey::Delete),
160                "backspace" => key = Some(ShortcutKey::Backspace),
161                "enter" | "return" => key = Some(ShortcutKey::Enter),
162                "esc" | "escape" => key = Some(ShortcutKey::Escape),
163                _ => {
164                    let mut chars = token.chars();
165                    let ch = chars.next()?;
166                    if chars.next().is_none() {
167                        key = Some(ShortcutKey::Char(ch.to_ascii_uppercase()));
168                    } else {
169                        return None;
170                    }
171                }
172            }
173        }
174        Some(Self {
175            key: key?,
176            command,
177            shift,
178            alt,
179        })
180    }
181
182    pub fn matches(self, key: &Key, modifiers: Modifiers) -> bool {
183        let command_matches = if self.command {
184            platform::command_modifier_pressed(modifiers)
185        } else {
186            platform::command_modifier_released(modifiers)
187        };
188        command_matches
189            && modifiers.shift == self.shift
190            && modifiers.alt == self.alt
191            && self.key.matches(key)
192    }
193
194    pub fn display_text(self) -> String {
195        let mut parts = Vec::new();
196        if self.command {
197            parts.push(platform::primary_modifier_label().to_string());
198        }
199        if self.shift {
200            parts.push("Shift".to_string());
201        }
202        if self.alt {
203            parts.push("Alt".to_string());
204        }
205        parts.push(self.key.display_text());
206        parts.join("+")
207    }
208}
209
210impl ShortcutKey {
211    fn matches(self, key: &Key) -> bool {
212        match (self, key) {
213            (Self::Char(expected), Key::Char(actual)) => {
214                expected.eq_ignore_ascii_case(&actual.to_ascii_uppercase())
215            }
216            (Self::Insert, Key::Insert)
217            | (Self::Delete, Key::Delete)
218            | (Self::Backspace, Key::Backspace)
219            | (Self::Enter, Key::Enter)
220            | (Self::Escape, Key::Escape) => true,
221            _ => false,
222        }
223    }
224
225    fn display_text(self) -> String {
226        match self {
227            Self::Char(ch) => ch.to_string(),
228            Self::Insert => "Insert".to_string(),
229            Self::Delete => "Delete".to_string(),
230            Self::Backspace => "Backspace".to_string(),
231            Self::Enter => "Enter".to_string(),
232            Self::Escape => "Esc".to_string(),
233        }
234    }
235}