turbo-vision 1.0.0

A Rust implementation of the classic Borland Turbo Vision text-mode UI framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
// (C) 2025 - Enzo Lombardi

//! Button view - clickable button with keyboard shortcuts and command dispatch.

use super::view::{write_line_to_terminal, View};
use crate::core::command::CommandId;
use crate::core::draw::DrawBuffer;
use crate::core::event::{Event, EventType, KB_ENTER, MB_LEFT_BUTTON};
use crate::core::geometry::Rect;
use crate::core::palette::{
    BUTTON_DEFAULT, BUTTON_DISABLED, BUTTON_NORMAL, BUTTON_SELECTED, BUTTON_SHADOW, BUTTON_SHORTCUT,
};
use crate::core::state::{StateFlags, SF_DISABLED, SHADOW_BOTTOM, SHADOW_SOLID, SHADOW_TOP};
use crate::terminal::Terminal;

pub struct Button {
    bounds: Rect,
    title: String,
    command: CommandId,
    is_default: bool,
    is_broadcast: bool,
    state: StateFlags,
    options: u16,
    owner: Option<*const dyn View>,
    owner_type: super::view::OwnerType,
}

impl Button {
    pub fn new(bounds: Rect, title: &str, command: CommandId, is_default: bool) -> Self {
        use crate::core::command_set;
        use crate::core::state::OF_POST_PROCESS;

        // Check if command is initially enabled
        // Matches Borland: TButton constructor checks commandEnabled() (tbutton.cc:55-56)
        let mut state = 0;
        if !command_set::command_enabled(command) {
            state |= SF_DISABLED;
        }

        Self {
            bounds,
            title: title.to_string(),
            command,
            is_default,
            is_broadcast: false,
            state,
            options: OF_POST_PROCESS, // Buttons process in post-process phase
            owner: None,
            owner_type: super::view::OwnerType::Dialog, // Buttons default to Dialog context
        }
    }

    pub fn set_disabled(&mut self, disabled: bool) {
        self.set_state_flag(SF_DISABLED, disabled);
    }

    pub fn is_disabled(&self) -> bool {
        self.get_state_flag(SF_DISABLED)
    }

    /// Set whether this button broadcasts its command instead of sending it as a command event
    /// Matches Borland: bfBroadcast flag
    pub fn set_broadcast(&mut self, broadcast: bool) {
        self.is_broadcast = broadcast;
    }

    /// Set whether this button is selectable (can receive focus)
    /// Matches Borland: ofSelectable flag
    pub fn set_selectable(&mut self, selectable: bool) {
        use crate::core::state::OF_SELECTABLE;
        if selectable {
            self.options |= OF_SELECTABLE;
        } else {
            self.options &= !OF_SELECTABLE;
        }
    }

    /// Extract the hotkey character from the button title
    /// Returns the uppercase character following the first '~', or None if no hotkey
    fn get_hotkey(&self) -> Option<char> {
        let mut chars = self.title.chars();
        while let Some(ch) = chars.next() {
            if ch == '~' {
                // Next character is the hotkey
                if let Some(hotkey) = chars.next() {
                    return Some(hotkey.to_uppercase().next().unwrap_or(hotkey));
                }
            }
        }
        None
    }
}

impl View for Button {
    fn bounds(&self) -> Rect {
        self.bounds
    }

    fn set_bounds(&mut self, bounds: Rect) {
        self.bounds = bounds;
    }

