Skip to main content

turbo_vision/views/
button.rs

1// (C) 2025 - Enzo Lombardi
2
3//! Button view - clickable button with keyboard shortcuts and command dispatch.
4
5use super::view::{write_line_to_terminal, View};
6use crate::core::command::CommandId;
7use crate::core::draw::DrawBuffer;
8use crate::core::event::{Event, EventType, KB_ENTER, MB_LEFT_BUTTON};
9use crate::core::geometry::Rect;
10use crate::core::palette::{
11    BUTTON_DEFAULT, BUTTON_DISABLED, BUTTON_NORMAL, BUTTON_SELECTED, BUTTON_SHADOW, BUTTON_SHORTCUT,
12};
13use crate::core::state::{StateFlags, SF_DISABLED, SHADOW_BOTTOM, SHADOW_SOLID, SHADOW_TOP};
14use crate::terminal::Terminal;
15
16pub struct Button {
17    bounds: Rect,
18    title: String,
19    command: CommandId,
20    is_default: bool,
21    is_broadcast: bool,
22    state: StateFlags,
23    options: u16,
24    palette_chain: Option<crate::core::palette_chain::PaletteChainNode>,
25}
26
27impl Button {
28    pub fn new(bounds: Rect, title: &str, command: CommandId, is_default: bool) -> Self {
29        use crate::core::command_set;
30        use crate::core::state::OF_POST_PROCESS;
31
32        // Check if command is initially enabled
33        // Matches Borland: TButton constructor checks commandEnabled() (tbutton.cc:55-56)
34        let mut state = 0;
35        if !command_set::command_enabled(command) {
36            state |= SF_DISABLED;
37        }
38
39        Self {
40            bounds,
41            title: title.to_string(),
42            command,
43            is_default,
44            is_broadcast: false,
45            state,
46            options: OF_POST_PROCESS, // Buttons process in post-process phase
47            palette_chain: None,
48        }
49    }
50
51    pub fn set_disabled(&mut self, disabled: bool) {
52        self.set_state_flag(SF_DISABLED, disabled);
53    }
54
55    pub fn is_disabled(&self) -> bool {
56        self.get_state_flag(SF_DISABLED)
57    }
58
59    /// Set whether this button broadcasts its command instead of sending it as a command event
60    /// Matches Borland: bfBroadcast flag
61    pub fn set_broadcast(&mut self, broadcast: bool) {
62        self.is_broadcast = broadcast;
63    }
64
65    /// Set whether this button is selectable (can receive focus)
66    /// Matches Borland: ofSelectable flag
67    pub fn set_selectable(&mut self, selectable: bool) {
68        use crate::core::state::OF_SELECTABLE;
69        if selectable {
70            self.options |= OF_SELECTABLE;
71        } else {
72            self.options &= !OF_SELECTABLE;
73        }
74    }
75
76    /// Extract the hotkey character from the button title
77    /// Returns the uppercase character following the first '~', or None if no hotkey
78    fn get_hotkey(&self) -> Option<char> {
79        let mut chars = self.title.chars();
80        while let Some(ch) = chars.next() {
81            if ch == '~' {
82                // Next character is the hotkey
83                if let Some(hotkey) = chars.next() {
84                    return Some(hotkey.to_uppercase().next().unwrap_or(hotkey));
85                }
86            }
87        }
88        None
89    }
90}
91
92impl View for Button {
93    fn bounds(&self) -> Rect {
94        self.bounds
95    }
96
97    fn set_bounds(&mut self, bounds: Rect) {
98        self.bounds = bounds;
99    }
100
101    fn draw(&mut self, terminal: &mut Terminal) {
102        let width = self.bounds.width_clamped() as usize;
103        let height = self.bounds.height_clamped() as usize;
104
105        // Don't render buttons that are too small
106        // Minimum width: 4 (at least 2 chars for content + 1 for right shadow + 1 for spacing)
107        // Minimum height: 2 (at least 1 line for content + 1 for bottom shadow)
108        if width < 4 || height < 2 {
109            return;
110        }
111
112        let is_disabled = self.is_disabled();
113        let is_focused = self.is_focused();
114
115        // Button color indices (from CP_BUTTON palette):
116        // 1: Normal text
117        // 2: Default text
118        // 3: Selected (focused) text
119        // 4: Disabled text
120        // 7: Shortcut text
121        // 8: Shadow
122        let button_attr = if is_disabled {
123            self.map_color(BUTTON_DISABLED) // Disabled
124        } else if is_focused {
125            self.map_color(BUTTON_SELECTED) // Selected/focused
126        } else if self.is_default {
127            self.map_color(BUTTON_DEFAULT) // Default but not focused
128        } else {
129            self.map_color(BUTTON_NORMAL) // Normal
130        };
131
132        // Shadow attribute - Borland uses spaces where BG is visible, we use blocks where FG is visible
133        // So we swap FG/BG: 0x70 (Black on LightGray) becomes 0x07 (LightGray on Black)
134        let mut shadow_attr = self.map_color(BUTTON_SHADOW);
135
136        // If shadow mapping failed, use a default shadow color.
137        // With the QCell palette chain, this should not normally trigger.
138        if shadow_attr.to_u8() == 0xCF {  // ERROR_ATTR
139            use crate::core::palette::Attr;
140            // Default: White on Black (standard shadow)
141            shadow_attr = Attr::from_u8(0x07);
142        }
143
144        let shadow_attr = shadow_attr.swap();
145
146        // Shortcut attributes
147        let shortcut_attr = if is_disabled {
148            self.map_color(BUTTON_DISABLED) // Disabled shortcut same as disabled text
149        } else {
150            self.map_color(BUTTON_SHORTCUT) // Shortcut color
151        };
152
153        // Draw all lines except the last (which is the bottom shadow)
154        for y in 0..(height - 1) {
155            let mut buf = DrawBuffer::new(width);
156
157            // Fill entire line with button color
158            buf.move_char(0, ' ', button_attr, width);
159
160            // Right edge gets shadow character and attribute (last column)
161            let shadow_char = if y == 0 { SHADOW_TOP } else { SHADOW_SOLID };
162            buf.put_char(width - 1, shadow_char, shadow_attr);
163
164            // Draw the label on the middle line
165            if y == (height - 1) / 2 {
166                // Calculate display length without tildes
167                let display_len = self.title.chars().filter(|&c| c != '~').count();
168                let content_width = width - 1; // Exclude right shadow column
169                let start = (content_width.saturating_sub(display_len)) / 2;
170                buf.move_str_with_shortcut(start, &self.title, button_attr, shortcut_attr);
171            }
172
173            write_line_to_terminal(terminal, self.bounds.a.x, self.bounds.a.y + y as i16, &buf);
174        }
175
176        // Draw bottom shadow line (1 char shorter, offset 1 to the right)
177        let mut bottom_buf = DrawBuffer::new(width - 1);
178        // Bottom shadow character across width-1
179        bottom_buf.move_char(0, SHADOW_BOTTOM, shadow_attr, width - 1);
180        write_line_to_terminal(
181            terminal,
182            self.bounds.a.x + 1,
183            self.bounds.a.y + (height - 1) as i16,
184            &bottom_buf,
185        );
186    }
187
188    fn handle_event(&mut self, event: &mut Event) {
189        // Handle broadcasts FIRST, even if button is disabled
190        //
191        // IMPORTANT: This matches Borland's TButton::handleEvent() behavior:
192        // - tbutton.cc:196 calls TView::handleEvent() first
193        // - TView::handleEvent() (tview.cc:486) only checks sfDisabled for evMouseDown, NOT broadcasts
194        // - tbutton.cc:235-263 processes evBroadcast in switch statement
195        // - tbutton.cc:255-262 handles cmCommandSetChanged regardless of disabled state
196        //
197        // This is critical: disabled buttons MUST receive CM_COMMAND_SET_CHANGED broadcasts
198        // so they can become enabled when their command becomes enabled in the global command set.
199        if event.what == EventType::Broadcast {
200            use crate::core::command::CM_COMMAND_SET_CHANGED;
201            use crate::core::command_set;
202
203            if event.command == CM_COMMAND_SET_CHANGED {
204                // Query global command set (thread-local static, like Borland)
205                let should_be_enabled = command_set::command_enabled(self.command);
206                let is_currently_disabled = self.is_disabled();
207
208                // Update disabled state if it changed
209                // Matches Borland: tbutton.cc:256-260
210                if should_be_enabled && is_currently_disabled {
211                    // Command was disabled, now enabled
212                    self.set_disabled(false);
213                } else if !should_be_enabled && !is_currently_disabled {
214                    // Command was enabled, now disabled
215                    self.set_disabled(true);
216                }
217
218                // Event is not cleared - other views may need it
219                // Matches Borland: broadcasts are not cleared in the button handler
220            }
221            return; // Broadcasts don't fall through to regular event handling
222        }
223
224        // Disabled buttons don't handle any other events (mouse, keyboard)
225        // Matches Borland: TView::handleEvent() checks sfDisabled for evMouseDown (tview.cc:486)
226        // and TButton's switch cases for evMouseDown/evKeyDown won't execute if disabled
227        if self.is_disabled() {
228            return;
229        }
230
231        match event.what {
232            EventType::Keyboard => {
233                // Handle hotkey (works even without focus, matches Borland PostProcess)
234                // Check if the key pressed matches this button's hotkey
235                if let Some(hotkey) = self.get_hotkey() {
236                    // Get the character from the key code (low byte)
237                    let key_char = (event.key_code & 0xFF) as u8 as char;
238                    let key_char_upper = key_char.to_uppercase().next().unwrap_or(key_char);
239
240                    if key_char_upper == hotkey {
241                        // Hotkey matched! Activate button
242                        if self.is_broadcast {
243                            *event = Event::broadcast(self.command);
244                        } else {
245                            *event = Event::command(self.command);
246                        }
247                        return;
248                    }
249                }
250
251                // Handle Enter/Space only if focused
252                if !self.is_focused() {
253                    return;
254                }
255                if event.key_code == KB_ENTER || event.key_code == ' ' as u16 {
256                    if self.is_broadcast {
257                        *event = Event::broadcast(self.command);
258                    } else {
259                        *event = Event::command(self.command);
260                    }
261                }
262            }
263            EventType::MouseDown => {
264                // Check if click is within button bounds
265                let mouse_pos = event.mouse.pos;
266                if event.mouse.buttons & MB_LEFT_BUTTON != 0
267                    && mouse_pos.x >= self.bounds.a.x
268                    && mouse_pos.x < self.bounds.b.x
269                    && mouse_pos.y >= self.bounds.a.y
270                    && mouse_pos.y < self.bounds.b.y - 1
271                // Exclude shadow line
272                {
273                    // Button clicked - generate command or broadcast
274                    if self.is_broadcast {
275                        *event = Event::broadcast(self.command);
276                    } else {
277                        *event = Event::command(self.command);
278                    }
279                }
280            }
281            _ => {}
282        }
283    }
284
285    fn can_focus(&self) -> bool {
286        !self.is_disabled()
287    }
288
289    // set_focus() now uses default implementation from View trait
290    // which sets/clears SF_FOCUSED flag
291
292    fn state(&self) -> StateFlags {
293        self.state
294    }
295
296    fn set_state(&mut self, state: StateFlags) {
297        self.state = state;
298    }
299
300    fn options(&self) -> u16 {
301        self.options
302    }
303
304    fn set_options(&mut self, options: u16) {
305        self.options = options;
306    }
307
308    fn is_default_button(&self) -> bool {
309        self.is_default
310    }
311
312    fn button_command(&self) -> Option<u16> {
313        Some(self.command)
314    }
315
316    fn set_palette_chain(&mut self, node: Option<crate::core::palette_chain::PaletteChainNode>) {
317        self.palette_chain = node;
318    }
319
320    fn get_palette_chain(&self) -> Option<&crate::core::palette_chain::PaletteChainNode> {
321        self.palette_chain.as_ref()
322    }
323
324    fn get_palette(&self) -> Option<crate::core::palette::Palette> {
325        use crate::core::palette::{palettes, Palette};
326        Some(Palette::from_slice(palettes::CP_BUTTON))
327    }
328}
329
330/// Builder for creating buttons with a fluent API.
331///
332/// # Examples
333///
334/// ```
335/// use turbo_vision::views::button::ButtonBuilder;
336/// use turbo_vision::core::geometry::Rect;
337/// use turbo_vision::core::command::CM_OK;
338///
339/// let button = ButtonBuilder::new()
340///     .bounds(Rect::new(10, 5, 20, 7))
341///     .title("OK")
342///     .command(CM_OK)
343///     .default(true)
344///     .build();
345/// ```
346pub struct ButtonBuilder {
347    bounds: Option<Rect>,
348    title: Option<String>,
349    command: Option<CommandId>,
350    is_default: bool,
351}
352
353impl ButtonBuilder {
354    /// Creates a new ButtonBuilder with default values.
355    pub fn new() -> Self {
356        Self {
357            bounds: None,
358            title: None,
359            command: None,
360            is_default: false,
361        }
362    }
363
364    /// Sets the button bounds (required).
365    #[must_use]
366    pub fn bounds(mut self, bounds: Rect) -> Self {
367        self.bounds = Some(bounds);
368        self
369    }
370
371    /// Sets the button title text (required).
372    #[must_use]
373    pub fn title(mut self, title: impl Into<String>) -> Self {
374        self.title = Some(title.into());
375        self
376    }
377
378    /// Sets the command ID to dispatch when clicked (required).
379    #[must_use]
380    pub fn command(mut self, command: CommandId) -> Self {
381        self.command = Some(command);
382        self
383    }
384
385    /// Sets whether this is the default button (optional, defaults to false).
386    ///
387    /// The default button is highlighted differently and can be activated
388    /// by pressing Enter even when not focused.
389    #[must_use]
390    pub fn default(mut self, is_default: bool) -> Self {
391        self.is_default = is_default;
392        self
393    }
394
395    /// Builds the Button.
396    ///
397    /// # Panics
398    ///
399    /// Panics if required fields (bounds, title, command) are not set.
400    pub fn build(self) -> Button {
401        let bounds = self.bounds.expect("Button bounds must be set");
402        let title = self.title.expect("Button title must be set");
403        let command = self.command.expect("Button command must be set");
404
405        Button::new(bounds, &title, command, self.is_default)
406    }
407}
408
409impl Default for ButtonBuilder {
410    fn default() -> Self {
411        Self::new()
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::core::command::CM_COMMAND_SET_CHANGED;
419    use crate::core::command_set;
420    use crate::core::geometry::Point;
421
422    #[test]
423    fn test_button_creation_with_disabled_command() {
424        // Test that button is created disabled when command is disabled
425        const TEST_CMD: u16 = 500;
426        command_set::disable_command(TEST_CMD);
427
428        let button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);
429
430        assert!(
431            button.is_disabled(),
432            "Button should start disabled when command is disabled"
433        );
434    }
435
436    #[test]
437    fn test_button_creation_with_enabled_command() {
438        // Test that button is created enabled when command is enabled
439        const TEST_CMD: u16 = 501;
440        command_set::enable_command(TEST_CMD);
441
442        let button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);
443
444        assert!(
445            !button.is_disabled(),
446            "Button should start enabled when command is enabled"
447        );
448    }
449
450    #[test]
451    fn test_disabled_button_receives_broadcast_and_becomes_enabled() {
452        // REGRESSION TEST: Disabled buttons must receive broadcasts to become enabled
453        // This tests the fix for the bug where disabled buttons returned early
454        // and never received CM_COMMAND_SET_CHANGED broadcasts
455
456        const TEST_CMD: u16 = 502;
457
458        // Start with command disabled
459        command_set::disable_command(TEST_CMD);
460
461        let mut button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);
462
463        // Verify button starts disabled
464        assert!(button.is_disabled(), "Button should start disabled");
465
466        // Enable the command in the global command set
467        command_set::enable_command(TEST_CMD);
468
469        // Send broadcast to button
470        let mut event = Event::broadcast(CM_COMMAND_SET_CHANGED);
471        button.handle_event(&mut event);
472
473        // Verify button is now enabled
474        assert!(
475            !button.is_disabled(),
476            "Button should be enabled after receiving broadcast"
477        );
478    }
479
480    #[test]
481    fn test_enabled_button_receives_broadcast_and_becomes_disabled() {
482        // Test that enabled buttons can be disabled via broadcast
483
484        const TEST_CMD: u16 = 503;
485
486        // Start with command enabled
487        command_set::enable_command(TEST_CMD);
488
489        let mut button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);
490
491        // Verify button starts enabled
492        assert!(!button.is_disabled(), "Button should start enabled");
493
494        // Disable the command in the global command set
495        command_set::disable_command(TEST_CMD);
496
497        // Send broadcast to button
498        let mut event = Event::broadcast(CM_COMMAND_SET_CHANGED);
499        button.handle_event(&mut event);
500
501        // Verify button is now disabled
502        assert!(
503            button.is_disabled(),
504            "Button should be disabled after receiving broadcast"
505        );
506    }
507
508    #[test]
509    fn test_disabled_button_ignores_keyboard_events() {
510        // Test that disabled buttons don't respond to keyboard input
511
512        const TEST_CMD: u16 = 504;
513        command_set::disable_command(TEST_CMD);
514
515        let mut button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);
516
517        button.set_focus(true);
518
519        // Try to activate with Enter key
520        let mut event = Event::keyboard(crate::core::event::KB_ENTER);
521        button.handle_event(&mut event);
522
523        // Event should not be converted to command
524        assert_ne!(
525            event.what,
526            EventType::Command,
527            "Disabled button should not generate command"
528        );
529    }
530
531    #[test]
532    fn test_disabled_button_ignores_mouse_clicks() {
533        // Test that disabled buttons don't respond to mouse clicks
534
535        const TEST_CMD: u16 = 505;
536        command_set::disable_command(TEST_CMD);
537
538        let mut button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);
539
540        // Try to click the button
541        let mut event = Event::mouse(
542            EventType::MouseDown,
543            Point::new(5, 1),
544            crate::core::event::MB_LEFT_BUTTON,
545            false,
546        );
547        button.handle_event(&mut event);
548
549        // Event should not be converted to command
550        assert_ne!(
551            event.what,
552            EventType::Command,
553            "Disabled button should not generate command"
554        );
555    }
556
557    #[test]
558    fn test_broadcast_does_not_clear_event() {
559        // Test that CM_COMMAND_SET_CHANGED broadcast is not cleared
560        // (so it can propagate to other buttons)
561
562        const TEST_CMD: u16 = 506;
563        command_set::disable_command(TEST_CMD);
564
565        let mut button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);
566
567        command_set::enable_command(TEST_CMD);
568
569        let mut event = Event::broadcast(CM_COMMAND_SET_CHANGED);
570        button.handle_event(&mut event);
571
572        // Event should still be a broadcast (not cleared)
573        assert_eq!(
574            event.what,
575            EventType::Broadcast,
576            "Broadcast should not be cleared"
577        );
578        assert_eq!(
579            event.command, CM_COMMAND_SET_CHANGED,
580            "Broadcast command should remain"
581        );
582    }
583
584    #[test]
585    fn test_button_builder() {
586        const TEST_CMD: u16 = 507;
587        command_set::enable_command(TEST_CMD);
588
589        let button = ButtonBuilder::new()
590            .bounds(Rect::new(5, 10, 15, 12))
591            .title("Test")
592            .command(TEST_CMD)
593            .default(true)
594            .build();
595
596        assert_eq!(button.bounds(), Rect::new(5, 10, 15, 12));
597        assert_eq!(button.is_default_button(), true);
598        assert_eq!(button.button_command(), Some(TEST_CMD));
599    }
600
601    #[test]
602    fn test_button_builder_default_is_false() {
603        const TEST_CMD: u16 = 508;
604        command_set::enable_command(TEST_CMD);
605
606        let button = ButtonBuilder::new()
607            .bounds(Rect::new(0, 0, 10, 2))
608            .title("Test")
609            .command(TEST_CMD)
610            .build();
611
612        assert_eq!(button.is_default_button(), false);
613    }
614
615    #[test]
616    #[should_panic(expected = "Button bounds must be set")]
617    fn test_button_builder_panics_without_bounds() {
618        const TEST_CMD: u16 = 509;
619        ButtonBuilder::new().title("Test").command(TEST_CMD).build();
620    }
621
622    #[test]
623    #[should_panic(expected = "Button title must be set")]
624    fn test_button_builder_panics_without_title() {
625        const TEST_CMD: u16 = 510;
626        ButtonBuilder::new()
627            .bounds(Rect::new(0, 0, 10, 2))
628            .command(TEST_CMD)
629            .build();
630    }
631
632    #[test]
633    #[should_panic(expected = "Button command must be set")]
634    fn test_button_builder_panics_without_command() {
635        ButtonBuilder::new()
636            .bounds(Rect::new(0, 0, 10, 2))
637            .title("Test")
638            .build();
639    }
640
641    #[test]
642    fn test_button_with_small_dimensions_doesnt_panic() {
643        // REGRESSION TEST: Buttons with small/negative dimensions should not panic
644        // This tests the fix for issue #53 where shrinking windows caused panics
645        //
646        // We can't actually call draw() in unit tests (no TTY), but we can verify
647        // that the dimension clamping logic works correctly.
648
649        const TEST_CMD: u16 = 511;
650
651        // Test various small dimensions - should not panic on creation
652        let test_cases = vec![
653            Rect::new(0, 0, 0, 0),   // Zero dimensions
654            Rect::new(0, 0, 1, 1),   // Too small (min is 4x2)
655            Rect::new(0, 0, 2, 1),   // Width too small
656            Rect::new(0, 0, 3, 1),   // Width too small
657            Rect::new(0, 0, 4, 1),   // Height too small
658            Rect::new(0, 0, 1, 2),   // Width too small
659            Rect::new(0, 0, 2, 2),   // Width too small
660            Rect::new(0, 0, 3, 2),   // Width too small
661            Rect::new(10, 5, 5, 2),  // Negative width (inverted)
662            Rect::new(5, 10, 2, 5),  // Negative height (inverted)
663        ];
664
665        for rect in test_cases {
666            // Should not panic on creation or bounds queries
667            let button = Button::new(rect, "Test", TEST_CMD, false);
668            let bounds = button.bounds();
669
670            // Verify clamping works
671            assert!(bounds.width_clamped() >= 0);
672            assert!(bounds.height_clamped() >= 0);
673        }
674    }
675}