Skip to content

Commit b7bd712

Browse files
committed
feat: set text mouse shape for text-mode panes using OSC 22 mouse shape protocol (#4173)
1 parent 226f5dc commit b7bd712

File tree

10 files changed

+152
-3
lines changed

10 files changed

+152
-3
lines changed

zellij-client/src/input_handler.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,12 @@ impl InputHandler {
281281
.send(ClientInstruction::SetSynchronizedOutput(enabled))
282282
.unwrap();
283283
},
284+
AnsiStdinInstruction::MousePointerShapesSupport(support_values) => {
285+
let is_supported = !support_values.is_empty() && support_values.iter().all(|&v| v);
286+
self.send_client_instructions
287+
.send(ClientInstruction::SetMousePointerShapesSupported(is_supported))
288+
.unwrap();
289+
},
284290
}
285291
}
286292
fn handle_mouse_event(&mut self, mouse_event: &MouseEvent) {

zellij-client/src/lib.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use crate::{
2626
os_input_output::ClientOsApi, stdin_handler::stdin_loop,
2727
};
2828
use termwiz::input::InputEvent;
29+
use zellij_utils::mouse_pointer_shapes::{MousePointerShape, MousePointerShapeProtocolMode};
2930
use zellij_utils::{
3031
channels::{self, ChannelWithContext, SenderWithContext},
3132
consts::{set_permissions, ZELLIJ_SOCK_DIR},
@@ -56,6 +57,8 @@ pub(crate) enum ClientInstruction {
5657
CliPipeOutput((), ()), // String -> pipe name, String -> output
5758
QueryTerminalSize,
5859
WriteConfigToDisk { config: String },
60+
SetMousePointerShapesSupported(bool),
61+
SetMousePointerShape(MousePointerShape),
5962
}
6063

6164
impl From<ServerToClientMsg> for ClientInstruction {
@@ -80,6 +83,9 @@ impl From<ServerToClientMsg> for ClientInstruction {
8083
ServerToClientMsg::WriteConfigToDisk { config } => {
8184
ClientInstruction::WriteConfigToDisk { config }
8285
},
86+
ServerToClientMsg::SetMousePointerShape(pointer_shape) => {
87+
ClientInstruction::SetMousePointerShape(pointer_shape)
88+
},
8389
}
8490
}
8591
}
@@ -102,6 +108,10 @@ impl From<&ClientInstruction> for ClientContext {
102108
ClientInstruction::CliPipeOutput(..) => ClientContext::CliPipeOutput,
103109
ClientInstruction::QueryTerminalSize => ClientContext::QueryTerminalSize,
104110
ClientInstruction::WriteConfigToDisk { .. } => ClientContext::WriteConfigToDisk,
111+
ClientInstruction::SetMousePointerShapesSupported(..) => {
112+
ClientContext::SetMousePointerShapesSupported
113+
},
114+
ClientInstruction::SetMousePointerShape(..) => ClientContext::SetMousePointerShape,
105115
}
106116
}
107117
}
@@ -441,10 +451,18 @@ pub fn start_client(
441451
let mut exit_msg = String::new();
442452
let mut loading = true;
443453
let mut pending_instructions = vec![];
444-
let mut synchronised_output = match os_input.env_variable("TERM").as_deref() {
454+
let env_term = os_input.env_variable("TERM");
455+
let env_term = env_term.as_deref();
456+
let mut synchronised_output = match env_term {
445457
Some("alacritty") => Some(SyncOutput::DCS),
446458
_ => None,
447459
};
460+
let is_xterm = env_term == Some("xterm") || env_term.map(|o| o.starts_with("xterm-")) == Some(true);
461+
let mut mouse_pointer_shape_protocol_mode = if is_xterm {
462+
Some(MousePointerShapeProtocolMode::XTerm)
463+
} else {
464+
None
465+
};
448466

449467
let mut stdout = os_input.get_stdout_writer();
450468
stdout
@@ -561,6 +579,22 @@ pub fn start_client(
561579
},
562580
}
563581
},
582+
ClientInstruction::SetMousePointerShapesSupported(is_supported) => {
583+
mouse_pointer_shape_protocol_mode = if is_supported {
584+
Some(MousePointerShapeProtocolMode::Kitty)
585+
} else {
586+
None
587+
}
588+
},
589+
ClientInstruction::SetMousePointerShape(shape) => {
590+
if let Some(mode) = mouse_pointer_shape_protocol_mode {
591+
let mut stdout = os_input.get_stdout_writer();
592+
stdout
593+
.write_all(shape.generate_set_mouse_pointer_escape_sequence(mode).as_bytes())
594+
.expect("cannot write to stdout");
595+
stdout.flush().expect("could not flush");
596+
}
597+
}
564598
_ => {},
565599
}
566600
}

zellij-client/src/stdin_ansi_parser.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ impl StdinAnsiParser {
6969
"\u{1b}[14t\u{1b}[16t\u{1b}]11;?\u{1b}\u{5c}\u{1b}]10;?\u{1b}\u{5c}\u{1b}[?2026$p",
7070
);
7171

72+
// query mouse pointer shapes support
73+
query_string += "\u{1b}]22;?default,text\u{1b}\u{5c}";
74+
7275
// query colors
7376
// eg. <ESC>]4;5;?<ESC>\ => query color register number 5
7477
for i in 0..256 {
@@ -151,6 +154,9 @@ impl StdinAnsiParser {
151154
if let Ok(ansi_sequence) = AnsiStdinInstruction::bg_or_fg_from_bytes(&self.raw_buffer) {
152155
self.pending_events.push(ansi_sequence);
153156
self.raw_buffer.clear();
157+
} else if let Some(ansi_sequence) = AnsiStdinInstruction::mouse_pointer_shapes_support_from_bytes(&self.raw_buffer) {
158+
self.pending_events.push(ansi_sequence);
159+
self.raw_buffer.clear();
154160
} else if let Ok((color_register, color_sequence)) =
155161
color_sequence_from_bytes(&self.raw_buffer)
156162
{
@@ -181,6 +187,7 @@ pub enum AnsiStdinInstruction {
181187
ForegroundColor(String),
182188
ColorRegisters(Vec<(usize, String)>),
183189
SynchronizedOutput(Option<SyncOutput>),
190+
MousePointerShapesSupport(Vec<bool>),
184191
}
185192

186193
impl AnsiStdinInstruction {
@@ -282,6 +289,20 @@ impl AnsiStdinInstruction {
282289
None
283290
}
284291
}
292+
pub fn mouse_pointer_shapes_support_from_bytes(bytes: &[u8]) -> Option<Self> {
293+
// eg. <ESC>]22;1,1,0,1<ESC>\
294+
lazy_static! {
295+
static ref RE: Regex = Regex::new(r"^\u{1b}]22;([0-9,]+)\u{1b}\\$").unwrap();
296+
}
297+
let key_string = String::from_utf8_lossy(bytes);
298+
let captures = RE.captures_iter(&key_string).next()?;
299+
let support_string = captures[1].to_string();
300+
let support_values = support_string
301+
.split(',')
302+
.map(|s| s == "1")
303+
.collect::<Vec<bool>>();
304+
Some(AnsiStdinInstruction::MousePointerShapesSupport(support_values))
305+
}
285306
}
286307

287308
fn color_sequence_from_bytes(bytes: &[u8]) -> Result<(usize, String), &'static str> {

zellij-server/src/panes/grid.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ pub const MAX_TITLE_STACK_SIZE: usize = 1000;
2828

2929
use vte::{Params, Perform};
3030
use zellij_utils::{consts::VERSION, shared::version_number};
31-
31+
use zellij_utils::mouse_pointer_shapes::MousePointerShape;
3232
use crate::output::{CharacterChunk, OutputBuffer, SixelImageChunk};
3333
use crate::panes::alacritty_functions::{parse_number, xparse_color};
3434
use crate::panes::link_handler::LinkHandler;
@@ -2386,6 +2386,12 @@ impl Grid {
23862386
},
23872387
}
23882388
}
2389+
pub fn get_mouse_pointer_shape(&self) -> MousePointerShape {
2390+
match &self.mouse_tracking {
2391+
MouseTracking::Off => MousePointerShape::Text,
2392+
_ => MousePointerShape::Default
2393+
}
2394+
}
23892395
pub fn is_alternate_mode_active(&self) -> bool {
23902396
self.alternate_screen_state.is_some()
23912397
}

zellij-server/src/panes/terminal_pane.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use zellij_utils::{
2929
position::Position,
3030
shared::make_terminal_title,
3131
};
32-
32+
use zellij_utils::mouse_pointer_shapes::MousePointerShape;
3333
use crate::ui::pane_boundaries_frame::{FrameParams, PaneFrame};
3434

3535
pub const SELECTION_SCROLL_INTERVAL_MS: u64 = 10;
@@ -642,6 +642,10 @@ impl Pane for TerminalPane {
642642
self.exclude_from_sync
643643
}
644644

645+
fn get_mouse_pointer_shape(&self, _relative_position: Position) -> MousePointerShape {
646+
self.grid.get_mouse_pointer_shape()
647+
}
648+
645649
fn mouse_event(&self, event: &MouseEvent, _client_id: ClientId) -> Option<String> {
646650
self.grid.mouse_event_signal(event)
647651
}

zellij-server/src/tab/mod.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ use zellij_utils::{
6363
},
6464
pane_size::{Offset, PaneGeom, Size, SizeInPixels, Viewport},
6565
};
66+
use zellij_utils::ipc::ServerToClientMsg;
67+
use zellij_utils::mouse_pointer_shapes::MousePointerShape;
6668

