Skip to main content

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}