feat(ui): initial mouse support (#448)

* Initial mouse support

* enable mouse support (termion MouseTerminal)
* handle scroll up and down events

* Allow enabling/disabling of mouse reporting

Store the mouse terminal on OsInputOutput

* WIP: switch pane focus with mouse

* testing to get mouse character selection

* wip mouse selection

* wip: mouse selection

- initial handling of mouse events for
  text selection within a pane
- wide characters currently problematic
- selection is not highlighted

* highlight currently selected text

* improve get currently selected text from TerminalPane

* copy selection to clipboard (wayland only for now)

* Add missing set_should_render on selection update

* Improve text selection

- Strip whitespace from end of lines
- Insert newlines when selection spans multiple lines

* Simplify selection logic

- Remove Range struct
- Selection is not an Option anymore, it's empty when start == end

* copy selection to clipboard with OSC-52 sequence

* Improve text selection with multiple panes

- Constrain mouse hold and release events to currently active pane
- Fix calculation of columns with side by side panes

* fix for PositionAndSize changes

* Fix mouse selection with full screen pane.

- Transform mouse event positions to relative when passing to pane.

* Move selection to grid, rework highlighting.

- Mark selected lines for update in grid output buffer, for now in the
  simplest way possible, but can be made more efficient by calculating
  changed lines only.
- Clear selection on pane resize.
- Re-add logic to forward mouse hold and release events only to
  currently active pane.

* Tidy up selection

- add method to get selection line range
- add method to sort the current selection

* Grid: move current selection up/down when scrolling

- Make the selection work with lines in lines_above and lines_below
- Todo: update selection end when scrolling with mouse button being held
- Todo: figure out how to move selection when new characters are added.

* Grid: move selection when new lines are added

* Keep track of selection being active

- Handle the selection growing/shrinking when scrolling with mouse
  button held down but not yet released.

* Improve selection end with multiple panes

* Tidying up

- remove logging statements, unused code

* Add some unit tests for selection, move position to zellij-utils

* Change Position::new to take i32 for line

* Grid: add unit tests for copy from selection

* add basic integration test for mouse pane focus

* Add basic integration test for mouse scroll

* Use color from palette for selection rendering

* Improve performance of selection render

- Try to minimize lines to update on selection start/update/end

* fixes for updated start method

* fix lines not in viewport being marked for rendering

- the previous optimization to grid selection rendering was always
adding the lines at the extremes of previous selection, causing problems
when scrolling up or down
- make sure to only add lines in viewport

* Disable mouse mode on exit

* use saturating_sub for usize subtractions

* copy selection to clipboard on mouse release

* disable mouse on exit after error

* remove left-over comments

* remove copy keybinding

* add default impl for selection methods in Pane

- remove the useless methods from Impl Pane in PluginPane

* move line diff between selections to selection

* add option to disable mouse mode

* Allow scrolling with mouse while selecting.

* move action repeater to os_input_output, change timeout to 10ms

- change repeater to take an action instead of a position with hardcoded
action

* replace mouse mode integration tests with e2e tests

* cleanup

* cleanup

* check if mouse mode is disabled from merged options

* fix missing changes in tests, cleanup
This commit is contained in:
Thomas Linford 2021-07-02 16:40:50 +02:00 committed by GitHub
parent 2a024db839
commit f93308f211
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1309 additions and 17 deletions

14
Cargo.lock generated
View file

@ -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",

View file

@ -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<u8> {
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);
}

View file

@ -0,0 +1,29 @@
---
source: src/tests/e2e/cases.rs
expression: last_snapshot
---
Zellij  Tab #1 
$ █ │$
Ctrl + <g> LOCK  <p> PANE  <t> TAB  <r> RESIZE  <s> SCROLL  <o> SESSION  <q> QUIT 
Tip: Alt + n => open new pane. Alt + [] or hjkl => navigate between panes.

View file

@ -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 + <g> LOCK  <p> PANE  <t> TAB  <r> RESIZE  <s> SCROLL  <o> SESSION  <q> QUIT 
Tip: Alt + n => open new pane. Alt + [] or hjkl => navigate between panes.

59
src/tests/fixtures/grid_copy vendored Executable file
View file

@ -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 
 

View file

@ -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" }

View file

@ -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<dyn ClientOsApi>,
config: Config,
options: Options,
command_is_executing: CommandIsExecuting,
send_client_instructions: SenderWithContext<ClientInstruction>,
should_exit: bool,
@ -32,6 +39,7 @@ impl InputHandler {
os_input: Box<dyn ClientOsApi>,
command_is_executing: CommandIsExecuting,
config: Config,
options: Options,
send_client_instructions: SenderWithContext<ClientInstruction>,
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<dyn ClientOsApi>,
config: Config,
options: Options,
command_is_executing: CommandIsExecuting,
send_client_instructions: SenderWithContext<ClientInstruction>,
default_mode: InputMode,
@ -188,6 +226,7 @@ pub(crate) fn input_loop(
os_input,
command_is_executing,
config,
options,
send_client_instructions,
default_mode,
)

View file

@ -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();

View file

@ -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<Mutex<termios::Termios>>,
send_instructions_to_server: Arc<Mutex<Option<IpcSenderWithContext<ClientToServerMsg>>>>,
receive_instructions_from_server: Arc<Mutex<Option<IpcReceiverWithContext<ServerToClientMsg>>>>,
mouse_term: Arc<Mutex<Option<termion::input::MouseTerminal<std::io::Stdout>>>>,
}
/// 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<dyn ClientOsApi> {
@ -191,9 +223,54 @@ impl Clone for Box<dyn ClientOsApi> {
pub fn get_client_os_input() -> Result<ClientOsInputOutput, nix::Error> {
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,
}
}
}

View file

@ -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,

View file

@ -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"

View file

@ -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<u8> {
if input.is_empty() {
@ -315,6 +317,7 @@ pub struct Grid {
pub width: usize,
pub height: usize,
pub pending_messages_to_pty: Vec<Vec<u8>>,
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<String> {
if self.selection.is_empty() {
return None;
}
let mut selection: Vec<String> = 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<TerminalCharacter> = 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 {

View file

@ -1,5 +1,6 @@
mod grid;
mod plugin_pane;
mod selection;
mod terminal_character;
mod terminal_pane;

View file

@ -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<isize> {
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<Item = isize> {
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<isize> = self.get_visible_indices(max).collect();
let new_lines: HashSet<isize> = 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<isize> {
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;

View file

@ -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<Vec<u8>> {
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<String> {
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 {

View file

@ -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<u8> {
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.

View file

@ -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);
}

View file

@ -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

View file

@ -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<u8>),
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;
}

View file

@ -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<String> {
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<dyn Pane>> {
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<PaneId> {
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)]

View file

@ -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.

View file

@ -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,
}

View file

@ -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;

View file

@ -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<termion::event::MouseEvent> 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<termion::event::MouseButton> 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,
}
}
}

View file

@ -27,6 +27,9 @@ pub struct Options {
/// subdirectory of config dir
#[structopt(long, parse(from_os_str))]
pub layout_dir: Option<PathBuf>,
#[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,
}
}

View file

@ -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;

View file

@ -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<Winsize> 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
}
}

View file

@ -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);