From a1c02f044122603ea91f76e65d8cdbd5544d6425 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sat, 2 May 2026 18:39:09 +0300 Subject: [PATCH] fix: anchor ime cursor to focused pane position --- src/client/blit.rs | 174 +++++++++++++++++++++++++++++++++++------ src/pane.rs | 17 +++- src/pane/terminal.rs | 27 +++++++ src/server/headless.rs | 59 +++++++++++++- 4 files changed, 250 insertions(+), 27 deletions(-) diff --git a/src/client/blit.rs b/src/client/blit.rs index 3df53572..87e8d27c 100644 --- a/src/client/blit.rs +++ b/src/client/blit.rs @@ -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" ); } diff --git a/src/pane.rs b/src/pane.rs index ae44bde1..51760ecf 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -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 { + 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() } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 0bb7632a..ba1dbea8 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -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 { + 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 { + 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, diff --git a/src/server/headless.rs b/src/server/headless.rs index d3e81a10..11b9d606 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -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 { + 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();