1use std::cell::RefCell;
22
23use ratatui::buffer::Buffer;
24use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent};
25use ratatui::layout::{Position, Rect};
26
27use crate::settings::themes::Theme;
28
29thread_local! {
30 static TARGETS: RefCell<Vec<Rect>> = const { RefCell::new(Vec::new()) };
31}
32
33pub fn clear() {
36 TARGETS.with(|t| t.borrow_mut().clear());
37}
38
39pub fn register(rect: Rect) {
41 if !rect.is_empty() {
42 TARGETS.with(|t| t.borrow_mut().push(rect));
43 }
44}
45
46#[derive(Debug, Default)]
56pub struct OpenGuard {
57 armed_at: Option<std::time::Instant>,
58 last_input_was_press: bool,
59}
60
61impl OpenGuard {
62 pub fn observe_input(&mut self, event: &crate::components::events::InputEvent) {
65 self.last_input_was_press = matches!(
66 event,
67 crate::components::events::InputEvent::Mouse(m)
68 if matches!(m.kind, ratatui::crossterm::event::MouseEventKind::Down(_))
69 );
70 }
71
72 pub fn opened(&mut self, now: std::time::Instant) {
74 self.armed_at = self.last_input_was_press.then_some(now);
75 }
76
77 pub fn closed(&mut self) {
79 self.armed_at = None;
80 }
81
82 pub fn swallows(
84 &self,
85 event: &crate::components::events::InputEvent,
86 now: std::time::Instant,
87 ) -> bool {
88 matches!(
89 event,
90 crate::components::events::InputEvent::Mouse(m)
91 if matches!(m.kind, ratatui::crossterm::event::MouseEventKind::Down(_))
92 ) && self
93 .armed_at
94 .is_some_and(|at| now.duration_since(at) < crate::components::DOUBLE_CLICK)
95 }
96}
97
98pub fn target_at<T: Copy>(targets: &[(Rect, T)], col: u16, row: u16) -> Option<T> {
101 let pos = Position::new(col, row);
102 targets
103 .iter()
104 .find(|(r, _)| r.contains(pos))
105 .map(|(_, t)| *t)
106}
107
108pub fn occlude(rect: Rect) {
112 TARGETS.with(|t| t.borrow_mut().retain(|r| !r.intersects(rect)));
113}
114
115pub fn hovered(pointer: Position) -> Option<Rect> {
118 TARGETS.with(|t| {
119 t.borrow()
120 .iter()
121 .rev()
122 .find(|r| r.contains(pointer))
123 .copied()
124 })
125}
126
127pub fn apply_hover(buf: &mut Buffer, pointer: Option<Position>, theme: &Theme) {
130 if let Some(rect) = pointer.and_then(hovered) {
131 buf.set_style(rect.intersection(buf.area), theme.hover());
132 }
133}
134
135pub fn is_left_press(m: &MouseEvent) -> bool {
139 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
140 matches!(m.kind, MouseEventKind::Down(MouseButton::Left))
141}
142
143pub fn key_at(targets: &[(Rect, KeyCode)], m: &MouseEvent) -> Option<KeyEvent> {
146 if !is_left_press(m) {
147 return None;
148 }
149 target_at(targets, m.column, m.row).map(KeyEvent::from)
150}
151
152pub fn list_index_at(m: &MouseEvent, rect: Rect, offset: usize, len: usize) -> Option<usize> {
156 if !is_left_press(m) || !rect.contains(Position::new(m.column, m.row)) {
157 return None;
158 }
159 let idx = offset + (m.row - rect.y) as usize;
160 (idx < len).then_some(idx)
161}
162
163pub fn is_press_outside(m: &MouseEvent, rect: Rect) -> bool {
166 use ratatui::crossterm::event::MouseEventKind;
167 matches!(m.kind, MouseEventKind::Down(_))
168 && !rect.is_empty()
169 && !rect.contains(Position::new(m.column, m.row))
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum Edge {
175 Top,
176 Bottom,
177}
178
179#[derive(Debug, Default)]
187pub struct BorderChip {
188 rect: Option<Rect>,
189}
190
191impl BorderChip {
192 pub fn place<'a>(
195 &mut self,
196 block: ratatui::widgets::Block<'a>,
197 area: Rect,
198 edge: Edge,
199 line: ratatui::text::Line<'a>,
200 ) -> ratatui::widgets::Block<'a> {
201 let inner_w = area.width.saturating_sub(2);
203 let w = (line.width() as u16).min(inner_w);
204 let fits = w > 0 && (edge == Edge::Top || area.height >= 2);
205 self.rect = fits.then(|| {
206 let y = match edge {
207 Edge::Top => area.y,
208 Edge::Bottom => area.bottom() - 1,
209 };
210 Rect::new(area.right() - 1 - w, y, w, 1)
211 });
212 let Some(r) = self.rect else {
213 return block;
214 };
215 register(r);
216 let line = line.right_aligned();
217 match edge {
218 Edge::Top => block.title(line),
219 Edge::Bottom => block.title_bottom(line),
220 }
221 }
222
223 pub fn hide(&mut self) {
225 self.rect = None;
226 }
227
228 pub fn hit(&self, m: &MouseEvent) -> bool {
230 is_left_press(m)
231 && self
232 .rect
233 .is_some_and(|r| r.contains(Position::new(m.column, m.row)))
234 }
235
236 #[cfg(test)]
237 pub(crate) fn rect(&self) -> Option<Rect> {
238 self.rect
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn a_border_chip_sits_rightmost_on_its_edge() {
248 clear();
249 let area = Rect::new(0, 0, 20, 3);
250 let mut chip = BorderChip::default();
251 let _ = chip.place(
252 ratatui::widgets::Block::default(),
253 area,
254 Edge::Bottom,
255 ratatui::text::Line::from("[F1] x"),
256 );
257 assert_eq!(chip.rect(), Some(Rect::new(13, 2, 6, 1)));
258 assert!(chip.hit(&crate::test_support::left_press(14, 2)));
259 assert!(!chip.hit(&crate::test_support::left_press(14, 1)));
260 assert!(
261 hovered(Position::new(14, 2)).is_some(),
262 "registered for hover"
263 );
264 chip.hide();
265 assert!(!chip.hit(&crate::test_support::left_press(14, 2)));
266 clear();
267 }
268
269 #[test]
270 fn outside_press_needs_a_rendered_rect() {
271 let rect = Rect::new(10, 10, 5, 5);
272 assert!(is_press_outside(
273 &crate::test_support::left_press(0, 0),
274 rect
275 ));
276 assert!(!is_press_outside(
277 &crate::test_support::left_press(11, 11),
278 rect
279 ));
280 assert!(!is_press_outside(
281 &crate::test_support::left_press(0, 0),
282 Rect::default()
283 ));
284 }
285
286 #[test]
287 fn the_last_registered_target_wins_and_clear_forgets() {
288 clear();
289 register(Rect::new(0, 0, 10, 1));
290 register(Rect::new(5, 0, 2, 1));
291 assert_eq!(hovered(Position::new(5, 0)), Some(Rect::new(5, 0, 2, 1)));
292 assert_eq!(hovered(Position::new(1, 0)), Some(Rect::new(0, 0, 10, 1)));
293 assert_eq!(hovered(Position::new(1, 3)), None);
294 clear();
295 assert_eq!(hovered(Position::new(1, 0)), None);
296 }
297
298 #[test]
299 fn the_open_guard_arms_only_for_mouse_opens() {
300 use crate::components::events::InputEvent;
301 let press = crate::test_support::mouse_down_at(1, 1);
302 let key = InputEvent::Key(ratatui::crossterm::event::KeyEvent::from(
303 ratatui::crossterm::event::KeyCode::Enter,
304 ));
305 let t0 = std::time::Instant::now();
306 let mut g = OpenGuard::default();
307
308 g.observe_input(&key);
309 g.opened(t0);
310 assert!(!g.swallows(&press, t0), "a keyboard open has no tail");
311
312 g.observe_input(&press);
313 g.opened(t0);
314 assert!(g.swallows(&press, t0 + crate::components::DOUBLE_CLICK / 2));
315 assert!(!g.swallows(&key, t0), "keys are never held back");
316 assert!(!g.swallows(&press, t0 + crate::components::DOUBLE_CLICK));
317 g.closed();
318 assert!(!g.swallows(&press, t0));
319 }
320
321 #[test]
322 fn an_opaque_layer_hides_what_it_covers() {
323 clear();
324 register(Rect::new(0, 0, 4, 1));
325 register(Rect::new(10, 0, 4, 1));
326 occlude(Rect::new(2, 0, 3, 3));
327 assert_eq!(hovered(Position::new(1, 0)), None, "covered target gone");
328 assert!(hovered(Position::new(11, 0)).is_some(), "others stay");
329 clear();
330 }
331
332 #[test]
333 fn hover_restyles_only_the_target() {
334 clear();
335 let theme = Theme::gruvbox_dark();
336 let mut buf = Buffer::empty(Rect::new(0, 0, 10, 2));
337 register(Rect::new(2, 1, 3, 1));
338 apply_hover(&mut buf, Some(Position::new(3, 1)), &theme);
339 assert_eq!(buf[(2, 1)].bg, theme.hover().bg.unwrap());
340 assert_ne!(buf[(1, 1)].bg, theme.hover().bg.unwrap());
341 assert_ne!(buf[(2, 0)].bg, theme.hover().bg.unwrap());
342 clear();
343 }
344}