diff options
| author | user <user@clank> | 2026-08-08 22:04:17 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-08 22:04:17 +0200 |
| commit | eb3ac443bacf7770f9aea412979f141ce2168661 (patch) | |
| tree | 1da980979f719375d8b16b461a03a8fe07567a32 | |
| parent | sl0p: double-click a pane title to rename it (diff) | |
| download | sl0ppij-eb3ac443bacf7770f9aea412979f141ce2168661.tar.gz sl0ppij-eb3ac443bacf7770f9aea412979f141ce2168661.tar.xz sl0ppij-eb3ac443bacf7770f9aea412979f141ce2168661.zip | |
sl0p: fix double-click-to-rename, which never fired
Two independent bugs, either of which alone made the feature dead code.
1. WRONG EDGE. The check was `details.edge == Some(PaneEdge::Top)`, but
get_edge_at_position() quadrant-splits a pane and only ever returns the four
CORNER variants - PaneEdge::Top exists in the enum and is never produced. A
title-row click reports as TopLeft/TopRight, so the condition was never true.
Replaced with Pane::position_is_on_title_row(), which tests the geometry
directly the same way position_is_on_frame() does for the top border.
2. RESET ON RELEASE. Click recording reset the tracker on any event that was
not a frame press - which includes the RELEASE between the two clicks of a
double-click. The sequence is press/release/press, so the count went
1 -> reset -> 1 and never reached 2.
The tests passed anyway because they hand-built a MouseEventContext with
edge: Some(PaneEdge::Top) and called the decision function directly. They
asserted my assumption rather than the behaviour, and exercised neither the
edge lookup nor the click sequence.
Click recording is now a small pure helper (record_frame_click) covered by
tests that replay real event sequences: press/release/press is a double-click,
motion in between does not break it, a press off the frame does break it, and
two clicks at different positions are not a double-click. The decision tests
now use the corner edge value that is actually produced.
Baselines held: zellij-server 1428/30 (7 new tests).
| -rw-r--r-- | zellij-server/src/tab/mod.rs | 13 | ||||
| -rw-r--r-- | zellij-server/src/tab/mouse_handler.rs | 142 |
2 files changed, 123 insertions, 32 deletions
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index bb02d4d1..e501d3c1 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -577,6 +577,19 @@ pub trait Pane { } false } + /// Is this position on the pane's TOP border, i.e. the row the title is + /// drawn on? + /// + /// Deliberately geometric rather than derived from get_edge_at_position(): + /// that function quadrant-splits the pane and only ever returns the four + /// CORNER variants, never PaneEdge::Top, so a title-row click reports as + /// TopLeft/TopRight. + fn position_is_on_title_row(&self, position: &Position) -> bool { + if !self.contains(position) { + return false; + } + (self.y() as isize..self.get_content_y() as isize).contains(&position.line()) + } fn get_edge_at_position(&self, position: &Position) -> Option<PaneEdge> { if !self.contains(position) { return None; diff --git a/zellij-server/src/tab/mouse_handler.rs b/zellij-server/src/tab/mouse_handler.rs index 99d39bbb..3e063824 100644 --- a/zellij-server/src/tab/mouse_handler.rs +++ b/zellij-server/src/tab/mouse_handler.rs @@ -6,6 +6,7 @@ use zellij_utils::pane_size::PaneGeom; use zellij_utils::position::Position; use crate::background_jobs::BackgroundJob; +use crate::panes::grid::Click; use crate::panes::PaneId; use crate::plugins::PluginInstruction; use crate::screen::{GuestModalOutcome, ScreenInstruction}; @@ -13,6 +14,30 @@ use crate::ClientId; use super::{Pane, Tab}; +/// Track repeated presses on a pane frame, returning true when this press is +/// the second click of a double-click. +/// +/// Only PRESS events touch the tracker. This is the whole subtlety: a +/// double-click is press/release/press/release, so treating the intervening +/// RELEASE as "something other than a frame press" and resetting there means +/// the count never reaches 2 and no double-click is ever detected. A press +/// somewhere that is not a frame does legitimately break the sequence. +fn record_frame_click(tracker: &mut Click, event: &MouseEvent, on_frame: bool) -> bool { + let is_plain_left_press = + event.left && event.event_type == MouseEventType::Press && !event.ctrl && !event.alt; + if !is_plain_left_press { + // release / drag / scroll are not part of the decision and must leave + // the tracker alone + return false; + } + if !on_frame { + tracker.reset(); + return false; + } + tracker.record_click(event.position); + tracker.is_double_click() +} + fn clear_hover_for_client(tab: &mut Tab, client_id: ClientId) -> bool { let mut cleared = false; if let Some(prev_pid) = tab.mouse_hover_pane_id.remove(&client_id) { @@ -216,6 +241,8 @@ pub struct PaneResizeState { struct ClickedPaneDetails { pane_id: PaneId, on_frame: bool, + /// on the top border specifically, i.e. the row the title is drawn on + on_title: bool, frame_intercepted: bool, edge: Option<PaneEdge>, is_floating: bool, @@ -462,24 +489,11 @@ impl MouseHandler { (None, None) }; - // Record repeated presses on a pane frame so a double-click on the - // title row can be distinguished from a plain click (which focuses, or - // starts a resize drag). Only plain left presses on a frame count. - let frame_double_click = if event.left - && event.event_type == MouseEventType::Press - && !event.ctrl - && !event.alt - && clicked_pane.map(|d| d.on_frame).unwrap_or(false) - { - let tracker = tab.frame_clicks.entry(client_id).or_default(); - tracker.record_click(event.position); - tracker.is_double_click() - } else { - if let Some(tracker) = tab.frame_clicks.get_mut(&client_id) { - tracker.reset(); - } - false - }; + let frame_double_click = record_frame_click( + tab.frame_clicks.entry(client_id).or_default(), + event, + clicked_pane.map(|d| d.on_frame).unwrap_or(false), + ); Ok(MouseEventContext { pane_id_at_position, @@ -511,6 +525,7 @@ impl MouseHandler { let pane = Self::get_pane_at(tab, position, false).ok()??; let on_frame = !is_hidden_stack_list_member && pane.position_is_on_frame(position); + let on_title = on_frame && pane.position_is_on_title_row(position); let frame_intercepted = on_frame && pane.intercept_mouse_event_on_frame(event, client_id); let edge = if on_frame { pane.get_edge_at_position(position) @@ -527,6 +542,7 @@ impl MouseHandler { Some(ClickedPaneDetails { pane_id, on_frame, + on_title, frame_intercepted, edge, is_floating, @@ -1480,7 +1496,7 @@ impl MouseHandler { // Checked before the resize branch, which would otherwise // consume the click as a top-edge resize drag. Corners are // deliberately excluded: they are resize handles. - if ctx.frame_double_click && details.edge == Some(PaneEdge::Top) { + if ctx.frame_double_click && details.on_title { return Ok(MouseAction::StartRenamingPane { pane_id: details.pane_id, }); @@ -1909,12 +1925,17 @@ mod tests { } } - fn frame_click_context(frame_double_click: bool, edge: Option<PaneEdge>) -> MouseEventContext { + fn frame_click_context( + frame_double_click: bool, + on_title: bool, + edge: Option<PaneEdge>, + ) -> MouseEventContext { let mut ctx = mouse_event_context(false); ctx.frame_double_click = frame_double_click; ctx.clicked_pane = Some(ClickedPaneDetails { pane_id: PaneId::Terminal(1), on_frame: true, + on_title, frame_intercepted: false, edge, is_floating: false, @@ -1924,8 +1945,69 @@ mod tests { } #[test] + fn a_real_double_click_sequence_is_detected() { + // REGRESSION: a double-click is press/release/press/release. An earlier + // version reset the tracker on any event that was not a frame press, + // which included the RELEASE between the two clicks, so the count never + // reached 2 and the feature was dead in production despite the + // decision-level tests passing. + let mut tracker = Click::default(); + let pos = Position::new(1, 5); + + assert!(!record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(pos), true)); + assert!(!record_frame_click(&mut tracker, &MouseEvent::new_left_release_event(pos), true)); + assert!( + record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(pos), true), + "second press at the same position must register as a double-click" + ); + } + + #[test] + fn motion_between_clicks_does_not_break_the_gesture() { + let mut tracker = Click::default(); + let pos = Position::new(1, 5); + record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(pos), true); + record_frame_click(&mut tracker, &MouseEvent::new_left_motion_event(pos), true); + record_frame_click(&mut tracker, &MouseEvent::new_left_release_event(pos), true); + assert!(record_frame_click( + &mut tracker, + &MouseEvent::new_left_press_event(pos), + true + )); + } + + #[test] + fn a_press_off_the_frame_breaks_the_sequence() { + let mut tracker = Click::default(); + let pos = Position::new(1, 5); + record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(pos), true); + // clicking into pane content is a genuine interruption + record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(pos), false); + assert!(!record_frame_click( + &mut tracker, + &MouseEvent::new_left_press_event(pos), + true + )); + } + + #[test] + fn clicks_at_different_positions_are_not_a_double_click() { + let mut tracker = Click::default(); + record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(Position::new(1, 5)), true); + assert!(!record_frame_click( + &mut tracker, + &MouseEvent::new_left_press_event(Position::new(1, 20)), + true + )); + } + + #[test] fn double_click_on_pane_title_starts_rename() { - let ctx = frame_click_context(true, Some(PaneEdge::Top)); + // NOTE the edge here is TopRight, not Top: get_edge_at_position() + // quadrant-splits a pane and never returns PaneEdge::Top. An earlier + // version of this feature keyed off Some(PaneEdge::Top) and was dead + // code in production while a hand-built test passed. + let ctx = frame_click_context(true, true, Some(PaneEdge::TopRight)); let event = MouseEvent::new_left_press_event(Position::new(1, 1)); assert_eq!( MouseHandler::determine_mouse_action(&event, &ctx).unwrap(), @@ -1937,8 +2019,7 @@ mod tests { #[test] fn single_click_on_pane_title_still_resizes() { - // the rename must not steal the ordinary top-edge resize drag - let ctx = frame_click_context(false, Some(PaneEdge::Top)); + let ctx = frame_click_context(false, true, Some(PaneEdge::TopRight)); let event = MouseEvent::new_left_press_event(Position::new(1, 1)); assert!(matches!( MouseHandler::determine_mouse_action(&event, &ctx).unwrap(), @@ -1947,23 +2028,20 @@ mod tests { } #[test] - fn double_click_on_frame_corners_and_sides_does_not_rename() { - // corners are resize handles; only the title row renames + fn double_click_elsewhere_on_the_frame_does_not_rename() { + // bottom/left/right borders are not the title row for edge in [ - PaneEdge::TopLeft, - PaneEdge::TopRight, - PaneEdge::Left, - PaneEdge::Right, - PaneEdge::Bottom, PaneEdge::BottomLeft, PaneEdge::BottomRight, + PaneEdge::TopLeft, + PaneEdge::TopRight, ] { - let ctx = frame_click_context(true, Some(edge)); + let ctx = frame_click_context(true, false, Some(edge)); let event = MouseEvent::new_left_press_event(Position::new(1, 1)); let action = MouseHandler::determine_mouse_action(&event, &ctx).unwrap(); assert!( !matches!(action, MouseAction::StartRenamingPane { .. }), - "edge {:?} must not trigger rename, got {:?}", + "edge {:?} off the title row must not rename, got {:?}", edge, action ); |
