1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
33use ratatui::{
34 Frame,
35 layout::{Alignment, Constraint, Direction, Layout, Rect},
36 style::{Color, Modifier, Style},
37 text::Span,
38 widgets::{Block, Borders, Clear, Paragraph},
39};
40
41use crate::{
42 state::FocusManager,
43 traits::{ClickRegionRegistry, ContainerAction, EventResult},
44};
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum DialogFocusTarget {
49 Child(usize),
51 Button(usize),
53 Close,
55}
56
57#[derive(Debug, Clone)]
59pub struct DialogState<T> {
60 pub children: T,
62 pub focus: FocusManager<DialogFocusTarget>,
64 pub click_regions: ClickRegionRegistry<DialogFocusTarget>,
66 pub visible: bool,
68}
69
70impl<T: Default> Default for DialogState<T> {
71 fn default() -> Self {
72 Self::new(T::default())
73 }
74}
75
76impl<T> DialogState<T> {
77 pub fn new(children: T) -> Self {
79 Self {
80 children,
81 focus: FocusManager::new(),
82 click_regions: ClickRegionRegistry::new(),
83 visible: false,
84 }
85 }
86
87 pub fn show(&mut self) {
89 self.visible = true;
90 }
91
92 pub fn hide(&mut self) {
94 self.visible = false;
95 }
96
97 pub fn toggle(&mut self) {
99 self.visible = !self.visible;
100 }
101
102 pub fn is_visible(&self) -> bool {
104 self.visible
105 }
106
107 pub fn register_child(&mut self, index: usize) {
109 self.focus.register(DialogFocusTarget::Child(index));
110 }
111
112 pub fn register_button(&mut self, index: usize) {
114 self.focus.register(DialogFocusTarget::Button(index));
115 }
116
117 pub fn current_focus(&self) -> Option<&DialogFocusTarget> {
119 self.focus.current()
120 }
121
122 pub fn is_child_focused(&self, index: usize) -> bool {
124 self.focus.is_focused(&DialogFocusTarget::Child(index))
125 }
126
127 pub fn is_button_focused(&self, index: usize) -> bool {
129 self.focus.is_focused(&DialogFocusTarget::Button(index))
130 }
131}
132
133#[derive(Debug, Clone)]
135pub struct DialogConfig {
136 pub title: String,
138 pub width_percent: u16,
140 pub height_percent: u16,
142 pub min_width: u16,
144 pub min_height: u16,
146 pub max_width: u16,
148 pub max_height: u16,
150 pub border_color: Color,
152 pub focused_border_color: Color,
154 pub close_on_escape: bool,
156 pub close_on_outside_click: bool,
158 pub buttons: Vec<(String, ContainerAction)>,
160}
161
162impl Default for DialogConfig {
163 fn default() -> Self {
164 Self {
165 title: String::new(),
166 width_percent: 60,
167 height_percent: 50,
168 min_width: 40,
169 min_height: 10,
170 max_width: 120,
171 max_height: 40,
172 border_color: Color::Blue,
173 focused_border_color: Color::Cyan,
174 close_on_escape: true,
175 close_on_outside_click: true,
176 buttons: vec![
177 ("Cancel".to_string(), ContainerAction::Close),
178 ("OK".to_string(), ContainerAction::Submit),
179 ],
180 }
181 }
182}
183
184impl DialogConfig {
185 pub fn new(title: impl Into<String>) -> Self {
187 Self {
188 title: title.into(),
189 ..Default::default()
190 }
191 }
192
193 pub fn width_percent(mut self, percent: u16) -> Self {
195 self.width_percent = percent.min(100);
196 self
197 }
198
199 pub fn height_percent(mut self, percent: u16) -> Self {
201 self.height_percent = percent.min(100);
202 self
203 }
204
205 pub fn min_size(mut self, width: u16, height: u16) -> Self {
207 self.min_width = width;
208 self.min_height = height;
209 self
210 }
211
212 pub fn max_size(mut self, width: u16, height: u16) -> Self {
214 self.max_width = width;
215 self.max_height = height;
216 self
217 }
218
219 pub fn border_color(mut self, color: Color) -> Self {
221 self.border_color = color;
222 self
223 }
224
225 pub fn focused_border_color(mut self, color: Color) -> Self {
227 self.focused_border_color = color;
228 self
229 }
230
231 pub fn close_on_escape(mut self, close: bool) -> Self {
233 self.close_on_escape = close;
234 self
235 }
236
237 pub fn close_on_outside_click(mut self, close: bool) -> Self {
239 self.close_on_outside_click = close;
240 self
241 }
242
243 pub fn buttons(mut self, buttons: Vec<(String, ContainerAction)>) -> Self {
245 self.buttons = buttons;
246 self
247 }
248
249 pub fn add_button(mut self, label: impl Into<String>, action: ContainerAction) -> Self {
251 self.buttons.push((label.into(), action));
252 self
253 }
254
255 pub fn no_buttons(mut self) -> Self {
257 self.buttons.clear();
258 self
259 }
260
261 pub fn ok_only(mut self) -> Self {
263 self.buttons = vec![("OK".to_string(), ContainerAction::Close)];
264 self
265 }
266
267 pub fn ok_cancel(mut self) -> Self {
269 self.buttons = vec![
270 ("Cancel".to_string(), ContainerAction::Close),
271 ("OK".to_string(), ContainerAction::Submit),
272 ];
273 self
274 }
275
276 pub fn yes_no(mut self) -> Self {
278 self.buttons = vec![
279 ("No".to_string(), ContainerAction::Close),
280 ("Yes".to_string(), ContainerAction::Submit),
281 ];
282 self
283 }
284}
285
286pub struct PopupDialog<'a, T, F>
290where
291 F: FnMut(&mut Frame, Rect, &mut T),
292{
293 config: &'a DialogConfig,
294 state: &'a mut DialogState<T>,
295 content_renderer: F,
296}
297
298impl<'a, T, F> PopupDialog<'a, T, F>
299where
300 F: FnMut(&mut Frame, Rect, &mut T),
301{
302 pub fn new(
310 config: &'a DialogConfig,
311 state: &'a mut DialogState<T>,
312 content_renderer: F,
313 ) -> Self {
314 Self {
315 config,
316 state,
317 content_renderer,
318 }
319 }
320
321 pub fn calculate_area(&self, screen: Rect) -> Rect {
323 let width = (screen.width * self.config.width_percent / 100)
324 .max(self.config.min_width)
325 .min(self.config.max_width)
326 .min(screen.width.saturating_sub(4));
327
328 let height = (screen.height * self.config.height_percent / 100)
329 .max(self.config.min_height)
330 .min(self.config.max_height)
331 .min(screen.height.saturating_sub(4));
332
333 let x = (screen.width.saturating_sub(width)) / 2;
334 let y = (screen.height.saturating_sub(height)) / 2;
335
336 Rect::new(x, y, width, height)
337 }
338
339 pub fn render(&mut self, frame: &mut Frame) {
341 if !self.state.visible {
342 return;
343 }
344
345 let screen = frame.area();
346 let area = self.calculate_area(screen);
347
348 self.state.click_regions.clear();
350
351 frame.render_widget(Clear, area);
353
354 let block = Block::default()
356 .borders(Borders::ALL)
357 .border_style(Style::default().fg(self.config.focused_border_color))
358 .title(format!(" {} ", self.config.title))
359 .title_alignment(Alignment::Center);
360
361 let inner = block.inner(area);
362 frame.render_widget(block, area);
363
364 let button_height = if self.config.buttons.is_empty() { 0 } else { 2 };
366 let chunks = Layout::default()
367 .direction(Direction::Vertical)
368 .constraints([Constraint::Min(1), Constraint::Length(button_height)])
369 .split(inner);
370
371 (self.content_renderer)(frame, chunks[0], &mut self.state.children);
373
374 if !self.config.buttons.is_empty() {
376 self.render_buttons(frame, chunks[1]);
377 }
378 }
379
380 fn render_buttons(&mut self, frame: &mut Frame, area: Rect) {
381 let button_count = self.config.buttons.len();
382 if button_count == 0 {
383 return;
384 }
385
386 let total_button_width: u16 = self
387 .config
388 .buttons
389 .iter()
390 .map(|(label, _)| label.len() as u16 + 4)
391 .sum::<u16>()
392 + (button_count as u16).saturating_sub(1) * 2;
393
394 let start_x = area.x + (area.width.saturating_sub(total_button_width)) / 2;
395 let mut x = start_x;
396
397 for (idx, (label, _action)) in self.config.buttons.iter().enumerate() {
398 let is_focused = self.state.is_button_focused(idx);
399 let btn_width = label.len() as u16 + 4;
400 let btn_area = Rect::new(x, area.y, btn_width, 1);
401
402 let style = if is_focused {
403 Style::default()
404 .fg(Color::Black)
405 .bg(Color::Yellow)
406 .add_modifier(Modifier::BOLD)
407 } else {
408 Style::default().fg(Color::White).bg(Color::DarkGray)
409 };
410
411 let button_text = format!(" {} ", label);
412 let paragraph = Paragraph::new(Span::styled(button_text, style));
413 frame.render_widget(paragraph, btn_area);
414
415 self.state
417 .click_regions
418 .register(btn_area, DialogFocusTarget::Button(idx));
419
420 x += btn_width + 2;
421 }
422 }
423
424 pub fn handle_key(&mut self, key: KeyEvent) -> EventResult {
426 if !self.state.visible {
427 return EventResult::NotHandled;
428 }
429
430 match key.code {
431 KeyCode::Esc if self.config.close_on_escape => {
432 self.state.hide();
433 EventResult::Action(ContainerAction::Close)
434 }
435 KeyCode::Tab if !key.modifiers.contains(KeyModifiers::SHIFT) => {
436 self.state.focus.next();
437 EventResult::Consumed
438 }
439 KeyCode::BackTab => {
440 self.state.focus.prev();
441 EventResult::Consumed
442 }
443 KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
444 self.state.focus.prev();
445 EventResult::Consumed
446 }
447 KeyCode::Enter => {
448 if let Some(DialogFocusTarget::Button(idx)) = self.state.focus.current() {
449 if let Some((_, action)) = self.config.buttons.get(*idx) {
450 let action = action.clone();
451 if action.is_close() {
452 self.state.hide();
453 }
454 return EventResult::Action(action);
455 }
456 }
457 EventResult::NotHandled
458 }
459 _ => EventResult::NotHandled,
460 }
461 }
462
463 pub fn handle_mouse(&mut self, mouse: MouseEvent) -> EventResult {
465 if !self.state.visible {
466 return EventResult::NotHandled;
467 }
468
469 let screen = Rect::new(0, 0, 80, 24); let area = self.calculate_area(screen);
471
472 if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
473 let col = mouse.column;
474 let row = mouse.row;
475
476 if self.config.close_on_outside_click
478 && (col < area.x
479 || col >= area.x + area.width
480 || row < area.y
481 || row >= area.y + area.height)
482 {
483 self.state.hide();
484 return EventResult::Action(ContainerAction::Close);
485 }
486
487 if let Some(target) = self.state.click_regions.handle_click(col, row) {
489 match target {
490 DialogFocusTarget::Button(idx) => {
491 if let Some((_, action)) = self.config.buttons.get(*idx) {
492 let action = action.clone();
493 if action.is_close() {
494 self.state.hide();
495 }
496 return EventResult::Action(action);
497 }
498 }
499 DialogFocusTarget::Child(idx) => {
500 self.state.focus.set(DialogFocusTarget::Child(*idx));
501 return EventResult::Consumed;
502 }
503 DialogFocusTarget::Close => {
504 self.state.hide();
505 return EventResult::Action(ContainerAction::Close);
506 }
507 }
508 }
509 }
510
511 EventResult::NotHandled
512 }
513
514 pub fn handle_mouse_with_screen(&mut self, mouse: MouseEvent, screen: Rect) -> EventResult {
516 if !self.state.visible {
517 return EventResult::NotHandled;
518 }
519
520 let area = self.calculate_area(screen);
521
522 if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
523 let col = mouse.column;
524 let row = mouse.row;
525
526 if self.config.close_on_outside_click
528 && (col < area.x
529 || col >= area.x + area.width
530 || row < area.y
531 || row >= area.y + area.height)
532 {
533 self.state.hide();
534 return EventResult::Action(ContainerAction::Close);
535 }
536
537 if let Some(target) = self.state.click_regions.handle_click(col, row) {
539 match target {
540 DialogFocusTarget::Button(idx) => {
541 if let Some((_, action)) = self.config.buttons.get(*idx) {
542 let action = action.clone();
543 if action.is_close() {
544 self.state.hide();
545 }
546 return EventResult::Action(action);
547 }
548 }
549 DialogFocusTarget::Child(idx) => {
550 self.state.focus.set(DialogFocusTarget::Child(*idx));
551 return EventResult::Consumed;
552 }
553 DialogFocusTarget::Close => {
554 self.state.hide();
555 return EventResult::Action(ContainerAction::Close);
556 }
557 }
558 }
559 }
560
561 EventResult::NotHandled
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn test_dialog_state_default() {
571 let state: DialogState<()> = DialogState::default();
572 assert!(!state.visible);
573 assert!(state.focus.is_empty());
574 }
575
576 #[test]
577 fn test_dialog_state_visibility() {
578 let mut state: DialogState<()> = DialogState::new(());
579
580 assert!(!state.is_visible());
581
582 state.show();
583 assert!(state.is_visible());
584
585 state.hide();
586 assert!(!state.is_visible());
587
588 state.toggle();
589 assert!(state.is_visible());
590
591 state.toggle();
592 assert!(!state.is_visible());
593 }
594
595 #[test]
596 fn test_dialog_state_focus_registration() {
597 let mut state: DialogState<()> = DialogState::new(());
598
599 state.register_child(0);
600 state.register_child(1);
601 state.register_button(0);
602 state.register_button(1);
603
604 assert!(state.is_child_focused(0)); state.focus.next();
607 assert!(state.is_child_focused(1));
608
609 state.focus.next();
610 assert!(state.is_button_focused(0));
611 }
612
613 #[test]
614 fn test_dialog_config_default() {
615 let config = DialogConfig::default();
616 assert_eq!(config.width_percent, 60);
617 assert_eq!(config.height_percent, 50);
618 assert!(config.close_on_escape);
619 assert!(config.close_on_outside_click);
620 assert_eq!(config.buttons.len(), 2);
621 }
622
623 #[test]
624 fn test_dialog_config_builder() {
625 let config = DialogConfig::new("Test Dialog")
626 .width_percent(80)
627 .height_percent(60)
628 .close_on_escape(false)
629 .close_on_outside_click(false);
630
631 assert_eq!(config.title, "Test Dialog");
632 assert_eq!(config.width_percent, 80);
633 assert_eq!(config.height_percent, 60);
634 assert!(!config.close_on_escape);
635 assert!(!config.close_on_outside_click);
636 }
637
638 #[test]
639 fn test_dialog_config_buttons() {
640 let config = DialogConfig::new("Test").ok_only();
641 assert_eq!(config.buttons.len(), 1);
642 assert_eq!(config.buttons[0].0, "OK");
643
644 let config = DialogConfig::new("Test").ok_cancel();
645 assert_eq!(config.buttons.len(), 2);
646
647 let config = DialogConfig::new("Test").yes_no();
648 assert_eq!(config.buttons.len(), 2);
649 assert_eq!(config.buttons[0].0, "No");
650 assert_eq!(config.buttons[1].0, "Yes");
651
652 let config = DialogConfig::new("Test").no_buttons();
653 assert!(config.buttons.is_empty());
654 }
655
656 #[test]
657 fn test_dialog_config_custom_buttons() {
658 let config = DialogConfig::new("Test")
659 .no_buttons()
660 .add_button("Apply", ContainerAction::custom("apply"))
661 .add_button("Close", ContainerAction::Close);
662
663 assert_eq!(config.buttons.len(), 2);
664 assert_eq!(config.buttons[0].0, "Apply");
665 assert_eq!(config.buttons[1].1, ContainerAction::Close);
666 }
667
668 #[test]
669 fn test_calculate_area() {
670 let config = DialogConfig::new("Test")
671 .width_percent(50)
672 .height_percent(50);
673 let mut state: DialogState<()> = DialogState::new(());
674
675 let dialog = PopupDialog::new(&config, &mut state, |_, _, _| {});
676
677 let screen = Rect::new(0, 0, 100, 50);
678 let area = dialog.calculate_area(screen);
679
680 assert_eq!(area.width, 50); assert_eq!(area.height, 25); assert_eq!(area.x, 25); assert_eq!(area.y, 12); }
685
686 #[test]
687 fn test_calculate_area_constrained() {
688 let config = DialogConfig::new("Test")
689 .width_percent(100)
690 .height_percent(100)
691 .max_size(60, 30);
692 let mut state: DialogState<()> = DialogState::new(());
693
694 let dialog = PopupDialog::new(&config, &mut state, |_, _, _| {});
695
696 let screen = Rect::new(0, 0, 100, 50);
697 let area = dialog.calculate_area(screen);
698
699 assert_eq!(area.width, 60);
701 assert_eq!(area.height, 30);
702 }
703
704 #[test]
705 fn test_dialog_focus_target_equality() {
706 assert_eq!(DialogFocusTarget::Child(0), DialogFocusTarget::Child(0));
707 assert_ne!(DialogFocusTarget::Child(0), DialogFocusTarget::Child(1));
708 assert_ne!(DialogFocusTarget::Child(0), DialogFocusTarget::Button(0));
709 assert_eq!(DialogFocusTarget::Close, DialogFocusTarget::Close);
710 }
711}