fix: anchor ime cursor to focused pane position

This commit is contained in:
Ogulcan Celik 2026-05-02 18:39:09 +03:00
parent 10ba9f023c
commit a1c02f0441
4 changed files with 250 additions and 27 deletions

View File

@ -11,6 +11,8 @@
//! `CUP` positions during the frame stream.
//! 5. After writing all changed cells, restore the final cursor visibility
//! and position from `frame.cursor`.
//! 6. After ending synchronized output, repeat the final cursor anchor so
//! external IMEs can place candidate windows at the real input position.
//!
//! Escape sequences used:
//! - `CSI H` (CUP) — move cursor to (row, col)
@ -240,32 +242,20 @@ fn blit_frame_to_with_cursor_memory(
// cell rather than the focused pane's input position. When the focused pane
// hides its cursor, still park the host cursor intentionally so IMEs do not
// anchor to whichever cell happened to be painted last.
if let Some(cursor) = &frame.cursor {
if cursor.visible {
let final_position = clamp_cursor_position(frame, cursor.x, cursor.y);
*last_visible_cursor = Some(final_position);
write_cursor_position(&mut writer, final_position);
// Show cursor only after it is already at the final position.
let _ = writer.write_all(b"\x1b[?25h");
} else {
let fallback = (*last_visible_cursor)
.map(|(x, y)| clamp_cursor_position(frame, x, y))
.or_else(|| Some(clamp_cursor_position(frame, cursor.x, cursor.y)))
.unwrap_or_else(|| default_hidden_cursor_position(frame));
write_cursor_position(&mut writer, fallback);
let _ = writer.write_all(b"\x1b[?25l");
}
} else {
let fallback = (*last_visible_cursor)
.map(|(x, y)| clamp_cursor_position(frame, x, y))
.unwrap_or_else(|| default_hidden_cursor_position(frame));
write_cursor_position(&mut writer, fallback);
let _ = writer.write_all(b"\x1b[?25l");
}
let host_cursor = resolve_host_cursor_state(frame, last_visible_cursor);
write_host_cursor_state(&mut writer, host_cursor);
// End the synchronized output block immediately after the final cursor
// state is emitted, then flush so supporting terminals can present it.
// state is emitted so supporting terminals can present the frame atomically.
let _ = writer.write_all(b"\x1b[?2026l");
// Some native IMEs track candidate-window placement from normal terminal
// cursor updates and may not observe cursor moves emitted inside synchronized
// output. Re-emit only the resolved final cursor anchor after the sync block;
// intermediate paint cursor positions remain hidden. When the focused pane
// explicitly reports a hidden cursor, expose that anchor to the host terminal
// so IMEs can attach to it instead of an older visible cursor position.
write_ime_anchor_cursor_state(&mut writer, host_cursor, frame.cursor.is_some());
let _ = writer.flush();
}
@ -274,6 +264,42 @@ fn cell_width(cell: &CellData) -> usize {
cell.symbol.width()
}
#[derive(Clone, Copy)]
struct HostCursorState {
position: (u16, u16),
visible: bool,
}
fn resolve_host_cursor_state(
frame: &FrameData,
last_visible_cursor: &mut Option<(u16, u16)>,
) -> HostCursorState {
if let Some(cursor) = &frame.cursor {
if cursor.visible {
let position = clamp_cursor_position(frame, cursor.x, cursor.y);
*last_visible_cursor = Some(position);
return HostCursorState {
position,
visible: true,
};
}
let position = clamp_cursor_position(frame, cursor.x, cursor.y);
return HostCursorState {
position,
visible: false,
};
}
let position = (*last_visible_cursor)
.map(|(x, y)| clamp_cursor_position(frame, x, y))
.unwrap_or_else(|| default_hidden_cursor_position(frame));
HostCursorState {
position,
visible: false,
}
}
fn default_hidden_cursor_position(frame: &FrameData) -> (u16, u16) {
(
frame.width.saturating_sub(1),
@ -293,6 +319,29 @@ 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) {
write_cursor_position(writer, cursor.position);
if cursor.visible {
// Show cursor only after it is already at the final position.
let _ = writer.write_all(b"\x1b[?25h");
} else {
let _ = writer.write_all(b"\x1b[?25l");
}
}
fn write_ime_anchor_cursor_state(
writer: &mut impl Write,
cursor: HostCursorState,
expose_hidden_anchor: bool,
) {
write_cursor_position(writer, cursor.position);
if cursor.visible || expose_hidden_anchor {
let _ = writer.write_all(b"\x1b[?25h");
} else {
let _ = writer.write_all(b"\x1b[?25l");
}
}
fn write_all_cells(writer: &mut impl Write, frame: &FrameData) {
for row in 0..frame.height {
let mut to_skip = 0usize;
@ -555,9 +604,84 @@ mod tests {
output_str.starts_with("\x1b[?2026h"),
"should begin synchronized output before frame writes"
);
let sync_end = output_str
.find("\x1b[?2026l")
.expect("should end synchronized output after frame writes");
assert!(
output_str.ends_with("\x1b[?2026l"),
"should end synchronized output after final cursor state"
sync_end + "\x1b[?2026l".len() < output_str.len(),
"should end synchronized output before trailing IME cursor update"
);
}
#[test]
fn blit_frame_repeats_final_cursor_state_after_synchronized_output() {
let frame = FrameData {
cells: vec![make_cell("A", 0, 0, 0); 9],
width: 3,
height: 3,
cursor: Some(CursorState {
x: 2,
y: 1,
visible: true,
}),
};
let mut output = Vec::new();
blit_frame_to(&mut output, &frame, None);
let output_str = String::from_utf8(output).unwrap();
let sync_end = output_str
.find("\x1b[?2026l")
.expect("should end synchronized output");
let trailing_cursor = &output_str[sync_end + "\x1b[?2026l".len()..];
assert_eq!(
trailing_cursor, "\x1b[2;3H\x1b[?25h",
"should expose only the final cursor state after synchronized output"
);
}
#[test]
fn blit_frame_exposes_explicit_hidden_cursor_anchor_after_synchronized_output() {
let visible = FrameData {
cells: vec![make_cell("A", 0, 0, 0); 9],
width: 3,
height: 3,
cursor: Some(CursorState {
x: 0,
y: 0,
visible: true,
}),
};
let hidden = FrameData {
cells: vec![make_cell("B", 0, 0, 0); 9],
width: 3,
height: 3,
cursor: Some(CursorState {
x: 2,
y: 1,
visible: false,
}),
};
let mut last_visible_cursor = None;
let mut output = Vec::new();
blit_frame_to_with_cursor_memory(&mut output, &visible, None, &mut last_visible_cursor);
output.clear();
blit_frame_to_with_cursor_memory(
&mut output,
&hidden,
Some(&visible),
&mut last_visible_cursor,
);
let output_str = String::from_utf8(output).unwrap();
let sync_end = output_str
.find("\x1b[?2026l")
.expect("should end synchronized output");
let trailing_cursor = &output_str[sync_end + "\x1b[?2026l".len()..];
assert_eq!(
trailing_cursor, "\x1b[2;3H\x1b[?25h",
"should expose the explicit hidden cursor position for IME anchoring"
);
}

View File

@ -26,7 +26,7 @@ use self::{
};
pub use self::{
state::{EffectiveStateChange, PaneState},
terminal::{InputState, ScrollMetrics},
terminal::{InputState, ScrollMetrics, TerminalCursorState},
};
const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1);
@ -684,6 +684,21 @@ impl PaneRuntime {
self.terminal.input_state()
}
pub fn cursor_state(&self, area: Rect, show_cursor: bool) -> Option<TerminalCursorState> {
if !show_cursor {
return None;
}
let cursor = self.terminal.cursor_state()?;
if cursor.x >= area.width || cursor.y >= area.height {
return None;
}
Some(TerminalCursorState {
x: area.x + cursor.x,
y: area.y + cursor.y,
visible: cursor.visible,
})
}
pub fn visible_text(&self) -> String {
self.terminal.visible_text()
}

View File

@ -32,6 +32,13 @@ pub struct ScrollMetrics {
pub viewport_rows: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalCursorState {
pub x: u16,
pub y: u16,
pub visible: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InputState {
pub alternate_screen: bool,
@ -118,6 +125,10 @@ impl PaneTerminal {
self.ghostty.input_state()
}
pub fn cursor_state(&self) -> Option<TerminalCursorState> {
self.ghostty.cursor_state()
}
pub fn visible_text(&self) -> String {
self.ghostty.visible_text()
}
@ -455,6 +466,22 @@ impl GhosttyPaneTerminal {
})
}
pub fn cursor_state(&self) -> Option<TerminalCursorState> {
let mut core = self.core.lock().ok()?;
let GhosttyPaneCore {
terminal,
render_state,
..
} = &mut *core;
render_state.update(terminal).ok()?;
let cursor = render_state.cursor_viewport().ok()??;
Some(TerminalCursorState {
x: cursor.x,
y: cursor.y,
visible: render_state.cursor_visible().ok()?,
})
}
pub fn encode_terminal_key(
&self,
key: crate::input::TerminalKey,

View File

@ -33,6 +33,7 @@ use base64::Engine;
use crate::api;
use crate::app;
use crate::app::state::AppState;
use crate::app::Mode;
use crate::config;
use crate::detect::AgentState;
use crate::events::AppEvent;
@ -361,11 +362,33 @@ fn render_virtual(
.expect("render to TestBackend should never fail");
let buffer = terminal.backend().buffer().clone();
let cursor = terminal.backend().rendered_cursor();
let cursor =
focused_terminal_cursor(app_state).or_else(|| terminal.backend().rendered_cursor());
(buffer, cursor)
}
fn focused_terminal_cursor(app_state: &AppState) -> Option<CursorState> {
if app_state.mode != Mode::Terminal {
return None;
}
let ws_idx = app_state.active?;
let ws = app_state.workspaces.get(ws_idx)?;
let info = app_state
.view
.pane_infos
.iter()
.find(|info| info.is_focused)?;
let rt = ws.runtimes.get(&info.id)?;
let cursor = rt.cursor_state(info.inner_rect, true)?;
Some(CursorState {
x: cursor.x,
y: cursor.y,
visible: cursor.visible,
})
}
// ---------------------------------------------------------------------------
// Headless server
// ---------------------------------------------------------------------------
@ -2309,6 +2332,40 @@ mod tests {
);
}
#[tokio::test]
async fn virtual_render_preserves_hidden_focused_pane_cursor_position() {
let mut state = AppState::test_new();
let mut ws = crate::workspace::Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
ws.tabs[0].runtimes.insert(
pane_id,
crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"),
);
state.workspaces = vec![ws];
state.active = Some(0);
state.selected = 0;
state.mode = crate::app::Mode::Terminal;
let area = Rect::new(0, 0, 80, 24);
let (_buffer, cursor) = render_virtual(&mut state, area, true);
let pane = state
.view
.pane_infos
.iter()
.find(|info| info.id == pane_id)
.expect("focused pane info");
assert_eq!(
cursor,
Some(CursorState {
x: pane.inner_rect.x + 4,
y: pane.inner_rect.y,
visible: false,
})
);
}
#[test]
fn latest_active_client_drives_shared_size_theme_and_fallback() {
let mut server = test_headless_server();