    fn draw(&mut self, terminal: &mut Terminal) {
        let width = self.bounds.width_clamped() as usize;
        let height = self.bounds.height_clamped() as usize;

        // Don't render buttons that are too small
        // Minimum width: 4 (at least 2 chars for content + 1 for right shadow + 1 for spacing)
        // Minimum height: 2 (at least 1 line for content + 1 for bottom shadow)
        if width < 4 || height < 2 {
            return;
        }

        let is_disabled = self.is_disabled();
        let is_focused = self.is_focused();

        // Button color indices (from CP_BUTTON palette):
        // 1: Normal text
        // 2: Default text
        // 3: Selected (focused) text
        // 4: Disabled text
        // 7: Shortcut text
        // 8: Shadow
        let button_attr = if is_disabled {
            self.map_color(BUTTON_DISABLED) // Disabled
        } else if is_focused {
            self.map_color(BUTTON_SELECTED) // Selected/focused
        } else if self.is_default {
            self.map_color(BUTTON_DEFAULT) // Default but not focused
        } else {
            self.map_color(BUTTON_NORMAL) // Normal
        };

        // Shadow attribute - Borland uses spaces where BG is visible, we use blocks where FG is visible
        // So we swap FG/BG: 0x70 (Black on LightGray) becomes 0x07 (LightGray on Black)
        let mut shadow_attr = self.map_color(BUTTON_SHADOW);

        // If shadow mapping failed (button in wrong owner context like Window instead of Dialog),
        // query the window background directly from the app palette based on owner type
        if shadow_attr.to_u8() == 0xCF {  // ERROR_ATTR
            use crate::core::palette::{palettes, Attr, Palette};

            // Get background color by directly querying the appropriate palette
            // All window/dialog types use index 1 for background
            let app_palette_data = palettes::get_app_palette();
            let app_palette = Palette::from_slice(&app_palette_data);

            let bg_app_index = match self.owner_type {
                super::view::OwnerType::Window => {
                    // Blue Window: palette[1] = 8 → app[8] = 0x17 (White on Blue)
                    let window_palette = Palette::from_slice(palettes::CP_BLUE_WINDOW);
                    window_palette.get(1)
                }
                super::view::OwnerType::Dialog => {
                    // Dialog: palette[1] = 32 → app[32] = 0x70 (Black on LightGray)
                    let dialog_palette = Palette::from_slice(palettes::CP_GRAY_DIALOG);
                    dialog_palette.get(1)
                }
                super::view::OwnerType::None => {
                    8  // Default to Blue Window background
                }
            };

            let bg_color = app_palette.get(bg_app_index as usize);
            let bg_attr = Attr::from_u8(bg_color);

            // Shadow is White on owner's background color
            shadow_attr = Attr::new(crate::core::palette::TvColor::White, bg_attr.bg);
        }

        let shadow_attr = shadow_attr.swap();

        // Shortcut attributes
        let shortcut_attr = if is_disabled {
            self.map_color(BUTTON_DISABLED) // Disabled shortcut same as disabled text
        } else {
            self.map_color(BUTTON_SHORTCUT) // Shortcut color
        };

        // Draw all lines except the last (which is the bottom shadow)
        for y in 0..(height - 1) {
            let mut buf = DrawBuffer::new(width);

            // Fill entire line with button color
            buf.move_char(0, ' ', button_attr, width);

            // Right edge gets shadow character and attribute (last column)
            let shadow_char = if y == 0 { SHADOW_TOP } else { SHADOW_SOLID };
            buf.put_char(width - 1, shadow_char, shadow_attr);

            // Draw the label on the middle line
            if y == (height - 1) / 2 {
                // Calculate display length without tildes
                let display_len = self.title.chars().filter(|&c| c != '~').count();
                let content_width = width - 1; // Exclude right shadow column
                let start = (content_width.saturating_sub(display_len)) / 2;
                buf.move_str_with_shortcut(start, &self.title, button_attr, shortcut_attr);
            }

            write_line_to_terminal(terminal, self.bounds.a.x, self.bounds.a.y + y as i16, &buf);
        }

        // Draw bottom shadow line (1 char shorter, offset 1 to the right)
        let mut bottom_buf = DrawBuffer::new(width - 1);
        // Bottom shadow character across width-1
        bottom_buf.move_char(0, SHADOW_BOTTOM, shadow_attr, width - 1);
        write_line_to_terminal(
            terminal,
            self.bounds.a.x + 1,
            self.bounds.a.y + (height - 1) as i16,
            &bottom_buf,
        );
    }

