From 34fb3ddc205d87f9b7619c0fec0370152179830d Mon Sep 17 00:00:00 2001 From: Wavesonics Date: Sat, 6 Jun 2026 21:40:48 -0700 Subject: [PATCH 1/2] Fix dead keys not working in the editor on desktop (#561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop editor never opened a platform input-method session — the desktop TextEditorTextInputService just called awaitCancellation(). Without an AWT input-method context, Linux/XIM had nowhere to deliver composed dead-key / accent text, so the editor only received the base letter (while standard BasicTextFields elsewhere worked because they open the session). Implement a real PlatformTextInputMethodRequest on desktop via startInputMethod: a live TextEditorState adapter, an editText scope, and focusedRectInRoot for IME candidate-window placement. Compose's DesktopTextInputService2 then attaches AWT InputMethodRequests and routes InputMethodEvents (dead keys, CJK, macOS press-and-hold, Windows emoji picker) into editText. Plain KEY_TYPED input is unchanged and coexists as it does in Compose's own BasicTextField. Extract the IME edit operations shared by Android and desktop into a new commonMain ImeEditLogic, and refactor the Android InputConnection to use it so composing/cursor/surrogate semantics stay identical across platforms. Capture the editor canvas layout coordinates so the desktop IME can place the composition window at the cursor. Also wire compose-hot-reload v1.2.0-alpha01 into :sampleApp for hot-reload + MCP-driven verification of the running desktop app. Verified: 429 desktop tests pass (incl. 8 new IME-logic tests covering the dead-key/accent/CJK/surrogate cases); live keypress test on Linux shows ñ/á/è/ü committed as single composed characters with no double-insertion. --- .gitignore | 2 + .../TextEditorTextInputService.android.kt | 171 +--------------- .../texteditor/BasicTextEditor.kt | 4 + .../texteditor/input/ImeEditLogic.kt | 184 ++++++++++++++++++ .../texteditor/state/TextEditorState.kt | 10 + .../TextEditorTextInputService.desktop.kt | 175 +++++++++++++++-- .../kotlin/input/ImeEditLogicTest.kt | 140 +++++++++++++ build.gradle.kts | 1 + gradle.properties | 2 + gradle/libs.versions.toml | 2 + sampleApp/build.gradle.kts | 1 + 11 files changed, 519 insertions(+), 173 deletions(-) create mode 100644 ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeEditLogic.kt create mode 100644 ComposeTextEditor/src/desktopTest/kotlin/input/ImeEditLogicTest.kt diff --git a/.gitignore b/.gitignore index 6a08ea1..df1cd68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.iml .kotlin .gradle +# Local Claude Code MCP server config (compose-hot-reload, etc.) +.mcp.json **/build/ xcuserdata !src/**/build/ diff --git a/ComposeTextEditor/src/androidMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.android.kt b/ComposeTextEditor/src/androidMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.android.kt index 9f71378..b2d40c0 100644 --- a/ComposeTextEditor/src/androidMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.android.kt +++ b/ComposeTextEditor/src/androidMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.android.kt @@ -75,203 +75,52 @@ private class CommitTextCommand( val text: String, val newCursorPosition: Int ) : EditCommand { - override fun applyTo(state: TextEditorState) { - val insertStart = replaceComposingOrInsert(state, text) - val insertEnd = insertStart + text.length - // state.replace / insertStringAtCursor don't touch composingRange — clear it explicitly. - state.clearComposingRange() - applyNewCursorPosition(state, insertStart, insertEnd, newCursorPosition) - } + override fun applyTo(state: TextEditorState) = state.imeCommitText(text, newCursorPosition) } private class SetComposingTextCommand( val text: String, val newCursorPosition: Int ) : EditCommand { - override fun applyTo(state: TextEditorState) { - val insertStart = replaceComposingOrInsert(state, text) - val insertEnd = insertStart + text.length - if (text.isNotEmpty()) { - state.updateComposingRange(insertStart, insertEnd) - } else { - state.clearComposingRange() - } - applyNewCursorPosition(state, insertStart, insertEnd, newCursorPosition) - } + override fun applyTo(state: TextEditorState) = state.imeSetComposingText(text, newCursorPosition) } private class SetComposingRegionCommand( val start: Int, val end: Int ) : EditCommand { - override fun applyTo(state: TextEditorState) { - val len = state.getTextLength() - val s = start.coerceIn(0, len) - val e = end.coerceIn(0, len) - if (s < e) state.updateComposingRange(s, e) else state.clearComposingRange() - } + override fun applyTo(state: TextEditorState) = state.imeSetComposingRegion(start, end) } private object FinishComposingTextCommand : EditCommand { - override fun applyTo(state: TextEditorState) { - state.clearComposingRange() - } + override fun applyTo(state: TextEditorState) = state.imeFinishComposing() } private class DeleteSurroundingTextCommand( val beforeLength: Int, val afterLength: Int ) : EditCommand { - override fun applyTo(state: TextEditorState) { - val cursorIndex = state.getCharacterIndex(state.cursorPosition) - val deleteStart = maxOf(0, cursorIndex - beforeLength) - val deleteEnd = minOf(state.getTextLength(), cursorIndex + afterLength) - if (deleteStart < deleteEnd) { - state.delete( - TextEditorRange( - state.getOffsetAtCharacter(deleteStart), - state.getOffsetAtCharacter(deleteEnd) - ) - ) - } - } + override fun applyTo(state: TextEditorState) = + state.imeDeleteSurroundingText(beforeLength, afterLength) } private class DeleteSurroundingTextInCodePointsCommand( val beforeLength: Int, val afterLength: Int ) : EditCommand { - override fun applyTo(state: TextEditorState) { - val cursorIndex = state.getCharacterIndex(state.cursorPosition) - val fullText = state.getAllText() - val charsBefore = codePointsToChars(fullText, cursorIndex, beforeLength, backwards = true) - val charsAfter = codePointsToChars(fullText, cursorIndex, afterLength, backwards = false) - val deleteStart = maxOf(0, cursorIndex - charsBefore) - val deleteEnd = minOf(state.getTextLength(), cursorIndex + charsAfter) - if (deleteStart < deleteEnd) { - state.delete( - TextEditorRange( - state.getOffsetAtCharacter(deleteStart), - state.getOffsetAtCharacter(deleteEnd) - ) - ) - } - } + override fun applyTo(state: TextEditorState) = + state.imeDeleteSurroundingTextInCodePoints(beforeLength, afterLength) } private class SetSelectionCommand( val start: Int, val end: Int ) : EditCommand { - override fun applyTo(state: TextEditorState) { - val len = state.getTextLength() - val s = start.coerceIn(0, len) - val e = end.coerceIn(0, len) - if (s == e) { - state.selector.clearSelection() - state.cursor.updatePosition(state.getOffsetAtCharacter(s)) - } else { - val lo = minOf(s, e) - val hi = maxOf(s, e) - state.selector.updateSelection( - state.getOffsetAtCharacter(lo), - state.getOffsetAtCharacter(hi) - ) - // Cursor goes to `end` per platform convention. - state.cursor.updatePosition(state.getOffsetAtCharacter(e)) - } - } + override fun applyTo(state: TextEditorState) = state.imeSetSelection(start, end) } private object PerformImeNewlineCommand : EditCommand { - override fun applyTo(state: TextEditorState) { - if (state.selector.hasSelection()) state.selector.deleteSelection() - state.insertNewlineAtCursor() - } -} - -// ============================================================ -// Shared command helpers -// ============================================================ - -/** - * Replaces the current composing region with [text], or — when there is no - * composition — deletes any selection and inserts at the cursor. Returns the - * character index at which the inserted text starts. - */ -private fun replaceComposingOrInsert(state: TextEditorState, text: String): Int { - val composing = state.composingRange - return if (composing != null) { - val start = state.getCharacterIndex(composing.start) - // inheritStyle keeps autocorrect from stripping bold/italic etc. - state.replace(TextEditorRange(composing.start, composing.end), text, inheritStyle = true) - start - } else { - if (state.selector.hasSelection()) state.selector.deleteSelection() - val start = state.getCharacterIndex(state.cursorPosition) - state.insertStringAtCursor(text) - start - } -} - -/** - * Implements the Android `newCursorPosition` contract: - * - `> 0`: position is relative to the end of the inserted text (1 = right after). - * - `<= 0`: position is relative to the start (0 = at start, -1 = one before). - */ -private fun applyNewCursorPosition( - state: TextEditorState, - insertStart: Int, - insertEnd: Int, - newCursorPosition: Int -) { - val len = state.getTextLength() - val target = if (newCursorPosition > 0) { - (insertEnd + (newCursorPosition - 1)).coerceIn(0, len) - } else { - (insertStart + newCursorPosition).coerceIn(0, len) - } - state.cursor.updatePosition(state.getOffsetAtCharacter(target)) - state.selector.clearSelection() -} - -private fun codePointsToChars( - text: CharSequence, - fromIndex: Int, - codePointCount: Int, - backwards: Boolean -): Int { - if (codePointCount <= 0) return 0 - var charCount = 0 - var codePointsRemaining = codePointCount - if (backwards) { - var index = fromIndex - while (codePointsRemaining > 0 && index > 0) { - index-- - charCount++ - if (Character.isLowSurrogate(text[index]) && index > 0 && - Character.isHighSurrogate(text[index - 1]) - ) { - index-- - charCount++ - } - codePointsRemaining-- - } - } else { - var index = fromIndex - while (codePointsRemaining > 0 && index < text.length) { - charCount++ - if (Character.isHighSurrogate(text[index]) && index + 1 < text.length && - Character.isLowSurrogate(text[index + 1]) - ) { - charCount++ - index++ - } - index++ - codePointsRemaining-- - } - } - return charCount + override fun applyTo(state: TextEditorState) = state.imePerformNewline() } // ============================================================ diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/BasicTextEditor.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/BasicTextEditor.kt index b79d25a..68dff22 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/BasicTextEditor.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/BasicTextEditor.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalDensity @@ -187,6 +188,9 @@ fun BasicTextEditor( onSpanClick = onRichSpanClick, onContextMenuRequest = { offset -> effectiveContextMenuState.showMenu(offset) } ) + // Capture the canvas position so the desktop IME can place the + // composition/candidate window relative to the cursor. + .onGloballyPositioned { state.canvasLayoutCoordinates = it } .size( width = state.viewportSize.width.dp, height = state.viewportSize.height.dp diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeEditLogic.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeEditLogic.kt new file mode 100644 index 0000000..e323d91 --- /dev/null +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeEditLogic.kt @@ -0,0 +1,184 @@ +package com.darkrockstudios.texteditor.input + +import com.darkrockstudios.texteditor.TextEditorRange +import com.darkrockstudios.texteditor.state.TextEditorState + +/** + * Shared IME edit operations used by every platform that drives the editor + * through a platform input-method connection: the Android `InputConnection` and + * the desktop `PlatformTextInputMethodRequest`. + * + * Centralizing these keeps composing-region and cursor semantics byte-for-byte + * identical across platforms. Each function mutates [TextEditorState] the way the + * corresponding IME command (`commitText`, `setComposingText`, ...) expects. + * + * Callers are responsible for batching (so a single composition collapses into + * one undo step) — these functions only perform the mutation. + */ + +/** + * `commitText`: replace the composing region (or selection / nothing) with + * [text], clear composition, then place the cursor per the Android + * `newCursorPosition` contract. + */ +internal fun TextEditorState.imeCommitText(text: String, newCursorPosition: Int) { + val insertStart = replaceComposingOrInsert(text) + val insertEnd = insertStart + text.length + // replace / insertStringAtCursor don't touch composingRange — clear it explicitly. + clearComposingRange() + applyNewCursorPosition(insertStart, insertEnd, newCursorPosition) +} + +/** + * `setComposingText`: like [imeCommitText] but the inserted text becomes the new + * composing region (rendered underlined) instead of being committed. This is the + * path dead-key / accent composition flows through on desktop. + */ +internal fun TextEditorState.imeSetComposingText(text: String, newCursorPosition: Int) { + val insertStart = replaceComposingOrInsert(text) + val insertEnd = insertStart + text.length + if (text.isNotEmpty()) { + updateComposingRange(insertStart, insertEnd) + } else { + clearComposingRange() + } + applyNewCursorPosition(insertStart, insertEnd, newCursorPosition) +} + +/** `setComposingRegion`: mark an existing text range as the composing region. */ +internal fun TextEditorState.imeSetComposingRegion(start: Int, end: Int) { + val len = getTextLength() + val s = start.coerceIn(0, len) + val e = end.coerceIn(0, len) + if (s < e) updateComposingRange(s, e) else clearComposingRange() +} + +/** `finishComposingText`: keep the text, drop the composing highlight. */ +internal fun TextEditorState.imeFinishComposing() { + clearComposingRange() +} + +/** `deleteSurroundingText`: delete [beforeLength]/[afterLength] chars around the cursor. */ +internal fun TextEditorState.imeDeleteSurroundingText(beforeLength: Int, afterLength: Int) { + val cursorIndex = getCharacterIndex(cursorPosition) + val deleteStart = maxOf(0, cursorIndex - beforeLength) + val deleteEnd = minOf(getTextLength(), cursorIndex + afterLength) + if (deleteStart < deleteEnd) { + delete(TextEditorRange(getOffsetAtCharacter(deleteStart), getOffsetAtCharacter(deleteEnd))) + } +} + +/** `deleteSurroundingTextInCodePoints`: same as [imeDeleteSurroundingText] but counted in code points. */ +internal fun TextEditorState.imeDeleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int) { + val cursorIndex = getCharacterIndex(cursorPosition) + val fullText = getAllText() + val charsBefore = codePointsToChars(fullText, cursorIndex, beforeLength, backwards = true) + val charsAfter = codePointsToChars(fullText, cursorIndex, afterLength, backwards = false) + val deleteStart = maxOf(0, cursorIndex - charsBefore) + val deleteEnd = minOf(getTextLength(), cursorIndex + charsAfter) + if (deleteStart < deleteEnd) { + delete(TextEditorRange(getOffsetAtCharacter(deleteStart), getOffsetAtCharacter(deleteEnd))) + } +} + +/** `setSelection`: collapse to a cursor when start == end, otherwise select; cursor goes to `end`. */ +internal fun TextEditorState.imeSetSelection(start: Int, end: Int) { + val len = getTextLength() + val s = start.coerceIn(0, len) + val e = end.coerceIn(0, len) + if (s == e) { + selector.clearSelection() + cursor.updatePosition(getOffsetAtCharacter(s)) + } else { + val lo = minOf(s, e) + val hi = maxOf(s, e) + selector.updateSelection(getOffsetAtCharacter(lo), getOffsetAtCharacter(hi)) + // Cursor goes to `end` per platform convention. + cursor.updatePosition(getOffsetAtCharacter(e)) + } +} + +/** Insert a newline, replacing any selection first (used for IME "enter" actions). */ +internal fun TextEditorState.imePerformNewline() { + if (selector.hasSelection()) selector.deleteSelection() + insertNewlineAtCursor() +} + +/** + * Replaces the current composing region with [text], or — when there is no + * composition — deletes any selection and inserts at the cursor. Returns the + * character index at which the inserted text starts. + */ +private fun TextEditorState.replaceComposingOrInsert(text: String): Int { + val composing = composingRange + return if (composing != null) { + val start = getCharacterIndex(composing.start) + // inheritStyle keeps autocorrect/composition from stripping bold/italic etc. + replace(TextEditorRange(composing.start, composing.end), text, inheritStyle = true) + start + } else { + if (selector.hasSelection()) selector.deleteSelection() + val start = getCharacterIndex(cursorPosition) + insertStringAtCursor(text) + start + } +} + +/** + * Implements the Android `newCursorPosition` contract: + * - `> 0`: position is relative to the end of the inserted text (1 = right after). + * - `<= 0`: position is relative to the start (0 = at start, -1 = one before). + */ +private fun TextEditorState.applyNewCursorPosition( + insertStart: Int, + insertEnd: Int, + newCursorPosition: Int +) { + val len = getTextLength() + val target = if (newCursorPosition > 0) { + (insertEnd + (newCursorPosition - 1)).coerceIn(0, len) + } else { + (insertStart + newCursorPosition).coerceIn(0, len) + } + cursor.updatePosition(getOffsetAtCharacter(target)) + selector.clearSelection() +} + +/** + * Converts a count of [codePointCount] code points (forwards or [backwards] from + * [fromIndex]) into a UTF-16 char count, keeping surrogate pairs intact. + */ +private fun codePointsToChars( + text: CharSequence, + fromIndex: Int, + codePointCount: Int, + backwards: Boolean +): Int { + if (codePointCount <= 0) return 0 + var charCount = 0 + var codePointsRemaining = codePointCount + if (backwards) { + var index = fromIndex + while (codePointsRemaining > 0 && index > 0) { + index-- + charCount++ + if (text[index].isLowSurrogate() && index > 0 && text[index - 1].isHighSurrogate()) { + index-- + charCount++ + } + codePointsRemaining-- + } + } else { + var index = fromIndex + while (codePointsRemaining > 0 && index < text.length) { + charCount++ + if (text[index].isHighSurrogate() && index + 1 < text.length && text[index + 1].isLowSurrogate()) { + charCount++ + index++ + } + index++ + codePointsRemaining-- + } + } + return charCount +} diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt index 3c2e1ad..01e678d 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/state/TextEditorState.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.text.* import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.unit.Constraints @@ -90,6 +91,15 @@ class TextEditorState( var lastCursorMetrics: CursorMetrics? = null internal set + /** + * Layout coordinates of the editor's drawing canvas, captured via + * `onGloballyPositioned`. Used by the desktop IME to translate the cursor's + * canvas-local [lastCursorMetrics] into root coordinates for placing the + * input-method candidate window. + */ + var canvasLayoutCoordinates: LayoutCoordinates? = null + internal set + private var _lineOffsets by mutableStateOf(emptyList()) val lineOffsets: List get() = _lineOffsets diff --git a/ComposeTextEditor/src/desktopMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.desktop.kt b/ComposeTextEditor/src/desktopMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.desktop.kt index de12e1f..e4bc6cd 100644 --- a/ComposeTextEditor/src/desktopMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.desktop.kt +++ b/ComposeTextEditor/src/desktopMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.desktop.kt @@ -1,27 +1,178 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + package com.darkrockstudios.texteditor.input +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.PlatformTextInputSession +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.EditCommand +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.text.input.TextEditingScope +import androidx.compose.ui.text.input.TextFieldValue import com.darkrockstudios.texteditor.state.TextEditorState -import kotlinx.coroutines.awaitCancellation +import androidx.compose.ui.text.input.TextEditorState as ComposeTextEditorState /** - * Desktop implementation of TextEditorTextInputService. + * Desktop implementation of [TextEditorTextInputService]. + * + * Unlike the previous version (which suspended indefinitely and relied solely on + * AWT `KEY_TYPED` events), this establishes a real platform input-method session + * via [PlatformTextInputSession.startInputMethod]. Compose's + * `DesktopTextInputService2` then attaches an AWT `InputMethodRequests` to the + * window and routes `InputMethodEvent`s — dead-key / accent composition, CJK + * input, the macOS press-and-hold accent popup, the Windows emoji picker — into + * our [PlatformTextInputMethodRequest.editText] callback. * - * Desktop uses KEY_TYPED events through KeyInputModifierNode for character input. + * Plain ASCII typing still arrives as `KEY_TYPED` and is handled by + * [TextEditorKeyCommandHandler.handleCharacterInput]; the two paths coexist + * exactly as they do in Compose's own `BasicTextField`. AWT delivers a given + * keystroke through one path or the other, not both, and the press-and-hold case + * (where a base char is typed and later replaced by the accented form) is handled + * by Compose calling `deleteSurroundingTextInCodePoints` into [editText]. * - * Note: Windows emoji picker (Win+.) and some IME features may not work with this - * custom text editor. This is a known limitation because Compose Desktop's - * PlatformTextInputMethodRequest interface requires internal types that are - * difficult to implement externally. Standard keyboard input (including Unicode - * characters typed directly) should work correctly via KEY_TYPED events. + * This fixes dead keys not working in the editor on Linux (issue #561). */ actual class TextEditorTextInputService actual constructor( private val state: TextEditorState ) { actual suspend fun startInput(session: PlatformTextInputSession): Nothing { - // Desktop uses KEY_TYPED events through KeyInputModifierNode - // IME features like emoji picker require internal Compose types to implement - // Just suspend indefinitely for now - awaitCancellation() + session.startInputMethod(DesktopTextEditorInputMethodRequest(state)) + } +} + +private class DesktopTextEditorInputMethodRequest( + private val editorState: TextEditorState +) : PlatformTextInputMethodRequest { + + /** Live view of the editor as the CharSequence + selection + composition Compose expects. */ + override val state: ComposeTextEditorState = EditorStateAdapter(editorState) + + override val value: () -> TextFieldValue = { + TextFieldValue( + text = editorState.getAllText().text, + selection = editorState.currentSelectionRange() + ) + } + + override val imeOptions: ImeOptions = ImeOptions.Default + + // The desktop input path (DesktopTextInputService2) drives every edit through + // `editText`. `onEditCommand` is the legacy BTF1 hook and is never invoked on + // this path, so a no-op satisfies the interface. + override val onEditCommand: (List) -> Unit = {} + + override val onImeAction: ((ImeAction) -> Unit)? = null + + // We render text with our own Canvas, not a Compose TextLayoutResult, so there + // is nothing to expose. Null is explicitly allowed ("not laid out yet"). + override val textLayoutResult: () -> TextLayoutResult? = { null } + + /** Cursor rectangle in root coordinates — positions the IME candidate window. */ + override val focusedRectInRoot: () -> Rect? = { + val coords = editorState.canvasLayoutCoordinates + val metrics = editorState.lastCursorMetrics + if (coords != null && coords.isAttached && metrics != null) { + val origin = coords.positionInRoot() + Rect( + left = origin.x + metrics.position.x, + top = origin.y + metrics.lineTop, + right = origin.x + metrics.position.x, + bottom = origin.y + metrics.lineBottom + ) + } else { + null + } + } + + override val textFieldRectInRoot: () -> Rect? = { editorBoundsInRoot() } + + override val textClippingRectInRoot: () -> Rect? = { editorBoundsInRoot() } + + /** Top-left of the (unclipped) text content in root coordinates. */ + override val unclippedTextOffsetInRoot: () -> Offset? = { + val coords = editorState.canvasLayoutCoordinates + if (coords != null && coords.isAttached) coords.positionInRoot() else null + } + + override val editText: (TextEditingScope.() -> Unit) -> Unit = { block -> + DesktopTextEditingScope(editorState).block() + } + + private fun editorBoundsInRoot(): Rect? { + val coords = editorState.canvasLayoutCoordinates ?: return null + if (!coords.isAttached) return null + val origin = coords.positionInRoot() + val size = coords.size + return Rect( + left = origin.x, + top = origin.y, + right = origin.x + size.width, + bottom = origin.y + size.height + ) + } +} + +/** + * Adapts the library's [TextEditorState] to Compose's [ComposeTextEditorState] + * (a `CharSequence` plus selection/composition), read live each time the AWT + * input-method framework queries it. + */ +private class EditorStateAdapter( + private val editorState: TextEditorState +) : ComposeTextEditorState { + + override val length: Int get() = editorState.getTextLength() + + override fun get(index: Int): Char = editorState.getAllText()[index] + + override fun subSequence(startIndex: Int, endIndex: Int): CharSequence = + editorState.getAllText().subSequence(startIndex, endIndex) + + override val selection: TextRange get() = editorState.currentSelectionRange() + + override val composition: TextRange? + get() { + val comp = editorState.composingRange ?: return null + return TextRange( + editorState.getCharacterIndex(comp.start), + editorState.getCharacterIndex(comp.end) + ) + } +} + +/** + * Bridges Compose's [TextEditingScope] IME edit commands to the shared + * [ImeEditLogic] operations on [TextEditorState]. + */ +private class DesktopTextEditingScope( + private val state: TextEditorState +) : TextEditingScope { + + override fun deleteSurroundingTextInCodePoints(lengthBeforeCursor: Int, lengthAfterCursor: Int) = + state.imeDeleteSurroundingTextInCodePoints(lengthBeforeCursor, lengthAfterCursor) + + override fun commitText(text: CharSequence, newCursorPosition: Int) = + state.imeCommitText(text.toString(), newCursorPosition) + + override fun setComposingText(text: CharSequence, newCursorPosition: Int) = + state.imeSetComposingText(text.toString(), newCursorPosition) + + override fun finishComposingText() = state.imeFinishComposing() +} + +/** The current selection as a character-index [TextRange], collapsed to the cursor when none. */ +private fun TextEditorState.currentSelectionRange(): TextRange { + val sel = selector.selection + return if (sel != null) { + TextRange(getCharacterIndex(sel.start), getCharacterIndex(sel.end)) + } else { + val cursorIndex = getCharacterIndex(cursorPosition) + TextRange(cursorIndex) } } diff --git a/ComposeTextEditor/src/desktopTest/kotlin/input/ImeEditLogicTest.kt b/ComposeTextEditor/src/desktopTest/kotlin/input/ImeEditLogicTest.kt new file mode 100644 index 0000000..2a078e7 --- /dev/null +++ b/ComposeTextEditor/src/desktopTest/kotlin/input/ImeEditLogicTest.kt @@ -0,0 +1,140 @@ +package input + +import androidx.compose.ui.text.AnnotatedString +import com.darkrockstudios.texteditor.CharLineOffset +import com.darkrockstudios.texteditor.input.imeCommitText +import com.darkrockstudios.texteditor.input.imeDeleteSurroundingTextInCodePoints +import com.darkrockstudios.texteditor.input.imeFinishComposing +import com.darkrockstudios.texteditor.input.imeSetComposingText +import com.darkrockstudios.texteditor.state.TextEditorState +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Unit tests for the shared [com.darkrockstudios.texteditor.input] IME edit logic + * that backs both the Android InputConnection and the desktop + * PlatformTextInputMethodRequest. These exercise the exact mutations AWT's + * input-method framework triggers for dead-key / accent / CJK composition — the + * path that fixes hammer-editor #561. + */ +class ImeEditLogicTest { + + private lateinit var state: TextEditorState + + @BeforeTest + fun setup() { + state = TextEditorState( + scope = TestScope(), + measurer = mockk(relaxed = true), + initialText = AnnotatedString(""), + ) + } + + private fun text() = state.getAllText().text + + private fun cursorCharIndex() = state.getCharacterIndex(state.cursorPosition) + + private fun moveCursorToCharIndex(index: Int) { + state.cursor.updatePosition(state.getOffsetAtCharacter(index)) + } + + @Test + fun `commitText inserts at the cursor and advances it`() { + state.imeCommitText("a", newCursorPosition = 1) + + assertEquals("a", text()) + assertEquals(1, cursorCharIndex()) + assertNull(state.composingRange, "commitText must not leave a composing region") + } + + @Test + fun `dead key composition - setComposingText then commitText yields the accented char`() { + // This is the shape AWT delivers for a dead-key sequence routed through the + // input method (e.g. an IME that previews the accent before committing). + state.imeSetComposingText("´", newCursorPosition = 1) // ´ + assertEquals("´", text()) + assertTrue(state.composingRange != null, "Composing region should be active mid-composition") + + state.imeCommitText("á", newCursorPosition = 1) // á + assertEquals("á", text(), "Composing ´ should be replaced by the committed á") + assertEquals(1, cursorCharIndex()) + assertNull(state.composingRange, "commitText must clear the composing region") + } + + @Test + fun `direct commitText of an accented char inserts it (single-event dead key)`() { + // Many Linux dead-key setups deliver the composed char as a single committed + // InputMethodEvent with no intermediate composing text. + state.setText("caf") + moveCursorToCharIndex(3) + + state.imeCommitText("é", newCursorPosition = 1) // é + + assertEquals("café", text()) + assertEquals(4, cursorCharIndex()) + } + + @Test + fun `CJK composition - successive setComposingText replaces the preview then commits`() { + state.imeSetComposingText("n", newCursorPosition = 1) + assertEquals("n", text()) + state.imeSetComposingText("ni", newCursorPosition = 1) + assertEquals("ni", text()) + state.imeSetComposingText("niこ", newCursorPosition = 1) + assertEquals("niこ", text()) + + state.imeCommitText("你", newCursorPosition = 1) // 你 + assertEquals("你", text(), "Committed character replaces the whole composing preview") + assertNull(state.composingRange) + assertEquals(1, cursorCharIndex()) + } + + @Test + fun `commitText replaces an active selection`() { + state.setText("abc") + state.selector.updateSelection( + start = CharLineOffset(0, 1), + end = CharLineOffset(0, 2), + ) + + state.imeCommitText("X", newCursorPosition = 1) + + assertEquals("aXc", text()) + assertNull(state.selector.selection, "Selection should be cleared after commit") + assertEquals(2, cursorCharIndex()) + } + + @Test + fun `newCursorPosition of zero leaves the cursor before the inserted text`() { + state.imeCommitText("hello", newCursorPosition = 0) + + assertEquals("hello", text()) + assertEquals(0, cursorCharIndex(), "newCursorPosition <= 0 is relative to the insert start") + } + + @Test + fun `deleteSurroundingTextInCodePoints removes a surrogate pair as one code point`() { + state.setText("😀") // 😀 (one code point, two chars) + moveCursorToCharIndex(2) + + state.imeDeleteSurroundingTextInCodePoints(beforeLength = 1, afterLength = 0) + + assertEquals("", text(), "Deleting one code point must remove both surrogate halves") + } + + @Test + fun `finishComposing clears the composing region without altering the text`() { + state.imeSetComposingText("abc", newCursorPosition = 1) + assertTrue(state.composingRange != null) + + state.imeFinishComposing() + + assertEquals("abc", text(), "Text is kept as-is") + assertNull(state.composingRange, "Composing region is dropped") + } +} diff --git a/build.gradle.kts b/build.gradle.kts index afc9a3d..dba0b4e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { // this is necessary to avoid the plugins to be loaded multiple times // in each subproject's classloader alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeHotReload) apply false alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.android.application) apply false diff --git a/gradle.properties b/gradle.properties index 0371a47..e226be1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,3 +11,5 @@ org.jetbrains.compose.experimental.wasm.enabled=true #Android android.useAndroidX=true android.nonTransitiveRClass=true +#Compose Hot Reload (needs JetBrains Runtime; let the plugin provision it) +compose.reload.jbr.autoProvisioningEnabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index beb1509..bd7fc72 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ agp = "9.0.0" jvm = "17" androidx-lifecycle = "2.10.0" compose-multiplatform = "1.11.1" +compose-hot-reload = "1.2.0-alpha01" kotlin = "2.3.21" kotlinx-coroutines = "1.11.0" markdown = "0.7.5" @@ -49,6 +50,7 @@ symspellkt-fdic = { module = "com.darkrockstudios:symspellktfdic", version.ref = [plugins] composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/sampleApp/build.gradle.kts b/sampleApp/build.gradle.kts index dd89de4..2582f8b 100644 --- a/sampleApp/build.gradle.kts +++ b/sampleApp/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.android.kmp.library) + alias(libs.plugins.composeHotReload) } kotlin { From 05a42e3bf97e73aadfef0c466b87e3f4ac1ac34e Mon Sep 17 00:00:00 2001 From: Wavesonics Date: Sat, 6 Jun 2026 22:25:06 -0700 Subject: [PATCH 2/2] Address code review: share IME state-adapter logic, scope IME text reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract the selection/composition/text mapping used by the desktop and iOS PlatformTextInputMethodRequests into commonMain helpers (selectionAsTextRange, composingAsTextRange, imeSubSequence, imeCharAt). Both platforms' adapters now delegate to one implementation instead of duplicating it. (The adapter class itself stays per-platform: the Compose TextEditorState interface is skiko-only and not in the common API surface.) - IME char/substring queries now read only the requested range via getStringInRange instead of rebuilding the entire document with getAllText() on every call — avoids O(n)-per-access allocation during composition on large documents. - imeCharAt enforces the CharSequence out-of-range contract explicitly. - Fix a misleading comment in ImeEditLogic: the platform "batch" suppresses IME cursor-sync notifications, it does not coalesce undo history. All three targets (desktop, android, iosSimulatorArm64) compile; 429 desktop tests pass. --- .../texteditor/input/ImeEditLogic.kt | 7 +- .../texteditor/input/ImeStateAdapter.kt | 55 ++++++++++++++ .../TextEditorTextInputService.desktop.kt | 46 +++--------- .../input/TextEditorTextInputService.ios.kt | 74 ++++--------------- 4 files changed, 86 insertions(+), 96 deletions(-) create mode 100644 ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeStateAdapter.kt diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeEditLogic.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeEditLogic.kt index e323d91..257eb23 100644 --- a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeEditLogic.kt +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeEditLogic.kt @@ -12,8 +12,11 @@ import com.darkrockstudios.texteditor.state.TextEditorState * identical across platforms. Each function mutates [TextEditorState] the way the * corresponding IME command (`commitText`, `setComposingText`, ...) expects. * - * Callers are responsible for batching (so a single composition collapses into - * one undo step) — these functions only perform the mutation. + * Each function performs a single mutation through the edit manager. Platforms + * that need to suppress intermediate IME cursor-sync notifications during a + * multi-command edit wrap the calls in their own batch (e.g. Android's + * `PlatformTextEditorExtensions.beginBatchEdit`/`endBatchEdit`); the batch does + * not coalesce undo history. */ /** diff --git a/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeStateAdapter.kt b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeStateAdapter.kt new file mode 100644 index 0000000..53735cc --- /dev/null +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeStateAdapter.kt @@ -0,0 +1,55 @@ +package com.darkrockstudios.texteditor.input + +import androidx.compose.ui.text.TextRange +import com.darkrockstudios.texteditor.TextEditorRange +import com.darkrockstudios.texteditor.state.TextEditorState + +/** + * Shared helpers for exposing the library's [TextEditorState] to Compose's + * platform input-method `TextEditorState` (a `CharSequence` plus + * selection/composition). + * + * The adapter *class* that implements `androidx.compose.ui.text.input.TextEditorState` + * must live per-platform (that interface is skiko-only — it isn't in the common + * Compose API surface), but the mapping logic below is shared by the desktop and + * iOS adapters so selection/composition/text access stays identical and is + * defined once. + */ + +/** + * The substring `[startIndex, endIndex)` (in flat character indices), built from + * only the requested range instead of materializing the whole document — so IME + * queries during composition stay cheap on large documents. + */ +internal fun TextEditorState.imeSubSequence(startIndex: Int, endIndex: Int): CharSequence { + if (startIndex >= endIndex) return "" + return getStringInRange( + TextEditorRange(getOffsetAtCharacter(startIndex), getOffsetAtCharacter(endIndex)) + ) +} + +/** A single character at flat index [index], without rebuilding the whole document. */ +internal fun TextEditorState.imeCharAt(index: Int): Char { + // Match the CharSequence/String contract: out-of-range indices throw. + if (index < 0 || index >= getTextLength()) { + throw IndexOutOfBoundsException("index: $index, length: ${getTextLength()}") + } + return imeSubSequence(index, index + 1)[0] +} + +/** The current selection as a character-index [TextRange], collapsed to the cursor when none. */ +internal fun TextEditorState.selectionAsTextRange(): TextRange { + val sel = selector.selection + return if (sel != null) { + TextRange(getCharacterIndex(sel.start), getCharacterIndex(sel.end)) + } else { + val cursorIndex = getCharacterIndex(cursorPosition) + TextRange(cursorIndex) + } +} + +/** The active composing region as a character-index [TextRange], or null when not composing. */ +internal fun TextEditorState.composingAsTextRange(): TextRange? { + val comp = composingRange ?: return null + return TextRange(getCharacterIndex(comp.start), getCharacterIndex(comp.end)) +} diff --git a/ComposeTextEditor/src/desktopMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.desktop.kt b/ComposeTextEditor/src/desktopMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.desktop.kt index e4bc6cd..2d63176 100644 --- a/ComposeTextEditor/src/desktopMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.desktop.kt +++ b/ComposeTextEditor/src/desktopMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.desktop.kt @@ -9,7 +9,6 @@ import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.PlatformTextInputSession import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.EditCommand import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeOptions @@ -51,12 +50,12 @@ private class DesktopTextEditorInputMethodRequest( ) : PlatformTextInputMethodRequest { /** Live view of the editor as the CharSequence + selection + composition Compose expects. */ - override val state: ComposeTextEditorState = EditorStateAdapter(editorState) + override val state: ComposeTextEditorState = ImeComposeStateAdapter(editorState) override val value: () -> TextFieldValue = { TextFieldValue( text = editorState.getAllText().text, - selection = editorState.currentSelectionRange() + selection = editorState.selectionAsTextRange() ) } @@ -119,31 +118,21 @@ private class DesktopTextEditorInputMethodRequest( } /** - * Adapts the library's [TextEditorState] to Compose's [ComposeTextEditorState] - * (a `CharSequence` plus selection/composition), read live each time the AWT - * input-method framework queries it. + * Adapts the library's [TextEditorState] to Compose's skiko `TextEditorState` + * (CharSequence + selection + composition), read live each time the AWT + * input-method framework queries it. Mapping logic is shared via the + * `*AsTextRange` / `imeSubSequence` helpers in commonMain. */ -private class EditorStateAdapter( +private class ImeComposeStateAdapter( private val editorState: TextEditorState ) : ComposeTextEditorState { - override val length: Int get() = editorState.getTextLength() - - override fun get(index: Int): Char = editorState.getAllText()[index] - + override fun get(index: Int): Char = editorState.imeCharAt(index) override fun subSequence(startIndex: Int, endIndex: Int): CharSequence = - editorState.getAllText().subSequence(startIndex, endIndex) - - override val selection: TextRange get() = editorState.currentSelectionRange() - - override val composition: TextRange? - get() { - val comp = editorState.composingRange ?: return null - return TextRange( - editorState.getCharacterIndex(comp.start), - editorState.getCharacterIndex(comp.end) - ) - } + editorState.imeSubSequence(startIndex, endIndex) + override fun toString(): String = editorState.getAllText().toString() + override val selection get() = editorState.selectionAsTextRange() + override val composition get() = editorState.composingAsTextRange() } /** @@ -165,14 +154,3 @@ private class DesktopTextEditingScope( override fun finishComposingText() = state.imeFinishComposing() } - -/** The current selection as a character-index [TextRange], collapsed to the cursor when none. */ -private fun TextEditorState.currentSelectionRange(): TextRange { - val sel = selector.selection - return if (sel != null) { - TextRange(getCharacterIndex(sel.start), getCharacterIndex(sel.end)) - } else { - val cursorIndex = getCharacterIndex(cursorPosition) - TextRange(cursorIndex) - } -} diff --git a/ComposeTextEditor/src/iosMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.ios.kt b/ComposeTextEditor/src/iosMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.ios.kt index 0300318..a075b42 100644 --- a/ComposeTextEditor/src/iosMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.ios.kt +++ b/ComposeTextEditor/src/iosMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.ios.kt @@ -26,57 +26,21 @@ actual class TextEditorTextInputService actual constructor( } /** - * Bridge implementation of Compose framework's TextEditorState interface - * that delegates to our custom TextEditorState. + * Adapts the library's [TextEditorState] to Compose's skiko `TextEditorState` + * (CharSequence + selection + composition). Mapping logic is shared via the + * `*AsTextRange` / `imeSubSequence` helpers in commonMain. */ @OptIn(ExperimentalComposeUiApi::class) -private class TextEditorStateBridge( +private class ImeComposeStateAdapter( private val editorState: com.darkrockstudios.texteditor.state.TextEditorState ) : androidx.compose.ui.text.input.TextEditorState { - - // CharSequence implementation - delegate to our editor state's text - override val length: Int - get() = editorState.getTextLength() - - override fun get(index: Int): Char { - val text = editorState.getAllText() - return text[index] - } - - override fun subSequence(startIndex: Int, endIndex: Int): CharSequence { - return editorState.getAllText().subSequence(startIndex, endIndex) - } - - override fun toString(): String { - return editorState.getAllText().toString() - } - - // Selection range - map from our custom state - override val selection: TextRange - get() { - val selection = editorState.selector.selection - return if (selection != null) { - val start = editorState.getCharacterIndex(selection.start) - val end = editorState.getCharacterIndex(selection.end) - TextRange(start, end) - } else { - val cursorPos = editorState.getCharacterIndex(editorState.cursorPosition) - TextRange(cursorPos, cursorPos) - } - } - - // Composition range - map from our custom state's composing range - override val composition: TextRange? - get() { - val composing = editorState.composingRange - return if (composing != null) { - val start = editorState.getCharacterIndex(composing.start) - val end = editorState.getCharacterIndex(composing.end) - TextRange(start, end) - } else { - null - } - } + override val length: Int get() = editorState.getTextLength() + override fun get(index: Int): Char = editorState.imeCharAt(index) + override fun subSequence(startIndex: Int, endIndex: Int): CharSequence = + editorState.imeSubSequence(startIndex, endIndex) + override fun toString(): String = editorState.getAllText().toString() + override val selection get() = editorState.selectionAsTextRange() + override val composition get() = editorState.composingAsTextRange() } /** @@ -88,9 +52,9 @@ private class TextEditorIOSInputMethodRequest( private val editorState: com.darkrockstudios.texteditor.state.TextEditorState ) : PlatformTextInputMethodRequest { - // Bridge to Compose framework's TextEditorState + // Shared bridge to Compose framework's TextEditorState (see ImeStateAdapter.kt) override val state: androidx.compose.ui.text.input.TextEditorState = - TextEditorStateBridge(editorState) + ImeComposeStateAdapter(editorState) override val imeOptions: ImeOptions = ImeOptions( keyboardType = KeyboardType.Text, @@ -100,17 +64,7 @@ private class TextEditorIOSInputMethodRequest( // Provide current text field value to iOS override val value: () -> TextFieldValue = { - val text = editorState.getAllText() - val cursorPos = editorState.getCharacterIndex(editorState.cursorPosition) - val selection = editorState.selector.selection - - if (selection != null) { - val start = editorState.getCharacterIndex(selection.start) - val end = editorState.getCharacterIndex(selection.end) - TextFieldValue(text, TextRange(start, end)) - } else { - TextFieldValue(text, TextRange(cursorPos, cursorPos)) - } + TextFieldValue(editorState.getAllText(), editorState.selectionAsTextRange()) } // Handle edit commands from iOS IME