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 Palette,
15 Style,
16 Theme,
17 stylize,
18 },
19};
20
21pub 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 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 pub fn title(mut self, title: impl Into<String>) -> Self {
58 self.title = Some(title.into());
59 self
60 }
61
62 pub fn border(mut self, border: Border) -> Self {
64 self.border = border;
65 self
66 }
67
68 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); 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 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 {
118 let mut top = String::new();
119 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 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 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 {
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 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}