From e7a6034b559dfdd23a7eaa8b9bba6e88d621221b Mon Sep 17 00:00:00 2001 From: DorotaC <43449960+dcz-self@users.noreply.github.com> Date: Sun, 13 Jul 2025 07:57:10 +0200 Subject: [PATCH] winit-core: add surrounding_text for IME Allow communicating surrounding text to IME to better handle user input and account for content around for preedit. --- examples/application.rs | 58 ++++++-- winit-core/src/window.rs | 172 ++++++++++++++++++++++- winit-wayland/src/seat/text_input/mod.rs | 37 ++++- 3 files changed, 250 insertions(+), 17 deletions(-) diff --git a/examples/application.rs b/examples/application.rs index 75d936e5..e6523c12 100644 --- a/examples/application.rs +++ b/examples/application.rs @@ -40,8 +40,8 @@ use winit::platform::web::{ActiveEventLoopExtWeb, WindowAttributesWeb}; #[cfg(x11_platform)] use winit::platform::x11::{ActiveEventLoopExtX11, WindowAttributesX11}; use winit::window::{ - CursorGrabMode, ImeCapabilities, ImeEnableRequest, ImePurpose, ImeRequestData, ResizeDirection, - Theme, Window, WindowAttributes, WindowId, + CursorGrabMode, ImeCapabilities, ImeEnableRequest, ImePurpose, ImeRequestData, + ImeSurroundingText, ResizeDirection, Theme, Window, WindowAttributes, WindowId, }; use winit_core::application::macos::ApplicationHandlerExtMacOS; use winit_core::window::ImeRequest; @@ -518,7 +518,12 @@ impl ApplicationHandler for Application { info!("Preedit: {}, with caret at {:?}", text, caret_pos); }, Ime::Commit(text) => { + window.text_field_contents.0.push_str(&text); + window.text_field_contents.1 += text.len(); + info!("Committed: {}", text); + let request_data = window.get_ime_update(); + window.window.request_ime_update(ImeRequest::Update(request_data)).unwrap(); }, Ime::Disabled => info!("IME disabled for Window={window_id:?}"), }, @@ -611,6 +616,9 @@ impl ApplicationHandlerExtMacOS for Application { /// State of the window. struct WindowState { ime_enabled: bool, + /// The contents of the emulated text field for IME purposes (not displayed). + /// (text, cursor position in bytes). + text_field_contents: (String, usize), /// Render surface. /// /// NOTE: This surface must be dropped before the `Window`. @@ -668,9 +676,10 @@ impl WindowState { // Allow IME out of the box. let request_data = ImeRequestData::default() .with_purpose(ImePurpose::Normal) - .with_cursor_area(LogicalPosition { x: 0, y: 0 }.into(), IME_CURSOR_SIZE.into()); + .with_cursor_area(LogicalPosition { x: 0, y: 0 }.into(), IME_CURSOR_SIZE.into()) + .with_surrounding_text(ImeSurroundingText::new(String::new(), 0, 0).unwrap()); let enable_request = ImeEnableRequest::new( - ImeCapabilities::new().with_purpose().with_cursor_area(), + ImeCapabilities::new().with_purpose().with_cursor_area().with_surrounding_text(), request_data, ) .unwrap(); @@ -695,6 +704,7 @@ impl WindowState { #[cfg(not(android_platform))] start_time: Instant::now(), ime_enabled: true, + text_field_contents: (String::new(), 0), cursor_position: Default::default(), cursor_hidden: Default::default(), modifiers: Default::default(), @@ -708,20 +718,42 @@ impl WindowState { Ok(state) } + pub fn get_ime_update(&self) -> ImeRequestData { + let (text, cursor) = &self.text_field_contents; + // A rudimentary text field emulation: the caret moves right by a constant amount for each + // code point. + + let text_before_caret = if text.is_char_boundary(*cursor) { &text[..*cursor] } else { "" }; + let chars_before_caret = text_before_caret.chars().count(); + let cursor_pos = LogicalPosition { x: 10 * chars_before_caret as u32, y: 0 }.into(); + + // Limit text field size + const MAX_BYTES: usize = ImeSurroundingText::MAX_TEXT_BYTES; + let minimal_offset = cursor / MAX_BYTES * MAX_BYTES; + let first_char_boundary = + (minimal_offset..*cursor).find(|off| text.is_char_boundary(*off)).unwrap_or(*cursor); + let last_char_boundary = (*cursor..(first_char_boundary + MAX_BYTES)) + .rev() + .find(|off| text.is_char_boundary(*off)) + .unwrap_or(*cursor); + let surrounding_text = &text[first_char_boundary..last_char_boundary]; + let relative_cursor = cursor - first_char_boundary; + let surrounding_text = + ImeSurroundingText::new(surrounding_text.into(), relative_cursor, relative_cursor) + .expect("Bug in example: bad byte calculations"); + ImeRequestData::default() + .with_purpose(ImePurpose::Normal) + .with_cursor_area(cursor_pos, IME_CURSOR_SIZE.into()) + .with_surrounding_text(surrounding_text) + } + pub fn toggle_ime(&mut self) { if self.ime_enabled { self.window.request_ime_update(ImeRequest::Disable).expect("disable can not fail"); } else { - let cursor_pos = self - .cursor_position - .map(Into::into) - .unwrap_or(LogicalPosition { x: 0, y: 0 }.into()); - let request_data = ImeRequestData::default() - .with_purpose(ImePurpose::Normal) - .with_cursor_area(cursor_pos, IME_CURSOR_SIZE.into()); let enable_request = ImeEnableRequest::new( - ImeCapabilities::new().with_purpose().with_cursor_area(), - request_data, + ImeCapabilities::new().with_purpose().with_cursor_area().with_surrounding_text(), + self.get_ime_update(), ) .unwrap(); self.window.request_ime_update(ImeRequest::Enable(enable_request)).unwrap(); diff --git a/winit-core/src/window.rs b/winit-core/src/window.rs index bea235ee..c8f678c1 100644 --- a/winit-core/src/window.rs +++ b/winit-core/src/window.rs @@ -1623,6 +1623,101 @@ impl Default for ImePurpose { } } +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub enum ImeSurroundingTextError { + /// Text exceeds 4000 bytes + TextTooLong, + /// Cursor not on a code point boundary, or past the end of text. + CursorBadPosition, + /// Anchor not on a code point boundary, or past the end of text. + AnchorBadPosition, +} + +/// Defines the text surrounding the caret +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ImeSurroundingText { + /// An excerpt of the text present in the text input field, excluding preedit. + text: String, + /// The position of the caret, in bytes from the beginning of the string + cursor: usize, + /// The position of the other end of selection, in bytes. + /// With no selection, it should be the same as the cursor. + anchor: usize, +} + +impl ImeSurroundingText { + /// The maximum size of the text excerpt. + pub const MAX_TEXT_BYTES: usize = 4000; + /// Defines the text surroundng the cursor and the selection within it. + /// + /// `text`: An excerpt of the text present in the text input field, excluding preedit. + /// It must be limited to 4000 bytes due to backend constraints. + /// `cursor`: The position of the caret, in bytes from the beginning of the string. + /// `anchor: The position of the other end of selection, in bytes. + /// With no selection, it should be the same as the cursor. + /// + /// This may fail if the byte indices don't fall on code point boundaries, + /// or if the text is too long. + /// + /// ## Examples: + /// + /// A text field containing `foo|bar` where `|` denotes the caret would correspond to a value + /// obtained by: + /// + /// ``` + /// # use winit_core::window::ImeSurroundingText; + /// let s = ImeSurroundingText::new("foobar".into(), 3, 3).unwrap(); + /// ``` + /// + /// Because preedit is excluded from the text string, a text field containing `foo[baz|]bar` + /// where `|` denotes the caret and [baz|] is the preedit would be created in exactly the same + /// way. + pub fn new( + text: String, + cursor: usize, + anchor: usize, + ) -> Result { + let text = if text.len() < 4000 { + text + } else { + return Err(ImeSurroundingTextError::TextTooLong); + }; + + let cursor = if text.is_char_boundary(cursor) && cursor <= text.len() { + cursor + } else { + return Err(ImeSurroundingTextError::CursorBadPosition); + }; + + let anchor = if text.is_char_boundary(anchor) && anchor <= text.len() { + anchor + } else { + return Err(ImeSurroundingTextError::AnchorBadPosition); + }; + + Ok(Self { text, cursor, anchor }) + } + + /// Consumes the object, releasing the text string only. + /// Use this call in the backend to avoid an extra clone when submitting the surrounding text. + pub fn into_text(self) -> String { + self.text + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + pub fn anchor(&self) -> usize { + self.anchor + } +} + /// Request to send to IME. #[derive(Debug, PartialEq, Clone)] pub enum ImeRequest { @@ -1658,7 +1753,7 @@ impl ImeEnableRequest { /// /// This will return [`None`] if some capability was requested but its initial value was not /// set by the user or value was set by the user, but capability not requested. - pub const fn new(capabilities: ImeCapabilities, request_data: ImeRequestData) -> Option { + pub fn new(capabilities: ImeCapabilities, request_data: ImeRequestData) -> Option { if capabilities.cursor_area() ^ request_data.cursor_area.is_some() { return None; } @@ -1667,6 +1762,9 @@ impl ImeEnableRequest { return None; } + if capabilities.surrounding_text() ^ request_data.surrounding_text.is_some() { + return None; + } Some(Self { capabilities, request_data }) } @@ -1681,7 +1779,7 @@ impl ImeEnableRequest { } /// Destruct [`ImeEnableRequest`] into its raw parts. - pub const fn into_raw(self) -> (ImeCapabilities, ImeRequestData) { + pub fn into_raw(self) -> (ImeCapabilities, ImeRequestData) { (self.capabilities, self.request_data) } } @@ -1712,6 +1810,13 @@ impl ImeCapabilities { Self(self.0.union(ImeCapabilitiesFlags::PURPOSE)) } + /// Marks `purpose` as unsupported. + /// + /// For more details see [`ImeRequestData::with_purpose`]. + pub const fn without_purpose(self) -> Self { + Self(self.0.difference(ImeCapabilitiesFlags::PURPOSE)) + } + /// Returns `true` if `purpose` is supported. pub const fn purpose(&self) -> bool { self.0.contains(ImeCapabilitiesFlags::PURPOSE) @@ -1724,10 +1829,36 @@ impl ImeCapabilities { Self(self.0.union(ImeCapabilitiesFlags::CURSOR_AREA)) } + /// Marks `cursor_area` as unsupported. + /// + /// For more details see [`ImeRequestData::with_cursor_area`]. + pub const fn without_cursor_area(self) -> Self { + Self(self.0.difference(ImeCapabilitiesFlags::CURSOR_AREA)) + } + /// Returns `true` if `cursor_area` is supported. pub const fn cursor_area(&self) -> bool { self.0.contains(ImeCapabilitiesFlags::CURSOR_AREA) } + + /// Marks `surrounding_text` as supported. + /// + /// For more details see [`ImeRequestData::with_surrounding_text`]. + pub const fn with_surrounding_text(self) -> Self { + Self(self.0.union(ImeCapabilitiesFlags::SURROUNDING_TEXT)) + } + + /// Marks `surrounding_text` as unsupported. + /// + /// For more details see [`ImeRequestData::with_surrounding_text`]. + pub const fn without_surrounding_text(self) -> Self { + Self(self.0.difference(ImeCapabilitiesFlags::SURROUNDING_TEXT)) + } + + /// Returns `true` if `surrounding_text` is supported. + pub const fn surrounding_text(&self) -> bool { + self.0.contains(ImeCapabilitiesFlags::SURROUNDING_TEXT) + } } bitflags! { @@ -1738,6 +1869,8 @@ bitflags! { /// Client supports reporting cursor area for IME popup to /// appear. const CURSOR_AREA = 1 << 1; + /// Client supports reporting the text around the caret + const SURROUNDING_TEXT = 1 << 2; } } @@ -1758,6 +1891,10 @@ pub struct ImeRequestData { /// /// To support updating it, enable [`ImeCapabilities::CURSOR_AREA`]. pub cursor_area: Option<(Position, Size)>, + /// The text surrounding the caret + /// + /// To support updating it, enable [`ImeCapabilities::SURROUNDING_TEXT`]. + pub surrounding_text: Option, } impl ImeRequestData { @@ -1810,6 +1947,15 @@ impl ImeRequestData { pub fn with_cursor_area(self, position: Position, size: Size) -> Self { Self { cursor_area: Some((position, size)), ..self } } + + /// Describes the text surrounding the caret. + /// + /// The IME can then continue providing suggestions for the continuation of the existing text, + /// as well as can erase text more accurately, for example glyphs composed of multiple code + /// points. + pub fn with_surrounding_text(self, surrounding_text: ImeSurroundingText) -> Self { + Self { surrounding_text: Some(surrounding_text), ..self } + } } /// Error from sending request to IME with @@ -1876,7 +2022,10 @@ mod tests { use dpi::{LogicalPosition, LogicalSize, Position, Size}; - use super::{ImeCapabilities, ImeEnableRequest, ImeRequestData}; + use super::{ + ImeCapabilities, ImeEnableRequest, ImeRequestData, ImeSurroundingText, + ImeSurroundingTextError, + }; use crate::window::ImePurpose; #[test] @@ -1930,5 +2079,22 @@ mod tests { .with_cursor_area(position, size) ) .is_some()); + + let text: &[u8] = ['a' as u8; 8000].as_slice(); + let text = std::str::from_utf8(text).unwrap(); + assert_eq!( + ImeSurroundingText::new(text.into(), 0, 0), + Err(ImeSurroundingTextError::TextTooLong), + ); + + assert_eq!( + ImeSurroundingText::new("short".into(), 110, 0), + Err(ImeSurroundingTextError::CursorBadPosition), + ); + + assert_eq!( + ImeSurroundingText::new("граница".into(), 1, 0), + Err(ImeSurroundingTextError::CursorBadPosition), + ); } } diff --git a/winit-wayland/src/seat/text_input/mod.rs b/winit-wayland/src/seat/text_input/mod.rs index 44e3e5ec..3aca8c2f 100644 --- a/winit-wayland/src/seat/text_input/mod.rs +++ b/winit-wayland/src/seat/text_input/mod.rs @@ -11,7 +11,7 @@ use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_v3:: }; use tracing::warn; use winit_core::event::{Ime, WindowEvent}; -use winit_core::window::{ImeCapabilities, ImePurpose, ImeRequestData}; +use winit_core::window::{ImeCapabilities, ImePurpose, ImeRequestData, ImeSurroundingText}; use crate::state::WinitState; @@ -191,6 +191,14 @@ impl ZwpTextInputV3Ext for ZwpTextInputV3 { self.set_cursor_rectangle(x, y, width, height); } + if let Some(surrounding) = state.surrounding_text() { + self.set_surrounding_text( + surrounding.text().into(), + surrounding.cursor() as i32, + surrounding.anchor() as i32, + ); + } + self.commit(); } } @@ -236,6 +244,10 @@ pub struct ClientState { /// The IME cursor area which should not be covered by the input method popup. cursor_area: (LogicalPosition, LogicalSize), + + /// The `ImeSurroundingText` struct is based on the Wayland model. + /// When this changes, another struct might be needed. + surrounding_text: ImeSurroundingText, } impl ClientState { @@ -248,8 +260,19 @@ impl ClientState { capabilities, content_type: Default::default(), cursor_area: Default::default(), + surrounding_text: ImeSurroundingText::new(String::new(), 0, 0).unwrap(), }; + let unsupported_flags = + capabilities.without_purpose().without_cursor_area().without_surrounding_text(); + + if unsupported_flags != ImeCapabilities::new() { + warn!( + "Backend doesn't support all requested IME capabilities: {:?}.\n Ignoring.", + unsupported_flags + ); + } + this.update(request_data, scale_factor); this } @@ -277,6 +300,14 @@ impl ClientState { warn!("discarding IME cursor area update without capability enabled."); } } + + if let Some(surrounding) = request_data.surrounding_text { + if self.capabilities.surrounding_text() { + self.surrounding_text = surrounding; + } else { + warn!("discarding IME surrounding text update without capability enabled."); + } + } } pub fn content_type(&self) -> Option { @@ -286,6 +317,10 @@ impl ClientState { pub fn cursor_area(&self) -> Option<(LogicalPosition, LogicalSize)> { self.capabilities.cursor_area().then_some(self.cursor_area) } + + pub fn surrounding_text(&self) -> Option<&ImeSurroundingText> { + self.capabilities.surrounding_text().then_some(&self.surrounding_text) + } } /// Arguments to content_type