1use 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#[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 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 #[must_use]
39 pub fn with_body(mut self, lines: Vec<Vec<Segment>>) -> Self {
40 self.body_lines = lines;
41 self
42 }
43
44 #[must_use]
46 pub fn with_style(mut self, style: Style) -> Self {
47 self.style = style;
48 self
49 }
50
51 #[must_use]
53 pub fn with_border(mut self, border: BorderStyle) -> Self {
54 self.border_style = border;
55 self
56 }
57
58 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 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 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 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 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 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
140struct 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
150fn 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); }
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 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 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}