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 = match self.content.render_rect(content_rect) {
108            | Ok(r) => r,
109            | Err(e) => return Err(e),
110        };
111
112        let content_h = content_rendered.lines.len().min(inner_h as usize) as u16;
113        let _total_h = content_h + 2;
114
115        let mut screen = Rendered::empty();
116
117        let fill_w = inner_w as usize;
118
119        // Top border
120        {
121            let mut top = String::new();
122            // Left corner
123            top.push_str(&border_prefix);
124            top.push(self.border.top_left);
125            top.push_str(suffix);
126
127            if let Some(ref title) = self.title {
128                let indicator = if self.focused { "▼ " } else { "▶ " };
129                let max_title = fill_w.saturating_sub(2);
130                let t = if title.len() > max_title {
131                    &title[..max_title]
132                } else {
133                    title
134                };
135                let label = format!(" {}{} ", indicator, t);
136                let label_styled = stylize(&label, &Style::new().fg(theme.text_primary()).bold());
137                let t_visible = crate::utils::visible_width(&label_styled);
138                let fill_count = fill_w.saturating_sub(t_visible);
139
140                top.push_str(&label_styled);
141                if fill_count > 0 {
142                    top.push_str(&border_prefix);
143                    top.push_str(&self.border.top.to_string().repeat(fill_count));
144                    top.push_str(suffix);
145                }
146            } else {
147                top.push_str(&border_prefix);
148                top.push_str(&self.border.top.to_string().repeat(fill_w));
149                top.push_str(suffix);
150            }
151
152            // Right corner
153            top.push_str(&border_prefix);
154            top.push(self.border.top_right);
155            top.push_str(suffix);
156            screen.lines.push(top);
157        }
158
159        // Content rows
160        for i in 0..content_h {
161            let mut line = String::new();
162            line.push_str(&border_prefix);
163            line.push(self.border.left);
164            line.push_str(suffix);
165
166            let content_line = content_rendered
167                .lines
168                .get(i as usize)
169                .map(|s| s.as_str())
170                .unwrap_or("");
171            let pad = inner_w as usize - crate::utils::visible_width(content_line);
172            line.push_str(content_line);
173            if pad > 0 {
174                line.push_str(&" ".repeat(pad));
175            }
176
177            line.push_str(&border_prefix);
178            line.push(self.border.right);
179            line.push_str(suffix);
180            screen.lines.push(line);
181        }
182
183        // Bottom border
184        {
185            let mut bottom = String::new();
186            bottom.push_str(&border_prefix);
187            bottom.push(self.border.bottom_left);
188            bottom.push_str(suffix);
189            bottom.push_str(&border_prefix);
190            bottom.push_str(&self.border.bottom.to_string().repeat(fill_w));
191            bottom.push_str(suffix);
192            bottom.push_str(&border_prefix);
193            bottom.push(self.border.bottom_right);
194            bottom.push_str(suffix);
195            screen.lines.push(bottom);
196        }
197
198        // Propagate cursor
199        if let Some((r, c)) = content_rendered.cursor &&
200            r + 1 < screen.lines.len()
201        {
202            screen.cursor = Some((r + 1, c + 1));
203        }
204
205        Ok(screen)
206    }
207
208    fn handle_input(&mut self, event: &Event) -> InputResult {
209        self.content.handle_input(event)
210    }
211
212    fn as_focusable(&self) -> Option<&dyn Focusable> {
213        Some(self)
214    }
215
216    fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
217        Some(self)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::{
225        components::Text,
226        theme::Theme,
227    };
228
229    #[test]
230    fn modal_renders_with_border() {
231        Theme::with(Theme::Light, || {
232            let modal = Modal::new(Box::new(Text::new("hi", 0, 0)));
233            let rendered = modal.render_rect(Rect::new(0, 0, 10, 5)).unwrap();
234            assert!(rendered.lines[0].contains("╭"));
235            assert!(rendered.lines[0].contains("╮"));
236            assert!(rendered.lines[2].contains("╰"));
237            assert!(rendered.lines[2].contains("╯"));
238        });
239    }
240
241    #[test]
242    fn modal_renders_title() {
243        Theme::with(Theme::Light, || {
244            let modal = Modal::new(Box::new(Text::new("hi", 0, 0))).title("Alert");
245            let rendered = modal.render_rect(Rect::new(0, 0, 20, 5)).unwrap();
246            assert!(rendered.lines[0].contains("Alert"));
247        });
248    }
249
250    #[test]
251    fn modal_forwards_focus() {
252        Theme::with(Theme::Light, || {
253            let mut modal = Modal::new(Box::new(Text::new("hi", 0, 0)));
254            modal.set_focused(true);
255            assert!(modal.focused());
256        });
257    }
258}