    fn handle_event(&mut self, event: &mut Event) {
        // Handle broadcasts FIRST, even if button is disabled
        //
        // IMPORTANT: This matches Borland's TButton::handleEvent() behavior:
        // - tbutton.cc:196 calls TView::handleEvent() first
        // - TView::handleEvent() (tview.cc:486) only checks sfDisabled for evMouseDown, NOT broadcasts
        // - tbutton.cc:235-263 processes evBroadcast in switch statement
        // - tbutton.cc:255-262 handles cmCommandSetChanged regardless of disabled state
        //
        // This is critical: disabled buttons MUST receive CM_COMMAND_SET_CHANGED broadcasts
        // so they can become enabled when their command becomes enabled in the global command set.
        if event.what == EventType::Broadcast {
            use crate::core::command::CM_COMMAND_SET_CHANGED;
            use crate::core::command_set;

            if event.command == CM_COMMAND_SET_CHANGED {
                // Query global command set (thread-local static, like Borland)
                let should_be_enabled = command_set::command_enabled(self.command);
                let is_currently_disabled = self.is_disabled();

                // Update disabled state if it changed
                // Matches Borland: tbutton.cc:256-260
                if should_be_enabled && is_currently_disabled {
                    // Command was disabled, now enabled
                    self.set_disabled(false);
                } else if !should_be_enabled && !is_currently_disabled {
                    // Command was enabled, now disabled
                    self.set_disabled(true);
                }

                // Event is not cleared - other views may need it
                // Matches Borland: broadcasts are not cleared in the button handler
            }
            return; // Broadcasts don't fall through to regular event handling
        }

        // Disabled buttons don't handle any other events (mouse, keyboard)
        // Matches Borland: TView::handleEvent() checks sfDisabled for evMouseDown (tview.cc:486)
        // and TButton's switch cases for evMouseDown/evKeyDown won't execute if disabled
        if self.is_disabled() {
            return;
        }

        match event.what {
            EventType::Keyboard => {
                // Handle hotkey (works even without focus, matches Borland PostProcess)
                // Check if the key pressed matches this button's hotkey
                if let Some(hotkey) = self.get_hotkey() {
                    // Get the character from the key code (low byte)
                    let key_char = (event.key_code & 0xFF) as u8 as char;
                    let key_char_upper = key_char.to_uppercase().next().unwrap_or(key_char);

                    if key_char_upper == hotkey {
                        // Hotkey matched! Activate button
                        if self.is_broadcast {
                            *event = Event::broadcast(self.command);
                        } else {
                            *event = Event::command(self.command);
                        }
                        return;
                    }
                }

                // Handle Enter/Space only if focused
                if !self.is_focused() {
                    return;
                }
                if event.key_code == KB_ENTER || event.key_code == ' ' as u16 {
                    if self.is_broadcast {
                        *event = Event::broadcast(self.command);
                    } else {
                        *event = Event::command(self.command);
                    }
                }
            }
            EventType::MouseDown => {
                // Check if click is within button bounds
                let mouse_pos = event.mouse.pos;
                if event.mouse.buttons & MB_LEFT_BUTTON != 0
                    && mouse_pos.x >= self.bounds.a.x
                    && mouse_pos.x < self.bounds.b.x
                    && mouse_pos.y >= self.bounds.a.y
                    && mouse_pos.y < self.bounds.b.y - 1
                // Exclude shadow line
                {
                    // Button clicked - generate command or broadcast
                    if self.is_broadcast {
                        *event = Event::broadcast(self.command);
                    } else {
                        *event = Event::command(self.command);
                    }
                }
            }
            _ => {}
        }
    }

    fn can_focus(&self) -> bool {
        !self.is_disabled()
    }

    // set_focus() now uses default implementation from View trait
    // which sets/clears SF_FOCUSED flag

    fn state(&self) -> StateFlags {
        self.state
    }

    fn set_state(&mut self, state: StateFlags) {
        self.state = state;
    }

    fn options(&self) -> u16 {
        self.options
    }

    fn set_options(&mut self, options: u16) {
        self.options = options;
    }

    fn is_default_button(&self) -> bool {
        self.is_default
    }

    fn button_command(&self) -> Option<u16> {
        Some(self.command)
    }

    fn set_owner(&mut self, owner: *const dyn View) {
        self.owner = Some(owner);
    }

    fn get_owner(&self) -> Option<*const dyn View> {
        self.owner
    }

    fn get_owner_type(&self) -> super::view::OwnerType {
        self.owner_type
    }

    fn set_owner_type(&mut self, owner_type: super::view::OwnerType) {
        self.owner_type = owner_type;
    }

    fn get_palette(&self) -> Option<crate::core::palette::Palette> {
        use crate::core::palette::{palettes, Palette};
        Some(Palette::from_slice(palettes::CP_BUTTON))
    }
}

/// Builder for creating buttons with a fluent API.
///
/// # Examples
///
/// ```
/// use turbo_vision::views::button::ButtonBuilder;
/// use turbo_vision::core::geometry::Rect;
/// use turbo_vision::core::command::CM_OK;
///
/// let button = ButtonBuilder::new()
///     .bounds(Rect::new(10, 5, 20, 7))
///     .title("OK")
///     .command(CM_OK)
///     .default(true)
///     .build();
/// ```
pub struct ButtonBuilder {
    bounds: Option<Rect>,
    title: Option<String>,
    command: Option<CommandId>,
    is_default: bool,
}

