Skip to main content

kimun_notes/components/
clickable.rs

1//! The one visual language for click targets: an **action** colour at rest
2//! ([`Theme::action`]) and a **hover** highlight under the pointer
3//! ([`Theme::hover`]). No underlines — a terminal draws them as a heavy rule
4//! that reads as noise, not as "link".
5//!
6//! Rest styling is each widget's own (it picks `theme.action()` for the part
7//! that is clickable). Hover is central: every widget that records a click
8//! rect for its own hit-testing also [`register`]s it here while rendering,
9//! and the screen calls [`apply_hover`] once at the end of the frame. So a
10//! new target gets hover by registering — no per-widget pointer tracking.
11//!
12//! The registry is per frame and per thread (rendering is single-threaded;
13//! tests on separate threads stay isolated). Every screen that draws click
14//! targets calls [`clear`] first thing in its `render`, and a modal overlay
15//! calls it again before drawing itself: the panels under it are not
16//! clickable, so they must not light up through it either.
17//!
18//! [`Theme::action`]: crate::settings::themes::Theme::action
19//! [`Theme::hover`]: crate::settings::themes::Theme::hover
20
21use 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
33/// Forget every registered target — at the start of a frame, and before a
34/// modal layer draws over the rest.
35pub fn clear() {
36    TARGETS.with(|t| t.borrow_mut().clear());
37}
38
39/// Record a click target drawn this frame. Empty rects are ignored.
40pub fn register(rect: Rect) {
41    if !rect.is_empty() {
42        TARGETS.with(|t| t.borrow_mut().push(rect));
43    }
44}
45
46/// Drops the tail of the gesture that opened a modal layer: the second
47/// press of a double-click on whatever opened it. Without this, a popup that
48/// closes on a press outside it would open and close in one double-click,
49/// and a dialog could take a press aimed at what was under it.
50///
51/// Armed only when the layer was opened by a mouse press — a keyboard open
52/// is not a gesture with a tail — and only for [`DOUBLE_CLICK`] after.
53///
54/// [`DOUBLE_CLICK`]: crate::components::DOUBLE_CLICK
55#[derive(Debug, Default)]
56pub struct OpenGuard {
57    armed_at: Option<std::time::Instant>,
58    last_input_was_press: bool,
59}
60
61impl OpenGuard {
62    /// Record each input before it is handled: an open that follows a
63    /// mouse press (directly, or via the app message it sent) arms.
64    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    /// A layer opened at `now`.
73    pub fn opened(&mut self, now: std::time::Instant) {
74        self.armed_at = self.last_input_was_press.then_some(now);
75    }
76
77    /// The layer closed.
78    pub fn closed(&mut self) {
79        self.armed_at = None;
80    }
81
82    /// Whether `event` is the opening gesture's tail, to be dropped.
83    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
98/// The target under (col,row) in a widget's own `(rect, target)` list —
99/// the one hit-test every recorded-rect click target uses.
100pub 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
108/// An opaque layer was drawn over `rect` (the which-key panel, an
109/// autocomplete popup): drop every target it covers, so hover cannot show
110/// through it. A layer that has targets of its own registers them after.
111pub fn occlude(rect: Rect) {
112    TARGETS.with(|t| t.borrow_mut().retain(|r| !r.intersects(rect)));
113}
114
115/// The target under `pointer`, if any. Later registrations win: whatever
116/// was drawn last is on top.
117pub 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
127/// Paint the hover highlight over the target under `pointer` — the last
128/// step of a frame, so it lands on top of whatever the widget drew.
129pub 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
135// ── Hit-testing primitives ──────────────────────────────────────────────────
136
137/// A left-button press — the one mouse kind dialogs act on.
138pub fn is_left_press(m: &MouseEvent) -> bool {
139    use ratatui::crossterm::event::{MouseButton, MouseEventKind};
140    matches!(m.kind, MouseEventKind::Down(MouseButton::Left))
141}
142
143/// The key of the `(rect, key)` target under a left press — for click
144/// targets that are not chips (menu actions, launcher rows).
145pub 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
152/// The index of the ratatui `List` row under a left press: `rect` is where
153/// the rows are drawn (inside any border), `offset` the list state's first
154/// visible index, `len` the item count.
155pub 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
163/// A press of any button outside `rect` — how a read-only popup is dismissed.
164/// An empty `rect` (not rendered yet) is never "outside".
165pub 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/// Which border a [`BorderChip`] sits on.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum Edge {
175    Top,
176    Bottom,
177}
178
179/// A click target drawn as a right-aligned title on a block's border — the
180/// sort label (`Name ↑`), `[F1] Syntax`. One place for its geometry,
181/// hover registration and hit-test, so the border chips cannot drift.
182///
183/// Ratatui stacks right-aligned titles leftward in the order they are
184/// added, so place a chip *after* any other right-aligned title on the
185/// same edge: its rect assumes it is the rightmost.
186#[derive(Debug, Default)]
187pub struct BorderChip {
188    rect: Option<Rect>,
189}
190
191impl BorderChip {
192    /// Put `line` on `block`'s `edge` border (`area` is where the block is
193    /// drawn), record and register its rect, and return the block.
194    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        // Ratatui keeps the corner cells clear of titles.
202        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    /// Not drawn this frame: nothing to click.
224    pub fn hide(&mut self) {
225        self.rect = None;
226    }
227
228    /// A left press on the chip as last drawn.
229    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}