Skip to main content

ftui_widgets/modal/
dialog.rs

1#![forbid(unsafe_code)]
2
3//! Dialog presets built on the Modal container.
4//!
5//! Provides common dialog patterns:
6//! - Alert: Message with OK button
7//! - Confirm: Message with OK/Cancel
8//! - Prompt: Message with text input + OK/Cancel
9//! - Custom: Builder for custom dialogs
10//!
11//! # Example
12//!
13//! ```ignore
14//! let dialog = Dialog::alert("Operation complete", "File saved successfully.");
15//! let dialog = Dialog::confirm("Delete file?", "This action cannot be undone.");
16//! let dialog = Dialog::prompt("Enter name", "Please enter your username:");
17//! ```
18
19use crate::block::{Alignment, Block};
20use crate::borders::Borders;
21use crate::modal::{Modal, ModalConfig, ModalPosition, ModalSizeConstraints};
22use crate::{StatefulWidget, Widget, draw_text_span, set_style_area};
23use ftui_core::event::{
24    Event, KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
25};
26use ftui_core::geometry::Rect;
27use ftui_render::frame::{Frame, HitData, HitId, HitRegion};
28use ftui_style::{Style, StyleFlags};
29use ftui_text::display_width;
30
31/// Hit region for dialog buttons.
32pub const DIALOG_HIT_BUTTON: HitRegion = HitRegion::Custom(10);
33
34/// Result from a dialog interaction.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum DialogResult {
37    /// Dialog was dismissed without action.
38    Dismissed,
39    /// OK / primary button pressed.
40    Ok,
41    /// Cancel / secondary button pressed.
42    Cancel,
43    /// Custom button pressed with its ID.
44    Custom(String),
45    /// Prompt dialog submitted with input value.
46    Input(String),
47}
48
49/// A button in a dialog.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct DialogButton {
52    /// Display label.
53    pub label: String,
54    /// Unique identifier.
55    pub id: String,
56    /// Whether this is the primary/default button.
57    pub primary: bool,
58}
59
60impl DialogButton {
61    /// Create a new dialog button.
62    pub fn new(label: impl Into<String>, id: impl Into<String>) -> Self {
63        Self {
64            label: label.into(),
65            id: id.into(),
66            primary: false,
67        }
68    }
69
70    /// Mark as primary button.
71    pub fn primary(mut self) -> Self {
72        self.primary = true;
73        self
74    }
75
76    /// Display width including brackets.
77    pub fn display_width(&self) -> usize {
78        // [ label ] = display_width(label) + 4
79        display_width(self.label.as_str()) + 4
80    }
81}
82
83/// Dialog type variants.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum DialogKind {
86    /// Alert: single OK button.
87    Alert,
88    /// Confirm: OK + Cancel buttons.
89    Confirm,
90    /// Prompt: input field + OK + Cancel.
91    Prompt,
92    /// Custom dialog.
93    Custom,
94}
95
96/// Dialog state for handling input and button focus.
97#[derive(Debug, Clone, Default)]
98pub struct DialogState {
99    /// Currently focused button index.
100    pub focused_button: Option<usize>,
101    /// Input field value (for Prompt dialogs).
102    pub input_value: String,
103    /// Whether the input field is focused.
104    pub input_focused: bool,
105    /// Whether the dialog is open.
106    pub open: bool,
107    /// Result after interaction.
108    pub result: Option<DialogResult>,
109}
110
111impl DialogState {
112    /// Create a new open dialog state.
113    pub fn new() -> Self {
114        Self {
115            open: true,
116            input_focused: true, // Start with input focused for prompts
117            ..Default::default()
118        }
119    }
120
121    /// Check if dialog is open.
122    pub fn is_open(&self) -> bool {
123        self.open
124    }
125
126    /// Close the dialog with a result.
127    pub fn close(&mut self, result: DialogResult) {
128        self.open = false;
129        self.result = Some(result);
130    }
131
132    /// Reset the dialog state to open.
133    pub fn reset(&mut self) {
134        self.open = true;
135        self.result = None;
136        self.input_value.clear();
137        self.focused_button = None;
138        self.input_focused = true;
139    }
140
141    /// Get the result if closed.
142    pub fn take_result(&mut self) -> Option<DialogResult> {
143        self.result.take()
144    }
145}
146
147/// Dialog configuration.
148#[derive(Debug, Clone)]
149pub struct DialogConfig {
150    /// Modal configuration.
151    pub modal_config: ModalConfig,
152    /// Dialog kind.
153    pub kind: DialogKind,
154    /// Button style.
155    pub button_style: Style,
156    /// Primary button style.
157    pub primary_button_style: Style,
158    /// Focused button style.
159    pub focused_button_style: Style,
160    /// Title style.
161    pub title_style: Style,
162    /// Message style.
163    pub message_style: Style,
164    /// Input style (for Prompt).
165    pub input_style: Style,
166}
167
168impl Default for DialogConfig {
169    fn default() -> Self {
170        Self {
171            modal_config: ModalConfig::default()
172                .position(ModalPosition::Center)
173                .size(ModalSizeConstraints::new().min_width(30).max_width(60)),
174            kind: DialogKind::Alert,
175            button_style: Style::new(),
176            primary_button_style: Style::new().bold(),
177            focused_button_style: Style::new().reverse(),
178            title_style: Style::new().bold(),
179            message_style: Style::new(),
180            input_style: Style::new(),
181        }
182    }
183}
184
185/// A dialog widget built on Modal.
186///
187/// Invariants:
188/// - At least one button is always present.
189/// - Button focus wraps around (modular arithmetic).
190/// - For Prompt dialogs, Tab cycles: input -> buttons -> input.
191///
192/// Failure modes:
193/// - If area is too small, content may be truncated but dialog never panics.
194/// - Empty title/message is allowed (renders nothing for that row).
195#[derive(Debug, Clone)]
196pub struct Dialog {
197    /// Dialog title.
198    title: String,
199    /// Dialog message.
200    message: String,
201    /// Buttons.
202    buttons: Vec<DialogButton>,
203    /// Configuration.
204    config: DialogConfig,
205    /// Hit ID for mouse interaction.
206    hit_id: Option<HitId>,
207}
208
209impl Dialog {
210    /// Create an alert dialog (message + OK).
211    pub fn alert(title: impl Into<String>, message: impl Into<String>) -> Self {
212        Self {
213            title: title.into(),
214            message: message.into(),
215            buttons: vec![DialogButton::new("OK", "ok").primary()],
216            config: DialogConfig {
217                kind: DialogKind::Alert,
218                ..Default::default()
219            },
220            hit_id: None,
221        }
222    }
223
224    /// Create a confirm dialog (message + OK/Cancel).
225    pub fn confirm(title: impl Into<String>, message: impl Into<String>) -> Self {
226        Self {
227            title: title.into(),
228            message: message.into(),
229            buttons: vec![
230                DialogButton::new("OK", "ok").primary(),
231                DialogButton::new("Cancel", "cancel"),
232            ],
233            config: DialogConfig {
234                kind: DialogKind::Confirm,
235                ..Default::default()
236            },
237            hit_id: None,
238        }
239    }
240
241    /// Create a prompt dialog (message + input + OK/Cancel).
242    pub fn prompt(title: impl Into<String>, message: impl Into<String>) -> Self {
243        Self {
244            title: title.into(),
245            message: message.into(),
246            buttons: vec![
247                DialogButton::new("OK", "ok").primary(),
248                DialogButton::new("Cancel", "cancel"),
249            ],
250            config: DialogConfig {
251                kind: DialogKind::Prompt,
252                ..Default::default()
253            },
254            hit_id: None,
255        }
256    }
257
258    /// Create a custom dialog with a builder.
259    pub fn custom(title: impl Into<String>, message: impl Into<String>) -> DialogBuilder {
260        DialogBuilder {
261            title: title.into(),
262            message: message.into(),
263            buttons: Vec::new(),
264            config: DialogConfig {
265                kind: DialogKind::Custom,
266                ..Default::default()
267            },
268            hit_id: None,
269        }
270    }
271
272    /// Set the hit ID for mouse interaction.
273    pub fn hit_id(mut self, id: HitId) -> Self {
274        self.hit_id = Some(id);
275        self.config.modal_config = self.config.modal_config.hit_id(id);
276        self
277    }
278
279    /// Set the modal configuration.
280    pub fn modal_config(mut self, config: ModalConfig) -> Self {
281        self.config.modal_config = config;
282        self
283    }
284
285    /// Set button style.
286    pub fn button_style(mut self, style: Style) -> Self {
287        self.config.button_style = style;
288        self
289    }
290
291    /// Set primary button style.
292    pub fn primary_button_style(mut self, style: Style) -> Self {
293        self.config.primary_button_style = style;
294        self
295    }
296
297    /// Set focused button style.
298    pub fn focused_button_style(mut self, style: Style) -> Self {
299        self.config.focused_button_style = style;
300        self
301    }
302
303    /// Handle an event and potentially update state.
304    pub fn handle_event(
305        &self,
306        event: &Event,
307        state: &mut DialogState,
308        hit: Option<(HitId, HitRegion, HitData)>,
309    ) -> Option<DialogResult> {
310        if !state.open {
311            return None;
312        }
313
314        if self.config.kind != DialogKind::Prompt && state.input_focused {
315            state.input_focused = false;
316        }
317
318        match event {
319            // Escape closes with Dismissed
320            Event::Key(KeyEvent {
321                code: KeyCode::Escape,
322                kind: KeyEventKind::Press,
323                ..
324            }) if self.config.modal_config.close_on_escape => {
325                state.close(DialogResult::Dismissed);
326                return Some(DialogResult::Dismissed);
327            }
328
329            // Tab cycles focus
330            Event::Key(KeyEvent {
331                code: KeyCode::Tab,
332                kind: KeyEventKind::Press,
333                modifiers,
334                ..
335            }) => {
336                let shift = modifiers.contains(Modifiers::SHIFT);
337                self.cycle_focus(state, shift);
338            }
339
340            // Enter activates focused button
341            Event::Key(KeyEvent {
342                code: KeyCode::Enter,
343                kind: KeyEventKind::Press,
344                ..
345            }) => {
346                return self.activate_button(state);
347            }
348
349            // Arrow keys navigate buttons
350            Event::Key(KeyEvent {
351                code: KeyCode::Left | KeyCode::Right,
352                kind: KeyEventKind::Press,
353                ..
354            }) if !state.input_focused => {
355                let forward = matches!(
356                    event,
357                    Event::Key(KeyEvent {
358                        code: KeyCode::Right,
359                        ..
360                    })
361                );
362                self.navigate_buttons(state, forward);
363            }
364
365            // Mouse click on button
366            Event::Mouse(MouseEvent {
367                kind: MouseEventKind::Down(MouseButton::Left),
368                ..
369            }) => {
370                if let (Some((id, region, data)), Some(expected)) = (hit, self.hit_id)
371                    && id == expected
372                    && region == DIALOG_HIT_BUTTON
373                    && let Ok(idx) = usize::try_from(data)
374                    && idx < self.buttons.len()
375                {
376                    state.focused_button = Some(idx);
377                    return self.activate_button(state);
378                }
379            }
380
381            // For prompt dialogs, handle text input
382            Event::Key(key_event)
383                if self.config.kind == DialogKind::Prompt && state.input_focused =>
384            {
385                self.handle_input_key(state, key_event);
386            }
387
388            _ => {}
389        }
390
391        None
392    }
393
394    fn cycle_focus(&self, state: &mut DialogState, reverse: bool) {
395        let has_input = self.config.kind == DialogKind::Prompt;
396        let button_count = self.buttons.len();
397
398        if has_input {
399            // Cycle: input -> button 0 -> button 1 -> ... -> input
400            if state.input_focused {
401                state.input_focused = false;
402                state.focused_button = if reverse {
403                    Some(button_count.saturating_sub(1))
404                } else {
405                    Some(0)
406                };
407            } else if let Some(idx) = state.focused_button {
408                if reverse {
409                    if idx == 0 {
410                        state.input_focused = true;
411                        state.focused_button = None;
412                    } else {
413                        state.focused_button = Some(idx - 1);
414                    }
415                } else if idx + 1 >= button_count {
416                    state.input_focused = true;
417                    state.focused_button = None;
418                } else {
419                    state.focused_button = Some(idx + 1);
420                }
421            }
422        } else {
423            // Just cycle buttons
424            let current = state.focused_button.unwrap_or(0);
425            state.focused_button = if reverse {
426                Some(if current == 0 {
427                    button_count - 1
428                } else {
429                    current - 1
430                })
431            } else {
432                Some((current + 1) % button_count)
433            };
434        }
435    }
436
437    fn navigate_buttons(&self, state: &mut DialogState, forward: bool) {
438        let count = self.buttons.len();
439        if count == 0 {
440            return;
441        }
442        let current = state.focused_button.unwrap_or(0);
443        state.focused_button = if forward {
444            Some((current + 1) % count)
445        } else {
446            Some(if current == 0 { count - 1 } else { current - 1 })
447        };
448    }
449
450    fn activate_button(&self, state: &mut DialogState) -> Option<DialogResult> {
451        let idx = state.focused_button.or_else(|| {
452            // Default to primary button
453            self.buttons.iter().position(|b| b.primary)
454        })?;
455
456        let button = self.buttons.get(idx)?;
457        let result = match button.id.as_str() {
458            "ok" => {
459                if self.config.kind == DialogKind::Prompt {
460                    DialogResult::Input(state.input_value.clone())
461                } else {
462                    DialogResult::Ok
463                }
464            }
465            "cancel" => DialogResult::Cancel,
466            id => DialogResult::Custom(id.to_string()),
467        };
468
469        state.close(result.clone());
470        Some(result)
471    }
472
473    fn handle_input_key(&self, state: &mut DialogState, key: &KeyEvent) {
474        if key.kind != KeyEventKind::Press {
475            return;
476        }
477
478        match key.code {
479            KeyCode::Char(c) => {
480                state.input_value.push(c);
481            }
482            KeyCode::Backspace => {
483                state.input_value.pop();
484            }
485            KeyCode::Delete => {
486                state.input_value.clear();
487            }
488            _ => {}
489        }
490    }
491
492    /// Calculate content height.
493    fn content_height(&self) -> u16 {
494        let mut height: u16 = 2; // Top and bottom border
495
496        // Title row
497        if !self.title.is_empty() {
498            height += 1;
499        }
500
501        // Message row(s) - simplified: 1 row
502        if !self.message.is_empty() {
503            height += 1;
504        }
505
506        // Spacing
507        height += 1;
508
509        // Input row (for Prompt)
510        if self.config.kind == DialogKind::Prompt {
511            height += 1;
512            height += 1; // Spacing
513        }
514
515        // Button row
516        height += 1;
517
518        height
519    }
520
521    /// Render the dialog content.
522    fn render_content(&self, area: Rect, frame: &mut Frame, state: &DialogState) {
523        if area.is_empty() {
524            return;
525        }
526
527        // Draw border
528        let block = Block::default()
529            .borders(Borders::ALL)
530            .title(&self.title)
531            .title_alignment(Alignment::Center);
532        block.render(area, frame);
533
534        let inner = block.inner(area);
535        if inner.is_empty() {
536            return;
537        }
538
539        let mut y = inner.y;
540
541        // Message
542        if !self.message.is_empty() && y < inner.bottom() {
543            self.draw_centered_text(
544                frame,
545                inner.x,
546                y,
547                inner.width,
548                &self.message,
549                self.config.message_style,
550            );
551            y += 1;
552        }
553
554        // Spacing
555        y += 1;
556
557        // Input field (for Prompt)
558        if self.config.kind == DialogKind::Prompt && y < inner.bottom() {
559            self.render_input(frame, inner.x, y, inner.width, state);
560            y += 2; // Input + spacing
561        }
562
563        // Buttons
564        if y < inner.bottom() {
565            self.render_buttons(frame, inner.x, y, inner.width, state);
566        }
567    }
568
569    fn draw_centered_text(
570        &self,
571        frame: &mut Frame,
572        x: u16,
573        y: u16,
574        width: u16,
575        text: &str,
576        style: Style,
577    ) {
578        let text_width = display_width(text).min(width as usize);
579        let offset = (width as usize - text_width) / 2;
580        let start_x = x.saturating_add(offset as u16);
581        draw_text_span(frame, start_x, y, text, style, x.saturating_add(width));
582    }
583
584    fn render_input(&self, frame: &mut Frame, x: u16, y: u16, width: u16, state: &DialogState) {
585        // Draw input background
586        let input_area = Rect::new(x + 1, y, width.saturating_sub(2), 1);
587        let input_style = self.config.input_style;
588        set_style_area(&mut frame.buffer, input_area, input_style);
589
590        // Draw input value or placeholder
591        let display_text = if state.input_value.is_empty() {
592            " "
593        } else {
594            &state.input_value
595        };
596
597        draw_text_span(
598            frame,
599            input_area.x,
600            y,
601            display_text,
602            input_style,
603            input_area.right(),
604        );
605
606        // Draw cursor if focused
607        if state.input_focused {
608            let input_width = display_width(state.input_value.as_str());
609            let cursor_x = input_area.x + input_width.min(input_area.width as usize) as u16;
610            if cursor_x < input_area.right() {
611                frame.cursor_position = Some((cursor_x, y));
612                frame.cursor_visible = true;
613            }
614        }
615    }
616
617    fn render_buttons(&self, frame: &mut Frame, x: u16, y: u16, width: u16, state: &DialogState) {
618        if self.buttons.is_empty() {
619            return;
620        }
621
622        // Calculate total button width
623        let total_width: usize = self
624            .buttons
625            .iter()
626            .map(|b| b.display_width())
627            .sum::<usize>()
628            + self.buttons.len().saturating_sub(1) * 2; // Spacing between buttons
629
630        // Center the buttons
631        let start_x = x + (width as usize - total_width.min(width as usize)) as u16 / 2;
632        let mut bx = start_x;
633
634        for (i, button) in self.buttons.iter().enumerate() {
635            let is_focused = state.focused_button == Some(i);
636
637            // Select style
638            let mut style = if is_focused {
639                self.config.focused_button_style
640            } else if button.primary {
641                self.config.primary_button_style
642            } else {
643                self.config.button_style
644            };
645            if is_focused {
646                let has_reverse = style
647                    .attrs
648                    .is_some_and(|attrs| attrs.contains(StyleFlags::REVERSE));
649                if !has_reverse {
650                    style = style.reverse();
651                }
652            }
653
654            // Draw button: [ label ]
655            let btn_text = format!("[ {} ]", button.label);
656            let btn_width = display_width(btn_text.as_str());
657            draw_text_span(frame, bx, y, &btn_text, style, x.saturating_add(width));
658
659            // Register hit region for button
660            if let Some(hit_id) = self.hit_id {
661                let max_btn_width = width.saturating_sub(bx.saturating_sub(x));
662                let btn_area_width = btn_width.min(max_btn_width as usize) as u16;
663                if btn_area_width > 0 {
664                    let btn_area = Rect::new(bx, y, btn_area_width, 1);
665                    frame.register_hit(btn_area, hit_id, DIALOG_HIT_BUTTON, i as u64);
666                }
667            }
668
669            bx = bx.saturating_add(btn_width as u16 + 2); // Button + spacing
670        }
671    }
672}
673
674impl StatefulWidget for Dialog {
675    type State = DialogState;
676
677    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
678        if !state.open || area.is_empty() {
679            return;
680        }
681
682        // Calculate content area
683        let content_height = self.content_height();
684        let config = self.config.modal_config.clone().size(
685            ModalSizeConstraints::new()
686                .min_width(30)
687                .max_width(60)
688                .min_height(content_height)
689                .max_height(content_height + 4),
690        );
691
692        // Create a wrapper widget for the dialog content
693        let content = DialogContent {
694            dialog: self,
695            state,
696        };
697
698        // Render via Modal
699        let modal = Modal::new(content).config(config);
700        modal.render(area, frame);
701    }
702}
703
704/// Internal wrapper for rendering dialog content.
705struct DialogContent<'a> {
706    dialog: &'a Dialog,
707    state: &'a DialogState,
708}
709
710impl Widget for DialogContent<'_> {
711    fn render(&self, area: Rect, frame: &mut Frame) {
712        self.dialog.render_content(area, frame, self.state);
713    }
714}
715
716/// Builder for custom dialogs.
717#[derive(Debug, Clone)]
718pub struct DialogBuilder {
719    title: String,
720    message: String,
721    buttons: Vec<DialogButton>,
722    config: DialogConfig,
723    hit_id: Option<HitId>,
724}
725
726impl DialogBuilder {
727    /// Add a button.
728    pub fn button(mut self, button: DialogButton) -> Self {
729        self.buttons.push(button);
730        self
731    }
732
733    /// Add an OK button.
734    pub fn ok_button(self) -> Self {
735        self.button(DialogButton::new("OK", "ok").primary())
736    }
737
738    /// Add a Cancel button.
739    pub fn cancel_button(self) -> Self {
740        self.button(DialogButton::new("Cancel", "cancel"))
741    }
742
743    /// Add a custom button.
744    pub fn custom_button(self, label: impl Into<String>, id: impl Into<String>) -> Self {
745        self.button(DialogButton::new(label, id))
746    }
747
748    /// Set modal configuration.
749    pub fn modal_config(mut self, config: ModalConfig) -> Self {
750        self.config.modal_config = config;
751        self
752    }
753
754    /// Set hit ID for mouse interaction.
755    pub fn hit_id(mut self, id: HitId) -> Self {
756        self.hit_id = Some(id);
757        self
758    }
759
760    /// Build the dialog.
761    pub fn build(self) -> Dialog {
762        let mut buttons = self.buttons;
763        if buttons.is_empty() {
764            buttons.push(DialogButton::new("OK", "ok").primary());
765        }
766
767        Dialog {
768            title: self.title,
769            message: self.message,
770            buttons,
771            config: self.config,
772            hit_id: self.hit_id,
773        }
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use ftui_render::grapheme_pool::GraphemePool;
781
782    #[test]
783    fn alert_dialog_single_button() {
784        let dialog = Dialog::alert("Title", "Message");
785        assert_eq!(dialog.buttons.len(), 1);
786        assert_eq!(dialog.buttons[0].label, "OK");
787        assert!(dialog.buttons[0].primary);
788    }
789
790    #[test]
791    fn confirm_dialog_two_buttons() {
792        let dialog = Dialog::confirm("Title", "Message");
793        assert_eq!(dialog.buttons.len(), 2);
794        assert_eq!(dialog.buttons[0].label, "OK");
795        assert_eq!(dialog.buttons[1].label, "Cancel");
796    }
797
798    #[test]
799    fn prompt_dialog_has_input() {
800        let dialog = Dialog::prompt("Title", "Message");
801        assert_eq!(dialog.config.kind, DialogKind::Prompt);
802        assert_eq!(dialog.buttons.len(), 2);
803    }
804
805    #[test]
806    fn custom_dialog_builder() {
807        let dialog = Dialog::custom("Custom", "Message")
808            .ok_button()
809            .cancel_button()
810            .custom_button("Help", "help")
811            .build();
812        assert_eq!(dialog.buttons.len(), 3);
813    }
814
815    #[test]
816    fn dialog_state_starts_open() {
817        let state = DialogState::new();
818        assert!(state.is_open());
819        assert!(state.result.is_none());
820    }
821
822    #[test]
823    fn dialog_state_close_sets_result() {
824        let mut state = DialogState::new();
825        state.close(DialogResult::Ok);
826        assert!(!state.is_open());
827        assert_eq!(state.result, Some(DialogResult::Ok));
828    }
829
830    #[test]
831    fn dialog_escape_closes() {
832        let dialog = Dialog::alert("Test", "Msg");
833        let mut state = DialogState::new();
834        let event = Event::Key(KeyEvent {
835            code: KeyCode::Escape,
836            modifiers: Modifiers::empty(),
837            kind: KeyEventKind::Press,
838        });
839        let result = dialog.handle_event(&event, &mut state, None);
840        assert_eq!(result, Some(DialogResult::Dismissed));
841        assert!(!state.is_open());
842    }
843
844    #[test]
845    fn dialog_enter_activates_primary() {
846        let dialog = Dialog::alert("Test", "Msg");
847        let mut state = DialogState::new();
848        state.input_focused = false; // Not on input
849        let event = Event::Key(KeyEvent {
850            code: KeyCode::Enter,
851            modifiers: Modifiers::empty(),
852            kind: KeyEventKind::Press,
853        });
854        let result = dialog.handle_event(&event, &mut state, None);
855        assert_eq!(result, Some(DialogResult::Ok));
856    }
857
858    #[test]
859    fn dialog_tab_cycles_focus() {
860        let dialog = Dialog::confirm("Test", "Msg");
861        let mut state = DialogState::new();
862        state.input_focused = false;
863        state.focused_button = Some(0);
864
865        let tab = Event::Key(KeyEvent {
866            code: KeyCode::Tab,
867            modifiers: Modifiers::empty(),
868            kind: KeyEventKind::Press,
869        });
870
871        dialog.handle_event(&tab, &mut state, None);
872        assert_eq!(state.focused_button, Some(1));
873
874        dialog.handle_event(&tab, &mut state, None);
875        assert_eq!(state.focused_button, Some(0)); // Wraps around
876    }
877
878    #[test]
879    fn prompt_enter_returns_input() {
880        let dialog = Dialog::prompt("Test", "Enter:");
881        let mut state = DialogState::new();
882        state.input_value = "hello".to_string();
883        state.input_focused = false;
884        state.focused_button = Some(0); // OK button
885
886        let enter = Event::Key(KeyEvent {
887            code: KeyCode::Enter,
888            modifiers: Modifiers::empty(),
889            kind: KeyEventKind::Press,
890        });
891
892        let result = dialog.handle_event(&enter, &mut state, None);
893        assert_eq!(result, Some(DialogResult::Input("hello".to_string())));
894    }
895
896    #[test]
897    fn button_display_width() {
898        let button = DialogButton::new("OK", "ok");
899        assert_eq!(button.display_width(), 6); // [ OK ]
900    }
901
902    #[test]
903    fn render_alert_does_not_panic() {
904        let dialog = Dialog::alert("Alert", "This is an alert message.");
905        let mut state = DialogState::new();
906        let mut pool = GraphemePool::new();
907        let mut frame = Frame::new(80, 24, &mut pool);
908        dialog.render(Rect::new(0, 0, 80, 24), &mut frame, &mut state);
909    }
910
911    #[test]
912    fn render_confirm_does_not_panic() {
913        let dialog = Dialog::confirm("Confirm", "Are you sure?");
914        let mut state = DialogState::new();
915        let mut pool = GraphemePool::new();
916        let mut frame = Frame::new(80, 24, &mut pool);
917        dialog.render(Rect::new(0, 0, 80, 24), &mut frame, &mut state);
918    }
919
920    #[test]
921    fn render_prompt_does_not_panic() {
922        let dialog = Dialog::prompt("Prompt", "Enter your name:");
923        let mut state = DialogState::new();
924        state.input_value = "Test User".to_string();
925        let mut pool = GraphemePool::new();
926        let mut frame = Frame::new(80, 24, &mut pool);
927        dialog.render(Rect::new(0, 0, 80, 24), &mut frame, &mut state);
928    }
929
930    #[test]
931    fn render_tiny_area_does_not_panic() {
932        let dialog = Dialog::alert("T", "M");
933        let mut state = DialogState::new();
934        let mut pool = GraphemePool::new();
935        let mut frame = Frame::new(10, 5, &mut pool);
936        dialog.render(Rect::new(0, 0, 10, 5), &mut frame, &mut state);
937    }
938
939    #[test]
940    fn custom_dialog_empty_buttons_gets_default() {
941        let dialog = Dialog::custom("Custom", "No buttons").build();
942        assert_eq!(dialog.buttons.len(), 1);
943        assert_eq!(dialog.buttons[0].label, "OK");
944    }
945
946    #[test]
947    fn render_unicode_message_does_not_panic() {
948        // CJK characters are 2 columns wide each
949        let dialog = Dialog::alert("你好", "这是一条消息 🎉");
950        let mut state = DialogState::new();
951        let mut pool = GraphemePool::new();
952        let mut frame = Frame::new(80, 24, &mut pool);
953        dialog.render(Rect::new(0, 0, 80, 24), &mut frame, &mut state);
954    }
955
956    #[test]
957    fn prompt_with_unicode_input_renders_correctly() {
958        let dialog = Dialog::prompt("入力", "名前を入力:");
959        let mut state = DialogState::new();
960        state.input_value = "田中太郎".to_string(); // CJK input
961        let mut pool = GraphemePool::new();
962        let mut frame = Frame::new(80, 24, &mut pool);
963        dialog.render(Rect::new(0, 0, 80, 24), &mut frame, &mut state);
964    }
965}