1use 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
14pub struct ModalStyle {
18 pub dim_factor: f32,
20 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 pub fn with_bg(bg: Color) -> Self {
40 let mut style = Self::default();
41 style.base.bg = Some(bg);
42 style
43 }
44
45 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#[derive(Debug, Clone)]
62pub struct ModalBehavior {
63 pub close_on_esc: bool,
65 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
78pub type ModalCloseCallback<A> = Rc<dyn Fn() -> A>;
80
81pub struct ModalProps<'a, A> {
83 pub is_open: bool,
85 pub is_focused: bool,
87 pub area: Rect,
89 pub style: ModalStyle,
91 pub behavior: ModalBehavior,
93 pub on_close: ModalCloseCallback<A>,
95 pub render_content: &'a mut dyn FnMut(&mut Frame, Rect),
97}
98
99#[derive(Default)]
101pub struct Modal;
102
103impl Modal {
104 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 if style.dim_factor > 0.0 {
148 dim_buffer(frame.buffer_mut(), style.dim_factor);
149 }
150
151 if let Some(bg) = style.base.bg {
153 frame.render_widget(BgFill(bg), area);
154 }
155
156 let mut content_area = area;
158
159 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 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 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
199struct 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
213pub 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
222fn 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}