impl ButtonBuilder {
    /// Creates a new ButtonBuilder with default values.
    pub fn new() -> Self {
        Self {
            bounds: None,
            title: None,
            command: None,
            is_default: false,
        }
    }

    /// Sets the button bounds (required).
    #[must_use]
    pub fn bounds(mut self, bounds: Rect) -> Self {
        self.bounds = Some(bounds);
        self
    }

    /// Sets the button title text (required).
    #[must_use]
    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.title = Some(title.into());
        self
    }

    /// Sets the command ID to dispatch when clicked (required).
    #[must_use]
    pub fn command(mut self, command: CommandId) -> Self {
        self.command = Some(command);
        self
    }

    /// Sets whether this is the default button (optional, defaults to false).
    ///
    /// The default button is highlighted differently and can be activated
    /// by pressing Enter even when not focused.
    #[must_use]
    pub fn default(mut self, is_default: bool) -> Self {
        self.is_default = is_default;
        self
    }

    /// Builds the Button.
    ///
    /// # Panics
    ///
    /// Panics if required fields (bounds, title, command) are not set.
    pub fn build(self) -> Button {
        let bounds = self.bounds.expect("Button bounds must be set");
        let title = self.title.expect("Button title must be set");
        let command = self.command.expect("Button command must be set");

        Button::new(bounds, &title, command, self.is_default)
    }
}

impl Default for ButtonBuilder {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::command::CM_COMMAND_SET_CHANGED;
    use crate::core::command_set;
    use crate::core::geometry::Point;

    #[test]
    fn test_button_creation_with_disabled_command() {
        // Test that button is created disabled when command is disabled
        const TEST_CMD: u16 = 500;
        command_set::disable_command(TEST_CMD);

        let button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);

