Skip to content

feat: set text mouse shape for text-mode panes using OSC 22 mouse shape protocol (#4173) #4174

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions zellij-client/src/input_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,12 @@ impl InputHandler {
.send(ClientInstruction::SetSynchronizedOutput(enabled))
.unwrap();
},
AnsiStdinInstruction::MousePointerShapesSupport(support_values) => {
let is_supported = !support_values.is_empty() && support_values.iter().all(|&v| v);
self.send_client_instructions
.send(ClientInstruction::SetMousePointerShapesSupported(is_supported))
.unwrap();
},
}
}
fn handle_mouse_event(&mut self, mouse_event: &MouseEvent) {
Expand Down
36 changes: 35 additions & 1 deletion zellij-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use crate::{
os_input_output::ClientOsApi, stdin_handler::stdin_loop,
};
use termwiz::input::InputEvent;
use zellij_utils::mouse_pointer_shapes::{MousePointerShape, MousePointerShapeProtocolMode};
use zellij_utils::{
channels::{self, ChannelWithContext, SenderWithContext},
consts::{set_permissions, ZELLIJ_SOCK_DIR},
Expand Down Expand Up @@ -56,6 +57,8 @@ pub(crate) enum ClientInstruction {
CliPipeOutput((), ()), // String -> pipe name, String -> output
QueryTerminalSize,
WriteConfigToDisk { config: String },
SetMousePointerShapesSupported(bool),
SetMousePointerShape(MousePointerShape),
}

impl From<ServerToClientMsg> for ClientInstruction {
Expand All @@ -80,6 +83,9 @@ impl From<ServerToClientMsg> for ClientInstruction {
ServerToClientMsg::WriteConfigToDisk { config } => {
ClientInstruction::WriteConfigToDisk { config }
},
ServerToClientMsg::SetMousePointerShape(pointer_shape) => {
ClientInstruction::SetMousePointerShape(pointer_shape)
},
}
}
}
Expand All @@ -102,6 +108,10 @@ impl From<&ClientInstruction> for ClientContext {
ClientInstruction::CliPipeOutput(..) => ClientContext::CliPipeOutput,
ClientInstruction::QueryTerminalSize => ClientContext::QueryTerminalSize,
ClientInstruction::WriteConfigToDisk { .. } => ClientContext::WriteConfigToDisk,
ClientInstruction::SetMousePointerShapesSupported(..) => {
ClientContext::SetMousePointerShapesSupported
},
ClientInstruction::SetMousePointerShape(..) => ClientContext::SetMousePointerShape,
}
}
}
Expand Down Expand Up @@ -441,10 +451,18 @@ pub fn start_client(
let mut exit_msg = String::new();
let mut loading = true;
let mut pending_instructions = vec![];
let mut synchronised_output = match os_input.env_variable("TERM").as_deref() {
let env_term = os_input.env_variable("TERM");
let env_term = env_term.as_deref();
let mut synchronised_output = match env_term {
Some("alacritty") => Some(SyncOutput::DCS),
_ => None,
};
let is_xterm = env_term == Some("xterm") || env_term.map(|o| o.starts_with("xterm-")) == Some(true);
let mut mouse_pointer_shape_protocol_mode = if is_xterm {
Some(MousePointerShapeProtocolMode::XTerm)
} else {
None
};

let mut stdout = os_input.get_stdout_writer();
stdout
Expand Down Expand Up @@ -561,6 +579,22 @@ pub fn start_client(
},
}
},
ClientInstruction::SetMousePointerShapesSupported(is_supported) => {
mouse_pointer_shape_protocol_mode = if is_supported {
Some(MousePointerShapeProtocolMode::Kitty)
} else {
None
}
},
ClientInstruction::SetMousePointerShape(shape) => {
if let Some(mode) = mouse_pointer_shape_protocol_mode {
let mut stdout = os_input.get_stdout_writer();
stdout
.write_all(shape.generate_set_mouse_pointer_escape_sequence(mode).as_bytes())
.expect("cannot write to stdout");
stdout.flush().expect("could not flush");
}
}
_ => {},
}
}
Expand Down
21 changes: 21 additions & 0 deletions zellij-client/src/stdin_ansi_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ impl StdinAnsiParser {
"\u{1b}[14t\u{1b}[16t\u{1b}]11;?\u{1b}\u{5c}\u{1b}]10;?\u{1b}\u{5c}\u{1b}[?2026$p",
);

// query mouse pointer shapes support
query_string += "\u{1b}]22;?default,text\u{1b}\u{5c}";

