From e37a77777fa4769f27dde76d6abd2495403a5985 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 29 Aug 2026 23:15:14 +0300 Subject: [PATCH 1/3] fix(tao): drive macOS press-and-hold from replacementRange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../window/tao/TaoApplication.kt | 4 +- .../nucleusframework/window/tao/TaoWindow.kt | 15 +- .../window/tao/ffi/NativeTaoBridge.kt | 45 ++- .../window/tao/scene/TaoComposeSceneHost.kt | 45 +++ .../window/tao/scene/TaoImeSession.kt | 47 ++- .../src/main/native/macos/kotoeri.m | 5 +- .../main/native/macos/main_thread_dispatch.m | 362 +++++++++--------- .../native/macos/text_input_client_probe.m | 17 +- .../src/main/native/src/event_loop.rs | 20 +- .../src/main/native/src/events.rs | 35 ++ .../src/main/native/src/platform/macos/ffi.rs | 25 +- .../src/main/native/src/platform/macos/ime.rs | 103 +++-- .../src/main/native/vendor/tao/src/event.rs | 42 ++ .../tao/src/platform_impl/macos/view.rs | 18 +- .../reachability-metadata.json | 8 +- .../window/tao/headful/MacOsKotoeriProbe.kt | 4 +- .../tao/headful/MacOsTextInputClientProbe.kt | 15 +- 17 files changed, 529 insertions(+), 281 deletions(-) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt index f363b109c..c5671ef52 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt @@ -185,8 +185,10 @@ public object TaoApplication { override fun onImeReplaceCommit( handle: Long, text: String, + replacementStart: Long, + replacementLength: Long, ) { - lookup(handle)?.dispatchImeReplaceCommit(text) + lookup(handle)?.dispatchImeReplaceCommit(text, replacementStart, replacementLength) } override fun onImePreedit( diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt index abfdc1051..1f4f039c4 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt @@ -1059,11 +1059,20 @@ public class TaoWindow internal constructor( keyListener?.onKey(type, vkCode, keyLocation, modifiers, codePoint) } + /** + * macOS replacement commit — `insertText:` with a valid + * `replacementRange` (UTF-16, document-absolute) outside a composition. + * See [NativeTaoBridge.EventCallback.onImeReplaceCommit]. + */ @Volatile - internal var imeReplaceCommit: ((String) -> Unit)? = null + internal var imeReplaceCommit: ((String, Long, Long) -> Unit)? = null - internal fun dispatchImeReplaceCommit(text: String) { - imeReplaceCommit?.invoke(text) + internal fun dispatchImeReplaceCommit( + text: String, + replacementStart: Long, + replacementLength: Long, + ) { + imeReplaceCommit?.invoke(text, replacementStart, replacementLength) } /** diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt index b7fb90c04..1f8f3c876 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt @@ -107,13 +107,20 @@ internal object NativeTaoBridge { } /** - * macOS PressAndHold picked an accent. The base letter is already in - * the field; the host must replace it via Compose `TextEditingScope` - * (same as `DesktopTextInputService2` / JDK-8074882). Default no-op. + * macOS replacement commit: `insertText:` with a valid + * `replacementRange` outside a composition — how the press-and-hold + * accent picker replaces the base letter on a document-backed client + * (#611/#612). [replacementStart] / [replacementLength] are UTF-16 + * offsets in the same document-absolute space the host pushed + * through [nativeSetImeDocument]; the host replaces that range via + * Compose `TextEditingScope` (select-then-insert, Chromium's + * `ImeCommitText` semantics). Default no-op. */ fun onImeReplaceCommit( handle: Long, text: String, + replacementStart: Long, + replacementLength: Long, ) { } @@ -295,7 +302,8 @@ internal object NativeTaoBridge { * to TaoView for [handle]. [keyCode] is a Carbon virtual key * (`kVK_ANSI_*`). This is the same path a physical keystroke takes, so * Kotoeri's `interpretKeyEvents:` → `setMarkedText:` / `insertText:` - * runs for real. + * runs for real. [autorepeat] marks the event as a key repeat (held + * key) — what AppKit's press-and-hold machinery engages on. */ @JvmStatic external fun nativeMacOsPostKeyToView( @@ -303,6 +311,7 @@ internal object NativeTaoBridge { keyCode: Int, characters: String, down: Boolean, + autorepeat: Boolean, ): Boolean /** @@ -331,12 +340,18 @@ internal object NativeTaoBridge { ): Boolean /** - * macOS only, headful e2e: invoke `insertText:replacementRange:` on TaoView. + * macOS only, headful e2e: invoke `insertText:replacementRange:` on + * TaoView. A negative [replacementLocation] injects `{NSNotFound, 0}` + * (ordinary typing); a non-negative one replays the accent-picker + * replacement commit (`insertText:"é" replacementRange:{caret-1, 1}`, + * UTF-16 document-absolute). */ @JvmStatic external fun nativeMacOsInjectInsertText( handle: Long, text: String, + replacementLocation: Long, + replacementLength: Long, ): Boolean /** @@ -641,6 +656,26 @@ internal object NativeTaoBridge { height: Int, ) + /** + * macOS only: pushes the focused field's committed text (a bounded + * window), the window's document-absolute offset and the selection to + * the native `NSTextInputClient` cache — all offsets in UTF-16 code + * units. AppKit reads `selectedRange` / `attributedSubstringForProposedRange` + * from this cache (Chromium parity: the renderer→browser selection + + * surrounding-text push), which is what lets the press-and-hold accent + * picker engage and commit through `insertText:replacementRange:`. + * + * A negative [selectionStart] invalidates the cache (no focused field). + */ + @JvmStatic + external fun nativeSetImeDocument( + handle: Long, + text: String, + offset: Long, + selectionStart: Long, + selectionEnd: Long, + ) + /** Calls `[view.inputContext activate]` for TaoView's NSTextInputClient. */ @JvmStatic external fun nativeActivateInputContext(handle: Long) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index a5e008c4b..0c4c7b50a 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -1672,13 +1672,55 @@ private class TaoPlatformContext( } } } + launch { + // macOS document cache (Chromium parity: the renderer + // pushes selection + surrounding text so the browser + // view can answer `selectedRange` / + // `attributedSubstringForProposedRange` locally). This + // is what lets AppKit's press-and-hold picker engage on + // the committed text and commit through + // `insertText:replacementRange:` (#611/#612). + androidx.compose.runtime + .snapshotFlow { + request.value() + }.collect { value -> + pushImeDocument(windowHandle, value) + } + } awaitCancellation() } } finally { + NativeTaoBridge.nativeSetImeDocument(windowHandle, "", 0L, -1L, -1L) onInputSession(null) } } + /** + * Pushes a bounded UTF-16 window of the field text around the selection + * (Chromium ships ±100 chars; we ship ±[IME_DOCUMENT_WINDOW_UTF16]) plus + * the document-absolute selection. Window edges are nudged off surrogate + * pairs so the native `NSString` never receives a half code point. + */ + private fun pushImeDocument( + windowHandle: Long, + value: androidx.compose.ui.text.input.TextFieldValue, + ) { + val text = value.text + val selMin = value.selection.min + val selMax = value.selection.max + var start = (selMin - IME_DOCUMENT_WINDOW_UTF16).coerceAtLeast(0) + var end = (selMax + IME_DOCUMENT_WINDOW_UTF16).coerceAtMost(text.length) + if (start in 1 until text.length && text[start].isLowSurrogate()) start-- + if (end in 1 until text.length && text[end].isLowSurrogate()) end++ + NativeTaoBridge.nativeSetImeDocument( + windowHandle, + text.substring(start, end), + start.toLong(), + selMin.toLong(), + selMax.toLong(), + ) + } + private fun mapPointerIcon(icon: androidx.compose.ui.input.pointer.PointerIcon): Int { when { icon === androidx.compose.ui.input.pointer.PointerIcon.Default -> return TaoCursorIcon.DEFAULT @@ -1703,3 +1745,6 @@ private class TaoPlatformContext( }.getOrDefault(TaoCursorIcon.DEFAULT) } } + +/** UTF-16 code units of committed text shipped on each side of the selection. */ +private const val IME_DOCUMENT_WINDOW_UTF16 = 512 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoImeSession.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoImeSession.kt index aba0339af..7efd34f42 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoImeSession.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoImeSession.kt @@ -10,8 +10,10 @@ import androidx.compose.ui.platform.PlatformTextInputMethodRequest * - [preedit] — macOS `setMarkedText:` / `unmarkText` (empty text cancels) * - [commit] — `insertText:` while a composition is active, via * `TextEditingScope.commitText` (replaces the composing region in one edit) - * - [replaceCommit] — PressAndHold accent pick (delete the already-committed - * base letter, then commit the accented character) + * - [replaceCommit] — `insertText:` with a valid `replacementRange` outside + * a composition (the press-and-hold accent picker replacing the base + * letter); the range is replaced select-then-insert, Chromium's + * `ImeCommitText` semantics (#611/#612) * * Raw key delivery while the IME owns the keyboard is decided natively * (`keyDown`/`keyUp` in tao's macOS view). This object does not filter keys. @@ -79,25 +81,38 @@ internal class TaoImeSession( } /** - * PressAndHold picked an accent. The base letter is already in the - * field; replace it via `TextEditingScope` (same as - * `DesktopTextInputService2` / JDK-8074882). Falls back to a typed-key - * sequence when no text-input session is up yet. + * Commits [rawText] in place of the committed-text range + * [[replacementStart], [replacementStart] + [replacementLength]) \u2014 + * UTF-16 offsets in the same document-absolute space the host reports + * through `nativeSetImeDocument`. This is `insertText:` with a valid + * `replacementRange` (the press-and-hold accent picker replacing its + * base letter, #611/#612). Select-then-insert, exactly like Blink's + * `ReplaceTextAndKeepSelection` / WebKit's `_selectNSRange:` + insert. + * Falls back to a typed-key sequence when no text-input session is up. */ - fun replaceCommit(rawText: String) { - // Apple corporate (function-key) characters must never reach the field: - // they render as tofu, and `deleteSurroundingTextInCodePoints` would - // still remove a real character before "inserting" them (#595). + fun replaceCommit( + rawText: String, + replacementStart: Long, + replacementLength: Long, + ) { + // Apple corporate (function-key) characters must never reach the + // field: they render as tofu (#595). val text = rawText.filterNot { it in '\uF700'..'\uF8FF' } if (text.isEmpty()) return val request = activeRequest - if (request != null) { - request.editText { - deleteSurroundingTextInCodePoints(1, 0) - commitText(text, 1) - } + if (request == null) { + typedFallback(text) return } - typedFallback(text) + val length = request.value().text.length + val start = replacementStart.coerceIn(0L, length.toLong()).toInt() + val end = + (replacementStart + replacementLength) + .coerceIn(start.toLong(), length.toLong()) + .toInt() + request.editText { + setSelection(start, end) + commitText(text, 1) + } } } diff --git a/decorated-window-tao/src/main/native/macos/kotoeri.m b/decorated-window-tao/src/main/native/macos/kotoeri.m index 34802dc52..fea7ab6fe 100644 --- a/decorated-window-tao/src/main/native/macos/kotoeri.m +++ b/decorated-window-tao/src/main/native/macos/kotoeri.m @@ -203,7 +203,8 @@ int nucleus_tao_post_key_to_view( int64_t ns_view_ptr, int key_code, const char *chars, - int down + int down, + int autorepeat ) { if (ns_view_ptr == 0 || chars == NULL) { return 0; @@ -226,7 +227,7 @@ int nucleus_tao_post_key_to_view( if (cg == NULL) { return 0; } - CGEventSetIntegerValueField(cg, kCGKeyboardEventAutorepeat, 0); + CGEventSetIntegerValueField(cg, kCGKeyboardEventAutorepeat, autorepeat != 0 ? 1 : 0); CGEventPost(kCGSessionEventTap, cg); CFRelease(cg); return 1; diff --git a/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m b/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m index c6a12ad47..5dc6492e9 100644 --- a/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m +++ b/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m @@ -12,6 +12,7 @@ #import #import #include +#include @interface NucleusTaoMainLauncher : NSObject { @@ -62,45 +63,16 @@ void nucleus_tao_install_cmd_q_handler(void) { } // macOS press-and-hold (long-press a key → accent picker) is gated by the -// `ApplePressAndHoldEnabled` user default. We set it everywhere we can — App -// domain, Argument volatile domain, CFPreferences, registration domain — both -// from `+load` (= dyld load time, before any Compose/AWT static init) and at -// runtime. The picker itself is an input method: it only engages when -// `interpretKeyEvents:` sees the *repeat* keyDown after the initial press. -// Tao used to skip those repeats (see vendored `view.rs`); that was the -// actual blocker, not the NSView class hierarchy. These defaults stay -// required so a user-level `defaults write -g ApplePressAndHoldEnabled -bool -// false` cannot silently disable the picker for Nucleus apps. -static void nucleus_tao_force_press_and_hold(void) { - @autoreleasepool { - [[NSUserDefaults standardUserDefaults] - setVolatileDomain:@{@"ApplePressAndHoldEnabled": @YES} - forName:NSArgumentDomain]; - [[NSUserDefaults standardUserDefaults] - setBool:YES forKey:@"ApplePressAndHoldEnabled"]; - [[NSUserDefaults standardUserDefaults] synchronize]; - CFPreferencesSetAppValue(CFSTR("ApplePressAndHoldEnabled"), - kCFBooleanTrue, - kCFPreferencesCurrentApplication); - CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication); - [[NSUserDefaults standardUserDefaults] registerDefaults:@{ - @"ApplePressAndHoldEnabled": @YES, - }]; - } -} - -@interface NucleusTaoPressAndHoldEnabler : NSObject -@end - -@implementation NucleusTaoPressAndHoldEnabler -+ (void)load { - nucleus_tao_force_press_and_hold(); -} -@end - -void nucleus_tao_enable_press_and_hold(void) { - nucleus_tao_force_press_and_hold(); -} +// `ApplePressAndHoldEnabled` user default. Like Chromium (zero occurrences +// of the key in its tree), Nucleus never reads, sets or registers it — the +// OS/user decides whether a held letter repeats or opens the picker (#612). +// Forcing it on used to leave letter keys dead wherever the picker cannot +// engage (non-Apple keyboards, Karabiner virtual devices): the repeat was +// suppressed and nothing appeared in its place. The picker works because +// TaoView answers `selectedRange` / `attributedSubstringForProposedRange` +// over the committed text (document cache below) and honors +// `replacementRange` in `insertText:` (vendored view.rs) — and because +// repeat keyDowns are fed to `interpretKeyEvents:` (also view.rs). // ── IME caret rect plumbing (used by `firstRectForCharacterRange:` swizzle) ── // @@ -122,78 +94,128 @@ static NSRect tao_view_first_rect_for_character_range( return NSMakeRect(g_ime_screen_x, g_ime_screen_y, g_ime_w, g_ime_h); } -// PressAndHold (Apple PH11264) is not a marked-text IME on a custom NSView. -// Compose Desktop documents the same constraint in DesktopTextInputService2 -// (workaround for JDK-8074882): -// 1. The base letter is committed as a normal insertText:. -// 2. AppKit then queries selectedRange / attributedSubstring while the -// letter key is still down — that is the picker starting. -// 3. The accent arrives as a later insertText:. We replace the previous -// code point via Compose TextEditingScope, not a synthetic Backspace -// (those race ordinary typing and erase characters). -static BOOL g_did_insert_base = NO; -static BOOL g_letter_key_down = NO; -static BOOL g_press_and_hold_queried = NO; -static NSString *g_base_text = nil; -static unsigned short g_base_key_code = 0xFFFF; -static IMP g_orig_insert_text = NULL; -static IMP g_orig_key_up = NULL; -static IMP g_orig_attributed_substring = NULL; -static void (*g_ime_replace_commit)(long ns_view, const char *utf8) = NULL; - -void nucleus_tao_register_ime_replace_commit(void (*cb)(long, const char *)) { - g_ime_replace_commit = cb; +// ── Document-backed NSTextInputClient answers (Chromium parity) ───────────── +// +// TaoView's own NSTextInputClient (vendored view.rs) only knows the marked +// text — it has no document. AppKit's press-and-hold picker needs more: it +// reads `selectedRange` when it engages and commits the accent as +// `insertText:"é" replacementRange:{caret-1, 1}` — a UTF-16 document-absolute +// range. Chromium solves this with a cached window of committed text around +// the selection, pushed asynchronously from the renderer +// (`setTextSelectionText:offset:range:` in RenderWidgetHostViewCocoa). Same +// model here: the JVM pushes the focused field's text window, window offset +// and selection on every field change; the swizzled getters serve those +// answers. All access is on the AppKit main thread (the Tao event loop *is* +// `Dispatchers.Main`). +// +// During a composition the marked text bookkeeping stays authoritative and +// synchronous in the view's ivar; only its *location* needs an absolute +// anchor, maintained optimistically in the `setMarkedText:` swizzle exactly +// like Chromium's `_markedRange` fallback chain (replacementRange → +// selection start at composition start → keep). +static int64_t g_doc_view = 0; +static NSString *g_doc_text = nil; +static int64_t g_doc_offset = 0; +static NSRange g_doc_selection = {NSNotFound, 0}; +static NSUInteger g_marked_anchor = 0; + +void nucleus_tao_set_ime_document( + int64_t ns_view_handle, + const uint16_t *utf16, + int64_t utf16_len, + int64_t offset, + int64_t sel_start, + int64_t sel_end +) { + if (sel_start < 0 || utf16 == NULL) { + g_doc_view = 0; + g_doc_text = nil; + g_doc_offset = 0; + g_doc_selection = NSMakeRange(NSNotFound, 0); + return; + } + g_doc_view = ns_view_handle; + g_doc_text = [NSString stringWithCharacters:(const unichar *)utf16 + length:(NSUInteger)utf16_len]; + g_doc_offset = offset; + g_doc_selection = NSMakeRange( + (NSUInteger)sel_start, + (NSUInteger)(sel_end >= sel_start ? sel_end - sel_start : 0) + ); } -static BOOL nucleus_is_letter_key_event(NSEvent *event) { - if (!event || event.type != NSEventTypeKeyDown) return NO; - NSString *chars = event.charactersIgnoringModifiers; - if (chars.length != 1) return NO; - unichar c = [chars characterAtIndex:0]; - return [[NSCharacterSet letterCharacterSet] characterIsMember:c]; +static BOOL nucleus_doc_valid_for(id view) { + return g_doc_view != 0 && + g_doc_view == (int64_t)(intptr_t)(__bridge void *)view && + g_doc_text != nil && + g_doc_selection.location != NSNotFound; } -/// JBR's `-[NSEvent isARepeat]` throws on non-key events (KitDefined -/// window-update events are often `currentEvent` when IMKit calls -/// `insertText:` outside `keyDown:`). -static BOOL nucleus_event_is_key_repeat(NSEvent *event) { - if (event == nil) { - return NO; - } - NSEventType type = event.type; - if (type != NSEventTypeKeyDown && type != NSEventTypeKeyUp) { - return NO; +static IMP g_orig_selected_range = NULL; +static IMP g_orig_marked_range = NULL; +static IMP g_orig_attributed_substring = NULL; +static IMP g_orig_set_marked_text = NULL; + +/// Tao's own `markedRange` — `{0, len}` while composing, `{NSNotFound, 0}` +/// otherwise. The length is authoritative (updated synchronously by IMKit's +/// own `setMarkedText:`); only the location is view-relative. +static NSRange nucleus_orig_marked_range(id self) { + if (g_orig_marked_range) { + return ((NSRange (*)(id, SEL))g_orig_marked_range)(self, @selector(markedRange)); } - return event.isARepeat; + return NSMakeRange(NSNotFound, 0); } -static void nucleus_clear_press_and_hold(void) { - g_did_insert_base = NO; - g_letter_key_down = NO; - g_press_and_hold_queried = NO; - g_base_text = nil; - g_base_key_code = 0xFFFF; +static BOOL nucleus_is_composing(id self) { + NSRange marked = nucleus_orig_marked_range(self); + return marked.location != NSNotFound && marked.length > 0; } -static void nucleus_note_press_and_hold_query(void) { - if (g_did_insert_base && g_letter_key_down) { - g_press_and_hold_queried = YES; +/// `selectedRange` is document-absolute, like every document-backed client +/// (NSTextView, Chromium). While composing, tao reports the IME's selection +/// relative to the marked text — shift it by the composition anchor. +static NSRange tao_view_selected_range(id self, SEL _cmd) { + if (nucleus_is_composing(self)) { + NSRange rel = g_orig_selected_range + ? ((NSRange (*)(id, SEL))g_orig_selected_range)(self, _cmd) + : NSMakeRange(0, 0); + return NSMakeRange(g_marked_anchor + rel.location, rel.length); + } + if (nucleus_doc_valid_for(self)) { + return g_doc_selection; } + return NSMakeRange(0, 0); } -// PressAndHold reads `selectedRange` after the base letter is committed — -// that query is how we detect the picker (Compose AWT uses getSelectedText). -// Forward to TaoView so IMKit sees the real caret / marked-text selection -// (nucleusframework#595); `{0, 0}` is only the fallback when the original -// implementation is missing. -static IMP g_orig_selected_range = NULL; +static NSRange tao_view_marked_range(id self, SEL _cmd) { + (void)_cmd; + NSRange marked = nucleus_orig_marked_range(self); + if (marked.location == NSNotFound || marked.length == 0) { + return NSMakeRange(NSNotFound, 0); + } + return NSMakeRange(g_marked_anchor, marked.length); +} -static NSRange tao_view_selected_range(id self, SEL _cmd) { - nucleus_note_press_and_hold_query(); - if (g_orig_selected_range) { - return ((NSRange (*)(id, SEL))g_orig_selected_range)(self, _cmd); +/// Maintains the absolute anchor of the marked text. A valid +/// replacementRange wins; otherwise a *starting* composition anchors at the +/// committed caret; a continuing one keeps its anchor (Chromium's +/// `_markedRange` fallback chain). The replacement's delete-committed-text +/// side is not applied — same self-declared limitation as Chromium's +/// `setMarkedText:` ("hard to support replacementRange without accessing +/// the full web content"); no mainstream IME depends on it. +static void tao_view_set_marked_text( + id self, SEL sel, id string, NSRange selectedRange, NSRange replacementRange +) { + if (replacementRange.location != NSNotFound) { + g_marked_anchor = replacementRange.location; + } else if (!nucleus_is_composing(self)) { + g_marked_anchor = nucleus_doc_valid_for(self) ? g_doc_selection.location : 0; + } + if (g_orig_set_marked_text) { + ((void (*)(id, SEL, id, NSRange, NSRange))g_orig_set_marked_text)( + self, sel, string, selectedRange, replacementRange + ); } - return NSMakeRange(0, 0); } // Tao's `validAttributesForMarkedText` returns `@[]`, which AppKit treats as @@ -212,93 +234,61 @@ static NSRange tao_view_selected_range(id self, SEL _cmd) { ]; } -static NSString *nucleus_string_from_ime_arg(id string) { - if ([string isKindOfClass:[NSAttributedString class]]) { - return [(NSAttributedString *)string string]; - } - return (NSString *)string; -} - +/// While composing, the marked text in the view's ivar is authoritative — +/// serve requests inside the (absolute) marked range from it. Everything +/// else is served from the JVM-pushed committed-text window, clamped like +/// Chromium's `attributedSubstringForProposedRange:` (answer locally, write +/// the clamped `actualRange`, `nil` outside the window). static id nucleus_attributed_substring( id self, SEL sel, NSRange range, NSRangePointer actual ) { - nucleus_note_press_and_hold_query(); - if (g_orig_attributed_substring) { - return ((id (*)(id, SEL, NSRange, NSRangePointer))g_orig_attributed_substring)( - self, sel, range, actual - ); - } - return nil; -} - -static void nucleus_insert_text(id self, SEL sel, id string, NSRange replacement) { - NSString *incoming = nucleus_string_from_ime_arg(string) ?: @""; - NSEvent *event = NSApp.currentEvent; - BOOL isLetterDown = nucleus_is_letter_key_event(event); - BOOL isRepeat = nucleus_event_is_key_repeat(event); - BOOL sameAsBase = (g_base_text != nil) && [incoming isEqualToString:g_base_text]; - - // Nucleus patch (nucleusframework#595 follow-up): a marked-text IME - // (Japanese, Chinese, ...) commits through insertText: while the view - // still holds the preedit. That is never PressAndHold: its picker only - // engages on a plain committed letter, outside any composition - // (JDK-8074882 flow). Without this gate a segment commit that lands - // while the next romaji key is down is registered as a "base letter", - // the IME's own selectedRange queries then look like the picker - // starting, and the *next* commit is hijacked into the replace-commit - // path — deleting a code point, skipping ImeCommit, and leaving the - // preedit stranded (visible as duplicated segments). - if ([(NSView *)self hasMarkedText]) { - nucleus_clear_press_and_hold(); - if (g_orig_insert_text) { - ((void (*)(id, SEL, id, NSRange))g_orig_insert_text)(self, sel, string, replacement); - } - return; - } - - // First repeat / selectedRange query: PressAndHold re-inserts the base - // letter. Swallow it or we consume the replace flag and the accent - // arrives later as a second KEY_TYPED (eé). - if (g_did_insert_base && sameAsBase && (g_press_and_hold_queried || isRepeat)) { - g_press_and_hold_queried = YES; - return; - } - - if (g_press_and_hold_queried && !sameAsBase && incoming.length > 0) { - if (g_ime_replace_commit) { - const char *utf8 = incoming.UTF8String ?: ""; - g_ime_replace_commit((long)(__bridge void *)self, utf8); + if (nucleus_is_composing(self)) { + NSRange marked = nucleus_orig_marked_range(self); + NSRange abs = NSMakeRange(g_marked_anchor, marked.length); + if (range.location >= abs.location && NSMaxRange(range) <= NSMaxRange(abs) && + g_orig_attributed_substring) { + NSRange rel = NSMakeRange(range.location - g_marked_anchor, range.length); + NSRange relActual = rel; + id result = ((id (*)(id, SEL, NSRange, NSRangePointer))g_orig_attributed_substring)( + self, sel, rel, &relActual + ); + if (actual) { + *actual = NSMakeRange(relActual.location + g_marked_anchor, relActual.length); + } + return result; } - nucleus_clear_press_and_hold(); - return; } - - // New letter after the previous hold ended: drop a stale picker flag - // so typing the next character is not treated as an accent pick. - if (!g_letter_key_down && isLetterDown && !isRepeat) { - g_press_and_hold_queried = NO; + if (!nucleus_doc_valid_for(self) || range.location == NSNotFound) { + return nil; } - - if (g_orig_insert_text) { - ((void (*)(id, SEL, id, NSRange))g_orig_insert_text)(self, sel, string, replacement); + NSUInteger window_start = (NSUInteger)g_doc_offset; + NSUInteger window_end = window_start + g_doc_text.length; + if (range.location >= window_end || NSMaxRange(range) <= window_start) { + return nil; } - if (isLetterDown && !isRepeat) { - g_did_insert_base = YES; - g_letter_key_down = YES; - g_base_text = [incoming copy]; - g_base_key_code = event.keyCode; + NSUInteger loc = MAX(range.location, window_start); + NSUInteger end = MIN(NSMaxRange(range), window_end); + NSRange clamped = NSMakeRange(loc, end - loc); + if (actual) { + *actual = clamped; } + NSRange local = NSMakeRange(clamped.location - window_start, clamped.length); + NSString *sub = [g_doc_text substringWithRange:local]; + return [[NSAttributedString alloc] initWithString:sub]; } -static void nucleus_key_up(id self, SEL sel, NSEvent *event) { - if (g_orig_key_up) { - ((void (*)(id, SEL, NSEvent *))g_orig_key_up)(self, sel, event); +/// IMKit uses this as the caret index (#595). No glyph map — report the +/// insertion point: end of the marked text while composing, the committed +/// caret otherwise. +static NSUInteger tao_view_character_index_for_point(id self, SEL _cmd, NSPoint point) { + (void)_cmd; (void)point; + if (nucleus_is_composing(self)) { + return g_marked_anchor + nucleus_orig_marked_range(self).length; } - // Only the base letter's keyUp ends the hold. A number-key keyUp - // (picker shortcut) must not drop the session before insertText:é. - if (event && event.keyCode == g_base_key_code) { - g_letter_key_down = NO; + if (nucleus_doc_valid_for(self)) { + return g_doc_selection.location; } + return 0; } static void nucleus_tao_swizzle_view_methods_once(void) { @@ -313,6 +303,20 @@ static void nucleus_tao_swizzle_view_methods_once(void) { g_orig_selected_range = method_setImplementation(selectedRange, (IMP)tao_view_selected_range); } + Method markedRange = class_getInstanceMethod( + taoViewClass, @selector(markedRange) + ); + if (markedRange) { + g_orig_marked_range = + method_setImplementation(markedRange, (IMP)tao_view_marked_range); + } + Method setMarkedText = class_getInstanceMethod( + taoViewClass, @selector(setMarkedText:selectedRange:replacementRange:) + ); + if (setMarkedText) { + g_orig_set_marked_text = + method_setImplementation(setMarkedText, (IMP)tao_view_set_marked_text); + } class_replaceMethod(taoViewClass, @selector(firstRectForCharacterRange:actualRange:), (IMP)tao_view_first_rect_for_character_range, @@ -328,16 +332,10 @@ static void nucleus_tao_swizzle_view_methods_once(void) { g_orig_attributed_substring = method_setImplementation(attrSub, (IMP)nucleus_attributed_substring); } - Method insertText = class_getInstanceMethod( - taoViewClass, @selector(insertText:replacementRange:) - ); - if (insertText) { - g_orig_insert_text = method_setImplementation(insertText, (IMP)nucleus_insert_text); - } - Method keyUp = class_getInstanceMethod(taoViewClass, @selector(keyUp:)); - if (keyUp) { - g_orig_key_up = method_setImplementation(keyUp, (IMP)nucleus_key_up); - } + class_replaceMethod(taoViewClass, + @selector(characterIndexForPoint:), + (IMP)tao_view_character_index_for_point, + "Q@:{CGPoint=dd}"); }); } diff --git a/decorated-window-tao/src/main/native/macos/text_input_client_probe.m b/decorated-window-tao/src/main/native/macos/text_input_client_probe.m index 52c4d961f..a74726a89 100644 --- a/decorated-window-tao/src/main/native/macos/text_input_client_probe.m +++ b/decorated-window-tao/src/main/native/macos/text_input_client_probe.m @@ -72,7 +72,16 @@ int nucleus_tao_inject_marked_text( return 1; } -int nucleus_tao_inject_insert_text(int64_t ns_view_ptr, const char *utf8) { +/// [rr_loc] < 0 means "no replacement range" ({NSNotFound, 0}) — the shape +/// of ordinary typing. A non-negative [rr_loc] replays the accent-picker +/// commit AppKit sends to document-backed clients: `insertText:'é' +/// replacementRange:{caret-1, 1}` (UTF-16, document-absolute). +int nucleus_tao_inject_insert_text( + int64_t ns_view_ptr, + const char *utf8, + int64_t rr_loc, + int64_t rr_len +) { if (ns_view_ptr == 0 || utf8 == NULL) { return 0; } @@ -81,7 +90,9 @@ int nucleus_tao_inject_insert_text(int64_t ns_view_ptr, const char *utf8) { if (str == nil) { return 0; } - [(id)view insertText:str - replacementRange:NSMakeRange(NSNotFound, 0)]; + NSRange replacement = rr_loc < 0 + ? NSMakeRange(NSNotFound, 0) + : NSMakeRange((NSUInteger)rr_loc, (NSUInteger)rr_len); + [(id)view insertText:str replacementRange:replacement]; return 1; } diff --git a/decorated-window-tao/src/main/native/src/event_loop.rs b/decorated-window-tao/src/main/native/src/event_loop.rs index 5ee083ef0..046f5bf6c 100644 --- a/decorated-window-tao/src/main/native/src/event_loop.rs +++ b/decorated-window-tao/src/main/native/src/event_loop.rs @@ -11,7 +11,8 @@ use tao::event_loop::{ControlFlow, EventLoopBuilder}; use tao::window::WindowBuilder; use crate::events::{ - current_modifier_bits, dispatch, dispatch_ime_commit, dispatch_ime_preedit, dispatch_key, + current_modifier_bits, dispatch, dispatch_ime_commit, dispatch_ime_preedit, + dispatch_ime_replace_commit, dispatch_key, dispatch_touch_input, handle_for, mouse_button_code, pack_modifiers, UserEvent, CURSOR_FIXED_SCALE, EVENT_CLOSE_REQUESTED, EVENT_CURSOR_LEFT, EVENT_CURSOR_MOVED, EVENT_DESTROYED, EVENT_FOCUSED, EVENT_KEY_DOWN, EVENT_KEY_TYPED, EVENT_KEY_UP, EVENT_LAUNCHED, @@ -199,15 +200,13 @@ pub(crate) fn run_event_loop_blocking() { tao::platform::linux::set_minimized_hook(on_tao_minimized); // Install the Cmd-Q interceptor once we're on the main thread (NSEvent - // local monitors must be added there). Press-and-hold accent picker and - // the drag-event latch live alongside it. + // local monitors must be added there). The drag-event latch lives + // alongside it. `ApplePressAndHoldEnabled` is deliberately not touched: + // like Chromium, Nucleus lets the OS/user default decide whether a held + // letter repeats or opens the accent picker (#612). #[cfg(target_os = "macos")] unsafe { crate::platform::macos::ffi::nucleus_tao_install_cmd_q_handler(); - crate::platform::macos::ffi::nucleus_tao_enable_press_and_hold(); - crate::platform::macos::ffi::nucleus_tao_register_ime_replace_commit( - crate::platform::macos::ime::ime_replace_commit_callback, - ); crate::platform::macos::ffi::nucleus_tao_install_drag_monitor(); crate::platform::macos::ffi::nucleus_tao_register_trackpad_gesture_callback( crate::platform::macos::trackpad_gesture_callback, @@ -863,6 +862,13 @@ pub(crate) fn run_event_loop_blocking() { WindowEvent::ImeCommit(text) => { dispatch_ime_commit(handle, &text); } + WindowEvent::ImeReplaceCommit { + text, + start, + length, + } => { + dispatch_ime_replace_commit(handle, &text, start, length); + } WindowEvent::ModifiersChanged(state) => { let modifiers = pack_modifiers(state); if let Ok(mut g) = CURRENT_MODIFIERS.lock() { diff --git a/decorated-window-tao/src/main/native/src/events.rs b/decorated-window-tao/src/main/native/src/events.rs index 7479736f4..ad2114519 100644 --- a/decorated-window-tao/src/main/native/src/events.rs +++ b/decorated-window-tao/src/main/native/src/events.rs @@ -479,6 +479,41 @@ pub(crate) fn dispatch_ime_commit(handle: u64, text: &str) { dispatch_ime_string(handle, "onImeCommit", text); } +/// Replacement commit — macOS only (#611/#612). `insertText:` with a valid +/// `replacementRange` outside a composition (the press-and-hold accent +/// picker). [start] / [length] are UTF-16 offsets in the document-absolute +/// space the JVM pushed through `nativeSetImeDocument`. +pub(crate) fn dispatch_ime_replace_commit(handle: u64, text: &str, start: u64, length: u64) { + let Some(vm) = JAVA_VM.get() else { return }; + let Ok(guard) = EVENT_CALLBACK.lock() else { + return; + }; + let Some(callback) = guard.as_ref() else { + return; + }; + let Ok(mut env) = vm.attach_current_thread_permanently() else { + return; + }; + let Ok(jstr) = env.new_string(text) else { + return; + }; + let _ = env.call_method( + callback.as_obj(), + "onImeReplaceCommit", + "(JLjava/lang/String;JJ)V", + &[ + JValue::Long(handle as jlong), + JValue::Object(&jstr.into()), + JValue::Long(start as jlong), + JValue::Long(length as jlong), + ], + ); + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); + } +} + #[allow(clippy::too_many_arguments, dead_code)] pub(crate) fn dispatch_trackpad_gesture( handle: u64, diff --git a/decorated-window-tao/src/main/native/src/platform/macos/ffi.rs b/decorated-window-tao/src/main/native/src/platform/macos/ffi.rs index ad9935cc7..31344960f 100644 --- a/decorated-window-tao/src/main/native/src/platform/macos/ffi.rs +++ b/decorated-window-tao/src/main/native/src/platform/macos/ffi.rs @@ -12,11 +12,20 @@ extern "C" { ); pub(crate) fn nucleus_tao_is_main_thread() -> i32; pub(crate) fn nucleus_tao_install_cmd_q_handler(); - pub(crate) fn nucleus_tao_enable_press_and_hold(); - pub(crate) fn nucleus_tao_register_ime_replace_commit( - cb: extern "C" fn(i64, *const std::os::raw::c_char), - ); pub(crate) fn nucleus_tao_activate_input_context(ns_view_handle: i64); + /// Pushes the focused field's committed text (a bounded UTF-16 window), + /// selection and composition so the swizzled `NSTextInputClient` getters + /// can answer AppKit like a document-backed client (Chromium's + /// `setTextSelectionText:offset:range:` cache). Negative selection + /// offsets invalidate the cache (no focused field). + pub(crate) fn nucleus_tao_set_ime_document( + ns_view_handle: i64, + utf16: *const u16, + utf16_len: i64, + offset: i64, + sel_start: i64, + sel_end: i64, + ); pub(crate) fn nucleus_tao_set_ime_local_rect( ns_view_handle: i64, x_px: f64, @@ -53,11 +62,14 @@ extern "C" { /// Headful e2e: restore the input source saved by [nucleus_tao_kotoeri_select]. pub(crate) fn nucleus_tao_kotoeri_restore(); /// Headful e2e: deliver a real `keyDown:` / `keyUp:` to TaoView. + /// [autorepeat] stamps `kCGKeyboardEventAutorepeat` so AppKit sees a held + /// key (press-and-hold engages on the first repeat). pub(crate) fn nucleus_tao_post_key_to_view( ns_view_ptr: i64, key_code: i32, chars: *const std::os::raw::c_char, down: i32, + autorepeat: i32, ) -> i32; /// Headful e2e: current TIS keyboard source id into [buf]. Returns 1 on success. pub(crate) fn nucleus_tao_current_input_source_id( @@ -79,10 +91,13 @@ extern "C" { selected_loc: i32, selected_len: i32, ) -> i32; - /// Headful e2e: `[view insertText:replacementRange:]`. + /// Headful e2e: `[view insertText:replacementRange:]`. A negative + /// [rr_loc] means `{NSNotFound, 0}` (ordinary typing). pub(crate) fn nucleus_tao_inject_insert_text( ns_view_ptr: i64, utf8: *const std::os::raw::c_char, + rr_loc: i64, + rr_len: i64, ) -> i32; pub(crate) fn nucleus_tao_register_trackpad_gesture_callback( cb: extern "C" fn( diff --git a/decorated-window-tao/src/main/native/src/platform/macos/ime.rs b/decorated-window-tao/src/main/native/src/platform/macos/ime.rs index e77b80575..ce98ac252 100644 --- a/decorated-window-tao/src/main/native/src/platform/macos/ime.rs +++ b/decorated-window-tao/src/main/native/src/platform/macos/ime.rs @@ -3,7 +3,7 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; -use jni::objects::{JClass, JLongArray, JString, JValue}; +use jni::objects::{JClass, JLongArray, JString}; use jni::sys::{jboolean, jint, jlong, jlongArray, JNI_FALSE, JNI_TRUE}; use jni::JNIEnv; @@ -13,9 +13,10 @@ use crate::platform::macos::ffi::{ nucleus_tao_activate_input_context, nucleus_tao_current_input_source_id, nucleus_tao_inject_insert_text, nucleus_tao_inject_marked_text, nucleus_tao_kotoeri_available, nucleus_tao_kotoeri_restore, nucleus_tao_kotoeri_select, nucleus_tao_post_key_to_view, - nucleus_tao_query_text_input_client, nucleus_tao_set_ime_local_rect, + nucleus_tao_query_text_input_client, nucleus_tao_set_ime_document, + nucleus_tao_set_ime_local_rect, }; -use crate::state::{EVENT_CALLBACK, JAVA_VM, WINDOWS}; +use crate::state::WINDOWS; fn ns_view_for_handle(handle: jlong) -> Option { let guard = WINDOWS.lock().ok()?; @@ -24,53 +25,6 @@ fn ns_view_for_handle(handle: jlong) -> Option { Some(window.ns_view() as i64) } -fn handle_for_ns_view(ns_view_ptr: i64) -> Option { - if ns_view_ptr == 0 { - return None; - } - let target = ns_view_ptr as usize; - let guard = WINDOWS.lock().ok()?; - let map = guard.as_ref()?; - map.iter() - .find(|(_, w)| w.ns_view() as usize == target) - .map(|(h, _)| *h) -} - -/// PressAndHold picked an accent. Compose Desktop replaces the already- -/// committed base letter via `TextEditingScope`, not a Backspace key. -pub(crate) extern "C" fn ime_replace_commit_callback(ns_view: i64, utf8: *const c_char) { - if utf8.is_null() { - return; - } - let Some(handle) = handle_for_ns_view(ns_view) else { - return; - }; - let text = unsafe { CStr::from_ptr(utf8) }.to_string_lossy(); - let Some(vm) = JAVA_VM.get() else { return }; - let Ok(guard) = EVENT_CALLBACK.lock() else { - return; - }; - let Some(callback) = guard.as_ref() else { - return; - }; - let Ok(mut env) = vm.attach_current_thread_permanently() else { - return; - }; - let Ok(jstr) = env.new_string(text.as_ref()) else { - return; - }; - let _ = env.call_method( - callback.as_obj(), - "onImeReplaceCommit", - "(JLjava/lang/String;)V", - &[JValue::Long(handle as jlong), JValue::Object(&jstr.into())], - ); - if env.exception_check().unwrap_or(false) { - let _ = env.exception_describe(); - let _ = env.exception_clear(); - } -} - #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeActivateInputContext( _env: JNIEnv, @@ -121,6 +75,44 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ } } +/// Pushes the focused field's committed text (a bounded window), selection +/// and window offset to the swizzled `NSTextInputClient` cache — all offsets +/// UTF-16 and document-absolute, the same space `selectedRange` reports and +/// `insertText:replacementRange:` receives. Chromium parity: the async +/// renderer→browser selection/±100-chars push (`setTextSelectionText:`). +/// Negative [sel_start] invalidates the cache (no focused field). +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeSetImeDocument( + mut env: JNIEnv, + _class: JClass, + handle: jlong, + text: JString, + offset: jlong, + sel_start: jlong, + sel_end: jlong, +) { + let Some(ns_view) = ns_view_for_handle(handle) else { + return; + }; + // UTF-16 straight from the JVM string — no encoding conversion, so the + // offsets the JVM computed stay valid verbatim. + let s: String = match env.get_string(&text) { + Ok(s) => s.into(), + Err(_) => return, + }; + let utf16: Vec = s.encode_utf16().collect(); + unsafe { + nucleus_tao_set_ime_document( + ns_view, + utf16.as_ptr(), + utf16.len() as i64, + offset, + sel_start, + sel_end, + ); + } +} + /// Headful e2e: Japanese Kotoeri (romaji/hiragana) is installed on this Mac. #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeMacOsKotoeriAvailable( @@ -161,6 +153,7 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ } /// Headful e2e: deliver a real AppKit `keyDown:` / `keyUp:` to TaoView. +/// [autorepeat] marks the event as a key repeat (held key). #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeMacOsPostKeyToView( mut env: JNIEnv, @@ -169,6 +162,7 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ key_code: jint, characters: JString, down: jboolean, + autorepeat: jboolean, ) -> jboolean { let Some(ns_view) = ns_view_for_handle(handle) else { return JNI_FALSE; @@ -186,6 +180,7 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ key_code, cstr.as_ptr(), if down != JNI_FALSE { 1 } else { 0 }, + if autorepeat != JNI_FALSE { 1 } else { 0 }, ) }; if ok != 0 { @@ -283,13 +278,17 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ } } -/// Headful e2e: `insertText:replacementRange:` on TaoView. +/// Headful e2e: `insertText:replacementRange:` on TaoView. A negative +/// [rr_loc] injects `{NSNotFound, 0}` (ordinary typing); a non-negative one +/// replays the accent-picker replacement commit. #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeMacOsInjectInsertText( mut env: JNIEnv, _class: JClass, handle: jlong, text: JString, + rr_loc: jlong, + rr_len: jlong, ) -> jboolean { let Some(ns_view) = ns_view_for_handle(handle) else { return JNI_FALSE; @@ -301,7 +300,7 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ let Ok(cstr) = CString::new(utf8) else { return JNI_FALSE; }; - let ok = unsafe { nucleus_tao_inject_insert_text(ns_view, cstr.as_ptr()) }; + let ok = unsafe { nucleus_tao_inject_insert_text(ns_view, cstr.as_ptr(), rr_loc, rr_len) }; if ok != 0 { JNI_TRUE } else { diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/event.rs b/decorated-window-tao/src/main/native/vendor/tao/src/event.rs index 864747764..005858573 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/event.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/event.rs @@ -366,6 +366,30 @@ pub enum WindowEvent<'a> { /// - Not emitted on other platforms. ImeCommit(String), + /// The input system committed text in place of a committed-text range. + /// Nucleus patch for nucleusframework#611/#612 — `insertText:` with a + /// valid (non-`NSNotFound`) `replacementRange`, outside any composition. + /// + /// This is how the macOS press-and-hold accent picker replaces the base + /// letter on a document-backed `NSTextInputClient` (Chromium parity: + /// `ImeCommitText(text, replacementRange)`): the range carries everything, + /// no client-side heuristics. [`ImeReplaceCommit::start`] / + /// [`ImeReplaceCommit::length`] are UTF-16 offsets in the same + /// document-absolute space the client reports through `selectedRange`. + /// + /// ## Platform-specific + /// - **macOS**: `insertText:replacementRange:` with a valid range while no + /// marked text is active. + /// - Not emitted on other platforms. + ImeReplaceCommit { + /// The committed text. + text: String, + /// UTF-16 start offset of the range to replace (document-absolute). + start: u64, + /// UTF-16 length of the range to replace. + length: u64, + }, + /// The window gained or lost focus. /// /// The parameter is true if the window has gained focus, and false if it has lost focus. @@ -504,6 +528,15 @@ impl Clone for WindowEvent<'static> { ReceivedImeText(c) => ReceivedImeText(c.clone()), ImePreedit(text) => ImePreedit(text.clone()), ImeCommit(text) => ImeCommit(text.clone()), + ImeReplaceCommit { + text, + start, + length, + } => ImeReplaceCommit { + text: text.clone(), + start: *start, + length: *length, + }, Focused(f) => Focused(*f), KeyboardInput { device_id, @@ -598,6 +631,15 @@ impl<'a> WindowEvent<'a> { ReceivedImeText(c) => Some(ReceivedImeText(c)), ImePreedit(text) => Some(ImePreedit(text)), ImeCommit(text) => Some(ImeCommit(text)), + ImeReplaceCommit { + text, + start, + length, + } => Some(ImeReplaceCommit { + text, + start, + length, + }), Focused(focused) => Some(Focused(focused)), KeyboardInput { device_id, diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs index 9ebdc8ad9..6cb3de7fe 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs @@ -641,7 +641,7 @@ extern "C" fn insert_text( this: &mut Object, _sel: Sel, string: &NSString, - _replacement_range: NSRange, + replacement_range: NSRange, ) { trace!("Triggered `insertText`"); unsafe { @@ -684,6 +684,22 @@ extern "C" fn insert_text( reset_marked_text_ivar(this); queue_window_event(state, WindowEvent::ImeCommit(string)); } + } else if replacement_range.location != NSNotFound as NSUInteger && !string.is_empty() { + // Nucleus patch (nucleusframework#611/#612): a valid replacementRange + // outside a composition is a replacement commit — the press-and-hold + // accent picker replacing the base letter on a document-backed client. + // Chromium parity (`RenderWidgetHostViewCocoa insertText:`): a valid + // range routes to an immediate replace-commit, everything else stays + // ordinary insertion. The range is UTF-16, in the document-absolute + // space the client reports through `selectedRange`. + queue_window_event( + state, + WindowEvent::ImeReplaceCommit { + text: string, + start: replacement_range.location as u64, + length: replacement_range.length as u64, + }, + ); } else if !string.is_empty() { queue_window_event(state, WindowEvent::ReceivedImeText(string)); } diff --git a/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json b/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json index a7cda45da..1a6547508 100644 --- a/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json +++ b/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json @@ -80,7 +80,9 @@ "name": "onImeReplaceCommit", "parameterTypes": [ "long", - "java.lang.String" + "java.lang.String", + "long", + "long" ] }, { @@ -150,7 +152,9 @@ "name": "onImeReplaceCommit", "parameterTypes": [ "long", - "java.lang.String" + "java.lang.String", + "long", + "long" ] }, { diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsKotoeriProbe.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsKotoeriProbe.kt index 61eb987cb..91e22500a 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsKotoeriProbe.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsKotoeriProbe.kt @@ -29,10 +29,12 @@ internal object MacOsKotoeriProbe { fun currentInputSource(): String = NativeTaoBridge.nativeMacOsCurrentInputSource() + /** [autorepeat] marks the keyDown as a key repeat (held key). */ fun postKey( handle: Long, keyCode: Int, characters: String, down: Boolean, - ): Boolean = NativeTaoBridge.nativeMacOsPostKeyToView(handle, keyCode, characters, down) + autorepeat: Boolean = false, + ): Boolean = NativeTaoBridge.nativeMacOsPostKeyToView(handle, keyCode, characters, down, autorepeat) } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTextInputClientProbe.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTextInputClientProbe.kt index 87c6a47ea..81045e46c 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTextInputClientProbe.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTextInputClientProbe.kt @@ -39,10 +39,23 @@ internal object MacOsTextInputClientProbe { selectedLength, ) + /** + * A negative [replacementLocation] injects `{NSNotFound, 0}` (ordinary + * typing); a non-negative one replays the accent-picker replacement + * commit (UTF-16 document-absolute range). + */ fun insertText( handle: Long, text: String, - ): Boolean = NativeTaoBridge.nativeMacOsInjectInsertText(handle, text) + replacementLocation: Long = -1L, + replacementLength: Long = 0L, + ): Boolean = + NativeTaoBridge.nativeMacOsInjectInsertText( + handle, + text, + replacementLocation, + replacementLength, + ) data class Snapshot( val markedLocation: Long, From efc383c35670c54b6885f01e2b006554cbf1e784 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 29 Aug 2026 23:15:27 +0300 Subject: [PATCH 2/3] test(tao): cover macOS press-and-hold against Chrome's behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../window/tao/TaoPressAndHoldE2ETest.kt | 147 ++++++++++ .../window/tao/TaoSceneTestBattery.kt | 9 + .../tao/TaoSceneTestBatteryDriftTest.kt | 2 + .../tao/headful/PressAndHoldProbeMain.kt | 256 ++++++++++++++++++ .../window/tao/scene/TaoSceneImeTest.kt | 31 +++ .../window/tao/scene/TaoSceneKeyboardTest.kt | 7 +- .../window/tao/scene/TaoSceneTestHarness.kt | 16 ++ 7 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoPressAndHoldE2ETest.kt create mode 100644 decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PressAndHoldProbeMain.kt diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoPressAndHoldE2ETest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoPressAndHoldE2ETest.kt new file mode 100644 index 000000000..247abb6c1 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoPressAndHoldE2ETest.kt @@ -0,0 +1,147 @@ +package dev.nucleusframework.window.tao + +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Opt-in e2e for #611/#612 (set `NUCLEUS_TAO_SMOKE=1`): macOS press-and-hold + * must behave exactly like every document-backed AppKit client — Chrome, + * Notes, TextEdit. The reference behavior was recorded from AppKit driving a + * real `NSTextView` and a custom document-backed `NSTextInputClient`: + * + * - `defaults write -g ApplePressAndHoldEnabled -bool false` (or the + * `-ApplePressAndHoldEnabled NO` argument domain) → a held letter repeats + * (`eeeeee`), because every autorepeat keyDown reaches `insertText:`. A + * client that suppresses those repeats — or force-overrides the user + * default — leaves the key dead where the picker cannot engage (#612). + * - Picker pick → `insertText:"é" replacementRange:{caret-1, 1}` — a UTF-16 + * document-absolute range. No heuristics: the replacement range carries + * everything. + * - Key roll-over during fast typing must never be misread as a picker + * session (#611: `xcode` landing as `xcde`). + * + * Each scenario runs in a child JVM ([headful.PressAndHoldProbeMain]) so + * `ApplePressAndHoldEnabled` can be controlled per scenario through the + * process argument domain, and posts real CGEvents at the session tap. + * + * Not run by default: opens a real window, so it needs a display. + */ +class TaoPressAndHoldE2ETest { + @Test + fun heldLetterRepeatsWhenUserDisabledPressAndHold() { + // #612: the user's ApplePressAndHoldEnabled=false must be honored — + // Chrome parity is 1 insert + 5 autorepeat inserts. + runScenario("disabled-repeat", "-ApplePressAndHoldEnabled", "NO") { text -> + assertTrue( + text == "eeeeee", + "held 'e' with press-and-hold disabled must repeat like Chrome/Notes " + + "(expected 'eeeeee', got '$text')", + ) + } + } + + @Test + fun heldLetterNeverGoesDeadAndNextKeystrokeIsNotEaten() { + // #612: with press-and-hold enabled the repeats are consumed while + // the picker engages ('e') — or type where it cannot ('eeeeee') — + // but the key must never go dead and the following 'x' must land. + runScenario("enabled-hold") { text -> + assertTrue( + Regex("^e+x$").matches(text), + "held 'e' then 'x' must match ^e+x$ (Chrome parity), got '$text'", + ) + } + } + + @Test + fun pickerReplacementCommitReplacesTheBaseLetter() { + // The exact protocol AppKit sends on an accent pick — the + // replacement range does the work, no state machine required. + runScenario("picker-replay") { text -> + assertTrue( + text == "é", + "insertText:\"é\" replacementRange:{0, 1} must replace the base letter " + + "(expected 'é', got '$text')", + ) + } + } + + @Test + fun fastTypingRollOverIsNotMisreadAsAnAccentPick() { + // #611: 'o' still down when 'd' goes down + a selectedRange query in + // the overlap window — 'xcode' must not land as 'xcde'. + runScenario("rollover") { text -> + assertTrue( + text == "xcode", + "roll-over typing must land intact (expected 'xcode', got '$text')", + ) + } + } + + private fun runScenario( + scenario: String, + vararg extraArgs: String, + assertText: (String) -> Unit, + ) { + if (!System.getProperty("os.name", "").lowercase().contains("mac")) return + if (System.getenv("NUCLEUS_TAO_SMOKE") == null) { + println("SKIPPED: set NUCLEUS_TAO_SMOKE=1 to run the press-and-hold e2e") + return + } + + val java = File(File(System.getProperty("java.home"), "bin"), "java") + assertTrue(java.isFile, "java launcher not found at $java") + + // Scenario first; the -ApplePressAndHoldEnabled pair lands in the + // process argv, which is what AppKit parses into NSArgumentDomain. + val pb = ProcessBuilder(java.absolutePath, PROBE_MAIN_CLASS, scenario, *extraArgs) + // CLASSPATH env instead of -cp: the test classpath can exceed argv limits. + pb.environment()["CLASSPATH"] = System.getProperty("java.class.path") + + val proc = pb.start() + val stdout = StringBuilder() + val stderr = StringBuilder() + val outPump = + thread(name = "pah-probe-stdout") { + proc.inputStream.bufferedReader().forEachLine { synchronized(stdout) { stdout.appendLine(it) } } + } + val errPump = + thread(name = "pah-probe-stderr") { + proc.errorStream.bufferedReader().forEachLine { synchronized(stderr) { stderr.appendLine(it) } } + } + val finished = proc.waitFor(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (!finished) proc.destroyForcibly() + outPump.join(PUMP_JOIN_MS) + errPump.join(PUMP_JOIN_MS) + val out = synchronized(stdout) { stdout.toString() } + val err = synchronized(stderr) { stderr.toString() } + + assertTrue(finished, "probe timed out after ${PROBE_TIMEOUT_SECONDS}s\n${tail(err)}") + assertTrue( + proc.exitValue() == 0, + "probe exited abnormally (${proc.exitValue()})\n${tail(out)}\n${tail(err)}", + ) + + val skip = Regex("""\[pah] skip=(\S+)""").find(out)?.groupValues?.get(1) + if (skip != null) { + println("SKIPPED ($scenario): $skip — non-Latin layout or no key delivery\n${tail(out)}") + return + } + val text = + Regex("""\[pah] text='(.*)'""").find(out)?.groupValues?.get(1) + ?: error("probe never reported its text\n${tail(out)}\n${tail(err)}") + assertText(text) + } + + private fun tail(s: CharSequence): String = s.takeLast(MAX_REPORT_CHARS).toString() + + private companion object { + const val PROBE_MAIN_CLASS = "dev.nucleusframework.window.tao.headful.PressAndHoldProbeMain" + const val PROBE_TIMEOUT_SECONDS = 120L + const val PUMP_JOIN_MS = 5_000L + const val MAX_REPORT_CHARS = 4_000 + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index 4c0d4855b..ce985e2cd 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt @@ -245,6 +245,15 @@ public object TaoSceneTestBattery { run("TaoSceneImeTest: typing after a commit works normally") { TaoSceneImeTest().`typing after a commit works normally`() } + run("TaoSceneImeTest: replacement commit replaces the range the picker names") { + TaoSceneImeTest().`replacement commit replaces the range the picker names`() + } + run("TaoSceneImeTest: replacement commit leaves surrounding text intact and typing continues") { + TaoSceneImeTest().`replacement commit leaves surrounding text intact and typing continues`() + } + run("TaoSceneImeTest: replacement commit with an out-of-bounds range is clamped") { + TaoSceneImeTest().`replacement commit with an out-of-bounds range is clamped`() + } run("TaoScenePointerTest: click on a clickable box fires exactly once") { TaoScenePointerTest().`click on a clickable box fires exactly once`() } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt index 5207b3482..a33a09e78 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt @@ -82,6 +82,8 @@ class TaoSceneTestBatteryDriftTest { TaoRuntimeResizableSmokeTest::class.java to "opt-in headful smoke (NUCLEUS_TAO_SMOKE=1)", TaoMetalMissingPoolE2ETest::class.java to "opt-in headful e2e (NUCLEUS_TAO_SMOKE=1); spawns a child JVM", + TaoPressAndHoldE2ETest::class.java to + "opt-in headful e2e (NUCLEUS_TAO_SMOKE=1); spawns a child JVM", TaoSyntheticDndTest::class.java to "pins an AWT DropTarget; the no-AWT image never initialises AWT", TaoSceneRectManagerRaceTest::class.java to "races the real AWT EDT against wall-clock frames; the no-AWT image never initialises AWT", diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PressAndHoldProbeMain.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PressAndHoldProbeMain.kt new file mode 100644 index 000000000..d29b74c92 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PressAndHoldProbeMain.kt @@ -0,0 +1,256 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.window.tao.DecoratedWindow +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.taoApplication +import kotlinx.coroutines.delay +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.system.exitProcess + +/** + * Child process of `TaoPressAndHoldE2ETest` (#611/#612): opens a real Tao + * window with a focused `BasicTextField` and replays macOS press-and-hold + * keyboard traffic against it, exactly as recorded from AppKit driving a + * document-backed reference `NSTextInputClient` (an `NSTextView`). + * + * Runs as a separate process because `ApplePressAndHoldEnabled` must be + * controlled per scenario through `NSArgumentDomain` (JVM argv), and because + * `taoApplication` ends in `exitProcess(0)`. + * + * Scenario is `args[0]`; the reference behavior (what Chrome, Notes and + * TextEdit do) is asserted by the parent from the `[pah] text='…'` marker: + * + * - `disabled-repeat` — launched with `-ApplePressAndHoldEnabled NO`: hold a + * letter → every autorepeat types. Expected `eeeeee`. + * - `enabled-hold` — defaults untouched: hold a letter (repeats are consumed + * while the picker engages — or type, where it cannot), then type `x`. The + * held letter must never go dead and `x` must never be eaten. + * Expected `^e+x$`. + * - `picker-replay` — defaults untouched: commit `e`, replay the picker's + * protocol traffic — a `selectedRange` query while the key is down, then + * `insertText:"é" replacementRange:{0, 1}`. Expected `é`. + * - `rollover` — defaults untouched: type `xcode` with the `o` still down + * when `d` goes down (fast-typist roll-over) and a `selectedRange` query + * in the overlap window. Expected `xcode`. + */ +object PressAndHoldProbeMain { + private const val WATCHDOG_MS = 60_000L + private const val WATCHDOG_EXIT_CODE = 42 + + private const val KEY_E = 0x0E + private const val KEY_X = 0x07 + private const val KEY_C = 0x08 + private const val KEY_O = 0x1F + private const val KEY_D = 0x02 + private const val KEY_ESCAPE = 0x35 + + private const val FOCUS_SETTLE_MS = 700L + private const val KEY_GAP_MS = 80L + private const val REPEAT_GAP_MS = 100L + private const val REPEATS = 5 + private const val AWAIT_TIMEOUT_MS = 5_000L + private const val AWAIT_POLL_MS = 25L + private const val FINAL_SETTLE_MS = 600L + + private val text = AtomicReference("") + private val focused = AtomicBoolean(false) + + @JvmStatic + fun main(args: Array) { + val scenario = args.firstOrNull() ?: error("usage: PressAndHoldProbeMain ") + thread(isDaemon = true, name = "pah-probe-watchdog") { + Thread.sleep(WATCHDOG_MS) + Runtime.getRuntime().halt(WATCHDOG_EXIT_CODE) + } + + taoApplication { + DecoratedWindow( + onCloseRequest = ::exitApplication, + state = rememberWindowState(size = DpSize(480.dp, 360.dp)), + title = "press-and-hold probe", + ) { + val requester = remember { FocusRequester() } + var field by remember { mutableStateOf(TextFieldValue("")) } + LaunchedEffect(Unit) { + requester.requestFocus() + focused.set(true) + } + BasicTextField( + value = field, + onValueChange = { + field = it + text.set(it.text) + }, + modifier = Modifier.fillMaxSize().focusRequester(requester), + ) + val window = window + LaunchedEffect(window) { + runCatching { drive(scenario, window) } + .onFailure { println("[pah] error=${it.message}") } + println("[pah] text='${text.get()}'") + exitApplication() + } + } + } + exitProcess(0) + } + + private suspend fun drive( + scenario: String, + window: TaoWindow, + ) { + awaitUntil("text field focused") { focused.get() } + delay(FOCUS_SETTLE_MS) + val handle = window.handle + when (scenario) { + "disabled-repeat" -> driveHold(handle, tailKey = null) + "enabled-hold" -> driveHold(handle, tailKey = KEY_X to "x") + "picker-replay" -> drivePickerReplay(handle) + "rollover" -> driveRollover(handle) + else -> error("unknown scenario '$scenario'") + } + // Dismiss any accent picker this scenario left engaged — a lingering + // bubble steals the next scenario's keystrokes (it is a system + // window, so it outlives this child process). + stroke(handle, KEY_ESCAPE, "") + delay(FINAL_SETTLE_MS) + } + + /** + * Hold `e`: initial keyDown, [REPEATS] autorepeat keyDowns, keyUp — the + * exact event stream a held key produces. Layout sanity: if the first + * keystroke does not land as `e` (non-Latin layout), report a skip. + */ + private suspend fun driveHold( + handle: Long, + tailKey: Pair?, + ) { + postKey(handle, KEY_E, "e", down = true) + if (!awaitLayoutSanity()) return + repeat(REPEATS) { + delay(REPEAT_GAP_MS) + postKey(handle, KEY_E, "e", down = true, autorepeat = true) + } + delay(REPEAT_GAP_MS) + postKey(handle, KEY_E, "e", down = false) + if (tailKey != null) { + // The hold may have engaged the real picker; dismiss it first — + // that is what a user does — so the tail key lands in the field + // instead of the picker bubble. + delay(KEY_GAP_MS * 3) + stroke(handle, KEY_ESCAPE, "") + delay(KEY_GAP_MS * 3) + stroke(handle, tailKey.first, tailKey.second) + } + } + + /** + * The accent-pick protocol recorded from AppKit against a reference + * document-backed client (macOS 26, `NSTextView` and a custom + * `NSTextInputClient`): + * + * ``` + * keyDown 'e' → insertText:"e" replacementRange:{NSNotFound, 0} + * (picker engages) → selectedRange queried while 'e' is still down + * keyUp 'e' + * pick → insertText:"é" replacementRange:{0, 1} + * ``` + * + * The replacement range is UTF-16 and document-absolute: `{caret-1, 1}`. + */ + private suspend fun drivePickerReplay(handle: Long) { + postKey(handle, KEY_E, "e", down = true) + if (!awaitLayoutSanity()) return + MacOsTextInputClientProbe.query(handle) + delay(KEY_GAP_MS) + postKey(handle, KEY_E, "e", down = false) + delay(KEY_GAP_MS) + check(MacOsTextInputClientProbe.insertText(handle, "é", 0L, 1L)) { + "insertText(é, {0,1}) was not delivered" + } + } + + /** + * Fast-typist roll-over from #611: `o` is still physically down when `d` + * goes down. The `selectedRange` query in the overlap window is what a + * live Compose relayout (or IMKit housekeeping) issues at that moment. + */ + private suspend fun driveRollover(handle: Long) { + stroke(handle, KEY_X, "x") + if (!awaitLayoutSanity(expected = "x")) return + stroke(handle, KEY_C, "c") + postKey(handle, KEY_O, "o", down = true) + delay(KEY_GAP_MS) + MacOsTextInputClientProbe.query(handle) + postKey(handle, KEY_D, "d", down = true) + delay(KEY_GAP_MS) + postKey(handle, KEY_O, "o", down = false) + postKey(handle, KEY_D, "d", down = false) + delay(KEY_GAP_MS) + stroke(handle, KEY_E, "e") + } + + private suspend fun awaitLayoutSanity(expected: String = "e"): Boolean { + val deadline = System.currentTimeMillis() + AWAIT_TIMEOUT_MS + while (System.currentTimeMillis() < deadline) { + val current = text.get() + if (current == expected) return true + if (current.isNotEmpty()) { + println("[pah] skip=layout typed='$current'") + return false + } + delay(AWAIT_POLL_MS) + } + println("[pah] skip=no-keystroke-arrived") + return false + } + + private suspend fun stroke( + handle: Long, + keyCode: Int, + characters: String, + ) { + postKey(handle, keyCode, characters, down = true) + postKey(handle, keyCode, characters, down = false) + delay(KEY_GAP_MS) + } + + private fun postKey( + handle: Long, + keyCode: Int, + characters: String, + down: Boolean, + autorepeat: Boolean = false, + ) { + check(MacOsKotoeriProbe.postKey(handle, keyCode, characters, down, autorepeat)) { + "postKey(keyCode=$keyCode, down=$down, autorepeat=$autorepeat) failed" + } + } + + private suspend fun awaitUntil( + description: String, + predicate: () -> Boolean, + ) { + val deadline = System.currentTimeMillis() + AWAIT_TIMEOUT_MS + while (!predicate()) { + check(System.currentTimeMillis() < deadline) { "timed out: $description" } + delay(AWAIT_POLL_MS) + } + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneImeTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneImeTest.kt index 20382dd80..4b32cfd40 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneImeTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneImeTest.kt @@ -128,6 +128,37 @@ class TaoSceneImeTest { assertEquals("日本語ok", field.value) } + @Test + fun `replacement commit replaces the range the picker names`() = + runTaoSceneTest { + val field = focusedField() + typeText("Xe") + // The accent pick as AppKit sends it to a document-backed client + // (#611/#612): insertText:"é" replacementRange:{caret-1, 1}. + imeReplaceCommit("é", start = 1L, length = 1L) + assertEquals("Xé", field.value) + } + + @Test + fun `replacement commit leaves surrounding text intact and typing continues`() = + runTaoSceneTest { + val field = focusedField() + typeText("abe") + imeReplaceCommit("è", start = 2L, length = 1L) + typeText("cd") + assertEquals("abècd", field.value, "the caret must land after the replacement") + } + + @Test + fun `replacement commit with an out-of-bounds range is clamped`() = + runTaoSceneTest { + val field = focusedField() + typeText("e") + // A stale range (field changed under the pick) must not throw. + imeReplaceCommit("é", start = 5L, length = 3L) + assertEquals("eé", field.value) + } + @Test fun `empty IME commit while composing does not wipe the preedit`() = runTaoSceneTest { diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneKeyboardTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneKeyboardTest.kt index 905643d06..8b60bacd3 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneKeyboardTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneKeyboardTest.kt @@ -83,8 +83,11 @@ class TaoSceneKeyboardTest { click(100f, 15f) typeText("e") pressKey(TaoSceneTestScope.NamedKey.Backspace) - // Same sequence the macOS PressAndHold path now emits when the - // user picks é: Backspace the already-committed e, then KEY_TYPED é. + // Same sequence the macOS PressAndHold typed-key fallback emits + // when the accent pick arrives before any text-input session is + // up: Backspace the already-committed e, then KEY_TYPED é. (With + // a session, the pick is a replacement commit — see + // TaoSceneImeTest.) keyDown(vkCode = 'E'.code, codePoint = 'é'.code) assertEquals("é", value) } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt index ce0355215..84bec70d4 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt @@ -505,6 +505,22 @@ internal class TaoSceneTestScope( frame() } + /** + * Simulates a replacement commit (macOS `insertText:` with a valid + * `replacementRange`, outside a composition — the press-and-hold accent + * picker replacing its base letter, #611/#612). [start] / [length] are + * UTF-16 document-absolute offsets. Mirrors the host's + * `window.imeReplaceCommit` wiring. + */ + fun imeReplaceCommit( + text: String, + start: Long, + length: Long, + ) { + imeSession.replaceCommit(text, start, length) + frame() + } + /** * Named keys at the wire level: the native vk code each platform's event * source would actually put on the wire (kVK_* on macOS, XK_* keysyms on From cf83ec2e09d4f6a468b1834bfc663c7b622026e6 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Sat, 29 Aug 2026 23:29:02 +0300 Subject: [PATCH 3/3] fix(tao): harden the press-and-hold replacement path after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../window/tao/scene/TaoComposeSceneHost.kt | 30 ++++++++--- .../window/tao/scene/TaoImeSession.kt | 23 +++++--- .../main/native/macos/main_thread_dispatch.m | 18 ++++++- .../src/main/native/src/events.rs | 53 ++++++++----------- .../src/main/native/src/platform/macos/ime.rs | 32 ++++++++--- .../tao/src/platform_impl/macos/view.rs | 8 +++ .../window/tao/TaoPressAndHoldE2ETest.kt | 6 ++- .../tao/headful/PressAndHoldProbeMain.kt | 14 ++++- 8 files changed, 128 insertions(+), 56 deletions(-) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index 0c4c7b50a..740e9f5d8 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -102,12 +102,19 @@ internal class TaoComposeSceneHost( /** * CoreTextField session not up yet: same gated sequence Compose AWT - * uses (delete one code point, then commit). Never used for ordinary - * typing — native only calls this after PressAndHold queried the view. + * uses (delete the replaced characters, then commit). Never used for + * ordinary typing — only for a replacement commit that beat the + * text-input session, where [deleteBefore] is the length the input + * method asked to replace (0 for a pure insertion). */ - private fun emitImeTypedFallback(text: String) { - onKeyEvent(TaoEventCode.KEY_DOWN, 8, TaoKeyLocation.STANDARD, 0, 0) - onKeyEvent(TaoEventCode.KEY_UP, 8, TaoKeyLocation.STANDARD, 0, 0) + private fun emitImeTypedFallback( + text: String, + deleteBefore: Int, + ) { + repeat(deleteBefore) { + onKeyEvent(TaoEventCode.KEY_DOWN, 8, TaoKeyLocation.STANDARD, 0, 0) + onKeyEvent(TaoEventCode.KEY_UP, 8, TaoKeyLocation.STANDARD, 0, 0) + } for (ch in text) { onKeyEvent(TaoEventCode.KEY_TYPED, 0, TaoKeyLocation.STANDARD, 0, ch.code) } @@ -1515,6 +1522,10 @@ internal class TaoComposeSceneHost( window.imePreedit = null window.imeCommit = null imeSession.onInputSession(null) + // The native cache keys on the NSView pointer; leaving it set would + // let a later view allocated at the same address inherit this + // window's text and caret. + NativeTaoBridge.nativeSetImeDocument(window.handle, "", 0L, -1L, -1L) shutdownA11yScheduler() // Drop the transition hook before the scene goes: a late // willEnterFS would otherwise re-enter a torn-down host. @@ -1746,5 +1757,10 @@ private class TaoPlatformContext( } } -/** UTF-16 code units of committed text shipped on each side of the selection. */ -private const val IME_DOCUMENT_WINDOW_UTF16 = 512 +/** + * UTF-16 code units of committed text shipped on each side of the selection. + * This runs on every keystroke and caret move, so it stays close to the ±100 + * Chromium ships: the only reader is `attributedSubstringForProposedRange:`, + * which AppKit only ever asks near the caret. + */ +private const val IME_DOCUMENT_WINDOW_UTF16 = 128 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoImeSession.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoImeSession.kt index 7efd34f42..8452fc5c2 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoImeSession.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoImeSession.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.platform.PlatformTextInputMethodRequest */ @OptIn(ExperimentalComposeUiApi::class) internal class TaoImeSession( - private val typedFallback: (String) -> Unit = {}, + private val typedFallback: (String, Int) -> Unit = { _, _ -> }, ) { @Volatile private var activeRequest: PlatformTextInputMethodRequest? = null @@ -81,10 +81,10 @@ internal class TaoImeSession( } /** - * Commits [rawText] in place of the committed-text range - * [[replacementStart], [replacementStart] + [replacementLength]) \u2014 - * UTF-16 offsets in the same document-absolute space the host reports - * through `nativeSetImeDocument`. This is `insertText:` with a valid + * Commits [rawText] over the half-open committed-text range starting at + * [replacementStart] and spanning [replacementLength] \u2014 UTF-16 offsets + * in the same document-absolute space the host reports through + * `nativeSetImeDocument`. This is `insertText:` with a valid * `replacementRange` (the press-and-hold accent picker replacing its * base letter, #611/#612). Select-then-insert, exactly like Blink's * `ReplaceTextAndKeepSelection` / WebKit's `_selectNSRange:` + insert. @@ -101,7 +101,9 @@ internal class TaoImeSession( if (text.isEmpty()) return val request = activeRequest if (request == null) { - typedFallback(text) + // No field to address the range against; approximate it with + // backspaces, bounded so a bogus range cannot eat the document. + typedFallback(text, replacementLength.coerceIn(0L, MAX_FALLBACK_DELETE).toInt()) return } val length = request.value().text.length @@ -110,9 +112,18 @@ internal class TaoImeSession( (replacementStart + replacementLength) .coerceIn(start.toLong(), length.toLong()) .toInt() + // `commitText` replaces the composing region when one exists, which + // would silently ignore the selection set here \u2014 end the + // composition first, as [commit] does. + isComposing = false request.editText { + finishComposingText() setSelection(start, end) commitText(text, 1) } } + + private companion object { + const val MAX_FALLBACK_DELETE = 8L + } } diff --git a/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m b/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m index 5dc6492e9..6276a2e75 100644 --- a/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m +++ b/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m @@ -128,10 +128,18 @@ void nucleus_tao_set_ime_document( int64_t sel_end ) { if (sel_start < 0 || utf16 == NULL) { + // Scoped to the owning view: focus moving between windows tears down + // the old input session *after* the new one starts, so a blanket + // invalidate would wipe the cache the newly focused field just + // installed and leave its picker committing against a stale caret. + if (ns_view_handle != 0 && ns_view_handle != g_doc_view) { + return; + } g_doc_view = 0; g_doc_text = nil; g_doc_offset = 0; g_doc_selection = NSMakeRange(NSNotFound, 0); + g_marked_anchor = 0; return; } g_doc_view = ns_view_handle; @@ -206,9 +214,12 @@ static NSRange tao_view_marked_range(id self, SEL _cmd) { static void tao_view_set_marked_text( id self, SEL sel, id string, NSRange selectedRange, NSRange replacementRange ) { + // A stale `markedText` ivar (Compose cancelled the session without an + // `unmarkText` reaching the view) would otherwise pin the anchor from a + // previous field, so an invalid cache also forces a re-anchor. if (replacementRange.location != NSNotFound) { g_marked_anchor = replacementRange.location; - } else if (!nucleus_is_composing(self)) { + } else if (!nucleus_is_composing(self) || !nucleus_doc_valid_for(self)) { g_marked_anchor = nucleus_doc_valid_for(self) ? g_doc_selection.location : 0; } if (g_orig_set_marked_text) { @@ -268,6 +279,11 @@ static id nucleus_attributed_substring( } NSUInteger loc = MAX(range.location, window_start); NSUInteger end = MIN(NSMaxRange(range), window_end); + // `NSMaxRange` wraps on an overflowing proposed range, which slips past + // the guards above and would underflow `end - loc` into a huge length. + if (end <= loc) { + return nil; + } NSRange clamped = NSMakeRange(loc, end - loc); if (actual) { *actual = clamped; diff --git a/decorated-window-tao/src/main/native/src/events.rs b/decorated-window-tao/src/main/native/src/events.rs index ad2114519..fd4b37d35 100644 --- a/decorated-window-tao/src/main/native/src/events.rs +++ b/decorated-window-tao/src/main/native/src/events.rs @@ -441,7 +441,15 @@ pub(crate) fn dispatch_key( } } -fn dispatch_ime_string(handle: u64, method: &str, text: &str) { +/// Calls an `EventCallback` method whose first two arguments are the window +/// handle and a string, plus any [extra] trailing arguments. +fn dispatch_ime_string_with( + handle: u64, + method: &str, + signature: &str, + text: &str, + extra: &[jlong], +) { let Some(vm) = JAVA_VM.get() else { return }; let Ok(guard) = EVENT_CALLBACK.lock() else { return; @@ -455,18 +463,20 @@ fn dispatch_ime_string(handle: u64, method: &str, text: &str) { let Ok(jstr) = env.new_string(text) else { return; }; - let _ = env.call_method( - callback.as_obj(), - method, - "(JLjava/lang/String;)V", - &[JValue::Long(handle as jlong), JValue::Object(&jstr.into())], - ); + let jobj = jstr.into(); + let mut args = vec![JValue::Long(handle as jlong), JValue::Object(&jobj)]; + args.extend(extra.iter().map(|v| JValue::Long(*v))); + let _ = env.call_method(callback.as_obj(), method, signature, &args); if env.exception_check().unwrap_or(false) { let _ = env.exception_describe(); let _ = env.exception_clear(); } } +fn dispatch_ime_string(handle: u64, method: &str, text: &str) { + dispatch_ime_string_with(handle, method, "(JLjava/lang/String;)V", text, &[]); +} + /// IME composition (marked text) update — macOS only. Empty [text] cancels /// the composition (`unmarkText`). See issue #595. pub(crate) fn dispatch_ime_preedit(handle: u64, text: &str) { @@ -484,34 +494,13 @@ pub(crate) fn dispatch_ime_commit(handle: u64, text: &str) { /// picker). [start] / [length] are UTF-16 offsets in the document-absolute /// space the JVM pushed through `nativeSetImeDocument`. pub(crate) fn dispatch_ime_replace_commit(handle: u64, text: &str, start: u64, length: u64) { - let Some(vm) = JAVA_VM.get() else { return }; - let Ok(guard) = EVENT_CALLBACK.lock() else { - return; - }; - let Some(callback) = guard.as_ref() else { - return; - }; - let Ok(mut env) = vm.attach_current_thread_permanently() else { - return; - }; - let Ok(jstr) = env.new_string(text) else { - return; - }; - let _ = env.call_method( - callback.as_obj(), + dispatch_ime_string_with( + handle, "onImeReplaceCommit", "(JLjava/lang/String;JJ)V", - &[ - JValue::Long(handle as jlong), - JValue::Object(&jstr.into()), - JValue::Long(start as jlong), - JValue::Long(length as jlong), - ], + text, + &[start as jlong, length as jlong], ); - if env.exception_check().unwrap_or(false) { - let _ = env.exception_describe(); - let _ = env.exception_clear(); - } } #[allow(clippy::too_many_arguments, dead_code)] diff --git a/decorated-window-tao/src/main/native/src/platform/macos/ime.rs b/decorated-window-tao/src/main/native/src/platform/macos/ime.rs index ce98ac252..4dfd6f14e 100644 --- a/decorated-window-tao/src/main/native/src/platform/macos/ime.rs +++ b/decorated-window-tao/src/main/native/src/platform/macos/ime.rs @@ -94,13 +94,33 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ let Some(ns_view) = ns_view_for_handle(handle) else { return; }; - // UTF-16 straight from the JVM string — no encoding conversion, so the - // offsets the JVM computed stay valid verbatim. - let s: String = match env.get_string(&text) { - Ok(s) => s.into(), - Err(_) => return, + // Raw `GetStringRegion` rather than `get_string`: the safe wrapper goes + // through modified UTF-8 and a lossy decode, which both costs two extra + // copies per keystroke and can change the UTF-16 length (unpaired + // surrogates), desynchronising the offsets the JVM computed. This reads + // the JVM's UTF-16 verbatim, in one copy. + let raw_env = env.get_raw(); + let jstr = text.as_raw(); + if raw_env.is_null() || jstr.is_null() { + return; + } + let utf16: Vec = unsafe { + let Some(get_length) = (**raw_env).GetStringLength else { + return; + }; + let len = get_length(raw_env, jstr); + if len < 0 { + return; + } + let mut buf = vec![0u16; len as usize]; + if len > 0 { + let Some(get_region) = (**raw_env).GetStringRegion else { + return; + }; + get_region(raw_env, jstr, 0, len, buf.as_mut_ptr()); + } + buf }; - let utf16: Vec = s.encode_utf16().collect(); unsafe { nucleus_tao_set_ime_document( ns_view, diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs index 6cb3de7fe..b0fb8472f 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs @@ -692,6 +692,14 @@ extern "C" fn insert_text( // range routes to an immediate replace-commit, everything else stays // ordinary insertion. The range is UTF-16, in the document-absolute // space the client reports through `selectedRange`. + // + // The input method consumed this keystroke, so it must not also be + // delivered as a raw key event (#595 invariant): the accent is picked + // with a number key, and an app shortcut bound to that digit must not + // fire while the user is only choosing an accent. Chromium forwards + // the RawKeyDown because the web platform mandates a `keydown`; the + // AWT/Compose contract this backend follows does not. + state.key_triggered_ime = true; queue_window_event( state, WindowEvent::ImeReplaceCommit { diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoPressAndHoldE2ETest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoPressAndHoldE2ETest.kt index 247abb6c1..7321a4a69 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoPressAndHoldE2ETest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoPressAndHoldE2ETest.kt @@ -35,10 +35,12 @@ class TaoPressAndHoldE2ETest { // #612: the user's ApplePressAndHoldEnabled=false must be honored — // Chrome parity is 1 insert + 5 autorepeat inserts. runScenario("disabled-repeat", "-ApplePressAndHoldEnabled", "NO") { text -> + // The claim is "it repeats", not an exact count: synthetic + // autorepeats can be coalesced or dropped by the window server. assertTrue( - text == "eeeeee", + text.length >= 2 && text.all { it == 'e' }, "held 'e' with press-and-hold disabled must repeat like Chrome/Notes " + - "(expected 'eeeeee', got '$text')", + "(expected repeated 'e', got '$text')", ) } } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PressAndHoldProbeMain.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PressAndHoldProbeMain.kt index d29b74c92..cad95fea0 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PressAndHoldProbeMain.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PressAndHoldProbeMain.kt @@ -142,7 +142,12 @@ object PressAndHoldProbeMain { tailKey: Pair?, ) { postKey(handle, KEY_E, "e", down = true) - if (!awaitLayoutSanity()) return + if (!awaitLayoutSanity()) { + // The key state lives in the window server and outlives this + // process — never leave a scenario with a key held down. + postKey(handle, KEY_E, "e", down = false) + return + } repeat(REPEATS) { delay(REPEAT_GAP_MS) postKey(handle, KEY_E, "e", down = true, autorepeat = true) @@ -176,7 +181,10 @@ object PressAndHoldProbeMain { */ private suspend fun drivePickerReplay(handle: Long) { postKey(handle, KEY_E, "e", down = true) - if (!awaitLayoutSanity()) return + if (!awaitLayoutSanity()) { + postKey(handle, KEY_E, "e", down = false) + return + } MacOsTextInputClientProbe.query(handle) delay(KEY_GAP_MS) postKey(handle, KEY_E, "e", down = false) @@ -194,6 +202,8 @@ object PressAndHoldProbeMain { private suspend fun driveRollover(handle: Long) { stroke(handle, KEY_X, "x") if (!awaitLayoutSanity(expected = "x")) return + // From here on `o` and `d` are held deliberately; both are released + // before the scenario ends. stroke(handle, KEY_C, "c") postKey(handle, KEY_O, "o", down = true) delay(KEY_GAP_MS)