        assert!(
            button.is_disabled(),
            "Button should start disabled when command is disabled"
        );
    }

    #[test]
    fn test_button_creation_with_enabled_command() {
        // Test that button is created enabled when command is enabled
        const TEST_CMD: u16 = 501;
        command_set::enable_command(TEST_CMD);

        let button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);

        assert!(
            !button.is_disabled(),
            "Button should start enabled when command is enabled"
        );
    }

    #[test]
    fn test_disabled_button_receives_broadcast_and_becomes_enabled() {
        // REGRESSION TEST: Disabled buttons must receive broadcasts to become enabled
        // This tests the fix for the bug where disabled buttons returned early
        // and never received CM_COMMAND_SET_CHANGED broadcasts

        const TEST_CMD: u16 = 502;

        // Start with command disabled
        command_set::disable_command(TEST_CMD);

        let mut button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);

        // Verify button starts disabled
        assert!(button.is_disabled(), "Button should start disabled");

        // Enable the command in the global command set
        command_set::enable_command(TEST_CMD);

        // Send broadcast to button
        let mut event = Event::broadcast(CM_COMMAND_SET_CHANGED);
        button.handle_event(&mut event);

        // Verify button is now enabled
        assert!(
            !button.is_disabled(),
            "Button should be enabled after receiving broadcast"
        );
    }

    #[test]
    fn test_enabled_button_receives_broadcast_and_becomes_disabled() {
        // Test that enabled buttons can be disabled via broadcast

        const TEST_CMD: u16 = 503;

        // Start with command enabled
        command_set::enable_command(TEST_CMD);

        let mut button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);

        // Verify button starts enabled
        assert!(!button.is_disabled(), "Button should start enabled");

        // Disable the command in the global command set
        command_set::disable_command(TEST_CMD);

        // Send broadcast to button
        let mut event = Event::broadcast(CM_COMMAND_SET_CHANGED);
        button.handle_event(&mut event);

        // Verify button is now disabled
        assert!(
            button.is_disabled(),
            "Button should be disabled after receiving broadcast"
        );
    }

    #[test]
    fn test_disabled_button_ignores_keyboard_events() {
        // Test that disabled buttons don't respond to keyboard input

        const TEST_CMD: u16 = 504;
        command_set::disable_command(TEST_CMD);

        let mut button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);

        button.set_focus(true);

        // Try to activate with Enter key
        let mut event = Event::keyboard(crate::core::event::KB_ENTER);
        button.handle_event(&mut event);

        // Event should not be converted to command
        assert_ne!(
            event.what,
            EventType::Command,
            "Disabled button should not generate command"
        );
    }

    #[test]
    fn test_disabled_button_ignores_mouse_clicks() {
        // Test that disabled buttons don't respond to mouse clicks

        const TEST_CMD: u16 = 505;
        command_set::disable_command(TEST_CMD);

        let mut button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);

        // Try to click the button
        let mut event = Event::mouse(
            EventType::MouseDown,
            Point::new(5, 1),
            crate::core::event::MB_LEFT_BUTTON,
            false,
        );
        button.handle_event(&mut event);

        // Event should not be converted to command
        assert_ne!(
            event.what,
            EventType::Command,
            "Disabled button should not generate command"
        );
    }

    #[test]
    fn test_broadcast_does_not_clear_event() {
        // Test that CM_COMMAND_SET_CHANGED broadcast is not cleared
        // (so it can propagate to other buttons)

        const TEST_CMD: u16 = 506;
        command_set::disable_command(TEST_CMD);

        let mut button = Button::new(Rect::new(0, 0, 10, 2), "Test", TEST_CMD, false);

        command_set::enable_command(TEST_CMD);

        let mut event = Event::broadcast(CM_COMMAND_SET_CHANGED);
        button.handle_event(&mut event);

        // Event should still be a broadcast (not cleared)
        assert_eq!(
            event.what,
            EventType::Broadcast,
            "Broadcast should not be cleared"
        );
        assert_eq!(
            event.command, CM_COMMAND_SET_CHANGED,
            "Broadcast command should remain"
        );
    }

    #[test]
    fn test_button_builder() {
        const TEST_CMD: u16 = 507;
        command_set::enable_command(TEST_CMD);

        let button = ButtonBuilder::new()
            .bounds(Rect::new(5, 10, 15, 12))
            .title("Test")
            .command(TEST_CMD)
            .default(true)
            .build();

        assert_eq!(button.bounds(), Rect::new(5, 10, 15, 12));
        assert_eq!(button.is_default_button(), true);
        assert_eq!(button.button_command(), Some(TEST_CMD));
    }

    #[test]
    fn test_button_builder_default_is_false() {
        const TEST_CMD: u16 = 508;
        command_set::enable_command(TEST_CMD);

        let button = ButtonBuilder::new()
            .bounds(Rect::new(0, 0, 10, 2))
            .title("Test")
            .command(TEST_CMD)
            .build();

        assert_eq!(button.is_default_button(), false);
    }

    #[test]
    #[should_panic(expected = "Button bounds must be set")]
    fn test_button_builder_panics_without_bounds() {
        const TEST_CMD: u16 = 509;
        ButtonBuilder::new().title("Test").command(TEST_CMD).build();
    }

    #[test]
    #[should_panic(expected = "Button title must be set")]
    fn test_button_builder_panics_without_title() {
        const TEST_CMD: u16 = 510;
        ButtonBuilder::new()
            .bounds(Rect::new(0, 0, 10, 2))
            .command(TEST_CMD)
            .build();
    }

    #[test]
    #[should_panic(expected = "Button command must be set")]
    fn test_button_builder_panics_without_command() {
        ButtonBuilder::new()
            .bounds(Rect::new(0, 0, 10, 2))
            .title("Test")
            .build();
    }

    #[test]
    fn test_button_with_small_dimensions_doesnt_panic() {
        // REGRESSION TEST: Buttons with small/negative dimensions should not panic
        // This tests the fix for issue #53 where shrinking windows caused panics
        //
        // We can't actually call draw() in unit tests (no TTY), but we can verify
        // that the dimension clamping logic works correctly.

        const TEST_CMD: u16 = 511;

        // Test various small dimensions - should not panic on creation
        let test_cases = vec![
            Rect::new(0, 0, 0, 0),   // Zero dimensions
            Rect::new(0, 0, 1, 1),   // Too small (min is 4x2)
            Rect::new(0, 0, 2, 1),   // Width too small
            Rect::new(0, 0, 3, 1),   // Width too small
            Rect::new(0, 0, 4, 1),   // Height too small
            Rect::new(0, 0, 1, 2),   // Width too small
            Rect::new(0, 0, 2, 2),   // Width too small
            Rect::new(0, 0, 3, 2),   // Width too small
            Rect::new(10, 5, 5, 2),  // Negative width (inverted)
            Rect::new(5, 10, 2, 5),  // Negative height (inverted)
        ];

        for rect in test_cases {
            // Should not panic on creation or bounds queries
            let button = Button::new(rect, "Test", TEST_CMD, false);
            let bounds = button.bounds();

            // Verify clamping works
            assert!(bounds.width_clamped() >= 0);
            assert!(bounds.height_clamped() >= 0);
        }
    }
}