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 = 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 {
121 let mut top = String::new();
122 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 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 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 {
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 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}