Skip to content

Android: keep the keyboard's bottom row out from under the navigation bar - #22

Merged
bojieli merged 7 commits into
mainfrom
fix/android-keyboard-reach
Aug 30, 2026
Merged

Android: keep the keyboard's bottom row out from under the navigation bar#22
bojieli merged 7 commits into
mainfrom
fix/android-keyboard-reach

Conversation

@bojieli

@bojieli bojieli commented Aug 30, 2026

Copy link
Copy Markdown
Owner

What this changes, and why

Reported from a phone: the keyboard's bottom row is half-covered by the system's navigation
buttons and very hard to hit. Two causes, and only fixing one of them would have left the report
half-true.

The row was too short. 38dp is iOS's number, taken deliberately so the two keyboards feel like
one product. It is the right number there, because iOS keeps the bottom of the keyboard clear for
the app. Android does not: from API 35 an InputMethodService window is laid out behind the
navigation bar. 48dp is Android's minimum touch target, and the bottom row of a keyboard is the
one place in this product where that minimum is not advice.

The existing inset listener could silently never run. ViewCompat.setOnApplyWindowInsetsListener
was already there, and it is the documented fix — but a listener installed on a view that is added
to a window which is already laid out is not guaranteed a dispatch, and one that never ran leaves
the padding at zero, which is indistinguishable from having no listener. So the bar now asks for a
dispatch on every show (which also catches a rotation between two of them) and treats the display's
own navigation inset as a floor.

That floor is applied only while the window is edge to edge, which is what KeyboardInsets
exists to decide. Below API 35 the window manager places the keyboard above the navigation bar and
reports no inset; adding the display's measurement there would put a strip of dead keyboard on
every older phone — the exact opposite bug. There is no emulator assertion for "the user cannot
press this", so that arithmetic is a plain object with a test for each case, including the two
failure directions.

The mode chip keeps the smaller pill it was deliberately given (a second saturated block beside the
talk button competes with it) and gains the row's height as its touch target, through an inset
background rather than a taller fill.

Platforms

  • macOS
  • Windows
  • Android
  • iOS

./gradlew test and assembleDebug pass locally. Untested on a device — no emulator or phone
attached here, and the thing this fixes is by definition invisible without one. Worth a look on a
phone with three-button navigation, and on one running Android 14 or earlier to confirm the bar has
not grown a gap.

Checks

  • swift test, dotnet test and ./gradlew test pass
  • Comments explain why where a decision looks arbitrary
  • Nothing new is logged that could contain a transcript or a key

bojieli and others added 7 commits August 30, 2026 13:52
The Model field is free text because model IDs are not ours to enumerate, so it
accepts anything — including a sentence typed into a window that merely happened
to have the field focused. Nothing notices until the end of the next dictation,
where it comes back as a 404 that reads exactly like a bad key.

Checks shape and never existence: no client can know which models an account can
reach, but every client can know a model ID has no spaces in it and no CJK. What
passes is still sent to the provider, which stays the authority on whether the
model is real.

Mirrored word for word across Swift, C# and Kotlin, with the tests duplicated in
each language rather than shared — a fixture would be read by whichever platform
remembered to read it. The accepted set is taken from IDs these providers really
serve: google/gemini-3.6-flash, qwen2.5:7b-instruct-q4_K_M, ft:gpt-4o:acme:tone:1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
Two ways the caret could start in a text field of the General panel, both of
them in the field that saves as you type:

Left to itself AppKit hands a new window's first responder to its first text
field, which here is Model. `defaultFocus` pins it to nothing instead, at
`.userInitiated` priority — "no default" is a preference, and the automatic
evaluation would otherwise read it as no preference at all and fall back to that
same first field.

And the window is cached rather than released on close, which is what keeps the
sidebar where it was left — AppKit restores the first responder along with it.
So one click in Model, once, made every later ⌘, put the caret back there.
Reopening a settings window is not typing into it.

A missing key still takes the caret, from the task on the key field: that
placement has a reason and this one never had one.

The three model fields — primary, rewrite, fallback — now refuse to store a
value that could not be a model ID, and say why underneath. What was typed stays
in the field; the previous value stays in effect and keeps running dictations,
which is what "In use" now reports rather than the unsaved text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
Same guard and the same sentence as the macOS panel. iOS focuses nothing when
Settings opens — SwiftUI on iOS has no equivalent of the AppKit first-responder
grab — so this half is only the validation: the primary and fallback model
fields save as you type, and a stray keystroke should not replace a working
configuration before anyone can read why.

The note goes directly under the field it is about, like the endpoint note, since
this Section has no separate block of explanations the way macOS does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
Shown as the field is typed in rather than only when Save is pressed — the check
is about the shape of what is in the box, so it can answer immediately, and a
field that objects only after you commit it makes you find the mistake twice.

Save is refused outright, because the rest of the form saves around it: without
this the screen would say "Saved" while the one field just edited was the one
thing that had not been, and the previous model would go on running dictations
with nothing on screen saying so.

No focus change was needed here. `screenScaffold` already takes the initial focus
itself so the topmost EditText does not — it was added to stop a ScrollView
opening halfway down its own screen, and it happens to close this door too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
WinForms gives the focus to the first control in tab order when a form is shown,
which on the General tab is the Service dropdown with Model directly behind it.
Both take keystrokes aimed at neither: a DropDownList jumps its selection to
whatever letter is typed at it, and the Model box keeps the characters. Opening a
settings window is not typing into it, so nothing starts focused; Tab still
reaches every control in the same order.

The Model box says what is wrong with it as it is typed, and Save is refused
while it holds something that could not be a model ID. Refused before anything
else is written, because this method saves the whole form: otherwise the window
would report "Saved." while the one field just edited was the one thing that had
not been.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
PARITY gains the rule itself, since it is now a claim all four clients make in
the same words. MANUAL-CHECKS gains the focus check as its eighth entry, and it
belongs there rather than in a test for a concrete reason: a SwiftUI window
reports zero children to System Events, so nothing outside the app can ask which
field holds the caret. What can be checked from outside is the consequence —
open the panel, type, and see whether anything moved — including the reopened
case, which is the half AppKit had to be told about directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
… bar

Settings, the mode chip, Return and Backspace were 38dp tall and sat at the
bottom edge of a keyboard window that, from API 35, is laid out behind the
navigation bar. A three-button bar covered half of each of them.

The inset listener that was already there is not enough by itself. A view added
to a window that is already laid out is not guaranteed a dispatch, and a
listener that never ran leaves the padding at zero — which looks exactly like
having no listener at all. Three things together:

- the bar asks for a dispatch on every show, which also catches a rotation
  between two of them;
- KeyboardInsets takes the display's own navigation inset as a floor, but only
  while the window is edge to edge. Below API 35 the window manager puts the
  keyboard above the bar, so honouring a display measurement there would add a
  dead strip to every older phone. That distinction is the whole reason the
  arithmetic is a separate object with tests: there is no emulator assertion for
  "the user cannot press this";
- the row is 48dp, Android's minimum touch target, and the keys keep 10dp of
  trailing room whatever the bars do.

The mode chip stays visually smaller than the talk button it sits beside — that
was deliberate — and gains the row's height as its touch target through an
inset background rather than a taller fill.

Compiles and unit-tests locally; untested on a device.
@bojieli
bojieli merged commit 0204a2e into main Aug 30, 2026
13 checks passed
@bojieli
bojieli deleted the fix/android-keyboard-reach branch August 30, 2026 14:25
@bojieli
bojieli restored the fix/android-keyboard-reach branch August 30, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant