Skip to main content

tix/app/
mouse.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    /// Route a [`MouseEvent`] into the active mode's pointer handler (D2),
6    /// mirroring [`App::handle_key`]: one dispatch trunk per mode, so mouse and
7    /// keyboard share the same single-mode mental model. Modes that declare no
8    /// mouse behavior fall through to a no-op. Per-mode handlers land with the
9    /// click/hover/dialog tasks.
10    pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
11        // The pointer's hovered region is tracked for **every** mode, before the
12        // per-mode dispatch, so widgets shared across modes (the vertical mode
13        // navigation) highlight wherever they are drawn. Regions come from the
14        // previous frame, which is the frame the pointer is actually over.
15        if matches!(mouse.kind, MouseEventKind::Moved) {
16            self.hover_action = self.resolve_region(mouse.column, mouse.row);
17        }
18        let mut action = match self.mode.clone() {
19            Mode::Board => self.handle_board_mouse(mouse),
20            Mode::Detail => self.handle_detail_mouse(mouse),
21            Mode::ConfirmDelete => self.handle_confirm_delete_mouse(mouse),
22            Mode::Help { .. } => self.handle_help_mouse(mouse),
23            Mode::ActionMenu { .. } => self.handle_action_menu_mouse(mouse),
24            Mode::FilterModal => self.handle_filter_modal_mouse(mouse),
25            Mode::Settings => self.handle_settings_mouse(mouse),
26            Mode::Views => self.handle_views_mouse(mouse),
27            Mode::DatePicker { .. } => self.handle_date_picker_mouse(mouse),
28            Mode::Schedule => self.handle_schedule_mouse(mouse),
29            _ => Ok(Action::None),
30        }?;
31        if action == Action::None
32            && matches!(mouse.kind, MouseEventKind::Down(_))
33            && !matches!(
34                self.mode,
35                Mode::ActionMenu { .. }
36                    // A confirmation has no actions underneath it: a click must
37                    // answer it or be ignored, never run a board hit region.
38                    | Mode::ConfirmArchive { .. }
39                    // #1510: the conflict prompt's three decisions are the only way
40                    // out. A click that fell through to the board drawn underneath
41                    // would leave the prompt — and the temp copy it holds — without
42                    // a decision ever being made.
43                    | Mode::EditConflict { .. }
44                    // #1506: the repair prompt's decisions are the only way out for
45                    // the same reason, and its retained copy must not be abandoned
46                    // by a stray click on the board.
47                    | Mode::EditRepair { .. }
48                    // tix #1632: the tips modal's decisions are pressed, not
49                    // clicked, and a stray click must not run the board action it
50                    // happens to land on while the dialog is up.
51                    | Mode::Tips { .. }
52            )
53        {
54            // Fall back to widget-recorded hit regions (D3/D6): a click on a
55            // region a widget declared during draw runs its associated action,
56            // regardless of which mode did not handle the event. The context
57            // menu owns every click while it is open (inside = item, outside =
58            // dismiss), so underlying board regions must not resolve through
59            // the fallback.
60            if let Some(region_action) = self.resolve_region(mouse.column, mouse.row) {
61                action = region_action;
62            }
63        }
64        Ok(action)
65    }
66
67    /// Record a click target bounding `rect` for the current draw (D3).
68    /// Widgets call this while rendering; `handle_mouse` resolves the region
69    /// under a pointer via [`App::resolve_region`]. Regions are cleared at the
70    /// start of every frame, so stale targets from previous frames never fire.
71    pub fn record_region(&self, rect: Rect, action: Action) {
72        self.frame_hit_regions
73            .borrow_mut()
74            .push(HitRegion { rect, action });
75    }
76
77    /// Whether `action`'s hit region is the one the pointer is currently over.
78    /// Widgets use this to style themselves on hover without owning any pointer
79    /// state; the value is refreshed by each `Moved` event (see `handle_mouse`).
80    pub fn is_hovered(&self, action: &Action) -> bool {
81        self.hover_action.as_ref() == Some(action)
82    }
83
84    /// The innermost (last-recorded) region under `(x, y)`, if any (D3).
85    pub(in crate::app) fn resolve_region(&self, x: u16, y: u16) -> Option<Action> {
86        self.frame_hit_regions
87            .borrow()
88            .iter()
89            .rev()
90            .find(|region| region.rect.contains(Position::new(x, y)))
91            .map(|region| region.action.clone())
92    }
93
94    /// Board-mode pointer handling (D4): `Down` selects a card/column and
95    /// records the press origin, `Drag` frames promote an active drag without
96    /// touching the board, and a resolving `Up` decides between a plain click,
97    /// a double-click (opens the card's detail) and a drag-move to another
98    /// real column. Right-click opens the context menu (task 4.2); scroll
99    /// wheel behavior lands with task 6.1.
100    pub(in crate::app) fn handle_board_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
101        match mouse.kind {
102            MouseEventKind::Down(MouseButton::Left) => {
103                self.drag_preview_col = None;
104                let hit = self.board_hit_at(mouse.column, mouse.row);
105                self.mouse_down_left(hit)
106            }
107            MouseEventKind::Down(MouseButton::Right) => {
108                self.drag_preview_col = None;
109                let hit = self.board_hit_at(mouse.column, mouse.row);
110                self.open_action_menu(hit, true)
111            }
112            MouseEventKind::Up(MouseButton::Left) => {
113                self.drag_preview_col = None;
114                self.mouse_up_left(mouse.column, mouse.row)
115            }
116            MouseEventKind::Drag(_) => {
117                self.mouse_drag();
118                self.update_drag_preview(mouse.column, mouse.row);
119                Ok(Action::None)
120            }
121            MouseEventKind::ScrollUp => {
122                self.board.move_up();
123                Ok(Action::None)
124            }
125            MouseEventKind::ScrollDown => {
126                self.board.move_down();
127                Ok(Action::None)
128            }
129            MouseEventKind::Moved => {
130                self.drag_preview_col = None;
131                if let Some(tab) = self.tab_hover_at(mouse.column, mouse.row) {
132                    self.tabs.hovered = Some(tab);
133                    self.board.hover = None;
134                    self.board.hover_col = None;
135                } else {
136                    self.tabs.hovered = None;
137                    self.mouse_hover(mouse.column, mouse.row);
138                }
139                Ok(Action::None)
140            }
141            _ => Ok(Action::None),
142        }
143    }
144
145    /// Track which tab, if any, the pointer is hovering. This mirrors the tab
146    /// bar geometry so the hover highlight stays aligned with the rendered row.
147    pub(in crate::app) fn tab_hover_at(&self, x: u16, y: u16) -> Option<usize> {
148        if !self.tabs.needs_bar() {
149            return None;
150        }
151        let area = self.frame_size.get();
152        if y < area.y || y >= area.y + TAB_HEIGHT {
153            return None;
154        }
155        let tabs_area = Rect {
156            y: area.y,
157            height: TAB_HEIGHT,
158            ..area
159        };
160        (0..self.tabs.len()).find(|&index| {
161            self.tabs
162                .tab_rect(index, tabs_area)
163                .is_some_and(|rect| rect.contains(Position::new(x, y)))
164        })
165    }
166
167    /// Track the pointer for the hover highlight (D8): `Moved` records the
168    /// card under the pointer on `board.hover` and the column on
169    /// `board.hover_col` (never touching the selection). The column is recorded
170    /// for empty columns too, so hovering anywhere inside one marks it; both are
171    /// cleared over the board chrome or outside the board.
172    pub(in crate::app) fn mouse_hover(&mut self, x: u16, y: u16) {
173        match self.board_hit_at(x, y) {
174            Some(HitTarget::Card { col, row }) => {
175                self.board.hover = Some((col, row));
176                self.board.hover_col = Some(col);
177            }
178            Some(HitTarget::Column { col }) => {
179                self.board.hover = None;
180                self.board.hover_col = Some(col);
181            }
182            None => {
183                self.board.hover = None;
184                self.board.hover_col = None;
185            }
186        }
187    }
188
189    /// Update the current drag preview target while a card is held down.
190    pub(in crate::app) fn update_drag_preview(&mut self, x: u16, y: u16) {
191        match self.board_hit_at(x, y) {
192            Some(HitTarget::Card { col, row }) => {
193                self.drag_preview_col = Some(col);
194                self.board.hover = Some((col, row));
195                self.board.hover_col = Some(col);
196            }
197            Some(HitTarget::Column { col }) if col < self.board.real_cols() => {
198                self.drag_preview_col = Some(col);
199                self.board.hover = None;
200                self.board.hover_col = Some(col);
201            }
202            _ => {
203                self.drag_preview_col = None;
204                self.board.hover = None;
205                self.board.hover_col = None;
206            }
207        }
208    }
209
210    /// Board-detail mouse handling: only the wheel changes scroll offset.
211    pub(in crate::app) fn handle_detail_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
212        match mouse.kind {
213            MouseEventKind::ScrollUp => {
214                if let Some(d) = self.detail.as_mut() {
215                    d.scroll_by(-i32::from(WHEEL_ROWS));
216                }
217            }
218            MouseEventKind::ScrollDown => {
219                if let Some(d) = self.detail.as_mut() {
220                    d.scroll_by(i32::from(WHEEL_ROWS));
221                }
222            }
223            _ => {}
224        }
225        Ok(Action::None)
226    }
227
228    /// The rect the board widget draws into within the frame `area`, mirroring
229    /// `render_board`: tabs bar and hover line on top, filter row (when
230    /// active), board, then the hint bar at the bottom. `None` when the frame
231    /// is too small to contain a board at all.
232    pub(in crate::app) fn board_mouse_area(&self, area: Rect) -> Option<Rect> {
233        let tab_h: u16 = if self.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
234        // The same band the renderers lay out (WP05, T024): a hit test that
235        // ignored the saved-view indicator row would be off by one and select the
236        // wrong card.
237        let band_h = filter_band_height(self);
238        let hint_h: u16 = if self.one_shot { 0 } else { HINT_HEIGHT };
239        let mention_h = mention_row_height(self);
240        if area.height <= hint_h + mention_h + tab_h + HOVER_HEIGHT + band_h {
241            return None;
242        }
243        Some(Rect {
244            y: area.y + tab_h + HOVER_HEIGHT + band_h,
245            height: area.height - tab_h - HOVER_HEIGHT - band_h - hint_h - mention_h,
246            ..area
247        })
248    }
249
250    /// Resolve a terminal pointer against the board using the geometry of the
251    /// most recent draw (D3), or `None` outside the board / when unframed.
252    pub(in crate::app) fn board_hit_at(&self, x: u16, y: u16) -> Option<HitTarget> {
253        let frame = self.frame_size.get();
254        let board_area = self.board_mouse_area(frame)?;
255        self.board.hit_test(
256            board_area,
257            board::BoardDisplay::from(self.active_board_config()),
258            self.status_msg.is_some(),
259            x,
260            y,
261        )
262    }
263
264    /// Move the cursor to the card or column the pointer resolved to (D4:
265    /// selection changes only on `Down`).
266    pub(in crate::app) fn select_at(&mut self, hit: HitTarget) {
267        match hit {
268            HitTarget::Card { col, row } => {
269                self.board.active_col = col;
270                self.board.selected = row;
271            }
272            HitTarget::Column { col } => {
273                self.board.active_col = col;
274                self.board.clamp_selection();
275            }
276        }
277    }
278
279    pub(in crate::app) fn mouse_down_left(&mut self, hit: Option<HitTarget>) -> Result<Action> {
280        let Some(target) = hit else {
281            return Ok(Action::None);
282        };
283        self.select_at(target);
284        let (col, card) = match target {
285            HitTarget::Card { col, row } => (col, Some(row)),
286            HitTarget::Column { col } => (col, None),
287        };
288
289        // Record the press origin. The release decides whether this was a plain
290        // click (open the action menu) or a drag (move/reorder); a second press
291        // on the same card inside the window is handled by the menu's own mouse
292        // handler as the double-click that opens the detail pane.
293        self.mouse.last_down = Some(MouseDown {
294            col,
295            card,
296            at: Instant::now(),
297        });
298        Ok(Action::None)
299    }
300
301    /// Open the detail view for the card at the hit target (mirrors the board
302    /// `Enter` branch) — the resolving half of a double-click.
303    pub(in crate::app) fn open_detail_for(&mut self, hit: HitTarget) -> Result<Action> {
304        if let HitTarget::Card { col, row } = hit {
305            if let Some(card) = self.board.column_cards(col).get(row).cloned() {
306                let issue = self.store.get(IssueSelector::Filename(card.filename))?;
307                self.open_detail_root(issue)?;
308            }
309        }
310        Ok(Action::None)
311    }
312
313    /// A press followed by `Drag` frames promotes the recorded press into an
314    /// active drag (D4). The board is not mutated during drag frames; the
315    /// eventual `Up` decides the outcome. Dragging requires a card origin —
316    /// grabbing empty column space never moves anything.
317    pub(in crate::app) fn mouse_drag(&mut self) {
318        if self.mouse.drag.is_none() {
319            if let Some(down) = &self.mouse.last_down {
320                if down.card.is_some() {
321                    self.mouse.drag = Some(Drag {
322                        col: down.col,
323                        card: down.card,
324                    });
325                }
326            }
327        }
328    }
329
330    /// Resolve a left-button release (D4): an active drag becomes a drop —
331    /// moved to a different real column via `move_selected_to`, no-op on the
332    /// source column, the mismatched column, or outside the board — while a
333    /// plain click keeps the press recorded so a fast second press on the same
334    /// card registers as a double-click.
335    pub(in crate::app) fn mouse_up_left(&mut self, x: u16, y: u16) -> Result<Action> {
336        self.board.hover = None;
337        self.board.hover_col = None;
338        if let Some(drag) = self.mouse.drag.take() {
339            self.mouse.last_down = None;
340            if drag.card.is_none() {
341                return Ok(Action::None);
342            }
343            let target = match self.board_hit_at(x, y) {
344                Some(target) => target,
345                None => return Ok(Action::None),
346            };
347            match target {
348                HitTarget::Card { col, row } if col == drag.col => {
349                    if Some(row) == drag.card {
350                        return Ok(Action::None);
351                    }
352                    return self.reorder_selected_within_column(col, drag.card.unwrap(), row);
353                }
354                HitTarget::Column { col } if col == drag.col => {
355                    return self.reorder_selected_within_column(
356                        col,
357                        drag.card.unwrap(),
358                        self.board.column_cards(col).len().saturating_sub(1),
359                    );
360                }
361                HitTarget::Card { col, .. } | HitTarget::Column { col }
362                    if col < self.board.real_cols() && col != drag.col =>
363                {
364                    return self.move_selected_to(col);
365                }
366                _ => return Ok(Action::None),
367            }
368        }
369
370        // A plain left click (no drag) opens the action menu for the card it
371        // started and ended on. Drags already returned above, so a move is never
372        // mistaken for a click.
373        if let Some(down) = self.mouse.last_down.as_ref()
374            && let Some(HitTarget::Card { col, row }) = self.board_hit_at(x, y)
375            && down.col == col
376            && down.card == Some(row)
377        {
378            return self.open_action_menu(Some(HitTarget::Card { col, row }), false);
379        }
380        Ok(Action::None)
381    }
382
383    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
384    pub(in crate::app) fn handle_confirm_delete_mouse(
385        &mut self,
386        _mouse: MouseEvent,
387    ) -> Result<Action> {
388        Ok(Action::None)
389    }
390
391    #[allow(clippy::unnecessary_wraps)]
392    pub(in crate::app) fn handle_help_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
393        // tix #1441: the wheel scrolls the overlay, like the board and the detail
394        // pane already did. It used to resolve to a click-anywhere-closes region,
395        // which is still true for clicks — a wheel event is not a click.
396        match mouse.kind {
397            MouseEventKind::ScrollUp => self.scroll_help(-i32::from(WHEEL_ROWS)),
398            MouseEventKind::ScrollDown => self.scroll_help(i32::from(WHEEL_ROWS)),
399            _ => {}
400        }
401        Ok(Action::None)
402    }
403
404    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
405    pub(in crate::app) fn handle_filter_modal_mouse(
406        &mut self,
407        _mouse: MouseEvent,
408    ) -> Result<Action> {
409        Ok(Action::None)
410    }
411
412    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
413    pub(in crate::app) fn handle_settings_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
414        Ok(Action::None)
415    }
416
417    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
418    pub(in crate::app) fn handle_date_picker_mouse(
419        &mut self,
420        _mouse: MouseEvent,
421    ) -> Result<Action> {
422        Ok(Action::None)
423    }
424}