1use 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
12pub struct ModalStyle {
16 pub dim_factor: f32,
18 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 pub fn with_bg(bg: Color) -> Self {
38 let mut style = Self::default();
39 style.base.bg = Some(bg);
40 style
41 }
42
43 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#[derive(Debug, Clone)]
60pub struct ModalBehavior {
61 pub close_on_esc: bool,
63 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
76pub struct ModalProps<'a, A> {
78 pub is_open: bool,
80 pub is_focused: bool,
82 pub area: Rect,
84 pub style: ModalStyle,
86 pub behavior: ModalBehavior,
88 pub on_close: fn() -> A,
90 pub render_content: &'a mut dyn FnMut(&mut Frame, Rect),
92}
93
94#[derive(Default)]
96pub struct Modal;
97
98impl Modal {
99 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 if style.dim_factor > 0.0 {
143 dim_buffer(frame.buffer_mut(), style.dim_factor);
144 }
145
146 if let Some(bg) = style.base.bg {
148 frame.render_widget(BgFill(bg), area);
149 }
150
151 let mut content_area = area;
153
154 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 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 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
194struct 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
208pub 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
217fn 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}