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::color::Color;
7use crate::event::{Key, Modifiers};
8use crate::platform;
9
10#[derive(Clone, Debug)]
11pub enum MenuEntry {
12    Item(MenuItem),
13    Separator,
14}
15
16#[derive(Clone, Debug)]
17pub struct MenuItem {
18    pub label: String,
19    pub icon: Option<char>,
20    /// Colour swatch painted in the icon slot.  When `Some`, takes
21    /// precedence over `icon` and over the check / radio selection
22    /// glyph — the popup paints a rounded filled rect in the icon
23    /// column.  For radio-style rows the selected item gets a thin
24    /// stroke around the swatch instead of the usual `radio_glyph`,
25    /// so the colour itself remains the dominant cue.
26    ///
27    /// Intended for menus where each item represents a colour the
28    /// user is picking (accent palette, brush colour, etc.).
29    pub swatch: Option<Color>,
30    pub shortcut: Option<String>,
31    pub accelerator: Option<MenuShortcut>,
32    pub enabled: bool,
33    pub selection: MenuSelection,
34    pub action: Option<String>,
35    pub submenu: Vec<MenuEntry>,
36    pub close_on_activate: bool,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum MenuSelection {
41    None,
42    Check { selected: bool },
43    Radio { selected: bool },
44}
45
46impl MenuItem {
47    pub fn action(label: impl Into<String>, action: impl Into<String>) -> Self {
48        Self {
49            label: label.into(),
50            icon: None,
51            swatch: None,
52            shortcut: None,
53            accelerator: None,
54            enabled: true,
55            selection: MenuSelection::None,
56            action: Some(action.into()),
57            submenu: Vec::new(),
58            close_on_activate: true,
59        }
60    }
61
62    pub fn submenu(label: impl Into<String>, submenu: Vec<MenuEntry>) -> Self {
63        Self {
64            label: label.into(),
65            icon: None,
66            swatch: None,
67            shortcut: None,
68            accelerator: None,
69            enabled: true,
70            selection: MenuSelection::None,
71            action: None,
72            submenu,
73            close_on_activate: false,
74        }
75    }
76
77    pub fn disabled(mut self) -> Self {
78        self.enabled = false;
79        self
80    }
81
82    pub fn checked(mut self, checked: bool) -> Self {
83        self.selection = MenuSelection::Check { selected: checked };
84        self
85    }
86
87    pub fn radio(mut self, selected: bool) -> Self {
88        self.selection = MenuSelection::Radio { selected };
89        self
90    }
91
92    pub fn keep_open(mut self) -> Self {
93        self.close_on_activate = false;
94        self
95    }
96
97    pub fn close_on_activate(mut self, close: bool) -> Self {
98        self.close_on_activate = close;
99        self
100    }
101
102    pub fn icon(mut self, icon: char) -> Self {
103        self.icon = Some(icon);
104        self
105    }
106
107    /// Paint a colour swatch in the icon slot instead of a glyph.  See
108    /// [`MenuItem::swatch`] for behaviour details.
109    pub fn swatch(mut self, color: Color) -> Self {
110        self.swatch = Some(color);
111        self
112    }
113
114    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
115        let shortcut = shortcut.into();
116        self.accelerator = MenuShortcut::parse(&shortcut);
117        self.shortcut = Some(shortcut);
118        self
119    }
120
121    pub fn accelerator(mut self, accelerator: MenuShortcut) -> Self {
122        self.shortcut = Some(accelerator.display_text());
123        self.accelerator = Some(accelerator);
124        self
125    }
126
127    pub fn has_submenu(&self) -> bool {
128        !self.submenu.is_empty()
129    }
130}
131
132impl From<MenuItem> for MenuEntry {
133    fn from(item: MenuItem) -> Self {
134        Self::Item(item)
135    }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub struct MenuShortcut {
140    pub key: ShortcutKey,
141    /// Portable command modifier: Ctrl on Windows/Linux, Cmd on macOS.
142    pub command: bool,
143    pub shift: bool,
144    pub alt: bool,
145}
146
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum ShortcutKey {
149    Char(char),
150    Insert,
151    Delete,
152    Backspace,
153    Enter,
154    Escape,
155}
156
157impl MenuShortcut {
158    pub fn command_char(ch: char) -> Self {
159        Self {
160            key: ShortcutKey::Char(ch.to_ascii_uppercase()),
161            command: true,
162            shift: false,
163            alt: false,
164        }
165    }
166
167    pub fn parse(text: &str) -> Option<Self> {
168        let mut command = false;
169        let mut shift = false;
170        let mut alt = false;
171        let mut key = None;
172        for part in text.split('+') {
173            let token = part.trim();
174            match token.to_ascii_lowercase().as_str() {
175                "ctrl" | "control" | "cmd" | "command" | "meta" => command = true,
176                "shift" => shift = true,
177                "alt" | "option" => alt = true,
178                "insert" => key = Some(ShortcutKey::Insert),
179                "delete" | "del" => key = Some(ShortcutKey::Delete),
180                "backspace" => key = Some(ShortcutKey::Backspace),
181                "enter" | "return" => key = Some(ShortcutKey::Enter),
182                "esc" | "escape" => key = Some(ShortcutKey::Escape),
183                _ => {
184                    let mut chars = token.chars();
185                    let ch = chars.next()?;
186                    if chars.next().is_none() {
187                        key = Some(ShortcutKey::Char(ch.to_ascii_uppercase()));
188                    } else {
189                        return None;
190                    }
191                }
192            }
193        }
194        Some(Self {
195            key: key?,
196            command,
197            shift,
198            alt,
199        })
200    }
201
202    pub fn matches(self, key: &Key, modifiers: Modifiers) -> bool {
203        let command_matches = if self.command {
204            platform::command_modifier_pressed(modifiers)
205        } else {
206            platform::command_modifier_released(modifiers)
207        };
208        command_matches
209            && modifiers.shift == self.shift
210            && modifiers.alt == self.alt
211            && self.key.matches(key)
212    }
213
214    pub fn display_text(self) -> String {
215        let mut parts = Vec::new();
216        if self.command {
217            parts.push(platform::primary_modifier_label().to_string());
218        }
219        if self.shift {
220            parts.push("Shift".to_string());
221        }
222        if self.alt {
223            parts.push("Alt".to_string());
224        }
225        parts.push(self.key.display_text());
226        parts.join("+")
227    }
228}
229
230impl ShortcutKey {
231    fn matches(self, key: &Key) -> bool {
232        match (self, key) {
233            (Self::Char(expected), Key::Char(actual)) => {
234                expected.eq_ignore_ascii_case(&actual.to_ascii_uppercase())
235            }
236            (Self::Insert, Key::Insert)
237            | (Self::Delete, Key::Delete)
238            | (Self::Backspace, Key::Backspace)
239            | (Self::Enter, Key::Enter)
240            | (Self::Escape, Key::Escape) => true,
241            _ => false,
242        }
243    }
244
245    fn display_text(self) -> String {
246        match self {
247            Self::Char(ch) => ch.to_string(),
248            Self::Insert => "Insert".to_string(),
249            Self::Delete => "Delete".to_string(),
250            Self::Backspace => "Backspace".to_string(),
251            Self::Enter => "Enter".to_string(),
252            Self::Escape => "Esc".to_string(),
253        }
254    }
255}