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..257eb23 --- /dev/null +++ b/ComposeTextEditor/src/commonMain/kotlin/com/darkrockstudios/texteditor/input/ImeEditLogic.kt @@ -0,0 +1,187 @@ +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. + * + * 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. + */ + +/** + * `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/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/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..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 @@ -1,27 +1,156 @@ +@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.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 = ImeComposeStateAdapter(editorState) + + override val value: () -> TextFieldValue = { + TextFieldValue( + text = editorState.getAllText().text, + selection = editorState.selectionAsTextRange() + ) + } + + 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 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 ImeComposeStateAdapter( + private val editorState: TextEditorState +) : ComposeTextEditorState { + 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() +} + +/** + * 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() } 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/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 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 {