Skip to content
Open
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
@@ -1,11 +1,15 @@
package com.swmansion.enriched.textinput

import android.text.Editable
import android.view.KeyEvent
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputConnectionWrapper
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan
import com.swmansion.enriched.textinput.events.OnInputKeyPressEvent
import com.swmansion.enriched.textinput.spans.EnrichedSpans

// This class is based on the implementation from Facebook React Native to provide 'onKeyPress' API on android.
// Original source:
Expand All @@ -16,6 +20,20 @@ class EnrichedTextInputConnectionWrapper(
private val editText: EnrichedTextInputView,
private val experimentalSynchronousEvents: Boolean,
) : InputConnectionWrapper(target, false) {
private data class InlineSpanSnapshot(
val style: String,
val start: Int,
val end: Int,
)

private data class ComposingTextSnapshot(
val start: Int,
val end: Int,
val editableLength: Int,
val text: String,
val inlineSpans: List<InlineSpanSnapshot>,
)

private var isBatchEdit = false
private var key: String? = null

Expand All @@ -39,9 +57,14 @@ class EnrichedTextInputConnectionWrapper(
): Boolean {
val previousSelectionStart = editText.selectionStart
val previousSelectionEnd = editText.selectionEnd
val composingTextSnapshot = captureComposingText()

val consumed = super.setComposingText(text, newCursorPosition)

if (consumed && composingTextSnapshot != null) {
restoreInlineSpans(composingTextSnapshot)
}

val currentSelectionStart = editText.selectionStart
val noPreviousSelection = previousSelectionStart == previousSelectionEnd
val cursorDidNotMove = currentSelectionStart == previousSelectionStart
Expand All @@ -61,6 +84,143 @@ class EnrichedTextInputConnectionWrapper(
return consumed
}

// Snapshots the inline styles carried by the text the IME is about to replace. Returns null
// whenever there is nothing to preserve, which keeps plain typing down to a single span lookup.
private fun captureComposingText(): ComposingTextSnapshot? {
val editable = editText.text ?: return null
val composingStart = BaseInputConnection.getComposingSpanStart(editable)
val composingEnd = BaseInputConnection.getComposingSpanEnd(editable)
if (composingStart < 0 || composingEnd <= composingStart) return null

val inlineSpans = captureInlineSpans(editable, composingStart, composingEnd)
if (inlineSpans.isEmpty()) return null

return ComposingTextSnapshot(
start = composingStart,
end = composingEnd,
editableLength = editable.length,
text = editable.substring(composingStart, composingEnd),
inlineSpans = inlineSpans,
)
}

private fun captureInlineSpans(
editable: Editable,
composingStart: Int,
composingEnd: Int,
): List<InlineSpanSnapshot> =
editable
.getSpans(composingStart, composingEnd, EnrichedInlineSpan::class.java)
.mapNotNull { span ->
val style =
EnrichedSpans.inlineSpans.entries
.firstOrNull { (_, config) -> config.clazz.isInstance(span) }
?.key
?: return@mapNotNull null
val start = editable.getSpanStart(span).coerceAtLeast(composingStart)
val end = editable.getSpanEnd(span).coerceAtMost(composingEnd)

if (start < end) {
InlineSpanSnapshot(style, start - composingStart, end - composingStart)
} else {
null
}
}

Comment on lines +112 to +129

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would look cleaner and we would get rid of the "guessing" style lookup. What do you think?

Suggested change
editable
.getSpans(composingStart, composingEnd, EnrichedInlineSpan::class.java)
.mapNotNull { span ->
val style =
EnrichedSpans.inlineSpans.entries
.firstOrNull { (_, config) -> config.clazz.isInstance(span) }
?.key
?: return@mapNotNull null
val start = editable.getSpanStart(span).coerceAtLeast(composingStart)
val end = editable.getSpanEnd(span).coerceAtMost(composingEnd)
if (start < end) {
InlineSpanSnapshot(style, start - composingStart, end - composingStart)
} else {
null
}
}
EnrichedSpans.inlineSpans.flatMap { (style, config) ->
editable.getSpans(composingStart, composingEnd, config.clazz).mapNotNull { span ->
val start = editable.getSpanStart(span).coerceAtLeast(composingStart)
val end = editable.getSpanEnd(span).coerceAtMost(composingEnd)
if (start < end) {
InlineSpanSnapshot(style, start - composingStart, end - composingStart)
} else {
null
}
}
}

private fun restoreInlineSpans(snapshot: ComposingTextSnapshot) {
val editable = editText.text ?: return
val previousComposingLength = snapshot.end - snapshot.start
val currentComposingLength =
editable.length - (snapshot.editableLength - previousComposingLength)
val currentComposingStart = snapshot.start.coerceAtMost(editable.length)
val currentComposingEnd =
(currentComposingStart + currentComposingLength)
.coerceAtLeast(currentComposingStart)
.coerceAtMost(editable.length)
if (currentComposingStart >= currentComposingEnd) return

val currentComposingText = editable.substring(currentComposingStart, currentComposingEnd)
val commonPrefixLength = commonPrefixLength(snapshot.text, currentComposingText)
val commonSuffixLength =
commonSuffixLength(
snapshot.text,
currentComposingText,
commonPrefixLength,
)

for (inlineSpan in snapshot.inlineSpans) {
restoreSnapshotIntersection(
inlineSpan,
oldRangeStart = 0,
oldRangeEnd = commonPrefixLength,
newRangeStart = 0,
composingStart = currentComposingStart,
)

val previousSuffixStart = snapshot.text.length - commonSuffixLength
val currentSuffixStart = currentComposingText.length - commonSuffixLength
restoreSnapshotIntersection(
inlineSpan,
oldRangeStart = previousSuffixStart,
oldRangeEnd = snapshot.text.length,
newRangeStart = currentSuffixStart,
composingStart = currentComposingStart,
)
}
}

private fun restoreSnapshotIntersection(
snapshot: InlineSpanSnapshot,
oldRangeStart: Int,
oldRangeEnd: Int,
newRangeStart: Int,
composingStart: Int,
) {
val intersectionStart = snapshot.start.coerceAtLeast(oldRangeStart)
val intersectionEnd = snapshot.end.coerceAtMost(oldRangeEnd)
if (intersectionStart >= intersectionEnd) return

val mappedStart = newRangeStart + intersectionStart - oldRangeStart
val mappedEnd = newRangeStart + intersectionEnd - oldRangeStart
editText.inlineStyles?.restoreStyleOnRange(
snapshot.style,
composingStart + mappedStart,
composingStart + mappedEnd,
)
}

private fun commonPrefixLength(
previousText: String,
currentText: String,
): Int {
val maximum = minOf(previousText.length, currentText.length)
var length = 0
while (length < maximum && previousText[length] == currentText[length]) {
length++
}
return length
}

private fun commonSuffixLength(
previousText: String,
currentText: String,
commonPrefixLength: Int,
): Int {
val maximum =
minOf(
previousText.length - commonPrefixLength,
currentText.length - commonPrefixLength,
)
var length = 0
while (
length < maximum &&
previousText[previousText.lastIndex - length] == currentText[currentText.lastIndex - length]
) {
length++
}
return length
}

override fun commitText(
text: CharSequence,
newCursorPosition: Int,
Expand All @@ -73,7 +233,13 @@ class EnrichedTextInputConnectionWrapper(
}
dispatchKeyEventOrEnqueue(inputKey)
}
return super.commitText(text, newCursorPosition)

val composingTextSnapshot = captureComposingText()
val consumed = super.commitText(text, newCursorPosition)
if (consumed && composingTextSnapshot != null) {
restoreInlineSpans(composingTextSnapshot)
}
return consumed
}

override fun deleteSurroundingText(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,24 @@ class InlineStyles(
setAndMergeSpans(spannable, type, start, end)
}

fun restoreStyleOnRange(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It strongly feels like duplicated logic, maybe instead of defining a new function, let's reuse applyStyleOnRange that we have above?

name: String,
start: Int,
end: Int,
) {
if (start >= end) return

val config = EnrichedSpans.inlineSpans[name] ?: return
val spannable = view.text as? Spannable ?: return
val spans = spannable.getSpans(start, end, config.clazz)

if (spans.any { spannable.getSpanStart(it) <= start && spannable.getSpanEnd(it) >= end }) {
return
}

setSpan(spannable, config.clazz, start, end)
}

fun toggleStyle(name: String) {
if (view.selection == null) return
val (start, end) = view.selection.getInlineSelection()
Expand Down
Loading