// query colors
// eg. <ESC>]4;5;?<ESC>\ => query color register number 5
for i in 0..256 {
Expand Down Expand Up @@ -151,6 +154,9 @@ impl StdinAnsiParser {
if let Ok(ansi_sequence) = AnsiStdinInstruction::bg_or_fg_from_bytes(&self.raw_buffer) {
self.pending_events.push(ansi_sequence);
self.raw_buffer.clear();
} else if let Some(ansi_sequence) = AnsiStdinInstruction::mouse_pointer_shapes_support_from_bytes(&self.raw_buffer) {
self.pending_events.push(ansi_sequence);
self.raw_buffer.clear();
} else if let Ok((color_register, color_sequence)) =
color_sequence_from_bytes(&self.raw_buffer)
{
Expand Down Expand Up @@ -181,6 +187,7 @@ pub enum AnsiStdinInstruction {
ForegroundColor(String),
ColorRegisters(Vec<(usize, String)>),
SynchronizedOutput(Option<SyncOutput>),
MousePointerShapesSupport(Vec<bool>),
}

impl AnsiStdinInstruction {
Expand Down Expand Up @@ -282,6 +289,20 @@ impl AnsiStdinInstruction {
None
}
}
pub fn mouse_pointer_shapes_support_from_bytes(bytes: &[u8]) -> Option<Self> {
// eg. <ESC>]22;1,1,0,1<ESC>\
lazy_static! {
static ref RE: Regex = Regex::new(r"^\u{1b}]22;([0-9,]+)\u{1b}\\$").unwrap();
}
let key_string = String::from_utf8_lossy(bytes);
let captures = RE.captures_iter(&key_string).next()?;
let support_string = captures[1].to_string();
let support_values = support_string
.split(',')
.map(|s| s == "1")
.collect::<Vec<bool>>();
Some(AnsiStdinInstruction::MousePointerShapesSupport(support_values))
}
}

fn color_sequence_from_bytes(bytes: &[u8]) -> Result<(usize, String), &'static str> {
Expand Down
8 changes: 7 additions & 1 deletion zellij-server/src/panes/grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub const MAX_TITLE_STACK_SIZE: usize = 1000;

use vte::{Params, Perform};
use zellij_utils::{consts::VERSION, shared::version_number};

use zellij_utils::mouse_pointer_shapes::MousePointerShape;
use crate::output::{CharacterChunk, OutputBuffer, SixelImageChunk};
use crate::panes::alacritty_functions::{parse_number, xparse_color};
use crate::panes::link_handler::LinkHandler;
Expand Down Expand Up @@ -2386,6 +2386,12 @@ impl Grid {
},
}
}
pub fn get_mouse_pointer_shape(&self) -> MousePointerShape {
match &self.mouse_tracking {
MouseTracking::Off => MousePointerShape::Text,
_ => MousePointerShape::Default
}
}
pub fn is_alternate_mode_active(&self) -> bool {
self.alternate_screen_state.is_some()
}
Expand Down
6 changes: 5 additions & 1 deletion zellij-server/src/panes/terminal_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use zellij_utils::{
position::Position,
shared::make_terminal_title,
};

use zellij_utils::mouse_pointer_shapes::MousePointerShape;
use crate::ui::pane_boundaries_frame::{FrameParams, PaneFrame};

pub const SELECTION_SCROLL_INTERVAL_MS: u64 = 10;
Expand Down Expand Up @@ -642,6 +642,10 @@ impl Pane for TerminalPane {
self.exclude_from_sync
}

fn get_mouse_pointer_shape(&self, _relative_position: Position) -> MousePointerShape {
self.grid.get_mouse_pointer_shape()
}

fn mouse_event(&self, event: &MouseEvent, _client_id: ClientId) -> Option<String> {
self.grid.mouse_event_signal(event)
}
Expand Down
30 changes: 30 additions & 0 deletions zellij-server/src/tab/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ use zellij_utils::{
},
pane_size::{Offset, PaneGeom, Size, SizeInPixels, Viewport},
};
use zellij_utils::ipc::ServerToClientMsg;
use zellij_utils::mouse_pointer_shapes::MousePointerShape;

