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.
This commit is contained in:
parent
eb66c25980
commit
e7a6034b55
3 changed files with 250 additions and 17 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<Self, ImeSurroundingTextError> {
|
||||
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<Self> {
|
||||
pub fn new(capabilities: ImeCapabilities, request_data: ImeRequestData) -> Option<Self> {
|
||||
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<ImeSurroundingText>,
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u32>, LogicalSize<u32>),
|
||||
|
||||
/// 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<ContentType> {
|
||||
|
|
@ -286,6 +317,10 @@ impl ClientState {
|
|||
pub fn cursor_area(&self) -> Option<(LogicalPosition<u32>, LogicalSize<u32>)> {
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue