photon_ui/components/
modal.rs1use crate::{
2 Component,
3 Event,
4 Focusable,
5 InputResult,
6 RenderError,
7 Rendered,
8 layout::{
9 Border,
10 Rect,
11 },
12 theme::{
13 ColorMode,
14 Style,
15 Theme,
16 stylize,
17 },
18};
19
20pub struct Modal {
36 content: Box<dyn Component>,
37 title: Option<String>,
38 border: Border,
39 width: u16,
40 focused: bool,
41}
42
43impl Modal {
44 pub fn new(content: Box<dyn Component>) -> Self {
46 Self {
47 content,
48 title: None,
49 border: Border::ROUNDED,
50 width: 40,
51 focused: false,
52 }
53 }
54
55 pub fn title(mut self, title: impl Into<String>) -> Self {
57 self.title = Some(title.into());
58 self
59 }
60
61 pub fn border(mut self, border: Border) -> Self {
63 self.border = border;
64 self
65 }
66
67 pub fn width(mut self, width: u16) -> Self {
69 self.width = width;
70 self
71 }
72}
73
74impl Focusable for Modal {
75 fn focused(&self) -> bool {
76 self.focused
77 }
78
79 fn set_focused(&mut self, focused: bool) {
80 self.focused = focused;
81 if let Some(f) = self.content.as_focusable_mut() {
82 f.set_focused(focused);
83 }
84 }
85}
86
87impl Component for Modal {
88 fn render(&self, width: u16) -> Result<Rendered, RenderError> {
89 let w = width.min(self.width);
90 let rect = Rect::new(0, 0, w, 24); self.render_rect(rect)
92 }
93
94 fn render_rect(&self, rect: Rect) -> Result<Rendered, RenderError> {
95 let theme = Theme::palette();
96 let mode = ColorMode::detect();
97 let border_style = Style::new().fg(theme.border());
98 let border_prefix = border_style.prefix(mode);
99 let suffix = border_style.suffix();
100
101 let inner_w = rect.width.saturating_sub(2);
102 let inner_h = rect.height.saturating_sub(2);
103
104 let content_rect = Rect::new(1, 1, inner_w, inner_h);
106 let content_rendered = match self.content.render_rect(content_rect) {
107 | Ok(r) => r,
108 | Err(e) => return Err(e),
109 };
110
111 let content_h = content_rendered.lines.len().min(inner_h as usize) as u16;
112 let _total_h = content_h + 2;
113
114 let mut screen = Rendered::empty();
115
116 let fill_w = inner_w as usize;
117
118 {
120 let mut top = String::new();
121 top.push_str(&border_prefix);
123 top.push(self.border.top_left);
124 top.push_str(suffix);
125
126 if let Some(ref title) = self.title {
127 let indicator = if self.focused { "▼ " } else { "▶ " };
128 let max_title = fill_w.saturating_sub(2);
129 let t = if title.len() > max_title {
130 &title[..max_title]
131 } else {
132 title
133 };
134 let label = format!(" {}{} ", indicator, t);
135 let label_styled = stylize(&label, &Style::new().fg(theme.text()).bold());
136 let t_visible = crate::utils::visible_width(&label_styled);
137 let fill_count = fill_w.saturating_sub(t_visible);
138
139 top.push_str(&label_styled);
140 if fill_count > 0 {
141 top.push_str(&border_prefix);
142 top.push_str(&self.border.top.to_string().repeat(fill_count));
143 top.push_str(suffix);
144 }
145 } else {
146 top.push_str(&border_prefix);
147 top.push_str(&self.border.top.to_string().repeat(fill_w));
148 top.push_str(suffix);
149 }
150
151 top.push_str(&border_prefix);
153 top.push(self.border.top_right);
154 top.push_str(suffix);
155 screen.lines.push(top);
156 }
157
158 for i in 0..content_h {
160 let mut line = String::new();
161 line.push_str(&border_prefix);
162 line.push(self.border.left);
163 line.push_str(suffix);
164
165 let content_line = content_rendered
166 .lines
167 .get(i as usize)
168 .map(|s| s.as_str())
169 .unwrap_or("");
170 let pad = inner_w as usize - crate::utils::visible_width(content_line);
171 line.push_str(content_line);
172 if pad > 0 {
173 line.push_str(&" ".repeat(pad));
174 }
175
176 line.push_str(&border_prefix);
177 line.push(self.border.right);
178 line.push_str(suffix);
179 screen.lines.push(line);
180 }
181
182 {
184 let mut bottom = String::new();
185 bottom.push_str(&border_prefix);
186 bottom.push(self.border.bottom_left);
187 bottom.push_str(suffix);
188 bottom.push_str(&border_prefix);
189 bottom.push_str(&self.border.bottom.to_string().repeat(fill_w));
190 bottom.push_str(suffix);
191 bottom.push_str(&border_prefix);
192 bottom.push(self.border.bottom_right);
193 bottom.push_str(suffix);
194 screen.lines.push(bottom);
195 }
196
197 if let Some((r, c)) = content_rendered.cursor &&
199 r + 1 < screen.lines.len()
200 {
201 screen.cursor = Some((r + 1, c + 1));
202 }
203
204 Ok(screen)
205 }
206
207 fn handle_input(&mut self, event: &Event) -> InputResult {
208 self.content.handle_input(event)
209 }
210
211 fn as_focusable(&self) -> Option<&dyn Focusable> {
212 Some(self)
213 }
214
215 fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
216 Some(self)
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::{
224 components::Text,
225 theme::Theme,
226 };
227
228 #[test]
229 fn modal_renders_with_border() {
230 Theme::with(Theme::Light, || {
231 let modal = Modal::new(Box::new(Text::new("hi", 0, 0)));
232 let rendered = modal.render_rect(Rect::new(0, 0, 10, 5)).unwrap();
233 assert!(rendered.lines[0].contains("╭"));
234 assert!(rendered.lines[0].contains("╮"));
235 assert!(rendered.lines[2].contains("╰"));
236 assert!(rendered.lines[2].contains("╯"));
237 });
238 }
239
240 #[test]
241 fn modal_renders_title() {
242 Theme::with(Theme::Light, || {
243 let modal = Modal::new(Box::new(Text::new("hi", 0, 0))).title("Alert");
244 let rendered = modal.render_rect(Rect::new(0, 0, 20, 5)).unwrap();
245 assert!(rendered.lines[0].contains("Alert"));
246 });
247 }
248
249 #[test]
250 fn modal_forwards_focus() {
251 Theme::with(Theme::Light, || {
252 let mut modal = Modal::new(Box::new(Text::new("hi", 0, 0)));
253 modal.set_focused(true);
254 assert!(modal.focused());
255 });
256 }
257
258 #[test]
259 fn modal_builder_methods() {
260 let modal = Modal::new(Box::new(Text::new("hi", 0, 0)))
261 .border(Border::DOUBLE)
262 .width(80)
263 .title("T");
264 assert_eq!(modal.width, 80);
265 }
266
267 #[test]
268 fn modal_render_uses_self_width() {
269 Theme::with(Theme::Light, || {
270 let modal = Modal::new(Box::new(Text::new("hi", 0, 0))).width(12);
271 let rendered = modal.render(100).unwrap();
272 assert_eq!(crate::utils::visible_width(&rendered.lines[0]), 12);
273 });
274 }
275
276 #[test]
277 fn modal_focused_title_indicator() {
278 Theme::with(Theme::Light, || {
279 let mut modal = Modal::new(Box::new(Text::new("hi", 0, 0))).title("A");
280 modal.set_focused(true);
281 let rendered = modal.render_rect(Rect::new(0, 0, 20, 5)).unwrap();
282 assert!(rendered.lines[0].contains("▼"));
283 });
284 }
285
286 #[test]
287 fn modal_handle_input_forwards_to_content() {
288 Theme::with(Theme::Light, || {
289 let mut modal = Modal::new(Box::new(Text::new("hi", 0, 0)));
290 let result = modal.handle_input(&Event::Resize(80, 24));
291 assert_eq!(result, InputResult::Ignored);
292 });
293 }
294
295 #[test]
296 fn modal_focusable_trait_objects() {
297 let modal = Modal::new(Box::new(Text::new("hi", 0, 0)));
298 assert!(modal.as_focusable().is_some());
299 let mut modal = Modal::new(Box::new(Text::new("hi", 0, 0)));
300 assert!(modal.as_focusable_mut().is_some());
301 }
302}