winit-core/ime: add more purposes and content hints

Also move IME example into `ime.rs` thus making IME integrations more
easily comprehendible.

Co-authored-by: Kirill Chibisov <contact@kchibisov.com>
This commit is contained in:
DorotaC 2025-09-06 06:27:55 +02:00 committed by GitHub
parent 014fb68a26
commit 6de5041a94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 554 additions and 182 deletions

1
examples Symbolic link
View file

@ -0,0 +1 @@
winit/examples

View file

@ -1136,9 +1136,9 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug {
let action = if allowed { let action = if allowed {
let position = LogicalPosition::new(0, 0); let position = LogicalPosition::new(0, 0);
let size = LogicalSize::new(0, 0); let size = LogicalSize::new(0, 0);
let ime_caps = ImeCapabilities::new().with_purpose().with_cursor_area(); let ime_caps = ImeCapabilities::new().without_hint_and_purpose().with_cursor_area();
let request_data = ImeRequestData { let request_data = ImeRequestData {
purpose: Some(ImePurpose::Normal), hint_and_purpose: Some((ImeHint::NONE, ImePurpose::Normal)),
// WARNING: there's nothing sensible to use here by default. // WARNING: there's nothing sensible to use here by default.
cursor_area: Some((position.into(), size.into())), cursor_area: Some((position.into(), size.into())),
..ImeRequestData::default() ..ImeRequestData::default()
@ -1160,9 +1160,9 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug {
/// - **iOS / Android / Web / Windows / X11 / macOS / Orbital:** Unsupported. /// - **iOS / Android / Web / Windows / X11 / macOS / Orbital:** Unsupported.
#[deprecated = "use Window::request_ime_update instead"] #[deprecated = "use Window::request_ime_update instead"]
fn set_ime_purpose(&self, purpose: ImePurpose) { fn set_ime_purpose(&self, purpose: ImePurpose) {
if self.ime_capabilities().map(|caps| caps.purpose()).unwrap_or(false) { if self.ime_capabilities().map(|caps| caps.hint_and_purpose()).unwrap_or(false) {
let _ = self.request_ime_update(ImeRequest::Update(ImeRequestData { let _ = self.request_ime_update(ImeRequest::Update(ImeRequestData {
purpose: Some(purpose), hint_and_purpose: Some((ImeHint::NONE, purpose)),
..ImeRequestData::default() ..ImeRequestData::default()
})); }));
} }
@ -1186,14 +1186,14 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug {
/// ///
/// ```no_run /// ```no_run
/// # use dpi::{Position, Size}; /// # use dpi::{Position, Size};
/// # use winit_core::window::{Window, ImePurpose, ImeRequest, ImeCapabilities, ImeRequestData, ImeEnableRequest}; /// # use winit_core::window::{Window, ImeHint, ImePurpose, ImeRequest, ImeCapabilities, ImeRequestData, ImeEnableRequest};
/// # fn scope(window: &dyn Window, cursor_pos: Position, cursor_size: Size) { /// # fn scope(window: &dyn Window, cursor_pos: Position, cursor_size: Size) {
/// // Clear previous state by switching off IME /// // Clear previous state by switching off IME
/// window.request_ime_update(ImeRequest::Disable).expect("Disable cannot fail"); /// window.request_ime_update(ImeRequest::Disable).expect("Disable cannot fail");
/// ///
/// let ime_caps = ImeCapabilities::new().with_cursor_area().with_purpose(); /// let ime_caps = ImeCapabilities::new().with_cursor_area().with_hint_and_purpose();
/// let request_data = ImeRequestData::default() /// let request_data = ImeRequestData::default()
/// .with_purpose(ImePurpose::Normal) /// .with_hint_and_purpose(ImeHint::NONE, ImePurpose::Normal)
/// .with_cursor_area(cursor_pos, cursor_size); /// .with_cursor_area(cursor_pos, cursor_size);
/// let enable_ime = ImeEnableRequest::new(ime_caps, request_data.clone()).unwrap(); /// let enable_ime = ImeEnableRequest::new(ime_caps, request_data.clone()).unwrap();
/// window.request_ime_update(ImeRequest::Enable(enable_ime)).expect("Enabling may fail if IME is not supported"); /// window.request_ime_update(ImeRequest::Enable(enable_ime)).expect("Enabling may fail if IME is not supported");
@ -1598,7 +1598,10 @@ pub enum WindowLevel {
/// Generic IME purposes for use in [`Window::set_ime_purpose`]. /// Generic IME purposes for use in [`Window::set_ime_purpose`].
/// ///
/// The purpose should reflect the kind of data to be entered.
/// The purpose may improve UX by optimizing the IME for the specific use case, /// The purpose may improve UX by optimizing the IME for the specific use case,
/// for example showing relevant characters and hiding unneeded ones,
/// or changing the icon of the confrirmation button,
/// if winit can express the purpose to the platform and the platform reacts accordingly. /// if winit can express the purpose to the platform and the platform reacts accordingly.
/// ///
/// ## Platform-specific /// ## Platform-specific
@ -1608,14 +1611,31 @@ pub enum WindowLevel {
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum ImePurpose { pub enum ImePurpose {
/// No special hints for the IME (default). /// No special purpose for the IME (default).
Normal, Normal,
/// The IME is used for password input. /// The IME is used for password input.
/// The IME will treat the contents as sensitive.
Password, Password,
/// The IME is used to input into a terminal. /// The IME is used to input into a terminal.
/// ///
/// For example, that could alter OSK on Wayland to show extra buttons. /// For example, that could alter OSK on Wayland to show extra buttons.
Terminal, Terminal,
/// Number (including decimal separator and sign)
Number,
/// Phone number
Phone,
/// URL
Url,
/// Email address
Email,
/// Password composed only of digits (treated as sensitive data)
Pin,
/// Date
Date,
/// Time
Time,
/// Date and time
DateTime,
} }
impl Default for ImePurpose { impl Default for ImePurpose {
@ -1624,6 +1644,48 @@ impl Default for ImePurpose {
} }
} }
bitflags! {
/// IME hints
///
/// The hint should reflect the desired behaviour of the IME
/// while entering text.
/// The purpose may improve UX by optimizing the IME for the specific use case,
/// beyond just the general data type specified in `ImePurpose`.
///
/// ## Platform-specific
///
/// - **iOS / Android / Web / Windows / X11 / macOS / Orbital:** Unsupported.
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct ImeHint: u32 {
/// No special behaviour.
const NONE = 0;
/// Suggest word completions.
const COMPLETION = 0x1;
/// Suggest word corrections.
const SPELLCHECK = 0x2;
/// Switch to uppercase letters at the start of a sentence.
const AUTO_CAPITALIZATION = 0x4;
/// Prefer lowercase letters.
const LOWERCASE = 0x8;
/// Prefer uppercase letters.
const UPPERCASE = 0x10;
/// Prefer casing for titles and headings (can be language dependent).
const TITLECASE = 0x20;
/// Characters should be hidden.
///
/// This may prevent e.g. layout switching with some IMEs, unless hint is disabled.
const HIDDEN_TEXT = 0x40;
/// Typed text should not be stored.
const SENSITIVE_DATA = 0x80;
/// Just Latin characters should be entered.
const LATIN = 0x100;
/// The text input is multiline.
const MULTILINE = 0x200;
}
}
#[derive(Debug, PartialEq, Eq, Clone, Hash)] #[derive(Debug, PartialEq, Eq, Clone, Hash)]
pub enum ImeSurroundingTextError { pub enum ImeSurroundingTextError {
/// Text exceeds 4000 bytes /// Text exceeds 4000 bytes
@ -1759,7 +1821,7 @@ impl ImeEnableRequest {
return None; return None;
} }
if capabilities.purpose() ^ request_data.purpose.is_some() { if capabilities.hint_and_purpose() ^ request_data.hint_and_purpose.is_some() {
return None; return None;
} }
@ -1804,23 +1866,23 @@ impl ImeCapabilities {
Self::default() Self::default()
} }
/// Marks `purpose` as supported. /// Marks `hint and purpose` as supported.
/// ///
/// For more details see [`ImeRequestData::with_purpose`]. /// For more details see [`ImeRequestData::with_hint_and_purpose`].
pub const fn with_purpose(self) -> Self { pub const fn with_hint_and_purpose(self) -> Self {
Self(self.0.union(ImeCapabilitiesFlags::PURPOSE)) Self(self.0.union(ImeCapabilitiesFlags::HINT_AND_PURPOSE))
} }
/// Marks `purpose` as unsupported. /// Marks `hint and purpose` as unsupported.
/// ///
/// For more details see [`ImeRequestData::with_purpose`]. /// For more details see [`ImeRequestData::with_hint_and_purpose`].
pub const fn without_purpose(self) -> Self { pub const fn without_hint_and_purpose(self) -> Self {
Self(self.0.difference(ImeCapabilitiesFlags::PURPOSE)) Self(self.0.difference(ImeCapabilitiesFlags::HINT_AND_PURPOSE))
} }
/// Returns `true` if `purpose` is supported. /// Returns `true` if `hint and purpose` is supported.
pub const fn purpose(&self) -> bool { pub const fn hint_and_purpose(&self) -> bool {
self.0.contains(ImeCapabilitiesFlags::PURPOSE) self.0.contains(ImeCapabilitiesFlags::HINT_AND_PURPOSE)
} }
/// Marks `cursor_area` as supported. /// Marks `cursor_area` as supported.
@ -1865,8 +1927,8 @@ impl ImeCapabilities {
bitflags! { bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub(crate) struct ImeCapabilitiesFlags : u8 { pub(crate) struct ImeCapabilitiesFlags : u8 {
/// Client supports setting IME purpose. /// Client supports setting IME hint and purpose.
const PURPOSE = 1 << 0; const HINT_AND_PURPOSE = 1 << 0;
/// Client supports reporting cursor area for IME popup to /// Client supports reporting cursor area for IME popup to
/// appear. /// appear.
const CURSOR_AREA = 1 << 1; const CURSOR_AREA = 1 << 1;
@ -1884,10 +1946,10 @@ bitflags! {
#[derive(Debug, PartialEq, Clone, Default)] #[derive(Debug, PartialEq, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ImeRequestData { pub struct ImeRequestData {
/// Text input purpose. /// Text input hint and purpose.
/// ///
/// To support updating it, enable [`ImeCapabilities::PURPOSE`]. /// To support updating it, enable [`ImeCapabilities::HINT_AND_PURPOSE`].
pub purpose: Option<ImePurpose>, pub hint_and_purpose: Option<(ImeHint, ImePurpose)>,
/// The IME cursor area which should not be covered by the input method popup. /// The IME cursor area which should not be covered by the input method popup.
/// ///
/// To support updating it, enable [`ImeCapabilities::CURSOR_AREA`]. /// To support updating it, enable [`ImeCapabilities::CURSOR_AREA`].
@ -1899,9 +1961,9 @@ pub struct ImeRequestData {
} }
impl ImeRequestData { impl ImeRequestData {
/// Sets the purpose hint of the current text input. /// Sets the hint and purpose of the current text input content.
pub fn with_purpose(self, purpose: ImePurpose) -> Self { pub fn with_hint_and_purpose(self, hint: ImeHint, purpose: ImePurpose) -> Self {
Self { purpose: Some(purpose), ..self } Self { hint_and_purpose: Some((hint, purpose)), ..self }
} }
/// Sets the IME cursor editing area. /// Sets the IME cursor editing area.
@ -2027,7 +2089,7 @@ mod tests {
ImeCapabilities, ImeEnableRequest, ImeRequestData, ImeSurroundingText, ImeCapabilities, ImeEnableRequest, ImeRequestData, ImeSurroundingText,
ImeSurroundingTextError, ImeSurroundingTextError,
}; };
use crate::window::ImePurpose; use crate::window::{ImeHint, ImePurpose};
#[test] #[test]
fn ime_initial_request_caps_match() { fn ime_initial_request_caps_match() {
@ -2040,21 +2102,21 @@ mod tests {
) )
.is_none()); .is_none());
assert!(ImeEnableRequest::new( assert!(ImeEnableRequest::new(
ImeCapabilities::new().with_purpose(), ImeCapabilities::new().with_hint_and_purpose(),
ImeRequestData::default() ImeRequestData::default()
) )
.is_none()); .is_none());
assert!(ImeEnableRequest::new( assert!(ImeEnableRequest::new(
ImeCapabilities::new().with_cursor_area(), ImeCapabilities::new().with_cursor_area(),
ImeRequestData::default().with_purpose(ImePurpose::Normal) ImeRequestData::default().with_hint_and_purpose(ImeHint::NONE, ImePurpose::Normal)
) )
.is_none()); .is_none());
assert!(ImeEnableRequest::new( assert!(ImeEnableRequest::new(
ImeCapabilities::new(), ImeCapabilities::new(),
ImeRequestData::default() ImeRequestData::default()
.with_purpose(ImePurpose::Normal) .with_hint_and_purpose(ImeHint::NONE, ImePurpose::Normal)
.with_cursor_area(position, size) .with_cursor_area(position, size)
) )
.is_none()); .is_none());
@ -2062,7 +2124,7 @@ mod tests {
assert!(ImeEnableRequest::new( assert!(ImeEnableRequest::new(
ImeCapabilities::new().with_cursor_area(), ImeCapabilities::new().with_cursor_area(),
ImeRequestData::default() ImeRequestData::default()
.with_purpose(ImePurpose::Normal) .with_hint_and_purpose(ImeHint::NONE, ImePurpose::Normal)
.with_cursor_area(position, size) .with_cursor_area(position, size)
) )
.is_none()); .is_none());
@ -2074,9 +2136,9 @@ mod tests {
.is_some()); .is_some());
assert!(ImeEnableRequest::new( assert!(ImeEnableRequest::new(
ImeCapabilities::new().with_purpose().with_cursor_area(), ImeCapabilities::new().with_hint_and_purpose().with_cursor_area(),
ImeRequestData::default() ImeRequestData::default()
.with_purpose(ImePurpose::Normal) .with_hint_and_purpose(ImeHint::NONE, ImePurpose::Normal)
.with_cursor_area(position, size) .with_cursor_area(position, size)
) )
.is_some()); .is_some());

View file

@ -11,7 +11,9 @@ use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_v3::
}; };
use tracing::warn; use tracing::warn;
use winit_core::event::{Ime, WindowEvent}; use winit_core::event::{Ime, WindowEvent};
use winit_core::window::{ImeCapabilities, ImePurpose, ImeRequestData, ImeSurroundingText}; use winit_core::window::{
ImeCapabilities, ImeHint, ImePurpose, ImeRequestData, ImeSurroundingText,
};
use crate::state::WinitState; use crate::state::WinitState;
@ -278,9 +280,7 @@ struct DeleteSurroundingText {
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub struct ClientState { pub struct ClientState {
capabilities: ImeCapabilities, capabilities: ImeCapabilities,
content_type: ContentType, content_type: ContentType,
/// The IME cursor area which should not be covered by the input method popup. /// The IME cursor area which should not be covered by the input method popup.
cursor_area: (LogicalPosition<u32>, LogicalSize<u32>), cursor_area: (LogicalPosition<u32>, LogicalSize<u32>),
@ -302,8 +302,10 @@ impl ClientState {
surrounding_text: ImeSurroundingText::new(String::new(), 0, 0).unwrap(), surrounding_text: ImeSurroundingText::new(String::new(), 0, 0).unwrap(),
}; };
let unsupported_flags = let unsupported_flags = capabilities
capabilities.without_purpose().without_cursor_area().without_surrounding_text(); .without_hint_and_purpose()
.without_cursor_area()
.without_surrounding_text();
if unsupported_flags != ImeCapabilities::new() { if unsupported_flags != ImeCapabilities::new() {
warn!( warn!(
@ -322,12 +324,10 @@ impl ClientState {
/// Updates the fields of the state which are present in update_fields. /// Updates the fields of the state which are present in update_fields.
pub fn update(&mut self, request_data: ImeRequestData, scale_factor: f64) { pub fn update(&mut self, request_data: ImeRequestData, scale_factor: f64) {
if let Some(purpose) = request_data.purpose { if let Some((hint, purpose)) =
if self.capabilities.purpose() { request_data.hint_and_purpose.filter(|_| self.capabilities.hint_and_purpose())
self.content_type = purpose.into(); {
} else { self.content_type = (hint, purpose).into();
warn!("discarding ImePurpose update without capability enabled.");
}
} }
if let Some((position, size)) = request_data.cursor_area { if let Some((position, size)) = request_data.cursor_area {
@ -350,7 +350,7 @@ impl ClientState {
} }
pub fn content_type(&self) -> Option<ContentType> { pub fn content_type(&self) -> Option<ContentType> {
self.capabilities.purpose().then_some(self.content_type) self.capabilities.hint_and_purpose().then_some(self.content_type)
} }
pub fn cursor_area(&self) -> Option<(LogicalPosition<u32>, LogicalSize<u32>)> { pub fn cursor_area(&self) -> Option<(LogicalPosition<u32>, LogicalSize<u32>)> {
@ -365,20 +365,69 @@ impl ClientState {
/// Arguments to content_type /// Arguments to content_type
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContentType { pub struct ContentType {
/// Text input purpose /// Text input hint.
purpose: ContentPurpose,
hint: ContentHint, hint: ContentHint,
/// Text input purpose.
purpose: ContentPurpose,
} }
impl From<ImePurpose> for ContentType { /// The two options influence each other, so they must be converted together.
fn from(purpose: ImePurpose) -> Self { impl From<(ImeHint, ImePurpose)> for ContentType {
let (hint, purpose) = match purpose { fn from((hint, purpose): (ImeHint, ImePurpose)) -> Self {
ImePurpose::Password => (ContentHint::SensitiveData, ContentPurpose::Password), let purpose = match purpose {
ImePurpose::Terminal => (ContentHint::None, ContentPurpose::Terminal), ImePurpose::Password => ContentPurpose::Password,
_ => return Default::default(), ImePurpose::Terminal => ContentPurpose::Terminal,
ImePurpose::Phone => ContentPurpose::Phone,
ImePurpose::Number => ContentPurpose::Number,
ImePurpose::Url => ContentPurpose::Url,
ImePurpose::Email => ContentPurpose::Email,
ImePurpose::Pin => ContentPurpose::Pin,
ImePurpose::Date => ContentPurpose::Date,
ImePurpose::Time => ContentPurpose::Time,
ImePurpose::DateTime => ContentPurpose::Datetime,
_ => ContentPurpose::Normal,
}; };
Self { hint, purpose } let base_hint = match purpose {
// Before the hint API was introduced, password purpose guaranteed the
// sensitive hint. Keep this behaviour for the sake of backwards compatibility.
ContentPurpose::Password | ContentPurpose::Pin => ContentHint::SensitiveData,
_ => ContentHint::None,
};
let mut new_hint = base_hint;
if hint.contains(ImeHint::COMPLETION) {
new_hint |= ContentHint::Completion;
}
if hint.contains(ImeHint::SPELLCHECK) {
new_hint |= ContentHint::Spellcheck;
}
if hint.contains(ImeHint::AUTO_CAPITALIZATION) {
new_hint |= ContentHint::AutoCapitalization;
}
if hint.contains(ImeHint::LOWERCASE) {
new_hint |= ContentHint::Lowercase;
}
if hint.contains(ImeHint::UPPERCASE) {
new_hint |= ContentHint::Uppercase;
}
if hint.contains(ImeHint::TITLECASE) {
new_hint |= ContentHint::Titlecase;
}
if hint.contains(ImeHint::HIDDEN_TEXT) {
new_hint |= ContentHint::HiddenText;
}
if hint.contains(ImeHint::SENSITIVE_DATA) {
new_hint |= ContentHint::SensitiveData;
}
if hint.contains(ImeHint::LATIN) {
new_hint |= ContentHint::Latin;
}
if hint.contains(ImeHint::MULTILINE) {
new_hint |= ContentHint::Multiline;
}
Self { hint: new_hint, purpose }
} }
} }

View file

@ -1 +0,0 @@
../examples

View file

@ -1,4 +1,7 @@
//! Simple winit application. //! Simple winit application.
//!
//! Note that a real application accepting text input **should** support
//! the IME interface. See the `ime` example.
use std::collections::HashMap; use std::collections::HashMap;
use std::error::Error; use std::error::Error;
@ -9,22 +12,21 @@ use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc; use std::sync::Arc;
#[cfg(all(not(android_platform), not(web_platform)))] #[cfg(all(not(android_platform), not(web_platform)))]
use std::time::Instant; use std::time::Instant;
use std::{cmp, fmt, mem}; use std::{fmt, mem};
use ::tracing::{error, info};
use cursor_icon::CursorIcon; use cursor_icon::CursorIcon;
use dpi::LogicalPosition;
#[cfg(not(android_platform))] #[cfg(not(android_platform))]
use rwh_06::{DisplayHandle, HasDisplayHandle}; use rwh_06::{DisplayHandle, HasDisplayHandle};
#[cfg(not(android_platform))] #[cfg(not(android_platform))]
use softbuffer::{Context, Surface}; use softbuffer::{Context, Surface};
use tracing::{error, info};
#[cfg(all(web_platform, not(android_platform)))] #[cfg(all(web_platform, not(android_platform)))]
use web_time::Instant; use web_time::Instant;
use winit::application::ApplicationHandler; use winit::application::ApplicationHandler;
use winit::cursor::{Cursor, CustomCursor, CustomCursorSource}; use winit::cursor::{Cursor, CustomCursor, CustomCursorSource};
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize}; use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
use winit::error::RequestError; use winit::error::RequestError;
use winit::event::{DeviceEvent, DeviceId, Ime, MouseButton, MouseScrollDelta, WindowEvent}; use winit::event::{DeviceEvent, DeviceId, MouseButton, MouseScrollDelta, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::icon::{Icon, RgbaIcon}; use winit::icon::{Icon, RgbaIcon};
use winit::keyboard::{Key, ModifiersState}; use winit::keyboard::{Key, ModifiersState};
@ -39,28 +41,23 @@ use winit::platform::wayland::{ActiveEventLoopExtWayland, WindowAttributesWaylan
use winit::platform::web::{ActiveEventLoopExtWeb, WindowAttributesWeb}; use winit::platform::web::{ActiveEventLoopExtWeb, WindowAttributesWeb};
#[cfg(x11_platform)] #[cfg(x11_platform)]
use winit::platform::x11::{ActiveEventLoopExtX11, WindowAttributesX11}; use winit::platform::x11::{ActiveEventLoopExtX11, WindowAttributesX11};
use winit::window::{ use winit::window::{CursorGrabMode, 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::application::macos::ApplicationHandlerExtMacOS;
use winit_core::window::ImeRequest;
#[path = "util/tracing.rs"] #[path = "util/tracing.rs"]
mod tracing; mod tracing_init;
#[path = "util/fill.rs"] #[path = "util/fill.rs"]
mod fill; mod fill;
/// The amount of points to around the window for drag resize direction calculations. /// The amount of points to around the window for drag resize direction calculations.
const BORDER_SIZE: f64 = 20.; const BORDER_SIZE: f64 = 20.;
const IME_CURSOR_SIZE: PhysicalSize<u32> = PhysicalSize::new(20, 20);
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
#[cfg(web_platform)] #[cfg(web_platform)]
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
tracing::init(); tracing_init::init();
let event_loop = EventLoop::new()?; let event_loop = EventLoop::new()?;
let (sender, receiver) = mpsc::channel(); let (sender, receiver) = mpsc::channel();
@ -245,7 +242,6 @@ impl Application {
window.window.set_simple_fullscreen(!window.window.simple_fullscreen()); window.window.set_simple_fullscreen(!window.window.simple_fullscreen());
}, },
Action::ToggleMaximize => window.toggle_maximize(), Action::ToggleMaximize => window.toggle_maximize(),
Action::ToggleImeInput => window.toggle_ime(),
Action::Minimize => window.minimize(), Action::Minimize => window.minimize(),
Action::NextCursor => window.next_cursor(), Action::NextCursor => window.next_cursor(),
Action::NextCustomCursor => { Action::NextCustomCursor => {
@ -512,41 +508,6 @@ impl ApplicationHandler for Application {
} }
} }
}, },
WindowEvent::Ime(event) => match event {
Ime::Enabled => info!("IME enabled for Window={window_id:?}"),
Ime::Preedit(text, caret_pos) => {
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::DeleteSurrounding { before_bytes, after_bytes } => {
let (text, cursor) = &window.text_field_contents;
// To anyone copying this, keep in mind that this doesn't take text selection
// into account. The deletion happens *around* the pre-edit,
// and may remove the whole selection or a part of it.
let delete_start = cursor.saturating_sub(before_bytes);
let delete_end = cmp::min(cursor.saturating_add(after_bytes), text.len());
if text.is_char_boundary(delete_start) && text.is_char_boundary(delete_end) {
let new_text = {
let mut t = String::from(&text[..delete_start]);
t.push_str(&text[delete_end..]);
t
};
window.text_field_contents = (new_text, delete_start);
info!("IME deleted bytes: {before_bytes}, {after_bytes}");
} else {
error!("Buggy IME tried to delete with indices not on char boundary.");
}
},
Ime::Disabled => info!("IME disabled for Window={window_id:?}"),
},
WindowEvent::PinchGesture { delta, .. } => { WindowEvent::PinchGesture { delta, .. } => {
window.zoom += delta; window.zoom += delta;
let zoom = window.zoom; let zoom = window.zoom;
@ -581,6 +542,7 @@ impl ApplicationHandler for Application {
| WindowEvent::DragMoved { .. } | WindowEvent::DragMoved { .. }
| WindowEvent::DragDropped { .. } | WindowEvent::DragDropped { .. }
| WindowEvent::Destroyed | WindowEvent::Destroyed
| WindowEvent::Ime(_)
| WindowEvent::Moved(_) => (), | WindowEvent::Moved(_) => (),
} }
} }
@ -635,10 +597,6 @@ impl ApplicationHandlerExtMacOS for Application {
/// State of the window. /// State of the window.
struct WindowState { 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. /// Render surface.
/// ///
/// NOTE: This surface must be dropped before the `Window`. /// NOTE: This surface must be dropped before the `Window`.
@ -693,21 +651,6 @@ impl WindowState {
let named_idx = 0; let named_idx = 0;
window.set_cursor(CURSORS[named_idx].into()); window.set_cursor(CURSORS[named_idx].into());
// 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_surrounding_text(ImeSurroundingText::new(String::new(), 0, 0).unwrap());
let enable_request = ImeEnableRequest::new(
ImeCapabilities::new().with_purpose().with_cursor_area().with_surrounding_text(),
request_data,
)
.unwrap();
let enable_ime = ImeRequest::Enable(enable_request);
// Initial update
window.request_ime_update(enable_ime).unwrap();
let size = window.surface_size(); let size = window.surface_size();
let mut state = Self { let mut state = Self {
#[cfg(macos_platform)] #[cfg(macos_platform)]
@ -723,8 +666,6 @@ impl WindowState {
continuous_redraw: false, continuous_redraw: false,
#[cfg(not(android_platform))] #[cfg(not(android_platform))]
start_time: Instant::now(), start_time: Instant::now(),
ime_enabled: true,
text_field_contents: (String::new(), 0),
cursor_position: Default::default(), cursor_position: Default::default(),
cursor_hidden: Default::default(), cursor_hidden: Default::default(),
modifiers: Default::default(), modifiers: Default::default(),
@ -738,65 +679,12 @@ impl WindowState {
Ok(state) 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 enable_request = ImeEnableRequest::new(
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();
};
self.ime_enabled = !self.ime_enabled;
}
pub fn minimize(&mut self) { pub fn minimize(&mut self) {
self.window.set_minimized(true); self.window.set_minimized(true);
} }
pub fn cursor_moved(&mut self, position: PhysicalPosition<f64>) { pub fn cursor_moved(&mut self, position: PhysicalPosition<f64>) {
// the IME really cares about the caret,
// but there's nothing else to demonstrate a position
self.cursor_position = Some(position); self.cursor_position = Some(position);
if self.ime_enabled {
let request_data =
ImeRequestData::default().with_cursor_area(position.into(), IME_CURSOR_SIZE.into());
self.window
.request_ime_update(ImeRequest::Update(request_data))
.expect("A capability was not initially declared");
}
} }
pub fn cursor_left(&mut self) { pub fn cursor_left(&mut self) {
@ -1113,7 +1001,6 @@ enum Action {
ToggleCursorVisibility, ToggleCursorVisibility,
CreateNewWindow, CreateNewWindow,
ToggleResizeIncrements, ToggleResizeIncrements,
ToggleImeInput,
ToggleDecorations, ToggleDecorations,
ToggleResizable, ToggleResizable,
ToggleFullscreen, ToggleFullscreen,
@ -1150,7 +1037,6 @@ impl Action {
Action::CloseWindow => "Close window", Action::CloseWindow => "Close window",
Action::ToggleCursorVisibility => "Hide cursor", Action::ToggleCursorVisibility => "Hide cursor",
Action::CreateNewWindow => "Create new window", Action::CreateNewWindow => "Create new window",
Action::ToggleImeInput => "Toggle IME input",
Action::ToggleDecorations => "Toggle decorations", Action::ToggleDecorations => "Toggle decorations",
Action::ToggleResizable => "Toggle window resizable state", Action::ToggleResizable => "Toggle window resizable state",
Action::ToggleFullscreen => "Toggle fullscreen", Action::ToggleFullscreen => "Toggle fullscreen",
@ -1370,7 +1256,6 @@ const KEY_BINDINGS: &[Binding<&'static str>] = &[
#[cfg(macos_platform)] #[cfg(macos_platform)]
Binding::new("F", ModifiersState::ALT, Action::ToggleSimpleFullscreen), Binding::new("F", ModifiersState::ALT, Action::ToggleSimpleFullscreen),
Binding::new("D", ModifiersState::CONTROL, Action::ToggleDecorations), Binding::new("D", ModifiersState::CONTROL, Action::ToggleDecorations),
Binding::new("I", ModifiersState::CONTROL, Action::ToggleImeInput),
Binding::new("L", ModifiersState::CONTROL, Action::CycleCursorGrab), Binding::new("L", ModifiersState::CONTROL, Action::CycleCursorGrab),
Binding::new("P", ModifiersState::CONTROL, Action::ToggleResizeIncrements), Binding::new("P", ModifiersState::CONTROL, Action::ToggleResizeIncrements),
Binding::new("R", ModifiersState::CONTROL, Action::ToggleResizable), Binding::new("R", ModifiersState::CONTROL, Action::ToggleResizable),

View file

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 159 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 129 B

After

Width:  |  Height:  |  Size: 129 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 229 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

374
winit/examples/ime.rs Normal file
View file

@ -0,0 +1,374 @@
//! Showcases the use of an input method engine (IME)
//! by emulating a text edit field.
//!
//! Use CTRL+i to toggle IME support.
//! Use CTRL+p to cycle content purpose values.
//! Use CTRL+h to cycle content hint permutations.
use std::cmp;
use std::error::Error;
use dpi::{LogicalPosition, PhysicalSize};
use tracing::{error, info};
use winit::application::ApplicationHandler;
use winit::event::{Ime, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::keyboard::{Key, ModifiersState, NamedKey};
#[cfg(web_platform)]
use winit::platform::web::WindowAttributesWeb;
use winit::window::{
ImeCapabilities, ImeEnableRequest, ImeHint, ImePurpose, ImeRequest, ImeRequestData,
ImeSurroundingText, Window, WindowAttributes, WindowId,
};
#[path = "util/fill.rs"]
mod fill;
#[path = "util/tracing.rs"]
mod tracing_init;
const IME_CURSOR_SIZE: PhysicalSize<u32> = PhysicalSize::new(20, 20);
#[derive(Debug)]
struct App {
window: Option<Box<dyn Window>>,
input_state: TextInputState,
modifiers: ModifiersState,
}
/// State of the undisplayed text input field.
#[derive(Debug)]
struct TextInputState {
ime_enabled: bool,
/// The contents of the emulated text field for IME purposes (not displayed).
/// (text, cursor position in bytes).
contents: String,
/// The purpose of the contents the emulated text field expects
purpose: ImePurpose,
/// The behaviour hints for the IME regarding the emulated text field
hint: ImeHint,
}
impl TextInputState {
fn text_and_cursor(&self) -> (&str, usize) {
(&self.contents, self.contents.len())
}
fn append_text(&mut self, text: &str) {
self.contents.push_str(text);
}
fn set_text(&mut self, text: String) {
self.contents = text;
}
fn pop(&mut self) {
self.contents.pop();
}
}
impl ApplicationHandler for App {
fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) {
#[cfg(not(web_platform))]
let window_attributes = WindowAttributes::default();
#[cfg(web_platform)]
let window_attributes = WindowAttributes::default()
.with_platform_attributes(Box::new(WindowAttributesWeb::default().with_append(true)));
self.window = match event_loop.create_window(window_attributes) {
Ok(window) => Some(window),
Err(err) => {
eprintln!("error creating window: {err}");
event_loop.exit();
return;
},
};
// Allow IME out of the box.
let enable_request = ImeEnableRequest::new(
ImeCapabilities::new()
.with_hint_and_purpose()
.with_cursor_area()
.with_surrounding_text(),
self.get_ime_update(),
)
.unwrap();
let enable_ime = ImeRequest::Enable(enable_request);
// Initial update
self.window().request_ime_update(enable_ime).unwrap();
}
fn window_event(&mut self, event_loop: &dyn ActiveEventLoop, _: WindowId, event: WindowEvent) {
match event {
WindowEvent::CloseRequested => {
info!("Close was requested; stopping");
self.window = None;
event_loop.exit();
},
WindowEvent::SurfaceResized(_) => {
self.window.as_ref().expect("resize event without a window").request_redraw();
},
WindowEvent::RedrawRequested => {
// Redraw the application.
//
// It's preferable for applications that do not render continuously to render in
// this event rather than in AboutToWait, since rendering in here allows
// the program to gracefully handle redraws requested by the OS.
let window = self.window.as_ref().expect("redraw request without a window");
// Notify that you're about to draw.
window.pre_present_notify();
// Draw.
fill::fill_window(window.as_ref());
// For contiguous redraw loop you can request a redraw from here.
// window.request_redraw();
},
WindowEvent::Ime(event) => {
self.handle_ime_event(event);
},
WindowEvent::ModifiersChanged(modifiers) => {
self.modifiers = modifiers.state();
info!("Modifiers changed to {:?}", self.modifiers);
},
WindowEvent::KeyboardInput { event, is_synthetic: false, .. } => {
let mods = self.modifiers;
// Dispatch actions only on press.
if event.state.is_pressed() {
self.handle_key_pressed(event, mods);
}
},
_ => (),
}
}
}
impl App {
fn handle_key_pressed(&mut self, event: winit::event::KeyEvent, mods: ModifiersState) {
match event.key_without_modifiers.as_ref() {
Key::Character("i") if mods == ModifiersState::CONTROL => self.toggle_ime(),
Key::Character("p") if mods == ModifiersState::CONTROL => {
self.input_state.purpose = match self.input_state.purpose {
ImePurpose::Normal => ImePurpose::Password,
ImePurpose::Password => ImePurpose::Terminal,
ImePurpose::Terminal => ImePurpose::Number,
ImePurpose::Number => ImePurpose::Phone,
ImePurpose::Phone => ImePurpose::Url,
ImePurpose::Url => ImePurpose::Email,
ImePurpose::Email => ImePurpose::Pin,
ImePurpose::Pin => ImePurpose::Date,
ImePurpose::Date => ImePurpose::Time,
ImePurpose::Time => ImePurpose::DateTime,
ImePurpose::DateTime => ImePurpose::Normal,
_ => ImePurpose::Normal,
};
if self.input_state.ime_enabled {
self.window()
.request_ime_update(ImeRequest::Update(self.get_ime_update()))
.unwrap();
}
info!("text input purpose now {:?}", self.input_state.purpose);
},
Key::Character("h") if mods == ModifiersState::CONTROL => {
let bump = |hint: ImeHint| {
if hint.is_all() {
ImeHint::NONE
} else {
// Go through all integers. We'll skip invalid ones
ImeHint::from_bits_retain(hint.bits().wrapping_add(1))
}
};
let mut new_hint = bump(self.input_state.hint);
while !ImeHint::all().contains(new_hint) {
new_hint = bump(new_hint);
}
self.input_state.hint = new_hint;
if self.input_state.ime_enabled {
self.window()
.request_ime_update(ImeRequest::Update(self.get_ime_update()))
.unwrap();
}
info!("text input IME hint now {:?}", self.input_state.hint);
},
Key::Named(NamedKey::Backspace) => {
self.input_state.pop();
self.print_input_state();
},
_ => {
if let Some(text) = event.text_with_all_modifiers {
self.input_state.append_text(&text);
if self.input_state.ime_enabled {
self.window()
.request_ime_update(ImeRequest::Update(self.get_ime_update()))
.unwrap();
}
self.print_input_state();
}
},
}
}
fn handle_ime_event(&mut self, event: Ime) {
let window = self.window.as_ref().expect("IME request without a window");
match event {
Ime::Enabled => info!("IME enabled for Window={:?}", window.id()),
Ime::Preedit(text, caret_pos) => info!("Preedit: {text}, with caret at {caret_pos:?}"),
Ime::Commit(text) => {
self.input_state.append_text(&text);
let request_data = self.get_ime_update();
window.request_ime_update(ImeRequest::Update(request_data)).unwrap();
self.print_input_state();
},
Ime::DeleteSurrounding { before_bytes, after_bytes } => {
let (text, cursor) = &self.input_state.text_and_cursor();
// To anyone copying this, keep in mind that this doesn't take text
// selection into account. The deletion happens
// *around* the pre-edit, and may remove the whole
// selection or a part of it.
let delete_start = cursor.saturating_sub(before_bytes);
let delete_end = cmp::min(cursor.saturating_add(after_bytes), text.len());
if text.is_char_boundary(delete_start) && text.is_char_boundary(delete_end) {
let new_text = {
let mut t = String::from(&text[..delete_start]);
t.push_str(&text[delete_end..]);
t
};
self.input_state.set_text(new_text);
info!("IME deleted bytes: {before_bytes}, {after_bytes}");
self.print_input_state();
} else {
error!("Buggy IME tried to delete with indices not on char boundary.");
}
},
Ime::Disabled => info!("IME disabled for Window={:?}", window.id()),
}
}
fn toggle_ime(&mut self) {
let enable = !self.input_state.ime_enabled;
if enable {
let enable_request = ImeEnableRequest::new(
ImeCapabilities::new()
.with_hint_and_purpose()
.with_cursor_area()
.with_surrounding_text(),
self.get_ime_update(),
)
.unwrap();
self.window().request_ime_update(ImeRequest::Enable(enable_request)).unwrap();
} else {
self.window().request_ime_update(ImeRequest::Disable).expect("disable can not fail");
};
self.input_state.ime_enabled = enable;
info!("IME enabled now {}", self.input_state.ime_enabled);
}
fn get_ime_update(&self) -> ImeRequestData {
let text = &self.input_state.contents;
let cursor = text.len();
// 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::<u32> { x: 10 * chars_before_caret as u32, y: 0 };
// 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_hint_and_purpose(self.input_state.hint, self.input_state.purpose)
.with_cursor_area(cursor_pos.into(), IME_CURSOR_SIZE.into())
.with_surrounding_text(surrounding_text)
}
fn print_input_state(&self) {
let (text, cursor) = &self.input_state.text_and_cursor();
// Representing a selection with the cursor and anchor as ends is not
// supported yet. Using the same position for anchor to mark no
// selection.
info!("{}", preedit_with_cursor(text, *cursor, *cursor));
}
fn window(&self) -> &dyn Window {
self.window.as_ref().unwrap().as_ref()
}
}
/// Prints text of the text field, highlighting cursor position
fn preedit_with_cursor(text: &str, cursor: usize, anchor: usize) -> String {
preedit_with_cursor_checked(text, cursor, anchor).unwrap_or_else(|e| format!("INVALID: {e}"))
}
fn preedit_with_cursor_checked(text: &str, cursor: usize, anchor: usize) -> Result<String, &str> {
let first = cmp::min(cursor, anchor);
let before = text.get(0..first).ok_or("first segment ends not on char boundary")?;
let second = cmp::max(cursor, anchor);
let mid = if second == first {
None
} else {
Some(text.get(first..second).ok_or("second segment ends not on char boundary")?)
};
let end = text.get(second..).unwrap();
Ok(match (first == cursor, before, mid, end) {
(_, before, None, end) => format!("{before}|{end}"),
(true, before, Some(mid), end) => format!("{before}|{mid}_{end}"),
(false, before, Some(mid), end) => format!("{before}_{mid}|{end}"),
})
}
fn main() -> Result<(), Box<dyn Error>> {
#[cfg(web_platform)]
console_error_panic_hook::set_once();
tracing_init::init();
let event_loop = EventLoop::new()?;
println!(
r#"This showcases the use of an input method engine (IME) by emulating a text edit field.
Use CTRL+i to toggle IME support.
Use CTRL+p to cycle content purpose values.
Use CTRL+h to cycle content hint permutations.
"#
);
let app = App {
window: None,
input_state: TextInputState {
ime_enabled: true,
contents: String::new(),
purpose: ImePurpose::Normal,
// While we don't show text and thus we use ImeHint::HIDDEN
// it may cause the IME to not do layout switch, etc at all.
hint: ImeHint::NONE,
},
modifiers: ModifiersState::default(),
};
// For alternative loop run options see `pump_events` and `run_on_demand` examples.
event_loop.run_app(app)?;
Ok(())
}

View file

@ -84,6 +84,8 @@ changelog entry.
- On Wayland, added implementation for `Window::set_window_icon` - On Wayland, added implementation for `Window::set_window_icon`
- Add `Window::request_ime_update` to atomically apply set of IME changes. - Add `Window::request_ime_update` to atomically apply set of IME changes.
- Add `Ime::DeleteSurrounding` to let the input method delete text. - Add `Ime::DeleteSurrounding` to let the input method delete text.
- Add more `ImePurpose` values.
- Add `ImeHints` to request particular IME behaviour.
### Changed ### Changed