diff --git a/Cargo.lock b/Cargo.lock index 264fb743..df0db731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,12 +190,12 @@ checksum = "065374052e7df7ee4047b1160cca5e1467a12351a40b3da123c870ba0b8eda2a" [[package]] name = "atty" -version = "0.2.14" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "9a7d5b8723950951411ee34d271d99dddcc2035a16ab25310ea2c8cfd4369652" dependencies = [ - "hermit-abi", "libc", + "termion", "winapi", ] @@ -220,6 +220,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + [[package]] name = "bincode" version = "1.3.3" @@ -2457,6 +2463,7 @@ name = "zellij-client" version = "0.14.0" dependencies = [ "insta", + "mio", "termbg", "zellij-utils", ] @@ -2467,6 +2474,7 @@ version = "0.14.0" dependencies = [ "ansi_term 0.12.1", "async-trait", + "base64", "cassowary", "daemonize", "insta", diff --git a/src/tests/e2e/cases.rs b/src/tests/e2e/cases.rs index 69233f25..8d41d44f 100644 --- a/src/tests/e2e/cases.rs +++ b/src/tests/e2e/cases.rs @@ -1,7 +1,7 @@ #![allow(unused)] use ::insta::assert_snapshot; -use zellij_utils::pane_size::PositionAndSize; +use zellij_utils::{pane_size::PositionAndSize, position::Position}; use rand::Rng; @@ -54,6 +54,17 @@ pub const BRACKETED_PASTE_START: [u8; 6] = [27, 91, 50, 48, 48, 126]; // \u{1b}[ pub const BRACKETED_PASTE_END: [u8; 6] = [27, 91, 50, 48, 49, 126]; // \u{1b}[201 pub const SLEEP: [u8; 0] = []; +// simplified, slighty adapted version of alacritty mouse reporting code +pub fn normal_mouse_report(position: Position, button: u8) -> Vec { + let Position { line, column } = position; + + let mut command = vec![b'\x1b', b'[', b'M', 32 + button]; + command.push(32 + 1 + column.0 as u8); + command.push(32 + 1 + line.0 as u8); + + command +} + // All the E2E tests are marked as "ignored" so that they can be run separately from the normal // tests @@ -785,3 +796,144 @@ pub fn accepts_basic_layout() { .run_all_steps(); assert_snapshot!(last_snapshot); } + +#[test] +#[ignore] +fn focus_pane_with_mouse() { + let fake_win_size = PositionAndSize { + cols: 120, + rows: 24, + x: 0, + y: 0, + ..Default::default() + }; + + let last_snapshot = RemoteRunner::new("split_terminals_vertically", fake_win_size, None) + .add_step(Step { + name: "Split pane to the right", + instruction: |mut remote_terminal: RemoteTerminal| -> bool { + let mut step_is_complete = false; + if remote_terminal.status_bar_appears() && remote_terminal.cursor_position_is(2, 2) + { + remote_terminal.send_key(&PANE_MODE); + remote_terminal.send_key(&SPLIT_RIGHT_IN_PANE_MODE); + // back to normal mode after split + remote_terminal.send_key(&ENTER); + step_is_complete = true; + } + step_is_complete + }, + }) + .add_step(Step { + name: "Click left pane", + instruction: |mut remote_terminal: RemoteTerminal| -> bool { + let mut step_is_complete = false; + if remote_terminal.cursor_position_is(63, 2) && remote_terminal.tip_appears() { + remote_terminal.send_key(&normal_mouse_report(Position::new(5, 2), 0)); + step_is_complete = true; + } + step_is_complete + }, + }) + .add_step(Step { + name: "Wait for left pane to be focused", + instruction: |remote_terminal: RemoteTerminal| -> bool { + let mut step_is_complete = false; + if remote_terminal.cursor_position_is(2, 2) && remote_terminal.tip_appears() { + // cursor is in the newly opened second pane + step_is_complete = true; + } + step_is_complete + }, + }) + .run_all_steps(); + assert_snapshot!(last_snapshot); +} + +#[test] +#[ignore] +pub fn scrolling_inside_a_pane_with_mouse() { + let fake_win_size = PositionAndSize { + cols: 120, + rows: 24, + x: 0, + y: 0, + ..Default::default() + }; + let last_snapshot = + RemoteRunner::new("scrolling_inside_a_pane_with_mouse", fake_win_size, None) + .add_step(Step { + name: "Split pane to the right", + instruction: |mut remote_terminal: RemoteTerminal| -> bool { + let mut step_is_complete = false; + if remote_terminal.status_bar_appears() + && remote_terminal.cursor_position_is(2, 2) + { + remote_terminal.send_key(&PANE_MODE); + remote_terminal.send_key(&SPLIT_RIGHT_IN_PANE_MODE); + // back to normal mode after split + remote_terminal.send_key(&ENTER); + step_is_complete = true; + } + step_is_complete + }, + }) + .add_step(Step { + name: "Fill terminal with text", + instruction: |mut remote_terminal: RemoteTerminal| -> bool { + let mut step_is_complete = false; + if remote_terminal.cursor_position_is(63, 2) && remote_terminal.tip_appears() { + // cursor is in the newly opened second pane + remote_terminal.send_key(&format!("{:0<57}", "line1 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line2 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line3 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line4 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line5 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line6 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line7 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line8 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line9 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line10 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line11 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line12 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line13 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line14 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line15 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line16 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line17 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line18 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<59}", "line19 ").as_bytes()); + remote_terminal.send_key(&format!("{:0<58}", "line20 ").as_bytes()); + step_is_complete = true; + } + step_is_complete + }, + }) + .add_step(Step { + name: "Scroll up inside pane", + instruction: |mut remote_terminal: RemoteTerminal| -> bool { + let mut step_is_complete = false; + if remote_terminal.cursor_position_is(119, 20) { + // all lines have been written to the pane + remote_terminal.send_key(&normal_mouse_report(Position::new(2, 64), 64)); + step_is_complete = true; + } + step_is_complete + }, + }) + .add_step(Step { + name: "Wait for scroll to finish", + instruction: |remote_terminal: RemoteTerminal| -> bool { + let mut step_is_complete = false; + if remote_terminal.cursor_position_is(119, 20) + && remote_terminal.snapshot_contains("line1 ") + { + // scrolled up one line + step_is_complete = true; + } + step_is_complete + }, + }) + .run_all_steps(); + assert_snapshot!(last_snapshot); +} diff --git a/src/tests/e2e/snapshots/zellij__tests__e2e__cases__focus_pane_with_mouse.snap b/src/tests/e2e/snapshots/zellij__tests__e2e__cases__focus_pane_with_mouse.snap new file mode 100644 index 00000000..79e84810 --- /dev/null +++ b/src/tests/e2e/snapshots/zellij__tests__e2e__cases__focus_pane_with_mouse.snap @@ -0,0 +1,29 @@ +--- +source: src/tests/e2e/cases.rs +expression: last_snapshot + +--- + Zellij  Tab #1  + +$ █ │$ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + + Ctrl + LOCK 

PANE  TAB  RESIZE  SCROLL  SESSION  QUIT  + Tip: Alt + n => open new pane. Alt + [] or hjkl => navigate between panes. diff --git a/src/tests/e2e/snapshots/zellij__tests__e2e__cases__scrolling_inside_a_pane_with_mouse.snap b/src/tests/e2e/snapshots/zellij__tests__e2e__cases__scrolling_inside_a_pane_with_mouse.snap new file mode 100644 index 00000000..bff40376 --- /dev/null +++ b/src/tests/e2e/snapshots/zellij__tests__e2e__cases__scrolling_inside_a_pane_with_mouse.snap @@ -0,0 +1,29 @@ +--- +source: src/tests/e2e/cases.rs +expression: last_snapshot + +--- + Zellij  Tab #1  + +$ │$ line1 000000000000000000000000000000000000000000000000000 + │line2 00000000000000000000000000000000000000000000000000000 + │line3 00000000000000000000000000000000000000000000000000000 + │line4 00000000000000000000000000000000000000000000000000000 + │line5 00000000000000000000000000000000000000000000000000000 + │line6 00000000000000000000000000000000000000000000000000000 + │line7 00000000000000000000000000000000000000000000000000000 + │line8 00000000000000000000000000000000000000000000000000000 + │line9 00000000000000000000000000000000000000000000000000000 + │line10 0000000000000000000000000000000000000000000000000000 + │line11 0000000000000000000000000000000000000000000000000000 + │line12 0000000000000000000000000000000000000000000000000000 + │line13 0000000000000000000000000000000000000000000000000000 + │line14 0000000000000000000000000000000000000000000000000000 + │line15 0000000000000000000000000000000000000000000000000000 + │line16 0000000000000000000000000000000000000000000000000000 + │line17 0000000000000000000000000000000000000000000000000000 + │line18 0000000000000000000000000000000000000000000000000000 + │line19 000000000000000000000000000000000000000000000000000█ + + Ctrl + LOCK 

PANE  TAB  RESIZE  SCROLL  SESSION  QUIT  + Tip: Alt + n => open new pane. Alt + [] or hjkl => navigate between panes. diff --git a/src/tests/fixtures/grid_copy b/src/tests/fixtures/grid_copy new file mode 100755 index 00000000..6d093741 --- /dev/null +++ b/src/tests/fixtures/grid_copy @@ -0,0 +1,59 @@ +⏎(B ⏎ Welcome to fish, the friendly interactive shell +Type `help` for instructions on how to use fish +[?2004h]0;fish /home/thomas/Projects/zellij(B +zellij on  mouse-support [?] is 📦 v0.14.0 via 🦀 v1.53.0-beta.3  +❯  cc(Bat test-input.txt(Bat test-input.txt(Bt test-input.txt(Bcat test-input.txt(B test-input.txt(B test-input.txt(B +(B[?2004l]0;cat test-input.txt /home/thomas/Projects/zellij(B Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +Velit ut tortor pretium viverra suspendisse potenti nullam ac tortor. Adipiscing elit ut aliquam purus sit amet luctus venenatis. +Duis ut diam quam nulla porttitor massa id neque aliquam. Suspendisse potenti nullam ac tortor vitae purus faucibus ornare suspendisse. +Vitae nunc sed velit dignissim sodales ut eu sem integer. +Tortor id aliquet lectus proin nibh nisl. +Commodo odio aenean sed adipiscing diam donec adipiscing tristique risus. +Velit dignissim sodales ut eu sem. Lacus suspendisse faucibus interdum posuere lorem. Ac placerat vestibulum lectus mauris ultrices eros. Elementum integer enim neque volutpat ac. Augue interdum velit euismod in. + +Egestas sed sed risus pretium quam vulputate dignissim. +Gravida rutrum quisque non tellus orci ac auctor augue. +Risus nec feugiat in fermentum posuere urna nec tincidunt praesent. +Elementum eu facilisis sed odio morbi quis. +Mattis ullamcorper velit sed ullamcorper morbi. +Dui vivamus arcu felis bibendum. Sit amet aliquam id diam. +Suscipit tellus mauris a diam maecenas sed enim. +Odio ut sem nulla pharetra. +Cras ornare arcu dui vivamus arcu felis bibendum. +Egestas fringilla phasellus faucibus scelerisque eleifend. +Purus semper eget duis at tellus at urna condimentum. +Aliquam etiam erat velit scelerisque in dictum non. +Porta non pulvinar neque laoreet suspendisse interdum consectetur. +Tempor nec feugiat nisl pretium. Sit amet consectetur adipiscing elit. +Cras semper auctor neque vitae tempus quam pellentesque. +Laoreet non curabitur gravida arcu ac tortor dignissim. +Sed nisi lacus sed viverra tellus in. +Rutrum tellus pellentesque eu tincidunt tortor aliquam nulla. + +Nascetur ridiculus mus mauris vitae ultricies leo integer malesuada. +Interdum posuere lorem ipsum dolor sit amet consectetur. +Porta non pulvinar neque laoreet suspendisse interdum. +Fames ac turpis egestas integer eget aliquet nibh praesent. +Congue nisi vitae suscipit tellus mauris a diam maecenas sed. +Nec ultrices dui sapien eget mi proin sed libero enim. +Tellus rutrum tellus pellentesque eu tincidunt. +Ultrices eros in cursus turpis massa tincidunt dui ut ornare. +Arcu cursus vitae congue mauris rhoncus aenean vel elit scelerisque. +Viverra mauris in aliquam sem fringilla ut. +Vulputate eu scelerisque felis imperdiet proin fermentum leo. +Cursus risus at ultrices mi tempus. +Laoreet id donec ultrices tincidunt arcu non sodales. +Amet dictum sit amet justo donec enim. +Hac habitasse platea dictumst vestibulum rhoncus est pellentesque. +Facilisi cras fermentum odio eu feugiat. +Elit ut aliquam purus sit amet luctus venenatis lectus. +Dignissim enim sit amet venenatis urna cursus. +Amet consectetur adipiscing elit ut aliquam purus. +Elementum pulvinar etiam non quam lacus suspendisse. + +Quisque id diam vel quam. Id porta nibh venenatis cras sed felis eget velit aliquet. Sagittis aliquam malesuada bibendum arcu. Libero id faucibus nisl tincidunt eget nullam non. Sed elementum tempus egestas sed sed risus pretium quam vulputate. Turpis egestas maecenas pharetra convallis. Arcu cursus vitae congue mauris rhoncus aenean vel. Augue ut lectus arcu bibendum. Scelerisque varius morbi enim nunc faucibus a pellentesque. Mattis pellentesque id nibh tortor id aliquet lectus proin nibh. In aliquam sem fringilla ut. Urna et pharetra pharetra massa massa ultricies mi. Enim nulla aliquet porttitor lacus luctus accumsan tortor posuere. Malesuada fames ac turpis egestas integer. Venenatis tellus in metus vulputate eu scelerisque felis. Suspendisse faucibus interdum posuere lorem ipsum dolor sit amet. + +Quam elementum pulvinar etiam non quam lacus suspendisse faucibus. Egestas sed sed risus pretium quam vulputate dignissim suspendisse. Risus nec feugiat in fermentum posuere urna. Vestibulum lorem sed risus ultricies. Egestas maecenas pharetra convallis posuere morbi. Egestas tellus rutrum tellus pellentesque. Pulvinar etiam non quam lacus suspendisse faucibus. Lectus proin nibh nisl condimentum id venenatis a condimentum. Adipiscing elit pellentesque habitant morbi tristique senectus et netus. Nunc id cursus metus aliquam eleifend. Urna nec tincidunt praesent semper feugiat nibh sed pulvinar. Donec ultrices tincidunt arcu non sodales neque sodales ut etiam. Suspendisse sed nisi lacus sed viverra tellus in hac habitasse. Nunc scelerisque viverra mauris in aliquam sem fringilla. +⏎(B ⏎ [?2004h]0;fish /home/thomas/Projects/zellij(B +zellij on  mouse-support [?] is 📦 v0.14.0 via 🦀 v1.53.0-beta.3  +❯  \ No newline at end of file diff --git a/zellij-client/Cargo.toml b/zellij-client/Cargo.toml index 9b04104b..4ca49bcc 100644 --- a/zellij-client/Cargo.toml +++ b/zellij-client/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +mio = "0.7.11" termbg = "0.2.3" zellij-utils = { path = "../zellij-utils/", version = "0.14.0" } diff --git a/zellij-client/src/input_handler.rs b/zellij-client/src/input_handler.rs index e563e345..f02b5ad8 100644 --- a/zellij-client/src/input_handler.rs +++ b/zellij-client/src/input_handler.rs @@ -1,6 +1,12 @@ //! Main input logic. -use zellij_utils::{termion, zellij_tile}; +use zellij_utils::{ + input::{ + mouse::{MouseButton, MouseEvent}, + options::Options, + }, + termion, zellij_tile, +}; use crate::{os_input_output::ClientOsApi, ClientInstruction, CommandIsExecuting}; use zellij_utils::{ @@ -20,6 +26,7 @@ struct InputHandler { mode: InputMode, os_input: Box, config: Config, + options: Options, command_is_executing: CommandIsExecuting, send_client_instructions: SenderWithContext, should_exit: bool, @@ -32,6 +39,7 @@ impl InputHandler { os_input: Box, command_is_executing: CommandIsExecuting, config: Config, + options: Options, send_client_instructions: SenderWithContext, mode: InputMode, ) -> Self { @@ -39,6 +47,7 @@ impl InputHandler { mode, os_input, config, + options, command_is_executing, send_client_instructions, should_exit: false, @@ -54,6 +63,10 @@ impl InputHandler { let alt_left_bracket = vec![27, 91]; let bracketed_paste_start = vec![27, 91, 50, 48, 48, 126]; // \u{1b}[200~ let bracketed_paste_end = vec![27, 91, 50, 48, 49, 126]; // \u{1b}[201 + + if !self.options.disable_mouse_mode { + self.os_input.enable_mouse(); + } loop { if self.should_exit { break; @@ -66,6 +79,10 @@ impl InputHandler { let key = cast_termion_key(key); self.handle_key(&key, raw_bytes); } + termion::event::Event::Mouse(me) => { + let mouse_event = zellij_utils::input::mouse::MouseEvent::from(me); + self.handle_mouse_event(&mouse_event); + } termion::event::Event::Unsupported(unsupported_key) => { // we have to do this because of a bug in termion // this should be a key event and not an unsupported event @@ -82,10 +99,6 @@ impl InputHandler { self.handle_unknown_key(raw_bytes); } } - termion::event::Event::Mouse(_) => { - // Mouse events aren't implemented yet, - // use a NoOp untill then. - } }, Err(err) => panic!("Encountered read error: {:?}", err), } @@ -117,6 +130,30 @@ impl InputHandler { } } } + fn handle_mouse_event(&mut self, mouse_event: &MouseEvent) { + match *mouse_event { + MouseEvent::Press(button, point) => match button { + MouseButton::WheelUp => { + self.dispatch_action(Action::ScrollUpAt(point)); + } + MouseButton::WheelDown => { + self.dispatch_action(Action::ScrollDownAt(point)); + } + MouseButton::Left => { + self.dispatch_action(Action::LeftClick(point)); + } + _ => {} + }, + MouseEvent::Release(point) => { + self.dispatch_action(Action::MouseRelease(point)); + } + MouseEvent::Hold(point) => { + self.dispatch_action(Action::MouseHold(point)); + self.os_input + .start_action_repeater(Action::MouseHold(point)); + } + } + } /// Dispatches an [`Action`]. /// @@ -180,6 +217,7 @@ impl InputHandler { pub(crate) fn input_loop( os_input: Box, config: Config, + options: Options, command_is_executing: CommandIsExecuting, send_client_instructions: SenderWithContext, default_mode: InputMode, @@ -188,6 +226,7 @@ pub(crate) fn input_loop( os_input, command_is_executing, config, + options, send_client_instructions, default_mode, ) diff --git a/zellij-client/src/lib.rs b/zellij-client/src/lib.rs index 5b409e75..f3e42a74 100644 --- a/zellij-client/src/lib.rs +++ b/zellij-client/src/lib.rs @@ -187,6 +187,7 @@ pub fn start_client( input_loop( os_input, config, + config_options, command_is_executing, send_client_instructions, default_mode, @@ -242,6 +243,7 @@ pub fn start_client( os_input.unset_raw_mode(0); let goto_start_of_last_line = format!("\u{1b}[{};{}H", full_screen_ws.rows, 1); let restore_snapshot = "\u{1b}[?1049l"; + os_input.disable_mouse(); let error = format!( "{}\n{}{}", goto_start_of_last_line, restore_snapshot, backtrace @@ -300,6 +302,7 @@ pub fn start_client( goto_start_of_last_line, restore_snapshot, reset_style, show_cursor, exit_msg ); + os_input.disable_mouse(); os_input.unset_raw_mode(0); let mut stdout = os_input.get_stdout_writer(); let _ = stdout.write(goodbye_message.as_bytes()).unwrap(); diff --git a/zellij-client/src/os_input_output.rs b/zellij-client/src/os_input_output.rs index 405422ab..7242bf7a 100644 --- a/zellij-client/src/os_input_output.rs +++ b/zellij-client/src/os_input_output.rs @@ -1,14 +1,16 @@ -use zellij_utils::{interprocess, libc, nix, signal_hook, zellij_tile}; +use zellij_utils::input::actions::Action; +use zellij_utils::{interprocess, libc, nix, signal_hook, termion, zellij_tile}; use interprocess::local_socket::LocalSocketStream; +use mio::{unix::SourceFd, Events, Interest, Poll, Token}; use nix::pty::Winsize; use nix::sys::termios; use signal_hook::{consts::signal::*, iterator::Signals}; -use std::io; use std::io::prelude::*; use std::os::unix::io::RawFd; use std::path::Path; use std::sync::{Arc, Mutex}; +use std::{io, time}; use zellij_tile::data::Palette; use zellij_utils::{ errors::ErrorContext, @@ -60,6 +62,7 @@ pub struct ClientOsInputOutput { orig_termios: Arc>, send_instructions_to_server: Arc>>>, receive_instructions_from_server: Arc>>>, + mouse_term: Arc>>>, } /// The `ClientOsApi` trait represents an abstract interface to the features of an operating system that @@ -88,6 +91,10 @@ pub trait ClientOsApi: Send + Sync { /// Establish a connection with the server socket. fn connect_to_server(&self, path: &Path); fn load_palette(&self) -> Palette; + fn enable_mouse(&self); + fn disable_mouse(&self); + // Repeatedly send action, until stdin is readable again + fn start_action_repeater(&mut self, action: Action); } impl ClientOsApi for ClientOsInputOutput { @@ -180,6 +187,31 @@ impl ClientOsApi for ClientOsInputOutput { // }; default_palette() } + fn enable_mouse(&self) { + let mut mouse_term = self.mouse_term.lock().unwrap(); + if mouse_term.is_none() { + *mouse_term = Some(termion::input::MouseTerminal::from(std::io::stdout())); + } + } + + fn disable_mouse(&self) { + let mut mouse_term = self.mouse_term.lock().unwrap(); + if mouse_term.is_some() { + *mouse_term = None; + } + } + + fn start_action_repeater(&mut self, action: Action) { + let mut poller = StdinPoller::default(); + + loop { + let ready = poller.ready(); + if ready { + break; + } + self.send_to_server(ClientToServerMsg::Action(action.clone())); + } + } } impl Clone for Box { @@ -191,9 +223,54 @@ impl Clone for Box { pub fn get_client_os_input() -> Result { let current_termios = termios::tcgetattr(0)?; let orig_termios = Arc::new(Mutex::new(current_termios)); + let mouse_term = Arc::new(Mutex::new(None)); Ok(ClientOsInputOutput { orig_termios, send_instructions_to_server: Arc::new(Mutex::new(None)), receive_instructions_from_server: Arc::new(Mutex::new(None)), + mouse_term, }) } + +pub const DEFAULT_STDIN_POLL_TIMEOUT_MS: u64 = 10; + +struct StdinPoller { + poll: Poll, + events: Events, + timeout: time::Duration, +} + +impl StdinPoller { + // use mio poll to check if stdin is readable without blocking + fn ready(&mut self) -> bool { + self.poll + .poll(&mut self.events, Some(self.timeout)) + .expect("could not poll stdin for readiness"); + for event in &self.events { + if event.token() == Token(0) && event.is_readable() { + return true; + } + } + false + } +} + +impl Default for StdinPoller { + fn default() -> Self { + let stdin = 0; + let mut stdin_fd = SourceFd(&stdin); + let events = Events::with_capacity(128); + let poll = Poll::new().unwrap(); + poll.registry() + .register(&mut stdin_fd, Token(0), Interest::READABLE) + .expect("could not create stdin poll"); + + let timeout = time::Duration::from_millis(DEFAULT_STDIN_POLL_TIMEOUT_MS); + + Self { + poll, + events, + timeout, + } + } +} diff --git a/zellij-client/src/unit/input_handler_tests.rs b/zellij-client/src/unit/input_handler_tests.rs index bf9bc45a..f40344fe 100644 --- a/zellij-client/src/unit/input_handler_tests.rs +++ b/zellij-client/src/unit/input_handler_tests.rs @@ -1,6 +1,7 @@ use super::input_loop; use zellij_utils::input::actions::{Action, Direction}; use zellij_utils::input::config::Config; +use zellij_utils::input::options::Options; use zellij_utils::pane_size::PositionAndSize; use zellij_utils::zellij_tile::data::Palette; @@ -137,6 +138,9 @@ impl ClientOsApi for FakeClientOsApi { fn load_palette(&self) -> Palette { unimplemented!() } + fn enable_mouse(&self) {} + fn disable_mouse(&self) {} + fn start_action_repeater(&mut self, _action: Action) {} } fn extract_actions_sent_to_server( @@ -162,6 +166,7 @@ pub fn quit_breaks_input_loop() { command_is_executing.clone(), )); let config = Config::from_default_assets().unwrap(); + let options = Options::default(); let (send_client_instructions, _receive_client_instructions): ChannelWithContext< ClientInstruction, @@ -172,6 +177,7 @@ pub fn quit_breaks_input_loop() { drop(input_loop( client_os_api, config, + options, command_is_executing, send_client_instructions, default_mode, @@ -196,6 +202,7 @@ pub fn move_focus_left_in_pane_mode() { command_is_executing.clone(), )); let config = Config::from_default_assets().unwrap(); + let options = Options::default(); let (send_client_instructions, _receive_client_instructions): ChannelWithContext< ClientInstruction, @@ -206,6 +213,7 @@ pub fn move_focus_left_in_pane_mode() { drop(input_loop( client_os_api, config, + options, command_is_executing, send_client_instructions, default_mode, @@ -234,6 +242,7 @@ pub fn bracketed_paste() { command_is_executing.clone(), )); let config = Config::from_default_assets().unwrap(); + let options = Options::default(); let (send_client_instructions, _receive_client_instructions): ChannelWithContext< ClientInstruction, @@ -244,6 +253,7 @@ pub fn bracketed_paste() { drop(input_loop( client_os_api, config, + options, command_is_executing, send_client_instructions, default_mode, diff --git a/zellij-server/Cargo.toml b/zellij-server/Cargo.toml index 1a99bb40..7408eb8b 100644 --- a/zellij-server/Cargo.toml +++ b/zellij-server/Cargo.toml @@ -11,6 +11,7 @@ license = "MIT" [dependencies] ansi_term = "0.12.1" async-trait = "0.1.50" +base64 = "0.13.0" daemonize = "0.4.1" serde_json = "1.0" unicode-width = "0.1.8" diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs index 7ea62ed6..27eea695 100644 --- a/zellij-server/src/panes/grid.rs +++ b/zellij-server/src/panes/grid.rs @@ -7,7 +7,7 @@ use std::{ str, }; -use zellij_utils::{vte, zellij_tile}; +use zellij_utils::{position::Position, vte, zellij_tile}; const TABSTOP_WIDTH: usize = 8; // TODO: is this always right? const SCROLL_BACK: usize = 10_000; @@ -21,6 +21,8 @@ use crate::panes::terminal_character::{ EMPTY_TERMINAL_CHARACTER, }; +use super::selection::Selection; + // this was copied verbatim from alacritty fn parse_number(input: &[u8]) -> Option { if input.is_empty() { @@ -315,6 +317,7 @@ pub struct Grid { pub width: usize, pub height: usize, pub pending_messages_to_pty: Vec>, + pub selection: Selection, } impl Debug for Grid { @@ -354,6 +357,7 @@ impl Grid { pending_messages_to_pty: vec![], colors, output_buffer: Default::default(), + selection: Default::default(), } } pub fn render_full_viewport(&mut self) { @@ -473,6 +477,7 @@ impl Grid { self.lines_below.insert(0, line_to_push_down); let line_to_insert_at_viewport_top = self.lines_above.pop_back().unwrap(); self.viewport.insert(0, line_to_insert_at_viewport_top); + self.selection.move_down(1); } self.output_buffer.update_all_lines(); } @@ -488,10 +493,12 @@ impl Grid { } let line_to_insert_at_viewport_bottom = self.lines_below.remove(0); self.viewport.push(line_to_insert_at_viewport_bottom); + self.selection.move_up(1); self.output_buffer.update_all_lines(); } } pub fn change_size(&mut self, new_rows: usize, new_columns: usize) { + self.selection.reset(); if new_columns != self.width { let mut cursor_canonical_line_index = self.cursor_canonical_line_index(); let cursor_index_in_canonical_line = self.cursor_index_in_canonical_line(); @@ -764,6 +771,7 @@ impl Grid { Some(self.width), None, ); + self.selection.move_up(1); self.output_buffer.update_all_lines(); } else { self.cursor.y += 1; @@ -840,6 +848,7 @@ impl Grid { ); let wrapped_row = Row::new(self.width); self.viewport.push(wrapped_row); + self.selection.move_up(1); self.output_buffer.update_all_lines(); } else { self.cursor.y += 1; @@ -1135,6 +1144,104 @@ impl Grid { fn set_preceding_character(&mut self, terminal_character: TerminalCharacter) { self.preceding_char = Some(terminal_character); } + pub fn start_selection(&mut self, start: &Position) { + let old_selection = self.selection.clone(); + self.selection.start(*start); + self.update_selected_lines(&old_selection, &self.selection.clone()); + self.mark_for_rerender(); + } + pub fn update_selection(&mut self, to: &Position) { + let old_selection = self.selection.clone(); + self.selection.to(*to); + self.update_selected_lines(&old_selection, &self.selection.clone()); + self.mark_for_rerender(); + } + + pub fn end_selection(&mut self, end: Option<&Position>) { + let old_selection = self.selection.clone(); + self.selection.end(end); + self.update_selected_lines(&old_selection, &self.selection.clone()); + self.mark_for_rerender(); + } + + pub fn reset_selection(&mut self) { + let old_selection = self.selection.clone(); + self.selection.reset(); + self.update_selected_lines(&old_selection, &self.selection.clone()); + self.mark_for_rerender(); + } + pub fn get_selected_text(&self) -> Option { + if self.selection.is_empty() { + return None; + } + let mut selection: Vec = vec![]; + + let sorted_selection = self.selection.sorted(); + let (start, end) = (sorted_selection.start, sorted_selection.end); + + for l in sorted_selection.line_indices() { + let mut line_selection = String::new(); + + // on the first line of the selection, use the selection start column + // otherwise, start at the beginning of the line + let start_column = if l == start.line.0 { start.column.0 } else { 0 }; + + // same thing on the last line, but with the selection end column + let end_column = if l == end.line.0 { + end.column.0 + } else { + self.width + }; + + if start_column == end_column { + continue; + } + + let empty_row = Row::from_columns(vec![EMPTY_TERMINAL_CHARACTER; self.width]); + + // get the row from lines_above, viewport, or lines below depending on index + let row = if l < 0 { + let offset_from_end = l.abs(); + &self.lines_above[self + .lines_above + .len() + .saturating_sub(offset_from_end as usize)] + } else if l >= 0 && (l as usize) < self.viewport.len() { + &self.viewport[l as usize] + } else if (l as usize) < self.height { + // index is in viewport but there is no line + &empty_row + } else { + &self.lines_below[(l as usize) - self.viewport.len()] + }; + + let excess_width = row.excess_width(); + let mut line: Vec = row.columns.iter().copied().collect(); + // pad line + line.resize( + self.width.saturating_sub(excess_width), + EMPTY_TERMINAL_CHARACTER, + ); + + let mut terminal_col = 0; + for terminal_character in line { + if (start_column..end_column).contains(&terminal_col) { + line_selection.push(terminal_character.character); + } + + terminal_col += terminal_character.width; + } + selection.push(String::from(line_selection.trim_end())); + } + + Some(selection.join("\n")) + } + + fn update_selected_lines(&mut self, old_selection: &Selection, new_selection: &Selection) { + for l in old_selection.diff(new_selection, self.height) { + self.output_buffer.update_line(l as usize); + } + } } impl Perform for Grid { diff --git a/zellij-server/src/panes/mod.rs b/zellij-server/src/panes/mod.rs index 187b364d..3830b242 100644 --- a/zellij-server/src/panes/mod.rs +++ b/zellij-server/src/panes/mod.rs @@ -1,5 +1,6 @@ mod grid; mod plugin_pane; +mod selection; mod terminal_character; mod terminal_pane; diff --git a/zellij-server/src/panes/selection.rs b/zellij-server/src/panes/selection.rs new file mode 100644 index 00000000..f01cf94c --- /dev/null +++ b/zellij-server/src/panes/selection.rs @@ -0,0 +1,135 @@ +use std::{collections::HashSet, ops::Range}; + +use zellij_utils::position::Position; + +// The selection is empty when start == end +// it includes the character at start, and everything before end. +#[derive(Debug, Clone)] +pub struct Selection { + pub start: Position, + pub end: Position, + active: bool, // used to handle moving the selection up and down +} + +impl Default for Selection { + fn default() -> Self { + Self { + start: Position::new(0, 0), + end: Position::new(0, 0), + active: false, + } + } +} + +impl Selection { + pub fn start(&mut self, start: Position) { + self.active = true; + self.start = start; + self.end = start; + } + + pub fn to(&mut self, to: Position) { + self.end = to + } + + pub fn end(&mut self, to: Option<&Position>) { + self.active = false; + if let Some(to) = to { + self.end = *to + } + } + + pub fn contains(&self, row: usize, col: usize) -> bool { + let row = row as isize; + let (start, end) = if self.start <= self.end { + (self.start, self.end) + } else { + (self.end, self.start) + }; + + if (start.line.0) < row && row < end.line.0 { + return true; + } + if start.line == end.line { + return row == start.line.0 && start.column.0 <= col && col < end.column.0; + } + if start.line.0 == row && col >= start.column.0 { + return true; + } + end.line.0 == row && col < end.column.0 + } + + pub fn is_empty(&self) -> bool { + self.start == self.end + } + + pub fn reset(&mut self) { + self.start = Position::new(0, 0); + self.end = self.start; + } + + pub fn sorted(&self) -> Self { + let (start, end) = if self.start <= self.end { + (self.start, self.end) + } else { + (self.end, self.start) + }; + Self { + start, + end, + active: self.active, + } + } + + pub fn line_indices(&self) -> std::ops::RangeInclusive { + let sorted = self.sorted(); + sorted.start.line.0..=sorted.end.line.0 + } + + pub fn move_up(&mut self, lines: usize) { + self.start.line.0 -= lines as isize; + if !self.active { + self.end.line.0 -= lines as isize; + } + } + + pub fn move_down(&mut self, lines: usize) { + self.start.line.0 += lines as isize; + if !self.active { + self.end.line.0 += lines as isize; + } + } + + /// Return an iterator over the line indices, up to max, that are not present in both self and other, + /// except for the indices of the first and last line of both self and s2, that are always included. + pub fn diff(&self, other: &Self, max: usize) -> impl Iterator { + let mut lines_to_update = HashSet::new(); + + lines_to_update.insert(self.start.line.0); + lines_to_update.insert(self.end.line.0); + lines_to_update.insert(other.start.line.0); + lines_to_update.insert(other.end.line.0); + + let old_lines: HashSet = self.get_visible_indices(max).collect(); + let new_lines: HashSet = other.get_visible_indices(max).collect(); + + old_lines.symmetric_difference(&new_lines).for_each(|&l| { + let _ = lines_to_update.insert(l); + }); + + lines_to_update + .into_iter() + .filter(move |&l| l >= 0 && l < max as isize) + } + + fn get_visible_indices(&self, max: usize) -> Range { + let Selection { start, end, .. } = self.sorted(); + let start = start.line.0.max(0); + let end = end.line.0.min(max as isize); + start..end + } +} + +#[cfg(test)] +#[path = "./unit/selection_tests.rs"] +mod selection_tests; diff --git a/zellij-server/src/panes/terminal_pane.rs b/zellij-server/src/panes/terminal_pane.rs index 0abc7a5a..087817d6 100644 --- a/zellij-server/src/panes/terminal_pane.rs +++ b/zellij-server/src/panes/terminal_pane.rs @@ -1,11 +1,14 @@ +use zellij_utils::position::Position; +use zellij_utils::zellij_tile::prelude::PaletteColor; use zellij_utils::{vte, zellij_tile}; use std::fmt::Debug; use std::os::unix::io::RawFd; -use std::time::Instant; +use std::time::{self, Instant}; use zellij_tile::data::Palette; use zellij_utils::pane_size::PositionAndSize; +use crate::panes::AnsiCode; use crate::panes::{ grid::Grid, terminal_character::{ @@ -15,6 +18,8 @@ use crate::panes::{ use crate::pty::VteBytes; use crate::tab::Pane; +pub const SELECTION_SCROLL_INTERVAL_MS: u64 = 10; + #[derive(PartialEq, Eq, Ord, PartialOrd, Hash, Clone, Copy, Debug)] pub enum PaneId { Terminal(RawFd), @@ -30,6 +35,7 @@ pub struct TerminalPane { pub active_at: Instant, pub colors: Palette, vte_parser: vte::Parser, + selection_scrolled_at: time::Instant, } impl Pane for TerminalPane { @@ -181,11 +187,22 @@ impl Pane for TerminalPane { )); // goto row/col and reset styles let mut chunk_width = character_chunk.x; - for t_character in terminal_characters { + for mut t_character in terminal_characters { + // adjust the background of currently selected characters + // doing it here is much easier than in grid + if self.grid.selection.contains(character_chunk.y, chunk_width) { + let color = match self.colors.bg { + PaletteColor::Rgb(rgb) => AnsiCode::RgbCode(rgb), + PaletteColor::EightBit(col) => AnsiCode::ColorIndex(col), + }; + + t_character.styles = t_character.styles.background(Some(color)); + } chunk_width += t_character.width; if chunk_width > max_width { break; } + if let Some(new_styles) = character_styles.update_and_return_diff(&t_character.styles) { @@ -285,6 +302,41 @@ impl Pane for TerminalPane { fn drain_messages_to_pty(&mut self) -> Vec> { self.grid.pending_messages_to_pty.drain(..).collect() } + + fn start_selection(&mut self, start: &Position) { + self.grid.start_selection(start); + self.set_should_render(true); + } + + fn update_selection(&mut self, to: &Position) { + let should_scroll = self.selection_scrolled_at.elapsed() + >= time::Duration::from_millis(SELECTION_SCROLL_INTERVAL_MS); + // TODO: check how far up/down mouse is relative to pane, to increase scroll lines? + if to.line.0 < 0 && should_scroll { + self.grid.scroll_up_one_line(); + self.selection_scrolled_at = time::Instant::now(); + } else if to.line.0 as usize >= self.grid.height && should_scroll { + self.grid.scroll_down_one_line(); + self.selection_scrolled_at = time::Instant::now(); + } else if to.line.0 >= 0 && (to.line.0 as usize) < self.grid.height { + self.grid.update_selection(to); + } + + self.set_should_render(true); + } + + fn end_selection(&mut self, end: Option<&Position>) { + self.grid.end_selection(end); + self.set_should_render(true); + } + + fn reset_selection(&mut self) { + self.grid.reset_selection(); + } + + fn get_selected_text(&self) -> Option { + self.grid.get_selected_text() + } } impl TerminalPane { @@ -299,6 +351,7 @@ impl TerminalPane { vte_parser: vte::Parser::new(), active_at: Instant::now(), colors: palette, + selection_scrolled_at: time::Instant::now(), } } pub fn get_x(&self) -> usize { diff --git a/zellij-server/src/panes/unit/grid_tests.rs b/zellij-server/src/panes/unit/grid_tests.rs index 048d3d0a..0facc066 100644 --- a/zellij-server/src/panes/unit/grid_tests.rs +++ b/zellij-server/src/panes/unit/grid_tests.rs @@ -1,6 +1,6 @@ use super::super::Grid; use ::insta::assert_snapshot; -use zellij_utils::{vte, zellij_tile::data::Palette}; +use zellij_utils::{position::Position, vte, zellij_tile::data::Palette}; fn read_fixture(fixture_name: &str) -> Vec { let mut path_to_file = std::path::PathBuf::new(); @@ -556,6 +556,68 @@ fn wrap_wide_characters_at_the_end_of_the_line() { assert_snapshot!(format!("{:?}", grid)); } +#[test] +fn copy_selected_text_from_viewport() { + let mut vte_parser = vte::Parser::new(); + let mut grid = Grid::new(27, 125, Palette::default()); + let fixture_name = "grid_copy"; + let content = read_fixture(fixture_name); + for byte in content { + vte_parser.advance(&mut grid, byte); + } + + grid.start_selection(&Position::new(23, 6)); + // check for widechar, 📦 occupies columns 34, 35, and gets selected even if only the first column is selected + grid.end_selection(Some(&Position::new(25, 35))); + let text = grid.get_selected_text(); + assert_eq!( + text.unwrap(), + "mauris in aliquam sem fringilla.\n\nzellij on  mouse-support [?] is 📦" + ); +} + +#[test] +fn copy_selected_text_from_lines_above() { + let mut vte_parser = vte::Parser::new(); + let mut grid = Grid::new(27, 125, Palette::default()); + let fixture_name = "grid_copy"; + let content = read_fixture(fixture_name); + for byte in content { + vte_parser.advance(&mut grid, byte); + } + + grid.start_selection(&Position::new(-2, 10)); + // check for widechar, 📦 occupies columns 34, 35, and gets selected even if only the first column is selected + grid.end_selection(Some(&Position::new(2, 8))); + let text = grid.get_selected_text(); + assert_eq!( + text.unwrap(), + "eu scelerisque felis imperdiet proin fermentum leo.\nCursus risus at ultrices mi tempus.\nLaoreet id donec ultrices tincidunt arcu non sodales.\nAmet dictum sit amet justo donec enim.\nHac habi" + ); +} + +#[test] +fn copy_selected_text_from_lines_below() { + let mut vte_parser = vte::Parser::new(); + let mut grid = Grid::new(27, 125, Palette::default()); + let fixture_name = "grid_copy"; + let content = read_fixture(fixture_name); + for byte in content { + vte_parser.advance(&mut grid, byte); + } + + grid.move_viewport_up(40); + + grid.start_selection(&Position::new(63, 6)); + // check for widechar, 📦 occupies columns 34, 35, and gets selected even if only the first column is selected + grid.end_selection(Some(&Position::new(65, 35))); + let text = grid.get_selected_text(); + assert_eq!( + text.unwrap(), + "mauris in aliquam sem fringilla.\n\nzellij on  mouse-support [?] is 📦" + ); +} + /* * These tests below are general compatibility tests for non-trivial scenarios running in the terminal. * They use fake TTY input replicated from these scenarios. diff --git a/zellij-server/src/panes/unit/selection_tests.rs b/zellij-server/src/panes/unit/selection_tests.rs new file mode 100644 index 00000000..1b41f522 --- /dev/null +++ b/zellij-server/src/panes/unit/selection_tests.rs @@ -0,0 +1,197 @@ +use super::*; + +#[test] +fn selection_start() { + let mut selection = Selection::default(); + selection.start(Position::new(10, 10)); + + assert!(selection.active); + assert_eq!(selection.start, Position::new(10, 10)); + assert_eq!(selection.end, Position::new(10, 10)); +} + +#[test] +fn selection_to() { + let mut selection = Selection::default(); + selection.start(Position::new(10, 10)); + let is_active = selection.active; + selection.to(Position::new(20, 30)); + + assert_eq!(selection.active, is_active); + assert_eq!(selection.end, Position::new(20, 30)); +} + +#[test] +fn selection_end_with_position() { + let mut selection = Selection::default(); + selection.start(Position::new(10, 10)); + selection.end(Some(&Position::new(20, 30))); + + assert!(!selection.active); + assert_eq!(selection.end, Position::new(20, 30)); +} + +#[test] +fn selection_end_without_position() { + let mut selection = Selection::default(); + selection.start(Position::new(10, 10)); + selection.to(Position::new(15, 100)); + selection.end(None); + + assert!(!selection.active); + assert_eq!(selection.end, Position::new(15, 100)); +} + +#[test] +fn contains() { + struct TestCase<'a> { + selection: &'a Selection, + position: Position, + result: bool, + } + + let selection = Selection { + start: Position::new(10, 5), + end: Position::new(40, 20), + active: false, + }; + + let test_cases = vec![ + TestCase { + selection: &selection, + position: Position::new(10, 5), + result: true, + }, + TestCase { + selection: &selection, + position: Position::new(10, 4), + result: false, + }, + TestCase { + selection: &selection, + position: Position::new(20, 0), + result: true, + }, + TestCase { + selection: &selection, + position: Position::new(20, 21), + result: true, + }, + TestCase { + selection: &selection, + position: Position::new(40, 19), + result: true, + }, + TestCase { + selection: &selection, + position: Position::new(40, 20), + result: false, + }, + ]; + + for test_case in test_cases { + let result = test_case.selection.contains( + test_case.position.line.0 as usize, + test_case.position.column.0, + ); + assert_eq!(result, test_case.result) + } +} + +#[test] +fn sorted() { + let selection = Selection { + start: Position::new(1, 1), + end: Position::new(10, 2), + active: false, + }; + let sorted_selection = selection.sorted(); + assert_eq!(selection.start, sorted_selection.start); + assert_eq!(selection.end, sorted_selection.end); + + let selection = Selection { + start: Position::new(10, 2), + end: Position::new(1, 1), + active: false, + }; + let sorted_selection = selection.sorted(); + assert_eq!(selection.end, sorted_selection.start); + assert_eq!(selection.start, sorted_selection.end); +} + +#[test] +fn line_indices() { + let selection = Selection { + start: Position::new(1, 1), + end: Position::new(10, 2), + active: false, + }; + + assert_eq!(selection.line_indices(), (1..=10)) +} + +#[test] +fn move_up_inactive() { + let start = Position::new(10, 1); + let end = Position::new(20, 2); + let mut inactive_selection = Selection { + start, + end, + active: false, + }; + + inactive_selection.move_up(2); + assert_eq!(inactive_selection.start, Position::new(8, 1)); + assert_eq!(inactive_selection.end, Position::new(18, 2)); + inactive_selection.move_up(10); + assert_eq!(inactive_selection.start, Position::new(-2, 1)); + assert_eq!(inactive_selection.end, Position::new(8, 2)); +} + +#[test] +fn move_up_active() { + let start = Position::new(10, 1); + let end = Position::new(20, 2); + let mut inactive_selection = Selection { + start, + end, + active: true, + }; + + inactive_selection.move_up(2); + assert_eq!(inactive_selection.start, Position::new(8, 1)); + assert_eq!(inactive_selection.end, end); +} + +#[test] +fn move_down_inactive() { + let start = Position::new(10, 1); + let end = Position::new(20, 2); + let mut inactive_selection = Selection { + start, + end, + active: false, + }; + + inactive_selection.move_down(2); + assert_eq!(inactive_selection.start, Position::new(12, 1)); + assert_eq!(inactive_selection.end, Position::new(22, 2)); + inactive_selection.move_down(10); + assert_eq!(inactive_selection.start, Position::new(22, 1)); + assert_eq!(inactive_selection.end, Position::new(32, 2)); +} + +#[test] +fn move_down_active() { + let start = Position::new(10, 1); + let end = Position::new(20, 2); + let mut inactive_selection = Selection { + start, + end, + active: true, + }; + + inactive_selection.move_down(2); + assert_eq!(inactive_selection.start, Position::new(12, 1)); + assert_eq!(inactive_selection.end, end); +} diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs index 7839f796..22d843f0 100644 --- a/zellij-server/src/route.rs +++ b/zellij-server/src/route.rs @@ -109,12 +109,24 @@ fn route_action( .send_to_screen(ScreenInstruction::ScrollUp) .unwrap(); } + Action::ScrollUpAt(point) => { + session + .senders + .send_to_screen(ScreenInstruction::ScrollUpAt(point)) + .unwrap(); + } Action::ScrollDown => { session .senders .send_to_screen(ScreenInstruction::ScrollDown) .unwrap(); } + Action::ScrollDownAt(point) => { + session + .senders + .send_to_screen(ScreenInstruction::ScrollDownAt(point)) + .unwrap(); + } Action::PageScrollUp => { session .senders @@ -214,6 +226,30 @@ fn route_action( to_server.send(ServerInstruction::DetachSession).unwrap(); should_break = true; } + Action::LeftClick(point) => { + session + .senders + .send_to_screen(ScreenInstruction::LeftClick(point)) + .unwrap(); + } + Action::MouseRelease(point) => { + session + .senders + .send_to_screen(ScreenInstruction::MouseRelease(point)) + .unwrap(); + } + Action::MouseHold(point) => { + session + .senders + .send_to_screen(ScreenInstruction::MouseHold(point)) + .unwrap(); + } + Action::Copy => { + session + .senders + .send_to_screen(ScreenInstruction::Copy) + .unwrap(); + } Action::NoOp => {} } should_break diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs index 88fb7848..9005d795 100644 --- a/zellij-server/src/screen.rs +++ b/zellij-server/src/screen.rs @@ -5,7 +5,7 @@ use std::os::unix::io::RawFd; use std::str; use std::sync::{Arc, RwLock}; -use zellij_utils::{input::layout::Layout, zellij_tile}; +use zellij_utils::{input::layout::Layout, position::Position, zellij_tile}; use crate::{ panes::PaneId, @@ -47,7 +47,9 @@ pub(crate) enum ScreenInstruction { MoveFocusRightOrNextTab, Exit, ScrollUp, + ScrollUpAt(Position), ScrollDown, + ScrollDownAt(Position), PageScrollUp, PageScrollDown, ClearScroll, @@ -68,6 +70,10 @@ pub(crate) enum ScreenInstruction { UpdateTabName(Vec), TerminalResize(PositionAndSize), ChangeMode(ModeInfo), + LeftClick(Position), + MouseRelease(Position), + MouseHold(Position), + Copy, } impl From<&ScreenInstruction> for ScreenContext { @@ -119,6 +125,12 @@ impl From<&ScreenInstruction> for ScreenContext { ScreenInstruction::TerminalResize(_) => ScreenContext::TerminalResize, ScreenInstruction::ChangeMode(_) => ScreenContext::ChangeMode, ScreenInstruction::ToggleActiveSyncTab => ScreenContext::ToggleActiveSyncTab, + ScreenInstruction::ScrollUpAt(_) => ScreenContext::ScrollUpAt, + ScreenInstruction::ScrollDownAt(_) => ScreenContext::ScrollDownAt, + ScreenInstruction::LeftClick(_) => ScreenContext::LeftClick, + ScreenInstruction::MouseRelease(_) => ScreenContext::MouseRelease, + ScreenInstruction::MouseHold(_) => ScreenContext::MouseHold, + ScreenInstruction::Copy => ScreenContext::Copy, } } } @@ -547,12 +559,24 @@ pub(crate) fn screen_thread_main( .unwrap() .scroll_active_terminal_up(); } + ScreenInstruction::ScrollUpAt(point) => { + screen + .get_active_tab_mut() + .unwrap() + .scroll_terminal_up(&point, 3); + } ScreenInstruction::ScrollDown => { screen .get_active_tab_mut() .unwrap() .scroll_active_terminal_down(); } + ScreenInstruction::ScrollDownAt(point) => { + screen + .get_active_tab_mut() + .unwrap() + .scroll_terminal_down(&point, 3); + } ScreenInstruction::PageScrollUp => { screen .get_active_tab_mut() @@ -674,6 +698,27 @@ pub(crate) fn screen_thread_main( .toggle_sync_panes_is_active(); screen.update_tabs(); } + ScreenInstruction::LeftClick(point) => { + screen + .get_active_tab_mut() + .unwrap() + .handle_left_click(&point); + } + ScreenInstruction::MouseRelease(point) => { + screen + .get_active_tab_mut() + .unwrap() + .handle_mouse_release(&point); + } + ScreenInstruction::MouseHold(point) => { + screen + .get_active_tab_mut() + .unwrap() + .handle_mouse_hold(&point); + } + ScreenInstruction::Copy => { + screen.get_active_tab().unwrap().copy_selection(); + } ScreenInstruction::Exit => { break; } diff --git a/zellij-server/src/tab.rs b/zellij-server/src/tab.rs index 523f65df..68e7b7a6 100644 --- a/zellij-server/src/tab.rs +++ b/zellij-server/src/tab.rs @@ -1,7 +1,7 @@ //! `Tab`s holds multiple panes. It tracks their coordinates (x/y) and size, //! as well as how they should be resized -use zellij_utils::{serde, zellij_tile}; +use zellij_utils::{position::Position, serde, zellij_tile}; #[cfg(not(feature = "parametric_resize_beta"))] use crate::ui::pane_resizer::PaneResizer; @@ -143,6 +143,19 @@ pub trait Pane { fn cursor_shape_csi(&self) -> String { "\u{1b}[0 q".to_string() // default to non blinking block } + fn contains(&self, position: &Position) -> bool { + match self.position_and_size_override() { + Some(position_and_size) => position_and_size.contains(position), + None => self.position_and_size().contains(position), + } + } + fn start_selection(&mut self, _start: &Position) {} + fn update_selection(&mut self, _position: &Position) {} + fn end_selection(&mut self, _end: Option<&Position>) {} + fn reset_selection(&mut self) {} + fn get_selected_text(&self) -> Option { + None + } fn right_boundary_x_coords(&self) -> usize { self.x() + self.columns() @@ -223,6 +236,12 @@ pub trait Pane { vec![] } fn render_full_viewport(&mut self) {} + fn relative_position(&self, position: &Position) -> Position { + match self.position_and_size_override() { + Some(position_and_size) => position.relative_to(&position_and_size), + None => position.relative_to(&self.position_and_size()), + } + } } impl Tab { @@ -2271,6 +2290,97 @@ impl Tab { active_terminal.clear_scroll(); } } + pub fn scroll_terminal_up(&mut self, point: &Position, lines: usize) { + if let Some(pane) = self.get_pane_at(point) { + pane.scroll_up(lines); + self.render(); + } + } + pub fn scroll_terminal_down(&mut self, point: &Position, lines: usize) { + if let Some(pane) = self.get_pane_at(point) { + pane.scroll_down(lines); + self.render(); + } + } + fn get_pane_at(&mut self, point: &Position) -> Option<&mut Box> { + if let Some(pane_id) = self.get_pane_id_at(point) { + self.panes.get_mut(&pane_id) + } else { + None + } + } + fn get_pane_id_at(&self, point: &Position) -> Option { + if self.fullscreen_is_active { + return self.get_active_pane_id(); + } + + self.get_selectable_panes() + .find(|(_, p)| p.contains(point)) + .map(|(&id, _)| id) + } + pub fn handle_left_click(&mut self, position: &Position) { + self.focus_pane_at(position); + + if let Some(pane) = self.get_pane_at(position) { + let relative_position = pane.relative_position(position); + pane.start_selection(&relative_position); + self.render(); + }; + } + fn focus_pane_at(&mut self, point: &Position) { + if let Some(clicked_pane) = self.get_pane_id_at(point) { + self.active_terminal = Some(clicked_pane); + self.render(); + } + } + pub fn handle_mouse_release(&mut self, position: &Position) { + let active_pane_id = self.get_active_pane_id(); + // on release, get the selected text from the active pane, and reset it's selection + let mut selected_text = None; + if active_pane_id != self.get_pane_id_at(position) { + if let Some(active_pane_id) = active_pane_id { + if let Some(active_pane) = self.panes.get_mut(&active_pane_id) { + active_pane.end_selection(None); + selected_text = active_pane.get_selected_text(); + active_pane.reset_selection(); + self.render(); + } + } + } else if let Some(pane) = self.get_pane_at(position) { + let relative_position = pane.relative_position(position); + pane.end_selection(Some(&relative_position)); + selected_text = pane.get_selected_text(); + pane.reset_selection(); + self.render(); + } + + if let Some(selected_text) = selected_text { + self.write_selection_to_clipboard(&selected_text); + } + } + pub fn handle_mouse_hold(&mut self, position: &Position) { + if let Some(active_pane_id) = self.get_active_pane_id() { + if let Some(active_pane) = self.panes.get_mut(&active_pane_id) { + let relative_position = active_pane.relative_position(position); + active_pane.update_selection(&relative_position); + } + } + self.render(); + } + + pub fn copy_selection(&self) { + let selected_text = self.get_active_pane().and_then(|p| p.get_selected_text()); + if let Some(selected_text) = selected_text { + self.write_selection_to_clipboard(&selected_text); + } + } + + fn write_selection_to_clipboard(&self, selection: &str) { + let output = format!("\u{1b}]52;c;{}\u{1b}\\", base64::encode(selection)); + self.senders + .send_to_server(ServerInstruction::Render(Some(output))) + .unwrap(); + } } #[cfg(test)] diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs index b37a3a07..b32dd337 100644 --- a/zellij-utils/src/errors.rs +++ b/zellij-utils/src/errors.rs @@ -190,6 +190,7 @@ pub enum ScreenContext { SwitchFocus, FocusNextPane, FocusPreviousPane, + FocusPaneAt, MoveFocusLeft, MoveFocusLeftOrPreviousTab, MoveFocusDown, @@ -198,7 +199,9 @@ pub enum ScreenContext { MoveFocusRightOrNextTab, Exit, ScrollUp, + ScrollUpAt, ScrollDown, + ScrollDownAt, PageScrollUp, PageScrollDown, ClearScroll, @@ -219,6 +222,10 @@ pub enum ScreenContext { UpdateTabName, TerminalResize, ChangeMode, + LeftClick, + MouseRelease, + MouseHold, + Copy, } /// Stack call representations corresponding to the different types of [`PtyInstruction`]s. diff --git a/zellij-utils/src/input/actions.rs b/zellij-utils/src/input/actions.rs index 36fd93b1..ecd515a4 100644 --- a/zellij-utils/src/input/actions.rs +++ b/zellij-utils/src/input/actions.rs @@ -4,6 +4,8 @@ use super::command::RunCommandAction; use serde::{Deserialize, Serialize}; use zellij_tile::data::InputMode; +use crate::position::Position; + /// The four directions (left, right, up, down). #[derive(Eq, Clone, Debug, PartialEq, Deserialize, Serialize)] pub enum Direction { @@ -39,8 +41,12 @@ pub enum Action { MoveFocusOrTab(Direction), /// Scroll up in focus pane. ScrollUp, + /// Scroll up at point + ScrollUpAt(Position), /// Scroll down in focus pane. ScrollDown, + /// Scroll down at point + ScrollDownAt(Position), /// Scroll up one page in focus pane. PageScrollUp, /// Scroll down one page in focus pane. @@ -70,4 +76,8 @@ pub enum Action { Run(RunCommandAction), /// Detach session and exit Detach, + LeftClick(Position), + MouseRelease(Position), + MouseHold(Position), + Copy, } diff --git a/zellij-utils/src/input/mod.rs b/zellij-utils/src/input/mod.rs index 5c1dc262..11c5a843 100644 --- a/zellij-utils/src/input/mod.rs +++ b/zellij-utils/src/input/mod.rs @@ -5,6 +5,7 @@ pub mod command; pub mod config; pub mod keybinds; pub mod layout; +pub mod mouse; pub mod options; pub mod theme; diff --git a/zellij-utils/src/input/mouse.rs b/zellij-utils/src/input/mouse.rs new file mode 100644 index 00000000..d5dc4330 --- /dev/null +++ b/zellij-utils/src/input/mouse.rs @@ -0,0 +1,69 @@ +use serde::{Deserialize, Serialize}; + +use crate::position::Position; + +/// A mouse related event +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub enum MouseEvent { + /// A mouse button was pressed. + /// + /// The coordinates are zero-based. + Press(MouseButton, Position), + /// A mouse button was released. + /// + /// The coordinates are zero-based. + Release(Position), + /// A mouse button is held over the given coordinates. + /// + /// The coordinates are zero-based. + Hold(Position), +} + +impl From for MouseEvent { + fn from(event: termion::event::MouseEvent) -> Self { + match event { + termion::event::MouseEvent::Press(button, x, y) => Self::Press( + MouseButton::from(button), + Position::new((y.saturating_sub(1)) as i32, x.saturating_sub(1)), + ), + termion::event::MouseEvent::Release(x, y) => Self::Release(Position::new( + (y.saturating_sub(1)) as i32, + x.saturating_sub(1), + )), + termion::event::MouseEvent::Hold(x, y) => Self::Hold(Position::new( + (y.saturating_sub(1)) as i32, + x.saturating_sub(1), + )), + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] +pub enum MouseButton { + /// The left mouse button. + Left, + /// The right mouse button. + Right, + /// The middle mouse button. + Middle, + /// Mouse wheel is going up. + /// + /// This event is typically only used with Mouse::Press. + WheelUp, + /// Mouse wheel is going down. + /// + /// This event is typically only used with Mouse::Press. + WheelDown, +} + +impl From for MouseButton { + fn from(button: termion::event::MouseButton) -> Self { + match button { + termion::event::MouseButton::Left => Self::Left, + termion::event::MouseButton::Right => Self::Right, + termion::event::MouseButton::Middle => Self::Middle, + termion::event::MouseButton::WheelUp => Self::WheelUp, + termion::event::MouseButton::WheelDown => Self::WheelDown, + } + } +} diff --git a/zellij-utils/src/input/options.rs b/zellij-utils/src/input/options.rs index 10571978..2ba2be86 100644 --- a/zellij-utils/src/input/options.rs +++ b/zellij-utils/src/input/options.rs @@ -27,6 +27,9 @@ pub struct Options { /// subdirectory of config dir #[structopt(long, parse(from_os_str))] pub layout_dir: Option, + #[structopt(long)] + #[serde(default)] + pub disable_mouse_mode: bool, } impl Options { @@ -68,12 +71,19 @@ impl Options { other => other, }; + let disable_mouse_mode = if other.disable_mouse_mode { + true + } else { + self.disable_mouse_mode + }; + Options { simplified_ui, theme, default_mode, default_shell, layout_dir, + disable_mouse_mode, } } diff --git a/zellij-utils/src/lib.rs b/zellij-utils/src/lib.rs index 6285047f..0aa95830 100644 --- a/zellij-utils/src/lib.rs +++ b/zellij-utils/src/lib.rs @@ -6,6 +6,7 @@ pub mod input; pub mod ipc; pub mod logging; pub mod pane_size; +pub mod position; pub mod setup; pub mod shared; diff --git a/zellij-utils/src/pane_size.rs b/zellij-utils/src/pane_size.rs index 8a939381..da165d4e 100644 --- a/zellij-utils/src/pane_size.rs +++ b/zellij-utils/src/pane_size.rs @@ -1,6 +1,8 @@ use nix::pty::Winsize; use serde::{Deserialize, Serialize}; +use crate::position::Position; + /// Contains the position and size of a [`Pane`], or more generally of any terminal, measured /// in character rows and columns. #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] @@ -25,3 +27,11 @@ impl From for PositionAndSize { } } } + +impl PositionAndSize { + pub fn contains(&self, point: &Position) -> bool { + let col = point.column.0 as usize; + let row = point.line.0 as usize; + self.x <= col && col < self.x + self.cols && self.y <= row && row < self.y + self.rows + } +} diff --git a/zellij-utils/src/position.rs b/zellij-utils/src/position.rs new file mode 100644 index 00000000..71547424 --- /dev/null +++ b/zellij-utils/src/position.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +use crate::pane_size::PositionAndSize; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Deserialize, Serialize)] +pub struct Position { + pub line: Line, + pub column: Column, +} + +impl Position { + pub fn new(line: i32, column: u16) -> Self { + Self { + line: Line(line as isize), + column: Column(column as usize), + } + } + + pub fn relative_to(&self, position_and_size: &PositionAndSize) -> Self { + Self { + line: Line(self.line.0 - position_and_size.y as isize), + column: Column(self.column.0.saturating_sub(position_and_size.x)), + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize, PartialOrd)] +pub struct Line(pub isize); +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize, PartialOrd)] +pub struct Column(pub usize);