aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-15 20:53:34 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-15 20:53:55 +0300
commit73dba97521f5a0e3104518ef48b775a178998f52 (patch)
treead9f95b4836303a35974b9ccdde229a1b05b03f4 /src/client
parent4cbe72ec2d8a174e3c4f9bde2101aa189c7f38e7 (diff)
fix: forward pane cursor shape
fixes #116
Diffstat (limited to 'src/client')
-rw-r--r--src/client/blit.rs122
1 files changed, 115 insertions, 7 deletions
diff --git a/src/client/blit.rs b/src/client/blit.rs
index 4a5c90c3..2196e1ff 100644
--- a/src/client/blit.rs
+++ b/src/client/blit.rs
@@ -18,6 +18,7 @@
//! - `CSI H` (CUP) — move cursor to (row, col)
//! - `CSI m` (SGR) — set graphic rendition (colors, bold, etc.)
//! - `CSI ? 2026 h/l` — begin/end synchronized output
+//! - `CSI Ps SP q` — DECSCUSR cursor shape
//! - `ESC ] 52 ; c ; <base64> BEL` — OSC 52 clipboard write
//!
//! The goal is minimal output: skip unchanged cells, batch adjacent changes,
@@ -37,6 +38,7 @@ pub(crate) struct EncodedBlit {
/// Whether this frame was encoded as a full redraw.
pub(crate) full: bool,
next_last_visible_cursor: Option<(u16, u16)>,
+ next_last_cursor_shape: u8,
}
/// Stateful encoder that diffs semantic frames into terminal ANSI bytes.
@@ -44,6 +46,7 @@ pub(crate) struct EncodedBlit {
pub(crate) struct BlitEncoder {
last_frame: Option<FrameData>,
last_visible_cursor: Option<(u16, u16)>,
+ last_cursor_shape: u8,
}
impl BlitEncoder {
@@ -62,16 +65,25 @@ impl BlitEncoder {
|| prev.is_some_and(|p| p.width != frame.width || p.height != frame.height);
let mut bytes = Vec::new();
let mut next_last_visible_cursor = self.last_visible_cursor;
- blit_frame_to_with_cursor_memory(&mut bytes, frame, prev, &mut next_last_visible_cursor);
+ let mut next_last_cursor_shape = self.last_cursor_shape;
+ blit_frame_to_with_cursor_memory(
+ &mut bytes,
+ frame,
+ prev,
+ &mut next_last_visible_cursor,
+ &mut next_last_cursor_shape,
+ );
EncodedBlit {
bytes,
full,
next_last_visible_cursor,
+ next_last_cursor_shape,
}
}
pub(crate) fn commit(&mut self, frame: FrameData, encoded: EncodedBlit) {
self.last_visible_cursor = encoded.next_last_visible_cursor;
+ self.last_cursor_shape = encoded.next_last_cursor_shape;
self.last_frame = Some(frame);
}
@@ -244,7 +256,14 @@ fn cells_equal(a: &CellData, b: &CellData) -> bool {
#[cfg(test)]
fn blit_frame_to(writer: impl Write, frame: &FrameData, prev: Option<&FrameData>) {
let mut last_visible_cursor = None;
- blit_frame_to_with_cursor_memory(writer, frame, prev, &mut last_visible_cursor);
+ let mut last_cursor_shape = 0;
+ blit_frame_to_with_cursor_memory(
+ writer,
+ frame,
+ prev,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
}
fn blit_frame_to_with_cursor_memory(
@@ -252,6 +271,7 @@ fn blit_frame_to_with_cursor_memory(
frame: &FrameData,
prev: Option<&FrameData>,
last_visible_cursor: &mut Option<(u16, u16)>,
+ last_cursor_shape: &mut u8,
) {
// On first frame or size change, do a full redraw.
let full_redraw =
@@ -288,7 +308,7 @@ fn blit_frame_to_with_cursor_memory(
// hides its cursor, still park the host cursor intentionally so IMEs do not
// anchor to whichever cell happened to be painted last.
let host_cursor = resolve_host_cursor_state(frame, last_visible_cursor);
- write_host_cursor_state(&mut writer, host_cursor);
+ write_host_cursor_state(&mut writer, host_cursor, last_cursor_shape);
// End the synchronized output block immediately after the final cursor
// state is emitted so supporting terminals can present the frame atomically.
@@ -312,6 +332,8 @@ fn cell_width(cell: &CellData) -> usize {
struct HostCursorState {
position: (u16, u16),
visible: bool,
+ /// DECSCUSR parameter (0–6). 0 means terminal default.
+ shape: u8,
}
fn resolve_host_cursor_state(
@@ -325,6 +347,7 @@ fn resolve_host_cursor_state(
return HostCursorState {
position,
visible: true,
+ shape: normalize_cursor_shape(cursor.shape),
};
}
@@ -332,6 +355,7 @@ fn resolve_host_cursor_state(
return HostCursorState {
position,
visible: false,
+ shape: normalize_cursor_shape(cursor.shape),
};
}
@@ -341,6 +365,15 @@ fn resolve_host_cursor_state(
HostCursorState {
position,
visible: false,
+ shape: 0,
+ }
+}
+
+fn normalize_cursor_shape(shape: u8) -> u8 {
+ if shape <= 6 {
+ shape
+ } else {
+ 0
}
}
@@ -363,8 +396,12 @@ fn write_cursor_position(writer: &mut impl Write, (x, y): (u16, u16)) {
let _ = write!(writer, "\x1b[{};{}H", y + 1, x + 1);
}
-fn write_host_cursor_state(writer: &mut impl Write, cursor: HostCursorState) {
+fn write_host_cursor_state(writer: &mut impl Write, cursor: HostCursorState, last_shape: &mut u8) {
write_cursor_position(writer, cursor.position);
+ if cursor.shape != *last_shape {
+ let _ = write!(writer, "\x1b[{} q", cursor.shape);
+ *last_shape = cursor.shape;
+ }
if cursor.visible {
// Show cursor only after it is already at the final position.
let _ = writer.write_all(b"\x1b[?25h");
@@ -780,6 +817,7 @@ mod tests {
x: 2,
y: 1,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -800,6 +838,43 @@ mod tests {
}
#[test]
+ fn blit_frame_emits_cursor_shape_before_visibility_without_touching_ime_anchor() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ shape: 6,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ let final_cursor = output_str
+ .find("\x1b[1;1H\x1b[6 q\x1b[?25h")
+ .expect("should set cursor shape before showing cursor");
+ let sync_end = output_str
+ .find("\x1b[?2026l")
+ .expect("should end synchronized output");
+ assert!(
+ final_cursor < sync_end,
+ "shape should be part of the synchronized final cursor state"
+ );
+ let trailing_cursor = &output_str[sync_end + "\x1b[?2026l".len()..];
+ assert_eq!(
+ trailing_cursor, "\x1b[1;1H\x1b[?25h",
+ "IME anchor update should preserve the existing position/visibility-only contract"
+ );
+ }
+
+ #[test]
fn blit_frame_repeats_explicit_hidden_cursor_anchor_after_synchronized_output() {
let visible = FrameData {
cells: vec![make_cell("A", 0, 0, 0); 9],
@@ -809,6 +884,7 @@ mod tests {
x: 0,
y: 0,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -821,20 +897,29 @@ mod tests {
x: 2,
y: 1,
visible: false,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
};
let mut last_visible_cursor = None;
+ let mut last_cursor_shape = 0;
let mut output = Vec::new();
- blit_frame_to_with_cursor_memory(&mut output, &visible, None, &mut last_visible_cursor);
+ blit_frame_to_with_cursor_memory(
+ &mut output,
+ &visible,
+ None,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
output.clear();
blit_frame_to_with_cursor_memory(
&mut output,
&hidden,
Some(&visible),
&mut last_visible_cursor,
+ &mut last_cursor_shape,
);
let output_str = String::from_utf8(output).unwrap();
@@ -976,6 +1061,7 @@ mod tests {
x: 0,
y: 0,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1001,6 +1087,7 @@ mod tests {
x: 0,
y: 0,
visible: false,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1048,6 +1135,7 @@ mod tests {
x: 0,
y: 0,
visible: false,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1069,6 +1157,7 @@ mod tests {
x: 0,
y: 0,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1097,6 +1186,7 @@ mod tests {
x: 0,
y: 0,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1107,6 +1197,7 @@ mod tests {
x: 2,
y: 2,
visible: true,
+ shape: 0,
});
let mut output = Vec::new();
@@ -1135,6 +1226,7 @@ mod tests {
x: 1,
y: 1,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),
@@ -1148,15 +1240,23 @@ mod tests {
graphics: Vec::new(),
};
let mut last_visible_cursor = None;
+ let mut last_cursor_shape = 0;
let mut output = Vec::new();
- blit_frame_to_with_cursor_memory(&mut output, &visible, None, &mut last_visible_cursor);
+ blit_frame_to_with_cursor_memory(
+ &mut output,
+ &visible,
+ None,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
output.clear();
blit_frame_to_with_cursor_memory(
&mut output,
&hidden,
Some(&visible),
&mut last_visible_cursor,
+ &mut last_cursor_shape,
);
let output_str = String::from_utf8(output).unwrap();
@@ -1180,9 +1280,16 @@ mod tests {
graphics: Vec::new(),
};
let mut last_visible_cursor = None;
+ let mut last_cursor_shape = 0;
let mut output = Vec::new();
- blit_frame_to_with_cursor_memory(&mut output, &frame, None, &mut last_visible_cursor);
+ blit_frame_to_with_cursor_memory(
+ &mut output,
+ &frame,
+ None,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
let output_str = String::from_utf8(output).unwrap();
assert!(
@@ -1201,6 +1308,7 @@ mod tests {
x: 0,
y: 0,
visible: true,
+ shape: 0,
}),
hyperlinks: Vec::new(),
graphics: Vec::new(),