Skip to content

Commit

Permalink
update CodeEditorView to run on multiple threads to avoid frequent la…
Browse files Browse the repository at this point in the history
…gging while searching and typing in a large text, and add thread-safe ConcurrentBigText
  • Loading branch information
sunny-chung committed Nov 13, 2024
1 parent d1e02d7 commit 8fdacc2
Show file tree
Hide file tree
Showing 8 changed files with 474 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTran
import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl
import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextViewState
import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.abbr
import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.rememberLargeAnnotatedBigTextFieldState
import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.rememberConcurrentLargeAnnotatedBigTextFieldState
import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast
import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor
import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalFont
Expand All @@ -90,6 +90,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incr
import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleTextDecorator
import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.SearchHighlightDecorator
import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
Expand All @@ -98,6 +99,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.regex.Pattern
import kotlin.random.Random

Expand Down Expand Up @@ -133,7 +135,7 @@ fun CodeEditorView(

var layoutResult by remember { mutableStateOf<BigTextSimpleLayoutResult?>(null) }

val bigTextFieldState: BigTextFieldState by rememberLargeAnnotatedBigTextFieldState(initialValue = initialText, cacheKey)
val bigTextFieldState: BigTextFieldState by rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue = initialText, cacheKey)
val bigTextValue: BigText = bigTextFieldState.text
var bigTextValueId by remember(bigTextFieldState) { mutableStateOf<Long>(Random.nextLong()) }

