Skip to main content

saorsa_core/widget/
modal.rs

1//! Modal dialog widget — centered overlay with title, body, and border.
2
3use crate::overlay::{OverlayConfig, OverlayPosition};
4use crate::segment::Segment;
5use crate::style::Style;
6use crate::text::{string_display_width, truncate_to_display_width};
7use crate::widget::container::BorderStyle;
8
9/// A modal dialog widget with title, body, border, and optional dim background.
10///
11/// Renders as a bordered box with a title in the top border and body content
12/// inside. Use [`Modal::to_overlay_config`] to create an overlay config for
13/// use with [`crate::overlay::ScreenStack`].
14#[derive(Clone, Debug)]
15pub struct Modal {
16    title: String,
17    body_lines: Vec<Vec<Segment>>,
18    style: Style,
19    border_style: BorderStyle,
20    width: u16,
21    height: u16,
22}
23
24impl Modal {
25    /// Create a new modal with the given title and dimensions.
26    pub fn new(title: impl Into<String>, width: u16, height: u16) -> Self {
27        Self {
28            title: title.into(),
29            body_lines: Vec::new(),
30            style: Style::default(),
31            border_style: BorderStyle::Single,
32            width,
33            height,
34        }
35    }
36
37    /// Set the body content lines.
38    #[must_use]
39    pub fn with_body(mut self, lines: Vec<Vec<Segment>>) -> Self {
40        self.body_lines = lines;
41        self
42    }
43
44    /// Set the style for the modal border and text.
45    #[must_use]
46    pub fn with_style(mut self, style: Style) -> Self {
47        self.style = style;
48        self
49    }
50
51    /// Set the border style.
52    #[must_use]
53    pub fn with_border(mut self, border: BorderStyle) -> Self {
54        self.border_style = border;
55        self
56    }
57
58    /// Render the modal to lines ready for the compositor.
59    ///
60    /// Produces a bordered box with the title in the top border line and
61    /// body content inside. Lines are padded to the modal's width.
62    pub fn render_to_lines(&self) -> Vec<Vec<Segment>> {
63        let chars = border_chars(self.border_style);
64        let w = self.width as usize;
65        let h = self.height as usize;
66
67        if w < 2 || h < 2 {
68            return Vec::new();
69        }
70
71        let mut lines: Vec<Vec<Segment>> = Vec::with_capacity(h);
72        let inner_w = w.saturating_sub(2);
73
74        // Top border with title
75        let mut top = String::new();
76        top.push_str(chars.top_left);
77        if !self.title.is_empty() && inner_w > 0 {
78            let truncated_title = truncate_to_display_width(&self.title, inner_w);
79            top.push_str(truncated_title);
80            let title_w = string_display_width(truncated_title) as usize;
81            for _ in title_w..inner_w {
82                top.push_str(chars.horizontal);
83            }
84        } else {
85            for _ in 0..inner_w {
86                top.push_str(chars.horizontal);
87            }
88        }
89        top.push_str(chars.top_right);
90        lines.push(vec![Segment::styled(top, self.style.clone())]);
91
92        // Body rows
93        let body_rows = h.saturating_sub(2);
94        for row_idx in 0..body_rows {
95            let mut row = String::new();
96            row.push_str(chars.vertical);
97
98            if row_idx < self.body_lines.len() {
99                // We need to flatten the body line segments into text, then pad
100                let body_text: String = self.body_lines[row_idx].iter().map(|s| &*s.text).collect();
101                let truncated_body = truncate_to_display_width(&body_text, inner_w);
102                let body_w = string_display_width(truncated_body) as usize;
103                row.push_str(truncated_body);
104                for _ in body_w..inner_w {
105                    row.push(' ');
106                }
107            } else {
108                for _ in 0..inner_w {
109                    row.push(' ');
110                }
111            }
112
113            row.push_str(chars.vertical);
114            lines.push(vec![Segment::styled(row, self.style.clone())]);
115        }
116
117        // Bottom border
118        let mut bottom = String::new();
119        bottom.push_str(chars.bottom_left);
120        for _ in 0..inner_w {
121            bottom.push_str(chars.horizontal);
122        }
123        bottom.push_str(chars.bottom_right);
124        lines.push(vec![Segment::styled(bottom, self.style.clone())]);
125
126        lines
127    }
128
129    /// Create an overlay config for this modal (centered, with dim background).
130    pub fn to_overlay_config(&self) -> OverlayConfig {
131        OverlayConfig {
132            position: OverlayPosition::Center,
133            size: crate::geometry::Size::new(self.width, self.height),
134            z_offset: 0,
135            dim_background: true,
136        }
137    }
138}
139
140/// Border character set for rendering.
141struct BorderCharSet {
142    top_left: &'static str,
143    top_right: &'static str,
144    bottom_left: &'static str,
145    bottom_right: &'static str,
146    horizontal: &'static str,
147    vertical: &'static str,
148}
149
150/// Get the border characters for a border style.
151fn border_chars(style: BorderStyle) -> BorderCharSet {
152    match style {
153        BorderStyle::None => BorderCharSet {
154            top_left: " ",
155            top_right: " ",
156            bottom_left: " ",
157            bottom_right: " ",
158            horizontal: " ",
159            vertical: " ",
160        },
161        BorderStyle::Single => BorderCharSet {
162            top_left: "┌",
163            top_right: "┐",
164            bottom_left: "└",
165            bottom_right: "┘",
166            horizontal: "─",
167            vertical: "│",
168        },
169        BorderStyle::Double => BorderCharSet {
170            top_left: "╔",
171            top_right: "╗",
172            bottom_left: "╚",
173            bottom_right: "╝",
174            horizontal: "═",
175            vertical: "║",
176        },
177        BorderStyle::Rounded => BorderCharSet {
178            top_left: "╭",
179            top_right: "╮",
180            bottom_left: "╰",
181            bottom_right: "╯",
182            horizontal: "─",
183            vertical: "│",
184        },
185        BorderStyle::Heavy => BorderCharSet {
186            top_left: "┏",
187            top_right: "┓",
188            bottom_left: "┗",
189            bottom_right: "┛",
190            horizontal: "━",
191            vertical: "┃",
192        },
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::geometry::Size;
200
201    #[test]
202    fn new_modal_defaults() {
203        let m = Modal::new("Test", 20, 10);
204        assert!(m.title == "Test");
205        assert!(m.width == 20);
206        assert!(m.height == 10);
207        assert!(m.body_lines.is_empty());
208    }
209
210    #[test]
211    fn render_to_lines_correct_count() {
212        let m = Modal::new("Title", 20, 5);
213        let lines = m.render_to_lines();
214        assert!(lines.len() == 5); // top + 3 body + bottom
215    }
216
217    #[test]
218    fn title_in_top_border() {
219        let m = Modal::new("Hello", 20, 5);
220        let lines = m.render_to_lines();
221        assert!(!lines.is_empty());
222        let top_text: String = lines[0].iter().map(|s| &*s.text).collect();
223        assert!(top_text.contains("Hello"));
224        assert!(top_text.starts_with('┌'));
225        assert!(top_text.ends_with('┐'));
226    }
227
228    #[test]
229    fn body_content_inside_border() {
230        let body = vec![vec![Segment::new("content")]];
231        let m = Modal::new("T", 20, 5).with_body(body);
232        let lines = m.render_to_lines();
233        // Row 1 is the first body row
234        let row_text: String = lines[1].iter().map(|s| &*s.text).collect();
235        assert!(row_text.contains("content"));
236        assert!(row_text.starts_with('│'));
237        assert!(row_text.ends_with('│'));
238    }
239
240    #[test]
241    fn empty_body_border_only() {
242        let m = Modal::new("", 10, 3);
243        let lines = m.render_to_lines();
244        assert!(lines.len() == 3);
245        // Top, one body row (empty), bottom
246        let body_text: String = lines[1].iter().map(|s| &*s.text).collect();
247        assert!(body_text.starts_with('│'));
248        assert!(body_text.ends_with('│'));
249    }
250
251    #[test]
252    fn style_applied() {
253        let style = Style::new().bold(true);
254        let m = Modal::new("S", 10, 3).with_style(style);
255        let lines = m.render_to_lines();
256        assert!(!lines.is_empty());
257        assert!(lines[0][0].style.bold);
258    }
259
260    #[test]
261    fn overlay_config_centered_with_dim() {
262        let m = Modal::new("M", 30, 10);
263        let config = m.to_overlay_config();
264        assert!(config.position == OverlayPosition::Center);
265        assert!(config.size == Size::new(30, 10));
266        assert!(config.dim_background);
267    }
268
269    #[test]
270    fn custom_border_style() {
271        let m = Modal::new("D", 10, 3).with_border(BorderStyle::Double);
272        let lines = m.render_to_lines();
273        let top_text: String = lines[0].iter().map(|s| &*s.text).collect();
274        assert!(top_text.starts_with('╔'));
275        assert!(top_text.ends_with('╗'));
276    }
277
278    #[test]
279    fn too_small_modal_returns_empty() {
280        let m = Modal::new("X", 1, 1);
281        let lines = m.render_to_lines();
282        assert!(lines.is_empty());
283    }
284
285    #[test]
286    fn bottom_border_correct() {
287        let m = Modal::new("B", 10, 3);
288        let lines = m.render_to_lines();
289        let bottom_text: String = lines[2].iter().map(|s| &*s.text).collect();
290        assert!(bottom_text.starts_with('└'));
291        assert!(bottom_text.ends_with('┘'));
292    }
293}