Skip to main content

tui_dispatch_components/
modal.rs

1//! Modal overlay component with background dimming
2//!
3//! Dims the background on each frame (keeping animations live) and renders
4//! modal content on top.
5
6use std::rc::Rc;
7
8use crossterm::event::KeyCode;
9use ratatui::{buffer::Buffer, layout::Rect, style::Color, widgets::Widget, Frame};
10use tui_dispatch_core::{Component, EventKind};
11
12use crate::style::{BaseStyle, BorderStyle, ComponentStyle};
13
14/// Configuration for modal appearance
15///
16/// Follows the standard component style pattern with `base` plus a dim factor.
17pub struct ModalStyle {
18    /// Dim factor for background (0.0 = no dim, 1.0 = black)
19    pub dim_factor: f32,
20    /// Shared base style
21    pub base: BaseStyle,
22}
23
24impl Default for ModalStyle {
25    fn default() -> Self {
26        Self {
27            dim_factor: 0.5,
28            base: BaseStyle {
29                border: None,
30                fg: None,
31                ..Default::default()
32            },
33        }
34    }
35}
36
37impl ModalStyle {
38    /// Create a style with a background color
39    pub fn with_bg(bg: Color) -> Self {
40        let mut style = Self::default();
41        style.base.bg = Some(bg);
42        style
43    }
44
45    /// Create a style with a background color and border
46    pub fn with_bg_and_border(bg: Color, border: BorderStyle) -> Self {
47        let mut style = Self::default();
48        style.base.bg = Some(bg);
49        style.base.border = Some(border);
50        style
51    }
52}
53
54impl ComponentStyle for ModalStyle {
55    fn base(&self) -> &BaseStyle {
56        &self.base
57    }
58}
59
60/// Behavior configuration for Modal
61#[derive(Debug, Clone)]
62pub struct ModalBehavior {
63    /// Close when the escape key is pressed
64    pub close_on_esc: bool,
65    /// Close when clicking outside the modal area
66    pub close_on_backdrop: bool,
67}
68
69impl Default for ModalBehavior {
70    fn default() -> Self {
71        Self {
72            close_on_esc: true,
73            close_on_backdrop: false,
74        }
75    }
76}
77
78/// Callback to create an action when the modal should close.
79pub type ModalCloseCallback<A> = Rc<dyn Fn() -> A>;
80
81/// Props for Modal component
82pub struct ModalProps<'a, A> {
83    /// Whether the modal is open
84    pub is_open: bool,
85    /// Whether this component has focus
86    pub is_focused: bool,
87    /// Modal area to render in
88    pub area: Rect,
89    /// Unified styling
90    pub style: ModalStyle,
91    /// Behavior configuration
92    pub behavior: ModalBehavior,
93    /// Callback when the modal should close
94    pub on_close: ModalCloseCallback<A>,
95    /// Render modal content into the inner area
96    pub render_content: &'a mut dyn FnMut(&mut Frame, Rect),
97}
98
99/// Modal overlay component
100#[derive(Default)]
101pub struct Modal;
102
103impl Modal {
104    /// Create a new Modal
105    pub fn new() -> Self {
106        Self
107    }
108}
109
110impl<A> Component<A> for Modal {
111    type Props<'a> = ModalProps<'a, A>;
112
113    fn handle_event(
114        &mut self,
115        event: &EventKind,
116        props: Self::Props<'_>,
117    ) -> impl IntoIterator<Item = A> {
118        if !props.is_open {
119            return None;
120        }
121
122        match event {
123            EventKind::Key(key) if props.behavior.close_on_esc && key.code == KeyCode::Esc => {
124                Some((props.on_close.as_ref())())
125            }
126            EventKind::Mouse(mouse) if props.behavior.close_on_backdrop => {
127                if !point_in_rect(props.area, mouse.column, mouse.row) {
128                    Some((props.on_close.as_ref())())
129                } else {
130                    None
131                }
132            }
133            _ => None,
134        }
135    }
136
137    #[allow(unused_mut)]
138    fn render(&mut self, frame: &mut Frame, _area: Rect, mut props: Self::Props<'_>) {
139        if !props.is_open {
140            return;
141        }
142
143        let style = &props.style;
144        let area = props.area;
145
146        // Dim the background (everything rendered so far)
147        if style.dim_factor > 0.0 {
148            dim_buffer(frame.buffer_mut(), style.dim_factor);
149        }
150
151        // Fill modal area with background color
152        if let Some(bg) = style.base.bg {
153            frame.render_widget(BgFill(bg), area);
154        }
155
156        // Calculate content area (inside border and padding)
157        let mut content_area = area;
158
159        // Render border if configured
160        if let Some(border) = &style.base.border {
161            use ratatui::widgets::Block;
162            let block = Block::default()
163                .borders(border.borders)
164                .border_style(border.style_for_focus(props.is_focused));
165            frame.render_widget(block, area);
166
167            // Shrink content area for border
168            content_area = Rect {
169                x: content_area.x + 1,
170                y: content_area.y + 1,
171                width: content_area.width.saturating_sub(2),
172                height: content_area.height.saturating_sub(2),
173            };
174        }
175
176        // Apply padding
177        let inner_area = Rect {
178            x: content_area.x + style.base.padding.left,
179            y: content_area.y + style.base.padding.top,
180            width: content_area
181                .width
182                .saturating_sub(style.base.padding.horizontal()),
183            height: content_area
184                .height
185                .saturating_sub(style.base.padding.vertical()),
186        };
187
188        (props.render_content)(frame, inner_area);
189    }
190}
191
192fn point_in_rect(area: Rect, x: u16, y: u16) -> bool {
193    x >= area.x
194        && x < area.x.saturating_add(area.width)
195        && y >= area.y
196        && y < area.y.saturating_add(area.height)
197}
198
199/// Simple widget that fills an area with a background color
200struct BgFill(Color);
201
202impl Widget for BgFill {
203    fn render(self, area: Rect, buf: &mut Buffer) {
204        for y in area.y..area.y.saturating_add(area.height) {
205            for x in area.x..area.x.saturating_add(area.width) {
206                buf[(x, y)].set_bg(self.0);
207                buf[(x, y)].set_symbol(" ");
208            }
209        }
210    }
211}
212
213/// Calculate a centered rectangle within an area
214pub fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
215    let width = width.min(area.width.saturating_sub(2));
216    let height = height.min(area.height.saturating_sub(2));
217    let x = area.x + (area.width.saturating_sub(width)) / 2;
218    let y = area.y + (area.height.saturating_sub(height)) / 2;
219    Rect::new(x, y, width, height)
220}
221
222/// Dim a buffer by scaling colors towards black
223///
224/// `factor` ranges from 0.0 (no change) to 1.0 (fully dimmed/black).
225/// Emoji characters are replaced with spaces (they can't be dimmed).
226fn dim_buffer(buffer: &mut Buffer, factor: f32) {
227    let factor = factor.clamp(0.0, 1.0);
228    let scale = 1.0 - factor;
229
230    for cell in buffer.content.iter_mut() {
231        if contains_emoji(cell.symbol()) {
232            cell.set_symbol(" ");
233        }
234        cell.fg = dim_color(cell.fg, scale);
235        cell.bg = dim_color(cell.bg, scale);
236    }
237}
238
239fn contains_emoji(s: &str) -> bool {
240    s.chars().any(is_emoji)
241}
242
243fn is_emoji(c: char) -> bool {
244    let cp = c as u32;
245    matches!(
246        cp,
247        0x1F300..=0x1F5FF |
248        0x1F600..=0x1F64F |
249        0x1F680..=0x1F6FF |
250        0x1F900..=0x1F9FF |
251        0x1FA00..=0x1FA6F |
252        0x1FA70..=0x1FAFF |
253        0x1F1E0..=0x1F1FF
254    )
255}
256
257fn dim_color(color: Color, scale: f32) -> Color {
258    match color {
259        Color::Rgb(r, g, b) => Color::Rgb(
260            ((r as f32) * scale) as u8,
261            ((g as f32) * scale) as u8,
262            ((b as f32) * scale) as u8,
263        ),
264        Color::Indexed(idx) => indexed_to_rgb(idx)
265            .map(|(r, g, b)| {
266                Color::Rgb(
267                    ((r as f32) * scale) as u8,
268                    ((g as f32) * scale) as u8,
269                    ((b as f32) * scale) as u8,
270                )
271            })
272            .unwrap_or(color),
273        Color::Black => Color::Black,
274        Color::Red => dim_named_color(205, 0, 0, scale),
275        Color::Green => dim_named_color(0, 205, 0, scale),
276        Color::Yellow => dim_named_color(205, 205, 0, scale),
277        Color::Blue => dim_named_color(0, 0, 238, scale),
278        Color::Magenta => dim_named_color(205, 0, 205, scale),
279        Color::Cyan => dim_named_color(0, 205, 205, scale),
280        Color::Gray => dim_named_color(229, 229, 229, scale),
281        Color::DarkGray => dim_named_color(127, 127, 127, scale),
282        Color::LightRed => dim_named_color(255, 0, 0, scale),
283        Color::LightGreen => dim_named_color(0, 255, 0, scale),
284        Color::LightYellow => dim_named_color(255, 255, 0, scale),
285        Color::LightBlue => dim_named_color(92, 92, 255, scale),
286        Color::LightMagenta => dim_named_color(255, 0, 255, scale),
287        Color::LightCyan => dim_named_color(0, 255, 255, scale),
288        Color::White => dim_named_color(255, 255, 255, scale),
289        Color::Reset => Color::Reset,
290    }
291}
292
293fn dim_named_color(r: u8, g: u8, b: u8, scale: f32) -> Color {
294    Color::Rgb(
295        ((r as f32) * scale) as u8,
296        ((g as f32) * scale) as u8,
297        ((b as f32) * scale) as u8,
298    )
299}
300
301fn indexed_to_rgb(idx: u8) -> Option<(u8, u8, u8)> {
302    match idx {
303        0 => Some((0, 0, 0)),
304        1 => Some((128, 0, 0)),
305        2 => Some((0, 128, 0)),
306        3 => Some((128, 128, 0)),
307        4 => Some((0, 0, 128)),
308        5 => Some((128, 0, 128)),
309        6 => Some((0, 128, 128)),
310        7 => Some((192, 192, 192)),
311        8 => Some((128, 128, 128)),
312        9 => Some((255, 0, 0)),
313        10 => Some((0, 255, 0)),
314        11 => Some((255, 255, 0)),
315        12 => Some((0, 0, 255)),
316        13 => Some((255, 0, 255)),
317        14 => Some((0, 255, 255)),
318        15 => Some((255, 255, 255)),
319        _ => None,
320    }
321}