Expand Down Expand Up @@ -262,30 +264,37 @@ fun CodeEditorView(
log.d { "get search pattern ${searchPattern?.pattern}" }

LaunchedEffect(bigTextValue) {
searchTrigger.receiveAsFlow()
.debounce(210L)
.filter { isSearchVisible }
.collectLatest {
log.d { "search triggered ${searchPatternLatest?.pattern}" }
if (searchPatternLatest != null) {
try {
val fullText = bigTextValue.buildString()
val r = searchPatternLatest!!
.findAll(fullText)
.map { it.range }
.sortedBy { it.start }
.toList()
searchResultRangesState.value = r
searchResultRangeTreeState.value = TreeRangeMaps.from(r)
log.d { "search r ${r.size}" }
} catch (e: Throwable) {
log.d(e) { "search error" }
withContext(Dispatchers.IO) {
searchTrigger.receiveAsFlow()
.debounce(210L)
.filter { isSearchVisible }
.collectLatest {
log.d { "search triggered ${searchPatternLatest?.pattern}" }
if (searchPatternLatest != null) {
try {
val fullText = bigTextValue.buildString()
val r = searchPatternLatest!!
.findAll(fullText)
.map { it.range }
.sortedBy { it.start }
.toList()
val treeRangeMap = TreeRangeMaps.from(r)
withContext(Dispatchers.Main) {
searchResultRangesState.value = r
searchResultRangeTreeState.value = treeRangeMap
log.d { "search r ${r.size}" }
}
} catch (e: Throwable) {
log.d(e) { "search error" }
}
} else {
withContext(Dispatchers.Main) {
searchResultRangesState.value = null
searchResultRangeTreeState.value = null
}
}
} else {
searchResultRangesState.value = null
searchResultRangeTreeState.value = null
}
}
}
}
var searchResultSummary = if (!searchResultRanges.isNullOrEmpty()) {
"${searchResultViewIndex + 1}/${searchResultRanges?.size}"
Expand Down Expand Up @@ -488,21 +497,25 @@ fun CodeEditorView(
} else {
LaunchedEffect(bigTextFieldState, onTextChange) { // FIXME the flow is frequently recreated
log.i { "CEV recreate change collection flow $bigTextFieldState ${onTextChange.hashCode()}" }
bigTextFieldState.valueChangesFlow
.onEach { log.d { "bigTextFieldState change each ${it.changeId}" } }
.chunkedLatest(200.milliseconds())
.collect {
log.d { "bigTextFieldState change collect ${it.changeId} ${it.bigText.length} ${it.bigText.buildString()}" }
onTextChange?.let { onTextChange ->
val string = it.bigText.buildCharSequence() as AnnotatedString
log.d { "${bigTextFieldState.text} : ${it.bigText} onTextChange(${string.text.abbr()})" }
onTextChange(string.text)
}
bigTextValueId = it.changeId
searchTrigger.trySend(Unit)
withContext(Dispatchers.IO) {
bigTextFieldState.valueChangesFlow
.onEach { log.d { "bigTextFieldState change each ${it.changeId}" } }
.chunkedLatest(200.milliseconds())
.collect {
log.d { "bigTextFieldState change collect ${it.changeId} ${it.bigText.length} ${it.bigText.buildString()}" }
onTextChange?.let { onTextChange ->
val string = it.bigText.buildCharSequence() as AnnotatedString
withContext(Dispatchers.Main) {
log.d { "${bigTextFieldState.text} : ${it.bigText} onTextChange(${string.text.abbr()})" }
onTextChange(string.text)
}
}
bigTextValueId = it.changeId
searchTrigger.trySend(Unit)

bigTextFieldState.markConsumed(it.sequence)
}
bigTextFieldState.markConsumed(it.sequence)
}
}
}

var mouseHoverVariable by remember(bigTextFieldState) { mutableStateOf<String?>(null) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ interface BigText {
var undoMetadataSupplier: (() -> Any?)?
var changeHook: BigTextChangeHook?

val isThreadSafe: Boolean
get() = false

fun buildString(): String

fun buildCharSequence(): CharSequence
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext

import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write

class ConcurrentBigText(private val delegate: BigText) : BigText {

private val lock = ReentrantReadWriteLock()

override val length: Int
get() = lock.read { delegate.length }
override val lastIndex: Int
get() = lock.read { delegate.lastIndex }
override val isEmpty: Boolean
get() = lock.read { delegate.isEmpty }
override val isNotEmpty: Boolean
get() = lock.read { delegate.isNotEmpty }
override val hasLayouted: Boolean
get() = lock.read { delegate.hasLayouted }
override val layouter: TextLayouter?
get() = lock.read { delegate.layouter }
override val numOfLines: Int
get() = lock.read { delegate.numOfLines }
override val numOfRows: Int
get() = lock.read { delegate.numOfRows }
override val lastRowIndex: Int
get() = lock.read { delegate.lastRowIndex }
override val numOfOriginalLines: Int
get() = lock.read { delegate.numOfOriginalLines }
override val chunkSize: Int
get() = lock.read { delegate.chunkSize }
override val undoHistoryCapacity: Int
get() = lock.read { delegate.undoHistoryCapacity }
override val textBufferFactory: (capacity: Int) -> TextBuffer
get() = lock.read { delegate.textBufferFactory }
override val charSequenceBuilderFactory: (capacity: Int) -> Appendable
get() = lock.read { delegate.charSequenceBuilderFactory }
override val charSequenceFactory: (Appendable) -> CharSequence
get() = lock.read { delegate.charSequenceFactory }
override val tree: LengthTree<BigTextNodeValue>
get() = lock.read { delegate.tree }
override val contentWidth: Float?
get() = lock.read { delegate.contentWidth }
override var undoMetadataSupplier: (() -> Any?)?
get() = lock.read { delegate.undoMetadataSupplier }
set(value) { lock.write { delegate.undoMetadataSupplier = value } }
override var changeHook: BigTextChangeHook?
get() = lock.read { delegate.changeHook }
set(value) { lock.write { delegate.changeHook = value } }

override val isThreadSafe: Boolean
get() = true

override fun buildString(): String = lock.read { delegate.buildString() }

override fun buildCharSequence(): CharSequence = lock.read { delegate.buildCharSequence() }

override fun substring(start: Int, endExclusive: Int): CharSequence = lock.read { delegate.substring(start, endExclusive) }

override fun findLineString(lineIndex: Int): CharSequence = lock.read { delegate.findLineString(lineIndex) }

override fun findRowString(rowIndex: Int): CharSequence = lock.read { delegate.findRowString(rowIndex) }

override fun append(text: CharSequence): Int = lock.write { delegate.append(text) }

override fun insertAt(pos: Int, text: CharSequence): Int = lock.write { delegate.insertAt(pos, text) }

override fun delete(start: Int, endExclusive: Int): Int = lock.write { delegate.delete(start, endExclusive) }

override fun replace(start: Int, endExclusive: Int, text: CharSequence) = lock.write {
delegate.replace(start, endExclusive, text)
}

override fun replace(range: IntRange, text: CharSequence) = lock.write {
delegate.replace(range, text)
}

override fun recordCurrentChangeSequenceIntoUndoHistory() = lock.write { delegate.recordCurrentChangeSequenceIntoUndoHistory() }

override fun undo(callback: BigTextChangeCallback?): Pair<Boolean, Any?> = lock.write { delegate.undo(callback) }

override fun redo(callback: BigTextChangeCallback?): Pair<Boolean, Any?> = lock.write { delegate.redo(callback) }

override fun isUndoable(): Boolean = lock.read { delegate.isUndoable() }

override fun isRedoable(): Boolean = lock.read { delegate.isRedoable() }

override fun findLineAndColumnFromRenderPosition(renderPosition: Int): Pair<Int, Int> = lock.read { delegate.findLineAndColumnFromRenderPosition(renderPosition) }

override fun findRenderCharIndexByLineAndColumn(lineIndex: Int, columnIndex: Int): Int = lock.read { delegate.findRenderCharIndexByLineAndColumn(lineIndex, columnIndex) }

override fun findPositionStartOfLine(lineIndex: Int): Int = lock.read { delegate.findPositionStartOfLine(lineIndex) }

override fun findLineIndexByRowIndex(rowIndex: Int): Int = lock.read { delegate.findLineIndexByRowIndex(rowIndex) }

override fun findFirstRowIndexOfLine(lineIndex: Int): Int = lock.read { delegate.findFirstRowIndexOfLine(lineIndex) }

override fun setLayouter(layouter: TextLayouter) = lock.write { delegate.setLayouter(layouter) }

override fun setContentWidth(contentWidth: Float) = lock.write { delegate.setContentWidth(contentWidth) }

override fun layout() = lock.write { delegate.layout() }

// the first call to `hashCode()` would write to cache
// override fun hashCode(): Int = lock.write { delegate.hashCode() }
// currently, BigTextImpl has no custom implementation over built-in's one, so no lock is needed.
override fun hashCode(): Int = delegate.hashCode()

// override fun equals(other: Any?): Boolean = lock.read { delegate.equals(other) }
// currently, BigTextImpl has no custom implementation over built-in's one, so no lock is needed.
override fun equals(other: Any?): Boolean {
if (other !is ConcurrentBigText) return delegate.equals(other)
return delegate.equals(other.delegate)
}

override fun inspect(label: String): String = lock.read { delegate.inspect(label) }

override fun printDebug(label: String) = lock.read { delegate.printDebug(label) }

fun withWriteLock(operation: (BigText) -> Unit) = lock.write { operation(delegate) }

fun withReadLock(operation: (BigText) -> Unit) = lock.read { operation(delegate) }

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext

import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.text.AnnotatedString

/**
* Create a BigTextFieldState with a thread-safe large text buffer. Specifically, only the `BigText` class is thread-safe.
*
* `BigTextViewState` is NOT thread-safe and can only be manipulated in UI thread.
*
* The argument `initialValue` is only used when there is a cache miss using the cache key `cacheKey`.
*/
@Composable
fun rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue: String = "", vararg cacheKeys: Any?): MutableState<BigTextFieldState> {
return rememberSaveable(*cacheKeys) {
log.i { "cache miss concurrent 1" }
mutableStateOf(
BigTextFieldState(
ConcurrentBigText(BigText.createFromLargeAnnotatedString(AnnotatedString(initialValue))),
BigTextViewState()
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,17 @@ class MultipleIncrementalTransformation(val transformations: List<IncrementalTex
}
}

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MultipleIncrementalTransformation) return false

if (transformations != other.transformations) return false

return true
}

override fun hashCode(): Int {
return transformations.hashCode()
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,17 @@ class MultipleTextDecorator(val decorators: List<BigTextDecorator>): BigTextDeco
}
return text
}

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MultipleTextDecorator) return false

if (decorators != other.decorators) return false

return true
}

override fun hashCode(): Int {
return decorators.hashCode()
}
}
Loading

0 comments on commit 8fdacc2

Please sign in to comment.