From 08907148ec9718801b9939f7d23a02ae892900ba Mon Sep 17 00:00:00 2001 From: DorotaC <43449960+dcz-self@users.noreply.github.com> Date: Sat, 28 Jun 2025 06:14:20 +0200 Subject: [PATCH] winit-core/window: add Window::request_ime_update Allow updating IME state atomically to make it easier for platforms where it's atomic by its nature, like Wayland. The old API is marked as deprecated and is routed to the new atomic API. Co-authored-by: dcz --- examples/application.rs | 53 +++- winit-android/src/event_loop.rs | 43 ++- winit-appkit/src/view.rs | 20 +- winit-appkit/src/window.rs | 16 +- winit-appkit/src/window_delegate.rs | 56 ++-- winit-core/src/window.rs | 350 ++++++++++++++++++++++- winit-orbital/src/window.rs | 17 +- winit-uikit/src/window.rs | 72 +++-- winit-wayland/src/seat/mod.rs | 4 +- winit-wayland/src/seat/text_input/mod.rs | 143 ++++++++- winit-wayland/src/window/mod.rs | 28 +- winit-wayland/src/window/state.rs | 116 ++++---- winit-web/src/window.rs | 16 +- winit-win32/src/event_loop.rs | 6 +- winit-win32/src/ime.rs | 2 +- winit-win32/src/window.rs | 69 ++++- winit-win32/src/window_state.rs | 6 +- winit-x11/src/window.rs | 69 ++++- winit/src/changelog/unreleased.md | 2 + 19 files changed, 866 insertions(+), 222 deletions(-) diff --git a/examples/application.rs b/examples/application.rs index b319c4bc..d2abca07 100644 --- a/examples/application.rs +++ b/examples/application.rs @@ -13,6 +13,7 @@ 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))] @@ -38,8 +39,12 @@ 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, ResizeDirection, Theme, Window, WindowAttributes, WindowId}; +use winit::window::{ + CursorGrabMode, ImeCapabilities, ImeEnableRequest, ImePurpose, ImeRequestData, ResizeDirection, + Theme, Window, WindowAttributes, WindowId, +}; use winit_core::application::macos::ApplicationHandlerExtMacOS; +use winit_core::window::ImeRequest; #[path = "util/tracing.rs"] mod tracing; @@ -49,6 +54,7 @@ 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)] @@ -604,8 +610,7 @@ impl ApplicationHandlerExtMacOS for Application { /// State of the window. struct WindowState { - /// IME input. - ime: bool, + ime_enabled: bool, /// Render surface. /// /// NOTE: This surface must be dropped before the `Window`. @@ -661,8 +666,14 @@ impl WindowState { window.set_cursor(CURSORS[named_idx].into()); // Allow IME out of the box. - let ime = true; - window.set_ime_allowed(ime); + let request_data = ImeRequestData::default() + .with_purpose(ImePurpose::Normal) + .with_cursor_area(LogicalPosition { x: 0, y: 0 }.into(), IME_CURSOR_SIZE.into()); + let enable_request = ImeEnableRequest::new(ImeCapabilities::all(), 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 { @@ -679,7 +690,7 @@ impl WindowState { continuous_redraw: false, #[cfg(not(android_platform))] start_time: Instant::now(), - ime, + ime_enabled: true, cursor_position: Default::default(), cursor_hidden: Default::default(), modifiers: Default::default(), @@ -694,11 +705,21 @@ impl WindowState { } pub fn toggle_ime(&mut self) { - self.ime = !self.ime; - self.window.set_ime_allowed(self.ime); - if let Some(position) = self.ime.then_some(self.cursor_position).flatten() { - self.window.set_ime_cursor_area(position.into(), PhysicalSize::new(20, 20).into()); - } + if self.ime_enabled { + self.window.request_ime_update(ImeRequest::Disable).expect("disable can not fail"); + } else { + let cursor_pos = self + .cursor_position + .map(Into::into) + .unwrap_or(LogicalPosition { x: 0, y: 0 }.into()); + let request_data = + ImeRequestData::default().with_cursor_area(cursor_pos, IME_CURSOR_SIZE.into()); + let enable_request = + ImeEnableRequest::new(ImeCapabilities::all(), request_data).unwrap(); + self.window.request_ime_update(ImeRequest::Enable(enable_request)).unwrap(); + }; + + self.ime_enabled = !self.ime_enabled; } pub fn minimize(&mut self) { @@ -706,9 +727,15 @@ impl WindowState { } 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 { - self.window.set_ime_cursor_area(position.into(), PhysicalSize::new(20, 20).into()); + 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"); } } diff --git a/winit-android/src/event_loop.rs b/winit-android/src/event_loop.rs index 56d18283..1396574c 100644 --- a/winit-android/src/event_loop.rs +++ b/winit-android/src/event_loop.rs @@ -23,8 +23,9 @@ use winit_core::event_loop::{ }; use winit_core::monitor::{Fullscreen, MonitorHandle as CoreMonitorHandle}; use winit_core::window::{ - self, CursorGrabMode, ImePurpose, ResizeDirection, Theme, Window as CoreWindow, - WindowAttributes, WindowButtons, WindowId, WindowLevel, + self, CursorGrabMode, ImeCapabilities, ImePurpose, ImeRequest, ImeRequestError, + ResizeDirection, Theme, Window as CoreWindow, WindowAttributes, WindowButtons, WindowId, + WindowLevel, }; use crate::keycodes; @@ -758,6 +759,7 @@ pub struct PlatformSpecificWindowAttributes; #[derive(Debug)] pub struct Window { app: AndroidApp, + ime_capabilities: Mutex>, redraw_requester: RedrawRequester, } @@ -768,7 +770,11 @@ impl Window { ) -> Result { // FIXME this ignores requested window attributes - Ok(Self { app: el.app.clone(), redraw_requester: el.redraw_requester.clone() }) + Ok(Self { + app: el.app.clone(), + ime_capabilities: Default::default(), + redraw_requester: el.redraw_requester.clone(), + }) } pub(crate) fn config(&self) -> ConfigurationRef { @@ -936,12 +942,33 @@ impl CoreWindow for Window { fn set_ime_cursor_area(&self, _position: Position, _size: Size) {} - fn set_ime_allowed(&self, allowed: bool) { - if allowed { - self.app.show_soft_input(true); - } else { - self.app.hide_soft_input(true); + fn request_ime_update(&self, request: ImeRequest) -> Result<(), ImeRequestError> { + let mut current_caps = self.ime_capabilities.lock().unwrap(); + match request { + ImeRequest::Enable(enable) => { + let (capabilities, _) = enable.into_raw(); + if current_caps.is_some() { + return Err(ImeRequestError::AlreadyEnabled); + } + *current_caps = Some(capabilities); + self.app.show_soft_input(true); + }, + ImeRequest::Update(_) => { + if current_caps.is_none() { + return Err(ImeRequestError::NotEnabled); + } + }, + ImeRequest::Disable => { + *current_caps = None; + self.app.hide_soft_input(true); + }, } + + Ok(()) + } + + fn ime_capabilities(&self) -> Option { + *self.ime_capabilities.lock().unwrap() } fn set_ime_purpose(&self, _purpose: ImePurpose) {} diff --git a/winit-appkit/src/view.rs b/winit-appkit/src/view.rs index 8435ebf7..8f8976e9 100644 --- a/winit-appkit/src/view.rs +++ b/winit-appkit/src/view.rs @@ -21,6 +21,7 @@ use winit_core::event::{ PointerKind, PointerSource, TouchPhase, WindowEvent, }; use winit_core::keyboard::{Key, KeyCode, KeyLocation, ModifiersState, NamedKey}; +use winit_core::window::ImeCapabilities; use super::app_state::AppState; use super::cursor::{default_cursor, invisible_cursor}; @@ -124,7 +125,7 @@ pub struct ViewState { /// True iff the application wants IME events. /// /// Can be set using `set_ime_allowed` - ime_allowed: Cell, + ime_capabilities: Cell>, /// True if the current key event should be forwarded /// to the application, even during IME @@ -456,7 +457,7 @@ define_class!( // we must send the `KeyboardInput` event during IME if it triggered // `doCommandBySelector`. (doCommandBySelector means that the keyboard input // is not handled by IME and should be handled by the application) - if self.ivars().ime_allowed.get() { + if self.ivars().ime_capabilities.get().is_some() { let events_for_nsview = NSArray::from_slice(&[&*event]); unsafe { self.interpretKeyEvents(&events_for_nsview) }; @@ -797,7 +798,7 @@ impl WinitView { tracking_rect: Default::default(), ime_state: Default::default(), input_source: Default::default(), - ime_allowed: Default::default(), + ime_capabilities: Default::default(), forward_key_to_app: Default::default(), marked_text: Default::default(), accepts_first_mouse, @@ -859,12 +860,13 @@ impl WinitView { } } - pub(super) fn set_ime_allowed(&self, ime_allowed: bool) { - if self.ivars().ime_allowed.get() == ime_allowed { + pub(super) fn set_ime_allowed(&self, capabilities: Option) { + if self.ivars().ime_capabilities.get().is_some() { return; } - self.ivars().ime_allowed.set(ime_allowed); - if self.ivars().ime_allowed.get() { + self.ivars().ime_capabilities.set(capabilities); + + if capabilities.is_some() { return; } @@ -877,6 +879,10 @@ impl WinitView { } } + pub(super) fn ime_capabilities(&self) -> Option { + self.ivars().ime_capabilities.get() + } + pub(super) fn set_ime_cursor_area(&self, position: NSPoint, size: NSSize) { self.ivars().ime_position.set(position); self.ivars().ime_size.set(size); diff --git a/winit-appkit/src/window.rs b/winit-appkit/src/window.rs index 05d2199f..515ac55f 100644 --- a/winit-appkit/src/window.rs +++ b/winit-appkit/src/window.rs @@ -13,8 +13,8 @@ use winit_core::error::RequestError; use winit_core::icon::Icon; use winit_core::monitor::{Fullscreen, MonitorHandle as CoreMonitorHandle}; use winit_core::window::{ - ImePurpose, Theme, UserAttentionType, Window as CoreWindow, WindowAttributes, WindowButtons, - WindowId, WindowLevel, + ImeCapabilities, ImeRequest, ImeRequestError, Theme, UserAttentionType, Window as CoreWindow, + WindowAttributes, WindowButtons, WindowId, WindowLevel, }; use super::event_loop::ActiveEventLoop; @@ -233,16 +233,12 @@ impl CoreWindow for Window { self.maybe_wait_on_main(|delegate| delegate.set_window_icon(window_icon)); } - fn set_ime_cursor_area(&self, position: Position, size: Size) { - self.maybe_wait_on_main(|delegate| delegate.set_ime_cursor_area(position, size)); + fn request_ime_update(&self, request: ImeRequest) -> Result<(), ImeRequestError> { + self.maybe_wait_on_main(|delegate| delegate.request_ime_update(request)) } - fn set_ime_allowed(&self, allowed: bool) { - self.maybe_wait_on_main(|delegate| delegate.set_ime_allowed(allowed)); - } - - fn set_ime_purpose(&self, purpose: ImePurpose) { - self.maybe_wait_on_main(|delegate| delegate.set_ime_purpose(purpose)); + fn ime_capabilities(&self) -> Option { + self.maybe_wait_on_main(|delegate| delegate.ime_capabilities()) } fn focus_window(&self) { diff --git a/winit-appkit/src/window_delegate.rs b/winit-appkit/src/window_delegate.rs index c8eb1d93..f8f06016 100644 --- a/winit-appkit/src/window_delegate.rs +++ b/winit-appkit/src/window_delegate.rs @@ -46,8 +46,8 @@ use winit_core::event::{SurfaceSizeWriter, WindowEvent}; use winit_core::icon::Icon; use winit_core::monitor::{Fullscreen, MonitorHandle as CoreMonitorHandle, MonitorHandleProvider}; use winit_core::window::{ - CursorGrabMode, ImePurpose, ResizeDirection, Theme, UserAttentionType, WindowAttributes, - WindowButtons, WindowId, WindowLevel, + CursorGrabMode, ImeCapabilities, ImeRequest, ImeRequestError, ResizeDirection, Theme, + UserAttentionType, WindowAttributes, WindowButtons, WindowId, WindowLevel, }; use super::app_state::AppState; @@ -1674,26 +1674,50 @@ impl WindowDelegate { // https://developer.apple.com/library/content/documentation/Cocoa/Conceptual/WinPanel/Tasks/SettingWindowTitle.html } - #[inline] - pub fn set_ime_cursor_area(&self, spot: Position, size: Size) { - let scale_factor = self.scale_factor(); - let logical_spot = spot.to_logical(scale_factor); - let logical_spot = NSPoint::new(logical_spot.x, logical_spot.y); + pub fn request_ime_update(&self, request: ImeRequest) -> Result<(), ImeRequestError> { + let current_caps = self.view().ime_capabilities(); + let request_data = match request { + ImeRequest::Enable(enable) => { + let (capabilities, request_data) = enable.into_raw(); + if current_caps.is_some() { + return Err(ImeRequestError::AlreadyEnabled); + } + self.view().set_ime_allowed(Some(capabilities)); + request_data + }, + ImeRequest::Update(request_data) => { + if current_caps.is_none() { + return Err(ImeRequestError::NotEnabled); + } + request_data + }, + ImeRequest::Disable => { + self.view().set_ime_allowed(None); + return Ok(()); + }, + }; - let size = size.to_logical(scale_factor); - let size = NSSize::new(size.width, size.height); + if let Some((spot, size)) = request_data.cursor_area { + if self.view().ime_capabilities().unwrap().contains(ImeCapabilities::CURSOR_AREA) { + let scale_factor = self.scale_factor(); + let logical_spot = spot.to_logical(scale_factor); + let logical_spot = NSPoint::new(logical_spot.x, logical_spot.y); - self.view().set_ime_cursor_area(logical_spot, size); + let size = size.to_logical(scale_factor); + let size = NSSize::new(size.width, size.height); + self.view().set_ime_cursor_area(logical_spot, size); + } else { + warn!("discarding IME cursor area update without capability enabled."); + } + } + + Ok(()) } - #[inline] - pub fn set_ime_allowed(&self, allowed: bool) { - self.view().set_ime_allowed(allowed); + pub fn ime_capabilities(&self) -> Option { + self.view().ime_capabilities() } - #[inline] - pub fn set_ime_purpose(&self, _purpose: ImePurpose) {} - #[inline] pub fn focus_window(&self) { let mtm = MainThreadMarker::from(self); diff --git a/winit-core/src/window.rs b/winit-core/src/window.rs index 3b021cb2..8926e948 100644 --- a/winit-core/src/window.rs +++ b/winit-core/src/window.rs @@ -2,7 +2,9 @@ use std::fmt; use cursor_icon::CursorIcon; -use dpi::{PhysicalInsets, PhysicalPosition, PhysicalSize, Position, Size}; +use dpi::{ + LogicalPosition, LogicalSize, PhysicalInsets, PhysicalPosition, PhysicalSize, Position, Size, +}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -767,7 +769,7 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug { /// /// - **Android / Orbital / Wayland / Windows / X11:** Unimplemented, returns `(0, 0, 0, 0)`. /// - /// ## Examples + /// ## Example /// /// Convert safe area insets to a size and a position. /// @@ -1096,13 +1098,24 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug { /// /// [chinese]: https://support.apple.com/guide/chinese-input-method/use-the-candidate-window-cim12992/104/mac/12.0 /// [japanese]: https://support.apple.com/guide/japanese-input-method/use-the-candidate-window-jpim10262/6.3/mac/12.0 - fn set_ime_cursor_area(&self, position: Position, size: Size); + #[deprecated = "use Window::request_ime_update instead"] + fn set_ime_cursor_area(&self, position: Position, size: Size) { + if self + .ime_capabilities() + .map(|caps| caps.contains(ImeCapabilities::CURSOR_AREA)) + .unwrap_or(false) + { + let _ = self.request_ime_update(ImeRequest::Update( + ImeRequestData::default().with_cursor_area(position, size), + )); + } + } /// Sets whether the window should get IME events /// /// When IME is allowed, the window will receive [`Ime`] events, and during the /// preedit phase the window will NOT get [`KeyboardInput`] events. The window - /// should allow IME while it is expecting text input. + /// should allow IME when it is expecting text input. /// /// When IME is not allowed, the window won't receive [`Ime`] events, and will /// receive [`KeyboardInput`] events for every keypress instead. Not allowing @@ -1120,14 +1133,101 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug { /// /// [`Ime`]: crate::event::WindowEvent::Ime /// [`KeyboardInput`]: crate::event::WindowEvent::KeyboardInput - fn set_ime_allowed(&self, allowed: bool); + #[deprecated = "use Window::request_ime_update instead"] + fn set_ime_allowed(&self, allowed: bool) { + let action = if allowed { + let position = LogicalPosition::new(0, 0); + let size = LogicalSize::new(0, 0); + let ime_caps = ImeCapabilities::CURSOR_AREA | ImeCapabilities::PURPOSE; + let request_data = ImeRequestData { + purpose: Some(ImePurpose::Normal), + // WARNING: there's nothing sensible to use here by default. + cursor_area: Some((position.into(), size.into())), + ..ImeRequestData::default() + }; + + // Enable all capabilities to reflect the old behavior. + ImeRequest::Enable(ImeEnableRequest::new(ime_caps, request_data).unwrap()) + } else { + ImeRequest::Disable + }; + + let _ = self.request_ime_update(action); + } /// Sets the IME purpose for the window using [`ImePurpose`]. /// /// ## Platform-specific /// /// - **iOS / Android / Web / Windows / X11 / macOS / Orbital:** Unsupported. - fn set_ime_purpose(&self, purpose: ImePurpose); + #[deprecated = "use Window::request_ime_update instead"] + fn set_ime_purpose(&self, purpose: ImePurpose) { + if self + .ime_capabilities() + .map(|caps| caps.contains(ImeCapabilities::PURPOSE)) + .unwrap_or(false) + { + let _ = self.request_ime_update(ImeRequest::Update(ImeRequestData { + purpose: Some(purpose), + ..ImeRequestData::default() + })); + } + } + + /// Atomically apply request to IME. + /// + /// For details consult [`ImeRequest`] and [`ImeCapabilities`]. + /// + /// Input methods allows the user to compose text without using a keyboard. Requesting one may + /// be beneficial for touch screen environments or ones where, for example, East Asian scripts + /// may be entered. + /// + /// If the focus within the application changes from one logical text input area to another, the + /// application should inform the IME of the switch by disabling the IME and enabling it again + /// in the other area. + /// + /// IME is **not** enabled by default. + /// + /// ## Example + /// + /// ```no_run + /// # use dpi::{Position, Size}; + /// # use winit_core::window::{Window, 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::CURSOR_AREA | ImeCapabilities::PURPOSE; + /// let request_data = ImeRequestData::default() + /// .with_purpose(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"); + /// + /// // Update the current state + /// window + /// .request_ime_update(ImeRequest::Update(request_data.clone())) + /// .expect("will fail if it's not enabled or ime is not supported"); + /// + /// // Update the current state + /// window + /// .request_ime_update(ImeRequest::Update( + /// request_data.with_cursor_area(cursor_pos, cursor_size), + /// )) + /// .expect("Can fail - we didn't submit a cursor position initially"); + /// + /// // Switch off IME + /// window.request_ime_update(ImeRequest::Disable).expect("Disable cannot fail"); + /// # } + /// ``` + fn request_ime_update(&self, request: ImeRequest) -> Result<(), ImeRequestError>; + + /// Return enabled by the client [`ImeCapabilities`] for this window. + /// + /// When the IME is not yet enabled it'll return `None`. + /// + /// By default IME is disabled, thus will return `None`. + fn ime_capabilities(&self) -> Option; /// Brings the window to the front and sets input focus. Has no effect if the window is /// already in focus, minimized, or not visible. @@ -1235,7 +1335,7 @@ pub trait Window: AsAny + Send + Sync + fmt::Debug { /// Set grabbing [mode][CursorGrabMode] on the cursor preventing it from leaving the window. /// - /// # Example + /// ## Example /// /// First try confining the cursor, and if that fails, try locking it instead. /// @@ -1530,6 +1630,185 @@ impl Default for ImePurpose { } } +/// Request to send to IME. +#[derive(Debug, PartialEq, Clone)] +pub enum ImeRequest { + /// Enable the IME with the [`ImeCapabilities`] and [`ImeRequestData`] as initial state. When + /// the [`ImeRequestData`] is **not** matching capabilities fully, the default values will be + /// used instead. + /// + /// **Requesting to update data matching not enabled capabilities will result in update + /// being ignored.** The winit backend in such cases is recommended to log a warning. This + /// appiles to both [`ImeRequest::Enable`] and [`ImeRequest::Update`]. For details on + /// capabilities refer to [`ImeCapabilities`]. + /// + /// To update the [`ImeCapabilities`], the IME must be disabled and then re-enabled. + Enable(ImeEnableRequest), + /// Update the state of already enabled IME. Issuing this request before [`ImeRequest::Enable`] + /// will result in error. + Update(ImeRequestData), + /// Disable the IME. + /// + /// **The disable request can not fail**. + Disable, +} + +/// Initial IME request. +#[derive(Debug, Clone, PartialEq)] +pub struct ImeEnableRequest { + capabilities: ImeCapabilities, + request_data: ImeRequestData, +} + +impl ImeEnableRequest { + /// Create request for the [`ImeRequest::Enable`] + /// + /// This will return [`None`] if some capability was requested but its initial value was not + /// set by the user or value was set by the user, but capability not requested. + pub const fn new(capabilities: ImeCapabilities, request_data: ImeRequestData) -> Option { + if capabilities.contains(ImeCapabilities::CURSOR_AREA) ^ request_data.cursor_area.is_some() + { + return None; + } + + if capabilities.contains(ImeCapabilities::PURPOSE) ^ request_data.purpose.is_some() { + return None; + } + + Some(Self { capabilities, request_data }) + } + + /// [`ImeCapabilities`] to enable. + pub const fn capabilities(&self) -> &ImeCapabilities { + &self.capabilities + } + + /// Request data attached to request. + pub const fn request_data(&self) -> &ImeRequestData { + &self.request_data + } + + /// Destruct [`ImeEnableRequest`] into its raw parts. + pub const fn into_raw(self) -> (ImeCapabilities, ImeRequestData) { + (self.capabilities, self.request_data) + } +} + +bitflags::bitflags! { + /// IME capabilities supported by client. + /// + /// For example, if the client doesn't support [`ImeCapabilities::CURSOR_AREA`] not enabling + /// it will make IME hide the popup window instead of placing it arbitrary over the + /// client's window surface. + /// + /// When the capability is not enabled or not supported by the IME, trying to update its' + /// corresponding data with [`ImeRequest`] will be ignored. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct ImeCapabilities: u32 { + /// Client supports setting IME purpose. + const PURPOSE = 0b1; + /// Client supports reporting cursor area for IME popup to + /// appear. + const CURSOR_AREA = 0b10; + } +} + +/// The [`ImeRequest`] data to communicate to system's IME. +/// +/// This applies multiple IME state properties at once. +/// Fields set to `None` are not updated and the previously sent +/// value is reused. +#[non_exhaustive] +#[derive(Debug, PartialEq, Clone, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ImeRequestData { + /// Text input purpose. + /// + /// To support updating it, enable [`ImeCapabilities::PURPOSE`]. + pub purpose: Option, + /// The IME cursor area which should not be covered by the input method popup. + /// + /// To support updating it, enable [`ImeCapabilities::CURSOR_AREA`]. + pub cursor_area: Option<(Position, Size)>, +} + +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 IME cursor editing area. + /// + /// The `position` is the top left corner of that area + /// in surface coordinates and `size` is the size of this area starting from the position. An + /// example of such area could be a input field in the UI or line in the editor. + /// + /// The windowing system could place a candidate box close to that area, but try to not obscure + /// the specified area, so the user input to it stays visible. + /// + /// The candidate box is the window / popup / overlay that allows you to select the desired + /// characters. The look of this box may differ between input devices, even on the same + /// platform. + /// + /// (Apple's official term is "candidate window", see their [chinese] and [japanese] guides). + /// + /// ## Example + /// + /// ```no_run + /// # use dpi::{LogicalPosition, PhysicalPosition, LogicalSize, PhysicalSize}; + /// # use winit_core::window::ImeRequestData; + /// # fn scope(ime_request_data: ImeRequestData) { + /// // Specify the position in logical dimensions like this: + /// let ime_request_data = ime_request_data.with_cursor_area( + /// LogicalPosition::new(400.0, 200.0).into(), + /// LogicalSize::new(100, 100).into(), + /// ); + /// + /// // Or specify the position in physical dimensions like this: + /// let ime_request_data = ime_request_data.with_cursor_area( + /// PhysicalPosition::new(400, 200).into(), + /// PhysicalSize::new(100, 100).into(), + /// ); + /// # } + /// ``` + /// + /// ## Platform-specific + /// + /// - **iOS / Android / Web / Orbital:** Unsupported. + /// + /// [chinese]: https://support.apple.com/guide/chinese-input-method/use-the-candidate-window-cim12992/104/mac/12.0 + /// [japanese]: https://support.apple.com/guide/japanese-input-method/use-the-candidate-window-jpim10262/6.3/mac/12.0 + pub fn with_cursor_area(self, position: Position, size: Size) -> Self { + Self { cursor_area: Some((position, size)), ..self } + } +} + +/// Error from sending request to IME with +/// [`Window::request_ime_update`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ImeRequestError { + /// IME is not yet enabled. + NotEnabled, + /// IME is already enabled. + AlreadyEnabled, + /// Not supported. + NotSupported, +} + +impl fmt::Display for ImeRequestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ImeRequestError::NotEnabled => write!(f, "ime is not enabled."), + ImeRequestError::AlreadyEnabled => write!(f, "ime is already enabled."), + ImeRequestError::NotSupported => write!(f, "ime is not supported."), + } + } +} + +impl std::error::Error for ImeRequestError {} + /// An opaque token used to activate the [`Window`]. /// /// [`Window`]: crate::window::Window @@ -1563,3 +1842,60 @@ impl ActivationToken { &self.token } } + +#[cfg(test)] +mod tests { + + use dpi::{LogicalPosition, LogicalSize, Position, Size}; + + use super::{ImeCapabilities, ImeEnableRequest, ImeRequestData}; + use crate::window::ImePurpose; + + #[test] + fn ime_initial_request_caps_match() { + let position: Position = LogicalPosition::new(0, 0).into(); + let size: Size = LogicalSize::new(0, 0).into(); + + assert!(ImeEnableRequest::new(ImeCapabilities::CURSOR_AREA, ImeRequestData::default()) + .is_none()); + assert!( + ImeEnableRequest::new(ImeCapabilities::PURPOSE, ImeRequestData::default()).is_none() + ); + + assert!(ImeEnableRequest::new( + ImeCapabilities::CURSOR_AREA, + ImeRequestData::default().with_purpose(ImePurpose::Normal) + ) + .is_none()); + + assert!(ImeEnableRequest::new( + ImeCapabilities::empty(), + ImeRequestData::default() + .with_purpose(ImePurpose::Normal) + .with_cursor_area(position, size) + ) + .is_none()); + + assert!(ImeEnableRequest::new( + ImeCapabilities::CURSOR_AREA, + ImeRequestData::default() + .with_purpose(ImePurpose::Normal) + .with_cursor_area(position, size) + ) + .is_none()); + + assert!(ImeEnableRequest::new( + ImeCapabilities::CURSOR_AREA, + ImeRequestData::default().with_cursor_area(position, size) + ) + .is_some()); + + assert!(ImeEnableRequest::new( + ImeCapabilities::all(), + ImeRequestData::default() + .with_purpose(ImePurpose::Normal) + .with_cursor_area(position, size) + ) + .is_some()); + } +} diff --git a/winit-orbital/src/window.rs b/winit-orbital/src/window.rs index 3452f00c..7f3918f7 100644 --- a/winit-orbital/src/window.rs +++ b/winit-orbital/src/window.rs @@ -6,7 +6,7 @@ use dpi::{PhysicalInsets, PhysicalPosition, PhysicalSize, Position, Size}; use winit_core::cursor::Cursor; use winit_core::error::{NotSupportedError, RequestError}; use winit_core::monitor::{Fullscreen, MonitorHandle as CoreMonitorHandle}; -use winit_core::window::{self, ImePurpose, Window as CoreWindow, WindowId}; +use winit_core::window::{self, Window as CoreWindow, WindowId}; use crate::event_loop::{ActiveEventLoop, EventLoopProxy}; use crate::{RedoxSocket, WindowProperties}; @@ -161,6 +161,10 @@ impl CoreWindow for Window { WindowId::from_raw(self.window_socket.fd) } + fn ime_capabilities(&self) -> Option { + None + } + #[inline] fn primary_monitor(&self) -> Option { None @@ -354,14 +358,9 @@ impl CoreWindow for Window { #[inline] fn set_window_icon(&self, _window_icon: Option) {} - #[inline] - fn set_ime_cursor_area(&self, _position: Position, _size: Size) {} - - #[inline] - fn set_ime_allowed(&self, _allowed: bool) {} - - #[inline] - fn set_ime_purpose(&self, _purpose: ImePurpose) {} + fn request_ime_update(&self, _: window::ImeRequest) -> Result<(), window::ImeRequestError> { + Err(window::ImeRequestError::NotSupported) + } #[inline] fn focus_window(&self) {} diff --git a/winit-uikit/src/window.rs b/winit-uikit/src/window.rs index 5b9d7bcd..52acb038 100644 --- a/winit-uikit/src/window.rs +++ b/winit-uikit/src/window.rs @@ -1,7 +1,7 @@ #![allow(clippy::unnecessary_cast)] use std::collections::VecDeque; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use dispatch2::MainThreadBound; use dpi::{ @@ -23,8 +23,9 @@ use winit_core::event::WindowEvent; use winit_core::icon::Icon; use winit_core::monitor::{Fullscreen, MonitorHandle as CoreMonitorHandle}; use winit_core::window::{ - CursorGrabMode, ImePurpose, ResizeDirection, Theme, UserAttentionType, Window as CoreWindow, - WindowAttributes, WindowButtons, WindowId, WindowLevel, + CursorGrabMode, ImeCapabilities, ImeRequest, ImeRequestError, ResizeDirection, Theme, + UserAttentionType, Window as CoreWindow, WindowAttributes, WindowButtons, WindowId, + WindowLevel, }; use super::app_state::EventWrapper; @@ -113,6 +114,7 @@ pub struct Inner { view_controller: Retained, view: Retained, gl_or_metal_backed: bool, + ime_capabilities: Mutex>, } impl Inner { @@ -377,28 +379,42 @@ impl Inner { warn!("`Window::set_window_icon` is ignored on iOS") } - pub fn set_ime_cursor_area(&self, _position: Position, _size: Size) { - warn!("`Window::set_ime_cursor_area` is ignored on iOS") - } - /// Show / hide the keyboard. To show the keyboard, we call `becomeFirstResponder`, /// requesting focus for the [WinitView]. Since [WinitView] implements /// [objc2_ui_kit::UIKeyInput], the keyboard will be shown. /// - pub fn set_ime_allowed(&self, allowed: bool) { - if allowed { - unsafe { - self.view.becomeFirstResponder(); - } - } else { - unsafe { - self.view.resignFirstResponder(); - } + fn request_ime_update(&self, request: ImeRequest) -> Result<(), ImeRequestError> { + let mut current_caps = self.ime_capabilities.lock().unwrap(); + match request { + ImeRequest::Enable(enable) => { + let (capabilities, _) = enable.into_raw(); + if current_caps.is_some() { + return Err(ImeRequestError::AlreadyEnabled); + } + *current_caps = Some(capabilities); + + unsafe { + self.view.becomeFirstResponder(); + } + }, + ImeRequest::Update(_) => { + if current_caps.is_none() { + return Err(ImeRequestError::NotEnabled); + } + }, + ImeRequest::Disable => { + *current_caps = None; + unsafe { + self.view.resignFirstResponder(); + } + }, } + + Ok(()) } - pub fn set_ime_purpose(&self, _purpose: ImePurpose) { - warn!("`Window::set_ime_purpose` is ignored on iOS") + fn ime_capabilities(&self) -> Option { + *self.ime_capabilities.lock().unwrap() } pub fn focus_window(&self) { @@ -529,7 +545,13 @@ impl Window { let window = WinitUIWindow::new(mtm, &window_attributes, frame, &view_controller); window.makeKeyAndVisible(); - let inner = Inner { window, view_controller, view, gl_or_metal_backed }; + let inner = Inner { + window, + view_controller, + view, + gl_or_metal_backed, + ime_capabilities: Default::default(), + }; Ok(Window { inner: MainThreadBound::new(inner, mtm) }) } @@ -711,16 +733,12 @@ impl CoreWindow for Window { self.maybe_wait_on_main(|delegate| delegate.set_window_icon(window_icon)); } - fn set_ime_cursor_area(&self, position: Position, size: Size) { - self.maybe_wait_on_main(|delegate| delegate.set_ime_cursor_area(position, size)); + fn request_ime_update(&self, request: ImeRequest) -> Result<(), ImeRequestError> { + self.maybe_wait_on_main(|delegate| delegate.request_ime_update(request)) } - fn set_ime_allowed(&self, allowed: bool) { - self.maybe_wait_on_main(|delegate| delegate.set_ime_allowed(allowed)); - } - - fn set_ime_purpose(&self, purpose: ImePurpose) { - self.maybe_wait_on_main(|delegate| delegate.set_ime_purpose(purpose)); + fn ime_capabilities(&self) -> Option { + self.maybe_wait_on_main(|delegate| delegate.ime_capabilities()) } fn focus_window(&self) { diff --git a/winit-wayland/src/seat/mod.rs b/winit-wayland/src/seat/mod.rs index e206e4c0..bf68d107 100644 --- a/winit-wayland/src/seat/mod.rs +++ b/winit-wayland/src/seat/mod.rs @@ -26,9 +26,11 @@ use keyboard::{KeyboardData, KeyboardState}; pub use pointer::relative_pointer::RelativePointerState; pub use pointer::{PointerConstraintsState, WinitPointerData, WinitPointerDataExt}; use text_input::TextInputData; -pub use text_input::{TextInputState, ZwpTextInputV3Ext}; +pub use text_input::{ClientState as TextInputClientState, TextInputState}; use touch::TouchPoint; +pub(crate) use crate::seat::text_input::ZwpTextInputV3Ext; + #[derive(Debug, Default)] pub struct WinitSeatState { /// The pointer bound on the seat. diff --git a/winit-wayland/src/seat/text_input/mod.rs b/winit-wayland/src/seat/text_input/mod.rs index be54eed5..02f8a9fb 100644 --- a/winit-wayland/src/seat/text_input/mod.rs +++ b/winit-wayland/src/seat/text_input/mod.rs @@ -1,5 +1,6 @@ use std::ops::Deref; +use dpi::{LogicalPosition, LogicalSize}; use sctk::globals::GlobalData; use sctk::reexports::client::globals::{BindError, GlobalList}; use sctk::reexports::client::protocol::wl_surface::WlSurface; @@ -8,8 +9,9 @@ use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_mana use sctk::reexports::protocols::wp::text_input::zv3::client::zwp_text_input_v3::{ ContentHint, ContentPurpose, Event as TextInputEvent, ZwpTextInputV3, }; +use tracing::warn; use winit_core::event::{Ime, WindowEvent}; -use winit_core::window::ImePurpose; +use winit_core::window::{ImeCapabilities, ImePurpose, ImeRequestData}; use crate::state::WinitState; @@ -69,10 +71,10 @@ impl Dispatch for TextInputState { None => return, }; - if window.ime_allowed() { - text_input.enable(); - text_input.set_content_type_by_purpose(window.ime_purpose()); - text_input.commit(); + if let Some(text_input_state) = window.text_input_state() { + text_input.set_state(Some(text_input_state), true); + // The input method doesn't have to reply anything, so a synthetic event + // carrying an empty state notifies the application about its presence. state.events_sink.push_window_event(WindowEvent::Ime(Ime::Enabled), window_id); } @@ -156,17 +158,40 @@ impl Dispatch for TextInputState { } pub trait ZwpTextInputV3Ext { - fn set_content_type_by_purpose(&self, purpose: ImePurpose); + /// Applies the entire state atomically to the input method. It will skip the "enable" request + /// if `already_enabled` is `true`. + fn set_state(&self, state: Option<&ClientState>, send_enable: bool); } impl ZwpTextInputV3Ext for ZwpTextInputV3 { - fn set_content_type_by_purpose(&self, purpose: ImePurpose) { - let (hint, purpose) = match purpose { - ImePurpose::Password => (ContentHint::SensitiveData, ContentPurpose::Password), - ImePurpose::Terminal => (ContentHint::None, ContentPurpose::Terminal), - _ => (ContentHint::None, ContentPurpose::Normal), + fn set_state(&self, state: Option<&ClientState>, send_enable: bool) { + let state = match state { + Some(state) => state, + None => { + self.disable(); + self.commit(); + return; + }, }; - self.set_content_type(hint, purpose); + + if send_enable { + self.enable(); + } + + if let Some(content_type) = state.content_type() { + self.set_content_type(content_type.hint, content_type.purpose); + } + + if let Some((position, size)) = state.cursor_area() { + let (x, y) = (position.x as i32, position.y as i32); + let (width, height) = (size.width as i32, size.height as i32); + // The same cursor can be applied on different seats. + // It's the compositor's responsibility to make sure that any present popups don't + // overlap. + self.set_cursor_rectangle(x, y, width, height); + } + + self.commit(); } } @@ -189,11 +214,105 @@ pub struct TextInputDataInner { } /// The state of the preedit. +#[derive(Clone)] struct Preedit { text: String, cursor_begin: Option, cursor_end: Option, } +/// State change requested by the application. +/// +/// This is a version that uses text_input abstractions translated from the ones used in +/// winit::core::window::ImeStateChange. +/// +/// Fields that are initially set to None are unsupported capabilities +/// and trying to set them raises an error. +#[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), +} + +impl ClientState { + pub fn new( + capabilities: ImeCapabilities, + request_data: ImeRequestData, + scale_factor: f64, + ) -> Self { + let mut this = Self { + capabilities, + content_type: Default::default(), + cursor_area: Default::default(), + }; + + this.update(request_data, scale_factor); + this + } + + pub fn capabilities(&self) -> ImeCapabilities { + self.capabilities + } + + /// 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.contains(ImeCapabilities::PURPOSE) { + self.content_type = purpose.into(); + } else { + warn!("discarding ImePurpose update without capability enabled."); + } + } + + if let Some((position, size)) = request_data.cursor_area { + if self.capabilities.contains(ImeCapabilities::CURSOR_AREA) { + let position: LogicalPosition = position.to_logical(scale_factor); + let size: LogicalSize = size.to_logical(scale_factor); + self.cursor_area = (position, size); + } else { + warn!("discarding IME cursor area update without capability enabled."); + } + } + } + + pub fn content_type(&self) -> Option { + self.capabilities.contains(ImeCapabilities::PURPOSE).then_some(self.content_type) + } + + pub fn cursor_area(&self) -> Option<(LogicalPosition, LogicalSize)> { + self.capabilities.contains(ImeCapabilities::CURSOR_AREA).then_some(self.cursor_area) + } +} + +/// Arguments to content_type +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ContentType { + /// Text input purpose + purpose: ContentPurpose, + hint: ContentHint, +} + +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(), + }; + + Self { hint, purpose } + } +} + +impl Default for ContentType { + fn default() -> Self { + ContentType { purpose: ContentPurpose::Normal, hint: ContentHint::None } + } +} + delegate_dispatch!(WinitState: [ZwpTextInputManagerV3: GlobalData] => TextInputState); delegate_dispatch!(WinitState: [ZwpTextInputV3: TextInputData] => TextInputState); diff --git a/winit-wayland/src/window/mod.rs b/winit-wayland/src/window/mod.rs index d5b1312e..fb5f22fe 100644 --- a/winit-wayland/src/window/mod.rs +++ b/winit-wayland/src/window/mod.rs @@ -20,8 +20,9 @@ use winit_core::event::{Ime, WindowEvent}; use winit_core::event_loop::AsyncRequestSerial; use winit_core::monitor::{Fullscreen, MonitorHandle as CoreMonitorHandle}; use winit_core::window::{ - CursorGrabMode, ImePurpose, ResizeDirection, Theme, UserAttentionType, Window as CoreWindow, - WindowAttributes, WindowButtons, WindowId, WindowLevel, + CursorGrabMode, ImeCapabilities, ImeRequest, ImeRequestError, ResizeDirection, Theme, + UserAttentionType, Window as CoreWindow, WindowAttributes, WindowButtons, WindowId, + WindowLevel, }; use super::event_loop::sink::EventSink; @@ -508,30 +509,21 @@ impl CoreWindow for Window { } #[inline] - fn set_ime_cursor_area(&self, position: Position, size: Size) { - let window_state = self.window_state.lock().unwrap(); - if window_state.ime_allowed() { - let scale_factor = window_state.scale_factor(); - let position = position.to_logical(scale_factor); - let size = size.to_logical(scale_factor); - window_state.set_ime_cursor_area(position, size); - } - } + fn request_ime_update(&self, request: ImeRequest) -> Result<(), ImeRequestError> { + let state_changed = self.window_state.lock().unwrap().request_ime_update(request)?; - #[inline] - fn set_ime_allowed(&self, allowed: bool) { - let mut window_state = self.window_state.lock().unwrap(); - - if window_state.ime_allowed() != allowed && window_state.set_ime_allowed(allowed) { + if let Some(allowed) = state_changed { let event = WindowEvent::Ime(if allowed { Ime::Enabled } else { Ime::Disabled }); self.window_events_sink.lock().unwrap().push_window_event(event, self.window_id); self.event_loop_awakener.ping(); } + + Ok(()) } #[inline] - fn set_ime_purpose(&self, purpose: ImePurpose) { - self.window_state.lock().unwrap().set_ime_purpose(purpose); + fn ime_capabilities(&self) -> Option { + self.window_state.lock().unwrap().ime_allowed() } fn focus_window(&self) {} diff --git a/winit-wayland/src/window/state.rs b/winit-wayland/src/window/state.rs index 8d87d665..899b0e01 100644 --- a/winit-wayland/src/window/state.rs +++ b/winit-wayland/src/window/state.rs @@ -32,12 +32,15 @@ use wayland_protocols::xdg::toplevel_icon::v1::client::xdg_toplevel_icon_manager use wayland_protocols_plasma::blur::client::org_kde_kwin_blur::OrgKdeKwinBlur; use winit_core::cursor::{CursorIcon, CustomCursor as CoreCustomCursor}; use winit_core::error::{NotSupportedError, RequestError}; -use winit_core::window::{CursorGrabMode, ImePurpose, ResizeDirection, Theme, WindowId}; +use winit_core::window::{ + CursorGrabMode, ImeCapabilities, ImeRequest, ImeRequestError, ResizeDirection, Theme, WindowId, +}; use crate::event_loop::OwnedDisplayHandle; use crate::logical_to_physical_rounded; use crate::seat::{ - PointerConstraintsState, WinitPointerData, WinitPointerDataExt, ZwpTextInputV3Ext, + PointerConstraintsState, TextInputClientState, WinitPointerData, WinitPointerDataExt, + ZwpTextInputV3Ext, }; use crate::state::{WindowCompositorUpdate, WinitState}; use crate::types::cursor::{CustomCursor, SelectedCursor, WaylandCustomCursor}; @@ -113,11 +116,11 @@ pub struct WindowState { /// The current cursor grabbing mode. cursor_grab_mode: GrabState, - /// Whether the IME input is allowed for that window. - ime_allowed: bool, - - /// The current IME purpose. - ime_purpose: ImePurpose, + /// The input method properties provided by the application to the IME. + /// + /// This state is cached here so that the window can automatically send the state to the IME as + /// soon as it becomes available without application involvement. + text_input_state: Option, /// The text inputs observed on the window. text_inputs: Vec, @@ -211,8 +214,7 @@ impl WindowState { frame_callback_state: FrameCallbackState::None, seat_focus: Default::default(), has_pending_move: None, - ime_allowed: false, - ime_purpose: ImePurpose::Normal, + text_input_state: None, last_configure: None, max_surface_size: None, min_surface_size: MIN_WINDOW_SIZE, @@ -544,8 +546,12 @@ impl WindowState { /// Whether the IME is allowed. #[inline] - pub fn ime_allowed(&self) -> bool { - self.ime_allowed + pub fn ime_allowed(&self) -> Option { + self.text_input_state.as_ref().map(|state| state.capabilities()) + } + + pub(crate) fn text_input_state(&self) -> Option<&TextInputClientState> { + self.text_input_state.as_ref() } /// Get the size of the window. @@ -983,53 +989,61 @@ impl WindowState { self.seat_focus.remove(seat); } - /// Returns `true` if the requested state was applied. - pub fn set_ime_allowed(&mut self, allowed: bool) -> bool { - self.ime_allowed = allowed; + /// Atomically update input method state. + /// + /// Returns `None` if an input method state haven't changed. Alternatively `Some(true)` and + /// `Some(false)` is returned respectfully. + pub fn request_ime_update( + &mut self, + request: ImeRequest, + ) -> Result, ImeRequestError> { + let state_change = match request { + ImeRequest::Enable(enable) => { + let (capabilities, request_data) = enable.into_raw(); - let mut applied = false; + if self.text_input_state.is_some() { + return Err(ImeRequestError::AlreadyEnabled); + } + + self.text_input_state = Some(TextInputClientState::new( + capabilities, + request_data, + self.scale_factor(), + )); + true + }, + ImeRequest::Update(request_data) => { + let scale_factor = self.scale_factor(); + if let Some(text_input_state) = self.text_input_state.as_mut() { + text_input_state.update(request_data, scale_factor); + } else { + return Err(ImeRequestError::NotEnabled); + } + false + }, + ImeRequest::Disable => { + self.text_input_state = None; + true + }, + }; + + // Only one input method may be active per (seat, surface), + // but there may be multiple seats focused on a surface, + // resulting in multiple text input objects. + // + // WARNING: this doesn't actually handle different seats with independent cursors. There's + // no API to set a per-seat input method state, so they all share a single state. for text_input in &self.text_inputs { - applied = true; - if allowed { - text_input.enable(); - text_input.set_content_type_by_purpose(self.ime_purpose); - } else { - text_input.disable(); - } - text_input.commit(); + text_input.set_state(self.text_input_state.as_ref(), state_change); } - applied - } - - /// Set the IME position. - pub fn set_ime_cursor_area(&self, position: LogicalPosition, size: LogicalSize) { - // FIXME: This won't fly unless user will have a way to request IME window per seat, since - // the ime windows will be overlapping, but winit doesn't expose API to specify for - // which seat we're setting IME position. - let (x, y) = (position.x as i32, position.y as i32); - let (width, height) = (size.width as i32, size.height as i32); - for text_input in self.text_inputs.iter() { - text_input.set_cursor_rectangle(x, y, width, height); - text_input.commit(); + if state_change { + Ok(Some(self.text_input_state.is_some())) + } else { + Ok(None) } } - /// Set the IME purpose. - pub fn set_ime_purpose(&mut self, purpose: ImePurpose) { - self.ime_purpose = purpose; - - for text_input in &self.text_inputs { - text_input.set_content_type_by_purpose(purpose); - text_input.commit(); - } - } - - /// Get the IME purpose. - pub fn ime_purpose(&self) -> ImePurpose { - self.ime_purpose - } - /// Set the scale factor for the given window. #[inline] pub fn set_scale_factor(&mut self, scale_factor: f64) { diff --git a/winit-web/src/window.rs b/winit-web/src/window.rs index b6e21215..a3579a33 100644 --- a/winit-web/src/window.rs +++ b/winit-web/src/window.rs @@ -12,8 +12,8 @@ use winit_core::error::{NotSupportedError, RequestError}; use winit_core::icon::Icon; use winit_core::monitor::{Fullscreen, MonitorHandle as CoremMonitorHandle}; use winit_core::window::{ - CursorGrabMode, ImePurpose, ResizeDirection, Theme, UserAttentionType, Window as RootWindow, - WindowAttributes, WindowButtons, WindowId, WindowLevel, + CursorGrabMode, ImeRequestError, ResizeDirection, Theme, UserAttentionType, + Window as RootWindow, WindowAttributes, WindowButtons, WindowId, WindowLevel, }; use crate::event_loop::ActiveEventLoop; @@ -308,16 +308,12 @@ impl RootWindow for Window { // Currently an intentional no-op } - fn set_ime_cursor_area(&self, _: Position, _: Size) { - // Currently not implemented + fn ime_capabilities(&self) -> Option { + None } - fn set_ime_allowed(&self, _: bool) { - // Currently not implemented - } - - fn set_ime_purpose(&self, _: ImePurpose) { - // Currently not implemented + fn request_ime_update(&self, _: winit_core::window::ImeRequest) -> Result<(), ImeRequestError> { + Err(ImeRequestError::NotSupported) } fn focus_window(&self) { diff --git a/winit-win32/src/event_loop.rs b/winit-win32/src/event_loop.rs index db0008f3..a14023f0 100644 --- a/winit-win32/src/event_loop.rs +++ b/winit-win32/src/event_loop.rs @@ -1416,7 +1416,7 @@ unsafe fn public_window_callback_inner( }, WM_IME_STARTCOMPOSITION => { - let ime_allowed = userdata.window_state_lock().ime_allowed; + let ime_allowed = userdata.window_state_lock().ime_capabilities.is_some(); if ime_allowed { userdata.window_state_lock().ime_state = ImeState::Enabled; @@ -1429,7 +1429,7 @@ unsafe fn public_window_callback_inner( WM_IME_COMPOSITION => { let ime_allowed_and_composing = { let w = userdata.window_state_lock(); - w.ime_allowed && w.ime_state != ImeState::Disabled + w.ime_capabilities.is_some() && w.ime_state != ImeState::Disabled }; // Windows Hangul IME sends WM_IME_COMPOSITION after WM_IME_ENDCOMPOSITION, so // check whether composing. @@ -1480,7 +1480,7 @@ unsafe fn public_window_callback_inner( WM_IME_ENDCOMPOSITION => { let ime_allowed_or_composing = { let w = userdata.window_state_lock(); - w.ime_allowed || w.ime_state != ImeState::Disabled + w.ime_capabilities.is_some() || w.ime_state != ImeState::Disabled }; if ime_allowed_or_composing { if userdata.window_state_lock().ime_state == ImeState::Preedit { diff --git a/winit-win32/src/ime.rs b/winit-win32/src/ime.rs index 534fe02c..6b379c4e 100644 --- a/winit-win32/src/ime.rs +++ b/winit-win32/src/ime.rs @@ -149,7 +149,7 @@ impl ImeContext { } } - unsafe fn system_has_ime() -> bool { + pub unsafe fn system_has_ime() -> bool { unsafe { GetSystemMetrics(SM_IMMENABLED) != 0 } } } diff --git a/winit-win32/src/window.rs b/winit-win32/src/window.rs index a02996b0..2286a319 100644 --- a/winit-win32/src/window.rs +++ b/winit-win32/src/window.rs @@ -51,8 +51,9 @@ use winit_core::error::RequestError; use winit_core::icon::{Icon, RgbaIcon}; use winit_core::monitor::{Fullscreen, MonitorHandle as CoreMonitorHandle, MonitorHandleProvider}; use winit_core::window::{ - CursorGrabMode, ImePurpose, ResizeDirection, Theme, UserAttentionType, Window as CoreWindow, - WindowAttributes, WindowButtons, WindowId, WindowLevel, + CursorGrabMode, ImeCapabilities, ImeRequest, ImeRequestError, ResizeDirection, Theme, + UserAttentionType, Window as CoreWindow, WindowAttributes, WindowButtons, WindowId, + WindowLevel, }; use crate::dark_mode::try_theme; @@ -1008,26 +1009,64 @@ impl CoreWindow for Window { } } - fn set_ime_cursor_area(&self, spot: Position, size: Size) { + fn ime_capabilities(&self) -> Option { + self.window_state.lock().unwrap().ime_capabilities + } + + fn request_ime_update(&self, request: ImeRequest) -> Result<(), ImeRequestError> { + // NOTE: this is racy way of doing this, but unless we remove the `Send` from the `Window` + // we can not do much about that. + let cap = self.window_state.lock().unwrap().ime_capabilities; + match &request { + ImeRequest::Enable(..) if cap.is_some() => return Err(ImeRequestError::AlreadyEnabled), + ImeRequest::Update(_) if cap.is_none() => return Err(ImeRequestError::NotEnabled), + _ => (), + } + let window = self.window; let state = self.window_state.clone(); self.thread_executor.execute_in_thread(move || unsafe { - let scale_factor = state.lock().unwrap().scale_factor; - ImeContext::current(window.hwnd()).set_ime_cursor_area(spot, size, scale_factor); + let hwnd = window.hwnd(); + let mut state = state.lock().unwrap(); + let (capabilities, request_data) = match &request { + ImeRequest::Enable(enable) => { + let capabilities = *enable.capabilities(); + state.ime_capabilities = Some(capabilities); + ImeContext::set_ime_allowed(hwnd, true); + (capabilities, enable.request_data()) + }, + ImeRequest::Update(request_data) => { + if let Some(capabilities) = state.ime_capabilities { + (capabilities, request_data) + } else { + warn!("ime update without IME enabled."); + return; + } + }, + ImeRequest::Disable => { + state.ime_capabilities = None; + ImeContext::set_ime_allowed(window.hwnd(), false); + return; + }, + }; + + if let Some((spot, size)) = request_data.cursor_area { + if capabilities.contains(ImeCapabilities::CURSOR_AREA) { + let scale_factor = state.scale_factor; + ImeContext::current(window.hwnd()).set_ime_cursor_area( + spot, + size, + scale_factor, + ); + } else { + warn!("discarding IME cursor area update without capability enabled."); + } + } }); - } - fn set_ime_allowed(&self, allowed: bool) { - let window = self.window; - let state = self.window_state.clone(); - self.thread_executor.execute_in_thread(move || unsafe { - state.lock().unwrap().ime_allowed = allowed; - ImeContext::set_ime_allowed(window.hwnd(), allowed); - }) + Ok(()) } - fn set_ime_purpose(&self, _purpose: ImePurpose) {} - fn request_user_attention(&self, request_type: Option) { let window = self.window; let active_window_handle = unsafe { GetActiveWindow() }; diff --git a/winit-win32/src/window_state.rs b/winit-win32/src/window_state.rs index 06135b05..08dfe84e 100644 --- a/winit-win32/src/window_state.rs +++ b/winit-win32/src/window_state.rs @@ -19,7 +19,7 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ use winit_core::icon::Icon; use winit_core::keyboard::ModifiersState; use winit_core::monitor::Fullscreen; -use winit_core::window::{Theme, WindowAttributes}; +use winit_core::window::{ImeCapabilities, Theme, WindowAttributes}; use crate::{event_loop, util, SelectedCursor}; @@ -48,7 +48,7 @@ pub(crate) struct WindowState { pub window_flags: WindowFlags, pub ime_state: ImeState, - pub ime_allowed: bool, + pub ime_capabilities: Option, // Used by WM_NCACTIVATE, WM_SETFOCUS and WM_KILLFOCUS pub is_active: bool, @@ -178,7 +178,7 @@ impl WindowState { window_flags: WindowFlags::empty(), ime_state: ImeState::Disabled, - ime_allowed: false, + ime_capabilities: None, is_active: false, is_focused: false, diff --git a/winit-x11/src/window.rs b/winit-x11/src/window.rs index 5fd0ccd3..a9cc2191 100644 --- a/winit-x11/src/window.rs +++ b/winit-x11/src/window.rs @@ -20,8 +20,9 @@ use winit_core::monitor::{ Fullscreen, MonitorHandle as CoreMonitorHandle, MonitorHandleProvider, VideoMode, }; use winit_core::window::{ - CursorGrabMode, ImePurpose, ResizeDirection, Theme, UserAttentionType, Window as CoreWindow, - WindowAttributes, WindowButtons, WindowId, WindowLevel, + CursorGrabMode, ImeCapabilities, ImeRequest as CoreImeRequest, ImeRequestError, + ResizeDirection, Theme, UserAttentionType, Window as CoreWindow, WindowAttributes, + WindowButtons, WindowId, WindowLevel, }; use x11rb::connection::{Connection, RequestConnection}; use x11rb::properties::{WmHints, WmSizeHints, WmSizeHintsSpecification}; @@ -210,16 +211,12 @@ impl CoreWindow for Window { self.0.set_window_icon(icon) } - fn set_ime_cursor_area(&self, position: Position, size: Size) { - self.0.set_ime_cursor_area(position, size); + fn request_ime_update(&self, action: CoreImeRequest) -> Result<(), ImeRequestError> { + self.0.request_ime_update(action) } - fn set_ime_allowed(&self, allowed: bool) { - self.0.set_ime_allowed(allowed); - } - - fn set_ime_purpose(&self, purpose: ImePurpose) { - self.0.set_ime_purpose(purpose); + fn ime_capabilities(&self) -> Option { + self.0.ime_capabilities() } fn focus_window(&self) { @@ -349,6 +346,7 @@ pub struct SharedState { pub inner_position_rel_parent: Option<(i32, i32)>, pub is_resizable: bool, pub is_decorated: bool, + pub ime_capabilities: Option, pub last_monitor: X11MonitorHandle, pub dpi_adjusted: Option<(u32, u32)>, pub(crate) fullscreen: Option, @@ -392,6 +390,7 @@ impl SharedState { size: None, position: None, inner_position: None, + ime_capabilities: None, inner_position_rel_parent: None, dpi_adjusted: None, fullscreen: None, @@ -2081,7 +2080,55 @@ impl UnownedWindow { } #[inline] - pub fn set_ime_purpose(&self, _purpose: ImePurpose) {} + pub fn request_ime_update(&self, request: CoreImeRequest) -> Result<(), ImeRequestError> { + let mut shared_state = self.shared_state_lock(); + let (capabilities, state) = match request { + CoreImeRequest::Enable(enable) => { + let (capabilities, request_data) = enable.into_raw(); + + if shared_state.ime_capabilities.is_some() { + return Err(ImeRequestError::AlreadyEnabled); + } + + shared_state.ime_capabilities = Some(capabilities); + drop(shared_state); + self.set_ime_allowed(true); + (capabilities, request_data) + }, + CoreImeRequest::Update(state) => { + if let Some(capabilities) = shared_state.ime_capabilities { + (capabilities, state) + } else { + // The IME was not yet enabled, so discard the update. + return Err(ImeRequestError::NotEnabled); + } + }, + CoreImeRequest::Disable => { + shared_state.ime_capabilities = None; + drop(shared_state); + self.set_ime_allowed(false); + return Ok(()); + }, + }; + + if let Some((position, size)) = state.cursor_area { + if capabilities.contains(ImeCapabilities::CURSOR_AREA) { + self.set_ime_cursor_area(position, size); + } else { + warn!("discarding IME cursor area update without capability enabled."); + } + } + + // Pretend that there is always some input method available. + // Better to make an application think it has an input method and send more events when it + // doesn't than think there is no input method and not send any IME events. + Ok(()) + } + + #[inline] + pub fn ime_capabilities(&self) -> Option { + self.shared_state_lock().ime_capabilities + } #[inline] pub fn focus_window(&self) { diff --git a/winit/src/changelog/unreleased.md b/winit/src/changelog/unreleased.md index 84db1a3d..e255263d 100644 --- a/winit/src/changelog/unreleased.md +++ b/winit/src/changelog/unreleased.md @@ -82,6 +82,7 @@ changelog entry. - `ActivationToken::as_raw` to get a ref to raw token. - Each platform now has corresponding `WindowAttributes` struct instead of trait extension. - On Wayland, added implementation for `Window::set_window_icon` +- Add `Window::request_ime_update` to atomically apply set of IME changes. ### Changed @@ -203,6 +204,7 @@ changelog entry. - Move `EventLoopExtPumpEvents` and `PumpStatus` from platform module to `winit::event_loop::pump_events`. - Move `EventLoopExtRunOnDemand` from platform module to `winit::event_loop::run_on_demand`. - Use `NamedKey`, `Code` and `Location` from the `keyboard-types` v0.8 crate. +- Deprecate `Window::set_ime_allowed`, `Window::set_ime_cursor_area`, and `Window::set_ime_purpose`. ### Removed