Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}

Expand Down Expand Up @@ -295,14 +302,16 @@ 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(
handle: Long,
keyCode: Int,
characters: String,
down: Boolean,
autorepeat: Boolean,
): Boolean

/**
Expand Down Expand Up @@ -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

/**
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
5 changes: 3 additions & 2 deletions decorated-window-tao/src/main/native/macos/kotoeri.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading