1use super::traits::{RenderContext, View, WidgetProps};
4use crate::render::Cell;
5use crate::style::Color;
6use crate::{impl_props_builders, impl_styled_view};
7
8#[derive(Clone)]
14pub struct ModalButton {
15 pub label: String,
17 pub style: ModalButtonStyle,
19}
20
21#[derive(Clone, Copy, Default)]
23pub enum ModalButtonStyle {
24 #[default]
26 Default,
27 Primary,
29 Danger,
31}
32
33impl ModalButton {
34 pub fn new(label: impl Into<String>) -> Self {
36 Self {
37 label: label.into(),
38 style: ModalButtonStyle::Default,
39 }
40 }
41
42 pub fn primary(label: impl Into<String>) -> Self {
44 Self {
45 label: label.into(),
46 style: ModalButtonStyle::Primary,
47 }
48 }
49
50 pub fn danger(label: impl Into<String>) -> Self {
52 Self {
53 label: label.into(),
54 style: ModalButtonStyle::Danger,
55 }
56 }
57}
58
59pub struct Modal {
61 title: String,
62 content: Vec<String>,
64 body: Option<Box<dyn View>>,
66 buttons: Vec<ModalButton>,
67 selected_button: usize,
68 visible: bool,
69 width: u16,
70 height: Option<u16>,
71 title_fg: Option<Color>,
72 border_fg: Option<Color>,
73 props: WidgetProps,
74}
75
76impl Modal {
77 pub fn new() -> Self {
79 Self {
80 title: String::new(),
81 content: Vec::new(),
82 body: None,
83 buttons: Vec::new(),
84 selected_button: 0,
85 visible: false,
86 width: 40,
87 height: None,
88 title_fg: Some(Color::WHITE),
89 border_fg: Some(Color::WHITE),
90 props: WidgetProps::new(),
91 }
92 }
93
94 pub fn title(mut self, title: impl Into<String>) -> Self {
96 self.title = title.into();
97 self
98 }
99
100 pub fn content(mut self, content: impl Into<String>) -> Self {
102 self.content = content.into().lines().map(|s| s.to_string()).collect();
103 self
104 }
105
106 pub fn line(mut self, line: impl Into<String>) -> Self {
108 self.content.push(line.into());
109 self
110 }
111
112 pub fn buttons(mut self, buttons: Vec<ModalButton>) -> Self {
114 self.buttons = buttons;
115 self
116 }
117
118 pub fn ok(mut self) -> Self {
120 self.buttons.push(ModalButton::primary("OK"));
121 self
122 }
123
124 pub fn cancel(mut self) -> Self {
126 self.buttons.push(ModalButton::new("Cancel"));
127 self
128 }
129
130 pub fn ok_cancel(mut self) -> Self {
132 self.buttons.push(ModalButton::primary("OK"));
133 self.buttons.push(ModalButton::new("Cancel"));
134 self
135 }
136
137 pub fn yes_no(mut self) -> Self {
139 self.buttons.push(ModalButton::primary("Yes"));
140 self.buttons.push(ModalButton::new("No"));
141 self
142 }
143
144 pub fn yes_no_cancel(mut self) -> Self {
146 self.buttons.push(ModalButton::primary("Yes"));
147 self.buttons.push(ModalButton::new("No"));
148 self.buttons.push(ModalButton::new("Cancel"));
149 self
150 }
151
152 pub fn width(mut self, width: u16) -> Self {
154 self.width = width;
155 self
156 }
157
158 pub fn height(mut self, height: u16) -> Self {
160 self.height = Some(height);
161 self
162 }
163
164 pub fn body(mut self, widget: impl View + 'static) -> Self {
185 self.body = Some(Box::new(widget));
186 self
187 }
188
189 pub fn title_fg(mut self, color: Color) -> Self {
191 self.title_fg = Some(color);
192 self
193 }
194
195 pub fn border_fg(mut self, color: Color) -> Self {
197 self.border_fg = Some(color);
198 self
199 }
200
201 pub fn show(&mut self) {
203 self.visible = true;
204 }
205
206 pub fn hide(&mut self) {
208 self.visible = false;
209 }
210
211 pub fn toggle(&mut self) {
213 self.visible = !self.visible;
214 }
215
216 pub fn is_visible(&self) -> bool {
218 self.visible
219 }
220
221 pub fn selected_button(&self) -> usize {
223 self.selected_button
224 }
225
226 pub fn next_button(&mut self) {
228 if !self.buttons.is_empty() {
229 self.selected_button = (self.selected_button + 1) % self.buttons.len();
230 }
231 }
232
233 pub fn prev_button(&mut self) {
235 if !self.buttons.is_empty() {
236 self.selected_button = self
237 .selected_button
238 .checked_sub(1)
239 .unwrap_or(self.buttons.len() - 1);
240 }
241 }
242
243 pub fn handle_key(&mut self, key: &crate::event::Key) -> Option<usize> {
245 use crate::event::Key;
246
247 match key {
248 Key::Enter | Key::Char(' ') => {
249 if !self.buttons.is_empty() {
250 Some(self.selected_button)
251 } else {
252 None
253 }
254 }
255 Key::Left | Key::Char('h') => {
256 self.prev_button();
257 None
258 }
259 Key::Right | Key::Char('l') => {
260 self.next_button();
261 None
262 }
263 Key::Tab => {
264 self.next_button();
265 None
266 }
267 Key::Escape => {
268 self.hide();
269 None
270 }
271 _ => None,
272 }
273 }
274
275 pub fn alert(title: impl Into<String>, message: impl Into<String>) -> Self {
277 Self::new().title(title).content(message).ok()
278 }
279
280 pub fn confirm(title: impl Into<String>, message: impl Into<String>) -> Self {
282 Self::new().title(title).content(message).yes_no()
283 }
284
285 pub fn error(message: impl Into<String>) -> Self {
287 Self::new()
288 .title("Error")
289 .title_fg(Color::RED)
290 .border_fg(Color::RED)
291 .content(message)
292 .ok()
293 }
294
295 pub fn warning(message: impl Into<String>) -> Self {
297 Self::new()
298 .title("Warning")
299 .title_fg(Color::YELLOW)
300 .border_fg(Color::YELLOW)
301 .content(message)
302 .ok()
303 }
304
305 fn required_height(&self) -> u16 {
307 if let Some(h) = self.height {
309 return h;
310 }
311
312 let content_lines = if self.body.is_some() {
314 5u16
315 } else {
316 self.content.len() as u16
317 };
318
319 let button_line = if self.buttons.is_empty() { 0 } else { 1 };
320 3 + content_lines + 1 + button_line + 1
322 }
323}
324
325impl Default for Modal {
326 fn default() -> Self {
327 Self::new()
328 }
329}
330
331impl View for Modal {
332 fn render(&self, ctx: &mut RenderContext) {
333 if !self.visible {
334 return;
335 }
336
337 let area = ctx.area;
338 let modal_width = self.width.min(area.width.saturating_sub(4));
339 let modal_height = self.required_height().min(area.height.saturating_sub(2));
340
341 let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
343 let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
344
345 self.render_border(ctx, x, y, modal_width, modal_height);
347
348 if !self.title.is_empty() {
350 let title_x = x + 2;
351 let title_width = (modal_width - 4) as usize;
352 let title: String = self.title.chars().take(title_width).collect();
353
354 for (i, ch) in title.chars().enumerate() {
355 let mut cell = Cell::new(ch);
356 cell.fg = self.title_fg;
357 cell.modifier |= crate::render::Modifier::BOLD;
358 ctx.buffer.set(title_x + i as u16, y + 1, cell);
359 }
360
361 for dx in 1..(modal_width - 1) {
363 ctx.buffer.set(x + dx, y + 2, Cell::new('─'));
364 }
365 ctx.buffer.set(x, y + 2, Cell::new('├'));
366 ctx.buffer.set(x + modal_width - 1, y + 2, Cell::new('┤'));
367 }
368
369 let content_y = y + 3;
371 let content_width = modal_width.saturating_sub(4);
372 let content_height = modal_height.saturating_sub(6); if let Some(ref body_widget) = self.body {
375 let content_area =
377 crate::layout::Rect::new(x + 2, content_y, content_width, content_height);
378 let mut body_ctx = RenderContext::new(ctx.buffer, content_area);
379 body_widget.render(&mut body_ctx);
380 } else {
381 for (i, line) in self.content.iter().enumerate() {
383 let cy = content_y + i as u16;
384 if cy >= y + modal_height - 2 {
385 break;
386 }
387 let truncated: String = line.chars().take(content_width as usize).collect();
388 for (j, ch) in truncated.chars().enumerate() {
389 ctx.buffer.set(x + 2 + j as u16, cy, Cell::new(ch));
390 }
391 }
392 }
393
394 if !self.buttons.is_empty() {
396 let button_y = y + modal_height - 2;
397 let total_button_width: usize = self
398 .buttons
399 .iter()
400 .map(|b| b.label.len() + 4) .sum::<usize>()
402 + (self.buttons.len() - 1) * 2; let start_x = x + (modal_width - total_button_width as u16) / 2;
405 let mut bx = start_x;
406
407 for (i, button) in self.buttons.iter().enumerate() {
408 let is_selected = i == self.selected_button;
409 let button_text = format!("[ {} ]", button.label);
410
411 let (fg, bg) = if is_selected {
412 match button.style {
413 ModalButtonStyle::Primary => (Some(Color::WHITE), Some(Color::BLUE)),
414 ModalButtonStyle::Danger => (Some(Color::WHITE), Some(Color::RED)),
415 ModalButtonStyle::Default => (Some(Color::BLACK), Some(Color::WHITE)),
416 }
417 } else {
418 (None, None)
419 };
420
421 for (j, ch) in button_text.chars().enumerate() {
422 let mut cell = Cell::new(ch);
423 cell.fg = fg;
424 cell.bg = bg;
425 ctx.buffer.set(bx + j as u16, button_y, cell);
426 }
427
428 bx += button_text.len() as u16 + 2;
429 }
430 }
431 }
432
433 crate::impl_view_meta!("Modal");
434}
435
436impl Modal {
437 fn render_border(&self, ctx: &mut RenderContext, x: u16, y: u16, width: u16, height: u16) {
438 for dy in 1..height.saturating_sub(1) {
440 for dx in 1..width.saturating_sub(1) {
441 ctx.buffer.set(x + dx, y + dy, Cell::new(' '));
442 }
443 }
444
445 let mut corner = Cell::new('┌');
447 corner.fg = self.border_fg;
448 ctx.buffer.set(x, y, corner);
449
450 for dx in 1..(width - 1) {
451 let mut cell = Cell::new('─');
452 cell.fg = self.border_fg;
453 ctx.buffer.set(x + dx, y, cell);
454 }
455
456 let mut corner = Cell::new('┐');
457 corner.fg = self.border_fg;
458 ctx.buffer.set(x + width - 1, y, corner);
459
460 for dy in 1..(height - 1) {
462 let mut cell = Cell::new('│');
463 cell.fg = self.border_fg;
464 ctx.buffer.set(x, y + dy, cell);
465 ctx.buffer.set(x + width - 1, y + dy, cell);
466 }
467
468 let mut corner = Cell::new('└');
470 corner.fg = self.border_fg;
471 ctx.buffer.set(x, y + height - 1, corner);
472
473 for dx in 1..(width - 1) {
474 let mut cell = Cell::new('─');
475 cell.fg = self.border_fg;
476 ctx.buffer.set(x + dx, y + height - 1, cell);
477 }
478
479 let mut corner = Cell::new('┘');
480 corner.fg = self.border_fg;
481 ctx.buffer.set(x + width - 1, y + height - 1, corner);
482 }
483}
484
485pub fn modal() -> Modal {
487 Modal::new()
488}
489
490impl_styled_view!(Modal);
491impl_props_builders!(Modal);
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use crate::layout::Rect;
497 use crate::render::Buffer;
498
499 #[test]
500 fn test_modal_new() {
501 let m = Modal::new();
502 assert!(!m.is_visible());
503 assert!(m.title.is_empty());
504 assert!(m.content.is_empty());
505 assert!(m.buttons.is_empty());
506 }
507
508 #[test]
509 fn test_modal_builder() {
510 let m = Modal::new()
511 .title("Test")
512 .content("Hello\nWorld")
513 .ok_cancel();
514
515 assert_eq!(m.title, "Test");
516 assert_eq!(m.content.len(), 2);
517 assert_eq!(m.buttons.len(), 2);
518 }
519
520 #[test]
521 fn test_modal_visibility() {
522 let mut m = Modal::new();
523 assert!(!m.is_visible());
524
525 m.show();
526 assert!(m.is_visible());
527
528 m.hide();
529 assert!(!m.is_visible());
530
531 m.toggle();
532 assert!(m.is_visible());
533 }
534
535 #[test]
536 fn test_modal_button_navigation() {
537 let mut m = Modal::new().ok_cancel();
538
539 assert_eq!(m.selected_button(), 0);
540
541 m.next_button();
542 assert_eq!(m.selected_button(), 1);
543
544 m.next_button(); assert_eq!(m.selected_button(), 0);
546
547 m.prev_button(); assert_eq!(m.selected_button(), 1);
549 }
550
551 #[test]
552 fn test_modal_handle_key() {
553 use crate::event::Key;
554
555 let mut m = Modal::new().yes_no();
556 m.show();
557
558 m.handle_key(&Key::Right);
560 assert_eq!(m.selected_button(), 1);
561
562 m.handle_key(&Key::Left);
563 assert_eq!(m.selected_button(), 0);
564
565 let result = m.handle_key(&Key::Enter);
567 assert_eq!(result, Some(0));
568
569 m.handle_key(&Key::Escape);
571 assert!(!m.is_visible());
572 }
573
574 #[test]
575 fn test_modal_presets() {
576 let alert = Modal::alert("Title", "Message");
577 assert_eq!(alert.title, "Title");
578 assert_eq!(alert.buttons.len(), 1);
579
580 let confirm = Modal::confirm("Title", "Question?");
581 assert_eq!(confirm.buttons.len(), 2);
582
583 let error = Modal::error("Something went wrong");
584 assert_eq!(error.title, "Error");
585 }
586
587 #[test]
588 fn test_modal_render_hidden() {
589 let mut buffer = Buffer::new(80, 24);
590 let area = Rect::new(0, 0, 80, 24);
591 let mut ctx = RenderContext::new(&mut buffer, area);
592
593 let m = Modal::new().title("Test");
594 m.render(&mut ctx);
595
596 assert_eq!(buffer.get(0, 0).unwrap().symbol, ' ');
598 }
599
600 #[test]
601 fn test_modal_render_visible() {
602 let mut buffer = Buffer::new(80, 24);
603 let area = Rect::new(0, 0, 80, 24);
604 let mut ctx = RenderContext::new(&mut buffer, area);
605
606 let mut m = Modal::new().title("Test Dialog").content("Hello").ok();
607 m.show();
608 m.render(&mut ctx);
609
610 let center_x = (80 - 40) / 2;
613 let center_y = (24 - m.required_height()) / 2;
614
615 assert_eq!(buffer.get(center_x, center_y).unwrap().symbol, '┌');
616 }
617
618 #[test]
619 fn test_modal_button_styles() {
620 let btn = ModalButton::new("Test");
621 assert!(matches!(btn.style, ModalButtonStyle::Default));
622
623 let btn = ModalButton::primary("OK");
624 assert!(matches!(btn.style, ModalButtonStyle::Primary));
625
626 let btn = ModalButton::danger("Delete");
627 assert!(matches!(btn.style, ModalButtonStyle::Danger));
628 }
629
630 #[test]
631 fn test_modal_helper() {
632 let m = modal().title("Quick").ok();
633
634 assert_eq!(m.title, "Quick");
635 }
636
637 #[test]
638 fn test_modal_with_body() {
639 use crate::widget::Text;
640
641 let m = Modal::new()
642 .title("Form")
643 .body(Text::new("Custom content"))
644 .height(10)
645 .ok();
646
647 assert!(m.body.is_some());
648 assert_eq!(m.height, Some(10));
649 }
650
651 #[test]
652 fn test_modal_body_render() {
653 use crate::widget::Text;
654
655 let mut buffer = Buffer::new(80, 24);
656 let area = Rect::new(0, 0, 80, 24);
657 let mut ctx = RenderContext::new(&mut buffer, area);
658
659 let mut m = Modal::new()
660 .title("Body Test")
661 .body(Text::new("Widget content"))
662 .width(50)
663 .height(12)
664 .ok();
665 m.show();
666 m.render(&mut ctx);
667
668 let center_x = (80 - 50) / 2;
670 let center_y = (24 - 12) / 2;
671 assert_eq!(buffer.get(center_x, center_y).unwrap().symbol, '┌');
672 }
673}