Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Soft keyboard

A finger landing in a text field is the one input the desktop has no answer for. There is no physical keyboard behind it, and nothing in the ordinary focus path summons a soft one. This page is what each desktop platform will do about that, what the framework promises, and what it deliberately refuses to do.

The short version: on three of four desktop platforms the framework will not raise a keyboard, and that is a contract, not a gap. A text surface built for touch has to know which it is on.


1. The capability

#![allow(unused)]
fn main() {
pub enum SoftKeyboardSupport { None, ViaAccessibility, Explicit }
}

Read it from a handler:

#![allow(unused)]
fn main() {
if ctx.soft_keyboard_support() == SoftKeyboardSupport::None {
    // Nothing will rise. Offer an in-app affordance instead.
}
}

The three answers differ in what a caller may promise a user, not in how much code stands behind them.

  • None — the framework has no keyboard request to send, and makes no promise that anything will rise on its own. Either the platform has no software keyboard at all, or it has one whose appearance is the platform's business and not reliable enough to promise. A request is dropped. A text surface that expects a finger has to offer its own affordance.
  • ViaAccessibility — a keyboard exists and is guaranteed to rise when a text control takes focus through the accessibility layer, so a touch-driven text surface needs no affordance of its own. There is still no request to make: the framework's ordinary IME-allowance reconcile is what summons it, and an explicit ask would at best duplicate that and at worst cancel a live composition (§3). The guarantee is the whole difference from None.
  • Explicit — a keyboard exists and can be shown and hidden on demand. Only a backend that can honour both directions may report this. A toggle whose current state is unknown cannot, because "show" would sometimes hide.

BackendCaps::osk carries the same value, and soft_keyboard::support_for is the pure function behind both — so every row is asserted from any host, including the two a Linux runner cannot boot.

2. Per platform

answerwhy
WindowsExplicitITipInvocation::Toggle on the UIHostNoLaunch coclass, plus a visibility probe
macOSNoneno client-facing request exists
WaylandNonethe zwp_text_input_v3 version winit binds has no request to make, and no panel can be promised
X11Noneno request in the core protocol, XInput2 or EWMH

Windows

The touch keyboard is TabTip.exe, reached through the undocumented ITipInvocation COM interface. Its one method is Toggle — there is no Show and no Hide — so honouring both directions means finding the keyboard's own window (IPTip_Main_Window, or the ApplicationFrameWindow that hosts it on Windows 10 and later), asking whether it is visible, and poking the toggle only when the state is the wrong one. That decision is soft_keyboard::should_toggle, a pure function, and it is what makes Explicit honest here rather than a toggle wearing a promise.

Windows also raises the keyboard for a UIA text pattern under touch focus, so the accessibility path works too; the probe is what stops the two from fighting.

Verification status. Written against the windows crate 0.62 API and the published GUIDs, and not yet exercised on a Windows host. Windows 11 22H2 changed the touch-keyboard model, so this is a hardware sign-off item. Everything decidable without an OS — the capability row, the toggle decision, the request resolution — is unit-tested.

macOS

No API lets a desktop app raise a software keyboard. The Accessibility Keyboard is a user setting under System Settings ▸ Accessibility ▸ Keyboard and has no client-facing request. NSTextInputClient raises the IME candidate window, which is not a keyboard.

Wayland

This is the answer worth spelling out, because it is not the one you would guess.

zwp_text_input_v3 as winit speaks it has no way to ask. The interface's version 1 — the version winit 0.30 binds (globals.bind(queue_handle, 1..=1, ..)) — has no show_input_panel request: v1 and v2 of the protocol family had one, v3 dropped it, and version 2 of the v3 interface has since added show_input_panel / hide_input_panel back (they are in the wayland-protocols this workspace ships). Nothing in winit 0.30 binds that version or exposes the requests, so from where Teksilo stands there is no explicit verb to send, and what a panel does instead is follow the enable + commit pair the framework already issues when a text widget takes focus.

That is the shape ViaAccessibility describes, and Wayland still does not get that row — because that row is a guarantee, and this is not one.

