Skip to main content

photon_ui/components/
modal.rs

1use 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
20/// A modal dialog that wraps content in a bordered box with an optional title.
21///
22/// The modal itself does not handle dismissal — that is the responsibility of
23/// the caller (typically [`TUI`](crate::TUI) intercepting `Esc`).
24///
25/// # Example
26///
27/// ```
28/// use photon_ui::components::{
29///     Modal,
30///     Text,
31/// };
32///
33/// let modal = Modal::new(Box::new(Text::new("Are you sure?", 0, 0))).title("Confirm");
34/// ```
35pub 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    /// Create a new modal wrapping the given content.
45    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    /// Set the title rendered in the top border.
56    pub fn title(mut self, title: impl Into<String>) -> Self {
57        self.title = Some(title.into());
58        self
59    }
60
61    /// Set the border style (default is rounded).
62    pub fn border(mut self, border: Border) -> Self {
63        self.border = border;
64        self
65    }
66
67    /// Set the desired width of the modal content area.
68    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); // height will be computed from content
91        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        // Render content inside the modal
105        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        // Top border
119        {
120            let mut top = String::new();
121            // Left corner
122            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            // Right corner
152            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        // Content rows
159        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        // Bottom border
183        {
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        // Propagate cursor
198        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}