diff options
| author | user <user@clank> | 2026-08-08 23:54:38 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-08 23:54:38 +0200 |
| commit | 813d4b80658bfb717d93d539019d970b6fbbd1d0 (patch) | |
| tree | 22849b81eb2a5aa35b9ab8fb964d40374047f294 /src | |
| parent | chore: bump version to v0.24.0 (diff) | |
| download | sl0pstatus-813d4b80658bfb717d93d539019d970b6fbbd1d0.tar.gz sl0pstatus-813d4b80658bfb717d93d539019d970b6fbbd1d0.tar.xz sl0pstatus-813d4b80658bfb717d93d539019d970b6fbbd1d0.zip | |
Double-clicking a tab in the bar switches to it and drops into RenameTab mode,
matching the double-click-to-rename we added for panes in the sl0ppij zellij
fork. Enter commits, Esc cancels, exactly as the rename keybind does.
Widgets are held behind &self and cannot keep click state, so double-click
detection lives centrally in ModuleConfig::handle_mouse_action and is passed
to Widget::process_click as a flag.
THE TRAP, and the reason this is not a two-line change:
handle_mouse_action also runs for Mouse::Release. A single click therefore
arrives as LeftClick followed by Release at the SAME position. Counting
releases would make every single click register as a double click, and
resetting on release would mean a real double click never registers at all,
since the release falls between the two clicks. Only LeftClick participates;
Release neither counts nor resets.
A completed pair is consumed, so a triple click does not fire a second rename.
switch_tab_to() is called before switch_to_input_mode(), because RenameTab acts
on the ACTIVE tab - renaming without switching first would rename the wrong one.
Threshold is 400ms, the same one zellij uses for text-selection double clicks,
so the bar feels consistent with the panes.
Detection is extracted as register_click_for_double_click() so it can be tested
without constructing a ModuleConfig. Tests cover: a real double click, the
release-in-between regression, too-slow clicks, clicks at different positions,
and the triple-click case.
Upstream tests still pass: 25 total (20 upstream + 5 new).
Note: this repo's .cargo/config.toml targets wasm32-wasip1 and runs tests under
wasmtime, which is not installed here. Tests are run with
`cargo test --target x86_64-unknown-linux-gnu` instead.
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.rs | 172 | ||||
| -rw-r--r-- | src/widgets/command.rs | 2 | ||||
| -rw-r--r-- | src/widgets/datetime.rs | 2 | ||||
| -rw-r--r-- | src/widgets/mode.rs | 2 | ||||
| -rw-r--r-- | src/widgets/notification.rs | 2 | ||||
| -rw-r--r-- | src/widgets/pipe.rs | 2 | ||||
| -rw-r--r-- | src/widgets/session.rs | 2 | ||||
| -rw-r--r-- | src/widgets/swap_layout.rs | 2 | ||||
| -rw-r--r-- | src/widgets/tabs.rs | 11 | ||||
| -rw-r--r-- | src/widgets/widget.rs | 6 |
10 files changed, 190 insertions, 13 deletions
diff --git a/src/config.rs b/src/config.rs index 093d15e..473ced0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -73,6 +73,15 @@ pub fn event_mask_from_widget_name(name: &str) -> u8 { #[derive(Default, Debug)] pub struct ModuleConfig { + /// Position and time of the last LEFT CLICK, for double-click detection. + /// + /// Only Mouse::LeftClick updates this. Mouse::Release must NOT, even though + /// handle_mouse_action also runs for it: a single click arrives as + /// LeftClick followed by Release at the same position, so counting releases + /// would make every single click look like a double click. Release is also + /// not allowed to RESET it, because it legitimately falls between the two + /// clicks of a real double click. + pub last_left_click: Option<(usize, chrono::DateTime<chrono::Local>)>, pub left_parts_config: String, pub left_parts: Vec<FormattedPart>, pub center_parts_config: String, @@ -89,6 +98,42 @@ pub struct ModuleConfig { pub hide_on_overlength: bool, } + +/// 400ms, the same threshold zellij uses for text-selection double clicks, so +/// the bar feels consistent with the panes. +pub const DOUBLE_CLICK_MS: i64 = 400; + +/// Record a mouse event and report whether it completes a double click. +/// +/// Only Mouse::LeftClick participates. handle_mouse_action also runs for +/// Mouse::Release, and a single click arrives as LeftClick then Release at the +/// same position - counting releases would make every single click look like a +/// double click. Release must not RESET the tracker either, since it falls +/// between the two clicks of a real double click. +pub fn register_click_for_double_click( + last_left_click: &mut Option<(usize, chrono::DateTime<chrono::Local>)>, + mouse: &Mouse, + click_pos: usize, + now: chrono::DateTime<chrono::Local>, +) -> bool { + if !matches!(mouse, Mouse::LeftClick(..)) { + return false; + } + let is_double = last_left_click + .map(|(prev_pos, prev_time)| { + prev_pos == click_pos && (now - prev_time).num_milliseconds() <= DOUBLE_CLICK_MS + }) + .unwrap_or(false); + // consume the pair, so a third click starts a new sequence rather than + // counting as another double + *last_left_click = if is_double { + None + } else { + Some((click_pos, now)) + }; + is_double +} + impl ModuleConfig { pub fn new(config: &BTreeMap<String, String>) -> anyhow::Result<Self> { let format_space_config = match config.get("format_space") { @@ -168,6 +213,7 @@ impl ModuleConfig { border: border_config, format_precedence, hide_on_overlength, + last_left_click: None, }) } @@ -187,6 +233,13 @@ impl ModuleConfig { Mouse::Hover(_, _) => return, }; + let is_double_click = register_click_for_double_click( + &mut self.last_left_click, + &mouse, + click_pos, + chrono::Local::now(), + ); + let output_left = self.left_parts.iter_mut().fold("".to_owned(), |acc, part| { format!( "{}{}", @@ -224,7 +277,7 @@ impl ModuleConfig { let mut offset = console::measure_text_width(&output_left); - self.process_widget_click(click_pos, &self.left_parts, &widget_map, &state, 0); + self.process_widget_click(click_pos, &self.left_parts, &widget_map, &state, 0, is_double_click); if click_pos <= offset { return; @@ -244,6 +297,7 @@ impl ModuleConfig { &widget_map, &state, offset, + is_double_click, ); if click_pos <= offset { @@ -263,7 +317,14 @@ impl ModuleConfig { )); } - self.process_widget_click(click_pos, &self.right_parts, &widget_map, &state, offset); + self.process_widget_click( + click_pos, + &self.right_parts, + &widget_map, + &state, + offset, + is_double_click, + ); } fn process_widget_click( @@ -273,6 +334,7 @@ impl ModuleConfig { widget_map: &BTreeMap<String, Arc<dyn Widget>>, state: &ZellijState, offset: usize, + is_double_click: bool, ) -> usize { let widget_string = widgets.iter().fold(String::new(), |a, b| a + &b.content); @@ -320,7 +382,7 @@ impl ModuleConfig { continue; } - wid.process_click(widget_key, state, click_pos - (pos + offset)); + wid.process_click(widget_key, state, click_pos - (pos + offset), is_double_click); } console::measure_text_width(&rendered_output) @@ -550,3 +612,107 @@ mod test { ) } } + +#[cfg(test)] +mod sl0p_double_click_tests { + use super::*; + use chrono::Duration; + + fn t0() -> chrono::DateTime<chrono::Local> { + chrono::Local::now() + } + + #[test] + fn two_quick_left_clicks_at_the_same_spot_are_a_double_click() { + let mut last = None; + let start = t0(); + assert!(!register_click_for_double_click( + &mut last, + &Mouse::LeftClick(0, 12), + 12, + start + )); + assert!(register_click_for_double_click( + &mut last, + &Mouse::LeftClick(0, 12), + 12, + start + Duration::milliseconds(120) + )); + } + + #[test] + fn the_release_between_two_clicks_is_ignored() { + // REGRESSION GUARD: handle_mouse_action runs for Release too. If Release + // were counted, a single click (LeftClick + Release) would register as a + // double click; if it reset the tracker, a real double click never would. + let mut last = None; + let start = t0(); + register_click_for_double_click(&mut last, &Mouse::LeftClick(0, 12), 12, start); + assert!( + !register_click_for_double_click( + &mut last, + &Mouse::Release(0, 12), + 12, + start + Duration::milliseconds(10) + ), + "a release must never count as a click" + ); + assert!( + register_click_for_double_click( + &mut last, + &Mouse::LeftClick(0, 12), + 12, + start + Duration::milliseconds(120) + ), + "the release must not have reset the sequence" + ); + } + + #[test] + fn slow_clicks_are_not_a_double_click() { + let mut last = None; + let start = t0(); + register_click_for_double_click(&mut last, &Mouse::LeftClick(0, 12), 12, start); + assert!(!register_click_for_double_click( + &mut last, + &Mouse::LeftClick(0, 12), + 12, + start + Duration::milliseconds(DOUBLE_CLICK_MS + 1) + )); + } + + #[test] + fn clicks_at_different_positions_are_not_a_double_click() { + let mut last = None; + let start = t0(); + register_click_for_double_click(&mut last, &Mouse::LeftClick(0, 12), 12, start); + assert!(!register_click_for_double_click( + &mut last, + &Mouse::LeftClick(0, 40), + 40, + start + Duration::milliseconds(50) + )); + } + + #[test] + fn a_triple_click_does_not_fire_a_second_rename() { + let mut last = None; + let start = t0(); + register_click_for_double_click(&mut last, &Mouse::LeftClick(0, 12), 12, start); + assert!(register_click_for_double_click( + &mut last, + &Mouse::LeftClick(0, 12), + 12, + start + Duration::milliseconds(100) + )); + assert!( + !register_click_for_double_click( + &mut last, + &Mouse::LeftClick(0, 12), + 12, + start + Duration::milliseconds(200) + ), + "the pair is consumed, so a third click starts over" + ); + } +} diff --git a/src/widgets/command.rs b/src/widgets/command.rs index 245c5fc..55498c5 100644 --- a/src/widgets/command.rs +++ b/src/widgets/command.rs @@ -142,7 +142,7 @@ impl Widget for CommandWidget { } } - fn process_click(&self, name: &str, _state: &ZellijState, _pos: usize) { + fn process_click(&self, name: &str, _state: &ZellijState, _pos: usize, _is_double_click: bool) { let command_config = match self.config.get(name) { Some(cc) => cc, None => { diff --git a/src/widgets/datetime.rs b/src/widgets/datetime.rs index f500b5d..8e2a67e 100644 --- a/src/widgets/datetime.rs +++ b/src/widgets/datetime.rs @@ -105,5 +105,5 @@ impl Widget for DateTimeWidget { }) } - fn process_click(&self, _name: &str, _state: &ZellijState, _pos: usize) {} + fn process_click(&self, _name: &str, _state: &ZellijState, _pos: usize, _is_double_click: bool) {} } diff --git a/src/widgets/mode.rs b/src/widgets/mode.rs index 8544cf9..f604291 100644 --- a/src/widgets/mode.rs +++ b/src/widgets/mode.rs @@ -139,7 +139,7 @@ impl Widget for ModeWidget { }) } - fn process_click(&self, _name: &str, _state: &ZellijState, _pos: usize) {} + fn process_click(&self, _name: &str, _state: &ZellijState, _pos: usize, _is_double_click: bool) {} } impl ModeWidget { diff --git a/src/widgets/notification.rs b/src/widgets/notification.rs index 458d12a..b892d4a 100644 --- a/src/widgets/notification.rs +++ b/src/widgets/notification.rs @@ -74,5 +74,5 @@ impl Widget for NotificationWidget { output.to_owned() } - fn process_click(&self, _name: &str, _state: &ZellijState, _pos: usize) {} + fn process_click(&self, _name: &str, _state: &ZellijState, _pos: usize, _is_double_click: bool) {} } diff --git a/src/widgets/pipe.rs b/src/widgets/pipe.rs index 2c5f724..2c41378 100644 --- a/src/widgets/pipe.rs +++ b/src/widgets/pipe.rs @@ -85,7 +85,7 @@ impl Widget for PipeWidget { } } - fn process_click(&self, _name: &str, _state: &crate::config::ZellijState, _pos: usize) {} + fn process_click(&self, _name: &str, _state: &crate::config::ZellijState, _pos: usize, _is_double_click: bool) {} } fn render_dynamic_formatted_content(content: &str, config: &BTreeMap<String, String>) -> String { diff --git a/src/widgets/session.rs b/src/widgets/session.rs index 9e27f6d..107d5fa 100644 --- a/src/widgets/session.rs +++ b/src/widgets/session.rs @@ -18,5 +18,5 @@ impl Widget for SessionWidget { } } - fn process_click(&self, _name: &str, _state: &ZellijState, _pos: usize) {} + fn process_click(&self, _name: &str, _state: &ZellijState, _pos: usize, _is_double_click: bool) {} } diff --git a/src/widgets/swap_layout.rs b/src/widgets/swap_layout.rs index 8c9e036..ca58f97 100644 --- a/src/widgets/swap_layout.rs +++ b/src/widgets/swap_layout.rs @@ -66,7 +66,7 @@ impl Widget for SwapLayoutWidget { output } - fn process_click(&self, _name: &str, _state: &ZellijState, _pos: usize) { + fn process_click(&self, _name: &str, _state: &ZellijState, _pos: usize, _is_double_click: bool) { next_swap_layout() } } diff --git a/src/widgets/tabs.rs b/src/widgets/tabs.rs index b62f581..970449a 100644 --- a/src/widgets/tabs.rs +++ b/src/widgets/tabs.rs @@ -2,7 +2,7 @@ use std::{cmp, collections::BTreeMap}; use zellij_tile::{ prelude::{InputMode, ModeInfo, PaneInfo, PaneManifest, TabInfo}, - shim::switch_tab_to, + shim::{switch_tab_to, switch_to_input_mode}, }; use crate::{config::ZellijState, render::FormattedPart}; @@ -178,7 +178,7 @@ impl Widget for TabsWidget { output } - fn process_click(&self, _name: &str, state: &ZellijState, pos: usize) { + fn process_click(&self, _name: &str, state: &ZellijState, pos: usize, is_double_click: bool) { let mut offset = 0; let mut counter = 0; @@ -226,6 +226,13 @@ impl Widget for TabsWidget { if pos > offset && pos < offset + content_len { switch_tab_to(tab.position as u32 + 1); + if is_double_click { + // RenameTab acts on the ACTIVE tab, so the switch above has + // to happen first. Mirrors how the rename keybind works, so + // Enter commits and Esc cancels exactly as usual. + switch_to_input_mode(&InputMode::RenameTab); + } + break; } diff --git a/src/widgets/widget.rs b/src/widgets/widget.rs index 7087cba..4745713 100644 --- a/src/widgets/widget.rs +++ b/src/widgets/widget.rs @@ -2,5 +2,9 @@ use crate::config::ZellijState; pub trait Widget { fn process(&self, name: &str, state: &ZellijState) -> String; - fn process_click(&self, name: &str, state: &ZellijState, pos: usize); + /// `is_double_click` is true when this is the second click of a + /// double-click at the same position. Detected centrally in + /// ModuleConfig::handle_mouse_action, because widgets are held behind &self + /// and cannot keep click state of their own. + fn process_click(&self, name: &str, state: &ZellijState, pos: usize, is_double_click: bool); } |
