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}