fix: anchor ime cursor to focused pane position
This commit is contained in:
parent
10ba9f023c
commit
a1c02f0441
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
17
src/pane.rs
17
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue