teksilo_platform/soft_keyboard.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Raising and dismissing the platform's on-screen keyboard.
5//!
6//! A finger landing in a text field is the one input that has no desktop
7//! answer: there is no physical keyboard behind it, and nothing in the
8//! ordinary focus path summons a soft one. What each platform will do about
9//! that differs so much that the honest surface is a *capability* — see
10//! [`SoftKeyboardSupport`] — and three of the four desktop answers are "not
11//! much".
12//!
13//! # Per platform
14//!
15//! - **Windows** — [`SoftKeyboardSupport::Explicit`]. The touch keyboard is
16//! `TabTip.exe`, driven through the undocumented `ITipInvocation` COM
17//! interface on the `UIHostNoLaunch` coclass. Its one method is `Toggle`:
18//! there is no *Show* and no *Hide*, so honouring both directions means
19//! reading the keyboard window's visibility first and toggling only when the
20//! state to change is the wrong one. [`should_toggle`] is that decision, and
21//! it is a pure function so it can be checked from any host.
22//! - **macOS** — [`SoftKeyboardSupport::None`]. There is no client-facing
23//! request. The Accessibility Keyboard is a user setting under System
24//! Settings ▸ Accessibility ▸ Keyboard; `NSTextInputClient` raises the IME
25//! *candidate* window, which is not a keyboard.
26//! - **Wayland** — [`SoftKeyboardSupport::None`], and this is the answer worth
27//! spelling out because it is not the one you would guess. Version 1 of
28//! `zwp_text_input_v3` — the version winit 0.30 binds, `1..=1` — has no
29//! `show_input_panel` request, so there is no explicit verb to send from
30//! where Teksilo stands. (Version **2** of the interface has since added
31//! `show_input_panel` / `hide_input_panel` back; reaching them means binding
32//! our own manager at that version, and whether that is worth doing turns on
33//! compositor support for a new interface version — see
34//! `docs/soft-keyboard.md`.) What a panel does instead is follow the
35//! `enable` + `commit` pair the framework already issues when a text widget
36//! takes focus — which is what [`SoftKeyboardSupport::ViaAccessibility`]
37//! describes, and Wayland still does not get that row, because that row is a
38//! *guarantee* and this is not one: mutter needs the pair **twice** before it
39//! shows the panel (GNOME/mutter issue #1506) and winit 0.30 sends it exactly
40//! once per `set_ime_allowed(true)`, so a GNOME session can end up with a
41//! focused field and no keyboard. Teksilo does not work around it, for a
42//! reason that is not laziness: the only reachable second `enable` is a
43//! second `set_ime_allowed(true)`, and `enable` is specified to reset "the
44//! state associated with preedit_string, commit_string, and
45//! delete_surrounding_text events" — it destroys a live composition. Binding
46//! a second `zwp_text_input_v3` of our own does not help either; the protocol
47//! says requests to enable a text input while another is enabled on the same
48//! seat must be ignored. So the choice is between a keyboard that sometimes
49//! does not appear and a composition that sometimes vanishes mid-word, and
50//! this is the side of it that loses no user data. Reporting `None` is the
51//! matching honesty: a widget that offers its own affordance is right on the
52//! session where nothing rises, and merely redundant on the session where
53//! something does.
54//! - **X11** — [`SoftKeyboardSupport::None`]. On-screen keyboards are separate
55//! clients driven by AT-SPI or by the user; the core protocol, XInput2 and
56//! EWMH between them have no client request.
57//!
58//! # What `None` obliges a caller to do
59//!
60//! It is a contract, not a gap: where the answer is `None` the framework will
61//! never raise a keyboard and promises nothing about whether the platform will,
62//! so a text surface that expects to be driven by a finger has to offer its own
63//! affordance. Read it through
64//! [`EventContext::soft_keyboard_support`](teksilo_core::widget::EventContext::soft_keyboard_support).
65
66use teksilo_core::window::SoftKeyboardSupport;
67
68use crate::pointer_backend::PlatformKind;
69
70/// What `platform` can do about an on-screen keyboard.
71///
72/// A pure function of the platform, like
73/// [`BackendCaps::for_platform`](crate::pointer_backend::BackendCaps::for_platform),
74/// so every row can be asserted from any host.
75pub const fn support_for(platform: PlatformKind) -> SoftKeyboardSupport {
76 match platform {
77 PlatformKind::Windows => SoftKeyboardSupport::Explicit,
78 PlatformKind::MacOs | PlatformKind::Unix => SoftKeyboardSupport::None,
79 }
80}
81
82/// What the host this binary was compiled for can do.
83pub const fn support() -> SoftKeyboardSupport {
84 support_for(PlatformKind::HOST)
85}
86
87/// What the framework should do about a pending soft-keyboard request.
88#[derive(Copy, Clone, PartialEq, Eq, Debug)]
89pub enum SoftKeyboardAction {
90 /// Nothing — either the framework has no keyboard request to send, or the
91 /// request is already satisfied by the IME-allowance reconcile.
92 Nothing,
93 /// Ask the platform to show (`true`) or hide (`false`) the keyboard.
94 Ask(bool),
95}
96
97/// Resolve a pending request against what the platform can do.
98///
99/// The rule that matters is the [`SoftKeyboardSupport::ViaAccessibility`] one:
100/// *asking* there means re-asserting IME allowance, and re-asserting allowance
101/// is what destroys a live composition — so on that platform the framework's
102/// ordinary focus-driven reconcile is the only request that will ever be made,
103/// and an explicit one resolves to nothing. That is what makes placing a caret
104/// with a finger mid-composition safe: the request cannot reach
105/// `set_ime_allowed` because nothing on this path calls it.
106///
107/// [`SoftKeyboardSupport::Explicit`] has no such hazard — its request goes to
108/// the keyboard's own control, not through the IME channel — so it is passed
109/// through, and the *state* question ("is it already up?") belongs to
110/// [`should_toggle`] inside the platform call.
111pub const fn resolve(support: SoftKeyboardSupport, want_visible: bool) -> SoftKeyboardAction {
112 match support {
113 SoftKeyboardSupport::None | SoftKeyboardSupport::ViaAccessibility => {
114 SoftKeyboardAction::Nothing
115 }
116 SoftKeyboardSupport::Explicit => SoftKeyboardAction::Ask(want_visible),
117 // `SoftKeyboardSupport` is `#[non_exhaustive]`: a variant added later
118 // has not been thought about here, and doing nothing is the answer
119 // that cannot cancel a composition.
120 _ => SoftKeyboardAction::Nothing,
121 }
122}
123
124/// Whether a toggle-only keyboard control has to be poked.
125///
126/// The whole of the Windows decision, extracted because `ITipInvocation` offers
127/// only `Toggle`: poking it when the keyboard is already in the wanted state
128/// puts it in the wrong one, which is how a naive "always show" ends up hiding
129/// the keyboard it was asked to raise.
130pub const fn should_toggle(currently_visible: bool, want_visible: bool) -> bool {
131 currently_visible != want_visible
132}
133
134/// Ask the platform to show or hide its on-screen keyboard.
135///
136/// Returns `true` when the platform both understood the request and acted on
137/// it — which is `false` on every platform reporting
138/// [`SoftKeyboardSupport::None`], and `false` on Windows when the keyboard is
139/// already in the wanted state (nothing to do is not a failure to the caller,
140/// but it is also not an action, and the app layer uses the distinction only
141/// for tracing).
142pub fn set_visible(window: &winit::window::Window, visible: bool) -> bool {
143 #[cfg(target_os = "windows")]
144 {
145 windows_impl::set_visible(window, visible)
146 }
147 #[cfg(not(target_os = "windows"))]
148 {
149 let _ = (window, visible);
150 false
151 }
152}
153
154/// The rectangle the on-screen keyboard currently covers, in **screen**
155/// coordinates and physical pixels, or `None` when no keyboard is up.
156///
157/// Windows only, and for the same reason as [`set_visible`]: it is the only
158/// desktop platform whose keyboard is a findable window. macOS has no keyboard
159/// to find; `zwp_text_input_v3` has no event carrying the input panel's
160/// geometry (enter, leave, preedit_string, commit_string,
161/// delete_surrounding_text, done and the newer action/language/preedit_hint —
162/// none of them a rectangle); and under X11 the keyboard is an unrelated
163/// client with no hint saying where it is.
164pub fn keyboard_screen_rect() -> Option<(i32, i32, i32, i32)> {
165 #[cfg(target_os = "windows")]
166 {
167 windows_impl::keyboard_screen_rect()
168 }
169 #[cfg(not(target_os = "windows"))]
170 {
171 None
172 }
173}
174
175#[cfg(target_os = "windows")]
176// `ITipInvocation::Toggle` is named for its COM vtable slot and cannot be
177// snake_cased; `#[interface]` rejects any attribute placed beside it, so the
178// allowance has to sit here.
179#[allow(non_snake_case)]
180mod windows_impl {
181 //! **Verification status:** written against the `windows` crate 0.62 API
182 //! and the published `ITipInvocation` GUIDs. It is
183 //! `cfg(target_os = "windows")` and has **not** been exercised on a
184 //! Windows host — the touch-keyboard model changed in Windows 11 22H2, so
185 //! this is a P44 hardware sign-off item. Everything decidable without an
186 //! OS ([`super::should_toggle`], the capability row) is unit-tested.
187
188 use windows::Win32::Foundation::HWND;
189 use windows::Win32::System::Com::{
190 CLSCTX_INPROC_HANDLER, CLSCTX_LOCAL_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance,
191 CoInitializeEx,
192 };
193 use windows::Win32::UI::WindowsAndMessaging::{
194 FindWindowW, GetDesktopWindow, GetWindowRect, IsWindowVisible,
195 };
196 use windows::core::{GUID, HRESULT, IUnknown, IUnknown_Vtbl, interface, w};
197
198 /// `UIHostNoLaunch` — the coclass that talks to an *already running*
199 /// `TabTip.exe` without starting one.
200 const CLSID_UI_HOST_NO_LAUNCH: GUID = GUID::from_u128(0x4ce576fa_83dc_4f88_951c_9d0782b4e376);
201
202 /// The touch keyboard's own top-level window class.
203 ///
204 /// Two of them exist across Windows versions: `IPTip_Main_Window` is the
205 /// classic one, and Windows 10+ hosts the keyboard inside an
206 /// `ApplicationFrameWindow` whose title is the keyboard's. Both are
207 /// checked, because which one answers depends on the OS build.
208 fn keyboard_window() -> Option<HWND> {
209 unsafe {
210 if let Ok(hwnd) = FindWindowW(w!("IPTip_Main_Window"), None)
211 && !hwnd.is_invalid()
212 {
213 return Some(hwnd);
214 }
215 if let Ok(hwnd) = FindWindowW(
216 w!("ApplicationFrameWindow"),
217 w!("Microsoft Text Input Application"),
218 ) && !hwnd.is_invalid()
219 {
220 return Some(hwnd);
221 }
222 }
223 None
224 }
225
226 fn is_visible() -> bool {
227 keyboard_window().is_some_and(|hwnd| unsafe { IsWindowVisible(hwnd) }.as_bool())
228 }
229
230 #[interface("37c994e7-432b-4834-a2f7-dce1f13b834b")]
231 unsafe trait ITipInvocation: IUnknown {
232 fn Toggle(&self, hwnd: HWND) -> HRESULT;
233 }
234
235 pub(super) fn set_visible(_window: &winit::window::Window, visible: bool) -> bool {
236 if !super::should_toggle(is_visible(), visible) {
237 return false;
238 }
239 unsafe {
240 // The winit main thread is already an STA (winit's own OLE
241 // initialisation for drag-and-drop), so this is normally a
242 // no-op returning `S_FALSE`; calling it makes the module
243 // correct on a thread that is not.
244 let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
245 let Ok(tip) = CoCreateInstance::<_, ITipInvocation>(
246 &CLSID_UI_HOST_NO_LAUNCH,
247 None,
248 CLSCTX_INPROC_HANDLER | CLSCTX_LOCAL_SERVER,
249 ) else {
250 return false;
251 };
252 // `Toggle` takes the window the keyboard should position itself
253 // against; the documented value is the desktop window, which is
254 // what every known consumer passes.
255 tip.Toggle(GetDesktopWindow()).is_ok()
256 }
257 }
258
259 pub(super) fn keyboard_screen_rect() -> Option<(i32, i32, i32, i32)> {
260 let hwnd = keyboard_window()?;
261 if !unsafe { IsWindowVisible(hwnd) }.as_bool() {
262 return None;
263 }
264 let mut rect = windows::Win32::Foundation::RECT::default();
265 unsafe { GetWindowRect(hwnd, &mut rect) }.ok()?;
266 Some((rect.left, rect.top, rect.right, rect.bottom))
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn every_platform_row_is_stated() {
276 // Windows is the only desktop platform with a request to make, and
277 // Unix covers both Wayland and X11 because neither has one: Wayland's
278 // v3 text-input interface winit binds has no `show_input_panel`, and
279 // X11 never had a client request at all. Wayland is not
280 // `ViaAccessibility` either: that row promises a panel *will* rise on
281 // the IME enable, and mutter's double-`enable` requirement means it
282 // sometimes does not.
283 assert_eq!(
284 support_for(PlatformKind::Windows),
285 SoftKeyboardSupport::Explicit
286 );
287 assert_eq!(support_for(PlatformKind::MacOs), SoftKeyboardSupport::None);
288 assert_eq!(support_for(PlatformKind::Unix), SoftKeyboardSupport::None);
289 }
290
291 #[test]
292 fn a_request_never_reaches_the_ime_channel() {
293 // The one rule that protects a live composition: where "ask" would
294 // mean "re-assert IME allowance", the framework does not ask.
295 assert_eq!(
296 resolve(SoftKeyboardSupport::ViaAccessibility, true),
297 SoftKeyboardAction::Nothing
298 );
299 assert_eq!(
300 resolve(SoftKeyboardSupport::ViaAccessibility, false),
301 SoftKeyboardAction::Nothing
302 );
303 // Nothing to raise at all.
304 assert_eq!(
305 resolve(SoftKeyboardSupport::None, true),
306 SoftKeyboardAction::Nothing
307 );
308 // A keyboard with its own control is asked in both directions; it is
309 // `should_toggle`, not this, that knows whether the ask is a no-op.
310 assert_eq!(
311 resolve(SoftKeyboardSupport::Explicit, true),
312 SoftKeyboardAction::Ask(true)
313 );
314 assert_eq!(
315 resolve(SoftKeyboardSupport::Explicit, false),
316 SoftKeyboardAction::Ask(false)
317 );
318 }
319
320 #[test]
321 fn a_toggle_only_control_is_poked_only_when_the_state_is_wrong() {
322 // The whole reason `Explicit` is honest on Windows: `Toggle` would
323 // *hide* a keyboard that is already up if it were called blind.
324 assert!(should_toggle(false, true), "hidden, asked to show");
325 assert!(should_toggle(true, false), "shown, asked to hide");
326 assert!(!should_toggle(true, true), "already shown");
327 assert!(!should_toggle(false, false), "already hidden");
328 }
329}