1#![forbid(unsafe_code)]
2
3use crate::Widget;
13use crate::set_style_area;
14use ftui_core::event::{
15 Event, KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind,
16};
17use ftui_core::geometry::{Rect, Size};
18use ftui_render::cell::PackedRgba;
19use ftui_render::frame::{Frame, HitData, HitId, HitRegion};
20use ftui_style::Style;
21
22pub const MODAL_HIT_BACKDROP: HitRegion = HitRegion::Custom(1);
24pub const MODAL_HIT_CONTENT: HitRegion = HitRegion::Custom(2);
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ModalAction {
30 Close,
32 BackdropClicked,
34 EscapePressed,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct BackdropConfig {
41 pub color: PackedRgba,
43 pub opacity: f32,
45}
46
47impl BackdropConfig {
48 pub fn new(color: PackedRgba, opacity: f32) -> Self {
50 Self { color, opacity }
51 }
52
53 pub fn color(mut self, color: PackedRgba) -> Self {
55 self.color = color;
56 self
57 }
58
59 pub fn opacity(mut self, opacity: f32) -> Self {
61 self.opacity = opacity;
62 self
63 }
64}
65
66impl Default for BackdropConfig {
67 fn default() -> Self {
68 Self {
69 color: PackedRgba::rgb(0, 0, 0),
70 opacity: 0.6,
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub struct ModalSizeConstraints {
78 pub min_width: Option<u16>,
79 pub max_width: Option<u16>,
80 pub min_height: Option<u16>,
81 pub max_height: Option<u16>,
82}
83
84impl ModalSizeConstraints {
85 pub const fn new() -> Self {
87 Self {
88 min_width: None,
89 max_width: None,
90 min_height: None,
91 max_height: None,
92 }
93 }
94
95 pub fn min_width(mut self, value: u16) -> Self {
97 self.min_width = Some(value);
98 self
99 }
100
101 pub fn max_width(mut self, value: u16) -> Self {
103 self.max_width = Some(value);
104 self
105 }
106
107 pub fn min_height(mut self, value: u16) -> Self {
109 self.min_height = Some(value);
110 self
111 }
112
113 pub fn max_height(mut self, value: u16) -> Self {
115 self.max_height = Some(value);
116 self
117 }
118
119 pub fn clamp(self, available: Size) -> Size {
121 let mut width = available.width;
122 let mut height = available.height;
123
124 if let Some(max_width) = self.max_width {
125 width = width.min(max_width);
126 }
127 if let Some(max_height) = self.max_height {
128 height = height.min(max_height);
129 }
130 if let Some(min_width) = self.min_width {
131 width = width.max(min_width).min(available.width);
132 }
133 if let Some(min_height) = self.min_height {
134 height = height.max(min_height).min(available.height);
135 }
136
137 Size::new(width, height)
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
143pub enum ModalPosition {
144 #[default]
145 Center,
146 CenterOffset {
147 x: i16,
148 y: i16,
149 },
150 TopCenter {
151 margin: u16,
152 },
153 Custom {
154 x: u16,
155 y: u16,
156 },
157}
158
159impl ModalPosition {
160 fn resolve(self, area: Rect, size: Size) -> Rect {
161 let base_x = area.x as i32;
162 let base_y = area.y as i32;
163 let max_x = base_x + (area.width as i32 - size.width as i32);
164 let max_y = base_y + (area.height as i32 - size.height as i32);
165
166 let (mut x, mut y) = match self {
167 Self::Center => (
168 base_x + (area.width as i32 - size.width as i32) / 2,
169 base_y + (area.height as i32 - size.height as i32) / 2,
170 ),
171 Self::CenterOffset { x, y } => (
172 base_x + (area.width as i32 - size.width as i32) / 2 + x as i32,
173 base_y + (area.height as i32 - size.height as i32) / 2 + y as i32,
174 ),
175 Self::TopCenter { margin } => (
176 base_x + (area.width as i32 - size.width as i32) / 2,
177 base_y + margin as i32,
178 ),
179 Self::Custom { x, y } => (x as i32, y as i32),
180 };
181
182 x = x.clamp(base_x, max_x);
183 y = y.clamp(base_y, max_y);
184
185 Rect::new(x as u16, y as u16, size.width, size.height)
186 }
187}
188
189#[derive(Debug, Clone)]
191pub struct ModalConfig {
192 pub position: ModalPosition,
193 pub backdrop: BackdropConfig,
194 pub size: ModalSizeConstraints,
195 pub close_on_backdrop: bool,
196 pub close_on_escape: bool,
197 pub hit_id: Option<HitId>,
198}
199
200impl Default for ModalConfig {
201 fn default() -> Self {
202 Self {
203 position: ModalPosition::Center,
204 backdrop: BackdropConfig::default(),
205 size: ModalSizeConstraints::default(),
206 close_on_backdrop: true,
207 close_on_escape: true,
208 hit_id: None,
209 }
210 }
211}
212
213impl ModalConfig {
214 pub fn position(mut self, position: ModalPosition) -> Self {
215 self.position = position;
216 self
217 }
218
219 pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
220 self.backdrop = backdrop;
221 self
222 }
223
224 pub fn size(mut self, size: ModalSizeConstraints) -> Self {
225 self.size = size;
226 self
227 }
228
229 pub fn close_on_backdrop(mut self, close: bool) -> Self {
230 self.close_on_backdrop = close;
231 self
232 }
233
234 pub fn close_on_escape(mut self, close: bool) -> Self {
235 self.close_on_escape = close;
236 self
237 }
238
239 pub fn hit_id(mut self, id: HitId) -> Self {
240 self.hit_id = Some(id);
241 self
242 }
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct ModalState {
248 open: bool,
249}
250
251impl Default for ModalState {
252 fn default() -> Self {
253 Self { open: true }
254 }
255}
256
257impl ModalState {
258 pub fn is_open(&self) -> bool {
259 self.open
260 }
261
262 pub fn open(&mut self) {
263 self.open = true;
264 }
265
266 pub fn close(&mut self) {
267 self.open = false;
268 }
269
270 pub fn handle_event(
275 &mut self,
276 event: &Event,
277 hit: Option<(HitId, HitRegion, HitData)>,
278 config: &ModalConfig,
279 ) -> Option<ModalAction> {
280 if !self.open {
281 return None;
282 }
283
284 match event {
285 Event::Key(KeyEvent {
286 code: KeyCode::Escape,
287 kind: KeyEventKind::Press,
288 ..
289 }) if config.close_on_escape => {
290 self.open = false;
291 return Some(ModalAction::EscapePressed);
292 }
293 Event::Mouse(MouseEvent {
294 kind: MouseEventKind::Down(MouseButton::Left),
295 ..
296 }) if config.close_on_backdrop => {
297 if let (Some((id, region, _)), Some(expected)) = (hit, config.hit_id)
298 && id == expected
299 && region == MODAL_HIT_BACKDROP
300 {
301 self.open = false;
302 return Some(ModalAction::BackdropClicked);
303 }
304 }
305 _ => {}
306 }
307
308 None
309 }
310}
311
312#[derive(Debug, Clone)]
324pub struct Modal<C> {
325 content: C,
326 config: ModalConfig,
327}
328
329impl<C> Modal<C> {
330 pub fn new(content: C) -> Self {
332 Self {
333 content,
334 config: ModalConfig::default(),
335 }
336 }
337
338 pub fn config(mut self, config: ModalConfig) -> Self {
340 self.config = config;
341 self
342 }
343
344 pub fn position(mut self, position: ModalPosition) -> Self {
346 self.config.position = position;
347 self
348 }
349
350 pub fn backdrop(mut self, backdrop: BackdropConfig) -> Self {
352 self.config.backdrop = backdrop;
353 self
354 }
355
356 pub fn size(mut self, size: ModalSizeConstraints) -> Self {
358 self.config.size = size;
359 self
360 }
361
362 pub fn close_on_backdrop(mut self, close: bool) -> Self {
364 self.config.close_on_backdrop = close;
365 self
366 }
367
368 pub fn close_on_escape(mut self, close: bool) -> Self {
370 self.config.close_on_escape = close;
371 self
372 }
373
374 pub fn hit_id(mut self, id: HitId) -> Self {
376 self.config.hit_id = Some(id);
377 self
378 }
379
380 pub fn content_rect(&self, area: Rect) -> Rect {
382 let available = Size::new(area.width, area.height);
383 let size = self.config.size.clamp(available);
384 if size.width == 0 || size.height == 0 {
385 return Rect::new(area.x, area.y, 0, 0);
386 }
387 self.config.position.resolve(area, size)
388 }
389}
390
391impl<C: Widget> Widget for Modal<C> {
392 fn render(&self, area: Rect, frame: &mut Frame) {
393 if area.is_empty() {
394 return;
395 }
396
397 let opacity = self.config.backdrop.opacity.clamp(0.0, 1.0);
399 if opacity > 0.0 {
400 let bg = self.config.backdrop.color.with_opacity(opacity);
401 set_style_area(&mut frame.buffer, area, Style::new().bg(bg));
402 }
403
404 let content_area = self.content_rect(area);
405 if !content_area.is_empty() {
406 self.content.render(content_area, frame);
407 }
408
409 if let Some(hit_id) = self.config.hit_id {
411 frame.register_hit(area, hit_id, MODAL_HIT_BACKDROP, 0);
412 if !content_area.is_empty() {
413 frame.register_hit(content_area, hit_id, MODAL_HIT_CONTENT, 0);
414 }
415 }
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use ftui_render::frame::Frame;
423 use ftui_render::grapheme_pool::GraphemePool;
424
425 #[derive(Debug, Clone)]
426 struct Stub;
427
428 impl Widget for Stub {
429 fn render(&self, _area: Rect, _frame: &mut Frame) {}
430 }
431
432 #[test]
433 fn center_positioning() {
434 let modal = Modal::new(Stub).size(
435 ModalSizeConstraints::new()
436 .min_width(10)
437 .max_width(10)
438 .min_height(4)
439 .max_height(4),
440 );
441 let area = Rect::new(0, 0, 40, 20);
442 let rect = modal.content_rect(area);
443 assert_eq!(rect, Rect::new(15, 8, 10, 4));
444 }
445
446 #[test]
447 fn offset_positioning() {
448 let modal = Modal::new(Stub)
449 .size(
450 ModalSizeConstraints::new()
451 .min_width(10)
452 .max_width(10)
453 .min_height(4)
454 .max_height(4),
455 )
456 .position(ModalPosition::CenterOffset { x: -2, y: 3 });
457 let area = Rect::new(0, 0, 40, 20);
458 let rect = modal.content_rect(area);
459 assert_eq!(rect, Rect::new(13, 11, 10, 4));
460 }
461
462 #[test]
463 fn size_constraints_respect_available() {
464 let modal = Modal::new(Stub).size(
465 ModalSizeConstraints::new()
466 .min_width(10)
467 .max_width(30)
468 .min_height(6)
469 .max_height(20),
470 );
471 let area = Rect::new(0, 0, 8, 4);
472 let rect = modal.content_rect(area);
473 assert_eq!(rect.width, 8);
474 assert_eq!(rect.height, 4);
475 }
476
477 #[test]
478 fn hit_regions_registered() {
479 let modal = Modal::new(Stub)
480 .size(
481 ModalSizeConstraints::new()
482 .min_width(6)
483 .max_width(6)
484 .min_height(3)
485 .max_height(3),
486 )
487 .hit_id(HitId::new(7));
488
489 let mut pool = GraphemePool::new();
490 let mut frame = Frame::with_hit_grid(20, 10, &mut pool);
491 let area = Rect::new(0, 0, 20, 10);
492 modal.render(area, &mut frame);
493
494 let backdrop_hit = frame.hit_test(0, 0);
495 assert_eq!(backdrop_hit, Some((HitId::new(7), MODAL_HIT_BACKDROP, 0)));
496
497 let content = modal.content_rect(area);
498 let cx = content.x + 1;
499 let cy = content.y + 1;
500 let content_hit = frame.hit_test(cx, cy);
501 assert_eq!(content_hit, Some((HitId::new(7), MODAL_HIT_CONTENT, 0)));
502 }
503
504 #[test]
505 fn backdrop_click_triggers_close() {
506 let mut state = ModalState::default();
507 let config = ModalConfig::default().hit_id(HitId::new(9));
508 let hit = Some((HitId::new(9), MODAL_HIT_BACKDROP, 0));
509 let event = Event::Mouse(MouseEvent::new(
510 MouseEventKind::Down(MouseButton::Left),
511 0,
512 0,
513 ));
514
515 let action = state.handle_event(&event, hit, &config);
516 assert_eq!(action, Some(ModalAction::BackdropClicked));
517 assert!(!state.is_open());
518 }
519
520 #[test]
521 fn content_rect_within_bounds_for_positions() {
522 let base_constraints = ModalSizeConstraints::new()
523 .min_width(2)
524 .min_height(2)
525 .max_width(30)
526 .max_height(10);
527 let positions = [
528 ModalPosition::Center,
529 ModalPosition::CenterOffset { x: 3, y: -2 },
530 ModalPosition::TopCenter { margin: 1 },
531 ModalPosition::Custom { x: 100, y: 100 },
532 ];
533 let areas = [
534 Rect::new(0, 0, 10, 6),
535 Rect::new(2, 3, 40, 20),
536 Rect::new(5, 1, 8, 4),
537 ];
538
539 for area in areas {
540 for &position in &positions {
541 let modal = Modal::new(Stub).size(base_constraints).position(position);
542 let rect = modal.content_rect(area);
543 if rect.is_empty() {
544 continue;
545 }
546 assert!(rect.x >= area.x);
547 assert!(rect.y >= area.y);
548 assert!(rect.right() <= area.right());
549 assert!(rect.bottom() <= area.bottom());
550 }
551 }
552 }
553}