diff --git a/examples b/examples new file mode 120000 index 00000000..17ce88c4 --- /dev/null +++ b/examples @@ -0,0 +1 @@ +winit/examples \ No newline at end of file diff --git a/winit-core/src/window.rs b/winit-core/src/window.rs index d2d1d187..8cd6116b 100644 --- a/winit-core/src/window.rs +++ b/winit-core/src/window.rs @@ -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, + /// 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()); diff --git a/winit-wayland/src/seat/text_input/mod.rs b/winit-wayland/src/seat/text_input/mod.rs index f505fc70..0d1a4075 100644 --- a/winit-wayland/src/seat/text_input/mod.rs +++ b/winit-wayland/src/seat/text_input/mod.rs @@ -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, LogicalSize), @@ -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 { - 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, LogicalSize)> { @@ -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 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 } } } diff --git a/winit/examples b/winit/examples deleted file mode 120000 index a6573af9..00000000 --- a/winit/examples +++ /dev/null @@ -1 +0,0 @@ -../examples \ No newline at end of file diff --git a/examples/application.rs b/winit/examples/application.rs similarity index 88% rename from examples/application.rs rename to winit/examples/application.rs index 5832de16..2abc1b42 100644 --- a/examples/application.rs +++ b/winit/examples/application.rs @@ -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 = PhysicalSize::new(20, 20); fn main() -> Result<(), Box> { #[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) { - // 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), diff --git a/examples/child_window.rs b/winit/examples/child_window.rs similarity index 100% rename from examples/child_window.rs rename to winit/examples/child_window.rs diff --git a/examples/control_flow.rs b/winit/examples/control_flow.rs similarity index 100% rename from examples/control_flow.rs rename to winit/examples/control_flow.rs diff --git a/examples/data/cross.png b/winit/examples/data/cross.png similarity index 100% rename from examples/data/cross.png rename to winit/examples/data/cross.png diff --git a/examples/data/cross2.png b/winit/examples/data/cross2.png similarity index 100% rename from examples/data/cross2.png rename to winit/examples/data/cross2.png diff --git a/examples/data/gradient.png b/winit/examples/data/gradient.png similarity index 100% rename from examples/data/gradient.png rename to winit/examples/data/gradient.png diff --git a/examples/data/icon.png b/winit/examples/data/icon.png similarity index 100% rename from examples/data/icon.png rename to winit/examples/data/icon.png diff --git a/examples/dnd.rs b/winit/examples/dnd.rs similarity index 100% rename from examples/dnd.rs rename to winit/examples/dnd.rs diff --git a/winit/examples/ime.rs b/winit/examples/ime.rs new file mode 100644 index 00000000..28e5c487 --- /dev/null +++ b/winit/examples/ime.rs @@ -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 = PhysicalSize::new(20, 20); + +#[derive(Debug)] +struct App { + window: Option>, + 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:: { 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 { + 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> { + #[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(()) +} diff --git a/examples/pump_events.rs b/winit/examples/pump_events.rs similarity index 100% rename from examples/pump_events.rs rename to winit/examples/pump_events.rs diff --git a/examples/run_on_demand.rs b/winit/examples/run_on_demand.rs similarity index 100% rename from examples/run_on_demand.rs rename to winit/examples/run_on_demand.rs diff --git a/examples/util/fill.rs b/winit/examples/util/fill.rs similarity index 100% rename from examples/util/fill.rs rename to winit/examples/util/fill.rs diff --git a/examples/util/tracing.rs b/winit/examples/util/tracing.rs similarity index 100% rename from examples/util/tracing.rs rename to winit/examples/util/tracing.rs diff --git a/examples/window.rs b/winit/examples/window.rs similarity index 100% rename from examples/window.rs rename to winit/examples/window.rs diff --git a/examples/x11_embed.rs b/winit/examples/x11_embed.rs similarity index 100% rename from examples/x11_embed.rs rename to winit/examples/x11_embed.rs diff --git a/winit/src/changelog/unreleased.md b/winit/src/changelog/unreleased.md index 89ca902b..33aa4194 100644 --- a/winit/src/changelog/unreleased.md +++ b/winit/src/changelog/unreleased.md @@ -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