aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-08 22:04:17 +0200
committeruser <user@clank>2026-08-08 22:04:17 +0200
commiteb3ac443bacf7770f9aea412979f141ce2168661 (patch)
tree1da980979f719375d8b16b461a03a8fe07567a32
parentsl0p: double-click a pane title to rename it (diff)
downloadsl0ppij-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.rs13
-rw-r--r--zellij-server/src/tab/mouse_handler.rs142
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
);