tuiserial_core/
menu_def.rs

1//! Menu definition and structure
2//!
3//! This module provides a centralized, type-safe menu structure that eliminates
4//! hardcoded indices and simplifies menu navigation and interaction.
5//!
6//! Following Linus's principle: "Bad programmers worry about the code.
7//! Good programmers worry about data structures."
8
9use crate::Language;
10
11/// Calculate display width of a string (handles CJK characters)
12///
13/// CJK (Chinese, Japanese, Korean) characters take up 2 display columns,
14/// while ASCII characters take up 1 column.
15fn display_width(s: &str) -> usize {
16    s.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum()
17}
18
19/// Menu action that can be triggered
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum MenuAction {
22    // File menu
23    SaveConfig,
24    LoadConfig,
25    Exit,
26
27    // Session menu (for multi-session support)
28    NewSession,
29    DuplicateSession,
30    RenameSession,
31    CloseSession,
32
33    // View menu (for layout support)
34    ViewSingle,
35    ViewSplitHorizontal,
36    ViewSplitVertical,
37    ViewGrid2x2,
38    ViewNextPane,
39    ViewPrevPane,
40
41    // Settings menu
42    ToggleLanguage,
43
44    // Help menu
45    ShowShortcuts,
46    ShowAbout,
47
48    // Special
49    Separator, // Not an action, just for display
50}
51
52impl MenuAction {
53    /// Get the translation key for this action
54    pub fn label_key(&self) -> &'static str {
55        match self {
56            MenuAction::SaveConfig => "menu.file.save_config",
57            MenuAction::LoadConfig => "menu.file.load_config",
58            MenuAction::Exit => "menu.file.exit",
59            MenuAction::NewSession => "menu.session.new",
60            MenuAction::DuplicateSession => "menu.session.duplicate",
61            MenuAction::RenameSession => "menu.session.rename",
62            MenuAction::CloseSession => "menu.session.close",
63            MenuAction::ViewSingle => "menu.view.single",
64            MenuAction::ViewSplitHorizontal => "menu.view.split_h",
65            MenuAction::ViewSplitVertical => "menu.view.split_v",
66            MenuAction::ViewGrid2x2 => "menu.view.grid_2x2",
67            MenuAction::ViewNextPane => "menu.view.next_pane",
68            MenuAction::ViewPrevPane => "menu.view.prev_pane",
69            MenuAction::ToggleLanguage => "menu.settings.toggle_language",
70            MenuAction::ShowShortcuts => "menu.help.shortcuts",
71            MenuAction::ShowAbout => "menu.help.about",
72            MenuAction::Separator => "",
73        }
74    }
75
76    /// Check if this is a separator
77    pub fn is_separator(&self) -> bool {
78        matches!(self, MenuAction::Separator)
79    }
80}
81
82/// A single menu with its items
83#[derive(Debug, Clone)]
84pub struct Menu {
85    pub label_key: &'static str,
86    pub items: &'static [MenuAction],
87}
88
89impl Menu {
90    /// Get the number of items (including separators)
91    pub fn item_count(&self) -> usize {
92        self.items.len()
93    }
94
95    /// Get the item at index
96    pub fn get_item(&self, index: usize) -> Option<MenuAction> {
97        self.items.get(index).copied()
98    }
99}
100
101/// All application menus
102pub struct MenuBar {
103    pub menus: &'static [Menu],
104}
105
106impl MenuBar {
107    /// Get the number of menus
108    pub fn menu_count(&self) -> usize {
109        self.menus.len()
110    }
111
112    /// Get a menu by index
113    pub fn get_menu(&self, index: usize) -> Option<&Menu> {
114        self.menus.get(index)
115    }
116
117    /// Get menu label key
118    pub fn get_menu_label_key(&self, index: usize) -> Option<&'static str> {
119        self.menus.get(index).map(|m| m.label_key)
120    }
121
122    /// Get item count for a menu
123    pub fn get_item_count(&self, menu_index: usize) -> usize {
124        self.menus
125            .get(menu_index)
126            .map(|m| m.item_count())
127            .unwrap_or(0)
128    }
129
130    /// Get a specific menu action
131    pub fn get_action(&self, menu_index: usize, item_index: usize) -> Option<MenuAction> {
132        self.menus
133            .get(menu_index)
134            .and_then(|m| m.get_item(item_index))
135    }
136}
137
138// Menu definitions - the single source of truth
139const FILE_MENU_ITEMS: &[MenuAction] = &[
140    MenuAction::SaveConfig,
141    MenuAction::LoadConfig,
142    MenuAction::Separator,
143    MenuAction::Exit,
144];
145
146const SESSION_MENU_ITEMS: &[MenuAction] = &[
147    MenuAction::NewSession,
148    MenuAction::DuplicateSession,
149    MenuAction::RenameSession,
150    MenuAction::Separator,
151    MenuAction::CloseSession,
152];
153
154const VIEW_MENU_ITEMS: &[MenuAction] = &[
155    MenuAction::ViewSingle,
156    MenuAction::ViewSplitHorizontal,
157    MenuAction::ViewSplitVertical,
158    MenuAction::ViewGrid2x2,
159    MenuAction::Separator,
160    MenuAction::ViewNextPane,
161    MenuAction::ViewPrevPane,
162];
163
164const SETTINGS_MENU_ITEMS: &[MenuAction] = &[MenuAction::ToggleLanguage];
165
166const HELP_MENU_ITEMS: &[MenuAction] = &[
167    MenuAction::ShowShortcuts,
168    MenuAction::Separator,
169    MenuAction::ShowAbout,
170];
171
172// All menus in order
173const ALL_MENUS: &[Menu] = &[
174    Menu {
175        label_key: "menu.file",
176        items: FILE_MENU_ITEMS,
177    },
178    Menu {
179        label_key: "menu.session",
180        items: SESSION_MENU_ITEMS,
181    },
182    Menu {
183        label_key: "menu.view",
184        items: VIEW_MENU_ITEMS,
185    },
186    Menu {
187        label_key: "menu.settings",
188        items: SETTINGS_MENU_ITEMS,
189    },
190    Menu {
191        label_key: "menu.help",
192        items: HELP_MENU_ITEMS,
193    },
194];
195
196/// Global menu bar instance
197pub const MENU_BAR: MenuBar = MenuBar { menus: ALL_MENUS };
198
199/// Calculate x offset for a menu in the menu bar
200pub fn calculate_menu_x_offset(menu_index: usize, lang: Language) -> u16 {
201    use crate::i18n::t;
202
203    let mut offset = 0u16;
204    for i in 0..menu_index.min(MENU_BAR.menu_count()) {
205        if let Some(label_key) = MENU_BAR.get_menu_label_key(i) {
206            let label = t(label_key, lang);
207            // Each menu has format " Label " (label + 2 spaces for padding + 2 spaces between menus)
208            offset += display_width(label) as u16 + 4;
209        }
210    }
211    offset
212}
213
214/// Find which menu was clicked based on x position
215pub fn find_clicked_menu(x: u16, lang: Language) -> Option<usize> {
216    use crate::i18n::t;
217
218    let mut current_x = 0u16;
219    for (i, menu) in MENU_BAR.menus.iter().enumerate() {
220        let label = t(menu.label_key, lang);
221        let menu_width = display_width(label) as u16 + 2; // " Label "
222
223        if x >= current_x && x < current_x + menu_width {
224            return Some(i);
225        }
226
227        current_x += menu_width + 2; // +2 for spacing between menus
228    }
229    None
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_menu_bar_structure() {
238        assert_eq!(MENU_BAR.menu_count(), 5);
239        assert_eq!(MENU_BAR.get_item_count(0), 4); // File: Save, Load, Sep, Exit
240        assert_eq!(MENU_BAR.get_item_count(1), 5); // Session
241        assert_eq!(MENU_BAR.get_item_count(2), 7); // View
242        assert_eq!(MENU_BAR.get_item_count(3), 1); // Settings
243        assert_eq!(MENU_BAR.get_item_count(4), 3); // Help: Shortcuts, Sep, About
244    }
245
246    #[test]
247    fn test_menu_actions() {
248        assert_eq!(MENU_BAR.get_action(0, 0), Some(MenuAction::SaveConfig));
249        assert_eq!(MENU_BAR.get_action(0, 3), Some(MenuAction::Exit));
250        assert_eq!(MENU_BAR.get_action(4, 0), Some(MenuAction::ShowShortcuts));
251    }
252
253    #[test]
254    fn test_separator_detection() {
255        assert!(MenuAction::Separator.is_separator());
256        assert!(!MenuAction::SaveConfig.is_separator());
257    }
258
259    #[test]
260    fn test_invalid_indices() {
261        assert_eq!(MENU_BAR.get_action(99, 0), None);
262        assert_eq!(MENU_BAR.get_action(0, 99), None);
263    }
264
265    #[test]
266    fn test_menu_label_keys() {
267        assert_eq!(MENU_BAR.get_menu_label_key(0), Some("menu.file"));
268        assert_eq!(MENU_BAR.get_menu_label_key(1), Some("menu.session"));
269        assert_eq!(MENU_BAR.get_menu_label_key(4), Some("menu.help"));
270    }
271}