1#![forbid(unsafe_code)]
2
3use 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
31pub const DIALOG_HIT_BUTTON: HitRegion = HitRegion::Custom(10);
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum DialogResult {
37 Dismissed,
39 Ok,
41 Cancel,
43 Custom(String),
45 Input(String),
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct DialogButton {
52 pub label: String,
54 pub id: String,
56 pub primary: bool,
58}
59
60impl DialogButton {
61 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 pub fn primary(mut self) -> Self {
72 self.primary = true;
73 self
74 }
75
76 pub fn display_width(&self) -> usize {
78 display_width(self.label.as_str()) + 4
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum DialogKind {
86 Alert,
88 Confirm,
90 Prompt,
92 Custom,
94}
95
96#[derive(Debug, Clone, Default)]
98pub struct DialogState {
99 pub focused_button: Option<usize>,
101 pub input_value: String,
103 pub input_focused: bool,
105 pub open: bool,
107 pub result: Option<DialogResult>,
109}
110
111impl DialogState {
112 pub fn new() -> Self {
114 Self {
115 open: true,
116 input_focused: true, ..Default::default()
118 }
119 }
120
121 pub fn is_open(&self) -> bool {
123 self.open
124 }
125
126 pub fn close(&mut self, result: DialogResult) {
128 self.open = false;
129 self.result = Some(result);
130 }
131
132 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 pub fn take_result(&mut self) -> Option<DialogResult> {
143 self.result.take()
144 }
145}
146
147#[derive(Debug, Clone)]
149pub struct DialogConfig {
150 pub modal_config: ModalConfig,
152 pub kind: DialogKind,
154 pub button_style: Style,
156 pub primary_button_style: Style,
158 pub focused_button_style: Style,
160 pub title_style: Style,
162 pub message_style: Style,
164 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#[derive(Debug, Clone)]
196pub struct Dialog {
197 title: String,
199 message: String,
201 buttons: Vec<DialogButton>,
203 config: DialogConfig,
205 hit_id: Option<HitId>,
207}
208
209impl Dialog {
210 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 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 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 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 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 pub fn modal_config(mut self, config: ModalConfig) -> Self {
281 self.config.modal_config = config;
282 self
283 }
284
285 pub fn button_style(mut self, style: Style) -> Self {
287 self.config.button_style = style;
288 self
289 }
290
291 pub fn primary_button_style(mut self, style: Style) -> Self {
293 self.config.primary_button_style = style;
294 self
295 }
296
297 pub fn focused_button_style(mut self, style: Style) -> Self {
299 self.config.focused_button_style = style;
300 self
301 }
302
303 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 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 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 Event::Key(KeyEvent {
342 code: KeyCode::Enter,
343 kind: KeyEventKind::Press,
344 ..
345 }) => {
346 return self.activate_button(state);
347 }
348
349 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 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 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 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 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 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 fn content_height(&self) -> u16 {
494 let mut height: u16 = 2; if !self.title.is_empty() {
498 height += 1;
499 }
500
501 if !self.message.is_empty() {
503 height += 1;
504 }
505
506 height += 1;
508
509 if self.config.kind == DialogKind::Prompt {
511 height += 1;
512 height += 1; }
514
515 height += 1;
517
518 height
519 }
520
521 fn render_content(&self, area: Rect, frame: &mut Frame, state: &DialogState) {
523 if area.is_empty() {
524 return;
525 }
526
527 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 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 y += 1;
556
557 if self.config.kind == DialogKind::Prompt && y < inner.bottom() {
559 self.render_input(frame, inner.x, y, inner.width, state);
560 y += 2; }
562
563 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 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 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 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 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; 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 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 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 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); }
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 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 let content = DialogContent {
694 dialog: self,
695 state,
696 };
697
698 let modal = Modal::new(content).config(config);
700 modal.render(area, frame);
701 }
702}
703
704struct 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#[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 pub fn button(mut self, button: DialogButton) -> Self {
729 self.buttons.push(button);
730 self
731 }
732
733 pub fn ok_button(self) -> Self {
735 self.button(DialogButton::new("OK", "ok").primary())
736 }
737
738 pub fn cancel_button(self) -> Self {
740 self.button(DialogButton::new("Cancel", "cancel"))
741 }
742
743 pub fn custom_button(self, label: impl Into<String>, id: impl Into<String>) -> Self {
745 self.button(DialogButton::new(label, id))
746 }
747
748 pub fn modal_config(mut self, config: ModalConfig) -> Self {
750 self.config.modal_config = config;
751 self
752 }
753
754 pub fn hit_id(mut self, id: HitId) -> Self {
756 self.hit_id = Some(id);
757 self
758 }
759
760 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; 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)); }
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); 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); }
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 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(); 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}