#[macro_export]
macro_rules! resize_pty {
Expand Down Expand Up @@ -265,6 +267,7 @@ pub(crate) struct Tab {
current_pane_group: Rc<RefCell<HashMap<ClientId, Vec<PaneId>>>>,
advanced_mouse_actions: bool,
currently_marking_pane_group: Rc<RefCell<HashMap<ClientId, bool>>>,
mouse_cursor_shape: HashMap<ClientId, MousePointerShape>,
}

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
Expand Down Expand Up @@ -501,6 +504,10 @@ pub trait Pane {
fn set_exclude_from_sync(&mut self, exclude_from_sync: bool);
fn exclude_from_sync(&self) -> bool;

fn get_mouse_pointer_shape(&self, relative_position: Position) -> MousePointerShape {
MousePointerShape::Text
}

// TODO: this should probably be merged with the mouse_right_click
fn handle_right_click(&mut self, _to: &Position, _client_id: ClientId) {}
fn mouse_event(&self, _event: &MouseEvent, _client_id: ClientId) -> Option<String> {
Expand Down Expand Up @@ -776,6 +783,7 @@ impl Tab {
current_pane_group,
currently_marking_pane_group,
advanced_mouse_actions,
mouse_cursor_shape: HashMap::new(),
}
}

Expand Down Expand Up @@ -3902,6 +3910,28 @@ impl Tab {
.get_active_pane_id(client_id)
.ok_or_else(|| anyhow!("Failed to find pane at position"))?;

let new_pointer_shape = if let Some(pane) = self
.get_pane_at(&absolute_position, true)
.with_context(err_context)?
{
if pane.position_is_on_frame(&absolute_position) {
Some(MousePointerShape::Default)
} else {
let relative_position = pane.relative_position(&absolute_position);
Some(pane.get_mouse_pointer_shape(relative_position))
}
} else {
None
};

if let Some(new_shape) = new_pointer_shape {
let last_shape = self.mouse_cursor_shape.get(&client_id).copied().unwrap_or(MousePointerShape::Default);
if last_shape != new_shape {
self.mouse_cursor_shape.insert(client_id, new_shape);
let _ = self.os_api.send_to_client(client_id, ServerToClientMsg::SetMousePointerShape(new_shape));
}
}

if let Some(pane) = self
.get_pane_at(&absolute_position, false)
.with_context(err_context)?
Expand Down
2 changes: 2 additions & 0 deletions zellij-utils/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,8 @@ pub enum ClientContext {
CliPipeOutput,
QueryTerminalSize,
WriteConfigToDisk,
SetMousePointerShapesSupported,
SetMousePointerShape,
}

/// Stack call representations corresponding to the different types of [`ServerInstruction`]s.
Expand Down
2 changes: 2 additions & 0 deletions zellij-utils/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use std::{
os::unix::io::{AsRawFd, FromRawFd},
path::PathBuf,
};
use crate::mouse_pointer_shapes::MousePointerShape;

type SessionId = u64;

Expand Down Expand Up @@ -110,6 +111,7 @@ pub enum ServerToClientMsg {
CliPipeOutput(String, String), // String -> pipe name, String -> Output
QueryTerminalSize,
WriteConfigToDisk { config: String },
SetMousePointerShape(MousePointerShape),
}

#[derive(Serialize, Deserialize, Debug, Clone)]
Expand Down
1 change: 1 addition & 0 deletions zellij-utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod position;
pub mod session_serialization;
pub mod setup;
pub mod shared;
pub mod mouse_pointer_shapes;

// The following modules can't be used when targeting wasm
#[cfg(not(target_family = "wasm"))]
Expand Down
43 changes: 43 additions & 0 deletions zellij-utils/src/mouse_pointer_shapes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};

/// Mouse pointer shapes as defined in the OSC 22 protocol.
/// See https://sw.kovidgoyal.net/kitty/pointer-shapes/
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum MousePointerShape {
/// Default cursor (arrow)
Default,
/// Text cursor (I-beam)
Text,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MousePointerShapeProtocolMode {
XTerm,
Kitty,
}

impl MousePointerShape {
/// See https://sw.kovidgoyal.net/kitty/pointer-shapes/
fn kitty_name(&self) -> &'static str {
match self {
Self::Default => "default",
Self::Text => "text",
}
}

/// See https://github.com/xterm-x11/xterm-snapshots/blob/5b7a08a3482b425c97/xterm.man#L4674
fn xterm_name(&self) -> &'static str {
match self {
Self::Default => "left_ptr",
Self::Text => "xterm",
}
}

pub fn generate_set_mouse_pointer_escape_sequence(&self, mode: MousePointerShapeProtocolMode) -> String {
let name = match mode {
MousePointerShapeProtocolMode::XTerm => self.xterm_name(),
MousePointerShapeProtocolMode::Kitty => self.kitty_name(),
};
format!("\x1b]22;{}\x1b\\", name)
}
}