1use jag_draw::{Brush, ColorLinPremul, Rect, RoundedRadii, RoundedRect};
4use jag_surface::Canvas;
5
6use crate::event::{
7 ElementState, EventHandler, EventResult, KeyCode, KeyboardEvent, MouseButton, MouseClickEvent,
8 MouseMoveEvent, ScrollEvent,
9};
10use crate::focus::FocusId;
11
12use super::Element;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ModalClickResult {
17 CloseButton,
19 Button(usize),
21 Background,
23 Panel,
25}
26
27#[derive(Debug, Clone)]
29pub struct ModalButton {
30 pub label: String,
31 pub primary: bool,
32}
33
34impl ModalButton {
35 pub fn new(label: impl Into<String>) -> Self {
36 Self {
37 label: label.into(),
38 primary: false,
39 }
40 }
41
42 pub fn primary(label: impl Into<String>) -> Self {
43 Self {
44 label: label.into(),
45 primary: true,
46 }
47 }
48}
49
50pub struct Modal {
53 pub rect: Rect,
55 pub panel_width: f32,
57 pub panel_height: f32,
59 pub title: String,
61 pub content: String,
63 pub buttons: Vec<ModalButton>,
65 pub overlay_color: ColorLinPremul,
67 pub panel_bg: ColorLinPremul,
69 pub panel_border_color: ColorLinPremul,
71 pub title_color: ColorLinPremul,
73 pub content_color: ColorLinPremul,
75 pub title_size: f32,
77 pub content_size: f32,
79 pub button_label_size: f32,
81 pub panel_radius: f32,
83 pub visible: bool,
85 pub focus_id: FocusId,
87}
88
89impl Modal {
90 pub fn new(
92 viewport: Rect,
93 title: impl Into<String>,
94 content: impl Into<String>,
95 buttons: Vec<ModalButton>,
96 ) -> Self {
97 Self {
98 rect: viewport,
99 panel_width: 480.0,
100 panel_height: 300.0,
101 title: title.into(),
102 content: content.into(),
103 buttons,
104 overlay_color: ColorLinPremul::from_srgba_u8([0, 0, 0, 140]),
105 panel_bg: ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
106 panel_border_color: ColorLinPremul::from_srgba_u8([200, 200, 200, 255]),
107 title_color: ColorLinPremul::from_srgba_u8([20, 20, 20, 255]),
108 content_color: ColorLinPremul::from_srgba_u8([60, 60, 60, 255]),
109 title_size: 20.0,
110 content_size: 14.0,
111 button_label_size: 14.0,
112 panel_radius: 8.0,
113 visible: true,
114 focus_id: FocusId(0),
115 }
116 }
117
118 pub fn panel_rect(&self) -> Rect {
120 Rect {
121 x: self.rect.x + (self.rect.w - self.panel_width) * 0.5,
122 y: self.rect.y + (self.rect.h - self.panel_height) * 0.5,
123 w: self.panel_width,
124 h: self.panel_height,
125 }
126 }
127
128 pub fn close_button_rect(&self) -> Rect {
130 let panel = self.panel_rect();
131 let size = 32.0;
132 Rect {
133 x: panel.x + panel.w - size - 8.0,
134 y: panel.y + 8.0,
135 w: size,
136 h: size,
137 }
138 }
139
140 pub fn button_rects(&self) -> Vec<Rect> {
142 let panel = self.panel_rect();
143 let btn_h = 36.0;
144 let btn_w = 100.0;
145 let spacing = 12.0;
146 let n = self.buttons.len();
147 if n == 0 {
148 return vec![];
149 }
150 let total_w = btn_w * n as f32 + spacing * (n - 1) as f32;
151 let start_x = panel.x + (panel.w - total_w) * 0.5;
152 let y = panel.y + panel.h - 20.0 - btn_h;
153 (0..n)
154 .map(|i| Rect {
155 x: start_x + (btn_w + spacing) * i as f32,
156 y,
157 w: btn_w,
158 h: btn_h,
159 })
160 .collect()
161 }
162
163 pub fn handle_click(&self, x: f32, y: f32) -> ModalClickResult {
165 let panel = self.panel_rect();
166 let in_panel =
167 x >= panel.x && x <= panel.x + panel.w && y >= panel.y && y <= panel.y + panel.h;
168
169 if !in_panel {
170 return ModalClickResult::Background;
171 }
172
173 let close = self.close_button_rect();
175 if x >= close.x && x <= close.x + close.w && y >= close.y && y <= close.y + close.h {
176 return ModalClickResult::CloseButton;
177 }
178
179 for (i, r) in self.button_rects().iter().enumerate() {
181 if x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h {
182 return ModalClickResult::Button(i);
183 }
184 }
185
186 ModalClickResult::Panel
187 }
188}
189
190impl Element for Modal {
195 fn rect(&self) -> Rect {
196 self.rect
197 }
198
199 fn set_rect(&mut self, rect: Rect) {
200 self.rect = rect;
201 }
202
203 fn render(&self, canvas: &mut Canvas, z: i32) {
204 if !self.visible {
205 return;
206 }
207
208 canvas.fill_rect(
210 self.rect.x,
211 self.rect.y,
212 self.rect.w,
213 self.rect.h,
214 Brush::Solid(self.overlay_color),
215 z,
216 );
217
218 let panel = self.panel_rect();
220 let rrect = RoundedRect {
221 rect: panel,
222 radii: RoundedRadii {
223 tl: self.panel_radius,
224 tr: self.panel_radius,
225 br: self.panel_radius,
226 bl: self.panel_radius,
227 },
228 };
229 jag_surface::shapes::draw_snapped_rounded_rectangle(
230 canvas,
231 rrect,
232 Some(Brush::Solid(self.panel_bg)),
233 Some(1.0),
234 Some(Brush::Solid(self.panel_border_color)),
235 z + 1,
236 );
237
238 let close = self.close_button_rect();
240 let x_text_x = close.x + close.w * 0.5 - 4.0;
241 let x_text_y = close.y + close.h * 0.5 + 5.0;
242 canvas.draw_text_run_weighted(
243 [x_text_x, x_text_y],
244 "\u{2715}".to_string(),
245 14.0,
246 400.0,
247 ColorLinPremul::from_srgba_u8([100, 100, 100, 255]),
248 z + 3,
249 );
250
251 canvas.draw_text_run_weighted(
253 [panel.x + 20.0, panel.y + 30.0],
254 self.title.clone(),
255 self.title_size,
256 600.0,
257 self.title_color,
258 z + 2,
259 );
260
261 let line_height = self.content_size * 1.4;
263 let content_y = panel.y + 70.0;
264 for (i, line) in self.content.split('\n').enumerate() {
265 canvas.draw_text_run_weighted(
266 [panel.x + 20.0, content_y + i as f32 * line_height],
267 line.to_string(),
268 self.content_size,
269 400.0,
270 self.content_color,
271 z + 2,
272 );
273 }
274
275 for (i, (button, btn_rect)) in self.buttons.iter().zip(self.button_rects()).enumerate() {
277 let (bg, fg) = if button.primary {
278 (
279 ColorLinPremul::from_srgba_u8([59, 130, 246, 255]),
280 ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
281 )
282 } else {
283 (
284 ColorLinPremul::from_srgba_u8([240, 240, 240, 255]),
285 ColorLinPremul::from_srgba_u8([60, 60, 60, 255]),
286 )
287 };
288
289 let btn_rrect = RoundedRect {
290 rect: btn_rect,
291 radii: RoundedRadii {
292 tl: 6.0,
293 tr: 6.0,
294 br: 6.0,
295 bl: 6.0,
296 },
297 };
298 canvas.rounded_rect(btn_rrect, Brush::Solid(bg), z + 3 + i as i32);
299
300 let text_w = button.label.len() as f32 * self.button_label_size * 0.5;
301 let tx = btn_rect.x + (btn_rect.w - text_w) * 0.5;
302 let ty = btn_rect.y + btn_rect.h * 0.5 + self.button_label_size * 0.35;
303 canvas.draw_text_run_weighted(
304 [tx, ty],
305 button.label.clone(),
306 self.button_label_size,
307 600.0,
308 fg,
309 z + 4 + i as i32,
310 );
311 }
312 }
313
314 fn focus_id(&self) -> Option<FocusId> {
315 Some(self.focus_id)
316 }
317}
318
319impl EventHandler for Modal {
324 fn handle_mouse_click(&mut self, event: &MouseClickEvent) -> EventResult {
325 if !self.visible {
326 return EventResult::Ignored;
327 }
328 if event.button != MouseButton::Left || event.state != ElementState::Pressed {
329 return EventResult::Ignored;
330 }
331 let _result = self.handle_click(event.x, event.y);
333 EventResult::Handled
334 }
335
336 fn handle_keyboard(&mut self, event: &KeyboardEvent) -> EventResult {
337 if !self.visible || event.state != ElementState::Pressed {
338 return EventResult::Ignored;
339 }
340 match event.key {
341 KeyCode::Escape => EventResult::Handled,
342 KeyCode::Enter => {
343 if self.buttons.iter().any(|b| b.primary) {
344 EventResult::Handled
345 } else {
346 EventResult::Ignored
347 }
348 }
349 _ => EventResult::Ignored,
350 }
351 }
352
353 fn handle_mouse_move(&mut self, _event: &MouseMoveEvent) -> EventResult {
354 if self.visible {
355 EventResult::Handled
356 } else {
357 EventResult::Ignored
358 }
359 }
360
361 fn handle_scroll(&mut self, _event: &ScrollEvent) -> EventResult {
362 if self.visible {
363 EventResult::Handled
364 } else {
365 EventResult::Ignored
366 }
367 }
368
369 fn is_focused(&self) -> bool {
370 self.visible
371 }
372
373 fn set_focused(&mut self, _focused: bool) {}
374
375 fn contains_point(&self, _x: f32, _y: f32) -> bool {
376 self.visible
378 }
379}
380
381#[cfg(test)]
386mod tests {
387 use super::*;
388
389 fn viewport() -> Rect {
390 Rect {
391 x: 0.0,
392 y: 0.0,
393 w: 800.0,
394 h: 600.0,
395 }
396 }
397
398 #[test]
399 fn modal_panel_centered() {
400 let m = Modal::new(viewport(), "Title", "Body", vec![]);
401 let p = m.panel_rect();
402 let cx = p.x + p.w * 0.5;
403 let cy = p.y + p.h * 0.5;
404 assert!((cx - 400.0).abs() < 1.0);
405 assert!((cy - 300.0).abs() < 1.0);
406 }
407
408 #[test]
409 fn modal_click_background() {
410 let m = Modal::new(viewport(), "T", "C", vec![]);
411 let result = m.handle_click(0.0, 0.0);
412 assert_eq!(result, ModalClickResult::Background);
413 }
414
415 #[test]
416 fn modal_click_close_button() {
417 let m = Modal::new(viewport(), "T", "C", vec![]);
418 let close = m.close_button_rect();
419 let result = m.handle_click(close.x + 5.0, close.y + 5.0);
420 assert_eq!(result, ModalClickResult::CloseButton);
421 }
422
423 #[test]
424 fn modal_click_action_button() {
425 let m = Modal::new(
426 viewport(),
427 "T",
428 "C",
429 vec![ModalButton::new("Cancel"), ModalButton::primary("OK")],
430 );
431 let rects = m.button_rects();
432 assert_eq!(rects.len(), 2);
433 let r = rects[1];
434 let result = m.handle_click(r.x + 5.0, r.y + 5.0);
435 assert_eq!(result, ModalClickResult::Button(1));
436 }
437
438 #[test]
439 fn modal_captures_input_when_visible() {
440 let m = Modal::new(viewport(), "T", "C", vec![]);
441 assert!(m.contains_point(0.0, 0.0));
442 }
443
444 #[test]
445 fn modal_ignores_when_hidden() {
446 let mut m = Modal::new(viewport(), "T", "C", vec![]);
447 m.visible = false;
448 assert!(!m.contains_point(400.0, 300.0));
449 }
450
451 #[test]
452 fn modal_escape_handled() {
453 let mut m = Modal::new(viewport(), "T", "C", vec![]);
454 let evt = KeyboardEvent {
455 key: KeyCode::Escape,
456 state: ElementState::Pressed,
457 modifiers: Default::default(),
458 text: None,
459 };
460 assert_eq!(m.handle_keyboard(&evt), EventResult::Handled);
461 }
462}