Skip to main content

turbo_vision/core/
menu_data.rs

1// (C) 2025 - Enzo Lombardi
2
3//! Menu data structures - declarative menu building with Borland-compatible API.
4// Borland-compatible menu data structures
5// Matches the original Turbo Vision TMenuItem, TSubMenu, TMenu architecture
6//
7// This module provides data structures for building menus in a declarative way,
8// matching Borland's approach while being Rust-idiomatic.
9
10use crate::core::command::CommandId;
11use crate::core::event::KeyCode;
12
13/// Menu item - can be a regular command, a submenu, or a separator
14///
15/// Matches Borland: `TMenuItem`
16///
17/// In Borland, `TMenuItem` is a linked list node with a `next` pointer.
18/// In Rust, we use Vec for type safety, but provide builder methods
19/// that match Borland's ergonomics.
20#[derive(Clone, Debug)]
21pub enum MenuItem {
22    /// Regular menu item that executes a command
23    /// Matches Borland: `TMenuItem` with command
24    Regular {
25        /// Display text (use ~x~ to mark accelerator key)
26        text: String,
27        /// Command to execute when selected
28        command: CommandId,
29        /// Keyboard shortcut
30        key_code: KeyCode,
31        /// Help context ID
32        help_ctx: u16,
33        /// Whether item is enabled
34        enabled: bool,
35        /// Optional shortcut text to display (e.g., "Ctrl+O", "F3")
36        shortcut: Option<String>,
37        /// Optional flag query: when set, the item is drawn with a check mark
38        /// while the function returns true (Borland: `TMenuItem` check marks)
39        checked: Option<fn() -> bool>,
40    },
41    /// Submenu item that opens a nested menu
42    /// Matches Borland: `TMenuItem` with subMenu
43    SubMenu {
44        /// Display text (use ~x~ to mark accelerator key)
45        text: String,
46        /// Keyboard shortcut to open submenu
47        key_code: KeyCode,
48        /// Help context ID
49        help_ctx: u16,
50        /// Nested menu
51        menu: Menu,
52    },
53    /// Separator line
54    /// Matches Borland: `TMenuItem` with null name
55    Separator,
56}
57
58impl MenuItem {
59    /// Assemble a regular item; the public way in is [`MenuItemBuilder`] or
60    /// [`MenuBuilder::item`] / [`MenuBuilder::item_key`].
61    pub(crate) fn from_parts(
62        text: &str,
63        command: CommandId,
64        key_code: KeyCode,
65        shortcut: Option<String>,
66        help_ctx: u16,
67        enabled: bool,
68    ) -> Self {
69        Self::Regular {
70            text: text.to_string(),
71            command,
72            key_code,
73            help_ctx,
74            enabled,
75            shortcut,
76            checked: None,
77        }
78    }
79
80    /// Create a flag (checkable) menu item
81    ///
82    /// `checked` is queried each time the menu is drawn, so the check mark
83    /// always reflects the current state - no menu rebuild needed.
84    ///
85    /// # Example
86    /// ```ignore
87    /// let item = MenuItem::flag("Bloc~k~ mode", CM_TOGGLE_BLOCK_MODE, 0, 0, block_edit_mode);
88    /// ```
89    pub fn flag(
90        text: &str,
91        command: CommandId,
92        key_code: KeyCode,
93        help_ctx: u16,
94        checked: fn() -> bool,
95    ) -> Self {
96        Self::Regular {
97            text: text.to_string(),
98            command,
99            key_code,
100            help_ctx,
101            enabled: true,
102            shortcut: None,
103            checked: Some(checked),
104        }
105    }
106
107    /// Create a submenu item
108    ///
109    /// Matches Borland: `TMenuItem(name, keyCode, subMenu, helpCtx)`
110    ///
111    /// # Example
112    /// ```ignore
113    /// let item = MenuItem::submenu("~F~ile", KB_ALT_F, file_menu, hcFile);
114    /// ```
115    pub fn submenu(text: &str, key_code: KeyCode, menu: Menu, help_ctx: u16) -> Self {
116        Self::SubMenu {
117            text: text.to_string(),
118            key_code,
119            help_ctx,
120            menu,
121        }
122    }
123
124    /// Create a separator
125    ///
126    /// Matches Borland: `newLine()`
127    pub fn separator() -> Self {
128        Self::Separator
129    }
130
131    /// Check if this item is selectable (not a separator and not disabled)
132    pub fn is_selectable(&self) -> bool {
133        match self {
134            Self::Regular { enabled, .. } => *enabled,
135            Self::SubMenu { .. } => true,
136            Self::Separator => false,
137        }
138    }
139
140    /// Extract the accelerator key from the text (character between ~ marks)
141    pub fn get_accelerator(&self) -> Option<char> {
142        let text = match self {
143            Self::Regular { text, .. } | Self::SubMenu { text, .. } => text,
144            Self::Separator => return None,
145        };
146
147        let mut chars = text.chars();
148        while let Some(ch) = chars.next() {
149            if ch == '~' {
150                // Next char is the accelerator
151                if let Some(accel) = chars.next() {
152                    return Some(accel.to_ascii_lowercase());
153                }
154            }
155        }
156        None
157    }
158
159    /// Get the display text (with ~ markers)
160    pub fn text(&self) -> &str {
161        match self {
162            Self::Regular { text, .. } | Self::SubMenu { text, .. } => text,
163            Self::Separator => "",
164        }
165    }
166
167    /// Get the command (for Regular items only)
168    pub fn command(&self) -> Option<CommandId> {
169        match self {
170            Self::Regular { command, .. } => Some(*command),
171            _ => None,
172        }
173    }
174
175    /// Get the shortcut display text (for Regular items only)
176    pub fn shortcut(&self) -> Option<&str> {
177        match self {
178            Self::Regular { shortcut, .. } => shortcut.as_deref(),
179            _ => None,
180        }
181    }
182}
183
184/// Menu - a collection of menu items
185///
186/// Matches Borland: `TMenu`
187///
188/// In Borland, `TMenu` has:
189/// - `items`: pointer to first item in linked list
190/// - `deflt`: pointer to default item
191///
192/// In Rust, we use Vec for type safety and provide convenient builders.
193#[derive(Clone, Debug)]
194pub struct Menu {
195    /// Menu items
196    pub items: Vec<MenuItem>,
197    /// Index of default item (if any)
198    pub default_index: Option<usize>,
199}
200
201impl Menu {
202    /// Create an empty menu
203    ///
204    /// Matches Borland: `TMenu()`
205    pub fn new() -> Self {
206        Self {
207            items: Vec::new(),
208            default_index: None,
209        }
210    }
211
212    /// Create a menu from items
213    ///
214    /// Matches Borland: `TMenu(itemList)`
215    pub fn from_items(items: Vec<MenuItem>) -> Self {
216        Self {
217            items,
218            default_index: None,
219        }
220    }
221
222    /// Create a menu with a default item
223    ///
224    /// Matches Borland: `TMenu(itemList, deflt)`
225    pub fn with_default(items: Vec<MenuItem>, default_index: usize) -> Self {
226        Self {
227            items,
228            default_index: Some(default_index),
229        }
230    }
231
232    /// Add an item to the menu
233    ///
234    /// Matches Borland: appending to `TMenuItem` linked list
235    pub fn add(&mut self, item: MenuItem) {
236        self.items.push(item);
237    }
238
239    /// Find the command bound to a keyboard shortcut, searching submenus.
240    ///
241    /// Matches Borland: `TMenuView::findHotKey()` — lets item shortcuts like F2
242    /// work while the menu is closed.
243    pub fn find_hotkey(&self, key_code: KeyCode) -> Option<CommandId> {
244        if key_code == 0 {
245            return None;
246        }
247        for item in &self.items {
248            match item {
249                MenuItem::Regular {
250                    command,
251                    key_code: item_key,
252                    enabled: true,
253                    ..
254                } if *item_key == key_code => return Some(*command),
255                MenuItem::SubMenu { menu, .. } => {
256                    if let Some(cmd) = menu.find_hotkey(key_code) {
257                        return Some(cmd);
258                    }
259                }
260                _ => {}
261            }
262        }
263        None
264    }
265
266    /// Set the default item by index
267    pub fn set_default(&mut self, index: usize) {
268        if index < self.items.len() {
269            self.default_index = Some(index);
270        }
271    }
272
273    /// Get the number of items
274    pub fn len(&self) -> usize {
275        self.items.len()
276    }
277
278    /// Check if menu is empty
279    pub fn is_empty(&self) -> bool {
280        self.items.is_empty()
281    }
282}
283
284impl Default for Menu {
285    fn default() -> Self {
286        Self::new()
287    }
288}
289
290/// Builder for constructing menus fluently
291///
292/// This provides a Borland-style builder API while being Rust-idiomatic.
293///
294/// # Example (Borland-style)
295/// ```ignore
296/// let menu = MenuBuilder::new()
297///     .item_key("~O~pen", CM_OPEN, "F3")
298///     .item_key("~S~ave", CM_SAVE, "F2")
299///     .separator()
300///     .item_key("E~x~it", CM_QUIT, "Alt+X")
301///     .build();
302/// ```
303pub struct MenuBuilder {
304    items: Vec<MenuItem>,
305    help_ctx: u16,
306}
307
308impl MenuBuilder {
309    /// Create a new menu builder
310    pub fn new() -> Self {
311        Self {
312            items: Vec::new(),
313            help_ctx: 0,
314        }
315    }
316
317    /// Set the default help context for subsequent items
318    pub fn help_context(mut self, help_ctx: u16) -> Self {
319        self.help_ctx = help_ctx;
320        self
321    }
322
323    /// Add an item with no key binding.
324    #[must_use]
325    pub fn item(mut self, text: &str, command: CommandId) -> Self {
326        self.items.push(MenuItem::from_parts(
327            text,
328            command,
329            0,
330            None,
331            self.help_ctx,
332            true,
333        ));
334        self
335    }
336
337    /// Add an item bound to a key chord such as `"Ctrl+O"`, `"F3"` or
338    /// `"Alt+X"`; the chord is both bound and shown next to the text.
339    ///
340    /// # Panics
341    ///
342    /// Panics on a chord `parse_key_chord` does not understand: a menu
343    /// definition is program text, and a typo should fail at first run rather
344    /// than silently bind nothing.
345    #[must_use]
346    pub fn item_key(mut self, text: &str, command: CommandId, chord: &str) -> Self {
347        let key = key_code_for_chord(chord);
348        self.items.push(MenuItem::from_parts(
349            text,
350            command,
351            key,
352            Some(chord.to_string()),
353            self.help_ctx,
354            true,
355        ));
356        self
357    }
358
359    /// Add a disabled item.
360    #[must_use]
361    pub fn item_disabled(mut self, text: &str, command: CommandId) -> Self {
362        self.items.push(MenuItem::from_parts(
363            text,
364            command,
365            0,
366            None,
367            self.help_ctx,
368            false,
369        ));
370        self
371    }
372
373    /// Add an item built elsewhere, typically with [`MenuItemBuilder`].
374    #[must_use]
375    pub fn push_item(mut self, item: MenuItem) -> Self {
376        self.items.push(item);
377        self
378    }
379
380    /// Add a submenu
381    pub fn submenu(mut self, text: &str, key_code: KeyCode, menu: Menu) -> Self {
382        self.items
383            .push(MenuItem::submenu(text, key_code, menu, self.help_ctx));
384        self
385    }
386
387    /// Add a separator
388    pub fn separator(mut self) -> Self {
389        self.items.push(MenuItem::separator());
390        self
391    }
392
393    /// Build the menu
394    pub fn build(self) -> Menu {
395        Menu::from_items(self.items)
396    }
397}
398
399impl Default for MenuBuilder {
400    fn default() -> Self {
401        Self::new()
402    }
403}
404
405/// Builder for creating regular menu items with a fluent API.
406///
407/// This builder focuses on the Regular variant of `MenuItem` where most
408/// configuration options exist (enabled state, shortcut display).
409///
410/// # Examples
411///
412/// ```ignore
413/// use turbo_vision::core::menu_data::MenuItemBuilder;
414/// use turbo_vision::core::command::CM_OPEN;
415/// use turbo_vision::core::event::KB_F3;
416///
417/// // Create a simple menu item
418/// let item = MenuItemBuilder::new()
419///     .text("~O~pen")
420///     .command(CM_OPEN)
421///     .key_code(KB_F3)
422///     .build();
423///
424/// // Create a menu item with shortcut display
425/// let item = MenuItemBuilder::new()
426///     .text("~S~ave")
427///     .command(CM_SAVE)
428///     .key_code(KB_F2)
429///     .shortcut("F2")
430///     .build();
431///
432/// // Create a disabled menu item
433/// let item = MenuItemBuilder::new()
434///     .text("~P~rint")
435///     .command(CM_PRINT)
436///     .key_code(KB_CTRL_P)
437///     .enabled(false)
438///     .build();
439/// ```
440pub struct MenuItemBuilder {
441    text: Option<String>,
442    command: Option<CommandId>,
443    key_code: KeyCode,
444    help_ctx: u16,
445    enabled: bool,
446    shortcut: Option<String>,
447    checked: Option<fn() -> bool>,
448}
449
450impl MenuItemBuilder {
451    /// Creates a new `MenuItemBuilder` with default values.
452    pub fn new() -> Self {
453        Self {
454            text: None,
455            command: None,
456            key_code: 0,
457            help_ctx: 0,
458            enabled: true,
459            shortcut: None,
460            checked: None,
461        }
462    }
463
464    /// Sets the menu item text (required).
465    /// Use ~x~ to mark the accelerator key.
466    #[must_use]
467    pub fn text(mut self, text: impl Into<String>) -> Self {
468        self.text = Some(text.into());
469        self
470    }
471
472    /// Sets the command to execute (required).
473    #[must_use]
474    pub fn command(mut self, command: CommandId) -> Self {
475        self.command = Some(command);
476        self
477    }
478
479    /// Sets the keyboard shortcut key code.
480    #[must_use]
481    pub fn key_code(mut self, key_code: KeyCode) -> Self {
482        self.key_code = key_code;
483        self
484    }
485
486    /// Sets the help context ID.
487    #[must_use]
488    pub fn help_ctx(mut self, help_ctx: u16) -> Self {
489        self.help_ctx = help_ctx;
490        self
491    }
492
493    /// Sets whether the menu item is enabled (default: true).
494    #[must_use]
495    pub fn enabled(mut self, enabled: bool) -> Self {
496        self.enabled = enabled;
497        self
498    }
499
500    /// Sets the shortcut display text (e.g., "F3", "Ctrl+O") without binding
501    /// a key; see [`key`](Self::key) to do both from one chord.
502    #[must_use]
503    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
504        self.shortcut = Some(shortcut.into());
505        self
506    }
507
508    /// Binds the item to a key chord such as `"Ctrl+O"` and shows it next to
509    /// the text.
510    ///
511    /// # Panics
512    ///
513    /// Panics on a chord `parse_key_chord` does not understand.
514    #[must_use]
515    pub fn key(mut self, chord: &str) -> Self {
516        self.key_code = key_code_for_chord(chord);
517        self.shortcut = Some(chord.to_string());
518        self
519    }
520
521    /// Makes this a flag (checkable) item; `checked` is queried on every draw.
522    #[must_use]
523    pub fn checked(mut self, checked: fn() -> bool) -> Self {
524        self.checked = Some(checked);
525        self
526    }
527
528    /// Builds the `MenuItem::Regular` variant.
529    ///
530    /// # Panics
531    ///
532    /// Panics if required fields (text, command) are not set.
533    pub fn build(self) -> MenuItem {
534        let text = self.text.expect("MenuItem text must be set");
535        let command = self.command.expect("MenuItem command must be set");
536
537        MenuItem::Regular {
538            text,
539            command,
540            key_code: self.key_code,
541            help_ctx: self.help_ctx,
542            enabled: self.enabled,
543            shortcut: self.shortcut,
544            checked: self.checked,
545        }
546    }
547}
548
549/// The key code a chord string stands for.
550///
551/// # Panics
552///
553/// Panics on a chord `parse_key_chord` does not understand.
554pub(crate) fn key_code_for_chord(chord: &str) -> KeyCode {
555    crate::core::event::parse_key_chord(chord)
556        .map_or_else(|| panic!("unknown key chord {chord:?}"), |e| e.key_code)
557}
558
559impl Default for MenuItemBuilder {
560    fn default() -> Self {
561        Self::new()
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn item_key_binds_and_displays_the_chord() {
571        use crate::core::event::KB_CTRL_O;
572        let menu = MenuBuilder::new().item_key("~O~pen", 301, "Ctrl+O").build();
573        let item = &menu.items[0];
574        assert_eq!(item.shortcut(), Some("Ctrl+O"));
575        assert_eq!(menu.find_hotkey(KB_CTRL_O), Some(301));
576    }
577
578    #[test]
579    fn test_menu_builder() {
580        let menu = MenuBuilder::new()
581            .push_item(
582                MenuItemBuilder::new()
583                    .text("~O~pen")
584                    .command(100)
585                    .key_code(0x3D00)
586                    .build(),
587            )
588            .push_item(
589                MenuItemBuilder::new()
590                    .text("~S~ave")
591                    .command(101)
592                    .key_code(0x3C00)
593                    .build(),
594            )
595            .separator()
596            .push_item(
597                MenuItemBuilder::new()
598                    .text("E~x~it")
599                    .command(102)
600                    .key_code(0x2D00)
601                    .build(),
602            )
603            .build();
604
605        assert_eq!(menu.len(), 4);
606        assert!(matches!(menu.items[0], MenuItem::Regular { .. }));
607        assert!(matches!(menu.items[2], MenuItem::Separator));
608    }
609
610    #[test]
611    fn test_accelerator() {
612        let item = MenuItemBuilder::new()
613            .text("~O~pen")
614            .command(100)
615            .key_code(0x3D00)
616            .build();
617        assert_eq!(item.get_accelerator(), Some('o'));
618    }
619
620    #[test]
621    fn test_menu_item_builder() {
622        let item = MenuItemBuilder::new()
623            .text("~O~pen")
624            .command(100)
625            .key_code(0x3D00)
626            .build();
627
628        assert_eq!(item.text(), "~O~pen");
629        assert_eq!(item.command(), Some(100));
630        assert!(item.is_selectable());
631    }
632
633    #[test]
634    fn test_menu_item_builder_with_shortcut() {
635        let item = MenuItemBuilder::new()
636            .text("~S~ave")
637            .command(101)
638            .key_code(0x3C00)
639            .shortcut("F2")
640            .build();
641
642        assert_eq!(item.shortcut(), Some("F2"));
643    }
644
645    #[test]
646    fn test_menu_item_builder_disabled() {
647        let item = MenuItemBuilder::new()
648            .text("~P~rint")
649            .command(102)
650            .key_code(0)
651            .enabled(false)
652            .build();
653
654        assert!(!item.is_selectable());
655    }
656
657    #[test]
658    fn find_hotkey_searches_nested_submenus() {
659        let inner = Menu::from_items(vec![MenuItem::Regular {
660            text: "Deep".into(),
661            command: 42,
662            key_code: 0x3C00, // F2
663            help_ctx: 0,
664            enabled: true,
665            shortcut: None,
666            checked: None,
667        }]);
668        let menu = Menu::from_items(vec![
669            MenuItem::Regular {
670                text: "Top".into(),
671                command: 1,
672                key_code: 0,
673                help_ctx: 0,
674                enabled: true,
675                shortcut: None,
676                checked: None,
677            },
678            MenuItem::SubMenu {
679                text: "Sub".into(),
680                key_code: 0,
681                help_ctx: 0,
682                menu: inner,
683            },
684        ]);
685        assert_eq!(menu.find_hotkey(0x3C00), Some(42));
686        assert_eq!(menu.find_hotkey(0x9999), None);
687        // key_code 0 never matches (plain items would otherwise all match)
688        assert_eq!(menu.find_hotkey(0), None);
689    }
690}