Skip to content

fix(tao/macos): keep the input-source indicator off a caret that is gone - #690

Merged
kdroidFilter merged 2 commits into
nucleus-2.6from
fix/macos-input-source-indicator
Sep 19, 2026
Merged

kdroidFilter merged 2 commits into
nucleus-2.6from
fix/macos-input-source-indicator

Conversation

@kdroidFilter

Copy link
Copy Markdown
Collaborator

Summary

On macOS the badge a Caps Lock bound to keyboard-layout switching raises is a window HIToolbox creates inside our own process, positioned from whatever firstRectForCharacterRange: answers. TaoView answered a process-global caret rect that nothing ever invalidated, so:

  • once a focused text field was destroyed, the badge kept appearing over the spot the field used to occupy;
  • before any field had been focused, the overrides were not even installed yet, so tao's own implementation answered — the content-rect corner with a top-down y handed back as a Cocoa bottom-up coordinate — parking the badge in the bottom-left corner of the screen for an app that has never shown a text field.

Three changes:

  • The cached rect is scoped to the view that pushed it, and every other answer is NSZeroRect. That exact shape is the only one AppKit reads as "no insertion point": a zero size alone does not suppress the badge ((0, 30, 0x0), what tao answers, still draws it), and selectedRange = NSNotFound + invalidateCharacterCoordinates change nothing.
  • The NSTextInputClient overrides are installed per window creation instead of on the first text-input session, so the answer is ours from the first frame (TaoView only exists once a window is built; the swizzle is idempotent).
  • The input context is deactivated when the session ends, carrying the activation token it was handed. Deactivating alone is not enough — interpretKeyEvents: re-activates it on the next keystroke — but it takes the badge down immediately for an app that is not being typed into. The token guard matters because focus moving between fields starts the incoming session before the outgoing one is torn down: without it, focusing a second field deactivates the context the first teardown then finds live (same ordering trap as the document cache).

Measurements behind each claim came from a logging NSTextInputClient probe driven by TISSelectInputSource, with the badge window tracked through CGWindowListCopyWindowInfo. A rect falling outside the key window is not drawn at all, which is why the corner case only reproduces on a window large enough to contain it.

Test plan

  • New headful cases macOS caret rect is dropped with the focused field and macOS publishes no caret rect before any text field; both fail on the code before this branch
  • Before/after on a real Nucleus window (2560x1080 at (0,0), no text field, input-source switch): badge at (-25, 1027) before, none after
  • Before/after with a focused field: badge follows the caret while the field lives, nothing once it is destroyed
  • #595 Kotoeri live-conversion and NSTextInputClient headful cases still pass
  • TaoPressAndHoldE2ETest — 4/4
  • Full headful suite: 285 run, 54 skipped, 4 failed — one flake (workspace header drag…, passes on retry) and three that fail identically on a clean tree (#569 popup above the work area, #569 dialog clamped back on, tab hover card)
  • apiCheck, detekt, ktlint, :decorated-window-tao:test

The badge a Caps Lock bound to keyboard-layout switching raises is a window
HIToolbox creates *inside our own process*, positioned from whatever
`firstRectForCharacterRange:` answers. TaoView answered a process-global caret
rect that nothing ever invalidated, so once a focused text field was destroyed
the badge kept appearing over the spot the field used to occupy. Before any
field had been focused it was worse: the overrides were only installed when the
first text-input session started, so tao's own implementation answered — the
content-rect corner with a *top-down* y handed back as a Cocoa bottom-up
coordinate — which parks the badge in the bottom-left corner of the screen of
an app that has never shown a text field.

Three changes, all measured against a logging `NSTextInputClient` probe driven
by `TISSelectInputSource` with the badge window tracked through
`CGWindowListCopyWindowInfo`:

The cached rect is now scoped to the view that pushed it, and every other
answer is `NSZeroRect`. That exact shape is the one AppKit reads as "no
insertion point": a zero *size* alone does not suppress the badge — `(0, 30,
0x0)`, which is what tao answers, still draws it — and `selectedRange =
NSNotFound` plus `invalidateCharacterCoordinates` change nothing at all. A
rect falling outside the key window is not drawn either, which is why the
corner case only shows on a window large enough to contain it.

The overrides are installed per window creation rather than on the first
session, so the answer is ours from the first frame. `TaoView` only exists once
a window has been built, and the swizzle is idempotent.

The input context is deactivated when the session ends. On its own that is not
enough — `interpretKeyEvents:` re-activates it on the next keystroke — but it
takes the badge down immediately for an app that is not being typed into. The
teardown carries the activation token it was handed, because focus moving
between fields starts the incoming session *before* the outgoing one is torn
down: without the guard, focusing a second field deactivates the context the
first teardown then finds live. Same ordering trap as the document cache.
`MacOsTextInputClientProbe.imeRect` reads what TaoView answers AppKit and
reports an all-zero rect as "no insertion point", which is the property both
cases assert.

`caretRectDiesWithTheFocusedField` walks one field lifecycle: the caret is
published, it follows focus to a second field (the ordering guard — without it
the run takes three times as long because the incoming session's context is
deactivated and has to be re-established), it is dropped when the fields are
destroyed, a keystroke does not republish it, and a field composed again gets
it back. `noCaretRectBeforeAnyField` covers the window that never shows one.

Both fail on the code before this branch.
@kdroidFilter
kdroidFilter merged commit 7a248b9 into nucleus-2.6 Sep 19, 2026
11 of 23 checks passed
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