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 position = LogicalPosition::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 {
purpose: Some(ImePurpose::Normal),
hint_and_purpose: Some((ImeHint::NONE, ImePurpose::Normal)),
// WARNING: there's nothing sensible to use here by default.
cursor_area: Some((position.into(), size.into())),
..ImeRequestData::default()
@ -1160,9 +1160,9 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug {
/// - **iOS / Android / Web / Windows / X11 / macOS / Orbital:** Unsupported.
#[deprecated = "use Window::request_ime_update instead"]
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 {
purpose: Some(purpose),
hint_and_purpose: Some((ImeHint::NONE, purpose)),
..ImeRequestData::default()
}));
}
@ -1186,14 +1186,14 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug {
///
/// ```no_run
/// # 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) {
/// // Clear previous state by switching off IME
/// 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()
/// .with_purpose(ImePurpose::Normal)
/// .with_hint_and_purpose(ImeHint::NONE, ImePurpose::Normal)
/// .with_cursor_area(cursor_pos, cursor_size);
/// 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");
@ -1598,7 +1598,10 @@ pub enum WindowLevel {
/// 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,
/// 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.
///
/// ## Platform-specific
@ -1608,14 +1611,31 @@ pub enum WindowLevel {
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum ImePurpose {
/// No special hints for the IME (default).
/// No special purpose for the IME (default).
Normal,
/// The IME is used for password input.
/// The IME will treat the contents as sensitive.
Password,
/// The IME is used to input into a terminal.
///
/// For example, that could alter OSK on Wayland to show extra buttons.
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 {
@ -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)]
pub enum ImeSurroundingTextError {
/// Text exceeds 4000 bytes
@ -1759,7 +1821,7 @@ impl ImeEnableRequest {
return None;
}
if capabilities.purpose() ^ request_data.purpose.is_some() {
if capabilities.hint_and_purpose() ^ request_data.hint_and_purpose.is_some() {
return None;
}
@ -1804,23 +1866,23 @@ impl ImeCapabilities {
Self::default()
}
/// Marks `purpose` as supported.
/// Marks `hint and purpose` as supported.
///
/// For more details see [`ImeRequestData::with_purpose`].
pub const fn with_purpose(self) -> Self {
Self(self.0.union(ImeCapabilitiesFlags::PURPOSE))
/// For more details see [`ImeRequestData::with_hint_and_purpose`].
pub const fn with_hint_and_purpose(self) -> Self {
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`].
pub const fn without_purpose(self) -> Self {
Self(self.0.difference(ImeCapabilitiesFlags::PURPOSE))
/// For more details see [`ImeRequestData::with_hint_and_purpose`].
pub const fn without_hint_and_purpose(self) -> Self {
Self(self.0.difference(ImeCapabilitiesFlags::HINT_AND_PURPOSE))
}
/// Returns `true` if `purpose` is supported.
pub const fn purpose(&self) -> bool {
self.0.contains(ImeCapabilitiesFlags::PURPOSE)
/// Returns `true` if `hint and purpose` is supported.
pub const fn hint_and_purpose(&self) -> bool {
self.0.contains(ImeCapabilitiesFlags::HINT_AND_PURPOSE)
}
/// Marks `cursor_area` as supported.
@ -1865,8 +1927,8 @@ impl ImeCapabilities {
bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub(crate) struct ImeCapabilitiesFlags : u8 {
/// Client supports setting IME purpose.
const PURPOSE = 1 << 0;
/// Client supports setting IME hint and purpose.
const HINT_AND_PURPOSE = 1 << 0;
/// Client supports reporting cursor area for IME popup to
/// appear.
const CURSOR_AREA = 1 << 1;
@ -1884,10 +1946,10 @@ bitflags! {
#[derive(Debug, PartialEq, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ImeRequestData {
/// Text input purpose.
/// Text input hint and purpose.
///
/// To support updating it, enable [`ImeCapabilities::PURPOSE`].
pub purpose: Option<ImePurpose>,
/// To support updating it, enable [`ImeCapabilities::HINT_AND_PURPOSE`].
pub hint_and_purpose: Option<(ImeHint, ImePurpose)>,
/// The IME cursor area which should not be covered by the input method popup.
///
/// To support updating it, enable [`ImeCapabilities::CURSOR_AREA`].
@ -1899,9 +1961,9 @@ pub struct ImeRequestData {
}
impl ImeRequestData {
/// Sets the purpose hint of the current text input.
pub fn with_purpose(self, purpose: ImePurpose) -> Self {
Self { purpose: Some(purpose), ..self }
/// Sets the hint and purpose of the current text input content.
pub fn with_hint_and_purpose(self, hint: ImeHint, purpose: ImePurpose) -> Self {
Self { hint_and_purpose: Some((hint, purpose)), ..self }
}
/// Sets the IME cursor editing area.
@ -2027,7 +2089,7 @@ mod tests {
ImeCapabilities, ImeEnableRequest, ImeRequestData, ImeSurroundingText,
ImeSurroundingTextError,
};
use crate::window::ImePurpose;
use crate::window::{ImeHint, ImePurpose};
#[test]
fn ime_initial_request_caps_match() {
@ -2040,21 +2102,21 @@ mod tests {
)
.is_none());
assert!(ImeEnableRequest::new(
ImeCapabilities::new().with_purpose(),
ImeCapabilities::new().with_hint_and_purpose(),
ImeRequestData::default()
)
.is_none());
assert!(ImeEnableRequest::new(
ImeCapabilities::new().with_cursor_area(),
ImeRequestData::default().with_purpose(ImePurpose::Normal)
ImeRequestData::default().with_hint_and_purpose(ImeHint::NONE, ImePurpose::Normal)
)
.is_none());
assert!(ImeEnableRequest::new(
ImeCapabilities::new(),
ImeRequestData::default()
.with_purpose(ImePurpose::Normal)
.with_hint_and_purpose(ImeHint::NONE, ImePurpose::Normal)
.with_cursor_area(position, size)
)
.is_none());
@ -2062,7 +2124,7 @@ mod tests {
assert!(ImeEnableRequest::new(
ImeCapabilities::new().with_cursor_area(),
ImeRequestData::default()
.with_purpose(ImePurpose::Normal)
.with_hint_and_purpose(ImeHint::NONE, ImePurpose::Normal)
.with_cursor_area(position, size)
)
.is_none());
@ -2074,9 +2136,9 @@ mod tests {
.is_some());
assert!(ImeEnableRequest::new(
ImeCapabilities::new().with_purpose().with_cursor_area(),
ImeCapabilities::new().with_hint_and_purpose().with_cursor_area(),
ImeRequestData::default()
.with_purpose(ImePurpose::Normal)
.with_hint_and_purpose(ImeHint::NONE, ImePurpose::Normal)
.with_cursor_area(position, size)
)
.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 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;
@ -278,9 +280,7 @@ struct DeleteSurroundingText {
#[derive(Debug, PartialEq, Clone)]
pub struct ClientState {
capabilities: ImeCapabilities,
content_type: ContentType,
/// The IME cursor area which should not be covered by the input method popup.
cursor_area: (LogicalPosition<u32>, LogicalSize<u32>),
@ -302,8 +302,10 @@ impl ClientState {
surrounding_text: ImeSurroundingText::new(String::new(), 0, 0).unwrap(),
};
let unsupported_flags =
capabilities.without_purpose().without_cursor_area().without_surrounding_text();
let unsupported_flags = capabilities
.without_hint_and_purpose()
.without_cursor_area()
.without_surrounding_text();
if unsupported_flags != ImeCapabilities::new() {
warn!(
@ -322,12 +324,10 @@ impl ClientState {
/// Updates the fields of the state which are present in update_fields.
pub fn update(&mut self, request_data: ImeRequestData, scale_factor: f64) {
if let Some(purpose) = request_data.purpose {
if self.capabilities.purpose() {
self.content_type = purpose.into();
} else {
warn!("discarding ImePurpose update without capability enabled.");
}
if let Some((hint, purpose)) =
request_data.hint_and_purpose.filter(|_| self.capabilities.hint_and_purpose())
{
self.content_type = (hint, purpose).into();
}
if let Some((position, size)) = request_data.cursor_area {
@ -350,7 +350,7 @@ impl ClientState {
}
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>)> {
@ -365,20 +365,69 @@ impl ClientState {
/// Arguments to content_type
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContentType {
/// Text input purpose
purpose: ContentPurpose,
/// Text input hint.
hint: ContentHint,
/// Text input purpose.
purpose: ContentPurpose,
}
impl From<ImePurpose> for ContentType {
fn from(purpose: ImePurpose) -> Self {
let (hint, purpose) = match purpose {
ImePurpose::Password => (ContentHint::SensitiveData, ContentPurpose::Password),
ImePurpose::Terminal => (ContentHint::None, ContentPurpose::Terminal),
_ => return Default::default(),
/// The two options influence each other, so they must be converted together.
impl From<(ImeHint, ImePurpose)> for ContentType {
fn from((hint, purpose): (ImeHint, ImePurpose)) -> Self {
let purpose = match purpose {
ImePurpose::Password => ContentPurpose::Password,
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.
//!
//! Note that a real application accepting text input **should** support
//! the IME interface. See the `ime` example.
use std::collections::HashMap;
use std::error::Error;
@ -9,22 +12,21 @@ use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
#[cfg(all(not(android_platform), not(web_platform)))]
use std::time::Instant;
use std::{cmp, fmt, mem};
use std::{fmt, mem};
use ::tracing::{error, info};
use cursor_icon::CursorIcon;
use dpi::LogicalPosition;
#[cfg(not(android_platform))]
use rwh_06::{DisplayHandle, HasDisplayHandle};
#[cfg(not(android_platform))]
use softbuffer::{Context, Surface};
use tracing::{error, info};
#[cfg(all(web_platform, not(android_platform)))]
use web_time::Instant;
use winit::application::ApplicationHandler;
use winit::cursor::{Cursor, CustomCursor, CustomCursorSource};
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
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::icon::{Icon, RgbaIcon};
use winit::keyboard::{Key, ModifiersState};
@ -39,28 +41,23 @@ use winit::platform::wayland::{ActiveEventLoopExtWayland, WindowAttributesWaylan
use winit::platform::web::{ActiveEventLoopExtWeb, WindowAttributesWeb};
#[cfg(x11_platform)]
use winit::platform::x11::{ActiveEventLoopExtX11, WindowAttributesX11};
use winit::window::{
CursorGrabMode, ImeCapabilities, ImeEnableRequest, ImePurpose, ImeRequestData,
ImeSurroundingText, ResizeDirection, Theme, Window, WindowAttributes, WindowId,
};
use winit::window::{CursorGrabMode, ResizeDirection, Theme, Window, WindowAttributes, WindowId};
use winit_core::application::macos::ApplicationHandlerExtMacOS;
use winit_core::window::ImeRequest;
#[path = "util/tracing.rs"]
mod tracing;
mod tracing_init;
#[path = "util/fill.rs"]
mod fill;
/// The amount of points to around the window for drag resize direction calculations.
const BORDER_SIZE: f64 = 20.;
const IME_CURSOR_SIZE: PhysicalSize<u32> = PhysicalSize::new(20, 20);
fn main() -> Result<(), Box<dyn Error>> {
#[cfg(web_platform)]
console_error_panic_hook::set_once();
tracing::init();
tracing_init::init();
let event_loop = EventLoop::new()?;
let (sender, receiver) = mpsc::channel();
@ -245,7 +242,6 @@ impl Application {
window.window.set_simple_fullscreen(!window.window.simple_fullscreen());
},
Action::ToggleMaximize => window.toggle_maximize(),
Action::ToggleImeInput => window.toggle_ime(),
Action::Minimize => window.minimize(),
Action::NextCursor => window.next_cursor(),
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, .. } => {
window.zoom += delta;
let zoom = window.zoom;
@ -581,6 +542,7 @@ impl ApplicationHandler for Application {
| WindowEvent::DragMoved { .. }
| WindowEvent::DragDropped { .. }
| WindowEvent::Destroyed
| WindowEvent::Ime(_)
| WindowEvent::Moved(_) => (),
}
}
@ -635,10 +597,6 @@ 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`.
@ -693,21 +651,6 @@ impl WindowState {
let named_idx = 0;
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 mut state = Self {
#[cfg(macos_platform)]
@ -723,8 +666,6 @@ impl WindowState {
continuous_redraw: false,
#[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(),
@ -738,65 +679,12 @@ 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 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) {
self.window.set_minimized(true);
}
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);
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) {
@ -1113,7 +1001,6 @@ enum Action {
ToggleCursorVisibility,
CreateNewWindow,
ToggleResizeIncrements,
ToggleImeInput,
ToggleDecorations,
ToggleResizable,
ToggleFullscreen,
@ -1150,7 +1037,6 @@ impl Action {
Action::CloseWindow => "Close window",
Action::ToggleCursorVisibility => "Hide cursor",
Action::CreateNewWindow => "Create new window",
Action::ToggleImeInput => "Toggle IME input",
Action::ToggleDecorations => "Toggle decorations",
Action::ToggleResizable => "Toggle window resizable state",
Action::ToggleFullscreen => "Toggle fullscreen",
@ -1370,7 +1256,6 @@ const KEY_BINDINGS: &[Binding<&'static str>] = &[
#[cfg(macos_platform)]
Binding::new("F", ModifiersState::ALT, Action::ToggleSimpleFullscreen),
Binding::new("D", ModifiersState::CONTROL, Action::ToggleDecorations),
Binding::new("I", ModifiersState::CONTROL, Action::ToggleImeInput),
Binding::new("L", ModifiersState::CONTROL, Action::CycleCursorGrab),
Binding::new("P", ModifiersState::CONTROL, Action::ToggleResizeIncrements),
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`
- Add `Window::request_ime_update` to atomically apply set of IME changes.
- Add `Ime::DeleteSurrounding` to let the input method delete text.
- Add more `ImePurpose` values.
- Add `ImeHints` to request particular IME behaviour.
### Changed