Skip to content
This repository has been archived by the owner on Sep 27, 2024. It is now read-only.

Commit

Permalink
Merge pull request #948 from matrix-org/fix/android-reconcile-text-an…
Browse files Browse the repository at this point in the history
…d-editor

Android: reconcile UI text with editor text
  • Loading branch information
jmartinesp authored Mar 20, 2024
2 parents 5d3e6c3 + cc9abf6 commit 57ea7da
Show file tree
Hide file tree
Showing 7 changed files with 216 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.widget.ArrayAdapter
import android.widget.LinearLayout
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import io.element.android.wysiwyg.EditorEditText
import io.element.android.wysiwyg.poc.databinding.ViewRichTextEditorBinding
import io.element.android.wysiwyg.poc.matrix.MatrixMentionMentionDisplayHandler
Expand Down Expand Up @@ -42,6 +43,17 @@ class RichTextEditor : LinearLayout {
super.onAttachedToWindow()

with(binding) {
richTextEditText.addTextChangedListener(
beforeTextChanged = { text, start, count, after ->
Timber.d("Before text changed: '$text', start: $start, count: $count, after: $after")
},
onTextChanged = { text, start, before, count ->
Timber.d("Text changed: '$text', start: $start, before: $before, count: $count")
},
afterTextChanged = { text ->
Timber.d("After text changed: '$text'")
}
)
formattingSwitch.apply {
isChecked = true
setOnCheckedChangeListener { _, isChecked ->
Expand Down Expand Up @@ -194,10 +206,9 @@ class RichTextEditor : LinearLayout {
override fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean {
return binding.richTextEditText.requestFocus(direction, previouslyFocusedRect)
}

}

interface OnSetLinkListener {
fun openSetLinkDialog(currentLink: String?, callback: (url: String?) -> Unit)
fun openInsertLinkDialog(callback: (text: String, url: String) -> Unit)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.wysiwyg.display.TextDisplay
import io.element.android.wysiwyg.test.R
import io.element.android.wysiwyg.test.rules.createFlakyEmulatorRule
import io.element.android.wysiwyg.test.utils.*
import io.element.android.wysiwyg.test.utils.AnyViewAction
import io.element.android.wysiwyg.test.utils.EditorActions
import io.element.android.wysiwyg.test.utils.ImeActions
import io.element.android.wysiwyg.test.utils.ScreenshotFailureHandler
import io.element.android.wysiwyg.test.utils.TestActivity
import io.element.android.wysiwyg.test.utils.TestMentionDisplayHandler
import io.element.android.wysiwyg.test.utils.selectionIsAt
import io.element.android.wysiwyg.utils.NBSP
import io.element.android.wysiwyg.utils.RustErrorCollector
import io.element.android.wysiwyg.view.models.InlineFormat
Expand All @@ -45,9 +51,12 @@ import io.mockk.verify
import org.hamcrest.CoreMatchers
import org.hamcrest.CoreMatchers.containsString
import org.hamcrest.Description
import org.hamcrest.MatcherAssert
import org.hamcrest.Matchers
import org.junit.*
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import uniffi.wysiwyg_composer.ActionState
import uniffi.wysiwyg_composer.ComposerAction
Expand Down Expand Up @@ -145,7 +154,6 @@ class EditorEditTextInputTests {
val emoji = "\uD83E\uDD17"
onView(withId(R.id.rich_text_edit_text))
// pressKey doesn't seem to work if no `typeText` is used before
.perform(pressKey(KeyEvent.KEYCODE_A))
.perform(replaceText(emoji))
.perform(pressKey(KeyEvent.KEYCODE_ENTER))
.check(matches(withText(containsString(emoji + "\n"))))
Expand Down Expand Up @@ -569,15 +577,15 @@ class EditorEditTextInputTests {

verify(exactly = 2) {
// In future when we support parsing/loading of pasted html into the model
// we can make more assertions on that the corrrect formating is applied
textWatcher.invoke(match { it.toString() == ipsum + ipsum })
// we can make more assertions on that the correct formatting is applied
textWatcher(match { it.toString() == ipsum + ipsum })
}

confirmVerified(contentWatcher)
}


private fun pasteFromClipboard(clipData: ClipData, pasteAsPlainText: Boolean){
private fun pasteFromClipboard(clipData: ClipData, pasteAsPlainText: Boolean) {
scenarioRule.scenario.onActivity { activity ->
val editor = activity.findViewById<EditorEditText>(R.id.rich_text_edit_text)
val clipboardManager =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import androidx.test.core.app.ApplicationProvider
import io.element.android.wysiwyg.EditorTextWatcher
import io.element.android.wysiwyg.fakes.createFakeStyleConfig
import io.element.android.wysiwyg.internal.viewmodel.EditorInputAction
import io.element.android.wysiwyg.internal.viewmodel.EditorViewModel
import io.element.android.wysiwyg.test.utils.Editor
import io.element.android.wysiwyg.test.utils.dumpSpans
import io.element.android.wysiwyg.utils.HtmlConverter
import io.element.android.wysiwyg.utils.NBSP
Expand All @@ -34,6 +36,7 @@ class InterceptInputConnectionIntegrationTest {
viewModel = viewModel,
editorEditText = textView,
baseInputConnection = textView.onCreateInputConnection(EditorInfo()),
textWatcher = EditorTextWatcher(),
)

private val baseEditedSpans = listOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.os.Build
import android.os.Parcelable
import android.text.Selection
import android.text.Spanned
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.KeyEvent
import android.view.MotionEvent
Expand All @@ -20,7 +21,6 @@ import androidx.annotation.VisibleForTesting
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.graphics.withTranslation
import androidx.core.view.ViewCompat
import androidx.lifecycle.*
import io.element.android.wysiwyg.display.MentionDisplayHandler
import io.element.android.wysiwyg.inputhandlers.InterceptInputConnection
import io.element.android.wysiwyg.internal.display.MemoizingMentionDisplayHandler
Expand All @@ -29,21 +29,27 @@ import io.element.android.wysiwyg.internal.view.EditorEditTextAttributeReader
import io.element.android.wysiwyg.internal.view.viewModel
import io.element.android.wysiwyg.internal.viewmodel.EditorInputAction
import io.element.android.wysiwyg.internal.viewmodel.EditorViewModel
import io.element.android.wysiwyg.utils.*
import io.element.android.wysiwyg.utils.EditorIndexMapper
import io.element.android.wysiwyg.utils.HtmlConverter
import io.element.android.wysiwyg.utils.HtmlToSpansParser.FormattingSpans.removeFormattingSpans
import io.element.android.wysiwyg.utils.RustErrorCollector
import io.element.android.wysiwyg.view.StyleConfig
import io.element.android.wysiwyg.view.inlinebg.SpanBackgroundHelper
import io.element.android.wysiwyg.view.inlinebg.SpanBackgroundHelperFactory
import io.element.android.wysiwyg.view.models.InlineFormat
import io.element.android.wysiwyg.view.models.LinkAction
import io.element.android.wysiwyg.view.spans.ReuseSourceSpannableFactory
import uniffi.wysiwyg_composer.*
import uniffi.wysiwyg_composer.ActionState
import uniffi.wysiwyg_composer.ComposerAction
import uniffi.wysiwyg_composer.MentionsState
import uniffi.wysiwyg_composer.MenuAction
import uniffi.wysiwyg_composer.newComposerModel

/**
* An [EditText] that handles rich text editing.
*/
class EditorEditText : AppCompatEditText {

private lateinit var textWatcher: EditorTextWatcher
private var inputConnection: InterceptInputConnection? = null

/**
Expand Down Expand Up @@ -75,7 +81,7 @@ class EditorEditText : AppCompatEditText {
rerender()
}

private fun createHtmlConverter(styleConfig: StyleConfig, mentionDisplayHandler: MentionDisplayHandler?): HtmlConverter? {
private fun createHtmlConverter(styleConfig: StyleConfig, mentionDisplayHandler: MentionDisplayHandler?): HtmlConverter {
return HtmlConverter.Factory.create(
context = context.applicationContext,
styleConfig = styleConfig,
Expand Down Expand Up @@ -209,7 +215,7 @@ class EditorEditText : AppCompatEditText {
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection {
val baseInputConnection = requireNotNull(super.onCreateInputConnection(outAttrs))
val inputConnection =
InterceptInputConnection(baseInputConnection, this, viewModel)
InterceptInputConnection(baseInputConnection, this, viewModel, textWatcher)
this.inputConnection = inputConnection
return inputConnection
}
Expand Down Expand Up @@ -521,9 +527,6 @@ class EditorEditText : AppCompatEditText {

/**
* Set a mention link that applies to the current suggestion range
*
* @param url The url of the new link
* @param text The text to insert into the current suggestion range
*/
fun insertAtRoomMentionAtSuggestion() {
val result = viewModel.processInput(
Expand Down Expand Up @@ -578,6 +581,18 @@ class EditorEditText : AppCompatEditText {
)
}

override fun addTextChangedListener(watcher: TextWatcher) {
if (!this::textWatcher.isInitialized) {
this.textWatcher = EditorTextWatcher()
super.addTextChangedListener(this.textWatcher)
}
textWatcher.addChild(watcher)
}

override fun removeTextChangedListener(watcher: TextWatcher) {
textWatcher.removeChild(watcher)
}

/**
* Force redisplay the current editor model.
*
Expand All @@ -591,8 +606,14 @@ class EditorEditText : AppCompatEditText {

private fun setTextFromComposerUpdate(text: CharSequence) {
beginBatchEdit()
editableText.removeFormattingSpans()
editableText.replace(0, editableText.length, text)
textWatcher.runInEditor {
val beforeLength = editableText.length
notifyBeforeTextChanged(editableText, 0, beforeLength, text.length)
editableText.removeFormattingSpans()
editableText.replace(0, editableText.length, text)
notifyOnTextChanged(editableText, 0, beforeLength, text.length)
notifyAfterTextChanged(editableText)
}
endBatchEdit()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package io.element.android.wysiwyg

import android.text.Editable
import android.text.TextWatcher
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean

/**
* A [TextWatcher] that intercepts changes in the underlying text and can have child watchers.
*/
internal class EditorTextWatcher: TextWatcher {
private val nestedWatchers: MutableList<TextWatcher> = mutableListOf()
private val updateIsFromEditor: AtomicBoolean = AtomicBoolean(false)

var enableDebugLogs = false

private var beforeText: CharSequence? = null

/**
* The callback to be called when the text changes unexpectedly.
* These changes don't come from the editor, they might come from the OS or other sources.
*/
var updateCallback: (CharSequence, Int, Int, CharSequence?) -> Unit = { _, _, _, _ -> }

/**
* Add a child watcher to be notified of text changes.
*/
fun addChild(watcher: TextWatcher) {
nestedWatchers.add(watcher)
}

/**
* Remove a child watcher from being notified of text changes.
*/
fun removeChild(watcher: TextWatcher) {
nestedWatchers.remove(watcher)
}

/**
* Run a block of code that comes from the editor.
*
* This is used to prevent the editor from updating itself when it's already updating itself and
* entering an endless loop.
*
* @param block The block of code to run.
*/
fun runInEditor(block: EditorTextWatcher.() -> Unit) {
updateIsFromEditor.set(true)
this.block()
updateIsFromEditor.set(false)
}

override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
if (enableDebugLogs) {
Timber.v("beforeTextChanged | text: \"$s\", start: $start, count: $count, after: $after")
}
if (!updateIsFromEditor.get()) {
beforeText = s?.subSequence(start, start + count)
}
}

override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
if (enableDebugLogs) {
Timber.v("onTextChanged | text: \"$s\", start: $start, before: $before, count: $count")
}
if (!updateIsFromEditor.get()) {
val newText = s?.subSequence(start, start + count) ?: ""
updateCallback(newText, start, start + before, beforeText)
}
}

override fun afterTextChanged(s: Editable?) {
if (enableDebugLogs) {
Timber.v("afterTextChanged")
}
if (!updateIsFromEditor.get()) {
beforeText = null
}
}

/**
* Notify the nested watchers that the text is about to change.
* @param text The text that will be changed.
* @param start The start index of the change.
* @param count The number of characters that will be removed.
* @param after The number of characters that will be added.
*/
fun notifyBeforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {
nestedWatchers.forEach { it.beforeTextChanged(text, start, count, after) }
}

/**
* Notify the nested watchers that the text is changing.
* @param text The new text.
* @param start The start index of the change.
* @param before The number of characters that were removed.
* @param count The number of characters that were added.
*/
fun notifyOnTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {
nestedWatchers.forEach { it.onTextChanged(text, start, before, count) }
}

/**
* Notify the nested watchers that the text changed.
* @param editable The updated text.
*/
fun notifyAfterTextChanged(editable: Editable) {
nestedWatchers.forEach { it.afterTextChanged(editable) }
}
}
Loading

0 comments on commit 57ea7da

Please sign in to comment.