Skip to content

fix(tao): drive macOS press-and-hold from replacementRange, not heuristics - #617

Merged
kdroidFilter merged 3 commits into
mainfrom
fix/612-press-and-hold-chrome-parity
Aug 30, 2026
Merged

fix(tao): drive macOS press-and-hold from replacementRange, not heuristics#617
kdroidFilter merged 3 commits into
mainfrom
fix/612-press-and-hold-chrome-parity

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

macOS press-and-hold is rewritten to the contract every document-backed AppKit client follows — Chrome, Notes, TextEdit — replacing the forced user default and the picker-detection state machine.

Two bugs shared one root cause: Nucleus was guessing when the accent picker was active instead of listening to the protocol.

  • macOS: forcing ApplePressAndHoldEnabled leaves letter keys dead where the picker cannot appear #612ApplePressAndHoldEnabled was forced on in four domains (app, argument volatile, CFPreferences, registration) from both +load and runtime. Forcing it suppresses key repeat but cannot make the picker engage; where it cannot (non-Apple keyboards, Karabiner virtual devices) a held letter did nothing at all — no repeat, no picker. Strictly worse than either OS behaviour, and invisible: every other app on that machine repeats, the Nucleus one just stops.
  • fix(tao): stop key roll-over from being read as a PressAndHold accent pick #611 — the heuristics (g_letter_key_down, g_press_and_hold_queried, g_base_text) misread ordinary fast typing. With the previous key still physically down, a selectedRange query armed the picker flag and the next commit was routed into the replace path, deleting the character just committed: XcodeXcde.

What Chrome actually does

ApplePressAndHoldEnabled has zero occurrences in the Chromium tree and its entire commit history — the OS decides. The picker works because RenderWidgetHostViewCocoa answers selectedRange / attributedSubstringForProposedRange over a cached window of committed text (the renderer pushes selection ±100 chars) and honors replacementRange in insertText:. There is no press-and-hold state machine anywhere in content/.

Recording AppKit against a reference NSTextView and a custom document-backed NSTextInputClient confirms the wire protocol: the accent pick arrives as insertText:"é" replacementRange:{caret-1, 1} — UTF-16, document-absolute. The range carries everything; no detection is needed.

This PR

  • Never reads, sets or registers the user default.
  • The host pushes the focused field's committed text (bounded window), its document-absolute offset and the selection via nativeSetImeDocument; the swizzled getters serve document-absolute answers from that cache. The marked-text anchor is maintained optimistically in a setMarkedText: swizzle — Chromium's _markedRange fallback chain.
  • insert_text routes a valid replacementRange outside a composition to a new WindowEvent::ImeReplaceCommit, and TaoImeSession.replaceCommit applies it select-then-insert (Blink's ReplaceTextAndKeepSelection) instead of a blind deleteSurroundingTextInCodePoints(1, 0).
  • Going through tao's event queue also removes a re-entrant EVENT_CALLBACK lock: the previous direct upcall from the AppKit callout deadlocked when the JVM re-entered native on the same thread.

Repeat keyDowns still reach interpretKeyEvents: (unchanged) — that is what lets the picker engage where the OS allows it.

Everything touched is internal; no ABI change (apiCheck clean). GraalVM reachability metadata updated for the new callback signature.

Test plan

TaoPressAndHoldE2ETest (opt-in, NUCLEUS_TAO_SMOKE=1) posts real CGEvents at the session tap with kCGKeyboardEventAutorepeat stamped, against a focused Compose field. One child JVM per scenario, so ApplePressAndHoldEnabled is controlled through the process argument domain. Expectations are the behaviour recorded from AppKit driving NSTextView / a document-backed client on the same machine.

Scenario Before After
User disabled press-and-hold, held letter edead key (#612) eeeeee
Press-and-hold enabled, hold then type x x — held letter eaten ^e+x$
Picker commit insertText:"é" rr:{0,1} deadlock (watchdog) é
Roll-over typing xcode xcde (#611) xcode
  • All four scenarios fail on main, pass after the fix
  • 4 consecutive full runs green (the only flake found was a lingering system picker bubble stealing the next scenario's keys — scenarios now dismiss it with Escape, as a user would)
  • #595 Kotoeri headful cases pass — the IME cross-checks exactly the NSTextInputClient answers rewritten here
  • Full headful suite: 25 PASS / 0 FAIL
  • :decorated-window-tao:test green, incl. 3 new stage-1 cases in TaoSceneImeTest (in-range replacement, surrounding text intact, out-of-bounds range clamped)
  • ktlintCheck / detekt / apiCheck green
  • Verify on a machine where the picker cannot engage (@takke's Mac mini M4 Pro + Karabiner): a held letter should now repeat like Notes

Notes

Supersedes #611 — that PR patches nucleus_note_press_and_hold_query() and nucleus_insert_text(), functions this branch deletes along with the rest of the state machine. Its roll-over scenario is preserved as a permanent e2e case. @takke's diagnosis is what pinned the real problem: guarding an architecture that guesses, rather than reading the range AppKit already provides.

With the user default no longer forced, the opt-out requested in #612 is unnecessary — Nucleus now behaves like every other macOS app.

Nucleus forced `ApplePressAndHoldEnabled` on in four domains — app,
argument volatile, CFPreferences, registration — from both `+load` and
runtime, then guessed when the accent picker was active from a state
machine (`g_letter_key_down`, `g_press_and_hold_queried`, `g_base_text`).
Both halves were wrong:

- Forcing the default suppresses key repeat, but does not guarantee the
  picker engages. Where it cannot (non-Apple keyboards, Karabiner virtual
  devices), a held letter did nothing at all — no repeat, no picker,
  strictly worse than either OS behaviour and invisible to the user
  (#612).
- The heuristics misread ordinary fast typing: with the previous key
  still physically down, a `selectedRange` query armed the picker flag
  and the next commit was routed into the replace path, deleting the
  character that had just been committed ("Xcode" landing as "Xcde")
  (#611).

Chromium does neither. `ApplePressAndHoldEnabled` has zero occurrences
in its tree and its history; the picker works because
RenderWidgetHostViewCocoa answers `selectedRange` /
`attributedSubstringForProposedRange` over a cached window of committed
text and honors `replacementRange` in `insertText:`. Recording AppKit
against an `NSTextView` and a document-backed `NSTextInputClient`
confirms the protocol: the accent pick arrives as `insertText:"é"
replacementRange:{caret-1, 1}` — UTF-16, document-absolute — and the
range carries everything. No client-side detection is involved.

Same model here:

- Never read, set or register the user default. The OS decides whether a
  held letter repeats or opens the picker.
- The host pushes the focused field's committed text (a bounded window),
  its document-absolute offset and the selection through
  `nativeSetImeDocument`, mirroring Chromium's renderer-to-browser
  selection + surrounding-text push. The swizzled getters serve
  document-absolute answers from that cache; the marked-text anchor is
  maintained optimistically in a `setMarkedText:` swizzle, the same
  fallback chain Chromium uses for `_markedRange`.
- `insert_text` routes a valid `replacementRange` outside a composition
  to `WindowEvent::ImeReplaceCommit`, and `TaoImeSession.replaceCommit`
  applies it select-then-insert — Blink's `ReplaceTextAndKeepSelection`
  semantics — instead of a blind `deleteSurroundingTextInCodePoints(1, 0)`.

Routing the pick through tao's event queue also removes a re-entrant
`EVENT_CALLBACK` lock: the previous direct upcall from the AppKit callout
deadlocked when the JVM re-entered native on the same thread.

Repeat keyDowns still reach `interpretKeyEvents:` (unchanged), which is
what lets the picker engage where the OS allows it.

Closes #612.
`TaoPressAndHoldE2ETest` (opt-in, `NUCLEUS_TAO_SMOKE=1`) replays real
keyboard traffic — CGEvents at the session tap, `kCGKeyboardEventAutorepeat`
stamped so AppKit sees a held key — against a focused Compose field. Each
scenario runs in its own child JVM so `ApplePressAndHoldEnabled` can be
set per scenario through the process argument domain, and because
`taoApplication` ends in `exitProcess(0)`.

The expectations are not invented: they are the behaviour recorded from
AppKit driving a reference `NSTextView` and a document-backed
`NSTextInputClient` on the same machine — what Chrome, Notes and TextEdit
do.

- press-and-hold disabled by the user: a held letter repeats
- press-and-hold enabled: the held letter never goes dead and the next
  keystroke is not eaten
- the picker's `insertText:"é" replacementRange:{0, 1}` replaces the base
  letter
- key roll-over while typing `xcode` is not misread as an accent pick

All four fail on the previous implementation: 'e' instead of 'eeeeee',
'x' instead of 'e…x', a watchdog timeout on the replacement commit, and
'xcde' instead of 'xcode'.

Stage-1 coverage for the replacement commit itself lands in
`TaoSceneImeTest`: in-range replacement, surrounding text left intact
with the caret after the accent, and an out-of-bounds range clamped
rather than thrown.

Two traps worth knowing, both hit while writing this:

- A scenario that engages the real picker must dismiss it with Escape
  before the next keystroke and at scenario end — the bubble is a system
  window and outlives the child process, so it steals the following
  scenario's keys.
- CGEvent posting inherits the machine keyboard layout, so the probe
  sanity-checks the first keystroke and reports `skip=layout` instead of
  failing on a non-Latin layout.

`nucleus_tao_post_key_to_view` gained an autorepeat flag and
`nucleus_tao_inject_insert_text` a replacement range; both default to the
previous behaviour for existing callers.
Follow-ups from review of the two commits above.

Correctness:

- The `ImeReplaceCommit` branch did not set `key_triggered_ime`, so the
  key that picked the accent was also delivered as a raw key event. An app
  shortcut bound to a digit fired while the user was only choosing an
  accent. Both sibling IME branches already set it; the #595 invariant is
  that a key the input method consumed is not double-delivered.
- `attributedSubstringForProposedRange:` could underflow: `NSMaxRange`
  wraps on an overflowing proposed range, slips past both guards, and
  `end - loc` became a huge length, raising `NSRangeException` from inside
  an AppKit callout.
- Document-cache invalidation was global, so focus moving between windows
  (the old session tears down after the new one starts) wiped the cache
  the newly focused field had just installed. It is now scoped to the
  owning view, resets the marked-text anchor, and `detach()` invalidates
  explicitly so a freed view's address cannot be inherited by a later view
  allocated at the same address.
- A stale `markedText` ivar pinned the marked-text anchor from a previous
  field; an invalid cache now forces a re-anchor.
- `replaceCommit` ends the composition before committing: `commitText`
  replaces the composing region when one exists, which would have silently
  ignored the selection it had just set.
- The no-session fallback hardcoded a single Backspace regardless of the
  replaced length; it now deletes what the range asked for, bounded.

Fidelity and cost, both on the keystroke hot path:

- `nativeSetImeDocument` read the JVM string through modified UTF-8 and a
  lossy decode — two extra copies, and a length that can drift from the
  offsets the JVM computed (unpaired surrogates). It now reads the UTF-16
  directly via `GetStringRegion`, as its comment always claimed.
- The pushed window drops from ±512 to ±128 UTF-16 units, closer to the
  ±100 Chromium ships. The only reader is
  `attributedSubstringForProposedRange:`, which is only ever asked near
  the caret.
- `dispatch_ime_replace_commit` reuses the shared JNI helper instead of
  duplicating its attach/exception handling.

Tests:

- The probe left `e` held down when the layout-sanity check bailed out.
  Key state lives in the window server and outlives the child process, so
  that leaked a stuck key into the next scenario and the rest of the
  session.
- The repeat scenario asserted an exact six characters; the claim is that
  the key repeats, not how many synthetic autorepeats survive coalescing.
@takke

takke commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Tested this branch (cf83ec2) on the same machine that produced both reports — Mac mini M4 Pro, non-Apple (Windows layout) keyboard, Karabiner-Elements — running our app with real-world typing. Everything checks out:

The replacementRange-driven design is clearly the right call — thanks for turning two symptom reports into a protocol-level fix. From our side both #611 and #612 can be closed in favor of this.

@kdroidFilter

Copy link
Copy Markdown
Collaborator Author

@takke Thank you very much, I was going to ask you to check but I fell asleep before I could :)

@kdroidFilter
kdroidFilter merged commit 89de516 into main Aug 30, 2026
19 of 23 checks passed
@kdroidFilter
kdroidFilter deleted the fix/612-press-and-hold-chrome-parity branch August 30, 2026 04:19
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.

2 participants