From b04cddc35243115410e73c470d55bb4e406818a5 Mon Sep 17 00:00:00 2001 From: Aram Drevekenin Date: Sun, 16 Feb 2025 17:55:23 +0100 Subject: [PATCH] feat(mouse): double-click to mark word boundaries, triple-click to mark paragraph (#3996) * double-triple click on word/line boundaries * adjust snapshots * style(fmt): rustfmt --- zellij-server/src/os_input_output.rs | 1 + zellij-server/src/panes/grid.rs | 244 +++++++++++++++++- zellij-server/src/panes/selection.rs | 5 + zellij-server/src/panes/terminal_pane.rs | 1 + ...r__panes__grid__grid_tests__vttest1_3.snap | 12 +- ...r__panes__grid__grid_tests__vttest2_0.snap | 4 +- ...r__panes__grid__grid_tests__vttest2_2.snap | 4 +- ...r__panes__grid__grid_tests__vttest2_4.snap | 4 +- zellij-server/src/tab/mod.rs | 1 - 9 files changed, 259 insertions(+), 17 deletions(-) diff --git a/zellij-server/src/os_input_output.rs b/zellij-server/src/os_input_output.rs index e4263857..1ac4d64e 100644 --- a/zellij-server/src/os_input_output.rs +++ b/zellij-server/src/os_input_output.rs @@ -388,6 +388,7 @@ impl ClientSender { sender.send(msg).with_context(err_context).non_fatal(); } // If we're here, the message buffer is broken for some reason + log::error!("Client buffer overflow!"); let _ = sender.send(ServerToClientMsg::Exit(ExitReason::Disconnect)); }); ClientSender { diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs index 34f68986..4e0ba9b8 100644 --- a/zellij-server/src/panes/grid.rs +++ b/zellij-server/src/panes/grid.rs @@ -365,7 +365,47 @@ pub struct Grid { styled_underlines: bool, pub supports_kitty_keyboard_protocol: bool, // has the app requested kitty keyboard support? explicitly_disable_kitty_keyboard_protocol: bool, // has kitty keyboard support been explicitly - // disabled by user config? + // disabled by user config? + click: Click, +} + +const CLICK_TIME_THRESHOLD: u128 = 400; // Doherty Threshold + +#[derive(Clone, Debug, Default)] +struct Click { + position_and_time: Option<(Position, std::time::Instant)>, + count: usize, +} + +impl Click { + pub fn record_click(&mut self, position: Position) { + let click_is_same_position_as_last_click = self + .position_and_time + .map(|(p, _t)| p == position) + .unwrap_or(false); + let click_is_within_time_threshold = self + .position_and_time + .map(|(_p, t)| t.elapsed().as_millis() <= CLICK_TIME_THRESHOLD) + .unwrap_or(false); + if click_is_same_position_as_last_click && click_is_within_time_threshold { + self.count += 1; + } else { + self.count = 1; + } + self.position_and_time = Some((position, std::time::Instant::now())); + if self.count == 4 { + self.reset(); + } + } + pub fn is_double_click(&self) -> bool { + self.count == 2 + } + pub fn is_triple_click(&self) -> bool { + self.count == 3 + } + pub fn reset(&mut self) { + self.count = 0; + } } #[derive(Clone, Debug)] @@ -513,6 +553,7 @@ impl Grid { lock_renders: false, supports_kitty_keyboard_protocol: false, explicitly_disable_kitty_keyboard_protocol, + click: Click::default(), } } pub fn render_full_viewport(&mut self) { @@ -1478,6 +1519,8 @@ impl Grid { let line_wrapped_row = Row::new(); self.viewport.push(line_wrapped_row); self.output_buffer.update_line(self.cursor.y); + } else if let Some(current_line) = self.viewport.get_mut(self.cursor.y) { + current_line.is_canonical = false; } } } @@ -1750,6 +1793,39 @@ impl Grid { } pub fn start_selection(&mut self, start: &Position) { let old_selection = self.selection; + self.click.record_click(*start); + + if self.click.is_double_click() { + let Some((start_position, end_position)) = self.word_around_position(&start) else { + // no-op + return; + }; + self.selection + .set_start_and_end_positions(start_position, end_position); + for i in std::cmp::min(start_position.line.0, end_position.line.0) + ..=std::cmp::max(start_position.line.0, end_position.line.0) + { + self.output_buffer.update_line(i as usize); + } + self.mark_for_rerender(); + return; + } else if self.click.is_triple_click() { + let Some((start_position, end_position)) = self.canonical_line_around_position(&start) + else { + // no-op + return; + }; + self.selection + .set_start_and_end_positions(start_position, end_position); + for i in std::cmp::min(start_position.line.0, end_position.line.0) + ..=std::cmp::max(start_position.line.0, end_position.line.0) + { + self.output_buffer.update_line(i as usize); + } + self.mark_for_rerender(); + return; + } + self.selection.start(*start); self.update_selected_lines(&old_selection, &self.selection.clone()); self.mark_for_rerender(); @@ -1764,9 +1840,11 @@ impl Grid { } pub fn end_selection(&mut self, end: &Position) { - let old_selection = self.selection; - self.selection.end(*end); - self.update_selected_lines(&old_selection, &self.selection.clone()); + if !self.click.is_double_click() && !self.click.is_triple_click() { + let old_selection = self.selection; + self.selection.end(*end); + self.update_selected_lines(&old_selection, &self.selection.clone()); + } self.mark_for_rerender(); } @@ -1860,6 +1938,87 @@ impl Grid { pub fn absolute_position_in_scrollback(&self) -> usize { self.lines_above.len() + self.cursor.y } + pub fn word_around_position(&self, position: &Position) -> Option<(Position, Position)> { + let position_row = self.viewport.get(position.line.0 as usize)?; + let (index_start, index_end) = + position_row.word_indices_around_character_index(position.column.0)?; + + let mut position_start = Position::new(position.line.0 as i32, index_start as u16); + let mut position_end = Position::new(position.line.0 as i32, index_end as u16); + let mut position_row_is_canonical = position_row.is_canonical; + + while !position_row_is_canonical && position_start.column.0 == 0 { + if let Some(position_row_above) = self + .viewport + .get(position_start.line.0.saturating_sub(1) as usize) + { + let new_start_index = position_row_above.word_start_index_of_last_character(); + position_start = Position::new( + position_start.line.0.saturating_sub(1) as i32, + new_start_index as u16, + ); + position_row_is_canonical = position_row_above.is_canonical; + } else { + break; + } + } + + let mut column_count_in_row = position_row.columns.len(); + while position_end.column.0 == column_count_in_row { + if let Some(position_row_below) = self.viewport.get(position_end.line.0 as usize + 1) { + if position_row_below.is_canonical { + break; + } + let new_end_index = position_row_below.word_end_index_of_first_character(); + position_end = Position::new(position_end.line.0 as i32 + 1, new_end_index as u16); + column_count_in_row = position_row_below.columns.len(); + } else { + break; + } + } + + Some((position_start, position_end)) + } + pub fn canonical_line_around_position( + &self, + position: &Position, + ) -> Option<(Position, Position)> { + let position_row = self.viewport.get(position.line.0 as usize)?; + + let mut position_start = Position::new(position.line.0 as i32, 0); + let mut position_end = + Position::new(position.line.0 as i32, position_row.columns.len() as u16); + + let mut found_canonical_row_start = position_row.is_canonical; + while !found_canonical_row_start { + if let Some(row_above) = self + .viewport + .get(position_start.line.0.saturating_sub(1) as usize) + { + position_start.line.0 = position_start.line.0.saturating_sub(1); + found_canonical_row_start = row_above.is_canonical; + } else { + break; + } + } + + let mut found_canonical_row_end = false; + while !found_canonical_row_end { + if let Some(row_below) = self.viewport.get(position_end.line.0 as usize + 1) { + if row_below.is_canonical { + found_canonical_row_end = true; + } else { + position_end = Position::new( + position_end.line.0 as i32 + 1, + row_below.columns.len() as u16, + ); + } + } else { + break; + } + } + Some((position_start, position_end)) + } fn update_selected_lines(&mut self, old_selection: &Selection, new_selection: &Selection) { for l in old_selection.diff(new_selection, self.height) { @@ -3644,6 +3803,83 @@ impl Row { self.width = None; parts } + pub fn word_indices_around_character_index(&self, index: usize) -> Option<(usize, usize)> { + let character_at_index = self.columns.get(index)?; + if is_selection_boundary_character(character_at_index.character) { + return Some((index, index + 1)); + } + let mut end_position = self + .columns + .iter() + .enumerate() + .skip(index) + .find_map(|(i, t_c)| { + if is_selection_boundary_character(t_c.character) { + Some(i) + } else { + None + } + }) + .unwrap_or_else(|| self.columns.len()); + let start_position = self + .columns + .iter() + .enumerate() + .take(index) + .rev() + .find_map(|(i, t_c)| { + if is_selection_boundary_character(t_c.character) { + Some(i + 1) + } else { + None + } + }) + .unwrap_or(0); + if start_position == end_position { + // so that if this is only one character, it'll still be marked + end_position += 1; + } + Some((start_position, end_position)) + } + pub fn word_start_index_of_last_character(&self) -> usize { + self.columns + .iter() + .enumerate() + .rev() + .find_map(|(i, t_c)| { + if is_selection_boundary_character(t_c.character) { + Some(i + 1) + } else { + None + } + }) + .unwrap_or(0) + } + pub fn word_end_index_of_first_character(&self) -> usize { + self.columns + .iter() + .enumerate() + .find_map(|(i, t_c)| { + if is_selection_boundary_character(t_c.character) { + Some(i) + } else { + None + } + }) + .unwrap_or_else(|| self.columns.len()) + } +} + +fn is_selection_boundary_character(character: char) -> bool { + character.is_ascii_whitespace() + || character == '[' + || character == ']' + || character == '{' + || character == '}' + || character == '<' + || character == '>' + || character == '(' + || character == ')' } #[cfg(test)] diff --git a/zellij-server/src/panes/selection.rs b/zellij-server/src/panes/selection.rs index cfe10c5c..cc0fa675 100644 --- a/zellij-server/src/panes/selection.rs +++ b/zellij-server/src/panes/selection.rs @@ -37,6 +37,11 @@ impl Selection { self.end = end; } + pub fn set_start_and_end_positions(&mut self, start: Position, end: Position) { + self.start = start; + self.end = end; + } + pub fn contains(&self, row: usize, col: usize) -> bool { let row = row as isize; let (start, end) = if self.start <= self.end { diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index 3ad21ddc..186b8ac5 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -220,6 +220,7 @@ impl Pane for TerminalPane { // here we match against those cases - if need be, we adjust the input and if not // we send back the original input + self.reset_selection(); if !self.grid.bracketed_paste_mode { // Zellij itself operates in bracketed paste mode, so the terminal sends these // instructions (bracketed paste start and bracketed paste end respectively) diff --git a/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest1_3.snap b/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest1_3.snap index f33ce414..1052e377 100644 --- a/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest1_3.snap +++ b/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest1_3.snap @@ -1,28 +1,28 @@ --- source: zellij-server/src/panes/./unit/grid_tests.rs +assertion_line: 148 expression: "format!(\"{:?}\", grid)" - --- 00 (C): Test of autowrap, mixing control and print characters. 01 (C): The left/right margins should have letters in order: 02 (C): I i -03 (C): J j +03 (W): J j 04 (C): K k 05 (C): L l 06 (C): M m -07 (C): N n +07 (W): N n 08 (C): O o 09 (C): P p 10 (C): Q q -11 (C): R r +11 (W): R r 12 (C): S s 13 (C): T t 14 (C): U u -15 (C): V v +15 (W): V v 16 (C): W w 17 (C): X x 18 (C): Y y -19 (C): Z z +19 (W): Z z 20 (C): 21 (C): Push 22 (C): diff --git a/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_0.snap b/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_0.snap index a8250e29..408dbb32 100644 --- a/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_0.snap +++ b/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_0.snap @@ -1,10 +1,10 @@ --- source: zellij-server/src/panes/./unit/grid_tests.rs +assertion_line: 241 expression: "format!(\"{:?}\", grid)" - --- 00 (C): ************************************************************************************************************** -01 (C): ************************************************** +01 (W): ************************************************** 02 (C): ************************************************************************************************************** 03 (C): 04 (C): This should be three identical lines of *'s completely filling diff --git a/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_2.snap b/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_2.snap index d751b7b5..10c900a8 100644 --- a/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_2.snap +++ b/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_2.snap @@ -1,10 +1,10 @@ --- source: zellij-server/src/panes/./unit/grid_tests.rs +assertion_line: 303 expression: "format!(\"{:?}\", grid)" - --- 00 (C): 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 -01 (C): 123456789012345678901 +01 (W): 123456789012345678901 02 (C): This is 132 column mode, light background. 03 (C): This is 132 column mode, light background. 04 (C): This is 132 column mode, light background. diff --git a/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_4.snap b/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_4.snap index 7c819279..8d9822c2 100644 --- a/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_4.snap +++ b/zellij-server/src/panes/unit/snapshots/zellij_server__panes__grid__grid_tests__vttest2_4.snap @@ -1,10 +1,10 @@ --- source: zellij-server/src/panes/./unit/grid_tests.rs +assertion_line: 365 expression: "format!(\"{:?}\", grid)" - --- 00 (C): 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 -01 (C): 123456789012345678901 +01 (W): 123456789012345678901 02 (C): This is 132 column mode, dark background. 03 (C): This is 132 column mode, dark background. 04 (C): This is 132 column mode, dark background. diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs index 8d94744c..e5a7e217 100644 --- a/zellij-server/src/tab/mod.rs +++ b/zellij-server/src/tab/mod.rs @@ -3587,7 +3587,6 @@ impl Tab { if let PaneId::Terminal(_) = pane_with_selection.pid() { if copy_on_release { let selected_text = pane_with_selection.get_selected_text(); - pane_with_selection.reset_selection(); if let Some(selected_text) = selected_text { self.write_selection_to_clipboard(&selected_text)