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