Mutter needs that pair twice before it shows the panel (GNOME/mutter#1506), and winit 0.30 sends it exactly once per set_ime_allowed(true) — so a GNOME session can end up with a focused field and no keyboard. Teksilo does not work around it, and the reason is not laziness:

  • The only reachable second enable is a second set_ime_allowed(true), and enable is specified to reset "the state associated with preedit_string, commit_string, and delete_surrounding_text events". It destroys a live composition.
  • Binding a second zwp_text_input_v3 of our own — the pattern the pen and drag-and-drop backends use to reach the compositor past winit — does not help for the double enable. The protocol says requests to enable a text input while another is enabled on the same seat must be ignored.

So the choice is between a keyboard that sometimes does not appear and a composition that sometimes vanishes mid-word. This is the side of it that loses no user data — and None is the capability row that matches it. A widget that offers its own affordance is right on the session where nothing rises, and merely redundant on the session where something does; a widget told ViaAccessibility would offer nothing, and on GNOME the field would be unreachable.

The route to Explicit on Wayland, when it opens. Bind our own zwp_text_input_manager_v3 at interface version 2 on winit's display — the pattern pen/wayland.rs already uses — and send show_input_panel / hide_input_panel, which are ordinary requests and not the enable the seat serialises. That is not done here because the value of it turns entirely on compositor support for a version-2 interface that is new, and a capability row is a promise: Explicit may not be claimed on the strength of a request the compositor is free to ignore. Verifying it is a hardware sign-off item, not a code change.

X11

On-screen keyboards are separate clients driven by AT-SPI or by the user. There is no client request in the core protocol, in XInput2, or in any EWMH hint.

3. Why a request never re-asserts IME allowance

EventContext::request_soft_keyboard records a request; the app layer applies it once per dispatch, after its IME-allowance reconcile. That order is the whole rule.

On a platform whose keyboard follows the IME enable, "asking" means re-asserting allowance — and re-asserting allowance is what destroys a live composition. So the request resolves to nothing there, and nothing on that path calls set_ime_allowed. Placing a caret with a finger while a composition is in flight keeps the preedit because the code that would have destroyed it is not reachable from the request.

soft_keyboard::resolve is that decision as a pure function, and it is tested per capability row. Explicit has no such hazard — its request goes to the keyboard's own control, not through the IME channel — so it is passed through, and the "is it already up?" question belongs to should_toggle inside the platform call.

How the guarantee is stated. A promise about a call that must not happen can only be tested where that call would have been visible, so the two winit pushes (set_ime_purpose / set_ime_allowed) are behind input_loop::InputChrome::set_ime rather than written inline in the event loop. input_loop::settle_ime is the reconcile and the request in one function, in that order, and the assertion is that a turn at a focus that has not moved — which is every turn of a live composition — pushes nothing at all. The alternative, asserting that a document still reads "ni" after the request, holds no matter what the code does, because apply_soft_keyboard_request has no route to a TextDocument in the first place.

4. The occluded band

A keyboard covers part of the window. WidgetTree::set_occluded_inset is where that rectangle is reported, and overlay placement keeps the largest free slab: a Centered modal recomputes against the band above the keyboard, and pins to the top of it when it is taller than the band rather than sliding off-screen.

A rectangle, not a named edge, because that is what a platform reports and because a candidate window docked to a side is the same problem with a different geometry.

Scope: this reaches overlay placement only. The root layout proposal is still the whole window, so a keyboard rising does not reflow the document behind it — which is what the desktop convention wants, and what keeps a keyboard appearing from being a full relayout. Bringing a focused field out from behind the band is a scroll against WidgetTree::usable_viewport, not a resize.

Where the rectangle comes from is the same undocumented window lookup as §2's Windows path, and for the same reason it exists nowhere else: macOS has no keyboard to find, zwp_text_input_v3 has no event that carries the input panel's geometry at all, and under X11 the keyboard is an unrelated client with no hint saying where it is.

Nothing notifies us when it moves or goes away, so teksilo-app re-reads it — at most four times a second, and only on an event-loop turn it was awake for anyway. An idle app never looks, and an idle app is not placing overlays. The consequence to state plainly: a keyboard the user dismissed is noticed within that quarter second rather than immediately.

5. The safe area, which is the same shape of problem

A window owns a rectangle; it does not always get all of it. A display cutout eats the top, a rounded corner clips the corners, a home indicator reserves the bottom. teksilo_platform::safe_area reads it and teksilo-app hands it to WidgetTree::set_safe_area when a window is created and after every resize and scale change, from where it reaches the same overlay viewport as the occluded band.

Only macOS reports one on the desktop (NSView.safeAreaInsets, macOS 11+), and there only when the window covers the camera housing — in practice, full screen on a 14"/16" MacBook Pro. Windows has no cutout and no client-area inset API; neither Wayland nor X11 carries a display cutout in any stable protocol. Those three zeroes are answers, stated at the branch that returns them.

SafeAreaSides names physical edges — left and right, not leading and trailing — because that is what a platform reports, and it is the widget tree, which knows the layout direction, that decides which is which.

6. What this does not do

  • It does not decide when to ask, and that is now an unowned gap rather than a dependency. When this page was written the touch text contract did not exist; it does now, and every editing surface is a host — and still no widget calls request_soft_keyboard. Its only caller is the plumbing that drains EventContext::soft_keyboard_request. What is missing is the policy: which gesture on which surface counts as a request (a caret placed by a release? a hold that selected a word? a focus arriving from a Tab?), and what SoftKeyboardPolicy::Auto should mean for each.
  • TouchSelection::report_ime_area is gone. Each editing stack reports the IME area from its own touch paths — the direct-pointer arm of its mouse.rs, a second reporter in its place_caret_at, and a third on a caret-handle drag — so a caret a finger placed or dragged does move the candidate area. The stacks use their own reporter rather than a controller-side one deliberately: it holds the focus / read-only / layout guard and the dedup that keeps an input method from feeding an unchanged rectangle back as a fresh empty preedit. What the controller supplies instead is the question: text_touch::drag_moves_the_caret(kind, phase) says which handle-drag samples moved a caret, and the host answers them with its own reporter. See docs/touch-and-pen.md §10.1 for the drag gap that closing this found.
  • It does not learn instantly that the user dismissed the keyboard by hand; see the polling note in §4.
  • It does not raise a keyboard on None, and it will not pretend to.

Source

  • crates/teksilo-platform/src/soft_keyboard.rs — the capability, the resolution, the toggle decision, the Windows path
  • crates/teksilo-platform/src/safe_area.rsSafeAreaSides and the per-OS read
  • crates/teksilo-core/src/window/ops.rsSoftKeyboardSupport, WindowOps::soft_keyboard_support
  • crates/teksilo-core/src/overlay/viewport.rs — how the two insets become one usable rectangle

See also

  • Touch text editing — the contract that would decide when to ask.
  • Touch & pen — the pointer model, and §9's list of what a hardware sign-off has to check by hand.
  • Overlays — how the occluded and safe-area insets reach a placement.