From ceb9015f34953a96a93411537f96f22a2ccf8b5f Mon Sep 17 00:00:00 2001 From: Nico Burns Date: Tue, 8 Sep 2026 13:53:24 +0000 Subject: [PATCH] Anchor IME cursor area at the text input caret --- packages/blitz-dom/src/document.rs | 21 ++++++++++++-- packages/blitz-dom/src/node/node.rs | 43 +++++++++++++++++++++++------ packages/blitz-shell/src/lib.rs | 25 ++++++++++++----- 3 files changed, 72 insertions(+), 17 deletions(-) diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index 50c5ea52bf..f8168fc9dc 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -3362,12 +3362,29 @@ mod ime_focus_tests { assert!(shell.areas.lock().unwrap().is_empty()); doc.resolve(0.0); - assert_eq!(*shell.areas.lock().unwrap(), vec![(0.0, 0.0, 100.0, 20.0)]); + // The area is the caret rect: at the start of the (empty) input, caret-width wide, + // vertically centred within the 20px content box. + let (x, y, w, h) = shell.areas.lock().unwrap()[0]; + assert_eq!(x, 0.0); + assert!(w > 0.0 && w < 5.0, "caret width {w}"); + assert!(h > 0.0 && h <= 20.0, "caret height {h}"); + assert!(y >= 0.0 && y + h <= 20.0, "caret y {y} height {h}"); + assert_eq!(shell.areas.lock().unwrap().len(), 1); - // Unchanged layout does not re-report the area + // Unchanged caret does not re-report the area doc.resolve(0.0); assert_eq!(shell.areas.lock().unwrap().len(), 1); + // Typing moves the caret, so the area is re-reported further right + doc.with_text_input(input, |mut driver| { + driver.insert_or_replace_selection("abc") + }); + doc.resolve(0.0); + let areas = shell.areas.lock().unwrap(); + assert_eq!(areas.len(), 2); + assert!(areas[1].0 > x, "caret should move right: {:?}", areas[1]); + drop(areas); + doc.clear_focus(); assert_eq!(*shell.enabled.lock().unwrap(), vec![true, false]); } diff --git a/packages/blitz-dom/src/node/node.rs b/packages/blitz-dom/src/node/node.rs index 340e64f2e8..a7b3271551 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -680,18 +680,45 @@ impl Node { self.element_data().is_some_and(|el| el.is_text_input()) } - /// The node's content box as `(x, y, width, height)` in CSS pixels, for use as the IME - /// cursor area. Returns `None` if the node is not a text input or has not been laid out - /// yet (e.g. it was focussed before the first layout). + /// The IME cursor area as `(x, y, width, height)` in CSS pixels relative to the viewport: + /// the caret rectangle of the text input (so IME candidate windows appear next to the + /// caret), falling back to the input's content box if the caret geometry is unavailable. + /// Returns `None` if the node is not a text input or has not been laid out yet (e.g. it + /// was focussed before the first layout). pub fn ime_cursor_area(&self) -> Option<(f32, f32, f32, f32)> { - self.element_data()?.text_input_data()?; + let input_data = self.element_data()?.text_input_data()?; let layout = self.final_layout(); let pos = self.absolute_position(0.0, 0.0); + let content_x = pos.x + layout.content_box_x(); + let content_y = pos.y + layout.content_box_y(); + + let caret = input_data + .editor + .try_layout() + .map(|text_layout| text_layout.scale()) + .zip(input_data.editor.cursor_geometry(1.5)); + let Some((scale, caret)) = caret else { + return Some(( + content_x, + content_y, + layout.content_box_width(), + layout.content_box_height(), + )); + }; + + // Caret geometry is in scaled (device) pixels relative to the text content; convert + // to CSS pixels and apply the same centering/scroll offsets used when painting. + let (scroll_x, scroll_y) = if input_data.is_multiline { + (0.0, input_data.scroll_offset) + } else { + (input_data.scroll_offset, 0.0) + }; + let y_offset = self.text_input_v_centering_offset(scale as f64) as f32; Some(( - pos.x + layout.content_box_x(), - pos.y + layout.content_box_y(), - layout.content_box_width(), - layout.content_box_height(), + content_x + caret.x0 as f32 / scale - scroll_x, + content_y + y_offset + caret.y0 as f32 / scale - scroll_y, + (caret.x1 - caret.x0) as f32 / scale, + (caret.y1 - caret.y0) as f32 / scale, )) } diff --git a/packages/blitz-shell/src/lib.rs b/packages/blitz-shell/src/lib.rs index a89e9cd94b..3a5466ffb6 100644 --- a/packages/blitz-shell/src/lib.rs +++ b/packages/blitz-shell/src/lib.rs @@ -38,7 +38,7 @@ pub use crate::net::DataUriNetProvider; ))] use blitz_traits::shell::FileDialogFilter; use blitz_traits::shell::ShellProvider; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use winit::cursor::{Cursor, CursorIcon}; use winit::dpi::{LogicalPosition, LogicalSize}; pub use winit::event_loop::{ControlFlow, EventLoop, EventLoopProxy}; @@ -87,10 +87,17 @@ pub fn current_android_app() -> android_activity::AndroidApp { pub struct BlitzShellProvider { window: Arc, proxy: BlitzShellProxy, + /// The most recently reported IME cursor area (logical position and size). Winit requires + /// an initial cursor area when enabling the IME with the `cursor_area` capability. + ime_cursor_area: Mutex<(LogicalPosition, LogicalSize)>, } impl BlitzShellProvider { pub fn new(window: Arc, proxy: BlitzShellProxy) -> Self { - Self { window, proxy } + Self { + window, + proxy, + ime_cursor_area: Mutex::new(Default::default()), + } } } @@ -115,19 +122,23 @@ impl ShellProvider for BlitzShellProvider { } fn set_ime_enabled(&self, is_enabled: bool) { if is_enabled { + let (position, size) = *self.ime_cursor_area.lock().unwrap(); + let request_data = + ImeRequestData::default().with_cursor_area(position.into(), size.into()); let _ = self.window.request_ime_update(ImeRequest::Enable( - ImeEnableRequest::new(ImeCapabilities::new(), ImeRequestData::default()).unwrap(), + ImeEnableRequest::new(ImeCapabilities::new().with_cursor_area(), request_data) + .unwrap(), )); } else { let _ = self.window.request_ime_update(ImeRequest::Disable); } } fn set_ime_cursor_area(&self, x: f32, y: f32, width: f32, height: f32) { + let position = LogicalPosition::new(x, y); + let size = LogicalSize::new(width, height); + *self.ime_cursor_area.lock().unwrap() = (position, size); let _ = self.window.request_ime_update(ImeRequest::Update( - ImeRequestData::default().with_cursor_area( - LogicalPosition::new(x, y).into(), - LogicalSize::new(width, height).into(), - ), + ImeRequestData::default().with_cursor_area(position.into(), size.into()), )); }