aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-08 23:54:38 +0200
committeruser <user@clank>2026-08-08 23:54:38 +0200
commit813d4b80658bfb717d93d539019d970b6fbbd1d0 (patch)
tree22849b81eb2a5aa35b9ab8fb964d40374047f294 /src
parentchore: bump version to v0.24.0 (diff)
downloadsl0pstatus-main.tar.gz
sl0pstatus-main.tar.xz
sl0pstatus-main.zip
sl0p: double-click a tab to rename itHEADmain
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.rs172
-rw-r--r--src/widgets/command.rs2
-rw-r--r--src/widgets/datetime.rs2
-rw-r--r--src/widgets/mode.rs2
-rw-r--r--src/widgets/notification.rs2
-rw-r--r--src/widgets/pipe.rs2
-rw-r--r--src/widgets/session.rs2
-rw-r--r--src/widgets/swap_layout.rs2
-rw-r--r--src/widgets/tabs.rs11
-rw-r--r--src/widgets/widget.rs6
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);
}