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        Palette,
15        Style,
16        Theme,
17        stylize,
18    },
19};
20
21/// A modal dialog that wraps content in a bordered box with an optional title.
22///
23/// The modal itself does not handle dismissal — that is the responsibility of
24/// the caller (typically [`TUI`](crate::TUI) intercepting `Esc`).
25///
26/// # Example
27///
28/// ```
29/// use photon_ui::components::{
30///     Modal,
31///     Text,
32/// };
33///
34/// let modal = Modal::new(Box::new(Text::new("Are you sure?", 0, 0))).title("Confirm");
35/// ```
36pub struct Modal {
37    content: Box<dyn Component>,
38    title: Option<String>,
39    border: Border,
40    width: u16,
41    focused: bool,
42}
43
44impl Modal {
45    /// Create a new modal wrapping the given content.
46    pub fn new(content: Box<dyn Component>) -> Self {
47        Self {
48            content,
49            title: None,
50            border: Border::ROUNDED,
51            width: 40,
52            focused: false,
53        }
54    }
55
56    /// Set the title rendered in the top border.
57    pub fn title(mut self, title: impl Into<String>) -> Self {
58        self.title = Some(title.into());
59        self
60    }
61
62    /// Set the border style (default is rounded).
63    pub fn border(mut self, border: Border) -> Self {
64        self.border = border;
65        self
66    }
67
68    /// Set the desired width of the modal content area.
69    pub fn width(mut self, width: u16) -> Self {
70        self.width = width;
71        self
72    }
73}
74
75impl Focusable for Modal {
76    fn focused(&self) -> bool {
77        self.focused
78    }
79
80    fn set_focused(&mut self, focused: bool) {
81        self.focused = focused;
82        if let Some(f) = self.content.as_focusable_mut() {
83            f.set_focused(focused);
84        }
85    }
86}
87
88impl Component for Modal {
89    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
90        let w = width.min(self.width);
91        let rect = Rect::new(0, 0, w, 24); // height will be computed from content
92        self.render_rect(rect)
93    }
94
95    fn render_rect(&self, rect: Rect) -> Result<Rendered, RenderError> {
96        let theme = Theme::current();
97        let mode = ColorMode::detect();
98        let border_style = Style::new().fg(theme.border_default());
99        let border_prefix = border_style.prefix(mode);
100        let suffix = Style::suffix();
101
102        let inner_w = rect.width.saturating_sub(2);
103        let inner_h = rect.height.saturating_sub(2);
104
105        // Render content inside the modal
106        let content_rect = Rect::new(1, 1, inner_w, inner_h);
107        let content_rendered = self.content.render_rect(content_rect)?;
108
109        let content_h = content_rendered.lines.len().min(inner_h as usize) as u16;
110        let _total_h = content_h + 2;
111
112        let mut screen = Rendered::empty();
113
114        let fill_w = inner_w as usize;
115
116        // Top border
117        {
118            let mut top = String::new();
119            // Left corner
120            top.push_str(&border_prefix);
121            top.push(self.border.top_left);
122            top.push_str(suffix);
123
124            if let Some(ref title) = self.title {
125                let indicator = if self.focused { "▼ " } else { "▶ " };
126                let max_title = fill_w.saturating_sub(2);
127                let t = if title.len() > max_title {
128                    &title[..max_title]
129                } else {
130                    title
131                };
132                let label = format!(" {}{} ", indicator, t);
133                let label_styled = stylize(&label, &Style::new().fg(theme.text_primary()).bold());
134                let t_visible = crate::utils::visible_width(&label_styled);
135                let fill_count = fill_w.saturating_sub(t_visible);
136
137                top.push_str(&label_styled);
138                if fill_count > 0 {
139                    top.push_str(&border_prefix);
140                    top.push_str(&self.border.top.to_string().repeat(fill_count));
141                    top.push_str(suffix);
142                }
143            } else {
144                top.push_str(&border_prefix);
145                top.push_str(&self.border.top.to_string().repeat(fill_w));
146                top.push_str(suffix);
147            }
148
149            // Right corner
150            top.push_str(&border_prefix);
151            top.push(self.border.top_right);
152            top.push_str(suffix);
153            screen.lines.push(top);
154        }
155
156        // Content rows
157        for i in 0..content_h {
158            let mut line = String::new();
159            line.push_str(&border_prefix);
160            line.push(self.border.left);
161            line.push_str(suffix);
162
163            let content_line = content_rendered
164                .lines
165                .get(i as usize)
166                .map(|s| s.as_str())
167                .unwrap_or("");
168            let pad = inner_w as usize - crate::utils::visible_width(content_line);
169            line.push_str(content_line);
170            if pad > 0 {
171                line.push_str(&" ".repeat(pad));
172            }
173
174            line.push_str(&border_prefix);
175            line.push(self.border.right);
176            line.push_str(suffix);
177            screen.lines.push(line);
178        }
179
180        // Bottom border
181        {
182            let mut bottom = String::new();
183            bottom.push_str(&border_prefix);
184            bottom.push(self.border.bottom_left);
185            bottom.push_str(suffix);
186            bottom.push_str(&border_prefix);
187            bottom.push_str(&self.border.bottom.to_string().repeat(fill_w));
188            bottom.push_str(suffix);
189            bottom.push_str(&border_prefix);
190            bottom.push(self.border.bottom_right);
191            bottom.push_str(suffix);
192            screen.lines.push(bottom);
193        }
194
195        // Propagate cursor
196        if let Some((r, c)) = content_rendered.cursor {
197            if ((r + 1) as usize) < screen.lines.len() {
198                screen.cursor = Some((r + 1, c + 1));
199            }
200        }
201
202        Ok(screen)
203    }
204
205    fn handle_input(&mut self, event: &Event) -> InputResult {
206        self.content.handle_input(event)
207    }
208
209    fn as_focusable(&self) -> Option<&dyn Focusable> {
210        Some(self)
211    }
212
213    fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
214        Some(self)
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::{
222        components::Text,
223        theme::Theme,
224    };
225
226    #[test]
227    fn modal_renders_with_border() {
228        Theme::with(Theme::Light, || {
229            let modal = Modal::new(Box::new(Text::new("hi", 0, 0)));
230            let rendered = modal.render_rect(Rect::new(0, 0, 10, 5)).unwrap();
231            assert!(rendered.lines[0].contains("╭"));
232            assert!(rendered.lines[0].contains("╮"));
233            assert!(rendered.lines[2].contains("╰"));
234            assert!(rendered.lines[2].contains("╯"));
235        });
236    }
237
238    #[test]
239    fn modal_renders_title() {
240        Theme::with(Theme::Light, || {
241            let modal = Modal::new(Box::new(Text::new("hi", 0, 0))).title("Alert");
242            let rendered = modal.render_rect(Rect::new(0, 0, 20, 5)).unwrap();
243            assert!(rendered.lines[0].contains("Alert"));
244        });
245    }
246
247    #[test]
248    fn modal_forwards_focus() {
249        Theme::with(Theme::Light, || {
250            let mut modal = Modal::new(Box::new(Text::new("hi", 0, 0)));
251            modal.set_focused(true);
252            assert!(modal.focused());
253        });
254    }
255}