6769
#[macro_export]
6870
macro_rules! resize_pty {
@@ -265,6 +267,7 @@ pub(crate) struct Tab {
265267
current_pane_group: Rc<RefCell<HashMap<ClientId, Vec<PaneId>>>>,
266268
advanced_mouse_actions: bool,
267269
currently_marking_pane_group: Rc<RefCell<HashMap<ClientId, bool>>>,
270+
mouse_cursor_shape: HashMap<ClientId, MousePointerShape>,
268271
}
269272

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

507+
fn get_mouse_pointer_shape(&self, relative_position: Position) -> MousePointerShape {
508+
MousePointerShape::Text
509+
}
510+
504511
// TODO: this should probably be merged with the mouse_right_click
505512
fn handle_right_click(&mut self, _to: &Position, _client_id: ClientId) {}
506513
fn mouse_event(&self, _event: &MouseEvent, _client_id: ClientId) -> Option<String> {
@@ -776,6 +783,7 @@ impl Tab {
776783
current_pane_group,
777784
currently_marking_pane_group,
778785
advanced_mouse_actions,
786+
mouse_cursor_shape: HashMap::new(),
779787
}
780788
}
781789

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

3913+
let new_pointer_shape = if let Some(pane) = self
3914+
.get_pane_at(&absolute_position, true)
3915+
.with_context(err_context)?
3916+
{
3917+
if pane.position_is_on_frame(&absolute_position) {
3918+
Some(MousePointerShape::Default)
3919+
} else {
3920+
let relative_position = pane.relative_position(&absolute_position);
3921+
Some(pane.get_mouse_pointer_shape(relative_position))
3922+
}
3923+
} else {
3924+
None
3925+
};
3926+
3927+
if let Some(new_shape) = new_pointer_shape {
3928+
let last_shape = self.mouse_cursor_shape.get(&client_id).copied().unwrap_or(MousePointerShape::Default);
3929+
if last_shape != new_shape {
3930+
self.mouse_cursor_shape.insert(client_id, new_shape);
3931+
let _ = self.os_api.send_to_client(client_id, ServerToClientMsg::SetMousePointerShape(new_shape));
3932+
}
3933+
}
3934+
39053935
if let Some(pane) = self
39063936
.get_pane_at(&absolute_position, false)
39073937
.with_context(err_context)?

zellij-utils/src/errors.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,8 @@ pub enum ClientContext {
468468
CliPipeOutput,
469469
QueryTerminalSize,
470470
WriteConfigToDisk,
471+
SetMousePointerShapesSupported,
472+
SetMousePointerShape,
471473
}
472474

473475
/// Stack call representations corresponding to the different types of [`ServerInstruction`]s.

zellij-utils/src/ipc.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use std::{
1818
os::unix::io::{AsRawFd, FromRawFd},
1919
path::PathBuf,
2020
};
21+
use crate::mouse_pointer_shapes::MousePointerShape;
2122

2223
type SessionId = u64;
2324

@@ -110,6 +111,7 @@ pub enum ServerToClientMsg {
110111
CliPipeOutput(String, String), // String -> pipe name, String -> Output
111112
QueryTerminalSize,
112113
WriteConfigToDisk { config: String },
114+
SetMousePointerShape(MousePointerShape),
113115
}
114116

115117
#[derive(Serialize, Deserialize, Debug, Clone)]

zellij-utils/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod position;
1212
pub mod session_serialization;
1313
pub mod setup;
1414
pub mod shared;
15+
pub mod mouse_pointer_shapes;
1516

1617
// The following modules can't be used when targeting wasm
1718
#[cfg(not(target_family = "wasm"))]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
/// Mouse pointer shapes as defined in the OSC 22 protocol.
4+
/// See https://sw.kovidgoyal.net/kitty/pointer-shapes/
5+
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
6+
pub enum MousePointerShape {
7+
/// Default cursor (arrow)
8+
Default,
9+
/// Text cursor (I-beam)
10+
Text,
11+
}
12+
13+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14+
pub enum MousePointerShapeProtocolMode {
15+
XTerm,
16+
Kitty,
17+
}
18+
19+
impl MousePointerShape {
20+
/// See https://sw.kovidgoyal.net/kitty/pointer-shapes/
21+
fn kitty_name(&self) -> &'static str {
22+
match self {
23+
Self::Default => "default",
24+
Self::Text => "text",
25+
}
26+
}
27+
28+
/// See https://github.com/xterm-x11/xterm-snapshots/blob/5b7a08a3482b425c97/xterm.man#L4674
29+
fn xterm_name(&self) -> &'static str {
30+
match self {
31+
Self::Default => "left_ptr",
32+
Self::Text => "xterm",
33+
}
34+
}
35+
36+
pub fn generate_set_mouse_pointer_escape_sequence(&self, mode: MousePointerShapeProtocolMode) -> String {
37+
let name = match mode {
38+
MousePointerShapeProtocolMode::XTerm => self.xterm_name(),
39+
MousePointerShapeProtocolMode::Kitty => self.kitty_name(),
40+
};
41+
format!("\x1b]22;{}\x1b\\", name)
42+
}
43+
}

0 commit comments

Comments
 (0)