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..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. @@ -1672,13 +1683,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 +1756,11 @@ private class TaoPlatformContext( }.getOrDefault(TaoCursorIcon.DEFAULT) } } + +/** + * 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 aba0339af..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 @@ -10,15 +10,17 @@ 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. */ @OptIn(ExperimentalComposeUiApi::class) internal class TaoImeSession( - private val typedFallback: (String) -> Unit = {}, + private val typedFallback: (String, Int) -> Unit = { _, _ -> }, ) { @Volatile private var activeRequest: PlatformTextInputMethodRequest? = null @@ -79,25 +81,49 @@ 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] 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. + * 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) { + // 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 } - 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() + // `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/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..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 @@ -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,139 @@ 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) { + // 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; + 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 +) { + // 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) || !nucleus_doc_valid_for(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 +245,66 @@ 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); + 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; } - 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); - } - nucleus_clear_press_and_hold(); - return; + if (!nucleus_doc_valid_for(self) || range.location == NSNotFound) { + return nil; } - - // 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; + 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 (g_orig_insert_text) { - ((void (*)(id, SEL, id, NSRange))g_orig_insert_text)(self, sel, string, replacement); + 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; } - if (isLetterDown && !isRepeat) { - g_did_insert_base = YES; - g_letter_key_down = YES; - g_base_text = [incoming copy]; - g_base_key_code = event.keyCode; + 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 +319,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 +348,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..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) { @@ -479,6 +489,20 @@ 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) { + dispatch_ime_string_with( + handle, + "onImeReplaceCommit", + "(JLjava/lang/String;JJ)V", + text, + &[start as jlong, length as jlong], + ); +} + #[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..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 @@ -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,64 @@ 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; + }; + // 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 + }; + 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 +173,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 +182,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 +200,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 +298,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 +320,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..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 @@ -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,30 @@ 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`. + // + // 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 { + 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/TaoPressAndHoldE2ETest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoPressAndHoldE2ETest.kt new file mode 100644 index 000000000..7321a4a69 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoPressAndHoldE2ETest.kt @@ -0,0 +1,149 @@ +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 -> + // The claim is "it repeats", not an exact count: synthetic + // autorepeats can be coalesced or dropped by the window server. + assertTrue( + text.length >= 2 && text.all { it == 'e' }, + "held 'e' with press-and-hold disabled must repeat like Chrome/Notes " + + "(expected repeated 'e', 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/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, 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..cad95fea0 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PressAndHoldProbeMain.kt @@ -0,0 +1,266 @@ +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()) { + // 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) + } + 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()) { + postKey(handle, KEY_E, "e", down = false) + 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 + // 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) + 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