aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client/blit.rs174
-rw-r--r--src/pane.rs17
-rw-r--r--src/pane/terminal.rs27
-rw-r--r--src/server/headless.rs59
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<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()
}
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
@@ -33,6 +33,13 @@ pub struct ScrollMetrics {
}
#[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,
pub application_cursor: 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,
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<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();