From f29b0c0606b7d66cd46c2d6db780ad75f58f282c Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Thu, 1 Aug 2024 20:16:02 +0800 Subject: [PATCH 001/195] update version to 1.6.1-SNAPSHOT --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index be5aaf2b..a23cd500 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ plugins { } group = "com.sunnychung.application" -version = "1.6.0" // must be in 'x.y.z' for native distributions +version = "1.6.1-SNAPSHOT" // must be in 'x.y.z' for native distributions repositories { google() From 0ae80dc681bbe928076212e83d36e41d9d649580 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Thu, 1 Aug 2024 20:27:53 +0800 Subject: [PATCH 002/195] update DataBackwardCompatibilityTest to throw a readable exception message --- .../hellohttp/test/DataBackwardCompatibilityTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/DataBackwardCompatibilityTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/DataBackwardCompatibilityTest.kt index c8cb5758..df81c810 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/DataBackwardCompatibilityTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/DataBackwardCompatibilityTest.kt @@ -117,7 +117,11 @@ class DataBackwardCompatibilityTest { } val allVersions = appVersionsHavingTestData() - val index = allVersions.indexOfFirst { it == currentVersion } + val index = allVersions.indexOfFirst { it == currentVersion }.also { + if (it < 0) { + throw IllegalArgumentException("`allVersions` does not include the current version code '$currentVersion'") + } + } val appVersionToCopyDataFrom = allVersions[index - 1] val sourceBackupFile = File(testDataBaseDirOfAppVersion(appVersionToCopyDataFrom), "app-data-backup.dump") From fc9190aa808e65e83dee3b6c2d32f27bc3bf7b10 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Thu, 1 Aug 2024 20:29:14 +0800 Subject: [PATCH 003/195] add app version 1.6.1 to DataBackwardCompatibilityTest --- .../hellohttp/test/DataBackwardCompatibilityTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/DataBackwardCompatibilityTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/DataBackwardCompatibilityTest.kt index df81c810..fcb193b2 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/DataBackwardCompatibilityTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/DataBackwardCompatibilityTest.kt @@ -40,7 +40,7 @@ class DataBackwardCompatibilityTest { companion object { @JvmStatic fun appVersionsHavingTestData(): List = - listOf("1.5.2", "1.6.0") // sorted by versions ascending + listOf("1.5.2", "1.6.0", "1.6.1") // sorted by versions ascending } internal fun currentAppVersionExcludingLabel(): String = From ae83abf4cd6b63ef0894d1d23ea74043ab952956 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 2 Aug 2024 21:01:40 +0800 Subject: [PATCH 004/195] [WIP] add BigMonospaceText to optimize rendering performance of large syntax highlighted response JSON --- .../multiplatform/hellohttp/util/Logger.kt | 2 +- .../hellohttp/ux/CodeEditorView.kt | 107 ++++++----- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 178 ++++++++++++++++++ 3 files changed, 239 insertions(+), 48 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt index c230625c..3535b154 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt @@ -9,7 +9,7 @@ import com.sunnychung.lib.multiplatform.kdatetime.KZonedInstant val log = Logger(object : MutableLoggerConfig { override var logWriterList: List = listOf(JvmLogger()) - override var minSeverity: Severity = Severity.Debug + override var minSeverity: Severity = Severity.Verbose }, tag = "Hello") val llog = log diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 8872b128..2222fb4a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -63,6 +63,7 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearch import com.sunnychung.application.multiplatform.hellohttp.extension.contains import com.sunnychung.application.multiplatform.hellohttp.extension.insert import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldColors import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDefaults import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast @@ -437,64 +438,76 @@ fun CodeEditorView( }, modifier = Modifier.fillMaxHeight(), ) - AppTextField( - value = textValue, - onValueChange = { - textValue = it - log.d { "CEV sel ${textValue.selection.start}" } - onTextChange?.invoke(it.text) - }, - visualTransformation = visualTransformationToUse, - readOnly = isReadOnly, - textStyle = LocalTextStyle.current.copy( - fontFamily = FontFamily.Monospace, + if (isReadOnly) { + BigMonospaceText( + text = textValue.text, + visualTransformation = visualTransformationToUse, fontSize = LocalFont.current.codeEditorBodyFontSize, - ), - colors = colors, - onTextLayout = { textLayoutResult = it }, - modifier = Modifier.fillMaxSize().verticalScroll(scrollState) - .focusRequester(textFieldFocusRequester) - .run { - if (!isReadOnly) { - this.onPreviewKeyEvent { - if (it.type == KeyEventType.KeyDown) { - when (it.key) { - Key.Enter -> { - if (!it.isShiftPressed - && !it.isAltPressed - && !it.isCtrlPressed - && !it.isMetaPressed + scrollState = scrollState, + modifier = Modifier.fillMaxSize(), + ) +// return@Row // compose bug: return here would crash + } else { + + AppTextField( + value = textValue, + onValueChange = { + textValue = it + log.d { "CEV sel ${textValue.selection.start}" } + onTextChange?.invoke(it.text) + }, + visualTransformation = visualTransformationToUse, + readOnly = isReadOnly, + textStyle = LocalTextStyle.current.copy( + fontFamily = FontFamily.Monospace, + fontSize = LocalFont.current.codeEditorBodyFontSize, + ), + colors = colors, + onTextLayout = { textLayoutResult = it }, + modifier = Modifier.fillMaxSize().verticalScroll(scrollState) + .focusRequester(textFieldFocusRequester) + .run { + if (!isReadOnly) { + this.onPreviewKeyEvent { + if (it.type == KeyEventType.KeyDown) { + when (it.key) { + Key.Enter -> { + if (!it.isShiftPressed + && !it.isAltPressed + && !it.isCtrlPressed + && !it.isMetaPressed ) { - onPressEnterAddIndent() + onPressEnterAddIndent() + true + } else { + false + } + } + + Key.Tab -> { + onPressTab(it.isShiftPressed) true - } else { - false } - } - Key.Tab -> { - onPressTab(it.isShiftPressed) - true + else -> false } - - else -> false + } else { + false } - } else { - false } + } else { + this } - } else { - this } - } - .run { - if (testTag != null) { - testTag(testTag) - } else { - this + .run { + if (testTag != null) { + testTag(testTag) + } else { + this + } } - } - ) + ) + } } VerticalScrollbar( modifier = Modifier.align(Alignment.CenterEnd), diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt new file mode 100644 index 00000000..d3651121 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -0,0 +1,178 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.LocalTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Paragraph +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.TextUnit +import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.ux.AppText +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 +import kotlin.math.roundToInt +import kotlin.reflect.KMutableProperty +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.jvm.isAccessible + +private val LINE_BREAK_REGEX = "\n".toRegex() + +@Composable +fun BigMonospaceText( + modifier: Modifier = Modifier, + text: String, + fontSize: TextUnit = LocalFont.current.bodyFontSize, + color: Color = LocalColor.current.text, + visualTransformation: VisualTransformation, + scrollState: ScrollState = rememberScrollState(), +) { + val density = LocalDensity.current + val fontFamilyResolver = LocalFontFamilyResolver.current + var width by remember { mutableIntStateOf(0) } + var height by remember { mutableIntStateOf(0) } + var lineHeight by remember { mutableStateOf(0f) } + val textStyle = LocalTextStyle.current.copy( + fontSize = fontSize, + fontFamily = FontFamily.Monospace, + color = color, + ) + val numOfCharsPerLine = rememberLast(density.density, density.fontScale, fontSize, width) { + if (width > 0) { + Paragraph( + text = "0".repeat(1000), + style = textStyle, + constraints = Constraints(maxWidth = width), + density = density, + fontFamilyResolver = fontFamilyResolver, + ).let { + lineHeight = it.getLineTop(1) - it.getLineTop(0) + it.getLineEnd(0) + } + } else { + 0 + } + } + val transformedText = rememberLast(text.length, text.hashCode(), visualTransformation) { + visualTransformation.filter(AnnotatedString(text)).also { + log.v { "transformed text = `$it`" } + } + } + // a line may span multiple rows + val rowStartCharIndices = rememberLast(transformedText.text.length, transformedText.hashCode(), numOfCharsPerLine) { + if (numOfCharsPerLine < 1) { + return@rememberLast listOf(0) + } + val lineStartIndices = ( + sequenceOf(0) + + LINE_BREAK_REGEX.findAll(transformedText.text).sortedBy { it.range.last }.map { it.range.last + 1 } + ).toList() + lineStartIndices.flatMapIndexed { index, it -> + if (index + 1 <= lineStartIndices.lastIndex) { + val numCharsInThisLine = lineStartIndices[index + 1] - it - (if (transformedText.text[lineStartIndices[index + 1] - 1] == '\n') 1 else 0) + (0 until (numCharsInThisLine divRoundUp numOfCharsPerLine)).map { j -> + (it + j * numOfCharsPerLine).also { k -> + log.v { "calc index $index -> $it ($numCharsInThisLine, $numOfCharsPerLine) $k" } + } + } + } else { + listOf(it) + } + } + }.also { +// log.v { "rowStartCharIndices = ${it}" } + } + +// rememberLast(rowStartCharIndices.size) { +// scrollState::class.declaredMemberProperties.first { it.name == "maxValue" } +// .apply { +// (this as KMutableProperty) +// setter.isAccessible = true +// setter.call(scrollState, ((rowStartCharIndices.size - 1) * lineHeight).roundToInt()) +// } +// } + + var scrollOffset by remember { mutableStateOf(0f) } +// val scrollState = + val scrollableState = rememberScrollableState { delta -> + scrollOffset = minOf(maxOf(0f, scrollOffset - delta), maxOf(0f, rowStartCharIndices.size * lineHeight - height)) + delta + } + + Box( + modifier = modifier + .onGloballyPositioned { + width = it.size.width + height = it.size.height + } + .clipToBounds() + .scrollable(scrollableState, orientation = Orientation.Vertical) + ) { +// val viewportTop = scrollState.value.toFloat() + val viewportTop = scrollOffset + val viewportBottom = viewportTop + height + if (lineHeight > 0) { + val firstRowIndex = maxOf(0, (viewportTop / lineHeight).toInt()) + val lastRowIndex = minOf(rowStartCharIndices.lastIndex, (viewportBottom / lineHeight).toInt() + 1) + log.v { "row index = [$firstRowIndex, $lastRowIndex]; scroll = $scrollOffset ~ $viewportBottom; line h = $lineHeight" } + with(density) { + (firstRowIndex..lastRowIndex).forEach { i -> + val startIndex = rowStartCharIndices[i] + val endIndex = if (i + 1 > rowStartCharIndices.lastIndex) { + transformedText.text.length + } else { + rowStartCharIndices[i + 1] + } + log.v { "line #$i: [$startIndex, $endIndex)" } + BasicText( + text = transformedText.text.subSequence( + startIndex = startIndex, + endIndex = endIndex, + ), + style = textStyle, + maxLines = 1, + modifier = Modifier.offset(y = (- viewportTop + (i/* - firstRowIndex*/) * lineHeight).toDp()) + ) + } + } + } + +// VerticalScrollbar( +// modifier = Modifier.align(Alignment.CenterEnd), +// adapter = rememberScrollbarAdapter(scrollState, ((rowStartCharIndices.size - 1) * lineHeight).roundToInt()), +// ) + } +} + +private infix fun Int.divRoundUp(other: Int): Int { + val div = this / other + val remainder = this % other + return if (remainder == 0) { + div + } else { + div + 1 + } +} From a5b2f5f136095d6ac674d01d0239a84a2a2e76bf Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 2 Aug 2024 21:12:27 +0800 Subject: [PATCH 005/195] update BigMonospaceText to leverage the scrollState input and thus external vertical scrollbars --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index d3651121..2f8b11c6 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.rememberScrollableState +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.offset @@ -15,6 +16,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -34,6 +36,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.AppText 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 +import kotlinx.coroutines.launch import kotlin.math.roundToInt import kotlin.reflect.KMutableProperty import kotlin.reflect.full.declaredMemberProperties @@ -106,19 +109,23 @@ fun BigMonospaceText( // log.v { "rowStartCharIndices = ${it}" } } -// rememberLast(rowStartCharIndices.size) { -// scrollState::class.declaredMemberProperties.first { it.name == "maxValue" } -// .apply { -// (this as KMutableProperty) -// setter.isAccessible = true -// setter.call(scrollState, ((rowStartCharIndices.size - 1) * lineHeight).roundToInt()) -// } -// } + rememberLast(height, rowStartCharIndices.size, lineHeight) { + scrollState::class.declaredMemberProperties.first { it.name == "maxValue" } + .apply { + (this as KMutableProperty) + setter.isAccessible = true + setter.call(scrollState, maxOf(0f, rowStartCharIndices.size * lineHeight - height).roundToInt()) + } + } + val coroutineScope = rememberCoroutineScope() // for scrolling var scrollOffset by remember { mutableStateOf(0f) } // val scrollState = val scrollableState = rememberScrollableState { delta -> - scrollOffset = minOf(maxOf(0f, scrollOffset - delta), maxOf(0f, rowStartCharIndices.size * lineHeight - height)) + coroutineScope.launch { + scrollState.scrollBy(-delta) + } +// scrollOffset = minOf(maxOf(0f, scrollOffset - delta), maxOf(0f, rowStartCharIndices.size * lineHeight - height)) delta } @@ -131,13 +138,13 @@ fun BigMonospaceText( .clipToBounds() .scrollable(scrollableState, orientation = Orientation.Vertical) ) { -// val viewportTop = scrollState.value.toFloat() - val viewportTop = scrollOffset + val viewportTop = scrollState.value.toFloat() +// val viewportTop = scrollOffset val viewportBottom = viewportTop + height if (lineHeight > 0) { val firstRowIndex = maxOf(0, (viewportTop / lineHeight).toInt()) val lastRowIndex = minOf(rowStartCharIndices.lastIndex, (viewportBottom / lineHeight).toInt() + 1) - log.v { "row index = [$firstRowIndex, $lastRowIndex]; scroll = $scrollOffset ~ $viewportBottom; line h = $lineHeight" } + log.v { "row index = [$firstRowIndex, $lastRowIndex]; scroll = $viewportTop ~ $viewportBottom; line h = $lineHeight" } with(density) { (firstRowIndex..lastRowIndex).forEach { i -> val startIndex = rowStartCharIndices[i] @@ -159,11 +166,6 @@ fun BigMonospaceText( } } } - -// VerticalScrollbar( -// modifier = Modifier.align(Alignment.CenterEnd), -// adapter = rememberScrollbarAdapter(scrollState, ((rowStartCharIndices.size - 1) * lineHeight).roundToInt()), -// ) } } From 232c42e8c05601f97d2f7acea3891dcbebc74e5f Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 2 Aug 2024 22:05:39 +0800 Subject: [PATCH 006/195] refactor LineNumbersView by extracting the UX part as CoreLineNumbersView --- .../hellohttp/ux/CodeEditorView.kt | 188 +++++++++++------- 1 file changed, 120 insertions(+), 68 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 2222fb4a..f1a14103 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -51,12 +51,15 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForInsertionPoint @@ -597,7 +600,6 @@ fun LineNumbersView( val colours = LocalColor.current val fonts = LocalFont.current var size by remember { mutableStateOf(null) } - val textMeasurer = rememberTextMeasurer() val textStyle = LocalTextStyle.current.copy( fontSize = fonts.codeEditorLineNumberFontSize, fontFamily = FontFamily.Monospace, @@ -613,93 +615,143 @@ fun LineNumbersView( lastTextLayoutResult = textLayoutResult lastLineTops = lineTops - val lineNumDigits = lineTops?.let { "${it.lastIndex}".length } ?: 1 - val width = rememberLast(lineNumDigits, collapsableLines.isEmpty()) { - maxOf(textMeasurer.measure("8".repeat(lineNumDigits), textStyle, maxLines = 1).size.width.toDp(), 20.dp) + - 4.dp + (if (collapsableLines.isNotEmpty()) 24.dp else 0.dp) + 4.dp + val collapsedLinesState = CollapsedLinesState(collapsableLines = collapsableLines, collapsedLines = collapsedLines) + + var lineHeight = 0f + val viewportTop = scrollState.value.toFloat() + val (firstLine, lastLine) = if (size != null && textLayoutResult != null && lineTops != null) { + val viewportBottom = viewportTop + size!!.height + log.d { "LineNumbersView before calculation" } + // 0-based line index + // include the partially visible line before the first line that is entirely visible + val firstLine = maxOf(0, lineTops.binarySearchForInsertionPoint { if (it >= viewportTop) 1 else -1 } - 1) + val lastLine = lineTops.binarySearchForInsertionPoint { if (it > viewportBottom) 1 else -1 } + log.v { "LineNumbersView $firstLine ~ <$lastLine / $viewportTop ~ $viewportBottom" } + log.v { "lineTops = $lineTops" } + log.v { "collapsedLines = $collapsedLines" } + log.d { "LineNumbersView after calculation" } + lineHeight = textLayoutResult.getLineBottom(0) - textLayoutResult.getLineTop(0) + + firstLine to lastLine + } else { + 0 to -1 } + CoreLineNumbersView( + modifier = modifier.onGloballyPositioned { size = it.size }, + firstLine = firstLine, + lastLine = minOf(lastLine, (lineTops?.size ?: 0) - 1), + totalLines = lineTops?.size ?: 1, + lineHeight = lineHeight.toDp(), + getLineOffset = { (lineTops!![it] - viewportTop).toDp() }, + textStyle = textStyle, + collapsedLinesState = collapsedLinesState, + onCollapseLine = onCollapseLine, + onExpandLine = onExpandLine + ) +} +/** + * The purpose of this class is to avoid unnecessary heavy computations of cache keys. + * It must be wrapped by another @Composable with collapsableLines and collapsedLines as parameters. + */ +class CollapsedLinesState(val collapsableLines: List, collapsedLines: List) { val collapsableLinesMap = collapsableLines.associateBy { it.start } val collapsedLines = collapsedLines.associateBy { it.first }.toSortedMap() // TODO optimize using range tree +} + +@Composable +private fun CoreLineNumbersView( + modifier: Modifier = Modifier, + firstLine: Int, + lastLine: Int, + totalLines: Int, + lineHeight: Dp, + getLineOffset: (Int) -> Dp, + textStyle: TextStyle, +// collapsableLines: List, +// collapsedLines: List, + collapsedLinesState: CollapsedLinesState, + onCollapseLine: (Int) -> Unit, + onExpandLine: (Int) -> Unit, +) = with(LocalDensity.current) { + val colours = LocalColor.current + val fonts = LocalFont.current + + val collapsableLines = collapsedLinesState.collapsableLines + val collapsableLinesMap = collapsedLinesState.collapsableLinesMap + val collapsedLines = collapsedLinesState.collapsedLines + + val textMeasurer = rememberTextMeasurer() + val lineNumDigits = "$totalLines".length + val width = rememberLast(lineNumDigits, collapsableLines.isEmpty()) { + maxOf(textMeasurer.measure("8".repeat(lineNumDigits), textStyle, maxLines = 1).size.width.toDp(), 20.dp) + + 4.dp + (if (collapsableLines.isNotEmpty()) 24.dp else 0.dp) + 4.dp + } Box( modifier = modifier .width(width) .fillMaxHeight() .clipToBounds() - .onGloballyPositioned { size = it.size } .background(colours.backgroundLight) .padding(top = 6.dp, start = 4.dp, end = 4.dp), // see AppTextField ) { - if (size != null && textLayoutResult != null && lineTops != null) { - val viewportTop = scrollState.value.toFloat() - val viewportBottom = viewportTop + size!!.height - log.d { "LineNumbersView before calculation" } - // 0-based line index - // include the partially visible line before the first line that is entirely visible - val firstLine = maxOf(0, lineTops.binarySearchForInsertionPoint { if (it >= viewportTop) 1 else -1 } - 1) - val lastLine = lineTops.binarySearchForInsertionPoint { if (it > viewportBottom) 1 else -1 } - log.v { "LineNumbersView $firstLine ~ <$lastLine / $viewportTop ~ $viewportBottom" } - log.v { "lineTops = $lineTops" } - log.v { "collapsedLines = $collapsedLines" } - log.d { "LineNumbersView after calculation" } - val lineHeight = textLayoutResult.getLineBottom(0) - textLayoutResult.getLineTop(0) - - var ii: Int = firstLine - while (ii < minOf(lastLine, lineTops.size - 1)) { - val i: Int = ii // `ii` is passed by ref - - if (i > firstLine && lineTops[i] - lineTops[i-1] < 1) { - // optimization: there is an instant that collapsedLines is empty but lineTops = [0, 0, ..., 0, 1234] - // skip drawing if there are duplicated lineTops - } else { - Row( + + var ii: Int = firstLine + while (ii < lastLine) { + val i: Int = ii // `ii` is passed by ref + + if (i > firstLine && getLineOffset(i).value - getLineOffset(i - 1).value < 1) { + // optimization: there is an instant that collapsedLines is empty but lineTops = [0, 0, ..., 0, 1234] + // skip drawing if there are duplicated lineTops + } else { + Row( + modifier = Modifier + .height(lineHeight) + .offset(y = getLineOffset(i)), + ) { + Box( + contentAlignment = Alignment.CenterEnd, modifier = Modifier - .height(lineHeight.toDp()) - .offset(y = (lineTops[i] - viewportTop).toDp()), + .weight(1f) ) { - Box( - contentAlignment = Alignment.CenterEnd, + AppText( + text = "${i + 1}", + style = textStyle, + fontSize = fonts.codeEditorLineNumberFontSize, + fontFamily = FontFamily.Monospace, + maxLines = 1, + color = colours.unimportant, + ) + } + if (collapsableLinesMap.contains(i)) { + AppImageButton( + resource = if (collapsedLines.containsKey(i)) "expand.svg" else "collapse.svg", + size = 12.dp, + innerPadding = PaddingValues(horizontal = 4.dp), + onClick = { + if (collapsedLines.containsKey(i)) { + onExpandLine(i) + } else { + onCollapseLine(i) + } + }, modifier = Modifier - .weight(1f) - ) { - AppText( - text = "${i + 1}", - style = textStyle, - fontSize = fonts.codeEditorLineNumberFontSize, - fontFamily = FontFamily.Monospace, - maxLines = 1, - color = colours.unimportant, - ) - } - if (collapsableLinesMap.contains(i)) { - AppImageButton( - resource = if (collapsedLines.containsKey(i)) "expand.svg" else "collapse.svg", - size = 12.dp, - innerPadding = PaddingValues(horizontal = 4.dp), - onClick = { - if (collapsedLines.containsKey(i)) { - onExpandLine(i) - } else { - onCollapseLine(i) - } - }, - modifier = modifier - .width(24.dp) - .padding(start = 4.dp), - ) - } else if (collapsableLines.isNotEmpty()) { - Spacer(modifier.width(24.dp)) - } + .fillMaxHeight() + .width(24.dp) + .padding(start = 4.dp), + ) + } else if (collapsableLines.isNotEmpty()) { + Spacer(Modifier.fillMaxHeight().width(24.dp)) } } - collapsedLines.headMap(i + 1).forEach { - if (it.value.contains(i)) { - ii = maxOf(ii, it.value.last) - } + } + collapsedLines.headMap(i + 1).forEach { + if (it.value.contains(i)) { + ii = maxOf(ii, it.value.last) } - ++ii } + ++ii } } } From 4fa15a8c4dd641028a367ba6ae893fd663af003c Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 3 Aug 2024 15:10:46 +0800 Subject: [PATCH 007/195] add BigLineNumbersView and support collapsing lines of response JSON in BigMonospaceText --- .../hellohttp/annotation/TemporaryApi.kt | 4 + .../hellohttp/extension/ListExtension.kt | 23 ++++ .../hellohttp/ux/CodeEditorView.kt | 110 +++++++++++++---- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 116 ++++++++++++++++-- .../transformation/CollapseTransformation.kt | 16 +++ .../transformation/FunctionTransformation.kt | 15 +++ ...aphqlQuerySyntaxHighlightTransformation.kt | 13 ++ .../JsonSyntaxHighlightTransformation.kt | 13 ++ .../KotlinSyntaxHighlightTransformation.kt | 13 ++ .../MultipleVisualTransformation.kt | 2 +- .../SearchHighlightTransformation.kt | 20 +++ 11 files changed, 304 insertions(+), 41 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/annotation/TemporaryApi.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/annotation/TemporaryApi.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/annotation/TemporaryApi.kt new file mode 100644 index 00000000..4fbffbc0 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/annotation/TemporaryApi.kt @@ -0,0 +1,4 @@ +package com.sunnychung.application.multiplatform.hellohttp.annotation + +@RequiresOptIn +annotation class TemporaryApi diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt index 112195b1..d9927085 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt @@ -10,3 +10,26 @@ fun List.binarySearchForInsertionPoint(comparison: (T) -> Int): Int { if (r >= 0) throw IllegalArgumentException("Parameter `comparison` should never return 0") return -(r + 1) } + +/** + * Can only be used on an **ascending** list. + * + * R = A.f(x); + * Return minimum R so that A[R] >= x. + * + * For example, for following values: + * + * [0, 2, 37, 57, 72, 85, 91, 113] + * f(0) = -1, f(0) = 0, f(72) = 4, f(73) = 4, f(84) = 4, f(85) = 5, f(113) = 7, f(999) = 7 + * + * @param searchValue + */ +fun List.binarySearchForMinIndexOfValueAtLeast(searchValue: Int): Int { + val insertionPoint = binarySearchForInsertionPoint { if (it >= searchValue) 1 else -1 } + if (insertionPoint > lastIndex) return lastIndex + return if (searchValue < this[insertionPoint]) { + insertionPoint - 1 + } else { + insertionPoint + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index f1a14103..84e55de5 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -3,7 +3,6 @@ package com.sunnychung.application.multiplatform.hellohttp.ux import androidx.compose.foundation.ScrollState import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -11,12 +10,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.onClick import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.verticalScroll @@ -56,24 +53,23 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryApi import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForInsertionPoint import com.sunnychung.application.multiplatform.hellohttp.extension.contains import com.sunnychung.application.multiplatform.hellohttp.extension.insert import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextViewState import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldColors import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDefaults 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 import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.CollapseTransformation -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.CollapseTransformationOffsetMapping import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.EnvironmentVariableTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.FunctionTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.MultipleVisualTransformation @@ -423,34 +419,54 @@ fun CodeEditorView( Box(modifier = Modifier.weight(1f).onGloballyPositioned { textFieldSize = it.size }) { // log.v { "CodeEditorView text=$text" } Row { - LineNumbersView( - scrollState = scrollState, - textLayoutResult = textLayoutResult, - lineTops = lineTops, - collapsableLines = collapsableLines, - collapsedLines = collapsedLines.values.toList(), - onCollapseLine = { i -> - val index = collapsableLines.indexOfFirst { it.start == i } - collapsedLines[collapsableLines[index]] = collapsableLines[index] - collapsedChars[collapsableChars[index]] = collapsableChars[index] - }, - onExpandLine = { i -> - val index = collapsableLines.indexOfFirst { it.start == i } - collapsedLines -= collapsableLines[index] - collapsedChars -= collapsableChars[index] - }, - modifier = Modifier.fillMaxHeight(), - ) + val onCollapseLine = { i: Int -> + val index = collapsableLines.indexOfFirst { it.start == i } + collapsedLines[collapsableLines[index]] = collapsableLines[index] + collapsedChars[collapsableChars[index]] = collapsableChars[index] + } + val onExpandLine = { i: Int -> + val index = collapsableLines.indexOfFirst { it.start == i } + collapsedLines -= collapsableLines[index] + collapsedChars -= collapsableChars[index] + } + if (isReadOnly) { + val bigTextViewState = remember { BigTextViewState() } + var layoutResult by remember { mutableStateOf(null) } + + BigLineNumbersView( + scrollState = scrollState, + bigTextViewState = bigTextViewState, + textLayout = layoutResult, + collapsableLines = collapsableLines, + collapsedLines = collapsedLines.values.toList(), + onCollapseLine = onCollapseLine, + onExpandLine = onExpandLine, + modifier = Modifier.fillMaxHeight(), + ) + BigMonospaceText( text = textValue.text, + padding = PaddingValues(4.dp), visualTransformation = visualTransformationToUse, fontSize = LocalFont.current.codeEditorBodyFontSize, scrollState = scrollState, + viewState = bigTextViewState, + onTextLayoutResult = { layoutResult = it }, modifier = Modifier.fillMaxSize(), ) // return@Row // compose bug: return here would crash } else { + LineNumbersView( + scrollState = scrollState, + textLayoutResult = textLayoutResult, + lineTops = lineTops, + collapsableLines = collapsableLines, + collapsedLines = collapsedLines.values.toList(), + onCollapseLine = onCollapseLine, + onExpandLine = onExpandLine, + modifier = Modifier.fillMaxHeight(), + ) AppTextField( value = textValue, @@ -637,7 +653,6 @@ fun LineNumbersView( 0 to -1 } CoreLineNumbersView( - modifier = modifier.onGloballyPositioned { size = it.size }, firstLine = firstLine, lastLine = minOf(lastLine, (lineTops?.size ?: 0) - 1), totalLines = lineTops?.size ?: 1, @@ -646,7 +661,48 @@ fun LineNumbersView( textStyle = textStyle, collapsedLinesState = collapsedLinesState, onCollapseLine = onCollapseLine, - onExpandLine = onExpandLine + onExpandLine = onExpandLine, + modifier = modifier.onGloballyPositioned { size = it.size } + ) +} + +@OptIn(TemporaryApi::class) +@Composable +fun BigLineNumbersView( + modifier: Modifier = Modifier, + bigTextViewState: BigTextViewState, + textLayout: BigTextLayoutResult?, + scrollState: ScrollState, + collapsableLines: List, + collapsedLines: List, + onCollapseLine: (Int) -> Unit, + onExpandLine: (Int) -> Unit, +) = with(LocalDensity.current) { + val colours = LocalColor.current + val fonts = LocalFont.current + + val textStyle = LocalTextStyle.current.copy( + fontSize = fonts.codeEditorLineNumberFontSize, + fontFamily = FontFamily.Monospace, + color = colours.unimportant, + ) + val collapsedLinesState = CollapsedLinesState(collapsableLines = collapsableLines, collapsedLines = collapsedLines) + + val viewportTop = scrollState.value + val firstLine = textLayout?.findLineNumberByRowNumber(bigTextViewState.firstVisibleRow) ?: 0 + val lastLine = (textLayout?.findLineNumberByRowNumber(bigTextViewState.lastVisibleRow) ?: -100) + 1 + log.v { "lastVisibleRow = ${bigTextViewState.lastVisibleRow} (L $lastLine); totalLines = ${textLayout?.totalLinesBeforeTransformation}" } + CoreLineNumbersView( + firstLine = firstLine, + lastLine = minOf(lastLine, textLayout?.totalLinesBeforeTransformation ?: 1), + totalLines = textLayout?.totalLinesBeforeTransformation ?: 1, + lineHeight = (textLayout?.rowHeight ?: 0f).toDp(), + getLineOffset = { (textLayout!!.getLineTop(it) - viewportTop).toDp() }, + textStyle = textStyle, + collapsedLinesState = collapsedLinesState, + onCollapseLine = onCollapseLine, + onExpandLine = onExpandLine, + modifier = modifier ) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 2f8b11c6..df7728bc 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -1,13 +1,14 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.rememberScrollableState import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.material.LocalTextStyle @@ -18,7 +19,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color @@ -31,8 +31,10 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryApi +import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast import com.sunnychung.application.multiplatform.hellohttp.util.log -import com.sunnychung.application.multiplatform.hellohttp.ux.AppText 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 @@ -48,10 +50,13 @@ private val LINE_BREAK_REGEX = "\n".toRegex() fun BigMonospaceText( modifier: Modifier = Modifier, text: String, + padding: PaddingValues = PaddingValues(4.dp), fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), + viewState: BigTextViewState = remember { BigTextViewState() }, + onTextLayoutResult: ((BigTextLayoutResult) -> Unit)? = null, ) { val density = LocalDensity.current val fontFamilyResolver = LocalFontFamilyResolver.current @@ -85,18 +90,33 @@ fun BigMonospaceText( } } // a line may span multiple rows - val rowStartCharIndices = rememberLast(transformedText.text.length, transformedText.hashCode(), numOfCharsPerLine) { + val layoutResult = rememberLast(transformedText.text.length, transformedText.hashCode(), numOfCharsPerLine) { if (numOfCharsPerLine < 1) { - return@rememberLast listOf(0) + return@rememberLast BigTextLayoutResult( + lineRowSpans = listOf(1), + lineFirstRowIndices = listOf(0), + rowStartCharIndices = listOf(0), + rowHeight = lineHeight, + totalLinesBeforeTransformation = 1, + totalLines = 1, + totalRows = 1, + ) } - val lineStartIndices = ( + val originalLineStartIndices = ( + sequenceOf(0) + + LINE_BREAK_REGEX.findAll(text).sortedBy { it.range.last }.map { it.range.last + 1 } + ).toList() + val transformedLineStartIndices = ( sequenceOf(0) + LINE_BREAK_REGEX.findAll(transformedText.text).sortedBy { it.range.last }.map { it.range.last + 1 } ).toList() - lineStartIndices.flatMapIndexed { index, it -> - if (index + 1 <= lineStartIndices.lastIndex) { - val numCharsInThisLine = lineStartIndices[index + 1] - it - (if (transformedText.text[lineStartIndices[index + 1] - 1] == '\n') 1 else 0) - (0 until (numCharsInThisLine divRoundUp numOfCharsPerLine)).map { j -> + val lineRowSpans = MutableList(originalLineStartIndices.size) { 1 } + val lineRowIndices = MutableList(originalLineStartIndices.size + 1) { 0 } + val transformedRowStartCharIndices = transformedLineStartIndices.flatMapIndexed { index, it -> + if (index + 1 <= transformedLineStartIndices.lastIndex) { + val numCharsInThisLine = transformedLineStartIndices[index + 1] - it - (if (transformedText.text[transformedLineStartIndices[index + 1] - 1] == '\n') 1 else 0) + val numOfRows = numCharsInThisLine divRoundUp numOfCharsPerLine + (0 until numOfRows).map { j -> (it + j * numOfCharsPerLine).also { k -> log.v { "calc index $index -> $it ($numCharsInThisLine, $numOfCharsPerLine) $k" } } @@ -104,17 +124,55 @@ fun BigMonospaceText( } else { listOf(it) } + }.also { + log.v { "rowStartCharIndices = $it" } + } + originalLineStartIndices.forEachIndexed { index, it -> + val transformedStartCharIndex = transformedText.offsetMapping.originalToTransformed(originalLineStartIndices[index]) + val transformedEndCharIndex = if (index + 1 <= originalLineStartIndices.lastIndex) { + transformedText.offsetMapping.originalToTransformed(originalLineStartIndices[index + 1]) + } else { + transformedText.text.lastIndex + 1 + } + val displayRowStart = transformedRowStartCharIndices.binarySearchForMinIndexOfValueAtLeast(transformedStartCharIndex) + val displayRowEnd = transformedRowStartCharIndices.binarySearchForMinIndexOfValueAtLeast(transformedEndCharIndex) + val numOfRows = displayRowEnd - displayRowStart + lineRowSpans[index] = numOfRows + lineRowIndices[index + 1] = lineRowIndices[index] + numOfRows + log.v { "lineRowSpans[$index] = ${lineRowSpans[index]} ($transformedStartCharIndex ..< $transformedEndCharIndex) (L $displayRowStart ..< $displayRowEnd)" } + } + log.v { "totalLinesBeforeTransformation = ${originalLineStartIndices.size}" } + log.v { "totalLines = ${transformedLineStartIndices.size}" } + log.v { "totalRows = ${transformedRowStartCharIndices.size}" } + BigTextLayoutResult( + lineRowSpans = lineRowSpans.toList(), + lineFirstRowIndices = lineRowIndices.toList(), + rowStartCharIndices = transformedRowStartCharIndices, + rowHeight = lineHeight, + totalLines = transformedLineStartIndices.size, + totalRows = transformedRowStartCharIndices.size, + totalLinesBeforeTransformation = originalLineStartIndices.size, + ).also { + if (onTextLayoutResult != null) { + onTextLayoutResult(it) + } } - }.also { -// log.v { "rowStartCharIndices = ${it}" } } + val rowStartCharIndices = layoutResult.rowStartCharIndices rememberLast(height, rowStartCharIndices.size, lineHeight) { scrollState::class.declaredMemberProperties.first { it.name == "maxValue" } .apply { (this as KMutableProperty) setter.isAccessible = true - setter.call(scrollState, maxOf(0f, rowStartCharIndices.size * lineHeight - height).roundToInt()) + val scrollableHeight = maxOf( + 0f, + rowStartCharIndices.size * lineHeight - height + + with (density) { + (padding.calculateTopPadding() + padding.calculateBottomPadding()).toPx() + } + ) + setter.call(scrollState, scrollableHeight.roundToInt()) } } @@ -136,6 +194,7 @@ fun BigMonospaceText( height = it.size.height } .clipToBounds() + .padding(padding) .scrollable(scrollableState, orientation = Orientation.Vertical) ) { val viewportTop = scrollState.value.toFloat() @@ -145,6 +204,9 @@ fun BigMonospaceText( val firstRowIndex = maxOf(0, (viewportTop / lineHeight).toInt()) val lastRowIndex = minOf(rowStartCharIndices.lastIndex, (viewportBottom / lineHeight).toInt() + 1) log.v { "row index = [$firstRowIndex, $lastRowIndex]; scroll = $viewportTop ~ $viewportBottom; line h = $lineHeight" } + viewState.firstVisibleRow = firstRowIndex + viewState.lastVisibleRow = lastRowIndex + with(density) { (firstRowIndex..lastRowIndex).forEach { i -> val startIndex = rowStartCharIndices[i] @@ -169,6 +231,34 @@ fun BigMonospaceText( } } +@OptIn(TemporaryApi::class) +class BigTextLayoutResult( + /** Number of transformed row spans of non-transformed lines */ + @property:TemporaryApi val lineRowSpans: List, // O(L) + /** First transformed row index of non-transformed lines */ + @property:TemporaryApi val lineFirstRowIndices: List, // O(L) + /** Transformed start char index of transformed rows */ + internal val rowStartCharIndices: List, // O(R) + val rowHeight: Float, + val totalLines: Int, + val totalRows: Int, + /** Total number of lines before transformation */ val totalLinesBeforeTransformation: Int, +) { + fun findLineNumberByRowNumber(rowNumber: Int): Int { + return lineFirstRowIndices.binarySearchForMinIndexOfValueAtLeast(rowNumber) + } + + fun getLineTop(originalLineNumber: Int): Float = lineFirstRowIndices[originalLineNumber] * rowHeight +} + +class BigTextViewState { + var firstVisibleRow: Int by mutableStateOf(0) + internal set + + var lastVisibleRow: Int by mutableStateOf(0) + internal set +} + private infix fun Int.divRoundUp(other: Int): Int { val div = this / other val remainder = this % other diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/CollapseTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/CollapseTransformation.kt index d3dafe34..73fb6911 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/CollapseTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/CollapseTransformation.kt @@ -38,6 +38,22 @@ class CollapseTransformation(colours: AppColor, collapsedCharRanges: List) : VisualTransformation { +data class MultipleVisualTransformation(val transforms: List) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { var annotatedStringResult = text val mappings = mutableListOf() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/SearchHighlightTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/SearchHighlightTransformation.kt index 52a15453..b4b66c89 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/SearchHighlightTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/SearchHighlightTransformation.kt @@ -21,4 +21,24 @@ class SearchHighlightTransformation(private val searchPattern: Regex, private va } return TransformedText(AnnotatedString(s, text.spanStyles + spans), OffsetMapping.Identity) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SearchHighlightTransformation) return false + + if (searchPattern != other.searchPattern) return false + if (currentIndex != other.currentIndex) return false + if (highlightStyle != other.highlightStyle) return false + if (currentHighlightStyle != other.currentHighlightStyle) return false + + return true + } + + override fun hashCode(): Int { + var result = searchPattern.hashCode() + result = 31 * result + (currentIndex ?: 0) + result = 31 * result + highlightStyle.hashCode() + result = 31 * result + currentHighlightStyle.hashCode() + return result + } } \ No newline at end of file From b4de23c07a991bbae5513f3a1028f9a4d46b92b3 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 3 Aug 2024 15:15:04 +0800 Subject: [PATCH 008/195] fix some characters were cut in BigMonospaceText --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index df7728bc..aecfdee4 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -30,6 +32,7 @@ import androidx.compose.ui.text.Paragraph import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryApi @@ -62,6 +65,9 @@ fun BigMonospaceText( val fontFamilyResolver = LocalFontFamilyResolver.current var width by remember { mutableIntStateOf(0) } var height by remember { mutableIntStateOf(0) } + val contentWidth = width - with(density) { + (padding.calculateStartPadding(LayoutDirection.Ltr) + padding.calculateEndPadding(LayoutDirection.Ltr)).toPx() + } var lineHeight by remember { mutableStateOf(0f) } val textStyle = LocalTextStyle.current.copy( fontSize = fontSize, @@ -73,7 +79,7 @@ fun BigMonospaceText( Paragraph( text = "0".repeat(1000), style = textStyle, - constraints = Constraints(maxWidth = width), + constraints = Constraints(maxWidth = contentWidth.toInt()), density = density, fontFamilyResolver = fontFamilyResolver, ).let { From 1bdd49eedeb1bb7db59484f7e56e9cd949df6184 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 3 Aug 2024 16:48:14 +0800 Subject: [PATCH 009/195] fix CI runner out of space due to excessive logs --- .../application/multiplatform/hellohttp/util/Logger.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt index 3535b154..c230625c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt @@ -9,7 +9,7 @@ import com.sunnychung.lib.multiplatform.kdatetime.KZonedInstant val log = Logger(object : MutableLoggerConfig { override var logWriterList: List = listOf(JvmLogger()) - override var minSeverity: Severity = Severity.Verbose + override var minSeverity: Severity = Severity.Debug }, tag = "Hello") val llog = log From 2f6df6a84b926e4734c44873e7159c4d6fc4f0a4 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 3 Aug 2024 22:27:45 +0800 Subject: [PATCH 010/195] add selection and basic copying to BigMonospaceText --- .../hellohttp/extension/KeyEventExtension.kt | 15 ++ .../hellohttp/extension/RangeExtension.kt | 12 ++ .../hellohttp/ux/CodeEditorView.kt | 1 + .../hellohttp/ux/bigtext/BigMonospaceText.kt | 129 +++++++++++++++++- 4 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt new file mode 100644 index 00000000..23808392 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt @@ -0,0 +1,15 @@ +package com.sunnychung.application.multiplatform.hellohttp.extension + +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import com.sunnychung.application.multiplatform.hellohttp.platform.MacOS +import com.sunnychung.application.multiplatform.hellohttp.platform.currentOS + +fun KeyEvent.isCtrlOrCmdPressed(): Boolean { + return if (currentOS() == MacOS) { + isMetaPressed + } else { + isCtrlPressed + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt index d8674452..2de54f67 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt @@ -3,3 +3,15 @@ package com.sunnychung.application.multiplatform.hellohttp.extension operator fun IntRange.contains(other: IntRange): Boolean { return other.start in this && other.endInclusive in this } + +infix fun IntRange.intersect(other: IntRange): IntRange { + if (start > other.endInclusive || endInclusive < other.start) { + return IntRange.EMPTY + } + val from = if (start in other) start else other.start + val to = if (last in other) last else other.last + return from .. to +} + +val IntRange.length: Int + get() = this.endInclusive - this.start + 1 diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 84e55de5..f1b61ae1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -450,6 +450,7 @@ fun CodeEditorView( padding = PaddingValues(4.dp), visualTransformation = visualTransformationToUse, fontSize = LocalFont.current.codeEditorBodyFontSize, + isSelectable = true, scrollState = scrollState, viewState = bigTextViewState, onTextLayoutResult = { layoutResult = it }, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index aecfdee4..7d88ffde 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -1,7 +1,11 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.onDrag import androidx.compose.foundation.gestures.rememberScrollableState import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollable @@ -9,10 +13,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.onClick import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.material.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -23,8 +31,18 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.text.AnnotatedString @@ -37,6 +55,9 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryApi import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast +import com.sunnychung.application.multiplatform.hellohttp.extension.intersect +import com.sunnychung.application.multiplatform.hellohttp.extension.isCtrlOrCmdPressed +import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor @@ -49,6 +70,7 @@ import kotlin.reflect.jvm.isAccessible private val LINE_BREAK_REGEX = "\n".toRegex() +@OptIn(ExperimentalFoundationApi::class) @Composable fun BigMonospaceText( modifier: Modifier = Modifier, @@ -56,19 +78,25 @@ fun BigMonospaceText( padding: PaddingValues = PaddingValues(4.dp), fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, + isSelectable: Boolean = false, visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, onTextLayoutResult: ((BigTextLayoutResult) -> Unit)? = null, ) { val density = LocalDensity.current + val textSelectionColors = LocalTextSelectionColors.current val fontFamilyResolver = LocalFontFamilyResolver.current + val clipboardManager = LocalClipboardManager.current + val focusRequester = remember { FocusRequester() } + var width by remember { mutableIntStateOf(0) } var height by remember { mutableIntStateOf(0) } val contentWidth = width - with(density) { (padding.calculateStartPadding(LayoutDirection.Ltr) + padding.calculateEndPadding(LayoutDirection.Ltr)).toPx() } var lineHeight by remember { mutableStateOf(0f) } + var charWidth by remember { mutableStateOf(0f) } val textStyle = LocalTextStyle.current.copy( fontSize = fontSize, fontFamily = FontFamily.Monospace, @@ -84,14 +112,16 @@ fun BigMonospaceText( fontFamilyResolver = fontFamilyResolver, ).let { lineHeight = it.getLineTop(1) - it.getLineTop(0) + charWidth = it.width / it.getLineEnd(0) it.getLineEnd(0) } } else { 0 } } - val transformedText = rememberLast(text.length, text.hashCode(), visualTransformation) { - visualTransformation.filter(AnnotatedString(text)).also { + val visualTransformationToUse = visualTransformation + val transformedText = rememberLast(text.length, text.hashCode(), visualTransformationToUse) { + visualTransformationToUse.filter(AnnotatedString(text)).also { log.v { "transformed text = `$it`" } } } @@ -182,6 +212,11 @@ fun BigMonospaceText( } } + rememberLast(viewState.selection.start, viewState.selection.last, visualTransformation) { + viewState.transformedSelection = transformedText.offsetMapping.originalToTransformed(viewState.selection.start) .. + transformedText.offsetMapping.originalToTransformed(viewState.selection.last) + } + val coroutineScope = rememberCoroutineScope() // for scrolling var scrollOffset by remember { mutableStateOf(0f) } // val scrollState = @@ -192,6 +227,29 @@ fun BigMonospaceText( // scrollOffset = minOf(maxOf(0f, scrollOffset - delta), maxOf(0f, rowStartCharIndices.size * lineHeight - height)) delta } + var draggedPoint by remember { mutableStateOf(Offset.Zero) } + var selectionStart by remember { mutableStateOf(-1) } + var selectionEnd by remember { mutableStateOf(-1) } + + val viewportTop = scrollState.value.toFloat() + + fun getTransformedCharIndex(x: Float, y: Float): Int { + val row = ((viewportTop + y) / lineHeight).toInt() + val col = (x / charWidth).toInt() + if (row > layoutResult.rowStartCharIndices.lastIndex) { + return transformedText.text.length - 1 + } else if (row < 0) { + return 0 + } + return minOf( + layoutResult.rowStartCharIndices[row] + col, + if (row + 1 <= layoutResult.rowStartCharIndices.lastIndex) { + layoutResult.rowStartCharIndices[row + 1] - 1 + } else { + transformedText.text.length - 1 + } + ) + } Box( modifier = modifier @@ -202,8 +260,52 @@ fun BigMonospaceText( .clipToBounds() .padding(padding) .scrollable(scrollableState, orientation = Orientation.Vertical) + .focusRequester(focusRequester) + .onDrag( + enabled = isSelectable, + onDragStart = { + log.v { "onDragStart ${it.x} ${it.y}" } + draggedPoint = it + val selectedCharIndex = getTransformedCharIndex(x = it.x, y = it.y) + selectionStart = selectedCharIndex + viewState.transformedSelection = selectedCharIndex .. selectedCharIndex + viewState.selection = transformedText.offsetMapping.transformedToOriginal(viewState.transformedSelection.first) .. + transformedText.offsetMapping.transformedToOriginal(viewState.transformedSelection.last) + focusRequester.requestFocus() + focusRequester.captureFocus() + }, + onDrag = { + log.v { "onDrag ${it.x} ${it.y}" } + draggedPoint += it + val selectedCharIndex = getTransformedCharIndex(x = draggedPoint.x, y = draggedPoint.y) + selectionEnd = selectedCharIndex + viewState.transformedSelection = minOf(selectionStart, selectionEnd) .. maxOf(selectionStart, selectionEnd) + viewState.selection = transformedText.offsetMapping.transformedToOriginal(viewState.transformedSelection.first) .. + transformedText.offsetMapping.transformedToOriginal(viewState.transformedSelection.last) + } + ) + .onClick { + viewState.transformedSelection = IntRange.EMPTY + focusRequester.freeFocus() + } + .onFocusChanged { log.v { "BigMonospaceText onFocusChanged ${it.isFocused}" } } + .onPreviewKeyEvent { + log.v { "BigMonospaceText onPreviewKeyEvent" } + when { + it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.C && !viewState.transformedSelection.isEmpty() -> { + // Hit Ctrl-C or Cmd-C to copy + log.d { "BigMonospaceText hit copy" } + val textToCopy = text.substring( + viewState.selection.first.. viewState.selection.last + ) + clipboardManager.setText(AnnotatedString(textToCopy)) + true + } + else -> false + } + } + .focusable(isSelectable) // `focusable` should be after callback modifiers that use focus ) { - val viewportTop = scrollState.value.toFloat() // val viewportTop = scrollOffset val viewportBottom = viewportTop + height if (lineHeight > 0) { @@ -222,6 +324,19 @@ fun BigMonospaceText( rowStartCharIndices[i + 1] } log.v { "line #$i: [$startIndex, $endIndex)" } + val yOffset = (- viewportTop + (i/* - firstRowIndex*/) * lineHeight).toDp() + if (viewState.hasSelection()) { + val intersection = viewState.transformedSelection intersect (startIndex .. endIndex - 1) + if (!intersection.isEmpty()) { + Box( + Modifier + .height(lineHeight.toDp()) + .width((intersection.length * charWidth).toDp()) + .offset(x = ((intersection.start - startIndex) * charWidth).toDp(), y = yOffset) + .background(color = textSelectionColors.backgroundColor) // `background` modifier must be after `offset` in order to take effect + ) + } + } BasicText( text = transformedText.text.subSequence( startIndex = startIndex, @@ -229,7 +344,7 @@ fun BigMonospaceText( ), style = textStyle, maxLines = 1, - modifier = Modifier.offset(y = (- viewportTop + (i/* - firstRowIndex*/) * lineHeight).toDp()) + modifier = Modifier.offset(y = yOffset) ) } } @@ -263,6 +378,12 @@ class BigTextViewState { var lastVisibleRow: Int by mutableStateOf(0) internal set + + internal var transformedSelection: IntRange by mutableStateOf(0 .. -1) + + var selection: IntRange by mutableStateOf(0 .. -1) + + fun hasSelection(): Boolean = !transformedSelection.isEmpty() } private infix fun Int.divRoundUp(other: Int): Int { From aede88552c9d536f30f908af274dadc5d78aa9ac Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 3 Aug 2024 23:36:05 +0800 Subject: [PATCH 011/195] add shift click selection to BigMonospaceText --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 7d88ffde..3dae0c49 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -41,6 +41,8 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity @@ -48,6 +50,7 @@ import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Paragraph import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.LayoutDirection @@ -230,6 +233,7 @@ fun BigMonospaceText( var draggedPoint by remember { mutableStateOf(Offset.Zero) } var selectionStart by remember { mutableStateOf(-1) } var selectionEnd by remember { mutableStateOf(-1) } + var isHoldingShiftKey by remember { mutableStateOf(false) } val viewportTop = scrollState.value.toFloat() @@ -269,8 +273,7 @@ fun BigMonospaceText( val selectedCharIndex = getTransformedCharIndex(x = it.x, y = it.y) selectionStart = selectedCharIndex viewState.transformedSelection = selectedCharIndex .. selectedCharIndex - viewState.selection = transformedText.offsetMapping.transformedToOriginal(viewState.transformedSelection.first) .. - transformedText.offsetMapping.transformedToOriginal(viewState.transformedSelection.last) + viewState.updateSelectionByTransformedSelection(transformedText) focusRequester.requestFocus() focusRequester.captureFocus() }, @@ -280,13 +283,30 @@ fun BigMonospaceText( val selectedCharIndex = getTransformedCharIndex(x = draggedPoint.x, y = draggedPoint.y) selectionEnd = selectedCharIndex viewState.transformedSelection = minOf(selectionStart, selectionEnd) .. maxOf(selectionStart, selectionEnd) - viewState.selection = transformedText.offsetMapping.transformedToOriginal(viewState.transformedSelection.first) .. - transformedText.offsetMapping.transformedToOriginal(viewState.transformedSelection.last) + viewState.updateSelectionByTransformedSelection(transformedText) } ) - .onClick { - viewState.transformedSelection = IntRange.EMPTY - focusRequester.freeFocus() + .pointerInput(layoutResult, scrollState.value, lineHeight, charWidth, transformedText.text.length, transformedText.text.hashCode()) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + when (event.type) { + PointerEventType.Press -> { + val position = event.changes.first().position + log.v { "press ${position.x} ${position.y}" } + if (isHoldingShiftKey) { + selectionEnd = getTransformedCharIndex(x = position.x, y = position.y) + log.v { "selectionEnd => $selectionEnd" } + viewState.transformedSelection = minOf(selectionStart, selectionEnd) .. maxOf(selectionStart, selectionEnd) + viewState.updateSelectionByTransformedSelection(transformedText) + } else { + viewState.transformedSelection = IntRange.EMPTY + focusRequester.freeFocus() + } + } + } + } + } } .onFocusChanged { log.v { "BigMonospaceText onFocusChanged ${it.isFocused}" } } .onPreviewKeyEvent { @@ -301,6 +321,14 @@ fun BigMonospaceText( clipboardManager.setText(AnnotatedString(textToCopy)) true } + it.type == KeyEventType.KeyDown && it.key in listOf(Key.ShiftLeft, Key.ShiftRight) -> { + isHoldingShiftKey = true + false + } + it.type == KeyEventType.KeyUp && it.key in listOf(Key.ShiftLeft, Key.ShiftRight) -> { + isHoldingShiftKey = false + false + } else -> false } } @@ -384,6 +412,11 @@ class BigTextViewState { var selection: IntRange by mutableStateOf(0 .. -1) fun hasSelection(): Boolean = !transformedSelection.isEmpty() + + internal fun updateSelectionByTransformedSelection(transformedText: TransformedText) { + selection = transformedText.offsetMapping.transformedToOriginal(transformedSelection.first) .. + transformedText.offsetMapping.transformedToOriginal(transformedSelection.last) + } } private infix fun Int.divRoundUp(other: Int): Int { From d18c53d6484203db280ca5440f98ec381ceadb0a Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 3 Aug 2024 23:44:52 +0800 Subject: [PATCH 012/195] refactor BigMonospaceText text layout code to individual source files --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 104 ++---------------- .../ux/bigtext/BigTextLayoutResult.kt | 24 ++++ .../ux/bigtext/MonospaceTextLayouter.kt | 84 ++++++++++++++ 3 files changed, 115 insertions(+), 97 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 3dae0c49..363247ec 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.onClick import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.selection.LocalTextSelectionColors @@ -56,8 +55,6 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryApi -import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.isCtrlOrCmdPressed import com.sunnychung.application.multiplatform.hellohttp.extension.length @@ -71,8 +68,6 @@ import kotlin.reflect.KMutableProperty import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.jvm.isAccessible -private val LINE_BREAK_REGEX = "\n".toRegex() - @OptIn(ExperimentalFoundationApi::class) @Composable fun BigMonospaceText( @@ -91,7 +86,9 @@ fun BigMonospaceText( val textSelectionColors = LocalTextSelectionColors.current val fontFamilyResolver = LocalFontFamilyResolver.current val clipboardManager = LocalClipboardManager.current + val focusRequester = remember { FocusRequester() } + val textLayouter = remember { MonospaceTextLayouter() } var width by remember { mutableIntStateOf(0) } var height by remember { mutableIntStateOf(0) } @@ -128,69 +125,12 @@ fun BigMonospaceText( log.v { "transformed text = `$it`" } } } - // a line may span multiple rows val layoutResult = rememberLast(transformedText.text.length, transformedText.hashCode(), numOfCharsPerLine) { - if (numOfCharsPerLine < 1) { - return@rememberLast BigTextLayoutResult( - lineRowSpans = listOf(1), - lineFirstRowIndices = listOf(0), - rowStartCharIndices = listOf(0), - rowHeight = lineHeight, - totalLinesBeforeTransformation = 1, - totalLines = 1, - totalRows = 1, - ) - } - val originalLineStartIndices = ( - sequenceOf(0) + - LINE_BREAK_REGEX.findAll(text).sortedBy { it.range.last }.map { it.range.last + 1 } - ).toList() - val transformedLineStartIndices = ( - sequenceOf(0) + - LINE_BREAK_REGEX.findAll(transformedText.text).sortedBy { it.range.last }.map { it.range.last + 1 } - ).toList() - val lineRowSpans = MutableList(originalLineStartIndices.size) { 1 } - val lineRowIndices = MutableList(originalLineStartIndices.size + 1) { 0 } - val transformedRowStartCharIndices = transformedLineStartIndices.flatMapIndexed { index, it -> - if (index + 1 <= transformedLineStartIndices.lastIndex) { - val numCharsInThisLine = transformedLineStartIndices[index + 1] - it - (if (transformedText.text[transformedLineStartIndices[index + 1] - 1] == '\n') 1 else 0) - val numOfRows = numCharsInThisLine divRoundUp numOfCharsPerLine - (0 until numOfRows).map { j -> - (it + j * numOfCharsPerLine).also { k -> - log.v { "calc index $index -> $it ($numCharsInThisLine, $numOfCharsPerLine) $k" } - } - } - } else { - listOf(it) - } - }.also { - log.v { "rowStartCharIndices = $it" } - } - originalLineStartIndices.forEachIndexed { index, it -> - val transformedStartCharIndex = transformedText.offsetMapping.originalToTransformed(originalLineStartIndices[index]) - val transformedEndCharIndex = if (index + 1 <= originalLineStartIndices.lastIndex) { - transformedText.offsetMapping.originalToTransformed(originalLineStartIndices[index + 1]) - } else { - transformedText.text.lastIndex + 1 - } - val displayRowStart = transformedRowStartCharIndices.binarySearchForMinIndexOfValueAtLeast(transformedStartCharIndex) - val displayRowEnd = transformedRowStartCharIndices.binarySearchForMinIndexOfValueAtLeast(transformedEndCharIndex) - val numOfRows = displayRowEnd - displayRowStart - lineRowSpans[index] = numOfRows - lineRowIndices[index + 1] = lineRowIndices[index] + numOfRows - log.v { "lineRowSpans[$index] = ${lineRowSpans[index]} ($transformedStartCharIndex ..< $transformedEndCharIndex) (L $displayRowStart ..< $displayRowEnd)" } - } - log.v { "totalLinesBeforeTransformation = ${originalLineStartIndices.size}" } - log.v { "totalLines = ${transformedLineStartIndices.size}" } - log.v { "totalRows = ${transformedRowStartCharIndices.size}" } - BigTextLayoutResult( - lineRowSpans = lineRowSpans.toList(), - lineFirstRowIndices = lineRowIndices.toList(), - rowStartCharIndices = transformedRowStartCharIndices, - rowHeight = lineHeight, - totalLines = transformedLineStartIndices.size, - totalRows = transformedRowStartCharIndices.size, - totalLinesBeforeTransformation = originalLineStartIndices.size, + textLayouter.layout( + text = text, + transformedText = transformedText, + lineHeight = lineHeight, + numOfCharsPerLine = numOfCharsPerLine, ).also { if (onTextLayoutResult != null) { onTextLayoutResult(it) @@ -380,26 +320,6 @@ fun BigMonospaceText( } } -@OptIn(TemporaryApi::class) -class BigTextLayoutResult( - /** Number of transformed row spans of non-transformed lines */ - @property:TemporaryApi val lineRowSpans: List, // O(L) - /** First transformed row index of non-transformed lines */ - @property:TemporaryApi val lineFirstRowIndices: List, // O(L) - /** Transformed start char index of transformed rows */ - internal val rowStartCharIndices: List, // O(R) - val rowHeight: Float, - val totalLines: Int, - val totalRows: Int, - /** Total number of lines before transformation */ val totalLinesBeforeTransformation: Int, -) { - fun findLineNumberByRowNumber(rowNumber: Int): Int { - return lineFirstRowIndices.binarySearchForMinIndexOfValueAtLeast(rowNumber) - } - - fun getLineTop(originalLineNumber: Int): Float = lineFirstRowIndices[originalLineNumber] * rowHeight -} - class BigTextViewState { var firstVisibleRow: Int by mutableStateOf(0) internal set @@ -418,13 +338,3 @@ class BigTextViewState { transformedText.offsetMapping.transformedToOriginal(transformedSelection.last) } } - -private infix fun Int.divRoundUp(other: Int): Int { - val div = this / other - val remainder = this % other - return if (remainder == 0) { - div - } else { - div + 1 - } -} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt new file mode 100644 index 00000000..a5bf6d37 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt @@ -0,0 +1,24 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryApi +import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast + +@OptIn(TemporaryApi::class) +class BigTextLayoutResult( + /** Number of transformed row spans of non-transformed lines */ + @property:TemporaryApi val lineRowSpans: List, // O(L) + /** First transformed row index of non-transformed lines */ + @property:TemporaryApi val lineFirstRowIndices: List, // O(L) + /** Transformed start char index of transformed rows */ + internal val rowStartCharIndices: List, // O(R) + val rowHeight: Float, + val totalLines: Int, + val totalRows: Int, + /** Total number of lines before transformation */ val totalLinesBeforeTransformation: Int, +) { + fun findLineNumberByRowNumber(rowNumber: Int): Int { + return lineFirstRowIndices.binarySearchForMinIndexOfValueAtLeast(rowNumber) + } + + fun getLineTop(originalLineNumber: Int): Float = lineFirstRowIndices[originalLineNumber] * rowHeight +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt new file mode 100644 index 00000000..5f7bb3ff --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt @@ -0,0 +1,84 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import androidx.compose.ui.text.input.TransformedText +import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast +import com.sunnychung.application.multiplatform.hellohttp.util.log + +private val LINE_BREAK_REGEX = "\n".toRegex() + +class MonospaceTextLayouter { + fun layout(text: String, transformedText: TransformedText, lineHeight: Float, numOfCharsPerLine: Int): BigTextLayoutResult { + if (numOfCharsPerLine < 1) { + return BigTextLayoutResult( + lineRowSpans = listOf(1), + lineFirstRowIndices = listOf(0), + rowStartCharIndices = listOf(0), + rowHeight = lineHeight, + totalLinesBeforeTransformation = 1, + totalLines = 1, + totalRows = 1, + ) + } + val originalLineStartIndices = ( + sequenceOf(0) + + LINE_BREAK_REGEX.findAll(text).sortedBy { it.range.last }.map { it.range.last + 1 } + ).toList() + val transformedLineStartIndices = ( + sequenceOf(0) + + LINE_BREAK_REGEX.findAll(transformedText.text).sortedBy { it.range.last }.map { it.range.last + 1 } + ).toList() + val lineRowSpans = MutableList(originalLineStartIndices.size) { 1 } + val lineRowIndices = MutableList(originalLineStartIndices.size + 1) { 0 } + val transformedRowStartCharIndices = transformedLineStartIndices.flatMapIndexed { index, it -> + if (index + 1 <= transformedLineStartIndices.lastIndex) { + val numCharsInThisLine = transformedLineStartIndices[index + 1] - it - (if (transformedText.text[transformedLineStartIndices[index + 1] - 1] == '\n') 1 else 0) + val numOfRows = numCharsInThisLine divRoundUp numOfCharsPerLine + (0 until numOfRows).map { j -> + (it + j * numOfCharsPerLine).also { k -> + log.v { "calc index $index -> $it ($numCharsInThisLine, $numOfCharsPerLine) $k" } + } + } + } else { + listOf(it) + } + }.also { + log.v { "rowStartCharIndices = $it" } + } + originalLineStartIndices.forEachIndexed { index, it -> + val transformedStartCharIndex = transformedText.offsetMapping.originalToTransformed(originalLineStartIndices[index]) + val transformedEndCharIndex = if (index + 1 <= originalLineStartIndices.lastIndex) { + transformedText.offsetMapping.originalToTransformed(originalLineStartIndices[index + 1]) + } else { + transformedText.text.lastIndex + 1 + } + val displayRowStart = transformedRowStartCharIndices.binarySearchForMinIndexOfValueAtLeast(transformedStartCharIndex) + val displayRowEnd = transformedRowStartCharIndices.binarySearchForMinIndexOfValueAtLeast(transformedEndCharIndex) + val numOfRows = displayRowEnd - displayRowStart + lineRowSpans[index] = numOfRows + lineRowIndices[index + 1] = lineRowIndices[index] + numOfRows + log.v { "lineRowSpans[$index] = ${lineRowSpans[index]} ($transformedStartCharIndex ..< $transformedEndCharIndex) (L $displayRowStart ..< $displayRowEnd)" } + } + log.v { "totalLinesBeforeTransformation = ${originalLineStartIndices.size}" } + log.v { "totalLines = ${transformedLineStartIndices.size}" } + log.v { "totalRows = ${transformedRowStartCharIndices.size}" } + return BigTextLayoutResult( + lineRowSpans = lineRowSpans.toList(), + lineFirstRowIndices = lineRowIndices.toList(), + rowStartCharIndices = transformedRowStartCharIndices, + rowHeight = lineHeight, + totalLines = transformedLineStartIndices.size, + totalRows = transformedRowStartCharIndices.size, + totalLinesBeforeTransformation = originalLineStartIndices.size, + ) + } +} + +private infix fun Int.divRoundUp(other: Int): Int { + val div = this / other + val remainder = this % other + return if (remainder == 0) { + div + } else { + div + 1 + } +} From 6fb6e9ce65c40f9b7a6412fed711bddca2772f91 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 3 Aug 2024 23:46:37 +0800 Subject: [PATCH 013/195] cleanup --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 363247ec..36868a96 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -161,13 +161,10 @@ fun BigMonospaceText( } val coroutineScope = rememberCoroutineScope() // for scrolling - var scrollOffset by remember { mutableStateOf(0f) } -// val scrollState = val scrollableState = rememberScrollableState { delta -> coroutineScope.launch { scrollState.scrollBy(-delta) } -// scrollOffset = minOf(maxOf(0f, scrollOffset - delta), maxOf(0f, rowStartCharIndices.size * lineHeight - height)) delta } var draggedPoint by remember { mutableStateOf(Offset.Zero) } @@ -274,7 +271,6 @@ fun BigMonospaceText( } .focusable(isSelectable) // `focusable` should be after callback modifiers that use focus ) { -// val viewportTop = scrollOffset val viewportBottom = viewportTop + height if (lineHeight > 0) { val firstRowIndex = maxOf(0, (viewportTop / lineHeight).toInt()) From 5b2106d619c7b35d5cf093eb354fb90d3712cc02 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 4 Aug 2024 12:14:02 +0800 Subject: [PATCH 014/195] refactor BigMonospaceText to allow reusing by text field --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 35 +++++++++++++-- .../hellohttp/ux/bigtext/BigText.kt | 25 +++++++++++ .../ux/bigtext/InefficientBigText.kt | 43 +++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 36868a96..8d7b8d7e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -68,7 +68,6 @@ import kotlin.reflect.KMutableProperty import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.jvm.isAccessible -@OptIn(ExperimentalFoundationApi::class) @Composable fun BigMonospaceText( modifier: Modifier = Modifier, @@ -81,6 +80,36 @@ fun BigMonospaceText( scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, onTextLayoutResult: ((BigTextLayoutResult) -> Unit)? = null, +) = CoreBigMonospaceText( + modifier = modifier, + text = InefficientBigText(text), + padding = padding, + fontSize = fontSize, + color = color, + isSelectable = isSelectable, + isEditable = false, + onUpdateText = {}, + visualTransformation = visualTransformation, + scrollState = scrollState, + viewState = viewState, + onTextLayoutResult = onTextLayoutResult, +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CoreBigMonospaceText( + modifier: Modifier = Modifier, + text: BigText, + padding: PaddingValues = PaddingValues(4.dp), + fontSize: TextUnit = LocalFont.current.bodyFontSize, + color: Color = LocalColor.current.text, + isSelectable: Boolean = false, + isEditable: Boolean = false, + onUpdateText: (BigText) -> Unit, + visualTransformation: VisualTransformation, + scrollState: ScrollState = rememberScrollState(), + viewState: BigTextViewState = remember { BigTextViewState() }, + onTextLayoutResult: ((BigTextLayoutResult) -> Unit)? = null, ) { val density = LocalDensity.current val textSelectionColors = LocalTextSelectionColors.current @@ -121,13 +150,13 @@ fun BigMonospaceText( } val visualTransformationToUse = visualTransformation val transformedText = rememberLast(text.length, text.hashCode(), visualTransformationToUse) { - visualTransformationToUse.filter(AnnotatedString(text)).also { + visualTransformationToUse.filter(AnnotatedString(text.fullString())).also { log.v { "transformed text = `$it`" } } } val layoutResult = rememberLast(transformedText.text.length, transformedText.hashCode(), numOfCharsPerLine) { textLayouter.layout( - text = text, + text = text.fullString(), transformedText = transformedText, lineHeight = lineHeight, numOfCharsPerLine = numOfCharsPerLine, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt new file mode 100644 index 00000000..3e20451a --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -0,0 +1,25 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +/** + * Manipulates large String. This is NOT thread-safe. + */ +interface BigText { + + val length: Int + + fun fullString(): String + + fun substring(start: Int, endExclusive: Int): String + + fun substring(range: IntRange): String + + fun append(text: String) + + fun insertAt(pos: Int, text: String) + + fun delete(start: Int, endExclusive: Int) + + override fun hashCode(): Int + + override fun equals(other: Any?): Boolean +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt new file mode 100644 index 00000000..80b82c8f --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt @@ -0,0 +1,43 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import com.sunnychung.application.multiplatform.hellohttp.extension.insert + +class InefficientBigText(text: String) : BigText { + private var string: String = text + + override val length: Int + get() = string.length + + override fun fullString(): String = string + + override fun substring(start: Int, endExclusive: Int): String = + string.substring(start, endExclusive) + + override fun substring(range: IntRange): String = + substring(range.first, range.last) + + override fun append(text: String) { + string += text + } + + override fun insertAt(pos: Int, text: String) { + string = string.insert(pos, text) + } + + override fun delete(start: Int, endExclusive: Int) { + string = string.removeRange(start, endExclusive) + } + + override fun hashCode(): Int = + string.hashCode() + + override fun equals(other: Any?): Boolean { + if (other !is BigText) { + return false + } + return when(other) { + is InefficientBigText -> string == other.fullString() + else -> TODO() + } + } +} From f4ef9aa35891b5eb8186d2ff212da6a843144fca Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 4 Aug 2024 12:17:52 +0800 Subject: [PATCH 015/195] rename onTextLayoutResult to onTextLayout --- .../multiplatform/hellohttp/ux/CodeEditorView.kt | 2 +- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index f1b61ae1..686d01c9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -453,7 +453,7 @@ fun CodeEditorView( isSelectable = true, scrollState = scrollState, viewState = bigTextViewState, - onTextLayoutResult = { layoutResult = it }, + onTextLayout = { layoutResult = it }, modifier = Modifier.fillMaxSize(), ) // return@Row // compose bug: return here would crash diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 8d7b8d7e..e61f6d2c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -79,7 +79,7 @@ fun BigMonospaceText( visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, - onTextLayoutResult: ((BigTextLayoutResult) -> Unit)? = null, + onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, ) = CoreBigMonospaceText( modifier = modifier, text = InefficientBigText(text), @@ -92,7 +92,7 @@ fun BigMonospaceText( visualTransformation = visualTransformation, scrollState = scrollState, viewState = viewState, - onTextLayoutResult = onTextLayoutResult, + onTextLayout = onTextLayout, ) @OptIn(ExperimentalFoundationApi::class) @@ -109,7 +109,7 @@ private fun CoreBigMonospaceText( visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, - onTextLayoutResult: ((BigTextLayoutResult) -> Unit)? = null, + onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, ) { val density = LocalDensity.current val textSelectionColors = LocalTextSelectionColors.current @@ -161,8 +161,8 @@ private fun CoreBigMonospaceText( lineHeight = lineHeight, numOfCharsPerLine = numOfCharsPerLine, ).also { - if (onTextLayoutResult != null) { - onTextLayoutResult(it) + if (onTextLayout != null) { + onTextLayout(it) } } } From a39804b5e5ea940f7678e0c81fa1f7a4117ee806 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 4 Aug 2024 17:03:08 +0800 Subject: [PATCH 016/195] fix consecutive line breaks were displayed as one line break in BigMonospaceText --- .../multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt index 5f7bb3ff..2210ff7b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt @@ -32,7 +32,7 @@ class MonospaceTextLayouter { val transformedRowStartCharIndices = transformedLineStartIndices.flatMapIndexed { index, it -> if (index + 1 <= transformedLineStartIndices.lastIndex) { val numCharsInThisLine = transformedLineStartIndices[index + 1] - it - (if (transformedText.text[transformedLineStartIndices[index + 1] - 1] == '\n') 1 else 0) - val numOfRows = numCharsInThisLine divRoundUp numOfCharsPerLine + val numOfRows = maxOf(1, numCharsInThisLine divRoundUp numOfCharsPerLine) (0 until numOfRows).map { j -> (it + j * numOfCharsPerLine).also { k -> log.v { "calc index $index -> $it ($numCharsInThisLine, $numOfCharsPerLine) $k" } From c2da26b90124629d91e7358f07b0a94bc3b383e1 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 4 Aug 2024 18:18:47 +0800 Subject: [PATCH 017/195] [WIP] add BigMonospaceTextField with very limited editing, IME and emoji support --- .../hellohttp/extension/KeyEventExtension.kt | 10 + .../hellohttp/ux/CodeEditorView.kt | 96 ++++++-- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 212 ++++++++++++++++-- .../ux/bigtext/BigTextFieldCursor.kt | 40 ++++ .../EnvironmentVariableTransformation.kt | 16 ++ 5 files changed, 345 insertions(+), 29 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldCursor.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt index 23808392..50e82408 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt @@ -1,8 +1,12 @@ package com.sunnychung.application.multiplatform.hellohttp.extension +import androidx.compose.foundation.text.isTypedEvent +import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.utf16CodePoint import com.sunnychung.application.multiplatform.hellohttp.platform.MacOS import com.sunnychung.application.multiplatform.hellohttp.platform.currentOS @@ -12,4 +16,10 @@ fun KeyEvent.isCtrlOrCmdPressed(): Boolean { } else { isCtrlPressed } + key +} + +fun KeyEvent.toTextInput(): String? { + if (!isTypedEvent) return null + return StringBuilder().appendCodePoint(utf16CodePoint).toString() } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 686d01c9..c04b3265 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -62,8 +62,11 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.contains import com.sunnychung.application.multiplatform.hellohttp.extension.insert import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextViewState +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.InefficientBigText import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldColors import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDefaults import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast @@ -430,21 +433,21 @@ fun CodeEditorView( collapsedChars -= collapsableChars[index] } - if (isReadOnly) { - val bigTextViewState = remember { BigTextViewState() } - var layoutResult by remember { mutableStateOf(null) } - - BigLineNumbersView( - scrollState = scrollState, - bigTextViewState = bigTextViewState, - textLayout = layoutResult, - collapsableLines = collapsableLines, - collapsedLines = collapsedLines.values.toList(), - onCollapseLine = onCollapseLine, - onExpandLine = onExpandLine, - modifier = Modifier.fillMaxHeight(), - ) + val bigTextViewState = remember { BigTextViewState() } + var layoutResult by remember { mutableStateOf(null) } + + BigLineNumbersView( + scrollState = scrollState, + bigTextViewState = bigTextViewState, + textLayout = layoutResult, + collapsableLines = collapsableLines, + collapsedLines = collapsedLines.values.toList(), + onCollapseLine = onCollapseLine, + onExpandLine = onExpandLine, + modifier = Modifier.fillMaxHeight(), + ) + if (isReadOnly) { BigMonospaceText( text = textValue.text, padding = PaddingValues(4.dp), @@ -458,7 +461,7 @@ fun CodeEditorView( ) // return@Row // compose bug: return here would crash } else { - LineNumbersView( + /*LineNumbersView( scrollState = scrollState, textLayoutResult = textLayoutResult, lineTops = lineTops, @@ -526,6 +529,69 @@ fun CodeEditorView( this } } + )*/ + + var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(InefficientBigText(text)) } // FIXME performance + + BigMonospaceTextField( + text = bigTextValue, + onTextChange = { + bigTextValue = it + log.d { "CEV sel ${textValue.selection.start}" } + onTextChange?.invoke(it.fullString()) + }, + visualTransformation = visualTransformationToUse, + fontSize = LocalFont.current.codeEditorBodyFontSize, +// textStyle = LocalTextStyle.current.copy( +// fontFamily = FontFamily.Monospace, +// fontSize = LocalFont.current.codeEditorBodyFontSize, +// ), +// colors = colors, + scrollState = scrollState, + viewState = bigTextViewState, + onTextLayout = { layoutResult = it }, + modifier = Modifier.fillMaxSize().verticalScroll(scrollState) + .focusRequester(textFieldFocusRequester) + .run { + if (!isReadOnly) { + this.onPreviewKeyEvent { + if (it.type == KeyEventType.KeyDown) { + when (it.key) { + Key.Enter -> { + if (!it.isShiftPressed + && !it.isAltPressed + && !it.isCtrlPressed + && !it.isMetaPressed && false // FIXME + ) { + onPressEnterAddIndent() + true + } else { + false + } + } + + Key.Tab -> { + onPressTab(it.isShiftPressed) + true + } + + else -> false + } + } else { + false + } + } + } else { + this + } + } + .run { + if (testTag != null) { + testTag(testTag) + } else { + this + } + } ) } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index e61f6d2c..47cd325a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.isTypedEvent import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.material.LocalTextStyle import androidx.compose.runtime.Composable @@ -34,21 +35,35 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalTextInputService import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Paragraph import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.CommitTextCommand +import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.text.input.SetComposingTextCommand +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Constraints @@ -58,6 +73,7 @@ import androidx.compose.ui.unit.dp import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.isCtrlOrCmdPressed import com.sunnychung.application.multiplatform.hellohttp.extension.length +import com.sunnychung.application.multiplatform.hellohttp.extension.toTextInput import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor @@ -88,7 +104,34 @@ fun BigMonospaceText( color = color, isSelectable = isSelectable, isEditable = false, - onUpdateText = {}, + onTextChange = {}, + visualTransformation = visualTransformation, + scrollState = scrollState, + viewState = viewState, + onTextLayout = onTextLayout, +) + +@Composable +fun BigMonospaceTextField( + modifier: Modifier = Modifier, + text: BigText, + padding: PaddingValues = PaddingValues(4.dp), + fontSize: TextUnit = LocalFont.current.bodyFontSize, + color: Color = LocalColor.current.text, + onTextChange: (BigText) -> Unit, + visualTransformation: VisualTransformation, + scrollState: ScrollState = rememberScrollState(), + viewState: BigTextViewState = remember { BigTextViewState() }, + onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, +) = CoreBigMonospaceText( + modifier = modifier, + text = text, + padding = padding, + fontSize = fontSize, + color = color, + isSelectable = true, + isEditable = true, + onTextChange = onTextChange, visualTransformation = visualTransformation, scrollState = scrollState, viewState = viewState, @@ -105,7 +148,7 @@ private fun CoreBigMonospaceText( color: Color = LocalColor.current.text, isSelectable: Boolean = false, isEditable: Boolean = false, - onUpdateText: (BigText) -> Unit, + onTextChange: (BigText) -> Unit, visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, @@ -115,12 +158,15 @@ private fun CoreBigMonospaceText( val textSelectionColors = LocalTextSelectionColors.current val fontFamilyResolver = LocalFontFamilyResolver.current val clipboardManager = LocalClipboardManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val textInputService = LocalTextInputService.current val focusRequester = remember { FocusRequester() } val textLayouter = remember { MonospaceTextLayouter() } var width by remember { mutableIntStateOf(0) } var height by remember { mutableIntStateOf(0) } + var layoutCoordinates by remember { mutableStateOf(null) } val contentWidth = width - with(density) { (padding.calculateStartPadding(LayoutDirection.Ltr) + padding.calculateEndPadding(LayoutDirection.Ltr)).toPx() } @@ -203,11 +249,11 @@ private fun CoreBigMonospaceText( val viewportTop = scrollState.value.toFloat() - fun getTransformedCharIndex(x: Float, y: Float): Int { + fun getTransformedCharIndex(x: Float, y: Float, mode: ResolveCharPositionMode): Int { val row = ((viewportTop + y) / lineHeight).toInt() val col = (x / charWidth).toInt() if (row > layoutResult.rowStartCharIndices.lastIndex) { - return transformedText.text.length - 1 + return maxOf(0, transformedText.text.length - if (mode == ResolveCharPositionMode.Selection) 1 else 0) } else if (row < 0) { return 0 } @@ -216,16 +262,52 @@ private fun CoreBigMonospaceText( if (row + 1 <= layoutResult.rowStartCharIndices.lastIndex) { layoutResult.rowStartCharIndices[row + 1] - 1 } else { - transformedText.text.length - 1 + maxOf(0, transformedText.text.length - if (mode == ResolveCharPositionMode.Selection) 1 else 0) } ) } + fun onType(textInput: String) { + log.v { "key in '$textInput'" } + text.insertAt(viewState.cursorIndex, textInput) + viewState.cursorIndex += textInput.length + viewState.updateTransformedCursorIndexByOriginal(transformedText) + log.v { "set cursor pos 2 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } + onTextChange(text) + } + + fun onDelete(direction: TextFBDirection): Boolean { + val cursor = viewState.cursorIndex + when (direction) { + TextFBDirection.Forward -> { + if (cursor + 1 <= text.length) { + text.delete(cursor, cursor + 1) + onTextChange(text) + return true + } + } + TextFBDirection.Backward -> { + if (cursor - 1 >= 0) { + text.delete(cursor - 1, cursor) + --viewState.cursorIndex + viewState.updateTransformedCursorIndexByOriginal(transformedText) + log.v { "set cursor pos 3 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } + onTextChange(text) + return true + } + } + } + return false + } + + val tv = remember { TextFieldValue() } // this value is not used + Box( modifier = modifier .onGloballyPositioned { width = it.size.width height = it.size.height + layoutCoordinates = it } .clipToBounds() .padding(padding) @@ -236,23 +318,23 @@ private fun CoreBigMonospaceText( onDragStart = { log.v { "onDragStart ${it.x} ${it.y}" } draggedPoint = it - val selectedCharIndex = getTransformedCharIndex(x = it.x, y = it.y) + val selectedCharIndex = getTransformedCharIndex(x = it.x, y = it.y, mode = ResolveCharPositionMode.Selection) selectionStart = selectedCharIndex viewState.transformedSelection = selectedCharIndex .. selectedCharIndex viewState.updateSelectionByTransformedSelection(transformedText) focusRequester.requestFocus() - focusRequester.captureFocus() +// focusRequester.captureFocus() }, onDrag = { log.v { "onDrag ${it.x} ${it.y}" } draggedPoint += it - val selectedCharIndex = getTransformedCharIndex(x = draggedPoint.x, y = draggedPoint.y) + val selectedCharIndex = getTransformedCharIndex(x = draggedPoint.x, y = draggedPoint.y, mode = ResolveCharPositionMode.Selection) selectionEnd = selectedCharIndex viewState.transformedSelection = minOf(selectionStart, selectionEnd) .. maxOf(selectionStart, selectionEnd) viewState.updateSelectionByTransformedSelection(transformedText) } ) - .pointerInput(layoutResult, scrollState.value, lineHeight, charWidth, transformedText.text.length, transformedText.text.hashCode()) { + .pointerInput(isEditable, layoutResult, scrollState.value, lineHeight, charWidth, transformedText.text.length, transformedText.text.hashCode()) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() @@ -261,20 +343,66 @@ private fun CoreBigMonospaceText( val position = event.changes.first().position log.v { "press ${position.x} ${position.y}" } if (isHoldingShiftKey) { - selectionEnd = getTransformedCharIndex(x = position.x, y = position.y) + selectionEnd = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Selection) log.v { "selectionEnd => $selectionEnd" } viewState.transformedSelection = minOf(selectionStart, selectionEnd) .. maxOf(selectionStart, selectionEnd) viewState.updateSelectionByTransformedSelection(transformedText) } else { viewState.transformedSelection = IntRange.EMPTY - focusRequester.freeFocus() +// focusRequester.freeFocus() + + if (isEditable) { + viewState.transformedCursorIndex = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Cursor) + viewState.updateCursorIndexByTransformed(transformedText) + log.v { "set cursor pos 1 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } + focusRequester.requestFocus() + } } } } } } } - .onFocusChanged { log.v { "BigMonospaceText onFocusChanged ${it.isFocused}" } } + .onFocusChanged { + log.v { "BigMonospaceText onFocusChanged ${it.isFocused}" } + if (isEditable) { + if (it.isFocused) { + val textInputSession = textInputService?.startInput( + tv, + ImeOptions.Default, + { ed -> + log.v { "onEditCommand [$ed] ${ed.joinToString { it::class.simpleName!! }} $tv" } + ed.forEach { + when (it) { + is CommitTextCommand -> { + if (it.text.isNotEmpty()) { + onType(it.text) + } + } + is SetComposingTextCommand -> { // temporary text, e.g. SetComposingTextCommand(text='竹戈', newCursorPosition=1) + // TODO + } + } + } + }, + { a -> log.v { "onImeActionPerformed $a" } }, + ) + textInputSession?.notifyFocusedRect( + Rect( + layoutCoordinates!!.positionInRoot(), + Size( + layoutCoordinates!!.size.width.toFloat(), + layoutCoordinates!!.size.height.toFloat() + ) + ) + ) + log.v { "started text input session" } +// keyboardController?.show() + } else { +// keyboardController?.hide() + } + } + } .onPreviewKeyEvent { log.v { "BigMonospaceText onPreviewKeyEvent" } when { @@ -295,10 +423,32 @@ private fun CoreBigMonospaceText( isHoldingShiftKey = false false } + isEditable && it.isTypedEvent -> { + log.v { "key type '${it.key}'" } + val textInput = it.toTextInput() + if (textInput != null) { + onType(textInput) + true + } else { + false + } + } + isEditable && it.type == KeyEventType.KeyDown && it.key == Key.Enter && !it.isShiftPressed && !it.isCtrlPressed && !it.isAltPressed && !it.isMetaPressed -> { + onType("\n") + true + } + isEditable && it.type == KeyEventType.KeyDown && it.key == Key.Backspace -> { + onDelete(TextFBDirection.Backward) + } + isEditable && it.type == KeyEventType.KeyDown && it.key == Key.Delete -> { + onDelete(TextFBDirection.Forward) + } else -> false } } +// .then(BigTextInputModifierElement(1)) .focusable(isSelectable) // `focusable` should be after callback modifiers that use focus + ) { val viewportBottom = viewportTop + height if (lineHeight > 0) { @@ -316,10 +466,16 @@ private fun CoreBigMonospaceText( } else { rowStartCharIndices[i + 1] } - log.v { "line #$i: [$startIndex, $endIndex)" } + val nonVisualEndIndex = maxOf(endIndex, startIndex + 1) + val cursorDisplayRangeEndIndex = if (i + 1 > rowStartCharIndices.lastIndex) { + transformedText.text.length + } else { + maxOf(rowStartCharIndices[i + 1] - 1, startIndex) + } +// log.v { "line #$i: [$startIndex, $endIndex)" } val yOffset = (- viewportTop + (i/* - firstRowIndex*/) * lineHeight).toDp() if (viewState.hasSelection()) { - val intersection = viewState.transformedSelection intersect (startIndex .. endIndex - 1) + val intersection = viewState.transformedSelection intersect (startIndex .. nonVisualEndIndex - 1) if (!intersection.isEmpty()) { Box( Modifier @@ -339,6 +495,15 @@ private fun CoreBigMonospaceText( maxLines = 1, modifier = Modifier.offset(y = yOffset) ) + if (isEditable && viewState.transformedCursorIndex in startIndex .. cursorDisplayRangeEndIndex) { + BigTextFieldCursor( + lineHeight = lineHeight.toDp(), + modifier = Modifier.offset( + x = ((viewState.transformedCursorIndex - startIndex) * charWidth).toDp(), + y = yOffset, + ) + ) + } } } } @@ -362,4 +527,23 @@ class BigTextViewState { selection = transformedText.offsetMapping.transformedToOriginal(transformedSelection.first) .. transformedText.offsetMapping.transformedToOriginal(transformedSelection.last) } + + internal var transformedCursorIndex by mutableStateOf(0) + var cursorIndex by mutableStateOf(0) + + fun updateCursorIndexByTransformed(transformedText: TransformedText) { + cursorIndex = transformedText.offsetMapping.transformedToOriginal(transformedCursorIndex) + } + + fun updateTransformedCursorIndexByOriginal(transformedText: TransformedText) { + transformedCursorIndex = transformedText.offsetMapping.originalToTransformed(cursorIndex) + } +} + +private enum class ResolveCharPositionMode { + Selection, Cursor +} + +private enum class TextFBDirection { + Forward, Backward } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldCursor.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldCursor.kt new file mode 100644 index 00000000..5ff91714 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldCursor.kt @@ -0,0 +1,40 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor +import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds +import com.sunnychung.lib.multiplatform.kdatetime.extension.seconds +import kotlinx.coroutines.delay + +@Composable +fun BigTextFieldCursor(modifier: Modifier = Modifier, lineHeight: Dp) { + var isVisible by remember { mutableStateOf(true) } + + if (isVisible) { + Box( + modifier = modifier + .width(2.dp) + .height(lineHeight) + .background(LocalColor.current.cursor) + ) + } + + LaunchedEffect(Unit) { + while (true) { + delay(700.milliseconds().millis) + isVisible = !isVisible + } + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/EnvironmentVariableTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/EnvironmentVariableTransformation.kt index 44877673..4fc27181 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/EnvironmentVariableTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/EnvironmentVariableTransformation.kt @@ -62,6 +62,22 @@ class EnvironmentVariableTransformation(val themeColors: AppColor, val knownVari offsetMapping = offsetMapping ) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is EnvironmentVariableTransformation) return false + + if (themeColors !== other.themeColors) return false + if (knownVariables != other.knownVariables) return false + + return true + } + + override fun hashCode(): Int { + var result = themeColors.hashCode() + result = 31 * result + knownVariables.hashCode() + return result + } } private const val PREFIX_LENGTH = 3 From 721078970a0aaf100174caf9f04680ac1f181279 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 4 Aug 2024 18:46:25 +0800 Subject: [PATCH 018/195] add basic keyboard navigation to BigMonospaceTextField --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 47cd325a..ae571662 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -70,6 +70,7 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.isCtrlOrCmdPressed import com.sunnychung.application.multiplatform.hellohttp.extension.length @@ -433,15 +434,51 @@ private fun CoreBigMonospaceText( false } } - isEditable && it.type == KeyEventType.KeyDown && it.key == Key.Enter && !it.isShiftPressed && !it.isCtrlPressed && !it.isAltPressed && !it.isMetaPressed -> { - onType("\n") - true - } - isEditable && it.type == KeyEventType.KeyDown && it.key == Key.Backspace -> { - onDelete(TextFBDirection.Backward) - } - isEditable && it.type == KeyEventType.KeyDown && it.key == Key.Delete -> { - onDelete(TextFBDirection.Forward) + isEditable && it.type == KeyEventType.KeyDown -> when { + it.key == Key.Enter && !it.isShiftPressed && !it.isCtrlPressed && !it.isAltPressed && !it.isMetaPressed -> { + onType("\n") + true + } + it.key == Key.Backspace -> { + onDelete(TextFBDirection.Backward) + } + it.key == Key.Delete -> { + onDelete(TextFBDirection.Forward) + } + it.key in listOf(Key.DirectionLeft, Key.DirectionRight) -> { + val delta = if (it.key == Key.DirectionRight) 1 else -1 + if (viewState.transformedCursorIndex + delta in 0 .. transformedText.text.length) { + viewState.transformedCursorIndex += delta + viewState.updateCursorIndexByTransformed(transformedText) + } + true + } + it.key in listOf(Key.DirectionUp, Key.DirectionDown) -> { + val row = layoutResult.rowStartCharIndices.binarySearchForMinIndexOfValueAtLeast(viewState.transformedCursorIndex) + val newRow = row + if (it.key == Key.DirectionDown) 1 else -1 + viewState.transformedCursorIndex = Unit.let { + if (newRow < 0) { + 0 + } else if (newRow > layoutResult.rowStartCharIndices.lastIndex) { + transformedText.text.length + } else { + val col = viewState.transformedCursorIndex - layoutResult.rowStartCharIndices[row] + val newRowLength = if (newRow + 1 <= layoutResult.rowStartCharIndices.lastIndex) { + layoutResult.rowStartCharIndices[newRow + 1] - 1 + } else { + transformedText.text.length + } - layoutResult.rowStartCharIndices[newRow] + if (col <= newRowLength) { + layoutResult.rowStartCharIndices[newRow] + col + } else { + layoutResult.rowStartCharIndices[newRow] + newRowLength + } + } + } + viewState.updateCursorIndexByTransformed(transformedText) + true + } + else -> false } else -> false } From 60e20db152a49f5e9a06373e34de1ff7ccb31cf2 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 4 Aug 2024 19:41:37 +0800 Subject: [PATCH 019/195] fix code editor text field cannot scroll after switching to BigMonospaceTextField --- .../application/multiplatform/hellohttp/ux/CodeEditorView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index c04b3265..0084421a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -550,7 +550,7 @@ fun CodeEditorView( scrollState = scrollState, viewState = bigTextViewState, onTextLayout = { layoutResult = it }, - modifier = Modifier.fillMaxSize().verticalScroll(scrollState) + modifier = Modifier.fillMaxSize() .focusRequester(textFieldFocusRequester) .run { if (!isReadOnly) { From 7ca0d8c19e061acb52de3234e94c2fc2d0a56550 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 4 Aug 2024 19:47:56 +0800 Subject: [PATCH 020/195] update BigMonospaceTextField to display cursor only if the text field gets the focus --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index ae571662..0b4527c3 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -247,6 +247,7 @@ private fun CoreBigMonospaceText( var selectionStart by remember { mutableStateOf(-1) } var selectionEnd by remember { mutableStateOf(-1) } var isHoldingShiftKey by remember { mutableStateOf(false) } + var isFocused by remember { mutableStateOf(false) } val viewportTop = scrollState.value.toFloat() @@ -366,6 +367,7 @@ private fun CoreBigMonospaceText( } .onFocusChanged { log.v { "BigMonospaceText onFocusChanged ${it.isFocused}" } + isFocused = it.isFocused if (isEditable) { if (it.isFocused) { val textInputSession = textInputService?.startInput( @@ -532,7 +534,7 @@ private fun CoreBigMonospaceText( maxLines = 1, modifier = Modifier.offset(y = yOffset) ) - if (isEditable && viewState.transformedCursorIndex in startIndex .. cursorDisplayRangeEndIndex) { + if (isEditable && isFocused && viewState.transformedCursorIndex in startIndex .. cursorDisplayRangeEndIndex) { BigTextFieldCursor( lineHeight = lineHeight.toDp(), modifier = Modifier.offset( From 6c3b221f40f28ab4f2c8867c0cf9459ceb78b282 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 4 Aug 2024 21:24:18 +0800 Subject: [PATCH 021/195] add replace selection by input to BigMonospaceTextField --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 0b4527c3..2c9cc606 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -271,6 +271,12 @@ private fun CoreBigMonospaceText( fun onType(textInput: String) { log.v { "key in '$textInput'" } + if (viewState.hasSelection()) { + text.delete(viewState.selection.start, viewState.selection.endInclusive + 1) + viewState.cursorIndex = viewState.selection.start + viewState.selection = IntRange.EMPTY + viewState.transformedSelection = IntRange.EMPTY + } text.insertAt(viewState.cursorIndex, textInput) viewState.cursorIndex += textInput.length viewState.updateTransformedCursorIndexByOriginal(transformedText) From 8c7e548b7174268b4efb4b49f264259563e961d0 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 4 Aug 2024 21:25:01 +0800 Subject: [PATCH 022/195] update BigMonospaceTextField to move cursor to follow selection during dragging --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 2c9cc606..2aaddea7 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -340,6 +340,8 @@ private fun CoreBigMonospaceText( selectionEnd = selectedCharIndex viewState.transformedSelection = minOf(selectionStart, selectionEnd) .. maxOf(selectionStart, selectionEnd) viewState.updateSelectionByTransformedSelection(transformedText) + viewState.transformedCursorIndex = selectionEnd + if (selectionEnd == viewState.transformedSelection.last) 1 else 0 + viewState.updateCursorIndexByTransformed(transformedText) } ) .pointerInput(isEditable, layoutResult, scrollState.value, lineHeight, charWidth, transformedText.text.length, transformedText.text.hashCode()) { From 2c57c3660c9e169132c291987f26278a9572126c Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 4 Aug 2024 22:21:29 +0800 Subject: [PATCH 023/195] fix BigMonospaceText is missing semantics which leads to UX tests fail --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 2aaddea7..8a1f0ec4 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -57,6 +57,9 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalTextInputService +import androidx.compose.ui.semantics.editableText +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.text import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Paragraph import androidx.compose.ui.text.font.FontFamily @@ -321,6 +324,13 @@ private fun CoreBigMonospaceText( .padding(padding) .scrollable(scrollableState, orientation = Orientation.Vertical) .focusRequester(focusRequester) + .semantics { + if (isEditable) { + editableText = transformedText.text + } else { + this.text = transformedText.text + } + } .onDrag( enabled = isSelectable, onDragStart = { From 9bfe9da7e2d75ce54fdc5abb8e5f5489046470f8 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 7 Aug 2024 23:42:16 +0800 Subject: [PATCH 024/195] update MonospaceTextLayouter and add UnicodeCharMeasurer to support layouting inconsistent character width (e.g. CJK characters) except emoji --- .../hellohttp/util/UnicodeCharMeasurer.kt | 85 ++++++++++++++++++ .../hellohttp/ux/bigtext/BigMonospaceText.kt | 88 ++++++++++++++----- .../ux/bigtext/BigTextLayoutResult.kt | 4 + .../ux/bigtext/MonospaceTextLayouter.kt | 66 +++++++++++--- 4 files changed, 206 insertions(+), 37 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/UnicodeCharMeasurer.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/UnicodeCharMeasurer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/UnicodeCharMeasurer.kt new file mode 100644 index 00000000..6346194d --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/UnicodeCharMeasurer.kt @@ -0,0 +1,85 @@ +package com.sunnychung.application.multiplatform.hellohttp.util + +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import java.util.LinkedHashMap + +class UnicodeCharMeasurer(private val measurer: TextMeasurer, private val style: TextStyle) { + private val charWidth: MutableMap = LinkedHashMap(256) + + /** + * Time complexity = O(S lg C) + */ + fun measureFullText(text: String) { + val charToMeasure = mutableSetOf() + text.forEach { + val s = it.toString() + if (!charWidth.containsKey(s) && shouldIndexChar(s)) { + charToMeasure += s + } + } + measureAndIndex(charToMeasure) + log.v { "UnicodeCharMeasurer measureFullText cache size ${charWidth.size}" } + } + + /** + * Time complexity = O(lg C) + */ + fun findCharWidth(char: String): Float { + when (char.codePoints().findFirst().asInt) { + in 0x4E00..0x9FFF, + in 0x3400..0x4DBF, + in 0x20000..0x2A6DF, + in 0xAC00..0xD7AF -> + return charWidth[CJK_FULLWIDTH_REPRESENTABLE_CHAR]!! + } + return charWidth[char] ?: run { + measureAndIndex(setOf(char)) + charWidth[char]!! + } + } + + private fun measureAndIndex(charSet: Set) { + val chars = charSet.toList() + measureExactWidthOf(chars).forEachIndexed { index, r -> + charWidth[chars[index]] = r + if (r < 1f) { + log.w { "measure '${chars[index]}' width = $r" } + } + } + } + + fun measureExactWidthOf(targets: List): List { + val result = measurer.measure(targets.joinToString("\n"), style, softWrap = false) + return targets.mapIndexed { index, s -> + result.getLineRight(index) - result.getLineLeft(index) + } + } + + inline fun shouldIndexChar(s: String): Boolean { + val cp = s.codePoints().findFirst().asInt + return when (cp) { + in 0x4E00..0x9FFF -> false // CJK Unified Ideographs + in 0x3400..0x4DBF -> false // CJK Unified Ideographs Extension A + in 0x20000..0x2A6DF -> false // CJK Unified Ideographs Extension B + in 0xAC00..0xD7AF -> false // Hangul Syllables + else -> true + } + } + + init { + measureAndIndex(COMPULSORY_MEASURES) + // hardcode, because calling TextMeasurer#measure() against below characters returns zero width + charWidth[" "] = charWidth["_"]!! + charWidth["?"] = charWidth["!"]!! + charWidth["’"] = charWidth["'"]!! + } + + companion object { + private const val CJK_FULLWIDTH_REPRESENTABLE_CHAR = "好" + private val COMPULSORY_MEASURES = ( + (0x20.toChar() .. 0x7E.toChar()).map(Char::toString).toMutableSet() + + CJK_FULLWIDTH_REPRESENTABLE_CHAR + ).toSet() + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 8a1f0ec4..3eb26e67 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.text import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Paragraph +import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.CommitTextCommand import androidx.compose.ui.text.input.ImeOptions @@ -69,6 +70,7 @@ import androidx.compose.ui.text.input.SetComposingTextCommand import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.substring import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit @@ -165,8 +167,22 @@ private fun CoreBigMonospaceText( val keyboardController = LocalSoftwareKeyboardController.current val textInputService = LocalTextInputService.current + val textStyle = LocalTextStyle.current.copy( + fontSize = fontSize, + fontFamily = FontFamily.Monospace, + color = color, + ) + val focusRequester = remember { FocusRequester() } - val textLayouter = remember { MonospaceTextLayouter() } + val textLayouter = remember(density, fontFamilyResolver, textStyle) { + MonospaceTextLayouter( + TextMeasurer( + fontFamilyResolver, + density, + LayoutDirection.Ltr, + ), textStyle + ) + } var width by remember { mutableIntStateOf(0) } var height by remember { mutableIntStateOf(0) } @@ -176,11 +192,6 @@ private fun CoreBigMonospaceText( } var lineHeight by remember { mutableStateOf(0f) } var charWidth by remember { mutableStateOf(0f) } - val textStyle = LocalTextStyle.current.copy( - fontSize = fontSize, - fontFamily = FontFamily.Monospace, - color = color, - ) val numOfCharsPerLine = rememberLast(density.density, density.fontScale, fontSize, width) { if (width > 0) { Paragraph( @@ -204,12 +215,12 @@ private fun CoreBigMonospaceText( log.v { "transformed text = `$it`" } } } - val layoutResult = rememberLast(transformedText.text.length, transformedText.hashCode(), numOfCharsPerLine) { + val layoutResult = rememberLast(transformedText.text.length, transformedText.hashCode(), textStyle, lineHeight, contentWidth, textLayouter) { textLayouter.layout( text = text.fullString(), transformedText = transformedText, lineHeight = lineHeight, - numOfCharsPerLine = numOfCharsPerLine, + contentWidth = contentWidth, ).also { if (onTextLayout != null) { onTextLayout(it) @@ -256,20 +267,40 @@ private fun CoreBigMonospaceText( fun getTransformedCharIndex(x: Float, y: Float, mode: ResolveCharPositionMode): Int { val row = ((viewportTop + y) / lineHeight).toInt() - val col = (x / charWidth).toInt() +// val col = (x / charWidth).toInt() if (row > layoutResult.rowStartCharIndices.lastIndex) { return maxOf(0, transformedText.text.length - if (mode == ResolveCharPositionMode.Selection) 1 else 0) } else if (row < 0) { return 0 } - return minOf( - layoutResult.rowStartCharIndices[row] + col, - if (row + 1 <= layoutResult.rowStartCharIndices.lastIndex) { - layoutResult.rowStartCharIndices[row + 1] - 1 - } else { - maxOf(0, transformedText.text.length - if (mode == ResolveCharPositionMode.Selection) 1 else 0) + val numCharsInThisRow = if (row + 1 <= layoutResult.rowStartCharIndices.lastIndex) { + layoutResult.rowStartCharIndices[row + 1] - layoutResult.rowStartCharIndices[row] - 1 + } else { + maxOf(0, transformedText.text.length - layoutResult.rowStartCharIndices[row] - if (mode == ResolveCharPositionMode.Selection) 1 else 0) + } + val charIndex = (layoutResult.rowStartCharIndices[row] .. layoutResult.rowStartCharIndices[row] + numCharsInThisRow).let { range -> + var accumWidth = 0f + range.first { + if (it < range.last) { + accumWidth += layoutResult.findCharWidth(transformedText.text.substring(it..it)) + } + return@first (x < accumWidth || it >= range.last) } - ) + } + return charIndex + } + + fun getTransformedStringWidth(start: Int, endExclusive: Int): Float { + return (start .. endExclusive - 1) + .map { + val char = transformedText.text.substring(it..it) + if (char == "\n") { // selecting \n shows a narrow width + textLayouter.charMeasurer.findCharWidth(" ") + } else { + layoutResult.findCharWidth(char) + } + } + .sum() } fun onType(textInput: String) { @@ -354,7 +385,7 @@ private fun CoreBigMonospaceText( viewState.updateCursorIndexByTransformed(transformedText) } ) - .pointerInput(isEditable, layoutResult, scrollState.value, lineHeight, charWidth, transformedText.text.length, transformedText.text.hashCode()) { + .pointerInput(isEditable, layoutResult, scrollState.value, lineHeight, contentWidth, transformedText.text.length, transformedText.text.hashCode()) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() @@ -470,6 +501,7 @@ private fun CoreBigMonospaceText( if (viewState.transformedCursorIndex + delta in 0 .. transformedText.text.length) { viewState.transformedCursorIndex += delta viewState.updateCursorIndexByTransformed(transformedText) + log.v { "set cursor pos LR => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } } true } @@ -534,29 +566,37 @@ private fun CoreBigMonospaceText( if (viewState.hasSelection()) { val intersection = viewState.transformedSelection intersect (startIndex .. nonVisualEndIndex - 1) if (!intersection.isEmpty()) { + log.v { "row #$i - intersection: $intersection" } Box( Modifier .height(lineHeight.toDp()) - .width((intersection.length * charWidth).toDp()) - .offset(x = ((intersection.start - startIndex) * charWidth).toDp(), y = yOffset) + .width(getTransformedStringWidth(intersection.start, intersection.endInclusive + 1).toDp()) + .offset(x = getTransformedStringWidth(startIndex, intersection.start).toDp(), y = yOffset) .background(color = textSelectionColors.backgroundColor) // `background` modifier must be after `offset` in order to take effect ) } } + val rowText = transformedText.text.subSequence( + startIndex = startIndex, + endIndex = endIndex, + ) + log.v { "text R$i TT $startIndex ..< $endIndex: $rowText" } BasicText( - text = transformedText.text.subSequence( - startIndex = startIndex, - endIndex = endIndex, - ), + text = rowText, style = textStyle, maxLines = 1, + softWrap = false, modifier = Modifier.offset(y = yOffset) ) if (isEditable && isFocused && viewState.transformedCursorIndex in startIndex .. cursorDisplayRangeEndIndex) { + var x = 0f + (startIndex + 1 .. viewState.transformedCursorIndex).forEach { + x += layoutResult.findCharWidth(transformedText.text.substring(it - 1.. it - 1)) + } BigTextFieldCursor( lineHeight = lineHeight.toDp(), modifier = Modifier.offset( - x = ((viewState.transformedCursorIndex - startIndex) * charWidth).toDp(), + x = x.toDp(), y = yOffset, ) ) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt index a5bf6d37..4314eb80 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt @@ -2,6 +2,7 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryApi import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast +import com.sunnychung.application.multiplatform.hellohttp.util.UnicodeCharMeasurer @OptIn(TemporaryApi::class) class BigTextLayoutResult( @@ -15,10 +16,13 @@ class BigTextLayoutResult( val totalLines: Int, val totalRows: Int, /** Total number of lines before transformation */ val totalLinesBeforeTransformation: Int, + private val charMeasurer: UnicodeCharMeasurer, ) { fun findLineNumberByRowNumber(rowNumber: Int): Int { return lineFirstRowIndices.binarySearchForMinIndexOfValueAtLeast(rowNumber) } fun getLineTop(originalLineNumber: Int): Float = lineFirstRowIndices[originalLineNumber] * rowHeight + + fun findCharWidth(char: String) = charMeasurer.findCharWidth(char) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt index 2210ff7b..5c324f28 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt @@ -1,14 +1,20 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.TransformedText import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast +import com.sunnychung.application.multiplatform.hellohttp.util.UnicodeCharMeasurer import com.sunnychung.application.multiplatform.hellohttp.util.log private val LINE_BREAK_REGEX = "\n".toRegex() -class MonospaceTextLayouter { - fun layout(text: String, transformedText: TransformedText, lineHeight: Float, numOfCharsPerLine: Int): BigTextLayoutResult { - if (numOfCharsPerLine < 1) { +class MonospaceTextLayouter(textMeasurer: TextMeasurer, textStyle: TextStyle) { + val charMeasurer = UnicodeCharMeasurer(textMeasurer, textStyle) + + fun layout(text: String, transformedText: TransformedText, lineHeight: Float, contentWidth: Float): BigTextLayoutResult { + log.v { "layout cw=$contentWidth" } + if (contentWidth < 1) { return BigTextLayoutResult( lineRowSpans = listOf(1), lineFirstRowIndices = listOf(0), @@ -17,32 +23,65 @@ class MonospaceTextLayouter { totalLinesBeforeTransformation = 1, totalLines = 1, totalRows = 1, + charMeasurer = charMeasurer, ) } - val originalLineStartIndices = ( + charMeasurer.measureFullText(text) // O(S lg C) + + val originalLineStartIndices = ( // O(L lg L) sequenceOf(0) + LINE_BREAK_REGEX.findAll(text).sortedBy { it.range.last }.map { it.range.last + 1 } ).toList() - val transformedLineStartIndices = ( + val transformedLineStartIndices = ( // O(L lg L) sequenceOf(0) + LINE_BREAK_REGEX.findAll(transformedText.text).sortedBy { it.range.last }.map { it.range.last + 1 } ).toList() val lineRowSpans = MutableList(originalLineStartIndices.size) { 1 } val lineRowIndices = MutableList(originalLineStartIndices.size + 1) { 0 } - val transformedRowStartCharIndices = transformedLineStartIndices.flatMapIndexed { index, it -> + val transformedRowStartCharIndices = listOf(0) + transformedLineStartIndices.flatMapIndexed { index, lineStartIndex -> if (index + 1 <= transformedLineStartIndices.lastIndex) { - val numCharsInThisLine = transformedLineStartIndices[index + 1] - it - (if (transformedText.text[transformedLineStartIndices[index + 1] - 1] == '\n') 1 else 0) - val numOfRows = maxOf(1, numCharsInThisLine divRoundUp numOfCharsPerLine) - (0 until numOfRows).map { j -> - (it + j * numOfCharsPerLine).also { k -> - log.v { "calc index $index -> $it ($numCharsInThisLine, $numOfCharsPerLine) $k" } + val numCharsInThisLine = transformedLineStartIndices[index + 1] - lineStartIndex - (if (transformedText.text[transformedLineStartIndices[index + 1] - 1] == '\n') 1 else 0) + // O(line string length * lg C) + val charWidths = text.substring(lineStartIndex, lineStartIndex + numCharsInThisLine).map { charMeasurer.findCharWidth(it.toString()) } +// val numOfRows = maxOf(1, numCharsInThisLine divRoundUp numOfCharsPerLine) +// (0 until numOfRows).map { j -> +// (it + j * numOfCharsPerLine).also { k -> +// log.v { "calc index $index -> $it ($numCharsInThisLine, $numOfCharsPerLine) $k" } +// } +// } + var numCharsPerRow = mutableListOf() + var currentRowOccupiedWidth = 0f + var numCharsInCurrentRow = 0 + charWidths.forEachIndexed { i, w -> // O(line string length) + if (currentRowOccupiedWidth + w > contentWidth && numCharsInCurrentRow > 0) { + numCharsPerRow += numCharsInCurrentRow + numCharsInCurrentRow = 0 + currentRowOccupiedWidth = 0f } + currentRowOccupiedWidth += w + ++numCharsInCurrentRow + } + if (numCharsInCurrentRow > 0) { + numCharsPerRow += numCharsInCurrentRow + } + if (numCharsPerRow.isEmpty()) { + numCharsPerRow += 0 + } + var s = 0 + numCharsPerRow.mapIndexed { index, it -> + s += it + minOf( + lineStartIndex + s + if (index >= numCharsPerRow.lastIndex) 1 else 0 /* skip the last char '\n' */, + text.length + ) } } else { - listOf(it) + emptyList() + }.also { + log.v { "transformedLineStartIndices flatMap $index -> $it" } } }.also { - log.v { "rowStartCharIndices = $it" } + log.v { "transformedRowStartCharIndices = $it" } } originalLineStartIndices.forEachIndexed { index, it -> val transformedStartCharIndex = transformedText.offsetMapping.originalToTransformed(originalLineStartIndices[index]) @@ -69,6 +108,7 @@ class MonospaceTextLayouter { totalLines = transformedLineStartIndices.size, totalRows = transformedRowStartCharIndices.size, totalLinesBeforeTransformation = originalLineStartIndices.size, + charMeasurer = charMeasurer, ) } } From 9940413aacfe4d840f50a69bfb03725b7687ae1f Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 11 Aug 2024 21:45:31 +0800 Subject: [PATCH 025/195] [WIP] add BigTextImpl, with append, insert and read full string implementation --- .../balancedtree/RedBlackTree.java | 461 ++++++++++++++++++ .../hellohttp/ux/bigtext/BigText.kt | 2 +- .../hellohttp/ux/bigtext/BigTextImpl.kt | 278 +++++++++++ .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 55 +++ .../hellohttp/ux/bigtext/BigTextVerifyImpl.kt | 77 +++ .../hellohttp/ux/bigtext/RedBlackTree2.kt | 212 ++++++++ .../hellohttp/test/bigtext/BigTextImplTest.kt | 252 ++++++++++ 7 files changed, 1336 insertions(+), 1 deletion(-) create mode 100644 src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt diff --git a/src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java b/src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java new file mode 100644 index 00000000..428835c7 --- /dev/null +++ b/src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java @@ -0,0 +1,461 @@ +/** + * This file contains an implementation of a Red-Black tree. A RB tree is a special type of binary + * tree which self balances itself to keep operations logarithmic. + * + *

Great visualization tool: https://www.cs.usfca.edu/~galles/visualization/RedBlack.html + * + * @author nishantc1527 + * @author William Fiset, william.alexandre.fiset@gmail.com + */ +package com.williamfiset.algorithms.datastructures.balancedtree; + +public class RedBlackTree> implements Iterable { + + public static final boolean RED = true; + public static final boolean BLACK = false; + + public class Node { + + // The color of this node. By default all nodes start red. + public boolean color = RED; + + // The value/data contained within the node. + public T value; + + // The left, right and parent references of this node. + public Node left, right, parent; + + public Node(T value, Node parent) { + this.value = value; + this.parent = parent; + } + + public Node(boolean color, T value) { + this.color = color; + this.value = value; + } + + public Node(T key, boolean color, Node parent, Node left, Node right) { + this.value = key; + this.color = color; + + if (parent == null && left == null && right == null) { + parent = this; + left = this; + right = this; + } + + this.parent = parent; + this.left = left; + this.right = right; + } + + public boolean getColor() { + return color; + } + + public void setColor(boolean color) { + this.color = color; + } + + public T getValue() { + return value; + } + + public void setValue(T value) { + this.value = value; + } + + public Node getLeft() { + return left; + } + + public void setLeft(Node left) { + this.left = left; + } + + public Node getRight() { + return right; + } + + public void setRight(Node right) { + this.right = right; + } + + public Node getParent() { + return parent; + } + + public void setParent(Node parent) { + this.parent = parent; + } + + public boolean isNil() { + return this == NIL; + } + } + + // The root node of the RB tree. + public Node root; + + // Tracks the number of nodes inside the tree. + protected int nodeCount = 0; + + public final Node NIL; + + public RedBlackTree() { + NIL = new Node(BLACK, null); + NIL.left = NIL; + NIL.right = NIL; + NIL.parent = NIL; + + root = NIL; + } + + // Returns the number of nodes in the tree. + public int size() { + return nodeCount; + } + + // Returns whether or not the tree is empty. + public boolean isEmpty() { + return size() == 0; + } + + public boolean contains(T value) { + + Node node = root; + + if (node == null || value == null) return false; + + while (node != NIL) { + + // Compare current value to the value in the node. + int cmp = value.compareTo(node.value); + + // Dig into left subtree. + if (cmp < 0) node = node.left; + + // Dig into right subtree. + else if (cmp > 0) node = node.right; + + // Found value in tree. + else return true; + } + + return false; + } + + public boolean insert(T val) { + if (val == null) { + throw new IllegalArgumentException("Red-Black tree does not allow null values."); + } + + Node x = root, y = NIL; + + while (x != NIL) { + y = x; + + if (x.getValue().compareTo(val) > 0) { + x = x.left; + } else if (x.getValue().compareTo(val) < 0) { + x = x.right; + } else { + return false; + } + } + + Node z = new Node(val, RED, y, NIL, NIL); + + if (y == NIL) { + root = z; + } else if (z.getValue().compareTo(y.getValue()) < 0) { + y.left = z; + } else { + y.right = z; + } + insertFix(z); + + nodeCount++; + return true; + } + + protected void insertFix(Node z) { + Node y; + while (z.parent.color == RED) { + if (z.parent == z.parent.parent.left) { + y = z.parent.parent.right; + if (y.color == RED) { + z.parent.color = BLACK; + y.color = BLACK; + z.parent.parent.color = RED; + z = z.parent.parent; + } else { + if (z == z.parent.right) { + z = z.parent; + leftRotate(z); + } + z.parent.color = BLACK; + z.parent.parent.color = RED; + rightRotate(z.parent.parent); + } + } else { + y = z.parent.parent.left; + if (y.color == RED) { + z.parent.color = BLACK; + y.color = BLACK; + z.parent.parent.color = RED; + z = z.parent.parent; + } else { + if (z == z.parent.left) { + z = z.parent; + rightRotate(z); + } + z.parent.color = BLACK; + z.parent.parent.color = RED; + leftRotate(z.parent.parent); + } + } + } + root.setColor(BLACK); + NIL.setParent(null); + } + + protected void leftRotate(Node x) { + Node y = x.right; + x.setRight(y.getLeft()); + if (y.getLeft() != NIL) y.getLeft().setParent(x); + y.setParent(x.getParent()); + if (x.getParent() == NIL) root = y; + if (x == x.getParent().getLeft()) x.getParent().setLeft(y); + else x.getParent().setRight(y); + y.setLeft(x); + x.setParent(y); + } + + protected void rightRotate(Node y) { + Node x = y.left; + y.left = x.right; + if (x.right != NIL) x.right.parent = y; + x.parent = y.parent; + if (y.parent == NIL) root = x; + if (y == y.parent.left) y.parent.left = x; + else y.parent.right = x; + x.right = y; + y.parent = x; + } + + public boolean delete(T key) { + Node z; + if (key == null || (z = (search(key, root))) == NIL) return false; + Node x; + Node y = z; // temporary reference y + boolean y_original_color = y.getColor(); + + if (z.getLeft() == NIL) { + x = z.getRight(); + transplant(z, z.getRight()); + } else if (z.getRight() == NIL) { + x = z.getLeft(); + transplant(z, z.getLeft()); + } else { + y = successor(z.getRight()); + y_original_color = y.getColor(); + x = y.getRight(); + if (y.getParent() == z) x.setParent(y); + else { + transplant(y, y.getRight()); + y.setRight(z.getRight()); + y.getRight().setParent(y); + } + transplant(z, y); + y.setLeft(z.getLeft()); + y.getLeft().setParent(y); + y.setColor(z.getColor()); + } + if (y_original_color == BLACK) deleteFix(x); + nodeCount--; + return true; + } + + private void deleteFix(Node x) { + while (x != root && x.getColor() == BLACK) { + if (x == x.getParent().getLeft()) { + Node w = x.getParent().getRight(); + if (w.getColor() == RED) { + w.setColor(BLACK); + x.getParent().setColor(RED); + leftRotate(x.parent); + w = x.getParent().getRight(); + } + if (w.getLeft().getColor() == BLACK && w.getRight().getColor() == BLACK) { + w.setColor(RED); + x = x.getParent(); + continue; + } else if (w.getRight().getColor() == BLACK) { + w.getLeft().setColor(BLACK); + w.setColor(RED); + rightRotate(w); + w = x.getParent().getRight(); + } + if (w.getRight().getColor() == RED) { + w.setColor(x.getParent().getColor()); + x.getParent().setColor(BLACK); + w.getRight().setColor(BLACK); + leftRotate(x.getParent()); + x = root; + } + } else { + Node w = (x.getParent().getLeft()); + if (w.color == RED) { + w.color = BLACK; + x.getParent().setColor(RED); + rightRotate(x.getParent()); + w = (x.getParent()).getLeft(); + } + if (w.right.color == BLACK && w.left.color == BLACK) { + w.color = RED; + x = x.getParent(); + continue; + } else if (w.left.color == BLACK) { + w.right.color = BLACK; + w.color = RED; + leftRotate(w); + w = (x.getParent().getLeft()); + } + if (w.left.color == RED) { + w.color = x.getParent().getColor(); + x.getParent().setColor(BLACK); + w.left.color = BLACK; + rightRotate(x.getParent()); + x = root; + } + } + } + x.setColor(BLACK); + } + + private Node successor(Node root) { + if (root == NIL || root.left == NIL) return root; + else return successor(root.left); + } + + private void transplant(Node u, Node v) { + if (u.parent == NIL) { + root = v; + } else if (u == u.parent.left) { + u.parent.left = v; + } else u.parent.right = v; + v.parent = u.parent; + } + + private Node search(T val, Node curr) { + if (curr == NIL) return NIL; + else if (curr.value.equals(val)) return curr; + else if (curr.value.compareTo(val) < 0) return search(val, curr.right); + else return search(val, curr.left); + } + + public int height() { + return height(root); + } + + private int height(Node curr) { + if (curr == NIL) { + return 0; + } + if (curr.left == NIL && curr.right == NIL) { + return 1; + } + + return 1 + Math.max(height(curr.left), height(curr.right)); + } + + private void swapColors(Node a, Node b) { + boolean tmpColor = a.color; + a.color = b.color; + b.color = tmpColor; + } + + // Sometimes the left or right child node of a parent changes and the + // parent's reference needs to be updated to point to the new child. + // This is a helper method to do just that. + private void updateParentChildLink(Node parent, Node oldChild, Node newChild) { + if (parent != NIL) { + if (parent.left == oldChild) { + parent.left = newChild; + } else { + parent.right = newChild; + } + } + } + + // Helper method to find the leftmost node (which has the smallest value) + private Node findMin(Node node) { + while (node.left != NIL) node = node.left; + return node; + } + + // Helper method to find the rightmost node (which has the largest value) + private Node findMax(Node node) { + while (node.right != NIL) node = node.right; + return node; + } + + // Returns as iterator to traverse the tree in order. + @Override + public java.util.Iterator iterator() { + + final int expectedNodeCount = nodeCount; + final java.util.Stack stack = new java.util.Stack<>(); + stack.push(root); + + return new java.util.Iterator() { + Node trav = root; + + @Override + public boolean hasNext() { + if (expectedNodeCount != nodeCount) throw new java.util.ConcurrentModificationException(); + return root != NIL && !stack.isEmpty(); + } + + @Override + public T next() { + + if (expectedNodeCount != nodeCount) throw new java.util.ConcurrentModificationException(); + + while (trav != NIL && trav.left != NIL) { + stack.push(trav.left); + trav = trav.left; + } + + Node node = stack.pop(); + + if (node.right != NIL) { + stack.push(node.right); + trav = node.right; + } + + return node.value; + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + }; + } + + // Example usage of RB tree: + public static void main(String[] args) { + + int[] values = {5, 8, 1, -4, 6, -2, 0, 7}; + RedBlackTree rbTree = new RedBlackTree<>(); + for (int v : values) rbTree.insert(v); + + System.out.printf("RB tree contains %d: %s\n", 6, rbTree.contains(6)); + System.out.printf("RB tree contains %d: %s\n", -5, rbTree.contains(-5)); + System.out.printf("RB tree contains %d: %s\n", 1, rbTree.contains(1)); + System.out.printf("RB tree contains %d: %s\n", 99, rbTree.contains(99)); + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt index 3e20451a..dcab031b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -11,7 +11,7 @@ interface BigText { fun substring(start: Int, endExclusive: Int): String - fun substring(range: IntRange): String + fun substring(range: IntRange): String = substring(range.start, range.endInclusive + 1) fun append(text: String) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt new file mode 100644 index 00000000..a57c3549 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -0,0 +1,278 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Logger +import co.touchlab.kermit.MutableLoggerConfig +import co.touchlab.kermit.Severity +import com.sunnychung.application.multiplatform.hellohttp.extension.length +import com.sunnychung.application.multiplatform.hellohttp.util.JvmLogger +import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree + +val log = Logger(object : MutableLoggerConfig { + override var logWriterList: List = listOf(JvmLogger()) + override var minSeverity: Severity = Severity.Info +}, tag = "BigText") + +class BigTextImpl : BigText { + val tree = RedBlackTree2( + object : RedBlackTreeComputations { + override fun recomputeFromLeaf(it: RedBlackTree.Node) = recomputeAggregatedValues(it) + override fun computeWhenLeftRotate(x: BigTextNodeValue, y: BigTextNodeValue) = computeWhenLeftRotate0(x, y) + override fun computeWhenRightRotate(x: BigTextNodeValue, y: BigTextNodeValue) = computeWhenRightRotate0(x, y) + } + ) + val buffers = mutableListOf() + + val chunkSize: Int // TODO change to a large number + + constructor() { + chunkSize = 64 + } + + internal constructor(chunkSize: Int) { + this.chunkSize = chunkSize + } + + fun RedBlackTree2.findNodeByCharIndex(index: Int): RedBlackTree.Node? { + var find = index + return findNode { + when (find) { + in Int.MIN_VALUE until it.value.leftStringLength -> -1 + in it.value.leftStringLength until it.value.leftStringLength + it.value.bufferLength -> 0 + in it.value.leftStringLength + it.value.bufferLength until Int.MAX_VALUE -> 1.also { compareResult -> + val isTurnRight = compareResult > 0 + if (isTurnRight) { + find -= it.value.leftStringLength + it.value.bufferLength + } + } + else -> throw IllegalStateException("what is find? $find") + } + } + } + + fun findPositionStart(node: RedBlackTree.Node): Int { + var start = node.value.leftStringLength + var node = node + while (node.parent.isNotNil()) { + if (node === node.parent.right) { + start += node.parent.value.leftStringLength + node.parent.value.bufferLength + } + node = node.parent + } + return start + } + + private fun appendChunk(chunkedString: String) { + insertChunkAtPosition(tree.root.length(), chunkedString) + } + + private fun insertChunkAtPosition(position: Int, chunkedString: String) { + log.d { "insertChunkAtPosition($position, $chunkedString)" } + require(chunkedString.length <= chunkSize) +// if (position == 64) { +// log.d { inspect("$position") } +// } + var buffer = if (buffers.isNotEmpty()) { + buffers.last().takeIf { it.length + chunkedString.length <= chunkSize } + } else null + if (buffer == null) { + buffer = TextBuffer() + buffers += buffer + } + require(buffer.length + chunkedString.length <= chunkSize) + val range = buffer.append(chunkedString) + var node = tree.findNodeByCharIndex(maxOf(0, position - 1)) // TODO optimize, don't do twice + val nodeStart = node?.let { findPositionStart(it) } // TODO optimize, don't do twice + if (node != null) { + log.d { "> existing node (${node!!.value.debugKey()}) $nodeStart .. ${nodeStart!! + node!!.value.bufferLength - 1}" } + require(maxOf(0, position - 1) in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1) { + printDebug() + findPositionStart(node!!) + "Found node ${node!!.value.debugKey()} but it is not in searching range" + } + } + var insertDirection: InsertDirection = InsertDirection.Undefined + val newNodeValues = if (node != null && position in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1) { + val splitAtIndex = position - nodeStart + log.d { "> split at $splitAtIndex" } + val oldEnd = node.value.bufferOffsetEndExclusive + val secondPartNodeValue = BigTextNodeValue().apply { // the second part of the old string + bufferIndex = node!!.value.bufferIndex + bufferOffsetStart = node!!.value.bufferOffsetStart + splitAtIndex + bufferOffsetEndExclusive = oldEnd + + leftStringLength = 0 + } + if (splitAtIndex > 0) { + node.value.bufferOffsetEndExclusive = node.value.bufferOffsetStart + splitAtIndex + } else { + tree.delete(node.value) + node = tree.findNodeByCharIndex(maxOf(0, position - 1)) + insertDirection = InsertDirection.Left + } + require(splitAtIndex + chunkedString.length <= chunkSize) + listOf( + BigTextNodeValue().apply { // new string + bufferIndex = buffers.lastIndex + bufferOffsetStart = range.start + bufferOffsetEndExclusive = range.endInclusive + 1 + + leftStringLength = 0 + }, + secondPartNodeValue + ).reversed() // IMPORTANT: the insertion order is reversed + } else if (node == null || node.value.bufferIndex != buffers.lastIndex || node.value.bufferOffsetEndExclusive != range.start) { + log.d { "> create new node" } + listOf(BigTextNodeValue().apply { + bufferIndex = buffers.lastIndex + bufferOffsetStart = range.start + bufferOffsetEndExclusive = range.endInclusive + 1 + + leftStringLength = 0 + }) + } else { + node.value.apply { + log.d { "> update existing node end from $bufferOffsetEndExclusive to ${bufferOffsetEndExclusive + range.length}" } + bufferOffsetEndExclusive += range.length + } + recomputeAggregatedValues(node) + emptyList() + } + if (newNodeValues.isNotEmpty() && insertDirection == InsertDirection.Left) { + node = if (node != null) { + tree.insertLeft(node, newNodeValues.first()) + } else { + tree.insertValue(newNodeValues.first())!! + } + (1 .. newNodeValues.lastIndex).forEach { + node = tree.insertLeft(node!!, newNodeValues[it]) + } + } else { + newNodeValues.forEach { + if (node?.value?.leftStringLength == position) { + tree.insertLeft(node!!, it) // insert before existing node + } else if (node != null) { + tree.insertRight(node!!, it) + } else { + tree.insertValue(it) + } + } + } + + log.d { inspect("Finish " + node?.value?.debugKey()) } + } + + fun recomputeAggregatedValues(node: RedBlackTree.Node) { + log.v { inspect("${node.value?.debugKey()} start") } + + var node = node + while (node.isNotNil()) { + val left = node.left.takeIf { it.isNotNil() } + with (node.getValue()) { + leftStringLength = left?.length() ?: 0 + log.d { ">> ${node.value.debugKey()} -> $leftStringLength (${left?.value?.debugKey()}/ ${left?.length()})" } + // TODO calc other metrics + } + log.v { ">> ${node.parent.value?.debugKey()} parent -> ${node.value?.debugKey()}" } + node = node.parent + } + log.v { inspect("${node.value?.debugKey()} end") } + log.v { "" } + } + + fun computeWhenLeftRotate0(x: BigTextNodeValue, y: BigTextNodeValue) { + y.leftStringLength += x.leftStringLength + x.bufferLength + // TODO calc other metrics + } + + fun computeWhenRightRotate0(x: BigTextNodeValue, y: BigTextNodeValue) { + y.leftStringLength -= x.leftStringLength + x.bufferLength + // TODO calc other metrics + } + + override val length: Int + get() = tree.root.length() + + override fun fullString(): String { + return tree.joinToString("") { + buffers[it.bufferIndex].toString().substring(it.bufferOffsetStart, it.bufferOffsetEndExclusive) + } + } + + override fun substring(start: Int, endExclusive: Int): String { + TODO("Not yet implemented") + } + + override fun append(text: String) { + insertAt(length, text) +// var start = 0 +// while (start < text.length) { +// var last = buffers.lastOrNull()?.length +// if (last == null || last >= chunkSize) { +// buffers += TextBuffer() +// last = 0 +// } +// val available = chunkSize - last +// val append = minOf(available, text.length - start) +// appendChunk(text.substring(start until start + append)) +// start += append +// } + } + + override fun insertAt(pos: Int, text: String) { + var start = 0 + val prevNode = tree.findNodeByCharIndex(maxOf(0, pos - 1)) + val nodeStart = prevNode?.let { findPositionStart(it) }?.also { + require(pos in it .. it + prevNode.value.bufferLength) + } + var last = buffers.lastOrNull()?.length // prevNode?.let { buffers[it.value.bufferIndex].length } + if (prevNode != null && pos in nodeStart!! .. nodeStart!! + prevNode.value.bufferLength - 1) { + val splitAtIndex = pos - nodeStart + last = maxOf((last ?: 0) % chunkSize, splitAtIndex) + } + while (start < text.length) { + if (last == null || last >= chunkSize) { +// buffers += TextBuffer() + last = 0 + } + val available = chunkSize - last + val append = minOf(available, text.length - start) + insertChunkAtPosition(pos + start, text.substring(start until start + append)) + start += append + last = buffers.last().length + } + } + + override fun delete(start: Int, endExclusive: Int) { + TODO("Not yet implemented") + } + + override fun hashCode(): Int { + TODO("Not yet implemented") + } + + override fun equals(other: Any?): Boolean { + TODO("Not yet implemented") + } + + fun inspect(label: String = "") = buildString { + appendLine("[$label] Buffer:\n${buffers.mapIndexed { i, it -> " $i:\t$it\n" }.joinToString("")}") + appendLine("[$label] Tree:\nflowchart TD\n${tree.debugTree()}") + appendLine("[$label] String:\n${fullString()}") + } + + fun printDebug(label: String = "") { + println(inspect(label)) + } + + +} + +fun RedBlackTree.Node.length(): Int = + (getValue()?.leftStringLength ?: 0) + + (getValue()?.bufferLength ?: 0) + + (getRight().takeIf { it.isNotNil() }?.length() ?: 0) + +private enum class InsertDirection { + Left, Right, Undefined +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt new file mode 100644 index 00000000..32696c60 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -0,0 +1,55 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree +import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree.Node +import kotlin.random.Random + +class BigTextNodeValue : Comparable, DebuggableNode { + var leftNumOfLineBreaks: Int = -1 + var leftNumOfRows: Int = -1 + var leftLastRowWidth: Int = -1 + var leftStringLength: Int = -1 +// var rowOffsetStarts: List = emptyList() + + var bufferIndex: Int = -1 + var bufferOffsetStart: Int = -1 + var bufferOffsetEndExclusive: Int = -1 + + val bufferLength: Int + get() = bufferOffsetEndExclusive - bufferOffsetStart + + private val key = Random.nextInt() + + override fun compareTo(other: BigTextNodeValue): Int { + return compareValues(leftStringLength, other.leftStringLength) + } + + override fun debugKey(): String = "$key" + override fun debugLabel(node: RedBlackTree.Node): String = + "$leftStringLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.length()}" +} + +class TextBuffer { + private val buffer = StringBuilder() + + var lineOffsetStarts: List = emptyList() +// var rowOffsetStarts: List = emptyList() + + val length: Int + get() = buffer.length + + fun append(text: String): IntRange { + val start = buffer.length + buffer.append(text) + text.forEachIndexed { index, c -> + if (c == '\n') { + lineOffsetStarts += start + index + } + } + return start until start + text.length + } + + override fun toString(): String { + return buffer.toString() + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt new file mode 100644 index 00000000..dc00050b --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt @@ -0,0 +1,77 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : BigText { + val bigTextImpl = if (chunkSize > 0) BigTextImpl(chunkSize) else BigTextImpl() + val stringImpl = InefficientBigText("") + + val tree = bigTextImpl.tree + val buffers = bigTextImpl.buffers + + override val length: Int + get() { + val l = bigTextImpl.length + val tl = stringImpl.length + assert(l == tl) { "length expected $tl, actual $l" } + return l + } + + override fun fullString(): String { + val r = bigTextImpl.fullString() + val tr = stringImpl.fullString() + assert(r == tr) { "fullString expected $tr, actual $r" } + return r + } + + override fun substring(start: Int, endExclusive: Int): String { + val r = bigTextImpl.substring(start, endExclusive) + val tr = stringImpl.substring(start, endExclusive) + assert(r == tr) { "substring expected $tr, actual $r" } + return r + } + + override fun append(text: String) { + println("append ${text.length}") + bigTextImpl.append(text) + stringImpl.append(text) + verify() + } + + override fun insertAt(pos: Int, text: String) { + println("insert $pos, ${text.length}") + bigTextImpl.insertAt(pos, text) + stringImpl.insertAt(pos, text) + verify() + } + + override fun delete(start: Int, endExclusive: Int) { + bigTextImpl.delete(start, endExclusive) + stringImpl.delete(start, endExclusive) + verify() + } + + override fun hashCode(): Int { + val r = bigTextImpl.hashCode() + val tr = stringImpl.hashCode() + assert(r == tr) { "hashCode expected $tr, actual $r" } + return r + } + + override fun equals(other: Any?): Boolean { + val r = bigTextImpl.equals(other) + val tr = stringImpl.equals(other) + assert(r == tr) { "equals expected $tr, actual $r" } + return r + } + + fun verify(label: String = "") { + try { + length + fullString() + } catch (e: Throwable) { + printDebug(label) + throw e + } + } + + fun printDebug(label: String = "") = bigTextImpl.printDebug(label) +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt new file mode 100644 index 00000000..250c4a0a --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt @@ -0,0 +1,212 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree + +interface DebuggableNode> { + fun debugKey(): String + fun debugLabel(node: RedBlackTree.Node): String +} + +interface RedBlackTreeComputations> { + fun recomputeFromLeaf(it: RedBlackTree.Node) + fun computeWhenLeftRotate(x: T, y: T) + fun computeWhenRightRotate(x: T, y: T) +} + +open class RedBlackTree2(private val computations: RedBlackTreeComputations) : RedBlackTree() where T : Comparable, T : DebuggableNode { + + fun lastNodeOrNull(): Node? { + var child: Node = root + while (child.getLeft().isNotNil() || child.getRight().isNotNil()) { + child = child.getRight() ?: child.getLeft() + } + return child.takeIf { it.isNotNil() } + } + + fun lastOrNull(): T? { + return lastNodeOrNull()?.getValue() + } + + fun find(comparison: (T) -> Int): T? { + return findNode { comparison(it.value) }?.getValue() + } + + fun findNode(comparison: (Node) -> Int): Node? { + var child: Node = root + while (child.isNotNil()) { + val compareResult = comparison(child) + if (compareResult == 0) { + return child.takeIf { it.isNotNil() } + } else if (compareResult > 0) { + child = child.getRight() + } else { + child = child.getLeft() + } + } + return null + } + + inline fun Node.isLeaf(): Boolean = left.isNil() && right.isNil() + + @Deprecated("use `insertValue` instead") + override fun insert(`val`: T): Boolean { + throw UnsupportedOperationException() + } + + fun insertValue(`val`: T): Node? { + requireNotNull(`val`) { "Red-Black tree does not allow null values." } + + var x: Node = root + var y: Node = NIL + + while (x !== NIL) { + y = x + + x = if (x.getValue().compareTo(`val`) > 0) { + x.left + } else if (x.getValue().compareTo(`val`) < 0) { + x.right + } else { + return null + } + } + + val z: Node = Node(`val`, RED, y, NIL, NIL) + + if (y === NIL) { + root = z + } else if (z.getValue().compareTo(y.getValue()) < 0) { + y.left = z + } else { + y.right = z + } + insertFix(z) + + nodeCount++ + return z + } + + /** + * parent parent + * / \ / \ + * a b ----> a b + * \ + * z + */ + fun insertLeft(parent: Node, value: T): Node { + val z: Node = Node(value, RED, parent, NIL, NIL) + if (root.isNil) { + TODO("insertLeft root") + } + if (parent.left.isNil) { + parent.left = z + } else { + val prevNode = rightmost(parent.left) + prevNode.right = z + z.parent = prevNode + } + insertFix(z) + nodeCount++ + return z + } + + /** + * parent parent + * / \ / \ + * a b ----> a b + * / + * z + */ + fun insertRight(parent: Node, value: T): Node { + val z: Node = Node(value, RED, parent, NIL, NIL) + if (root.isNil) { + TODO("insertRight root") + } + if (parent.right.isNil) { + parent.right = z + } else { + val nextNode = leftmost(parent.right) + nextNode.left = z + z.parent = nextNode + } + insertFix(z) + nodeCount++ + return z + } + + override fun insertFix(z: Node) { + computations.recomputeFromLeaf(z) + super.insertFix(z) + } + + fun leftmost(node: Node): Node { + var node = node + while (node.left.isNotNil()) { + node = node.left + } + return node + } + + fun rightmost(node: Node): Node { + var node = node + while (node.right.isNotNil()) { + node = node.right + } + return node + } + + /** + * parent parent + * / / + * y <--- x + * / \ / \ + * x c a y + * / \ / \ + * a b b c + */ + override fun leftRotate(x: Node) { + val y = x.right + computations.computeWhenLeftRotate(x.value, y.value) + super.leftRotate(x) + } + + /** + * parent parent + * / / + * y ---> x + * / \ / \ + * x c a y + * / \ / \ + * a b b c + */ + override fun rightRotate(y: Node) { + val x = y.left + computations.computeWhenRightRotate(x.value, y.value) + super.rightRotate(y) + } + +// fun visitUpwards(node: Node, visitor: (T) -> Unit) { +// var node = node +// while (node.isNotNil()) { +// vi +// } +// } + + fun debugTree(prepend: String = " "): String = buildString { + fun visit(node: Node): String { + val key = node.value?.debugKey().toString() + if (node === root) { + appendLine("$prepend$key[/\"${node.value?.debugLabel(node)}\"\\]") + } else { + appendLine("$prepend$key[\"${node.value?.debugLabel(node)}\"]") + } + node.left.takeIf { it.isNotNil() }?.also { appendLine("$prepend$key--L-->${visit(it)}") } + node.right.takeIf { it.isNotNil() }?.also { appendLine("$prepend$key--R-->${visit(it)}") } +// node.parent.takeIf { it.isNotNil() }?.also { appendLine("$prepend$key--P-->${node.parent.value.debugKey()}") } + return key + } + visit(root) + } +} + +inline fun > RedBlackTree.Node.isNotNil(): Boolean = !isNil() diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt new file mode 100644 index 00000000..02adcb32 --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt @@ -0,0 +1,252 @@ +package com.sunnychung.application.multiplatform.hellohttp.test.bigtext + +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextVerifyImpl +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class BigTextImplTest { + + @Test + fun appendMultipleShort() { + val t = BigTextVerifyImpl() + t.append("abc") + t.append("defgh") + t.append("ijk") + t.printDebug() + assertEquals(1, t.tree.size()) + } + + @Test + fun appendMultipleLong() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("a".repeat(60)) + t.append("b".repeat(50)) + t.append("c".repeat(100)) + t.printDebug() + assertEquals(4, t.tree.size()) + } + + @Test + fun insertBetweenChunks() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("a".repeat(64)) + t.append("b".repeat(64)) + t.insertAt(64, "1".repeat(64)) + t.append("c".repeat(100)) + t.printDebug() + assertEquals(5, t.tree.size()) + } + + @Test + fun insertMultipleBetweenChunks() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("a".repeat(64)) + t.append("b".repeat(64)) + t.insertAt(64, "1".repeat(128)) + t.append("c".repeat(100)) + t.insertAt(64 * 4, "2".repeat(128)) + t.insertAt(t.length, "3".repeat(70)) + t.printDebug() + assertEquals(11, t.tree.size()) + } + + @Test + fun insertWithinChunks1() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("a".repeat(64)) + t.append("b".repeat(64)) + t.insertAt(50, "1".repeat(20)) + t.append("c".repeat(30)) + t.printDebug() + assertEquals(5, t.tree.size()) + } + + @Test + fun insertWithinChunks2() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("a".repeat(64)) + t.append("b".repeat(64)) + t.insertAt(70, "1".repeat(20)) + t.append("c".repeat(30)) + t.printDebug() + assertEquals(5, t.tree.size()) + } + + @Test + fun insertMultipleWithinChunks1() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("a".repeat(64)) // 0 ..< 50, 50 ..< 64 + t.append("b".repeat(64)) // 64 ..< 128 + t.insertAt(50, "1".repeat(150)) // 128 ..< 192, 192 ..< 256, 256 ..< 278 + t.append("c".repeat(30)) // 278 ..< 308 + t.printDebug() + assertEquals(7, t.tree.size()) + } + + @Test + fun insertMultipleWithinChunks2() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("a".repeat(64)) + t.append("b".repeat(64)) + t.insertAt(70, "1".repeat(150)) + t.append("c".repeat(30)) + t.printDebug() + assertEquals(7, t.tree.size()) + } + + @Test + fun empty() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.printDebug() + assertEquals(0, t.tree.size()) + assertEquals(0, t.fullString().length) + assertEquals(0, t.length) + } + + @Test + fun insertEmpty() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("abcd") + t.append("") + t.insertAt(2, "") + t.insertAt(1, "") + t.insertAt(1, "") + t.insertAt(4, "") + t.printDebug() + assertEquals(1, t.tree.size()) + assertEquals(4, t.fullString().length) + assertEquals(4, t.length) + } + + @Test + fun insertLonger() { + val t = BigTextVerifyImpl(chunkSize = 16) + t.append((0..99).map { 'a' + (it % 26) }.joinToString("")) + t.insertAt(60, (0..99).map { 'A' + (it % 26) }.joinToString("")) + t.append((0..39).map { '0' + (it % 10) }.joinToString("")) + t.printDebug() + assertEquals(240 / 16, t.buffers.size) + assertEquals((100 / 16 + 1) + 1 + (100 / 16 + 1) + (40 / 16 + 1), t.tree.size()) + assertEquals(240, t.length) + assertEquals(240, t.fullString().length) + } + + @Test + fun insertALotLonger() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append((0..9999).map { 'a' + (it % 26) }.joinToString("")) + t.insertAt(4600, (0..9999).map { 'A' + (it % 26) }.joinToString("")) + t.append((0..9999).map { '0' + (it % 10) }.joinToString("")) + t.printDebug() + assertEquals(30000 / 64 + 1, t.buffers.size) +// assertEquals((30000 / 64 + 1) + 1 + (30000 / 64 + 1) + (30000 / 64 + 1), t.tree.size()) + assertEquals(30000, t.length) + assertEquals(30000, t.fullString().length) + } + + @Test + fun insertStringOfMillionChars() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("a".repeat(1000000)) + t.insertAt(300000, "b".repeat(1000000)) + t.append("c".repeat(3000000)) +// t.printDebug() + assertEquals(5000000 / 64, t.buffers.size) + assertEquals((1000000 / 64 + 1) + (1000000 / 64 + 1) + (3000000 / 64 + 1) - 2, t.tree.size()) + assertEquals(5000000, t.length) + assertEquals(5000000, t.fullString().length) + } + +// @Test +// fun appendNearEndOfChunk() { +// val t = BigTextVerifyImpl(chunkSize = 64) +// t.append("-".repeat(6400)) +// t.append("a".repeat(64 + 41)) +// t.append("b".repeat(21)) +// t.append("c".repeat(7)) +//// t.printDebug() +// val len = 6400 + 60 + 7 +// assertEquals(len / 64 + 1, t.buffers.size) +// assertEquals(len, t.length) +// assertEquals(len, t.fullString().length) +// } + + @Test + fun insertAtStart() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("a".repeat(339)) + t.insertAt(0, "b".repeat(46)) + t.printDebug() + val len = 339 + 46 + assertEquals(len / 64 + 1, t.buffers.size) + assertEquals(len, t.length) + assertEquals(len, t.fullString().length) + } + + @Test + fun insertMoreAtStart() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("a".repeat(150)) + t.insertAt(0, "B".repeat(13)) + t.insertAt(0, "C".repeat(62)) + t.insertAt(0, "D".repeat(58)) + t.insertAt(0, "E".repeat(7)) + t.insertAt(0, "F".repeat(90)) + t.insertAt(0, "G".repeat(64)) + t.insertAt(0, "H".repeat(129)) + t.printDebug() + val len = 150 + 13 + 62 + 58 + 7 + 90 + 64 + 129 + assertEquals(len / 64 + 1, t.buffers.size) + assertEquals(len, t.length) + assertEquals(len, t.fullString().length) + } + + @Test + fun insertAtFixedPosition() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append("a".repeat(339)) + t.insertAt(29, "E".repeat(46)) + t.insertAt(29, "D".repeat(46)) + t.insertAt(29, "C".repeat(46)) + t.insertAt(29, "B".repeat(46)) + t.printDebug() + val len = 339 + 46 * 4 + assertEquals(len / 64 + 1, t.buffers.size) + assertEquals(len, t.length) + assertEquals(len, t.fullString().length) + } + + @Test + fun multipleRandomInserts() { + val t = BigTextVerifyImpl(chunkSize = 64) + var totalLength = 0 + repeat(2000) { + println("it #$it") + val length = when (Random.nextInt(0, 6)) { + 0 -> 0 + 1 -> Random.nextInt(1, 20) + 2 -> Random.nextInt(20, 100) + 3 -> Random.nextInt(100, 400) + 4 -> Random.nextInt(400, 4000) + 5 -> Random.nextInt(4000, 100000) + else -> throw IllegalStateException() + } + val newString = if (length > 0) { + val startChar: Char = if (it % 2 == 0) 'A' else 'a' + (0 until length - 1).asSequence().map { (startChar + it % 26).toString() }.joinToString("") + "|" + } else { + "" + } + when (Random.nextInt(0, 6)) { + 0 -> t.append(newString) + 1 -> t.insertAt(t.length, newString) + 2 -> t.insertAt(0, newString) + in 3..5 -> t.insertAt(if (t.length > 0) Random.nextInt(0, t.length) else 0, newString) + else -> throw IllegalStateException() + } + totalLength += length + assertEquals(totalLength, t.length) + } + } +} From e35b4cdc1031e3a22910bf09690a1b4ca8845fb3 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Tue, 13 Aug 2024 23:35:14 +0800 Subject: [PATCH 026/195] add deletion to BigTextImpl and related tests --- .../balancedtree/RedBlackTree.java | 6 +- .../hellohttp/ux/bigtext/BigText.kt | 2 + .../hellohttp/ux/bigtext/BigTextImpl.kt | 84 ++++++- .../hellohttp/ux/bigtext/BigTextVerifyImpl.kt | 15 +- .../hellohttp/ux/bigtext/RedBlackTree2.kt | 229 +++++++++++++++++- .../hellohttp/test/bigtext/BigTextImplTest.kt | 57 +++++ 6 files changed, 380 insertions(+), 13 deletions(-) diff --git a/src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java b/src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java index 428835c7..1deedafa 100644 --- a/src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java +++ b/src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java @@ -278,7 +278,7 @@ public boolean delete(T key) { return true; } - private void deleteFix(Node x) { + protected void deleteFix(Node x) { while (x != root && x.getColor() == BLACK) { if (x == x.getParent().getLeft()) { Node w = x.getParent().getRight(); @@ -335,12 +335,12 @@ private void deleteFix(Node x) { x.setColor(BLACK); } - private Node successor(Node root) { + protected Node successor(Node root) { if (root == NIL || root.left == NIL) return root; else return successor(root.left); } - private void transplant(Node u, Node v) { + protected void transplant(Node u, Node v) { if (u.parent == NIL) { root = v; } else if (u == u.parent.left) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt index dcab031b..1c041c1c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -19,6 +19,8 @@ interface BigText { fun delete(start: Int, endExclusive: Int) + fun delete(range: IntRange) = delete(range.start, range.endInclusive + 1) + override fun hashCode(): Int override fun equals(other: Any?): Boolean diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index a57c3549..d5b7a5c9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -13,12 +13,15 @@ val log = Logger(object : MutableLoggerConfig { override var minSeverity: Severity = Severity.Info }, tag = "BigText") +var isD = false + class BigTextImpl : BigText { val tree = RedBlackTree2( object : RedBlackTreeComputations { override fun recomputeFromLeaf(it: RedBlackTree.Node) = recomputeAggregatedValues(it) override fun computeWhenLeftRotate(x: BigTextNodeValue, y: BigTextNodeValue) = computeWhenLeftRotate0(x, y) override fun computeWhenRightRotate(x: BigTextNodeValue, y: BigTextNodeValue) = computeWhenRightRotate0(x, y) +// override fun transferComputeResultTo(from: BigTextNodeValue, to: BigTextNodeValue) = transferComputeResultTo0(from, to) } ) val buffers = mutableListOf() @@ -106,7 +109,7 @@ class BigTextImpl : BigText { if (splitAtIndex > 0) { node.value.bufferOffsetEndExclusive = node.value.bufferOffsetStart + splitAtIndex } else { - tree.delete(node.value) + tree.delete(node) node = tree.findNodeByCharIndex(maxOf(0, position - 1)) insertDirection = InsertDirection.Left } @@ -159,11 +162,11 @@ class BigTextImpl : BigText { } } - log.d { inspect("Finish " + node?.value?.debugKey()) } + log.d { inspect("Finish I " + node?.value?.debugKey()) } } fun recomputeAggregatedValues(node: RedBlackTree.Node) { - log.v { inspect("${node.value?.debugKey()} start") } + log.d { inspect("${node.value?.debugKey()} start") } var node = node while (node.isNotNil()) { @@ -190,6 +193,11 @@ class BigTextImpl : BigText { // TODO calc other metrics } +// fun transferComputeResultTo0(from: BigTextNodeValue, to: BigTextNodeValue) { +// to.leftStringLength = from.leftStringLength +// // TODO calc other metrics +// } + override val length: Int get() = tree.root.length() @@ -244,7 +252,75 @@ class BigTextImpl : BigText { } override fun delete(start: Int, endExclusive: Int) { - TODO("Not yet implemented") + require(start <= endExclusive) { "start should be <= endExclusive" } + require(0 <= start) { "Invalid start" } + require(endExclusive <= length) { "endExclusive is out of bound" } + + if (start == endExclusive) { + return + } + + log.d { "delete $start ..< $endExclusive" } + + var node: RedBlackTree.Node? = tree.findNodeByCharIndex(endExclusive - 1) + var nodeRange = charIndexRangeOfNode(node!!) + val newNodesInDescendingOrder = mutableListOf() + while (node?.isNotNil() == true && start <= nodeRange.endInclusive) { + if (endExclusive - 1 in nodeRange.start..nodeRange.last - 1) { + // need to split + val splitAtIndex = endExclusive - nodeRange.start + log.d { "Split E at $splitAtIndex" } + newNodesInDescendingOrder += BigTextNodeValue().apply { // the second part of the existing string + bufferIndex = node!!.value.bufferIndex + bufferOffsetStart = node!!.value.bufferOffsetStart + splitAtIndex + bufferOffsetEndExclusive = node!!.value.bufferOffsetEndExclusive + + leftStringLength = 0 + } + } + if (start in nodeRange.start + 1 .. nodeRange.last) { + // need to split + val splitAtIndex = start - nodeRange.start + log.d { "Split S at $splitAtIndex" } + newNodesInDescendingOrder += BigTextNodeValue().apply { // the first part of the existing string + bufferIndex = node!!.value.bufferIndex + bufferOffsetStart = node!!.value.bufferOffsetStart + bufferOffsetEndExclusive = node!!.value.bufferOffsetStart + splitAtIndex + + leftStringLength = 0 + } + } + val prev = tree.prevNode(node) + log.d { "Delete node ${node!!.value.debugKey()} at ${nodeRange.start} .. ${nodeRange.last}" } + if (isD && nodeRange.start == 384) { + isD = true + } + tree.delete(node) + log.d { inspect("After delete " + node?.value?.debugKey()) } +// if (!tree.delete(node.value)) { +// throw IllegalStateException("Cannot delete node ${node.value.debugKey()} at ${nodeRange.start} .. ${nodeRange.last}") +// } + node = prev +// nodeRange = nodeRange.start - chunkSize .. nodeRange.last - chunkSize + if (node != null) { + nodeRange = charIndexRangeOfNode(node) // TODO optimize by calculation instead of querying + } + } + + newNodesInDescendingOrder.asReversed().forEach { + if (node != null) { + node = tree.insertRight(node!!, it) + } else { + node = tree.insertValue(it) + } + } + + log.v { inspect("Finish D " + node?.value?.debugKey()) } + } + + fun charIndexRangeOfNode(node: RedBlackTree.Node): IntRange { + val start = findPositionStart(node) + return start until start + node.value.bufferLength } override fun hashCode(): Int { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt index dc00050b..d8a0be27 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt @@ -44,8 +44,11 @@ internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : Big } override fun delete(start: Int, endExclusive: Int) { - bigTextImpl.delete(start, endExclusive) - stringImpl.delete(start, endExclusive) + log.d { "delete $start ..< $endExclusive" } + printDebugIfError { + bigTextImpl.delete(start, endExclusive) + stringImpl.delete(start, endExclusive) + } verify() } @@ -64,9 +67,15 @@ internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : Big } fun verify(label: String = "") { - try { + printDebugIfError(label) { length fullString() + } + } + + fun printDebugIfError(label: String = "", operation: () -> Unit) { + try { + operation() } catch (e: Throwable) { printDebug(label) throw e diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt index 250c4a0a..381b8e8b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt @@ -11,6 +11,7 @@ interface RedBlackTreeComputations> { fun recomputeFromLeaf(it: RedBlackTree.Node) fun computeWhenLeftRotate(x: T, y: T) fun computeWhenRightRotate(x: T, y: T) +// fun transferComputeResultTo(from: T, to: T) } open class RedBlackTree2(private val computations: RedBlackTreeComputations) : RedBlackTree() where T : Comparable, T : DebuggableNode { @@ -137,6 +138,7 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations Unit) { // var node = node // while (node.isNotNil()) { @@ -202,7 +425,7 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations${visit(it)}") } node.right.takeIf { it.isNotNil() }?.also { appendLine("$prepend$key--R-->${visit(it)}") } -// node.parent.takeIf { it.isNotNil() }?.also { appendLine("$prepend$key--P-->${node.parent.value.debugKey()}") } + node.parent.takeIf { it.isNotNil() }?.also { appendLine("$prepend$key--P-->${node.parent.value.debugKey()}") } return key } visit(root) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt index 02adcb32..f057c2c7 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt @@ -1,6 +1,8 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext +import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextVerifyImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.isD import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals @@ -217,6 +219,19 @@ class BigTextImplTest { assertEquals(len, t.fullString().length) } + @Test + fun insertAtVariousPositions() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append((0..653).map { 'a' + (it % 26) }.joinToString("")) + t.insertAt(80, (0..79).map { 'A' + (it % 26) }.joinToString("")) + t.insertAt(0, (0..25).map { '0' + (it % 10) }.joinToString("")) + t.printDebug() + val len = 654 + 80 + 26 + assertEquals(len / 64 + 1, t.buffers.size) + assertEquals(len, t.length) + assertEquals(len, t.fullString().length) + } + @Test fun multipleRandomInserts() { val t = BigTextVerifyImpl(chunkSize = 64) @@ -249,4 +264,46 @@ class BigTextImplTest { assertEquals(totalLength, t.length) } } + + @Test + fun deleteWithinChunk() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append((0 until 64 * 3).map { 'a' + (it % 26) }.joinToString("")) + t.delete(64 * 2 + 10, 64 * 2 + 30) + t.delete(64 * 1 + 10, 64 * 1 + 30) + t.delete(64 * 0 + 10, 64 * 0 + 30) + val len = 64 * 3 - 20 * 3 + assertEquals(len, t.length) + assertEquals(len, t.fullString().length) + } + + @Test + fun deleteAmongChunks() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append((0 until 64 * 10).map { 'a' + (it % 26) }.joinToString("")) + val d1range = 64 * 4 + 10 until 64 * 7 + 30 + isD = true + t.delete(d1range) + val d2range = 64 * 8 + 10 - d1range.length until 64 * 9 + 47 - d1range.length + t.delete(d2range) + val d3range = 64 * 1 + 10 until 64 * 3 + 30 + t.delete(d3range) + val len = 64 * 10 - d1range.length - d2range.length - d3range.length + assertEquals(len, t.length) + assertEquals(len, t.fullString().length) + } + + @Test + fun deleteAtBeginning() { + val t = BigTextVerifyImpl(chunkSize = 64) + t.append((0 until 654).map { 'a' + (it % 26) }.joinToString("")) + t.delete(0 .. 19) + t.delete(0 .. 19) + t.delete(0 .. 19) + t.delete(0 .. 19) + t.delete(0 .. 29) + val len = 654 - 20 * 4 - 30 + assertEquals(len, t.length) + assertEquals(len, t.fullString().length) + } } From be0c5fdddaf2cef5e497ea12c2bfc9857eb59080 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 14 Aug 2024 20:56:50 +0800 Subject: [PATCH 027/195] fix BigTextImpl deletions --- .../balancedtree/RedBlackTree.java | 2 +- .../hellohttp/ux/bigtext/BigText.kt | 8 +- .../hellohttp/ux/bigtext/BigTextImpl.kt | 38 ++-- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 4 +- .../hellohttp/ux/bigtext/BigTextVerifyImpl.kt | 20 +- .../ux/bigtext/InefficientBigText.kt | 9 +- .../hellohttp/ux/bigtext/RedBlackTree2.kt | 47 +++- .../hellohttp/test/bigtext/BigTextImplTest.kt | 203 +++++++++++++++++- 8 files changed, 279 insertions(+), 52 deletions(-) diff --git a/src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java b/src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java index 1deedafa..f02a9dd5 100644 --- a/src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java +++ b/src/jvmMain/java/com/williamfiset/algorithms/datastructures/balancedtree/RedBlackTree.java @@ -96,7 +96,7 @@ public boolean isNil() { } // The root node of the RB tree. - public Node root; + protected Node root; // Tracks the number of nodes inside the tree. protected int nodeCount = 0; diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt index 1c041c1c..fb29e699 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -13,13 +13,13 @@ interface BigText { fun substring(range: IntRange): String = substring(range.start, range.endInclusive + 1) - fun append(text: String) + fun append(text: String): Int - fun insertAt(pos: Int, text: String) + fun insertAt(pos: Int, text: String): Int - fun delete(start: Int, endExclusive: Int) + fun delete(start: Int, endExclusive: Int): Int - fun delete(range: IntRange) = delete(range.start, range.endInclusive + 1) + fun delete(range: IntRange): Int = delete(range.start, range.endInclusive + 1) override fun hashCode(): Int diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index d5b7a5c9..4575dd3b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -13,7 +13,7 @@ val log = Logger(object : MutableLoggerConfig { override var minSeverity: Severity = Severity.Info }, tag = "BigText") -var isD = false +internal var isD = false class BigTextImpl : BigText { val tree = RedBlackTree2( @@ -32,7 +32,7 @@ class BigTextImpl : BigText { chunkSize = 64 } - internal constructor(chunkSize: Int) { + constructor(chunkSize: Int) { this.chunkSize = chunkSize } @@ -65,10 +65,6 @@ class BigTextImpl : BigText { return start } - private fun appendChunk(chunkedString: String) { - insertChunkAtPosition(tree.root.length(), chunkedString) - } - private fun insertChunkAtPosition(position: Int, chunkedString: String) { log.d { "insertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) @@ -79,7 +75,7 @@ class BigTextImpl : BigText { buffers.last().takeIf { it.length + chunkedString.length <= chunkSize } } else null if (buffer == null) { - buffer = TextBuffer() + buffer = TextBuffer(chunkSize) buffers += buffer } require(buffer.length + chunkedString.length <= chunkSize) @@ -166,7 +162,7 @@ class BigTextImpl : BigText { } fun recomputeAggregatedValues(node: RedBlackTree.Node) { - log.d { inspect("${node.value?.debugKey()} start") } + log.v { inspect("${node.value?.debugKey()} start") } var node = node while (node.isNotNil()) { @@ -199,7 +195,7 @@ class BigTextImpl : BigText { // } override val length: Int - get() = tree.root.length() + get() = tree.getRoot().length() override fun fullString(): String { return tree.joinToString("") { @@ -211,8 +207,8 @@ class BigTextImpl : BigText { TODO("Not yet implemented") } - override fun append(text: String) { - insertAt(length, text) + override fun append(text: String): Int { + return insertAt(length, text) // var start = 0 // while (start < text.length) { // var last = buffers.lastOrNull()?.length @@ -227,7 +223,7 @@ class BigTextImpl : BigText { // } } - override fun insertAt(pos: Int, text: String) { + override fun insertAt(pos: Int, text: String): Int { var start = 0 val prevNode = tree.findNodeByCharIndex(maxOf(0, pos - 1)) val nodeStart = prevNode?.let { findPositionStart(it) }?.also { @@ -249,15 +245,16 @@ class BigTextImpl : BigText { start += append last = buffers.last().length } + return text.length } - override fun delete(start: Int, endExclusive: Int) { + override fun delete(start: Int, endExclusive: Int): Int { require(start <= endExclusive) { "start should be <= endExclusive" } require(0 <= start) { "Invalid start" } require(endExclusive <= length) { "endExclusive is out of bound" } if (start == endExclusive) { - return + return 0 } log.d { "delete $start ..< $endExclusive" } @@ -266,6 +263,9 @@ class BigTextImpl : BigText { var nodeRange = charIndexRangeOfNode(node!!) val newNodesInDescendingOrder = mutableListOf() while (node?.isNotNil() == true && start <= nodeRange.endInclusive) { + if (isD && nodeRange.start == 0) { + isD = true + } if (endExclusive - 1 in nodeRange.start..nodeRange.last - 1) { // need to split val splitAtIndex = endExclusive - nodeRange.start @@ -297,9 +297,6 @@ class BigTextImpl : BigText { } tree.delete(node) log.d { inspect("After delete " + node?.value?.debugKey()) } -// if (!tree.delete(node.value)) { -// throw IllegalStateException("Cannot delete node ${node.value.debugKey()} at ${nodeRange.start} .. ${nodeRange.last}") -// } node = prev // nodeRange = nodeRange.start - chunkSize .. nodeRange.last - chunkSize if (node != null) { @@ -310,12 +307,17 @@ class BigTextImpl : BigText { newNodesInDescendingOrder.asReversed().forEach { if (node != null) { node = tree.insertRight(node!!, it) + } else if (!tree.isEmpty) { // no previous node, so insert at leftmost of the tree + val leftmost = tree.leftmost(tree.getRoot()) + node = tree.insertLeft(leftmost, it) } else { node = tree.insertValue(it) } } - log.v { inspect("Finish D " + node?.value?.debugKey()) } + log.d { inspect("Finish D " + node?.value?.debugKey()) } + + return -(endExclusive - start) } fun charIndexRangeOfNode(node: RedBlackTree.Node): IntRange { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 32696c60..01772ed0 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -29,8 +29,8 @@ class BigTextNodeValue : Comparable, DebuggableNode = emptyList() // var rowOffsetStarts: List = emptyList() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt index d8a0be27..7cd42604 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt @@ -29,27 +29,31 @@ internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : Big return r } - override fun append(text: String) { + override fun append(text: String): Int { println("append ${text.length}") - bigTextImpl.append(text) + val r = bigTextImpl.append(text) stringImpl.append(text) verify() + return r } - override fun insertAt(pos: Int, text: String) { + override fun insertAt(pos: Int, text: String): Int { println("insert $pos, ${text.length}") - bigTextImpl.insertAt(pos, text) + val r = bigTextImpl.insertAt(pos, text) stringImpl.insertAt(pos, text) verify() + return r } - override fun delete(start: Int, endExclusive: Int) { - log.d { "delete $start ..< $endExclusive" } + override fun delete(start: Int, endExclusive: Int): Int { + println("delete $start ..< $endExclusive") + var r: Int = 0 printDebugIfError { - bigTextImpl.delete(start, endExclusive) + r = bigTextImpl.delete(start, endExclusive) stringImpl.delete(start, endExclusive) } verify() + return r } override fun hashCode(): Int { @@ -73,7 +77,7 @@ internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : Big } } - fun printDebugIfError(label: String = "", operation: () -> Unit) { + fun printDebugIfError(label: String = "ERROR", operation: () -> Unit) { try { operation() } catch (e: Throwable) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt index 80b82c8f..18420408 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt @@ -16,16 +16,19 @@ class InefficientBigText(text: String) : BigText { override fun substring(range: IntRange): String = substring(range.first, range.last) - override fun append(text: String) { + override fun append(text: String): Int { string += text + return text.length } - override fun insertAt(pos: Int, text: String) { + override fun insertAt(pos: Int, text: String): Int { string = string.insert(pos, text) + return text.length } - override fun delete(start: Int, endExclusive: Int) { + override fun delete(start: Int, endExclusive: Int): Int { string = string.removeRange(start, endExclusive) + return -(endExclusive - start) } override fun hashCode(): Int = diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt index 381b8e8b..af514855 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt @@ -16,6 +16,8 @@ interface RedBlackTreeComputations> { open class RedBlackTree2(private val computations: RedBlackTreeComputations) : RedBlackTree() where T : Comparable, T : DebuggableNode { + fun getRoot() = root + fun lastNodeOrNull(): Node? { var child: Node = root while (child.getLeft().isNotNil() || child.getRight().isNotNil()) { @@ -165,13 +167,24 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations> RedBlackTree.Node.isNotNil(): Boolean = !isNil() diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt index f057c2c7..c5b3f5f1 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt @@ -1,14 +1,34 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext import com.sunnychung.application.multiplatform.hellohttp.extension.length +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextVerifyImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.isD +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals +/** + * Some cases in this test may look very specific, but they had consistently failed before. + */ class BigTextImplTest { + companion object { + @JvmStatic + fun argumentsOfMultipleRandomDeletes(): Array + = arrayOf(64, 1024, 16 * 1024, 64 * 1024, 128 * 1024, 512 * 1024) // chunk size + .flatMap { chunkSize -> + arrayOf(64, 100, 200, 257, 1024, 64 * 1024, 1024 * 1024, 5 * 1024 * 1024, 12 * 1024 * 1024 + 3) // initial length + .map { initialLength -> + intArrayOf(chunkSize, initialLength) + } + } + .toTypedArray() + } + @Test fun appendMultipleShort() { val t = BigTextVerifyImpl() @@ -232,19 +252,32 @@ class BigTextImplTest { assertEquals(len, t.fullString().length) } - @Test - fun multipleRandomInserts() { - val t = BigTextVerifyImpl(chunkSize = 64) + /** + * Benchmark: + * + * Chunk Size | Completion Time (s) + * 64B | 13.3 + * 1KB | 6.9 + * 16KB | 9.2 + * 64KB | 12.9 + * 128KB | 16.0 + * 512KB | 49.5 + * 2MB | 194 + */ + @ParameterizedTest + @ValueSource(ints = [64, 1024, 16 * 1024, 64 * 1024, 128 * 1024, 512 * 1024]) + fun multipleRandomInserts(chunkSize: Int) { + val t = BigTextVerifyImpl(chunkSize = chunkSize) var totalLength = 0 repeat(2000) { println("it #$it") - val length = when (Random.nextInt(0, 6)) { + val length = when (random(0, 6)) { 0 -> 0 - 1 -> Random.nextInt(1, 20) - 2 -> Random.nextInt(20, 100) - 3 -> Random.nextInt(100, 400) - 4 -> Random.nextInt(400, 4000) - 5 -> Random.nextInt(4000, 100000) + 1 -> random(1, 20) + 2 -> random(20, 100) + 3 -> random(100, 400) + 4 -> random(400, 4000) + 5 -> random(4000, 100000) else -> throw IllegalStateException() } val newString = if (length > 0) { @@ -253,11 +286,11 @@ class BigTextImplTest { } else { "" } - when (Random.nextInt(0, 6)) { + when (random(0, 6)) { 0 -> t.append(newString) 1 -> t.insertAt(t.length, newString) 2 -> t.insertAt(0, newString) - in 3..5 -> t.insertAt(if (t.length > 0) Random.nextInt(0, t.length) else 0, newString) + in 3..5 -> t.insertAt(if (t.length > 0) random(0, t.length) else 0, newString) else -> throw IllegalStateException() } totalLength += length @@ -306,4 +339,152 @@ class BigTextImplTest { assertEquals(len, t.length) assertEquals(len, t.fullString().length) } + + @ParameterizedTest + @ValueSource(ints = [60, 64, 67, 128, 192, 640, 6483, 64_000_000, 64_000_001, 64 * 1024 * 1024]) + fun deleteWholeThing(length: Int) { +// val t = BigTextVerifyImpl(chunkSize = 64) + val t = BigTextImpl(chunkSize = 64) + t.append("X".repeat(length)) + assertEquals(length, t.length) + if (length == 640) isD = true + t.delete(0 until t.length) + assertEquals(0, t.length) + assertEquals(0, t.fullString().length) + assertEquals(0, t.tree.size()) + } + + @Test + fun deleteWithinLongerChunk() { + val t = BigTextVerifyImpl(chunkSize = 1024) + t.append((0 until 1024).map { 'a' + (it % 26) }.joinToString("")) + t.delete(300 .. 319) + t.delete(300 .. 800) + isD = true + t.delete(0 .. 279) + t.delete(13 .. 69) + val len = 1024 - 20 - 501 - 280 - 57 + assertEquals(len, t.length) + assertEquals(len, t.fullString().length) + } + + @Test + fun deleteSomeLeftChunks() { + val t = BigTextVerifyImpl(chunkSize = 64) +// t.append((0 until 16384).map { 'a' + (it % 26) }.joinToString("")) +// t.delete(3443 .. 4568) + t.append((0 until 1024).map { 'a' + (it % 26) }.joinToString("")) + t.delete(343 .. 456) + val len = 1024 - (457 - 343) + assertEquals(len, t.length) + assertEquals(len, t.fullString().length) + } + + @ParameterizedTest + @ValueSource(ints = [256, 12 * 1024, 6 * 1024 * 1024]) + fun deleteRepeatedlyAtBeginning(initialLength: Int) { + val t = BigTextVerifyImpl(chunkSize = 64) + var len = initialLength +// t.append((0 until len).map { 'a' + (it % 26) }.joinToString("")) + t.append((0 until len).map { 'a' + Random.nextInt(26) }.joinToString("")) + repeat(1200) { + if (t.length >= 12) { + len += t.delete(0..11) + if (t.length == 76) isD = true + } + } + assertEquals(len, t.length) + assertEquals(len, t.fullString().length) + } + + @ParameterizedTest + @MethodSource("argumentsOfMultipleRandomDeletes") + fun multipleRandomDeletes(arguments: IntArray) { + val chunkSize = arguments[0] + val initialLength = arguments[1] + + val t = BigTextVerifyImpl(chunkSize = chunkSize) + t.append((0 until initialLength).map { 'a' + (it % 26) }.joinToString("")) + var totalLength = initialLength + repeat(3000) { + println("it #$it") + val length = when (random(0, 6)) { + 0 -> 0 + 1 -> random(1, 20) + 2 -> random(20, 100) + 3 -> random(100, 400) + 4 -> random(400, if (initialLength <= 1024 * 1024) 2000 else 4000) + 5 -> random( + if (initialLength <= 1024 * 1024) 2000 else 4000, + if (initialLength <= 1024 * 1024) 12000 else 60000 + ) + else -> throw IllegalStateException() + } + val textLengthChange = when (random(0, 5)) { + in 0 .. 2 -> if (t.length > 0) { + val p1 = random(0, t.length) + val p2 = p1 + minOf(length, t.length - p1) // p1 + p2 <= t.length + t.delete(minOf(p1, p2), maxOf(p1, p2)) + } else { + t.delete(0, 0) + } + 3 -> t.delete(0, random(0, minOf(length, t.length))) // delete from start + 4 -> t.delete(t.length - random(0, minOf(length, t.length)), t.length) // delete from end + else -> throw IllegalStateException() + } + totalLength += textLengthChange + assertEquals(totalLength, t.length) + } + } + + @ParameterizedTest + @ValueSource(ints = [64, 1024, 16 * 1024, 64 * 1024, 512 * 1024]) + fun multipleRandomOperations(chunkSize: Int) { + val t = BigTextVerifyImpl(chunkSize = chunkSize) + var totalLength = 0 + repeat(5000) { + println("it #$it") + val length = when (random(0, 6)) { + 0 -> 0 + 1 -> random(1, 20) + 2 -> random(20, 100) + 3 -> random(100, 400) + 4 -> random(400, 4000) + 5 -> random(4000, 100000) + else -> throw IllegalStateException() + } + val newString = if (length > 0) { + val startChar: Char = if (it % 2 == 0) 'A' else 'a' + (0 until length - 1).asSequence().map { (startChar + it % 26).toString() }.joinToString("") + "|" + } else { + "" + } + val textLengthChange = when (random(0, 15)) { + in 0 .. 1 -> t.append(newString) + 2 -> t.insertAt(t.length, newString) + 3 -> t.insertAt(0, newString) + in 4..8 -> t.insertAt(random(0, t.length), newString) + in 9..11 -> if (t.length > 0) { + val p1 = random(0, t.length) + val p2 = p1 + minOf(length, t.length - p1) // p1 + p2 <= t.length + t.delete(minOf(p1, p2), maxOf(p1, p2)) + } else { + t.delete(0, 0) + } + 12 -> t.delete(0, random(0, minOf(length, t.length))) // delete from start + 13 -> t.delete(t.length - random(0, minOf(length, t.length)), t.length) // delete from end + 14 -> t.delete(0, t.length) // delete whole string + else -> throw IllegalStateException() + } + totalLength += textLengthChange + assertEquals(totalLength, t.length) + } + } +} + +private fun random(from: Int, toExclusive: Int): Int { + if (toExclusive == from) { + return 0 + } + return Random.nextInt(from, toExclusive) } From ee9cb9300bd6517c2b57cd761ca527dc7c3b87c9 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 14 Aug 2024 23:21:30 +0800 Subject: [PATCH 028/195] add substring implementation to BigTextImpl --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 36 ++++++++++- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 4 ++ .../hellohttp/ux/bigtext/RedBlackTree2.kt | 13 ++++ .../hellohttp/test/bigtext/BigTextImplTest.kt | 61 +++++++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 4575dd3b..0b1c4add 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -199,12 +199,42 @@ class BigTextImpl : BigText { override fun fullString(): String { return tree.joinToString("") { - buffers[it.bufferIndex].toString().substring(it.bufferOffsetStart, it.bufferOffsetEndExclusive) + buffers[it.bufferIndex].subSequence(it.bufferOffsetStart, it.bufferOffsetEndExclusive) } } - override fun substring(start: Int, endExclusive: Int): String { - TODO("Not yet implemented") + override fun substring(start: Int, endExclusive: Int): String { // O(lg L + (e - s)) + require(start <= endExclusive) { "start should be <= endExclusive" } + require(0 <= start) { "Invalid start" } + require(endExclusive <= length) { "endExclusive is out of bound" } + + if (start == endExclusive) { + return "" + } + + val result = StringBuilder(endExclusive - start) + var node = tree.findNodeByCharIndex(start) ?: throw IllegalStateException("Cannot find string node for position $start") + var nodeStartPos = findPositionStart(node) + var numRemainCharsToCopy = endExclusive - start + var copyFromBufferIndex = start - nodeStartPos + node.value.bufferOffsetStart + while (numRemainCharsToCopy > 0) { + val numCharsToCopy = minOf(endExclusive, nodeStartPos + node.value.bufferLength) - maxOf(start, nodeStartPos) + val copyUntilBufferIndex = copyFromBufferIndex + numCharsToCopy + if (numCharsToCopy > 0) { + val subsequence = buffers[node.value.bufferIndex].subSequence(copyFromBufferIndex, copyUntilBufferIndex) + result.append(subsequence) + numRemainCharsToCopy -= numCharsToCopy + } else { + break + } + if (numRemainCharsToCopy > 0) { + nodeStartPos += node.value.bufferLength + node = tree.nextNode(node) ?: throw IllegalStateException("Cannot find the next string node") + copyFromBufferIndex = node.value.bufferOffsetStart + } + } + + return result.toString() } override fun append(text: String): Int { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 01772ed0..2c27e56a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -52,4 +52,8 @@ class TextBuffer(val size: Int) { override fun toString(): String { return buffer.toString() } + + fun subSequence(start: Int, endExclusive: Int): CharSequence { + return buffer.subSequence(start, endExclusive) + } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt index af514855..6bf1aba8 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt @@ -439,6 +439,19 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations Unit) { // var node = node // while (node.isNotNil()) { diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt index c5b3f5f1..57f6fd31 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt @@ -480,6 +480,67 @@ class BigTextImplTest { assertEquals(totalLength, t.length) } } + + @ParameterizedTest + @ValueSource(ints = [64, 1024, 64 * 1024]) + fun exhaustSubstring(chunkSize: Int) { + (0..1025).forEach { length -> // O(length ^ 3) * O(verify), where O(verify) = O(length) + val t = BigTextVerifyImpl(chunkSize = chunkSize) + t.append((0 until length).map { 'a' + Random.nextInt(26) }.joinToString("")) + (0 .. length - 1).forEach { i -> + (i .. length).forEach { j -> +// println("substring $i, $j") + val ss = t.substring(i, j) // the substring content is verified by BigTextVerifyImpl + assertEquals(j - i, ss.length) + } + } + } + } + + /** + * Benchmark: + * + * Chunk Size | Completion Time (s) + * 64B | 86 + * 1KB | 49.0 + * 64KB | 41.4 + * 512KB | 41.7 + * 2MB | 38.2 + */ + @ParameterizedTest + @ValueSource(ints = [64, 1024, 64 * 1024, 512 * 1024, 2 * 1024 * 1024]) + fun randomLongSubstring(chunkSize: Int) { + repeat(500) { // 500 * O(length) * 1000 + println("it #$it") + val length = when (random(0, 5)) { + 0 -> random(1026, 4097) + 1 -> random(4097, 65537) + 2 -> random(65537, 100 * 1024 + 1) + 3 -> random(100 * 1024 + 1, 1 * 1024 * 1024 + 1) + 4 -> random(1 * 1024 * 1024 + 1, 16 * 1024 * 1024) + else -> throw IllegalStateException() + } + val t = BigTextVerifyImpl(chunkSize = chunkSize) + t.append((0 until length).map { 'a' + Random.nextInt(26) }.joinToString("")) + + repeat(1000) { + val p1 = random(0, length) + val pl: Int = when (random(0, 6)) { + 0 -> t.length * 5 / 100 + 1 -> t.length * 16 / 100 + 2 -> t.length * 34 / 100 + 3 -> t.length * 63 / 100 + 4 -> t.length * 87 / 100 + 5 -> t.length + else -> throw IllegalStateException() + } + val p2 = minOf(length, p1 + random(0, maxOf(2, pl - p1))) // p1 + p2 <= t.length +// println("L = $length. ss $p1 ..< $p2") + val ss = t.substring(p1, p2) + assertEquals(p2 - p1, ss.length) + } + } + } } private fun random(from: Int, toExclusive: Int): Int { From 7b7473325e21df668efd07119ae6202fb5163869 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 14 Aug 2024 23:24:37 +0800 Subject: [PATCH 029/195] fix BigTextImplTest.multipleRandomOperations test case --- .../multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt index 57f6fd31..7a4859fc 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt @@ -438,7 +438,7 @@ class BigTextImplTest { } @ParameterizedTest - @ValueSource(ints = [64, 1024, 16 * 1024, 64 * 1024, 512 * 1024]) + @ValueSource(ints = [64, 1024, 16 * 1024, 64 * 1024, 512 * 1024, 2 * 1024 * 1024]) fun multipleRandomOperations(chunkSize: Int) { val t = BigTextVerifyImpl(chunkSize = chunkSize) var totalLength = 0 @@ -466,7 +466,7 @@ class BigTextImplTest { in 4..8 -> t.insertAt(random(0, t.length), newString) in 9..11 -> if (t.length > 0) { val p1 = random(0, t.length) - val p2 = p1 + minOf(length, t.length - p1) // p1 + p2 <= t.length + val p2 = minOf(t.length, p1 + random(0, t.length - p1)) // p1 + p2 <= t.length t.delete(minOf(p1, p2), maxOf(p1, p2)) } else { t.delete(0, 0) @@ -496,7 +496,7 @@ class BigTextImplTest { } } } - + /** * Benchmark: * From 63fccb04b50339e8f4567709f77c5376f3ab8e32 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Thu, 15 Aug 2024 23:51:28 +0800 Subject: [PATCH 030/195] add BigTextImpl benchmark test --- build.gradle.kts | 1 + .../test/bigtext/BigTextImplBenchmarkTest.kt | 267 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplBenchmarkTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index a23cd500..9fd8c09e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -152,6 +152,7 @@ tasks.getByName("jvmMainClasses") { tasks.withType { useJUnitPlatform() + jvmArgs("-Xmx6144m") testLogging { events = setOf(TestLogEvent.STARTED, TestLogEvent.FAILED, TestLogEvent.PASSED, TestLogEvent.SKIPPED) showStandardStreams = true diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplBenchmarkTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplBenchmarkTest.kt new file mode 100644 index 00000000..635e3068 --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplBenchmarkTest.kt @@ -0,0 +1,267 @@ +package com.sunnychung.application.multiplatform.hellohttp.test.bigtext + +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Logger +import co.touchlab.kermit.MutableLoggerConfig +import co.touchlab.kermit.Severity +import com.sunnychung.application.multiplatform.hellohttp.util.JvmLogger +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.lib.multiplatform.kdatetime.KInstant +import kotlin.random.Random +import kotlin.test.Test + +private val log = Logger(object : MutableLoggerConfig { + override var logWriterList: List = listOf(JvmLogger()) + override var minSeverity: Severity = Severity.Info +}, tag = "BigTextImplBenchmarkTest") + +class BigTextImplBenchmarkTest { + + private fun chunkSizes() = listOf(64, 1024, 64 * 1024, 256 * 1024, 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024) + + private fun benchmark(label: String, testOperation: (BigTextImpl, String) -> Unit) { + chunkSizes().forEach { chunkSize -> + log.i("-".repeat(29)) + val logHeader = "[$label] [chunkSize=$chunkSize]" + log.i("$logHeader Start") + val startInstant = KInstant.now() + val text = BigTextImpl(chunkSize = chunkSize) + testOperation(text, logHeader) + val endInstant = KInstant.now() + log.i("$logHeader End. ${(endInstant - startInstant).millis / 1000.0}s") + log.i("-".repeat(29)) + } + } + + private fun buildStringOfLength(length: Int): String { + return ('a' + Random.nextInt(26)).toString().repeat(length) + } + + @Test + fun appendShort() { + benchmark("appendShort") { text, logHeader -> + repeat(10_000_000) { + val length = Random.nextInt(0, 40) + text.append(buildStringOfLength(length)) + } + log.i("$logHeader Length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun appendLong() { + val longStrings = listOf(2930851, 1314698, 16_526_034).map { buildStringOfLength(it) } + benchmark("appendLong") { text, logHeader -> + repeat(200) { + text.append(longStrings[Random.nextInt(0, 3)]) + } + log.i("$logHeader Length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun appendGiant() { + val longStrings = listOf(129_730_851, 103_214_698, 156_526_034).map { buildStringOfLength(it) } + benchmark("appendGiant") { text, logHeader -> + repeat(10) { + text.append(longStrings[Random.nextInt(0, 3)]) + } + log.i("$logHeader Length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun appendMixedLengths() { + benchmark("appendMixedLengths") { text, logHeader -> + repeat(1500) { + val length = when (Random.nextInt(100)) { + in 0 .. 34 -> Random.nextInt(0, 20) + in 35 .. 64 -> Random.nextInt(20, 500) + in 65 .. 84 -> Random.nextInt(500, 6000) + in 85 .. 94 -> Random.nextInt(6000, 120_000) + in 95 .. 98 -> Random.nextInt(120_000, 1_600_000) + in 99 .. 99 -> Random.nextInt(1_600_000, 150_000_000) + else -> throw IllegalStateException() + } + text.append(buildStringOfLength(length)) + } + log.i("$logHeader Length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun insertShort() { + benchmark("insertShort") { text, logHeader -> + repeat(10_000_000) { + val length = Random.nextInt(0, 40) + text.insertAt(random(0, text.length), buildStringOfLength(length)) + } + log.i("$logHeader Length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun insertLong() { + val longStrings = listOf(2930851, 1314698, 16_526_034).map { buildStringOfLength(it) } + benchmark("insertLong") { text, logHeader -> + repeat(200) { + text.insertAt(random(0, text.length), longStrings[Random.nextInt(0, 3)]) + } + log.i("$logHeader Length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun insertGiant() { + val longStrings = listOf(129_730_851, 103_214_698, 156_526_034).map { buildStringOfLength(it) } + benchmark("insertGiant") { text, logHeader -> + repeat(10) { + text.insertAt(random(0, text.length), longStrings[Random.nextInt(0, 3)]) + } + log.i("$logHeader Length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun insertMixedLengths() { + benchmark("insertMixedLengths") { text, logHeader -> + repeat(1500) { + val length = when (Random.nextInt(100)) { + in 0 .. 34 -> Random.nextInt(0, 20) + in 35 .. 64 -> Random.nextInt(20, 500) + in 65 .. 84 -> Random.nextInt(500, 6000) + in 85 .. 94 -> Random.nextInt(6000, 120_000) + in 95 .. 98 -> Random.nextInt(120_000, 1_600_000) + in 99 .. 99 -> Random.nextInt(1_600_000, 150_000_000) + else -> throw IllegalStateException() + } + text.insertAt(random(0, text.length), buildStringOfLength(length)) + } + log.i("$logHeader Length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun deleteShort() { + benchmark("deleteShort") { text, logHeader -> + text.append("a".repeat(1_234_567_890)) + repeat(10_000_000) { + val length = Random.nextInt(0, 40) + val pos = random(0, text.length) + text.delete(pos, minOf(text.length, pos + length)) + } + log.i("$logHeader Remain length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun deleteLong() { + benchmark("deleteLong") { text, logHeader -> + text.append("a".repeat(1_006_984_321)) + text.append("b".repeat(378_984_320)) + repeat(200) { + val length = Random.nextInt(1314698, 6526034) + val pos = random(0, text.length) + text.delete(pos, minOf(text.length, pos + length)) + } + log.i("$logHeader Remain length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun deleteGiant() { + benchmark("deleteGiant") { text, logHeader -> + text.append("a".repeat(1_006_984_324)) + text.append("b".repeat(378_984_320)) + repeat(10) { + val length = Random.nextInt(103_214_698, 156_526_034) + val pos = random(0, text.length) + text.delete(pos, minOf(text.length, pos + length)) + } + log.i("$logHeader Remain length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun deleteMixedLengths() { + benchmark("deleteMixedLengths") { text, logHeader -> + text.append("a".repeat(1_006_984_320)) + text.append("b".repeat(378_984_320)) + repeat(1500) { + val length = when (Random.nextInt(100)) { + in 0 .. 34 -> Random.nextInt(0, 20) + in 35 .. 64 -> Random.nextInt(20, 500) + in 65 .. 84 -> Random.nextInt(500, 6000) + in 85 .. 94 -> Random.nextInt(6000, 120_000) + in 95 .. 98 -> Random.nextInt(120_000, 1_600_000) + in 99 .. 99 -> Random.nextInt(1_600_000, 150_000_000) + else -> throw IllegalStateException() + } + val pos = random(0, text.length) + text.delete(pos, minOf(text.length, pos + length)) + } + log.i("$logHeader Remain length = ${text.length}") + log.i("$logHeader Node Count = ${text.tree.size()}") + } + } + + @Test + fun randomMutateOperations() { + benchmark("randomMutateOperations") { t, logHeader -> + repeat(5000) { + val length = when (Random.nextInt(100)) { + in 0 .. 34 -> Random.nextInt(0, 20) + in 35 .. 64 -> Random.nextInt(20, 500) + in 65 .. 84 -> Random.nextInt(500, 6000) + in 85 .. 94 -> Random.nextInt(6000, 120_000) + in 95 .. 98 -> Random.nextInt(120_000, 1_600_000) + in 99 .. 99 -> Random.nextInt(1_600_000, 150_000_000) + else -> throw IllegalStateException() + } + val newString = if (length > 0) { + val startChar: Char = if (it % 2 == 0) 'A' else 'a' + (0 until length - 1).asSequence().map { (startChar + it % 26).toString() }.joinToString("") + "|" + } else { + "" + } + when (random(0, 15)) { + in 0..1 -> t.append(newString) + 2 -> t.insertAt(t.length, newString) + 3 -> t.insertAt(0, newString) + in 4..8 -> t.insertAt(random(0, t.length), newString) + in 9..11 -> if (t.length > 0) { + val p1 = random(0, t.length) + val p2 = minOf(t.length, p1 + random(0, t.length - p1)) // p1 + p2 <= t.length + t.delete(minOf(p1, p2), maxOf(p1, p2)) + } else { + t.delete(0, 0) + } + 12 -> t.delete(0, random(0, minOf(length, t.length))) // delete from start + 13 -> t.delete(t.length - random(0, minOf(length, t.length)), t.length) // delete from end + 14 -> t.delete(0, t.length) // delete whole string + else -> throw IllegalStateException() + } + } + log.i("$logHeader Remain length = ${t.length}") + log.i("$logHeader Node Count = ${t.tree.size()}") + } + } +} + +private fun random(from: Int, toExclusive: Int): Int { + if (toExclusive == from) { + return 0 + } + return Random.nextInt(from, toExclusive) +} From 9310f9f83e986e7f991d145831399f4fd34c2a0c Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 16 Aug 2024 22:44:51 +0800 Subject: [PATCH 031/195] add `BigTextImpl#length` benchmark --- .../test/bigtext/BigTextImplBenchmarkTest.kt | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplBenchmarkTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplBenchmarkTest.kt index 635e3068..02b6d8b1 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplBenchmarkTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplBenchmarkTest.kt @@ -6,6 +6,7 @@ import co.touchlab.kermit.MutableLoggerConfig import co.touchlab.kermit.Severity import com.sunnychung.application.multiplatform.hellohttp.util.JvmLogger import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.lib.multiplatform.kdatetime.KDuration import com.sunnychung.lib.multiplatform.kdatetime.KInstant import kotlin.random.Random import kotlin.test.Test @@ -19,16 +20,23 @@ class BigTextImplBenchmarkTest { private fun chunkSizes() = listOf(64, 1024, 64 * 1024, 256 * 1024, 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024) + private fun measureOne(operation: () -> Unit): KDuration { + val startInstant = KInstant.now() + operation() + val endInstant = KInstant.now() + return endInstant - startInstant + } + private fun benchmark(label: String, testOperation: (BigTextImpl, String) -> Unit) { chunkSizes().forEach { chunkSize -> log.i("-".repeat(29)) val logHeader = "[$label] [chunkSize=$chunkSize]" log.i("$logHeader Start") - val startInstant = KInstant.now() - val text = BigTextImpl(chunkSize = chunkSize) - testOperation(text, logHeader) - val endInstant = KInstant.now() - log.i("$logHeader End. ${(endInstant - startInstant).millis / 1000.0}s") + val testDuration = measureOne { + val text = BigTextImpl(chunkSize = chunkSize) + testOperation(text, logHeader) + } + log.i("$logHeader End. ${testDuration.millis / 1000.0}s") log.i("-".repeat(29)) } } @@ -257,6 +265,26 @@ class BigTextImplBenchmarkTest { log.i("$logHeader Node Count = ${t.tree.size()}") } } + + @Test + fun length() { + benchmark("length") { t, logHeader -> + val durations = mutableListOf() + repeat(5) { + val text = BigTextImpl(t.chunkSize) + text.append("a".repeat(random(1_006_984_321, 1_200_000_000))) + var l = 0 + log.i("$logHeader Start evaluating length") + durations += measureOne { + repeat(10_000_000) { + l = text.length + } + } + log.i("$logHeader Length = $l") + } + log.i("$logHeader Average: ${durations.map { it.millis }.average() / 1000.0}s") + } + } } private fun random(from: Int, toExclusive: Int): Int { From 5e955d20da9a45fcd64ccc064bae1ca9a96a7126 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 18 Aug 2024 00:26:31 +0800 Subject: [PATCH 032/195] add BigTextImpl#findLineString(lineIndex: Int) --- .../multiplatform/hellohttp/util/Strings.kt | 10 ++ .../hellohttp/ux/bigtext/BigTextImpl.kt | 91 ++++++++++++++++- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 24 ++++- .../hellohttp/ux/bigtext/RedBlackTree2.kt | 45 +++++++++ .../test/bigtext/BigTextImplQueryTest.kt | 97 +++++++++++++++++++ 5 files changed, 261 insertions(+), 6 deletions(-) create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Strings.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Strings.kt index 9eca231f..77dba607 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Strings.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Strings.kt @@ -3,3 +3,13 @@ package com.sunnychung.application.multiplatform.hellohttp.util fun String?.emptyToNull(): String? { return if (this == "") null else this } + +fun String.findAllIndicesOfChar(char: Char): List { + val result = mutableListOf() + for (i in this.indices) { + if (char == this[i]) { + result.add(i) + } + } + return result +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 0b1c4add..4cde41b4 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.LogWriter import co.touchlab.kermit.Logger import co.touchlab.kermit.MutableLoggerConfig import co.touchlab.kermit.Severity +import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.util.JvmLogger import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree @@ -13,6 +14,11 @@ val log = Logger(object : MutableLoggerConfig { override var minSeverity: Severity = Severity.Info }, tag = "BigText") +val logQ = Logger(object : MutableLoggerConfig { + override var logWriterList: List = listOf(JvmLogger()) + override var minSeverity: Severity = Severity.Info +}, tag = "BigTextQuery") + internal var isD = false class BigTextImpl : BigText { @@ -53,6 +59,27 @@ class BigTextImpl : BigText { } } + fun RedBlackTree2.findNodeByLineBreaks(index: Int): Pair.Node, Int>? { + var find = index + var lineStart = 0 + return findNode { + index + when (find) { + in Int.MIN_VALUE until it.value.leftNumOfLineBreaks -> if (it.left.isNotNil()) -1 else 0 +// it.value.leftNumOfLineBreaks -> if (it.left.isNotNil()) -1 else 0 + in it.value.leftNumOfLineBreaks until it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange -> 0 + in it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange until Int.MAX_VALUE -> 1.also { compareResult -> + val isTurnRight = compareResult > 0 + if (isTurnRight) { + find -= it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange + lineStart += it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange + } + } + else -> throw IllegalStateException("what is find? $find") + } + }?.let { it to /*lineStart *//*+ it.value.leftNumOfLineBreaks*/ findLineStart(it) } + } + fun findPositionStart(node: RedBlackTree.Node): Int { var start = node.value.leftStringLength var node = node @@ -65,6 +92,18 @@ class BigTextImpl : BigText { return start } + fun findLineStart(node: RedBlackTree.Node): Int { + var start = node.value.leftNumOfLineBreaks + var node = node + while (node.parent.isNotNil()) { + if (node === node.parent.right) { + start += node.parent.value.leftNumOfLineBreaks + node.parent.value.bufferNumLineBreaksInRange + } + node = node.parent + } + return start + } + private fun insertChunkAtPosition(position: Int, chunkedString: String) { log.d { "insertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) @@ -158,7 +197,15 @@ class BigTextImpl : BigText { } } - log.d { inspect("Finish I " + node?.value?.debugKey()) } + log.v { inspect("Finish I " + node?.value?.debugKey()) } + } + + fun computeCurrentNodeProperties(nodeValue: BigTextNodeValue) = with (nodeValue) { +// bufferNumLineBreaksInRange = buffers[bufferIndex].lineOffsetStarts.subSet(bufferOffsetStart, bufferOffsetEndExclusive).size + bufferNumLineBreaksInRange = buffers[bufferIndex].lineOffsetStarts.run { + binarySearchForMinIndexOfValueAtLeast(bufferOffsetEndExclusive - 1) + 1 - maxOf(0, binarySearchForMinIndexOfValueAtLeast(bufferOffsetStart)) + } + leftNumOfLineBreaks = node?.left?.numLineBreaks() ?: 0 } fun recomputeAggregatedValues(node: RedBlackTree.Node) { @@ -168,8 +215,13 @@ class BigTextImpl : BigText { while (node.isNotNil()) { val left = node.left.takeIf { it.isNotNil() } with (node.getValue()) { + // recompute leftStringLength leftStringLength = left?.length() ?: 0 - log.d { ">> ${node.value.debugKey()} -> $leftStringLength (${left?.value?.debugKey()}/ ${left?.length()})" } + log.v { ">> ${node.value.debugKey()} -> $leftStringLength (${left?.value?.debugKey()}/ ${left?.length()})" } + + // recompute leftNumOfLineBreaks + computeCurrentNodeProperties(this) + // TODO calc other metrics } log.v { ">> ${node.parent.value?.debugKey()} parent -> ${node.value?.debugKey()}" } @@ -237,6 +289,34 @@ class BigTextImpl : BigText { return result.toString() } + fun findLineString(lineIndex: Int): String { + fun findCharPosOfLineOffset(node: RedBlackTree.Node, lineOffset: Int): Int { + val buffer = buffers[node.value!!.bufferIndex] + val lineStartIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.value!!.bufferOffsetStart) + val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) + val charOffsetInBuffer = if (offsetedLineOffset >= 0) { + buffer.lineOffsetStarts[offsetedLineOffset] + 1 + } else { + 0 + } + return findPositionStart(node) + (charOffsetInBuffer - node.value!!.bufferOffsetStart) + } + + val (startNode, startNodeLineStart) = tree.findNodeByLineBreaks(lineIndex - 1)!! + val endNodeFindPair = tree.findNodeByLineBreaks(lineIndex) + val endCharIndex = if (endNodeFindPair != null) { // includes the last '\n' char + val (endNode, endNodeLineStart) = endNodeFindPair + require(endNodeLineStart <= lineIndex) +// val lca = tree.lowestCommonAncestor(startNode, endNode) + findCharPosOfLineOffset(endNode, lineIndex - endNodeLineStart) + } else { + length + } + val startCharIndex = findCharPosOfLineOffset(startNode, lineIndex - 1 - startNodeLineStart) + logQ.d { "line #$lineIndex -> $startCharIndex ..< $endCharIndex" } + return substring(startCharIndex, endCharIndex) // includes the last '\n' char + } + override fun append(text: String): Int { return insertAt(length, text) // var start = 0 @@ -381,6 +461,13 @@ fun RedBlackTree.Node.length(): Int = (getValue()?.bufferLength ?: 0) + (getRight().takeIf { it.isNotNil() }?.length() ?: 0) +fun RedBlackTree.Node.numLineBreaks(): Int { + val value = getValue() + return (value?.leftNumOfLineBreaks ?: 0) + + (value?.bufferNumLineBreaksInRange ?: 0) + + (getRight().takeIf { it.isNotNil() }?.numLineBreaks() ?: 0) +} + private enum class InsertDirection { Left, Right, Undefined } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 2c27e56a..5af13d90 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -1,5 +1,6 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext +import com.sunnychung.application.multiplatform.hellohttp.util.findAllIndicesOfChar import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree.Node import kotlin.random.Random @@ -14,12 +15,23 @@ class BigTextNodeValue : Comparable, DebuggableNode.Node? = null + private val key = Random.nextInt() + override fun attach(node: RedBlackTree.Node) { + this.node = node + } + + override fun detach() { + node = null + } + override fun compareTo(other: BigTextNodeValue): Int { return compareValues(leftStringLength, other.leftStringLength) } @@ -33,6 +45,7 @@ class TextBuffer(val size: Int) { private val buffer = StringBuilder(size) var lineOffsetStarts: List = emptyList() +// var lineOffsetStarts: SortedSet = sortedSetOf() // var rowOffsetStarts: List = emptyList() val length: Int @@ -41,10 +54,13 @@ class TextBuffer(val size: Int) { fun append(text: String): IntRange { val start = buffer.length buffer.append(text) - text.forEachIndexed { index, c -> - if (c == '\n') { - lineOffsetStarts += start + index - } +// text.forEachIndexed { index, c -> +// if (c == '\n') { +// lineOffsetStarts += start + index +// } +// } + text.findAllIndicesOfChar('\n').forEach { + lineOffsetStarts += start + it } return start until start + text.length } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt index 6bf1aba8..e40d98a5 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt @@ -1,10 +1,13 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree +import java.util.Stack interface DebuggableNode> { fun debugKey(): String fun debugLabel(node: RedBlackTree.Node): String + fun attach(node: RedBlackTree.Node) + fun detach() } interface RedBlackTreeComputations> { @@ -75,6 +78,7 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations Unit) { + fun visit(node: Node) { + if (node.isNil) return + visit(node.left) + visit(node.right) + visitor(node) + } + visit(root) + } + + fun pathUntilRoot(node: Node): List = buildList { + if (node.isNil) { + throw IllegalArgumentException("Given node does not exist") + } + var n = node + while (n.isNotNil()) { + add(n) + n = n.parent + } + } + + fun lowestCommonAncestor(node1: Node, node2: Node): Node { + val path1 = pathUntilRoot(node1) + val path2 = pathUntilRoot(node2) + var i1 = path1.lastIndex + var i2 = path2.lastIndex + while (i1 >= 0 && i2 >= 0) { + if (path1[i1] !== path2[i2]) { + return path1[i1 + 1] + } + --i1 + --i2 + } + throw IllegalArgumentException("One or more given nodes do not belong to this tree") + } + fun debugTree(prepend: String = " "): String = buildString { fun visit(node: Node): String { val key = node.value?.debugKey().toString() @@ -476,6 +520,7 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations + val result = t.findLineString(i) + assertEquals(line, result) + } + } + + @Test + fun findLineStringInGiantString() { + listOf(16391, 32781).forEach { upperBound -> + val lines = ((1..12) + (0..23) + (13..upperBound)).map { "${('0' + it % 10).toString().repeat(it)}\n" } + val s = lines.joinToString("") + val t = BigTextImpl(chunkSize = 2 * 1024 * 1024).apply { + append(s) + } + lines.forEachIndexed { i, line -> + val result = t.findLineString(i) + assertEquals(line, result) + } + } + } + + @Test + fun findLineStringInEvenSizedLines() { + // each line has exactly 4 characters including '\n' + val lines = (0 .. 10_000_004).map { "${(it % 1000).toString().padStart(3, '0')}\n" } + val s = lines.joinToString("") + val t = BigTextImpl(chunkSize = 64).apply { + append(s) + } + lines.forEachIndexed { i, line -> + val result = t.findLineString(i) + assertEquals(line, result) + } + } + + @Test + fun findLineStringWithoutNLAtTheEnd() { + // each line has exactly 4 characters including '\n' + val s = "abcdefgh\nijk\nlm" + val t = BigTextImpl(chunkSize = 64).apply { + append(s) + } + s.split("\n").forEachIndexed { i, line -> + val result = t.findLineString(i) + assertEquals("$line${if (i < 2) "\n" else "" }", result) + } + } + + @Test + fun findLineStringWithNLAtTheEnd() { + // each line has exactly 4 characters including '\n' + val s = "abcdefgh\nijk\nlm\n" + val t = BigTextImpl(chunkSize = 64).apply { + append(s) + } + (0..3).forEachIndexed { i, line -> + val result = t.findLineString(i) + val expected = when (i) { + 0 -> "abcdefgh\n" + 1 -> "ijk\n" + 2 -> "lm\n" + 3 -> "" + else -> throw IllegalArgumentException() + } + assertEquals(expected, result) + } + } + + @Test + fun findLineStringInFullOfEmptyLines() { + // each line has exactly 4 characters including '\n' + val s = "\n".repeat(10_000_009) + val t = BigTextImpl(chunkSize = 64).apply { + append(s) + } + println("[findLineStringInFullOfEmptyLines] initialized") + (0 .. 10_000_009).forEachIndexed { i, line -> + val result = t.findLineString(i) + assertEquals(if (i < 10_000_009) "\n" else "", result) + } + } +} From 39e80cfaa42a4611283ecb32436cddd127382fec Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 18 Aug 2024 12:28:24 +0800 Subject: [PATCH 033/195] fix `BigTextImpl#findLineString` --- .../hellohttp/extension/ListExtension.kt | 26 +++- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 6 +- .../hellohttp/ux/bigtext/BigTextImpl.kt | 29 +++-- .../ux/bigtext/BigTextLayoutResult.kt | 4 +- .../ux/bigtext/MonospaceTextLayouter.kt | 6 +- .../hellohttp/ux/bigtext/RedBlackTree2.kt | 4 + .../hellohttp/test/BinarySearchTest.kt | 39 ++++++ .../test/bigtext/BigTextImplQueryTest.kt | 120 ++++++++++++++++++ 8 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/BinarySearchTest.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt index d9927085..7476de3a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt @@ -15,16 +15,16 @@ fun List.binarySearchForInsertionPoint(comparison: (T) -> Int): Int { * Can only be used on an **ascending** list. * * R = A.f(x); - * Return minimum R so that A[R] >= x. + * Return maximum R so that A[R] <= x. * * For example, for following values: * * [0, 2, 37, 57, 72, 85, 91, 113] - * f(0) = -1, f(0) = 0, f(72) = 4, f(73) = 4, f(84) = 4, f(85) = 5, f(113) = 7, f(999) = 7 + * f(-1) = -1, f(0) = 0, f(72) = 4, f(73) = 4, f(84) = 4, f(85) = 5, f(113) = 7, f(999) = 7 * * @param searchValue */ -fun List.binarySearchForMinIndexOfValueAtLeast(searchValue: Int): Int { +fun List.binarySearchForMaxIndexOfValueAtMost(searchValue: Int): Int { val insertionPoint = binarySearchForInsertionPoint { if (it >= searchValue) 1 else -1 } if (insertionPoint > lastIndex) return lastIndex return if (searchValue < this[insertionPoint]) { @@ -33,3 +33,23 @@ fun List.binarySearchForMinIndexOfValueAtLeast(searchValue: Int): Int { insertionPoint } } + +/** + * Can only be used on an **ascending** list. + * + * R = A.f(x); + * Return minimum R so that A[R] >= x. + * + * For example, for following values: + * + * [0, 2, 37, 57, 72, 85, 91, 113] + * f(-1) = 0, f(0) = 0, f(72) = 4, f(73) = 5, f(84) = 5, f(85) = 5, f(113) = 7, f(999) = 8 + * + * + * + * @param searchValue + */ +fun List.binarySearchForMinIndexOfValueAtLeast(searchValue: Int): Int { + val insertionPoint = binarySearchForInsertionPoint { if (it >= searchValue) 1 else -1 } + return insertionPoint +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 3eb26e67..9a3cc3ab 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -70,15 +70,13 @@ import androidx.compose.ui.text.input.SetComposingTextCommand import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.substring import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast +import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMaxIndexOfValueAtMost import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.isCtrlOrCmdPressed -import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.extension.toTextInput import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast @@ -506,7 +504,7 @@ private fun CoreBigMonospaceText( true } it.key in listOf(Key.DirectionUp, Key.DirectionDown) -> { - val row = layoutResult.rowStartCharIndices.binarySearchForMinIndexOfValueAtLeast(viewState.transformedCursorIndex) + val row = layoutResult.rowStartCharIndices.binarySearchForMaxIndexOfValueAtMost(viewState.transformedCursorIndex) val newRow = row + if (it.key == Key.DirectionDown) 1 else -1 viewState.transformedCursorIndex = Unit.let { if (newRow < 0) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 4cde41b4..030de032 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.LogWriter import co.touchlab.kermit.Logger import co.touchlab.kermit.MutableLoggerConfig import co.touchlab.kermit.Severity +import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMaxIndexOfValueAtMost import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.util.JvmLogger @@ -17,7 +18,7 @@ val log = Logger(object : MutableLoggerConfig { val logQ = Logger(object : MutableLoggerConfig { override var logWriterList: List = listOf(JvmLogger()) override var minSeverity: Severity = Severity.Info -}, tag = "BigTextQuery") +}, tag = "BigText.Query") internal var isD = false @@ -203,9 +204,10 @@ class BigTextImpl : BigText { fun computeCurrentNodeProperties(nodeValue: BigTextNodeValue) = with (nodeValue) { // bufferNumLineBreaksInRange = buffers[bufferIndex].lineOffsetStarts.subSet(bufferOffsetStart, bufferOffsetEndExclusive).size bufferNumLineBreaksInRange = buffers[bufferIndex].lineOffsetStarts.run { - binarySearchForMinIndexOfValueAtLeast(bufferOffsetEndExclusive - 1) + 1 - maxOf(0, binarySearchForMinIndexOfValueAtLeast(bufferOffsetStart)) + binarySearchForMinIndexOfValueAtLeast(bufferOffsetEndExclusive) - maxOf(0, binarySearchForMinIndexOfValueAtLeast(bufferOffsetStart)) } leftNumOfLineBreaks = node?.left?.numLineBreaks() ?: 0 + log.v { ">> leftNumOfLineBreaks ${node?.value?.debugKey()} -> $leftNumOfLineBreaks" } } fun recomputeAggregatedValues(node: RedBlackTree.Node) { @@ -290,14 +292,20 @@ class BigTextImpl : BigText { } fun findLineString(lineIndex: Int): String { + + /** + * @param lineOffset 0 = start of buffer; 1 = char index after the first '\n' + */ fun findCharPosOfLineOffset(node: RedBlackTree.Node, lineOffset: Int): Int { val buffer = buffers[node.value!!.bufferIndex] val lineStartIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.value!!.bufferOffsetStart) - val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) - val charOffsetInBuffer = if (offsetedLineOffset >= 0) { + val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) - 1 + val charOffsetInBuffer = if (lineOffset - 1 > buffer.lineOffsetStarts.lastIndex) { + node.value!!.bufferOffsetEndExclusive + } else if (offsetedLineOffset >= 0) { buffer.lineOffsetStarts[offsetedLineOffset] + 1 } else { - 0 + node.value!!.bufferOffsetStart } return findPositionStart(node) + (charOffsetInBuffer - node.value!!.bufferOffsetStart) } @@ -306,13 +314,13 @@ class BigTextImpl : BigText { val endNodeFindPair = tree.findNodeByLineBreaks(lineIndex) val endCharIndex = if (endNodeFindPair != null) { // includes the last '\n' char val (endNode, endNodeLineStart) = endNodeFindPair - require(endNodeLineStart <= lineIndex) + require(endNodeLineStart <= lineIndex) { "Node ${endNode.value.debugKey()} violates [endNodeLineStart <= lineIndex]" } // val lca = tree.lowestCommonAncestor(startNode, endNode) - findCharPosOfLineOffset(endNode, lineIndex - endNodeLineStart) + findCharPosOfLineOffset(endNode, lineIndex + 1 - endNodeLineStart) } else { length } - val startCharIndex = findCharPosOfLineOffset(startNode, lineIndex - 1 - startNodeLineStart) + val startCharIndex = findCharPosOfLineOffset(startNode, lineIndex - startNodeLineStart) logQ.d { "line #$lineIndex -> $startCharIndex ..< $endCharIndex" } return substring(startCharIndex, endCharIndex) // includes the last '\n' char } @@ -425,6 +433,11 @@ class BigTextImpl : BigText { } } +// // FIXME remove +// tree.visitInPostOrder { +// computeCurrentNodeProperties(it.value) +// } + log.d { inspect("Finish D " + node?.value?.debugKey()) } return -(endExclusive - start) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt index 4314eb80..3adef564 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt @@ -1,7 +1,7 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryApi -import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast +import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMaxIndexOfValueAtMost import com.sunnychung.application.multiplatform.hellohttp.util.UnicodeCharMeasurer @OptIn(TemporaryApi::class) @@ -19,7 +19,7 @@ class BigTextLayoutResult( private val charMeasurer: UnicodeCharMeasurer, ) { fun findLineNumberByRowNumber(rowNumber: Int): Int { - return lineFirstRowIndices.binarySearchForMinIndexOfValueAtLeast(rowNumber) + return lineFirstRowIndices.binarySearchForMaxIndexOfValueAtMost(rowNumber) } fun getLineTop(originalLineNumber: Int): Float = lineFirstRowIndices[originalLineNumber] * rowHeight diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt index 5c324f28..e26d924d 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt @@ -3,7 +3,7 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.TransformedText -import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast +import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMaxIndexOfValueAtMost import com.sunnychung.application.multiplatform.hellohttp.util.UnicodeCharMeasurer import com.sunnychung.application.multiplatform.hellohttp.util.log @@ -90,8 +90,8 @@ class MonospaceTextLayouter(textMeasurer: TextMeasurer, textStyle: TextStyle) { } else { transformedText.text.lastIndex + 1 } - val displayRowStart = transformedRowStartCharIndices.binarySearchForMinIndexOfValueAtLeast(transformedStartCharIndex) - val displayRowEnd = transformedRowStartCharIndices.binarySearchForMinIndexOfValueAtLeast(transformedEndCharIndex) + val displayRowStart = transformedRowStartCharIndices.binarySearchForMaxIndexOfValueAtMost(transformedStartCharIndex) + val displayRowEnd = transformedRowStartCharIndices.binarySearchForMaxIndexOfValueAtMost(transformedEndCharIndex) val numOfRows = displayRowEnd - displayRowStart lineRowSpans[index] = numOfRows lineRowIndices[index + 1] = lineRowIndices[index] + numOfRows diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt index e40d98a5..e557c3a3 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt @@ -88,6 +88,7 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations(private val computations: RedBlackTreeComputations + val result = t.findLineString(i) + assertEquals(if (i == splitted.lastIndex) line else "$line\n", result) + } + } + + @Test + fun findLineStringAfterRemoves() { + val lines1 = ((1..12) + (0 .. 23) + (13 .. 1029)).map { "${('0' + it % 10).toString().repeat(it)}\n" }.joinToString("") + val pos1 = (lines1.length * 0.4 - 1).toInt() + val pos2 = (lines1.length * 0.7).toInt() + val pos3 = (lines1.length * 0.3).toInt() + val t = BigTextVerifyImpl(chunkSize = 64).apply { + append(lines1) + delete(pos1, pos1 + 100000) + delete(pos2, pos2 + 58888) + delete(0, 37777) + delete(pos3, pos3 + 45678) + delete(pos1, pos1 + 19) + } + val s = t.stringImpl.fullString() + println("len = ${s.length}") + val splitted = s.split("\n") + splitted.forEachIndexed { i, line -> + val result = t.bigTextImpl.findLineString(i) + assertEquals(if (i == splitted.lastIndex) line else "$line\n", result) + } + } + + fun generateString(length: Int): String { + return "${('0' + length % 10).toString().repeat(length)}\n" + } + + @Test + fun findLineStringAfterSomeInsertsAndRemoves() { + val lines1 = ((1..12) + (0 .. 23) + (13 .. 1029)).map { generateString(it) }.joinToString("") + val pos1 = (lines1.length * 0.4 - 1).toInt() + val pos2 = (lines1.length * 0.7).toInt() + val pos3 = (lines1.length * 0.3).toInt() + val t = BigTextVerifyImpl(chunkSize = 64).apply { + append(lines1) + delete(pos1, pos1 + 100000) + delete(pos2, pos2 + 58888) + insertAt(pos2 - 9, generateString(50)) + delete(0, 37777) + delete(pos3, pos3 + 45678) + insertAt(pos3 - 9, generateString(50)) + insertAt(0, generateString(29)) + delete(pos1, pos1 + 19) + } + val s = t.stringImpl.fullString() + println("len = ${s.length}") + val splitted = s.split("\n") + splitted.forEachIndexed { i, line -> + val result = t.bigTextImpl.findLineString(i) + assertEquals(if (i == splitted.lastIndex) line else "$line\n", result) + } + } + + @ParameterizedTest + @ValueSource(ints = [64, 64 * 1024, 2 * 1024 * 1024]) + fun findLineStringAfterMoreInsertsAndRemoves(chunkSize: Int) { + val t = BigTextVerifyImpl(chunkSize = chunkSize) + t.append(generateString(12_345_678)) + repeat(4000) { + val len = when (random(0, 100)) { + in 0 .. 49 -> random(0, 20) + in 50 .. 74 -> random(20, 100) + in 75 .. 84 -> random(100, 1000) + in 85 .. 94 -> random(1000, 10000) + in 95 .. 98 -> random(10000, 100000) + in 99 .. 99 -> random(100000, 1000000) + else -> throw IllegalStateException() + } + when (random(0, 6)) { + 0 -> t.append(generateString(len)) + 1 -> t.insertAt(0, generateString(len)) + 2 -> t.insertAt(random(0, t.length), generateString(len)) + 3 -> t.delete(0, minOf(len, t.length)) + 4 -> t.delete(maxOf(t.length - minOf(len, t.length), 0), t.length) + 5 -> { + val len = minOf(len, maxOf(t.length - minOf(len, t.length), 0)) + val start = random(0, len) + t.delete(start, start + len) + } + } + } + val splitted = t.stringImpl.fullString().split("\n") + splitted.forEachIndexed { i, line -> + val result = t.bigTextImpl.findLineString(i) + assertEquals(if (i == splitted.lastIndex) line else "$line\n", result) + } + } +} + +private fun random(from: Int, toExclusive: Int): Int { + if (toExclusive == from) { + return 0 + } + return Random.nextInt(from, toExclusive) } From 592076d27b27f88c0fb799c13ac5260fc3de03d4 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 18 Aug 2024 15:04:30 +0800 Subject: [PATCH 034/195] fix `BigTextImpl#findLineString` --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 2 +- .../test/bigtext/BigTextImplQueryTest.kt | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 030de032..0100f142 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -302,7 +302,7 @@ class BigTextImpl : BigText { val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) - 1 val charOffsetInBuffer = if (lineOffset - 1 > buffer.lineOffsetStarts.lastIndex) { node.value!!.bufferOffsetEndExclusive - } else if (offsetedLineOffset >= 0) { + } else if (lineOffset - 1 >= 0) { buffer.lineOffsetStarts[offsetedLineOffset] + 1 } else { node.value!!.bufferOffsetStart diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt index 7d8092cc..0f38f3e1 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt @@ -143,6 +143,22 @@ class BigTextImplQueryTest { } } + @Test + fun findLineStringAfterRemoves2() { + val lines = ((1..12)/* + (0 .. 23) + (13 .. 1029)*/).map { "${('0' + it % 10).toString().repeat(it)}\n" }.joinToString("") + val t = BigTextVerifyImpl(chunkSize = 65536).apply { + append(lines) + delete(0, 18) + } + val s = t.stringImpl.fullString() + println("len = ${s.length}") + val splitted = s.split("\n") + splitted.forEachIndexed { i, line -> + val result = t.bigTextImpl.findLineString(i) + assertEquals(if (i == splitted.lastIndex) line else "$line\n", result) + } + } + fun generateString(length: Int): String { return "${('0' + length % 10).toString().repeat(length)}\n" } From 02cb9db944c00ecefe31ab31c18bed7ec16da789 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 18 Aug 2024 15:16:22 +0800 Subject: [PATCH 035/195] optimize `BigTextImpl#findLineString` --- .../multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 0100f142..1679d641 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -78,7 +78,7 @@ class BigTextImpl : BigText { } else -> throw IllegalStateException("what is find? $find") } - }?.let { it to /*lineStart *//*+ it.value.leftNumOfLineBreaks*/ findLineStart(it) } + }?.let { it to lineStart + it.value.leftNumOfLineBreaks /*findLineStart(it)*/ } } fun findPositionStart(node: RedBlackTree.Node): Int { @@ -292,17 +292,16 @@ class BigTextImpl : BigText { } fun findLineString(lineIndex: Int): String { - /** * @param lineOffset 0 = start of buffer; 1 = char index after the first '\n' */ fun findCharPosOfLineOffset(node: RedBlackTree.Node, lineOffset: Int): Int { val buffer = buffers[node.value!!.bufferIndex] - val lineStartIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.value!!.bufferOffsetStart) - val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) - 1 val charOffsetInBuffer = if (lineOffset - 1 > buffer.lineOffsetStarts.lastIndex) { node.value!!.bufferOffsetEndExclusive } else if (lineOffset - 1 >= 0) { + val lineStartIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.value!!.bufferOffsetStart) + val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) - 1 buffer.lineOffsetStarts[offsetedLineOffset] + 1 } else { node.value!!.bufferOffsetStart From 730356ea5d4c9b4b09bed7cc58bf2e8888e5c47a Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 18 Aug 2024 18:25:09 +0800 Subject: [PATCH 036/195] refactor MonospaceTextLayouter to allow use of character measurers that are not bound to Jetpack Compose for easier testing --- .../multiplatform/hellohttp/util/CharMeasurer.kt | 8 ++++++++ ...harMeasurer.kt => ComposeUnicodeCharMeasurer.kt} | 6 +++--- .../hellohttp/ux/bigtext/BigTextLayoutResult.kt | 4 ++-- .../hellohttp/ux/bigtext/MonospaceTextLayouter.kt | 13 ++++++++++--- 4 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CharMeasurer.kt rename src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/{UnicodeCharMeasurer.kt => ComposeUnicodeCharMeasurer.kt} (92%) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CharMeasurer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CharMeasurer.kt new file mode 100644 index 00000000..a5720739 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CharMeasurer.kt @@ -0,0 +1,8 @@ +package com.sunnychung.application.multiplatform.hellohttp.util + +interface CharMeasurer { + + fun measureFullText(text: String) + + fun findCharWidth(char: String): Float +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/UnicodeCharMeasurer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt similarity index 92% rename from src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/UnicodeCharMeasurer.kt rename to src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt index 6346194d..d6d17e87 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/UnicodeCharMeasurer.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt @@ -4,13 +4,13 @@ import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import java.util.LinkedHashMap -class UnicodeCharMeasurer(private val measurer: TextMeasurer, private val style: TextStyle) { +class ComposeUnicodeCharMeasurer(private val measurer: TextMeasurer, private val style: TextStyle) : CharMeasurer { private val charWidth: MutableMap = LinkedHashMap(256) /** * Time complexity = O(S lg C) */ - fun measureFullText(text: String) { + override fun measureFullText(text: String) { val charToMeasure = mutableSetOf() text.forEach { val s = it.toString() @@ -25,7 +25,7 @@ class UnicodeCharMeasurer(private val measurer: TextMeasurer, private val style: /** * Time complexity = O(lg C) */ - fun findCharWidth(char: String): Float { + override fun findCharWidth(char: String): Float { when (char.codePoints().findFirst().asInt) { in 0x4E00..0x9FFF, in 0x3400..0x4DBF, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt index 3adef564..6840b970 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt @@ -2,7 +2,7 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryApi import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMaxIndexOfValueAtMost -import com.sunnychung.application.multiplatform.hellohttp.util.UnicodeCharMeasurer +import com.sunnychung.application.multiplatform.hellohttp.util.CharMeasurer @OptIn(TemporaryApi::class) class BigTextLayoutResult( @@ -16,7 +16,7 @@ class BigTextLayoutResult( val totalLines: Int, val totalRows: Int, /** Total number of lines before transformation */ val totalLinesBeforeTransformation: Int, - private val charMeasurer: UnicodeCharMeasurer, + private val charMeasurer: CharMeasurer, ) { fun findLineNumberByRowNumber(rowNumber: Int): Int { return lineFirstRowIndices.binarySearchForMaxIndexOfValueAtMost(rowNumber) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt index e26d924d..a010109a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt @@ -4,13 +4,20 @@ import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.TransformedText import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMaxIndexOfValueAtMost -import com.sunnychung.application.multiplatform.hellohttp.util.UnicodeCharMeasurer +import com.sunnychung.application.multiplatform.hellohttp.util.CharMeasurer +import com.sunnychung.application.multiplatform.hellohttp.util.ComposeUnicodeCharMeasurer import com.sunnychung.application.multiplatform.hellohttp.util.log private val LINE_BREAK_REGEX = "\n".toRegex() -class MonospaceTextLayouter(textMeasurer: TextMeasurer, textStyle: TextStyle) { - val charMeasurer = UnicodeCharMeasurer(textMeasurer, textStyle) +class MonospaceTextLayouter { + val charMeasurer: CharMeasurer + + constructor(charMeasurer: CharMeasurer) { + this.charMeasurer = charMeasurer + } + + constructor(textMeasurer: TextMeasurer, textStyle: TextStyle) : this(ComposeUnicodeCharMeasurer(textMeasurer, textStyle)) fun layout(text: String, transformedText: TransformedText, lineHeight: Float, contentWidth: Float): BigTextLayoutResult { log.v { "layout cw=$contentWidth" } From 2a8fa7a5fa9ffb4bdb681c9e683d904f4121f878 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 18 Aug 2024 21:20:33 +0800 Subject: [PATCH 037/195] add layout and rows query to BigTextImpl --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 140 ++++++++++++++++++ .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 9 +- .../ux/bigtext/MonospaceTextLayouter.kt | 34 ++++- .../hellohttp/ux/bigtext/TextLayouter.kt | 8 + .../test/bigtext/BigTextImplLayoutTest.kt | 118 +++++++++++++++ .../test/bigtext/FixedWidthCharMeasurer.kt | 11 ++ 6 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/TextLayouter.kt create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/FixedWidthCharMeasurer.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 1679d641..c1af4485 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -20,6 +20,11 @@ val logQ = Logger(object : MutableLoggerConfig { override var minSeverity: Severity = Severity.Info }, tag = "BigText.Query") +val logL = Logger(object : MutableLoggerConfig { + override var logWriterList: List = listOf(JvmLogger()) + override var minSeverity: Severity = Severity.Info +}, tag = "BigText.Layout") + internal var isD = false class BigTextImpl : BigText { @@ -35,6 +40,9 @@ class BigTextImpl : BigText { val chunkSize: Int // TODO change to a large number + private var layouter: TextLayouter? = null + private var contentWidth: Float? = null + constructor() { chunkSize = 64 } @@ -81,6 +89,26 @@ class BigTextImpl : BigText { }?.let { it to lineStart + it.value.leftNumOfLineBreaks /*findLineStart(it)*/ } } + fun RedBlackTree2.findNodeByRowBreaks(index: Int): Pair.Node, Int>? { + var find = index + var rowStart = 0 + return findNode { + index + when (find) { + in Int.MIN_VALUE until it.value.leftNumOfRowBreaks -> if (it.left.isNotNil()) -1 else 0 + in it.value.leftNumOfRowBreaks until it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size -> 0 + in it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size until Int.MAX_VALUE -> 1.also { compareResult -> + val isTurnRight = compareResult > 0 + if (isTurnRight) { + find -= it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size + rowStart += it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size + } + } + else -> throw IllegalStateException("what is find? $find") + } + }?.let { it to rowStart + it.value.leftNumOfRowBreaks /*findLineStart(it)*/ } + } + fun findPositionStart(node: RedBlackTree.Node): Int { var start = node.value.leftStringLength var node = node @@ -208,6 +236,8 @@ class BigTextImpl : BigText { } leftNumOfLineBreaks = node?.left?.numLineBreaks() ?: 0 log.v { ">> leftNumOfLineBreaks ${node?.value?.debugKey()} -> $leftNumOfLineBreaks" } + + leftNumOfRowBreaks = node?.left?.numRowBreaks() ?: 0 } fun recomputeAggregatedValues(node: RedBlackTree.Node) { @@ -324,6 +354,37 @@ class BigTextImpl : BigText { return substring(startCharIndex, endCharIndex) // includes the last '\n' char } + fun findRowString(rowIndex: Int): String { + /** + * @param rowOffset 0 = start of buffer; 1 = char index of the first row break + */ + fun findCharPosOfRowOffset(node: RedBlackTree.Node, rowOffset: Int): Int { + val charOffsetInBuffer = if (rowOffset - 1 > node.value!!.rowBreakOffsets.size - 1) { + node.value!!.bufferOffsetEndExclusive + } else if (rowOffset - 1 >= 0) { + val offsetedRowOffset = rowOffset - 1 + node.value!!.rowBreakOffsets[offsetedRowOffset] + } else { + node.value!!.bufferOffsetStart + } + return findPositionStart(node) + (charOffsetInBuffer - node.value!!.bufferOffsetStart) + } + + val (startNode, startNodeRowStart) = tree.findNodeByRowBreaks(rowIndex - 1)!! + val endNodeFindPair = tree.findNodeByRowBreaks(rowIndex) + val endCharIndex = if (endNodeFindPair != null) { // includes the last '\n' char + val (endNode, endNodeRowStart) = endNodeFindPair + require(endNodeRowStart <= rowIndex) { "Node ${endNode.value.debugKey()} violates [endNodeRowStart <= rowIndex]" } +// val lca = tree.lowestCommonAncestor(startNode, endNode) + findCharPosOfRowOffset(endNode, rowIndex + 1 - endNodeRowStart) + } else { + length + } + val startCharIndex = findCharPosOfRowOffset(startNode, rowIndex - startNodeRowStart) + logQ.d { "row #$rowIndex -> $startCharIndex ..< $endCharIndex" } + return substring(startCharIndex, endCharIndex) // includes the last '\n' char + } + override fun append(text: String): Int { return insertAt(length, text) // var start = 0 @@ -465,6 +526,78 @@ class BigTextImpl : BigText { println(inspect(label)) } + fun setLayouter(layouter: TextLayouter) { + if (this.layouter == layouter) { + return + } + + tree.forEach { + val buffer = buffers[it.bufferIndex] + val chunkString = buffer.subSequence(it.bufferOffsetStart, it.bufferOffsetEndExclusive) + layouter.indexCharWidth(chunkString.toString()) + } + + this.layouter = layouter + + layout() + } + + fun setContentWidth(contentWidth: Float) { + if (this.contentWidth == contentWidth) { + return + } + + this.contentWidth = contentWidth + + layout() + } + + fun layout() { + val layouter = this.layouter ?: return + val contentWidth = this.contentWidth ?: return + + var lastOccupiedWidth = 0f + val treeLastIndex = tree.size() - 1 + tree.forEachIndexed { index, node -> + val buffer = buffers[node.bufferIndex] + val lineBreakIndexFrom = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.bufferOffsetStart) + val lineBreakIndexTo = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(node.bufferOffsetEndExclusive - 1) + var charStartIndexInBuffer = node.bufferOffsetStart +// node.rowBreakOffsets.clear() + val rowBreakOffsets = mutableListOf() + (lineBreakIndexFrom .. lineBreakIndexTo).forEach { lineBreakEntryIndex -> + val lineBreakCharIndex = buffer.lineOffsetStarts[lineBreakEntryIndex] + val subsequence = buffer.subSequence(charStartIndexInBuffer, lineBreakCharIndex) + logL.d { "node ${node.debugKey()} line break #$lineBreakEntryIndex seq $charStartIndexInBuffer ..< $lineBreakCharIndex" } + + val (rowCharOffsets, _) = layouter.layoutOneLine(subsequence, contentWidth, lastOccupiedWidth, charStartIndexInBuffer) +// node.rowBreakOffsets += rowCharOffsets + logL.d { "row break add $rowCharOffsets" } + rowBreakOffsets += rowCharOffsets + logL.d { "row break add ${lineBreakCharIndex + 1}" } + rowBreakOffsets += lineBreakCharIndex + 1 + + charStartIndexInBuffer = lineBreakCharIndex + 1 + lastOccupiedWidth = 0f + } + if (charStartIndexInBuffer < node.bufferOffsetEndExclusive) { + val subsequence = buffer.subSequence(charStartIndexInBuffer, node.bufferOffsetEndExclusive) + logL.d { "node ${node.debugKey()} last row seq $charStartIndexInBuffer ..< ${node.bufferOffsetEndExclusive}" } + + val (rowCharOffsets, lastRowOccupiedWidth) = layouter.layoutOneLine(subsequence, contentWidth, lastOccupiedWidth, charStartIndexInBuffer) +// node.rowBreakOffsets += rowCharOffsets + logL.d { "row break add $rowCharOffsets" } + rowBreakOffsets += rowCharOffsets + lastOccupiedWidth = lastRowOccupiedWidth + } + node.rowBreakOffsets = rowBreakOffsets + node.lastRowWidth = lastOccupiedWidth + } + + } + + val numOfRows: Int + get() = tree.getRoot().numRowBreaks() + 1 } @@ -480,6 +613,13 @@ fun RedBlackTree.Node.numLineBreaks(): Int { (getRight().takeIf { it.isNotNil() }?.numLineBreaks() ?: 0) } +fun RedBlackTree.Node.numRowBreaks(): Int { + val value = getValue() + return (value?.leftNumOfRowBreaks ?: 0) + + (value?.rowBreakOffsets?.size ?: 0) + + (getRight().takeIf { it.isNotNil() }?.numRowBreaks() ?: 0) +} + private enum class InsertDirection { Left, Right, Undefined } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 5af13d90..1fa4c4ac 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -2,15 +2,16 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import com.sunnychung.application.multiplatform.hellohttp.util.findAllIndicesOfChar import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree -import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree.Node +import java.util.SortedSet import kotlin.random.Random class BigTextNodeValue : Comparable, DebuggableNode { var leftNumOfLineBreaks: Int = -1 - var leftNumOfRows: Int = -1 - var leftLastRowWidth: Int = -1 + var leftNumOfRowBreaks: Int = -1 var leftStringLength: Int = -1 -// var rowOffsetStarts: List = emptyList() +// var rowBreakOffsets: SortedSet = sortedSetOf() + var rowBreakOffsets: List = emptyList() + var lastRowWidth: Float = -1f var bufferIndex: Int = -1 var bufferOffsetStart: Int = -1 diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt index a010109a..9b1f4ce7 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt @@ -10,7 +10,7 @@ import com.sunnychung.application.multiplatform.hellohttp.util.log private val LINE_BREAK_REGEX = "\n".toRegex() -class MonospaceTextLayouter { +class MonospaceTextLayouter : TextLayouter { val charMeasurer: CharMeasurer constructor(charMeasurer: CharMeasurer) { @@ -118,6 +118,38 @@ class MonospaceTextLayouter { charMeasurer = charMeasurer, ) } + + override fun indexCharWidth(text: String) { + charMeasurer.measureFullText(text) + } + + override fun layoutOneLine(line: CharSequence, contentWidth: Float, firstRowOccupiedWidth: Float, offset: Int): Pair, Float> { + val charWidths = line.map { charMeasurer.findCharWidth(it.toString()) } + val isOffsetLastLine = line.endsWith('\n') + var numCharsPerRow = mutableListOf() + var currentRowOccupiedWidth = firstRowOccupiedWidth + var numCharsInCurrentRow = 0 + charWidths.forEachIndexed { i, w -> // O(line string length) + if (currentRowOccupiedWidth + w > contentWidth && numCharsInCurrentRow > 0) { + numCharsPerRow += numCharsInCurrentRow + numCharsInCurrentRow = 0 + currentRowOccupiedWidth = 0f + } + currentRowOccupiedWidth += w + ++numCharsInCurrentRow + } +// if (numCharsInCurrentRow > 0) { +// numCharsPerRow += numCharsInCurrentRow +// } +// if (numCharsPerRow.isEmpty()) { +// numCharsPerRow += 0 +// } + var s = 0 + return numCharsPerRow.mapIndexed { index, it -> + s += it + offset + s + if (index >= numCharsPerRow.lastIndex && isOffsetLastLine) 1 else 0 /* skip the last char '\n' */ + } to currentRowOccupiedWidth + } } private infix fun Int.divRoundUp(other: Int): Int { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/TextLayouter.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/TextLayouter.kt new file mode 100644 index 00000000..2d495a84 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/TextLayouter.kt @@ -0,0 +1,8 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +interface TextLayouter { + + fun indexCharWidth(text: String) + + fun layoutOneLine(line: CharSequence, contentWidth: Float, firstRowOccupiedWidth: Float, offset: Int): Pair, Float> +} diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt new file mode 100644 index 00000000..c6412345 --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt @@ -0,0 +1,118 @@ +package com.sunnychung.application.multiplatform.hellohttp.test.bigtext + +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.MonospaceTextLayouter +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import kotlin.test.Test +import kotlin.test.assertEquals + +class BigTextImplLayoutTest { + + @ParameterizedTest + @ValueSource(ints = [65536, 64]) + fun layoutOneLineJustFit(chunkSize: Int) { + val testString = "1234567890<234567890 + assertEquals(s, t.findRowString(index)) + } + } + + @ParameterizedTest + @ValueSource(ints = [65536, 64]) + fun layoutOneLineWithExtraSpace(chunkSize: Int) { + val testString = "1234567890<234567890 + assertEquals(s, t.findRowString(index)) + } + } + + fun verifyBigTextImplAgainstTestString(testString: String, bigTextImpl: BigTextImpl) { + val splitted = testString.split("\n") + val expectedRows = splitted.flatMapIndexed { index: Int, str: String -> +// val str = if (index < splitted.lastIndex) "$s\n" else s + str.chunked(10).let { ss -> + val ss = if (ss.isEmpty()) listOf(str) else ss + if (index < splitted.lastIndex) { + ss.mapIndexed { i, s -> + if (i == ss.lastIndex) { + "$s\n" + } else { + s + } + } + } else { + ss + } + } + } + println("exp $expectedRows") + assertEquals(expectedRows.size, bigTextImpl.numOfRows) + expectedRows.forEachIndexed { index, s -> + assertEquals(s, bigTextImpl.findRowString(index)) + } + } + + @ParameterizedTest + @ValueSource(ints = [65536, 64]) + fun layoutMultipleLines1(chunkSize: Int) { + val testString = "abcd\n1234567890<234567890 Date: Sun, 18 Aug 2024 21:25:57 +0800 Subject: [PATCH 038/195] update default chunkSize of BigTextImpl to 2 MB, in reference to the benchmark result --- .../multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index c1af4485..010dc8c5 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -44,10 +44,11 @@ class BigTextImpl : BigText { private var contentWidth: Float? = null constructor() { - chunkSize = 64 + chunkSize = 2 * 1024 * 1024 // 2 MB } constructor(chunkSize: Int) { + require(chunkSize > 0) { "chunkSize must be positive" } this.chunkSize = chunkSize } From 6fca3ada296f722d9cef6e93a7efa5ec4bc059bc Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 18 Aug 2024 21:28:03 +0800 Subject: [PATCH 039/195] refactor BigTextVerifyImpl to the test source set --- .../hellohttp/test/bigtext/BigTextImplQueryTest.kt | 1 - .../multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt | 1 - .../hellohttp/test}/bigtext/BigTextVerifyImpl.kt | 6 +++++- 3 files changed, 5 insertions(+), 3 deletions(-) rename src/{jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux => jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test}/bigtext/BigTextVerifyImpl.kt (89%) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt index 0f38f3e1..a72f8658 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt @@ -2,7 +2,6 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext import com.sunnychung.application.multiplatform.hellohttp.extension.insert import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl -import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextVerifyImpl import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import kotlin.random.Random diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt index 7a4859fc..303e382d 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt @@ -2,7 +2,6 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl -import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextVerifyImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.isD import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt similarity index 89% rename from src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt rename to src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index 7cd42604..c393a00a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -1,4 +1,8 @@ -package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext +package com.sunnychung.application.multiplatform.hellohttp.test.bigtext + +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.InefficientBigText internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : BigText { val bigTextImpl = if (chunkSize > 0) BigTextImpl(chunkSize) else BigTextImpl() From 4f2209ebbee996e6d2598c24df76cb4d16f4c3ac Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 24 Aug 2024 18:09:31 +0800 Subject: [PATCH 040/195] add layout process to insertions to BigTextImpl --- .../hellohttp/extension/ListExtension.kt | 37 +++ .../hellohttp/ux/bigtext/BigTextImpl.kt | 246 +++++++++++++++- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 5 +- .../ux/bigtext/MonospaceTextLayouter.kt | 2 +- .../test/bigtext/BigTextImplLayoutTest.kt | 267 +++++++++++++++++- .../test/bigtext/BigTextVerifyImpl.kt | 2 + 6 files changed, 543 insertions(+), 16 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt index 7476de3a..40f376eb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/ListExtension.kt @@ -53,3 +53,40 @@ fun List.binarySearchForMinIndexOfValueAtLeast(searchValue: Int): Int { val insertionPoint = binarySearchForInsertionPoint { if (it >= searchValue) 1 else -1 } return insertionPoint } + +/** + * `this` has to be ascending. `newElements` has to be strictly ascending. + */ +fun > MutableList.addToThisAscendingListWithoutDuplicate(newElements: List) { + if (isEmpty()) { + this.addAll(newElements) + return + } + val thisLast = last() + var insertStartIndex = 0 + while (insertStartIndex <= newElements.lastIndex && thisLast >= newElements[insertStartIndex]) { + ++insertStartIndex + } + if (insertStartIndex > newElements.lastIndex) { + return + } else if (insertStartIndex == 0) { + this.addAll(newElements) + return + } + this.addAll(newElements.subList(insertStartIndex, newElements.size)) +} + +/** + * `this` has to be ascending. `newElements` has to be strictly ascending. + */ +fun > MutableList.addToThisAscendingListWithoutDuplicate(newElement: T) { + if (isEmpty()) { + this.add(newElement) + return + } + val thisLast = last() + if (thisLast == newElement) { + return + } + this.add(newElement) +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 010dc8c5..55aa087f 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.LogWriter import co.touchlab.kermit.Logger import co.touchlab.kermit.MutableLoggerConfig import co.touchlab.kermit.Severity +import com.sunnychung.application.multiplatform.hellohttp.extension.addToThisAscendingListWithoutDuplicate import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMaxIndexOfValueAtMost import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast import com.sunnychung.application.multiplatform.hellohttp.extension.length @@ -27,6 +28,8 @@ val logL = Logger(object : MutableLoggerConfig { internal var isD = false +private const val EPS = 1e-4f + class BigTextImpl : BigText { val tree = RedBlackTree2( object : RedBlackTreeComputations { @@ -160,6 +163,7 @@ class BigTextImpl : BigText { } } var insertDirection: InsertDirection = InsertDirection.Undefined + val toBeRelayouted = mutableListOf() val newNodeValues = if (node != null && position in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1) { val splitAtIndex = position - nodeStart log.d { "> split at $splitAtIndex" } @@ -179,6 +183,7 @@ class BigTextImpl : BigText { insertDirection = InsertDirection.Left } require(splitAtIndex + chunkedString.length <= chunkSize) + toBeRelayouted += secondPartNodeValue listOf( BigTextNodeValue().apply { // new string bufferIndex = buffers.lastIndex @@ -227,6 +232,12 @@ class BigTextImpl : BigText { } } + toBeRelayouted.forEach { + val startPos = findPositionStart(it.node!!) + val endPos = startPos + it.bufferLength + layout(startPos, endPos) + } + log.v { inspect("Finish I " + node?.value?.debugKey()) } } @@ -282,6 +293,9 @@ class BigTextImpl : BigText { override val length: Int get() = tree.getRoot().length() + val lastIndex: Int + get() = length - 1 + override fun fullString(): String { return tree.joinToString("") { buffers[it.bufferIndex].subSequence(it.bufferOffsetStart, it.bufferOffsetEndExclusive) @@ -371,7 +385,12 @@ class BigTextImpl : BigText { return findPositionStart(node) + (charOffsetInBuffer - node.value!!.bufferOffsetStart) } - val (startNode, startNodeRowStart) = tree.findNodeByRowBreaks(rowIndex - 1)!! + val (startNode, startNodeRowStart) = tree.findNodeByRowBreaks(rowIndex - 1) ?: + if (rowIndex <= numOfRows) { + return "" + } else { + throw IndexOutOfBoundsException("numOfRows = $numOfRows; but given index = $rowIndex") + } val endNodeFindPair = tree.findNodeByRowBreaks(rowIndex) val endCharIndex = if (endNodeFindPair != null) { // includes the last '\n' char val (endNode, endNodeRowStart) = endNodeFindPair @@ -424,6 +443,7 @@ class BigTextImpl : BigText { start += append last = buffers.last().length } + layout(maxOf(0, pos - 1), minOf(length, pos + text.length + 1)) return text.length } @@ -499,6 +519,8 @@ class BigTextImpl : BigText { // computeCurrentNodeProperties(it.value) // } + layout(maxOf(0, start - 1), minOf(length, start + 1)) + log.d { inspect("Finish D " + node?.value?.debugKey()) } return -(endExclusive - start) @@ -519,8 +541,18 @@ class BigTextImpl : BigText { fun inspect(label: String = "") = buildString { appendLine("[$label] Buffer:\n${buffers.mapIndexed { i, it -> " $i:\t$it\n" }.joinToString("")}") + appendLine("[$label] Buffer Line Breaks:\n${buffers.mapIndexed { i, it -> " $i:\t${it.lineOffsetStarts}\n" }.joinToString("")}") appendLine("[$label] Tree:\nflowchart TD\n${tree.debugTree()}") appendLine("[$label] String:\n${fullString()}") + if (layouter != null && contentWidth != null) { + appendLine("[$label] Layouted String:\n${(0 until numOfRows).joinToString("") { + try { + "{${findRowString(it)}}\n" + } catch (e: Throwable) { + "[$e]!\n" + } + }}") + } } fun printDebug(label: String = "") { @@ -557,6 +589,9 @@ class BigTextImpl : BigText { val layouter = this.layouter ?: return val contentWidth = this.contentWidth ?: return + return layout(0, length) + // the code below doesn't pass insertTriggersRelayout3(16) + var lastOccupiedWidth = 0f val treeLastIndex = tree.size() - 1 tree.forEachIndexed { index, node -> @@ -597,8 +632,215 @@ class BigTextImpl : BigText { } + protected fun layout(startPos: Int, endPosExclusive: Int) { + val layouter = this.layouter ?: return + val contentWidth = this.contentWidth ?: return + + var lastOccupiedWidth = 0f + var node: RedBlackTree.Node? = tree.findNodeByCharIndex(startPos) ?: return + logL.d { "layout($startPos, $endPosExclusive)" } + logL.v { inspect("before layout($startPos, $endPosExclusive)") } + var nodeStartPos = findPositionStart(node!!) + val nodeValue = node.value + val buffer = buffers[nodeValue.bufferIndex] + var lineBreakIndexFrom = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast( + (startPos - nodeStartPos) + nodeValue.bufferOffsetStart + ) + var charStartIndexInBuffer = nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost((startPos - nodeStartPos) + nodeValue.bufferOffsetStart).let { + if (it >= 0) { + nodeValue.rowBreakOffsets[it] + } else { + val prevNode = tree.prevNode(node!!) + if (prevNode != null) { + lastOccupiedWidth = prevNode.value.lastRowWidth // carry over + logL.d { "carry over width $lastOccupiedWidth" } + } + + nodeValue.bufferOffsetStart + } + } + logL.d { "charStartIndexInBuffer = $charStartIndexInBuffer" } + + // we are starting at charStartIndexInBuffer without carrying over last width, so include the row break at charStartIndexInBuffer + val restoreRowBreakOffsets = nodeValue.rowBreakOffsets.subList(0, maxOf(0, nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(charStartIndexInBuffer) + 1)) + logL.d { "restore row breaks of starting node $restoreRowBreakOffsets" } + var hasRestoredRowBreaks = false + + var isBreakOnEncounterLineBreak = false + + // TODO refactor + while (node != null) { + var isBreakAfterThisIteration = false + val nodeValue = node.value + val buffer = buffers[nodeValue.bufferIndex] + val lineBreakIndexTo = + buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(nodeValue.bufferOffsetEndExclusive - 1) + .let { + if (endPosExclusive in nodeStartPos..nodeStartPos + nodeValue.bufferLength) { + minOf( + it, + buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(endPosExclusive - nodeStartPos + nodeValue.bufferOffsetStart) + ) + } else { + it + } + } + logL.d { "node ${nodeValue.debugKey()} LB $lineBreakIndexFrom .. $lineBreakIndexTo P $nodeStartPos" } + logL.d { "buffer ${nodeValue.bufferIndex} LB ${buffer.lineOffsetStarts}" } + +// if (lineBreakIndexFrom > lineBreakIndexTo) { + if (nodeStartPos > endPosExclusive + 1) { // do 1 more char because last round may just fill up the row but a row break is not created + logL.d { "set BreakOnEncounterLineBreak" } + isBreakOnEncounterLineBreak = true + } + +// nodeValue.rowBreakOffsets.clear() + val rowBreakOffsets = mutableListOf() + var isEndWithForceRowBreak = false + logL.d { "orig row breaks ${nodeValue.rowBreakOffsets}" } +// if (true || nodeStartPos == 0) { +// // we are starting at charStartIndexInBuffer without carrying over last width, so include the row break at charStartIndexInBuffer +// rowBreakOffsets += nodeValue.rowBreakOffsets.subList(0, maxOf(0, nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(charStartIndexInBuffer) + 1)) +// logL.d { "restore row breaks of leftmost node $rowBreakOffsets" } +// } else { +// rowBreakOffsets += nodeValue.rowBreakOffsets.subList( +// 0, +// maxOf(0, nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(charStartIndexInBuffer - 1) + 1) +// ) +// logL.d { "restore row breaks $rowBreakOffsets" } +// } + if (!hasRestoredRowBreaks) { + rowBreakOffsets.addToThisAscendingListWithoutDuplicate(restoreRowBreakOffsets) + hasRestoredRowBreaks = true + } + (lineBreakIndexFrom..lineBreakIndexTo).forEach { lineBreakEntryIndex -> + val lineBreakCharIndex = buffer.lineOffsetStarts[lineBreakEntryIndex] + val subsequence = buffer.subSequence(charStartIndexInBuffer, lineBreakCharIndex) + logL.d { "node ${nodeValue.debugKey()} buf #${nodeValue.bufferIndex} line break #$lineBreakEntryIndex seq $charStartIndexInBuffer ..< $lineBreakCharIndex" } + + val (rowCharOffsets, _) = layouter.layoutOneLine( + subsequence, + contentWidth, + lastOccupiedWidth, + charStartIndexInBuffer + ) +// nodeValue.rowBreakOffsets += rowCharOffsets + logL.d { "row break add $rowCharOffsets lw = 0" } + rowBreakOffsets.addToThisAscendingListWithoutDuplicate(rowCharOffsets) + + if (subsequence.isEmpty() && lastOccupiedWidth >= contentWidth - EPS) { + logL.d { "row break add carry-over force break ${lineBreakCharIndex}" } + rowBreakOffsets.addToThisAscendingListWithoutDuplicate(lineBreakCharIndex) + } + + charStartIndexInBuffer = lineBreakCharIndex + 1 + if (lineBreakCharIndex + 1 < nodeValue.bufferOffsetEndExclusive) { + logL.d { "row break add ${lineBreakCharIndex + 1}" } + rowBreakOffsets.addToThisAscendingListWithoutDuplicate(lineBreakCharIndex + 1) + lastOccupiedWidth = 0f + } else { + lastOccupiedWidth = contentWidth + 0.1f // force a row break at the start of next layout + isEndWithForceRowBreak = true + } + + if (isBreakOnEncounterLineBreak) { + isBreakAfterThisIteration = true + } + } + val nextBoundary = if ( + lineBreakIndexTo + 1 <= buffer.lineOffsetStarts.lastIndex +// && buffer.lineOffsetStarts[lineBreakIndexTo + 1] - node.value.bufferOffsetStart + nodeStartPos < endPosExclusive + && buffer.lineOffsetStarts[lineBreakIndexTo + 1] < nodeValue.bufferOffsetEndExclusive + ) { + buffer.lineOffsetStarts[lineBreakIndexTo + 1] + } else { + nodeValue.bufferOffsetEndExclusive + } +// if (charStartIndexInBuffer < nodeValue.bufferOffsetEndExclusive) { +// if (charStartIndexInBuffer < nodeValue.bufferOffsetEndExclusive && nodeStartPos + charStartIndexInBuffer - nodeValue.bufferOffsetStart < endPosExclusive) { + if (charStartIndexInBuffer < nextBoundary) { +// val subsequence = buffer.subSequence(charStartIndexInBuffer, nodeValue.bufferOffsetEndExclusive) + val readRowUntilPos = nextBoundary //nodeValue.bufferOffsetEndExclusive //minOf(nodeValue.bufferOffsetEndExclusive, endPosExclusive - nodeStartPos + nodeValue.bufferOffsetStart) + logL.d { "node ${nodeValue.debugKey()} last row seq $charStartIndexInBuffer ..< ${readRowUntilPos}. start = $nodeStartPos" } + val subsequence = buffer.subSequence(charStartIndexInBuffer, readRowUntilPos) + + val (rowCharOffsets, lastRowOccupiedWidth) = layouter.layoutOneLine( + subsequence, + contentWidth, + lastOccupiedWidth, + charStartIndexInBuffer + ) +// nodeValue.rowBreakOffsets += rowCharOffsets + logL.d { "row break add $rowCharOffsets lw = $lastRowOccupiedWidth" } + rowBreakOffsets.addToThisAscendingListWithoutDuplicate(rowCharOffsets) + lastOccupiedWidth = lastRowOccupiedWidth + charStartIndexInBuffer = readRowUntilPos + } + if (charStartIndexInBuffer < nodeValue.bufferOffsetEndExclusive) { +// val preserveIndexFrom = nodeValue.rowBreakOffsets.binarySearchForMinIndexOfValueAtLeast(endPosExclusive) + val searchForValue = minOf(nodeValue.bufferOffsetEndExclusive, maxOf((rowBreakOffsets.lastOrNull() ?: -1) + 1, charStartIndexInBuffer)) + val preserveIndexFrom = nodeValue.rowBreakOffsets.binarySearchForMinIndexOfValueAtLeast(searchForValue) + val preserveIndexTo = if (nodeStartPos + nodeValue.bufferLength >= length) { + nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(nodeValue.bufferOffsetEndExclusive) // keep the row after the last '\n' + } else { + nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(nodeValue.bufferOffsetEndExclusive - 1) + } + logL.d { "reach the end, preserve RB from $preserveIndexFrom (at least $searchForValue) ~ $preserveIndexTo (${nodeValue.bufferOffsetEndExclusive}). RB = ${nodeValue.rowBreakOffsets}." } + val restoreRowBreaks = nodeValue.rowBreakOffsets.subList(preserveIndexFrom, minOf(nodeValue.rowBreakOffsets.size, preserveIndexTo + 1)) + if (restoreRowBreaks.isNotEmpty() || nodeValue.isEndWithForceRowBreak) { + rowBreakOffsets.addToThisAscendingListWithoutDuplicate(restoreRowBreaks) + logL.d { "Restore lw ${nodeValue.lastRowWidth}." } + lastOccupiedWidth = nodeValue.lastRowWidth + isEndWithForceRowBreak = isEndWithForceRowBreak || nodeValue.isEndWithForceRowBreak + } + } + nodeValue.rowBreakOffsets = rowBreakOffsets + nodeValue.lastRowWidth = lastOccupiedWidth + nodeValue.isEndWithForceRowBreak = isEndWithForceRowBreak + recomputeAggregatedValues(node) // TODO optimize + + if (isBreakOnEncounterLineBreak && isBreakAfterThisIteration) { // TODO it can be further optimized to break immediately on line break + logL.d { "break" } + break + } + + node = tree.nextNode(node)?.also { + logL.d { "node ${node!!.value.debugKey()} b#${node!!.value.bufferIndex} next ${it.value.debugKey()} b#${it!!.value.bufferIndex}" } + } + if (node != null) { + nodeStartPos += nodeValue.bufferLength + val nodeValue = node.value + val buffer = buffers[nodeValue.bufferIndex] + lineBreakIndexFrom = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(nodeValue.bufferOffsetStart) + charStartIndexInBuffer = nodeValue.bufferOffsetStart + } + } + +// tree.visitInPostOrder { +// recomputeAggregatedValues(it) +// } + } + val numOfRows: Int - get() = tree.getRoot().numRowBreaks() + 1 + get() = tree.getRoot().numRowBreaks() + 1 + // TODO cache the result + run { + val lastNode = tree.rightmost(tree.getRoot()).takeIf { it.isNotNil() } + val lastValue = lastNode?.value ?: return@run 0 + val lastLineOffset = buffers[lastValue.bufferIndex].lineOffsetStarts.let { + val lastIndex = it.binarySearchForMaxIndexOfValueAtMost(lastValue.bufferOffsetEndExclusive - 1) + if (lastIndex in 0 .. it.lastIndex) { + it[lastIndex] + } else { + null + } + } ?: return@run 0 + val lastNodePos = findPositionStart(lastNode) + if (lastNodePos + (lastLineOffset - lastValue.bufferOffsetStart) == lastIndex) { + 1 // one extra row if the string ends with '\n' + } else { + 0 + } + } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 1fa4c4ac..76c9ec1a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -11,7 +11,8 @@ class BigTextNodeValue : Comparable, DebuggableNode = sortedSetOf() var rowBreakOffsets: List = emptyList() - var lastRowWidth: Float = -1f + var lastRowWidth: Float = 0f + var isEndWithForceRowBreak: Boolean = false var bufferIndex: Int = -1 var bufferOffsetStart: Int = -1 @@ -39,7 +40,7 @@ class BigTextNodeValue : Comparable, DebuggableNode.Node): String = - "$leftStringLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.length()}" + "$leftStringLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.length()} r $leftNumOfRowBreaks/$rowBreakOffsets lw $lastRowWidth" } class TextBuffer(val size: Int) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt index 9b1f4ce7..42829153 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/MonospaceTextLayouter.kt @@ -130,7 +130,7 @@ class MonospaceTextLayouter : TextLayouter { var currentRowOccupiedWidth = firstRowOccupiedWidth var numCharsInCurrentRow = 0 charWidths.forEachIndexed { i, w -> // O(line string length) - if (currentRowOccupiedWidth + w > contentWidth && numCharsInCurrentRow > 0) { + if (currentRowOccupiedWidth + w > contentWidth && (numCharsInCurrentRow > 0 || currentRowOccupiedWidth > 0)) { numCharsPerRow += numCharsInCurrentRow numCharsInCurrentRow = 0 currentRowOccupiedWidth = 0f diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt index c6412345..ecfdedf9 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt @@ -2,15 +2,25 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.MonospaceTextLayouter +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.isD +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.logL +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.TestMethodOrder import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource +import kotlin.random.Random +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +private var random: Random = Random + +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) class BigTextImplLayoutTest { @ParameterizedTest - @ValueSource(ints = [65536, 64]) + @ValueSource(ints = [65536, 64, 16]) fun layoutOneLineJustFit(chunkSize: Int) { val testString = "1234567890<234567890 // val str = if (index < splitted.lastIndex) "$s\n" else s - str.chunked(10).let { ss -> + str.chunked(softWrapAt).let { ss -> val ss = if (ss.isEmpty()) listOf(str) else ss if (index < splitted.lastIndex) { ss.mapIndexed { i, s -> @@ -60,15 +70,21 @@ class BigTextImplLayoutTest { } } } - println("exp $expectedRows") - assertEquals(expectedRows.size, bigTextImpl.numOfRows) - expectedRows.forEachIndexed { index, s -> - assertEquals(s, bigTextImpl.findRowString(index)) +// println("exp $expectedRows") + try { + assertEquals(expectedRows.size, bigTextImpl.numOfRows) + expectedRows.forEachIndexed { index, s -> + assertEquals(s, bigTextImpl.findRowString(index)) + } + } catch (e: Throwable) { + bigTextImpl.printDebug("ERROR") + println("exp $expectedRows") + throw e } } @ParameterizedTest - @ValueSource(ints = [65536, 64]) + @ValueSource(ints = [65536, 64, 16]) fun layoutMultipleLines1(chunkSize: Int) { val testString = "abcd\n1234567890<234567890 + val initial = randomString(666, isAddNewLine = true) + val t = BigTextVerifyImpl(chunkSize = chunkSize).apply { + append(initial) + bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + bigTextImpl.setContentWidth(16f * softWrapAt + 1.23f) + } + listOf(15, 4, 1, 1, 2, 8, 16, 19, 200, 1235, 2468, 10001, 257).forEachIndexed { i, it -> + t.insertAt(0, randomString(it, isAddNewLine = false) + "\n") + t.printDebug("after relayout $softWrapAt, $i") + verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + } + } + } + + @ParameterizedTest + @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) + @Order(Integer.MAX_VALUE - 200) // This test is time-consuming. Run at 2nd last! + fun insertNewLines(chunkSize: Int) { + random = Random(1234566) // use a fixed seed for easier debug + listOf(10, 37, 100, 1000).forEach { softWrapAt -> + val initial = randomString(666, isAddNewLine = true) + val t = BigTextVerifyImpl(chunkSize = chunkSize).apply { + append(initial) + bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + bigTextImpl.setContentWidth(16f * softWrapAt + 1.23f) + } + repeat(1000) { i -> + println("Iterate $softWrapAt, $i") + val length = when (random.nextInt(100)) { + in 0..9 -> 0 + in 10..39 -> 1 + in 40..59 -> random.nextInt(2, 6) + in 60..74 -> random.nextInt(6, 15) + in 75..86 -> random.nextInt(15, 100) + in 87..95 -> random.nextInt(100, 350) + in 96..98 -> random.nextInt(350, 1000) + in 99..99 -> random.nextInt(1000, 10000) + else -> throw IllegalStateException() + } + val pos = when (random.nextInt(10)) { + in 0..1 -> 0 + in 2..3 -> t.length + else -> random.nextInt(t.length + 1) + } + t.insertAt(pos, "\n".repeat(length)) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + } + } + } + + @ParameterizedTest + @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) + @Order(Integer.MAX_VALUE - 100) // This test is pretty time-consuming. Run at the last! + fun manyInserts(chunkSize: Int) { //if (chunkSize != 256) return + random = Random(1234567) // use a fixed seed for easier debug + repeat(10) { repeatIt -> + listOf(10, 37, 100, 1000, 10000).forEach { softWrapAt -> + val initial = randomString(random.nextInt(1000), isAddNewLine = true) + val t = BigTextVerifyImpl(chunkSize = chunkSize).apply { + append(initial) + bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + bigTextImpl.setContentWidth(16f * softWrapAt + 1.23f) + } + val numInsertTimes = if (repeatIt > 0) { + when (softWrapAt) { + in 0 .. 30 -> 407 + in 31 .. 200 -> 607 + else -> 1007 + } + } else { + 1007 + } + repeat(numInsertTimes) { i -> + val length = when (random.nextInt(100)) { + in 0..44 -> random.nextInt(10) + in 45..69 -> random.nextInt(10, 100) + in 70..87 -> random.nextInt(100, 1000) + in 88..97 -> random.nextInt(1000, 10000) + in 98..99 -> random.nextInt(10000, 80000) + else -> throw IllegalStateException() + } + val pos = when (random.nextInt(10)) { + in 0..1 -> 0 + in 2..3 -> t.length + else -> random.nextInt(t.length + 1) + } + t.insertAt(pos, randomString(length, isAddNewLine = random.nextBoolean()) + "\n") + logL.d { t.inspect("after relayout $repeatIt $softWrapAt, $i") } + println("Iterate $repeatIt, $softWrapAt, $i") + if (i >= 43) { + isD = true + } + verifyBigTextImplAgainstTestString( + testString = t.stringImpl.fullString(), + bigTextImpl = t.bigTextImpl, + softWrapAt = softWrapAt + ) + } + } + } + } + + @BeforeTest + fun beforeEach() { + random = Random + } +} + +fun randomString(length: Int, isAddNewLine: Boolean): String = (0 until length).joinToString("") { + when { + isAddNewLine && random.nextInt(100) == 0 -> "\n" + else -> ('a' + random.nextInt(26)).toString() + } } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index c393a00a..0bc62618 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -91,4 +91,6 @@ internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : Big } fun printDebug(label: String = "") = bigTextImpl.printDebug(label) + + fun inspect(label: String = "") = bigTextImpl.inspect(label) } From b7bb5c31a8e83781e51e3a0047e557803e4142ed Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 24 Aug 2024 20:50:45 +0800 Subject: [PATCH 041/195] add layout process to deletions in BigTextImpl --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 12 +- .../test/bigtext/BigTextImplLayoutTest.kt | 234 +++++++++++++++++- 2 files changed, 244 insertions(+), 2 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 55aa087f..e613ccd2 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -519,6 +519,12 @@ class BigTextImpl : BigText { // computeCurrentNodeProperties(it.value) // } + newNodesInDescendingOrder.forEach { + val startPos = findPositionStart(it.node!!) + val endPos = startPos + it.bufferLength + layout(startPos, endPos) + } + layout(maxOf(0, start - 1), minOf(length, start + 1)) log.d { inspect("Finish D " + node?.value?.debugKey()) } @@ -662,7 +668,11 @@ class BigTextImpl : BigText { logL.d { "charStartIndexInBuffer = $charStartIndexInBuffer" } // we are starting at charStartIndexInBuffer without carrying over last width, so include the row break at charStartIndexInBuffer - val restoreRowBreakOffsets = nodeValue.rowBreakOffsets.subList(0, maxOf(0, nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(charStartIndexInBuffer) + 1)) + val restoreRowBreakOffsets = if (startPos > 0) { + nodeValue.rowBreakOffsets.subList(0, maxOf(0, nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(charStartIndexInBuffer) + 1)) + } else { + emptyList() + } logL.d { "restore row breaks of starting node $restoreRowBreakOffsets" } var hasRestoredRowBreaks = false diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt index ecfdedf9..b9dbf80d 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt @@ -1,5 +1,6 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext +import com.sunnychung.application.multiplatform.hellohttp.extension.insert import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.MonospaceTextLayouter import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.isD @@ -9,6 +10,7 @@ import org.junit.jupiter.api.Order import org.junit.jupiter.api.TestMethodOrder import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource +import kotlin.math.min import kotlin.random.Random import kotlin.test.BeforeTest import kotlin.test.Test @@ -243,7 +245,6 @@ class BigTextImplLayoutTest { @ParameterizedTest @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) - @Order(Integer.MAX_VALUE - 300) fun insertAtBeginning(chunkSize: Int) { random = Random(123456) // use a fixed seed for easier debug listOf(10, 25, 37, 100, 1000, 10000).forEach { softWrapAt -> @@ -349,6 +350,237 @@ class BigTextImplLayoutTest { } } + @ParameterizedTest + @ValueSource(ints = [65536, 64]) + fun deleteTriggersRelayout1(chunkSize: Int) { + val initial = "abcd\n1234567890<234567890 + val initial = randomString(16666, isAddNewLine = true) + val t = BigTextVerifyImpl(chunkSize = chunkSize).apply { + append(initial) + bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + bigTextImpl.setContentWidth(16f * softWrapAt + 1.23f) + } + listOf(15, 4, 1, 1, 2, 8, 16, 19, 200, 1235, 2468, 10001, 257, 1, 0, 13).forEachIndexed { i, it -> + t.delete(0, 0 + it) +// t.printDebug("after relayout $softWrapAt, $i") + verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + } + } + } + + @ParameterizedTest + @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) + fun deleteNewLines1(chunkSize: Int) { + val initial = "\n\nabc\n\ndefghij\n\n\n\n\nxyz\n\n\n\n\n\n\n\n\n\nA\n\n\n" + val t = BigTextVerifyImpl(chunkSize = chunkSize).apply { + append(initial) + bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + bigTextImpl.setContentWidth(16f * 10 + 1.23f) + } + verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + listOf((25..32), (14 .. 16), (14 .. 15), (5 .. 7), (1 .. 1), (0 .. 0), (14 .. 16)).forEach { + t.delete(it) + t.printDebug("after delete $it") + verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + } + } + + @ParameterizedTest + @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) + @Order(Integer.MAX_VALUE - 200) // This test is time-consuming. Run at 2nd last! + fun deleteNewLines2(chunkSize: Int) { + random = Random(23456) // use a fixed seed for easier debug + listOf(10, 25, 37, 100, 1000, 10000).forEach { softWrapAt -> + var s = randomString(666, isAddNewLine = true) + repeat(1000) { + val length = when (random.nextInt(100)) { + in 0..59 -> random.nextInt(1, 6) + in 60..74 -> random.nextInt(6, 15) + in 75..86 -> random.nextInt(15, 100) + in 87..95 -> random.nextInt(100, 350) + in 96..98 -> random.nextInt(350, 1000) + in 99..99 -> random.nextInt(1000, 10000) + else -> throw IllegalStateException() + } + s = s.insert(random.nextInt(s.length), "\n".repeat(length)) + } + val initial = s + val t = BigTextVerifyImpl(chunkSize = chunkSize).apply { + append(initial) + bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + bigTextImpl.setContentWidth(16f * softWrapAt + 1.23f) + } + repeat(1000) { i -> + val length = when (random.nextInt(100)) { + in 0..39 -> random.nextInt(1, 3) + in 40..59 -> random.nextInt(3, 6) + in 60..74 -> random.nextInt(6, 15) + in 75..86 -> random.nextInt(15, 100) + in 87..94 -> random.nextInt(100, 350) + in 95..97 -> random.nextInt(350, 1000) + in 98..99 -> random.nextInt(1000, 10000) + else -> throw IllegalStateException() + } + val pos = when (random.nextInt(10)) { + in 0..1 -> 0 + in 2..3 -> t.length + else -> random.nextInt(t.length + 1) + } + t.delete(pos, minOf(t.length, pos + length)) +// t.printDebug("after relayout $softWrapAt, $i") + verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + } + } + } + + @ParameterizedTest + @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) + @Order(Integer.MAX_VALUE - 100) // This test is pretty time-consuming. Run at the last! + fun manyDeletes(chunkSize: Int) { + random = Random(2345678) // use a fixed seed for easier debug + repeat(10) { repeatIt -> + listOf(10, 37, 100, 1000, 10000).forEach { softWrapAt -> + val initial = randomString(random.nextInt(850_000, 1_000_000), isAddNewLine = true) + val t = BigTextVerifyImpl(chunkSize = chunkSize).apply { + append(initial) + bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + bigTextImpl.setContentWidth(16f * softWrapAt + 1.23f) + } + val numDeleteTimes = if (repeatIt > 0) { + when (softWrapAt) { + in 0 .. 30 -> 407 + in 31 .. 200 -> 507 + else -> 807 + } + } else { + 1007 + } + repeat(numDeleteTimes) { i -> + val length = when (random.nextInt(100)) { + in 0..44 -> random.nextInt(10) + in 45..69 -> random.nextInt(10, 100) + in 70..87 -> random.nextInt(100, 1000) + in 88..97 -> random.nextInt(1000, 10000) + in 98..99 -> random.nextInt(10000, 80000) + else -> throw IllegalStateException() + } + val pos = when (random.nextInt(10)) { + in 0..1 -> 0 + in 2..3 -> t.length + else -> random.nextInt(t.length + 1) + } + t.delete(pos, min(t.length, pos + length)) + logL.d { t.inspect("after relayout $repeatIt $softWrapAt, $i") } + println("Iterate $repeatIt, $softWrapAt, $i") + verifyBigTextImplAgainstTestString( + testString = t.stringImpl.fullString(), + bigTextImpl = t.bigTextImpl, + softWrapAt = softWrapAt + ) + } + } + } + } + @BeforeTest fun beforeEach() { random = Random From 7328a66f6f48dd07cee18d577fc0429ab73f4460 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 24 Aug 2024 22:38:24 +0800 Subject: [PATCH 042/195] add tests on multiple insertions and deletions --- .../hellohttp/ux/bigtext/BigText.kt | 10 ++ .../test/bigtext/BigTextImplLayoutTest.kt | 154 +++++++++++++++++- 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt index fb29e699..fdd33f2b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -21,6 +21,16 @@ interface BigText { fun delete(range: IntRange): Int = delete(range.start, range.endInclusive + 1) + fun replace(start: Int, endExclusive: Int, text: String) { + delete(start, endExclusive) + insertAt(start, text) + } + + fun replace(range: IntRange, text: String) { + delete(range) + insertAt(range.start, text) + } + override fun hashCode(): Int override fun equals(other: Any?): Boolean diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt index b9dbf80d..95bb7b29 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt @@ -334,7 +334,7 @@ class BigTextImplLayoutTest { in 2..3 -> t.length else -> random.nextInt(t.length + 1) } - t.insertAt(pos, randomString(length, isAddNewLine = random.nextBoolean()) + "\n") + t.insertAt(pos, randomString(length, isAddNewLine = random.nextBoolean())) logL.d { t.inspect("after relayout $repeatIt $softWrapAt, $i") } println("Iterate $repeatIt, $softWrapAt, $i") if (i >= 43) { @@ -581,6 +581,158 @@ class BigTextImplLayoutTest { } } + @ParameterizedTest + @ValueSource(ints = [65536, 64]) + fun replaceTriggersRelayout1(chunkSize: Int) { + val beingReplaced = "XYZ\n" + val initial = "abcd\nABCDEFGHIJ + val initial = randomString(66666, isAddNewLine = true) + val t = BigTextVerifyImpl(chunkSize = chunkSize).apply { + append(initial) + bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + bigTextImpl.setContentWidth(16f * softWrapAt + 1.23f) + } + val lengths = listOf(15, 4, 1, 1, 2, 8, 16, 19, 200, 1235, 2468, 10001, 257) + lengths.forEachIndexed { i, it -> + t.replace(0 until lengths.random(random), randomString(it, isAddNewLine = false) + "\n") +// t.printDebug("after relayout $softWrapAt, $i") + verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + } + } + } + + @ParameterizedTest + @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) + @Order(Integer.MAX_VALUE - 100) // This test is pretty time-consuming. Run at the last! + fun manyInsertsAndDeletes(chunkSize: Int) { //if (chunkSize != 256) return + random = Random(34567890) // use a fixed seed for easier debug + repeat(10) { repeatIt -> + listOf(10, 37, 100, 1000, 10000).forEach { softWrapAt -> + val initial = randomString(random.nextInt(1000, 2000), isAddNewLine = true) + val t = BigTextVerifyImpl(chunkSize = chunkSize).apply { + append(initial) + bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + bigTextImpl.setContentWidth(16f * softWrapAt + 1.23f) + } + val numOperationTimes = if (repeatIt > 0) { + when (softWrapAt) { + in 0 .. 30 -> 809 + in 31 .. 200 -> 1207 + else -> 1807 + } + } else { + 2109 + } + repeat(numOperationTimes) { i -> + val length = when (random.nextInt(100)) { + in 0..44 -> random.nextInt(10) + in 45..69 -> random.nextInt(10, 100) + in 70..87 -> random.nextInt(100, 1000) + in 88..97 -> random.nextInt(1000, 10000) + in 98..99 -> random.nextInt(10000, 80000) + else -> throw IllegalStateException() + } + val pos = when (random.nextInt(10)) { + in 0..1 -> 0 + in 2..3 -> t.length + else -> random.nextInt(t.length + 1) + } + println("Iterate $repeatIt, $softWrapAt, $i") + when (random.nextInt(5)) { + in 0 .. 1 -> t.insertAt(pos, randomString(length, isAddNewLine = random.nextBoolean())) + in 2 .. 3 -> t.delete(pos, min(t.length, pos + length)) + 4 -> t.replace(pos, min(t.length, pos + length), randomString(length, isAddNewLine = random.nextBoolean())) + } + logL.d { t.inspect("after relayout $repeatIt $softWrapAt, $i") } + verifyBigTextImplAgainstTestString( + testString = t.stringImpl.fullString(), + bigTextImpl = t.bigTextImpl, + softWrapAt = softWrapAt + ) + } + } + } + } + @BeforeTest fun beforeEach() { random = Random From 5dd27f637081dd64189331897a6e531a2a123cd4 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 25 Aug 2024 22:27:38 +0800 Subject: [PATCH 043/195] update BigMonospaceText and add BigTextLineNumbersView to integrate basics with BigTextImpl --- .../util/ComposeUnicodeCharMeasurer.kt | 5 + .../hellohttp/ux/CodeEditorView.kt | 72 ++++++- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 181 +++++++++++------- .../hellohttp/ux/bigtext/BigText.kt | 2 + .../hellohttp/ux/bigtext/BigTextImpl.kt | 179 ++++++++++++++++- .../ux/bigtext/BigTextLayoutResult.kt | 17 +- 6 files changed, 366 insertions(+), 90 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt index d6d17e87..621849a7 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt @@ -6,6 +6,9 @@ import java.util.LinkedHashMap class ComposeUnicodeCharMeasurer(private val measurer: TextMeasurer, private val style: TextStyle) : CharMeasurer { private val charWidth: MutableMap = LinkedHashMap(256) + private val charHeight: Float = measurer.measure("|\n|").let { + it.getLineTop(1) - it.getLineTop(0) + } /** * Time complexity = O(S lg C) @@ -49,6 +52,8 @@ class ComposeUnicodeCharMeasurer(private val measurer: TextMeasurer, private val } } + fun getRowHeight(): Float = charHeight + fun measureExactWidthOf(targets: List): List { val result = measurer.measure(targets.joinToString("\n"), style, softWrap = false) return targets.mapIndexed { index, s -> diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 0084421a..3e9d5f6c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.foundation.verticalScroll import androidx.compose.material.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -60,13 +59,16 @@ import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryAp import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForInsertionPoint import com.sunnychung.application.multiplatform.hellohttp.extension.contains import com.sunnychung.application.multiplatform.hellohttp.extension.insert +import com.sunnychung.application.multiplatform.hellohttp.util.ComposeUnicodeCharMeasurer import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextViewState -import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.InefficientBigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.MonospaceTextLayouter +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.createFromLargeString import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldColors import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDefaults import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast @@ -436,10 +438,23 @@ fun CodeEditorView( val bigTextViewState = remember { BigTextViewState() } var layoutResult by remember { mutableStateOf(null) } - BigLineNumbersView( +// BigLineNumbersView( +// scrollState = scrollState, +// bigTextViewState = bigTextViewState, +// textLayout = layoutResult, +// collapsableLines = collapsableLines, +// collapsedLines = collapsedLines.values.toList(), +// onCollapseLine = onCollapseLine, +// onExpandLine = onExpandLine, +// modifier = Modifier.fillMaxHeight(), +// ) + + var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(textValue.text)) } + + BigTextLineNumbersView( scrollState = scrollState, bigTextViewState = bigTextViewState, - textLayout = layoutResult, + bigText = bigTextValue as BigTextImpl, collapsableLines = collapsableLines, collapsedLines = collapsedLines.values.toList(), onCollapseLine = onCollapseLine, @@ -449,7 +464,7 @@ fun CodeEditorView( if (isReadOnly) { BigMonospaceText( - text = textValue.text, + text = bigTextValue as BigTextImpl, padding = PaddingValues(4.dp), visualTransformation = visualTransformationToUse, fontSize = LocalFont.current.codeEditorBodyFontSize, @@ -531,7 +546,7 @@ fun CodeEditorView( } )*/ - var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(InefficientBigText(text)) } // FIXME performance + var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(text)) } // FIXME performance BigMonospaceTextField( text = bigTextValue, @@ -782,6 +797,51 @@ class CollapsedLinesState(val collapsableLines: List, collapsedLines: val collapsedLines = collapsedLines.associateBy { it.first }.toSortedMap() // TODO optimize using range tree } +@Composable +fun BigTextLineNumbersView( + modifier: Modifier = Modifier, + bigTextViewState: BigTextViewState, + bigText: BigTextImpl, + scrollState: ScrollState, + collapsableLines: List, + collapsedLines: List, + onCollapseLine: (Int) -> Unit, + onExpandLine: (Int) -> Unit, +) = with(LocalDensity.current) { + val colours = LocalColor.current + val fonts = LocalFont.current + + val textStyle = LocalTextStyle.current.copy( + fontSize = fonts.codeEditorLineNumberFontSize, + fontFamily = FontFamily.Monospace, + color = colours.unimportant, + ) + val collapsedLinesState = CollapsedLinesState(collapsableLines = collapsableLines, collapsedLines = collapsedLines) + + var prevHasLayouted by remember { mutableStateOf(false) } + prevHasLayouted = bigText.hasLayouted + prevHasLayouted + + val viewportTop = scrollState.value + val firstLine = bigText.findLineIndexByRowIndex(bigTextViewState.firstVisibleRow) ?: 0 + val lastLine = (bigText.findLineIndexByRowIndex(bigTextViewState.lastVisibleRow) ?: -100) + 1 + log.v { "lastVisibleRow = ${bigTextViewState.lastVisibleRow} (L $lastLine); totalLines = ${bigText.numOfLines}" } + val rowHeight = ((bigText.layouter as? MonospaceTextLayouter)?.charMeasurer as? ComposeUnicodeCharMeasurer)?.getRowHeight() ?: 0f + CoreLineNumbersView( + firstLine = firstLine, + lastLine = minOf(lastLine, bigText.numOfLines ?: 1), + totalLines = bigText.numOfLines ?: 1, + lineHeight = (rowHeight).toDp(), +// getLineOffset = { (textLayout!!.getLineTop(it) - viewportTop).toDp() }, + getLineOffset = { ( bigText.findFirstRowIndexOfLine(it) * rowHeight - viewportTop).toDp() }, + textStyle = textStyle, + collapsedLinesState = collapsedLinesState, + onCollapseLine = onCollapseLine, + onExpandLine = onExpandLine, + modifier = modifier + ) +} + @Composable private fun CoreLineNumbersView( modifier: Modifier = Modifier, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 9a3cc3ab..6e6df8ee 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -61,7 +61,6 @@ import androidx.compose.ui.semantics.editableText import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.text import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Paragraph import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.CommitTextCommand @@ -70,14 +69,13 @@ import androidx.compose.ui.text.input.SetComposingTextCommand import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMaxIndexOfValueAtMost import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.isCtrlOrCmdPressed import com.sunnychung.application.multiplatform.hellohttp.extension.toTextInput +import com.sunnychung.application.multiplatform.hellohttp.util.ComposeUnicodeCharMeasurer import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor @@ -102,7 +100,34 @@ fun BigMonospaceText( onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, ) = CoreBigMonospaceText( modifier = modifier, - text = InefficientBigText(text), + text = BigText.createFromLargeString(text), //InefficientBigText(text), + padding = padding, + fontSize = fontSize, + color = color, + isSelectable = isSelectable, + isEditable = false, + onTextChange = {}, + visualTransformation = visualTransformation, + scrollState = scrollState, + viewState = viewState, + onTextLayout = onTextLayout, +) + +@Composable +fun BigMonospaceText( + modifier: Modifier = Modifier, + text: BigTextImpl, + padding: PaddingValues = PaddingValues(4.dp), + fontSize: TextUnit = LocalFont.current.bodyFontSize, + color: Color = LocalColor.current.text, + isSelectable: Boolean = false, + visualTransformation: VisualTransformation, + scrollState: ScrollState = rememberScrollState(), + viewState: BigTextViewState = remember { BigTextViewState() }, + onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, +) = CoreBigMonospaceText( + modifier = modifier, + text = text, padding = padding, fontSize = fontSize, color = color, @@ -129,7 +154,7 @@ fun BigMonospaceTextField( onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, ) = CoreBigMonospaceText( modifier = modifier, - text = text, + text = text as BigTextImpl, padding = padding, fontSize = fontSize, color = color, @@ -146,7 +171,7 @@ fun BigMonospaceTextField( @Composable private fun CoreBigMonospaceText( modifier: Modifier = Modifier, - text: BigText, + text: BigTextImpl, padding: PaddingValues = PaddingValues(4.dp), fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, @@ -189,23 +214,28 @@ private fun CoreBigMonospaceText( (padding.calculateStartPadding(LayoutDirection.Ltr) + padding.calculateEndPadding(LayoutDirection.Ltr)).toPx() } var lineHeight by remember { mutableStateOf(0f) } - var charWidth by remember { mutableStateOf(0f) } - val numOfCharsPerLine = rememberLast(density.density, density.fontScale, fontSize, width) { - if (width > 0) { - Paragraph( - text = "0".repeat(1000), - style = textStyle, - constraints = Constraints(maxWidth = contentWidth.toInt()), - density = density, - fontFamilyResolver = fontFamilyResolver, - ).let { - lineHeight = it.getLineTop(1) - it.getLineTop(0) - charWidth = it.width / it.getLineEnd(0) - it.getLineEnd(0) - } - } else { - 0 - } +// var charWidth by remember { mutableStateOf(0f) } +// val numOfCharsPerLine = rememberLast(density.density, density.fontScale, fontSize, width) { +// if (width > 0) { +// Paragraph( +// text = "0".repeat(1000), +// style = textStyle, +// constraints = Constraints(maxWidth = contentWidth.toInt()), +// density = density, +// fontFamilyResolver = fontFamilyResolver, +// ).let { +// lineHeight = it.getLineTop(1) - it.getLineTop(0) +// charWidth = it.width / it.getLineEnd(0) +// it.getLineEnd(0) +// } +// } else { +// 0 +// } +// } + if (width > 0) { + text.setLayouter(textLayouter) + text.setContentWidth(contentWidth) + lineHeight = (textLayouter.charMeasurer as ComposeUnicodeCharMeasurer).getRowHeight() } val visualTransformationToUse = visualTransformation val transformedText = rememberLast(text.length, text.hashCode(), visualTransformationToUse) { @@ -213,28 +243,28 @@ private fun CoreBigMonospaceText( log.v { "transformed text = `$it`" } } } - val layoutResult = rememberLast(transformedText.text.length, transformedText.hashCode(), textStyle, lineHeight, contentWidth, textLayouter) { - textLayouter.layout( - text = text.fullString(), - transformedText = transformedText, - lineHeight = lineHeight, - contentWidth = contentWidth, - ).also { - if (onTextLayout != null) { - onTextLayout(it) - } - } - } - val rowStartCharIndices = layoutResult.rowStartCharIndices +// val layoutResult = rememberLast(transformedText.text.length, transformedText.hashCode(), textStyle, lineHeight, contentWidth, textLayouter) { +// textLayouter.layout( +// text = text.fullString(), +// transformedText = transformedText, +// lineHeight = lineHeight, +// contentWidth = contentWidth, +// ).also { +// if (onTextLayout != null) { +// onTextLayout(it) +// } +// } +// } +// val rowStartCharIndices = layoutResult.rowStartCharIndices - rememberLast(height, rowStartCharIndices.size, lineHeight) { + rememberLast(height, text.numOfRows, lineHeight) { scrollState::class.declaredMemberProperties.first { it.name == "maxValue" } .apply { (this as KMutableProperty) setter.isAccessible = true val scrollableHeight = maxOf( 0f, - rowStartCharIndices.size * lineHeight - height + + text.numOfRows * lineHeight - height + with (density) { (padding.calculateTopPadding() + padding.calculateBottomPadding()).toPx() } @@ -266,25 +296,33 @@ private fun CoreBigMonospaceText( fun getTransformedCharIndex(x: Float, y: Float, mode: ResolveCharPositionMode): Int { val row = ((viewportTop + y) / lineHeight).toInt() // val col = (x / charWidth).toInt() - if (row > layoutResult.rowStartCharIndices.lastIndex) { + if (row > text.lastRowIndex) { return maxOf(0, transformedText.text.length - if (mode == ResolveCharPositionMode.Selection) 1 else 0) } else if (row < 0) { return 0 } - val numCharsInThisRow = if (row + 1 <= layoutResult.rowStartCharIndices.lastIndex) { - layoutResult.rowStartCharIndices[row + 1] - layoutResult.rowStartCharIndices[row] - 1 - } else { - maxOf(0, transformedText.text.length - layoutResult.rowStartCharIndices[row] - if (mode == ResolveCharPositionMode.Selection) 1 else 0) - } - val charIndex = (layoutResult.rowStartCharIndices[row] .. layoutResult.rowStartCharIndices[row] + numCharsInThisRow).let { range -> - var accumWidth = 0f - range.first { - if (it < range.last) { - accumWidth += layoutResult.findCharWidth(transformedText.text.substring(it..it)) - } - return@first (x < accumWidth || it >= range.last) - } - } +// val numCharsInThisRow = if (row + 1 <= layoutResult.rowStartCharIndices.lastIndex) { +// layoutResult.rowStartCharIndices[row + 1] - layoutResult.rowStartCharIndices[row] - 1 +// } else { +// maxOf(0, transformedText.text.length - layoutResult.rowStartCharIndices[row] - if (mode == ResolveCharPositionMode.Selection) 1 else 0) +// } +// val charIndex = (layoutResult.rowStartCharIndices[row] .. layoutResult.rowStartCharIndices[row] + numCharsInThisRow).let { range -> +// var accumWidth = 0f +// range.first { +// if (it < range.last) { +// accumWidth += layoutResult.findCharWidth(transformedText.text.substring(it..it)) +// } +// return@first (x < accumWidth || it >= range.last) +// } +// } + + val rowString = text.findRowString(row) + var accumWidth = 0f + val charIndex = rowString.indexOfFirst { + accumWidth += textLayouter.charMeasurer.findCharWidth(it.toString()) + x < accumWidth + }.takeIf { it >= 0 } ?: rowString.length + return charIndex } @@ -295,7 +333,7 @@ private fun CoreBigMonospaceText( if (char == "\n") { // selecting \n shows a narrow width textLayouter.charMeasurer.findCharWidth(" ") } else { - layoutResult.findCharWidth(char) + textLayouter.charMeasurer.findCharWidth(char) } } .sum() @@ -383,7 +421,7 @@ private fun CoreBigMonospaceText( viewState.updateCursorIndexByTransformed(transformedText) } ) - .pointerInput(isEditable, layoutResult, scrollState.value, lineHeight, contentWidth, transformedText.text.length, transformedText.text.hashCode()) { + .pointerInput(isEditable, text, scrollState.value, lineHeight, contentWidth, transformedText.text.length, transformedText.text.hashCode()) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() @@ -504,24 +542,25 @@ private fun CoreBigMonospaceText( true } it.key in listOf(Key.DirectionUp, Key.DirectionDown) -> { - val row = layoutResult.rowStartCharIndices.binarySearchForMaxIndexOfValueAtMost(viewState.transformedCursorIndex) +// val row = layoutResult.rowStartCharIndices.binarySearchForMaxIndexOfValueAtMost(viewState.transformedCursorIndex) + val row = text.findRowIndexByPosition(viewState.transformedCursorIndex) val newRow = row + if (it.key == Key.DirectionDown) 1 else -1 viewState.transformedCursorIndex = Unit.let { if (newRow < 0) { 0 - } else if (newRow > layoutResult.rowStartCharIndices.lastIndex) { + } else if (newRow > text.lastRowIndex) { transformedText.text.length } else { - val col = viewState.transformedCursorIndex - layoutResult.rowStartCharIndices[row] - val newRowLength = if (newRow + 1 <= layoutResult.rowStartCharIndices.lastIndex) { - layoutResult.rowStartCharIndices[newRow + 1] - 1 + val col = viewState.transformedCursorIndex - text.findRowPositionStartIndexByRowIndex(row) + val newRowLength = if (newRow + 1 <= text.lastRowIndex) { + text.findRowPositionStartIndexByRowIndex(newRow + 1) - 1 } else { transformedText.text.length - } - layoutResult.rowStartCharIndices[newRow] + } - text.findRowPositionStartIndexByRowIndex(newRow) if (col <= newRowLength) { - layoutResult.rowStartCharIndices[newRow] + col + text.findRowPositionStartIndexByRowIndex(newRow) + col } else { - layoutResult.rowStartCharIndices[newRow] + newRowLength + text.findRowPositionStartIndexByRowIndex(newRow) + newRowLength } } } @@ -538,26 +577,26 @@ private fun CoreBigMonospaceText( ) { val viewportBottom = viewportTop + height - if (lineHeight > 0) { + if (lineHeight > 0 && text.hasLayouted) { val firstRowIndex = maxOf(0, (viewportTop / lineHeight).toInt()) - val lastRowIndex = minOf(rowStartCharIndices.lastIndex, (viewportBottom / lineHeight).toInt() + 1) + val lastRowIndex = minOf(text.lastRowIndex, (viewportBottom / lineHeight).toInt() + 1) log.v { "row index = [$firstRowIndex, $lastRowIndex]; scroll = $viewportTop ~ $viewportBottom; line h = $lineHeight" } viewState.firstVisibleRow = firstRowIndex viewState.lastVisibleRow = lastRowIndex with(density) { (firstRowIndex..lastRowIndex).forEach { i -> - val startIndex = rowStartCharIndices[i] - val endIndex = if (i + 1 > rowStartCharIndices.lastIndex) { + val startIndex = text.findRowPositionStartIndexByRowIndex(i) + val endIndex = if (i + 1 > text.lastRowIndex) { transformedText.text.length } else { - rowStartCharIndices[i + 1] + text.findRowPositionStartIndexByRowIndex(i + 1) } val nonVisualEndIndex = maxOf(endIndex, startIndex + 1) - val cursorDisplayRangeEndIndex = if (i + 1 > rowStartCharIndices.lastIndex) { + val cursorDisplayRangeEndIndex = if (i + 1 > text.lastRowIndex) { transformedText.text.length } else { - maxOf(rowStartCharIndices[i + 1] - 1, startIndex) + maxOf(text.findRowPositionStartIndexByRowIndex(i + 1) - 1, startIndex) } // log.v { "line #$i: [$startIndex, $endIndex)" } val yOffset = (- viewportTop + (i/* - firstRowIndex*/) * lineHeight).toDp() @@ -589,7 +628,7 @@ private fun CoreBigMonospaceText( if (isEditable && isFocused && viewState.transformedCursorIndex in startIndex .. cursorDisplayRangeEndIndex) { var x = 0f (startIndex + 1 .. viewState.transformedCursorIndex).forEach { - x += layoutResult.findCharWidth(transformedText.text.substring(it - 1.. it - 1)) + x += textLayouter.charMeasurer.findCharWidth(transformedText.text.substring(it - 1.. it - 1)) } BigTextFieldCursor( lineHeight = lineHeight.toDp(), diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt index fdd33f2b..61b7c3bf 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -34,4 +34,6 @@ interface BigText { override fun hashCode(): Int override fun equals(other: Any?): Boolean + + companion object } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index e613ccd2..893e3f73 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -43,7 +43,9 @@ class BigTextImpl : BigText { val chunkSize: Int // TODO change to a large number - private var layouter: TextLayouter? = null + var layouter: TextLayouter? = null + private set + private var contentWidth: Float? = null constructor() { @@ -113,7 +115,114 @@ class BigTextImpl : BigText { }?.let { it to rowStart + it.value.leftNumOfRowBreaks /*findLineStart(it)*/ } } - fun findPositionStart(node: RedBlackTree.Node): Int { + fun findPositionByRowIndex(index: Int): Int { + if (!hasLayouted) { + return 0 + } + + return tree.findNodeByRowBreaks(index - 1)!!.second + } + + fun findRowIndexByPosition(position: Int): Int { + if (!hasLayouted) { + return 0 + } + + val node = tree.findNodeByCharIndex(position)!! + val startPos = findPositionStart(node) + val nv = node.value + val rowIndexInThisPartition = nv.rowBreakOffsets.binarySearchForMinIndexOfValueAtLeast(position - startPos) + val startRowIndex = findRowStart(node) + return startRowIndex + rowIndexInThisPartition + } + + fun findRowPositionStartIndexByRowIndex(index: Int): Int { + if (!hasLayouted) { + return 0 + } + + val node = tree.findNodeByRowBreaks(index - 1)!!.first + val rowStart = findRowStart(node) + val startPos = findPositionStart(node) + return startPos + if (index > 0) { + node.value.rowBreakOffsets[index - 1 - rowStart] + } else { + 0 + } + } + + fun findLineIndexByRowIndex(rowIndex: Int): Int { + if (!hasLayouted) { + return 0 + } + + val (node, rowIndexStart) = tree.findNodeByRowBreaks(rowIndex - 1)!! + val rowOffset = if (rowIndex > 0) { + node.value.rowBreakOffsets[rowIndex - 1 - rowIndexStart] + } else { + 0 + } + + val lineBreakOffsetStarts = buffers[node.value.bufferIndex].lineOffsetStarts + val lineBreakIndex = lineBreakOffsetStarts.binarySearchForMaxIndexOfValueAtMost(rowOffset) + return if (lineBreakIndex < 0) { + 0 + } else { + val lineStart = findLineStart(node) + lineStart + lineBreakIndex + 1 + } + } + + fun findFirstRowIndexOfLineOfRowIndex(rowIndex: Int): Int { + if (!hasLayouted) { + return 0 + } + + val lineIndex = findLineIndexByRowIndex(rowIndex) + val (lineStartNode, lineIndexStart) = tree.findNodeByLineBreaks(lineIndex - 1)!! +// val positionOfLineStartNode = findPositionStart(lineStartNode) + val lineOffsetStarts = buffers[lineStartNode.value.bufferIndex].lineOffsetStarts + val inRangeLineStartIndex = lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineStartNode.value.bufferOffsetStart) + val lineOffset = if (lineIndex - 1 >= 0) { + lineOffsetStarts[inRangeLineStartIndex + lineIndex - 1 - lineIndexStart] + } else { + 0 + } + + val rowBreakOffsetIndex = lineStartNode.value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(lineOffset + 1 /* rowBreak is 1 char after '\n' while lineBreak is right at '\n' */).also { // if lineOffset == 0, it should return -1 + if (it !in lineStartNode.value.rowBreakOffsets.indices && lineOffset != 0) { + throw IndexOutOfBoundsException("Cannot find rowBreakOffset $lineOffset") + } + } + val rowBreaksStart = findRowStart(lineStartNode) + return rowBreaksStart + rowBreakOffsetIndex + 1 + } + + fun findFirstRowIndexOfLine(lineIndex: Int): Int { + if (!hasLayouted) { + return 0 + } + + val (lineStartNode, lineIndexStart) = tree.findNodeByLineBreaks(lineIndex - 1)!! +// val positionOfLineStartNode = findPositionStart(lineStartNode) + val lineOffsetStarts = buffers[lineStartNode.value.bufferIndex].lineOffsetStarts + val inRangeLineStartIndex = lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineStartNode.value.bufferOffsetStart) + val lineOffset = if (lineIndex - 1 >= 0) { + lineOffsetStarts[inRangeLineStartIndex + lineIndex - 1 - lineIndexStart] + } else { + 0 + } + + val rowBreakOffsetIndex = lineStartNode.value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(lineOffset + 1 /* rowBreak is 1 char after '\n' while lineBreak is right at '\n' */).also { // if lineOffset == 0, it should return -1 + if (it !in lineStartNode.value.rowBreakOffsets.indices && lineOffset != 0) { + throw IndexOutOfBoundsException("Cannot find rowBreakOffset $lineOffset") + } + } + val rowBreaksStart = findRowStart(lineStartNode) + return rowBreaksStart + rowBreakOffsetIndex + 1 + } + + protected fun findPositionStart(node: RedBlackTree.Node): Int { var start = node.value.leftStringLength var node = node while (node.parent.isNotNil()) { @@ -125,7 +234,7 @@ class BigTextImpl : BigText { return start } - fun findLineStart(node: RedBlackTree.Node): Int { + protected fun findLineStart(node: RedBlackTree.Node): Int { var start = node.value.leftNumOfLineBreaks var node = node while (node.parent.isNotNil()) { @@ -137,6 +246,18 @@ class BigTextImpl : BigText { return start } + protected fun findRowStart(node: RedBlackTree.Node): Int { + var start = node.value.leftNumOfRowBreaks + var node = node + while (node.parent.isNotNil()) { + if (node === node.parent.right) { + start += node.parent.value.leftNumOfRowBreaks + node.parent.value.rowBreakOffsets.size + } + node = node.parent + } + return start + } + private fun insertChunkAtPosition(position: Int, chunkedString: String) { log.d { "insertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) @@ -538,11 +659,13 @@ class BigTextImpl : BigText { } override fun hashCode(): Int { - TODO("Not yet implemented") +// TODO("Not yet implemented") + return super.hashCode() } override fun equals(other: Any?): Boolean { - TODO("Not yet implemented") +// TODO("Not yet implemented") + return super.equals(other) } fun inspect(label: String = "") = buildString { @@ -582,6 +705,8 @@ class BigTextImpl : BigText { } fun setContentWidth(contentWidth: Float) { + require(contentWidth > EPS) { "contentWidth must be positive" } + if (this.contentWidth == contentWidth) { return } @@ -831,6 +956,9 @@ class BigTextImpl : BigText { // } } + val hasLayouted: Boolean + get() = layouter != null && contentWidth != null + val numOfRows: Int get() = tree.getRoot().numRowBreaks() + 1 + // TODO cache the result run { @@ -852,6 +980,43 @@ class BigTextImpl : BigText { } } + val numOfLines: Int + get() = tree.getRoot().numLineBreaks() + 1 + + val lastRowIndex: Int + get() = numOfRows - 1 + + /** + * This is an expensive operation that defeats the purpose of BigText. + * TODO: take out all the usage of this function + */ +// fun produceLayoutResult(): BigTextLayoutResult { +// val lineFirstRowIndices = ArrayList(numOfLines) +// val rowStartCharIndices = ArrayList(numOfRows) +// +// tree.forEach { nv -> +// val pos = findPositionStart(nv.node!!) +// rowStartCharIndices += nv.rowBreakOffsets.map { it - nv.bufferOffsetStart + pos } +// } +// +// tree.forEach { nv -> +// if (nv.bufferNumLineBreaksInRange < 1) { +// return@forEach +// } +// val pos = findPositionStart(nv.node!!) +// val buffer = buffers[nv.bufferIndex] +// val lb = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(nv.bufferOffsetStart) +// val ub = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(nv.bufferOffsetEndExclusive - 1) +// +// rows += nv.rowBreakOffsets.map { it - nv.bufferOffsetStart + pos } +// } +// +// return BigTextLayoutResult( +// lineRowSpans = emptyList(), // not used +// lineFirstRowIndices = +// ) +// } + } fun RedBlackTree.Node.length(): Int = @@ -876,3 +1041,7 @@ fun RedBlackTree.Node.numRowBreaks(): Int { private enum class InsertDirection { Left, Right, Undefined } + +fun BigText.Companion.createFromLargeString(initialContent: String) = BigTextImpl().apply { + append(initialContent) +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt index 6840b970..7ccb725d 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt @@ -5,24 +5,25 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearch import com.sunnychung.application.multiplatform.hellohttp.util.CharMeasurer @OptIn(TemporaryApi::class) +@Deprecated("Slow") class BigTextLayoutResult( /** Number of transformed row spans of non-transformed lines */ - @property:TemporaryApi val lineRowSpans: List, // O(L) + @Deprecated("Slow") @property:TemporaryApi val lineRowSpans: List, // O(L) /** First transformed row index of non-transformed lines */ - @property:TemporaryApi val lineFirstRowIndices: List, // O(L) + @Deprecated("Slow") @property:TemporaryApi val lineFirstRowIndices: List, // O(L) /** Transformed start char index of transformed rows */ internal val rowStartCharIndices: List, // O(R) - val rowHeight: Float, - val totalLines: Int, - val totalRows: Int, + @Deprecated("Slow") val rowHeight: Float, + @Deprecated("Slow") val totalLines: Int, + @Deprecated("Slow") val totalRows: Int, /** Total number of lines before transformation */ val totalLinesBeforeTransformation: Int, private val charMeasurer: CharMeasurer, ) { - fun findLineNumberByRowNumber(rowNumber: Int): Int { + @Deprecated("Slow") fun findLineNumberByRowNumber(rowNumber: Int): Int { return lineFirstRowIndices.binarySearchForMaxIndexOfValueAtMost(rowNumber) } - fun getLineTop(originalLineNumber: Int): Float = lineFirstRowIndices[originalLineNumber] * rowHeight + @Deprecated("Slow") fun getLineTop(originalLineNumber: Int): Float = lineFirstRowIndices[originalLineNumber] * rowHeight - fun findCharWidth(char: String) = charMeasurer.findCharWidth(char) + @Deprecated("Slow") fun findCharWidth(char: String) = charMeasurer.findCharWidth(char) } From 5a7fda9c77ae044f6508aac71c393eb73a2d78bb Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 25 Aug 2024 22:45:49 +0800 Subject: [PATCH 044/195] update BigMonospaceText to integrate selections with BigTextImpl --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 6e6df8ee..3584676e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -317,13 +317,14 @@ private fun CoreBigMonospaceText( // } val rowString = text.findRowString(row) + val rowPositionStart = text.findRowPositionStartIndexByRowIndex(row) var accumWidth = 0f val charIndex = rowString.indexOfFirst { accumWidth += textLayouter.charMeasurer.findCharWidth(it.toString()) x < accumWidth }.takeIf { it >= 0 } ?: rowString.length - return charIndex + return rowPositionStart + charIndex } fun getTransformedStringWidth(start: Int, endExclusive: Int): Float { @@ -421,7 +422,7 @@ private fun CoreBigMonospaceText( viewState.updateCursorIndexByTransformed(transformedText) } ) - .pointerInput(isEditable, text, scrollState.value, lineHeight, contentWidth, transformedText.text.length, transformedText.text.hashCode()) { + .pointerInput(isEditable, text, text.hasLayouted, scrollState.value, lineHeight, contentWidth, transformedText.text.length, transformedText.text.hashCode()) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() From 2c30c76b9f1bc26004717debda5625b17898d0ef Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 30 Aug 2024 01:00:04 +0800 Subject: [PATCH 045/195] update BigMonospaceTextField and BigTextLineNumbersView to integrate mutations, state updates, cursors and line/row number lookups with BigTextImpl --- .../hellohttp/ux/CodeEditorView.kt | 13 ++-- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 41 ++++++++++-- .../ux/bigtext/BigTextChangeEvent.kt | 19 ++++++ .../hellohttp/ux/bigtext/BigTextImpl.kt | 66 ++++++++++++++----- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 6 ++ 5 files changed, 118 insertions(+), 27 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 3e9d5f6c..a34a82d2 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -81,6 +81,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.Mult import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.SearchHighlightTransformation import kotlinx.coroutines.launch import java.util.regex.Pattern +import kotlin.random.Random val MAX_TEXT_FIELD_LENGTH = 4 * 1024 * 1024 // 4 MB @@ -450,10 +451,12 @@ fun CodeEditorView( // ) var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(textValue.text)) } + var bigTextValueId by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(Random.nextLong()) } BigTextLineNumbersView( scrollState = scrollState, bigTextViewState = bigTextViewState, + bigTextValueId = bigTextValueId, bigText = bigTextValue as BigTextImpl, collapsableLines = collapsableLines, collapsedLines = collapsedLines.values.toList(), @@ -546,14 +549,15 @@ fun CodeEditorView( } )*/ - var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(text)) } // FIXME performance +// var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(text)) } // FIXME performance BigMonospaceTextField( text = bigTextValue, onTextChange = { - bigTextValue = it - log.d { "CEV sel ${textValue.selection.start}" } - onTextChange?.invoke(it.fullString()) +// bigTextValue = it +// log.d { "CEV sel ${textValue.selection.start}" } +// onTextChange?.invoke(it.fullString()) + bigTextValueId = it.changeId }, visualTransformation = visualTransformationToUse, fontSize = LocalFont.current.codeEditorBodyFontSize, @@ -801,6 +805,7 @@ class CollapsedLinesState(val collapsableLines: List, collapsedLines: fun BigTextLineNumbersView( modifier: Modifier = Modifier, bigTextViewState: BigTextViewState, + bigTextValueId: Long, bigText: BigTextImpl, scrollState: ScrollState, collapsableLines: List, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 3584676e..9fb51717 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -82,6 +82,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalFont import kotlinx.coroutines.launch import kotlin.math.roundToInt +import kotlin.random.Random import kotlin.reflect.KMutableProperty import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.jvm.isAccessible @@ -147,7 +148,7 @@ fun BigMonospaceTextField( padding: PaddingValues = PaddingValues(4.dp), fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, - onTextChange: (BigText) -> Unit, + onTextChange: (BigTextChangeEvent) -> Unit, visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, @@ -177,7 +178,7 @@ private fun CoreBigMonospaceText( color: Color = LocalColor.current.text, isSelectable: Boolean = false, isEditable: Boolean = false, - onTextChange: (BigText) -> Unit, + onTextChange: (BigTextChangeEvent) -> Unit, visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, @@ -322,7 +323,8 @@ private fun CoreBigMonospaceText( val charIndex = rowString.indexOfFirst { accumWidth += textLayouter.charMeasurer.findCharWidth(it.toString()) x < accumWidth - }.takeIf { it >= 0 } ?: rowString.length + }.takeIf { it >= 0 } + ?: rowString.length - if (rowString.endsWith('\n')) 1 else 0 return rowPositionStart + charIndex } @@ -340,6 +342,20 @@ private fun CoreBigMonospaceText( .sum() } + fun onValueChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) { + viewState.lastVisibleRow = minOf(viewState.lastVisibleRow, text.lastRowIndex) + + viewState.version = Random.nextLong() + val event = BigTextChangeEvent( + changeId = viewState.version, + bigText = text, + eventType = eventType, + changeStartIndex = changeStartIndex, + changeEndExclusiveIndex = changeEndExclusiveIndex, + ) + onTextChange(event) + } + fun onType(textInput: String) { log.v { "key in '$textInput'" } if (viewState.hasSelection()) { @@ -348,11 +364,12 @@ private fun CoreBigMonospaceText( viewState.selection = IntRange.EMPTY viewState.transformedSelection = IntRange.EMPTY } - text.insertAt(viewState.cursorIndex, textInput) + val insertPos = viewState.cursorIndex + text.insertAt(insertPos, textInput) viewState.cursorIndex += textInput.length viewState.updateTransformedCursorIndexByOriginal(transformedText) log.v { "set cursor pos 2 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } - onTextChange(text) + onValueChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) } fun onDelete(direction: TextFBDirection): Boolean { @@ -361,7 +378,7 @@ private fun CoreBigMonospaceText( TextFBDirection.Forward -> { if (cursor + 1 <= text.length) { text.delete(cursor, cursor + 1) - onTextChange(text) + onValueChange(BigTextChangeEventType.Delete, cursor, cursor + 1) return true } } @@ -371,7 +388,7 @@ private fun CoreBigMonospaceText( --viewState.cursorIndex viewState.updateTransformedCursorIndexByOriginal(transformedText) log.v { "set cursor pos 3 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } - onTextChange(text) + onValueChange(BigTextChangeEventType.Delete, cursor - 1, cursor) return true } } @@ -535,6 +552,7 @@ private fun CoreBigMonospaceText( } it.key in listOf(Key.DirectionLeft, Key.DirectionRight) -> { val delta = if (it.key == Key.DirectionRight) 1 else -1 + viewState.transformedSelection = IntRange.EMPTY // TODO handle Shift key if (viewState.transformedCursorIndex + delta in 0 .. transformedText.text.length) { viewState.transformedCursorIndex += delta viewState.updateCursorIndexByTransformed(transformedText) @@ -546,6 +564,7 @@ private fun CoreBigMonospaceText( // val row = layoutResult.rowStartCharIndices.binarySearchForMaxIndexOfValueAtMost(viewState.transformedCursorIndex) val row = text.findRowIndexByPosition(viewState.transformedCursorIndex) val newRow = row + if (it.key == Key.DirectionDown) 1 else -1 + viewState.transformedSelection = IntRange.EMPTY // TODO handle Shift key viewState.transformedCursorIndex = Unit.let { if (newRow < 0) { 0 @@ -646,6 +665,14 @@ private fun CoreBigMonospaceText( } class BigTextViewState { + /** + * A unique value that changes when the BigText string value is changed. + * + * This field is generated randomly and is NOT a sequence number. + */ + var version: Long by mutableStateOf(0) + internal set + var firstVisibleRow: Int by mutableStateOf(0) internal set diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt new file mode 100644 index 00000000..5cd2597c --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt @@ -0,0 +1,19 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +data class BigTextChangeEvent( + /** + * Unique value to represent the BigText value. This is NOT a sequence number. + */ + val changeId: Long, + + val bigText: BigText, + + val eventType: BigTextChangeEventType, + + val changeStartIndex: Int, + val changeEndExclusiveIndex: Int, +) + +enum class BigTextChangeEventType { + Insert, Delete +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 893e3f73..f6b963b4 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -83,7 +83,7 @@ class BigTextImpl : BigText { in Int.MIN_VALUE until it.value.leftNumOfLineBreaks -> if (it.left.isNotNil()) -1 else 0 // it.value.leftNumOfLineBreaks -> if (it.left.isNotNil()) -1 else 0 in it.value.leftNumOfLineBreaks until it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange -> 0 - in it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange until Int.MAX_VALUE -> 1.also { compareResult -> + in it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange until Int.MAX_VALUE -> (if (it.right.isNotNil()) 1 else 0).also { compareResult -> val isTurnRight = compareResult > 0 if (isTurnRight) { find -= it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange @@ -103,7 +103,7 @@ class BigTextImpl : BigText { when (find) { in Int.MIN_VALUE until it.value.leftNumOfRowBreaks -> if (it.left.isNotNil()) -1 else 0 in it.value.leftNumOfRowBreaks until it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size -> 0 - in it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size until Int.MAX_VALUE -> 1.also { compareResult -> + in it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size until Int.MAX_VALUE -> (if (it.right.isNotNil()) 1 else 0).also { compareResult -> val isTurnRight = compareResult > 0 if (isTurnRight) { find -= it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size @@ -128,10 +128,17 @@ class BigTextImpl : BigText { return 0 } + this.length.let { length -> + if (position == length) { + return lastRowIndex + } else if (position > length) { + throw IndexOutOfBoundsException("position = $position but length = $length") + } + } val node = tree.findNodeByCharIndex(position)!! val startPos = findPositionStart(node) val nv = node.value - val rowIndexInThisPartition = nv.rowBreakOffsets.binarySearchForMinIndexOfValueAtLeast(position - startPos) + val rowIndexInThisPartition = nv.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(position - startPos + nv.bufferOffsetStart) + 1 val startRowIndex = findRowStart(node) return startRowIndex + rowIndexInThisPartition } @@ -145,12 +152,16 @@ class BigTextImpl : BigText { val rowStart = findRowStart(node) val startPos = findPositionStart(node) return startPos + if (index > 0) { - node.value.rowBreakOffsets[index - 1 - rowStart] + node.value.rowBreakOffsets[index - 1 - rowStart] - node.value.bufferOffsetStart } else { 0 } } + /** + * @param rowIndex 0-based + * @return 0-based + */ fun findLineIndexByRowIndex(rowIndex: Int): Int { if (!hasLayouted) { return 0 @@ -173,6 +184,22 @@ class BigTextImpl : BigText { } } + fun RedBlackTree.Node.findRowBreakIndexOfLineOffset(lineOffset: Int): Int { + return value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(lineOffset + 1 /* rowBreak is 1 char after '\n' while lineBreak is right at '\n' */).let { // if lineOffset == 0, it should return -1 + if (it in value.rowBreakOffsets.indices) { + return@let it + } + if (it == -1 && lineOffset == 0) { + return@let it + } + if (lineOffset + 1 == value.bufferOffsetEndExclusive && value.isEndWithForceRowBreak) { + return@let value.rowBreakOffsets.size + } + throw IndexOutOfBoundsException("Cannot find rowBreakOffset ${lineOffset + 1}") + } + } + + @Deprecated("not used. need to be fixed before use.") fun findFirstRowIndexOfLineOfRowIndex(rowIndex: Int): Int { if (!hasLayouted) { return 0 @@ -189,15 +216,15 @@ class BigTextImpl : BigText { 0 } - val rowBreakOffsetIndex = lineStartNode.value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(lineOffset + 1 /* rowBreak is 1 char after '\n' while lineBreak is right at '\n' */).also { // if lineOffset == 0, it should return -1 - if (it !in lineStartNode.value.rowBreakOffsets.indices && lineOffset != 0) { - throw IndexOutOfBoundsException("Cannot find rowBreakOffset $lineOffset") - } - } + val rowBreakOffsetIndex = lineStartNode.findRowBreakIndexOfLineOffset(lineOffset) val rowBreaksStart = findRowStart(lineStartNode) return rowBreaksStart + rowBreakOffsetIndex + 1 } + /** + * @param lineIndex 0-based line index + * @return 0-based row index + */ fun findFirstRowIndexOfLine(lineIndex: Int): Int { if (!hasLayouted) { return 0 @@ -208,17 +235,21 @@ class BigTextImpl : BigText { val lineOffsetStarts = buffers[lineStartNode.value.bufferIndex].lineOffsetStarts val inRangeLineStartIndex = lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineStartNode.value.bufferOffsetStart) val lineOffset = if (lineIndex - 1 >= 0) { - lineOffsetStarts[inRangeLineStartIndex + lineIndex - 1 - lineIndexStart] + lineOffsetStarts[inRangeLineStartIndex + lineIndex - 1 - lineIndexStart] - lineStartNode.value.bufferOffsetStart } else { 0 } + val lineStartPos = findPositionStart(lineStartNode) + lineOffset - val rowBreakOffsetIndex = lineStartNode.value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(lineOffset + 1 /* rowBreak is 1 char after '\n' while lineBreak is right at '\n' */).also { // if lineOffset == 0, it should return -1 - if (it !in lineStartNode.value.rowBreakOffsets.indices && lineOffset != 0) { - throw IndexOutOfBoundsException("Cannot find rowBreakOffset $lineOffset") - } - } - val rowBreaksStart = findRowStart(lineStartNode) +// val rowBreakOffsetIndex = lineStartNode.findRowBreakIndexOfLineOffset(lineOffset) +// val rowBreaksStart = findRowStart(lineStartNode) +// return rowBreaksStart + rowBreakOffsetIndex + 1 + + val rowStartPos = lineStartPos + 1 /* rowBreak is 1 char after '\n' while lineBreak is right at '\n' */ + val actualNode = tree.findNodeByCharIndex(rowStartPos) ?: throw IndexOutOfBoundsException("pos $rowStartPos is out of bound. length = $length") + val actualNodeStartPos = findPositionStart(actualNode) + val rowBreakOffsetIndex = actualNode.value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(rowStartPos - actualNodeStartPos + actualNode.value.bufferOffsetStart) + val rowBreaksStart = findRowStart(actualNode) return rowBreaksStart + rowBreakOffsetIndex + 1 } @@ -246,6 +277,9 @@ class BigTextImpl : BigText { return start } + /** + * Find the first 0-based row index of the node, in the global domain of this BigText. + */ protected fun findRowStart(node: RedBlackTree.Node): Int { var start = node.value.leftNumOfRowBreaks var node = node diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 76c9ec1a..2604da67 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -10,6 +10,9 @@ class BigTextNodeValue : Comparable, DebuggableNode = sortedSetOf() + /** + * Row break positions in the domain of character indices of the {bufferIndex}-th buffer. + */ var rowBreakOffsets: List = emptyList() var lastRowWidth: Float = 0f var isEndWithForceRowBreak: Boolean = false @@ -46,6 +49,9 @@ class BigTextNodeValue : Comparable, DebuggableNode = emptyList() // var lineOffsetStarts: SortedSet = sortedSetOf() // var rowOffsetStarts: List = emptyList() From cb2f78f291f0290a53f4fefeeff281daf1db74e9 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 31 Aug 2024 13:21:54 +0800 Subject: [PATCH 046/195] fix BigTextImpl.findLineString() --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 14 +++++++++----- .../test/bigtext/BigTextImplQueryTest.kt | 15 ++++++++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index f6b963b4..2b9a65b1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -460,7 +460,7 @@ class BigTextImpl : BigText { override fun substring(start: Int, endExclusive: Int): String { // O(lg L + (e - s)) require(start <= endExclusive) { "start should be <= endExclusive" } require(0 <= start) { "Invalid start" } - require(endExclusive <= length) { "endExclusive is out of bound" } + require(endExclusive <= length) { "endExclusive is out of bound. length = $length" } if (start == endExclusive) { return "" @@ -492,16 +492,20 @@ class BigTextImpl : BigText { } fun findLineString(lineIndex: Int): String { + require(0 <= lineIndex) { "lineIndex $lineIndex must be non-negative." } + require(lineIndex <= numOfLines) { "lineIndex $lineIndex out of bound, numOfLines = $numOfLines." } + /** - * @param lineOffset 0 = start of buffer; 1 = char index after the first '\n' + * @param lineOffset 0 = start of buffer; 1 = char index after the 1st '\n'; 2 = char index after the 2nd '\n'; ... */ fun findCharPosOfLineOffset(node: RedBlackTree.Node, lineOffset: Int): Int { val buffer = buffers[node.value!!.bufferIndex] - val charOffsetInBuffer = if (lineOffset - 1 > buffer.lineOffsetStarts.lastIndex) { + val lineStartIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.value!!.bufferOffsetStart) + val lineEndIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(node.value!!.bufferOffsetEndExclusive - 1) + val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) - 1 + val charOffsetInBuffer = if (offsetedLineOffset > lineEndIndexInBuffer) { node.value!!.bufferOffsetEndExclusive } else if (lineOffset - 1 >= 0) { - val lineStartIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.value!!.bufferOffsetStart) - val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) - 1 buffer.lineOffsetStarts[offsetedLineOffset] + 1 } else { node.value!!.bufferOffsetStart diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt index a72f8658..c1e61168 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt @@ -215,12 +215,17 @@ class BigTextImplQueryTest { t.delete(start, start + len) } } +// t.verifyAllLines() } - val splitted = t.stringImpl.fullString().split("\n") - splitted.forEachIndexed { i, line -> - val result = t.bigTextImpl.findLineString(i) - assertEquals(if (i == splitted.lastIndex) line else "$line\n", result) - } + t.verifyAllLines() + } +} + +private fun BigTextVerifyImpl.verifyAllLines() { + val splitted = this.stringImpl.fullString().split("\n") + splitted.forEachIndexed { i, line -> + val result = this.bigTextImpl.findLineString(i) + assertEquals(if (i == splitted.lastIndex) line else "$line\n", result) } } From 8100e3bcb2fd44ab84fe4067146c589da0b278ec Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 31 Aug 2024 16:54:02 +0800 Subject: [PATCH 047/195] fix BigTextImpl.findLineIndexByRowIndex() --- .../hellohttp/ux/CodeEditorView.kt | 2 +- .../hellohttp/ux/bigtext/BigTextImpl.kt | 29 ++++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index a34a82d2..e0ff37ff 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -830,7 +830,7 @@ fun BigTextLineNumbersView( val viewportTop = scrollState.value val firstLine = bigText.findLineIndexByRowIndex(bigTextViewState.firstVisibleRow) ?: 0 val lastLine = (bigText.findLineIndexByRowIndex(bigTextViewState.lastVisibleRow) ?: -100) + 1 - log.v { "lastVisibleRow = ${bigTextViewState.lastVisibleRow} (L $lastLine); totalLines = ${bigText.numOfLines}" } + log.v { "firstVisibleRow = ${bigTextViewState.firstVisibleRow} (L $firstLine); lastVisibleRow = ${bigTextViewState.lastVisibleRow} (L $lastLine); totalLines = ${bigText.numOfLines}" } val rowHeight = ((bigText.layouter as? MonospaceTextLayouter)?.charMeasurer as? ComposeUnicodeCharMeasurer)?.getRowHeight() ?: 0f CoreLineNumbersView( firstLine = firstLine, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 2b9a65b1..eecc46b6 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -167,21 +167,36 @@ class BigTextImpl : BigText { return 0 } + require(rowIndex in (0 .. numOfRows)) { "Row index $rowIndex is out of bound. numOfRows = $numOfRows"} + if (rowIndex == 0) { + return 0 + } + val (node, rowIndexStart) = tree.findNodeByRowBreaks(rowIndex - 1)!! val rowOffset = if (rowIndex > 0) { node.value.rowBreakOffsets[rowIndex - 1 - rowIndexStart] } else { 0 } - - val lineBreakOffsetStarts = buffers[node.value.bufferIndex].lineOffsetStarts - val lineBreakIndex = lineBreakOffsetStarts.binarySearchForMaxIndexOfValueAtMost(rowOffset) - return if (lineBreakIndex < 0) { + val positionStart = findPositionStart(node) + val rowPositionStart = positionStart + rowOffset - node.value.bufferOffsetStart + val lineBreakPosition = rowPositionStart - 1 + + val lineBreakAtNode = tree.findNodeByCharIndex(lineBreakPosition)!! + val lineStart = findLineStart(lineBreakAtNode) + val positionStartOfLineBreakNode = findPositionStart(lineBreakAtNode) + val lineBreakOffsetStarts = buffers[lineBreakAtNode.value.bufferIndex].lineOffsetStarts + val lineBreakMinIndex = lineBreakOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineBreakAtNode.value.bufferOffsetStart) + val lineBreakIndex = lineBreakOffsetStarts.binarySearchForMaxIndexOfValueAtMost(lineBreakPosition - positionStartOfLineBreakNode + lineBreakAtNode.value.bufferOffsetStart) + return (lineStart + if (lineBreakIndex < lineBreakMinIndex) { 0 } else { - val lineStart = findLineStart(node) - lineStart + lineBreakIndex + 1 - } + /** + * If lineBreakIndex >= lineBreakMinIndex, there are at least (lineBreakIndex - lineBreakMinIndex + 1) line breaks before this position. + * If there is 1 line break before, then line index should be 1, etc.. + */ + lineBreakIndex - lineBreakMinIndex + 1 + }).also { log.d { "findLineIndexByRowIndex($rowIndex) = $it" } } } fun RedBlackTree.Node.findRowBreakIndexOfLineOffset(lineOffset: Int): Int { From ec699c51c19ace00344cd644ca23c3aed4838324 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 31 Aug 2024 19:00:51 +0800 Subject: [PATCH 048/195] fix shift-clicking BigMonospaceText results in a wrong selection range --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 9fb51717..c164b58a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -287,7 +287,6 @@ private fun CoreBigMonospaceText( delta } var draggedPoint by remember { mutableStateOf(Offset.Zero) } - var selectionStart by remember { mutableStateOf(-1) } var selectionEnd by remember { mutableStateOf(-1) } var isHoldingShiftKey by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) } @@ -368,6 +367,7 @@ private fun CoreBigMonospaceText( text.insertAt(insertPos, textInput) viewState.cursorIndex += textInput.length viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex log.v { "set cursor pos 2 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } onValueChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) } @@ -387,6 +387,7 @@ private fun CoreBigMonospaceText( text.delete(cursor - 1, cursor) --viewState.cursorIndex viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex log.v { "set cursor pos 3 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } onValueChange(BigTextChangeEventType.Delete, cursor - 1, cursor) return true @@ -422,16 +423,17 @@ private fun CoreBigMonospaceText( log.v { "onDragStart ${it.x} ${it.y}" } draggedPoint = it val selectedCharIndex = getTransformedCharIndex(x = it.x, y = it.y, mode = ResolveCharPositionMode.Selection) - selectionStart = selectedCharIndex viewState.transformedSelection = selectedCharIndex .. selectedCharIndex viewState.updateSelectionByTransformedSelection(transformedText) + viewState.transformedSelectionStart = selectedCharIndex focusRequester.requestFocus() // focusRequester.captureFocus() }, - onDrag = { + onDrag = { // onDragStart happens before onDrag log.v { "onDrag ${it.x} ${it.y}" } draggedPoint += it val selectedCharIndex = getTransformedCharIndex(x = draggedPoint.x, y = draggedPoint.y, mode = ResolveCharPositionMode.Selection) + val selectionStart = viewState.transformedSelectionStart selectionEnd = selectedCharIndex viewState.transformedSelection = minOf(selectionStart, selectionEnd) .. maxOf(selectionStart, selectionEnd) viewState.updateSelectionByTransformedSelection(transformedText) @@ -439,15 +441,16 @@ private fun CoreBigMonospaceText( viewState.updateCursorIndexByTransformed(transformedText) } ) - .pointerInput(isEditable, text, text.hasLayouted, scrollState.value, lineHeight, contentWidth, transformedText.text.length, transformedText.text.hashCode()) { + .pointerInput(isEditable, text, text.hasLayouted, viewState, viewportTop, lineHeight, contentWidth, transformedText.text.length, transformedText.text.hashCode()) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() when (event.type) { PointerEventType.Press -> { val position = event.changes.first().position - log.v { "press ${position.x} ${position.y}" } + log.v { "press ${position.x} ${position.y} shift=$isHoldingShiftKey" } if (isHoldingShiftKey) { + val selectionStart = viewState.transformedSelectionStart selectionEnd = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Selection) log.v { "selectionEnd => $selectionEnd" } viewState.transformedSelection = minOf(selectionStart, selectionEnd) .. maxOf(selectionStart, selectionEnd) @@ -459,6 +462,7 @@ private fun CoreBigMonospaceText( if (isEditable) { viewState.transformedCursorIndex = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Cursor) viewState.updateCursorIndexByTransformed(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex log.v { "set cursor pos 1 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } focusRequester.requestFocus() } @@ -556,6 +560,7 @@ private fun CoreBigMonospaceText( if (viewState.transformedCursorIndex + delta in 0 .. transformedText.text.length) { viewState.transformedCursorIndex += delta viewState.updateCursorIndexByTransformed(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex log.v { "set cursor pos LR => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } } true @@ -585,6 +590,7 @@ private fun CoreBigMonospaceText( } } viewState.updateCursorIndexByTransformed(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex true } else -> false @@ -681,6 +687,12 @@ class BigTextViewState { internal var transformedSelection: IntRange by mutableStateOf(0 .. -1) + /** + * `transformedSelectionStart` can be different from `transformedSelection.start`. + * If a text is selected from position 5 to 1, transformedSelection = (1 .. 5) while transformedSelectionStart = 5. + */ + var transformedSelectionStart: Int by mutableStateOf(0) + var selection: IntRange by mutableStateOf(0 .. -1) fun hasSelection(): Boolean = !transformedSelection.isEmpty() From 596aa8426fc0535206325a2d6d33f8ee68bf2772 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 31 Aug 2024 19:04:05 +0800 Subject: [PATCH 049/195] fix shift-dragging in BigMonospaceText would unexpectedly cancel a selection --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index c164b58a..fd8cfd4d 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -422,10 +422,12 @@ private fun CoreBigMonospaceText( onDragStart = { log.v { "onDragStart ${it.x} ${it.y}" } draggedPoint = it - val selectedCharIndex = getTransformedCharIndex(x = it.x, y = it.y, mode = ResolveCharPositionMode.Selection) - viewState.transformedSelection = selectedCharIndex .. selectedCharIndex - viewState.updateSelectionByTransformedSelection(transformedText) - viewState.transformedSelectionStart = selectedCharIndex + if (!isHoldingShiftKey) { + val selectedCharIndex = getTransformedCharIndex(x = it.x, y = it.y, mode = ResolveCharPositionMode.Selection) + viewState.transformedSelection = selectedCharIndex..selectedCharIndex + viewState.updateSelectionByTransformedSelection(transformedText) + viewState.transformedSelectionStart = selectedCharIndex + } focusRequester.requestFocus() // focusRequester.captureFocus() }, From 05329d3c3df5886777d047754db8cf62ac109820 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 31 Aug 2024 19:12:43 +0800 Subject: [PATCH 050/195] fix shift-clicking in BigMonospaceText should change the cursor position --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index fd8cfd4d..a2f6beb1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -460,15 +460,16 @@ private fun CoreBigMonospaceText( } else { viewState.transformedSelection = IntRange.EMPTY // focusRequester.freeFocus() + } - if (isEditable) { - viewState.transformedCursorIndex = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Cursor) - viewState.updateCursorIndexByTransformed(transformedText) - viewState.transformedSelectionStart = viewState.transformedCursorIndex - log.v { "set cursor pos 1 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } - focusRequester.requestFocus() - } + viewState.transformedCursorIndex = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Cursor) + viewState.updateCursorIndexByTransformed(transformedText) + if (!isHoldingShiftKey) { + viewState.transformedSelectionStart = viewState.transformedCursorIndex } + log.v { "set cursor pos 1 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } + + focusRequester.requestFocus() } } } From 452ece68db571331618d08819ed3d38b004522c4 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 31 Aug 2024 19:51:11 +0800 Subject: [PATCH 051/195] fix exceptions if text is empty --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index eecc46b6..42c849fb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -148,7 +148,14 @@ class BigTextImpl : BigText { return 0 } - val node = tree.findNodeByRowBreaks(index - 1)!!.first + require(index in (0 .. numOfRows)) { "Row index $index is out of bound. numOfRows = $numOfRows" } + if (index == 0) { + return 0; + } + + val node = (tree.findNodeByRowBreaks(index - 1) + ?: throw IllegalStateException("Cannot find the node right after ${index - 1} row breaks") + ).first val rowStart = findRowStart(node) val startPos = findPositionStart(node) return startPos + if (index > 0) { @@ -245,7 +252,13 @@ class BigTextImpl : BigText { return 0 } - val (lineStartNode, lineIndexStart) = tree.findNodeByLineBreaks(lineIndex - 1)!! + require(lineIndex in (0 .. numOfLines)) { "Line index $lineIndex is out of bound. numOfLines = $numOfLines" } + if (lineIndex == 0) { + return 0; + } + + val (lineStartNode, lineIndexStart) = tree.findNodeByLineBreaks(lineIndex - 1) + ?: throw IllegalStateException("Cannot find the node right after ${lineIndex - 1} line breaks") // val positionOfLineStartNode = findPositionStart(lineStartNode) val lineOffsetStarts = buffers[lineStartNode.value.bufferIndex].lineOffsetStarts val inRangeLineStartIndex = lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineStartNode.value.bufferOffsetStart) From 4a0093091ad6144bf54028e0f1c1dc30bd2c34c3 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 31 Aug 2024 20:00:04 +0800 Subject: [PATCH 052/195] add semantic properties to BigMonospaceText to support UX testing --- .../multiplatform/hellohttp/ux/CodeEditorView.kt | 9 ++++++++- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index e0ff37ff..e9e95ec9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -475,7 +475,14 @@ fun CodeEditorView( scrollState = scrollState, viewState = bigTextViewState, onTextLayout = { layoutResult = it }, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize() + .run { + if (testTag != null) { + testTag(testTag) + } else { + this + } + } ) // return@Row // compose bug: return here would crash } else { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index a2f6beb1..3bb46e30 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -603,6 +603,14 @@ private fun CoreBigMonospaceText( } // .then(BigTextInputModifierElement(1)) .focusable(isSelectable) // `focusable` should be after callback modifiers that use focus + .semantics { + log.d { "semantic lambda" } + if (isEditable) { + editableText = AnnotatedString(text.fullString(), transformedText.text.spanStyles) + } else { + this.text = AnnotatedString(text.fullString(), transformedText.text.spanStyles) + } + } ) { val viewportBottom = viewportTop + height From 3f64ee6377f8fd24de5d25d9ac7cfb14295f6b33 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 31 Aug 2024 21:38:05 +0800 Subject: [PATCH 053/195] add setText and insertTextAtCursor semantic action to BigMonospaceText for UX testing --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 3bb46e30..3b5519aa 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -58,7 +58,9 @@ import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalTextInputService import androidx.compose.ui.semantics.editableText +import androidx.compose.ui.semantics.insertTextAtCursor import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setText import androidx.compose.ui.semantics.text import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextMeasurer @@ -413,8 +415,18 @@ private fun CoreBigMonospaceText( .semantics { if (isEditable) { editableText = transformedText.text + setText { + text.replace(0, text.length, it.text) + true + } + insertTextAtCursor { + text.insertAt(viewState.cursorIndex, it.text) + true + } } else { this.text = transformedText.text + setText { false } + insertTextAtCursor { false } } } .onDrag( From dd581c9713c39a37e67ebdd21357768bd7720bd1 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 1 Sep 2024 15:12:30 +0800 Subject: [PATCH 054/195] fix IllegalArgumentException - lineIndex is out of bounds --- .../multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt index 621849a7..3371a96c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt @@ -55,7 +55,7 @@ class ComposeUnicodeCharMeasurer(private val measurer: TextMeasurer, private val fun getRowHeight(): Float = charHeight fun measureExactWidthOf(targets: List): List { - val result = measurer.measure(targets.joinToString("\n"), style, softWrap = false) + val result = measurer.measure(targets.joinToString("") { "$it\n"}, style, softWrap = false) return targets.mapIndexed { index, s -> result.getLineRight(index) - result.getLineLeft(index) } From 21ef39ecf5f1c23810010e2acdd3b2d88c0897a2 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 1 Sep 2024 15:13:43 +0800 Subject: [PATCH 055/195] fix IndexOutOfBoundsException thrown from BigTextImpl when the text ends with a line break --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 42c849fb..9a31c453 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -158,7 +158,9 @@ class BigTextImpl : BigText { ).first val rowStart = findRowStart(node) val startPos = findPositionStart(node) - return startPos + if (index > 0) { + return startPos + if (index - 1 - rowStart == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { + node.value.bufferLength + } else if (index > 0) { node.value.rowBreakOffsets[index - 1 - rowStart] - node.value.bufferOffsetStart } else { 0 @@ -180,7 +182,9 @@ class BigTextImpl : BigText { } val (node, rowIndexStart) = tree.findNodeByRowBreaks(rowIndex - 1)!! - val rowOffset = if (rowIndex > 0) { + val rowOffset = if (rowIndex - 1 - rowIndexStart == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { + node.value.bufferOffsetEndExclusive + } else if (rowIndex > 0) { node.value.rowBreakOffsets[rowIndex - 1 - rowIndexStart] } else { 0 @@ -274,7 +278,12 @@ class BigTextImpl : BigText { // return rowBreaksStart + rowBreakOffsetIndex + 1 val rowStartPos = lineStartPos + 1 /* rowBreak is 1 char after '\n' while lineBreak is right at '\n' */ - val actualNode = tree.findNodeByCharIndex(rowStartPos) ?: throw IndexOutOfBoundsException("pos $rowStartPos is out of bound. length = $length") + val actualNode = tree.findNodeByCharIndex(rowStartPos) ?: run { + if (rowStartPos == length && tree.rightmost(tree.getRoot()).value.isEndWithForceRowBreak) { + return lastRowIndex + } + throw IndexOutOfBoundsException("pos $rowStartPos is out of bound. length = $length") + } val actualNodeStartPos = findPositionStart(actualNode) val rowBreakOffsetIndex = actualNode.value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(rowStartPos - actualNodeStartPos + actualNode.value.bufferOffsetStart) val rowBreaksStart = findRowStart(actualNode) From ba9fb9f6efedde04a857a1ded6a91358c7fff0ea Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 1 Sep 2024 15:18:05 +0800 Subject: [PATCH 056/195] update integration of BigMonospaceText to include text value mutation callback --- .../hellohttp/ux/CodeEditorView.kt | 38 ++++++++++++------- .../hellohttp/ux/KotliteCodeEditorView.kt | 2 + .../hellohttp/ux/RequestEditorView.kt | 8 ++++ .../hellohttp/ux/ResponseViewerView.kt | 8 +++- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 30 +++++++++++++++ .../hellohttp/ux/bigtext/BigTextFieldState.kt | 37 ++++++++++++++++++ .../hellohttp/ux/bigtext/BigTextImpl.kt | 5 +++ 7 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index e9e95ec9..0eeb0efe 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -63,12 +63,11 @@ import com.sunnychung.application.multiplatform.hellohttp.util.ComposeUnicodeCha import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField -import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextViewState import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.MonospaceTextLayouter -import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.createFromLargeString +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.rememberBigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldColors import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDefaults import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast @@ -79,6 +78,12 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.Envi import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.FunctionTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.MultipleVisualTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.SearchHighlightTransformation +import com.sunnychung.lib.multiplatform.kdatetime.extension.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.util.regex.Pattern import kotlin.random.Random @@ -89,6 +94,7 @@ val MAX_TEXT_FIELD_LENGTH = 4 * 1024 * 1024 // 4 MB fun CodeEditorView( modifier: Modifier = Modifier, isReadOnly: Boolean = false, + cacheKey: String, text: String, onTextChange: ((String) -> Unit)? = null, collapsableLines: List = emptyList(), @@ -436,7 +442,6 @@ fun CodeEditorView( collapsedChars -= collapsableChars[index] } - val bigTextViewState = remember { BigTextViewState() } var layoutResult by remember { mutableStateOf(null) } // BigLineNumbersView( @@ -450,12 +455,13 @@ fun CodeEditorView( // modifier = Modifier.fillMaxHeight(), // ) - var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(textValue.text)) } + val bigTextFieldState = rememberBigTextFieldState(cacheKey, textValue.text) + val bigTextValue = bigTextFieldState.text var bigTextValueId by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(Random.nextLong()) } BigTextLineNumbersView( scrollState = scrollState, - bigTextViewState = bigTextViewState, + bigTextViewState = bigTextFieldState.viewState, bigTextValueId = bigTextValueId, bigText = bigTextValue as BigTextImpl, collapsableLines = collapsableLines, @@ -473,7 +479,7 @@ fun CodeEditorView( fontSize = LocalFont.current.codeEditorBodyFontSize, isSelectable = true, scrollState = scrollState, - viewState = bigTextViewState, + viewState = bigTextFieldState.viewState, onTextLayout = { layoutResult = it }, modifier = Modifier.fillMaxSize() .run { @@ -558,14 +564,19 @@ fun CodeEditorView( // var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(text)) } // FIXME performance - BigMonospaceTextField( - text = bigTextValue, - onTextChange = { -// bigTextValue = it -// log.d { "CEV sel ${textValue.selection.start}" } -// onTextChange?.invoke(it.fullString()) + bigTextFieldState.valueChangesFlow + .debounce(1.seconds().toMilliseconds()) + .onEach { + log.d { "bigTextFieldState change ${it.changeId}" } + onTextChange?.let { onTextChange -> + onTextChange(it.bigText.fullString()) + } bigTextValueId = it.changeId - }, + } + .launchIn(CoroutineScope(Dispatchers.Main)) + + BigMonospaceTextField( + textFieldState = bigTextFieldState, visualTransformation = visualTransformationToUse, fontSize = LocalFont.current.codeEditorBodyFontSize, // textStyle = LocalTextStyle.current.copy( @@ -574,7 +585,6 @@ fun CodeEditorView( // ), // colors = colors, scrollState = scrollState, - viewState = bigTextViewState, onTextLayout = { layoutResult = it }, modifier = Modifier.fillMaxSize() .focusRequester(textFieldFocusRequester) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt index 0dc68f73..9096edb1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt @@ -10,6 +10,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.Kotl @Composable fun KotliteCodeEditorView( modifier: Modifier = Modifier, + cacheKey: String, isReadOnly: Boolean = false, isEnabled: Boolean = true, text: String, @@ -24,6 +25,7 @@ fun KotliteCodeEditorView( } CodeEditorView( modifier = modifier, + cacheKey = cacheKey, isReadOnly = isReadOnly, text = text, onTextChange = onTextChange, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt index 4c2786ae..ed7f352b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt @@ -776,6 +776,7 @@ private fun PreFlightEditorView( selectedExample } KotliteCodeEditorView( + cacheKey = "Request:${request.id}/Example:${example.id}/PreFlight/ExecuteCode", text = example.preFlight.executeCode, onTextChange = { onRequestModified( @@ -1121,6 +1122,7 @@ private fun RequestBodyEditor( when (selectedContentType) { ContentType.Json, ContentType.Raw -> { RequestBodyTextEditor( + cacheKey = "Request:${request.id}/Example:${selectedExample.id}/Body", request = request, onRequestModified = onRequestModified, environmentVariableKeys = environmentVariableKeys, @@ -1233,6 +1235,7 @@ private fun RequestBodyEditor( ContentType.Graphql -> { RequestBodyTextEditor( + cacheKey = "Request:${request.id}/Example:${selectedExample.id}/Body", request = request, onRequestModified = onRequestModified, environmentVariableKeys = environmentVariableKeys, @@ -1267,6 +1270,7 @@ private fun RequestBodyEditor( } } RequestBodyTextEditor( + cacheKey = "Request:${request.id}/Example:${selectedExample.id}/GraphQLVariables", request = request, onRequestModified = onRequestModified, environmentVariableKeys = environmentVariableKeys, @@ -1323,6 +1327,7 @@ private fun OverrideCheckboxWithLabel( @Composable private fun RequestBodyTextEditor( modifier: Modifier, + cacheKey: String, request: UserRequestTemplate, onRequestModified: (UserRequestTemplate?) -> Unit, environmentVariableKeys: Set, @@ -1339,6 +1344,7 @@ private fun RequestBodyTextEditor( if (overridePredicate(selectedExample.overrides)) { CodeEditorView( modifier = modifier, + cacheKey = cacheKey, isReadOnly = false, isEnableVariables = true, knownVariables = environmentVariableKeys, @@ -1358,6 +1364,7 @@ private fun RequestBodyTextEditor( } else { CodeEditorView( modifier = modifier, + cacheKey = cacheKey, isReadOnly = true, isEnableVariables = true, knownVariables = environmentVariableKeys, @@ -1545,6 +1552,7 @@ fun StreamingPayloadEditorView( CodeEditorView( modifier = Modifier.weight(1f), + cacheKey = "Request:${request.id}/PayloadExample:${selectedExample?.id}/Body", isReadOnly = false, isEnableVariables = true, knownVariables = knownVariables, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt index c67dd9c8..3a7a841c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt @@ -463,7 +463,7 @@ private val jsonEncoder = jacksonObjectMapper().disable(DeserializationFeature.F @Composable fun BodyViewerView( modifier: Modifier = Modifier, - key: Any? = Unit, + key: String, content: ByteArray, errorMessage: String?, prettifiers: List, @@ -541,6 +541,7 @@ fun BodyViewerView( CopyableContentContainer(textToCopy = prettifyResult.prettyString, modifier = modifier) { CodeEditorView( + cacheKey = key, isReadOnly = true, text = prettifyResult.prettyString, collapsableLines = prettifyResult.collapsableLineRange, @@ -557,6 +558,7 @@ fun BodyViewerView( val text = errorMessage ?: content.decodeToString() CopyableContentContainer(textToCopy = text, modifier = modifier) { CodeEditorView( + cacheKey = key, isReadOnly = true, text = text, textColor = colours.warning, @@ -613,7 +615,7 @@ fun ResponseBodyView(response: UserResponse) { Column(modifier = Modifier.padding(horizontal = 8.dp)) { BodyViewerView( - key = response.id, + key = "Response:${response.id}/Body", content = response.body ?: byteArrayOf(), prettifiers = prettifiers, errorMessage = response.errorMessage, @@ -629,6 +631,7 @@ fun ResponseBodyView(response: UserResponse) { if (response.postFlightErrorMessage?.isNotEmpty() == true) { AppText(text = "Post-flight Error", modifier = Modifier.padding(top = 20.dp, bottom = 8.dp)) CodeEditorView( + cacheKey = "Response:${response.id}/PostFlightError", isReadOnly = true, text = response.postFlightErrorMessage ?: "", textColor = LocalColor.current.warning, @@ -707,6 +710,7 @@ fun ResponseStreamView(response: UserResponse) { Column(modifier = Modifier.padding(horizontal = 8.dp)) { BodyViewerView( modifier = Modifier.weight(0.6f), + key = "Response:${response.id}/Stream:${selectedMessage?.id}/Body", content = detailData ?: byteArrayOf(), prettifiers = prettifiers, selectedPrettifierState = remember( diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 3b5519aa..8fc0795e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -143,6 +143,33 @@ fun BigMonospaceText( onTextLayout = onTextLayout, ) +@Composable +fun BigMonospaceTextField( + modifier: Modifier = Modifier, + textFieldState: BigTextFieldState, + padding: PaddingValues = PaddingValues(4.dp), + fontSize: TextUnit = LocalFont.current.bodyFontSize, + color: Color = LocalColor.current.text, + visualTransformation: VisualTransformation, + scrollState: ScrollState = rememberScrollState(), + onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, +) { + BigMonospaceTextField( + modifier = modifier, + text = textFieldState.text, + padding = padding, + fontSize = fontSize, + color = color, + onTextChange = { + textFieldState.emitValueChange(it.changeId) + }, + visualTransformation = visualTransformation, + scrollState = scrollState, + viewState = textFieldState.viewState, + onTextLayout = onTextLayout + ) +} + @Composable fun BigMonospaceTextField( modifier: Modifier = Modifier, @@ -186,6 +213,8 @@ private fun CoreBigMonospaceText( viewState: BigTextViewState = remember { BigTextViewState() }, onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, ) { + log.d { "CoreBigMonospaceText recompose" } + val density = LocalDensity.current val textSelectionColors = LocalTextSelectionColors.current val fontFamilyResolver = LocalFontFamilyResolver.current @@ -236,6 +265,7 @@ private fun CoreBigMonospaceText( // } // } if (width > 0) { + log.d { "CoreBigMonospaceText set contentWidth = $contentWidth" } text.setLayouter(textLayouter) text.setContentWidth(contentWidth) lineHeight = (textLayouter.charMeasurer as ComposeUnicodeCharMeasurer).getRowHeight() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt new file mode 100644 index 00000000..75efb884 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt @@ -0,0 +1,37 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.runBlocking + +@Composable +fun rememberBigTextFieldState(cacheKey: String, bigText: BigTextImpl): BigTextFieldState { + return remember(cacheKey) { + BigTextFieldState(cacheKey, bigText, BigTextViewState()) + } +} + +@Composable +fun rememberBigTextFieldState(cacheKey: String, initialValue: String = ""): BigTextFieldState { + return rememberBigTextFieldState(cacheKey, BigText.createFromLargeString(initialValue)) +} + +class BigTextFieldState(val cacheKey: String, val text: BigTextImpl, val viewState: BigTextViewState) { + private val valueChangesMutableFlow = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val valueChangesFlow: SharedFlow = valueChangesMutableFlow + + internal fun emitValueChange(changeId: Long) { + logV.v { "BigTextFieldState emitValueChange A $changeId" } +// logV.v { "BigTextFieldState emitValueChange B $changeId" } + valueChangesMutableFlow.tryEmit(BigTextChangeWithoutDetail(changeId = changeId, bigText = text)) + } +} + +class BigTextChangeWithoutDetail(val changeId: Long, val bigText: BigTextImpl) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 9a31c453..9b741f4f 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -26,6 +26,11 @@ val logL = Logger(object : MutableLoggerConfig { override var minSeverity: Severity = Severity.Info }, tag = "BigText.Layout") +val logV = Logger(object : MutableLoggerConfig { + override var logWriterList: List = listOf(JvmLogger()) + override var minSeverity: Severity = Severity.Debug +}, tag = "BigText.View") + internal var isD = false private const val EPS = 1e-4f From f7d64d078bf1adb7190aa8037214beb35cf24287 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 1 Sep 2024 17:03:58 +0800 Subject: [PATCH 057/195] fix exception when initial value of BigMonospaceTextField is empty --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 8fc0795e..b2150c50 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -671,7 +671,7 @@ private fun CoreBigMonospaceText( } else { text.findRowPositionStartIndexByRowIndex(i + 1) } - val nonVisualEndIndex = maxOf(endIndex, startIndex + 1) + val nonVisualEndIndex = maxOf(endIndex, minOf(transformedText.text.length, startIndex + 1)) val cursorDisplayRangeEndIndex = if (i + 1 > text.lastRowIndex) { transformedText.text.length } else { From 3f8292905c3c0429d3b8c7eb95d0736aef955c79 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 1 Sep 2024 17:12:15 +0800 Subject: [PATCH 058/195] fix there is no line number in CodeEditorView if initial text is empty --- .../hellohttp/ux/CodeEditorView.kt | 11 ++++--- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 32 +++++++++++++++---- .../hellohttp/ux/bigtext/BigTextImpl.kt | 4 +++ .../ux/bigtext/BigTextLayoutResult.kt | 5 +++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 0eeb0efe..676c4c7b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -59,14 +59,13 @@ import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryAp import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForInsertionPoint import com.sunnychung.application.multiplatform.hellohttp.extension.contains import com.sunnychung.application.multiplatform.hellohttp.extension.insert -import com.sunnychung.application.multiplatform.hellohttp.util.ComposeUnicodeCharMeasurer import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextSimpleLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextViewState -import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.MonospaceTextLayouter import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.rememberBigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldColors import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDefaults @@ -442,7 +441,7 @@ fun CodeEditorView( collapsedChars -= collapsableChars[index] } - var layoutResult by remember { mutableStateOf(null) } + var layoutResult by remember { mutableStateOf(null) } // BigLineNumbersView( // scrollState = scrollState, @@ -464,6 +463,7 @@ fun CodeEditorView( bigTextViewState = bigTextFieldState.viewState, bigTextValueId = bigTextValueId, bigText = bigTextValue as BigTextImpl, + layoutResult = layoutResult, collapsableLines = collapsableLines, collapsedLines = collapsedLines.values.toList(), onCollapseLine = onCollapseLine, @@ -824,6 +824,7 @@ fun BigTextLineNumbersView( bigTextViewState: BigTextViewState, bigTextValueId: Long, bigText: BigTextImpl, + layoutResult: BigTextSimpleLayoutResult?, scrollState: ScrollState, collapsableLines: List, collapsedLines: List, @@ -847,8 +848,8 @@ fun BigTextLineNumbersView( val viewportTop = scrollState.value val firstLine = bigText.findLineIndexByRowIndex(bigTextViewState.firstVisibleRow) ?: 0 val lastLine = (bigText.findLineIndexByRowIndex(bigTextViewState.lastVisibleRow) ?: -100) + 1 - log.v { "firstVisibleRow = ${bigTextViewState.firstVisibleRow} (L $firstLine); lastVisibleRow = ${bigTextViewState.lastVisibleRow} (L $lastLine); totalLines = ${bigText.numOfLines}" } - val rowHeight = ((bigText.layouter as? MonospaceTextLayouter)?.charMeasurer as? ComposeUnicodeCharMeasurer)?.getRowHeight() ?: 0f + log.d { "firstVisibleRow = ${bigTextViewState.firstVisibleRow} (L $firstLine); lastVisibleRow = ${bigTextViewState.lastVisibleRow} (L $lastLine); totalLines = ${bigText.numOfLines}" } + val rowHeight = layoutResult?.rowHeight ?: 0f CoreLineNumbersView( firstLine = firstLine, lastLine = minOf(lastLine, bigText.numOfLines ?: 1), diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index b2150c50..2311724d 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.text.isTypedEvent import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.material.LocalTextStyle import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -100,7 +101,7 @@ fun BigMonospaceText( visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, - onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, + onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) = CoreBigMonospaceText( modifier = modifier, text = BigText.createFromLargeString(text), //InefficientBigText(text), @@ -127,7 +128,7 @@ fun BigMonospaceText( visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, - onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, + onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) = CoreBigMonospaceText( modifier = modifier, text = text, @@ -152,7 +153,7 @@ fun BigMonospaceTextField( color: Color = LocalColor.current.text, visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), - onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, + onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) { BigMonospaceTextField( modifier = modifier, @@ -181,7 +182,7 @@ fun BigMonospaceTextField( visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, - onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, + onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) = CoreBigMonospaceText( modifier = modifier, text = text as BigTextImpl, @@ -211,7 +212,7 @@ private fun CoreBigMonospaceText( visualTransformation: VisualTransformation, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, - onTextLayout: ((BigTextLayoutResult) -> Unit)? = null, + onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) { log.d { "CoreBigMonospaceText recompose" } @@ -264,12 +265,29 @@ private fun CoreBigMonospaceText( // 0 // } // } + fun fireOnLayout() { + lineHeight = (textLayouter.charMeasurer as ComposeUnicodeCharMeasurer).getRowHeight() + onTextLayout?.let { callback -> + callback(BigTextSimpleLayoutResult( + text = text, + rowHeight = lineHeight, + )) + } + } + if (width > 0) { log.d { "CoreBigMonospaceText set contentWidth = $contentWidth" } + text.onLayoutCallback = { + fireOnLayout() + } text.setLayouter(textLayouter) text.setContentWidth(contentWidth) - lineHeight = (textLayouter.charMeasurer as ComposeUnicodeCharMeasurer).getRowHeight() + + LaunchedEffect(Unit) { + fireOnLayout() + } } + val visualTransformationToUse = visualTransformation val transformedText = rememberLast(text.length, text.hashCode(), visualTransformationToUse) { visualTransformationToUse.filter(AnnotatedString(text.fullString())).also { @@ -671,7 +689,7 @@ private fun CoreBigMonospaceText( } else { text.findRowPositionStartIndexByRowIndex(i + 1) } - val nonVisualEndIndex = maxOf(endIndex, minOf(transformedText.text.length, startIndex + 1)) + val nonVisualEndIndex = minOf(transformedText.text.length, maxOf(endIndex, startIndex + 1)) val cursorDisplayRangeEndIndex = if (i + 1 > text.lastRowIndex) { transformedText.text.length } else { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 9b741f4f..99bb26f9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -53,6 +53,8 @@ class BigTextImpl : BigText { private var contentWidth: Float? = null + var onLayoutCallback: (() -> Unit)? = null + constructor() { chunkSize = 2 * 1024 * 1024 // 2 MB } @@ -1034,6 +1036,8 @@ class BigTextImpl : BigText { // tree.visitInPostOrder { // recomputeAggregatedValues(it) // } + + onLayoutCallback?.invoke() } val hasLayouted: Boolean diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt index 7ccb725d..a41b5080 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt @@ -27,3 +27,8 @@ class BigTextLayoutResult( @Deprecated("Slow") fun findCharWidth(char: String) = charMeasurer.findCharWidth(char) } + +class BigTextSimpleLayoutResult( + val text: BigText, + val rowHeight: Float +) From 46ed98a1c309b2fc0587bdcce5e85d122635a39b Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 1 Sep 2024 19:10:56 +0800 Subject: [PATCH 059/195] fix code editor should not trim large content if the text field is not read-only --- .../application/multiplatform/hellohttp/ux/CodeEditorView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 676c4c7b..c6e687e9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -120,7 +120,7 @@ fun CodeEditorView( var textValue by remember { mutableStateOf(TextFieldValue(text = text.filterForTextField())) } var cursorDelta by remember { mutableStateOf(0) } val newText = text.filterForTextField().let { - if (it.length > MAX_TEXT_FIELD_LENGTH) { + if (isReadOnly && it.length > MAX_TEXT_FIELD_LENGTH) { it.substring(0 .. MAX_TEXT_FIELD_LENGTH - 1) + "\n... (trimmed. total ${it.length} bytes)" } else { it From 40ac5431c924642bc04ff6938c0aada72c03ba2b Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 1 Sep 2024 23:13:05 +0800 Subject: [PATCH 060/195] add BigTextAsCharSequence --- doc/bigtext/usage/ValueMutationCallback.md | 70 +++++++++++++++++++ .../ux/bigtext/BigTextAsCharSequence.kt | 25 +++++++ 2 files changed, 95 insertions(+) create mode 100644 doc/bigtext/usage/ValueMutationCallback.md create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt diff --git a/doc/bigtext/usage/ValueMutationCallback.md b/doc/bigtext/usage/ValueMutationCallback.md new file mode 100644 index 00000000..56eb9e18 --- /dev/null +++ b/doc/bigtext/usage/ValueMutationCallback.md @@ -0,0 +1,70 @@ +# Text Value Mutation Callback + +## Method 1: Receive via Flow + +Listen to changes via reactive `Flow`. Convert `BigText` back to `String` on every event. Use a `debounce` operator to reduce number of conversions. + +### Advantage +- Clear control of expensive operations + +### Disadvantage +- Need to maintain a unique cache key per text instance. +- The expensive `BigText#fullString` is invoked frequently (but the interval is controllable). +- Result is not immediately obtained, which may be a trouble in state synchronization. + +```kotlin +val bigTextFieldState = rememberBigTextFieldState(cacheKey, textValue.text) +val scrollState = rememberScrollState() + +bigTextFieldState.valueChangesFlow + .debounce(1.seconds().toMilliseconds()) + .onEach { + onTextChange?.let { onTextChange -> + onTextChange(it.bigText.fullString()) + } + bigTextValueId = it.changeId + } + .launchIn(CoroutineScope(Dispatchers.Main)) + +BigMonospaceTextField( + textFieldState = bigTextFieldState, + visualTransformation = visualTransformationToUse, + fontSize = LocalFont.current.codeEditorBodyFontSize, + scrollState = scrollState, + modifier = Modifier.fillMaxSize() +) +``` +## Method 2: Wrap BigText as CharSequence + +### Advantage +- Able to keep the declarative code pattern +- The actual value is immediately available when it is needed +- The expensive `BigText#fullString` is never invoked if it is not needed + +### Disadvantage +- Need to change the usage of String type in models and composables to CharSequence (or BigText). +- The expensive `BigText#fullString` may be invoked (via `BigTextAsCharSequence#toString`) out of control. +- Lots of code changes to an existing code base, hence a significant risk to existing projects. + +```kotlin +val bigTextValue = BigText.wrap(newText) +val scrollState = rememberScrollState() +val bigTextViewState = remember(bigTextValue) { BigTextViewState() } + +BigMonospaceTextField( + text = bigTextValue, + onTextChange = { + onTextChange?.let { onTextChange -> + onTextChange(it.bigText.asCharSequence()) + } + bigTextValueId = it.changeId + }, + viewState = bigTextViewState, + visualTransformation = visualTransformationToUse, + fontSize = LocalFont.current.codeEditorBodyFontSize, + scrollState = scrollState, + onTextLayout = { layoutResult = it }, + modifier = Modifier.fillMaxSize() +) +``` + diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt new file mode 100644 index 00000000..12ba67cc --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt @@ -0,0 +1,25 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +class BigTextAsCharSequence(internal val bigText: BigTextImpl) : CharSequence { + override val length: Int + get() = bigText.length + + override fun get(index: Int): Char { + return bigText.substring(index, index + 1)[0] + } + + override fun subSequence(startIndex: Int, endIndex: Int): CharSequence { + return bigText.substring(startIndex, endIndex) + } + + override fun toString(): String = bigText.fullString() +} + +fun BigText.Companion.wrap(charSequence: CharSequence): BigTextImpl { + return when (charSequence) { + is BigTextAsCharSequence -> charSequence.bigText + else -> BigText.createFromLargeString(charSequence.toString()) + } +} + +fun BigTextImpl.asCharSequence(): CharSequence = BigTextAsCharSequence(this) From 32cf71f424c17f563a8d79b5a53e6a972ddf6e67 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 1 Sep 2024 23:19:29 +0800 Subject: [PATCH 061/195] fix duplicated semantic properties --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 2311724d..32c52bd2 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -460,23 +460,6 @@ private fun CoreBigMonospaceText( .padding(padding) .scrollable(scrollableState, orientation = Orientation.Vertical) .focusRequester(focusRequester) - .semantics { - if (isEditable) { - editableText = transformedText.text - setText { - text.replace(0, text.length, it.text) - true - } - insertTextAtCursor { - text.insertAt(viewState.cursorIndex, it.text) - true - } - } else { - this.text = transformedText.text - setText { false } - insertTextAtCursor { false } - } - } .onDrag( enabled = isSelectable, onDragStart = { @@ -667,8 +650,18 @@ private fun CoreBigMonospaceText( log.d { "semantic lambda" } if (isEditable) { editableText = AnnotatedString(text.fullString(), transformedText.text.spanStyles) + setText { + text.replace(0, text.length, it.text) + true + } + insertTextAtCursor { + text.insertAt(viewState.cursorIndex, it.text) + true + } } else { this.text = AnnotatedString(text.fullString(), transformedText.text.spanStyles) + setText { false } + insertTextAtCursor { false } } } From b4b4b4a3e36f66e42ac8103a1cb5ee2c768e229f Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 1 Sep 2024 23:49:09 +0800 Subject: [PATCH 062/195] fix exception when copying a text where its last line ends with '\n' --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 8 +++++--- .../multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 32c52bd2..daceb567 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -345,9 +345,10 @@ private fun CoreBigMonospaceText( fun getTransformedCharIndex(x: Float, y: Float, mode: ResolveCharPositionMode): Int { val row = ((viewportTop + y) / lineHeight).toInt() + val maxIndex = maxOf(0, transformedText.text.length - if (mode == ResolveCharPositionMode.Selection) 1 else 0) // val col = (x / charWidth).toInt() if (row > text.lastRowIndex) { - return maxOf(0, transformedText.text.length - if (mode == ResolveCharPositionMode.Selection) 1 else 0) + return maxIndex } else if (row < 0) { return 0 } @@ -375,7 +376,7 @@ private fun CoreBigMonospaceText( }.takeIf { it >= 0 } ?: rowString.length - if (rowString.endsWith('\n')) 1 else 0 - return rowPositionStart + charIndex + return minOf(maxIndex, rowPositionStart + charIndex) } fun getTransformedStringWidth(start: Int, endExclusive: Int): Float { @@ -508,7 +509,8 @@ private fun CoreBigMonospaceText( viewState.transformedCursorIndex = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Cursor) viewState.updateCursorIndexByTransformed(transformedText) if (!isHoldingShiftKey) { - viewState.transformedSelectionStart = viewState.transformedCursorIndex + // for selection, max possible index is 1 less than that for cursor + viewState.transformedSelectionStart = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Selection) } log.v { "set cursor pos 1 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 99bb26f9..9811cb6e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -504,7 +504,7 @@ class BigTextImpl : BigText { override fun substring(start: Int, endExclusive: Int): String { // O(lg L + (e - s)) require(start <= endExclusive) { "start should be <= endExclusive" } require(0 <= start) { "Invalid start" } - require(endExclusive <= length) { "endExclusive is out of bound. length = $length" } + require(endExclusive <= length) { "endExclusive $endExclusive is out of bound. length = $length" } if (start == endExclusive) { return "" From fd4bc05f2bffa4a7321a2c55fb514a9436117f49 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 1 Sep 2024 23:53:38 +0800 Subject: [PATCH 063/195] add "Ctrl/Cmd-V to paste" to BigMonospaceTextField --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index daceb567..4d1f236c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -573,6 +573,17 @@ private fun CoreBigMonospaceText( clipboardManager.setText(AnnotatedString(textToCopy)) true } + isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.V -> { + // Hit Ctrl-C or Cmd-C to copy + log.d { "BigMonospaceTextField hit paste" } + val textToPaste = clipboardManager.getText()?.text + if (!textToPaste.isNullOrEmpty()) { + onType(textToPaste) + true + } else { + false + } + } it.type == KeyEventType.KeyDown && it.key in listOf(Key.ShiftLeft, Key.ShiftRight) -> { isHoldingShiftKey = true false From 902dfc5f6c26808b7e88598fc949f16b6023e9fd Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 1 Sep 2024 23:57:25 +0800 Subject: [PATCH 064/195] fix CodeEditorView is too slow in reacting to text input to pass UX tests --- .../application/multiplatform/hellohttp/ux/CodeEditorView.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index c6e687e9..50e819eb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -77,6 +77,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.Envi import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.FunctionTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.MultipleVisualTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.SearchHighlightTransformation +import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds import com.sunnychung.lib.multiplatform.kdatetime.extension.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -565,7 +566,7 @@ fun CodeEditorView( // var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(text)) } // FIXME performance bigTextFieldState.valueChangesFlow - .debounce(1.seconds().toMilliseconds()) + .debounce(100.milliseconds().toMilliseconds()) .onEach { log.d { "bigTextFieldState change ${it.changeId}" } onTextChange?.let { onTextChange -> From 9ce08a4a1ebc4faabfc471d2efaba78eae903d73 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 2 Sep 2024 00:08:50 +0800 Subject: [PATCH 065/195] add "Ctrl/Cmd-A to select all" to BigMonospaceText/BigMonospaceTextField --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 4d1f236c..e1ebe92f 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -574,7 +574,7 @@ private fun CoreBigMonospaceText( true } isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.V -> { - // Hit Ctrl-C or Cmd-C to copy + // Hit Ctrl-V or Cmd-V to paste log.d { "BigMonospaceTextField hit paste" } val textToPaste = clipboardManager.getText()?.text if (!textToPaste.isNullOrEmpty()) { @@ -584,6 +584,13 @@ private fun CoreBigMonospaceText( false } } + /* selection */ + it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.A -> { + // Hit Ctrl-A or Cmd-A to select all + viewState.selection = 0 .. text.lastIndex + viewState.updateTransformedSelectionBySelection(transformedText) + true + } it.type == KeyEventType.KeyDown && it.key in listOf(Key.ShiftLeft, Key.ShiftRight) -> { isHoldingShiftKey = true false @@ -592,6 +599,7 @@ private fun CoreBigMonospaceText( isHoldingShiftKey = false false } + /* text input */ isEditable && it.isTypedEvent -> { log.v { "key type '${it.key}'" } val textInput = it.toTextInput() @@ -779,6 +787,11 @@ class BigTextViewState { transformedText.offsetMapping.transformedToOriginal(transformedSelection.last) } + internal fun updateTransformedSelectionBySelection(transformedText: TransformedText) { + transformedSelection = transformedText.offsetMapping.originalToTransformed(selection.first) .. + transformedText.offsetMapping.originalToTransformed(selection.last) + } + internal var transformedCursorIndex by mutableStateOf(0) var cursorIndex by mutableStateOf(0) From 65f504d379c1cf8a2859ed32cc3471a4962d569f Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 2 Sep 2024 08:10:59 +0800 Subject: [PATCH 066/195] fix BigMonospaceTextField in UX test did not invoke callback on typing --- .../multiplatform/hellohttp/extension/KeyEventExtension.kt | 2 -- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 7 ++++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt index 50e82408..1f1171b1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/KeyEventExtension.kt @@ -5,7 +5,6 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.isMetaPressed -import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.utf16CodePoint import com.sunnychung.application.multiplatform.hellohttp.platform.MacOS import com.sunnychung.application.multiplatform.hellohttp.platform.currentOS @@ -16,7 +15,6 @@ fun KeyEvent.isCtrlOrCmdPressed(): Boolean { } else { isCtrlPressed } - key } fun KeyEvent.toTextInput(): String? { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index e1ebe92f..40091913 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -409,7 +409,7 @@ private fun CoreBigMonospaceText( fun onType(textInput: String) { log.v { "key in '$textInput'" } if (viewState.hasSelection()) { - text.delete(viewState.selection.start, viewState.selection.endInclusive + 1) + text.delete(viewState.selection.start, minOf(text.length, viewState.selection.endInclusive + 1)) viewState.cursorIndex = viewState.selection.start viewState.selection = IntRange.EMPTY viewState.transformedSelection = IntRange.EMPTY @@ -672,11 +672,12 @@ private fun CoreBigMonospaceText( if (isEditable) { editableText = AnnotatedString(text.fullString(), transformedText.text.spanStyles) setText { - text.replace(0, text.length, it.text) + viewState.selection = 0 .. text.lastIndex + onType(it.text) true } insertTextAtCursor { - text.insertAt(viewState.cursorIndex, it.text) + onType(it.text) true } } else { From 57187015b9f358eeae7b938ee4b9c18aa9924776 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 2 Sep 2024 20:56:57 +0800 Subject: [PATCH 067/195] refactor BigText#fullString to BigText#buildString --- .../hellohttp/ux/CodeEditorView.kt | 3 +- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 6 +- .../hellohttp/ux/bigtext/BigText.kt | 2 +- .../ux/bigtext/BigTextAsCharSequence.kt | 2 +- .../hellohttp/ux/bigtext/BigTextImpl.kt | 4 +- .../ux/bigtext/InefficientBigText.kt | 4 +- .../test/bigtext/BigTextImplLayoutTest.kt | 96 +++++++++---------- .../test/bigtext/BigTextImplQueryTest.kt | 8 +- .../hellohttp/test/bigtext/BigTextImplTest.kt | 32 +++---- .../test/bigtext/BigTextVerifyImpl.kt | 8 +- 10 files changed, 82 insertions(+), 83 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 50e819eb..c5c4bbd2 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -78,7 +78,6 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.Func import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.MultipleVisualTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.SearchHighlightTransformation import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds -import com.sunnychung.lib.multiplatform.kdatetime.extension.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.debounce @@ -570,7 +569,7 @@ fun CodeEditorView( .onEach { log.d { "bigTextFieldState change ${it.changeId}" } onTextChange?.let { onTextChange -> - onTextChange(it.bigText.fullString()) + onTextChange(it.bigText.buildString()) } bigTextValueId = it.changeId } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 40091913..90dbfa5a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -290,7 +290,7 @@ private fun CoreBigMonospaceText( val visualTransformationToUse = visualTransformation val transformedText = rememberLast(text.length, text.hashCode(), visualTransformationToUse) { - visualTransformationToUse.filter(AnnotatedString(text.fullString())).also { + visualTransformationToUse.filter(AnnotatedString(text.buildString())).also { log.v { "transformed text = `$it`" } } } @@ -670,7 +670,7 @@ private fun CoreBigMonospaceText( .semantics { log.d { "semantic lambda" } if (isEditable) { - editableText = AnnotatedString(text.fullString(), transformedText.text.spanStyles) + editableText = AnnotatedString(text.buildString(), transformedText.text.spanStyles) setText { viewState.selection = 0 .. text.lastIndex onType(it.text) @@ -681,7 +681,7 @@ private fun CoreBigMonospaceText( true } } else { - this.text = AnnotatedString(text.fullString(), transformedText.text.spanStyles) + this.text = AnnotatedString(text.buildString(), transformedText.text.spanStyles) setText { false } insertTextAtCursor { false } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt index 61b7c3bf..1620a012 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -7,7 +7,7 @@ interface BigText { val length: Int - fun fullString(): String + fun buildString(): String fun substring(start: Int, endExclusive: Int): String diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt index 12ba67cc..63f97346 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt @@ -12,7 +12,7 @@ class BigTextAsCharSequence(internal val bigText: BigTextImpl) : CharSequence { return bigText.substring(startIndex, endIndex) } - override fun toString(): String = bigText.fullString() + override fun toString(): String = bigText.buildString() } fun BigText.Companion.wrap(charSequence: CharSequence): BigTextImpl { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 9811cb6e..c25b5df6 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -495,7 +495,7 @@ class BigTextImpl : BigText { val lastIndex: Int get() = length - 1 - override fun fullString(): String { + override fun buildString(): String { return tree.joinToString("") { buffers[it.bufferIndex].subSequence(it.bufferOffsetStart, it.bufferOffsetEndExclusive) } @@ -756,7 +756,7 @@ class BigTextImpl : BigText { appendLine("[$label] Tree:\nflowchart TD\n${tree.debugTree()}") appendLine("[$label] String:\n${fullString()}") if (layouter != null && contentWidth != null) { - appendLine("[$label] Layouted String:\n${(0 until numOfRows).joinToString("") { + appendLine("[$label] Layouted String:\n${(0 until numOfRows).joinToString("") { try { "{${findRowString(it)}}\n" } catch (e: Throwable) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt index 18420408..ad7220ee 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt @@ -8,7 +8,7 @@ class InefficientBigText(text: String) : BigText { override val length: Int get() = string.length - override fun fullString(): String = string + override fun buildString(): String = string override fun substring(start: Int, endExclusive: Int): String = string.substring(start, endExclusive) @@ -39,7 +39,7 @@ class InefficientBigText(text: String) : BigText { return false } return when(other) { - is InefficientBigText -> string == other.fullString() + is InefficientBigText -> string == other.buildString() else -> TODO() } } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt index 95bb7b29..0dd96a36 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt @@ -158,10 +158,10 @@ class BigTextImplLayoutTest { bigTextImpl.setContentWidth(16f * 10 + 1.23f) printDebug("after 1st layout") } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.insertAt(5, add) t.printDebug("after relayout") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -174,10 +174,10 @@ class BigTextImplLayoutTest { bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) bigTextImpl.setContentWidth(16f * 10 + 1.23f) } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.insertAt(12, add) t.printDebug("after relayout") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -190,10 +190,10 @@ class BigTextImplLayoutTest { bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) bigTextImpl.setContentWidth(16f * 10 + 1.23f) } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.insertAt(39, add) t.printDebug("after relayout") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -206,16 +206,16 @@ class BigTextImplLayoutTest { bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) bigTextImpl.setContentWidth(16f * 10 + 1.23f) } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.insertAt(1677, randomString(1000, isAddNewLine = false) + "\n") t.printDebug("after relayout 1") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.insertAt(4989, randomString(2000, isAddNewLine = false) + "\n") t.printDebug("after relayout 2") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.insertAt(8912, randomString(1000, isAddNewLine = false) + "\n") t.printDebug("after relayout 3") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -228,19 +228,19 @@ class BigTextImplLayoutTest { bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) bigTextImpl.setContentWidth(16f * 10 + 1.23f) } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.insertAt(467, randomString(30, isAddNewLine = false) + "\n") t.printDebug("after relayout 1") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.insertAt(491, randomString(35, isAddNewLine = false) + "\n") t.printDebug("after relayout 2") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.insertAt(112, randomString(500, isAddNewLine = false) + "\n") t.printDebug("after relayout 3") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.insertAt(480, randomString(399, isAddNewLine = false) + "\n") t.printDebug("after relayout 4") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -257,7 +257,7 @@ class BigTextImplLayoutTest { listOf(15, 4, 1, 1, 2, 8, 16, 19, 200, 1235, 2468, 10001, 257).forEachIndexed { i, it -> t.insertAt(0, randomString(it, isAddNewLine = false) + "\n") t.printDebug("after relayout $softWrapAt, $i") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) } } } @@ -293,7 +293,7 @@ class BigTextImplLayoutTest { else -> random.nextInt(t.length + 1) } t.insertAt(pos, "\n".repeat(length)) - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) } } } @@ -341,7 +341,7 @@ class BigTextImplLayoutTest { isD = true } verifyBigTextImplAgainstTestString( - testString = t.stringImpl.fullString(), + testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt ) @@ -360,10 +360,10 @@ class BigTextImplLayoutTest { bigTextImpl.setContentWidth(16f * 10 + 1.23f) printDebug("after 1st layout") } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.delete(18, 18 + 6) t.printDebug("after relayout") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -378,10 +378,10 @@ class BigTextImplLayoutTest { bigTextImpl.setContentWidth(16f * 10 + 1.23f) printDebug("after 1st layout") } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.delete(5, 5 + s2.length) t.printDebug("after relayout") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -396,10 +396,10 @@ class BigTextImplLayoutTest { bigTextImpl.setContentWidth(16f * 10 + 1.23f) printDebug("after 1st layout") } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.delete(3, 5 + s2.length + 14) t.printDebug("after relayout") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -414,10 +414,10 @@ class BigTextImplLayoutTest { bigTextImpl.setContentWidth(16f * 10 + 1.23f) printDebug("after 1st layout") } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.delete(0, initial.length) t.printDebug("after relayout") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -430,22 +430,22 @@ class BigTextImplLayoutTest { bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) bigTextImpl.setContentWidth(16f * 10 + 1.23f) } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.delete(467, 467 + 30) t.printDebug("after relayout 1") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.delete(491, 491 + 35) t.printDebug("after relayout 2") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.delete(112, 112 + 500) t.printDebug("after relayout 3") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.delete(480, 480 + 299) t.printDebug("after relayout 4") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) t.delete(90, 90 + 338) t.printDebug("after relayout 5") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -462,7 +462,7 @@ class BigTextImplLayoutTest { listOf(15, 4, 1, 1, 2, 8, 16, 19, 200, 1235, 2468, 10001, 257, 1, 0, 13).forEachIndexed { i, it -> t.delete(0, 0 + it) // t.printDebug("after relayout $softWrapAt, $i") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) } } } @@ -476,11 +476,11 @@ class BigTextImplLayoutTest { bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) bigTextImpl.setContentWidth(16f * 10 + 1.23f) } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) listOf((25..32), (14 .. 16), (14 .. 15), (5 .. 7), (1 .. 1), (0 .. 0), (14 .. 16)).forEach { t.delete(it) t.printDebug("after delete $it") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } } @@ -527,7 +527,7 @@ class BigTextImplLayoutTest { } t.delete(pos, minOf(t.length, pos + length)) // t.printDebug("after relayout $softWrapAt, $i") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) } } } @@ -572,7 +572,7 @@ class BigTextImplLayoutTest { logL.d { t.inspect("after relayout $repeatIt $softWrapAt, $i") } println("Iterate $repeatIt, $softWrapAt, $i") verifyBigTextImplAgainstTestString( - testString = t.stringImpl.fullString(), + testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt ) @@ -593,11 +593,11 @@ class BigTextImplLayoutTest { bigTextImpl.setContentWidth(16f * 10 + 1.23f) printDebug("after 1st layout") } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) val pos = initial.indexOf(beingReplaced) t.replace(pos, pos + beingReplaced.length, replaceAs) t.printDebug("after relayout") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -612,16 +612,16 @@ class BigTextImplLayoutTest { bigTextImpl.setContentWidth(16f * 10 + 1.23f) printDebug("after 1st layout") } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) var pos = initial.indexOf(beingReplaced) t.replace(pos, pos + beingReplaced.length, replaceAs) t.printDebug("after relayout") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) pos = initial.indexOf("H") t.replace(pos, pos + 9, "--\n-") t.printDebug("after relayout") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -636,11 +636,11 @@ class BigTextImplLayoutTest { bigTextImpl.setContentWidth(16f * 10 + 1.23f) printDebug("after 1st layout") } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) var pos = initial.indexOf(beingReplaced) t.replace(pos, pos + beingReplaced.length, replaceAs) t.printDebug("after relayout") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } @ParameterizedTest @@ -652,11 +652,11 @@ class BigTextImplLayoutTest { bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) bigTextImpl.setContentWidth(16f * 10 + 1.23f) } - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) listOf((25..32), (14 .. 16), (14 .. 15), (5 .. 7), (1 .. 1), (0 .. 0), (14 .. 16)).forEach { t.replace(it, "A\n\n") t.printDebug("after delete $it") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } } @@ -675,7 +675,7 @@ class BigTextImplLayoutTest { lengths.forEachIndexed { i, it -> t.replace(0 until lengths.random(random), randomString(it, isAddNewLine = false) + "\n") // t.printDebug("after relayout $softWrapAt, $i") - verifyBigTextImplAgainstTestString(testString = t.stringImpl.fullString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) } } } @@ -724,7 +724,7 @@ class BigTextImplLayoutTest { } logL.d { t.inspect("after relayout $repeatIt $softWrapAt, $i") } verifyBigTextImplAgainstTestString( - testString = t.stringImpl.fullString(), + testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt ) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt index c1e61168..a70ff8c0 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt @@ -133,7 +133,7 @@ class BigTextImplQueryTest { delete(pos3, pos3 + 45678) delete(pos1, pos1 + 19) } - val s = t.stringImpl.fullString() + val s = t.stringImpl.buildString() println("len = ${s.length}") val splitted = s.split("\n") splitted.forEachIndexed { i, line -> @@ -149,7 +149,7 @@ class BigTextImplQueryTest { append(lines) delete(0, 18) } - val s = t.stringImpl.fullString() + val s = t.stringImpl.buildString() println("len = ${s.length}") val splitted = s.split("\n") splitted.forEachIndexed { i, line -> @@ -179,7 +179,7 @@ class BigTextImplQueryTest { insertAt(0, generateString(29)) delete(pos1, pos1 + 19) } - val s = t.stringImpl.fullString() + val s = t.stringImpl.buildString() println("len = ${s.length}") val splitted = s.split("\n") splitted.forEachIndexed { i, line -> @@ -222,7 +222,7 @@ class BigTextImplQueryTest { } private fun BigTextVerifyImpl.verifyAllLines() { - val splitted = this.stringImpl.fullString().split("\n") + val splitted = this.stringImpl.buildString().split("\n") splitted.forEachIndexed { i, line -> val result = this.bigTextImpl.findLineString(i) assertEquals(if (i == splitted.lastIndex) line else "$line\n", result) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt index 303e382d..709691b6 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplTest.kt @@ -121,7 +121,7 @@ class BigTextImplTest { val t = BigTextVerifyImpl(chunkSize = 64) t.printDebug() assertEquals(0, t.tree.size()) - assertEquals(0, t.fullString().length) + assertEquals(0, t.buildString().length) assertEquals(0, t.length) } @@ -136,7 +136,7 @@ class BigTextImplTest { t.insertAt(4, "") t.printDebug() assertEquals(1, t.tree.size()) - assertEquals(4, t.fullString().length) + assertEquals(4, t.buildString().length) assertEquals(4, t.length) } @@ -150,7 +150,7 @@ class BigTextImplTest { assertEquals(240 / 16, t.buffers.size) assertEquals((100 / 16 + 1) + 1 + (100 / 16 + 1) + (40 / 16 + 1), t.tree.size()) assertEquals(240, t.length) - assertEquals(240, t.fullString().length) + assertEquals(240, t.buildString().length) } @Test @@ -163,7 +163,7 @@ class BigTextImplTest { assertEquals(30000 / 64 + 1, t.buffers.size) // assertEquals((30000 / 64 + 1) + 1 + (30000 / 64 + 1) + (30000 / 64 + 1), t.tree.size()) assertEquals(30000, t.length) - assertEquals(30000, t.fullString().length) + assertEquals(30000, t.buildString().length) } @Test @@ -176,7 +176,7 @@ class BigTextImplTest { assertEquals(5000000 / 64, t.buffers.size) assertEquals((1000000 / 64 + 1) + (1000000 / 64 + 1) + (3000000 / 64 + 1) - 2, t.tree.size()) assertEquals(5000000, t.length) - assertEquals(5000000, t.fullString().length) + assertEquals(5000000, t.buildString().length) } // @Test @@ -202,7 +202,7 @@ class BigTextImplTest { val len = 339 + 46 assertEquals(len / 64 + 1, t.buffers.size) assertEquals(len, t.length) - assertEquals(len, t.fullString().length) + assertEquals(len, t.buildString().length) } @Test @@ -220,7 +220,7 @@ class BigTextImplTest { val len = 150 + 13 + 62 + 58 + 7 + 90 + 64 + 129 assertEquals(len / 64 + 1, t.buffers.size) assertEquals(len, t.length) - assertEquals(len, t.fullString().length) + assertEquals(len, t.buildString().length) } @Test @@ -235,7 +235,7 @@ class BigTextImplTest { val len = 339 + 46 * 4 assertEquals(len / 64 + 1, t.buffers.size) assertEquals(len, t.length) - assertEquals(len, t.fullString().length) + assertEquals(len, t.buildString().length) } @Test @@ -248,7 +248,7 @@ class BigTextImplTest { val len = 654 + 80 + 26 assertEquals(len / 64 + 1, t.buffers.size) assertEquals(len, t.length) - assertEquals(len, t.fullString().length) + assertEquals(len, t.buildString().length) } /** @@ -306,7 +306,7 @@ class BigTextImplTest { t.delete(64 * 0 + 10, 64 * 0 + 30) val len = 64 * 3 - 20 * 3 assertEquals(len, t.length) - assertEquals(len, t.fullString().length) + assertEquals(len, t.buildString().length) } @Test @@ -322,7 +322,7 @@ class BigTextImplTest { t.delete(d3range) val len = 64 * 10 - d1range.length - d2range.length - d3range.length assertEquals(len, t.length) - assertEquals(len, t.fullString().length) + assertEquals(len, t.buildString().length) } @Test @@ -336,7 +336,7 @@ class BigTextImplTest { t.delete(0 .. 29) val len = 654 - 20 * 4 - 30 assertEquals(len, t.length) - assertEquals(len, t.fullString().length) + assertEquals(len, t.buildString().length) } @ParameterizedTest @@ -349,7 +349,7 @@ class BigTextImplTest { if (length == 640) isD = true t.delete(0 until t.length) assertEquals(0, t.length) - assertEquals(0, t.fullString().length) + assertEquals(0, t.buildString().length) assertEquals(0, t.tree.size()) } @@ -364,7 +364,7 @@ class BigTextImplTest { t.delete(13 .. 69) val len = 1024 - 20 - 501 - 280 - 57 assertEquals(len, t.length) - assertEquals(len, t.fullString().length) + assertEquals(len, t.buildString().length) } @Test @@ -376,7 +376,7 @@ class BigTextImplTest { t.delete(343 .. 456) val len = 1024 - (457 - 343) assertEquals(len, t.length) - assertEquals(len, t.fullString().length) + assertEquals(len, t.buildString().length) } @ParameterizedTest @@ -393,7 +393,7 @@ class BigTextImplTest { } } assertEquals(len, t.length) - assertEquals(len, t.fullString().length) + assertEquals(len, t.buildString().length) } @ParameterizedTest diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index 0bc62618..4fb99ade 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -19,9 +19,9 @@ internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : Big return l } - override fun fullString(): String { - val r = bigTextImpl.fullString() - val tr = stringImpl.fullString() + override fun buildString(): String { + val r = bigTextImpl.buildString() + val tr = stringImpl.buildString() assert(r == tr) { "fullString expected $tr, actual $r" } return r } @@ -77,7 +77,7 @@ internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : Big fun verify(label: String = "") { printDebugIfError(label) { length - fullString() + buildString() } } From e7fc87ed5568d125edcdadc8d192d0394b7b274c Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 4 Sep 2024 22:18:42 +0800 Subject: [PATCH 068/195] fix BigMonospaceTextField state does not reset if initial text changes --- doc/bigtext/usage/ValueMutationCallback.md | 1 + .../hellohttp/ux/CodeEditorView.kt | 17 ++++++----- .../hellohttp/ux/bigtext/BigTextFieldState.kt | 30 +++++++++++++------ .../hellohttp/ux/bigtext/BigTextImpl.kt | 3 +- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/doc/bigtext/usage/ValueMutationCallback.md b/doc/bigtext/usage/ValueMutationCallback.md index 56eb9e18..e6e463ae 100644 --- a/doc/bigtext/usage/ValueMutationCallback.md +++ b/doc/bigtext/usage/ValueMutationCallback.md @@ -45,6 +45,7 @@ BigMonospaceTextField( - Need to change the usage of String type in models and composables to CharSequence (or BigText). - The expensive `BigText#fullString` may be invoked (via `BigTextAsCharSequence#toString`) out of control. - Lots of code changes to an existing code base, hence a significant risk to existing projects. +- Memory usage can be higher, as BigText uses more memory than String in linear to number of changes. ```kotlin val bigTextValue = BigText.wrap(newText) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index c5c4bbd2..916e12ba 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -62,6 +62,7 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.insert import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextSimpleLayoutResult @@ -454,13 +455,13 @@ fun CodeEditorView( // modifier = Modifier.fillMaxHeight(), // ) - val bigTextFieldState = rememberBigTextFieldState(cacheKey, textValue.text) - val bigTextValue = bigTextFieldState.text + val (secondCacheKey, bigTextFieldState) = rememberBigTextFieldState(initialValue = textValue.text, cacheKey) + val bigTextValue = bigTextFieldState.value.text var bigTextValueId by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(Random.nextLong()) } BigTextLineNumbersView( scrollState = scrollState, - bigTextViewState = bigTextFieldState.viewState, + bigTextViewState = bigTextFieldState.value.viewState, bigTextValueId = bigTextValueId, bigText = bigTextValue as BigTextImpl, layoutResult = layoutResult, @@ -479,7 +480,7 @@ fun CodeEditorView( fontSize = LocalFont.current.codeEditorBodyFontSize, isSelectable = true, scrollState = scrollState, - viewState = bigTextFieldState.viewState, + viewState = bigTextFieldState.value.viewState, onTextLayout = { layoutResult = it }, modifier = Modifier.fillMaxSize() .run { @@ -564,19 +565,21 @@ fun CodeEditorView( // var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(text)) } // FIXME performance - bigTextFieldState.valueChangesFlow + bigTextFieldState.value.valueChangesFlow .debounce(100.milliseconds().toMilliseconds()) .onEach { log.d { "bigTextFieldState change ${it.changeId}" } onTextChange?.let { onTextChange -> - onTextChange(it.bigText.buildString()) + val string = it.bigText.buildString() + onTextChange(string) + secondCacheKey.value = string } bigTextValueId = it.changeId } .launchIn(CoroutineScope(Dispatchers.Main)) BigMonospaceTextField( - textFieldState = bigTextFieldState, + textFieldState = bigTextFieldState.value, visualTransformation = visualTransformationToUse, fontSize = LocalFont.current.codeEditorBodyFontSize, // textStyle = LocalTextStyle.current.copy( diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt index 75efb884..a43dbdce 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt @@ -1,25 +1,37 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.runBlocking @Composable -fun rememberBigTextFieldState(cacheKey: String, bigText: BigTextImpl): BigTextFieldState { - return remember(cacheKey) { - BigTextFieldState(cacheKey, bigText, BigTextViewState()) +fun rememberBigTextFieldState(initialValue: String = "", vararg cacheKey: Any?): Pair, MutableState> { + val secondCacheKey = rememberSaveable(*cacheKey) { mutableStateOf(initialValue) } + val state = rememberSaveable(*cacheKey) { + log.d { "cache miss 1" } + mutableStateOf(BigTextFieldState(BigText.createFromLargeString(initialValue), BigTextViewState())) } + if (initialValue !== secondCacheKey.value) { + log.d { "cache miss. old key2 = ${secondCacheKey.value.abbr()}; new key2 = ${initialValue.abbr()}" } + secondCacheKey.value = initialValue + state.value = BigTextFieldState(BigText.createFromLargeString(initialValue), BigTextViewState()) + } + return secondCacheKey to state } -@Composable -fun rememberBigTextFieldState(cacheKey: String, initialValue: String = ""): BigTextFieldState { - return rememberBigTextFieldState(cacheKey, BigText.createFromLargeString(initialValue)) +private fun String.abbr(): String { + return if (this.length > 20) { + substring(0 .. 19) + } else { + this + } } -class BigTextFieldState(val cacheKey: String, val text: BigTextImpl, val viewState: BigTextViewState) { +class BigTextFieldState(val text: BigTextImpl, val viewState: BigTextViewState) { private val valueChangesMutableFlow = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index c25b5df6..26eb90f3 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -754,7 +754,7 @@ class BigTextImpl : BigText { appendLine("[$label] Buffer:\n${buffers.mapIndexed { i, it -> " $i:\t$it\n" }.joinToString("")}") appendLine("[$label] Buffer Line Breaks:\n${buffers.mapIndexed { i, it -> " $i:\t${it.lineOffsetStarts}\n" }.joinToString("")}") appendLine("[$label] Tree:\nflowchart TD\n${tree.debugTree()}") - appendLine("[$label] String:\n${fullString()}") + appendLine("[$label] String:\n${buildString()}") if (layouter != null && contentWidth != null) { appendLine("[$label] Layouted String:\n${(0 until numOfRows).joinToString("") { try { @@ -1127,5 +1127,6 @@ private enum class InsertDirection { } fun BigText.Companion.createFromLargeString(initialContent: String) = BigTextImpl().apply { + log.d { "createFromLargeString ${initialContent.length}" } append(initialContent) } From 63db38c099bbb66cb94155d6bc23a94d65b7767f Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Thu, 5 Sep 2024 00:55:01 +0800 Subject: [PATCH 069/195] fix rendering a surrogate pair (e.g. emoji) in BigMonospaceText/TextField would throw an exception --- .../util/ComposeUnicodeCharMeasurer.kt | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt index 3371a96c..a275ec67 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ComposeUnicodeCharMeasurer.kt @@ -15,8 +15,20 @@ class ComposeUnicodeCharMeasurer(private val measurer: TextMeasurer, private val */ override fun measureFullText(text: String) { val charToMeasure = mutableSetOf() + var surrogatePairFirst: Char? = null text.forEach { - val s = it.toString() + var s = it.toString() + if (surrogatePairFirst == null && s[0].isSurrogatePairFirst()) { + surrogatePairFirst = s[0] + return@forEach + } else if (surrogatePairFirst != null) { + if (s[0].isSurrogatePairSecond()) { + s = "$surrogatePairFirst${s[0]}" + } else { + s = s.substring(0, 1) + } + surrogatePairFirst = null + } if (!charWidth.containsKey(s) && shouldIndexChar(s)) { charToMeasure += s } @@ -27,8 +39,13 @@ class ComposeUnicodeCharMeasurer(private val measurer: TextMeasurer, private val /** * Time complexity = O(lg C) + * + * TODO: handle surrogate pair correctly */ override fun findCharWidth(char: String): Float { + if (char[0].isSurrogatePairFirst()) { + return 0f + } when (char.codePoints().findFirst().asInt) { in 0x4E00..0x9FFF, in 0x3400..0x4DBF, @@ -72,6 +89,14 @@ class ComposeUnicodeCharMeasurer(private val measurer: TextMeasurer, private val } } + fun Char.isSurrogatePairFirst(): Boolean { + return code in (0xD800 .. 0xDBFF) + } + + fun Char.isSurrogatePairSecond(): Boolean { + return code in (0xDC00 .. 0xDFFF) + } + init { measureAndIndex(COMPULSORY_MEASURES) // hardcode, because calling TextMeasurer#measure() against below characters returns zero width From af2e94ee7dc6546e9b9942304a53b30233e9f88e Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Thu, 5 Sep 2024 18:07:47 +0800 Subject: [PATCH 070/195] revert adding cache key to CodeEditorView --- .../multiplatform/hellohttp/ux/CodeEditorView.kt | 3 +-- .../multiplatform/hellohttp/ux/KotliteCodeEditorView.kt | 2 -- .../multiplatform/hellohttp/ux/RequestEditorView.kt | 8 -------- .../multiplatform/hellohttp/ux/ResponseViewerView.kt | 3 --- .../hellohttp/ux/bigtext/BigTextFieldState.kt | 6 +++--- 5 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 916e12ba..444a8bc8 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -94,7 +94,6 @@ val MAX_TEXT_FIELD_LENGTH = 4 * 1024 * 1024 // 4 MB fun CodeEditorView( modifier: Modifier = Modifier, isReadOnly: Boolean = false, - cacheKey: String, text: String, onTextChange: ((String) -> Unit)? = null, collapsableLines: List = emptyList(), @@ -455,7 +454,7 @@ fun CodeEditorView( // modifier = Modifier.fillMaxHeight(), // ) - val (secondCacheKey, bigTextFieldState) = rememberBigTextFieldState(initialValue = textValue.text, cacheKey) + val (secondCacheKey, bigTextFieldState) = rememberBigTextFieldState(initialValue = textValue.text) val bigTextValue = bigTextFieldState.value.text var bigTextValueId by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(Random.nextLong()) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt index 9096edb1..0dc68f73 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt @@ -10,7 +10,6 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.Kotl @Composable fun KotliteCodeEditorView( modifier: Modifier = Modifier, - cacheKey: String, isReadOnly: Boolean = false, isEnabled: Boolean = true, text: String, @@ -25,7 +24,6 @@ fun KotliteCodeEditorView( } CodeEditorView( modifier = modifier, - cacheKey = cacheKey, isReadOnly = isReadOnly, text = text, onTextChange = onTextChange, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt index ed7f352b..4c2786ae 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt @@ -776,7 +776,6 @@ private fun PreFlightEditorView( selectedExample } KotliteCodeEditorView( - cacheKey = "Request:${request.id}/Example:${example.id}/PreFlight/ExecuteCode", text = example.preFlight.executeCode, onTextChange = { onRequestModified( @@ -1122,7 +1121,6 @@ private fun RequestBodyEditor( when (selectedContentType) { ContentType.Json, ContentType.Raw -> { RequestBodyTextEditor( - cacheKey = "Request:${request.id}/Example:${selectedExample.id}/Body", request = request, onRequestModified = onRequestModified, environmentVariableKeys = environmentVariableKeys, @@ -1235,7 +1233,6 @@ private fun RequestBodyEditor( ContentType.Graphql -> { RequestBodyTextEditor( - cacheKey = "Request:${request.id}/Example:${selectedExample.id}/Body", request = request, onRequestModified = onRequestModified, environmentVariableKeys = environmentVariableKeys, @@ -1270,7 +1267,6 @@ private fun RequestBodyEditor( } } RequestBodyTextEditor( - cacheKey = "Request:${request.id}/Example:${selectedExample.id}/GraphQLVariables", request = request, onRequestModified = onRequestModified, environmentVariableKeys = environmentVariableKeys, @@ -1327,7 +1323,6 @@ private fun OverrideCheckboxWithLabel( @Composable private fun RequestBodyTextEditor( modifier: Modifier, - cacheKey: String, request: UserRequestTemplate, onRequestModified: (UserRequestTemplate?) -> Unit, environmentVariableKeys: Set, @@ -1344,7 +1339,6 @@ private fun RequestBodyTextEditor( if (overridePredicate(selectedExample.overrides)) { CodeEditorView( modifier = modifier, - cacheKey = cacheKey, isReadOnly = false, isEnableVariables = true, knownVariables = environmentVariableKeys, @@ -1364,7 +1358,6 @@ private fun RequestBodyTextEditor( } else { CodeEditorView( modifier = modifier, - cacheKey = cacheKey, isReadOnly = true, isEnableVariables = true, knownVariables = environmentVariableKeys, @@ -1552,7 +1545,6 @@ fun StreamingPayloadEditorView( CodeEditorView( modifier = Modifier.weight(1f), - cacheKey = "Request:${request.id}/PayloadExample:${selectedExample?.id}/Body", isReadOnly = false, isEnableVariables = true, knownVariables = knownVariables, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt index 3a7a841c..8b7baa4d 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt @@ -541,7 +541,6 @@ fun BodyViewerView( CopyableContentContainer(textToCopy = prettifyResult.prettyString, modifier = modifier) { CodeEditorView( - cacheKey = key, isReadOnly = true, text = prettifyResult.prettyString, collapsableLines = prettifyResult.collapsableLineRange, @@ -558,7 +557,6 @@ fun BodyViewerView( val text = errorMessage ?: content.decodeToString() CopyableContentContainer(textToCopy = text, modifier = modifier) { CodeEditorView( - cacheKey = key, isReadOnly = true, text = text, textColor = colours.warning, @@ -631,7 +629,6 @@ fun ResponseBodyView(response: UserResponse) { if (response.postFlightErrorMessage?.isNotEmpty() == true) { AppText(text = "Post-flight Error", modifier = Modifier.padding(top = 20.dp, bottom = 8.dp)) CodeEditorView( - cacheKey = "Response:${response.id}/PostFlightError", isReadOnly = true, text = response.postFlightErrorMessage ?: "", textColor = LocalColor.current.warning, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt index a43dbdce..6fd2d1b5 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt @@ -9,9 +9,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @Composable -fun rememberBigTextFieldState(initialValue: String = "", vararg cacheKey: Any?): Pair, MutableState> { - val secondCacheKey = rememberSaveable(*cacheKey) { mutableStateOf(initialValue) } - val state = rememberSaveable(*cacheKey) { +fun rememberBigTextFieldState(initialValue: String = ""): Pair, MutableState> { + val secondCacheKey = rememberSaveable { mutableStateOf(initialValue) } + val state = rememberSaveable { log.d { "cache miss 1" } mutableStateOf(BigTextFieldState(BigText.createFromLargeString(initialValue), BigTextViewState())) } From 63e23c194f26c068a6fac7416109549d7eaaa0c4 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 6 Sep 2024 12:04:08 +0800 Subject: [PATCH 071/195] refactor BigTextImpl usage of `buffers[node.value.bufferIndex]` to `node.value.buffer` --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 35 +++++++++++-------- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 6 ++++ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 26eb90f3..d094221b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -28,7 +28,7 @@ val logL = Logger(object : MutableLoggerConfig { val logV = Logger(object : MutableLoggerConfig { override var logWriterList: List = listOf(JvmLogger()) - override var minSeverity: Severity = Severity.Debug + override var minSeverity: Severity = Severity.Info }, tag = "BigText.View") internal var isD = false @@ -203,7 +203,7 @@ class BigTextImpl : BigText { val lineBreakAtNode = tree.findNodeByCharIndex(lineBreakPosition)!! val lineStart = findLineStart(lineBreakAtNode) val positionStartOfLineBreakNode = findPositionStart(lineBreakAtNode) - val lineBreakOffsetStarts = buffers[lineBreakAtNode.value.bufferIndex].lineOffsetStarts + val lineBreakOffsetStarts = lineBreakAtNode.value.buffer.lineOffsetStarts val lineBreakMinIndex = lineBreakOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineBreakAtNode.value.bufferOffsetStart) val lineBreakIndex = lineBreakOffsetStarts.binarySearchForMaxIndexOfValueAtMost(lineBreakPosition - positionStartOfLineBreakNode + lineBreakAtNode.value.bufferOffsetStart) return (lineStart + if (lineBreakIndex < lineBreakMinIndex) { @@ -241,7 +241,7 @@ class BigTextImpl : BigText { val lineIndex = findLineIndexByRowIndex(rowIndex) val (lineStartNode, lineIndexStart) = tree.findNodeByLineBreaks(lineIndex - 1)!! // val positionOfLineStartNode = findPositionStart(lineStartNode) - val lineOffsetStarts = buffers[lineStartNode.value.bufferIndex].lineOffsetStarts + val lineOffsetStarts = lineStartNode.value.buffer.lineOffsetStarts val inRangeLineStartIndex = lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineStartNode.value.bufferOffsetStart) val lineOffset = if (lineIndex - 1 >= 0) { lineOffsetStarts[inRangeLineStartIndex + lineIndex - 1 - lineIndexStart] @@ -271,7 +271,7 @@ class BigTextImpl : BigText { val (lineStartNode, lineIndexStart) = tree.findNodeByLineBreaks(lineIndex - 1) ?: throw IllegalStateException("Cannot find the node right after ${lineIndex - 1} line breaks") // val positionOfLineStartNode = findPositionStart(lineStartNode) - val lineOffsetStarts = buffers[lineStartNode.value.bufferIndex].lineOffsetStarts + val lineOffsetStarts = lineStartNode.value.buffer.lineOffsetStarts val inRangeLineStartIndex = lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineStartNode.value.bufferOffsetStart) val lineOffset = if (lineIndex - 1 >= 0) { lineOffsetStarts[inRangeLineStartIndex + lineIndex - 1 - lineIndexStart] - lineStartNode.value.bufferOffsetStart @@ -371,6 +371,7 @@ class BigTextImpl : BigText { bufferIndex = node!!.value.bufferIndex bufferOffsetStart = node!!.value.bufferOffsetStart + splitAtIndex bufferOffsetEndExclusive = oldEnd + this.buffer = buffers[bufferIndex] leftStringLength = 0 } @@ -388,17 +389,19 @@ class BigTextImpl : BigText { bufferIndex = buffers.lastIndex bufferOffsetStart = range.start bufferOffsetEndExclusive = range.endInclusive + 1 + this.buffer = buffers[bufferIndex] leftStringLength = 0 }, secondPartNodeValue ).reversed() // IMPORTANT: the insertion order is reversed - } else if (node == null || node.value.bufferIndex != buffers.lastIndex || node.value.bufferOffsetEndExclusive != range.start) { + } else if (node == null || node.value.bufferOwnership != BufferOwnership.Owned || node.value.bufferIndex != buffers.lastIndex || node.value.bufferOffsetEndExclusive != range.start) { log.d { "> create new node" } listOf(BigTextNodeValue().apply { bufferIndex = buffers.lastIndex bufferOffsetStart = range.start bufferOffsetEndExclusive = range.endInclusive + 1 + this.buffer = buffers[bufferIndex] leftStringLength = 0 }) @@ -442,7 +445,7 @@ class BigTextImpl : BigText { fun computeCurrentNodeProperties(nodeValue: BigTextNodeValue) = with (nodeValue) { // bufferNumLineBreaksInRange = buffers[bufferIndex].lineOffsetStarts.subSet(bufferOffsetStart, bufferOffsetEndExclusive).size - bufferNumLineBreaksInRange = buffers[bufferIndex].lineOffsetStarts.run { + bufferNumLineBreaksInRange = buffer.lineOffsetStarts.run { binarySearchForMinIndexOfValueAtLeast(bufferOffsetEndExclusive) - maxOf(0, binarySearchForMinIndexOfValueAtLeast(bufferOffsetStart)) } leftNumOfLineBreaks = node?.left?.numLineBreaks() ?: 0 @@ -497,7 +500,7 @@ class BigTextImpl : BigText { override fun buildString(): String { return tree.joinToString("") { - buffers[it.bufferIndex].subSequence(it.bufferOffsetStart, it.bufferOffsetEndExclusive) + it.buffer.subSequence(it.bufferOffsetStart, it.bufferOffsetEndExclusive) } } @@ -519,7 +522,7 @@ class BigTextImpl : BigText { val numCharsToCopy = minOf(endExclusive, nodeStartPos + node.value.bufferLength) - maxOf(start, nodeStartPos) val copyUntilBufferIndex = copyFromBufferIndex + numCharsToCopy if (numCharsToCopy > 0) { - val subsequence = buffers[node.value.bufferIndex].subSequence(copyFromBufferIndex, copyUntilBufferIndex) + val subsequence = node.value.buffer.subSequence(copyFromBufferIndex, copyUntilBufferIndex) result.append(subsequence) numRemainCharsToCopy -= numCharsToCopy } else { @@ -543,7 +546,7 @@ class BigTextImpl : BigText { * @param lineOffset 0 = start of buffer; 1 = char index after the 1st '\n'; 2 = char index after the 2nd '\n'; ... */ fun findCharPosOfLineOffset(node: RedBlackTree.Node, lineOffset: Int): Int { - val buffer = buffers[node.value!!.bufferIndex] + val buffer = node.value!!.buffer val lineStartIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.value!!.bufferOffsetStart) val lineEndIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(node.value!!.bufferOffsetEndExclusive - 1) val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) - 1 @@ -676,6 +679,7 @@ class BigTextImpl : BigText { bufferIndex = node!!.value.bufferIndex bufferOffsetStart = node!!.value.bufferOffsetStart + splitAtIndex bufferOffsetEndExclusive = node!!.value.bufferOffsetEndExclusive + buffer = buffers[bufferIndex] leftStringLength = 0 } @@ -688,6 +692,7 @@ class BigTextImpl : BigText { bufferIndex = node!!.value.bufferIndex bufferOffsetStart = node!!.value.bufferOffsetStart bufferOffsetEndExclusive = node!!.value.bufferOffsetStart + splitAtIndex + buffer = buffers[bufferIndex] leftStringLength = 0 } @@ -776,7 +781,7 @@ class BigTextImpl : BigText { } tree.forEach { - val buffer = buffers[it.bufferIndex] + val buffer = it.buffer val chunkString = buffer.subSequence(it.bufferOffsetStart, it.bufferOffsetEndExclusive) layouter.indexCharWidth(chunkString.toString()) } @@ -808,7 +813,7 @@ class BigTextImpl : BigText { var lastOccupiedWidth = 0f val treeLastIndex = tree.size() - 1 tree.forEachIndexed { index, node -> - val buffer = buffers[node.bufferIndex] + val buffer = node.buffer val lineBreakIndexFrom = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.bufferOffsetStart) val lineBreakIndexTo = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(node.bufferOffsetEndExclusive - 1) var charStartIndexInBuffer = node.bufferOffsetStart @@ -855,7 +860,7 @@ class BigTextImpl : BigText { logL.v { inspect("before layout($startPos, $endPosExclusive)") } var nodeStartPos = findPositionStart(node!!) val nodeValue = node.value - val buffer = buffers[nodeValue.bufferIndex] + val buffer = nodeValue.buffer var lineBreakIndexFrom = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast( (startPos - nodeStartPos) + nodeValue.bufferOffsetStart ) @@ -889,7 +894,7 @@ class BigTextImpl : BigText { while (node != null) { var isBreakAfterThisIteration = false val nodeValue = node.value - val buffer = buffers[nodeValue.bufferIndex] + val buffer = nodeValue.buffer val lineBreakIndexTo = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(nodeValue.bufferOffsetEndExclusive - 1) .let { @@ -1027,7 +1032,7 @@ class BigTextImpl : BigText { if (node != null) { nodeStartPos += nodeValue.bufferLength val nodeValue = node.value - val buffer = buffers[nodeValue.bufferIndex] + val buffer = nodeValue.buffer lineBreakIndexFrom = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(nodeValue.bufferOffsetStart) charStartIndexInBuffer = nodeValue.bufferOffsetStart } @@ -1048,7 +1053,7 @@ class BigTextImpl : BigText { run { val lastNode = tree.rightmost(tree.getRoot()).takeIf { it.isNotNil() } val lastValue = lastNode?.value ?: return@run 0 - val lastLineOffset = buffers[lastValue.bufferIndex].lineOffsetStarts.let { + val lastLineOffset = lastValue.buffer.lineOffsetStarts.let { val lastIndex = it.binarySearchForMaxIndexOfValueAtMost(lastValue.bufferOffsetEndExclusive - 1) if (lastIndex in 0 .. it.lastIndex) { it[lastIndex] diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 2604da67..0d9a93c5 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -21,6 +21,8 @@ class BigTextNodeValue : Comparable, DebuggableNode Date: Sun, 8 Sep 2024 20:56:28 +0800 Subject: [PATCH 072/195] add BigTextTransformerImpl with transformInsert, length, substring and buildString functions tested --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 191 +++++++++--------- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 26 ++- .../ux/bigtext/BigTextTransformNodeValue.kt | 59 ++++++ .../ux/bigtext/BigTextTransformerImpl.kt | 183 +++++++++++++++++ .../hellohttp/ux/bigtext/LengthNodeValue.kt | 13 ++ .../hellohttp/ux/bigtext/LengthTree.kt | 62 ++++++ .../hellohttp/ux/bigtext/RedBlackTree2.kt | 22 +- .../transform/BigTextTransformerImplTest.kt | 137 +++++++++++++ 8 files changed, 589 insertions(+), 104 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthNodeValue.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index d094221b..09de2773 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -35,8 +35,8 @@ internal var isD = false private const val EPS = 1e-4f -class BigTextImpl : BigText { - val tree = RedBlackTree2( +open class BigTextImpl : BigText { + open val tree: LengthTree = LengthTree( object : RedBlackTreeComputations { override fun recomputeFromLeaf(it: RedBlackTree.Node) = recomputeAggregatedValues(it) override fun computeWhenLeftRotate(x: BigTextNodeValue, y: BigTextNodeValue) = computeWhenLeftRotate0(x, y) @@ -46,12 +46,13 @@ class BigTextImpl : BigText { ) val buffers = mutableListOf() - val chunkSize: Int // TODO change to a large number + val chunkSize: Int var layouter: TextLayouter? = null - private set + @JvmName("_setLayouter") + protected set - private var contentWidth: Float? = null + internal var contentWidth: Float? = null var onLayoutCallback: (() -> Unit)? = null @@ -64,23 +65,6 @@ class BigTextImpl : BigText { this.chunkSize = chunkSize } - fun RedBlackTree2.findNodeByCharIndex(index: Int): RedBlackTree.Node? { - var find = index - return findNode { - when (find) { - in Int.MIN_VALUE until it.value.leftStringLength -> -1 - in it.value.leftStringLength until it.value.leftStringLength + it.value.bufferLength -> 0 - in it.value.leftStringLength + it.value.bufferLength until Int.MAX_VALUE -> 1.also { compareResult -> - val isTurnRight = compareResult > 0 - if (isTurnRight) { - find -= it.value.leftStringLength + it.value.bufferLength - } - } - else -> throw IllegalStateException("what is find? $find") - } - } - } - fun RedBlackTree2.findNodeByLineBreaks(index: Int): Pair.Node, Int>? { var find = index var lineStart = 0 @@ -309,6 +293,18 @@ class BigTextImpl : BigText { return start } + protected fun findRenderPositionStart(node: RedBlackTree.Node): Int { + var start = node.value.leftRenderLength + var node = node + while (node.parent.isNotNil()) { + if (node === node.parent.right) { + start += node.parent.value.leftRenderLength + node.parent.value.currentRenderLength + } + node = node.parent + } + return start + } + protected fun findLineStart(node: RedBlackTree.Node): Int { var start = node.value.leftNumOfLineBreaks var node = node @@ -336,6 +332,10 @@ class BigTextImpl : BigText { return start } + protected open fun createNodeValue(): BigTextNodeValue { + return BigTextNodeValue() + } + private fun insertChunkAtPosition(position: Int, chunkedString: String) { log.d { "insertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) @@ -351,30 +351,52 @@ class BigTextImpl : BigText { } require(buffer.length + chunkedString.length <= chunkSize) val range = buffer.append(chunkedString) + insertChunkAtPosition(position, chunkedString.length, BufferOwnership.Owned, buffer, range) { + bufferIndex = buffers.lastIndex + bufferOffsetStart = range.start + bufferOffsetEndExclusive = range.endInclusive + 1 + this.buffer = buffers[bufferIndex] + this.bufferOwnership = BufferOwnership.Owned + + leftStringLength = 0 + } + } + + protected fun insertChunkAtPosition(position: Int, chunkedStringLength: Int, ownership: BufferOwnership, buffer: TextBuffer, range: IntRange, newNodeConfigurer: BigTextNodeValue.() -> Unit) { var node = tree.findNodeByCharIndex(maxOf(0, position - 1)) // TODO optimize, don't do twice val nodeStart = node?.let { findPositionStart(it) } // TODO optimize, don't do twice if (node != null) { log.d { "> existing node (${node!!.value.debugKey()}) $nodeStart .. ${nodeStart!! + node!!.value.bufferLength - 1}" } - require(maxOf(0, position - 1) in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1) { + require(maxOf(0, position - 1) in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1 || node.value.bufferLength == 0) { printDebug() findPositionStart(node!!) "Found node ${node!!.value.debugKey()} but it is not in searching range" } + } else if (!tree.isEmpty) { + throw IllegalStateException("Node not found for position ${maxOf(0, position - 1)}") } var insertDirection: InsertDirection = InsertDirection.Undefined val toBeRelayouted = mutableListOf() - val newNodeValues = if (node != null && position in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1) { + val newNodeValues = if (node != null && position > 0 && position in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1) { val splitAtIndex = position - nodeStart log.d { "> split at $splitAtIndex" } val oldEnd = node.value.bufferOffsetEndExclusive - val secondPartNodeValue = BigTextNodeValue().apply { // the second part of the old string + val secondPartNodeValue = createNodeValue().apply { // the second part of the old string bufferIndex = node!!.value.bufferIndex bufferOffsetStart = node!!.value.bufferOffsetStart + splitAtIndex bufferOffsetEndExclusive = oldEnd - this.buffer = buffers[bufferIndex] + this.buffer = node!!.value.buffer + this.bufferOwnership = node!!.value.bufferOwnership leftStringLength = 0 } + /** + * Existing node char index range is (A ..< B), where A <= position < B, position is the insert position. + * Modify nodes so that existing node is (A ..< position), + * new node 1 (position ..< position + length), + * new node 2 (position + length ..< B). + * If A == position, then existing node is empty and thus can be deleted. + */ if (splitAtIndex > 0) { node.value.bufferOffsetEndExclusive = node.value.bufferOffsetStart + splitAtIndex } else { @@ -382,33 +404,23 @@ class BigTextImpl : BigText { node = tree.findNodeByCharIndex(maxOf(0, position - 1)) insertDirection = InsertDirection.Left } - require(splitAtIndex + chunkedString.length <= chunkSize) + // require(splitAtIndex + chunkedStringLength <= chunkSize) // this check appears to be not guarding anything toBeRelayouted += secondPartNodeValue listOf( - BigTextNodeValue().apply { // new string - bufferIndex = buffers.lastIndex - bufferOffsetStart = range.start - bufferOffsetEndExclusive = range.endInclusive + 1 - this.buffer = buffers[bufferIndex] - - leftStringLength = 0 + createNodeValue().apply { // new string + this.newNodeConfigurer() }, secondPartNodeValue ).reversed() // IMPORTANT: the insertion order is reversed - } else if (node == null || node.value.bufferOwnership != BufferOwnership.Owned || node.value.bufferIndex != buffers.lastIndex || node.value.bufferOffsetEndExclusive != range.start) { + } else if (node == null || node.value.bufferOwnership != ownership || node.value.bufferIndex != buffers.lastIndex || node.value.bufferOffsetEndExclusive != range.start || position == 0) { log.d { "> create new node" } - listOf(BigTextNodeValue().apply { - bufferIndex = buffers.lastIndex - bufferOffsetStart = range.start - bufferOffsetEndExclusive = range.endInclusive + 1 - this.buffer = buffers[bufferIndex] - - leftStringLength = 0 + listOf(createNodeValue().apply { + this.newNodeConfigurer() }) } else { node.value.apply { log.d { "> update existing node end from $bufferOffsetEndExclusive to ${bufferOffsetEndExclusive + range.length}" } - bufferOffsetEndExclusive += range.length + bufferOffsetEndExclusive += chunkedStringLength } recomputeAggregatedValues(node) emptyList() @@ -443,7 +455,12 @@ class BigTextImpl : BigText { log.v { inspect("Finish I " + node?.value?.debugKey()) } } - fun computeCurrentNodeProperties(nodeValue: BigTextNodeValue) = with (nodeValue) { + open fun computeCurrentNodeProperties(nodeValue: BigTextNodeValue, left: RedBlackTree.Node?) = with (nodeValue) { + // recompute leftStringLength + leftStringLength = left?.length() ?: 0 + log.v { ">> ${node?.value?.debugKey()} -> $leftStringLength (${left?.value?.debugKey()}/ ${left?.length()})" } + + // recompute leftNumOfLineBreaks // bufferNumLineBreaksInRange = buffers[bufferIndex].lineOffsetStarts.subSet(bufferOffsetStart, bufferOffsetEndExclusive).size bufferNumLineBreaksInRange = buffer.lineOffsetStarts.run { binarySearchForMinIndexOfValueAtLeast(bufferOffsetEndExclusive) - maxOf(0, binarySearchForMinIndexOfValueAtLeast(bufferOffsetStart)) @@ -461,14 +478,7 @@ class BigTextImpl : BigText { while (node.isNotNil()) { val left = node.left.takeIf { it.isNotNil() } with (node.getValue()) { - // recompute leftStringLength - leftStringLength = left?.length() ?: 0 - log.v { ">> ${node.value.debugKey()} -> $leftStringLength (${left?.value?.debugKey()}/ ${left?.length()})" } - - // recompute leftNumOfLineBreaks - computeCurrentNodeProperties(this) - - // TODO calc other metrics + computeCurrentNodeProperties(this, left) } log.v { ">> ${node.parent.value?.debugKey()} parent -> ${node.value?.debugKey()}" } node = node.parent @@ -500,7 +510,7 @@ class BigTextImpl : BigText { override fun buildString(): String { return tree.joinToString("") { - it.buffer.subSequence(it.bufferOffsetStart, it.bufferOffsetEndExclusive) + it.buffer.subSequence(it.renderBufferStart, it.renderBufferEndExclusive) } } @@ -514,24 +524,24 @@ class BigTextImpl : BigText { } val result = StringBuilder(endExclusive - start) - var node = tree.findNodeByCharIndex(start) ?: throw IllegalStateException("Cannot find string node for position $start") - var nodeStartPos = findPositionStart(node) + var node = tree.findNodeByRenderCharIndex(start) ?: throw IllegalStateException("Cannot find string node for position $start") + var nodeStartPos = findRenderPositionStart(node) var numRemainCharsToCopy = endExclusive - start - var copyFromBufferIndex = start - nodeStartPos + node.value.bufferOffsetStart + var copyFromBufferIndex = start - nodeStartPos + node.value.renderBufferStart while (numRemainCharsToCopy > 0) { - val numCharsToCopy = minOf(endExclusive, nodeStartPos + node.value.bufferLength) - maxOf(start, nodeStartPos) + val numCharsToCopy = minOf(endExclusive, nodeStartPos + node.value.currentRenderLength) - maxOf(start, nodeStartPos) val copyUntilBufferIndex = copyFromBufferIndex + numCharsToCopy if (numCharsToCopy > 0) { val subsequence = node.value.buffer.subSequence(copyFromBufferIndex, copyUntilBufferIndex) result.append(subsequence) numRemainCharsToCopy -= numCharsToCopy - } else { + } /*else { break - } + }*/ if (numRemainCharsToCopy > 0) { - nodeStartPos += node.value.bufferLength - node = tree.nextNode(node) ?: throw IllegalStateException("Cannot find the next string node") - copyFromBufferIndex = node.value.bufferOffsetStart + nodeStartPos += node.value.currentRenderLength + node = tree.nextNode(node) ?: throw IllegalStateException("Cannot find the next string node. Requested = $start ..< $endExclusive. Remain = $numRemainCharsToCopy") + copyFromBufferIndex = node.value.renderBufferStart } } @@ -551,13 +561,13 @@ class BigTextImpl : BigText { val lineEndIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(node.value!!.bufferOffsetEndExclusive - 1) val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) - 1 val charOffsetInBuffer = if (offsetedLineOffset > lineEndIndexInBuffer) { - node.value!!.bufferOffsetEndExclusive + node.value!!.renderBufferEndExclusive } else if (lineOffset - 1 >= 0) { buffer.lineOffsetStarts[offsetedLineOffset] + 1 } else { - node.value!!.bufferOffsetStart + node.value!!.renderBufferStart } - return findPositionStart(node) + (charOffsetInBuffer - node.value!!.bufferOffsetStart) + return findPositionStart(node) + (charOffsetInBuffer - node.value!!.renderBufferStart) } val (startNode, startNodeLineStart) = tree.findNodeByLineBreaks(lineIndex - 1)!! @@ -581,14 +591,14 @@ class BigTextImpl : BigText { */ fun findCharPosOfRowOffset(node: RedBlackTree.Node, rowOffset: Int): Int { val charOffsetInBuffer = if (rowOffset - 1 > node.value!!.rowBreakOffsets.size - 1) { - node.value!!.bufferOffsetEndExclusive + node.value!!.renderBufferEndExclusive } else if (rowOffset - 1 >= 0) { val offsetedRowOffset = rowOffset - 1 node.value!!.rowBreakOffsets[offsetedRowOffset] } else { - node.value!!.bufferOffsetStart + node.value!!.renderBufferStart } - return findPositionStart(node) + (charOffsetInBuffer - node.value!!.bufferOffsetStart) + return findPositionStart(node) + (charOffsetInBuffer - node.value!!.renderBufferStart) } val (startNode, startNodeRowStart) = tree.findNodeByRowBreaks(rowIndex - 1) ?: @@ -675,8 +685,8 @@ class BigTextImpl : BigText { // need to split val splitAtIndex = endExclusive - nodeRange.start log.d { "Split E at $splitAtIndex" } - newNodesInDescendingOrder += BigTextNodeValue().apply { // the second part of the existing string - bufferIndex = node!!.value.bufferIndex + newNodesInDescendingOrder += createNodeValue().apply { // the second part of the existing string + bufferIndex = node!!.value.bufferIndex // FIXME transform bufferOffsetStart = node!!.value.bufferOffsetStart + splitAtIndex bufferOffsetEndExclusive = node!!.value.bufferOffsetEndExclusive buffer = buffers[bufferIndex] @@ -688,7 +698,7 @@ class BigTextImpl : BigText { // need to split val splitAtIndex = start - nodeRange.start log.d { "Split S at $splitAtIndex" } - newNodesInDescendingOrder += BigTextNodeValue().apply { // the first part of the existing string + newNodesInDescendingOrder += createNodeValue().apply { // the first part of the existing string bufferIndex = node!!.value.bufferIndex bufferOffsetStart = node!!.value.bufferOffsetStart bufferOffsetEndExclusive = node!!.value.bufferOffsetStart + splitAtIndex @@ -862,9 +872,9 @@ class BigTextImpl : BigText { val nodeValue = node.value val buffer = nodeValue.buffer var lineBreakIndexFrom = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast( - (startPos - nodeStartPos) + nodeValue.bufferOffsetStart + (startPos - nodeStartPos) + nodeValue.renderBufferStart ) - var charStartIndexInBuffer = nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost((startPos - nodeStartPos) + nodeValue.bufferOffsetStart).let { + var charStartIndexInBuffer = nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost((startPos - nodeStartPos) + nodeValue.renderBufferStart).let { if (it >= 0) { nodeValue.rowBreakOffsets[it] } else { @@ -874,7 +884,7 @@ class BigTextImpl : BigText { logL.d { "carry over width $lastOccupiedWidth" } } - nodeValue.bufferOffsetStart + nodeValue.renderBufferStart } } logL.d { "charStartIndexInBuffer = $charStartIndexInBuffer" } @@ -896,12 +906,12 @@ class BigTextImpl : BigText { val nodeValue = node.value val buffer = nodeValue.buffer val lineBreakIndexTo = - buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(nodeValue.bufferOffsetEndExclusive - 1) + buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(nodeValue.renderBufferEndExclusive - 1) .let { - if (endPosExclusive in nodeStartPos..nodeStartPos + nodeValue.bufferLength) { + if (endPosExclusive in nodeStartPos..nodeStartPos + nodeValue.currentRenderLength) { minOf( it, - buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(endPosExclusive - nodeStartPos + nodeValue.bufferOffsetStart) + buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(endPosExclusive - nodeStartPos + nodeValue.renderBufferStart) ) } else { it @@ -956,7 +966,7 @@ class BigTextImpl : BigText { } charStartIndexInBuffer = lineBreakCharIndex + 1 - if (lineBreakCharIndex + 1 < nodeValue.bufferOffsetEndExclusive) { + if (lineBreakCharIndex + 1 < nodeValue.renderBufferEndExclusive) { logL.d { "row break add ${lineBreakCharIndex + 1}" } rowBreakOffsets.addToThisAscendingListWithoutDuplicate(lineBreakCharIndex + 1) lastOccupiedWidth = 0f @@ -972,11 +982,11 @@ class BigTextImpl : BigText { val nextBoundary = if ( lineBreakIndexTo + 1 <= buffer.lineOffsetStarts.lastIndex // && buffer.lineOffsetStarts[lineBreakIndexTo + 1] - node.value.bufferOffsetStart + nodeStartPos < endPosExclusive - && buffer.lineOffsetStarts[lineBreakIndexTo + 1] < nodeValue.bufferOffsetEndExclusive + && buffer.lineOffsetStarts[lineBreakIndexTo + 1] < nodeValue.renderBufferEndExclusive ) { buffer.lineOffsetStarts[lineBreakIndexTo + 1] } else { - nodeValue.bufferOffsetEndExclusive + nodeValue.renderBufferEndExclusive } // if (charStartIndexInBuffer < nodeValue.bufferOffsetEndExclusive) { // if (charStartIndexInBuffer < nodeValue.bufferOffsetEndExclusive && nodeStartPos + charStartIndexInBuffer - nodeValue.bufferOffsetStart < endPosExclusive) { @@ -998,16 +1008,16 @@ class BigTextImpl : BigText { lastOccupiedWidth = lastRowOccupiedWidth charStartIndexInBuffer = readRowUntilPos } - if (charStartIndexInBuffer < nodeValue.bufferOffsetEndExclusive) { + if (charStartIndexInBuffer < nodeValue.renderBufferEndExclusive) { // val preserveIndexFrom = nodeValue.rowBreakOffsets.binarySearchForMinIndexOfValueAtLeast(endPosExclusive) - val searchForValue = minOf(nodeValue.bufferOffsetEndExclusive, maxOf((rowBreakOffsets.lastOrNull() ?: -1) + 1, charStartIndexInBuffer)) + val searchForValue = minOf(nodeValue.renderBufferEndExclusive, maxOf((rowBreakOffsets.lastOrNull() ?: -1) + 1, charStartIndexInBuffer)) val preserveIndexFrom = nodeValue.rowBreakOffsets.binarySearchForMinIndexOfValueAtLeast(searchForValue) val preserveIndexTo = if (nodeStartPos + nodeValue.bufferLength >= length) { - nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(nodeValue.bufferOffsetEndExclusive) // keep the row after the last '\n' + nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(nodeValue.renderBufferEndExclusive) // keep the row after the last '\n' } else { - nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(nodeValue.bufferOffsetEndExclusive - 1) + nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(nodeValue.renderBufferEndExclusive - 1) } - logL.d { "reach the end, preserve RB from $preserveIndexFrom (at least $searchForValue) ~ $preserveIndexTo (${nodeValue.bufferOffsetEndExclusive}). RB = ${nodeValue.rowBreakOffsets}." } + logL.d { "reach the end, preserve RB from $preserveIndexFrom (at least $searchForValue) ~ $preserveIndexTo (${nodeValue.renderBufferEndExclusive}). RB = ${nodeValue.rowBreakOffsets}." } val restoreRowBreaks = nodeValue.rowBreakOffsets.subList(preserveIndexFrom, minOf(nodeValue.rowBreakOffsets.size, preserveIndexTo + 1)) if (restoreRowBreaks.isNotEmpty() || nodeValue.isEndWithForceRowBreak) { rowBreakOffsets.addToThisAscendingListWithoutDuplicate(restoreRowBreaks) @@ -1030,11 +1040,11 @@ class BigTextImpl : BigText { logL.d { "node ${node!!.value.debugKey()} b#${node!!.value.bufferIndex} next ${it.value.debugKey()} b#${it!!.value.bufferIndex}" } } if (node != null) { - nodeStartPos += nodeValue.bufferLength + nodeStartPos += nodeValue.currentRenderLength val nodeValue = node.value val buffer = nodeValue.buffer - lineBreakIndexFrom = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(nodeValue.bufferOffsetStart) - charStartIndexInBuffer = nodeValue.bufferOffsetStart + lineBreakIndexFrom = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(nodeValue.renderBufferStart) + charStartIndexInBuffer = nodeValue.renderBufferStart } } @@ -1108,11 +1118,6 @@ class BigTextImpl : BigText { } -fun RedBlackTree.Node.length(): Int = - (getValue()?.leftStringLength ?: 0) + - (getValue()?.bufferLength ?: 0) + - (getRight().takeIf { it.isNotNil() }?.length() ?: 0) - fun RedBlackTree.Node.numLineBreaks(): Int { val value = getValue() return (value?.leftNumOfLineBreaks ?: 0) + diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 0d9a93c5..e8d82275 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -5,10 +5,10 @@ import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree import java.util.SortedSet import kotlin.random.Random -class BigTextNodeValue : Comparable, DebuggableNode { +open class BigTextNodeValue : Comparable, DebuggableNode, LengthNodeValue { var leftNumOfLineBreaks: Int = -1 var leftNumOfRowBreaks: Int = -1 - var leftStringLength: Int = -1 + override var leftStringLength: Int = -1 // var rowBreakOffsets: SortedSet = sortedSetOf() /** * Row break positions in the domain of character indices of the {bufferIndex}-th buffer. @@ -24,9 +24,27 @@ class BigTextNodeValue : Comparable, DebuggableNode.Node? = null private val key = Random.nextInt() @@ -40,7 +58,7 @@ class BigTextNodeValue : Comparable, DebuggableNode.Node): String = buildString { + node as RedBlackTree.Node + + append("$leftStringLength ${bufferOwnership.name.first()} [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.renderLength()}") + append(" Tr [$transformedBufferStart ..< $transformedBufferEndExclusive]") + append(" Ren left=$leftRenderLength [$renderBufferStart ..< $renderBufferEndExclusive]") + append(" row $leftNumOfRowBreaks/$rowBreakOffsets lw $lastRowWidth") + } + +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt new file mode 100644 index 00000000..0c19ea44 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -0,0 +1,183 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import com.sunnychung.application.multiplatform.hellohttp.extension.length +import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree + +class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(chunkSize = delegate.chunkSize) { + + override val tree: LengthTree = LengthTree( + object : RedBlackTreeComputations { + override fun recomputeFromLeaf(it: RedBlackTree.Node) = recomputeAggregatedValues(it as RedBlackTree.Node) + override fun computeWhenLeftRotate(x: BigTextTransformNodeValue, y: BigTextTransformNodeValue) {} + override fun computeWhenRightRotate(x: BigTextTransformNodeValue, y: BigTextTransformNodeValue) {} + } + ) + + private fun BigTextNodeValue.toBigTextTransformNodeValue() : BigTextTransformNodeValue { + return BigTextTransformNodeValue().also { + it.leftNumOfLineBreaks = leftNumOfLineBreaks + it.leftNumOfRowBreaks = leftNumOfRowBreaks + it.leftStringLength = leftStringLength + it.rowBreakOffsets = rowBreakOffsets.toList() + it.lastRowWidth = lastRowWidth + it.isEndWithForceRowBreak = isEndWithForceRowBreak + it.bufferOffsetStart = bufferOffsetStart + it.bufferOffsetEndExclusive = bufferOffsetEndExclusive + it.bufferNumLineBreaksInRange = bufferNumLineBreaksInRange + it.buffer = buffer // copy by ref + it.bufferOwnership = BufferOwnership.Delegated + } + } + + fun RedBlackTree.Node.toBigTextTransformNode(parentNode: RedBlackTree.Node) : RedBlackTree.Node { + if (this === delegate.tree.NIL) { + return (tree as LengthTree).NIL + } + + return (tree as LengthTree).Node( + value.toBigTextTransformNodeValue(), + color, + parentNode, + tree.NIL, + tree.NIL, + ).also { + it.left = left.toBigTextTransformNode(it) + it.right = right.toBigTextTransformNode(it) + } + } + + init { + (tree as LengthTree).setRoot(delegate.tree.getRoot().toBigTextTransformNode(tree.NIL)) + layouter = delegate.layouter + contentWidth = delegate.contentWidth + } + + override val length: Int + get() = (tree as LengthTree).getRoot().renderLength() + + val originalLength: Int + get() = tree.getRoot().length() + + override fun createNodeValue(): BigTextNodeValue { + return BigTextTransformNodeValue() + } + + fun insertOriginal(pos: Int, nodeValue: BigTextNodeValue) { // FIXME call me + require(pos in 0 .. originalLength) { "Out of bound. pos = $pos, originalLength = $originalLength" } + + insertChunkAtPosition( + position = pos, + chunkedStringLength = nodeValue.bufferLength, + ownership = BufferOwnership.Delegated, + buffer = nodeValue.buffer, + range = nodeValue.bufferOffsetStart until nodeValue.bufferOffsetEndExclusive + ) { + bufferIndex = -1 + bufferOffsetStart = nodeValue.bufferOffsetStart + bufferOffsetEndExclusive = nodeValue.bufferOffsetEndExclusive + this.buffer = nodeValue.buffer + this.bufferOwnership = BufferOwnership.Delegated + + leftStringLength = 0 + } + } + + private fun transformInsertChunkAtPosition(position: Int, chunkedString: String) { + log.d { "transformInsertChunkAtPosition($position, $chunkedString)" } + require(chunkedString.length <= chunkSize) + var buffer = if (buffers.isNotEmpty()) { + buffers.last().takeIf { it.length + chunkedString.length <= chunkSize } + } else null + if (buffer == null) { + buffer = TextBuffer(chunkSize) + buffers += buffer + } + require(buffer.length + chunkedString.length <= chunkSize) + val range = buffer.append(chunkedString) + insertChunkAtPosition(position, chunkedString.length, BufferOwnership.Owned, buffer, range) { + this as BigTextTransformNodeValue + bufferIndex = -1 + bufferOffsetStart = -1 + bufferOffsetEndExclusive = -1 + transformedBufferStart = range.start + transformedBufferEndExclusive = range.endInclusive + 1 + this.buffer = buffer + this.bufferOwnership = BufferOwnership.Owned + + leftStringLength = 0 + } + } + + fun transformInsert(pos: Int, text: String): Int { + require(pos in 0 .. originalLength) { "Out of bound. pos = $pos, originalLength = $originalLength" } + + /** + * As insert position is searched by leftmost of original string position, + * the insert is done by inserting to the same point in reverse order, + * which is different from BigTextImpl#insertAt. + */ + + var start = text.length + var last = buffers.lastOrNull()?.length + while (start > 0) { + if (last == null || last >= chunkSize) { +// buffers += TextBuffer() + last = 0 + } + val available = chunkSize - last + val append = minOf(available, start) + start -= append + transformInsertChunkAtPosition(pos, text.substring(start until start + append)) + last = buffers.last().length + } + layout(maxOf(0, pos - 1), minOf(length, pos + text.length + 1)) + return text.length + } + + fun deleteOriginal(originalRange: IntRange) { // FIXME call me + require((originalRange.endInclusive + 1) in 0 .. originalLength) { "Out of bound. endExclusive = ${originalRange.endInclusive + 1}, originalLength = $originalLength" } + super.delete(originalRange) + } + + fun transformDelete(originalRange: IntRange): Int { + require(originalRange.start <= originalRange.endInclusive + 1) { "start should be <= endExclusive" } + require(0 <= originalRange.start) { "Invalid start" } + require(originalRange.endInclusive + 1 <= originalLength) { "endExclusive is out of bound" } + + if (originalRange.start == originalRange.endInclusive + 1) { + return 0 + } + + val startNode = tree.findNodeByCharIndex(originalRange.start)!! + val buffer = startNode.value.buffer // the buffer is not used. just to prevent NPE + super.delete(originalRange) + insertChunkAtPosition(originalRange.start, originalRange.length, BufferOwnership.Owned, buffer, -2 .. -2) { + this as BigTextTransformNodeValue + bufferIndex = -1 + bufferOffsetStart = 0 + bufferOffsetEndExclusive = originalRange.length + transformedBufferStart = -2 + transformedBufferEndExclusive = -2 + this.buffer = buffer + this.bufferOwnership = BufferOwnership.Owned + + leftStringLength = 0 + } + return - originalRange.length + } + + override fun computeCurrentNodeProperties(nodeValue: BigTextNodeValue, left: RedBlackTree.Node?) = with (nodeValue) { + super.computeCurrentNodeProperties(nodeValue, left) + + this as BigTextTransformNodeValue + left as RedBlackTree.Node? + leftTransformedLength = left?.transformedOffset() ?: 0 + leftRenderLength = left?.renderLength() ?: 0 + leftOverallLength = left?.overallLength() ?: 0 + } +} + +fun RedBlackTree.Node.transformedOffset(): Int = + (getValue()?.leftTransformedLength ?: 0) + + (getValue()?.currentTransformedLength ?: 0) + + (getRight().takeIf { it.isNotNil() }?.transformedOffset() ?: 0) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthNodeValue.kt new file mode 100644 index 00000000..e98b0afd --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthNodeValue.kt @@ -0,0 +1,13 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +interface LengthNodeValue { + val leftStringLength: Int + + val bufferLength: Int + + val leftOverallLength: Int + val currentOverallLength: Int + + val leftRenderLength: Int + val currentRenderLength: Int +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt new file mode 100644 index 00000000..31e9964c --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt @@ -0,0 +1,62 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree + +open class LengthTree(computations: RedBlackTreeComputations) : RedBlackTree2<@UnsafeVariance V>(computations) + where V : LengthNodeValue, V : Comparable<@UnsafeVariance V>, V : DebuggableNode { + + fun findNodeByCharIndex(index: Int): RedBlackTree.Node? { + var find = index + return findNode { + when (find) { + in Int.MIN_VALUE until it.value.leftStringLength -> -1 + it.value.leftStringLength, in it.value.leftStringLength until it.value.leftStringLength + it.value.bufferLength -> { + if (it.left.isNotNil() && find == it.value.leftStringLength && it.left.value.bufferLength == 0) { + -1 + } else { + 0 + } + } + in it.value.leftStringLength + it.value.bufferLength until Int.MAX_VALUE -> 1.also { compareResult -> + val isTurnRight = compareResult > 0 + if (isTurnRight) { + find -= it.value.leftStringLength + it.value.bufferLength + } + } + else -> throw IllegalStateException("what is find? $find") + } + } + } + + fun findNodeByRenderCharIndex(index: Int): RedBlackTree.Node? { + var find = index + return findNode { + when (find) { + in Int.MIN_VALUE until it.value.leftRenderLength -> -1 + in it.value.leftRenderLength until it.value.leftRenderLength + it.value.currentRenderLength -> 0 + in it.value.leftRenderLength + it.value.currentRenderLength until Int.MAX_VALUE -> 1.also { compareResult -> + val isTurnRight = compareResult > 0 + if (isTurnRight) { + find -= it.value.leftRenderLength + it.value.currentRenderLength + } + } + else -> throw IllegalStateException("what is find? $find") + } + } + } +} + +fun RedBlackTree.Node.length(): Int where V : LengthNodeValue, V : Comparable = + (getValue()?.leftStringLength ?: 0) + + (getValue()?.bufferLength ?: 0) + + (getRight().takeIf { it.isNotNil() }?.length() ?: 0) + +fun RedBlackTree.Node.renderLength(): Int where V : LengthNodeValue, V : Comparable = + (getValue()?.leftRenderLength ?: 0) + + (getValue()?.currentRenderLength ?: 0) + + (getRight().takeIf { it.isNotNil() }?.renderLength() ?: 0) + +fun RedBlackTree.Node.overallLength(): Int where V : LengthNodeValue, V : Comparable = + (getValue()?.leftOverallLength ?: 0) + + (getValue()?.currentOverallLength ?: 0) + + (getRight().takeIf { it.isNotNil() }?.overallLength() ?: 0) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt index e557c3a3..87eb60f3 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt @@ -17,10 +17,17 @@ interface RedBlackTreeComputations> { // fun transferComputeResultTo(from: T, to: T) } -open class RedBlackTree2(private val computations: RedBlackTreeComputations) : RedBlackTree() where T : Comparable, T : DebuggableNode { +open class RedBlackTree2(private val computations: RedBlackTreeComputations) : RedBlackTree() where T : Comparable, T : DebuggableNode { fun getRoot() = root + fun setRoot(node: RedBlackTree.Node) { + root = node + var numNodes = 0 + forEach { ++numNodes } + nodeCount = numNodes + } + fun lastNodeOrNull(): Node? { var child: Node = root while (child.getLeft().isNotNil() || child.getRight().isNotNil()) { @@ -78,7 +85,7 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations).attach(z) if (y === NIL) { root = z @@ -103,7 +110,7 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations).attach(z) if (root.isNil) { TODO("insertLeft root") } @@ -129,7 +136,7 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations).attach(z) if (root.isNil) { TODO("insertRight root") } @@ -509,11 +516,12 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations? + val key = nodeValue?.debugKey().toString() if (node === root) { - appendLine("$prepend$key[/\"${node.value?.debugLabel(node)}\"\\]") + appendLine("$prepend$key[/\"${nodeValue?.debugLabel(node)}\"\\]") } else { - appendLine("$prepend$key[\"${node.value?.debugLabel(node)}\"]") + appendLine("$prepend$key[\"${nodeValue?.debugLabel(node)}\"]") } node.left.takeIf { it.isNotNil() }?.also { appendLine("$prepend$key--L-->${visit(it)}") } node.right.takeIf { it.isNotNil() }?.also { appendLine("$prepend$key--R-->${visit(it)}") } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt new file mode 100644 index 00000000..6d3caed9 --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt @@ -0,0 +1,137 @@ +package com.sunnychung.application.multiplatform.hellohttp.test.bigtext.transform + +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.isD +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import kotlin.test.assertEquals + +class BigTextTransformerImplTest { + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformInsert(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("12345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformInsert(14, "ABCDEFG") + + transformed.printDebug() + + assertEquals("12345678901234ABCDEFG567890", transformed.buildString()) + assertAllSubstring("12345678901234ABCDEFG567890", transformed) + assertEquals("12345678901234567890", original.buildString()) + assertAllSubstring("12345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformInsertMultipleAtDifferentPos(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("12345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformInsert(14, "ABCDEFG") + transformed.transformInsert(7, "KJI") + transformed.transformInsert(16, "WXYZ") + + transformed.printDebug() + + assertEquals("1234567KJI8901234ABCDEFG56WXYZ7890", transformed.buildString()) + assertAllSubstring("1234567KJI8901234ABCDEFG56WXYZ7890", transformed) + assertEquals("12345678901234567890", original.buildString()) + assertAllSubstring("12345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformInsertMultipleAtSamePos(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("12345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformInsert(14, "WXYZ") + transformed.transformInsert(14, "KJI") + + transformed.printDebug() + + assertEquals("12345678901234KJIWXYZ567890", transformed.buildString()) + assertAllSubstring("12345678901234KJIWXYZ567890", transformed) + assertEquals("12345678901234567890", original.buildString()) + assertAllSubstring("12345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformInsertMultipleAtBeginning(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("12345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformInsert(0, "ABCDEFG") + transformed.transformInsert(0, "WXYZ") + transformed.transformInsert(0, "KJI") + + transformed.printDebug() + + assertEquals("KJIWXYZABCDEFG12345678901234567890", transformed.buildString()) + assertAllSubstring("KJIWXYZABCDEFG12345678901234567890", transformed) + assertEquals("12345678901234567890", original.buildString()) + assertAllSubstring("12345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformInsertMultipleAtEnd(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("12345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformInsert(20, "ABCDEFG") + transformed.transformInsert(20, "WXYZ") + transformed.transformInsert(20, "KJI") + + transformed.printDebug() + + "12345678901234567890KJIWXYZABCDEFG".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("12345678901234567890", original.buildString()) + assertAllSubstring("12345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformInsertLongString(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("12345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformInsert(14, "qwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopABCDEFG") + transformed.transformInsert(7, "KJI") + if (chunkSize == 16) { isD = true } + transformed.transformInsert(16, "WXYZ") + transformed.transformInsert(0, "qwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopBCDEFGH") + + transformed.printDebug() + + "qwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopBCDEFGH1234567KJI8901234qwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopABCDEFG56WXYZ7890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("12345678901234567890", original.buildString()) + assertAllSubstring("12345678901234567890", original) + } + + @BeforeEach + fun beforeEach() { + isD = false + } +} + +fun assertAllSubstring(expected: String, text: BigText) { + (0 .. expected.length).forEach { i -> + (i .. expected.length).forEach { j -> + assertEquals(expected.substring(i, j), text.substring(i, j)) + } + } +} From 473c170879115e0ef1782ea42cf85945641c9d2d Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 8 Sep 2024 21:46:46 +0800 Subject: [PATCH 073/195] add BigTextTransformerImpl transformDelete --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 10 +- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 3 + .../ux/bigtext/BigTextTransformerImpl.kt | 2 +- .../transform/BigTextTransformerImplTest.kt | 130 ++++++++++++++++++ 4 files changed, 142 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 09de2773..64db5eaa 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -668,6 +668,10 @@ open class BigTextImpl : BigText { require(0 <= start) { "Invalid start" } require(endExclusive <= length) { "endExclusive is out of bound" } + return deleteUnchecked(start, endExclusive) + } + + protected fun deleteUnchecked(start: Int, endExclusive: Int): Int { if (start == endExclusive) { return 0 } @@ -689,7 +693,8 @@ open class BigTextImpl : BigText { bufferIndex = node!!.value.bufferIndex // FIXME transform bufferOffsetStart = node!!.value.bufferOffsetStart + splitAtIndex bufferOffsetEndExclusive = node!!.value.bufferOffsetEndExclusive - buffer = buffers[bufferIndex] + buffer = node!!.value.buffer + bufferOwnership = node!!.value.bufferOwnership leftStringLength = 0 } @@ -702,7 +707,8 @@ open class BigTextImpl : BigText { bufferIndex = node!!.value.bufferIndex bufferOffsetStart = node!!.value.bufferOffsetStart bufferOffsetEndExclusive = node!!.value.bufferOffsetStart + splitAtIndex - buffer = buffers[bufferIndex] + buffer = node!!.value.buffer + bufferOwnership = node!!.value.bufferOwnership leftStringLength = 0 } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index e8d82275..391c39d9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -98,6 +98,9 @@ class TextBuffer(val size: Int) { } fun subSequence(start: Int, endExclusive: Int): CharSequence { + if (start >= endExclusive) { + return "" + } return buffer.subSequence(start, endExclusive) } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 0c19ea44..4af5404e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -150,7 +150,7 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch val startNode = tree.findNodeByCharIndex(originalRange.start)!! val buffer = startNode.value.buffer // the buffer is not used. just to prevent NPE - super.delete(originalRange) + super.deleteUnchecked(originalRange.start, originalRange.endInclusive + 1) insertChunkAtPosition(originalRange.start, originalRange.length, BufferOwnership.Owned, buffer, -2 .. -2) { this as BigTextTransformNodeValue bufferIndex = -1 diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt index 6d3caed9..3a220f68 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt @@ -122,6 +122,136 @@ class BigTextTransformerImplTest { assertAllSubstring("12345678901234567890", original) } + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformDelete(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformDelete(14 .. 18) + + transformed.printDebug() + + "12345678901234012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("1234567890123456789012345678901234567890", original.buildString()) + assertAllSubstring("1234567890123456789012345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformDeleteAtSamePosition1(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("12345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformDelete(14 .. 16) + transformed.transformDelete(14 .. 15) + + transformed.printDebug() + + "12345678901234890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("12345678901234567890", original.buildString()) + assertAllSubstring("12345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformDeleteAtSamePosition2(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("12345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformDelete(14 .. 15) + transformed.transformDelete(14 .. 16) + + transformed.printDebug() + + "12345678901234890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("12345678901234567890", original.buildString()) + assertAllSubstring("12345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformDeleteMultiple(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("12345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformDelete(14 .. 18) + transformed.transformDelete(3 .. 6) + transformed.transformDelete(10 .. 11) + + transformed.printDebug() + + "123890340".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("12345678901234567890", original.buildString()) + assertAllSubstring("12345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformDeleteAtBeginning(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformDelete(0 .. 11) + + transformed.printDebug() + + "3456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("1234567890123456789012345678901234567890", original.buildString()) + assertAllSubstring("1234567890123456789012345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformDeleteAtEnd(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformDelete(36 .. 39) + + transformed.printDebug() + + "123456789012345678901234567890123456".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("1234567890123456789012345678901234567890", original.buildString()) + assertAllSubstring("1234567890123456789012345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformDeleteWholeThing(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformDelete(0 .. 39) + + transformed.printDebug() + + "".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("1234567890123456789012345678901234567890", original.buildString()) + assertAllSubstring("1234567890123456789012345678901234567890", original) + } + @BeforeEach fun beforeEach() { isD = false From cf9b1860b40ffc1eef4e8bb9b87c604eb2983726 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Tue, 10 Sep 2024 23:41:50 +0800 Subject: [PATCH 074/195] add BigTextTransformerImpl transformReplace, and fix bugs on transformInsert and transformDelete --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 21 ++- .../ux/bigtext/BigTextTransformNodeValue.kt | 7 +- .../ux/bigtext/BigTextTransformerImpl.kt | 19 ++- .../hellohttp/ux/bigtext/LengthTree.kt | 37 ++++- .../hellohttp/ux/bigtext/RedBlackTree2.kt | 2 +- .../transform/BigTextTransformerImplTest.kt | 133 ++++++++++++++++++ 6 files changed, 200 insertions(+), 19 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 64db5eaa..55b4db4a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -282,15 +282,7 @@ open class BigTextImpl : BigText { } protected fun findPositionStart(node: RedBlackTree.Node): Int { - var start = node.value.leftStringLength - var node = node - while (node.parent.isNotNil()) { - if (node === node.parent.right) { - start += node.parent.value.leftStringLength + node.parent.value.bufferLength - } - node = node.parent - } - return start + return tree.findPositionStart(node) } protected fun findRenderPositionStart(node: RedBlackTree.Node): Int { @@ -363,8 +355,13 @@ open class BigTextImpl : BigText { } protected fun insertChunkAtPosition(position: Int, chunkedStringLength: Int, ownership: BufferOwnership, buffer: TextBuffer, range: IntRange, newNodeConfigurer: BigTextNodeValue.() -> Unit) { - var node = tree.findNodeByCharIndex(maxOf(0, position - 1)) // TODO optimize, don't do twice - val nodeStart = node?.let { findPositionStart(it) } // TODO optimize, don't do twice + var node = tree.findNodeByCharIndex(position) // TODO optimize, don't do twice + var nodeStart = node?.let { findPositionStart(it) } // TODO optimize, don't do twice + val findPosition = maxOf(0, position - 1) + if (node != null && findPosition < nodeStart!!) { + node = tree.prevNode(node) + nodeStart = node?.let { findPositionStart(it) } + } if (node != null) { log.d { "> existing node (${node!!.value.debugKey()}) $nodeStart .. ${nodeStart!! + node!!.value.bufferLength - 1}" } require(maxOf(0, position - 1) in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1 || node.value.bufferLength == 0) { @@ -678,7 +675,7 @@ open class BigTextImpl : BigText { log.d { "delete $start ..< $endExclusive" } - var node: RedBlackTree.Node? = tree.findNodeByCharIndex(endExclusive - 1) + var node: RedBlackTree.Node? = tree.findNodeByCharIndex(endExclusive - 1, isIncludeMarkerNodes = false) var nodeRange = charIndexRangeOfNode(node!!) val newNodesInDescendingOrder = mutableListOf() while (node?.isNotNil() == true && start <= nodeRange.endInclusive) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt index 1bcaf20f..ee8f353d 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt @@ -50,9 +50,12 @@ class BigTextTransformNodeValue : BigTextNodeValue() { override fun debugLabel(node: RedBlackTree.Node): String = buildString { node as RedBlackTree.Node - append("$leftStringLength ${bufferOwnership.name.first()} [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.renderLength()}") + append("$leftStringLength ${bufferOwnership.name.first()} blen=$bufferLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.renderLength()}") append(" Tr [$transformedBufferStart ..< $transformedBufferEndExclusive]") - append(" Ren left=$leftRenderLength [$renderBufferStart ..< $renderBufferEndExclusive]") + append(" Ren left=$leftRenderLength curr=$currentRenderLength [$renderBufferStart ..< $renderBufferEndExclusive]") + if (renderBufferStart in 0 until renderBufferEndExclusive) { + append(" '${buffer.subSequence(renderBufferStart, renderBufferEndExclusive)}'") + } append(" row $leftNumOfRowBreaks/$rowBreakOffsets lw $lastRowWidth") } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 4af5404e..7bafed8a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -1,8 +1,18 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext +import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Logger +import co.touchlab.kermit.MutableLoggerConfig +import co.touchlab.kermit.Severity import com.sunnychung.application.multiplatform.hellohttp.extension.length +import com.sunnychung.application.multiplatform.hellohttp.util.JvmLogger import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree +val logT = Logger(object : MutableLoggerConfig { + override var logWriterList: List = listOf(JvmLogger()) + override var minSeverity: Severity = Severity.Debug +}, tag = "BigText.Transform") + class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(chunkSize = delegate.chunkSize) { override val tree: LengthTree = LengthTree( @@ -83,7 +93,7 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch } private fun transformInsertChunkAtPosition(position: Int, chunkedString: String) { - log.d { "transformInsertChunkAtPosition($position, $chunkedString)" } + logT.d { "transformInsertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) var buffer = if (buffers.isNotEmpty()) { buffers.last().takeIf { it.length + chunkedString.length <= chunkSize } @@ -109,6 +119,7 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch } fun transformInsert(pos: Int, text: String): Int { + logT.d { "transformInsert($pos, \"$text\")" } require(pos in 0 .. originalLength) { "Out of bound. pos = $pos, originalLength = $originalLength" } /** @@ -140,6 +151,7 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch } fun transformDelete(originalRange: IntRange): Int { + logT.d { "transformDelete($originalRange)" } require(originalRange.start <= originalRange.endInclusive + 1) { "start should be <= endExclusive" } require(0 <= originalRange.start) { "Invalid start" } require(originalRange.endInclusive + 1 <= originalLength) { "endExclusive is out of bound" } @@ -166,6 +178,11 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch return - originalRange.length } + fun transformReplace(originalRange: IntRange, newText: String) { + transformDelete(originalRange) + transformInsert(originalRange.start, newText) + } + override fun computeCurrentNodeProperties(nodeValue: BigTextNodeValue, left: RedBlackTree.Node?) = with (nodeValue) { super.computeCurrentNodeProperties(nodeValue, left) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt index 31e9964c..85d9b55b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt @@ -5,19 +5,27 @@ import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree open class LengthTree(computations: RedBlackTreeComputations) : RedBlackTree2<@UnsafeVariance V>(computations) where V : LengthNodeValue, V : Comparable<@UnsafeVariance V>, V : DebuggableNode { - fun findNodeByCharIndex(index: Int): RedBlackTree.Node? { + fun findNodeByCharIndex(index: Int, isIncludeMarkerNodes: Boolean = true): RedBlackTree.Node? { var find = index + var lastMatch: RedBlackTree.Node? = null return findNode { when (find) { in Int.MIN_VALUE until it.value.leftStringLength -> -1 it.value.leftStringLength, in it.value.leftStringLength until it.value.leftStringLength + it.value.bufferLength -> { - if (it.left.isNotNil() && find == it.value.leftStringLength && it.left.value.bufferLength == 0) { + lastMatch = it + if (isIncludeMarkerNodes && it.left.isNotNil()) { -1 } else { 0 } } - in it.value.leftStringLength + it.value.bufferLength until Int.MAX_VALUE -> 1.also { compareResult -> + in it.value.leftStringLength + it.value.bufferLength until Int.MAX_VALUE -> ( + if (it.right.isNotNil()) { + 1 + } else { + 0 + } + ).also { compareResult -> val isTurnRight = compareResult > 0 if (isTurnRight) { find -= it.value.leftStringLength + it.value.bufferLength @@ -25,7 +33,18 @@ open class LengthTree(computations: RedBlackTreeComputations) : RedBla } else -> throw IllegalStateException("what is find? $find") } + }?.takeIf { + if (!isIncludeMarkerNodes) { + return@takeIf true + } + val nodePosStart = findPositionStart(it) + nodePosStart <= index && ( + index < nodePosStart + it.value.bufferLength + || it.value.bufferLength == 0 + || (index == getRoot().length() && it === rightmost(getRoot())) + ) } + ?: lastMatch } fun findNodeByRenderCharIndex(index: Int): RedBlackTree.Node? { @@ -44,6 +63,18 @@ open class LengthTree(computations: RedBlackTreeComputations) : RedBla } } } + + fun findPositionStart(node: RedBlackTree.Node): Int { + var start = node.value.leftStringLength + var node = node + while (node.parent.isNotNil()) { + if (node === node.parent.right) { + start += node.parent.value.leftStringLength + node.parent.value.bufferLength + } + node = node.parent + } + return start + } } fun RedBlackTree.Node.length(): Int where V : LengthNodeValue, V : Comparable = diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt index 87eb60f3..4b7a01e0 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/RedBlackTree2.kt @@ -525,7 +525,7 @@ open class RedBlackTree2(private val computations: RedBlackTreeComputations${visit(it)}") } node.right.takeIf { it.isNotNil() }?.also { appendLine("$prepend$key--R-->${visit(it)}") } - node.parent.takeIf { it.isNotNil() }?.also { appendLine("$prepend$key--P-->${node.parent.value.debugKey()}") } +// node.parent.takeIf { it.isNotNil() }?.also { appendLine("$prepend$key--P-->${node.parent.value.debugKey()}") } return key } visit(root) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt index 3a220f68..d524b0f2 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt @@ -49,9 +49,12 @@ class BigTextTransformerImplTest { @ValueSource(ints = [64, 16]) fun initialTransformInsertMultipleAtSamePos(chunkSize: Int) { val original = BigTextImpl(chunkSize = chunkSize) + if (chunkSize == 16) { isD = true } original.append("12345678901234567890") + isD = false val transformed = BigTextTransformerImpl(original) transformed.transformInsert(14, "WXYZ") + if (chunkSize == 16) { isD = true } transformed.transformInsert(14, "KJI") transformed.printDebug() @@ -252,6 +255,136 @@ class BigTextTransformerImplTest { assertAllSubstring("1234567890123456789012345678901234567890", original) } + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformReplace(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformReplace(13 .. 27, "abc") + + transformed.printDebug() + + "1234567890123abc901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("1234567890123456789012345678901234567890", original.buildString()) + assertAllSubstring("1234567890123456789012345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformReplaceMultiple(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformReplace(36 .. 38, "A") + transformed.transformReplace(13 .. 27, "abc") + transformed.transformReplace(4 .. 4, "def") + transformed.transformReplace(29 .. 30, "XY") + + transformed.printDebug() + + "1234def67890123abc9XY23456A0".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("1234567890123456789012345678901234567890", original.buildString()) + assertAllSubstring("1234567890123456789012345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformReplaceAtBeginning(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformReplace(0 .. 33, "A") + transformed.transformReplace(36 .. 37, "abc") + + transformed.printDebug() + + "A56abc90".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("1234567890123456789012345678901234567890", original.buildString()) + assertAllSubstring("1234567890123456789012345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [64, 16]) + fun initialTransformReplaceAtEnd(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformReplace(25 .. 39, "qwertyuiop".repeat(3)) + + transformed.printDebug() + + "1234567890123456789012345${"qwertyuiop".repeat(3)}".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("1234567890123456789012345678901234567890", original.buildString()) + assertAllSubstring("1234567890123456789012345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [1024 * 1024, 64, 16]) + fun initialTransformReplaceByLongString(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformReplace(6 .. 25, "qwertyuiop".repeat(10)) + transformed.transformReplace(32 .. 35, "qwertyuiop".repeat(7)) + + transformed.printDebug() + + "123456${"qwertyuiop".repeat(10)}789012${"qwertyuiop".repeat(7)}7890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals("1234567890123456789012345678901234567890", original.buildString()) + assertAllSubstring("1234567890123456789012345678901234567890", original) + } + + @ParameterizedTest + @ValueSource(ints = [1024 * 1024, 64, 16]) + fun initialTransformReplaceConsecutive(chunkSize: Int) { + val originalString = "1234567890123456789012345678901234567890" + val original = BigTextImpl(chunkSize = chunkSize) + original.append(originalString) + val transformed = BigTextTransformerImpl(original) + try { + var start = 0 + var counter = 0 + var expected = originalString + while (start < 40) { + var length = ((counter++) % 3) + 1 + val endExclusive = minOf(40, start + length) + length = endExclusive - start + val replacement = (counter % 10).digitToChar().toString().repeat(length) + +// if (chunkSize == 16 && counter == 3) { isD = true } + if (chunkSize == 16 && counter == 10) { isD = true } + expected = expected.replaceRange(start until endExclusive, replacement) + transformed.transformReplace(start until endExclusive, replacement) + + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + + start = endExclusive + } + } finally { + transformed.printDebug() + } + + assertEquals("1234567890123456789012345678901234567890", original.buildString()) + assertAllSubstring("1234567890123456789012345678901234567890", original) + } + @BeforeEach fun beforeEach() { isD = false From 6208567c0cfe396d0cddc35183a92125d61b3b63 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 11 Sep 2024 21:43:04 +0800 Subject: [PATCH 075/195] add insertion hook to BigTextImpl --- .../hellohttp/ux/bigtext/BigTextChangeHook.kt | 8 + .../hellohttp/ux/bigtext/BigTextImpl.kt | 22 ++- .../ux/bigtext/BigTextTransformerImpl.kt | 10 + .../hellohttp/ux/bigtext/LengthTree.kt | 2 +- .../transform/BigTextTransformerImplTest.kt | 180 ++++++++++++++++++ 5 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeHook.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeHook.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeHook.kt new file mode 100644 index 00000000..4b921f4c --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeHook.kt @@ -0,0 +1,8 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +interface BigTextChangeHook { + + fun afterInsertChunk(modifiedText: BigText, position: Int, newValue: BigTextNodeValue) + + fun afterDelete(modifiedText: BigText, position: IntRange) +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 55b4db4a..fec18aa8 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -9,6 +9,7 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearch import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.util.JvmLogger +import com.sunnychung.application.multiplatform.hellohttp.util.let import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree val log = Logger(object : MutableLoggerConfig { @@ -56,6 +57,8 @@ open class BigTextImpl : BigText { var onLayoutCallback: (() -> Unit)? = null + internal var changeHook: BigTextChangeHook? = null + constructor() { chunkSize = 2 * 1024 * 1024 // 2 MB } @@ -363,7 +366,7 @@ open class BigTextImpl : BigText { nodeStart = node?.let { findPositionStart(it) } } if (node != null) { - log.d { "> existing node (${node!!.value.debugKey()}) $nodeStart .. ${nodeStart!! + node!!.value.bufferLength - 1}" } + log.d { "> existing node (${node!!.value.debugKey()}) ${node!!.value.bufferOwnership.name.first()} $nodeStart .. ${nodeStart!! + node!!.value.bufferLength - 1}" } require(maxOf(0, position - 1) in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1 || node.value.bufferLength == 0) { printDebug() findPositionStart(node!!) @@ -374,6 +377,7 @@ open class BigTextImpl : BigText { } var insertDirection: InsertDirection = InsertDirection.Undefined val toBeRelayouted = mutableListOf() + var newContentNode: BigTextNodeValue? = null val newNodeValues = if (node != null && position > 0 && position in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1) { val splitAtIndex = position - nodeStart log.d { "> split at $splitAtIndex" } @@ -406,18 +410,24 @@ open class BigTextImpl : BigText { listOf( createNodeValue().apply { // new string this.newNodeConfigurer() + newContentNode = this }, secondPartNodeValue ).reversed() // IMPORTANT: the insertion order is reversed - } else if (node == null || node.value.bufferOwnership != ownership || node.value.bufferIndex != buffers.lastIndex || node.value.bufferOffsetEndExclusive != range.start || position == 0) { + } else if (node == null || node.value.bufferOwnership != ownership || (node.value.bufferOwnership == BufferOwnership.Owned && node.value.bufferIndex != buffers.lastIndex) || node.value.bufferOffsetEndExclusive != range.start || position == 0) { log.d { "> create new node" } listOf(createNodeValue().apply { this.newNodeConfigurer() + newContentNode = this }) } else { node.value.apply { log.d { "> update existing node end from $bufferOffsetEndExclusive to ${bufferOffsetEndExclusive + range.length}" } bufferOffsetEndExclusive += chunkedStringLength + newContentNode = createNodeValue().apply { + this.newNodeConfigurer() + newContentNode = this + } } recomputeAggregatedValues(node) emptyList() @@ -449,6 +459,10 @@ open class BigTextImpl : BigText { layout(startPos, endPos) } + let(changeHook, newContentNode) { hook, nodeValue -> + hook.afterInsertChunk(this, position, nodeValue) + } + log.v { inspect("Finish I " + node?.value?.debugKey()) } } @@ -665,7 +679,9 @@ open class BigTextImpl : BigText { require(0 <= start) { "Invalid start" } require(endExclusive <= length) { "endExclusive is out of bound" } - return deleteUnchecked(start, endExclusive) + return deleteUnchecked(start, endExclusive).also { + changeHook?.afterDelete(this, start until endExclusive) + } } protected fun deleteUnchecked(start: Int, endExclusive: Int): Int { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 7bafed8a..226ca0f9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -60,6 +60,14 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch (tree as LengthTree).setRoot(delegate.tree.getRoot().toBigTextTransformNode(tree.NIL)) layouter = delegate.layouter contentWidth = delegate.contentWidth + delegate.changeHook = object : BigTextChangeHook { + override fun afterInsertChunk(modifiedText: BigText, position: Int, newValue: BigTextNodeValue) { + insertOriginal(position, newValue) + } + override fun afterDelete(modifiedText: BigText, position: IntRange) { + deleteOriginal(position) + } + } } override val length: Int @@ -145,6 +153,8 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch return text.length } + fun transformInsertAtOriginalEnd(text: String): Int = transformInsert(originalLength, text) + fun deleteOriginal(originalRange: IntRange) { // FIXME call me require((originalRange.endInclusive + 1) in 0 .. originalLength) { "Out of bound. endExclusive = ${originalRange.endInclusive + 1}, originalLength = $originalLength" } super.delete(originalRange) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt index 85d9b55b..3d498a35 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt @@ -13,7 +13,7 @@ open class LengthTree(computations: RedBlackTreeComputations) : RedBla in Int.MIN_VALUE until it.value.leftStringLength -> -1 it.value.leftStringLength, in it.value.leftStringLength until it.value.leftStringLength + it.value.bufferLength -> { lastMatch = it - if (isIncludeMarkerNodes && it.left.isNotNil()) { + if (isIncludeMarkerNodes && find == it.value.leftStringLength && it.left.isNotNil()) { -1 } else { 0 diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt index d524b0f2..c6c8ae9f 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt @@ -385,6 +385,186 @@ class BigTextTransformerImplTest { assertAllSubstring("1234567890123456789012345678901234567890", original) } + @ParameterizedTest + @ValueSource(ints = [1024, 64, 16]) + fun insertToOriginalThenTransformInsert(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + original.insertAt(15, "ABCDEFGHIJabcdefghij!@#$%") + assertEquals(40 + 25, original.length) + transformed.transformInsert(8, "abcd") + transformed.transformInsert(63, "qwertyuiop".repeat(2)) + transformed.transformInsert(15, "XX") + isD = true + transformed.transformInsert(16, "OO") + isD = false + + transformed.printDebug() + + "12345678abcd9012345XXAOOBCDEFGHIJabcdefghij!@#\$%67890123456789012345678${"qwertyuiop".repeat(2)}90".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "123456789012345ABCDEFGHIJabcdefghij!@#\$%6789012345678901234567890".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + } + + @ParameterizedTest + @ValueSource(ints = [1024, 64, 16]) + fun insertToOriginalThenTransformDelete(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + original.insertAt(15, "ABCDEFGHIJabcdefghij!@#\$%") + assertEquals(40 + 25, original.length) + transformed.transformDelete(24 .. 30) + transformed.transformDelete(6 .. 9) + transformed.transformDelete(58 .. 61) + + transformed.printDebug() + + "12345612345ABCDEFGHIghij!@#\$%678901234567890123890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "123456789012345ABCDEFGHIJabcdefghij!@#\$%6789012345678901234567890".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + } + + @ParameterizedTest + @ValueSource(ints = [1024, 64, 16]) + fun insertToOriginalThenTransformReplace(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + original.insertAt(15, "ABCDEFGHIJabcdefghij!@#\$%") + assertEquals(40 + 25, original.length) + transformed.transformReplace(24 .. 30, "") + transformed.transformReplace(6 .. 9, "----") + transformed.transformReplace(31 .. 31, "XYZXYZxyz") + transformed.transformReplace(39 .. 41, "??") + transformed.transformReplace(32 .. 38, ".") + transformed.transformReplace(58 .. 61, " something longer") + + transformed.printDebug() + + "123456----12345ABCDEFGHIXYZXYZxyz.??8901234567890123 something longer890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "123456789012345ABCDEFGHIJabcdefghij!@#\$%6789012345678901234567890".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + } + + @ParameterizedTest + @ValueSource(ints = [1024, 64, 16]) + fun insertMultipleBetweenOriginalAndTransform(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformInsert(8, "abcd") + original.insertAt(15, "ABCDEFGHIJabcdefghij!@#\$%") + assertEquals(40 + 25, original.length) + "12345678abcd9012345ABCDEFGHIJabcdefghij!@#\$%6789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "123456789012345ABCDEFGHIJabcdefghij!@#\$%6789012345678901234567890".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformInsert(63, "qwertyuiop") + original.insertAt(62, "aa") + "12345678abcd9012345ABCDEFGHIJabcdefghij!@#\$%6789012345678901234567aa8qwertyuiop90".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "123456789012345ABCDEFGHIJabcdefghij!@#\$%6789012345678901234567aa890".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformInsert(67, "(end)") + transformed.transformInsert(64, "!") + "12345678abcd9012345ABCDEFGHIJabcdefghij!@#\$%6789012345678901234567aa!8qwertyuiop90(end)".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "123456789012345ABCDEFGHIJabcdefghij!@#\$%6789012345678901234567aa890".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + original.insertAt(0, "prepend") + original.insertAt(3, "PRE") + transformed.transformInsert(6, "XOXO") + transformed.transformInsert(12, "...") + original.insertAt(14, "/") + "prePREXOXOpend12...34/5678abcd9012345ABCDEFGHIJabcdefghij!@#\$%6789012345678901234567aa!8qwertyuiop90(end)".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "prePREpend1234/56789012345ABCDEFGHIJabcdefghij!@#\$%6789012345678901234567aa890".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.printDebug() + } + + @ParameterizedTest + @ValueSource(ints = [1024, 64, 16]) + fun insertOriginalAtEndMultipleBetweenOriginalAndTransform(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.transformInsert(8, "abcd") + original.append("ABCDEFGHIJabcdefghij!@#\$%") + assertEquals(40 + 25, original.length) + "12345678abcd90123456789012345678901234567890ABCDEFGHIJabcdefghij!@#\$%".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "1234567890123456789012345678901234567890ABCDEFGHIJabcdefghij!@#\$%".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformInsertAtOriginalEnd("qwertyuiop") + original.append("a") + original.append("a") + original.append("bb") + "12345678abcd90123456789012345678901234567890ABCDEFGHIJabcdefghij!@#\$%aabbqwertyuiop".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "1234567890123456789012345678901234567890ABCDEFGHIJabcdefghij!@#\$%aabb".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformInsertAtOriginalEnd("(end)") + transformed.transformInsert(69, "!") + "12345678abcd90123456789012345678901234567890ABCDEFGHIJabcdefghij!@#\$%aabb!(end)qwertyuiop".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "1234567890123456789012345678901234567890ABCDEFGHIJabcdefghij!@#\$%aabb".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.printDebug() + } + @BeforeEach fun beforeEach() { isD = false From be92220e01057daaaf92e063fea1a6dfe3683706 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 11 Sep 2024 23:00:14 +0800 Subject: [PATCH 076/195] add deletion hook to BigTextImpl --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 2 +- .../ux/bigtext/BigTextTransformerImpl.kt | 7 +- .../transform/BigTextTransformerImplTest.kt | 232 ++++++++++++++++++ 3 files changed, 237 insertions(+), 4 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index fec18aa8..55d87d36 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -414,7 +414,7 @@ open class BigTextImpl : BigText { }, secondPartNodeValue ).reversed() // IMPORTANT: the insertion order is reversed - } else if (node == null || node.value.bufferOwnership != ownership || (node.value.bufferOwnership == BufferOwnership.Owned && node.value.bufferIndex != buffers.lastIndex) || node.value.bufferOffsetEndExclusive != range.start || position == 0) { + } else if (node == null || node.value.bufferOwnership != ownership || node.value.buffer !== buffer || (node.value.bufferOwnership == BufferOwnership.Owned && node.value.bufferIndex != buffers.lastIndex) || node.value.bufferOffsetEndExclusive != range.start || position == 0) { log.d { "> create new node" } listOf(createNodeValue().apply { this.newNodeConfigurer() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 226ca0f9..8e435886 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -80,7 +80,7 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch return BigTextTransformNodeValue() } - fun insertOriginal(pos: Int, nodeValue: BigTextNodeValue) { // FIXME call me + fun insertOriginal(pos: Int, nodeValue: BigTextNodeValue) { require(pos in 0 .. originalLength) { "Out of bound. pos = $pos, originalLength = $originalLength" } insertChunkAtPosition( @@ -155,9 +155,10 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch fun transformInsertAtOriginalEnd(text: String): Int = transformInsert(originalLength, text) - fun deleteOriginal(originalRange: IntRange) { // FIXME call me + fun deleteOriginal(originalRange: IntRange) { + require(0 <= originalRange.start) { "Invalid start" } require((originalRange.endInclusive + 1) in 0 .. originalLength) { "Out of bound. endExclusive = ${originalRange.endInclusive + 1}, originalLength = $originalLength" } - super.delete(originalRange) + super.deleteUnchecked(originalRange.start, originalRange.endInclusive + 1) } fun transformDelete(originalRange: IntRange): Int { diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt index c6c8ae9f..a44c7588 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt @@ -1,5 +1,6 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext.transform +import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.randomString import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl @@ -565,6 +566,237 @@ class BigTextTransformerImplTest { transformed.printDebug() } + @ParameterizedTest + @ValueSource(ints = [1024, 64, 16]) + fun deleteMultipleBetweenOriginalAndTransform(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + original.delete(31, 33) + original.delete(35, 37) + transformed.transformInsert(8, "abcd") + original.delete(15, 18) + assertEquals(40 - 2 - 2 - 3, original.length) + "12345678abcd9012345901234567890145670".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "123456789012345901234567890145670".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformDelete(22 .. 26) + transformed.transformInsert(29, "qwertyuiop") + original.delete(4 .. 7) + "1234abcd9012345901234514qwertyuiop5670".let { expected -> + assertEquals(expected, transformed.buildString()) // + assertAllSubstring(expected, transformed) + } + "12349012345901234567890145670".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformDelete(5 .. 8) + transformed.transformDelete(26 .. 28) + "1234abcd945901234514qwertyuiop5".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "12349012345901234567890145670".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + original.delete(10 .. 15) + "1234abcd944514qwertyuiop5".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "12349012344567890145670".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + original.insertAt(10, "_") + original.insertAt(9, "ABC") + "1234abcd9ABC4_4514qwertyuiop5".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "123490123ABC4_4567890145670".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + original.delete(3 .. 26) + "123".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "123".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformDelete(0 .. 2) + "".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "123".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + original.delete(0 .. 1) + "".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "3".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + original.delete(0 .. 0) + "".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformInsert(0, "Zz") + "Zz".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.printDebug() + } + + @ParameterizedTest + @ValueSource(ints = [1024, 64, 16]) + fun replaceMultipleBetweenOriginalAndTransform(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("1234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + original.replace(31 .. 33, "zxcvb") + original.replace(5 .. 7, "ZXCV") + "12345ZXCV90123456789012345678901zxcvb567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + transformed.transformReplace(8 .. 11, "ab") + original.delete(15, 18) + "12345ZXCab23489012345678901zxcvb567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "12345ZXCV90123489012345678901zxcvb567890".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformReplace(13 .. 33, "qwerty") + "12345ZXCab2qwerty567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "12345ZXCV90123489012345678901zxcvb567890".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + original.replace(1 .. 35, "#######") + "1#######7890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "1#######7890".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformReplace(5 .. 9, "-") + "1####-90".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "1#######7890".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformInsert(12, "*") + original.replace(0 .. 10, "x") + "x0*".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "x0".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.transformReplace(0 .. 1, "@") + original.replace(0 .. 1, "") + "@*".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.printDebug() + } + + @ParameterizedTest + @ValueSource(ints = [1024, 64, 16]) + fun replaceLongString(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + val initial = "1234567890223456789032345678904234567890_234567890223456789032345678904234567890" + original.append(initial) + assertEquals(80, original.length) + val transformed = BigTextTransformerImpl(original) + val s1 = randomString(72 - 6 + 1, false) + transformed.transformReplace(6 .. 72, s1) + "123456${s1}4567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + initial.let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + val s2 = randomString(77 - 4 + 1, false) + transformed.transformReplace(1 .. 2, "!") + original.replace(4 .. 77, s2) + "1!4${s2}90".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + "1234${s2}90".let { expected -> + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, original) + } + + transformed.printDebug() + } + @BeforeEach fun beforeEach() { isD = false From f31db1f3b5bc1579c038c75cfa4f873eb67031c2 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 11 Sep 2024 23:05:20 +0800 Subject: [PATCH 077/195] update BigTextTransformerImpl to delegate BigText mutation functions to transformed mutations --- .../hellohttp/ux/bigtext/BigTextTransformerImpl.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 8e435886..251588c4 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -203,6 +203,14 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch leftRenderLength = left?.renderLength() ?: 0 leftOverallLength = left?.overallLength() ?: 0 } + + override fun insertAt(pos: Int, text: String): Int = transformInsert(pos, text) + + override fun append(text: String): Int = transformInsertAtOriginalEnd(text) + + override fun delete(start: Int, endExclusive: Int): Int = transformDelete(start until endExclusive) + + override fun replace(range: IntRange, text: String) = transformReplace(range, text) } fun RedBlackTree.Node.transformedOffset(): Int = From 4a060d04931bfa75db21165df32fa575fbfc1ba8 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 15 Sep 2024 11:42:56 +0800 Subject: [PATCH 078/195] add BigTextTransformerImpl insertion layout test, and fix some layout issues --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 48 +++++-- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 2 +- .../ux/bigtext/BigTextTransformerImpl.kt | 20 ++- .../test/bigtext/BigTextImplLayoutTest.kt | 121 +++++++++++----- .../test/bigtext/BigTextVerifyImpl.kt | 39 ++++- .../transform/BigTextTransformerLayoutTest.kt | 133 ++++++++++++++++++ 6 files changed, 306 insertions(+), 57 deletions(-) create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 55d87d36..89739844 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -609,7 +609,7 @@ open class BigTextImpl : BigText { } else { node.value!!.renderBufferStart } - return findPositionStart(node) + (charOffsetInBuffer - node.value!!.renderBufferStart) + return findRenderPositionStart(node) + (charOffsetInBuffer - node.value!!.renderBufferStart) } val (startNode, startNodeRowStart) = tree.findNodeByRowBreaks(rowIndex - 1) ?: @@ -790,7 +790,7 @@ open class BigTextImpl : BigText { appendLine("[$label] Tree:\nflowchart TD\n${tree.debugTree()}") appendLine("[$label] String:\n${buildString()}") if (layouter != null && contentWidth != null) { - appendLine("[$label] Layouted String:\n${(0 until numOfRows).joinToString("") { + appendLine("[$label] Layouted String ($numOfRows):\n${(0 until numOfRows).joinToString("") { try { "{${findRowString(it)}}\n" } catch (e: Throwable) { @@ -879,15 +879,20 @@ open class BigTextImpl : BigText { } + /** + * @param startPos Begin index of render positions. + * @param endPosExclusive End index (exclusive) of render positions. + */ protected fun layout(startPos: Int, endPosExclusive: Int) { val layouter = this.layouter ?: return val contentWidth = this.contentWidth ?: return var lastOccupiedWidth = 0f - var node: RedBlackTree.Node? = tree.findNodeByCharIndex(startPos) ?: return + var isLastEndWithForceRowBreak = false + var node: RedBlackTree.Node? = tree.findNodeByRenderCharIndex(startPos) ?: return logL.d { "layout($startPos, $endPosExclusive)" } logL.v { inspect("before layout($startPos, $endPosExclusive)") } - var nodeStartPos = findPositionStart(node!!) + var nodeStartPos = findRenderPositionStart(node!!) val nodeValue = node.value val buffer = nodeValue.buffer var lineBreakIndexFrom = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast( @@ -899,8 +904,10 @@ open class BigTextImpl : BigText { } else { val prevNode = tree.prevNode(node!!) if (prevNode != null) { - lastOccupiedWidth = prevNode.value.lastRowWidth // carry over - logL.d { "carry over width $lastOccupiedWidth" } + // carry over + lastOccupiedWidth = prevNode.value.lastRowWidth + isLastEndWithForceRowBreak = prevNode.value.isEndWithForceRowBreak + logL.d { "carry over width $lastOccupiedWidth $isLastEndWithForceRowBreak" } } nodeValue.renderBufferStart @@ -948,7 +955,7 @@ open class BigTextImpl : BigText { // nodeValue.rowBreakOffsets.clear() val rowBreakOffsets = mutableListOf() var isEndWithForceRowBreak = false - logL.d { "orig row breaks ${nodeValue.rowBreakOffsets}" } + logL.d { "orig row breaks ${nodeValue.rowBreakOffsets} lrw=${nodeValue.lastRowWidth} for ref only" } // if (true || nodeStartPos == 0) { // // we are starting at charStartIndexInBuffer without carrying over last width, so include the row break at charStartIndexInBuffer // rowBreakOffsets += nodeValue.rowBreakOffsets.subList(0, maxOf(0, nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(charStartIndexInBuffer) + 1)) @@ -964,6 +971,14 @@ open class BigTextImpl : BigText { rowBreakOffsets.addToThisAscendingListWithoutDuplicate(restoreRowBreakOffsets) hasRestoredRowBreaks = true } + + if (isLastEndWithForceRowBreak) { + logL.d { "row break add carry-over force break ${charStartIndexInBuffer}" } + rowBreakOffsets.addToThisAscendingListWithoutDuplicate(charStartIndexInBuffer) + lastOccupiedWidth = 0f + isLastEndWithForceRowBreak = false + } + (lineBreakIndexFrom..lineBreakIndexTo).forEach { lineBreakEntryIndex -> val lineBreakCharIndex = buffer.lineOffsetStarts[lineBreakEntryIndex] val subsequence = buffer.subSequence(charStartIndexInBuffer, lineBreakCharIndex) @@ -979,17 +994,20 @@ open class BigTextImpl : BigText { logL.d { "row break add $rowCharOffsets lw = 0" } rowBreakOffsets.addToThisAscendingListWithoutDuplicate(rowCharOffsets) - if (subsequence.isEmpty() && lastOccupiedWidth >= contentWidth - EPS) { - logL.d { "row break add carry-over force break ${lineBreakCharIndex}" } - rowBreakOffsets.addToThisAscendingListWithoutDuplicate(lineBreakCharIndex) - } +// if (subsequence.isEmpty() && lastOccupiedWidth >= contentWidth - EPS) { +// logL.d { "row break add carry-over force break ${lineBreakCharIndex}" } +// rowBreakOffsets.addToThisAscendingListWithoutDuplicate(lineBreakCharIndex) +// } charStartIndexInBuffer = lineBreakCharIndex + 1 + // place a row break right after the '\n' char if (lineBreakCharIndex + 1 < nodeValue.renderBufferEndExclusive) { logL.d { "row break add ${lineBreakCharIndex + 1}" } rowBreakOffsets.addToThisAscendingListWithoutDuplicate(lineBreakCharIndex + 1) lastOccupiedWidth = 0f } else { + // the char after the '\n' char is not in this node + // mark a carry-over row break lastOccupiedWidth = contentWidth + 0.1f // force a row break at the start of next layout isEndWithForceRowBreak = true } @@ -1009,7 +1027,7 @@ open class BigTextImpl : BigText { } // if (charStartIndexInBuffer < nodeValue.bufferOffsetEndExclusive) { // if (charStartIndexInBuffer < nodeValue.bufferOffsetEndExclusive && nodeStartPos + charStartIndexInBuffer - nodeValue.bufferOffsetStart < endPosExclusive) { - if (charStartIndexInBuffer < nextBoundary) { + if (!isBreakAfterThisIteration && charStartIndexInBuffer < nextBoundary) { // val subsequence = buffer.subSequence(charStartIndexInBuffer, nodeValue.bufferOffsetEndExclusive) val readRowUntilPos = nextBoundary //nodeValue.bufferOffsetEndExclusive //minOf(nodeValue.bufferOffsetEndExclusive, endPosExclusive - nodeStartPos + nodeValue.bufferOffsetStart) logL.d { "node ${nodeValue.debugKey()} last row seq $charStartIndexInBuffer ..< ${readRowUntilPos}. start = $nodeStartPos" } @@ -1022,7 +1040,7 @@ open class BigTextImpl : BigText { charStartIndexInBuffer ) // nodeValue.rowBreakOffsets += rowCharOffsets - logL.d { "row break add $rowCharOffsets lw = $lastRowOccupiedWidth" } + logL.d { "row break add $rowCharOffsets lrw = $lastRowOccupiedWidth" } rowBreakOffsets.addToThisAscendingListWithoutDuplicate(rowCharOffsets) lastOccupiedWidth = lastRowOccupiedWidth charStartIndexInBuffer = readRowUntilPos @@ -1038,16 +1056,18 @@ open class BigTextImpl : BigText { } logL.d { "reach the end, preserve RB from $preserveIndexFrom (at least $searchForValue) ~ $preserveIndexTo (${nodeValue.renderBufferEndExclusive}). RB = ${nodeValue.rowBreakOffsets}." } val restoreRowBreaks = nodeValue.rowBreakOffsets.subList(preserveIndexFrom, minOf(nodeValue.rowBreakOffsets.size, preserveIndexTo + 1)) - if (restoreRowBreaks.isNotEmpty() || nodeValue.isEndWithForceRowBreak) { + if (restoreRowBreaks.isNotEmpty() || nodeValue.isEndWithForceRowBreak || isBreakAfterThisIteration) { rowBreakOffsets.addToThisAscendingListWithoutDuplicate(restoreRowBreaks) logL.d { "Restore lw ${nodeValue.lastRowWidth}." } lastOccupiedWidth = nodeValue.lastRowWidth isEndWithForceRowBreak = isEndWithForceRowBreak || nodeValue.isEndWithForceRowBreak } } + logL.d { "node ${nodeValue.debugKey()} (${nodeStartPos} ..< ${nodeStartPos + nodeValue.renderBufferEndExclusive - nodeValue.renderBufferStart}) update lrw=$lastOccupiedWidth frb=$isEndWithForceRowBreak rb=$rowBreakOffsets" } nodeValue.rowBreakOffsets = rowBreakOffsets nodeValue.lastRowWidth = lastOccupiedWidth nodeValue.isEndWithForceRowBreak = isEndWithForceRowBreak + isLastEndWithForceRowBreak = isEndWithForceRowBreak recomputeAggregatedValues(node) // TODO optimize if (isBreakOnEncounterLineBreak && isBreakAfterThisIteration) { // TODO it can be further optimized to break immediately on line break diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 391c39d9..a5b2e158 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -63,7 +63,7 @@ open class BigTextNodeValue : Comparable, DebuggableNode.Node): String = - "$leftStringLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.length()} r $leftNumOfRowBreaks/$rowBreakOffsets lw $lastRowWidth" + "$leftStringLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.length()} r $leftNumOfRowBreaks/$rowBreakOffsets lw $lastRowWidth $isEndWithForceRowBreak '${buffer.subSequence(renderBufferStart, renderBufferEndExclusive).toString().replace("\n", "\\n")}'" } class TextBuffer(val size: Int) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 251588c4..5d673e1d 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -36,6 +36,9 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch it.bufferNumLineBreaksInRange = bufferNumLineBreaksInRange it.buffer = buffer // copy by ref it.bufferOwnership = BufferOwnership.Delegated + + it.leftRenderLength = leftStringLength + it.leftOverallLength = leftStringLength } } @@ -51,15 +54,20 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch tree.NIL, tree.NIL, ).also { - it.left = left.toBigTextTransformNode(it) - it.right = right.toBigTextTransformNode(it) + it.value.attach(it as RedBlackTree.Node) + val n = it as RedBlackTree.Node + n.left = left.toBigTextTransformNode(n) + n.right = right.toBigTextTransformNode(n) } } init { (tree as LengthTree).setRoot(delegate.tree.getRoot().toBigTextTransformNode(tree.NIL)) - layouter = delegate.layouter - contentWidth = delegate.contentWidth +// tree.visitInPostOrder { +// recomputeAggregatedValues(it as RedBlackTree.Node) +// } + delegate.layouter?.let { setLayouter(it) } + delegate.contentWidth?.let { setContentWidth(it) } delegate.changeHook = object : BigTextChangeHook { override fun afterInsertChunk(modifiedText: BigText, position: Int, newValue: BigTextNodeValue) { insertOriginal(position, newValue) @@ -149,7 +157,9 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch transformInsertChunkAtPosition(pos, text.substring(start until start + append)) last = buffers.last().length } - layout(maxOf(0, pos - 1), minOf(length, pos + text.length + 1)) + val renderPositionStart = findRenderPositionStart(tree.findNodeByCharIndex(pos)!!) + layout(maxOf(0, renderPositionStart - 1), minOf(length, renderPositionStart + text.length + 1)) +// layout() return text.length } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt index 0dd96a36..87d1cad9 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt @@ -16,7 +16,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -private var random: Random = Random +internal var random: Random = Random @TestMethodOrder(MethodOrderer.OrderAnnotation::class) class BigTextImplLayoutTest { @@ -53,38 +53,6 @@ class BigTextImplLayoutTest { } } - fun verifyBigTextImplAgainstTestString(testString: String, bigTextImpl: BigTextImpl, softWrapAt: Int = 10) { - val splitted = testString.split("\n") - val expectedRows = splitted.flatMapIndexed { index: Int, str: String -> -// val str = if (index < splitted.lastIndex) "$s\n" else s - str.chunked(softWrapAt).let { ss -> - val ss = if (ss.isEmpty()) listOf(str) else ss - if (index < splitted.lastIndex) { - ss.mapIndexed { i, s -> - if (i == ss.lastIndex) { - "$s\n" - } else { - s - } - } - } else { - ss - } - } - } -// println("exp $expectedRows") - try { - assertEquals(expectedRows.size, bigTextImpl.numOfRows) - expectedRows.forEachIndexed { index, s -> - assertEquals(s, bigTextImpl.findRowString(index)) - } - } catch (e: Throwable) { - bigTextImpl.printDebug("ERROR") - println("exp $expectedRows") - throw e - } - } - @ParameterizedTest @ValueSource(ints = [65536, 64, 16]) fun layoutMultipleLines1(chunkSize: Int) { @@ -196,6 +164,61 @@ class BigTextImplLayoutTest { verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl) } + @ParameterizedTest + @ValueSource(ints = [65536, 64, 16]) + fun insertTriggersRelayout4(chunkSize: Int) { + val initial = "1234567890<234567890 +// val str = if (index < splitted.lastIndex) "$s\n" else s + str.chunked(softWrapAt).let { ss -> + val ss = if (ss.isEmpty()) listOf(str) else ss + if (index < splitted.lastIndex) { + ss.mapIndexed { i, s -> + if (i == ss.lastIndex) { + "$s\n" + } else { + s + } + } + } else { + ss + } + } + } +// println("exp $expectedRows") + try { + assertEquals(expectedRows.size, bigTextImpl.numOfRows) + expectedRows.forEachIndexed { index, s -> + assertEquals(s, bigTextImpl.findRowString(index)) + } + } catch (e: Throwable) { + bigTextImpl.printDebug("ERROR") + println("exp $expectedRows") + throw e + } +} + fun randomString(length: Int, isAddNewLine: Boolean): String = (0 until length).joinToString("") { when { isAddNewLine && random.nextInt(100) == 0 -> "\n" diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index 4fb99ade..661708b1 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -2,14 +2,32 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextNodeValue +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.InefficientBigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.LengthTree +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.TextBuffer +import java.util.TreeMap -internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : BigText { - val bigTextImpl = if (chunkSize > 0) BigTextImpl(chunkSize) else BigTextImpl() +internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { + val bigTextImpl: BigTextImpl = bigTextImpl val stringImpl = InefficientBigText("") - val tree = bigTextImpl.tree - val buffers = bigTextImpl.buffers + init { + this.stringImpl.append(bigTextImpl.buildString()) + } + + internal constructor(chunkSize: Int = -1) : this( + if (chunkSize > 0) BigTextImpl(chunkSize) else BigTextImpl() + ) + + val tree: LengthTree + get() = bigTextImpl.tree + val buffers: MutableList + get() = bigTextImpl.buffers + + val isTransform = bigTextImpl is BigTextTransformerImpl + val transformOffsetsByPosition = TreeMap() override val length: Int get() { @@ -19,6 +37,9 @@ internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : Big return l } + val originalLength: Int + get() = length - transformOffsetsByPosition.values.sum() + override fun buildString(): String { val r = bigTextImpl.buildString() val tr = stringImpl.buildString() @@ -36,6 +57,10 @@ internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : Big override fun append(text: String): Int { println("append ${text.length}") val r = bigTextImpl.append(text) + if (isTransform) { + val pos = stringImpl.length + transformOffsetsByPosition[pos] = (transformOffsetsByPosition[pos] ?: 0) + text.length + } stringImpl.append(text) verify() return r @@ -44,6 +69,12 @@ internal class BigTextVerifyImpl internal constructor(chunkSize: Int = -1) : Big override fun insertAt(pos: Int, text: String): Int { println("insert $pos, ${text.length}") val r = bigTextImpl.insertAt(pos, text) + if (isTransform) { + transformOffsetsByPosition[pos] = (transformOffsetsByPosition[pos] ?: 0) + text.length + } + val pos = pos + transformOffsetsByPosition.subMap(0, pos).values.sum().also { + println("VerifyImpl pos $pos offset $it") + } stringImpl.insertAt(pos, text) verify() return r diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt new file mode 100644 index 00000000..42e4a7f7 --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -0,0 +1,133 @@ +package com.sunnychung.application.multiplatform.hellohttp.test.bigtext.transform + +import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.BigTextVerifyImpl +import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.FixedWidthCharMeasurer +import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.random +import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.randomString +import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.verifyBigTextImplAgainstTestString +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.MonospaceTextLayouter +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.TestMethodOrder +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import kotlin.random.Random + +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class BigTextTransformerLayoutTest { + + @ParameterizedTest + @ValueSource(ints = [65536, 64, 16]) + fun noTransformation(chunkSize: Int) { if (chunkSize != 16) return + val testString = "1234567890<234567890 0 + in 2 .. 3 -> v.originalLength + else -> random.nextInt(1, v.originalLength) + } + val length = when (random.nextInt(10)) { + in 0 .. 2 -> 1 + random.nextInt(3) + in 3 .. 4 -> random.nextInt(4, 11) + in 5 .. 6 -> random.nextInt(11, 300) + 7 -> random.nextInt(300, 1000) + 8 -> random.nextInt(1000, 10000) + 9 -> random.nextInt(10000, 100000) + else -> throw IllegalStateException() + } + v.insertAt(pos, randomString(length, isAddNewLine = true)) + verifyBigTextImplAgainstTestString(testString = v.stringImpl.buildString(), bigTextImpl = tt) + } + } +} From c022fd0e711b80b74b52c0ff66879de790aabca0 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 15 Sep 2024 15:19:55 +0800 Subject: [PATCH 079/195] add BigTextTransformerImpl deletion layout test --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 12 +- .../ux/bigtext/BigTextTransformerImpl.kt | 3 + .../test/bigtext/BigTextVerifyImpl.kt | 6 +- .../transform/BigTextTransformerLayoutTest.kt | 139 ++++++++++++++++++ 4 files changed, 154 insertions(+), 6 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 89739844..cf4c14b9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -756,9 +756,11 @@ open class BigTextImpl : BigText { // computeCurrentNodeProperties(it.value) // } + // layout the new nodes explicitly, as + // the layout outside the loop may not be able to touch the new nodes newNodesInDescendingOrder.forEach { - val startPos = findPositionStart(it.node!!) - val endPos = startPos + it.bufferLength + val startPos = findRenderPositionStart(it.node!!) + val endPos = startPos + it.currentRenderLength layout(startPos, endPos) } @@ -1103,15 +1105,15 @@ open class BigTextImpl : BigText { val lastNode = tree.rightmost(tree.getRoot()).takeIf { it.isNotNil() } val lastValue = lastNode?.value ?: return@run 0 val lastLineOffset = lastValue.buffer.lineOffsetStarts.let { - val lastIndex = it.binarySearchForMaxIndexOfValueAtMost(lastValue.bufferOffsetEndExclusive - 1) + val lastIndex = it.binarySearchForMaxIndexOfValueAtMost(lastValue.renderBufferEndExclusive - 1) if (lastIndex in 0 .. it.lastIndex) { it[lastIndex] } else { null } } ?: return@run 0 - val lastNodePos = findPositionStart(lastNode) - if (lastNodePos + (lastLineOffset - lastValue.bufferOffsetStart) == lastIndex) { + val lastNodePos = findRenderPositionStart(lastNode) + if (lastNodePos + (lastLineOffset - lastValue.renderBufferStart) == lastIndex) { 1 // one extra row if the string ends with '\n' } else { 0 diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 5d673e1d..335024c8 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -182,6 +182,7 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch } val startNode = tree.findNodeByCharIndex(originalRange.start)!! + val renderStartPos = findRenderPositionStart(startNode) val buffer = startNode.value.buffer // the buffer is not used. just to prevent NPE super.deleteUnchecked(originalRange.start, originalRange.endInclusive + 1) insertChunkAtPosition(originalRange.start, originalRange.length, BufferOwnership.Owned, buffer, -2 .. -2) { @@ -196,6 +197,8 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch leftStringLength = 0 } + layout(maxOf(0, renderStartPos - 1), minOf(length, renderStartPos + 1)) + logT.d { inspect("after transformDelete $originalRange") } return - originalRange.length } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index 661708b1..a714f646 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -85,7 +85,11 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { var r: Int = 0 printDebugIfError { r = bigTextImpl.delete(start, endExclusive) - stringImpl.delete(start, endExclusive) + if (isTransform) { + transformOffsetsByPosition[start] = (transformOffsetsByPosition[start] ?: 0) - (endExclusive - start) + } + val offset = transformOffsetsByPosition.subMap(0, start).values.sum() + stringImpl.delete(offset + start, offset + endExclusive) } verify() return r diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt index 42e4a7f7..4a0fd203 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -130,4 +130,143 @@ class BigTextTransformerLayoutTest { verifyBigTextImplAgainstTestString(testString = v.stringImpl.buildString(), bigTextImpl = tt) } } + + @ParameterizedTest + @ValueSource(ints = [65536, 64, 16]) + fun deletes(chunkSize: Int) { + val testString = "1234567890<234567890 Date: Sun, 15 Sep 2024 22:34:45 +0800 Subject: [PATCH 080/195] fix BigTextTransformerImpl wrong layout with large string --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 28 +++- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 9 +- .../ux/bigtext/BigTextTransformerImpl.kt | 3 +- .../test/bigtext/BigTextVerifyImpl.kt | 3 +- .../transform/BigTextTransformerLayoutTest.kt | 152 +++++++++++++++++- 5 files changed, 179 insertions(+), 16 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index cf4c14b9..bb48c1f3 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -454,8 +454,8 @@ open class BigTextImpl : BigText { } toBeRelayouted.forEach { - val startPos = findPositionStart(it.node!!) - val endPos = startPos + it.bufferLength + val startPos = findRenderPositionStart(it.node!!) + val endPos = startPos + it.currentRenderLength layout(startPos, endPos) } @@ -488,6 +488,7 @@ open class BigTextImpl : BigText { var node = node while (node.isNotNil()) { val left = node.left.takeIf { it.isNotNil() } +// assert(node === node.getValue().node) with (node.getValue()) { computeCurrentNodeProperties(this, left) } @@ -728,11 +729,11 @@ open class BigTextImpl : BigText { } val prev = tree.prevNode(node) log.d { "Delete node ${node!!.value.debugKey()} at ${nodeRange.start} .. ${nodeRange.last}" } - if (isD && nodeRange.start == 384) { + if (nodeRange.start == 2083112) { isD = true } tree.delete(node) - log.d { inspect("After delete " + node?.value?.debugKey()) } + log.v { inspect("After delete " + node?.value?.debugKey()) } node = prev // nodeRange = nodeRange.start - chunkSize .. nodeRange.last - chunkSize if (node != null) { @@ -753,7 +754,8 @@ open class BigTextImpl : BigText { // // FIXME remove // tree.visitInPostOrder { -// computeCurrentNodeProperties(it.value) +//// computeCurrentNodeProperties(it.value) +// recomputeAggregatedValues(it) // } // layout the new nodes explicitly, as @@ -766,7 +768,7 @@ open class BigTextImpl : BigText { layout(maxOf(0, start - 1), minOf(length, start + 1)) - log.d { inspect("Finish D " + node?.value?.debugKey()) } + log.v { inspect("Finish D " + node?.value?.debugKey()) } return -(endExclusive - start) } @@ -1173,6 +1175,20 @@ fun RedBlackTree.Node.numRowBreaks(): Int { (getRight().takeIf { it.isNotNil() }?.numRowBreaks() ?: 0) } +fun RedBlackTree.Node.computeLength(): Int { + val value = getValue() + return (value?.bufferLength ?: 0) + + (getLeft().takeIf { it.isNotNil() }?.computeLength() ?: 0) + + (getRight().takeIf { it.isNotNil() }?.computeLength() ?: 0) +} + +fun RedBlackTree.Node.computeRenderLength(): Int { + val value = getValue() + return (value?.currentRenderLength ?: 0) + + (getLeft().takeIf { it.isNotNil() }?.computeRenderLength() ?: 0) + + (getRight().takeIf { it.isNotNil() }?.computeRenderLength() ?: 0) +} + private enum class InsertDirection { Left, Right, Undefined } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index a5b2e158..321e9bfb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -47,7 +47,7 @@ open class BigTextNodeValue : Comparable, DebuggableNode.Node? = null - private val key = Random.nextInt() + private val key = RANDOM.nextInt() override fun attach(node: RedBlackTree.Node) { this.node = node @@ -63,7 +63,12 @@ open class BigTextNodeValue : Comparable, DebuggableNode.Node): String = - "$leftStringLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.length()} r $leftNumOfRowBreaks/$rowBreakOffsets lw $lastRowWidth $isEndWithForceRowBreak '${buffer.subSequence(renderBufferStart, renderBufferEndExclusive).toString().replace("\n", "\\n")}'" +// "$leftStringLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.length()} r $leftNumOfRowBreaks/$rowBreakOffsets lw $lastRowWidth $isEndWithForceRowBreak '${buffer.subSequence(renderBufferStart, renderBufferEndExclusive).toString().replace("\n", "\\n")}'" + "$leftStringLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.length()} r $leftNumOfRowBreaks/$rowBreakOffsets lw $lastRowWidth $isEndWithForceRowBreak" + + companion object { + private val RANDOM = Random(1000000) + } } class TextBuffer(val size: Int) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 335024c8..c7514c4f 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -10,7 +10,7 @@ import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree val logT = Logger(object : MutableLoggerConfig { override var logWriterList: List = listOf(JvmLogger()) - override var minSeverity: Severity = Severity.Debug + override var minSeverity: Severity = Severity.Info }, tag = "BigText.Transform") class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(chunkSize = delegate.chunkSize) { @@ -198,6 +198,7 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch leftStringLength = 0 } layout(maxOf(0, renderStartPos - 1), minOf(length, renderStartPos + 1)) +// tree.visitInPostOrder { recomputeAggregatedValues(it) } // logT.d { inspect("after transformDelete $originalRange") } return - originalRange.length } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index a714f646..0df5f9b1 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -91,6 +91,7 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { val offset = transformOffsetsByPosition.subMap(0, start).values.sum() stringImpl.delete(offset + start, offset + endExclusive) } + println("new len = ${bigTextImpl.length}") verify() return r } @@ -110,7 +111,7 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { } fun verify(label: String = "") { - printDebugIfError(label) { + printDebugIfError(label.ifEmpty { "ERROR" }) { length buildString() } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt index 4a0fd203..55c2da59 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -1,5 +1,6 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext.transform +import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.BigTextVerifyImpl import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.FixedWidthCharMeasurer import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.random @@ -13,6 +14,7 @@ import org.junit.jupiter.api.Order import org.junit.jupiter.api.TestMethodOrder import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource +import java.util.TreeMap import kotlin.random.Random @TestMethodOrder(MethodOrderer.OrderAnnotation::class) @@ -98,7 +100,7 @@ class BigTextTransformerLayoutTest { @ParameterizedTest @ValueSource(ints = [1048576, 64, 16]) @Order(Integer.MAX_VALUE - 100) // This test is pretty time-consuming. Run at the last! - fun manyInserts(chunkSize: Int) { + fun manyInserts1(chunkSize: Int) { val testString = "1234567890<234567890 0 + in 2 .. 3 -> v.originalLength + else -> random.nextInt(1, v.originalLength) + } + val length = when (random.nextInt(10)) { + in 0 .. 2 -> 1 + random.nextInt(3) + in 3 .. 4 -> random.nextInt(4, 11) + in 5 .. 6 -> random.nextInt(11, 300) + 7 -> random.nextInt(300, 1000) + 8 -> random.nextInt(1000, 10000) + 9 -> random.nextInt(10000, 100000) + else -> throw IllegalStateException() + } + v.insertAt(pos, randomString(length, isAddNewLine = true)) + verifyBigTextImplAgainstTestString(testString = v.stringImpl.buildString(), bigTextImpl = tt) + } + } + + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) fun deletes(chunkSize: Int) { val testString = "1234567890<234567890() + + repeat(1000) { + // create a random interval that does not overlap with previous ones + val pos = run { + var p: Int + do { + var isPositionUsed = false + p = random.nextInt(0, v.originalLength) + val deleted = deletedIntervals.subMap(-1, true, p, true).lastEntry() + if (deleted != null && deleted.key + deleted.value >= p) { + isPositionUsed = true + } + } while (isPositionUsed) + p + } + val length = run { + var len = when (random.nextInt(10)) { + in 0 .. 2 -> 1 + random.nextInt(3) + in 3 .. 4 -> random.nextInt(4, 11) + in 5 .. 6 -> random.nextInt(11, 100) + 7 -> random.nextInt(100, 1000) + 8 -> random.nextInt(1000, 10000) + 9 -> random.nextInt(10000, 100000) + else -> throw IllegalStateException() + } + len = minOf(v.originalLength - pos, len) + val deleted = deletedIntervals.subMap(pos, true, pos + len, false).firstEntry() + // for example, deleted interval: 3 ..< 10 + // pos = 1, len = 9 (1 ..< 10) + // -> new range = 1 ..< 3, len = 2 + if (deleted != null && !((deleted.key until deleted.key + deleted.value) intersect (pos until pos + len)).isEmpty()) { + len = deleted.key - pos + } + len + } + deletedIntervals[pos] = length + v.delete(pos until pos + length) +// println("new len = ${v.bigTextImpl.length}") + verifyBigTextImplAgainstTestString(testString = v.stringImpl.buildString(), bigTextImpl = tt) + } + } } From 4618bac898aa73d9608d0461db6c5e2e1e0b5f71 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 15 Sep 2024 22:57:04 +0800 Subject: [PATCH 081/195] add BigTextTransformerImpl replacement test cases --- .../transform/BigTextTransformerLayoutTest.kt | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt index 55c2da59..ee3aff70 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -409,4 +409,123 @@ class BigTextTransformerLayoutTest { verifyBigTextImplAgainstTestString(testString = v.stringImpl.buildString(), bigTextImpl = tt) } } + + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun replaces(chunkSize: Int) { + val testString = "1234567890<234567890() + + repeat(1000) { + // create a random interval that does not overlap with previous ones + val pos = run { + var p: Int + do { + var isPositionUsed = false + p = random.nextInt(0, v.originalLength) + val deleted = replacedIntervals.subMap(-1, true, p, true).lastEntry() + if (deleted != null && deleted.key + deleted.value >= p) { + isPositionUsed = true + } + } while (isPositionUsed) + p + } + val length = run { + var len = when (random.nextInt(10)) { + in 0 .. 2 -> 1 + random.nextInt(3) + in 3 .. 4 -> random.nextInt(4, 11) + in 5 .. 6 -> random.nextInt(11, 100) + 7 -> random.nextInt(100, 1000) + 8 -> random.nextInt(1000, 10000) + 9 -> random.nextInt(10000, 100000) + else -> throw IllegalStateException() + } + len = minOf(v.originalLength - pos, len) + val deleted = replacedIntervals.subMap(pos, true, pos + len, false).firstEntry() + // for example, deleted interval: 3 ..< 10 + // pos = 1, len = 9 (1 ..< 10) + // -> new range = 1 ..< 3, len = 2 + if (deleted != null && !((deleted.key until deleted.key + deleted.value) intersect (pos until pos + len)).isEmpty()) { + len = deleted.key - pos + } + assert(len >= 0) + len + } + val newLen = when (random.nextInt(10)) { + in 0 .. 2 -> random.nextInt(4) + in 3 .. 4 -> random.nextInt(4, 11) + in 5 .. 6 -> random.nextInt(11, 100) + 7 -> random.nextInt(100, 1000) + 8 -> random.nextInt(1000, 7000) + 9 -> random.nextInt(7000, 30000) + else -> throw IllegalStateException() + } + replacedIntervals[pos] = length + v.replace(pos until pos + length, randomString(newLen, isAddNewLine = true)) + verifyBigTextImplAgainstTestString(testString = v.stringImpl.buildString(), bigTextImpl = tt) + } + } } From 82a7f2ea0944a759d576f1942cb87b80b6ddee92 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 16 Sep 2024 22:15:05 +0800 Subject: [PATCH 082/195] update BigTextTransformerImpl to be able to delete and replace intervals overlapping with existing transformations --- .../hellohttp/extension/RangeExtension.kt | 7 ++ .../ux/bigtext/BigTextTransformerImpl.kt | 104 ++++++++++++++++++ .../transform/BigTextTransformerImplTest.kt | 98 +++++++++++++++++ .../transform/BigTextTransformerLayoutTest.kt | 45 ++++++++ 4 files changed, 254 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt index 2de54f67..6ba6a3a1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt @@ -13,5 +13,12 @@ infix fun IntRange.intersect(other: IntRange): IntRange { return from .. to } +fun IntRange.toNonEmptyRange(): IntRange { + if (length <= 0) { + return start .. start + } + return this +} + val IntRange.length: Int get() = this.endInclusive - this.start + 1 diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index c7514c4f..cfa32dd6 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -4,7 +4,9 @@ import co.touchlab.kermit.LogWriter import co.touchlab.kermit.Logger import co.touchlab.kermit.MutableLoggerConfig import co.touchlab.kermit.Severity +import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.length +import com.sunnychung.application.multiplatform.hellohttp.extension.toNonEmptyRange import com.sunnychung.application.multiplatform.hellohttp.util.JvmLogger import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree @@ -203,7 +205,109 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch return - originalRange.length } + /** + * Use cases: + * 1. Delete all transform operations within a range + * 2. Delete some transform operations that fulfills the filter within a range + * 3. Replace all transformed inserts within a range with a new insert + */ + fun deleteTransformIf(originalRange: IntRange, filter: (BigTextTransformNodeValue) -> Boolean = { it.currentTransformedLength > 0 }): Int { + logT.d { "deleteTransformIf($originalRange)" } + require(originalRange.start <= originalRange.endInclusive + 1) { "start should be <= endExclusive" } + require(0 <= originalRange.start) { "Invalid start" } + require(originalRange.endInclusive + 1 <= originalLength) { "endExclusive is out of bound" } + + if (originalRange.start == originalRange.endInclusive + 1) { + return 0 + } + + val startNode = tree.findNodeByCharIndex(originalRange.start)!! + val endNode = tree.findNodeByCharIndex(originalRange.endInclusive + 1)!! + val renderStartPos = findRenderPositionStart(startNode) + val renderEndPos = findRenderPositionStart(endNode) + endNode.value.currentRenderLength + + var node: RedBlackTree.Node? = endNode + var nodeRange = charIndexRangeOfNode(node!!) + val newNodesInDescendingOrder = mutableListOf() + while (node?.isNotNil() == true && (originalRange.start <= nodeRange.endInclusive || originalRange.start <= nodeRange.start)) { + val prev = tree.prevNode(node) + logT.d { "DTI nodeRange=$nodeRange, o=${node!!.value.bufferOwnership.name.first()}, int=${!(originalRange.toNonEmptyRange() intersect nodeRange.toNonEmptyRange()).isEmpty()}, f=${filter(node!!.value as BigTextTransformNodeValue)}" } + if (!(originalRange.toNonEmptyRange() intersect nodeRange.toNonEmptyRange()).isEmpty() + && node.value.bufferOwnership == BufferOwnership.Owned + && filter(node.value as BigTextTransformNodeValue) + ) { + if (originalRange.endInclusive in nodeRange.start..nodeRange.last - 1) { + // need to split + val splitAtIndex = originalRange.endInclusive + 1 - nodeRange.start + logT.d { "T Split E at $splitAtIndex" } + newNodesInDescendingOrder += createNodeValue().apply { // the second part of the existing string + bufferIndex = node!!.value.bufferIndex + bufferOffsetStart = node!!.value.bufferOffsetStart + splitAtIndex + bufferOffsetEndExclusive = node!!.value.bufferOffsetEndExclusive + buffer = node!!.value.buffer + bufferOwnership = node!!.value.bufferOwnership + + leftStringLength = 0 + + this as BigTextTransformNodeValue + val nv = node!!.value as BigTextTransformNodeValue + transformedBufferStart = nv.transformedBufferStart + transformedBufferEndExclusive = nv.transformedBufferEndExclusive + } + } + if (originalRange.start in nodeRange.start + 1..nodeRange.last) { + // need to split + val splitAtIndex = originalRange.start - nodeRange.start + logT.d { "T Split S at $splitAtIndex" } + newNodesInDescendingOrder += createNodeValue().apply { // the first part of the existing string + bufferIndex = node!!.value.bufferIndex + bufferOffsetStart = node!!.value.bufferOffsetStart + bufferOffsetEndExclusive = node!!.value.bufferOffsetStart + splitAtIndex + buffer = node!!.value.buffer + bufferOwnership = node!!.value.bufferOwnership + + leftStringLength = 0 + + this as BigTextTransformNodeValue + val nv = node!!.value as BigTextTransformNodeValue + transformedBufferStart = nv.transformedBufferStart + transformedBufferEndExclusive = nv.transformedBufferEndExclusive + } + } + logT.d { "T Delete node ${node!!.value.debugKey()} at ${nodeRange.start} .. ${nodeRange.last}" } + if (nodeRange.start == 2083112) { + isD = true + } + tree.delete(node) + logT.v { inspect("T After delete " + node?.value?.debugKey()) } + } + node = prev +// nodeRange = nodeRange.start - chunkSize .. nodeRange.last - chunkSize + if (node != null) { + nodeRange = charIndexRangeOfNode(node) // TODO optimize by calculation instead of querying + logT.d { "new range = $nodeRange" } + } + } + + newNodesInDescendingOrder.asReversed().forEach { + if (node != null) { + node = tree.insertRight(node!!, it) + } else if (!tree.isEmpty) { // no previous node, so insert at leftmost of the tree + val leftmost = tree.leftmost(tree.getRoot()) + node = tree.insertLeft(leftmost, it) + } else { + node = tree.insertValue(it) + } + } + + layout(maxOf(0, renderStartPos - 1), minOf(length, renderEndPos)) +// tree.visitInPostOrder { recomputeAggregatedValues(it) } // + logT.d { inspect("after deleteTransformIf $originalRange") } + return - originalRange.length + } + fun transformReplace(originalRange: IntRange, newText: String) { + deleteTransformIf(originalRange) transformDelete(originalRange) transformInsert(originalRange.start, newText) } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt index a44c7588..26c3f999 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt @@ -797,6 +797,104 @@ class BigTextTransformerImplTest { transformed.printDebug() } + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun deleteOverlap(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("12345678901234567890123456789012345678901234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.delete(15 .. 33) + transformed.delete(12 .. 37) + "123456789012901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.delete(12 .. 52) + "123456789012456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.delete(9 .. 52) + "123456789456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.delete(4 .. 78) + "12340".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.delete(14 .. 66) // no effect + "12340".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.delete(50 .. 68) // no effect + "12340".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.delete(0 .. 79) + "".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + } + + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun replaceOverlap(chunkSize: Int) { + val original = BigTextImpl(chunkSize = chunkSize) + original.append("12345678901234567890123456789012345678901234567890123456789012345678901234567890") + val transformed = BigTextTransformerImpl(original) + transformed.replace(15 .. 33, "AAA") + transformed.replace(12 .. 37, "BB") + "123456789012BB901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } +// transformed.replace(21 .. 34, "CCCCCC") // TODO prevent this operation to succeed +// "123456789012BB901234567890123456789012345678901234567890".let { expected -> +// assertEquals(expected, transformed.buildString()) +// assertAllSubstring(expected, transformed) +// } + transformed.replace(11 .. 11, "www") + "12345678901wwwBB901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.replace(12 .. 52, "DDDD") + "12345678901wwwDDDD456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.replace(9 .. 52, "eeeee") + "123456789eeeee456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.replace(4 .. 78, ".") + "1234.0".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.replace(0 .. 79, "GG") + "GG".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.replace(0 .. 79, "hihi") + "hihi".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + transformed.replace(0 .. 79, "") + "".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + } + @BeforeEach fun beforeEach() { isD = false diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt index ee3aff70..492f0745 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -528,4 +528,49 @@ class BigTextTransformerLayoutTest { verifyBigTextImplAgainstTestString(testString = v.stringImpl.buildString(), bigTextImpl = tt) } } + + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun deleteAndReplaceOverlapped(chunkSize: Int) { + val initial = "1234567890223456789032345678904234567890_234567890623456789072345678908234567890\n" + val t = BigTextImpl(chunkSize = chunkSize).apply { + append(initial) + } + val tt = BigTextTransformerImpl(t).apply { + setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + setContentWidth(16f * 10) + } + verifyBigTextImplAgainstTestString(testString = initial, bigTextImpl = tt) + tt.delete(29 .. 33) + verifyBigTextImplAgainstTestString(testString = initial.replaceRange(29 .. 33, ""), bigTextImpl = tt) + tt.delete(32 .. 38) + verifyBigTextImplAgainstTestString(testString = initial.replaceRange(29 .. 38, ""), bigTextImpl = tt) + tt.replace(55 .. 72, "ab\nc\n") + verifyBigTextImplAgainstTestString( + testString = initial + .replaceRange(55 .. 72, "ab\nc\n") + .replaceRange(29 .. 38, "") + , bigTextImpl = tt + ) + tt.replace(43 .. 60, "def") + verifyBigTextImplAgainstTestString( + testString = initial + .replaceRange(43 .. 72, "def") + .replaceRange(29 .. 38, "") + , bigTextImpl = tt + ) + tt.delete(42 .. 43) + verifyBigTextImplAgainstTestString( + testString = initial + .replaceRange(42 .. 72, "") + .replaceRange(29 .. 38, "") + , bigTextImpl = tt + ) + tt.delete(38 .. 43) + verifyBigTextImplAgainstTestString( + testString = initial + .replaceRange(29 .. 72, "") + , bigTextImpl = tt + ) + } } From 9571311cd9801092dc37a70f63d1d293c750eacb Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 16 Sep 2024 22:33:09 +0800 Subject: [PATCH 083/195] fix some test cases were disabled --- .../test/bigtext/transform/BigTextTransformerLayoutTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt index 492f0745..392976a2 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -22,7 +22,7 @@ class BigTextTransformerLayoutTest { @ParameterizedTest @ValueSource(ints = [65536, 64, 16]) - fun noTransformation(chunkSize: Int) { if (chunkSize != 16) return + fun noTransformation(chunkSize: Int) { val testString = "1234567890<234567890 Date: Sat, 21 Sep 2024 22:58:14 +0800 Subject: [PATCH 084/195] add offset calculator to BigTextTransformerImpl, and update transformReplace to accept incremental or block offset mapping (default incremental) --- .../ux/bigtext/BigTextTransformNodeValue.kt | 4 + .../bigtext/BigTextTransformOffsetMapping.kt | 10 + .../ux/bigtext/BigTextTransformerImpl.kt | 198 ++++++++++++++++- .../hellohttp/ux/bigtext/LengthTree.kt | 29 +-- .../test/bigtext/BigTextVerifyImpl.kt | 148 ++++++++++++- .../BigTextTransformPositionCalculatorTest.kt | 200 ++++++++++++++++++ 6 files changed, 569 insertions(+), 20 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformOffsetMapping.kt create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt index ee8f353d..806b3230 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt @@ -17,6 +17,9 @@ class BigTextTransformNodeValue : BigTextNodeValue() { var transformedBufferStart: Int = -1 var transformedBufferEndExclusive: Int = -1 + var transformOffsetMapping: BigTextTransformOffsetMapping = BigTextTransformOffsetMapping.WholeBlock + var incrementalTransformOffsetMappingLength = 0 + override val renderBufferStart: Int get() = if (bufferOwnership == BufferOwnership.Delegated) { bufferOffsetStart @@ -56,6 +59,7 @@ class BigTextTransformNodeValue : BigTextNodeValue() { if (renderBufferStart in 0 until renderBufferEndExclusive) { append(" '${buffer.subSequence(renderBufferStart, renderBufferEndExclusive)}'") } + append(" M $incrementalTransformOffsetMappingLength") append(" row $leftNumOfRowBreaks/$rowBreakOffsets lw $lastRowWidth") } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformOffsetMapping.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformOffsetMapping.kt new file mode 100644 index 00000000..618e5817 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformOffsetMapping.kt @@ -0,0 +1,10 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +enum class BigTextTransformOffsetMapping { + WholeBlock, + + /** + * Only applicable for replacements + */ + Incremental, +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index cfa32dd6..8983de7e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -15,7 +15,9 @@ val logT = Logger(object : MutableLoggerConfig { override var minSeverity: Severity = Severity.Info }, tag = "BigText.Transform") -class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(chunkSize = delegate.chunkSize) { +class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(chunkSize = delegate.chunkSize) { + + private var hasReachedExtensiveSearch: Boolean = false override val tree: LengthTree = LengthTree( object : RedBlackTreeComputations { @@ -110,7 +112,7 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch } } - private fun transformInsertChunkAtPosition(position: Int, chunkedString: String) { + private fun transformInsertChunkAtPosition(position: Int, chunkedString: String, offsetMapping: BigTextTransformOffsetMapping, incrementalTransformOffsetMappingLength: Int) { logT.d { "transformInsertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) var buffer = if (buffers.isNotEmpty()) { @@ -129,6 +131,8 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch bufferOffsetEndExclusive = -1 transformedBufferStart = range.start transformedBufferEndExclusive = range.endInclusive + 1 + transformOffsetMapping = offsetMapping + this.incrementalTransformOffsetMappingLength = incrementalTransformOffsetMappingLength this.buffer = buffer this.bufferOwnership = BufferOwnership.Owned @@ -137,6 +141,10 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch } fun transformInsert(pos: Int, text: String): Int { + return transformInsert(pos, text, BigTextTransformOffsetMapping.WholeBlock, 0) + } + + private fun transformInsert(pos: Int, text: String, offsetMapping: BigTextTransformOffsetMapping, incrementalTransformOffsetMappingLength: Int): Int { logT.d { "transformInsert($pos, \"$text\")" } require(pos in 0 .. originalLength) { "Out of bound. pos = $pos, originalLength = $originalLength" } @@ -156,7 +164,8 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch val available = chunkSize - last val append = minOf(available, start) start -= append - transformInsertChunkAtPosition(pos, text.substring(start until start + append)) + val incrementalOffsetLength = maxOf(0, minOf(append, incrementalTransformOffsetMappingLength - start)) + transformInsertChunkAtPosition(pos, text.substring(start until start + append), offsetMapping, incrementalOffsetLength) last = buffers.last().length } val renderPositionStart = findRenderPositionStart(tree.findNodeByCharIndex(pos)!!) @@ -306,10 +315,15 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch return - originalRange.length } - fun transformReplace(originalRange: IntRange, newText: String) { + fun transformReplace(originalRange: IntRange, newText: String, offsetMapping: BigTextTransformOffsetMapping = BigTextTransformOffsetMapping.Incremental) { deleteTransformIf(originalRange) transformDelete(originalRange) - transformInsert(originalRange.start, newText) + val incrementalTransformOffsetMappingLength = if (offsetMapping == BigTextTransformOffsetMapping.Incremental) { + minOf(originalRange.length, newText.length) + } else { + 0 + } + transformInsert(originalRange.start, newText, offsetMapping, incrementalTransformOffsetMappingLength) } override fun computeCurrentNodeProperties(nodeValue: BigTextNodeValue, left: RedBlackTree.Node?) = with (nodeValue) { @@ -322,6 +336,178 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch leftOverallLength = left?.overallLength() ?: 0 } + internal fun resetDebugFlags() { + hasReachedExtensiveSearch = false + } + + internal fun hasReachedExtensiveSearch() = hasReachedExtensiveSearch + + fun findTransformedPositionByOriginalPosition(originalPosition: Int): Int { + // TODO this function can be further optimized + if (originalPosition == originalLength) { // the retrieved 'node' is incorrect for the last position + return length + } + val node = tree.findNodeByCharIndex(originalPosition, isIncludeMarkerNodes = false) + ?: throw IndexOutOfBoundsException("Node at original position $originalPosition not found") + val nodeStart = findPositionStart(node) + val indexFromNodeStart = originalPosition - nodeStart + val firstMarkerNode = tree.findNodeByCharIndex(nodeStart, isIncludeMarkerNodes = true) + ?: throw IndexOutOfBoundsException("Node at original position $nodeStart not found") + val transformedStart = findRenderPositionStart(node) + if (firstMarkerNode === node) { + return transformedStart + if (node.value.bufferOwnership == BufferOwnership.Delegated) { + indexFromNodeStart + } else if ((node.value as? BigTextTransformNodeValue)?.transformOffsetMapping == BigTextTransformOffsetMapping.Incremental) { + indexFromNodeStart - (node.value as BigTextTransformNodeValue).incrementalTransformOffsetMappingLength + } else { + node.value.currentRenderLength + } + } + +// val nodeStartBeforeMarkers = findPositionStart(node) + hasReachedExtensiveSearch = true + logT.d { "hasReachedExtensiveSearch" } + +// val transformedStartBeforeMarkers = findRenderPositionStart(firstMarkerNode) + +// var itOffset = 0 +// var compulsoryOffset = 0 +// var isNotYetFulfillIncrementalOffset = true +// var n = firstMarkerNode as RedBlackTree.Node +// while (true) { +// if (/*isNotYetFulfillIncrementalOffset &&*/ n.value.currentTransformedLength > 0 && n !== node) { +// when (n.value.transformOffsetMapping) { +// BigTextTransformOffsetMapping.WholeBlock -> { +// compulsoryOffset += n.value.currentTransformedLength +// } +// BigTextTransformOffsetMapping.Incremental -> { +//// if (indexFromNodeStart in itOffset until itOffset + n.value.currentTransformedLength) { +//// itOffset = indexFromNodeStart +//// isNotYetFulfillIncrementalOffset = false +//// } +//// itOffset += n.value.currentTransformedLength +// } +// } +// } else if (n.value.bufferOwnership == BufferOwnership.Owned && n.value.currentTransformedLength < 0) { +// compulsoryOffset += n.value.currentTransformedLength +// } +// +// if (n === node) { +// break +// } +// +// n = tree.nextNode(n as RedBlackTree.Node) as RedBlackTree.Node +// } + +// return transformedStartBeforeMarkers + indexFromNodeStart + compulsoryOffset +// return transformedStart + indexFromNodeStart + compulsoryOffset + + var incrementalTransformLength = 0 + var incrementalTransformLimit = 0 + var n = firstMarkerNode as RedBlackTree.Node + while (true) { + if (n.value.transformOffsetMapping == BigTextTransformOffsetMapping.Incremental && n.value.currentTransformedLength > 0) { + incrementalTransformLength += n.value.currentTransformedLength + incrementalTransformLimit += n.value.incrementalTransformOffsetMappingLength + } + if (n === node) { + break + } + n = tree.nextNode(n as RedBlackTree.Node) as RedBlackTree.Node + } + if (incrementalTransformLimit > 0) { // incremental replacement + return transformedStart - maxOf(0, incrementalTransformLength - minOf(incrementalTransformLimit, indexFromNodeStart)) + } +// return transformedStart + indexFromNodeStart + return transformedStart + if (node.value.bufferOwnership == BufferOwnership.Delegated) { + indexFromNodeStart + } else { + node.value.currentRenderLength + } + } + + fun findOriginalPositionByTransformedPosition(transformedPosition: Int): Int { + // TODO this function can be further optimized + if (transformedPosition == length) { + return originalLength + } + val node = tree.findNodeByRenderCharIndex(transformedPosition) + ?: throw IndexOutOfBoundsException("Node at transformed position $transformedPosition not found") + val transformedStart = findRenderPositionStart(node) + val indexFromNodeStart = transformedPosition - transformedStart + val nodeStart = findPositionStart(node) + val nv = node.value as BigTextTransformNodeValue + + val firstMarkerNode = tree.findNodeByCharIndex(nodeStart, isIncludeMarkerNodes = true) + ?: throw IndexOutOfBoundsException("Node at original position $nodeStart not found") + + if (firstMarkerNode === node) { + return nodeStart + if (nv.bufferOwnership == BufferOwnership.Delegated) { + indexFromNodeStart + } else if (nv.currentTransformedLength < 0) { // deletion + -nv.currentTransformedLength + } else if (nv.transformOffsetMapping == BigTextTransformOffsetMapping.Incremental) { +// val incrementalLength = minOf(nv.currentTransformedLength, indexFromNodeStart) + val incrementalLength = minOf(nv.incrementalTransformOffsetMappingLength, indexFromNodeStart) + incrementalLength + } else { + 0 + } + } + + hasReachedExtensiveSearch = true + logT.d { "hasReachedExtensiveSearch" } + +// val nodeStartBeforeMarkers = findRenderPositionStart(firstMarkerNode) +// +// var itOffset = 0 +// var n = firstMarkerNode as RedBlackTree.Node +// while (n !== node) { +// if (n.value.currentTransformedLength > 0) { +// when (n.value.transformOffsetMapping) { +// BigTextTransformOffsetMapping.WholeBlock -> { +// } +// BigTextTransformOffsetMapping.Incremental -> { +// if (indexFromNodeStart in itOffset until itOffset + n.value.currentTransformedLength) { +// itOffset = indexFromNodeStart +// break +// } +// } +// } +// itOffset += n.value.currentTransformedLength +// } +// +// n = tree.nextNode(n as RedBlackTree.Node) as RedBlackTree.Node +// } +// +// return nodeStartBeforeMarkers + itOffset + + var n = firstMarkerNode as RedBlackTree.Node + var incrementalTransformLength = 0 + var incrementalTransformLimit = 0 + while (true) { + if (n.value.transformOffsetMapping == BigTextTransformOffsetMapping.Incremental && n.value.currentTransformedLength > 0) { + if (n !== node) { + incrementalTransformLength += n.value.currentTransformedLength + } + incrementalTransformLimit += n.value.incrementalTransformOffsetMappingLength + } + if (n === node) { + break + } + n = tree.nextNode(n as RedBlackTree.Node) as RedBlackTree.Node + } + if (incrementalTransformLimit > 0) { // incremental replacement +// return nodeStart - maxOf(0, incrementalTransformLength - indexFromNodeStart) +// return nodeStart - incrementalTransformLength + minOf(incrementalTransformLimit, indexFromNodeStart) + + val transformedStartBeforeMarkers = findRenderPositionStart(firstMarkerNode) + val indexFromNodeStart2 = transformedPosition - transformedStartBeforeMarkers + return nodeStart + minOf(incrementalTransformLimit, indexFromNodeStart2) + } + return nodeStart + minOf(node.value.bufferLength, indexFromNodeStart) + } + override fun insertAt(pos: Int, text: String): Int = transformInsert(pos, text) override fun append(text: String): Int = transformInsertAtOriginalEnd(text) @@ -329,6 +515,8 @@ class BigTextTransformerImpl(private val delegate: BigTextImpl) : BigTextImpl(ch override fun delete(start: Int, endExclusive: Int): Int = transformDelete(start until endExclusive) override fun replace(range: IntRange, text: String) = transformReplace(range, text) + + fun replace(range: IntRange, text: String, offsetMapping: BigTextTransformOffsetMapping) = transformReplace(range, text, offsetMapping) } fun RedBlackTree.Node.transformedOffset(): Int = diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt index 3d498a35..415d699e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthTree.kt @@ -5,7 +5,7 @@ import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree open class LengthTree(computations: RedBlackTreeComputations) : RedBlackTree2<@UnsafeVariance V>(computations) where V : LengthNodeValue, V : Comparable<@UnsafeVariance V>, V : DebuggableNode { - fun findNodeByCharIndex(index: Int, isIncludeMarkerNodes: Boolean = true): RedBlackTree.Node? { + fun findNodeByCharIndex(index: Int, isIncludeMarkerNodes: Boolean = true, isExact: Boolean = false): RedBlackTree.Node? { var find = index var lastMatch: RedBlackTree.Node? = null return findNode { @@ -13,8 +13,10 @@ open class LengthTree(computations: RedBlackTreeComputations) : RedBla in Int.MIN_VALUE until it.value.leftStringLength -> -1 it.value.leftStringLength, in it.value.leftStringLength until it.value.leftStringLength + it.value.bufferLength -> { lastMatch = it - if (isIncludeMarkerNodes && find == it.value.leftStringLength && it.left.isNotNil()) { + if (!isExact && isIncludeMarkerNodes && find == it.value.leftStringLength && it.left.isNotNil()) { -1 + } else if (!isExact && !isIncludeMarkerNodes && find == it.value.leftStringLength + it.value.bufferLength && it.right.isNotNil()) { + 1 } else { 0 } @@ -25,18 +27,15 @@ open class LengthTree(computations: RedBlackTreeComputations) : RedBla } else { 0 } - ).also { compareResult -> - val isTurnRight = compareResult > 0 - if (isTurnRight) { - find -= it.value.leftStringLength + it.value.bufferLength - } - } + ) else -> throw IllegalStateException("what is find? $find") + }.also { compareResult -> + val isTurnRight = compareResult > 0 + if (isTurnRight) { + find -= it.value.leftStringLength + it.value.bufferLength + } } }?.takeIf { - if (!isIncludeMarkerNodes) { - return@takeIf true - } val nodePosStart = findPositionStart(it) nodePosStart <= index && ( index < nodePosStart + it.value.bufferLength @@ -53,7 +52,13 @@ open class LengthTree(computations: RedBlackTreeComputations) : RedBla when (find) { in Int.MIN_VALUE until it.value.leftRenderLength -> -1 in it.value.leftRenderLength until it.value.leftRenderLength + it.value.currentRenderLength -> 0 - in it.value.leftRenderLength + it.value.currentRenderLength until Int.MAX_VALUE -> 1.also { compareResult -> + in it.value.leftRenderLength + it.value.currentRenderLength until Int.MAX_VALUE -> ( + if (it.right.isNotNil()) { + 1 + } else { + 0 + } + ).also { compareResult -> val isTurnRight = compareResult > 0 if (isTurnRight) { find -= it.value.leftRenderLength + it.value.currentRenderLength diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index 0df5f9b1..19fb6992 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -1,17 +1,21 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext +import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextNodeValue +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformOffsetMapping import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.InefficientBigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.LengthTree import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.TextBuffer import java.util.TreeMap +import kotlin.test.assertEquals internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { val bigTextImpl: BigTextImpl = bigTextImpl val stringImpl = InefficientBigText("") + var isDebug = true init { this.stringImpl.append(bigTextImpl.buildString()) @@ -27,7 +31,9 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { get() = bigTextImpl.buffers val isTransform = bigTextImpl is BigTextTransformerImpl - val transformOffsetsByPosition = TreeMap() + private val transformOffsetsByPosition = TreeMap() + private val transformOffsetsMappingByPosition = TreeMap() // temporary dirty solution + private val transformOps = mutableListOf() override val length: Int get() { @@ -40,17 +46,19 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { val originalLength: Int get() = length - transformOffsetsByPosition.values.sum() + private data class TransformOp(val originalRange: IntRange, val replaceMapping: BigTextTransformOffsetMapping) + override fun buildString(): String { val r = bigTextImpl.buildString() val tr = stringImpl.buildString() - assert(r == tr) { "fullString expected $tr, actual $r" } + assertEquals(tr, r, "fullString mismatch") return r } override fun substring(start: Int, endExclusive: Int): String { val r = bigTextImpl.substring(start, endExclusive) val tr = stringImpl.substring(start, endExclusive) - assert(r == tr) { "substring expected $tr, actual $r" } + assertEquals(tr, r, "substring mismatch") return r } @@ -60,6 +68,7 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { if (isTransform) { val pos = stringImpl.length transformOffsetsByPosition[pos] = (transformOffsetsByPosition[pos] ?: 0) + text.length + transformOffsetsMappingByPosition[pos] = (transformOffsetsMappingByPosition[pos] ?: 0) + text.length } stringImpl.append(text) verify() @@ -71,6 +80,7 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { val r = bigTextImpl.insertAt(pos, text) if (isTransform) { transformOffsetsByPosition[pos] = (transformOffsetsByPosition[pos] ?: 0) + text.length + transformOffsetsMappingByPosition[pos] = (transformOffsetsMappingByPosition[pos] ?: 0) + text.length } val pos = pos + transformOffsetsByPosition.subMap(0, pos).values.sum().also { println("VerifyImpl pos $pos offset $it") @@ -87,6 +97,9 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { r = bigTextImpl.delete(start, endExclusive) if (isTransform) { transformOffsetsByPosition[start] = (transformOffsetsByPosition[start] ?: 0) - (endExclusive - start) + (start + 1 .. endExclusive).forEach { i -> + transformOffsetsMappingByPosition[i] = (transformOffsetsMappingByPosition[i] ?: 0) - 1 + } } val offset = transformOffsetsByPosition.subMap(0, start).values.sum() stringImpl.delete(offset + start, offset + endExclusive) @@ -96,6 +109,135 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { return r } + override fun replace(range: IntRange, text: String) { + replace(range, text, BigTextTransformOffsetMapping.Incremental) + } + + fun replace(range: IntRange, text: String, offsetMapping: BigTextTransformOffsetMapping) { + println("replace $range -> ${text.length}") + var r: Int = 0 + printDebugIfError { + if (isTransform) { + (bigTextImpl as BigTextTransformerImpl).replace(range, text, offsetMapping) + when (offsetMapping) { + BigTextTransformOffsetMapping.WholeBlock -> { + transformOffsetsByPosition[range.start] = (transformOffsetsByPosition[range.start] ?: 0) - range.length + text.length +// transformOffsetsByPosition[range.start] = (transformOffsetsByPosition[range.start] ?: 0) + text.length + transformOffsetsMappingByPosition[range.start] = (transformOffsetsMappingByPosition[range.start] ?: 0) + text.length +// if (text.length < range.length) { +// (range.start + text.length .. range.endInclusive + 1).forEach { i -> +// transformOffsetsByPosition[i] = (transformOffsetsByPosition[i] ?: 0) - 1 +// } +// } + (range.start + 1 .. range.endInclusive + 1).forEach { i -> +// transformOffsetsByPosition[i] = (transformOffsetsByPosition[i] ?: 0) - 1 + transformOffsetsMappingByPosition[i] = (transformOffsetsMappingByPosition[i] ?: 0) - 1 + } + } + BigTextTransformOffsetMapping.Incremental -> { +// (1 .. minOf(text.length, range.length)).forEach { i -> +// transformOffsetsByPosition[i] = (transformOffsetsByPosition[i] ?: 0) + 1 +// } + if (text.length < range.length) { + (range.start + text.length + 1 .. range.endInclusive + 1).forEach { i -> + transformOffsetsByPosition[i] = (transformOffsetsByPosition[i] ?: 0) - 1 + transformOffsetsMappingByPosition[i] = (transformOffsetsMappingByPosition[i] ?: 0) - 1 + } + } else if (text.length > range.length) { + transformOffsetsByPosition[range.endInclusive] = + (transformOffsetsByPosition[range.endInclusive] ?: 0) + text.length - range.length + transformOffsetsMappingByPosition[range.endInclusive + 1] = + (transformOffsetsMappingByPosition[range.endInclusive + 1] ?: 0) + text.length - range.length + } + } + } + } else { + bigTextImpl.replace(range, text) + } + val offset = transformOffsetsByPosition.subMap(0, range.start).values.sum() + stringImpl.replace(range.start + offset .. range.endInclusive + offset, text) + + transformOps += TransformOp(range, offsetMapping) + } + println("new len = ${bigTextImpl.length}") + verify() +// return r + } + + fun verifyPositionCalculation() { + val t = bigTextImpl as BigTextTransformerImpl + val originalLength = originalLength + if (isDebug) { + println("Original: ${t.delegate.buildString()}") + println("Transformed: ${t.buildString()}") + } + val transformedLength = t.length + (0 .. originalLength).forEach { i -> + val expected = findTransformedPositionByOriginalPosition(i) + val actual = t.findTransformedPositionByOriginalPosition(i) + if (isDebug) { + println("Original pos $i to transformed = $actual") + } + assertEquals(expected, actual, "Original pos $i to transformed, expected = $expected, but actual = $actual") + } + (0 .. transformedLength).forEach { i -> + val expected = findOriginalPositionByTransformedPosition(i) + val actual = t.findOriginalPositionByTransformedPosition(i) + if (isDebug) { + println("Transformed pos $i to original = $actual") + } + assertEquals(expected, actual, "Transformed pos $i to original, expected = $expected, but actual = $actual") + } + } + + fun findTransformedPositionByOriginalPositionRaw(originalPosition: Int): Pair { + val subMap = transformOffsetsMappingByPosition.subMap(0, true, originalPosition, true) +// val offset = subMap.values.sum() + (subMap.lastEntry()?.let { +//// if (it.value < 0 && originalPosition in it.key until it.key - it.value) { +//// - it.value /* subtract the effect brought by sum() first */ + +//// maxOf(it.value, it.key - originalPosition /* incremental offset */) +//// } else { +// null +//// } +// } ?: 0) + val offsets = subMap.values.sum() to (subMap[originalPosition] ?: 0) + return offsets + } + + fun findTransformedPositionByOriginalPosition(originalPosition: Int): Int { + val o = findTransformedPositionByOriginalPositionRaw(originalPosition) + return originalPosition + o.first //+ o.second + } + + fun findOriginalPositionByTransformedPosition(transformedPosition: Int): Int { + var i = 0 + val originalLength = originalLength + var result: Int? = null + var start: Int? = null + while (i <= originalLength) { + val (offsetSum, offsetLast) = findTransformedPositionByOriginalPositionRaw(i) + val mapped = i + offsetSum + if (mapped > transformedPosition) { + if (result != null) { + return transformOps.firstOrNull { it.replaceMapping == BigTextTransformOffsetMapping.WholeBlock && it.originalRange.first == start } + ?.originalRange + ?.endInclusive + ?.let { it + 1 } + ?: result + } + return i + } else if (mapped == transformedPosition) { + result = i + if (start == null) { + start = i + } + } + ++i + } + result?.let { return it } + throw IndexOutOfBoundsException("Transformed position $transformedPosition not found") + } + override fun hashCode(): Int { val r = bigTextImpl.hashCode() val tr = stringImpl.hashCode() diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt new file mode 100644 index 00000000..fd36910e --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt @@ -0,0 +1,200 @@ +package com.sunnychung.application.multiplatform.hellohttp.test.bigtext.transform + +import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.BigTextVerifyImpl +import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.FixedWidthCharMeasurer +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformOffsetMapping +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.MonospaceTextLayouter +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.isD +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class BigTextTransformPositionCalculatorTest { + + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun noTransformation(chunkSize: Int) { + val t = BigTextImpl(chunkSize = chunkSize).apply { + append("1234567890<234567890 Date: Sun, 22 Sep 2024 00:53:08 +0800 Subject: [PATCH 086/195] add more test cases to BigText transform offset calculator tests --- .../test/bigtext/BigTextVerifyImpl.kt | 8 +- .../BigTextTransformPositionCalculatorTest.kt | 122 ++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index 19fb6992..c4b32a76 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -46,7 +46,7 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { val originalLength: Int get() = length - transformOffsetsByPosition.values.sum() - private data class TransformOp(val originalRange: IntRange, val replaceMapping: BigTextTransformOffsetMapping) + private data class TransformOp(val originalRange: IntRange, val offsetMapping: BigTextTransformOffsetMapping) override fun buildString(): String { val r = bigTextImpl.buildString() @@ -86,6 +86,7 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { println("VerifyImpl pos $pos offset $it") } stringImpl.insertAt(pos, text) +// transformOps += TransformOp(pos until pos + text.length, BigTextTransformOffsetMapping.WholeBlock) verify() return r } @@ -104,6 +105,7 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { val offset = transformOffsetsByPosition.subMap(0, start).values.sum() stringImpl.delete(offset + start, offset + endExclusive) } + transformOps += TransformOp(start until endExclusive, BigTextTransformOffsetMapping.WholeBlock) println("new len = ${bigTextImpl.length}") verify() return r @@ -219,10 +221,10 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { val mapped = i + offsetSum if (mapped > transformedPosition) { if (result != null) { - return transformOps.firstOrNull { it.replaceMapping == BigTextTransformOffsetMapping.WholeBlock && it.originalRange.first == start } + return transformOps.firstOrNull { it.offsetMapping == BigTextTransformOffsetMapping.WholeBlock && it.originalRange.first == start } ?.originalRange ?.endInclusive - ?.let { it + 1 } + ?.let { maxOf(it + 1, result!!) } ?: result } return i diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt index f8a5e51a..cf15e87b 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt @@ -90,6 +90,22 @@ class BigTextTransformPositionCalculatorTest { v.verifyPositionCalculation() } + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun transformDeleteWholeText(chunkSize: Int) { + val t = BigTextImpl(chunkSize = chunkSize).apply { + append("1234567890<234567890 Date: Sun, 22 Sep 2024 14:39:58 +0800 Subject: [PATCH 087/195] fix BigText layout query functions should use render (transformed) positions instead of original positions --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 12 +++++------ .../transform/BigTextTransformerLayoutTest.kt | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index bb48c1f3..538901f0 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -130,7 +130,7 @@ open class BigTextImpl : BigText { } } val node = tree.findNodeByCharIndex(position)!! - val startPos = findPositionStart(node) + val startPos = findRenderPositionStart(node) val nv = node.value val rowIndexInThisPartition = nv.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(position - startPos + nv.bufferOffsetStart) + 1 val startRowIndex = findRowStart(node) @@ -151,7 +151,7 @@ open class BigTextImpl : BigText { ?: throw IllegalStateException("Cannot find the node right after ${index - 1} row breaks") ).first val rowStart = findRowStart(node) - val startPos = findPositionStart(node) + val startPos = findRenderPositionStart(node) return startPos + if (index - 1 - rowStart == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { node.value.bufferLength } else if (index > 0) { @@ -183,13 +183,13 @@ open class BigTextImpl : BigText { } else { 0 } - val positionStart = findPositionStart(node) + val positionStart = findRenderPositionStart(node) val rowPositionStart = positionStart + rowOffset - node.value.bufferOffsetStart val lineBreakPosition = rowPositionStart - 1 val lineBreakAtNode = tree.findNodeByCharIndex(lineBreakPosition)!! val lineStart = findLineStart(lineBreakAtNode) - val positionStartOfLineBreakNode = findPositionStart(lineBreakAtNode) + val positionStartOfLineBreakNode = findRenderPositionStart(lineBreakAtNode) val lineBreakOffsetStarts = lineBreakAtNode.value.buffer.lineOffsetStarts val lineBreakMinIndex = lineBreakOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineBreakAtNode.value.bufferOffsetStart) val lineBreakIndex = lineBreakOffsetStarts.binarySearchForMaxIndexOfValueAtMost(lineBreakPosition - positionStartOfLineBreakNode + lineBreakAtNode.value.bufferOffsetStart) @@ -265,7 +265,7 @@ open class BigTextImpl : BigText { } else { 0 } - val lineStartPos = findPositionStart(lineStartNode) + lineOffset + val lineStartPos = findRenderPositionStart(lineStartNode) + lineOffset // val rowBreakOffsetIndex = lineStartNode.findRowBreakIndexOfLineOffset(lineOffset) // val rowBreaksStart = findRowStart(lineStartNode) @@ -278,7 +278,7 @@ open class BigTextImpl : BigText { } throw IndexOutOfBoundsException("pos $rowStartPos is out of bound. length = $length") } - val actualNodeStartPos = findPositionStart(actualNode) + val actualNodeStartPos = findRenderPositionStart(actualNode) val rowBreakOffsetIndex = actualNode.value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(rowStartPos - actualNodeStartPos + actualNode.value.bufferOffsetStart) val rowBreaksStart = findRowStart(actualNode) return rowBreaksStart + rowBreakOffsetIndex + 1 diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt index 392976a2..36dda1b2 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -16,6 +16,7 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import java.util.TreeMap import kotlin.random.Random +import kotlin.test.assertEquals @TestMethodOrder(MethodOrderer.OrderAnnotation::class) class BigTextTransformerLayoutTest { @@ -573,4 +574,23 @@ class BigTextTransformerLayoutTest { , bigTextImpl = tt ) } + + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun findRowPositionStartIndexByRowIndex(chunkSize: Int) { + val initial = "1234567890223456789032345678904234567890_234567890623456789072345678908234567890\n" + val t = BigTextImpl(chunkSize = chunkSize).apply { + append(initial) + } + val tt = BigTextTransformerImpl(t).apply { + setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + setContentWidth(160f * 10) + } + assertEquals(0, tt.findRowPositionStartIndexByRowIndex(0)) + assertEquals(81, tt.findRowPositionStartIndexByRowIndex(1)) + + tt.delete(29 .. 70) + assertEquals(0, tt.findRowPositionStartIndexByRowIndex(0)) + assertEquals(81 - 42, tt.findRowPositionStartIndexByRowIndex(1)) + } } From 447310e54d4ebf6125fdcb8b2893e120d4a24236 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 22 Sep 2024 21:53:52 +0800 Subject: [PATCH 088/195] fix incorrect BigTextImpl#findFirstRowIndexOfLine --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 5 ++++- .../test/bigtext/BigTextImplLayoutTest.kt | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 538901f0..c29d4251 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -279,8 +279,11 @@ open class BigTextImpl : BigText { throw IndexOutOfBoundsException("pos $rowStartPos is out of bound. length = $length") } val actualNodeStartPos = findRenderPositionStart(actualNode) - val rowBreakOffsetIndex = actualNode.value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(rowStartPos - actualNodeStartPos + actualNode.value.bufferOffsetStart) val rowBreaksStart = findRowStart(actualNode) + if (actualNode.value.isEndWithForceRowBreak && rowStartPos - actualNodeStartPos + actualNode.value.bufferOffsetStart >= actualNode.value.bufferOffsetEndExclusive) { + return rowBreaksStart + actualNode.value.rowBreakOffsets.size + 1 + } + val rowBreakOffsetIndex = actualNode.value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(rowStartPos - actualNodeStartPos + actualNode.value.bufferOffsetStart) return rowBreaksStart + rowBreakOffsetIndex + 1 } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt index 87d1cad9..4351e8b8 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt @@ -756,6 +756,20 @@ class BigTextImplLayoutTest { } } + @ParameterizedTest + @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) + fun findFirstRowIndexOfLine(chunkSize: Int) { + listOf(100, 10, 37, 1000, 10000).forEach { softWrapAt -> + val t = BigTextImpl(chunkSize = chunkSize).apply { + append("12345678901234567890123456789012345678901234567890123456789012345678901234567890\n") + setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + setContentWidth(16f * softWrapAt + 1.23f) + } + assertEquals(0, t.findFirstRowIndexOfLine(0)) + assertEquals(Math.ceil(79.0 / softWrapAt).toInt(), t.findFirstRowIndexOfLine(1)) + } + } + @BeforeTest fun beforeEach() { random = Random From 72b34a523cc5c90a38f0493957b6854382bbd951 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 22 Sep 2024 21:59:28 +0800 Subject: [PATCH 089/195] [WIP] update BigMonospaceText to replace VisualTransformation by IncrementalTextTransformation --- .../hellohttp/ux/CodeEditorView.kt | 21 ++- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 167 ++++++++++++------ .../hellohttp/ux/bigtext/BigText.kt | 2 + .../hellohttp/ux/bigtext/BigTextImpl.kt | 37 ++-- .../ux/bigtext/BigTextLayoutResult.kt | 2 +- .../hellohttp/ux/bigtext/BigTextLayoutable.kt | 28 +++ .../ux/bigtext/BigTextTransformed.kt | 12 ++ .../ux/bigtext/BigTextTransformer.kt | 20 +++ .../ux/bigtext/BigTextTransformerImpl.kt | 8 +- .../bigtext/IncrementalTextTransformation.kt | 8 + ...onmentVariableIncrementalTransformation.kt | 68 +++++++ 11 files changed, 290 insertions(+), 83 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformed.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 444a8bc8..a9b10ebc 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -78,6 +78,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.Envi import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.FunctionTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.MultipleVisualTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.SearchHighlightTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableIncrementalTransformation import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -580,6 +581,7 @@ fun CodeEditorView( BigMonospaceTextField( textFieldState = bigTextFieldState.value, visualTransformation = visualTransformationToUse, + textTransformation = remember { EnvironmentVariableIncrementalTransformation() }, // TODO replace this testing transformation fontSize = LocalFont.current.codeEditorBodyFontSize, // textStyle = LocalTextStyle.current.copy( // fontFamily = FontFamily.Monospace, @@ -843,22 +845,25 @@ fun BigTextLineNumbersView( ) val collapsedLinesState = CollapsedLinesState(collapsableLines = collapsableLines, collapsedLines = collapsedLines) + // Note that layoutResult.text != bigText + val layoutText = layoutResult?.text as? BigTextImpl + var prevHasLayouted by remember { mutableStateOf(false) } - prevHasLayouted = bigText.hasLayouted + prevHasLayouted = layoutText?.hasLayouted ?: false prevHasLayouted val viewportTop = scrollState.value - val firstLine = bigText.findLineIndexByRowIndex(bigTextViewState.firstVisibleRow) ?: 0 - val lastLine = (bigText.findLineIndexByRowIndex(bigTextViewState.lastVisibleRow) ?: -100) + 1 - log.d { "firstVisibleRow = ${bigTextViewState.firstVisibleRow} (L $firstLine); lastVisibleRow = ${bigTextViewState.lastVisibleRow} (L $lastLine); totalLines = ${bigText.numOfLines}" } + val firstLine = layoutText?.findLineIndexByRowIndex(bigTextViewState.firstVisibleRow) ?: 0 + val lastLine = (layoutText?.findLineIndexByRowIndex(bigTextViewState.lastVisibleRow) ?: -100) + 1 + log.d { "firstVisibleRow = ${bigTextViewState.firstVisibleRow} (L $firstLine); lastVisibleRow = ${bigTextViewState.lastVisibleRow} (L $lastLine); totalLines = ${layoutText?.numOfLines}" } val rowHeight = layoutResult?.rowHeight ?: 0f CoreLineNumbersView( firstLine = firstLine, - lastLine = minOf(lastLine, bigText.numOfLines ?: 1), - totalLines = bigText.numOfLines ?: 1, + lastLine = minOf(lastLine, layoutText?.numOfLines ?: 1), + totalLines = layoutText?.numOfLines ?: 1, lineHeight = (rowHeight).toDp(), // getLineOffset = { (textLayout!!.getLineTop(it) - viewportTop).toDp() }, - getLineOffset = { ( bigText.findFirstRowIndexOfLine(it) * rowHeight - viewportTop).toDp() }, + getLineOffset = { ( (layoutText?.findFirstRowIndexOfLine(it) ?: 0) * rowHeight - viewportTop).toDp() }, textStyle = textStyle, collapsedLinesState = collapsedLinesState, onCollapseLine = onCollapseLine, @@ -871,7 +876,7 @@ fun BigTextLineNumbersView( private fun CoreLineNumbersView( modifier: Modifier = Modifier, firstLine: Int, - lastLine: Int, + /* exclusive */ lastLine: Int, totalLines: Int, lineHeight: Dp, getLineOffset: (Int) -> Dp, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 90dbfa5a..8620b50b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -99,6 +99,7 @@ fun BigMonospaceText( color: Color = LocalColor.current.text, isSelectable: Boolean = false, visualTransformation: VisualTransformation, + textTransformation: IncrementalTextTransformation<*>? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, @@ -112,6 +113,7 @@ fun BigMonospaceText( isEditable = false, onTextChange = {}, visualTransformation = visualTransformation, + textTransformation = textTransformation, scrollState = scrollState, viewState = viewState, onTextLayout = onTextLayout, @@ -126,6 +128,7 @@ fun BigMonospaceText( color: Color = LocalColor.current.text, isSelectable: Boolean = false, visualTransformation: VisualTransformation, + textTransformation: IncrementalTextTransformation<*>? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, @@ -139,6 +142,7 @@ fun BigMonospaceText( isEditable = false, onTextChange = {}, visualTransformation = visualTransformation, + textTransformation = textTransformation, scrollState = scrollState, viewState = viewState, onTextLayout = onTextLayout, @@ -152,6 +156,7 @@ fun BigMonospaceTextField( fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, visualTransformation: VisualTransformation, + textTransformation: IncrementalTextTransformation<*>? = null, scrollState: ScrollState = rememberScrollState(), onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) { @@ -165,6 +170,7 @@ fun BigMonospaceTextField( textFieldState.emitValueChange(it.changeId) }, visualTransformation = visualTransformation, + textTransformation = textTransformation, scrollState = scrollState, viewState = textFieldState.viewState, onTextLayout = onTextLayout @@ -180,6 +186,7 @@ fun BigMonospaceTextField( color: Color = LocalColor.current.text, onTextChange: (BigTextChangeEvent) -> Unit, visualTransformation: VisualTransformation, + textTransformation: IncrementalTextTransformation<*>? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, @@ -193,6 +200,7 @@ fun BigMonospaceTextField( isEditable = true, onTextChange = onTextChange, visualTransformation = visualTransformation, + textTransformation = textTransformation, scrollState = scrollState, viewState = viewState, onTextLayout = onTextLayout, @@ -210,6 +218,7 @@ private fun CoreBigMonospaceText( isEditable: Boolean = false, onTextChange: (BigTextChangeEvent) -> Unit, visualTransformation: VisualTransformation, + textTransformation: IncrementalTextTransformation<*>? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, @@ -265,11 +274,18 @@ private fun CoreBigMonospaceText( // 0 // } // } + + val transformedText: BigTextTransformed = remember(text, textTransformation) { + log.d { "CoreBigMonospaceText recreate BigTextTransformed" } + BigTextTransformerImpl(text) + } + fun fireOnLayout() { lineHeight = (textLayouter.charMeasurer as ComposeUnicodeCharMeasurer).getRowHeight() onTextLayout?.let { callback -> callback(BigTextSimpleLayoutResult( - text = text, +// text = text, + text = transformedText, // layout is only performed in `transformedText` rowHeight = lineHeight, )) } @@ -277,23 +293,30 @@ private fun CoreBigMonospaceText( if (width > 0) { log.d { "CoreBigMonospaceText set contentWidth = $contentWidth" } - text.onLayoutCallback = { +// text.onLayoutCallback = { +// fireOnLayout() +// } +// text.setLayouter(textLayouter) +// text.setContentWidth(contentWidth) + + transformedText.onLayoutCallback = { fireOnLayout() } - text.setLayouter(textLayouter) - text.setContentWidth(contentWidth) + transformedText.setLayouter(textLayouter) + transformedText.setContentWidth(contentWidth) LaunchedEffect(Unit) { fireOnLayout() } } - val visualTransformationToUse = visualTransformation - val transformedText = rememberLast(text.length, text.hashCode(), visualTransformationToUse) { - visualTransformationToUse.filter(AnnotatedString(text.buildString())).also { - log.v { "transformed text = `$it`" } - } - } +// val visualTransformationToUse = visualTransformation +// val transformedText = rememberLast(text.length, text.hashCode(), visualTransformationToUse) { +// visualTransformationToUse.filter(AnnotatedString(text.buildString())).also { +// log.v { "transformed text = `$it`" } +// } +// } + // val layoutResult = rememberLast(transformedText.text.length, transformedText.hashCode(), textStyle, lineHeight, contentWidth, textLayouter) { // textLayouter.layout( // text = text.fullString(), @@ -308,14 +331,14 @@ private fun CoreBigMonospaceText( // } // val rowStartCharIndices = layoutResult.rowStartCharIndices - rememberLast(height, text.numOfRows, lineHeight) { + rememberLast(height, transformedText.numOfRows, lineHeight) { scrollState::class.declaredMemberProperties.first { it.name == "maxValue" } .apply { (this as KMutableProperty) setter.isAccessible = true val scrollableHeight = maxOf( 0f, - text.numOfRows * lineHeight - height + + transformedText.numOfRows * lineHeight - height + with (density) { (padding.calculateTopPadding() + padding.calculateBottomPadding()).toPx() } @@ -324,9 +347,24 @@ private fun CoreBigMonospaceText( } } - rememberLast(viewState.selection.start, viewState.selection.last, visualTransformation) { - viewState.transformedSelection = transformedText.offsetMapping.originalToTransformed(viewState.selection.start) .. - transformedText.offsetMapping.originalToTransformed(viewState.selection.last) +// rememberLast(viewState.selection.start, viewState.selection.last, visualTransformation) { +// viewState.transformedSelection = transformedText.offsetMapping.originalToTransformed(viewState.selection.start) .. +// transformedText.offsetMapping.originalToTransformed(viewState.selection.last) +// } + + val transformedState = remember(text, textTransformation) { + if (textTransformation != null) { + textTransformation.initialize(text, transformedText).also { + log.d { "CoreBigMonospaceText init transformedState ${it.hashCode()}" } + } + } else { + null + } + } + + rememberLast(viewState.selection.start, viewState.selection.last, textTransformation) { + viewState.transformedSelection = transformedText.findTransformedPositionByOriginalPosition(viewState.selection.start) .. + transformedText.findTransformedPositionByOriginalPosition(maxOf(0, viewState.selection.last)) } val coroutineScope = rememberCoroutineScope() // for scrolling @@ -345,9 +383,9 @@ private fun CoreBigMonospaceText( fun getTransformedCharIndex(x: Float, y: Float, mode: ResolveCharPositionMode): Int { val row = ((viewportTop + y) / lineHeight).toInt() - val maxIndex = maxOf(0, transformedText.text.length - if (mode == ResolveCharPositionMode.Selection) 1 else 0) + val maxIndex = maxOf(0, transformedText.length - if (mode == ResolveCharPositionMode.Selection) 1 else 0) // val col = (x / charWidth).toInt() - if (row > text.lastRowIndex) { + if (row > transformedText.lastRowIndex) { return maxIndex } else if (row < 0) { return 0 @@ -367,8 +405,8 @@ private fun CoreBigMonospaceText( // } // } - val rowString = text.findRowString(row) - val rowPositionStart = text.findRowPositionStartIndexByRowIndex(row) + val rowString = transformedText.findRowString(row) + val rowPositionStart = transformedText.findRowPositionStartIndexByRowIndex(row) var accumWidth = 0f val charIndex = rowString.indexOfFirst { accumWidth += textLayouter.charMeasurer.findCharWidth(it.toString()) @@ -382,7 +420,7 @@ private fun CoreBigMonospaceText( fun getTransformedStringWidth(start: Int, endExclusive: Int): Float { return (start .. endExclusive - 1) .map { - val char = transformedText.text.substring(it..it) + val char = transformedText.substring(it..it) if (char == "\n") { // selecting \n shows a narrow width textLayouter.charMeasurer.findCharWidth(" ") } else { @@ -393,7 +431,7 @@ private fun CoreBigMonospaceText( } fun onValueChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) { - viewState.lastVisibleRow = minOf(viewState.lastVisibleRow, text.lastRowIndex) + viewState.lastVisibleRow = minOf(viewState.lastVisibleRow, transformedText.lastRowIndex) viewState.version = Random.nextLong() val event = BigTextChangeEvent( @@ -403,6 +441,7 @@ private fun CoreBigMonospaceText( changeStartIndex = changeStartIndex, changeEndExclusiveIndex = changeEndExclusiveIndex, ) + (textTransformation as? IncrementalTextTransformation)?.onTextChange(event, transformedText, transformedState) onTextChange(event) } @@ -416,11 +455,11 @@ private fun CoreBigMonospaceText( } val insertPos = viewState.cursorIndex text.insertAt(insertPos, textInput) - viewState.cursorIndex += textInput.length + onValueChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) + viewState.cursorIndex = minOf(text.length, insertPos + textInput.length) viewState.updateTransformedCursorIndexByOriginal(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex log.v { "set cursor pos 2 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } - onValueChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) } fun onDelete(direction: TextFBDirection): Boolean { @@ -436,11 +475,11 @@ private fun CoreBigMonospaceText( TextFBDirection.Backward -> { if (cursor - 1 >= 0) { text.delete(cursor - 1, cursor) - --viewState.cursorIndex + onValueChange(BigTextChangeEventType.Delete, cursor - 1, cursor) + viewState.cursorIndex = maxOf(0, cursor - 1) viewState.updateTransformedCursorIndexByOriginal(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex log.v { "set cursor pos 3 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } - onValueChange(BigTextChangeEventType.Delete, cursor - 1, cursor) return true } } @@ -487,7 +526,7 @@ private fun CoreBigMonospaceText( viewState.updateCursorIndexByTransformed(transformedText) } ) - .pointerInput(isEditable, text, text.hasLayouted, viewState, viewportTop, lineHeight, contentWidth, transformedText.text.length, transformedText.text.hashCode()) { + .pointerInput(isEditable, text, transformedText.hasLayouted, viewState, viewportTop, lineHeight, contentWidth, transformedText.length, transformedText.hashCode()) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() @@ -624,7 +663,7 @@ private fun CoreBigMonospaceText( it.key in listOf(Key.DirectionLeft, Key.DirectionRight) -> { val delta = if (it.key == Key.DirectionRight) 1 else -1 viewState.transformedSelection = IntRange.EMPTY // TODO handle Shift key - if (viewState.transformedCursorIndex + delta in 0 .. transformedText.text.length) { + if (viewState.transformedCursorIndex + delta in 0 .. transformedText.length) { viewState.transformedCursorIndex += delta viewState.updateCursorIndexByTransformed(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex @@ -634,25 +673,25 @@ private fun CoreBigMonospaceText( } it.key in listOf(Key.DirectionUp, Key.DirectionDown) -> { // val row = layoutResult.rowStartCharIndices.binarySearchForMaxIndexOfValueAtMost(viewState.transformedCursorIndex) - val row = text.findRowIndexByPosition(viewState.transformedCursorIndex) + val row = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) val newRow = row + if (it.key == Key.DirectionDown) 1 else -1 viewState.transformedSelection = IntRange.EMPTY // TODO handle Shift key viewState.transformedCursorIndex = Unit.let { if (newRow < 0) { 0 - } else if (newRow > text.lastRowIndex) { - transformedText.text.length + } else if (newRow > transformedText.lastRowIndex) { + transformedText.length } else { - val col = viewState.transformedCursorIndex - text.findRowPositionStartIndexByRowIndex(row) - val newRowLength = if (newRow + 1 <= text.lastRowIndex) { - text.findRowPositionStartIndexByRowIndex(newRow + 1) - 1 + val col = viewState.transformedCursorIndex - transformedText.findRowPositionStartIndexByRowIndex(row) + val newRowLength = if (newRow + 1 <= transformedText.lastRowIndex) { + transformedText.findRowPositionStartIndexByRowIndex(newRow + 1) - 1 } else { - transformedText.text.length - } - text.findRowPositionStartIndexByRowIndex(newRow) + transformedText.length + } - transformedText.findRowPositionStartIndexByRowIndex(newRow) if (col <= newRowLength) { - text.findRowPositionStartIndexByRowIndex(newRow) + col + transformedText.findRowPositionStartIndexByRowIndex(newRow) + col } else { - text.findRowPositionStartIndexByRowIndex(newRow) + newRowLength + transformedText.findRowPositionStartIndexByRowIndex(newRow) + newRowLength } } } @@ -670,7 +709,8 @@ private fun CoreBigMonospaceText( .semantics { log.d { "semantic lambda" } if (isEditable) { - editableText = AnnotatedString(text.buildString(), transformedText.text.spanStyles) +// editableText = AnnotatedString(text.buildString(), transformedText.text.spanStyles) + editableText = AnnotatedString(transformedText.buildString()) setText { viewState.selection = 0 .. text.lastIndex onType(it.text) @@ -681,7 +721,8 @@ private fun CoreBigMonospaceText( true } } else { - this.text = AnnotatedString(text.buildString(), transformedText.text.spanStyles) +// this.text = AnnotatedString(text.buildString(), transformedText.text.spanStyles) + this.text = AnnotatedString(transformedText.buildString()) setText { false } insertTextAtCursor { false } } @@ -689,26 +730,26 @@ private fun CoreBigMonospaceText( ) { val viewportBottom = viewportTop + height - if (lineHeight > 0 && text.hasLayouted) { + if (lineHeight > 0 && transformedText.hasLayouted) { val firstRowIndex = maxOf(0, (viewportTop / lineHeight).toInt()) - val lastRowIndex = minOf(text.lastRowIndex, (viewportBottom / lineHeight).toInt() + 1) + val lastRowIndex = minOf(transformedText.lastRowIndex, (viewportBottom / lineHeight).toInt() + 1) log.v { "row index = [$firstRowIndex, $lastRowIndex]; scroll = $viewportTop ~ $viewportBottom; line h = $lineHeight" } viewState.firstVisibleRow = firstRowIndex viewState.lastVisibleRow = lastRowIndex with(density) { (firstRowIndex..lastRowIndex).forEach { i -> - val startIndex = text.findRowPositionStartIndexByRowIndex(i) - val endIndex = if (i + 1 > text.lastRowIndex) { - transformedText.text.length + val startIndex = transformedText.findRowPositionStartIndexByRowIndex(i) + val endIndex = if (i + 1 > transformedText.lastRowIndex) { + transformedText.length } else { - text.findRowPositionStartIndexByRowIndex(i + 1) + transformedText.findRowPositionStartIndexByRowIndex(i + 1) } - val nonVisualEndIndex = minOf(transformedText.text.length, maxOf(endIndex, startIndex + 1)) - val cursorDisplayRangeEndIndex = if (i + 1 > text.lastRowIndex) { - transformedText.text.length + val nonVisualEndIndex = minOf(transformedText.length, maxOf(endIndex, startIndex + 1)) + val cursorDisplayRangeEndIndex = if (i + 1 > transformedText.lastRowIndex) { + transformedText.length } else { - maxOf(text.findRowPositionStartIndexByRowIndex(i + 1) - 1, startIndex) + maxOf(transformedText.findRowPositionStartIndexByRowIndex(i + 1) - 1, startIndex) } // log.v { "line #$i: [$startIndex, $endIndex)" } val yOffset = (- viewportTop + (i/* - firstRowIndex*/) * lineHeight).toDp() @@ -725,7 +766,7 @@ private fun CoreBigMonospaceText( ) } } - val rowText = transformedText.text.subSequence( + val rowText = transformedText.subSequence( startIndex = startIndex, endIndex = endIndex, ) @@ -740,7 +781,7 @@ private fun CoreBigMonospaceText( if (isEditable && isFocused && viewState.transformedCursorIndex in startIndex .. cursorDisplayRangeEndIndex) { var x = 0f (startIndex + 1 .. viewState.transformedCursorIndex).forEach { - x += textLayouter.charMeasurer.findCharWidth(transformedText.text.substring(it - 1.. it - 1)) + x += textLayouter.charMeasurer.findCharWidth(transformedText.substring(it - 1.. it - 1)) } BigTextFieldCursor( lineHeight = lineHeight.toDp(), @@ -793,6 +834,16 @@ class BigTextViewState { transformedText.offsetMapping.originalToTransformed(selection.last) } + internal fun updateSelectionByTransformedSelection(transformedText: BigTextTransformed) { + selection = transformedText.findOriginalPositionByTransformedPosition(transformedSelection.first) .. + transformedText.findOriginalPositionByTransformedPosition(transformedSelection.last) + } + + internal fun updateTransformedSelectionBySelection(transformedText: BigTextTransformed) { + transformedSelection = transformedText.findTransformedPositionByOriginalPosition(selection.first) .. + transformedText.findTransformedPositionByOriginalPosition(selection.last) + } + internal var transformedCursorIndex by mutableStateOf(0) var cursorIndex by mutableStateOf(0) @@ -803,12 +854,24 @@ class BigTextViewState { fun updateTransformedCursorIndexByOriginal(transformedText: TransformedText) { transformedCursorIndex = transformedText.offsetMapping.originalToTransformed(cursorIndex) } + + fun updateCursorIndexByTransformed(transformedText: BigTextTransformed) { + cursorIndex = transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex).also { + log.d { "cursorIndex = $it" } + } + } + + fun updateTransformedCursorIndexByOriginal(transformedText: BigTextTransformed) { + transformedCursorIndex = transformedText.findTransformedPositionByOriginalPosition(cursorIndex).also { + log.d { "updateTransformedCursorIndexByOriginal = $it" } + } + } } private enum class ResolveCharPositionMode { Selection, Cursor } -private enum class TextFBDirection { +enum class TextFBDirection { Forward, Backward } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt index 1620a012..b751df63 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -13,6 +13,8 @@ interface BigText { fun substring(range: IntRange): String = substring(range.start, range.endInclusive + 1) + fun subSequence(startIndex: Int, endIndex: Int) = substring(startIndex, endIndex) + fun append(text: String): Int fun insertAt(pos: Int, text: String): Int diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index c29d4251..9e759601 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -36,7 +36,7 @@ internal var isD = false private const val EPS = 1e-4f -open class BigTextImpl : BigText { +open class BigTextImpl : BigText, BigTextLayoutable { open val tree: LengthTree = LengthTree( object : RedBlackTreeComputations { override fun recomputeFromLeaf(it: RedBlackTree.Node) = recomputeAggregatedValues(it) @@ -55,7 +55,7 @@ open class BigTextImpl : BigText { internal var contentWidth: Float? = null - var onLayoutCallback: (() -> Unit)? = null + override var onLayoutCallback: (() -> Unit)? = null internal var changeHook: BigTextChangeHook? = null @@ -109,7 +109,7 @@ open class BigTextImpl : BigText { }?.let { it to rowStart + it.value.leftNumOfRowBreaks /*findLineStart(it)*/ } } - fun findPositionByRowIndex(index: Int): Int { + override fun findPositionByRowIndex(index: Int): Int { if (!hasLayouted) { return 0 } @@ -117,7 +117,7 @@ open class BigTextImpl : BigText { return tree.findNodeByRowBreaks(index - 1)!!.second } - fun findRowIndexByPosition(position: Int): Int { + override fun findRowIndexByPosition(position: Int): Int { if (!hasLayouted) { return 0 } @@ -129,7 +129,7 @@ open class BigTextImpl : BigText { throw IndexOutOfBoundsException("position = $position but length = $length") } } - val node = tree.findNodeByCharIndex(position)!! + val node = tree.findNodeByRenderCharIndex(position)!! val startPos = findRenderPositionStart(node) val nv = node.value val rowIndexInThisPartition = nv.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(position - startPos + nv.bufferOffsetStart) + 1 @@ -137,7 +137,7 @@ open class BigTextImpl : BigText { return startRowIndex + rowIndexInThisPartition } - fun findRowPositionStartIndexByRowIndex(index: Int): Int { + override fun findRowPositionStartIndexByRowIndex(index: Int): Int { if (!hasLayouted) { return 0 } @@ -165,7 +165,7 @@ open class BigTextImpl : BigText { * @param rowIndex 0-based * @return 0-based */ - fun findLineIndexByRowIndex(rowIndex: Int): Int { + override fun findLineIndexByRowIndex(rowIndex: Int): Int { if (!hasLayouted) { return 0 } @@ -177,20 +177,21 @@ open class BigTextImpl : BigText { val (node, rowIndexStart) = tree.findNodeByRowBreaks(rowIndex - 1)!! val rowOffset = if (rowIndex - 1 - rowIndexStart == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { - node.value.bufferOffsetEndExclusive + node.value.renderBufferEndExclusive } else if (rowIndex > 0) { node.value.rowBreakOffsets[rowIndex - 1 - rowIndexStart] } else { 0 } val positionStart = findRenderPositionStart(node) - val rowPositionStart = positionStart + rowOffset - node.value.bufferOffsetStart + val rowPositionStart = positionStart + rowOffset - node.value.renderBufferStart val lineBreakPosition = rowPositionStart - 1 - val lineBreakAtNode = tree.findNodeByCharIndex(lineBreakPosition)!! + val lineBreakAtNode = tree.findNodeByRenderCharIndex(lineBreakPosition)!! val lineStart = findLineStart(lineBreakAtNode) val positionStartOfLineBreakNode = findRenderPositionStart(lineBreakAtNode) val lineBreakOffsetStarts = lineBreakAtNode.value.buffer.lineOffsetStarts + // FIXME render pos domain should be converted to buffer pos domain before searching val lineBreakMinIndex = lineBreakOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineBreakAtNode.value.bufferOffsetStart) val lineBreakIndex = lineBreakOffsetStarts.binarySearchForMaxIndexOfValueAtMost(lineBreakPosition - positionStartOfLineBreakNode + lineBreakAtNode.value.bufferOffsetStart) return (lineStart + if (lineBreakIndex < lineBreakMinIndex) { @@ -272,7 +273,7 @@ open class BigTextImpl : BigText { // return rowBreaksStart + rowBreakOffsetIndex + 1 val rowStartPos = lineStartPos + 1 /* rowBreak is 1 char after '\n' while lineBreak is right at '\n' */ - val actualNode = tree.findNodeByCharIndex(rowStartPos) ?: run { + val actualNode = tree.findNodeByRenderCharIndex(rowStartPos) ?: run { if (rowStartPos == length && tree.rightmost(tree.getRoot()).value.isEndWithForceRowBreak) { return lastRowIndex } @@ -600,7 +601,7 @@ open class BigTextImpl : BigText { return substring(startCharIndex, endCharIndex) // includes the last '\n' char } - fun findRowString(rowIndex: Int): String { + override fun findRowString(rowIndex: Int): String { /** * @param rowOffset 0 = start of buffer; 1 = char index of the first row break */ @@ -811,7 +812,7 @@ open class BigTextImpl : BigText { println(inspect(label)) } - fun setLayouter(layouter: TextLayouter) { + override fun setLayouter(layouter: TextLayouter) { if (this.layouter == layouter) { return } @@ -827,7 +828,7 @@ open class BigTextImpl : BigText { layout() } - fun setContentWidth(contentWidth: Float) { + override fun setContentWidth(contentWidth: Float) { require(contentWidth > EPS) { "contentWidth must be positive" } if (this.contentWidth == contentWidth) { @@ -1101,10 +1102,10 @@ open class BigTextImpl : BigText { onLayoutCallback?.invoke() } - val hasLayouted: Boolean + override val hasLayouted: Boolean get() = layouter != null && contentWidth != null - val numOfRows: Int + override val numOfRows: Int get() = tree.getRoot().numRowBreaks() + 1 + // TODO cache the result run { val lastNode = tree.rightmost(tree.getRoot()).takeIf { it.isNotNil() } @@ -1125,10 +1126,10 @@ open class BigTextImpl : BigText { } } - val numOfLines: Int + override val numOfLines: Int get() = tree.getRoot().numLineBreaks() + 1 - val lastRowIndex: Int + override val lastRowIndex: Int get() = numOfRows - 1 /** diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt index a41b5080..6d2f6967 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt @@ -29,6 +29,6 @@ class BigTextLayoutResult( } class BigTextSimpleLayoutResult( - val text: BigText, + val text: BigTextLayoutable, val rowHeight: Float ) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt new file mode 100644 index 00000000..fbc8fd30 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt @@ -0,0 +1,28 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +interface BigTextLayoutable { + + val hasLayouted: Boolean + + val numOfLines: Int + + val numOfRows: Int + + val lastRowIndex: Int + + var onLayoutCallback: (() -> Unit)? + + fun setLayouter(layouter: TextLayouter) + + fun setContentWidth(contentWidth: Float) + + fun findRowPositionStartIndexByRowIndex(index: Int): Int + + fun findLineIndexByRowIndex(rowIndex: Int): Int + + fun findRowString(rowIndex: Int): String + + fun findRowIndexByPosition(position: Int): Int + + fun findPositionByRowIndex(index: Int): Int +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformed.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformed.kt new file mode 100644 index 00000000..faf1c04d --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformed.kt @@ -0,0 +1,12 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +interface BigTextTransformed : BigTextTransformer, BigText, BigTextLayoutable { + + override fun delete(range: IntRange): Int { + return super.delete(range) + } + + fun findTransformedPositionByOriginalPosition(originalPosition: Int): Int + + fun findOriginalPositionByTransformedPosition(transformedPosition: Int): Int +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt new file mode 100644 index 00000000..faa7304d --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt @@ -0,0 +1,20 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import androidx.compose.ui.text.SpanStyle + +interface BigTextTransformer { + +// fun resetToOriginal() + +// fun applyStyle(style: SpanStyle, range: IntRange) + + fun append(text: String): Int + + fun insertAt(pos: Int, text: String): Int + + fun delete(range: IntRange): Int + + fun replace(range: IntRange, text: String, offsetMapping: BigTextTransformOffsetMapping) + +// fun restoreToOriginal(range: IntRange) +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index b8fe9fa6..978d4194 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -15,7 +15,7 @@ val logT = Logger(object : MutableLoggerConfig { override var minSeverity: Severity = Severity.Info }, tag = "BigText.Transform") -class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(chunkSize = delegate.chunkSize) { +class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(chunkSize = delegate.chunkSize), BigTextTransformed { private var hasReachedExtensiveSearch: Boolean = false @@ -342,7 +342,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c internal fun hasReachedExtensiveSearch() = hasReachedExtensiveSearch - fun findTransformedPositionByOriginalPosition(originalPosition: Int): Int { + override fun findTransformedPositionByOriginalPosition(originalPosition: Int): Int { // TODO this function can be further optimized if (originalPosition == originalLength) { // the retrieved 'node' is incorrect for the last position return length @@ -426,7 +426,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c } } - fun findOriginalPositionByTransformedPosition(transformedPosition: Int): Int { + override fun findOriginalPositionByTransformedPosition(transformedPosition: Int): Int { // TODO this function can be further optimized if (transformedPosition == length) { return originalLength @@ -520,7 +520,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c override fun replace(range: IntRange, text: String) = transformReplace(range, text) - fun replace(range: IntRange, text: String, offsetMapping: BigTextTransformOffsetMapping) = transformReplace(range, text, offsetMapping) + override fun replace(range: IntRange, text: String, offsetMapping: BigTextTransformOffsetMapping) = transformReplace(range, text, offsetMapping) } fun RedBlackTree.Node.transformedOffset(): Int = diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt new file mode 100644 index 00000000..6ad50ae6 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt @@ -0,0 +1,8 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +interface IncrementalTextTransformation { + + fun initialize(text: BigText, transformer: BigTextTransformer): C + + fun onTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: C) +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt new file mode 100644 index 00000000..668b18da --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt @@ -0,0 +1,68 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEventType +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformOffsetMapping +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformer +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.IncrementalTextTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.TextFBDirection + +class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformation { + private val variableRegex = "\\$\\{\\{([^{}]{1,20})\\}\\}".toRegex() + + override fun initialize(text: BigText, transformer: BigTextTransformer) { +// if (true) return + + // TODO avoid loading building string again which uses double memory + val variables = variableRegex.findAll(text.buildString()) + variables.forEach { + val variableName = it.groups[1]!!.value + transformer.replace(it.range, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) + } + } + + override fun onTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { + // TODO handle delete + if (change.eventType != BigTextChangeEventType.Insert) return + + val originalText = change.bigText + originalText.findPositionByPattern(change.changeStartIndex, change.changeEndExclusiveIndex, "}}", TextFBDirection.Forward).also { + log.d { "EnvironmentVariableIncrementalTransformation search end end=$it" } + }?.let { + val anotherBracket = originalText.findPositionByPattern(it - 20, it - 1, "\${{", TextFBDirection.Backward) + log.d { "EnvironmentVariableIncrementalTransformation search end start=$it" } + if (anotherBracket != null) { + val variableName = originalText.substring(anotherBracket + "\${{".length, it) + log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } + transformer.replace(anotherBracket until it + "}}".length, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) + } + } + originalText.findPositionByPattern(change.changeStartIndex, change.changeEndExclusiveIndex, "\${{", TextFBDirection.Forward).also { + log.d { "EnvironmentVariableIncrementalTransformation search start start=$it" } + }?.let { + val anotherBracket = originalText.findPositionByPattern(it + "\${{".length, it + 20, "}}", TextFBDirection.Forward) + log.d { "EnvironmentVariableIncrementalTransformation search start end=$it" } + if (anotherBracket != null) { + val variableName = originalText.substring(it + "\${{".length, anotherBracket) + log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } + transformer.replace(it until anotherBracket + "}}".length, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) + } + } + } + + fun createSpan(variableName: String): String { // TODO change to AnnotatedString + return "<$variableName>" + } +} + +fun BigText.findPositionByPattern(fromPosition: Int, toPosition: Int, pattern: String, direction: TextFBDirection): Int? { + val substringBeginIndex = maxOf(0, fromPosition - pattern.length) + val substring = substring(substringBeginIndex, minOf(length, toPosition + pattern.length)) + val lookupResult = when (direction) { + TextFBDirection.Forward -> substring.indexOf(pattern) + TextFBDirection.Backward -> substring.lastIndexOf(pattern) + } + return lookupResult.takeIf { it >= 0 }?.let { substringBeginIndex + lookupResult } +} From 7600310c73ab8a96316940c400738ae3956fda28 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 23 Sep 2024 00:52:02 +0800 Subject: [PATCH 090/195] add BigTextTransformer#restoreToOriginal(IntRange) --- .../ux/bigtext/BigTextTransformer.kt | 2 +- .../ux/bigtext/BigTextTransformerImpl.kt | 52 ++++++++++++++-- .../transform/BigTextTransformerImplTest.kt | 62 +++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt index faa7304d..844b03e4 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt @@ -16,5 +16,5 @@ interface BigTextTransformer { fun replace(range: IntRange, text: String, offsetMapping: BigTextTransformOffsetMapping) -// fun restoreToOriginal(range: IntRange) + fun restoreToOriginal(range: IntRange) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 978d4194..72a8df08 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -92,19 +92,24 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c return BigTextTransformNodeValue() } - fun insertOriginal(pos: Int, nodeValue: BigTextNodeValue) { - require(pos in 0 .. originalLength) { "Out of bound. pos = $pos, originalLength = $originalLength" } + fun insertOriginal( + pos: Int, + nodeValue: BigTextNodeValue, + bufferOffsetStart: Int = nodeValue.bufferOffsetStart, + bufferOffsetEndExclusive: Int = nodeValue.bufferOffsetEndExclusive, + ) { + require(pos in 0..originalLength) { "Out of bound. pos = $pos, originalLength = $originalLength" } insertChunkAtPosition( position = pos, - chunkedStringLength = nodeValue.bufferLength, + chunkedStringLength = bufferOffsetEndExclusive - bufferOffsetStart, ownership = BufferOwnership.Delegated, buffer = nodeValue.buffer, - range = nodeValue.bufferOffsetStart until nodeValue.bufferOffsetEndExclusive + range = bufferOffsetStart until bufferOffsetEndExclusive ) { bufferIndex = -1 - bufferOffsetStart = nodeValue.bufferOffsetStart - bufferOffsetEndExclusive = nodeValue.bufferOffsetEndExclusive + this.bufferOffsetStart = bufferOffsetStart + this.bufferOffsetEndExclusive = bufferOffsetEndExclusive this.buffer = nodeValue.buffer this.bufferOwnership = BufferOwnership.Delegated @@ -521,6 +526,41 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c override fun replace(range: IntRange, text: String) = transformReplace(range, text) override fun replace(range: IntRange, text: String, offsetMapping: BigTextTransformOffsetMapping) = transformReplace(range, text, offsetMapping) + + override fun restoreToOriginal(range: IntRange) { + val renderPositionAtOriginalStart = findTransformedPositionByOriginalPosition(range.start) + val renderPositionAtOriginalEnd = findTransformedPositionByOriginalPosition(range.endInclusive) + + deleteTransformIf(range) + deleteOriginal(range) + + // insert the original text from `delegate` + val originalNodeStart = delegate.tree.findNodeByCharIndex(range.start) + ?: throw IndexOutOfBoundsException("Original node at position ${range.start} not found") + var nodePositionStart = delegate.tree.findPositionStart(originalNodeStart) + var insertPoint = range.start + var node = originalNodeStart + var insertOffsetStart = node.value.bufferOffsetStart + (range.start - nodePositionStart) + do { + val insertOffsetEndExclusive = if ((nodePositionStart + node.value.bufferLength) > (range.endInclusive + 1)) { + node.value.bufferOffsetStart + (range.endInclusive + 1 - nodePositionStart) + } else { + node.value.bufferOffsetEndExclusive + } + insertOriginal(insertPoint, node.value, insertOffsetStart, insertOffsetEndExclusive) + + if (insertPoint + (insertOffsetEndExclusive - insertOffsetStart) > range.endInclusive) { + break + } + + node = delegate.tree.nextNode(node)!! + nodePositionStart = delegate.tree.findPositionStart(node) + insertPoint += insertOffsetEndExclusive - insertOffsetStart + insertOffsetStart = node.value.bufferOffsetStart + } while (nodePositionStart <= range.endInclusive) + + layout(maxOf(0, renderPositionAtOriginalStart - 1), minOf(length, renderPositionAtOriginalEnd + 1)) + } } fun RedBlackTree.Node.transformedOffset(): Int = diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt index 26c3f999..5ecce278 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt @@ -3,6 +3,7 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext.transfor import com.sunnychung.application.multiplatform.hellohttp.test.bigtext.randomString import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformOffsetMapping import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.isD import org.junit.jupiter.api.BeforeEach @@ -895,6 +896,67 @@ class BigTextTransformerImplTest { } } + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun restoreToOriginal(chunkSize: Int) { + val initialText = "12345678901234567890123456789012345678901234567890123456789012345678901234567890" + val original = BigTextImpl(chunkSize = chunkSize) + original.append(initialText) + val transformed = BigTextTransformerImpl(original) + + transformed.replace(11 .. 18, "ABCD", BigTextTransformOffsetMapping.Incremental) + "12345678901ABCD0123456789012345678901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertEquals(initialText, original.buildString()) + assertAllSubstring(expected, transformed) + } + + transformed.insertAt(61, "EFGHIJ") + "12345678901ABCD012345678901234567890123456789012345678901EFGHIJ2345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertEquals(initialText, original.buildString()) + assertAllSubstring(expected, transformed) + } + + transformed.restoreToOriginal(0 .. initialText.length - 1) + initialText.let { expected -> + assertEquals(expected, transformed.buildString()) + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, transformed) + } + } + + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun restoreToOriginalThenOriginalDelete(chunkSize: Int) { + val initialText = "12345678901234567890123456789012345678901234567890123456789012345678901234567890" + val original = BigTextImpl(chunkSize = chunkSize) + original.append(initialText) + val transformed = BigTextTransformerImpl(original) + + transformed.replace(11 .. 18, "ABCD", BigTextTransformOffsetMapping.WholeBlock) + "12345678901ABCD0123456789012345678901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertEquals(initialText, original.buildString()) + assertAllSubstring(expected, transformed) + } + + transformed.restoreToOriginal(11 .. 18) + initialText.let { expected -> + assertEquals(expected, transformed.buildString()) + assertEquals(expected, original.buildString()) + assertAllSubstring(expected, transformed) + } + + original.delete(18 .. 18) + + "1234567890123456780123456789012345678901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, original.buildString()) + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + } + @BeforeEach fun beforeEach() { isD = false From c7902c5b5499fe54ea314f21f4b05f1972a209a6 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 23 Sep 2024 00:57:36 +0800 Subject: [PATCH 091/195] update BigMonospaceText transformation listener to be fired before deletion, so that it can determine how to transform --- .../hellohttp/extension/RangeExtension.kt | 4 + .../hellohttp/ux/bigtext/BigMonospaceText.kt | 47 +++++++++-- ...onmentVariableIncrementalTransformation.kt | 82 ++++++++++++++----- 3 files changed, 102 insertions(+), 31 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt index 6ba6a3a1..69d8ad9c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt @@ -13,6 +13,10 @@ infix fun IntRange.intersect(other: IntRange): IntRange { return from .. to } +infix fun IntRange.hasIntersectWith(other: IntRange): Boolean { + return !intersect(other).isEmpty() +} + fun IntRange.toNonEmptyRange(): IntRange { if (length <= 0) { return start .. start diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 8620b50b..b05fb4b7 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -430,18 +430,42 @@ private fun CoreBigMonospaceText( .sum() } - fun onValueChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) { - viewState.lastVisibleRow = minOf(viewState.lastVisibleRow, transformedText.lastRowIndex) - - viewState.version = Random.nextLong() - val event = BigTextChangeEvent( + fun generateChangeEvent(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) : BigTextChangeEvent { + return BigTextChangeEvent( changeId = viewState.version, bigText = text, eventType = eventType, changeStartIndex = changeStartIndex, changeEndExclusiveIndex = changeEndExclusiveIndex, ) - (textTransformation as? IncrementalTextTransformation)?.onTextChange(event, transformedText, transformedState) + } + + fun onValuePreChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) { + if (eventType == BigTextChangeEventType.Delete) { + viewState.version = Random.nextLong() + val event = generateChangeEvent(eventType, changeStartIndex, changeEndExclusiveIndex) + + // invoke textTransformation listener before deletion, so that it knows what will be deleted and transform accordingly + (textTransformation as? IncrementalTextTransformation)?.onTextChange( + event, + transformedText, + transformedState + ) + } + } + + fun onValuePostChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) { + viewState.lastVisibleRow = minOf(viewState.lastVisibleRow, transformedText.lastRowIndex) + + viewState.version = Random.nextLong() + val event = generateChangeEvent(eventType, changeStartIndex, changeEndExclusiveIndex) + if (eventType != BigTextChangeEventType.Delete) { + (textTransformation as? IncrementalTextTransformation)?.onTextChange( + event, + transformedText, + transformedState + ) + } onTextChange(event) } @@ -454,8 +478,10 @@ private fun CoreBigMonospaceText( viewState.transformedSelection = IntRange.EMPTY } val insertPos = viewState.cursorIndex + onValuePreChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) text.insertAt(insertPos, textInput) - onValueChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) + onValuePostChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) + // update cursor after invoking listeners, because a transformation or change may take place viewState.cursorIndex = minOf(text.length, insertPos + textInput.length) viewState.updateTransformedCursorIndexByOriginal(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex @@ -467,15 +493,18 @@ private fun CoreBigMonospaceText( when (direction) { TextFBDirection.Forward -> { if (cursor + 1 <= text.length) { + onValuePreChange(BigTextChangeEventType.Delete, cursor, cursor + 1) text.delete(cursor, cursor + 1) - onValueChange(BigTextChangeEventType.Delete, cursor, cursor + 1) + onValuePostChange(BigTextChangeEventType.Delete, cursor, cursor + 1) return true } } TextFBDirection.Backward -> { if (cursor - 1 >= 0) { + onValuePreChange(BigTextChangeEventType.Delete, cursor - 1, cursor) text.delete(cursor - 1, cursor) - onValueChange(BigTextChangeEventType.Delete, cursor - 1, cursor) + onValuePostChange(BigTextChangeEventType.Delete, cursor - 1, cursor) + // update cursor after invoking listeners, because a transformation or change may take place viewState.cursorIndex = maxOf(0, cursor - 1) viewState.updateTransformedCursorIndexByOriginal(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt index 668b18da..6bf9f9a2 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt @@ -1,5 +1,7 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental +import com.sunnychung.application.multiplatform.hellohttp.extension.hasIntersectWith +import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent @@ -10,7 +12,9 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.Incremental import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.TextFBDirection class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformation { - private val variableRegex = "\\$\\{\\{([^{}]{1,20})\\}\\}".toRegex() + val processLengthLimit = 30 + + private val variableRegex = "\\$\\{\\{([^{}]{1,$processLengthLimit})\\}\\}".toRegex() override fun initialize(text: BigText, transformer: BigTextTransformer) { // if (true) return @@ -24,32 +28,66 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati } override fun onTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { - // TODO handle delete - if (change.eventType != BigTextChangeEventType.Insert) return + // TODO handle multiple matches (e.g. triggered by pasting text) val originalText = change.bigText - originalText.findPositionByPattern(change.changeStartIndex, change.changeEndExclusiveIndex, "}}", TextFBDirection.Forward).also { - log.d { "EnvironmentVariableIncrementalTransformation search end end=$it" } - }?.let { - val anotherBracket = originalText.findPositionByPattern(it - 20, it - 1, "\${{", TextFBDirection.Backward) - log.d { "EnvironmentVariableIncrementalTransformation search end start=$it" } - if (anotherBracket != null) { - val variableName = originalText.substring(anotherBracket + "\${{".length, it) - log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } - transformer.replace(anotherBracket until it + "}}".length, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) + when (change.eventType) { + BigTextChangeEventType.Insert -> { + // Find if there is pattern match ("\${{" or "}}") in the inserted text. + // If yes, try to locate the pair within `processLengthLimit`, and make desired replacement. + + originalText.findPositionByPattern(change.changeStartIndex, change.changeEndExclusiveIndex, "}}", TextFBDirection.Forward).also { + log.d { "EnvironmentVariableIncrementalTransformation search end end=$it" } + }?.let { + val anotherBracket = originalText.findPositionByPattern(it - processLengthLimit, it - 1, "\${{", TextFBDirection.Backward) + log.d { "EnvironmentVariableIncrementalTransformation search end start=$it" } + if (anotherBracket != null) { + val variableName = originalText.substring(anotherBracket + "\${{".length, it) + log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } + transformer.replace(anotherBracket until it + "}}".length, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) + } + } + originalText.findPositionByPattern(change.changeStartIndex, change.changeEndExclusiveIndex, "\${{", TextFBDirection.Forward).also { + log.d { "EnvironmentVariableIncrementalTransformation search start start=$it" } + }?.let { + val anotherBracket = originalText.findPositionByPattern(it + "\${{".length, it + processLengthLimit, "}}", TextFBDirection.Forward) + log.d { "EnvironmentVariableIncrementalTransformation search start end=$it" } + if (anotherBracket != null) { + val variableName = originalText.substring(it + "\${{".length, anotherBracket) + log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } + transformer.replace(it until anotherBracket + "}}".length, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) + } + } } - } - originalText.findPositionByPattern(change.changeStartIndex, change.changeEndExclusiveIndex, "\${{", TextFBDirection.Forward).also { - log.d { "EnvironmentVariableIncrementalTransformation search start start=$it" } - }?.let { - val anotherBracket = originalText.findPositionByPattern(it + "\${{".length, it + 20, "}}", TextFBDirection.Forward) - log.d { "EnvironmentVariableIncrementalTransformation search start end=$it" } - if (anotherBracket != null) { - val variableName = originalText.substring(it + "\${{".length, anotherBracket) - log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } - transformer.replace(it until anotherBracket + "}}".length, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) + + BigTextChangeEventType.Delete -> { + // Find if there is pattern match ("\${{" or "}}") in the inserted text. + // If yes, try to locate the pair within `processLengthLimit`, and remove the transformation by restoring them to original. + + val changeRange = change.changeStartIndex until change.changeEndExclusiveIndex + + originalText.findPositionByPattern(change.changeStartIndex - processLengthLimit, change.changeEndExclusiveIndex, "}}", TextFBDirection.Backward) + ?.takeIf { (it until it + "}}".length) hasIntersectWith changeRange } + ?.let { + originalText.findPositionByPattern(it - processLengthLimit, it - 1, "\${{", TextFBDirection.Backward) + ?.let { anotherStart -> + log.d { "EnvironmentVariableIncrementalTransformation delete A" } + transformer.restoreToOriginal(anotherStart until it + "}}".length) + } + } + originalText.findPositionByPattern(change.changeStartIndex - processLengthLimit, change.changeEndExclusiveIndex, "\${{", TextFBDirection.Forward) + ?.takeIf { (it until it + "\${{".length) hasIntersectWith changeRange } + ?.let { + originalText.findPositionByPattern(it + "\${{".length, it + processLengthLimit, "}}", TextFBDirection.Forward) + ?.let { anotherStart -> + log.d { "EnvironmentVariableIncrementalTransformation delete B" } + transformer.restoreToOriginal(it until anotherStart + "}}".length) + } + } } } + + } fun createSpan(variableName: String): String { // TODO change to AnnotatedString From 8af5cb648ef0287a4b21fd5731932911da5125a0 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 27 Sep 2024 23:04:39 +0800 Subject: [PATCH 092/195] update repository logging to use a dedicated logger to facilitate log filtering --- .../hellohttp/repository/BaseCollectionRepository.kt | 4 +++- .../application/multiplatform/hellohttp/util/Logger.kt | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/repository/BaseCollectionRepository.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/repository/BaseCollectionRepository.kt index f2338b5c..ba29fa17 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/repository/BaseCollectionRepository.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/repository/BaseCollectionRepository.kt @@ -5,7 +5,7 @@ import com.sunnychung.application.multiplatform.hellohttp.AppContext import com.sunnychung.application.multiplatform.hellohttp.document.Document import com.sunnychung.application.multiplatform.hellohttp.document.DocumentIdentifier import com.sunnychung.application.multiplatform.hellohttp.document.ResponsesDI -import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.util.logR import com.sunnychung.application.multiplatform.hellohttp.util.uuidString import com.sunnychung.lib.multiplatform.kotlite.extension.fullClassName import kotlinx.coroutines.CoroutineScope @@ -44,6 +44,8 @@ import java.util.concurrent.atomic.AtomicInteger import kotlin.math.absoluteValue import kotlin.random.Random +private val log = logR + sealed class BaseCollectionRepository, ID : DocumentIdentifier>(private val serializer: KSerializer) { private val persistenceManager by lazy { AppContext.PersistenceManager } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt index c230625c..0d18edc1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Logger.kt @@ -13,6 +13,11 @@ val log = Logger(object : MutableLoggerConfig { }, tag = "Hello") val llog = log +val logR = Logger(object : MutableLoggerConfig { + override var logWriterList: List = listOf(JvmLogger()) + override var minSeverity: Severity = Severity.Info +}, tag = "Hello.Repository") + class JvmLogger : LogWriter() { override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { val str = "[${KDateTimeFormat.FULL.format(KZonedInstant.nowAtLocalZoneOffset())}] ${severity.name.uppercase()} [${Thread.currentThread().name}] ($tag) -- $message" From 48e65f03623349c27711d86b8493066e28d81f53 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 27 Sep 2024 23:09:27 +0800 Subject: [PATCH 093/195] fix incorrect BigText query result after it is transformed --- .../hellohttp/ux/CodeEditorView.kt | 7 ++- .../hellohttp/ux/bigtext/BigTextImpl.kt | 35 ++++++++----- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 7 ++- .../ux/bigtext/BigTextTransformNodeValue.kt | 4 +- .../ux/bigtext/BigTextTransformerImpl.kt | 1 + .../test/bigtext/BigTextImplLayoutTest.kt | 33 +++++++++++++ .../transform/BigTextTransformerLayoutTest.kt | 49 ++++++++++++++++++- 7 files changed, 119 insertions(+), 17 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index a9b10ebc..29afd6da 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -863,7 +863,12 @@ fun BigTextLineNumbersView( totalLines = layoutText?.numOfLines ?: 1, lineHeight = (rowHeight).toDp(), // getLineOffset = { (textLayout!!.getLineTop(it) - viewportTop).toDp() }, - getLineOffset = { ( (layoutText?.findFirstRowIndexOfLine(it) ?: 0) * rowHeight - viewportTop).toDp() }, + getLineOffset = { + ((layoutText?.findFirstRowIndexOfLine(it).also { r -> + log.v { "layoutText.findFirstRowIndexOfLine($it) = $r" } + } + ?: 0) * rowHeight - viewportTop).toDp() + }, textStyle = textStyle, collapsedLinesState = collapsedLinesState, onCollapseLine = onCollapseLine, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 9e759601..ae2db0f7 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -76,12 +76,12 @@ open class BigTextImpl : BigText, BigTextLayoutable { when (find) { in Int.MIN_VALUE until it.value.leftNumOfLineBreaks -> if (it.left.isNotNil()) -1 else 0 // it.value.leftNumOfLineBreaks -> if (it.left.isNotNil()) -1 else 0 - in it.value.leftNumOfLineBreaks until it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange -> 0 - in it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange until Int.MAX_VALUE -> (if (it.right.isNotNil()) 1 else 0).also { compareResult -> + in it.value.leftNumOfLineBreaks until it.value.leftNumOfLineBreaks + it.value.renderNumLineBreaksInRange -> 0 + in it.value.leftNumOfLineBreaks + it.value.renderNumLineBreaksInRange until Int.MAX_VALUE -> (if (it.right.isNotNil()) 1 else 0).also { compareResult -> val isTurnRight = compareResult > 0 if (isTurnRight) { - find -= it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange - lineStart += it.value.leftNumOfLineBreaks + it.value.bufferNumLineBreaksInRange + find -= it.value.leftNumOfLineBreaks + it.value.renderNumLineBreaksInRange + lineStart += it.value.leftNumOfLineBreaks + it.value.renderNumLineBreaksInRange } } else -> throw IllegalStateException("what is find? $find") @@ -155,7 +155,7 @@ open class BigTextImpl : BigText, BigTextLayoutable { return startPos + if (index - 1 - rowStart == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { node.value.bufferLength } else if (index > 0) { - node.value.rowBreakOffsets[index - 1 - rowStart] - node.value.bufferOffsetStart + node.value.rowBreakOffsets[index - 1 - rowStart] - node.value.renderBufferStart } else { 0 } @@ -179,7 +179,11 @@ open class BigTextImpl : BigText, BigTextLayoutable { val rowOffset = if (rowIndex - 1 - rowIndexStart == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { node.value.renderBufferEndExclusive } else if (rowIndex > 0) { - node.value.rowBreakOffsets[rowIndex - 1 - rowIndexStart] + val i = rowIndex - 1 - rowIndexStart + if (i > node.value.rowBreakOffsets.lastIndex) { + throw IndexOutOfBoundsException("findLineIndexByRowIndex($rowIndex) rowBreakOffsets[$i] length ${node.value.rowBreakOffsets.size}") + } + node.value.rowBreakOffsets[i] } else { 0 } @@ -260,9 +264,9 @@ open class BigTextImpl : BigText, BigTextLayoutable { ?: throw IllegalStateException("Cannot find the node right after ${lineIndex - 1} line breaks") // val positionOfLineStartNode = findPositionStart(lineStartNode) val lineOffsetStarts = lineStartNode.value.buffer.lineOffsetStarts - val inRangeLineStartIndex = lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineStartNode.value.bufferOffsetStart) + val inRangeLineStartIndex = lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineStartNode.value.renderBufferStart) val lineOffset = if (lineIndex - 1 >= 0) { - lineOffsetStarts[inRangeLineStartIndex + lineIndex - 1 - lineIndexStart] - lineStartNode.value.bufferOffsetStart + lineOffsetStarts[inRangeLineStartIndex + lineIndex - 1 - lineIndexStart] - lineStartNode.value.renderBufferStart } else { 0 } @@ -281,10 +285,10 @@ open class BigTextImpl : BigText, BigTextLayoutable { } val actualNodeStartPos = findRenderPositionStart(actualNode) val rowBreaksStart = findRowStart(actualNode) - if (actualNode.value.isEndWithForceRowBreak && rowStartPos - actualNodeStartPos + actualNode.value.bufferOffsetStart >= actualNode.value.bufferOffsetEndExclusive) { + if (actualNode.value.isEndWithForceRowBreak && rowStartPos - actualNodeStartPos + actualNode.value.renderBufferStart >= actualNode.value.renderBufferEndExclusive) { return rowBreaksStart + actualNode.value.rowBreakOffsets.size + 1 } - val rowBreakOffsetIndex = actualNode.value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(rowStartPos - actualNodeStartPos + actualNode.value.bufferOffsetStart) + val rowBreakOffsetIndex = actualNode.value.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(rowStartPos - actualNodeStartPos + actualNode.value.renderBufferStart) return rowBreaksStart + rowBreakOffsetIndex + 1 } @@ -309,7 +313,7 @@ open class BigTextImpl : BigText, BigTextLayoutable { var node = node while (node.parent.isNotNil()) { if (node === node.parent.right) { - start += node.parent.value.leftNumOfLineBreaks + node.parent.value.bufferNumLineBreaksInRange + start += node.parent.value.leftNumOfLineBreaks + node.parent.value.renderNumLineBreaksInRange } node = node.parent } @@ -480,6 +484,13 @@ open class BigTextImpl : BigText, BigTextLayoutable { bufferNumLineBreaksInRange = buffer.lineOffsetStarts.run { binarySearchForMinIndexOfValueAtLeast(bufferOffsetEndExclusive) - maxOf(0, binarySearchForMinIndexOfValueAtLeast(bufferOffsetStart)) } + renderNumLineBreaksInRange = if (currentRenderLength > 0) { + buffer.lineOffsetStarts.run { + binarySearchForMinIndexOfValueAtLeast(renderBufferEndExclusive) - maxOf(0, binarySearchForMinIndexOfValueAtLeast(renderBufferStart)) + } + } else { + 0 + } leftNumOfLineBreaks = node?.left?.numLineBreaks() ?: 0 log.v { ">> leftNumOfLineBreaks ${node?.value?.debugKey()} -> $leftNumOfLineBreaks" } @@ -1168,7 +1179,7 @@ open class BigTextImpl : BigText, BigTextLayoutable { fun RedBlackTree.Node.numLineBreaks(): Int { val value = getValue() return (value?.leftNumOfLineBreaks ?: 0) + - (value?.bufferNumLineBreaksInRange ?: 0) + + (value?.renderNumLineBreaksInRange ?: 0) + (getRight().takeIf { it.isNotNil() }?.numLineBreaks() ?: 0) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 321e9bfb..63ca0d13 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -20,7 +20,8 @@ open class BigTextNodeValue : Comparable, DebuggableNode, DebuggableNode + val t = BigTextImpl(chunkSize = chunkSize).apply { + append("12345678901234567890123\n\n456789\n") + setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + setContentWidth(16f * softWrapAt + 1.23f) + } + val expectedRowPosStarts = when (softWrapAt) { + 10 -> listOf(0, 10, 20, 24, 25, 32) + else -> listOf(0, 24, 25, 32) + } + expectedRowPosStarts.forEachIndexed { i, expected -> + assertEquals(expected, t.findRowPositionStartIndexByRowIndex(i)) + } + } + listOf(100, 10, 37, 1000, 10000).forEach { softWrapAt -> + val t = BigTextImpl(chunkSize = chunkSize).apply { + append("{\"a\":\"bcdef}\"}\n\n\n\n") + setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + setContentWidth(16f * softWrapAt + 1.23f) + } + val expectedRowPosStarts = when (softWrapAt) { + 10 -> listOf(0, 10, 20, 21, 27, 28) + else -> listOf(0, 20, 21, 27, 28) + } + expectedRowPosStarts.forEachIndexed { i, expected -> + assertEquals(expected, t.findRowPositionStartIndexByRowIndex(i)) + } + } + } + @BeforeTest fun beforeEach() { random = Random diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt index 36dda1b2..717a31d7 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -577,7 +577,7 @@ class BigTextTransformerLayoutTest { @ParameterizedTest @ValueSource(ints = [1048576, 64, 16]) - fun findRowPositionStartIndexByRowIndex(chunkSize: Int) { + fun findRowPositionStartIndexByRowIndex1(chunkSize: Int) { val initial = "1234567890223456789032345678904234567890_234567890623456789072345678908234567890\n" val t = BigTextImpl(chunkSize = chunkSize).apply { append(initial) @@ -593,4 +593,51 @@ class BigTextTransformerLayoutTest { assertEquals(0, tt.findRowPositionStartIndexByRowIndex(0)) assertEquals(81 - 42, tt.findRowPositionStartIndexByRowIndex(1)) } + + @ParameterizedTest + @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) + fun findRowPositionStartIndexByRowIndex2(chunkSize: Int) { + listOf(100, 10, 37, 1000, 10000).forEach { softWrapAt -> + val t = BigTextImpl(chunkSize = chunkSize).apply { + append("{\"a\":\"bcd\${{abc}}ef}\"}\n\n\${{asd}}\n\n") + } + val tt = BigTextTransformerImpl(t).apply { + setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + setContentWidth(16f * softWrapAt + 1.23f) + } + tt.replace(9 .. 16, "") + tt.replace(24 .. 31, "") + val expectedRowPosStarts = when (softWrapAt) { + 10 -> listOf(0, 10, 20, 21, 27, 28) + else -> listOf(0, 20, 21, 27, 28) + } + expectedRowPosStarts.forEachIndexed { i, expected -> + assertEquals(expected, tt.findRowPositionStartIndexByRowIndex(i)) + } + } + } + + @ParameterizedTest + @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) + fun findFirstRowIndexOfLine(chunkSize: Int) { + listOf(100, 10, 37, 1000, 10000).forEach { softWrapAt -> + val t = BigTextImpl(chunkSize = chunkSize).apply { + append("{\"a\":\"bcd\${{abc}}ef}\"}\n\n\${{asd}}\n\n") + } + val tt = BigTextTransformerImpl(t).apply { + setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + setContentWidth(16f * softWrapAt + 1.23f) + } + tt.replace(9 .. 16, "") + tt.replace(24 .. 31, "") + val expectedRowPosStarts = when (softWrapAt) { + 10 -> listOf(0, 2, 3, 4, 5) + else -> listOf(0, 1, 2, 3, 4) + } + expectedRowPosStarts.forEachIndexed { i, expected -> + assertEquals(expected, tt.findFirstRowIndexOfLine(i)) + } + } + } + } From 302e3b55a8e8600d95fb501f59465a81f548c269 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 27 Sep 2024 23:53:47 +0800 Subject: [PATCH 094/195] update BigMonospaceText cursor behavior to skip transformed blocks --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 122 +++++++++++++++++- .../ux/bigtext/BigTextTransformerImpl.kt | 1 + 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index b05fb4b7..7b95df3d 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -75,6 +75,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import co.touchlab.kermit.Severity import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.isCtrlOrCmdPressed import com.sunnychung.application.multiplatform.hellohttp.extension.toTextInput @@ -277,7 +278,12 @@ private fun CoreBigMonospaceText( val transformedText: BigTextTransformed = remember(text, textTransformation) { log.d { "CoreBigMonospaceText recreate BigTextTransformed" } - BigTextTransformerImpl(text) + BigTextTransformerImpl(text).also { +// log.d { "transformedText = |${it.buildString()}|" } + if (log.config.minSeverity <= Severity.Debug) { + it.printDebug("transformedText") + } + } } fun fireOnLayout() { @@ -305,6 +311,10 @@ private fun CoreBigMonospaceText( transformedText.setLayouter(textLayouter) transformedText.setContentWidth(contentWidth) + if (log.config.minSeverity <= Severity.Verbose) { + (transformedText as BigTextImpl).printDebug("after init layout") + } + LaunchedEffect(Unit) { fireOnLayout() } @@ -356,6 +366,10 @@ private fun CoreBigMonospaceText( if (textTransformation != null) { textTransformation.initialize(text, transformedText).also { log.d { "CoreBigMonospaceText init transformedState ${it.hashCode()}" } +// (transformedText as BigTextImpl).layout() // FIXME remove + if (log.config.minSeverity <= Severity.Verbose) { + (transformedText as BigTextImpl).printDebug("init transformedState") + } } } else { null @@ -440,6 +454,11 @@ private fun CoreBigMonospaceText( ) } + fun updateViewState() { + viewState.lastVisibleRow = minOf(viewState.lastVisibleRow, transformedText.lastRowIndex) + log.d { "lastVisibleRow = ${viewState.lastVisibleRow}, lastRowIndex = ${transformedText.lastRowIndex}" } + } + fun onValuePreChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) { if (eventType == BigTextChangeEventType.Delete) { viewState.version = Random.nextLong() @@ -455,7 +474,7 @@ private fun CoreBigMonospaceText( } fun onValuePostChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) { - viewState.lastVisibleRow = minOf(viewState.lastVisibleRow, transformedText.lastRowIndex) + updateViewState() viewState.version = Random.nextLong() val event = generateChangeEvent(eventType, changeStartIndex, changeEndExclusiveIndex) @@ -481,6 +500,11 @@ private fun CoreBigMonospaceText( onValuePreChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) text.insertAt(insertPos, textInput) onValuePostChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) +// (transformedText as BigTextImpl).layout() // FIXME remove + updateViewState() + if (log.config.minSeverity <= Severity.Debug) { + (transformedText as BigTextImpl).printDebug("transformedText onType '${textInput.replace("\n", "\\n")}'") + } // update cursor after invoking listeners, because a transformation or change may take place viewState.cursorIndex = minOf(text.length, insertPos + textInput.length) viewState.updateTransformedCursorIndexByOriginal(transformedText) @@ -496,6 +520,11 @@ private fun CoreBigMonospaceText( onValuePreChange(BigTextChangeEventType.Delete, cursor, cursor + 1) text.delete(cursor, cursor + 1) onValuePostChange(BigTextChangeEventType.Delete, cursor, cursor + 1) +// (transformedText as BigTextImpl).layout() // FIXME remove + updateViewState() + if (log.config.minSeverity <= Severity.Debug) { + (transformedText as BigTextImpl).printDebug("transformedText onDelete $direction") + } return true } } @@ -504,6 +533,11 @@ private fun CoreBigMonospaceText( onValuePreChange(BigTextChangeEventType.Delete, cursor - 1, cursor) text.delete(cursor - 1, cursor) onValuePostChange(BigTextChangeEventType.Delete, cursor - 1, cursor) +// (transformedText as BigTextImpl).layout() // FIXME remove + updateViewState() + if (log.config.minSeverity <= Severity.Debug) { + (transformedText as BigTextImpl).printDebug("transformedText onDelete $direction") + } // update cursor after invoking listeners, because a transformation or change may take place viewState.cursorIndex = maxOf(0, cursor - 1) viewState.updateTransformedCursorIndexByOriginal(transformedText) @@ -518,6 +552,18 @@ private fun CoreBigMonospaceText( val tv = remember { TextFieldValue() } // this value is not used + LaunchedEffect(transformedText) { + if (log.config.minSeverity <= Severity.Debug) { + (0..text.length).forEach { + log.d { "findTransformedPositionByOriginalPosition($it) = ${transformedText.findTransformedPositionByOriginalPosition(it)}" } + } + + (0..transformedText.length).forEach { + log.d { "findOriginalPositionByTransformedPosition($it) = ${transformedText.findOriginalPositionByTransformedPosition(it)}" } + } + } + } + Box( modifier = modifier .onGloballyPositioned { @@ -536,6 +582,10 @@ private fun CoreBigMonospaceText( draggedPoint = it if (!isHoldingShiftKey) { val selectedCharIndex = getTransformedCharIndex(x = it.x, y = it.y, mode = ResolveCharPositionMode.Selection) + .let { + viewState.roundedTransformedCursorIndex(it, CursorAdjustDirection.Bidirectional, transformedText, it, true) + } + .also { log.d { "onDragStart selected=$it" } } viewState.transformedSelection = selectedCharIndex..selectedCharIndex viewState.updateSelectionByTransformedSelection(transformedText) viewState.transformedSelectionStart = selectedCharIndex @@ -546,8 +596,15 @@ private fun CoreBigMonospaceText( onDrag = { // onDragStart happens before onDrag log.v { "onDrag ${it.x} ${it.y}" } draggedPoint += it - val selectedCharIndex = getTransformedCharIndex(x = draggedPoint.x, y = draggedPoint.y, mode = ResolveCharPositionMode.Selection) val selectionStart = viewState.transformedSelectionStart + val selectedCharIndex = getTransformedCharIndex(x = draggedPoint.x, y = draggedPoint.y, mode = ResolveCharPositionMode.Selection) + .let { + if (it >= selectionStart) { + viewState.roundedTransformedCursorIndex(it, CursorAdjustDirection.Forward, transformedText, it, true) + } else { + viewState.roundedTransformedCursorIndex(it, CursorAdjustDirection.Backward, transformedText, it, true) + } + } selectionEnd = selectedCharIndex viewState.transformedSelection = minOf(selectionStart, selectionEnd) .. maxOf(selectionStart, selectionEnd) viewState.updateSelectionByTransformedSelection(transformedText) @@ -566,6 +623,9 @@ private fun CoreBigMonospaceText( if (isHoldingShiftKey) { val selectionStart = viewState.transformedSelectionStart selectionEnd = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Selection) + .let { + viewState.roundedTransformedCursorIndex(it, CursorAdjustDirection.Bidirectional, transformedText, it, true) + } log.v { "selectionEnd => $selectionEnd" } viewState.transformedSelection = minOf(selectionStart, selectionEnd) .. maxOf(selectionStart, selectionEnd) viewState.updateSelectionByTransformedSelection(transformedText) @@ -575,6 +635,7 @@ private fun CoreBigMonospaceText( } viewState.transformedCursorIndex = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Cursor) + viewState.roundTransformedCursorIndex(CursorAdjustDirection.Bidirectional, transformedText, viewState.transformedCursorIndex, true) viewState.updateCursorIndexByTransformed(transformedText) if (!isHoldingShiftKey) { // for selection, max possible index is 1 less than that for cursor @@ -694,6 +755,11 @@ private fun CoreBigMonospaceText( viewState.transformedSelection = IntRange.EMPTY // TODO handle Shift key if (viewState.transformedCursorIndex + delta in 0 .. transformedText.length) { viewState.transformedCursorIndex += delta + if (delta > 0) { + viewState.roundTransformedCursorIndex(CursorAdjustDirection.Forward, transformedText, viewState.transformedCursorIndex - delta, false) + } else { + viewState.roundTransformedCursorIndex(CursorAdjustDirection.Backward, transformedText, viewState.transformedCursorIndex, true) + } viewState.updateCursorIndexByTransformed(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex log.v { "set cursor pos LR => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } @@ -724,6 +790,7 @@ private fun CoreBigMonospaceText( } } } + viewState.roundTransformedCursorIndex(CursorAdjustDirection.Bidirectional, transformedText, viewState.transformedCursorIndex, true) viewState.updateCursorIndexByTransformed(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex true @@ -895,6 +962,51 @@ class BigTextViewState { log.d { "updateTransformedCursorIndexByOriginal = $it" } } } + + fun roundTransformedCursorIndex(direction: CursorAdjustDirection, transformedText: BigTextTransformed, compareWithPosition: Int, isOnlyWithinBlock: Boolean) { + transformedCursorIndex = roundedTransformedCursorIndex(transformedCursorIndex, direction, transformedText, compareWithPosition, isOnlyWithinBlock).also { + log.d { "roundedTransformedCursorIndex($transformedCursorIndex, $direction, ..., $compareWithPosition) = $it" } + } + } + + fun roundedTransformedCursorIndex(transformedCursorIndex: Int, direction: CursorAdjustDirection, transformedText: BigTextTransformed, compareWithPosition: Int, isOnlyWithinBlock: Boolean): Int { + val possibleRange = 0 .. transformedText.length + val previousMappedPosition = transformedText.findOriginalPositionByTransformedPosition(compareWithPosition) + when (direction) { + CursorAdjustDirection.Forward, CursorAdjustDirection.Backward -> { + val step = if (direction == CursorAdjustDirection.Forward) 1 else -1 + var delta = 0 + while (transformedCursorIndex + delta in possibleRange) { + if (transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex + delta) != previousMappedPosition) { + return transformedCursorIndex + delta + if (isOnlyWithinBlock) { + // for backward, we find the last index that is same as `previousMappedPosition` + - step + } else { + // for forward, we find the first index that is different from `previousMappedPosition` + 0 + } + } + delta += step + } + // (transformedCursorIndex + delta) is out of range + return transformedCursorIndex + delta - step + } + CursorAdjustDirection.Bidirectional -> { + var delta = 0 + while ((transformedCursorIndex + delta in possibleRange || transformedCursorIndex - delta in possibleRange)) { + if (transformedCursorIndex + delta in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex + delta) != previousMappedPosition) { + return transformedCursorIndex + delta + } + if (transformedCursorIndex - delta in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex - delta) != previousMappedPosition) { + // for backward, we find the last index that is same as `previousMappedPosition` + return transformedCursorIndex - delta + 1 + } + ++delta + } + return transformedCursorIndex + delta - 1 + } + } + } } private enum class ResolveCharPositionMode { @@ -904,3 +1016,7 @@ private enum class ResolveCharPositionMode { enum class TextFBDirection { Forward, Backward } + +enum class CursorAdjustDirection { + Forward, Backward, Bidirectional +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index cd4663da..416c34c8 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -322,6 +322,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c } fun transformReplace(originalRange: IntRange, newText: String, offsetMapping: BigTextTransformOffsetMapping = BigTextTransformOffsetMapping.Incremental) { + logT.d { "transformReplace($originalRange, $newText, $offsetMapping)" } deleteTransformIf(originalRange) transformDelete(originalRange) val incrementalTransformOffsetMappingLength = if (offsetMapping == BigTextTransformOffsetMapping.Incremental) { From 807ecd5f3b998d0c75dd4a68b9a5a98d110fd06c Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 28 Sep 2024 12:35:37 +0800 Subject: [PATCH 095/195] fix clicking in BigMonospaceText would place cursor at one char after the expected position --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 7b95df3d..5735d084 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -994,12 +994,19 @@ class BigTextViewState { CursorAdjustDirection.Bidirectional -> { var delta = 0 while ((transformedCursorIndex + delta in possibleRange || transformedCursorIndex - delta in possibleRange)) { - if (transformedCursorIndex + delta in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex + delta) != previousMappedPosition) { - return transformedCursorIndex + delta + if (transformedCursorIndex + delta + 1 in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex + delta + 1) != previousMappedPosition) { + return transformedCursorIndex + delta + if (transformedCursorIndex + delta - 1 in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex + delta - 1) == previousMappedPosition) { + // position (transformedCursorIndex + delta) is a block, + // while position (transformedCursorIndex + delta + 1) is not a block. + // so return (transformedCursorIndex + delta + 1) + 1 + } else { + 0 + } } - if (transformedCursorIndex - delta in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex - delta) != previousMappedPosition) { + if (transformedCursorIndex - delta - 1 in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex - delta - 1) != previousMappedPosition) { // for backward, we find the last index that is same as `previousMappedPosition` - return transformedCursorIndex - delta + 1 + return transformedCursorIndex - delta //+ 1 } ++delta } From 2a6f87457309e32f649bc2e538a8d7569c8db423 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 29 Sep 2024 10:27:24 +0800 Subject: [PATCH 096/195] add AnnotatedString support to BigMonospaceText (editable syntax highlighted text and style that changes character size are not yet supported) --- .../multiplatform/hellohttp/util/Strings.kt | 16 +++- .../hellohttp/ux/CodeEditorView.kt | 18 +++-- .../ux/bigtext/AnnotatedStringTextBuffer.kt | 56 ++++++++++++++ .../hellohttp/ux/bigtext/BigMonospaceText.kt | 39 +++++++--- .../hellohttp/ux/bigtext/BigText.kt | 14 ++-- .../ux/bigtext/BigTextAsCharSequence.kt | 2 +- .../hellohttp/ux/bigtext/BigTextFieldState.kt | 33 ++++++++- .../hellohttp/ux/bigtext/BigTextImpl.kt | 46 +++++++----- .../hellohttp/ux/bigtext/BigTextLayoutable.kt | 2 +- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 32 +++++--- .../ux/bigtext/BigTextTransformer.kt | 6 +- .../ux/bigtext/BigTextTransformerImpl.kt | 29 +++++--- .../ux/bigtext/InefficientBigText.kt | 13 ++-- .../ux/bigtext/JetpackComposeBigText.kt | 12 +++ .../hellohttp/ux/bigtext/StringTextBuffer.kt | 20 +++++ ...onmentVariableIncrementalTransformation.kt | 5 +- ...yntaxHighlightIncrementalTransformation.kt | 74 +++++++++++++++++++ .../test/bigtext/BigTextVerifyImpl.kt | 14 ++-- 18 files changed, 346 insertions(+), 85 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/StringTextBuffer.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Strings.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Strings.kt index 77dba607..f0646a2d 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Strings.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/Strings.kt @@ -1,10 +1,12 @@ package com.sunnychung.application.multiplatform.hellohttp.util +import androidx.compose.ui.text.AnnotatedString + fun String?.emptyToNull(): String? { return if (this == "") null else this } -fun String.findAllIndicesOfChar(char: Char): List { +fun CharSequence.findAllIndicesOfChar(char: Char): List { val result = mutableListOf() for (i in this.indices) { if (char == this[i]) { @@ -13,3 +15,15 @@ fun String.findAllIndicesOfChar(char: Char): List { } return result } + +fun CharSequence.string(): String = when (this) { + is String -> this + is AnnotatedString -> text + else -> toString() +} + +fun CharSequence.annotatedString(): AnnotatedString = when (this) { + is String -> AnnotatedString(this) + is AnnotatedString -> this + else -> AnnotatedString(toString()) +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 29afd6da..210e997c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -67,6 +67,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextSimpleLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextViewState +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.rememberAnnotatedBigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.rememberBigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldColors import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDefaults @@ -79,6 +80,8 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.Func import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.MultipleVisualTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.SearchHighlightTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableIncrementalTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightIncrementalTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleIncrementalTransformation import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -455,7 +458,7 @@ fun CodeEditorView( // modifier = Modifier.fillMaxHeight(), // ) - val (secondCacheKey, bigTextFieldState) = rememberBigTextFieldState(initialValue = textValue.text) + val (secondCacheKey, bigTextFieldState) = rememberAnnotatedBigTextFieldState(initialValue = textValue.text) val bigTextValue = bigTextFieldState.value.text var bigTextValueId by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(Random.nextLong()) } @@ -570,9 +573,9 @@ fun CodeEditorView( .onEach { log.d { "bigTextFieldState change ${it.changeId}" } onTextChange?.let { onTextChange -> - val string = it.bigText.buildString() - onTextChange(string) - secondCacheKey.value = string + val string = it.bigText.buildCharSequence() as AnnotatedString + onTextChange(string.text) + secondCacheKey.value = string.text } bigTextValueId = it.changeId } @@ -581,7 +584,12 @@ fun CodeEditorView( BigMonospaceTextField( textFieldState = bigTextFieldState.value, visualTransformation = visualTransformationToUse, - textTransformation = remember { EnvironmentVariableIncrementalTransformation() }, // TODO replace this testing transformation + textTransformation = remember { + MultipleIncrementalTransformation(listOf( + JsonSyntaxHighlightIncrementalTransformation(themeColours), + EnvironmentVariableIncrementalTransformation() + )) + }, // TODO replace this testing transformation fontSize = LocalFont.current.codeEditorBodyFontSize, // textStyle = LocalTextStyle.current.copy( // fontFamily = FontFamily.Monospace, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt new file mode 100644 index 00000000..d213dca7 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt @@ -0,0 +1,56 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import com.sunnychung.application.multiplatform.hellohttp.extension.hasIntersectWith +import java.util.TreeMap + +class AnnotatedStringTextBuffer(size: Int) : TextBuffer() { + private val buffer = StringBuilder(size) + + // TODO optimize it to use interval tree when styles that change character width are supported + // otherwise layout would be very slow + private val spanStyles = TreeMap>>() + + override val length: Int + get() = buffer.length + + override fun bufferAppend(text: CharSequence) { + if (text is AnnotatedString) { + val baseStart = buffer.length + text.spanStyles.forEach { + val start = it.start + baseStart + val endExclusive = it.end + baseStart + spanStyles.getOrPut(start) { mutableListOf() } += (start until endExclusive) to it.item + } + buffer.append(text) + return + } + buffer.append(text) + } + + override fun bufferSubstring(start: Int, endExclusive: Int): String { + return buffer.substring(start, endExclusive) + } + + override fun bufferSubSequence(start: Int, endExclusive: Int): CharSequence { + val queryRange = start until endExclusive + return AnnotatedString( + text = buffer.substring(start, endExclusive), + spanStyles = spanStyles.subMap(0, endExclusive) + .flatMap { e -> + e.value.filter { + queryRange hasIntersectWith it.first + } + .map { + AnnotatedString.Range( + item = it.second, + start = it.first.start - start, + end = minOf(endExclusive - start, it.first.endInclusive + 1 - start) + ) + } + }, + paragraphStyles = emptyList() + ) + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 5735d084..1c2963b4 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -80,10 +80,13 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.isCtrlOrCmdPressed import com.sunnychung.application.multiplatform.hellohttp.extension.toTextInput import com.sunnychung.application.multiplatform.hellohttp.util.ComposeUnicodeCharMeasurer +import com.sunnychung.application.multiplatform.hellohttp.util.annotatedString import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.util.string 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 +import com.sunnychung.lib.multiplatform.kdatetime.KInstant import kotlinx.coroutines.launch import kotlin.math.roundToInt import kotlin.random.Random @@ -280,7 +283,7 @@ private fun CoreBigMonospaceText( log.d { "CoreBigMonospaceText recreate BigTextTransformed" } BigTextTransformerImpl(text).also { // log.d { "transformedText = |${it.buildString()}|" } - if (log.config.minSeverity <= Severity.Debug) { + if (log.config.minSeverity <= Severity.Verbose) { it.printDebug("transformedText") } } @@ -305,12 +308,17 @@ private fun CoreBigMonospaceText( // text.setLayouter(textLayouter) // text.setContentWidth(contentWidth) + val startInstant = KInstant.now() + transformedText.onLayoutCallback = { fireOnLayout() } transformedText.setLayouter(textLayouter) transformedText.setContentWidth(contentWidth) + val endInstant = KInstant.now() + log.d { "BigText layout took ${endInstant - startInstant}" } + if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("after init layout") } @@ -364,8 +372,10 @@ private fun CoreBigMonospaceText( val transformedState = remember(text, textTransformation) { if (textTransformation != null) { + val startInstant = KInstant.now() textTransformation.initialize(text, transformedText).also { - log.d { "CoreBigMonospaceText init transformedState ${it.hashCode()}" } + val endInstant = KInstant.now() + log.d { "CoreBigMonospaceText init transformedState ${it.hashCode()} took ${endInstant - startInstant}" } // (transformedText as BigTextImpl).layout() // FIXME remove if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("init transformedState") @@ -434,7 +444,7 @@ private fun CoreBigMonospaceText( fun getTransformedStringWidth(start: Int, endExclusive: Int): Float { return (start .. endExclusive - 1) .map { - val char = transformedText.substring(it..it) + val char = transformedText.substring(it..it).string() if (char == "\n") { // selecting \n shows a narrow width textLayouter.charMeasurer.findCharWidth(" ") } else { @@ -502,7 +512,7 @@ private fun CoreBigMonospaceText( onValuePostChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) // (transformedText as BigTextImpl).layout() // FIXME remove updateViewState() - if (log.config.minSeverity <= Severity.Debug) { + if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("transformedText onType '${textInput.replace("\n", "\\n")}'") } // update cursor after invoking listeners, because a transformation or change may take place @@ -522,7 +532,7 @@ private fun CoreBigMonospaceText( onValuePostChange(BigTextChangeEventType.Delete, cursor, cursor + 1) // (transformedText as BigTextImpl).layout() // FIXME remove updateViewState() - if (log.config.minSeverity <= Severity.Debug) { + if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("transformedText onDelete $direction") } return true @@ -535,7 +545,7 @@ private fun CoreBigMonospaceText( onValuePostChange(BigTextChangeEventType.Delete, cursor - 1, cursor) // (transformedText as BigTextImpl).layout() // FIXME remove updateViewState() - if (log.config.minSeverity <= Severity.Debug) { + if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("transformedText onDelete $direction") } // update cursor after invoking listeners, because a transformation or change may take place @@ -553,13 +563,13 @@ private fun CoreBigMonospaceText( val tv = remember { TextFieldValue() } // this value is not used LaunchedEffect(transformedText) { - if (log.config.minSeverity <= Severity.Debug) { + if (log.config.minSeverity <= Severity.Verbose) { (0..text.length).forEach { - log.d { "findTransformedPositionByOriginalPosition($it) = ${transformedText.findTransformedPositionByOriginalPosition(it)}" } + log.v { "findTransformedPositionByOriginalPosition($it) = ${transformedText.findTransformedPositionByOriginalPosition(it)}" } } (0..transformedText.length).forEach { - log.d { "findOriginalPositionByTransformedPosition($it) = ${transformedText.findOriginalPositionByTransformedPosition(it)}" } + log.v { "findOriginalPositionByTransformedPosition($it) = ${transformedText.findOriginalPositionByTransformedPosition(it)}" } } } } @@ -699,7 +709,7 @@ private fun CoreBigMonospaceText( val textToCopy = text.substring( viewState.selection.first.. viewState.selection.last ) - clipboardManager.setText(AnnotatedString(textToCopy)) + clipboardManager.setText(textToCopy.annotatedString()) true } isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.V -> { @@ -833,6 +843,8 @@ private fun CoreBigMonospaceText( viewState.firstVisibleRow = firstRowIndex viewState.lastVisibleRow = lastRowIndex + val startInstant = KInstant.now() + with(density) { (firstRowIndex..lastRowIndex).forEach { i -> val startIndex = transformedText.findRowPositionStartIndexByRowIndex(i) @@ -868,7 +880,7 @@ private fun CoreBigMonospaceText( ) log.v { "text R$i TT $startIndex ..< $endIndex: $rowText" } BasicText( - text = rowText, + text = rowText.annotatedString(), style = textStyle, maxLines = 1, softWrap = false, @@ -877,7 +889,7 @@ private fun CoreBigMonospaceText( if (isEditable && isFocused && viewState.transformedCursorIndex in startIndex .. cursorDisplayRangeEndIndex) { var x = 0f (startIndex + 1 .. viewState.transformedCursorIndex).forEach { - x += textLayouter.charMeasurer.findCharWidth(transformedText.substring(it - 1.. it - 1)) + x += textLayouter.charMeasurer.findCharWidth(transformedText.substring(it - 1.. it - 1).string()) } BigTextFieldCursor( lineHeight = lineHeight.toDp(), @@ -889,6 +901,9 @@ private fun CoreBigMonospaceText( } } } + + val endInstant = KInstant.now() + log.d { "Declare BigText content for render took ${endInstant - startInstant}" } } } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt index b751df63..1dd3796b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -9,26 +9,28 @@ interface BigText { fun buildString(): String - fun substring(start: Int, endExclusive: Int): String + fun buildCharSequence(): CharSequence - fun substring(range: IntRange): String = substring(range.start, range.endInclusive + 1) + fun substring(start: Int, endExclusive: Int): CharSequence + + fun substring(range: IntRange): CharSequence = substring(range.start, range.endInclusive + 1) fun subSequence(startIndex: Int, endIndex: Int) = substring(startIndex, endIndex) - fun append(text: String): Int + fun append(text: CharSequence): Int - fun insertAt(pos: Int, text: String): Int + fun insertAt(pos: Int, text: CharSequence): Int fun delete(start: Int, endExclusive: Int): Int fun delete(range: IntRange): Int = delete(range.start, range.endInclusive + 1) - fun replace(start: Int, endExclusive: Int, text: String) { + fun replace(start: Int, endExclusive: Int, text: CharSequence) { delete(start, endExclusive) insertAt(start, text) } - fun replace(range: IntRange, text: String) { + fun replace(range: IntRange, text: CharSequence) { delete(range) insertAt(range.start, text) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt index 63f97346..264e654c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextAsCharSequence.kt @@ -9,7 +9,7 @@ class BigTextAsCharSequence(internal val bigText: BigTextImpl) : CharSequence { } override fun subSequence(startIndex: Int, endIndex: Int): CharSequence { - return bigText.substring(startIndex, endIndex) + return bigText.subSequence(startIndex, endIndex) } override fun toString(): String = bigText.buildString() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt index 6fd2d1b5..34480891 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt @@ -4,6 +4,7 @@ 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 import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -23,7 +24,37 @@ fun rememberBigTextFieldState(initialValue: String = ""): Pair, MutableState> { + val secondCacheKey = rememberSaveable { mutableStateOf(initialValue) } + val state = rememberSaveable { + log.d { "cache miss 1" } + mutableStateOf(BigTextFieldState(BigText.createFromLargeAnnotatedString(initialValue), BigTextViewState())) + } + if (initialValue !== secondCacheKey.value) { + log.d { "cache miss. old key2 = ${secondCacheKey.value.abbr()}; new key2 = ${initialValue.abbr()}" } + secondCacheKey.value = initialValue + state.value = BigTextFieldState(BigText.createFromLargeAnnotatedString(initialValue), BigTextViewState()) + } + return secondCacheKey to state +} + +@Composable +fun rememberAnnotatedBigTextFieldState(initialValue: String = ""): Pair, MutableState> { + val secondCacheKey = rememberSaveable { mutableStateOf(initialValue) } + val state = rememberSaveable { + log.d { "cache miss 1" } + mutableStateOf(BigTextFieldState(BigText.createFromLargeAnnotatedString(AnnotatedString(initialValue)), BigTextViewState())) + } + if (initialValue !== secondCacheKey.value) { + log.d { "cache miss. old key2 = ${secondCacheKey.value.abbr()}; new key2 = ${initialValue.abbr()}" } + secondCacheKey.value = initialValue + state.value = BigTextFieldState(BigText.createFromLargeAnnotatedString(AnnotatedString(initialValue)), BigTextViewState()) + } + return secondCacheKey to state +} + +private fun CharSequence.abbr(): CharSequence { return if (this.length > 20) { substring(0 .. 19) } else { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index ae2db0f7..8a42727f 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -36,7 +36,12 @@ internal var isD = false private const val EPS = 1e-4f -open class BigTextImpl : BigText, BigTextLayoutable { +open class BigTextImpl( + val chunkSize: Int = 2 * 1024 * 1024, // 2 MB + val textBufferFactory: ((capacity: Int) -> TextBuffer) = { StringTextBuffer(it) }, + val charSequenceBuilderFactory: ((capacity: Int) -> Appendable) = { StringBuilder(it) }, + val charSequenceFactory: ((Appendable) -> CharSequence) = { it: Appendable -> it.toString() }, +) : BigText, BigTextLayoutable { open val tree: LengthTree = LengthTree( object : RedBlackTreeComputations { override fun recomputeFromLeaf(it: RedBlackTree.Node) = recomputeAggregatedValues(it) @@ -47,8 +52,6 @@ open class BigTextImpl : BigText, BigTextLayoutable { ) val buffers = mutableListOf() - val chunkSize: Int - var layouter: TextLayouter? = null @JvmName("_setLayouter") protected set @@ -59,13 +62,8 @@ open class BigTextImpl : BigText, BigTextLayoutable { internal var changeHook: BigTextChangeHook? = null - constructor() { - chunkSize = 2 * 1024 * 1024 // 2 MB - } - - constructor(chunkSize: Int) { + init { require(chunkSize > 0) { "chunkSize must be positive" } - this.chunkSize = chunkSize } fun RedBlackTree2.findNodeByLineBreaks(index: Int): Pair.Node, Int>? { @@ -339,7 +337,7 @@ open class BigTextImpl : BigText, BigTextLayoutable { return BigTextNodeValue() } - private fun insertChunkAtPosition(position: Int, chunkedString: String) { + private fun insertChunkAtPosition(position: Int, chunkedString: CharSequence) { log.d { "insertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) // if (position == 64) { @@ -349,7 +347,7 @@ open class BigTextImpl : BigText, BigTextLayoutable { buffers.last().takeIf { it.length + chunkedString.length <= chunkSize } } else null if (buffer == null) { - buffer = TextBuffer(chunkSize) + buffer = textBufferFactory(chunkSize) buffers += buffer } require(buffer.length + chunkedString.length <= chunkSize) @@ -541,7 +539,15 @@ open class BigTextImpl : BigText, BigTextLayoutable { } } - override fun substring(start: Int, endExclusive: Int): String { // O(lg L + (e - s)) + override fun buildCharSequence(): CharSequence { + val builder = charSequenceBuilderFactory(length) + tree.forEach { + builder.append(it.buffer.subSequence(it.renderBufferStart, it.renderBufferEndExclusive)) + } + return charSequenceFactory(builder) + } + + override fun substring(start: Int, endExclusive: Int): CharSequence { // O(lg L + (e - s)) require(start <= endExclusive) { "start should be <= endExclusive" } require(0 <= start) { "Invalid start" } require(endExclusive <= length) { "endExclusive $endExclusive is out of bound. length = $length" } @@ -550,7 +556,7 @@ open class BigTextImpl : BigText, BigTextLayoutable { return "" } - val result = StringBuilder(endExclusive - start) + val result = charSequenceBuilderFactory(endExclusive - start) var node = tree.findNodeByRenderCharIndex(start) ?: throw IllegalStateException("Cannot find string node for position $start") var nodeStartPos = findRenderPositionStart(node) var numRemainCharsToCopy = endExclusive - start @@ -572,10 +578,10 @@ open class BigTextImpl : BigText, BigTextLayoutable { } } - return result.toString() + return charSequenceFactory(result) } - fun findLineString(lineIndex: Int): String { + fun findLineString(lineIndex: Int): CharSequence { require(0 <= lineIndex) { "lineIndex $lineIndex must be non-negative." } require(lineIndex <= numOfLines) { "lineIndex $lineIndex out of bound, numOfLines = $numOfLines." } @@ -612,7 +618,7 @@ open class BigTextImpl : BigText, BigTextLayoutable { return substring(startCharIndex, endCharIndex) // includes the last '\n' char } - override fun findRowString(rowIndex: Int): String { + override fun findRowString(rowIndex: Int): CharSequence { /** * @param rowOffset 0 = start of buffer; 1 = char index of the first row break */ @@ -648,7 +654,7 @@ open class BigTextImpl : BigText, BigTextLayoutable { return substring(startCharIndex, endCharIndex) // includes the last '\n' char } - override fun append(text: String): Int { + override fun append(text: CharSequence): Int { return insertAt(length, text) // var start = 0 // while (start < text.length) { @@ -664,7 +670,7 @@ open class BigTextImpl : BigText, BigTextLayoutable { // } } - override fun insertAt(pos: Int, text: String): Int { + override fun insertAt(pos: Int, text: CharSequence): Int { var start = 0 val prevNode = tree.findNodeByCharIndex(maxOf(0, pos - 1)) val nodeStart = prevNode?.let { findPositionStart(it) }?.also { @@ -1000,7 +1006,7 @@ open class BigTextImpl : BigText, BigTextLayoutable { (lineBreakIndexFrom..lineBreakIndexTo).forEach { lineBreakEntryIndex -> val lineBreakCharIndex = buffer.lineOffsetStarts[lineBreakEntryIndex] - val subsequence = buffer.subSequence(charStartIndexInBuffer, lineBreakCharIndex) + val subsequence = buffer.substring(charStartIndexInBuffer, lineBreakCharIndex) logL.d { "node ${nodeValue.debugKey()} buf #${nodeValue.bufferIndex} line break #$lineBreakEntryIndex seq $charStartIndexInBuffer ..< $lineBreakCharIndex" } val (rowCharOffsets, _) = layouter.layoutOneLine( @@ -1050,7 +1056,7 @@ open class BigTextImpl : BigText, BigTextLayoutable { // val subsequence = buffer.subSequence(charStartIndexInBuffer, nodeValue.bufferOffsetEndExclusive) val readRowUntilPos = nextBoundary //nodeValue.bufferOffsetEndExclusive //minOf(nodeValue.bufferOffsetEndExclusive, endPosExclusive - nodeStartPos + nodeValue.bufferOffsetStart) logL.d { "node ${nodeValue.debugKey()} last row seq $charStartIndexInBuffer ..< ${readRowUntilPos}. start = $nodeStartPos" } - val subsequence = buffer.subSequence(charStartIndexInBuffer, readRowUntilPos) + val subsequence = buffer.substring(charStartIndexInBuffer, readRowUntilPos) val (rowCharOffsets, lastRowOccupiedWidth) = layouter.layoutOneLine( subsequence, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt index fbc8fd30..5d383f39 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt @@ -20,7 +20,7 @@ interface BigTextLayoutable { fun findLineIndexByRowIndex(rowIndex: Int): Int - fun findRowString(rowIndex: Int): String + fun findRowString(rowIndex: Int): CharSequence fun findRowIndexByPosition(position: Int): Int diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 63ca0d13..a53ba993 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -76,9 +76,7 @@ open class BigTextNodeValue : Comparable, DebuggableNode = sortedSetOf() // var rowOffsetStarts: List = emptyList() - val length: Int - get() = buffer.length + abstract val length: Int - fun append(text: String): IntRange { - val start = buffer.length - buffer.append(text) + fun append(text: CharSequence): IntRange { + val start = length + bufferAppend(text) // text.forEachIndexed { index, c -> // if (c == '\n') { // lineOffsetStarts += start + index @@ -103,16 +100,29 @@ class TextBuffer(val size: Int) { return start until start + text.length } + abstract fun bufferAppend(text: CharSequence) + override fun toString(): String { - return buffer.toString() + return subSequence(0, length).toString() } - fun subSequence(start: Int, endExclusive: Int): CharSequence { + open fun subSequence(start: Int, endExclusive: Int): CharSequence { if (start >= endExclusive) { return "" } - return buffer.subSequence(start, endExclusive) + return bufferSubSequence(start, endExclusive) } + + open fun substring(start: Int, endExclusive: Int): String { + if (start >= endExclusive) { + return "" + } + return bufferSubstring(start, endExclusive) + } + + abstract fun bufferSubstring(start: Int, endExclusive: Int): String + + abstract fun bufferSubSequence(start: Int, endExclusive: Int): CharSequence } enum class BufferOwnership { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt index 844b03e4..aefe1c3b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt @@ -8,13 +8,13 @@ interface BigTextTransformer { // fun applyStyle(style: SpanStyle, range: IntRange) - fun append(text: String): Int + fun append(text: CharSequence): Int - fun insertAt(pos: Int, text: String): Int + fun insertAt(pos: Int, text: CharSequence): Int fun delete(range: IntRange): Int - fun replace(range: IntRange, text: String, offsetMapping: BigTextTransformOffsetMapping) + fun replace(range: IntRange, text: CharSequence, offsetMapping: BigTextTransformOffsetMapping) fun restoreToOriginal(range: IntRange) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 416c34c8..b8008928 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -15,7 +15,12 @@ val logT = Logger(object : MutableLoggerConfig { override var minSeverity: Severity = Severity.Info }, tag = "BigText.Transform") -class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(chunkSize = delegate.chunkSize), BigTextTransformed { +class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( + chunkSize = delegate.chunkSize, + textBufferFactory = delegate.textBufferFactory, + charSequenceBuilderFactory = delegate.charSequenceBuilderFactory, + charSequenceFactory = delegate.charSequenceFactory, +), BigTextTransformed { private var hasReachedExtensiveSearch: Boolean = false @@ -118,14 +123,14 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c } } - private fun transformInsertChunkAtPosition(position: Int, chunkedString: String, offsetMapping: BigTextTransformOffsetMapping, incrementalTransformOffsetMappingLength: Int) { + private fun transformInsertChunkAtPosition(position: Int, chunkedString: CharSequence, offsetMapping: BigTextTransformOffsetMapping, incrementalTransformOffsetMappingLength: Int) { logT.d { "transformInsertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) var buffer = if (buffers.isNotEmpty()) { buffers.last().takeIf { it.length + chunkedString.length <= chunkSize } } else null if (buffer == null) { - buffer = TextBuffer(chunkSize) + buffer = textBufferFactory(chunkSize) buffers += buffer } require(buffer.length + chunkedString.length <= chunkSize) @@ -146,11 +151,11 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c } } - fun transformInsert(pos: Int, text: String): Int { + fun transformInsert(pos: Int, text: CharSequence): Int { return transformInsert(pos, text, BigTextTransformOffsetMapping.WholeBlock, 0) } - private fun transformInsert(pos: Int, text: String, offsetMapping: BigTextTransformOffsetMapping, incrementalTransformOffsetMappingLength: Int): Int { + private fun transformInsert(pos: Int, text: CharSequence, offsetMapping: BigTextTransformOffsetMapping, incrementalTransformOffsetMappingLength: Int): Int { logT.d { "transformInsert($pos, \"$text\")" } require(pos in 0 .. originalLength) { "Out of bound. pos = $pos, originalLength = $originalLength" } @@ -171,7 +176,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c val append = minOf(available, start) start -= append val incrementalOffsetLength = maxOf(0, minOf(append, incrementalTransformOffsetMappingLength - start)) - transformInsertChunkAtPosition(pos, text.substring(start until start + append), offsetMapping, incrementalOffsetLength) + transformInsertChunkAtPosition(pos, text.subSequence(start until start + append), offsetMapping, incrementalOffsetLength) last = buffers.last().length } val renderPositionStart = findRenderPositionStart(tree.findNodeByCharIndex(pos)!!) @@ -180,7 +185,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c return text.length } - fun transformInsertAtOriginalEnd(text: String): Int = transformInsert(originalLength, text) + fun transformInsertAtOriginalEnd(text: CharSequence): Int = transformInsert(originalLength, text) fun deleteOriginal(originalRange: IntRange) { require(0 <= originalRange.start) { "Invalid start" } @@ -321,7 +326,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c return - originalRange.length } - fun transformReplace(originalRange: IntRange, newText: String, offsetMapping: BigTextTransformOffsetMapping = BigTextTransformOffsetMapping.Incremental) { + fun transformReplace(originalRange: IntRange, newText: CharSequence, offsetMapping: BigTextTransformOffsetMapping = BigTextTransformOffsetMapping.Incremental) { logT.d { "transformReplace($originalRange, $newText, $offsetMapping)" } deleteTransformIf(originalRange) transformDelete(originalRange) @@ -519,15 +524,15 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl(c return nodeStart + minOf(node.value.bufferLength, indexFromNodeStart) } - override fun insertAt(pos: Int, text: String): Int = transformInsert(pos, text) + override fun insertAt(pos: Int, text: CharSequence): Int = transformInsert(pos, text) - override fun append(text: String): Int = transformInsertAtOriginalEnd(text) + override fun append(text: CharSequence): Int = transformInsertAtOriginalEnd(text) override fun delete(start: Int, endExclusive: Int): Int = transformDelete(start until endExclusive) - override fun replace(range: IntRange, text: String) = transformReplace(range, text) + override fun replace(range: IntRange, text: CharSequence) = transformReplace(range, text) - override fun replace(range: IntRange, text: String, offsetMapping: BigTextTransformOffsetMapping) = transformReplace(range, text, offsetMapping) + override fun replace(range: IntRange, text: CharSequence, offsetMapping: BigTextTransformOffsetMapping) = transformReplace(range, text, offsetMapping) override fun restoreToOriginal(range: IntRange) { val renderPositionAtOriginalStart = findTransformedPositionByOriginalPosition(range.start) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt index ad7220ee..5c10b952 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt @@ -1,6 +1,7 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import com.sunnychung.application.multiplatform.hellohttp.extension.insert +import com.sunnychung.application.multiplatform.hellohttp.util.string class InefficientBigText(text: String) : BigText { private var string: String = text @@ -10,19 +11,21 @@ class InefficientBigText(text: String) : BigText { override fun buildString(): String = string - override fun substring(start: Int, endExclusive: Int): String = + override fun buildCharSequence(): CharSequence = string + + override fun substring(start: Int, endExclusive: Int): CharSequence = string.substring(start, endExclusive) - override fun substring(range: IntRange): String = + override fun substring(range: IntRange): CharSequence = substring(range.first, range.last) - override fun append(text: String): Int { + override fun append(text: CharSequence): Int { string += text return text.length } - override fun insertAt(pos: Int, text: String): Int { - string = string.insert(pos, text) + override fun insertAt(pos: Int, text: CharSequence): Int { + string = string.insert(pos, text.string()) return text.length } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt new file mode 100644 index 00000000..7d1f29bc --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt @@ -0,0 +1,12 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import androidx.compose.ui.text.AnnotatedString + +fun BigText.Companion.createFromLargeAnnotatedString(initialContent: AnnotatedString) = BigTextImpl( + textBufferFactory = { AnnotatedStringTextBuffer(it) }, + charSequenceBuilderFactory = { AnnotatedString.Builder(it) }, + charSequenceFactory = { (it as AnnotatedString.Builder).toAnnotatedString() }, +).apply { + log.d { "createFromLargeAnnotatedString ${initialContent.length}" } + append(initialContent) +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/StringTextBuffer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/StringTextBuffer.kt new file mode 100644 index 00000000..16b088c6 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/StringTextBuffer.kt @@ -0,0 +1,20 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +class StringTextBuffer(size: Int) : TextBuffer() { + private val buffer = StringBuilder(size) + + override val length: Int + get() = buffer.length + + override fun bufferAppend(text: CharSequence) { + buffer.append(text) + } + + override fun bufferSubstring(start: Int, endExclusive: Int): String { + return buffer.substring(start, endExclusive) + } + + override fun bufferSubSequence(start: Int, endExclusive: Int): CharSequence { + return buffer.subSequence(start, endExclusive) + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt index 6bf9f9a2..63752bea 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt @@ -3,6 +3,7 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.inc import com.sunnychung.application.multiplatform.hellohttp.extension.hasIntersectWith import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.util.string import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEventType @@ -42,7 +43,7 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati val anotherBracket = originalText.findPositionByPattern(it - processLengthLimit, it - 1, "\${{", TextFBDirection.Backward) log.d { "EnvironmentVariableIncrementalTransformation search end start=$it" } if (anotherBracket != null) { - val variableName = originalText.substring(anotherBracket + "\${{".length, it) + val variableName = originalText.substring(anotherBracket + "\${{".length, it).string() log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } transformer.replace(anotherBracket until it + "}}".length, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) } @@ -53,7 +54,7 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati val anotherBracket = originalText.findPositionByPattern(it + "\${{".length, it + processLengthLimit, "}}", TextFBDirection.Forward) log.d { "EnvironmentVariableIncrementalTransformation search start end=$it" } if (anotherBracket != null) { - val variableName = originalText.substring(it + "\${{".length, anotherBracket) + val variableName = originalText.substring(it + "\${{".length, anotherBracket).string() log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } transformer.replace(it until anotherBracket + "}}".length, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt new file mode 100644 index 00000000..1ef0d684 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt @@ -0,0 +1,74 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformOffsetMapping +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformer +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.IncrementalTextTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor + +private val TOKEN_REGEX = "(? { + val objectKeyStyle = SpanStyle(color = colours.syntaxColor.objectKey) + val stringLiteralStyle = SpanStyle(color = colours.syntaxColor.stringLiteral) + val numberLiteralStyle = SpanStyle(color = colours.syntaxColor.numberLiteral) + val booleanTrueLiteralStyle = SpanStyle(color = colours.syntaxColor.booleanTrueLiteral) + val booleanFalseLiteralStyle = SpanStyle(color = colours.syntaxColor.booleanFalseLiteral) + val nothingLiteralStyle = SpanStyle(color = colours.syntaxColor.nothingLiteral) + + val subPatterns = listOf( + OBJECT_KEY_REGEX to objectKeyStyle, + STRING_LITERAL_REGEX to stringLiteralStyle, + NUMBER_LITERAL_REGEX to numberLiteralStyle, + BOOLEAN_TRUE_LITERAL_REGEX to booleanTrueLiteralStyle, + BOOLEAN_FALSE_LITERAL_REGEX to booleanFalseLiteralStyle, + NOTHING_LITERAL_REGEX to nothingLiteralStyle, + ) + + override fun initialize(text: BigText, transformer: BigTextTransformer) { + val s = text.buildString() + val spans = mutableListOf>() + TOKEN_REGEX.findAll(s).forEach { m -> + val match = (m.groups[1] ?: m.groups[2])!! + subPatterns.firstOrNull { (pattern, style) -> + val subMatch = pattern.matchEntire(match.value) + if (subMatch != null) { + val range = if (subMatch.groups.size > 1) { + subMatch.groups[1]!!.range + .let { it.start + match.range.start .. it.endInclusive + match.range.start } + } else { + match.range + } + spans += AnnotatedString.Range(style, range.start, range.endInclusive + 1) + true + } else { + false + } + } + } + + if (spans.isNotEmpty()) { + transformer.replace( + range = 0 until text.length, + text = AnnotatedString(s, spans), + offsetMapping = BigTextTransformOffsetMapping.Incremental + ) + } + } + + override fun onTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { + + } + + +} diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index c4b32a76..2e33681e 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -55,14 +55,18 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { return r } - override fun substring(start: Int, endExclusive: Int): String { + override fun buildCharSequence(): CharSequence { + return buildString() + } + + override fun substring(start: Int, endExclusive: Int): CharSequence { val r = bigTextImpl.substring(start, endExclusive) val tr = stringImpl.substring(start, endExclusive) assertEquals(tr, r, "substring mismatch") return r } - override fun append(text: String): Int { + override fun append(text: CharSequence): Int { println("append ${text.length}") val r = bigTextImpl.append(text) if (isTransform) { @@ -75,7 +79,7 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { return r } - override fun insertAt(pos: Int, text: String): Int { + override fun insertAt(pos: Int, text: CharSequence): Int { println("insert $pos, ${text.length}") val r = bigTextImpl.insertAt(pos, text) if (isTransform) { @@ -111,11 +115,11 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { return r } - override fun replace(range: IntRange, text: String) { + override fun replace(range: IntRange, text: CharSequence) { replace(range, text, BigTextTransformOffsetMapping.Incremental) } - fun replace(range: IntRange, text: String, offsetMapping: BigTextTransformOffsetMapping) { + fun replace(range: IntRange, text: CharSequence, offsetMapping: BigTextTransformOffsetMapping) { println("replace $range -> ${text.length}") var r: Int = 0 printDebugIfError { From 79209b4847766f8112e21ead9721676b8b94d9dc Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 2 Oct 2024 00:50:05 +0800 Subject: [PATCH 097/195] fix BigText incremental offset transformation could not accept original insert in the middle, and update behavior of transform insert on the same position to always insert to the right of existing transformation, with an exception that would insert to the left of existing incremental offset transformations --- .../implementation/TransformReplace.md | 126 +++++++++ .../hellohttp/ux/bigtext/BigTextImpl.kt | 124 +++++++-- .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 3 + .../ux/bigtext/BigTextTransformNodeValue.kt | 2 +- .../ux/bigtext/BigTextTransformerImpl.kt | 253 +++++++++++++++--- .../hellohttp/ux/bigtext/LengthNodeValue.kt | 2 + .../test/bigtext/BigTextVerifyImpl.kt | 19 +- .../BigTextTransformPositionCalculatorTest.kt | 14 + .../transform/BigTextTransformerImplTest.kt | 49 +++- 9 files changed, 512 insertions(+), 80 deletions(-) create mode 100644 doc/bigtext/implementation/TransformReplace.md diff --git a/doc/bigtext/implementation/TransformReplace.md b/doc/bigtext/implementation/TransformReplace.md new file mode 100644 index 00000000..53d016d9 --- /dev/null +++ b/doc/bigtext/implementation/TransformReplace.md @@ -0,0 +1,126 @@ +# Transform Replaces + +## Block-offset transforms + +- Transformed positions are never mapped from original positions +- New transform inserts to the same position of existing block-offset transforms would append the new text to the end of it + +### `simpleBlockTransformReplaces` first test case + +```mermaid +block-beta + columns 20 + 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 + + O40["<"] O41["r"] O42["o"] O43["w"] O44[" "] O45["b"] O46["r"] O47["e"] O48["a"] O49["k"] O50["<"] O51[" "] O52["s"] O53["h"] O54["o"] O55["u"] O56["l"] O57["d"] O58[" "] O59["h"] + space:20 + T40["<"] T41["r"] T42["o"] T43["w"] T44[" "] T45["-"] T46["+"] T47["-"] T48["+"] T49["-"] T50["h"] T51["o"] T52["u"] T53["l"] T54["d"] T55[" "] T56["h"] T57["<"] T58["a"] T59["p"] + space:20 + o40["<"] o41["r"] o42["o"] o43["w"] o44[" "] o45["b"] o46["r"] o47["e"] o48["a"] o49["k"] o50["<"] o51[" "] o52["s"] o53["h"] o54["o"] o55["u"] o56["l"] o57["d"] o58[" "] o59["h"] + + style O45 fill:#d00 + style O46 fill:#d00 + style O47 fill:#d00 + style O48 fill:#d00 + style O49 fill:#d00 + style O50 fill:#d00 + style O51 fill:#d00 + style O52 fill:#d00 + + style T45 fill:#060 + style T46 fill:#060 + style T47 fill:#060 + style T48 fill:#060 + style T49 fill:#060 + + style o45 fill:#d00 + style o46 fill:#d00 + style o47 fill:#d00 + style o48 fill:#d00 + style o49 fill:#d00 + style o50 fill:#d00 + style o51 fill:#d00 + style o52 fill:#d00 + + O43-->T43 + O44-->T44 + O53-->T50 + O54-->T51 + + O45 --> T50 + O46 --> T50 + O47 -.-> T50 + O48 -.-> T50 + O49 -.-> T50 + O50 -.-> T50 + O51 -.-> T50 + O52 -.-> T50 + + T43 --> o43 + T44 --> o44 + T50 --> o53 + T51 --> o54 + + T45 --> o45 + T46 --> o45 + T47 --> o45 + T48 --> o45 + T49 --> o45 +``` + +## Incremental-offset transforms + +- Transformed positions are mapped 1-to-1 from original positions, until the earliest end of either one +- New transform inserts to the same position of existing incremental-offset transforms would append the new text to the start of it + +### `mixedTransformReplaces` first test case + +```mermaid +block-beta + columns 20 + 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 + + O30["<"] O31["B"] O32["C"] O33["D"] O34["E"] O35["F"] O36["G"] O37["H"] O38["I"] O39["J"] O40["<"] O41["r"] O42["o"] O43["w"] O44[" "] O45["b"] O46["r"] O47["e"] O48["a"] O49["k"] + space:20 + space:20 + T30["<"] T31["B"] T32["l"] T33["o"] T34["n"] T35["g"] T36[" "] T37["r"] T38["e"] T39["p"] T40["l"] T41["a"] T42["c"] T43["e"] T44["m"] T45["e"] T46["n"] T47["t"] T48["H"] T49["I"] + space:20 + o30["<"] o31["B"] o32["C"] o33["D"] o34["E"] o35["F"] o36["G"] o37["H"] o38["I"] o39["J"] o40["<"] o41["r"] o42["o"] o43["w"] o44[" "] o45["b"] o46["r"] o47["e"] o48["a"] o49["k"] + + style O32 fill:#d00 + style O33 fill:#d00 + style O34 fill:#d00 + style O35 fill:#d00 + style O36 fill:#d00 + style T32 fill:#060 + style T33 fill:#060 + style T34 fill:#060 + style T35 fill:#060 + style T36 fill:#060 + style T37 fill:#060 + style T38 fill:#060 + style T39 fill:#060 + style T40 fill:#060 + style T41 fill:#060 + style T42 fill:#060 + style T43 fill:#060 + style T44 fill:#060 + style T45 fill:#060 + style T46 fill:#060 + style T47 fill:#060 + + O30-->T30 + O31-->T31 + O37-->T48 + O38-->T49 + + O32 -.-> T32 + O33 -.-> T33 + O34 -.-> T34 + O35 -.-> T35 + O36 -.-> T36 +``` + +## Transform inserts + +Transform inserts behaves the same as block-offset transform replaces without deletions. diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 8a42727f..2106f7a1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -363,35 +363,63 @@ open class BigTextImpl( } } - protected fun insertChunkAtPosition(position: Int, chunkedStringLength: Int, ownership: BufferOwnership, buffer: TextBuffer, range: IntRange, newNodeConfigurer: BigTextNodeValue.() -> Unit) { - var node = tree.findNodeByCharIndex(position) // TODO optimize, don't do twice - var nodeStart = node?.let { findPositionStart(it) } // TODO optimize, don't do twice - val findPosition = maxOf(0, position - 1) - if (node != null && findPosition < nodeStart!!) { - node = tree.prevNode(node) - nodeStart = node?.let { findPositionStart(it) } - } - if (node != null) { - log.d { "> existing node (${node!!.value.debugKey()}) ${node!!.value.bufferOwnership.name.first()} $nodeStart .. ${nodeStart!! + node!!.value.bufferLength - 1}" } - require(maxOf(0, position - 1) in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1 || node.value.bufferLength == 0) { - printDebug() - findPositionStart(node!!) - "Found node ${node!!.value.debugKey()} but it is not in searching range" + protected fun insertChunkAtPosition(position: Int, chunkedStringLength: Int, ownership: BufferOwnership, buffer: TextBuffer, range: IntRange, isInsertAtRightmost: Boolean = false, newNodeConfigurer: BigTextNodeValue.() -> Unit): Int { +// var node = tree.findNodeByCharIndex(position, !isInsertAtRightmost) // TODO optimize, don't do twice + fun findNodeJustBeforePosition(position: Int, isThrowErrorIfMissing: Boolean): Pair.Node?, Int?> { + // kotlin compiler bug: `node` and `nodeStart` are wrongly interpreted as the outer one, thus smart cast is impossible + // make the variable names different to workaround + var node_ = tree.findNodeByCharIndex(position, true) // TODO optimize, don't do twice + var nodeStart_ = node_?.let { findPositionStart(it) } // TODO optimize, don't do twice + val findPosition = maxOf(0, position - 1) + if (node_ != null && findPosition < nodeStart_!!) { + node_ = tree.prevNode(node_!!) + nodeStart_ = node_?.let { findPositionStart(it) } + } + if (node_ != null) { + log.d { "> existing node (${node_!!.value.debugKey()}) ${node_!!.value.bufferOwnership.name.first()} $nodeStart_ .. ${nodeStart_!! + node_!!.value.bufferLength - 1}" } + require( + findPosition in nodeStart_!!..nodeStart_!! + node_!!.value.bufferLength - 1 || node_!!.value.bufferLength == 0 + ) { + printDebug() + findPositionStart(node_!!) + "Found node ${node_!!.value.debugKey()} but it is not in searching range" + } + } else if (isThrowErrorIfMissing && !tree.isEmpty) { + throw IllegalStateException("Node not found for position ${maxOf(0, position - 1)}") } - } else if (!tree.isEmpty) { - throw IllegalStateException("Node not found for position ${maxOf(0, position - 1)}") + return node_ to nodeStart_ + } + var (node, nodeStart) = findNodeJustBeforePosition( + position = if (isInsertAtRightmost) position + 1 else position, + isThrowErrorIfMissing = !isInsertAtRightmost + ) + if (node == null && isInsertAtRightmost) { + log.w { "Node $position not found. Find ${position - 1} instead" } + val r = findNodeJustBeforePosition(position = position, isThrowErrorIfMissing = true) + node = r.first + nodeStart = r.second } var insertDirection: InsertDirection = InsertDirection.Undefined val toBeRelayouted = mutableListOf() var newContentNode: BigTextNodeValue? = null - val newNodeValues = if (node != null && position > 0 && position in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1) { + val newNodeValues = /*if (isInsertAtRightmost && node != null && position == nodeStart!! + node.value.bufferLength - 1) { + log.d { "> create new node right" } + insertDirection = InsertDirection.Right + listOf(createNodeValue().apply { + this.newNodeConfigurer() + newContentNode = this + }) + } else*/ if (node != null && position > 0 && position in nodeStart!! .. nodeStart!! + node.value.bufferLength - 1) { val splitAtIndex = position - nodeStart log.d { "> split at $splitAtIndex" } val oldEnd = node.value.bufferOffsetEndExclusive val secondPartNodeValue = createNodeValue().apply { // the second part of the old string bufferIndex = node!!.value.bufferIndex - bufferOffsetStart = node!!.value.bufferOffsetStart + splitAtIndex - bufferOffsetEndExclusive = oldEnd + updateRightValueDuringNodeSplit( + rightNodeValue = this, + oldNodeValue = node!!.value, + splitAtIndex = splitAtIndex + ) this.buffer = node!!.value.buffer this.bufferOwnership = node!!.value.bufferOwnership @@ -405,11 +433,25 @@ open class BigTextImpl( * If A == position, then existing node is empty and thus can be deleted. */ if (splitAtIndex > 0) { - node.value.bufferOffsetEndExclusive = node.value.bufferOffsetStart + splitAtIndex + updateLeftValueDuringNodeSplit( + leftNodeValue = node!!.value, + oldNodeValue = node!!.value, + splitAtIndex = splitAtIndex + ) } else { + val prevNode = tree.prevNode(node) tree.delete(node) - node = tree.findNodeByCharIndex(maxOf(0, position - 1)) - insertDirection = InsertDirection.Left + node = prevNode + if (node != null && isInsertAtRightmost) { + val nodeStart = findPositionStart(node) + if (nodeStart + node.value.bufferLength <= position) { + insertDirection = InsertDirection.Right + } else { + insertDirection = InsertDirection.Left + } + } else { + insertDirection = InsertDirection.Left + } } // require(splitAtIndex + chunkedStringLength <= chunkSize) // this check appears to be not guarding anything toBeRelayouted += secondPartNodeValue @@ -438,6 +480,7 @@ open class BigTextImpl( recomputeAggregatedValues(node) emptyList() } + var leftmostNewNodeValue: RedBlackTree.Node? = null if (newNodeValues.isNotEmpty() && insertDirection == InsertDirection.Left) { node = if (node != null) { tree.insertLeft(node, newNodeValues.first()) @@ -447,15 +490,20 @@ open class BigTextImpl( (1 .. newNodeValues.lastIndex).forEach { node = tree.insertLeft(node!!, newNodeValues[it]) } + leftmostNewNodeValue = node } else { - newNodeValues.forEach { - if (node?.value?.leftStringLength == position) { + val existingNodePositionStart = node?.value?.leftStringLength + newNodeValues.reversed().forEach { + node = if (existingNodePositionStart == position && insertDirection != InsertDirection.Right) { tree.insertLeft(node!!, it) // insert before existing node } else if (node != null) { tree.insertRight(node!!, it) } else { tree.insertValue(it) } + if (leftmostNewNodeValue == null) { + leftmostNewNodeValue = node + } } } @@ -470,6 +518,21 @@ open class BigTextImpl( } log.v { inspect("Finish I " + node?.value?.debugKey()) } + + return leftmostNewNodeValue?.let { findRenderPositionStart(it) } ?: 0 + } + + protected open fun updateRightValueDuringNodeSplit(rightNodeValue: BigTextNodeValue, oldNodeValue: BigTextNodeValue, splitAtIndex: Int) { + with (rightNodeValue) { + bufferOffsetStart = oldNodeValue.bufferOffsetStart + splitAtIndex + bufferOffsetEndExclusive = oldNodeValue.bufferOffsetEndExclusive + } + } + + protected open fun updateLeftValueDuringNodeSplit(leftNodeValue: BigTextNodeValue, oldNodeValue: BigTextNodeValue, splitAtIndex: Int) { + with (leftNodeValue) { + bufferOffsetEndExclusive = oldNodeValue.bufferOffsetStart + splitAtIndex + } } open fun computeCurrentNodeProperties(nodeValue: BigTextNodeValue, left: RedBlackTree.Node?) = with (nodeValue) { @@ -706,7 +769,7 @@ open class BigTextImpl( } } - protected fun deleteUnchecked(start: Int, endExclusive: Int): Int { + protected fun deleteUnchecked(start: Int, endExclusive: Int, deleteMarker: BigTextNodeValue? = null): Int { if (start == endExclusive) { return 0 } @@ -716,6 +779,7 @@ open class BigTextImpl( var node: RedBlackTree.Node? = tree.findNodeByCharIndex(endExclusive - 1, isIncludeMarkerNodes = false) var nodeRange = charIndexRangeOfNode(node!!) val newNodesInDescendingOrder = mutableListOf() + var hasAddedDeleteMarker = false while (node?.isNotNil() == true && start <= nodeRange.endInclusive) { if (isD && nodeRange.start == 0) { isD = true @@ -735,6 +799,11 @@ open class BigTextImpl( } } if (start in nodeRange.start + 1 .. nodeRange.last) { + if (!hasAddedDeleteMarker && deleteMarker != null) { + newNodesInDescendingOrder += deleteMarker + hasAddedDeleteMarker = true + } + // need to split val splitAtIndex = start - nodeRange.start log.d { "Split S at $splitAtIndex" } @@ -762,6 +831,11 @@ open class BigTextImpl( } } + if (!hasAddedDeleteMarker && deleteMarker != null) { + newNodesInDescendingOrder += deleteMarker + hasAddedDeleteMarker = true + } + newNodesInDescendingOrder.asReversed().forEach { if (node != null) { node = tree.insertRight(node!!, it) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index a53ba993..23267eba 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -46,6 +46,9 @@ open class BigTextNodeValue : Comparable, DebuggableNode.Node? = null private val key = RANDOM.nextInt() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt index ac40be58..0c99e71a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformNodeValue.kt @@ -17,7 +17,7 @@ class BigTextTransformNodeValue : BigTextNodeValue() { var transformedBufferStart: Int = -1 var transformedBufferEndExclusive: Int = -1 - var transformOffsetMapping: BigTextTransformOffsetMapping = BigTextTransformOffsetMapping.WholeBlock + override var transformOffsetMapping: BigTextTransformOffsetMapping = BigTextTransformOffsetMapping.WholeBlock var incrementalTransformOffsetMappingLength = 0 override val renderBufferStart: Int diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index b8008928..38f7384e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -123,7 +123,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( } } - private fun transformInsertChunkAtPosition(position: Int, chunkedString: CharSequence, offsetMapping: BigTextTransformOffsetMapping, incrementalTransformOffsetMappingLength: Int) { + private fun transformInsertChunkAtPosition(position: Int, chunkedString: CharSequence, offsetMapping: BigTextTransformOffsetMapping, incrementalTransformOffsetMappingLength: Int, isReplaceOriginal: Boolean): Int { logT.d { "transformInsertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) var buffer = if (buffers.isNotEmpty()) { @@ -135,11 +135,16 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( } require(buffer.length + chunkedString.length <= chunkSize) val range = buffer.append(chunkedString) - insertChunkAtPosition(position, chunkedString.length, BufferOwnership.Owned, buffer, range) { + return insertChunkAtPosition(position, chunkedString.length, BufferOwnership.Owned, buffer, range, true) { this as BigTextTransformNodeValue bufferIndex = -1 - bufferOffsetStart = -1 - bufferOffsetEndExclusive = -1 + if (isReplaceOriginal && incrementalTransformOffsetMappingLength > 0) { + bufferOffsetStart = position + bufferOffsetEndExclusive = position + incrementalTransformOffsetMappingLength + } else { + bufferOffsetStart = -1 + bufferOffsetEndExclusive = -1 + } transformedBufferStart = range.start transformedBufferEndExclusive = range.endInclusive + 1 transformOffsetMapping = offsetMapping @@ -152,35 +157,111 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( } fun transformInsert(pos: Int, text: CharSequence): Int { - return transformInsert(pos, text, BigTextTransformOffsetMapping.WholeBlock, 0) + return transformInsert(pos, text, BigTextTransformOffsetMapping.WholeBlock, 0, false) } - private fun transformInsert(pos: Int, text: CharSequence, offsetMapping: BigTextTransformOffsetMapping, incrementalTransformOffsetMappingLength: Int): Int { + private fun transformInsert(pos: Int, text: CharSequence, offsetMapping: BigTextTransformOffsetMapping, incrementalTransformOffsetMappingLength: Int, isReplaceOriginal: Boolean): Int { logT.d { "transformInsert($pos, \"$text\")" } require(pos in 0 .. originalLength) { "Out of bound. pos = $pos, originalLength = $originalLength" } - /** - * As insert position is searched by leftmost of original string position, - * the insert is done by inserting to the same point in reverse order, - * which is different from BigTextImpl#insertAt. - */ + var renderPositionStart: Int? = null - var start = text.length - var last = buffers.lastOrNull()?.length - while (start > 0) { - if (last == null || last >= chunkSize) { + when (offsetMapping) { + BigTextTransformOffsetMapping.WholeBlock -> { +// /** +// * As insert position is searched by leftmost of original string position, +// * the insert is done by inserting to the same point in reverse order, +// * which is different from BigTextImpl#insertAt. +// */ +// +// var start = text.length +// var last = buffers.lastOrNull()?.length +//// var isTheLastChunk = true +// while (start > 0) { +// if (last == null || last >= chunkSize) { +//// buffers += TextBuffer() +// last = 0 +// } +// val available = chunkSize - last +// val append = minOf(available, start) +// start -= append +// +// val incrementalOffsetLength = maxOf(0, minOf(append, incrementalTransformOffsetMappingLength - start)) +// // the last chunk values at least 1-char length +//// val incrementalOffsetLength = if (isTheLastChunk && incrementalTransformOffsetMappingLength > 0) { +//// maxOf(1, minOf(append, incrementalTransformOffsetMappingLength - start)) +//// } else { +//// maxOf(0, minOf(append, incrementalTransformOffsetMappingLength - start - 1)) +//// } +// transformInsertChunkAtPosition(pos, text.subSequence(start until start + append), offsetMapping, incrementalOffsetLength, isReplaceOriginal) +// last = buffers.last().length +//// isTheLastChunk = false +// } + + var start = 0 + var last = buffers.lastOrNull()?.length + while (start < text.length) { + if (last == null || last >= chunkSize) { // buffers += TextBuffer() - last = 0 + last = 0 + } + val available = chunkSize - last + val append = minOf(available, text.length - start) + + val incrementalOffsetLength = maxOf(0, minOf(append, incrementalTransformOffsetMappingLength - start)) + // the last chunk values at least 1-char length +// val incrementalOffsetLength = if (isTheLastChunk && incrementalTransformOffsetMappingLength > 0) { +// maxOf(1, minOf(append, incrementalTransformOffsetMappingLength - start)) +// } else { +// maxOf(0, minOf(append, incrementalTransformOffsetMappingLength - start - 1)) +// } + transformInsertChunkAtPosition(pos, text.subSequence(start until start + append), offsetMapping, incrementalOffsetLength, isReplaceOriginal).let { + if (renderPositionStart == null) { + renderPositionStart = it + } + } + start += append + last = buffers.last().length + } } - val available = chunkSize - last - val append = minOf(available, start) - start -= append - val incrementalOffsetLength = maxOf(0, minOf(append, incrementalTransformOffsetMappingLength - start)) - transformInsertChunkAtPosition(pos, text.subSequence(start until start + append), offsetMapping, incrementalOffsetLength) - last = buffers.last().length + + BigTextTransformOffsetMapping.Incremental -> { + var start = 0 + var last = buffers.lastOrNull()?.length + var insertOffset = 0 + while (start < text.length) { + if (last == null || last >= chunkSize) { +// buffers += TextBuffer() + last = 0 + } + val available = chunkSize - last + val append = minOf(available, text.length - start) + + val incrementalOffsetLength = maxOf(0, minOf(append, incrementalTransformOffsetMappingLength - start)) + // the last chunk values at least 1-char length +// val incrementalOffsetLength = if (isTheLastChunk && incrementalTransformOffsetMappingLength > 0) { +// maxOf(1, minOf(append, incrementalTransformOffsetMappingLength - start)) +// } else { +// maxOf(0, minOf(append, incrementalTransformOffsetMappingLength - start - 1)) +// } + transformInsertChunkAtPosition(pos + insertOffset, text.subSequence(start until start + append), offsetMapping, incrementalOffsetLength, isReplaceOriginal).let { + if (renderPositionStart == null) { + renderPositionStart = it + } + } + insertOffset += incrementalOffsetLength + start += append + last = buffers.last().length + } + } + } + + if (renderPositionStart == null) { + renderPositionStart = 0 } - val renderPositionStart = findRenderPositionStart(tree.findNodeByCharIndex(pos)!!) - layout(maxOf(0, renderPositionStart - 1), minOf(length, renderPositionStart + text.length + 1)) + +// val renderPositionStart = findRenderPositionStart(tree.findNodeByCharIndex(pos)!!) + layout(maxOf(0, renderPositionStart!! - 1), minOf(length, renderPositionStart!! + text.length + 1)) // layout() return text.length } @@ -190,10 +271,14 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( fun deleteOriginal(originalRange: IntRange) { require(0 <= originalRange.start) { "Invalid start" } require((originalRange.endInclusive + 1) in 0 .. originalLength) { "Out of bound. endExclusive = ${originalRange.endInclusive + 1}, originalLength = $originalLength" } - super.deleteUnchecked(originalRange.start, originalRange.endInclusive + 1) + super.deleteUnchecked(originalRange.start, originalRange.endInclusive + 1, null) } fun transformDelete(originalRange: IntRange): Int { + return transformDelete(originalRange = originalRange, isAddMarker = true, deleteMarkerRange = originalRange) + } + + private fun transformDelete(originalRange: IntRange, isAddMarker: Boolean, deleteMarkerRange: IntRange): Int { logT.d { "transformDelete($originalRange)" } require(originalRange.start <= originalRange.endInclusive + 1) { "start should be <= endExclusive" } require(0 <= originalRange.start) { "Invalid start" } @@ -206,23 +291,46 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( val startNode = tree.findNodeByCharIndex(originalRange.start)!! val renderStartPos = findRenderPositionStart(startNode) val buffer = startNode.value.buffer // the buffer is not used. just to prevent NPE - super.deleteUnchecked(originalRange.start, originalRange.endInclusive + 1) - insertChunkAtPosition(originalRange.start, originalRange.length, BufferOwnership.Owned, buffer, -2 .. -2) { + super.deleteUnchecked(originalRange.start, originalRange.endInclusive + 1, if (isAddMarker) createDeleteMarkerNodeValue(deleteMarkerRange) else null) + if (isAddMarker) { +// insertDeleteMarker(originalRange) + } + layout(maxOf(0, renderStartPos - 1), minOf(length, renderStartPos + 1)) +// tree.visitInPostOrder { recomputeAggregatedValues(it) } // + logT.d { inspect("after transformDelete $originalRange") } + return - originalRange.length + } + + private fun createDeleteMarkerNodeValue(originalRange: IntRange): BigTextNodeValue { + val dummyBuffer = StringTextBuffer(1) + return createNodeValue().apply { this as BigTextTransformNodeValue bufferIndex = -1 bufferOffsetStart = 0 bufferOffsetEndExclusive = originalRange.length transformedBufferStart = -2 transformedBufferEndExclusive = -2 - this.buffer = buffer + this.buffer = dummyBuffer + this.bufferOwnership = BufferOwnership.Owned + + leftStringLength = 0 + } + } + + private fun insertDeleteMarker(originalRange: IntRange) { + val dummyBuffer = StringTextBuffer(1) + insertChunkAtPosition(originalRange.start, originalRange.length, BufferOwnership.Owned, dummyBuffer, -2..-2, true) { + this as BigTextTransformNodeValue + bufferIndex = -1 + bufferOffsetStart = 0 + bufferOffsetEndExclusive = originalRange.length + transformedBufferStart = -2 + transformedBufferEndExclusive = -2 + this.buffer = dummyBuffer this.bufferOwnership = BufferOwnership.Owned leftStringLength = 0 } - layout(maxOf(0, renderStartPos - 1), minOf(length, renderStartPos + 1)) -// tree.visitInPostOrder { recomputeAggregatedValues(it) } // - logT.d { inspect("after transformDelete $originalRange") } - return - originalRange.length } /** @@ -328,14 +436,62 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( fun transformReplace(originalRange: IntRange, newText: CharSequence, offsetMapping: BigTextTransformOffsetMapping = BigTextTransformOffsetMapping.Incremental) { logT.d { "transformReplace($originalRange, $newText, $offsetMapping)" } - deleteTransformIf(originalRange) - transformDelete(originalRange) +// deleteTransformIf(originalRange) val incrementalTransformOffsetMappingLength = if (offsetMapping == BigTextTransformOffsetMapping.Incremental) { minOf(originalRange.length, newText.length) } else { 0 } - transformInsert(originalRange.start, newText, offsetMapping, incrementalTransformOffsetMappingLength) +// transformDelete(originalRange = originalRange, isAddMarker = incrementalTransformOffsetMappingLength <= 0) + transformDelete(originalRange = originalRange, isAddMarker = incrementalTransformOffsetMappingLength <= 0 || originalRange.length > incrementalTransformOffsetMappingLength, deleteMarkerRange = if (incrementalTransformOffsetMappingLength <= 0) { + originalRange + } else { + originalRange.endInclusive + 1 - maxOf(0, originalRange.length - incrementalTransformOffsetMappingLength) .. originalRange.endInclusive + }) + transformInsert( + pos = originalRange.start, + text = newText, + offsetMapping = offsetMapping, + incrementalTransformOffsetMappingLength = incrementalTransformOffsetMappingLength, +// incrementalTransformOffsetMappingLength = incrementalTransformOffsetMappingLength - 1, + isReplaceOriginal = incrementalTransformOffsetMappingLength > 0, + ) +// if (incrementalTransformOffsetMappingLength > 0 && originalRange.length > incrementalTransformOffsetMappingLength) { +// insertDeleteMarker(originalRange.endInclusive + 1 - maxOf(0, originalRange.length - incrementalTransformOffsetMappingLength) .. originalRange.endInclusive) +// } + } + + override fun updateRightValueDuringNodeSplit( + rightNodeValue: BigTextNodeValue, + oldNodeValue: BigTextNodeValue, + splitAtIndex: Int + ) { + super.updateRightValueDuringNodeSplit(rightNodeValue, oldNodeValue, splitAtIndex) + with (rightNodeValue as BigTextTransformNodeValue) { + oldNodeValue as BigTextTransformNodeValue + transformOffsetMapping = oldNodeValue.transformOffsetMapping + incrementalTransformOffsetMappingLength = maxOf(0, oldNodeValue.incrementalTransformOffsetMappingLength - splitAtIndex) + if (oldNodeValue.transformedBufferStart >= 0) { + transformedBufferStart = oldNodeValue.transformedBufferStart + splitAtIndex + transformedBufferEndExclusive = oldNodeValue.transformedBufferEndExclusive + } + } + } + + override fun updateLeftValueDuringNodeSplit( + leftNodeValue: BigTextNodeValue, + oldNodeValue: BigTextNodeValue, + splitAtIndex: Int + ) { + super.updateLeftValueDuringNodeSplit(leftNodeValue, oldNodeValue, splitAtIndex) + with (leftNodeValue as BigTextTransformNodeValue) { + oldNodeValue as BigTextTransformNodeValue + transformOffsetMapping = oldNodeValue.transformOffsetMapping + incrementalTransformOffsetMappingLength = minOf(splitAtIndex, oldNodeValue.incrementalTransformOffsetMappingLength) + if (oldNodeValue.transformedBufferStart >= 0) { + transformedBufferEndExclusive = oldNodeValue.transformedBufferStart + splitAtIndex + } + } } override fun computeCurrentNodeProperties(nodeValue: BigTextNodeValue, left: RedBlackTree.Node?) = with (nodeValue) { @@ -370,7 +526,8 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( return transformedStart + if (node.value.bufferOwnership == BufferOwnership.Delegated) { indexFromNodeStart } else if ((node.value as? BigTextTransformNodeValue)?.transformOffsetMapping == BigTextTransformOffsetMapping.Incremental) { - indexFromNodeStart - (node.value as BigTextTransformNodeValue).incrementalTransformOffsetMappingLength +// indexFromNodeStart - (node.value as BigTextTransformNodeValue).incrementalTransformOffsetMappingLength + minOf((node.value as BigTextTransformNodeValue).incrementalTransformOffsetMappingLength, indexFromNodeStart) } else { node.value.currentRenderLength } @@ -416,25 +573,35 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( var incrementalTransformLength = 0 var incrementalTransformLimit = 0 - var n = firstMarkerNode as RedBlackTree.Node + var n = node as RedBlackTree.Node + var isBreakIfReachAnyIncrementalChunk = false while (true) { - if (n.value.transformOffsetMapping == BigTextTransformOffsetMapping.Incremental && n.value.currentTransformedLength > 0) { + if (n.value.transformOffsetMapping == BigTextTransformOffsetMapping.Incremental && n.value.transformedBufferEndExclusive - n.value.transformedBufferStart > 0) { + if (isBreakIfReachAnyIncrementalChunk) { // this chunk does not own by this offset, but previous offset with buffer length at least 1 + break + } incrementalTransformLength += n.value.currentTransformedLength incrementalTransformLimit += n.value.incrementalTransformOffsetMappingLength + } else if (n.value.transformOffsetMapping == BigTextTransformOffsetMapping.WholeBlock) { + isBreakIfReachAnyIncrementalChunk = true } - if (n === node) { + if (n === firstMarkerNode) { break } - n = tree.nextNode(n as RedBlackTree.Node) as RedBlackTree.Node + n = tree.prevNode(n as RedBlackTree.Node) as RedBlackTree.Node } if (incrementalTransformLimit > 0) { // incremental replacement - return transformedStart - maxOf(0, incrementalTransformLength - minOf(incrementalTransformLimit, indexFromNodeStart)) +// return transformedStart - maxOf(0, incrementalTransformLength - minOf(incrementalTransformLimit, indexFromNodeStart)) + +// val transformedStartBeforeMarkers = findRenderPositionStart(firstMarkerNode) + return transformedStart + minOf(incrementalTransformLimit, indexFromNodeStart) } // return transformedStart + indexFromNodeStart - return transformedStart + if (node.value.bufferOwnership == BufferOwnership.Delegated) { + return transformedStart + if (node.value.bufferOwnership == BufferOwnership.Delegated || incrementalTransformLength > 0) { indexFromNodeStart } else { - node.value.currentRenderLength + 0 +// node.value.currentRenderLength } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthNodeValue.kt index e98b0afd..d92c7180 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/LengthNodeValue.kt @@ -10,4 +10,6 @@ interface LengthNodeValue { val leftRenderLength: Int val currentRenderLength: Int + + val transformOffsetMapping: BigTextTransformOffsetMapping } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index 2e33681e..4be0157b 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -82,13 +82,15 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { override fun insertAt(pos: Int, text: CharSequence): Int { println("insert $pos, ${text.length}") val r = bigTextImpl.insertAt(pos, text) +// val offset = transformOffsetsByPosition.subMap(0, pos).values.sum().also { + val offset = (transformOffsetsMappingByPosition.subMap(0, true, pos, true).values.sum()).also { + println("VerifyImpl pos $pos offset $it") + } if (isTransform) { transformOffsetsByPosition[pos] = (transformOffsetsByPosition[pos] ?: 0) + text.length transformOffsetsMappingByPosition[pos] = (transformOffsetsMappingByPosition[pos] ?: 0) + text.length } - val pos = pos + transformOffsetsByPosition.subMap(0, pos).values.sum().also { - println("VerifyImpl pos $pos offset $it") - } + val pos = pos + offset stringImpl.insertAt(pos, text) // transformOps += TransformOp(pos until pos + text.length, BigTextTransformOffsetMapping.WholeBlock) verify() @@ -174,6 +176,17 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { val t = bigTextImpl as BigTextTransformerImpl val originalLength = originalLength if (isDebug) { + println( + " ${ + (0 until maxOf(t.delegate.length, t.length)).joinToString("") { + if (it % 10 != 0) { + (it % 10).toString() + } else { + ((it % 100) / 10).toString() + } + } + }" + ) println("Original: ${t.delegate.buildString()}") println("Transformed: ${t.buildString()}") } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt index cf15e87b..d5aa94ba 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformPositionCalculatorTest.kt @@ -47,6 +47,7 @@ class BigTextTransformPositionCalculatorTest { // isD = true // } v.verifyPositionCalculation() + isD = true v.insertAt(21, "xyzxxyyzz") // if (chunkSize == 16) { // isD = true @@ -156,6 +157,8 @@ class BigTextTransformPositionCalculatorTest { v.verifyPositionCalculation() v.replace(4 .. 5, "=") + v.bigTextImpl.printDebug("After replacement '='") + if (chunkSize == 64) isD = true v.verifyPositionCalculation() } @@ -178,6 +181,7 @@ class BigTextTransformPositionCalculatorTest { v.replace(55 .. 63, "-+-+-", BigTextTransformOffsetMapping.WholeBlock) v.verifyPositionCalculation() + if (chunkSize == 64) isD = true v.replace(65 .. 68, "some relatively long string that is longer than a chunk", BigTextTransformOffsetMapping.WholeBlock) if (chunkSize == 64) isD = true v.verifyPositionCalculation() @@ -234,6 +238,7 @@ class BigTextTransformPositionCalculatorTest { v.replace(15 .. 23, "!?", replaceMapping) v.verifyPositionCalculation() v.insertAt(15, "inserted text 15") + isD = true v.verifyPositionCalculation() v.replace(0 .. 2, "-+-+-", replaceMapping) @@ -291,22 +296,31 @@ class BigTextTransformPositionCalculatorTest { val v = BigTextVerifyImpl(tt) val originalLength = v.originalLength +// isD = true v.replace(32 .. 36, "long replacement", BigTextTransformOffsetMapping.Incremental) + tt.printDebug("after replacement") v.verifyPositionCalculation() +// isD = true v.replace(15 .. 23, "!?", BigTextTransformOffsetMapping.WholeBlock) + tt.printDebug("after replacement !?") v.verifyPositionCalculation() v.replace(0 .. 2, "-+-+-", BigTextTransformOffsetMapping.WholeBlock) v.verifyPositionCalculation() +// isD = true v.replace(originalLength - 12 until originalLength, "*-*-*", BigTextTransformOffsetMapping.Incremental) +// isD = true v.verifyPositionCalculation() +// if (chunkSize == 64) isD = true v.replace(3 .. 3, "some relatively long string that is longer than a chunk", BigTextTransformOffsetMapping.Incremental) +// if (chunkSize == 64) isD = true v.verifyPositionCalculation() v.replace(4 .. 5, "=", BigTextTransformOffsetMapping.WholeBlock) + if (chunkSize == 64) isD = true v.verifyPositionCalculation() } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt index 5ecce278..5407fb29 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt @@ -55,9 +55,9 @@ class BigTextTransformerImplTest { original.append("12345678901234567890") isD = false val transformed = BigTextTransformerImpl(original) - transformed.transformInsert(14, "WXYZ") - if (chunkSize == 16) { isD = true } transformed.transformInsert(14, "KJI") + if (chunkSize == 16) { isD = true } + transformed.transformInsert(14, "WXYZ") transformed.printDebug() @@ -73,9 +73,9 @@ class BigTextTransformerImplTest { val original = BigTextImpl(chunkSize = chunkSize) original.append("12345678901234567890") val transformed = BigTextTransformerImpl(original) - transformed.transformInsert(0, "ABCDEFG") - transformed.transformInsert(0, "WXYZ") transformed.transformInsert(0, "KJI") + transformed.transformInsert(0, "WXYZ") + transformed.transformInsert(0, "ABCDEFG") transformed.printDebug() @@ -91,9 +91,9 @@ class BigTextTransformerImplTest { val original = BigTextImpl(chunkSize = chunkSize) original.append("12345678901234567890") val transformed = BigTextTransformerImpl(original) - transformed.transformInsert(20, "ABCDEFG") - transformed.transformInsert(20, "WXYZ") transformed.transformInsert(20, "KJI") + transformed.transformInsert(20, "WXYZ") + transformed.transformInsert(20, "ABCDEFG") transformed.printDebug() @@ -555,7 +555,7 @@ class BigTextTransformerImplTest { transformed.transformInsertAtOriginalEnd("(end)") transformed.transformInsert(69, "!") - "12345678abcd90123456789012345678901234567890ABCDEFGHIJabcdefghij!@#\$%aabb!(end)qwertyuiop".let { expected -> + "12345678abcd90123456789012345678901234567890ABCDEFGHIJabcdefghij!@#\$%aabbqwertyuiop(end)!".let { expected -> assertEquals(expected, transformed.buildString()) assertAllSubstring(expected, transformed) } @@ -752,7 +752,7 @@ class BigTextTransformerImplTest { transformed.transformReplace(0 .. 1, "@") original.replace(0 .. 1, "") - "@*".let { expected -> + "*".let { expected -> assertEquals(expected, transformed.buildString()) assertAllSubstring(expected, transformed) } @@ -957,6 +957,39 @@ class BigTextTransformerImplTest { } } + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun transformReplaceThenInsertToOriginalAtMiddle(chunkSize: Int) { + listOf("EEEEEE", "E".repeat(70)).forEach { insertContent -> + val initialText = "12345678901234567890123456789012345678901234567890123456789012345678901234567890" + val original = BigTextImpl(chunkSize = chunkSize) + original.append(initialText) + val transformed = BigTextTransformerImpl(original) + + transformed.replace(3..10, "abcdef", BigTextTransformOffsetMapping.Incremental) + "123abcdef234567890123456789012345678901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertEquals(initialText, original.buildString()) + assertAllSubstring(expected, transformed) + } + + transformed.printDebug("before insert") + + original.insertAt(6, insertContent) + + transformed.printDebug("after insert") + + "123abc${insertContent}def234567890123456789012345678901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals( + "123456${insertContent}78901234567890123456789012345678901234567890123456789012345678901234567890", + original.buildString() + ) + } + } + @BeforeEach fun beforeEach() { isD = false From 2ab1f34ba0bcd2de6fdfb04c65c42a230ea84c35 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 2 Oct 2024 00:50:22 +0800 Subject: [PATCH 098/195] fix missing file --- .../MultipleIncrementalTransformation.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt new file mode 100644 index 00000000..233a2577 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt @@ -0,0 +1,22 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformer +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.IncrementalTextTransformation + +class MultipleIncrementalTransformation(val transformations: List>) : IncrementalTextTransformation { + override fun initialize(text: BigText, transformer: BigTextTransformer): Any? { + transformations.forEach { + it.initialize(text, transformer) + } + return Unit + } + + override fun onTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Any?) { + transformations.forEach { + (it as IncrementalTextTransformation).onTextChange(change, transformer, context) + } + } + +} From e75ffe5888dc06322340c6eec588b490e42542e8 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 2 Oct 2024 23:22:54 +0800 Subject: [PATCH 099/195] fix BigText incremental offset transformation could not accept original deletions in the middle --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 15 +++- .../ux/bigtext/BigTextTransformerImpl.kt | 7 +- .../transform/BigTextTransformerImplTest.kt | 86 +++++++++++++++++++ .../transform/BigTextTransformerLayoutTest.kt | 6 +- 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 2106f7a1..f8c86e47 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -531,6 +531,7 @@ open class BigTextImpl( protected open fun updateLeftValueDuringNodeSplit(leftNodeValue: BigTextNodeValue, oldNodeValue: BigTextNodeValue, splitAtIndex: Int) { with (leftNodeValue) { + bufferOffsetStart = oldNodeValue.bufferOffsetStart bufferOffsetEndExclusive = oldNodeValue.bufferOffsetStart + splitAtIndex } } @@ -790,8 +791,11 @@ open class BigTextImpl( log.d { "Split E at $splitAtIndex" } newNodesInDescendingOrder += createNodeValue().apply { // the second part of the existing string bufferIndex = node!!.value.bufferIndex // FIXME transform - bufferOffsetStart = node!!.value.bufferOffsetStart + splitAtIndex - bufferOffsetEndExclusive = node!!.value.bufferOffsetEndExclusive + updateRightValueDuringNodeSplit( + rightNodeValue = this, + oldNodeValue = node!!.value, + splitAtIndex = splitAtIndex + ) buffer = node!!.value.buffer bufferOwnership = node!!.value.bufferOwnership @@ -809,8 +813,11 @@ open class BigTextImpl( log.d { "Split S at $splitAtIndex" } newNodesInDescendingOrder += createNodeValue().apply { // the first part of the existing string bufferIndex = node!!.value.bufferIndex - bufferOffsetStart = node!!.value.bufferOffsetStart - bufferOffsetEndExclusive = node!!.value.bufferOffsetStart + splitAtIndex + updateLeftValueDuringNodeSplit( + leftNodeValue = this, + oldNodeValue = node!!.value, + splitAtIndex = splitAtIndex + ) buffer = node!!.value.buffer bufferOwnership = node!!.value.bufferOwnership diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 38f7384e..3a3530fd 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -271,7 +271,11 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( fun deleteOriginal(originalRange: IntRange) { require(0 <= originalRange.start) { "Invalid start" } require((originalRange.endInclusive + 1) in 0 .. originalLength) { "Out of bound. endExclusive = ${originalRange.endInclusive + 1}, originalLength = $originalLength" } - super.deleteUnchecked(originalRange.start, originalRange.endInclusive + 1, null) + super.deleteUnchecked( + start = findOriginalPositionByTransformedPosition(findTransformedPositionByOriginalPosition(originalRange.start)), + endExclusive = findOriginalPositionByTransformedPosition(findTransformedPositionByOriginalPosition(originalRange.endInclusive + 1)), + deleteMarker = null + ) } fun transformDelete(originalRange: IntRange): Int { @@ -489,6 +493,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( transformOffsetMapping = oldNodeValue.transformOffsetMapping incrementalTransformOffsetMappingLength = minOf(splitAtIndex, oldNodeValue.incrementalTransformOffsetMappingLength) if (oldNodeValue.transformedBufferStart >= 0) { + transformedBufferStart = oldNodeValue.transformedBufferStart transformedBufferEndExclusive = oldNodeValue.transformedBufferStart + splitAtIndex } } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt index 5407fb29..181b6624 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt @@ -896,6 +896,30 @@ class BigTextTransformerImplTest { } } + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun deleteAndReplaceOverlapped(chunkSize: Int) { + val initial = "1234567890223456789032345678904234567890_234567890623456789072345678908234567890\n" + val t = BigTextImpl(chunkSize = chunkSize).apply { + append(initial) + } + val tt = BigTextTransformerImpl(t) + tt.replace(43 .. 60, "def") // incremental replace + assertEquals( + expected = initial + .replaceRange(43 .. 60, "def"), + actual = tt.buildString() + ) + + tt.delete(42 .. 43) + assertEquals( + expected = initial + .replaceRange(44 .. 60, "ef") + .replaceRange(42 .. 43, ""), + actual = tt.buildString() + ) + } + @ParameterizedTest @ValueSource(ints = [1048576, 64, 16]) fun restoreToOriginal(chunkSize: Int) { @@ -990,6 +1014,68 @@ class BigTextTransformerImplTest { } } + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun transformReplaceThenDeleteOriginalAtMiddle(chunkSize: Int) { + val initialText = "12345678901234567890123456789012345678901234567890123456789012345678901234567890" + val original = BigTextImpl(chunkSize = chunkSize) + original.append(initialText) + val transformed = BigTextTransformerImpl(original) + + transformed.replace(3..10, "abcdef", BigTextTransformOffsetMapping.Incremental) + "123abcdef234567890123456789012345678901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertEquals(initialText, original.buildString()) + assertAllSubstring(expected, transformed) + } + + transformed.printDebug("before delete") + + original.delete(6 .. 7) + + transformed.printDebug("after delete") + + "123abcf234567890123456789012345678901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals( + "123456901234567890123456789012345678901234567890123456789012345678901234567890", + original.buildString() + ) + + original.delete(5 .. 5) + "123abf234567890123456789012345678901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals( + "12345901234567890123456789012345678901234567890123456789012345678901234567890", + original.buildString() + ) + + original.delete(5 .. 5) // equivalent to initial index 8 + "123ab234567890123456789012345678901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals( + "1234501234567890123456789012345678901234567890123456789012345678901234567890", + original.buildString() + ) + + isD = true + original.delete(5 .. 5) // equivalent to initial index 9, out of replacement range + "123ab34567890123456789012345678901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertAllSubstring(expected, transformed) + } + assertEquals( + "123451234567890123456789012345678901234567890123456789012345678901234567890", + original.buildString() + ) + } + @BeforeEach fun beforeEach() { isD = false diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt index 717a31d7..ba094845 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -563,14 +563,16 @@ class BigTextTransformerLayoutTest { tt.delete(42 .. 43) verifyBigTextImplAgainstTestString( testString = initial - .replaceRange(42 .. 72, "") + .replaceRange(44 .. 72, "ef") + .replaceRange(42 .. 43, "") .replaceRange(29 .. 38, "") , bigTextImpl = tt ) tt.delete(38 .. 43) verifyBigTextImplAgainstTestString( testString = initial - .replaceRange(29 .. 72, "") + .replaceRange(44 .. 72, "ef") + .replaceRange(29 .. 43, "") , bigTextImpl = tt ) } From 18326379b90624806def9a830fce521b3da20678 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 2 Oct 2024 23:58:31 +0800 Subject: [PATCH 100/195] fix exceptions and incorrect layout when inserting a line break at the end of a BigText --- .../multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt | 6 +++--- .../hellohttp/ux/bigtext/BigTextTransformerImpl.kt | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index f8c86e47..872e75ed 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -152,7 +152,7 @@ open class BigTextImpl( val startPos = findRenderPositionStart(node) return startPos + if (index - 1 - rowStart == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { node.value.bufferLength - } else if (index > 0) { + } else if (index - 1 - rowStart > 0) { node.value.rowBreakOffsets[index - 1 - rowStart] - node.value.renderBufferStart } else { 0 @@ -176,7 +176,7 @@ open class BigTextImpl( val (node, rowIndexStart) = tree.findNodeByRowBreaks(rowIndex - 1)!! val rowOffset = if (rowIndex - 1 - rowIndexStart == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { node.value.renderBufferEndExclusive - } else if (rowIndex > 0) { + } else if (rowIndex - 1 - rowIndexStart > 0) { val i = rowIndex - 1 - rowIndexStart if (i > node.value.rowBreakOffsets.lastIndex) { throw IndexOutOfBoundsException("findLineIndexByRowIndex($rowIndex) rowBreakOffsets[$i] length ${node.value.rowBreakOffsets.size}") @@ -187,7 +187,7 @@ open class BigTextImpl( } val positionStart = findRenderPositionStart(node) val rowPositionStart = positionStart + rowOffset - node.value.renderBufferStart - val lineBreakPosition = rowPositionStart - 1 + val lineBreakPosition = maxOf(0, rowPositionStart - 1) val lineBreakAtNode = tree.findNodeByRenderCharIndex(lineBreakPosition)!! val lineStart = findLineStart(lineBreakAtNode) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 3a3530fd..acdfcd15 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -106,6 +106,8 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( ) { require(pos in 0..originalLength) { "Out of bound. pos = $pos, originalLength = $originalLength" } + val renderPos = findTransformedPositionByOriginalPosition(pos) + insertChunkAtPosition( position = pos, chunkedStringLength = bufferOffsetEndExclusive - bufferOffsetStart, @@ -121,6 +123,9 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( leftStringLength = 0 } + + val insertLength = bufferOffsetEndExclusive - bufferOffsetStart + layout(maxOf(0, renderPos - 1), minOf(length, renderPos + insertLength + 1)) } private fun transformInsertChunkAtPosition(position: Int, chunkedString: CharSequence, offsetMapping: BigTextTransformOffsetMapping, incrementalTransformOffsetMappingLength: Int, isReplaceOriginal: Boolean): Int { From 0514957f73c15d9548b81802f80a7bb566c6d4d9 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 4 Oct 2024 11:56:02 +0800 Subject: [PATCH 101/195] fix BigText query functions --- .../multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 872e75ed..152e4fc4 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -152,7 +152,7 @@ open class BigTextImpl( val startPos = findRenderPositionStart(node) return startPos + if (index - 1 - rowStart == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { node.value.bufferLength - } else if (index - 1 - rowStart > 0) { + } else if (index - 1 - rowStart >= 0) { node.value.rowBreakOffsets[index - 1 - rowStart] - node.value.renderBufferStart } else { 0 @@ -176,7 +176,7 @@ open class BigTextImpl( val (node, rowIndexStart) = tree.findNodeByRowBreaks(rowIndex - 1)!! val rowOffset = if (rowIndex - 1 - rowIndexStart == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { node.value.renderBufferEndExclusive - } else if (rowIndex - 1 - rowIndexStart > 0) { + } else if (rowIndex - 1 - rowIndexStart >= 0) { val i = rowIndex - 1 - rowIndexStart if (i > node.value.rowBreakOffsets.lastIndex) { throw IndexOutOfBoundsException("findLineIndexByRowIndex($rowIndex) rowBreakOffsets[$i] length ${node.value.rowBreakOffsets.size}") From 0e37545a8437a652f63f17ef82697ca51afe0d5f Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 5 Oct 2024 09:36:10 +0800 Subject: [PATCH 102/195] fix StringIndexOutOfBoundsException --- .../multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 152e4fc4..4ce5bced 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -917,7 +917,7 @@ open class BigTextImpl( tree.forEach { val buffer = it.buffer - val chunkString = buffer.subSequence(it.bufferOffsetStart, it.bufferOffsetEndExclusive) + val chunkString = buffer.subSequence(it.renderBufferStart, it.renderBufferEndExclusive) layouter.indexCharWidth(chunkString.toString()) } From 37cd4021f4badbc0f27f318b956cba5d02e7f619 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 6 Oct 2024 12:52:04 +0800 Subject: [PATCH 103/195] fix BigText findLineIndexByRowIndex and findNodeByRowBreaks --- doc/bigtext/implementation/LayoutQueries.md | 43 +++++++++++++++++++ .../hellohttp/ux/bigtext/BigTextImpl.kt | 29 +++++++------ .../test/bigtext/BigTextImplLayoutTest.kt | 9 ++++ 3 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 doc/bigtext/implementation/LayoutQueries.md diff --git a/doc/bigtext/implementation/LayoutQueries.md b/doc/bigtext/implementation/LayoutQueries.md new file mode 100644 index 00000000..b7d8bbf6 --- /dev/null +++ b/doc/bigtext/implementation/LayoutQueries.md @@ -0,0 +1,43 @@ +# Layout Queries + +## Definition of Row Index + +- 0: The first row to the user. Start from the beginning of the string. +- 1: The 2nd row to the user. Start from the 1st (index = 0) row break. +- 2: The 3rd row to the user. Start from the 2nd (index = 1) row break. +- 3: The 4th row to the user. Start from the 3rd (index = 2) row break. + +### Example (Test case `BigTextImplLayoutTest#insertTriggersRelayout1`) + +For a string `"abcd\nABCDEFGHIJ if (it.left.isNotNil()) -1 else 0 - in it.value.leftNumOfRowBreaks until it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size -> 0 - in it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size until Int.MAX_VALUE -> (if (it.right.isNotNil()) 1 else 0).also { compareResult -> + in Int.MIN_VALUE .. it.value.leftNumOfRowBreaks -> if (it.left.isNotNil()) -1 else 0 + in it.value.leftNumOfRowBreaks + 1 .. it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size -> 0 + in it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size + 1 until Int.MAX_VALUE -> (if (it.right.isNotNil()) 1 else 0).also { compareResult -> val isTurnRight = compareResult > 0 if (isTurnRight) { find -= it.value.leftNumOfRowBreaks + it.value.rowBreakOffsets.size @@ -145,7 +145,7 @@ open class BigTextImpl( return 0; } - val node = (tree.findNodeByRowBreaks(index - 1) + val node = (tree.findNodeByRowBreaks(index) ?: throw IllegalStateException("Cannot find the node right after ${index - 1} row breaks") ).first val rowStart = findRowStart(node) @@ -173,12 +173,13 @@ open class BigTextImpl( return 0 } - val (node, rowIndexStart) = tree.findNodeByRowBreaks(rowIndex - 1)!! - val rowOffset = if (rowIndex - 1 - rowIndexStart == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { + val (node, rowIndexStart) = tree.findNodeByRowBreaks(rowIndex)!! + val rowOffset = if (rowIndex - rowIndexStart - 1 == node.value.rowBreakOffsets.size && node.value.isEndWithForceRowBreak) { node.value.renderBufferEndExclusive - } else if (rowIndex - 1 - rowIndexStart >= 0) { - val i = rowIndex - 1 - rowIndexStart + } else if (rowIndex - rowIndexStart - 1 >= 0) { // FIXME > or >=? + val i = rowIndex - rowIndexStart - 1 // 0-th row break is the 1st row break. Usually rowBreakOffsets[0] > 0. if (i > node.value.rowBreakOffsets.lastIndex) { + printDebug("IndexOutOfBoundsException") throw IndexOutOfBoundsException("findLineIndexByRowIndex($rowIndex) rowBreakOffsets[$i] length ${node.value.rowBreakOffsets.size}") } node.value.rowBreakOffsets[i] @@ -194,8 +195,8 @@ open class BigTextImpl( val positionStartOfLineBreakNode = findRenderPositionStart(lineBreakAtNode) val lineBreakOffsetStarts = lineBreakAtNode.value.buffer.lineOffsetStarts // FIXME render pos domain should be converted to buffer pos domain before searching - val lineBreakMinIndex = lineBreakOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineBreakAtNode.value.bufferOffsetStart) - val lineBreakIndex = lineBreakOffsetStarts.binarySearchForMaxIndexOfValueAtMost(lineBreakPosition - positionStartOfLineBreakNode + lineBreakAtNode.value.bufferOffsetStart) + val lineBreakMinIndex = lineBreakOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineBreakAtNode.value.renderBufferStart) + val lineBreakIndex = lineBreakOffsetStarts.binarySearchForMaxIndexOfValueAtMost(lineBreakPosition - positionStartOfLineBreakNode + lineBreakAtNode.value.renderBufferStart) return (lineStart + if (lineBreakIndex < lineBreakMinIndex) { 0 } else { @@ -692,22 +693,22 @@ open class BigTextImpl( } else if (rowOffset - 1 >= 0) { val offsetedRowOffset = rowOffset - 1 node.value!!.rowBreakOffsets[offsetedRowOffset] - } else { + } else { // rowOffset == 0 node.value!!.renderBufferStart } return findRenderPositionStart(node) + (charOffsetInBuffer - node.value!!.renderBufferStart) } - val (startNode, startNodeRowStart) = tree.findNodeByRowBreaks(rowIndex - 1) ?: + val (startNode, startNodeRowStart) = tree.findNodeByRowBreaks(rowIndex) ?: if (rowIndex <= numOfRows) { return "" } else { throw IndexOutOfBoundsException("numOfRows = $numOfRows; but given index = $rowIndex") } - val endNodeFindPair = tree.findNodeByRowBreaks(rowIndex) + val endNodeFindPair = tree.findNodeByRowBreaks(rowIndex + 1) val endCharIndex = if (endNodeFindPair != null) { // includes the last '\n' char val (endNode, endNodeRowStart) = endNodeFindPair - require(endNodeRowStart <= rowIndex) { "Node ${endNode.value.debugKey()} violates [endNodeRowStart <= rowIndex]" } + require(endNodeRowStart <= rowIndex + 1) { "Node ${endNode.value.debugKey()} violates [endNodeRowStart <= rowIndex] ($endNodeRowStart)" } // val lca = tree.lowestCommonAncestor(startNode, endNode) findCharPosOfRowOffset(endNode, rowIndex + 1 - endNodeRowStart) } else { diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt index 8dab5900..dba40787 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt @@ -1,5 +1,6 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext +import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMaxIndexOfValueAtMost import com.sunnychung.application.multiplatform.hellohttp.extension.insert import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.MonospaceTextLayouter @@ -811,6 +812,7 @@ class BigTextImplLayoutTest { fun verifyBigTextImplAgainstTestString(testString: String, bigTextImpl: BigTextImpl, softWrapAt: Int = 10) { val splitted = testString.split("\n") + val rowIndexAtLines = mutableListOf(0) val expectedRows = splitted.flatMapIndexed { index: Int, str: String -> // val str = if (index < splitted.lastIndex) "$s\n" else s str.chunked(softWrapAt).let { ss -> @@ -826,6 +828,8 @@ fun verifyBigTextImplAgainstTestString(testString: String, bigTextImpl: BigTextI } else { ss } + }.also { + rowIndexAtLines += (rowIndexAtLines.lastOrNull() ?: 0) + it.size } } // println("exp $expectedRows") @@ -833,6 +837,11 @@ fun verifyBigTextImplAgainstTestString(testString: String, bigTextImpl: BigTextI assertEquals(expectedRows.size, bigTextImpl.numOfRows) expectedRows.forEachIndexed { index, s -> assertEquals(s, bigTextImpl.findRowString(index)) + assertEquals( + rowIndexAtLines.binarySearchForMaxIndexOfValueAtMost(index), + bigTextImpl.findLineIndexByRowIndex(index), + "Line index of row index $index verify fail" + ) } } catch (e: Throwable) { bigTextImpl.printDebug("ERROR") From 5115a3b5daa65674a9ec7aa953665a963299474e Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 6 Oct 2024 20:53:01 +0800 Subject: [PATCH 104/195] fix BigText incorrect deleteOriginal layout calls leading to exceptions --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 1 + .../hellohttp/ux/bigtext/BigTextImpl.kt | 5 +++- .../ux/bigtext/BigTextTransformerImpl.kt | 4 ++- .../test/bigtext/BigTextImplLayoutTest.kt | 20 ++++++++++++++ .../transform/BigTextTransformerLayoutTest.kt | 27 +++++++++++++++++++ 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 1c2963b4..baa4dff6 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -371,6 +371,7 @@ private fun CoreBigMonospaceText( // } val transformedState = remember(text, textTransformation) { + log.v { "CoreBigMonospaceText text = |${text.buildString()}|" } if (textTransformation != null) { val startInstant = KInstant.now() textTransformation.initialize(text, transformedText).also { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 40db7a4e..eba23c33 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -990,10 +990,13 @@ open class BigTextImpl( * @param startPos Begin index of render positions. * @param endPosExclusive End index (exclusive) of render positions. */ - protected fun layout(startPos: Int, endPosExclusive: Int) { + fun layout(startPos: Int, endPosExclusive: Int) { val layouter = this.layouter ?: return val contentWidth = this.contentWidth ?: return + if (startPos >= length) return + if (startPos >= endPosExclusive) return + var lastOccupiedWidth = 0f var isLastEndWithForceRowBreak = false var node: RedBlackTree.Node? = tree.findNodeByRenderCharIndex(startPos) ?: return diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index acdfcd15..245b1041 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -276,11 +276,13 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( fun deleteOriginal(originalRange: IntRange) { require(0 <= originalRange.start) { "Invalid start" } require((originalRange.endInclusive + 1) in 0 .. originalLength) { "Out of bound. endExclusive = ${originalRange.endInclusive + 1}, originalLength = $originalLength" } + val renderPositionStart = findTransformedPositionByOriginalPosition(originalRange.start) super.deleteUnchecked( - start = findOriginalPositionByTransformedPosition(findTransformedPositionByOriginalPosition(originalRange.start)), + start = findOriginalPositionByTransformedPosition(renderPositionStart), endExclusive = findOriginalPositionByTransformedPosition(findTransformedPositionByOriginalPosition(originalRange.endInclusive + 1)), deleteMarker = null ) + layout(maxOf(0, renderPositionStart - 1), minOf(length, renderPositionStart + 1)) } fun transformDelete(originalRange: IntRange): Int { diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt index dba40787..f9a61fc6 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplLayoutTest.kt @@ -322,6 +322,26 @@ class BigTextImplLayoutTest { } } + @ParameterizedTest + @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) + fun invalidLayout(chunkSize: Int) { + listOf(10, 200).forEach { softWrapAt -> + val initial = "1234567890a\nbc\n" + val t = BigTextVerifyImpl(chunkSize = chunkSize).apply { + append(initial) + bigTextImpl.setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + bigTextImpl.setContentWidth(16f * softWrapAt + 1.23f) + } + t.insertAt(10, "ABCDE") + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + + t.printDebug("Before layout") + t.bigTextImpl.layout(20, 21) + t.printDebug("After layout") + verifyBigTextImplAgainstTestString(testString = t.stringImpl.buildString(), bigTextImpl = t.bigTextImpl, softWrapAt = softWrapAt) + } + } + @ParameterizedTest @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) @Order(Integer.MAX_VALUE - 100) // This test is pretty time-consuming. Run at the last! diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt index ba094845..89ac71a8 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -642,4 +642,31 @@ class BigTextTransformerLayoutTest { } } + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun deleteOriginal(chunkSize: Int) { + val testString = "1234567890<234567890 Date: Sun, 6 Oct 2024 20:53:55 +0800 Subject: [PATCH 105/195] fix EnvironmentVariableIncrementalTransformation --- ...onmentVariableIncrementalTransformation.kt | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt index 63752bea..1bbb5280 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt @@ -17,6 +17,8 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati private val variableRegex = "\\$\\{\\{([^{}]{1,$processLengthLimit})\\}\\}".toRegex() + private val variableNameRegex = "[^{}\n\r]{1,$processLengthLimit}".toRegex() + override fun initialize(text: BigText, transformer: BigTextTransformer) { // if (true) return @@ -44,8 +46,16 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati log.d { "EnvironmentVariableIncrementalTransformation search end start=$it" } if (anotherBracket != null) { val variableName = originalText.substring(anotherBracket + "\${{".length, it).string() - log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } - transformer.replace(anotherBracket until it + "}}".length, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) + if (isValidVariableName(variableName)) { + log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } + transformer.replace( + anotherBracket until it + "}}".length, + createSpan(variableName), + BigTextTransformOffsetMapping.WholeBlock + ) + } else { + log.d { "variableName '$variableName' is invalid" } + } } } originalText.findPositionByPattern(change.changeStartIndex, change.changeEndExclusiveIndex, "\${{", TextFBDirection.Forward).also { @@ -55,8 +65,16 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati log.d { "EnvironmentVariableIncrementalTransformation search start end=$it" } if (anotherBracket != null) { val variableName = originalText.substring(it + "\${{".length, anotherBracket).string() - log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } - transformer.replace(it until anotherBracket + "}}".length, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) + if (isValidVariableName(variableName)) { + log.d { "EnvironmentVariableIncrementalTransformation add '$variableName'" } + transformer.replace( + it until anotherBracket + "}}".length, + createSpan(variableName), + BigTextTransformOffsetMapping.WholeBlock + ) + } else { + log.d { "variableName '$variableName' is invalid" } + } } } } @@ -91,6 +109,10 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati } + fun isValidVariableName(name: String): Boolean { + return name.matches(variableNameRegex) + } + fun createSpan(variableName: String): String { // TODO change to AnnotatedString return "<$variableName>" } @@ -98,10 +120,14 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati fun BigText.findPositionByPattern(fromPosition: Int, toPosition: Int, pattern: String, direction: TextFBDirection): Int? { val substringBeginIndex = maxOf(0, fromPosition - pattern.length) - val substring = substring(substringBeginIndex, minOf(length, toPosition + pattern.length)) + val substringEndExclusiveIndex = minOf(length, toPosition + pattern.length) + val substring = substring(substringBeginIndex, substringEndExclusiveIndex) val lookupResult = when (direction) { TextFBDirection.Forward -> substring.indexOf(pattern) TextFBDirection.Backward -> substring.lastIndexOf(pattern) } - return lookupResult.takeIf { it >= 0 }?.let { substringBeginIndex + lookupResult } + return lookupResult.takeIf { it >= 0 } + ?.let { substringBeginIndex + lookupResult } + .also { log.d { "findPositionByPattern f=$fromPosition, t=$toPosition, s=$substringBeginIndex, e=$substringEndExclusiveIndex, sub=$substring, d=$direction, p=$pattern, res=$it" } } + ?.takeIf { (it until it + pattern.length) hasIntersectWith (fromPosition until toPosition) } } From fe17f7af49ef3752cb5af873d7ce6992907d5fc1 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 6 Oct 2024 22:16:42 +0800 Subject: [PATCH 106/195] fix `debounce` collection was registered for many times as the component was recomposed (not related to the use of `onEach` or `collect` operators) --- .../hellohttp/ux/CodeEditorView.kt | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 210e997c..3b330a72 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -568,18 +568,19 @@ fun CodeEditorView( // var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(text)) } // FIXME performance - bigTextFieldState.value.valueChangesFlow - .debounce(100.milliseconds().toMilliseconds()) - .onEach { - log.d { "bigTextFieldState change ${it.changeId}" } - onTextChange?.let { onTextChange -> - val string = it.bigText.buildCharSequence() as AnnotatedString - onTextChange(string.text) - secondCacheKey.value = string.text + LaunchedEffect(bigTextFieldState) { + bigTextFieldState.value.valueChangesFlow + .debounce(100.milliseconds().toMilliseconds()) + .collect { + log.d { "bigTextFieldState change ${it.changeId}" } + onTextChange?.let { onTextChange -> + val string = it.bigText.buildCharSequence() as AnnotatedString + onTextChange(string.text) + secondCacheKey.value = string.text + } + bigTextValueId = it.changeId } - bigTextValueId = it.changeId - } - .launchIn(CoroutineScope(Dispatchers.Main)) + } BigMonospaceTextField( textFieldState = bigTextFieldState.value, From 144c3a007eb71c22b648a4fc62a047157d6ad1bb Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Tue, 8 Oct 2024 23:10:29 +0800 Subject: [PATCH 107/195] fix BigTextTransformerImpl restoreToOriginal would change the length property --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 4 +-- .../hellohttp/ux/bigtext/BigTextImpl.kt | 8 ++--- .../ux/bigtext/BigTextTransformerImpl.kt | 26 +++++++++++----- .../transform/BigTextTransformerImplTest.kt | 31 ++++++++++++++++++- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index baa4dff6..76670541 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -969,13 +969,13 @@ class BigTextViewState { fun updateCursorIndexByTransformed(transformedText: BigTextTransformed) { cursorIndex = transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex).also { - log.d { "cursorIndex = $it" } + log.d { "cursorIndex = $it (from T $transformedCursorIndex)" } } } fun updateTransformedCursorIndexByOriginal(transformedText: BigTextTransformed) { transformedCursorIndex = transformedText.findTransformedPositionByOriginalPosition(cursorIndex).also { - log.d { "updateTransformedCursorIndexByOriginal = $it" } + log.d { "updateTransformedCursorIndexByOriginal = $it (from $cursorIndex)" } } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index eba23c33..a079d1ef 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -339,7 +339,7 @@ open class BigTextImpl( } private fun insertChunkAtPosition(position: Int, chunkedString: CharSequence) { - log.d { "insertChunkAtPosition($position, $chunkedString)" } + log.d { "$this insertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) // if (position == 64) { // log.d { inspect("$position") } @@ -763,8 +763,8 @@ open class BigTextImpl( override fun delete(start: Int, endExclusive: Int): Int { require(start <= endExclusive) { "start should be <= endExclusive" } - require(0 <= start) { "Invalid start" } - require(endExclusive <= length) { "endExclusive is out of bound" } + require(0 <= start) { "Invalid start ($start)" } + require(endExclusive <= length) { "endExclusive is out of bound ($endExclusive)" } return deleteUnchecked(start, endExclusive).also { changeHook?.afterDelete(this, start until endExclusive) @@ -776,7 +776,7 @@ open class BigTextImpl( return 0 } - log.d { "delete $start ..< $endExclusive" } + log.d { "$this delete $start ..< $endExclusive" } var node: RedBlackTree.Node? = tree.findNodeByCharIndex(endExclusive - 1, isIncludeMarkerNodes = false) var nodeRange = charIndexRangeOfNode(node!!) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 245b1041..8ed56de3 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -273,15 +273,27 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( fun transformInsertAtOriginalEnd(text: CharSequence): Int = transformInsert(originalLength, text) - fun deleteOriginal(originalRange: IntRange) { + fun deleteOriginal(originalRange: IntRange, isReMapPositionNeeded: Boolean = true) { require(0 <= originalRange.start) { "Invalid start" } require((originalRange.endInclusive + 1) in 0 .. originalLength) { "Out of bound. endExclusive = ${originalRange.endInclusive + 1}, originalLength = $originalLength" } val renderPositionStart = findTransformedPositionByOriginalPosition(originalRange.start) - super.deleteUnchecked( - start = findOriginalPositionByTransformedPosition(renderPositionStart), - endExclusive = findOriginalPositionByTransformedPosition(findTransformedPositionByOriginalPosition(originalRange.endInclusive + 1)), - deleteMarker = null - ) + if (isReMapPositionNeeded) { + super.deleteUnchecked( + start = findOriginalPositionByTransformedPosition(renderPositionStart), + endExclusive = findOriginalPositionByTransformedPosition( + findTransformedPositionByOriginalPosition( + originalRange.endInclusive + 1 + ) + ), + deleteMarker = null + ) + } else { + super.deleteUnchecked( + start = originalRange.start, + endExclusive = originalRange.endInclusive + 1, + deleteMarker = null + ) + } layout(maxOf(0, renderPositionStart - 1), minOf(length, renderPositionStart + 1)) } @@ -718,7 +730,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( val renderPositionAtOriginalEnd = findTransformedPositionByOriginalPosition(range.endInclusive) deleteTransformIf(range) - deleteOriginal(range) + deleteOriginal(range, isReMapPositionNeeded = false) // insert the original text from `delegate` val originalNodeStart = delegate.tree.findNodeByCharIndex(range.start) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt index 181b6624..2b05813e 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt @@ -922,7 +922,7 @@ class BigTextTransformerImplTest { @ParameterizedTest @ValueSource(ints = [1048576, 64, 16]) - fun restoreToOriginal(chunkSize: Int) { + fun restoreToOriginal1(chunkSize: Int) { val initialText = "12345678901234567890123456789012345678901234567890123456789012345678901234567890" val original = BigTextImpl(chunkSize = chunkSize) original.append(initialText) @@ -946,6 +946,35 @@ class BigTextTransformerImplTest { initialText.let { expected -> assertEquals(expected, transformed.buildString()) assertEquals(expected, original.buildString()) + assertEquals(expected.length, transformed.length) + assertEquals(initialText.length, transformed.originalLength) + assertAllSubstring(expected, transformed) + } + } + + @ParameterizedTest + @ValueSource(ints = [1048576, 64, 16]) + fun restoreToOriginal2(chunkSize: Int) { + val initialText = "12345678901234567890123456789012345678901234567890123456789012345678901234567890" + val original = BigTextImpl(chunkSize = chunkSize) + original.append(initialText) + val transformed = BigTextTransformerImpl(original) + + transformed.replace(11 .. 18, "ABCD", BigTextTransformOffsetMapping.WholeBlock) + "12345678901ABCD0123456789012345678901234567890123456789012345678901234567890".let { expected -> + assertEquals(expected, transformed.buildString()) + assertEquals(initialText, original.buildString()) + assertEquals(expected.length, transformed.length) + assertEquals(initialText.length, transformed.originalLength) + assertAllSubstring(expected, transformed) + } + + transformed.restoreToOriginal(11 .. 18) + initialText.let { expected -> + assertEquals(expected, transformed.buildString()) + assertEquals(expected, original.buildString()) + assertEquals(expected.length, transformed.length) + assertEquals(initialText.length, transformed.originalLength) assertAllSubstring(expected, transformed) } } From 4ee8126dc7a2c8eb02bad1ce06e606b75a52c5ee Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 9 Oct 2024 21:12:10 +0800 Subject: [PATCH 108/195] fix EnvironmentVariableIncrementalTransformation forward deletes a transformation did not restore original text --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 3 +++ .../EnvironmentVariableIncrementalTransformation.kt | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 76670541..3464b2c8 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -289,6 +289,9 @@ private fun CoreBigMonospaceText( } } +// log.v { "text = |${text.buildString()}|" } +// log.v { "transformedText = |${transformedText.buildString()}|" } + fun fireOnLayout() { lineHeight = (textLayouter.charMeasurer as ComposeUnicodeCharMeasurer).getRowHeight() onTextLayout?.let { callback -> diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt index 1bbb5280..6f192dae 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt @@ -94,7 +94,7 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati transformer.restoreToOriginal(anotherStart until it + "}}".length) } } - originalText.findPositionByPattern(change.changeStartIndex - processLengthLimit, change.changeEndExclusiveIndex, "\${{", TextFBDirection.Forward) + originalText.findPositionByPattern(change.changeStartIndex - "\${{".length + 1, change.changeEndExclusiveIndex + "\${{".length, "\${{", TextFBDirection.Forward) ?.takeIf { (it until it + "\${{".length) hasIntersectWith changeRange } ?.let { originalText.findPositionByPattern(it + "\${{".length, it + processLengthLimit, "}}", TextFBDirection.Forward) From 6affb53d00230bc267d02e52574cf0e8e3e5cf68 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 9 Oct 2024 21:24:26 +0800 Subject: [PATCH 109/195] update BigMonospaceText cursor position not to stay in the middle of a transformation after typing --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 3464b2c8..bc50fae8 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -980,6 +980,7 @@ class BigTextViewState { transformedCursorIndex = transformedText.findTransformedPositionByOriginalPosition(cursorIndex).also { log.d { "updateTransformedCursorIndexByOriginal = $it (from $cursorIndex)" } } + cursorIndex = transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex) } fun roundTransformedCursorIndex(direction: CursorAdjustDirection, transformedText: BigTextTransformed, compareWithPosition: Int, isOnlyWithinBlock: Boolean) { From 00cca18b507ae2f42880f39c1c655f7fc3d52c5a Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 11 Oct 2024 17:25:34 +0800 Subject: [PATCH 110/195] add transformation to BigTextChangeEvent --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 3 +++ .../multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index bc50fae8..2ba6644a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -465,6 +465,9 @@ private fun CoreBigMonospaceText( eventType = eventType, changeStartIndex = changeStartIndex, changeEndExclusiveIndex = changeEndExclusiveIndex, + renderText = transformedText, + changeTransformedStartIndex = transformedText.findTransformedPositionByOriginalPosition(changeStartIndex), + changeTransformedEndExclusiveIndex = transformedText.findTransformedPositionByOriginalPosition(changeEndExclusiveIndex) ) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt index 5cd2597c..98ca05c2 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt @@ -12,6 +12,10 @@ data class BigTextChangeEvent( val changeStartIndex: Int, val changeEndExclusiveIndex: Int, + + val renderText: BigText, + val changeTransformedStartIndex: Int, + val changeTransformedEndExclusiveIndex: Int, ) enum class BigTextChangeEventType { From a70ec8451940237c941423710a5ace2be22b849b Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 11 Oct 2024 20:56:47 +0800 Subject: [PATCH 111/195] add BigTextImpl#findLineAndColumnFromRenderPosition as required by Tree-sitter --- .../hellohttp/ux/bigtext/BigText.kt | 2 + .../hellohttp/ux/bigtext/BigTextImpl.kt | 41 +++++++++++++++++++ .../ux/bigtext/InefficientBigText.kt | 4 ++ .../test/bigtext/BigTextImplQueryTest.kt | 39 ++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt index 1dd3796b..d177ac2c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -35,6 +35,8 @@ interface BigText { insertAt(range.start, text) } + fun findLineAndColumnFromRenderPosition(renderPosition: Int): Pair + override fun hashCode(): Int override fun equals(other: Any?): Boolean diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index a079d1ef..27aef321 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -881,6 +881,47 @@ open class BigTextImpl( return start until start + node.value.bufferLength } + override fun findLineAndColumnFromRenderPosition(renderPosition: Int): Pair { + val node = tree.findNodeByRenderCharIndex(renderPosition) + ?: throw IndexOutOfBoundsException("Node for position $renderPosition not found") + val nodeStart = findRenderPositionStart(node) + val lineStart = findLineStart(node) + + if (node.renderLength() <= 0) { + throw IllegalStateException("Node render length is not positive") + } + + val buffer = node.value.buffer + val lineBreakStartIndex = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(node.value.renderBufferStart) + val lineBreakEndIndexInclusive = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(node.value.renderBufferEndExclusive) + val lineBreakOffset = minOf( + lineBreakEndIndexInclusive, + buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(renderPosition - nodeStart - 1) + ).let { + if (it >= 0) { + it - maxOf(0, lineBreakStartIndex) + 1 + } else { + 0 + } + } + + val lineIndex = lineStart + lineBreakOffset + + val (lineStartNode, lineStartNodeLineStart) = tree.findNodeByLineBreaks(lineIndex - 1)!! + val lineOffsetStarts = lineStartNode.value.buffer.lineOffsetStarts + val inRangeLineStartIndex = lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(lineStartNode.value.renderBufferStart) + val lineOffset = if (lineIndex - 1 >= 0) { + lineOffsetStarts[inRangeLineStartIndex + lineIndex - 1 - lineStartNodeLineStart] - lineStartNode.value.renderBufferStart + 1 + } else { + 0 + } + val lineStartPos = findRenderPositionStart(lineStartNode) + lineOffset + + val columnIndex = renderPosition - lineStartPos + + return lineIndex to columnIndex + } + override fun hashCode(): Int { // TODO("Not yet implemented") return super.hashCode() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt index 5c10b952..8a71bbe5 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt @@ -34,6 +34,10 @@ class InefficientBigText(text: String) : BigText { return -(endExclusive - start) } + override fun findLineAndColumnFromRenderPosition(renderPosition: Int): Pair { + TODO("Not yet implemented") + } + override fun hashCode(): Int = string.hashCode() diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt index a70ff8c0..f9f4d21e 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt @@ -219,6 +219,29 @@ class BigTextImplQueryTest { } t.verifyAllLines() } + + @ParameterizedTest + @ValueSource(ints = [1 * 1024 * 1024, 16, 64]) + fun findLineAndColumnFromRenderPosition(chunkSize: Int) { + val testStrings = listOf( + "abc\nde\n\nfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz\naa", + "\n\nabcde\nf\n\n\n", + "", + "ab", + "\n\n\n", + "1234567890123456\n\n" + ) + + testStrings.forEach { testString -> + val t = BigTextVerifyImpl(chunkSize = chunkSize) + t.append(testString) + + (0 until t.length).forEach { + val (lineIndex, columnIndex) = t.bigTextImpl.findLineAndColumnFromRenderPosition(it) + t.assertLineAndColumn(it, lineIndex, columnIndex) + } + } + } } private fun BigTextVerifyImpl.verifyAllLines() { @@ -229,6 +252,22 @@ private fun BigTextVerifyImpl.verifyAllLines() { } } +internal fun BigTextVerifyImpl.assertLineAndColumn(charIndex: Int, lineIndex: Int, columnIndex: Int) { + val s = stringImpl.buildString() + val numOfLineBreaks = s.substring(0 until charIndex).count { it == '\n' } + val lastLineBreakPosition = s.lastIndexOf('\n', charIndex - 1) + val lineStartPosition = if (lastLineBreakPosition >= 0) { + lastLineBreakPosition + 1 + } else { + 0 + } + assertEquals( + expected = numOfLineBreaks to (charIndex - lineStartPosition), + actual = lineIndex to columnIndex, + message = "Mismatch line/col at char index = $charIndex value = '${s[charIndex]}'" + ) +} + private fun random(from: Int, toExclusive: Int): Int { if (toExclusive == from) { return 0 From 3f769e5fd61a2726a6382a315557f32079c2e53c Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 12 Oct 2024 00:15:17 +0800 Subject: [PATCH 112/195] fix selecting middle of subsequence of annotated BigText returns negative start index, causing overlapped style ranges --- .../hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt index d213dca7..53b03149 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt @@ -45,7 +45,7 @@ class AnnotatedStringTextBuffer(size: Int) : TextBuffer() { .map { AnnotatedString.Range( item = it.second, - start = it.first.start - start, + start = maxOf(0, it.first.start - start), end = minOf(endExclusive - start, it.first.endInclusive + 1 - start) ) } From d44a73f2f6da63ac9c3aecba929fc48b9e605b52 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 12 Oct 2024 00:15:54 +0800 Subject: [PATCH 113/195] add BigText#findRenderCharIndexByLineAndColumn --- .../hellohttp/ux/bigtext/BigText.kt | 2 + .../hellohttp/ux/bigtext/BigTextImpl.kt | 47 ++++++++++++------- .../ux/bigtext/InefficientBigText.kt | 4 ++ 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt index d177ac2c..d3b71ca2 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -37,6 +37,8 @@ interface BigText { fun findLineAndColumnFromRenderPosition(renderPosition: Int): Pair + fun findRenderCharIndexByLineAndColumn(lineIndex: Int, columnIndex: Int): Int + override fun hashCode(): Int override fun equals(other: Any?): Boolean diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 27aef321..9c8279db 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -646,28 +646,28 @@ open class BigTextImpl( return charSequenceFactory(result) } + /** + * @param lineOffset 0 = start of buffer; 1 = char index after the 1st '\n'; 2 = char index after the 2nd '\n'; ... + */ + protected fun findCharPosOfLineOffset(node: RedBlackTree.Node, lineOffset: Int): Int { + val buffer = node.value!!.buffer + val lineStartIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.value!!.bufferOffsetStart) + val lineEndIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(node.value!!.bufferOffsetEndExclusive - 1) + val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) - 1 + val charOffsetInBuffer = if (offsetedLineOffset > lineEndIndexInBuffer) { + node.value!!.renderBufferEndExclusive + } else if (lineOffset - 1 >= 0) { + buffer.lineOffsetStarts[offsetedLineOffset] + 1 + } else { + node.value!!.renderBufferStart + } + return findPositionStart(node) + (charOffsetInBuffer - node.value!!.renderBufferStart) + } + fun findLineString(lineIndex: Int): CharSequence { require(0 <= lineIndex) { "lineIndex $lineIndex must be non-negative." } require(lineIndex <= numOfLines) { "lineIndex $lineIndex out of bound, numOfLines = $numOfLines." } - /** - * @param lineOffset 0 = start of buffer; 1 = char index after the 1st '\n'; 2 = char index after the 2nd '\n'; ... - */ - fun findCharPosOfLineOffset(node: RedBlackTree.Node, lineOffset: Int): Int { - val buffer = node.value!!.buffer - val lineStartIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.value!!.bufferOffsetStart) - val lineEndIndexInBuffer = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(node.value!!.bufferOffsetEndExclusive - 1) - val offsetedLineOffset = maxOf(0, lineStartIndexInBuffer) + (lineOffset) - 1 - val charOffsetInBuffer = if (offsetedLineOffset > lineEndIndexInBuffer) { - node.value!!.renderBufferEndExclusive - } else if (lineOffset - 1 >= 0) { - buffer.lineOffsetStarts[offsetedLineOffset] + 1 - } else { - node.value!!.renderBufferStart - } - return findPositionStart(node) + (charOffsetInBuffer - node.value!!.renderBufferStart) - } - val (startNode, startNodeLineStart) = tree.findNodeByLineBreaks(lineIndex - 1)!! val endNodeFindPair = tree.findNodeByLineBreaks(lineIndex) val endCharIndex = if (endNodeFindPair != null) { // includes the last '\n' char @@ -922,6 +922,17 @@ open class BigTextImpl( return lineIndex to columnIndex } + override fun findRenderCharIndexByLineAndColumn(lineIndex: Int, columnIndex: Int): Int { + require(0 <= lineIndex) { "lineIndex $lineIndex must be non-negative." } + require(lineIndex <= numOfLines) { "lineIndex $lineIndex out of bound, numOfLines = $numOfLines." } + require(0 <= columnIndex) { "columnIndex $lineIndex must be non-negative." } + + val (startNode, startNodeLineStart) = tree.findNodeByLineBreaks(lineIndex - 1)!! + val startCharIndex = findCharPosOfLineOffset(startNode, lineIndex - startNodeLineStart) + + return startCharIndex + columnIndex + } + override fun hashCode(): Int { // TODO("Not yet implemented") return super.hashCode() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt index 8a71bbe5..1016db69 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/InefficientBigText.kt @@ -38,6 +38,10 @@ class InefficientBigText(text: String) : BigText { TODO("Not yet implemented") } + override fun findRenderCharIndexByLineAndColumn(lineIndex: Int, columnIndex: Int): Int { + TODO("Not yet implemented") + } + override fun hashCode(): Int = string.hashCode() From e528967df73babdbe6d373309b5137216bf5649b Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 12 Oct 2024 20:28:53 +0800 Subject: [PATCH 114/195] fix BigTextImpl#findLineAndColumnFromRenderPosition sometimes returns negative values --- doc/bigtext/implementation/LineQueries.md | 41 +++++++++++++++++++ .../hellohttp/ux/bigtext/BigTextImpl.kt | 10 ++--- .../test/bigtext/BigTextImplQueryTest.kt | 25 ++++++++++- .../test/bigtext/BigTextVerifyImpl.kt | 8 ++++ 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 doc/bigtext/implementation/LineQueries.md diff --git a/doc/bigtext/implementation/LineQueries.md b/doc/bigtext/implementation/LineQueries.md new file mode 100644 index 00000000..430c5ec3 --- /dev/null +++ b/doc/bigtext/implementation/LineQueries.md @@ -0,0 +1,41 @@ +# Line Queries + +## Find Line Index and Column Index + +Example: + +```mermaid +block-beta + columns 20 + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 + a c1["#92;n"] b c d c5["#92;n"] e f c8["#92;n"] g h i j k l c15["#92;n"] c16["#92;n"] m n c19["#92;n"] +``` + +Line break indices are: +```mermaid +block-beta + columns 6 + 0 1 2 3 4 5 + l0["1"] l1["5"] 8 15 16 19 +``` + +```mermaid +block-beta + columns 20 + H0["0"] H1["1"] H2["2"] H3["3"] H4["4"] H5["5"] H6["6"] H7["7"] H8["8"] H9["9"] H10["10"] H11["11"] H12["12"] H13["13"] H14["14"] H15["15"] H16["16"] H17["17"] H18["18"] H19["19"] + a c1["#92;n"] b c d c5["#92;n"] e f c8["#92;n"] g h i j k l c15["#92;n"] c16["#92;n"] m n c19["#92;n"] + space:20 + space:20 + space:7 L0 L1 L2 L3 L4 L5 space:7 + space:7 l0["1"] l1["5"] 8 15 16 19 + + a-->L0 + c1-->L0 + b-->L1 + c-->L1 + d-->L1 + c5-->L1 + e-->L2 + f-->L2 + c8-->L2 +``` diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 9c8279db..4b87abbb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -892,14 +892,14 @@ open class BigTextImpl( } val buffer = node.value.buffer - val lineBreakStartIndex = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(node.value.renderBufferStart) + val lineBreakStartIndex = buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(node.value.renderBufferStart) val lineBreakEndIndexInclusive = buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(node.value.renderBufferEndExclusive) val lineBreakOffset = minOf( - lineBreakEndIndexInclusive, - buffer.lineOffsetStarts.binarySearchForMaxIndexOfValueAtMost(renderPosition - nodeStart - 1) + lineBreakEndIndexInclusive + 1, + buffer.lineOffsetStarts.binarySearchForMinIndexOfValueAtLeast(renderPosition - nodeStart + node.value.renderBufferStart) ).let { - if (it >= 0) { - it - maxOf(0, lineBreakStartIndex) + 1 + if (it >= 0 && it >= lineBreakStartIndex) { + it - maxOf(0, lineBreakStartIndex) } else { 0 } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt index f9f4d21e..6c049e53 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt @@ -229,7 +229,8 @@ class BigTextImplQueryTest { "", "ab", "\n\n\n", - "1234567890123456\n\n" + "1234567890123456\n\n", + "{\n\"mmm\": \"nn\",\n\"x\": \"dasc\",\n \"d\": {\n \"cc\": [\n \"v\"\n ]\n }\n}" ) testStrings.forEach { testString -> @@ -242,6 +243,28 @@ class BigTextImplQueryTest { } } } + + @ParameterizedTest + @ValueSource(ints = [1 * 1024 * 1024, 16, 64]) + fun findLineAndColumnFromRenderPositionAfterInsert(chunkSize: Int) { + val t = BigTextVerifyImpl(chunkSize = chunkSize) + t.append("{\n" + + "\"mmm\": \"nn\",\n" + + "\"x\": \"dasc\",\n" + + " \"d\": {\n" + + " \"cc\": [\n" + + " \n" + + " ]\n" + + " }\n" + + "}") + + t.insertAt(56, "\"v\"") + + (0 until t.length).forEach { + val (lineIndex, columnIndex) = t.bigTextImpl.findLineAndColumnFromRenderPosition(it) + t.assertLineAndColumn(it, lineIndex, columnIndex) + } + } } private fun BigTextVerifyImpl.verifyAllLines() { diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index 4be0157b..44521a14 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -257,6 +257,14 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { throw IndexOutOfBoundsException("Transformed position $transformedPosition not found") } + override fun findLineAndColumnFromRenderPosition(renderPosition: Int): Pair { + TODO("Not yet implemented") + } + + override fun findRenderCharIndexByLineAndColumn(lineIndex: Int, columnIndex: Int): Int { + TODO("Not yet implemented") + } + override fun hashCode(): Int { val r = bigTextImpl.hashCode() val tr = stringImpl.hashCode() From a6f0b3fef22e9fa43c14efd4c5251da5e1e455ee Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 12 Oct 2024 21:15:23 +0800 Subject: [PATCH 115/195] Revert "add transformation to BigTextChangeEvent" This reverts commit 00cca18b507ae2f42880f39c1c655f7fc3d52c5a. --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 3 --- .../multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt | 4 ---- 2 files changed, 7 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 2ba6644a..bc50fae8 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -465,9 +465,6 @@ private fun CoreBigMonospaceText( eventType = eventType, changeStartIndex = changeStartIndex, changeEndExclusiveIndex = changeEndExclusiveIndex, - renderText = transformedText, - changeTransformedStartIndex = transformedText.findTransformedPositionByOriginalPosition(changeStartIndex), - changeTransformedEndExclusiveIndex = transformedText.findTransformedPositionByOriginalPosition(changeEndExclusiveIndex) ) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt index 98ca05c2..5cd2597c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeEvent.kt @@ -12,10 +12,6 @@ data class BigTextChangeEvent( val changeStartIndex: Int, val changeEndExclusiveIndex: Int, - - val renderText: BigText, - val changeTransformedStartIndex: Int, - val changeTransformedEndExclusiveIndex: Int, ) enum class BigTextChangeEventType { From 00b6aeec882beb56a5b33eb9d8e6e8b51ac114c2 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 13 Oct 2024 16:52:55 +0800 Subject: [PATCH 116/195] add incremental JSON syntax highlighting using Tree-sitter --- build.gradle.kts | 4 + .../multiplatform/hellohttp/Main.kt | 45 ++++ .../hellohttp/extension/RangeExtension.kt | 9 + .../hellohttp/util/TreeSitterUtil.kt | 21 ++ ...yntaxHighlightIncrementalTransformation.kt | 245 +++++++++++++++++- 5 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TreeSitterUtil.kt diff --git a/build.gradle.kts b/build.gradle.kts index 9fd8c09e..ea6a5097 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -82,6 +82,10 @@ kotlin { // implementation("org.apache.logging.log4j:log4j-api:2.23.1") // implementation("org.apache.logging.log4j:log4j-core:2.23.1") + + // incremental parser + implementation("io.github.tree-sitter:ktreesitter:0.23.0") + implementation("io.github.sunny-chung:ktreesitter-json:0.23.0.0") } resources.srcDir("$buildDir/resources") diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt index 6679a4c4..7ace744f 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt @@ -26,14 +26,20 @@ import com.sunnychung.application.multiplatform.hellohttp.document.OperationalDI import com.sunnychung.application.multiplatform.hellohttp.document.UserPreferenceDI import com.sunnychung.application.multiplatform.hellohttp.error.MultipleProcessError import com.sunnychung.application.multiplatform.hellohttp.model.Version +import com.sunnychung.application.multiplatform.hellohttp.platform.LinuxOS +import com.sunnychung.application.multiplatform.hellohttp.platform.MacOS +import com.sunnychung.application.multiplatform.hellohttp.platform.WindowsOS +import com.sunnychung.application.multiplatform.hellohttp.platform.currentOS import com.sunnychung.application.multiplatform.hellohttp.platform.isMacOs import com.sunnychung.application.multiplatform.hellohttp.ux.AppView import com.sunnychung.application.multiplatform.hellohttp.ux.DataLossWarningDialogWindow +import io.github.treesitter.ktreesitter.json.TreeSitterJson import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import net.harawata.appdirs.AppDirsFactory import java.awt.Dimension import java.io.File +import java.io.FileOutputStream import java.util.concurrent.atomic.AtomicInteger import kotlin.system.exitProcess @@ -42,6 +48,7 @@ fun main() { val appDir = AppDirsFactory.getInstance().getUserDataDir("Hello HTTP", null, null) println("appDir = $appDir") AppContext.dataDir = File(appDir) + loadNativeLibraries() runBlocking { try { AppContext.SingleInstanceProcessService.apply { dataDir = File(appDir) }.enforce() @@ -144,3 +151,41 @@ fun main() { } } } + +fun loadNativeLibraries() { + val libraries = listOf("tree-sitter-json" to TreeSitterJson) + val systemArch = if (currentOS() == WindowsOS) { + "x64" + } else { + getSystemArchitecture() + }.uppercase() + libraries.forEach { (name, enclosingClazz) -> + val libFileName = when (currentOS()) { + LinuxOS -> "lib${name}-${systemArch}.so" + MacOS -> "lib${name}-${systemArch}.dylib" + else -> "${name}-${systemArch}.dll" + } + println("Loading native lib $libFileName") + val dest = File(File(AppContext.dataDir, "lib"), libFileName) + dest.parentFile.mkdirs() + enclosingClazz.javaClass.classLoader.getResourceAsStream(libFileName).use { + it.copyTo(FileOutputStream(dest)) + } + System.load(dest.absolutePath) + } +} + +fun getSystemArchitecture(): String { + return exec("uname", "-m").trim() +} + +fun exec(vararg components: String): String { + val pb = ProcessBuilder(*components) + val process = pb.start() + val output = process.inputStream.bufferedReader().readText() + val exitCode = process.waitFor() + if (exitCode != 0) { + throw RuntimeException("${components.first()} Process finished with exit code $exitCode") + } + return output +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt index 69d8ad9c..fe1634f8 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/extension/RangeExtension.kt @@ -17,6 +17,15 @@ infix fun IntRange.hasIntersectWith(other: IntRange): Boolean { return !intersect(other).isEmpty() } +/** + * Use this function may overflow. + */ +infix fun UIntRange.hasIntersectWith(other: UIntRange): Boolean { + fun UIntRange.toIntRange() = start.toInt() .. endInclusive.toInt() + + return !toIntRange().intersect(other.toIntRange()).isEmpty() +} + fun IntRange.toNonEmptyRange(): IntRange { if (length <= 0) { return start .. start diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TreeSitterUtil.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TreeSitterUtil.kt new file mode 100644 index 00000000..8061d653 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TreeSitterUtil.kt @@ -0,0 +1,21 @@ +package com.sunnychung.application.multiplatform.hellohttp.util + +import io.github.treesitter.ktreesitter.Node +import io.github.treesitter.ktreesitter.Point + +fun Pair.toPoint(): Point = Point(first.toUInt(), second.toUInt()) + +class VisitScope(private val node: Node, private val visitor: VisitScope.(Node) -> Unit) { + fun visit(anotherNode: Node) = anotherNode.visit(visitor) + + fun visitChildrens() { + node.children.forEach { + visit(it) + } + } +} + +fun Node.visit(visitor: VisitScope.(Node) -> Unit) { + val scope = VisitScope(this, visitor) + scope.visitor(this) +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt index 1ef0d684..12a7589c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt @@ -2,12 +2,27 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.inc import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import com.sunnychung.application.multiplatform.hellohttp.extension.hasIntersectWith +import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.util.toPoint +import com.sunnychung.application.multiplatform.hellohttp.util.visit import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEventType +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformOffsetMapping import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformer import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.IncrementalTextTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor +import io.github.treesitter.ktreesitter.InputEdit +import io.github.treesitter.ktreesitter.Language +import io.github.treesitter.ktreesitter.Node +import io.github.treesitter.ktreesitter.Parser +import io.github.treesitter.ktreesitter.Point +import io.github.treesitter.ktreesitter.Range +import io.github.treesitter.ktreesitter.Tree +import io.github.treesitter.ktreesitter.json.TreeSitterJson +import kotlin.streams.toList private val TOKEN_REGEX = "(?>() TOKEN_REGEX.findAll(s).forEach { m -> val match = (m.groups[1] ?: m.groups[2])!! @@ -57,6 +81,86 @@ class JsonSyntaxHighlightIncrementalTransformation(val colours: AppColor) : Incr } } + if (spans.isNotEmpty()) { + transformer.replace( + range = 0 until text.length, + text = AnnotatedString(s, spans), + offsetMapping = BigTextTransformOffsetMapping.Incremental + ) + }*/ + + fun Point.toCharIndex(): Int { + val charIndex = text.findRenderCharIndexByLineAndColumn(row.toInt(), column.toInt()) + return charIndex + } + + // Tree Sitter incremental approach + val s = text.buildString() + + // In Tree-sitter, multibyte utf8 characters would occupy multiple positions and have no workaround +// val singleByteCharSequence = s.map { // buggy +// if (it.code > 255) { +// 'X' +// } else { +// it +// } +// }.joinToString() +// ast = parser.parse(singleByteCharSequence) + ast = parser.parse { byte, point -> + if (byte in 0u until text.length.toUInt()) { + s.substring(byte.toInt() ..byte.toInt()).let { + val codePoints = it.codePoints().toArray() + if (codePoints.size > 1 || codePoints.first() > 255) { + "X" // replace multibyte char as single-byte char + } else { + it + } + } + } else { + "" // the doc is wrong. null would result in crash + }/*.also { + println("parse $byte = '$it'") + }*/ + } + +// ast.rootNode.children.forEach { +// log.d { "AST init parse ${it.range} type=${it.type} grammarType=${it.grammarType} p=${it.parent}" } +// } +// log.d { "AST init sexp = ${ast.rootNode.sexp()}" } + val spans = mutableListOf>() + ast.rootNode.visit { + log.v { "AST visit ${it.startByte} ..< ${it.endByte} = ${it.type}" } +// log.v { "AST visit ${it.startPoint.toCharIndex()} ..< ${it.endPoint.toCharIndex()} = ${it.type}" } + when (it.type) { + "pair" -> { + val keyChild = it.childByFieldName("key")!! + spans += createAnnotatedRange(text, objectKeyStyle, keyChild) + log.v { "AST highlight ${keyChild.startByte} ..< ${keyChild.endByte} = key" } +// log.v { "AST highlight ${keyChild.startPoint.toCharIndex()} ..< ${keyChild.endPoint.toCharIndex()} = key" } + it.childByFieldName("value")?.let { + visit(it) + } + } + "null" -> { + spans += createAnnotatedRange(text, nothingLiteralStyle, it) + } + "number" -> { + spans += createAnnotatedRange(text, numberLiteralStyle, it) + } + "string" -> { + spans += createAnnotatedRange(text, stringLiteralStyle, it) + } + "false" -> { + spans += createAnnotatedRange(text, booleanFalseLiteralStyle, it) + } + "true" -> { + spans += createAnnotatedRange(text, booleanTrueLiteralStyle, it) + } + else -> { + visitChildrens() + } + } + } if (spans.isNotEmpty()) { transformer.replace( range = 0 until text.length, @@ -67,7 +171,146 @@ class JsonSyntaxHighlightIncrementalTransformation(val colours: AppColor) : Incr } override fun onTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { + val oldAst = ast + + when (change.eventType) { + BigTextChangeEventType.Insert -> { + ast.edit( + createInputEdit( + change, + change.changeStartIndex, + change.changeStartIndex, + change.changeEndExclusiveIndex, + ) + ) + } + + BigTextChangeEventType.Delete -> { + ast.edit( + createInputEdit( + change, + change.changeStartIndex, + change.changeEndExclusiveIndex, + change.changeStartIndex, + ) + ) + } + } + + ast = parser.parse(oldAst) { byte, point -> + if (byte in 0u until change.bigText.length.toUInt()) { + change.bigText.substring(byte.toInt() ..byte.toInt()) + } else { + "" // the doc is wrong. null would result in crash + } + } + + val changedRanges = if (change.eventType == BigTextChangeEventType.Insert) { // if there is no structural change, `changedRanges` returns an empty list. but we need to update the display styles + listOf(Range(Point(0u, 0u), Point(0u, 0u), change.changeStartIndex.toUInt(), change.changeEndExclusiveIndex.toUInt())) + } else { + emptyList() + } + + oldAst.changedRanges(ast) + log.d { "AST changes (${changedRanges.size}) = ${changedRanges}" } + + changedRanges.forEach { + transformer.restoreToOriginal( + minOf(change.bigText.length - 1, it.startByte.toInt()) + until + minOf(change.bigText.length, it.endByte.toInt()) + ) + } + + changedRanges.forEach { cr -> +// ast.rootNode.descendant(it.startByte, it.endByte)?.let { +// log.d { "AST change ${it.range} type=${it.type} grammarType=${it.grammarType} p=${it.parent} sexp=${it.sexp()}" } +// } + + fun applyStyle(style: SpanStyle, node: Node) { + val startCharIndex: Int = node.startByte.toInt() + val endCharIndexExclusive: Int = node.endByte.toInt() +// val ar = AnnotatedString.Range(style, startCharIndex, endCharIndexExclusive) + transformer.replace(startCharIndex until endCharIndexExclusive, AnnotatedString(change.bigText.substring(startCharIndex until endCharIndexExclusive).toString(), style), BigTextTransformOffsetMapping.Incremental) + log.d { "AST change highlight -- $startCharIndex ..< $endCharIndexExclusive" } + } + + val cr = cr.startByte until cr.endByte + + ast.rootNode.visit { + log.d { "AST visit change ${it.startByte} ..< ${it.endByte} = ${it.type}" } + + fun visitChildrens() { + it.children.forEach { c -> + if ((c.startByte until c.endByte) hasIntersectWith cr) { + visit(c) + } + } + } + + when (it.type) { + "pair" -> { + val keyChild = it.childByFieldName("key")!! + applyStyle(objectKeyStyle, keyChild) +// log.v { "AST change highlight ${keyChild.startByte} ..< ${keyChild.endByte} = key" } +// log.v { "AST highlight ${keyChild.startPoint.toCharIndex()} ..< ${keyChild.endPoint.toCharIndex()} = key" } + it.childByFieldName("value")?.let { + visit(it) + } + } + "null" -> { + applyStyle(nothingLiteralStyle, it) + } + "number" -> { + applyStyle(numberLiteralStyle, it) + } + "string" -> { + applyStyle(stringLiteralStyle, it) + } + "false" -> { + applyStyle(booleanFalseLiteralStyle, it) + } + "true" -> { + applyStyle(booleanTrueLiteralStyle, it) + } + else -> { + visitChildrens() + } + } + } + } + + log.d { "AST change sexp = ${ast.rootNode.sexp()}" } + + } + + fun createInputEdit(event: BigTextChangeEvent, startOffset: Int, oldEndOffset: Int, newEndOffset: Int): InputEdit { + fun toPoint(offset: Int): Point { + return event.bigText.findLineAndColumnFromRenderPosition(offset) + .also { + require(it.first >= 0 && it.second >= 0) { + (event.bigText as BigTextImpl).printDebug("[ERROR]") + "convert out of range. i=$offset, lc=$it, s = |${event.bigText.buildString()}|" + } + } + .toPoint() + } + + return InputEdit( + startOffset.toUInt(), + oldEndOffset.toUInt(), + newEndOffset.toUInt(), + toPoint(startOffset), + toPoint(oldEndOffset), + toPoint(newEndOffset), + ) + } + fun createAnnotatedRange(text: BigText, style: SpanStyle, astNode: Node): AnnotatedString.Range { + val startCharIndex = astNode.startByte.toInt() + val endCharIndex = astNode.endByte.toInt() +// val startCharIndex = text.findRenderCharIndexByLineAndColumn(astNode.startPoint.row.toInt(), astNode.startPoint.column.toInt()) +// val endCharIndex = text.findRenderCharIndexByLineAndColumn(astNode.endPoint.row.toInt(), astNode.endPoint.column.toInt()) + return AnnotatedString.Range(style, startCharIndex, endCharIndex) } From 69cf9b77ff77547bdab69689d0386c7368fa3dbd Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 13 Oct 2024 18:17:45 +0800 Subject: [PATCH 117/195] fix native lib output stream was not closed after use --- .../sunnychung/application/multiplatform/hellohttp/Main.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt index 7ace744f..87a11a3a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt @@ -168,8 +168,11 @@ fun loadNativeLibraries() { println("Loading native lib $libFileName") val dest = File(File(AppContext.dataDir, "lib"), libFileName) dest.parentFile.mkdirs() - enclosingClazz.javaClass.classLoader.getResourceAsStream(libFileName).use { - it.copyTo(FileOutputStream(dest)) + enclosingClazz.javaClass.classLoader.getResourceAsStream(libFileName).use { `is` -> + `is` ?: throw RuntimeException("Lib $libFileName not found") + FileOutputStream(dest).use { os -> + `is`.copyTo(os) + } } System.load(dest.absolutePath) } From 0a220cc757bfd67069d1bbafa54d98770dd7f21a Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 13 Oct 2024 21:51:20 +0800 Subject: [PATCH 118/195] fix JSON syntax highlight is wrong after making changes to the text --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 32 ++++++++----------- .../bigtext/IncrementalTextTransformation.kt | 3 +- ...onmentVariableIncrementalTransformation.kt | 2 +- ...yntaxHighlightIncrementalTransformation.kt | 24 ++++++++++---- .../MultipleIncrementalTransformation.kt | 10 ++++-- 5 files changed, 43 insertions(+), 28 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index bc50fae8..16d89897 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -474,17 +474,15 @@ private fun CoreBigMonospaceText( } fun onValuePreChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) { - if (eventType == BigTextChangeEventType.Delete) { - viewState.version = Random.nextLong() - val event = generateChangeEvent(eventType, changeStartIndex, changeEndExclusiveIndex) - - // invoke textTransformation listener before deletion, so that it knows what will be deleted and transform accordingly - (textTransformation as? IncrementalTextTransformation)?.onTextChange( - event, - transformedText, - transformedState - ) - } + viewState.version = Random.nextLong() + val event = generateChangeEvent(eventType, changeStartIndex, changeEndExclusiveIndex) + + // invoke textTransformation listener before deletion, so that it knows what will be deleted and transform accordingly + (textTransformation as? IncrementalTextTransformation)?.beforeTextChange( + event, + transformedText, + transformedState + ) } fun onValuePostChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) { @@ -492,13 +490,11 @@ private fun CoreBigMonospaceText( viewState.version = Random.nextLong() val event = generateChangeEvent(eventType, changeStartIndex, changeEndExclusiveIndex) - if (eventType != BigTextChangeEventType.Delete) { - (textTransformation as? IncrementalTextTransformation)?.onTextChange( - event, - transformedText, - transformedState - ) - } + (textTransformation as? IncrementalTextTransformation)?.afterTextChange( + event, + transformedText, + transformedState + ) onTextChange(event) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt index 6ad50ae6..10e5c90e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt @@ -4,5 +4,6 @@ interface IncrementalTextTransformation { fun initialize(text: BigText, transformer: BigTextTransformer): C - fun onTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: C) + fun beforeTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: C) = Unit + fun afterTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: C) = Unit } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt index 6f192dae..d160634c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt @@ -30,7 +30,7 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati } } - override fun onTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { + override fun beforeTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { // TODO handle multiple matches (e.g. triggered by pasting text) val originalText = change.bigText diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt index 12a7589c..5fa3feec 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt @@ -51,12 +51,11 @@ class JsonSyntaxHighlightIncrementalTransformation(val colours: AppColor) : Incr ) val parser: Parser - var ast: Tree + lateinit var ast: Tree init { val language = Language(TreeSitterJson.language()) parser = Parser(language) - ast = parser.parse("") } override fun initialize(text: BigText, transformer: BigTextTransformer) { @@ -170,7 +169,7 @@ class JsonSyntaxHighlightIncrementalTransformation(val colours: AppColor) : Incr } } - override fun onTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { + override fun afterTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { val oldAst = ast when (change.eventType) { @@ -199,12 +198,23 @@ class JsonSyntaxHighlightIncrementalTransformation(val colours: AppColor) : Incr ast = parser.parse(oldAst) { byte, point -> if (byte in 0u until change.bigText.length.toUInt()) { - change.bigText.substring(byte.toInt() ..byte.toInt()) + change.bigText.substring(byte.toInt() ..byte.toInt()).let { + val codePoints = it.codePoints().toArray() + if (codePoints.size > 1 || codePoints.first() > 255) { + "X" // replace multibyte char as single-byte char + } else { + it + } + } } else { "" // the doc is wrong. null would result in crash + }.also { + println("parse $byte = '$it'") } } + log.d { "AST change sexp = ${ast.rootNode.sexp()}" } + val changedRanges = if (change.eventType == BigTextChangeEventType.Insert) { // if there is no structural change, `changedRanges` returns an empty list. but we need to update the display styles listOf(Range(Point(0u, 0u), Point(0u, 0u), change.changeStartIndex.toUInt(), change.changeEndExclusiveIndex.toUInt())) } else { @@ -279,7 +289,7 @@ class JsonSyntaxHighlightIncrementalTransformation(val colours: AppColor) : Incr } } - log.d { "AST change sexp = ${ast.rootNode.sexp()}" } + log.d { "AST change sexp after = ${ast.rootNode.sexp()}" } } @@ -302,7 +312,9 @@ class JsonSyntaxHighlightIncrementalTransformation(val colours: AppColor) : Incr toPoint(startOffset), toPoint(oldEndOffset), toPoint(newEndOffset), - ) + ).also { + log.d { "AST InputEdit ${it.startByte} ${it.oldEndByte} ${it.newEndByte} ${it.startPoint} ${it.oldEndPoint} ${it.newEndPoint}" } + } } fun createAnnotatedRange(text: BigText, style: SpanStyle, astNode: Node): AnnotatedString.Range { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt index 233a2577..ceee4953 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt @@ -13,9 +13,15 @@ class MultipleIncrementalTransformation(val transformations: List).onTextChange(change, transformer, context) + (it as IncrementalTextTransformation).beforeTextChange(change, transformer, context) + } + } + + override fun afterTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Any?) { + transformations.forEach { + (it as IncrementalTextTransformation).afterTextChange(change, transformer, context) } } From 366f2db7e4340912d75c04678139458d484002cd Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 13 Oct 2024 22:09:07 +0800 Subject: [PATCH 119/195] fix test --- .../com/sunnychung/application/multiplatform/hellohttp/Main.kt | 2 +- .../multiplatform/hellohttp/test/RequestResponseTest.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt index 87a11a3a..9ad31695 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt @@ -48,7 +48,6 @@ fun main() { val appDir = AppDirsFactory.getInstance().getUserDataDir("Hello HTTP", null, null) println("appDir = $appDir") AppContext.dataDir = File(appDir) - loadNativeLibraries() runBlocking { try { AppContext.SingleInstanceProcessService.apply { dataDir = File(appDir) }.enforce() @@ -69,6 +68,7 @@ fun main() { } exitProcess(1) } + loadNativeLibraries() println("Preparing to start") AppContext.PersistenceManager.initialize() diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTest.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTest.kt index c502338e..745a54fd 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTest.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTest.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import com.sunnychung.application.multiplatform.hellohttp.AppContext +import com.sunnychung.application.multiplatform.hellohttp.loadNativeLibraries import com.sunnychung.application.multiplatform.hellohttp.model.ContentType import com.sunnychung.application.multiplatform.hellohttp.model.FieldValueType import com.sunnychung.application.multiplatform.hellohttp.model.FileBody @@ -57,6 +58,7 @@ class RequestResponseTest(testName: String, httpVersion: HttpConfig.HttpProtocol val appDir = File("build/testrun/data") AppContext.dataDir = appDir AppContext.SingleInstanceProcessService.apply { dataDir = appDir }.enforce() + loadNativeLibraries() runBlocking { AppContext.PersistenceManager.initialize() } From 514b1e4b7cead26f521546039ba45ff10efdfedc Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 13 Oct 2024 22:11:28 +0800 Subject: [PATCH 120/195] fix could not run in Linux --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index ea6a5097..164efe5b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -85,7 +85,7 @@ kotlin { // incremental parser implementation("io.github.tree-sitter:ktreesitter:0.23.0") - implementation("io.github.sunny-chung:ktreesitter-json:0.23.0.0") + implementation("io.github.sunny-chung:ktreesitter-json:0.23.0.1") } resources.srcDir("$buildDir/resources") From 0a31209333e240dad52bdbdacf0b089d47d80e5b Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 14 Oct 2024 20:44:13 +0800 Subject: [PATCH 121/195] fix crash if drag within a BigMonospaceText with empty text --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 16d89897..12a6f874 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -618,7 +618,10 @@ private fun CoreBigMonospaceText( selectionEnd = selectedCharIndex viewState.transformedSelection = minOf(selectionStart, selectionEnd) .. maxOf(selectionStart, selectionEnd) viewState.updateSelectionByTransformedSelection(transformedText) - viewState.transformedCursorIndex = selectionEnd + if (selectionEnd == viewState.transformedSelection.last) 1 else 0 + viewState.transformedCursorIndex = minOf( + transformedText.length, + selectionEnd + if (selectionEnd == viewState.transformedSelection.last) 1 else 0 + ) viewState.updateCursorIndexByTransformed(transformedText) } ) From a5574d0656a700de0d7e607de32a71a155330187 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 14 Oct 2024 20:54:10 +0800 Subject: [PATCH 122/195] fix selecting a different request and then changing request body, the changes did not preserve --- .../application/multiplatform/hellohttp/ux/CodeEditorView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 3b330a72..b5fe883b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -568,7 +568,7 @@ fun CodeEditorView( // var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(text)) } // FIXME performance - LaunchedEffect(bigTextFieldState) { + LaunchedEffect(bigTextFieldState.value) { bigTextFieldState.value.valueChangesFlow .debounce(100.milliseconds().toMilliseconds()) .collect { From 8708aba41b318886a4e22356a2a292597fa65822 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 16 Oct 2024 23:57:18 +0800 Subject: [PATCH 123/195] refactor by extracting BigTextViewState class to a separated source file --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 125 ----------------- .../hellohttp/ux/bigtext/BigTextViewState.kt | 130 ++++++++++++++++++ 2 files changed, 130 insertions(+), 125 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 12a6f874..bd3edb30 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -70,7 +70,6 @@ import androidx.compose.ui.text.input.CommitTextCommand import androidx.compose.ui.text.input.ImeOptions import androidx.compose.ui.text.input.SetComposingTextCommand import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit @@ -911,130 +910,6 @@ private fun CoreBigMonospaceText( } } -class BigTextViewState { - /** - * A unique value that changes when the BigText string value is changed. - * - * This field is generated randomly and is NOT a sequence number. - */ - var version: Long by mutableStateOf(0) - internal set - - var firstVisibleRow: Int by mutableStateOf(0) - internal set - - var lastVisibleRow: Int by mutableStateOf(0) - internal set - - internal var transformedSelection: IntRange by mutableStateOf(0 .. -1) - - /** - * `transformedSelectionStart` can be different from `transformedSelection.start`. - * If a text is selected from position 5 to 1, transformedSelection = (1 .. 5) while transformedSelectionStart = 5. - */ - var transformedSelectionStart: Int by mutableStateOf(0) - - var selection: IntRange by mutableStateOf(0 .. -1) - - fun hasSelection(): Boolean = !transformedSelection.isEmpty() - - internal fun updateSelectionByTransformedSelection(transformedText: TransformedText) { - selection = transformedText.offsetMapping.transformedToOriginal(transformedSelection.first) .. - transformedText.offsetMapping.transformedToOriginal(transformedSelection.last) - } - - internal fun updateTransformedSelectionBySelection(transformedText: TransformedText) { - transformedSelection = transformedText.offsetMapping.originalToTransformed(selection.first) .. - transformedText.offsetMapping.originalToTransformed(selection.last) - } - - internal fun updateSelectionByTransformedSelection(transformedText: BigTextTransformed) { - selection = transformedText.findOriginalPositionByTransformedPosition(transformedSelection.first) .. - transformedText.findOriginalPositionByTransformedPosition(transformedSelection.last) - } - - internal fun updateTransformedSelectionBySelection(transformedText: BigTextTransformed) { - transformedSelection = transformedText.findTransformedPositionByOriginalPosition(selection.first) .. - transformedText.findTransformedPositionByOriginalPosition(selection.last) - } - - internal var transformedCursorIndex by mutableStateOf(0) - var cursorIndex by mutableStateOf(0) - - fun updateCursorIndexByTransformed(transformedText: TransformedText) { - cursorIndex = transformedText.offsetMapping.transformedToOriginal(transformedCursorIndex) - } - - fun updateTransformedCursorIndexByOriginal(transformedText: TransformedText) { - transformedCursorIndex = transformedText.offsetMapping.originalToTransformed(cursorIndex) - } - - fun updateCursorIndexByTransformed(transformedText: BigTextTransformed) { - cursorIndex = transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex).also { - log.d { "cursorIndex = $it (from T $transformedCursorIndex)" } - } - } - - fun updateTransformedCursorIndexByOriginal(transformedText: BigTextTransformed) { - transformedCursorIndex = transformedText.findTransformedPositionByOriginalPosition(cursorIndex).also { - log.d { "updateTransformedCursorIndexByOriginal = $it (from $cursorIndex)" } - } - cursorIndex = transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex) - } - - fun roundTransformedCursorIndex(direction: CursorAdjustDirection, transformedText: BigTextTransformed, compareWithPosition: Int, isOnlyWithinBlock: Boolean) { - transformedCursorIndex = roundedTransformedCursorIndex(transformedCursorIndex, direction, transformedText, compareWithPosition, isOnlyWithinBlock).also { - log.d { "roundedTransformedCursorIndex($transformedCursorIndex, $direction, ..., $compareWithPosition) = $it" } - } - } - - fun roundedTransformedCursorIndex(transformedCursorIndex: Int, direction: CursorAdjustDirection, transformedText: BigTextTransformed, compareWithPosition: Int, isOnlyWithinBlock: Boolean): Int { - val possibleRange = 0 .. transformedText.length - val previousMappedPosition = transformedText.findOriginalPositionByTransformedPosition(compareWithPosition) - when (direction) { - CursorAdjustDirection.Forward, CursorAdjustDirection.Backward -> { - val step = if (direction == CursorAdjustDirection.Forward) 1 else -1 - var delta = 0 - while (transformedCursorIndex + delta in possibleRange) { - if (transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex + delta) != previousMappedPosition) { - return transformedCursorIndex + delta + if (isOnlyWithinBlock) { - // for backward, we find the last index that is same as `previousMappedPosition` - - step - } else { - // for forward, we find the first index that is different from `previousMappedPosition` - 0 - } - } - delta += step - } - // (transformedCursorIndex + delta) is out of range - return transformedCursorIndex + delta - step - } - CursorAdjustDirection.Bidirectional -> { - var delta = 0 - while ((transformedCursorIndex + delta in possibleRange || transformedCursorIndex - delta in possibleRange)) { - if (transformedCursorIndex + delta + 1 in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex + delta + 1) != previousMappedPosition) { - return transformedCursorIndex + delta + if (transformedCursorIndex + delta - 1 in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex + delta - 1) == previousMappedPosition) { - // position (transformedCursorIndex + delta) is a block, - // while position (transformedCursorIndex + delta + 1) is not a block. - // so return (transformedCursorIndex + delta + 1) - 1 - } else { - 0 - } - } - if (transformedCursorIndex - delta - 1 in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex - delta - 1) != previousMappedPosition) { - // for backward, we find the last index that is same as `previousMappedPosition` - return transformedCursorIndex - delta //+ 1 - } - ++delta - } - return transformedCursorIndex + delta - 1 - } - } - } -} - private enum class ResolveCharPositionMode { Selection, Cursor } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt new file mode 100644 index 00000000..17ac6528 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt @@ -0,0 +1,130 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TransformedText + +class BigTextViewState { + /** + * A unique value that changes when the BigText string value is changed. + * + * This field is generated randomly and is NOT a sequence number. + */ + var version: Long by mutableStateOf(0) + internal set + + var firstVisibleRow: Int by mutableStateOf(0) + internal set + + var lastVisibleRow: Int by mutableStateOf(0) + internal set + + internal var transformedSelection: IntRange by mutableStateOf(0..-1) + + /** + * `transformedSelectionStart` can be different from `transformedSelection.start`. + * If a text is selected from position 5 to 1, transformedSelection = (1 .. 5) while transformedSelectionStart = 5. + */ + var transformedSelectionStart: Int by mutableStateOf(0) + + var selection: IntRange by mutableStateOf(0..-1) + + fun hasSelection(): Boolean = !transformedSelection.isEmpty() + + internal fun updateSelectionByTransformedSelection(transformedText: TransformedText) { + selection = transformedText.offsetMapping.transformedToOriginal(transformedSelection.first) .. + transformedText.offsetMapping.transformedToOriginal(transformedSelection.last) + } + + internal fun updateTransformedSelectionBySelection(transformedText: TransformedText) { + transformedSelection = transformedText.offsetMapping.originalToTransformed(selection.first) .. + transformedText.offsetMapping.originalToTransformed(selection.last) + } + + internal fun updateSelectionByTransformedSelection(transformedText: BigTextTransformed) { + selection = transformedText.findOriginalPositionByTransformedPosition(transformedSelection.first) .. + transformedText.findOriginalPositionByTransformedPosition(transformedSelection.last) + } + + internal fun updateTransformedSelectionBySelection(transformedText: BigTextTransformed) { + transformedSelection = transformedText.findTransformedPositionByOriginalPosition(selection.first) .. + transformedText.findTransformedPositionByOriginalPosition(selection.last) + } + + internal var transformedCursorIndex by mutableStateOf(0) + var cursorIndex by mutableStateOf(0) + + fun updateCursorIndexByTransformed(transformedText: TransformedText) { + cursorIndex = transformedText.offsetMapping.transformedToOriginal(transformedCursorIndex) + } + + fun updateTransformedCursorIndexByOriginal(transformedText: TransformedText) { + transformedCursorIndex = transformedText.offsetMapping.originalToTransformed(cursorIndex) + } + + fun updateCursorIndexByTransformed(transformedText: BigTextTransformed) { + cursorIndex = transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex).also { + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "cursorIndex = $it (from T $transformedCursorIndex)" } + } + } + + fun updateTransformedCursorIndexByOriginal(transformedText: BigTextTransformed) { + transformedCursorIndex = transformedText.findTransformedPositionByOriginalPosition(cursorIndex).also { + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "updateTransformedCursorIndexByOriginal = $it (from $cursorIndex)" } + } + cursorIndex = transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex) + } + + fun roundTransformedCursorIndex(direction: CursorAdjustDirection, transformedText: BigTextTransformed, compareWithPosition: Int, isOnlyWithinBlock: Boolean) { + transformedCursorIndex = roundedTransformedCursorIndex(transformedCursorIndex, direction, transformedText, compareWithPosition, isOnlyWithinBlock).also { + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "roundedTransformedCursorIndex($transformedCursorIndex, $direction, ..., $compareWithPosition) = $it" } + } + } + + fun roundedTransformedCursorIndex(transformedCursorIndex: Int, direction: CursorAdjustDirection, transformedText: BigTextTransformed, compareWithPosition: Int, isOnlyWithinBlock: Boolean): Int { + val possibleRange = 0 .. transformedText.length + val previousMappedPosition = transformedText.findOriginalPositionByTransformedPosition(compareWithPosition) + when (direction) { + CursorAdjustDirection.Forward, CursorAdjustDirection.Backward -> { + val step = if (direction == CursorAdjustDirection.Forward) 1 else -1 + var delta = 0 + while (transformedCursorIndex + delta in possibleRange) { + if (transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex + delta) != previousMappedPosition) { + return transformedCursorIndex + delta + if (isOnlyWithinBlock) { + // for backward, we find the last index that is same as `previousMappedPosition` + - step + } else { + // for forward, we find the first index that is different from `previousMappedPosition` + 0 + } + } + delta += step + } + // (transformedCursorIndex + delta) is out of range + return transformedCursorIndex + delta - step + } + CursorAdjustDirection.Bidirectional -> { + var delta = 0 + while ((transformedCursorIndex + delta in possibleRange || transformedCursorIndex - delta in possibleRange)) { + if (transformedCursorIndex + delta + 1 in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex + delta + 1) != previousMappedPosition) { + return transformedCursorIndex + delta + if (transformedCursorIndex + delta - 1 in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex + delta - 1) == previousMappedPosition) { + // position (transformedCursorIndex + delta) is a block, + // while position (transformedCursorIndex + delta + 1) is not a block. + // so return (transformedCursorIndex + delta + 1) + 1 + } else { + 0 + } + } + if (transformedCursorIndex - delta - 1 in possibleRange && transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex - delta - 1) != previousMappedPosition) { + // for backward, we find the last index that is same as `previousMappedPosition` + return transformedCursorIndex - delta //+ 1 + } + ++delta + } + return transformedCursorIndex + delta - 1 + } + } + } +} From 0c35ca69e3cd75ac8318c3621e7a9311ccb1945a Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 19 Oct 2024 15:17:49 +0800 Subject: [PATCH 124/195] optimize BigText deletions not to layout until the end of the node, which works poorly on a big node --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 53 +++++++++++++++---- .../ux/bigtext/BigTextTransformerImpl.kt | 23 ++++++-- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 4b87abbb..e119220e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -771,7 +771,7 @@ open class BigTextImpl( } } - protected fun deleteUnchecked(start: Int, endExclusive: Int, deleteMarker: BigTextNodeValue? = null): Int { + protected fun deleteUnchecked(start: Int, endExclusive: Int, deleteMarker: BigTextNodeValue? = null, isSkipLayout: Boolean = false): Int { if (start == endExclusive) { return 0 } @@ -861,6 +861,8 @@ open class BigTextImpl( // recomputeAggregatedValues(it) // } + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "deleteUnchecked -- before layout" } + // layout the new nodes explicitly, as // the layout outside the loop may not be able to touch the new nodes newNodesInDescendingOrder.forEach { @@ -869,7 +871,13 @@ open class BigTextImpl( layout(startPos, endPos) } - layout(maxOf(0, start - 1), minOf(length, start + 1)) + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "deleteUnchecked -- after inner layout 1" } + + if (!isSkipLayout) { + layout(maxOf(0, start - 1), minOf(length, start + 1)) + } + + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "deleteUnchecked -- after inner layout 2" } log.v { inspect("Finish D " + node?.value?.debugKey()) } @@ -1052,7 +1060,7 @@ open class BigTextImpl( var lastOccupiedWidth = 0f var isLastEndWithForceRowBreak = false var node: RedBlackTree.Node? = tree.findNodeByRenderCharIndex(startPos) ?: return - logL.d { "layout($startPos, $endPosExclusive)" } + logL.i { "layout($startPos, $endPosExclusive)" } logL.v { inspect("before layout($startPos, $endPosExclusive)") } var nodeStartPos = findRenderPositionStart(node!!) val nodeValue = node.value @@ -1083,7 +1091,7 @@ open class BigTextImpl( } else { emptyList() } - logL.d { "restore row breaks of starting node $restoreRowBreakOffsets" } + logL.v { "restore row breaks of starting node $restoreRowBreakOffsets" } var hasRestoredRowBreaks = false var isBreakOnEncounterLineBreak = false @@ -1106,7 +1114,7 @@ open class BigTextImpl( } } logL.d { "node ${nodeValue.debugKey()} LB $lineBreakIndexFrom .. $lineBreakIndexTo P $nodeStartPos" } - logL.d { "buffer ${nodeValue.bufferIndex} LB ${buffer.lineOffsetStarts}" } + logL.v { "buffer ${nodeValue.bufferIndex} LB ${buffer.lineOffsetStarts}" } // if (lineBreakIndexFrom > lineBreakIndexTo) { if (nodeStartPos > endPosExclusive + 1) { // do 1 more char because last round may just fill up the row but a row break is not created @@ -1117,7 +1125,7 @@ open class BigTextImpl( // nodeValue.rowBreakOffsets.clear() val rowBreakOffsets = mutableListOf() var isEndWithForceRowBreak = false - logL.d { "orig row breaks ${nodeValue.rowBreakOffsets} lrw=${nodeValue.lastRowWidth} for ref only" } + logL.v { "orig row breaks ${nodeValue.rowBreakOffsets} lrw=${nodeValue.lastRowWidth} for ref only" } // if (true || nodeStartPos == 0) { // // we are starting at charStartIndexInBuffer without carrying over last width, so include the row break at charStartIndexInBuffer // rowBreakOffsets += nodeValue.rowBreakOffsets.subList(0, maxOf(0, nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(charStartIndexInBuffer) + 1)) @@ -1141,7 +1149,7 @@ open class BigTextImpl( isLastEndWithForceRowBreak = false } - (lineBreakIndexFrom..lineBreakIndexTo).forEach { lineBreakEntryIndex -> + for (lineBreakEntryIndex in lineBreakIndexFrom..lineBreakIndexTo) { val lineBreakCharIndex = buffer.lineOffsetStarts[lineBreakEntryIndex] val subsequence = buffer.substring(charStartIndexInBuffer, lineBreakCharIndex) logL.d { "node ${nodeValue.debugKey()} buf #${nodeValue.bufferIndex} line break #$lineBreakEntryIndex seq $charStartIndexInBuffer ..< $lineBreakCharIndex" } @@ -1155,6 +1163,7 @@ open class BigTextImpl( // nodeValue.rowBreakOffsets += rowCharOffsets logL.d { "row break add $rowCharOffsets lw = 0" } rowBreakOffsets.addToThisAscendingListWithoutDuplicate(rowCharOffsets) + logL.d { "row break added ${rowBreakOffsets.size}" } // if (subsequence.isEmpty() && lastOccupiedWidth >= contentWidth - EPS) { // logL.d { "row break add carry-over force break ${lineBreakCharIndex}" } @@ -1166,6 +1175,7 @@ open class BigTextImpl( if (lineBreakCharIndex + 1 < nodeValue.renderBufferEndExclusive) { logL.d { "row break add ${lineBreakCharIndex + 1}" } rowBreakOffsets.addToThisAscendingListWithoutDuplicate(lineBreakCharIndex + 1) + logL.d { "row break added ${rowBreakOffsets.size}" } lastOccupiedWidth = 0f } else { // the char after the '\n' char is not in this node @@ -1176,6 +1186,25 @@ open class BigTextImpl( if (isBreakOnEncounterLineBreak) { isBreakAfterThisIteration = true + + if (lineBreakEntryIndex + 1 <= lineBreakIndexTo && buffer.lineOffsetStarts[lineBreakEntryIndex + 1] < nodeValue.renderBufferEndExclusive) { // still remain some rows to process + val rowBreakOffsetDiff = lineBreakCharIndex - buffer.lineOffsetStarts[lineBreakEntryIndex] + val restoreRowOffsetFromIndex = nodeValue.rowBreakOffsets.binarySearchForMinIndexOfValueAtLeast(buffer.lineOffsetStarts[lineBreakEntryIndex] + 1) + nodeValue.rowBreakOffsets.subList(restoreRowOffsetFromIndex, nodeValue.rowBreakOffsets.size) + .map { it + rowBreakOffsetDiff } + .filter { + // should not happen + if (it >= nodeValue.renderBufferEndExclusive) throw RuntimeException("exceeds. $it") + it < nodeValue.renderBufferEndExclusive + } + .let { restoreRowOffsets -> + rowBreakOffsets.addToThisAscendingListWithoutDuplicate(restoreRowOffsets) + } + charStartIndexInBuffer = (rowBreakOffsets.lastOrNull() ?: -1) + 1 + lastOccupiedWidth = nodeValue.lastRowWidth + isEndWithForceRowBreak = nodeValue.isEndWithForceRowBreak + } + break } } val nextBoundary = if ( @@ -1194,6 +1223,7 @@ open class BigTextImpl( val readRowUntilPos = nextBoundary //nodeValue.bufferOffsetEndExclusive //minOf(nodeValue.bufferOffsetEndExclusive, endPosExclusive - nodeStartPos + nodeValue.bufferOffsetStart) logL.d { "node ${nodeValue.debugKey()} last row seq $charStartIndexInBuffer ..< ${readRowUntilPos}. start = $nodeStartPos" } val subsequence = buffer.substring(charStartIndexInBuffer, readRowUntilPos) + logL.d { "after substring" } val (rowCharOffsets, lastRowOccupiedWidth) = layouter.layoutOneLine( subsequence, @@ -1204,6 +1234,7 @@ open class BigTextImpl( // nodeValue.rowBreakOffsets += rowCharOffsets logL.d { "row break add $rowCharOffsets lrw = $lastRowOccupiedWidth" } rowBreakOffsets.addToThisAscendingListWithoutDuplicate(rowCharOffsets) + logL.d { "row break added ${rowBreakOffsets.size}" } lastOccupiedWidth = lastRowOccupiedWidth charStartIndexInBuffer = readRowUntilPos } @@ -1216,21 +1247,23 @@ open class BigTextImpl( } else { nodeValue.rowBreakOffsets.binarySearchForMaxIndexOfValueAtMost(nodeValue.renderBufferEndExclusive - 1) } - logL.d { "reach the end, preserve RB from $preserveIndexFrom (at least $searchForValue) ~ $preserveIndexTo (${nodeValue.renderBufferEndExclusive}). RB = ${nodeValue.rowBreakOffsets}." } + logL.v { "reach the end, preserve RB from $preserveIndexFrom (at least $searchForValue) ~ $preserveIndexTo (${nodeValue.renderBufferEndExclusive}). RB = ${nodeValue.rowBreakOffsets}." } val restoreRowBreaks = nodeValue.rowBreakOffsets.subList(preserveIndexFrom, minOf(nodeValue.rowBreakOffsets.size, preserveIndexTo + 1)) if (restoreRowBreaks.isNotEmpty() || nodeValue.isEndWithForceRowBreak || isBreakAfterThisIteration) { rowBreakOffsets.addToThisAscendingListWithoutDuplicate(restoreRowBreaks) + logL.d { "row break restore end added ${rowBreakOffsets.size}" } logL.d { "Restore lw ${nodeValue.lastRowWidth}." } lastOccupiedWidth = nodeValue.lastRowWidth isEndWithForceRowBreak = isEndWithForceRowBreak || nodeValue.isEndWithForceRowBreak } } - logL.d { "node ${nodeValue.debugKey()} (${nodeStartPos} ..< ${nodeStartPos + nodeValue.renderBufferEndExclusive - nodeValue.renderBufferStart}) update lrw=$lastOccupiedWidth frb=$isEndWithForceRowBreak rb=$rowBreakOffsets" } + logL.v { "node ${nodeValue.debugKey()} (${nodeStartPos} ..< ${nodeStartPos + nodeValue.renderBufferEndExclusive - nodeValue.renderBufferStart}) update lrw=$lastOccupiedWidth frb=$isEndWithForceRowBreak rb=$rowBreakOffsets" } nodeValue.rowBreakOffsets = rowBreakOffsets nodeValue.lastRowWidth = lastOccupiedWidth nodeValue.isEndWithForceRowBreak = isEndWithForceRowBreak isLastEndWithForceRowBreak = isEndWithForceRowBreak recomputeAggregatedValues(node) // TODO optimize + logL.d { "after recomputeAggregatedValues" } if (isBreakOnEncounterLineBreak && isBreakAfterThisIteration) { // TODO it can be further optimized to break immediately on line break logL.d { "break" } @@ -1253,7 +1286,9 @@ open class BigTextImpl( // recomputeAggregatedValues(it) // } + logL.d { "before onLayoutCallback" } onLayoutCallback?.invoke() + logL.d { "after onLayoutCallback" } } override val hasLayouted: Boolean diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 8ed56de3..702d47cf 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -285,13 +285,15 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( originalRange.endInclusive + 1 ) ), - deleteMarker = null + deleteMarker = null, + isSkipLayout = true ) } else { super.deleteUnchecked( start = originalRange.start, endExclusive = originalRange.endInclusive + 1, - deleteMarker = null + deleteMarker = null, + isSkipLayout = true ) } layout(maxOf(0, renderPositionStart - 1), minOf(length, renderPositionStart + 1)) @@ -311,14 +313,24 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( return 0 } + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformDelete -- before findNodeByCharIndex" } val startNode = tree.findNodeByCharIndex(originalRange.start)!! + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformDelete -- after findNodeByCharIndex" } val renderStartPos = findRenderPositionStart(startNode) + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformDelete -- after findRenderPositionStart" } val buffer = startNode.value.buffer // the buffer is not used. just to prevent NPE - super.deleteUnchecked(originalRange.start, originalRange.endInclusive + 1, if (isAddMarker) createDeleteMarkerNodeValue(deleteMarkerRange) else null) + super.deleteUnchecked( + start = originalRange.start, + endExclusive = originalRange.endInclusive + 1, + deleteMarker = if (isAddMarker) createDeleteMarkerNodeValue(deleteMarkerRange) else null, + isSkipLayout = true, + ) + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformDelete -- after deleteUnchecked" } if (isAddMarker) { // insertDeleteMarker(originalRange) } layout(maxOf(0, renderStartPos - 1), minOf(length, renderStartPos + 1)) + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformDelete -- after layout" } // tree.visitInPostOrder { recomputeAggregatedValues(it) } // logT.d { inspect("after transformDelete $originalRange") } return - originalRange.length @@ -375,7 +387,8 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( val startNode = tree.findNodeByCharIndex(originalRange.start)!! val endNode = tree.findNodeByCharIndex(originalRange.endInclusive + 1)!! val renderStartPos = findRenderPositionStart(startNode) - val renderEndPos = findRenderPositionStart(endNode) + endNode.value.currentRenderLength +// val renderEndPos = findRenderPositionStart(endNode) + endNode.value.currentRenderLength + val renderEndPos = findTransformedPositionByOriginalPosition(originalRange.endInclusive + 1) var node: RedBlackTree.Node? = endNode var nodeRange = charIndexRangeOfNode(node!!) @@ -451,7 +464,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( } } - layout(maxOf(0, renderStartPos - 1), minOf(length, renderEndPos)) + layout(maxOf(0, renderStartPos - 1), minOf(length, renderEndPos + 1)) // tree.visitInPostOrder { recomputeAggregatedValues(it) } // logT.d { inspect("after deleteTransformIf $originalRange") } return - originalRange.length From bc85ed5121305202ac86c6fb44b18607027e65e2 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 20 Oct 2024 15:34:24 +0800 Subject: [PATCH 125/195] add CollapseIncrementalTransformation for JSON folding, and refactor JSON syntax highlighter as BigTextDecorator --- .../hellohttp/ux/CodeEditorView.kt | 65 +++-- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 37 ++- .../hellohttp/ux/bigtext/BigTextDecorator.kt | 11 + .../hellohttp/ux/bigtext/BigTextImpl.kt | 93 ++++++- .../hellohttp/ux/bigtext/BigTextLayoutable.kt | 2 + .../hellohttp/ux/bigtext/BigTextNodeValue.kt | 2 +- .../ux/bigtext/BigTextTransformed.kt | 7 + .../ux/bigtext/BigTextTransformer.kt | 2 + .../ux/bigtext/BigTextTransformerImpl.kt | 69 +++++- .../hellohttp/ux/bigtext/BigTextViewState.kt | 28 ++- .../bigtext/IncrementalTextTransformation.kt | 2 + .../CollapseIncrementalTransformation.kt | 76 ++++++ .../JsonSyntaxHighlightDecorator.kt | 231 ++++++++++++++++++ ...yntaxHighlightIncrementalTransformation.kt | 110 +++++---- .../MultipleIncrementalTransformation.kt | 11 + .../transform/BigTextTransformerLayoutTest.kt | 26 ++ 16 files changed, 692 insertions(+), 80 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextDecorator.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/CollapseIncrementalTransformation.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index b5fe883b..cc317d29 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -62,13 +62,13 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.insert import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField -import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextSimpleLayoutResult +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformed +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.rememberAnnotatedBigTextFieldState -import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.rememberBigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldColors import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDefaults import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast @@ -79,15 +79,13 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.Envi import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.FunctionTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.MultipleVisualTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.SearchHighlightTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.CollapseIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableIncrementalTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleIncrementalTransformation import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.util.regex.Pattern import kotlin.random.Random @@ -458,13 +456,14 @@ fun CodeEditorView( // modifier = Modifier.fillMaxHeight(), // ) - val (secondCacheKey, bigTextFieldState) = rememberAnnotatedBigTextFieldState(initialValue = textValue.text) - val bigTextValue = bigTextFieldState.value.text + val (secondCacheKey, bigTextFieldMutableState) = rememberAnnotatedBigTextFieldState(initialValue = textValue.text) + val bigTextFieldState = bigTextFieldMutableState.value + val bigTextValue = bigTextFieldState.text var bigTextValueId by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(Random.nextLong()) } BigTextLineNumbersView( scrollState = scrollState, - bigTextViewState = bigTextFieldState.value.viewState, + bigTextViewState = bigTextFieldState.viewState, bigTextValueId = bigTextValueId, bigText = bigTextValue as BigTextImpl, layoutResult = layoutResult, @@ -476,15 +475,34 @@ fun CodeEditorView( ) if (isReadOnly) { + val collapseIncrementalTransformation = remember(bigTextFieldState) { + CollapseIncrementalTransformation(themeColours, collapsedChars.values.toList()) + } + var transformedText by remember(bigTextFieldState) { mutableStateOf(null) } + + transformedText?.let { transformedText -> + collapseIncrementalTransformation.update(collapsedChars.values.toList(), bigTextFieldState.viewState) + } + BigMonospaceText( text = bigTextValue as BigTextImpl, padding = PaddingValues(4.dp), visualTransformation = visualTransformationToUse, + textTransformation = rememberLast(bigTextFieldState) { + MultipleIncrementalTransformation(listOf( +// JsonSyntaxHighlightIncrementalTransformation(themeColours), + collapseIncrementalTransformation, + )) + }, + textDecorator = rememberLast(bigTextFieldState, themeColours) { + JsonSyntaxHighlightDecorator(themeColours) + }, fontSize = LocalFont.current.codeEditorBodyFontSize, isSelectable = true, scrollState = scrollState, - viewState = bigTextFieldState.value.viewState, + viewState = bigTextFieldState.viewState, onTextLayout = { layoutResult = it }, + onTransformInit = { transformedText = it }, modifier = Modifier.fillMaxSize() .run { if (testTag != null) { @@ -568,8 +586,8 @@ fun CodeEditorView( // var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(text)) } // FIXME performance - LaunchedEffect(bigTextFieldState.value) { - bigTextFieldState.value.valueChangesFlow + LaunchedEffect(bigTextFieldState) { + bigTextFieldState.valueChangesFlow .debounce(100.milliseconds().toMilliseconds()) .collect { log.d { "bigTextFieldState change ${it.changeId}" } @@ -583,14 +601,17 @@ fun CodeEditorView( } BigMonospaceTextField( - textFieldState = bigTextFieldState.value, + textFieldState = bigTextFieldState, visualTransformation = visualTransformationToUse, textTransformation = remember { MultipleIncrementalTransformation(listOf( - JsonSyntaxHighlightIncrementalTransformation(themeColours), +// JsonSyntaxHighlightIncrementalTransformation(themeColours), EnvironmentVariableIncrementalTransformation() )) }, // TODO replace this testing transformation + textDecorator = rememberLast(bigTextFieldState, themeColours) { + JsonSyntaxHighlightDecorator(themeColours) + }, fontSize = LocalFont.current.codeEditorBodyFontSize, // textStyle = LocalTextStyle.current.copy( // fontFamily = FontFamily.Monospace, @@ -855,26 +876,26 @@ fun BigTextLineNumbersView( val collapsedLinesState = CollapsedLinesState(collapsableLines = collapsableLines, collapsedLines = collapsedLines) // Note that layoutResult.text != bigText - val layoutText = layoutResult?.text as? BigTextImpl + val layoutText = layoutResult?.text as? BigTextTransformerImpl var prevHasLayouted by remember { mutableStateOf(false) } prevHasLayouted = layoutText?.hasLayouted ?: false prevHasLayouted val viewportTop = scrollState.value - val firstLine = layoutText?.findLineIndexByRowIndex(bigTextViewState.firstVisibleRow) ?: 0 - val lastLine = (layoutText?.findLineIndexByRowIndex(bigTextViewState.lastVisibleRow) ?: -100) + 1 - log.d { "firstVisibleRow = ${bigTextViewState.firstVisibleRow} (L $firstLine); lastVisibleRow = ${bigTextViewState.lastVisibleRow} (L $lastLine); totalLines = ${layoutText?.numOfLines}" } + val firstLine = layoutText?.findOriginalLineIndexByRowIndex(bigTextViewState.firstVisibleRow) ?: 0 + val lastLine = (layoutText?.findOriginalLineIndexByRowIndex(bigTextViewState.lastVisibleRow) ?: -100) + 1 + log.d { "firstVisibleRow = ${bigTextViewState.firstVisibleRow} (L $firstLine); lastVisibleRow = ${bigTextViewState.lastVisibleRow} (L $lastLine); totalLines = ${layoutText?.numOfOriginalLines}" } val rowHeight = layoutResult?.rowHeight ?: 0f CoreLineNumbersView( firstLine = firstLine, - lastLine = minOf(lastLine, layoutText?.numOfLines ?: 1), - totalLines = layoutText?.numOfLines ?: 1, + lastLine = minOf(lastLine, layoutText?.numOfOriginalLines ?: 1), + totalLines = layoutText?.numOfOriginalLines ?: 1, lineHeight = (rowHeight).toDp(), // getLineOffset = { (textLayout!!.getLineTop(it) - viewportTop).toDp() }, getLineOffset = { - ((layoutText?.findFirstRowIndexOfLine(it).also { r -> - log.v { "layoutText.findFirstRowIndexOfLine($it) = $r" } + ((layoutText?.findFirstRowIndexByOriginalLineIndex(it).also { r -> + log.d { "layoutText.findFirstRowIndexOfLine($it) = $r" } } ?: 0) * rowHeight - viewportTop).toDp() }, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index bd3edb30..58c1e715 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -132,9 +132,11 @@ fun BigMonospaceText( isSelectable: Boolean = false, visualTransformation: VisualTransformation, textTransformation: IncrementalTextTransformation<*>? = null, + textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, + onTransformInit: ((BigTextTransformed) -> Unit)? = null, ) = CoreBigMonospaceText( modifier = modifier, text = text, @@ -146,9 +148,11 @@ fun BigMonospaceText( onTextChange = {}, visualTransformation = visualTransformation, textTransformation = textTransformation, + textDecorator = textDecorator, scrollState = scrollState, viewState = viewState, onTextLayout = onTextLayout, + onTransformInit = onTransformInit, ) @Composable @@ -160,6 +164,7 @@ fun BigMonospaceTextField( color: Color = LocalColor.current.text, visualTransformation: VisualTransformation, textTransformation: IncrementalTextTransformation<*>? = null, + textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) { @@ -174,6 +179,7 @@ fun BigMonospaceTextField( }, visualTransformation = visualTransformation, textTransformation = textTransformation, + textDecorator = textDecorator, scrollState = scrollState, viewState = textFieldState.viewState, onTextLayout = onTextLayout @@ -190,6 +196,7 @@ fun BigMonospaceTextField( onTextChange: (BigTextChangeEvent) -> Unit, visualTransformation: VisualTransformation, textTransformation: IncrementalTextTransformation<*>? = null, + textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, @@ -204,6 +211,7 @@ fun BigMonospaceTextField( onTextChange = onTextChange, visualTransformation = visualTransformation, textTransformation = textTransformation, + textDecorator = textDecorator, scrollState = scrollState, viewState = viewState, onTextLayout = onTextLayout, @@ -222,9 +230,11 @@ private fun CoreBigMonospaceText( onTextChange: (BigTextChangeEvent) -> Unit, visualTransformation: VisualTransformation, textTransformation: IncrementalTextTransformation<*>? = null, + textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, + onTransformInit: ((BigTextTransformed) -> Unit)? = null, ) { log.d { "CoreBigMonospaceText recompose" } @@ -278,10 +288,11 @@ private fun CoreBigMonospaceText( // } // } - val transformedText: BigTextTransformed = remember(text, textTransformation) { + val transformedText: BigTextTransformed = remember(text, textTransformation, textDecorator) { log.d { "CoreBigMonospaceText recreate BigTextTransformed" } BigTextTransformerImpl(text).also { // log.d { "transformedText = |${it.buildString()}|" } + it.decorator = textDecorator if (log.config.minSeverity <= Severity.Verbose) { it.printDebug("transformedText") } @@ -383,12 +394,34 @@ private fun CoreBigMonospaceText( if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("init transformedState") } + viewState.transformText = transformedText + onTransformInit?.invoke(transformedText) } } else { null } } + remember(text, textDecorator) { + if (textDecorator != null) { + val startInstant = KInstant.now() + textDecorator.initialize(text).also { + val endInstant = KInstant.now() + log.d { "CoreBigMonospaceText init textDecorator took ${endInstant - startInstant}" } + } + } + } + + if (textTransformation != null) { + viewState.pollReapplyTransformCharRanges().forEach { + log.d { "onReapplyTransform $it" } + val startInstant = KInstant.now() + (textTransformation as IncrementalTextTransformation) + .onReapplyTransform(text, it, transformedText, transformedState) + log.d { "onReapplyTransform done ${KInstant.now() - startInstant}" } + } + } + rememberLast(viewState.selection.start, viewState.selection.last, textTransformation) { viewState.transformedSelection = transformedText.findTransformedPositionByOriginalPosition(viewState.selection.start) .. transformedText.findTransformedPositionByOriginalPosition(maxOf(0, viewState.selection.last)) @@ -482,6 +515,7 @@ private fun CoreBigMonospaceText( transformedText, transformedState ) + textDecorator?.beforeTextChange(event) } fun onValuePostChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) { @@ -494,6 +528,7 @@ private fun CoreBigMonospaceText( transformedText, transformedState ) + textDecorator?.afterTextChange(event) onTextChange(event) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextDecorator.kt new file mode 100644 index 00000000..e7b567f4 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextDecorator.kt @@ -0,0 +1,11 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +interface BigTextDecorator { + + fun initialize(text: BigText) + + fun beforeTextChange(change: BigTextChangeEvent) = Unit + fun afterTextChange(change: BigTextChangeEvent) = Unit + + fun onApplyDecoration(text: BigText, range: IntRange): CharSequence +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index e119220e..590a598e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -56,10 +56,14 @@ open class BigTextImpl( @JvmName("_setLayouter") protected set + internal var isLayoutEnabled: Boolean = true + internal var contentWidth: Float? = null override var onLayoutCallback: (() -> Unit)? = null + var decorator: BigTextDecorator? = null + internal var changeHook: BigTextChangeHook? = null init { @@ -87,6 +91,27 @@ open class BigTextImpl( }?.let { it to lineStart + it.value.leftNumOfLineBreaks /*findLineStart(it)*/ } } + fun RedBlackTree2.findNodeByLineBreaksExact(index: Int): Pair.Node, Int>? { + var find = index + var lineStart = 0 + return findNode { + index + when (find) { + in Int.MIN_VALUE until it.value.leftNumOfLineBreaks -> if (it.left.isNotNil()) -1 else 0 +// it.value.leftNumOfLineBreaks -> if (it.left.isNotNil()) -1 else 0 + in it.value.leftNumOfLineBreaks .. it.value.leftNumOfLineBreaks + it.value.renderNumLineBreaksInRange -> 0 + in it.value.leftNumOfLineBreaks + it.value.renderNumLineBreaksInRange + 1 until Int.MAX_VALUE -> (if (it.right.isNotNil()) 1 else 0).also { compareResult -> + val isTurnRight = compareResult > 0 + if (isTurnRight) { + find -= it.value.leftNumOfLineBreaks + it.value.renderNumLineBreaksInRange + lineStart += it.value.leftNumOfLineBreaks + it.value.renderNumLineBreaksInRange + } + } + else -> throw IllegalStateException("what is find? $find") + } + }?.let { it to lineStart + it.value.leftNumOfLineBreaks /*findLineStart(it)*/ } + } + fun RedBlackTree2.findNodeByRowBreaks(index: Int): Pair.Node, Int>? { var find = index var rowStart = 0 @@ -338,6 +363,8 @@ open class BigTextImpl( return BigTextNodeValue() } + protected open fun isDecorate(nodeValue: BigTextNodeValue): Boolean = true + private fun insertChunkAtPosition(position: Int, chunkedString: CharSequence) { log.d { "$this insertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) @@ -646,6 +673,54 @@ open class BigTextImpl( return charSequenceFactory(result) } + // TODO: refactor not to duplicate implementation of substring + override fun subSequence(start: Int, endExclusive: Int): CharSequence { + require(start <= endExclusive) { "start should be <= endExclusive" } + require(0 <= start) { "Invalid start" } + require(endExclusive <= length) { "endExclusive $endExclusive is out of bound. length = $length" } + + if (start == endExclusive) { + return charSequenceFactory(charSequenceBuilderFactory(0)) + } + + val result = charSequenceBuilderFactory(endExclusive - start) + var node = tree.findNodeByRenderCharIndex(start) ?: throw IllegalStateException("Cannot find string node for position $start") + var nodeStartPos = findRenderPositionStart(node) + var numRemainCharsToCopy = endExclusive - start + var copyFromBufferIndex = start - nodeStartPos + node.value.renderBufferStart + while (numRemainCharsToCopy > 0) { + val copyEndExclusive = minOf(endExclusive, nodeStartPos + node.value.currentRenderLength) + val copyStart = maxOf(start, nodeStartPos) + val numCharsToCopy = copyEndExclusive - copyStart + val copyUntilBufferIndex = copyFromBufferIndex + numCharsToCopy + if (numCharsToCopy > 0) { + val subsequence = if (decorator != null && isDecorate(node.value)) { + decorate(copyStart, copyEndExclusive).also { + if (it.length != numCharsToCopy) { + throw IllegalStateException("Returned CharSequence from decorator has length of ${it.length}. Expected length: $numCharsToCopy") + } + } + } else { + node.value.buffer.subSequence(copyFromBufferIndex, copyUntilBufferIndex) + } + result.append(subsequence) + numRemainCharsToCopy -= numCharsToCopy + } /*else { + break + }*/ + if (numRemainCharsToCopy > 0) { + nodeStartPos += node.value.currentRenderLength + node = tree.nextNode(node) ?: throw IllegalStateException("Cannot find the next string node. Requested = $start ..< $endExclusive. Remain = $numRemainCharsToCopy") + copyFromBufferIndex = node.value.renderBufferStart + } + } + + return charSequenceFactory(result) + } + + protected open fun decorate(copyStart: Int, copyEndExclusive: Int) = + decorator!!.onApplyDecoration(this, copyStart until copyEndExclusive) + /** * @param lineOffset 0 = start of buffer; 1 = char index after the 1st '\n'; 2 = char index after the 2nd '\n'; ... */ @@ -861,7 +936,7 @@ open class BigTextImpl( // recomputeAggregatedValues(it) // } - com.sunnychung.application.multiplatform.hellohttp.util.log.d { "deleteUnchecked -- before layout" } + com.sunnychung.application.multiplatform.hellohttp.util.log.v { "deleteUnchecked -- before layout" } // layout the new nodes explicitly, as // the layout outside the loop may not be able to touch the new nodes @@ -871,13 +946,13 @@ open class BigTextImpl( layout(startPos, endPos) } - com.sunnychung.application.multiplatform.hellohttp.util.log.d { "deleteUnchecked -- after inner layout 1" } + com.sunnychung.application.multiplatform.hellohttp.util.log.v { "deleteUnchecked -- after inner layout 1" } if (!isSkipLayout) { layout(maxOf(0, start - 1), minOf(length, start + 1)) } - com.sunnychung.application.multiplatform.hellohttp.util.log.d { "deleteUnchecked -- after inner layout 2" } + com.sunnychung.application.multiplatform.hellohttp.util.log.v { "deleteUnchecked -- after inner layout 2" } log.v { inspect("Finish D " + node?.value?.debugKey()) } @@ -1051,6 +1126,15 @@ open class BigTextImpl( * @param endPosExclusive End index (exclusive) of render positions. */ fun layout(startPos: Int, endPosExclusive: Int) { + if (!isLayoutEnabled) return + _layout(startPos = startPos, endPosExclusive = endPosExclusive) + } + + fun forceLayout(startPos: Int, endPosExclusive: Int) { + _layout(startPos = startPos, endPosExclusive = endPosExclusive) + } + + private fun _layout(startPos: Int, endPosExclusive: Int) { val layouter = this.layouter ?: return val contentWidth = this.contentWidth ?: return @@ -1321,6 +1405,9 @@ open class BigTextImpl( override val lastRowIndex: Int get() = numOfRows - 1 + override val numOfOriginalLines: Int + get() = numOfLines + /** * This is an expensive operation that defeats the purpose of BigText. * TODO: take out all the usage of this function diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt index 5d383f39..f5f09218 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt @@ -6,6 +6,8 @@ interface BigTextLayoutable { val numOfLines: Int + val numOfOriginalLines: Int + val numOfRows: Int val lastRowIndex: Int diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt index 23267eba..14a339f1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextNodeValue.kt @@ -68,7 +68,7 @@ open class BigTextNodeValue : Comparable, DebuggableNode.Node): String = // "$leftStringLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.length()} r $leftNumOfRowBreaks/$rowBreakOffsets lw $lastRowWidth $isEndWithForceRowBreak '${buffer.subSequence(renderBufferStart, renderBufferEndExclusive).toString().replace("\n", "\\n")}'" - "$leftStringLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.length()} r $leftNumOfRowBreaks/$rowBreakOffsets lw $lastRowWidth $isEndWithForceRowBreak" + "$leftStringLength [$bufferIndex: $bufferOffsetStart ..< $bufferOffsetEndExclusive] L ${node.length()} r $leftNumOfRowBreaks/$rowBreakOffsets l $leftNumOfLineBreaks/$renderNumLineBreaksInRange lw $lastRowWidth $isEndWithForceRowBreak" protected fun CharSequence.quoteForMermaid(): String { return toString().replace("\n", "\\n").replace("\"", """) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformed.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformed.kt index faf1c04d..6a86ddd9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformed.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformed.kt @@ -9,4 +9,11 @@ interface BigTextTransformed : BigTextTransformer, BigText, BigTextLayoutable { fun findTransformedPositionByOriginalPosition(originalPosition: Int): Int fun findOriginalPositionByTransformedPosition(transformedPosition: Int): Int + + /** + * Request trigger reapplying transformation in the next UI pass. + * + * If BigText is used alone without UI framework, this function does nothing. + */ + fun requestReapplyTransformation(originalRange: IntRange) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt index aefe1c3b..7fcc1b0d 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformer.kt @@ -17,4 +17,6 @@ interface BigTextTransformer { fun replace(range: IntRange, text: CharSequence, offsetMapping: BigTextTransformOffsetMapping) fun restoreToOriginal(range: IntRange) + +// fun layoutTransaction(transaction: BigTextLayoutTransaction.() -> Unit) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 702d47cf..94c88e93 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.LogWriter import co.touchlab.kermit.Logger import co.touchlab.kermit.MutableLoggerConfig import co.touchlab.kermit.Severity +import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.extension.toNonEmptyRange @@ -94,6 +95,12 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( val originalLength: Int get() = tree.getRoot().length() + override val numOfOriginalLines: Int + get() = delegate.numOfOriginalLines + + // not thread-safe + val charRangesToReapplyTransforms = mutableSetOf() + override fun createNodeValue(): BigTextNodeValue { return BigTextTransformNodeValue() } @@ -313,11 +320,11 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( return 0 } - com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformDelete -- before findNodeByCharIndex" } + com.sunnychung.application.multiplatform.hellohttp.util.log.v { "transformDelete -- before findNodeByCharIndex" } val startNode = tree.findNodeByCharIndex(originalRange.start)!! - com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformDelete -- after findNodeByCharIndex" } + com.sunnychung.application.multiplatform.hellohttp.util.log.v { "transformDelete -- after findNodeByCharIndex" } val renderStartPos = findRenderPositionStart(startNode) - com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformDelete -- after findRenderPositionStart" } + com.sunnychung.application.multiplatform.hellohttp.util.log.v { "transformDelete -- after findRenderPositionStart" } val buffer = startNode.value.buffer // the buffer is not used. just to prevent NPE super.deleteUnchecked( start = originalRange.start, @@ -325,12 +332,12 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( deleteMarker = if (isAddMarker) createDeleteMarkerNodeValue(deleteMarkerRange) else null, isSkipLayout = true, ) - com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformDelete -- after deleteUnchecked" } + com.sunnychung.application.multiplatform.hellohttp.util.log.v { "transformDelete -- after deleteUnchecked" } if (isAddMarker) { // insertDeleteMarker(originalRange) } layout(maxOf(0, renderStartPos - 1), minOf(length, renderStartPos + 1)) - com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformDelete -- after layout" } + com.sunnychung.application.multiplatform.hellohttp.util.log.v { "transformDelete -- after layout" } // tree.visitInPostOrder { recomputeAggregatedValues(it) } // logT.d { inspect("after transformDelete $originalRange") } return - originalRange.length @@ -478,12 +485,14 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( } else { 0 } + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformReplace -- before transformDelete" } // transformDelete(originalRange = originalRange, isAddMarker = incrementalTransformOffsetMappingLength <= 0) transformDelete(originalRange = originalRange, isAddMarker = incrementalTransformOffsetMappingLength <= 0 || originalRange.length > incrementalTransformOffsetMappingLength, deleteMarkerRange = if (incrementalTransformOffsetMappingLength <= 0) { originalRange } else { originalRange.endInclusive + 1 - maxOf(0, originalRange.length - incrementalTransformOffsetMappingLength) .. originalRange.endInclusive }) + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformReplace -- before transformInsert" } transformInsert( pos = originalRange.start, text = newText, @@ -492,6 +501,7 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( // incrementalTransformOffsetMappingLength = incrementalTransformOffsetMappingLength - 1, isReplaceOriginal = incrementalTransformOffsetMappingLength > 0, ) + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "transformReplace -- after transformInsert" } // if (incrementalTransformOffsetMappingLength > 0 && originalRange.length > incrementalTransformOffsetMappingLength) { // insertDeleteMarker(originalRange.endInclusive + 1 - maxOf(0, originalRange.length - incrementalTransformOffsetMappingLength) .. originalRange.endInclusive) // } @@ -728,6 +738,32 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( return nodeStart + minOf(node.value.bufferLength, indexFromNodeStart) } + fun findFirstRowIndexByOriginalLineIndex(originalLineIndex: Int): Int { + require(originalLineIndex in 0 .. delegate.numOfLines) { "Original line index $originalLineIndex is out of range." } + val (originalNode, originalNodeLineStart) = delegate.tree.findNodeByLineBreaksExact(originalLineIndex) + ?: throw IllegalStateException("Node of line index $originalLineIndex is not found") + val originalNodePositionStart = delegate.tree.findPositionStart(originalNode) + val lineBreakIndex = originalLineIndex - originalNodeLineStart - 1 + val lineOriginalPositionStart = originalNodePositionStart + if (lineBreakIndex >= 0) { + val lineOffsets = originalNode.value.buffer.lineOffsetStarts + val lineOffsetStartIndex = lineOffsets.binarySearchForMinIndexOfValueAtLeast(originalNode.value.bufferOffsetStart) + require(lineOffsetStartIndex >= 0) + lineOffsets[lineOffsetStartIndex + lineBreakIndex] - originalNode.value.bufferOffsetStart + } else { + 0 + } + 1 + + val transformedPosition = findTransformedPositionByOriginalPosition(lineOriginalPositionStart) + return findRowIndexByPosition(transformedPosition) + } + + fun findOriginalLineIndexByRowIndex(rowIndex: Int): Int { + val rowPositionStart = findRowPositionStartIndexByRowIndex(rowIndex) + val originalPosition = findOriginalPositionByTransformedPosition(rowPositionStart) + val (originalLine, originalCol) = delegate.findLineAndColumnFromRenderPosition(originalPosition) + return originalLine + } + override fun insertAt(pos: Int, text: CharSequence): Int = transformInsert(pos, text) override fun append(text: CharSequence): Int = transformInsertAtOriginalEnd(text) @@ -772,6 +808,29 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( layout(maxOf(0, renderPositionAtOriginalStart - 1), minOf(length, renderPositionAtOriginalEnd + 1)) } + +// override fun layoutTransaction(transaction: BigTextLayoutTransaction.() -> Unit) { +// val transactionManager = BigTextLayoutTransaction(this) +// with(transactionManager) { +// start() +// transaction() +// close() +// } +// } + + override fun requestReapplyTransformation(originalRange: IntRange) { + charRangesToReapplyTransforms += originalRange + } + + override fun isDecorate(nodeValue: BigTextNodeValue): Boolean { + return nodeValue.bufferOwnership == BufferOwnership.Delegated + } + + override fun decorate(copyStart: Int, copyEndExclusive: Int): CharSequence { + val originalStart = findOriginalPositionByTransformedPosition(copyStart) + val originalEndExclusive = findOriginalPositionByTransformedPosition(copyEndExclusive) + return decorator!!.onApplyDecoration(delegate, originalStart until originalEndExclusive) + } } fun RedBlackTree.Node.transformedOffset(): Int = diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt index 17ac6528..6990a86a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt @@ -55,34 +55,34 @@ class BigTextViewState { internal var transformedCursorIndex by mutableStateOf(0) var cursorIndex by mutableStateOf(0) - fun updateCursorIndexByTransformed(transformedText: TransformedText) { + internal fun updateCursorIndexByTransformed(transformedText: TransformedText) { cursorIndex = transformedText.offsetMapping.transformedToOriginal(transformedCursorIndex) } - fun updateTransformedCursorIndexByOriginal(transformedText: TransformedText) { + internal fun updateTransformedCursorIndexByOriginal(transformedText: TransformedText) { transformedCursorIndex = transformedText.offsetMapping.originalToTransformed(cursorIndex) } - fun updateCursorIndexByTransformed(transformedText: BigTextTransformed) { + internal fun updateCursorIndexByTransformed(transformedText: BigTextTransformed) { cursorIndex = transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex).also { com.sunnychung.application.multiplatform.hellohttp.util.log.d { "cursorIndex = $it (from T $transformedCursorIndex)" } } } - fun updateTransformedCursorIndexByOriginal(transformedText: BigTextTransformed) { + internal fun updateTransformedCursorIndexByOriginal(transformedText: BigTextTransformed) { transformedCursorIndex = transformedText.findTransformedPositionByOriginalPosition(cursorIndex).also { com.sunnychung.application.multiplatform.hellohttp.util.log.d { "updateTransformedCursorIndexByOriginal = $it (from $cursorIndex)" } } cursorIndex = transformedText.findOriginalPositionByTransformedPosition(transformedCursorIndex) } - fun roundTransformedCursorIndex(direction: CursorAdjustDirection, transformedText: BigTextTransformed, compareWithPosition: Int, isOnlyWithinBlock: Boolean) { + internal fun roundTransformedCursorIndex(direction: CursorAdjustDirection, transformedText: BigTextTransformed, compareWithPosition: Int, isOnlyWithinBlock: Boolean) { transformedCursorIndex = roundedTransformedCursorIndex(transformedCursorIndex, direction, transformedText, compareWithPosition, isOnlyWithinBlock).also { com.sunnychung.application.multiplatform.hellohttp.util.log.d { "roundedTransformedCursorIndex($transformedCursorIndex, $direction, ..., $compareWithPosition) = $it" } } } - fun roundedTransformedCursorIndex(transformedCursorIndex: Int, direction: CursorAdjustDirection, transformedText: BigTextTransformed, compareWithPosition: Int, isOnlyWithinBlock: Boolean): Int { + internal fun roundedTransformedCursorIndex(transformedCursorIndex: Int, direction: CursorAdjustDirection, transformedText: BigTextTransformed, compareWithPosition: Int, isOnlyWithinBlock: Boolean): Int { val possibleRange = 0 .. transformedText.length val previousMappedPosition = transformedText.findOriginalPositionByTransformedPosition(compareWithPosition) when (direction) { @@ -127,4 +127,20 @@ class BigTextViewState { } } } + + private val charRangesToReapplyTransforms = mutableSetOf() + + fun requestReapplyTransformation(originalRange: IntRange) { + com.sunnychung.application.multiplatform.hellohttp.util.log.d { "requestReapplyTransformation $originalRange" } + charRangesToReapplyTransforms += originalRange + } + + fun pollReapplyTransformCharRanges(): List { + val result = charRangesToReapplyTransforms.toList() + charRangesToReapplyTransforms.clear() + return result + } + + var transformText: BigTextTransformed? = null + internal set } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt index 10e5c90e..7527a1fa 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/IncrementalTextTransformation.kt @@ -6,4 +6,6 @@ interface IncrementalTextTransformation { fun beforeTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: C) = Unit fun afterTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: C) = Unit + + fun onReapplyTransform(text: BigText, originalRange: IntRange, transformer: BigTextTransformer, context: C) = Unit } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/CollapseIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/CollapseIncrementalTransformation.kt new file mode 100644 index 00000000..be278474 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/CollapseIncrementalTransformation.kt @@ -0,0 +1,76 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import com.sunnychung.application.multiplatform.hellohttp.extension.hasIntersectWith +import com.sunnychung.application.multiplatform.hellohttp.extension.length +import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformOffsetMapping +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformer +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextViewState +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.IncrementalTextTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor + +class CollapseIncrementalTransformation(colours: AppColor, collapsedCharRanges: List) : IncrementalTextTransformation { + val collapsedStyle = SpanStyle(background = colours.backgroundCollapsed) + + private var collapsedCharRanges = collapsedCharRanges + + override fun initialize(text: BigText, transformer: BigTextTransformer) { + transform(emptyList(), collapsedCharRanges, transformer, null) + } + + fun update(newCollapsedCharRanges: List, viewState: BigTextViewState) { + val transformer: BigTextTransformer = viewState.transformText ?: return +// val newCollapsedCharRanges = filterOverlappedIntervals(newCollapsedCharRanges) + val old = collapsedCharRanges + collapsedCharRanges = newCollapsedCharRanges + + val restores = old.filter { r1 -> + newCollapsedCharRanges.none { r2 -> r1 hasIntersectWith r2 && !(r1 surrounds r2) } + } + val additions = filterOverlappedIntervals( + old.filter { r1 -> + restores.any { r2 -> r1 hasIntersectWith r2 && r2 surrounds r1 } + } + + newCollapsedCharRanges.filter { r1 -> + old.none { r2 -> r1 hasIntersectWith r2 && !(r1 surrounds r2) } + } + ) + transform(restores, additions, transformer, viewState) + } + + private fun filterOverlappedIntervals(intervals: List): List { + return intervals.filter { r1 -> + intervals.none { r2 -> + r1 !== r2 && r1 hasIntersectWith r2 && !(r1 surrounds r2) + } + } + } + + private infix fun IntRange.surrounds(other: IntRange): Boolean { + return start < other.start && endInclusive > other.endInclusive + } + + private fun transform(restores: List, additions: List, transformer: BigTextTransformer, viewState: BigTextViewState?) { + log.d { "CollapseBigTransformation restores=$restores, additions=$additions" } + + restores.forEach { + transformer.restoreToOriginal(it) + viewState?.requestReapplyTransformation(it) + } + + additions.forEach { + log.d { "CollapseBigTransformation Replace ${it}" } + if (it.length <= 2) return@forEach + transformer.replace(it.first + 1 .. it.last - 1, buildAnnotatedString { + append(" ") + append(AnnotatedString("...", collapsedStyle)) + append(" ") + }, BigTextTransformOffsetMapping.WholeBlock) + } + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt new file mode 100644 index 00000000..458fd029 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt @@ -0,0 +1,231 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import com.sunnychung.application.multiplatform.hellohttp.extension.hasIntersectWith +import com.sunnychung.application.multiplatform.hellohttp.extension.length +import com.sunnychung.application.multiplatform.hellohttp.util.VisitScope +import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.util.toPoint +import com.sunnychung.application.multiplatform.hellohttp.util.visit +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEventType +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextDecorator +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformOffsetMapping +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformer +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.IncrementalTextTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor +import com.sunnychung.lib.multiplatform.kdatetime.KInstant +import io.github.treesitter.ktreesitter.InputEdit +import io.github.treesitter.ktreesitter.Language +import io.github.treesitter.ktreesitter.Node +import io.github.treesitter.ktreesitter.Parser +import io.github.treesitter.ktreesitter.Point +import io.github.treesitter.ktreesitter.Range +import io.github.treesitter.ktreesitter.Tree +import io.github.treesitter.ktreesitter.json.TreeSitterJson + +class JsonSyntaxHighlightDecorator(val colours: AppColor) : BigTextDecorator { + val objectKeyStyle = SpanStyle(color = colours.syntaxColor.objectKey) + val stringLiteralStyle = SpanStyle(color = colours.syntaxColor.stringLiteral) + val numberLiteralStyle = SpanStyle(color = colours.syntaxColor.numberLiteral) + val booleanTrueLiteralStyle = SpanStyle(color = colours.syntaxColor.booleanTrueLiteral) + val booleanFalseLiteralStyle = SpanStyle(color = colours.syntaxColor.booleanFalseLiteral) + val nothingLiteralStyle = SpanStyle(color = colours.syntaxColor.nothingLiteral) + + val parser: Parser + lateinit var ast: Tree + + init { + val language = Language(TreeSitterJson.language()) + parser = Parser(language) + } + + override fun initialize(text: BigText) { + fun Point.toCharIndex(): Int { + val charIndex = text.findRenderCharIndexByLineAndColumn(row.toInt(), column.toInt()) + return charIndex + } + + // Tree Sitter incremental approach + val s = text.buildString() + + // In Tree-sitter, multibyte utf8 characters would occupy multiple positions and have no workaround +// val singleByteCharSequence = s.map { // buggy +// if (it.code > 255) { +// 'X' +// } else { +// it +// } +// }.joinToString() +// ast = parser.parse(singleByteCharSequence) + ast = parser.parse { byte, point -> + if (byte in 0u until text.length.toUInt()) { + s.substring(byte.toInt() ..byte.toInt()).let { + val codePoints = it.codePoints().toArray() + if (codePoints.size > 1 || codePoints.first() > 255) { + "X" // replace multibyte char as single-byte char + } else { + it + } + } + } else { + "" // the doc is wrong. null would result in crash + }/*.also { + println("parse $byte = '$it'") + }*/ + } + +// ast.rootNode.children.forEach { +// log.d { "AST init parse ${it.range} type=${it.type} grammarType=${it.grammarType} p=${it.parent}" } +// } +// log.d { "AST init sexp = ${ast.rootNode.sexp()}" } + } + + override fun afterTextChange(change: BigTextChangeEvent) { + val oldAst = ast + + when (change.eventType) { + BigTextChangeEventType.Insert -> { + ast.edit( + createInputEdit( + change, + change.changeStartIndex, + change.changeStartIndex, + change.changeEndExclusiveIndex, + ) + ) + } + + BigTextChangeEventType.Delete -> { + ast.edit( + createInputEdit( + change, + change.changeStartIndex, + change.changeEndExclusiveIndex, + change.changeStartIndex, + ) + ) + } + } + + ast = parser.parse(oldAst) { byte, point -> + if (byte in 0u until change.bigText.length.toUInt()) { + change.bigText.substring(byte.toInt() ..byte.toInt()).let { + val codePoints = it.codePoints().toArray() + if (codePoints.size > 1 || codePoints.first() > 255) { + "X" // replace multibyte char as single-byte char + } else { + it + } + } + } else { + "" // the doc is wrong. null would result in crash + }.also { + println("parse $byte = '$it'") + } + } + + log.v { "AST change sexp = ${ast.rootNode.sexp()}" } + } + + override fun onApplyDecoration(text: BigText, originalRange: IntRange): CharSequence { + log.v { "json sh onApplyDecoration ${originalRange}" } + return highlight(text, originalRange) { + it.children.forEach { c -> + if ((c.startByte.toInt() until c.endByte.toInt()) hasIntersectWith originalRange) { + visit(c) + } + } + } + } + + protected fun highlight(bigText: BigText, range: IntRange, visitChildrensFunction: VisitScope.(Node) -> Unit): AnnotatedString { + val spans = mutableListOf>() + + fun applyStyle(bigText: BigText, style: SpanStyle, node: Node) { + createAnnotatedRange(bigText, style, node, range)?.let { + spans += it + } + } + + ast.rootNode.visit { + log.v { "AST visit change ${it.startByte} ..< ${it.endByte} = ${it.type}" } + + fun visitChildrens() { + visitChildrensFunction(it) + } + + when (it.type) { + "pair" -> { + val keyChild = it.childByFieldName("key")!! + applyStyle(bigText, objectKeyStyle, keyChild) +// log.v { "AST change highlight ${keyChild.startByte} ..< ${keyChild.endByte} = key" } +// log.v { "AST highlight ${keyChild.startPoint.toCharIndex()} ..< ${keyChild.endPoint.toCharIndex()} = key" } + it.childByFieldName("value")?.let { + visit(it) + } + } + "null" -> { + applyStyle(bigText, nothingLiteralStyle, it) + } + "number" -> { + applyStyle(bigText, numberLiteralStyle, it) + } + "string" -> { + applyStyle(bigText, stringLiteralStyle, it) + } + "false" -> { + applyStyle(bigText, booleanFalseLiteralStyle, it) + } + "true" -> { + applyStyle(bigText, booleanTrueLiteralStyle, it) + } + else -> { + visitChildrens() + } + } + log.v { "AST finish visit change ${it.startByte} ..< ${it.endByte}" } + } + + return AnnotatedString(bigText.substring(range).toString(), spans) + } + + fun createInputEdit(event: BigTextChangeEvent, startOffset: Int, oldEndOffset: Int, newEndOffset: Int): InputEdit { + fun toPoint(offset: Int): Point { + return event.bigText.findLineAndColumnFromRenderPosition(offset) + .also { + require(it.first >= 0 && it.second >= 0) { + (event.bigText as BigTextImpl).printDebug("[ERROR]") + "convert out of range. i=$offset, lc=$it, s = |${event.bigText.buildString()}|" + } + } + .toPoint() + } + + return InputEdit( + startOffset.toUInt(), + oldEndOffset.toUInt(), + newEndOffset.toUInt(), + toPoint(startOffset), + toPoint(oldEndOffset), + toPoint(newEndOffset), + ).also { + log.d { "AST InputEdit ${it.startByte} ${it.oldEndByte} ${it.newEndByte} ${it.startPoint} ${it.oldEndPoint} ${it.newEndPoint}" } + } + } + + fun createAnnotatedRange(text: BigText, style: SpanStyle, astNode: Node, bigTextRange: IntRange): AnnotatedString.Range? { + val startCharIndex = maxOf(0, astNode.startByte.toInt() - bigTextRange.start) + val endCharIndex = minOf(bigTextRange.length, maxOf(0, astNode.endByte.toInt() - bigTextRange.start)) +// val startCharIndex = text.findRenderCharIndexByLineAndColumn(astNode.startPoint.row.toInt(), astNode.startPoint.column.toInt()) +// val endCharIndex = text.findRenderCharIndexByLineAndColumn(astNode.endPoint.row.toInt(), astNode.endPoint.column.toInt()) + if (startCharIndex >= endCharIndex) { + return null + } + return AnnotatedString.Range(style, startCharIndex, endCharIndex) + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt index 5fa3feec..a18183bd 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt @@ -3,6 +3,7 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.inc import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import com.sunnychung.application.multiplatform.hellohttp.extension.hasIntersectWith +import com.sunnychung.application.multiplatform.hellohttp.util.VisitScope import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.util.toPoint import com.sunnychung.application.multiplatform.hellohttp.util.visit @@ -12,8 +13,10 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChan import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformOffsetMapping import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformer +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.IncrementalTextTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor +import com.sunnychung.lib.multiplatform.kdatetime.KInstant import io.github.treesitter.ktreesitter.InputEdit import io.github.treesitter.ktreesitter.Language import io.github.treesitter.ktreesitter.Node @@ -236,61 +239,84 @@ class JsonSyntaxHighlightIncrementalTransformation(val colours: AppColor) : Incr // log.d { "AST change ${it.range} type=${it.type} grammarType=${it.grammarType} p=${it.parent} sexp=${it.sexp()}" } // } - fun applyStyle(style: SpanStyle, node: Node) { - val startCharIndex: Int = node.startByte.toInt() - val endCharIndexExclusive: Int = node.endByte.toInt() -// val ar = AnnotatedString.Range(style, startCharIndex, endCharIndexExclusive) - transformer.replace(startCharIndex until endCharIndexExclusive, AnnotatedString(change.bigText.substring(startCharIndex until endCharIndexExclusive).toString(), style), BigTextTransformOffsetMapping.Incremental) - log.d { "AST change highlight -- $startCharIndex ..< $endCharIndexExclusive" } + val cr = cr.startByte until cr.endByte + + highlight(change.bigText, transformer) { + it.children.forEach { c -> + if ((c.startByte until c.endByte) hasIntersectWith cr) { + visit(c) + } + } } + } - val cr = cr.startByte until cr.endByte + log.d { "AST change sexp after = ${ast.rootNode.sexp()}" } - ast.rootNode.visit { - log.d { "AST visit change ${it.startByte} ..< ${it.endByte} = ${it.type}" } + } - fun visitChildrens() { - it.children.forEach { c -> - if ((c.startByte until c.endByte) hasIntersectWith cr) { - visit(c) - } + override fun onReapplyTransform(text: BigText, originalRange: IntRange, transformer: BigTextTransformer, context: Unit) { + log.d { "json sh onReapplyTransform ${originalRange}" } +// transformer.layoutTransaction { + highlight(text, transformer) { + it.children.forEach { c -> + if ((c.startByte.toInt() until c.endByte.toInt()) hasIntersectWith originalRange) { + visit(c) } } + } +// layout(originalRange.start, originalRange.endInclusive + 1) +// } + log.d { "no. of nodes = ${(transformer as BigTextTransformerImpl).tree.size()}" } + } - when (it.type) { - "pair" -> { - val keyChild = it.childByFieldName("key")!! - applyStyle(objectKeyStyle, keyChild) + fun applyStyle(bigText: BigText, transformer: BigTextTransformer, style: SpanStyle, node: Node) { + val startCharIndex: Int = node.startByte.toInt() + val endCharIndexExclusive: Int = node.endByte.toInt() +// val ar = AnnotatedString.Range(style, startCharIndex, endCharIndexExclusive) + val startInstant = KInstant.now() + transformer.replace(startCharIndex until endCharIndexExclusive, AnnotatedString(bigText.substring(startCharIndex until endCharIndexExclusive).toString(), style), BigTextTransformOffsetMapping.Incremental) + log.d { "AST change highlight -- $startCharIndex ..< $endCharIndexExclusive -- ${KInstant.now() - startInstant}" } + } + + protected fun highlight(bigText: BigText, transformer: BigTextTransformer, visitChildrensFunction: VisitScope.(Node) -> Unit) { + ast.rootNode.visit { + log.d { "AST visit change ${it.startByte} ..< ${it.endByte} = ${it.type}" } + + fun visitChildrens() { + visitChildrensFunction(it) + } + + when (it.type) { + "pair" -> { + val keyChild = it.childByFieldName("key")!! + applyStyle(bigText, transformer, objectKeyStyle, keyChild) // log.v { "AST change highlight ${keyChild.startByte} ..< ${keyChild.endByte} = key" } // log.v { "AST highlight ${keyChild.startPoint.toCharIndex()} ..< ${keyChild.endPoint.toCharIndex()} = key" } - it.childByFieldName("value")?.let { - visit(it) - } - } - "null" -> { - applyStyle(nothingLiteralStyle, it) - } - "number" -> { - applyStyle(numberLiteralStyle, it) - } - "string" -> { - applyStyle(stringLiteralStyle, it) - } - "false" -> { - applyStyle(booleanFalseLiteralStyle, it) - } - "true" -> { - applyStyle(booleanTrueLiteralStyle, it) - } - else -> { - visitChildrens() + it.childByFieldName("value")?.let { + visit(it) } } + "null" -> { + applyStyle(bigText, transformer, nothingLiteralStyle, it) + } + "number" -> { + applyStyle(bigText, transformer, numberLiteralStyle, it) + } + "string" -> { + applyStyle(bigText, transformer, stringLiteralStyle, it) + } + "false" -> { + applyStyle(bigText, transformer, booleanFalseLiteralStyle, it) + } + "true" -> { + applyStyle(bigText, transformer, booleanTrueLiteralStyle, it) + } + else -> { + visitChildrens() + } } + log.d { "AST finish visit change ${it.startByte} ..< ${it.endByte}" } } - - log.d { "AST change sexp after = ${ast.rootNode.sexp()}" } - } fun createInputEdit(event: BigTextChangeEvent, startOffset: Int, oldEndOffset: Int, newEndOffset: Int): InputEdit { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt index ceee4953..64509a76 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt @@ -25,4 +25,15 @@ class MultipleIncrementalTransformation(val transformations: List).onReapplyTransform(text, originalRange, transformer, context) + } + } + } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt index 89ac71a8..9e79cfdb 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -642,6 +642,32 @@ class BigTextTransformerLayoutTest { } } + @ParameterizedTest + @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) + fun findFirstRowIndexByOriginalLineIndex(chunkSize: Int) { + listOf(100, 10, 37, 1000, 10000).forEach { softWrapAt -> + val t = BigTextImpl(chunkSize = chunkSize).apply { + append("{\"a\":\"bcd\${{abc\nde}}ef}\"}\n\n\${{asd\n\nf}}\n\n1234567890223456789032345678904234567890\n\n") + } + val tt = BigTextTransformerImpl(t).apply { + setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + setContentWidth(16f * softWrapAt + 1.23f) + } + val v = BigTextVerifyImpl(tt) + v.replace(9 .. 19, "") + v.replace(27 .. 37, "") + v.verifyPositionCalculation() + val expectedRowPosStarts = when (softWrapAt) { + 10 -> listOf(0, 1, 2, 3, 3, 3, 4, 5, 9, 10) + 37 -> listOf(0, 0, 1, 2, 2, 2, 3, 4, 6, 7) + else -> listOf(0, 0, 1, 2, 2, 2, 3, 4, 5, 6) + } + expectedRowPosStarts.forEachIndexed { i, expected -> + assertEquals(expected, tt.findFirstRowIndexByOriginalLineIndex(i), "chunkSize=$chunkSize, softWrapAt $softWrapAt, line $i") + } + } + } + @ParameterizedTest @ValueSource(ints = [1048576, 64, 16]) fun deleteOriginal(chunkSize: Int) { From 1b7aa706302d865f969a036e1e1fbe1a6edba403 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 20 Oct 2024 20:15:03 +0800 Subject: [PATCH 126/195] fix exception when BigMonospaceTextField has no text --- .../multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt | 3 +++ .../hellohttp/ux/bigtext/BigTextTransformerImpl.kt | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 590a598e..5236bca9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -965,6 +965,9 @@ open class BigTextImpl( } override fun findLineAndColumnFromRenderPosition(renderPosition: Int): Pair { + if (tree.isEmpty && renderPosition == 0) { + return 0 to 0 + } val node = tree.findNodeByRenderCharIndex(renderPosition) ?: throw IndexOutOfBoundsException("Node for position $renderPosition not found") val nodeStart = findRenderPositionStart(node) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 94c88e93..7073eb7b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -740,6 +740,9 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( fun findFirstRowIndexByOriginalLineIndex(originalLineIndex: Int): Int { require(originalLineIndex in 0 .. delegate.numOfLines) { "Original line index $originalLineIndex is out of range." } + if (delegate.tree.isEmpty && originalLineIndex == 0) { + return 0 + } val (originalNode, originalNodeLineStart) = delegate.tree.findNodeByLineBreaksExact(originalLineIndex) ?: throw IllegalStateException("Node of line index $originalLineIndex is not found") val originalNodePositionStart = delegate.tree.findPositionStart(originalNode) From 61b5cdd8cc17f54c8a508a92d46fdfee4d70620c Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 20 Oct 2024 20:19:50 +0800 Subject: [PATCH 127/195] update environment variable incremental transformation to display background color according to variable availability, like the original design --- .../hellohttp/ux/CodeEditorView.kt | 9 ++- .../ux/bigtext/AnnotatedStringTextBuffer.kt | 15 +++-- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 4 +- .../hellohttp/ux/bigtext/BigTextDecorator.kt | 5 +- .../hellohttp/ux/bigtext/BigTextImpl.kt | 13 ++-- .../ux/bigtext/BigTextTransformerImpl.kt | 17 +++--- .../EnvironmentVariableDecorator.kt | 41 +++++++++++++ ...onmentVariableIncrementalTransformation.kt | 61 ++++++++++++++++--- .../JsonSyntaxHighlightDecorator.kt | 24 ++++---- .../incremental/MultipleTextDecorator.kt | 41 +++++++++++++ 10 files changed, 183 insertions(+), 47 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleTextDecorator.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index cc317d29..2b4b9ed7 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -80,10 +80,12 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.Func import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.MultipleVisualTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.SearchHighlightTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.CollapseIncrementalTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleIncrementalTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleTextDecorator import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch @@ -609,8 +611,11 @@ fun CodeEditorView( EnvironmentVariableIncrementalTransformation() )) }, // TODO replace this testing transformation - textDecorator = rememberLast(bigTextFieldState, themeColours) { - JsonSyntaxHighlightDecorator(themeColours) + textDecorator = rememberLast(bigTextFieldState, themeColours, knownVariables) { + MultipleTextDecorator(listOf( + JsonSyntaxHighlightDecorator(themeColours), + EnvironmentVariableDecorator(themeColours, knownVariables) + )) }, fontSize = LocalFont.current.codeEditorBodyFontSize, // textStyle = LocalTextStyle.current.copy( diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt index 53b03149..e8b66366 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/AnnotatedStringTextBuffer.kt @@ -10,7 +10,7 @@ class AnnotatedStringTextBuffer(size: Int) : TextBuffer() { // TODO optimize it to use interval tree when styles that change character width are supported // otherwise layout would be very slow - private val spanStyles = TreeMap>>() + private val spanStyles = TreeMap>() override val length: Int get() = buffer.length @@ -21,7 +21,7 @@ class AnnotatedStringTextBuffer(size: Int) : TextBuffer() { text.spanStyles.forEach { val start = it.start + baseStart val endExclusive = it.end + baseStart - spanStyles.getOrPut(start) { mutableListOf() } += (start until endExclusive) to it.item + spanStyles.getOrPut(start) { mutableListOf() } += Entry(start until endExclusive, it.item, it.tag) } buffer.append(text) return @@ -40,17 +40,20 @@ class AnnotatedStringTextBuffer(size: Int) : TextBuffer() { spanStyles = spanStyles.subMap(0, endExclusive) .flatMap { e -> e.value.filter { - queryRange hasIntersectWith it.first + queryRange hasIntersectWith it.range } .map { AnnotatedString.Range( - item = it.second, - start = maxOf(0, it.first.start - start), - end = minOf(endExclusive - start, it.first.endInclusive + 1 - start) + item = it.style, + start = maxOf(0, it.range.start - start), + end = minOf(endExclusive - start, it.range.endInclusive + 1 - start), + tag = it.tag ) } }, paragraphStyles = emptyList() ) } + + private class Entry(val range: IntRange, val style: SpanStyle, val tag: String) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 58c1e715..def09769 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -288,16 +288,16 @@ private fun CoreBigMonospaceText( // } // } - val transformedText: BigTextTransformed = remember(text, textTransformation, textDecorator) { + val transformedText: BigTextTransformed = remember(text, textTransformation) { log.d { "CoreBigMonospaceText recreate BigTextTransformed" } BigTextTransformerImpl(text).also { // log.d { "transformedText = |${it.buildString()}|" } - it.decorator = textDecorator if (log.config.minSeverity <= Severity.Verbose) { it.printDebug("transformedText") } } } + (transformedText as BigTextTransformerImpl).decorator = textDecorator // log.v { "text = |${text.buildString()}|" } // log.v { "transformedText = |${transformedText.buildString()}|" } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextDecorator.kt index e7b567f4..9a82cd41 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextDecorator.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextDecorator.kt @@ -2,10 +2,11 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext interface BigTextDecorator { - fun initialize(text: BigText) + fun initialize(text: BigText) = Unit fun beforeTextChange(change: BigTextChangeEvent) = Unit fun afterTextChange(change: BigTextChangeEvent) = Unit - fun onApplyDecoration(text: BigText, range: IntRange): CharSequence + fun onApplyDecorationOnOriginal(text: CharSequence, originalRange: IntRange): CharSequence = text + fun onApplyDecorationOnTransformation(text: CharSequence, transformedRange: IntRange): CharSequence = text } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 5236bca9..7d821336 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -363,8 +363,6 @@ open class BigTextImpl( return BigTextNodeValue() } - protected open fun isDecorate(nodeValue: BigTextNodeValue): Boolean = true - private fun insertChunkAtPosition(position: Int, chunkedString: CharSequence) { log.d { "$this insertChunkAtPosition($position, $chunkedString)" } require(chunkedString.length <= chunkSize) @@ -694,14 +692,15 @@ open class BigTextImpl( val numCharsToCopy = copyEndExclusive - copyStart val copyUntilBufferIndex = copyFromBufferIndex + numCharsToCopy if (numCharsToCopy > 0) { - val subsequence = if (decorator != null && isDecorate(node.value)) { - decorate(copyStart, copyEndExclusive).also { + val bufferSubsequence = node.value.buffer.subSequence(copyFromBufferIndex, copyUntilBufferIndex) + val subsequence = if (decorator != null) { + decorate(node.value, bufferSubsequence, copyStart until copyEndExclusive).also { if (it.length != numCharsToCopy) { throw IllegalStateException("Returned CharSequence from decorator has length of ${it.length}. Expected length: $numCharsToCopy") } } } else { - node.value.buffer.subSequence(copyFromBufferIndex, copyUntilBufferIndex) + bufferSubsequence } result.append(subsequence) numRemainCharsToCopy -= numCharsToCopy @@ -718,8 +717,8 @@ open class BigTextImpl( return charSequenceFactory(result) } - protected open fun decorate(copyStart: Int, copyEndExclusive: Int) = - decorator!!.onApplyDecoration(this, copyStart until copyEndExclusive) + protected open fun decorate(nodeValue: BigTextNodeValue, text: CharSequence, renderPositions: IntRange) = + decorator!!.onApplyDecorationOnOriginal(text, renderPositions) /** * @param lineOffset 0 = start of buffer; 1 = char index after the 1st '\n'; 2 = char index after the 2nd '\n'; ... diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 7073eb7b..671f1a40 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -825,14 +825,15 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( charRangesToReapplyTransforms += originalRange } - override fun isDecorate(nodeValue: BigTextNodeValue): Boolean { - return nodeValue.bufferOwnership == BufferOwnership.Delegated - } - - override fun decorate(copyStart: Int, copyEndExclusive: Int): CharSequence { - val originalStart = findOriginalPositionByTransformedPosition(copyStart) - val originalEndExclusive = findOriginalPositionByTransformedPosition(copyEndExclusive) - return decorator!!.onApplyDecoration(delegate, originalStart until originalEndExclusive) + override fun decorate(nodeValue: BigTextNodeValue, text: CharSequence, renderPositions: IntRange): CharSequence { + val decorator = decorator ?: return text + return if (nodeValue.bufferOwnership == BufferOwnership.Delegated) { + val originalStart = findOriginalPositionByTransformedPosition(renderPositions.start) + val originalEndInclusive = findOriginalPositionByTransformedPosition(renderPositions.endInclusive) + decorator.onApplyDecorationOnOriginal(text, originalStart .. originalEndInclusive) + } else { + decorator.onApplyDecorationOnTransformation(text, renderPositions) + } } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt new file mode 100644 index 00000000..0f312b49 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt @@ -0,0 +1,41 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontFamily +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextDecorator +import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor + +class EnvironmentVariableDecorator(themeColors: AppColor, val knownVariables: Set) : BigTextDecorator { + val knownVariableStyle = SpanStyle( + color = themeColors.variableTextColor, + background = themeColors.variableBackgroundColor, + fontFamily = FontFamily.Monospace, + ) + val unknownVariableStyle = SpanStyle( + color = themeColors.variableTextColor, + background = themeColors.variableErrorBackgroundColor, + fontFamily = FontFamily.Monospace, + ) + + override fun onApplyDecorationOnTransformation(text: CharSequence, transformedRange: IntRange): CharSequence { + if (text is AnnotatedString) { +// val tagRanges = text.getStringAnnotations(EnvironmentVariableIncrementalTransformation.TAG, 0, text.length) + val tagRanges = text.spanStyles.filter { it.tag.startsWith(EnvironmentVariableIncrementalTransformation.TAG_PREFIX) } + if (tagRanges.isNotEmpty()) { + val previousSpanStyles = text.spanStyles + val newSpanStyles = tagRanges.map { tagRange -> + val name = tagRange.tag.replaceFirst(EnvironmentVariableIncrementalTransformation.TAG_PREFIX, "") + val style = if (name in knownVariables) { + knownVariableStyle + } else { + unknownVariableStyle + } + AnnotatedString.Range(style, tagRange.start, tagRange.end) + } + return AnnotatedString(text.text, previousSpanStyles + newSpanStyles, text.paragraphStyles) + } + } + return super.onApplyDecorationOnTransformation(text, transformedRange) + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt index d160634c..453645ec 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt @@ -1,7 +1,11 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withAnnotation import com.sunnychung.application.multiplatform.hellohttp.extension.hasIntersectWith -import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.util.string import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText @@ -13,6 +17,10 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.Incremental import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.TextFBDirection class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformation { + companion object { + const val TAG_PREFIX = "EnvVar/" + const val TAG = "EnvVar" + } val processLengthLimit = 30 private val variableRegex = "\\$\\{\\{([^{}]{1,$processLengthLimit})\\}\\}".toRegex() @@ -30,7 +38,7 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati } } - override fun beforeTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { + override fun afterTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { // TODO handle multiple matches (e.g. triggered by pasting text) val originalText = change.bigText @@ -39,10 +47,20 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati // Find if there is pattern match ("\${{" or "}}") in the inserted text. // If yes, try to locate the pair within `processLengthLimit`, and make desired replacement. - originalText.findPositionByPattern(change.changeStartIndex, change.changeEndExclusiveIndex, "}}", TextFBDirection.Forward).also { + originalText.findPositionByPattern( + change.changeStartIndex, + change.changeEndExclusiveIndex, + "}}", + TextFBDirection.Forward + ).also { log.d { "EnvironmentVariableIncrementalTransformation search end end=$it" } }?.let { - val anotherBracket = originalText.findPositionByPattern(it - processLengthLimit, it - 1, "\${{", TextFBDirection.Backward) + val anotherBracket = originalText.findPositionByPattern( + it - processLengthLimit, + it - 1, + "\${{", + TextFBDirection.Backward + ) log.d { "EnvironmentVariableIncrementalTransformation search end start=$it" } if (anotherBracket != null) { val variableName = originalText.substring(anotherBracket + "\${{".length, it).string() @@ -58,10 +76,20 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati } } } - originalText.findPositionByPattern(change.changeStartIndex, change.changeEndExclusiveIndex, "\${{", TextFBDirection.Forward).also { + originalText.findPositionByPattern( + change.changeStartIndex, + change.changeEndExclusiveIndex, + "\${{", + TextFBDirection.Forward + ).also { log.d { "EnvironmentVariableIncrementalTransformation search start start=$it" } }?.let { - val anotherBracket = originalText.findPositionByPattern(it + "\${{".length, it + processLengthLimit, "}}", TextFBDirection.Forward) + val anotherBracket = originalText.findPositionByPattern( + it + "\${{".length, + it + processLengthLimit, + "}}", + TextFBDirection.Forward + ) log.d { "EnvironmentVariableIncrementalTransformation search start end=$it" } if (anotherBracket != null) { val variableName = originalText.substring(it + "\${{".length, anotherBracket).string() @@ -79,6 +107,15 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati } } + else -> {} + } + } + + override fun beforeTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { + // TODO handle multiple matches (e.g. triggered by pasting text) + + val originalText = change.bigText + when (change.eventType) { BigTextChangeEventType.Delete -> { // Find if there is pattern match ("\${{" or "}}") in the inserted text. // If yes, try to locate the pair within `processLengthLimit`, and remove the transformation by restoring them to original. @@ -104,6 +141,8 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati } } } + + else -> {} } @@ -113,8 +152,14 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati return name.matches(variableNameRegex) } - fun createSpan(variableName: String): String { // TODO change to AnnotatedString - return "<$variableName>" +// @OptIn(ExperimentalTextApi::class) + fun createSpan(variableName: String): CharSequence { // TODO change to AnnotatedString +// return buildAnnotatedString { +// withAnnotation(TAG, variableName) { +// append(variableName) +// } +// } + return AnnotatedString(variableName, listOf(AnnotatedString.Range(SpanStyle(), 0, variableName.length, "$TAG_PREFIX$variableName"))) } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt index 458fd029..6da03723 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt @@ -132,7 +132,7 @@ class JsonSyntaxHighlightDecorator(val colours: AppColor) : BigTextDecorator { log.v { "AST change sexp = ${ast.rootNode.sexp()}" } } - override fun onApplyDecoration(text: BigText, originalRange: IntRange): CharSequence { + override fun onApplyDecorationOnOriginal(text: CharSequence, originalRange: IntRange): CharSequence { log.v { "json sh onApplyDecoration ${originalRange}" } return highlight(text, originalRange) { it.children.forEach { c -> @@ -143,11 +143,11 @@ class JsonSyntaxHighlightDecorator(val colours: AppColor) : BigTextDecorator { } } - protected fun highlight(bigText: BigText, range: IntRange, visitChildrensFunction: VisitScope.(Node) -> Unit): AnnotatedString { + protected fun highlight(text: CharSequence, range: IntRange, visitChildrensFunction: VisitScope.(Node) -> Unit): AnnotatedString { val spans = mutableListOf>() - fun applyStyle(bigText: BigText, style: SpanStyle, node: Node) { - createAnnotatedRange(bigText, style, node, range)?.let { + fun applyStyle(style: SpanStyle, node: Node) { + createAnnotatedRange(style, node, range)?.let { spans += it } } @@ -162,7 +162,7 @@ class JsonSyntaxHighlightDecorator(val colours: AppColor) : BigTextDecorator { when (it.type) { "pair" -> { val keyChild = it.childByFieldName("key")!! - applyStyle(bigText, objectKeyStyle, keyChild) + applyStyle(objectKeyStyle, keyChild) // log.v { "AST change highlight ${keyChild.startByte} ..< ${keyChild.endByte} = key" } // log.v { "AST highlight ${keyChild.startPoint.toCharIndex()} ..< ${keyChild.endPoint.toCharIndex()} = key" } it.childByFieldName("value")?.let { @@ -170,19 +170,19 @@ class JsonSyntaxHighlightDecorator(val colours: AppColor) : BigTextDecorator { } } "null" -> { - applyStyle(bigText, nothingLiteralStyle, it) + applyStyle(nothingLiteralStyle, it) } "number" -> { - applyStyle(bigText, numberLiteralStyle, it) + applyStyle(numberLiteralStyle, it) } "string" -> { - applyStyle(bigText, stringLiteralStyle, it) + applyStyle(stringLiteralStyle, it) } "false" -> { - applyStyle(bigText, booleanFalseLiteralStyle, it) + applyStyle(booleanFalseLiteralStyle, it) } "true" -> { - applyStyle(bigText, booleanTrueLiteralStyle, it) + applyStyle(booleanTrueLiteralStyle, it) } else -> { visitChildrens() @@ -191,7 +191,7 @@ class JsonSyntaxHighlightDecorator(val colours: AppColor) : BigTextDecorator { log.v { "AST finish visit change ${it.startByte} ..< ${it.endByte}" } } - return AnnotatedString(bigText.substring(range).toString(), spans) + return AnnotatedString(text.toString(), spans) } fun createInputEdit(event: BigTextChangeEvent, startOffset: Int, oldEndOffset: Int, newEndOffset: Int): InputEdit { @@ -218,7 +218,7 @@ class JsonSyntaxHighlightDecorator(val colours: AppColor) : BigTextDecorator { } } - fun createAnnotatedRange(text: BigText, style: SpanStyle, astNode: Node, bigTextRange: IntRange): AnnotatedString.Range? { + fun createAnnotatedRange(style: SpanStyle, astNode: Node, bigTextRange: IntRange): AnnotatedString.Range? { val startCharIndex = maxOf(0, astNode.startByte.toInt() - bigTextRange.start) val endCharIndex = minOf(bigTextRange.length, maxOf(0, astNode.endByte.toInt() - bigTextRange.start)) // val startCharIndex = text.findRenderCharIndexByLineAndColumn(astNode.startPoint.row.toInt(), astNode.startPoint.column.toInt()) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleTextDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleTextDecorator.kt new file mode 100644 index 00000000..786a299d --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleTextDecorator.kt @@ -0,0 +1,41 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextDecorator + +class MultipleTextDecorator(val decorators: List): BigTextDecorator { + override fun initialize(text: BigText) { + decorators.forEach { + it.initialize(text) + } + } + + override fun beforeTextChange(change: BigTextChangeEvent) { + decorators.forEach { + it.beforeTextChange(change) + } + } + + override fun afterTextChange(change: BigTextChangeEvent) { + decorators.forEach { + it.afterTextChange(change) + } + } + + override fun onApplyDecorationOnOriginal(text: CharSequence, originalRange: IntRange): CharSequence { + var text = text + decorators.forEach { + text = it.onApplyDecorationOnOriginal(text, originalRange) + } + return text + } + + override fun onApplyDecorationOnTransformation(text: CharSequence, transformedRange: IntRange): CharSequence { + var text = text + decorators.forEach { + text = it.onApplyDecorationOnTransformation(text, transformedRange) + } + return text + } +} From fc36b4cfde3f9aaf132f80dad73c23eea8a2d807 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 20 Oct 2024 21:47:19 +0800 Subject: [PATCH 128/195] fix deleting a character in BigText just before a transformed block would unexpectedly delete the block as well --- .../hellohttp/ux/bigtext/BigTextTransformerImpl.kt | 4 ++-- .../test/bigtext/transform/BigTextTransformerImplTest.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 671f1a40..96d2ea19 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -289,9 +289,9 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( start = findOriginalPositionByTransformedPosition(renderPositionStart), endExclusive = findOriginalPositionByTransformedPosition( findTransformedPositionByOriginalPosition( - originalRange.endInclusive + 1 + originalRange.endInclusive ) - ), + ) + 1, deleteMarker = null, isSkipLayout = true ) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt index 2b05813e..4f31afe1 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerImplTest.kt @@ -752,7 +752,7 @@ class BigTextTransformerImplTest { transformed.transformReplace(0 .. 1, "@") original.replace(0 .. 1, "") - "*".let { expected -> + "".let { expected -> assertEquals(expected, transformed.buildString()) assertAllSubstring(expected, transformed) } From 0e263f8d2cf6f6eaa8c320be5bfe9a5924995293 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 21 Oct 2024 20:04:31 +0800 Subject: [PATCH 129/195] update environment variable incremental transformation to be able to handle multiple variables insertions and deletions at the same time --- .../hellohttp/util/RangeWithResult.kt | 3 ++ ...onmentVariableIncrementalTransformation.kt | 49 +++++++++++++++---- 2 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/RangeWithResult.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/RangeWithResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/RangeWithResult.kt new file mode 100644 index 00000000..caeb7458 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/RangeWithResult.kt @@ -0,0 +1,3 @@ +package com.sunnychung.application.multiplatform.hellohttp.util + +class RangeWithResult(val range: IntRange, val result: T) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt index 453645ec..b6192925 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withAnnotation import com.sunnychung.application.multiplatform.hellohttp.extension.hasIntersectWith +import com.sunnychung.application.multiplatform.hellohttp.util.RangeWithResult import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.util.string import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText @@ -23,9 +24,9 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati } val processLengthLimit = 30 - private val variableRegex = "\\$\\{\\{([^{}]{1,$processLengthLimit})\\}\\}".toRegex() + private val variableNameRegex = "[^{}\$\n\r]{1,$processLengthLimit}".toRegex() - private val variableNameRegex = "[^{}\n\r]{1,$processLengthLimit}".toRegex() + private val variableRegex = "\\$\\{\\{(${variableNameRegex.pattern)\\}\\}".toRegex() override fun initialize(text: BigText, transformer: BigTextTransformer) { // if (true) return @@ -39,15 +40,13 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati } override fun afterTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { - // TODO handle multiple matches (e.g. triggered by pasting text) - val originalText = change.bigText when (change.eventType) { BigTextChangeEventType.Insert -> { // Find if there is pattern match ("\${{" or "}}") in the inserted text. // If yes, try to locate the pair within `processLengthLimit`, and make desired replacement. - originalText.findPositionByPattern( + /*originalText.findPositionByPattern( change.changeStartIndex, change.changeEndExclusiveIndex, "}}", @@ -104,16 +103,40 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati log.d { "variableName '$variableName' is invalid" } } } - } + }*/ + + val changeRange = change.changeStartIndex until change.changeEndExclusiveIndex + + val variables = findNearbyPatterns(change) + variables.filter { it.range hasIntersectWith changeRange } + .forEach { + val variableName = it.result.groups[1]!!.value + transformer.restoreToOriginal(it.range) + transformer.replace(it.range, createSpan(variableName), BigTextTransformOffsetMapping.WholeBlock) + } } else -> {} } } - override fun beforeTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { - // TODO handle multiple matches (e.g. triggered by pasting text) + private fun findNearbyPatterns(change: BigTextChangeEvent): Sequence> { + val startOffset = maxOf(0, change.changeStartIndex - processLengthLimit) + val substring = change.bigText.substring( + startOffset + .. + minOf(change.bigText.length, change.changeEndExclusiveIndex + processLengthLimit) + ) + return variableRegex.findAll(substring) + .map { + RangeWithResult( + range = it.range.start + startOffset..it.range.endInclusive + startOffset, + result = it + ) + } + } + override fun beforeTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { val originalText = change.bigText when (change.eventType) { BigTextChangeEventType.Delete -> { @@ -122,7 +145,7 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati val changeRange = change.changeStartIndex until change.changeEndExclusiveIndex - originalText.findPositionByPattern(change.changeStartIndex - processLengthLimit, change.changeEndExclusiveIndex, "}}", TextFBDirection.Backward) + /*originalText.findPositionByPattern(change.changeStartIndex - processLengthLimit, change.changeEndExclusiveIndex, "}}", TextFBDirection.Backward) ?.takeIf { (it until it + "}}".length) hasIntersectWith changeRange } ?.let { originalText.findPositionByPattern(it - processLengthLimit, it - 1, "\${{", TextFBDirection.Backward) @@ -139,6 +162,12 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati log.d { "EnvironmentVariableIncrementalTransformation delete B" } transformer.restoreToOriginal(it until anotherStart + "}}".length) } + }*/ + + val variables = findNearbyPatterns(change) + variables.filter { it.range hasIntersectWith changeRange } + .forEach { + transformer.restoreToOriginal(it.range) } } @@ -163,7 +192,7 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati } } -fun BigText.findPositionByPattern(fromPosition: Int, toPosition: Int, pattern: String, direction: TextFBDirection): Int? { +private fun BigText.findPositionByPattern(fromPosition: Int, toPosition: Int, pattern: String, direction: TextFBDirection): Int? { val substringBeginIndex = maxOf(0, fromPosition - pattern.length) val substringEndExclusiveIndex = minOf(length, toPosition + pattern.length) val substring = substring(substringBeginIndex, substringEndExclusiveIndex) From 7f2738f2b7f82c8b55efebe43bb72e1afdc48325 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 21 Oct 2024 20:14:55 +0800 Subject: [PATCH 130/195] add FunctionIncrementalTransformation for rendering `$((func))` substitutions (same as original design) --- .../hellohttp/ux/CodeEditorView.kt | 4 +- ...onmentVariableIncrementalTransformation.kt | 6 +- .../FunctionIncrementalTransformation.kt | 90 +++++++++++++++++++ 3 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/FunctionIncrementalTransformation.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 2b4b9ed7..b9ff0aff 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -82,6 +82,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.Sear import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.CollapseIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableIncrementalTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.FunctionIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleIncrementalTransformation @@ -608,7 +609,8 @@ fun CodeEditorView( textTransformation = remember { MultipleIncrementalTransformation(listOf( // JsonSyntaxHighlightIncrementalTransformation(themeColours), - EnvironmentVariableIncrementalTransformation() + EnvironmentVariableIncrementalTransformation(), + FunctionIncrementalTransformation(themeColours) )) }, // TODO replace this testing transformation textDecorator = rememberLast(bigTextFieldState, themeColours, knownVariables) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt index b6192925..491cedf9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableIncrementalTransformation.kt @@ -26,7 +26,7 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati private val variableNameRegex = "[^{}\$\n\r]{1,$processLengthLimit}".toRegex() - private val variableRegex = "\\$\\{\\{(${variableNameRegex.pattern)\\}\\}".toRegex() + private val variableRegex = "\\$\\{\\{(${variableNameRegex.pattern})\\}\\}".toRegex() override fun initialize(text: BigText, transformer: BigTextTransformer) { // if (true) return @@ -124,7 +124,7 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati val startOffset = maxOf(0, change.changeStartIndex - processLengthLimit) val substring = change.bigText.substring( startOffset - .. + until minOf(change.bigText.length, change.changeEndExclusiveIndex + processLengthLimit) ) return variableRegex.findAll(substring) @@ -182,7 +182,7 @@ class EnvironmentVariableIncrementalTransformation : IncrementalTextTransformati } // @OptIn(ExperimentalTextApi::class) - fun createSpan(variableName: String): CharSequence { // TODO change to AnnotatedString + private fun createSpan(variableName: String): CharSequence { // return buildAnnotatedString { // withAnnotation(TAG, variableName) { // append(variableName) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/FunctionIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/FunctionIncrementalTransformation.kt new file mode 100644 index 00000000..c3618f98 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/FunctionIncrementalTransformation.kt @@ -0,0 +1,90 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontFamily +import com.sunnychung.application.multiplatform.hellohttp.constant.UserFunctions +import com.sunnychung.application.multiplatform.hellohttp.extension.hasIntersectWith +import com.sunnychung.application.multiplatform.hellohttp.util.RangeWithResult +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEventType +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformOffsetMapping +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformer +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.IncrementalTextTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor + +class FunctionIncrementalTransformation(private val themeColors: AppColor) : IncrementalTextTransformation { + val processLengthLimit = 30 + + private val functionRegex = + ("\\$\\(\\((" + UserFunctions.keys.joinToString("|") { Regex.escape(it) } + ")\\)\\)").toRegex() + + private val style = SpanStyle( + color = themeColors.functionTextColor, + background = themeColors.functionBackgroundColor, + fontFamily = FontFamily.Monospace, + ) + + override fun initialize(text: BigText, transformer: BigTextTransformer) { + val targets = functionRegex.findAll(text.buildString()) + targets.forEach { + val name = it.groups[1]!!.value + transformer.replace(it.range, createSpan(name), BigTextTransformOffsetMapping.WholeBlock) + } + } + + override fun afterTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { + val originalText = change.bigText + when (change.eventType) { + BigTextChangeEventType.Insert -> { + val changeRange = change.changeStartIndex until change.changeEndExclusiveIndex + val targets = findNearbyPatterns(change) + targets.filter { it.range hasIntersectWith changeRange } + .forEach { + val name = it.result.groups[1]!!.value + transformer.restoreToOriginal(it.range) + transformer.replace(it.range, createSpan(name), BigTextTransformOffsetMapping.WholeBlock) + } + } + + else -> {} + } + } + + private fun findNearbyPatterns(change: BigTextChangeEvent): Sequence> { + val startOffset = maxOf(0, change.changeStartIndex - processLengthLimit) + val substring = change.bigText.substring( + startOffset + until + minOf(change.bigText.length, change.changeEndExclusiveIndex + processLengthLimit) + ) + return functionRegex.findAll(substring) + .map { + RangeWithResult( + range = it.range.start + startOffset .. it.range.endInclusive + startOffset, + result = it + ) + } + } + + override fun beforeTextChange(change: BigTextChangeEvent, transformer: BigTextTransformer, context: Unit) { + val originalText = change.bigText + when (change.eventType) { + BigTextChangeEventType.Delete -> { + val changeRange = change.changeStartIndex until change.changeEndExclusiveIndex + val targets = findNearbyPatterns(change) + targets.filter { it.range hasIntersectWith changeRange } + .forEach { + transformer.restoreToOriginal(it.range) + } + } + + else -> {} + } + } + + private fun createSpan(name: String): CharSequence { + return AnnotatedString(name, listOf(AnnotatedString.Range(style, 0, name.length))) + } +} From 97b83d7e56709327d8ea054254dbfe8b2daf31a1 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 23 Oct 2024 21:54:34 +0800 Subject: [PATCH 131/195] add GraphQL incremental syntax highlighting --- build.gradle.kts | 1 + .../multiplatform/hellohttp/Main.kt | 3 +- .../hellohttp/ux/CodeEditorView.kt | 4 +- .../AbstractSyntaxHighlightDecorator.kt | 152 ++++++++++++++++ .../GraphqlSyntaxHighlightDecorator.kt | 93 ++++++++++ .../JsonSyntaxHighlightDecorator.kt | 166 +----------------- ...yntaxHighlightIncrementalTransformation.kt | 1 + 7 files changed, 259 insertions(+), 161 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/GraphqlSyntaxHighlightDecorator.kt diff --git a/build.gradle.kts b/build.gradle.kts index 164efe5b..b2cff95d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -86,6 +86,7 @@ kotlin { // incremental parser implementation("io.github.tree-sitter:ktreesitter:0.23.0") implementation("io.github.sunny-chung:ktreesitter-json:0.23.0.1") + implementation("io.github.sunny-chung:ktreesitter-graphql:1.0.0.0") } resources.srcDir("$buildDir/resources") diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt index 9ad31695..6186bd77 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt @@ -33,6 +33,7 @@ import com.sunnychung.application.multiplatform.hellohttp.platform.currentOS import com.sunnychung.application.multiplatform.hellohttp.platform.isMacOs import com.sunnychung.application.multiplatform.hellohttp.ux.AppView import com.sunnychung.application.multiplatform.hellohttp.ux.DataLossWarningDialogWindow +import io.github.dralletje.ktreesitter.graphql.TreeSitterGraphql import io.github.treesitter.ktreesitter.json.TreeSitterJson import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking @@ -153,7 +154,7 @@ fun main() { } fun loadNativeLibraries() { - val libraries = listOf("tree-sitter-json" to TreeSitterJson) + val libraries = listOf("tree-sitter-json" to TreeSitterJson, "tree-sitter-graphql" to TreeSitterGraphql) val systemArch = if (currentOS() == WindowsOS) { "x64" } else { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index b9ff0aff..d24ca61b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -83,6 +83,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incr import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.FunctionIncrementalTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.GraphqlSyntaxHighlightDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleIncrementalTransformation @@ -615,7 +616,8 @@ fun CodeEditorView( }, // TODO replace this testing transformation textDecorator = rememberLast(bigTextFieldState, themeColours, knownVariables) { MultipleTextDecorator(listOf( - JsonSyntaxHighlightDecorator(themeColours), +// JsonSyntaxHighlightDecorator(themeColours), + GraphqlSyntaxHighlightDecorator(themeColours), EnvironmentVariableDecorator(themeColours, knownVariables) )) }, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt new file mode 100644 index 00000000..27841abc --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt @@ -0,0 +1,152 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import com.sunnychung.application.multiplatform.hellohttp.extension.hasIntersectWith +import com.sunnychung.application.multiplatform.hellohttp.extension.length +import com.sunnychung.application.multiplatform.hellohttp.util.VisitScope +import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.util.toPoint +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEventType +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextDecorator +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import io.github.treesitter.ktreesitter.InputEdit +import io.github.treesitter.ktreesitter.Language +import io.github.treesitter.ktreesitter.Node +import io.github.treesitter.ktreesitter.Parser +import io.github.treesitter.ktreesitter.Point +import io.github.treesitter.ktreesitter.Tree + +abstract class AbstractSyntaxHighlightDecorator(language: Language) : BigTextDecorator { + protected val parser: Parser = Parser(language) + protected lateinit var ast: Tree + + override fun initialize(text: BigText) { + val s = text.buildString() + +// val singleByteCharSequence = s.map { // buggy +// if (it.code > 255) { +// 'X' +// } else { +// it +// } +// }.joinToString() +// ast = parser.parse(singleByteCharSequence) + + ast = parser.parse { byte, point -> + if (byte in 0u until text.length.toUInt()) { + s.substring(byte.toInt() ..byte.toInt()).let { + val codePoints = it.codePoints().toArray() + if (codePoints.size > 1 || codePoints.first() > 255) { + "X" // replace multibyte char as single-byte char + } else { + it + } + } + } else { + "" // the doc is wrong. null would result in crash + }/*.also { + println("parse $byte = '$it'") + }*/ + } + } + + protected fun createInputEdit(event: BigTextChangeEvent, startOffset: Int, oldEndOffset: Int, newEndOffset: Int): InputEdit { + fun toPoint(offset: Int): Point { + return event.bigText.findLineAndColumnFromRenderPosition(offset) + .also { + require(it.first >= 0 && it.second >= 0) { + (event.bigText as BigTextImpl).printDebug("[ERROR]") + "convert out of range. i=$offset, lc=$it, s = |${event.bigText.buildString()}|" + } + } + .toPoint() + } + + return InputEdit( + startOffset.toUInt(), + oldEndOffset.toUInt(), + newEndOffset.toUInt(), + toPoint(startOffset), + toPoint(oldEndOffset), + toPoint(newEndOffset), + ).also { + log.d { "AST InputEdit ${it.startByte} ${it.oldEndByte} ${it.newEndByte} ${it.startPoint} ${it.oldEndPoint} ${it.newEndPoint}" } + } + } + + protected fun createAnnotatedRange(style: SpanStyle, astNode: Node, bigTextRange: IntRange): AnnotatedString.Range? { + val startCharIndex = maxOf(0, astNode.startByte.toInt() - bigTextRange.start) + val endCharIndex = minOf(bigTextRange.length, maxOf(0, astNode.endByte.toInt() - bigTextRange.start)) + if (startCharIndex >= endCharIndex) { + return null + } + return AnnotatedString.Range(style, startCharIndex, endCharIndex) + } + + override fun afterTextChange(change: BigTextChangeEvent) { + val oldAst = ast + + when (change.eventType) { + BigTextChangeEventType.Insert -> { + ast.edit( + createInputEdit( + change, + change.changeStartIndex, + change.changeStartIndex, + change.changeEndExclusiveIndex, + ) + ) + } + + BigTextChangeEventType.Delete -> { + ast.edit( + createInputEdit( + change, + change.changeStartIndex, + change.changeEndExclusiveIndex, + change.changeStartIndex, + ) + ) + } + } + + ast = parser.parse(oldAst) { byte, point -> + if (byte in 0u until change.bigText.length.toUInt()) { + change.bigText.substring(byte.toInt() ..byte.toInt()).let { + val codePoints = it.codePoints().toArray() + if (codePoints.size > 1 || codePoints.first() > 255) { + "X" // replace multibyte char as single-byte char + } else { + it + } + } + } else { + "" // the doc is wrong. null would result in crash + }.also { + println("parse $byte = '$it'") + } + } + + log.v { "AST change sexp = ${ast.rootNode.sexp()}" } + } + + override fun onApplyDecorationOnOriginal(text: CharSequence, originalRange: IntRange): CharSequence { + log.v { "syntax hi onApplyDecoration ${originalRange}" } + return highlight(text, originalRange) { + it.children.forEach { c -> + if ((c.startByte.toInt() until c.endByte.toInt()) hasIntersectWith originalRange) { + visit(c) + } + } + } + } + + protected abstract fun highlight(text: CharSequence, range: IntRange, visitChildrensFunction: VisitScope.(Node) -> Unit): AnnotatedString +} + +fun Node.childrensByGrammarType(type: String): List { + return children.filter { it.grammarType == type } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/GraphqlSyntaxHighlightDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/GraphqlSyntaxHighlightDecorator.kt new file mode 100644 index 00000000..257186bb --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/GraphqlSyntaxHighlightDecorator.kt @@ -0,0 +1,93 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import com.sunnychung.application.multiplatform.hellohttp.util.VisitScope +import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.util.visit +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor +import io.github.dralletje.ktreesitter.graphql.TreeSitterGraphql +import io.github.treesitter.ktreesitter.Language +import io.github.treesitter.ktreesitter.Node + +class GraphqlSyntaxHighlightDecorator(colours: AppColor) : AbstractSyntaxHighlightDecorator(Language(TreeSitterGraphql.language())) { + val COMMENT_STYLE = SpanStyle(color = colours.syntaxColor.comment, fontStyle = FontStyle.Italic) + val STRING_LITERAL_STYLE = SpanStyle(color = colours.syntaxColor.stringLiteral) + val OPERATION_OR_FRAGMENT_KEYWORD_STYLE = SpanStyle(color = colours.syntaxColor.keyword, fontWeight = FontWeight.Bold) + val OPERATION_OR_FRAGMENT_NAME_STYLE = SpanStyle(color = colours.syntaxColor.otherName) + val VARIABLE_NAME_STYLE = SpanStyle(color = colours.syntaxColor.variable) + val VARIABLE_TYPE_STYLE = SpanStyle(color = colours.syntaxColor.type) + val FRAGMENT_REFERENCE_STYLE = SpanStyle(color = colours.syntaxColor.otherName) + val VARIABLE_STYLE = SpanStyle(color = colours.syntaxColor.variable) + val DIRECTIVE_STYLE = SpanStyle(color = colours.syntaxColor.directive, fontStyle = FontStyle.Italic) + val OBJECT_KEY_STYLE = SpanStyle(color = colours.syntaxColor.objectKey) + val NUMBER_LITERAL_STYLE = SpanStyle(color = colours.syntaxColor.numberLiteral) + val BOOLEAN_TRUE_LITERAL_STYLE = SpanStyle(color = colours.syntaxColor.booleanTrueLiteral) + val BOOLEAN_FALSE_LITERAL_STYLE = SpanStyle(color = colours.syntaxColor.booleanFalseLiteral) + val NOTHING_LITERAL_STYLE = SpanStyle(color = colours.syntaxColor.nothingLiteral) + val FIELD_STYLE = SpanStyle(color = colours.syntaxColor.field) + + override fun initialize(text: BigText) { + super.initialize(text) + log.d { "Graphql sexp = ${ast.rootNode.sexp()}" } + } + + override fun highlight( + text: CharSequence, + range: IntRange, + visitChildrensFunction: VisitScope.(Node) -> Unit + ): AnnotatedString { + val spans = mutableListOf>() + + fun applyStyle(style: SpanStyle, node: Node) { + createAnnotatedRange(style, node, range)?.let { + spans += it + } + } + + ast.rootNode.visit { + log.v { "AST visit change ${it.startByte} ..< ${it.endByte} = ${it.type}" } + + fun visitChildrens() { + visitChildrensFunction(it) + } + + when (it.type) { + "comment" -> applyStyle(COMMENT_STYLE, it) + "StringValue" -> applyStyle(STRING_LITERAL_STYLE, it) + "OperationType" -> applyStyle(OPERATION_OR_FRAGMENT_KEYWORD_STYLE, it) + "fragment" -> applyStyle(OPERATION_OR_FRAGMENT_KEYWORD_STYLE, it) + "OperationDefinition" -> { + visitChildrens() + it.childrensByGrammarType("Name").forEach { + applyStyle(OPERATION_OR_FRAGMENT_NAME_STYLE, it) + } + } + "FragmentName" -> applyStyle(OPERATION_OR_FRAGMENT_NAME_STYLE, it) + "Variable" -> applyStyle(VARIABLE_NAME_STYLE, it) + "Type" -> applyStyle(VARIABLE_TYPE_STYLE, it) +// "FragmentName" -> applyStyle(FRAGMENT_REFERENCE_STYLE, it) +// "Variable" -> applyStyle(VARIABLE_STYLE, it) + "Directive" -> applyStyle(DIRECTIVE_STYLE, it) + "Name" -> applyStyle(OBJECT_KEY_STYLE, it) + "IntValue", "FloatValue" -> applyStyle(NUMBER_LITERAL_STYLE, it) + "true" -> applyStyle(BOOLEAN_TRUE_LITERAL_STYLE, it) + "false" -> applyStyle(BOOLEAN_FALSE_LITERAL_STYLE, it) + "NullValue" -> applyStyle(NOTHING_LITERAL_STYLE, it) + "Field" -> { + visitChildrens() + it.childrensByGrammarType("Name").forEach { + applyStyle(FIELD_STYLE, it) + } + } + else -> visitChildrens() + } + log.v { "AST finish visit change ${it.startByte} ..< ${it.endByte}" } + } + + return AnnotatedString(text.toString(), spans) + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt index 6da03723..69bd596c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightDecorator.kt @@ -28,7 +28,7 @@ import io.github.treesitter.ktreesitter.Range import io.github.treesitter.ktreesitter.Tree import io.github.treesitter.ktreesitter.json.TreeSitterJson -class JsonSyntaxHighlightDecorator(val colours: AppColor) : BigTextDecorator { +class JsonSyntaxHighlightDecorator(val colours: AppColor) : AbstractSyntaxHighlightDecorator(Language(TreeSitterJson.language())) { val objectKeyStyle = SpanStyle(color = colours.syntaxColor.objectKey) val stringLiteralStyle = SpanStyle(color = colours.syntaxColor.stringLiteral) val numberLiteralStyle = SpanStyle(color = colours.syntaxColor.numberLiteral) @@ -36,114 +36,7 @@ class JsonSyntaxHighlightDecorator(val colours: AppColor) : BigTextDecorator { val booleanFalseLiteralStyle = SpanStyle(color = colours.syntaxColor.booleanFalseLiteral) val nothingLiteralStyle = SpanStyle(color = colours.syntaxColor.nothingLiteral) - val parser: Parser - lateinit var ast: Tree - - init { - val language = Language(TreeSitterJson.language()) - parser = Parser(language) - } - - override fun initialize(text: BigText) { - fun Point.toCharIndex(): Int { - val charIndex = text.findRenderCharIndexByLineAndColumn(row.toInt(), column.toInt()) - return charIndex - } - - // Tree Sitter incremental approach - val s = text.buildString() - - // In Tree-sitter, multibyte utf8 characters would occupy multiple positions and have no workaround -// val singleByteCharSequence = s.map { // buggy -// if (it.code > 255) { -// 'X' -// } else { -// it -// } -// }.joinToString() -// ast = parser.parse(singleByteCharSequence) - ast = parser.parse { byte, point -> - if (byte in 0u until text.length.toUInt()) { - s.substring(byte.toInt() ..byte.toInt()).let { - val codePoints = it.codePoints().toArray() - if (codePoints.size > 1 || codePoints.first() > 255) { - "X" // replace multibyte char as single-byte char - } else { - it - } - } - } else { - "" // the doc is wrong. null would result in crash - }/*.also { - println("parse $byte = '$it'") - }*/ - } - -// ast.rootNode.children.forEach { -// log.d { "AST init parse ${it.range} type=${it.type} grammarType=${it.grammarType} p=${it.parent}" } -// } -// log.d { "AST init sexp = ${ast.rootNode.sexp()}" } - } - - override fun afterTextChange(change: BigTextChangeEvent) { - val oldAst = ast - - when (change.eventType) { - BigTextChangeEventType.Insert -> { - ast.edit( - createInputEdit( - change, - change.changeStartIndex, - change.changeStartIndex, - change.changeEndExclusiveIndex, - ) - ) - } - - BigTextChangeEventType.Delete -> { - ast.edit( - createInputEdit( - change, - change.changeStartIndex, - change.changeEndExclusiveIndex, - change.changeStartIndex, - ) - ) - } - } - - ast = parser.parse(oldAst) { byte, point -> - if (byte in 0u until change.bigText.length.toUInt()) { - change.bigText.substring(byte.toInt() ..byte.toInt()).let { - val codePoints = it.codePoints().toArray() - if (codePoints.size > 1 || codePoints.first() > 255) { - "X" // replace multibyte char as single-byte char - } else { - it - } - } - } else { - "" // the doc is wrong. null would result in crash - }.also { - println("parse $byte = '$it'") - } - } - - log.v { "AST change sexp = ${ast.rootNode.sexp()}" } - } - - override fun onApplyDecorationOnOriginal(text: CharSequence, originalRange: IntRange): CharSequence { - log.v { "json sh onApplyDecoration ${originalRange}" } - return highlight(text, originalRange) { - it.children.forEach { c -> - if ((c.startByte.toInt() until c.endByte.toInt()) hasIntersectWith originalRange) { - visit(c) - } - } - } - } - - protected fun highlight(text: CharSequence, range: IntRange, visitChildrensFunction: VisitScope.(Node) -> Unit): AnnotatedString { + override fun highlight(text: CharSequence, range: IntRange, visitChildrensFunction: VisitScope.(Node) -> Unit): AnnotatedString { val spans = mutableListOf>() fun applyStyle(style: SpanStyle, node: Node) { @@ -169,21 +62,11 @@ class JsonSyntaxHighlightDecorator(val colours: AppColor) : BigTextDecorator { visit(it) } } - "null" -> { - applyStyle(nothingLiteralStyle, it) - } - "number" -> { - applyStyle(numberLiteralStyle, it) - } - "string" -> { - applyStyle(stringLiteralStyle, it) - } - "false" -> { - applyStyle(booleanFalseLiteralStyle, it) - } - "true" -> { - applyStyle(booleanTrueLiteralStyle, it) - } + "null" -> applyStyle(nothingLiteralStyle, it) + "number" -> applyStyle(numberLiteralStyle, it) + "string" -> applyStyle(stringLiteralStyle, it) + "false" -> applyStyle(booleanFalseLiteralStyle, it) + "true" -> applyStyle(booleanTrueLiteralStyle, it) else -> { visitChildrens() } @@ -193,39 +76,4 @@ class JsonSyntaxHighlightDecorator(val colours: AppColor) : BigTextDecorator { return AnnotatedString(text.toString(), spans) } - - fun createInputEdit(event: BigTextChangeEvent, startOffset: Int, oldEndOffset: Int, newEndOffset: Int): InputEdit { - fun toPoint(offset: Int): Point { - return event.bigText.findLineAndColumnFromRenderPosition(offset) - .also { - require(it.first >= 0 && it.second >= 0) { - (event.bigText as BigTextImpl).printDebug("[ERROR]") - "convert out of range. i=$offset, lc=$it, s = |${event.bigText.buildString()}|" - } - } - .toPoint() - } - - return InputEdit( - startOffset.toUInt(), - oldEndOffset.toUInt(), - newEndOffset.toUInt(), - toPoint(startOffset), - toPoint(oldEndOffset), - toPoint(newEndOffset), - ).also { - log.d { "AST InputEdit ${it.startByte} ${it.oldEndByte} ${it.newEndByte} ${it.startPoint} ${it.oldEndPoint} ${it.newEndPoint}" } - } - } - - fun createAnnotatedRange(style: SpanStyle, astNode: Node, bigTextRange: IntRange): AnnotatedString.Range? { - val startCharIndex = maxOf(0, astNode.startByte.toInt() - bigTextRange.start) - val endCharIndex = minOf(bigTextRange.length, maxOf(0, astNode.endByte.toInt() - bigTextRange.start)) -// val startCharIndex = text.findRenderCharIndexByLineAndColumn(astNode.startPoint.row.toInt(), astNode.startPoint.column.toInt()) -// val endCharIndex = text.findRenderCharIndexByLineAndColumn(astNode.endPoint.row.toInt(), astNode.endPoint.column.toInt()) - if (startCharIndex >= endCharIndex) { - return null - } - return AnnotatedString.Range(style, startCharIndex, endCharIndex) - } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt index a18183bd..7c4807fa 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/JsonSyntaxHighlightIncrementalTransformation.kt @@ -36,6 +36,7 @@ private val BOOLEAN_TRUE_LITERAL_REGEX = "true".toRegex() private val BOOLEAN_FALSE_LITERAL_REGEX = "false".toRegex() private val NOTHING_LITERAL_REGEX = "null|undefined".toRegex() +@Deprecated(message = "Use JsonSyntaxHighlightDecorator instead.", replaceWith = ReplaceWith("JsonSyntaxHighlightDecorator")) class JsonSyntaxHighlightIncrementalTransformation(val colours: AppColor) : IncrementalTextTransformation { val objectKeyStyle = SpanStyle(color = colours.syntaxColor.objectKey) val stringLiteralStyle = SpanStyle(color = colours.syntaxColor.stringLiteral) From 55e8ffcd2ee9d30c813237d750a201f6cd6b0ffb Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 26 Oct 2024 19:47:45 +0800 Subject: [PATCH 132/195] fix exception "FocusRequester is not initialized" --- .../application/multiplatform/hellohttp/ux/CodeEditorView.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index d24ca61b..db0a8635 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -508,6 +508,7 @@ fun CodeEditorView( onTextLayout = { layoutResult = it }, onTransformInit = { transformedText = it }, modifier = Modifier.fillMaxSize() + .focusRequester(textFieldFocusRequester) .run { if (testTag != null) { testTag(testTag) From 0795e1c0a7a7c4fd4249935e6059518b1ca4e44b Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 26 Oct 2024 19:51:45 +0800 Subject: [PATCH 133/195] add search highlight decorator for BigMonospaceText --- .../hellohttp/util/TreeRangeMaps.kt | 15 ++ .../hellohttp/ux/CodeEditorView.kt | 134 ++++++++++++------ .../ux/bigtext/BigTextLayoutResult.kt | 5 +- .../ux/bigtext/CacheableBigTextDecorator.kt | 15 ++ .../AbstractSyntaxHighlightDecorator.kt | 5 +- .../EnvironmentVariableDecorator.kt | 2 +- .../GraphqlSyntaxHighlightDecorator.kt | 4 +- .../incremental/SearchHighlightDecorator.kt | 66 +++++++++ 8 files changed, 197 insertions(+), 49 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TreeRangeMaps.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/CacheableBigTextDecorator.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/SearchHighlightDecorator.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TreeRangeMaps.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TreeRangeMaps.kt new file mode 100644 index 00000000..96e62007 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TreeRangeMaps.kt @@ -0,0 +1,15 @@ +package com.sunnychung.application.multiplatform.hellohttp.util + +import com.google.common.collect.Range +import com.google.common.collect.TreeRangeMap + +object TreeRangeMaps { + + fun from(ranges: Iterable): TreeRangeMap { + val tree = TreeRangeMap.create() + ranges.forEachIndexed { i, it -> + tree.put(Range.closed(it.start, it.endInclusive), i) + } + return tree + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index db0a8635..caea5646 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -19,11 +19,13 @@ import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.material.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -55,10 +57,12 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import com.google.common.collect.TreeRangeMap import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryApi import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForInsertionPoint import com.sunnychung.application.multiplatform.hellohttp.extension.contains import com.sunnychung.application.multiplatform.hellohttp.extension.insert +import com.sunnychung.application.multiplatform.hellohttp.util.TreeRangeMaps import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField @@ -83,13 +87,17 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incr import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.FunctionIncrementalTransformation -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.GraphqlSyntaxHighlightDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightDecorator -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleIncrementalTransformation 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.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import java.util.regex.Pattern import kotlin.random.Random @@ -132,6 +140,14 @@ fun CodeEditorView( it } } + + val (secondCacheKey, bigTextFieldMutableState) = rememberAnnotatedBigTextFieldState(initialValue = textValue.text) + val bigTextFieldState = bigTextFieldMutableState.value + val bigTextValue = bigTextFieldState.text + var bigTextValueId by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(Random.nextLong()) } + + var layoutResult by remember { mutableStateOf(null) } + var textLayoutResult by rememberLast(newText) { mutableStateOf(null) } var lineTops by rememberLast(newText, textLayoutResult) { mutableStateOf?>(null) } log.d { "len newText ${newText.length}, textValue.text ${textValue.text.length}, text ${text.length}" } @@ -250,14 +266,25 @@ fun CodeEditorView( isWholeWord = false )) } var searchPattern by rememberLast(searchText, searchOptions) { mutableStateOf(null) } + val searchPatternLatest by rememberUpdatedState(searchPattern) +// var searchPattern4 by rememberUpdatedMutableState(searchPatternState) // for use in LaunchedEffect val scrollState = rememberScrollState() val searchBarFocusRequester = remember { FocusRequester() } val textFieldFocusRequester = remember { FocusRequester() } - var searchResultViewIndex by rememberLast(text) { mutableStateOf(0) } - var lastSearchResultViewIndex by rememberLast(text) { mutableStateOf(0) } - var searchResultRanges by rememberLast(text, searchPattern) { mutableStateOf?>(null) } + var searchResultViewIndex by rememberLast(bigTextValue) { mutableStateOf(0) } + var lastSearchResultViewIndex by rememberLast(bigTextValue) { mutableStateOf(0) } + val searchResultRangesState = rememberLast(bigTextValue) { MutableStateFlow?>(null) } //= rememberLast(text, searchPattern) { mutableStateOf?>(null) } + val searchResultRanges by searchResultRangesState.collectAsState() var textFieldSize by remember { mutableStateOf(null) } + val searchResultRangeTreeState = rememberLast(bigTextValue) { MutableStateFlow?>(null) } //= rememberLast(text, searchPattern) { mutableStateOf?>(null) } + val searchResultRangeTree by searchResultRangeTreeState.collectAsState() + + val searchTrigger = remember { Channel() } + + remember(searchOptions) { + searchTrigger.trySend(Unit) + } if (searchText.isNotEmpty() && searchPattern == null) { val regexOption = if (searchOptions.isCaseSensitive) setOf() else setOf(RegexOption.IGNORE_CASE) @@ -272,8 +299,39 @@ fun CodeEditorView( searchPattern = pattern searchResultViewIndex = 0 lastSearchResultViewIndex = -1 + log.d { "set search pattern ${searchPattern?.pattern}" } } catch (_: Throwable) {} } + log.d { "get search pattern ${searchPattern?.pattern}" } + + LaunchedEffect(bigTextValue) { + searchTrigger.receiveAsFlow() + .debounce(210L) + .filter { isSearchVisible } + .collectLatest { +// log.d { "search triggered ${searchPattern?.pattern} ${searchPattern0?.pattern} ${searchPattern1?.pattern} ${searchPattern2?.pattern} ${searchPattern3?.pattern}" } +// log.d { "search triggered ${searchPattern2?.pattern} ${searchPattern3?.pattern}" } + 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" } + } + } else { + searchResultRangesState.value = null + searchResultRangeTreeState.value = null + } + } + } var searchResultSummary = if (!searchResultRanges.isNullOrEmpty()) { "${searchResultViewIndex + 1}/${searchResultRanges?.size}" } else { @@ -313,31 +371,16 @@ fun CodeEditorView( ) } - if (searchPattern != null && searchResultRanges == null) { - try { - searchResultRanges = searchPattern!! - .findAll( - MultipleVisualTransformation(visualTransformations) - .filter(AnnotatedString(textValue.text)) - .text.text - ) - .map { it.range } - .sortedBy { it.start } - .toList() - } catch (_: Throwable) {} - } - - if (lastSearchResultViewIndex != searchResultViewIndex && textLayoutResult != null && textFieldSize != null && searchResultRanges != null) { + if (lastSearchResultViewIndex != searchResultViewIndex && layoutResult != null && textFieldSize != null && searchResultRanges != null) { lastSearchResultViewIndex = searchResultViewIndex - val index = searchResultRanges!!.getOrNull(searchResultViewIndex)?.start - index?.let { + searchResultRanges!!.getOrNull(searchResultViewIndex)?.start?.let { position -> val visibleVerticalRange = scrollState.value .. scrollState.value + textFieldSize!!.height - val lineIndex = textLayoutResult!!.getLineForOffset(it) - val lineVerticalRange = textLayoutResult!!.getLineTop(lineIndex).toInt() .. textLayoutResult!!.getLineBottom(lineIndex).toInt() - if (lineVerticalRange !in visibleVerticalRange) { + val rowIndex = layoutResult!!.text.findRowIndexByPosition(position) + val rowVerticalRange = layoutResult!!.getTopOfRow(rowIndex).toInt() .. layoutResult!!.getBottomOfRow(rowIndex).toInt() + if (rowVerticalRange !in visibleVerticalRange) { coroutineScope.launch { - log.d { "CEV scroll l=$lineIndex r=$lineVerticalRange v=$visibleVerticalRange" } - scrollState.animateScrollTo(lineVerticalRange.start) + log.d { "CEV scroll l=$rowIndex r=$rowVerticalRange v=$visibleVerticalRange" } + scrollState.animateScrollTo(rowVerticalRange.start) } } } @@ -403,7 +446,11 @@ fun CodeEditorView( if (isSearchVisible) { TextSearchBar( text = searchText, - onTextChange = { searchText = it }, + onTextChange = { + searchText = it + log.d { "searchTrigger send" } + searchTrigger.trySend(Unit) + }, statusText = searchResultSummary, searchOptions = searchOptions, onToggleRegex = { searchOptions = searchOptions.copy(isRegex = it) }, @@ -447,8 +494,6 @@ fun CodeEditorView( collapsedChars -= collapsableChars[index] } - var layoutResult by remember { mutableStateOf(null) } - // BigLineNumbersView( // scrollState = scrollState, // bigTextViewState = bigTextViewState, @@ -460,11 +505,6 @@ fun CodeEditorView( // modifier = Modifier.fillMaxHeight(), // ) - val (secondCacheKey, bigTextFieldMutableState) = rememberAnnotatedBigTextFieldState(initialValue = textValue.text) - val bigTextFieldState = bigTextFieldMutableState.value - val bigTextValue = bigTextFieldState.text - var bigTextValueId by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(Random.nextLong()) } - BigTextLineNumbersView( scrollState = scrollState, bigTextViewState = bigTextFieldState.viewState, @@ -478,6 +518,10 @@ fun CodeEditorView( modifier = Modifier.fillMaxHeight(), ) + val syntaxHighlightDecorator = rememberLast(bigTextFieldState, themeColours) { + JsonSyntaxHighlightDecorator(themeColours) + } + if (isReadOnly) { val collapseIncrementalTransformation = remember(bigTextFieldState) { CollapseIncrementalTransformation(themeColours, collapsedChars.values.toList()) @@ -498,8 +542,11 @@ fun CodeEditorView( collapseIncrementalTransformation, )) }, - textDecorator = rememberLast(bigTextFieldState, themeColours) { - JsonSyntaxHighlightDecorator(themeColours) + textDecorator = rememberLast(bigTextFieldState, themeColours, searchResultRangeTree, searchResultViewIndex) { + MultipleTextDecorator(listOf( + syntaxHighlightDecorator, + SearchHighlightDecorator(searchResultRangeTree ?: TreeRangeMap.create(), searchResultViewIndex, themeColours), + )) }, fontSize = LocalFont.current.codeEditorBodyFontSize, isSelectable = true, @@ -593,7 +640,7 @@ fun CodeEditorView( LaunchedEffect(bigTextFieldState) { bigTextFieldState.valueChangesFlow - .debounce(100.milliseconds().toMilliseconds()) + .debounce(200.milliseconds().toMilliseconds()) .collect { log.d { "bigTextFieldState change ${it.changeId}" } onTextChange?.let { onTextChange -> @@ -602,6 +649,7 @@ fun CodeEditorView( secondCacheKey.value = string.text } bigTextValueId = it.changeId + searchTrigger.trySend(Unit) } } @@ -615,11 +663,11 @@ fun CodeEditorView( FunctionIncrementalTransformation(themeColours) )) }, // TODO replace this testing transformation - textDecorator = rememberLast(bigTextFieldState, themeColours, knownVariables) { + textDecorator = rememberLast(bigTextFieldState, themeColours, knownVariables, searchResultRangeTree, searchResultViewIndex) { MultipleTextDecorator(listOf( -// JsonSyntaxHighlightDecorator(themeColours), - GraphqlSyntaxHighlightDecorator(themeColours), - EnvironmentVariableDecorator(themeColours, knownVariables) + syntaxHighlightDecorator, + EnvironmentVariableDecorator(themeColours, knownVariables), + SearchHighlightDecorator(searchResultRangeTree ?: TreeRangeMap.create(), searchResultViewIndex, themeColours), )) }, fontSize = LocalFont.current.codeEditorBodyFontSize, @@ -905,7 +953,7 @@ fun BigTextLineNumbersView( // getLineOffset = { (textLayout!!.getLineTop(it) - viewportTop).toDp() }, getLineOffset = { ((layoutText?.findFirstRowIndexByOriginalLineIndex(it).also { r -> - log.d { "layoutText.findFirstRowIndexOfLine($it) = $r" } + log.v { "layoutText.findFirstRowIndexOfLine($it) = $r" } } ?: 0) * rowHeight - viewportTop).toDp() }, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt index 6d2f6967..1d79cdc3 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt @@ -31,4 +31,7 @@ class BigTextLayoutResult( class BigTextSimpleLayoutResult( val text: BigTextLayoutable, val rowHeight: Float -) +) { + fun getTopOfRow(rowIndex: Int): Float = rowIndex * rowHeight + fun getBottomOfRow(rowIndex: Int): Float = (rowIndex + 1) * rowHeight +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/CacheableBigTextDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/CacheableBigTextDecorator.kt new file mode 100644 index 00000000..625afbce --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/CacheableBigTextDecorator.kt @@ -0,0 +1,15 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +abstract class CacheableBigTextDecorator : BigTextDecorator { + protected var hasInitialized = false + + final override fun initialize(text: BigText) { + if (hasInitialized) { + return + } + doInitialize(text) + hasInitialized = true + } + + open fun doInitialize(text: BigText) = Unit +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt index 27841abc..7b83ed2e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt @@ -12,6 +12,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChan import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEventType import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.CacheableBigTextDecorator import io.github.treesitter.ktreesitter.InputEdit import io.github.treesitter.ktreesitter.Language import io.github.treesitter.ktreesitter.Node @@ -19,11 +20,11 @@ import io.github.treesitter.ktreesitter.Parser import io.github.treesitter.ktreesitter.Point import io.github.treesitter.ktreesitter.Tree -abstract class AbstractSyntaxHighlightDecorator(language: Language) : BigTextDecorator { +abstract class AbstractSyntaxHighlightDecorator(language: Language) : CacheableBigTextDecorator() { protected val parser: Parser = Parser(language) protected lateinit var ast: Tree - override fun initialize(text: BigText) { + override fun doInitialize(text: BigText) { val s = text.buildString() // val singleByteCharSequence = s.map { // buggy diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt index 0f312b49..5d5159cf 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt @@ -22,7 +22,7 @@ class EnvironmentVariableDecorator(themeColors: AppColor, val knownVariables: Se if (text is AnnotatedString) { // val tagRanges = text.getStringAnnotations(EnvironmentVariableIncrementalTransformation.TAG, 0, text.length) val tagRanges = text.spanStyles.filter { it.tag.startsWith(EnvironmentVariableIncrementalTransformation.TAG_PREFIX) } - if (tagRanges.isNotEmpty()) { + if (tagRanges.isNotEmpty()) { val previousSpanStyles = text.spanStyles val newSpanStyles = tagRanges.map { tagRange -> val name = tagRange.tag.replaceFirst(EnvironmentVariableIncrementalTransformation.TAG_PREFIX, "") diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/GraphqlSyntaxHighlightDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/GraphqlSyntaxHighlightDecorator.kt index 257186bb..b1c1183e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/GraphqlSyntaxHighlightDecorator.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/GraphqlSyntaxHighlightDecorator.kt @@ -30,8 +30,8 @@ class GraphqlSyntaxHighlightDecorator(colours: AppColor) : AbstractSyntaxHighlig val NOTHING_LITERAL_STYLE = SpanStyle(color = colours.syntaxColor.nothingLiteral) val FIELD_STYLE = SpanStyle(color = colours.syntaxColor.field) - override fun initialize(text: BigText) { - super.initialize(text) + override fun doInitialize(text: BigText) { + super.doInitialize(text) log.d { "Graphql sexp = ${ast.rootNode.sexp()}" } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/SearchHighlightDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/SearchHighlightDecorator.kt new file mode 100644 index 00000000..cbd9361a --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/SearchHighlightDecorator.kt @@ -0,0 +1,66 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import com.google.common.collect.Range +import com.google.common.collect.TreeRangeMap +import com.sunnychung.application.multiplatform.hellohttp.extension.length +import com.sunnychung.application.multiplatform.hellohttp.util.TreeRangeMaps +import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextDecorator +import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor + +class SearchHighlightDecorator(private val searchResultRangesTree: TreeRangeMap, val currentIndex: Int?, colours: AppColor) + : BigTextDecorator { + + companion object { + fun create(ranges: Iterable, currentIndex: Int?, colours: AppColor) = + SearchHighlightDecorator(TreeRangeMaps.from(ranges), currentIndex, colours) + } + + val highlightStyle = SpanStyle(background = colours.backgroundInputFieldHighlight) + val currentHighlightStyle = SpanStyle(background = colours.backgroundInputFieldHighlightEmphasize) + + init { + log.d { "SearchHighlightDecorator searchResultRangesTree size ${searchResultRangesTree.asMapOfRanges().size}" } + } + + override fun onApplyDecorationOnOriginal(text: CharSequence, originalRange: IntRange): CharSequence { + val previousSpanStyles = if (text is AnnotatedString) { + text.spanStyles + } else { + emptyList() + } + val string = if (text is AnnotatedString) { + text.text + } else if (text is String) { + text + } else { + text.toString() + } + + val newSpanStyles = searchResultRangesTree + .subRangeMap(Range.closed(originalRange.start, originalRange.endInclusive)) + .asMapOfRanges() + .map { + val start = maxOf(0, it.key.lowerEndpoint() - originalRange.start) + val endExclusive = minOf(originalRange.length, it.key.upperEndpoint() + 1 - originalRange.start) + log.d { "search hl ${it.key.lowerEndpoint()} .. ${it.key.upperEndpoint()}" } + AnnotatedString.Range( + item = if (it.value == currentIndex) { + currentHighlightStyle + } else { + highlightStyle + }, + start = start, + end = endExclusive, + ) + } + + return if (newSpanStyles.isNotEmpty()) { + AnnotatedString(string, previousSpanStyles + newSpanStyles) + } else { + text + } + } +} From 05b49cbed58ab9246db54baa2345078b5ffb9983 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 26 Oct 2024 20:24:59 +0800 Subject: [PATCH 134/195] fix IndexOutOfBoundsException --- .../application/multiplatform/hellohttp/ux/CodeEditorView.kt | 2 ++ .../multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index caea5646..fac327b7 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -374,6 +374,8 @@ fun CodeEditorView( if (lastSearchResultViewIndex != searchResultViewIndex && layoutResult != null && textFieldSize != null && searchResultRanges != null) { lastSearchResultViewIndex = searchResultViewIndex searchResultRanges!!.getOrNull(searchResultViewIndex)?.start?.let { position -> + if (position > layoutResult!!.text.length) return@let + val visibleVerticalRange = scrollState.value .. scrollState.value + textFieldSize!!.height val rowIndex = layoutResult!!.text.findRowIndexByPosition(position) val rowVerticalRange = layoutResult!!.getTopOfRow(rowIndex).toInt() .. layoutResult!!.getBottomOfRow(rowIndex).toInt() diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt index f5f09218..1ad33cb3 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutable.kt @@ -4,6 +4,8 @@ interface BigTextLayoutable { val hasLayouted: Boolean + val length: Int + val numOfLines: Int val numOfOriginalLines: Int From 627b64cc1ded8066ab5aa43aaa37ec1de7f143e3 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 26 Oct 2024 20:55:44 +0800 Subject: [PATCH 135/195] add Kotlite non-incremental syntax highlight for BigMonospaceText --- .../hellohttp/ux/CodeEditorView.kt | 4 ++- .../KotlinSyntaxHighlightSlowDecorator.kt | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/KotlinSyntaxHighlightSlowDecorator.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index fac327b7..2ff00817 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -88,6 +88,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incr import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.FunctionIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightDecorator +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.KotlinSyntaxHighlightSlowDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleTextDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.SearchHighlightDecorator @@ -521,7 +522,8 @@ fun CodeEditorView( ) val syntaxHighlightDecorator = rememberLast(bigTextFieldState, themeColours) { - JsonSyntaxHighlightDecorator(themeColours) +// JsonSyntaxHighlightDecorator(themeColours) + KotlinSyntaxHighlightSlowDecorator(themeColours) } if (isReadOnly) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/KotlinSyntaxHighlightSlowDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/KotlinSyntaxHighlightSlowDecorator.kt new file mode 100644 index 00000000..525ef9e1 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/KotlinSyntaxHighlightSlowDecorator.kt @@ -0,0 +1,29 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental + +import androidx.compose.ui.text.AnnotatedString +import com.sunnychung.application.multiplatform.hellohttp.util.string +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeEvent +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextDecorator +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.CacheableBigTextDecorator +import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.KotlinSyntaxHighlightTransformation + +class KotlinSyntaxHighlightSlowDecorator(private val colours: AppColor) : CacheableBigTextDecorator() { + + private val transformation = KotlinSyntaxHighlightTransformation(colours) + private var transformedTextCache = AnnotatedString("") + + override fun doInitialize(text: BigText) { + transformedTextCache = transformation.filter(AnnotatedString(text.buildString())).text + } + + override fun afterTextChange(change: BigTextChangeEvent) { + transformedTextCache = transformation.filter(AnnotatedString(change.bigText.buildString())).text + } + + override fun onApplyDecorationOnOriginal(text: CharSequence, originalRange: IntRange): CharSequence { + val spanStyles = (transformedTextCache.subSequence(originalRange) as AnnotatedString).spanStyles + return AnnotatedString(text.string(), spanStyles) + } +} From d80bf52a59ae0ec2b3f510b0d55f533a40d67ef1 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 26 Oct 2024 22:25:40 +0800 Subject: [PATCH 136/195] fix BigMonospaceText should not recompute everything when the text is changed --- .../multiplatform/hellohttp/ux/CodeEditorView.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 2ff00817..2d8ec966 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -66,6 +66,7 @@ import com.sunnychung.application.multiplatform.hellohttp.util.TreeRangeMaps import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextSimpleLayoutResult @@ -142,11 +143,6 @@ fun CodeEditorView( } } - val (secondCacheKey, bigTextFieldMutableState) = rememberAnnotatedBigTextFieldState(initialValue = textValue.text) - val bigTextFieldState = bigTextFieldMutableState.value - val bigTextValue = bigTextFieldState.text - var bigTextValueId by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(Random.nextLong()) } - var layoutResult by remember { mutableStateOf(null) } var textLayoutResult by rememberLast(newText) { mutableStateOf(null) } @@ -168,6 +164,11 @@ fun CodeEditorView( cursorDelta = 0 } + val (secondCacheKey, bigTextFieldMutableState) = rememberAnnotatedBigTextFieldState(initialValue = textValue.text) + val bigTextFieldState: BigTextFieldState = bigTextFieldMutableState.value + val bigTextValue: BigTextImpl = bigTextFieldState.text + var bigTextValueId by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(Random.nextLong()) } + var collapsedLines = rememberLast(newText) { mutableStateMapOf() } var collapsedChars = rememberLast(newText) { mutableStateMapOf() } From c15eaefd32a595de43fd6114027131b8fe6f78a0 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 27 Oct 2024 10:55:25 +0800 Subject: [PATCH 137/195] fix linux x86_64 runners cannot run the application --- .../sunnychung/application/multiplatform/hellohttp/Main.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt index 6186bd77..b4a800d0 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt @@ -158,7 +158,12 @@ fun loadNativeLibraries() { val systemArch = if (currentOS() == WindowsOS) { "x64" } else { - getSystemArchitecture() + getSystemArchitecture().let { + when (it.lowercase()) { + "x86_64" -> "x64" + else -> it + } + } }.uppercase() libraries.forEach { (name, enclosingClazz) -> val libFileName = when (currentOS()) { From 55d76755ac11d87286655346cde71a6263705458 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 27 Oct 2024 11:19:48 +0800 Subject: [PATCH 138/195] fix test --- .../multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt index 6990a86a..30b367f9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt @@ -30,7 +30,7 @@ class BigTextViewState { var selection: IntRange by mutableStateOf(0..-1) - fun hasSelection(): Boolean = !transformedSelection.isEmpty() + fun hasSelection(): Boolean = transformedSelection.start >= 0 && !transformedSelection.isEmpty() internal fun updateSelectionByTransformedSelection(transformedText: TransformedText) { selection = transformedText.offsetMapping.transformedToOriginal(transformedSelection.first) .. From 3d37dbeff5c517e429c078c90ed8ffa62ff6508b Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 27 Oct 2024 19:49:42 +0800 Subject: [PATCH 139/195] add undo and redo to BigText and BigMonospaceText --- .../hellohttp/util/CircularList.kt | 74 ++++++ .../hellohttp/ux/bigtext/BigMonospaceText.kt | 53 +++++ .../ux/bigtext/BigTextChangeCallback.kt | 8 + .../hellohttp/ux/bigtext/BigTextImpl.kt | 155 +++++++++++++ .../ux/bigtext/BigTextInputChangeOperation.kt | 23 ++ .../ux/bigtext/JetpackComposeBigText.kt | 1 + .../test/bigtext/BigTextUndoRedoTest.kt | 166 ++++++++++++++ .../hellohttp/test/util/CircularListTest.kt | 214 ++++++++++++++++++ 8 files changed, 694 insertions(+) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeCallback.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputChangeOperation.kt create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextUndoRedoTest.kt create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/CircularListTest.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt new file mode 100644 index 00000000..fa4761f2 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt @@ -0,0 +1,74 @@ +package com.sunnychung.application.multiplatform.hellohttp.util + +/** + * Not thread-safe. + */ +class CircularList(val capacity: Int) { + + internal var head = -1 + internal var tail = -1 + + val size: Int + get() { + if (head < 0 || tail < 0) return 0 + return ((head + capacity - tail + 1) % capacity).let { + if (it == 0) capacity else it + } + } + + val isEmpty: Boolean + get() = size <= 0 + + private val store = ArrayList(capacity) + + fun push(item: T) { + val oldSize = size + head = (head + 1) % capacity + if (store.size <= head) { + store += item + } else { + if (oldSize >= capacity) { + tail = (head + 1) % capacity + } + store[head] = item + } + if (tail < 0) { + tail = head + } + } + + fun removeHead(): T? { + if (size == 0) { + return null + } + val item = store[head] + store[head] = null + if (head == tail) { + head = -1 + tail = -1 + } else { + head = (head - 1 + capacity) % capacity + } + return item + } + + fun removeTail(): T? { + if (size == 0) { + return null + } + val item = store[tail] + store[tail] = null + if (head == tail) { + head = -1 + tail = -1 + } else { + tail = (tail + 1) % capacity + } + return item + } + + fun clear() { + head = -1 + tail = -1 + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index def09769..7bd3fb68 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -543,6 +543,7 @@ private fun CoreBigMonospaceText( val insertPos = viewState.cursorIndex onValuePreChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) text.insertAt(insertPos, textInput) + text.recordCurrentChangeSequenceIntoUndoHistory() onValuePostChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) // (transformedText as BigTextImpl).layout() // FIXME remove updateViewState() @@ -563,6 +564,7 @@ private fun CoreBigMonospaceText( if (cursor + 1 <= text.length) { onValuePreChange(BigTextChangeEventType.Delete, cursor, cursor + 1) text.delete(cursor, cursor + 1) + text.recordCurrentChangeSequenceIntoUndoHistory() onValuePostChange(BigTextChangeEventType.Delete, cursor, cursor + 1) // (transformedText as BigTextImpl).layout() // FIXME remove updateViewState() @@ -576,6 +578,7 @@ private fun CoreBigMonospaceText( if (cursor - 1 >= 0) { onValuePreChange(BigTextChangeEventType.Delete, cursor - 1, cursor) text.delete(cursor - 1, cursor) + text.recordCurrentChangeSequenceIntoUndoHistory() onValuePostChange(BigTextChangeEventType.Delete, cursor - 1, cursor) // (transformedText as BigTextImpl).layout() // FIXME remove updateViewState() @@ -594,6 +597,44 @@ private fun CoreBigMonospaceText( return false } + fun onUndoRedo(operation: (BigTextChangeCallback) -> Unit) { + var lastChangeEnd = -1 + operation(object : BigTextChangeCallback { + override fun onValuePreChange( + eventType: BigTextChangeEventType, + changeStartIndex: Int, + changeEndExclusiveIndex: Int + ) { + onValuePreChange(eventType, changeStartIndex, changeEndExclusiveIndex) + } + + override fun onValuePostChange( + eventType: BigTextChangeEventType, + changeStartIndex: Int, + changeEndExclusiveIndex: Int + ) { + onValuePostChange(eventType, changeStartIndex, changeEndExclusiveIndex) + lastChangeEnd = when (eventType) { + BigTextChangeEventType.Insert -> changeEndExclusiveIndex + BigTextChangeEventType.Delete -> changeStartIndex + } + } + }) + if (lastChangeEnd >= 0) { + viewState.cursorIndex = lastChangeEnd + viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex + } + } + + fun undo() { + onUndoRedo { text.undo(it) } + } + + fun redo() { + onUndoRedo { text.redo(it) } + } + val tv = remember { TextFieldValue() } // this value is not used LaunchedEffect(transformedText) { @@ -760,6 +801,18 @@ private fun CoreBigMonospaceText( false } } + isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && !it.isShiftPressed && it.key == Key.Z -> { + // Hit Ctrl-Z or Cmd-Z to undo + log.d { "BigMonospaceTextField hit undo" } + undo() + true + } + isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.isShiftPressed && it.key == Key.Z -> { + // Hit Ctrl-Shift-Z or Cmd-Shift-Z to redo + log.d { "BigMonospaceTextField hit redo" } + redo() + true + } /* selection */ it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.A -> { // Hit Ctrl-A or Cmd-A to select all diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeCallback.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeCallback.kt new file mode 100644 index 00000000..4b01eeef --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextChangeCallback.kt @@ -0,0 +1,8 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +interface BigTextChangeCallback { + + fun onValuePreChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) = Unit + + fun onValuePostChange(eventType: BigTextChangeEventType, changeStartIndex: Int, changeEndExclusiveIndex: Int) = Unit +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 7d821336..4eeb2d15 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -8,6 +8,7 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.addToThisAsc import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMaxIndexOfValueAtMost import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForMinIndexOfValueAtLeast import com.sunnychung.application.multiplatform.hellohttp.extension.length +import com.sunnychung.application.multiplatform.hellohttp.util.CircularList import com.sunnychung.application.multiplatform.hellohttp.util.JvmLogger import com.sunnychung.application.multiplatform.hellohttp.util.let import com.williamfiset.algorithms.datastructures.balancedtree.RedBlackTree @@ -38,6 +39,7 @@ private const val EPS = 1e-4f open class BigTextImpl( val chunkSize: Int = 2 * 1024 * 1024, // 2 MB + val undoHistoryCapacity: Int = 1000, val textBufferFactory: ((capacity: Int) -> TextBuffer) = { StringTextBuffer(it) }, val charSequenceBuilderFactory: ((capacity: Int) -> Appendable) = { StringBuilder(it) }, val charSequenceFactory: ((Appendable) -> CharSequence) = { it: Appendable -> it.toString() }, @@ -62,8 +64,17 @@ open class BigTextImpl( override var onLayoutCallback: (() -> Unit)? = null + /** + * Note: It is required to call {@link recordCurrentChangeSequenceIntoUndoHistory()} manually to make undo works! + */ + var isUndoEnabled: Boolean = false + var decorator: BigTextDecorator? = null + var currentChanges: MutableList = mutableListOf() + val undoHistory = CircularList(undoHistoryCapacity) + val redoHistory = CircularList(undoHistoryCapacity) + internal var changeHook: BigTextChangeHook? = null init { @@ -387,6 +398,17 @@ open class BigTextImpl( leftStringLength = 0 } + if (isUndoEnabled) { + currentChanges += BigTextInputChange( + type = BigTextChangeEventType.Insert, + buffer = buffer, + bufferCharIndexes = range.start .. range.endInclusive, + positions = position until position + chunkedString.length + ).also { + log.i { "Record change for undo: $it" } + } + clearRedoHistory() + } } protected fun insertChunkAtPosition(position: Int, chunkedStringLength: Int, ownership: BufferOwnership, buffer: TextBuffer, range: IntRange, isInsertAtRightmost: Boolean = false, newNodeConfigurer: BigTextNodeValue.() -> Unit): Int { @@ -860,9 +882,12 @@ open class BigTextImpl( if (isD && nodeRange.start == 0) { isD = true } + var splitStartAt = 0 + var splitEndAt = node.value.bufferLength if (endExclusive - 1 in nodeRange.start..nodeRange.last - 1) { // need to split val splitAtIndex = endExclusive - nodeRange.start + splitEndAt = splitAtIndex log.d { "Split E at $splitAtIndex" } newNodesInDescendingOrder += createNodeValue().apply { // the second part of the existing string bufferIndex = node!!.value.bufferIndex // FIXME transform @@ -885,6 +910,7 @@ open class BigTextImpl( // need to split val splitAtIndex = start - nodeRange.start + splitStartAt = splitAtIndex log.d { "Split S at $splitAtIndex" } newNodesInDescendingOrder += createNodeValue().apply { // the first part of the existing string bufferIndex = node!!.value.bufferIndex @@ -904,6 +930,21 @@ open class BigTextImpl( if (nodeRange.start == 2083112) { isD = true } + if (isUndoEnabled) { + currentChanges += BigTextInputChange( + type = BigTextChangeEventType.Delete, + buffer = node.value.buffer, + bufferCharIndexes = node.value.bufferOffsetStart + splitStartAt + until + node.value.bufferOffsetStart + splitEndAt, + positions = nodeRange.start + splitStartAt + until + nodeRange.start + splitEndAt, + ).also { + log.i { "Record change for undo: $it" } + } + clearRedoHistory() + } tree.delete(node) log.v { inspect("After delete " + node?.value?.debugKey()) } node = prev @@ -958,6 +999,120 @@ open class BigTextImpl( return -(endExclusive - start) } + fun recordCurrentChangeSequenceIntoUndoHistory() { + if (!isUndoEnabled) { + return + } + + if (currentChanges.isNotEmpty()) { + undoHistory.push(BigTextInputOperation(currentChanges.toList())) + currentChanges = mutableListOf() + } + } + + protected fun clearRedoHistory() { + redoHistory.clear() + } + + protected fun applyReverseChangeSequence(changes: List, callback: BigTextChangeCallback?) { + if (!isUndoEnabled) { + return + } + try { + isUndoEnabled = false // don't record the following changes into the undo history + + changes.asReversed().forEach { + when (it.type) { + BigTextChangeEventType.Delete -> { + callback?.onValuePreChange(BigTextChangeEventType.Insert, it.positions.start, it.positions.endInclusive + 1) + insertChunkAtPosition(it.positions.start, it.bufferCharIndexes.length, BufferOwnership.Owned, it.buffer, it.bufferCharIndexes) { + bufferIndex = -3 + bufferOffsetStart = it.bufferCharIndexes.start + bufferOffsetEndExclusive = it.bufferCharIndexes.endInclusive + 1 + this.buffer = it.buffer + this.bufferOwnership = BufferOwnership.Owned + + leftStringLength = 0 + } + callback?.onValuePostChange(BigTextChangeEventType.Insert, it.positions.start, it.positions.endInclusive + 1) + } + + BigTextChangeEventType.Insert -> { + callback?.onValuePreChange(BigTextChangeEventType.Delete, it.positions.start, it.positions.endInclusive + 1) + delete(it.positions) + callback?.onValuePostChange(BigTextChangeEventType.Delete, it.positions.start, it.positions.endInclusive + 1) + } + } + } + } finally { + isUndoEnabled = true + } + } + + protected fun applyChangeSequence(changes: List, callback: BigTextChangeCallback?) { + if (!isUndoEnabled) { + return + } + try { + isUndoEnabled = false // don't record the following changes into the undo history + + changes.forEach { + when (it.type) { + BigTextChangeEventType.Insert -> { + callback?.onValuePreChange(BigTextChangeEventType.Insert, it.positions.start, it.positions.endInclusive + 1) + insertChunkAtPosition(it.positions.start, it.bufferCharIndexes.length, BufferOwnership.Owned, it.buffer, it.bufferCharIndexes) { + bufferIndex = -3 + bufferOffsetStart = it.bufferCharIndexes.start + bufferOffsetEndExclusive = it.bufferCharIndexes.endInclusive + 1 + this.buffer = it.buffer + this.bufferOwnership = BufferOwnership.Owned + + leftStringLength = 0 + } + callback?.onValuePostChange(BigTextChangeEventType.Insert, it.positions.start, it.positions.endInclusive + 1) + } + + BigTextChangeEventType.Delete -> { + callback?.onValuePreChange(BigTextChangeEventType.Delete, it.positions.start, it.positions.endInclusive + 1) + delete(it.positions) + callback?.onValuePostChange(BigTextChangeEventType.Delete, it.positions.start, it.positions.endInclusive + 1) + } + } + } + } finally { + isUndoEnabled = true + } + } + + fun undo(callback: BigTextChangeCallback? = null): Boolean { + if (!isUndoEnabled) { + return false + } + if (currentChanges.isNotEmpty()) { + applyReverseChangeSequence(currentChanges, callback) + redoHistory.push(BigTextInputOperation(currentChanges.toList())) + currentChanges = mutableListOf() + return true + } + val lastOperation = undoHistory.removeHead() ?: return false + applyReverseChangeSequence(lastOperation.changes, callback) + redoHistory.push(lastOperation) + return true + } + + fun redo(callback: BigTextChangeCallback? = null): Boolean { + if (!isUndoEnabled) { + return false + } + if (currentChanges.isNotEmpty()) { // should not happen + return false + } + val lastOperation = redoHistory.removeHead() ?: return false + applyChangeSequence(lastOperation.changes, callback) + undoHistory.push(lastOperation) + return true + } + fun charIndexRangeOfNode(node: RedBlackTree.Node): IntRange { val start = findPositionStart(node) return start until start + node.value.bufferLength diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputChangeOperation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputChangeOperation.kt new file mode 100644 index 00000000..47be6a73 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputChangeOperation.kt @@ -0,0 +1,23 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import com.sunnychung.application.multiplatform.hellohttp.extension.length + +data class BigTextInputOperation( + val changes: List +) + +data class BigTextInputChange( + val type: BigTextChangeEventType, + val buffer: TextBuffer, + val bufferCharIndexes: IntRange, + val positions: IntRange, +) { + init { + require(positions.length == bufferCharIndexes.length) + } + + override fun toString(): String { + val substring = buffer.substring(bufferCharIndexes.start, minOf(bufferCharIndexes.endInclusive + 1, bufferCharIndexes.start + 10)) + return "{$type, buf=$bufferCharIndexes, pos=$positions (${substring} ...)}" + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt index 7d1f29bc..a7a05728 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt @@ -9,4 +9,5 @@ fun BigText.Companion.createFromLargeAnnotatedString(initialContent: AnnotatedSt ).apply { log.d { "createFromLargeAnnotatedString ${initialContent.length}" } append(initialContent) + isUndoEnabled = true // it has to be after append to avoid recording into the undo history } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextUndoRedoTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextUndoRedoTest.kt new file mode 100644 index 00000000..21db5622 --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextUndoRedoTest.kt @@ -0,0 +1,166 @@ +package com.sunnychung.application.multiplatform.hellohttp.test.bigtext + +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import kotlin.test.Test +import kotlin.test.assertEquals + +class BigTextUndoRedoTest { + + @ParameterizedTest + @ValueSource(ints = [6, 64, 2 * 1024 * 1024]) + fun undoRedoSimpleInsertDeleteSingleCharacter(chunkSize: Int) { + val t = BigTextImpl(chunkSize = chunkSize).apply { + isUndoEnabled = true + } + "abcde".forEach { + t.append(it.toString()) + t.recordCurrentChangeSequenceIntoUndoHistory() + } + t.insertAt(2, "A") + t.recordCurrentChangeSequenceIntoUndoHistory() + t.insertAt(3, "B") + t.recordCurrentChangeSequenceIntoUndoHistory() + t.insertAt(4, "C") + t.recordCurrentChangeSequenceIntoUndoHistory() + assertEquals("abABCcde", t.buildString()) + t.delete(3 .. 3) + t.recordCurrentChangeSequenceIntoUndoHistory() + t.delete(5 .. 5) + t.recordCurrentChangeSequenceIntoUndoHistory() + t.delete(5 .. 5) + t.recordCurrentChangeSequenceIntoUndoHistory() + t.append("x") + assertEquals("abACcx", t.buildString()) + assertUndoRedoUndo(listOf( + "abACcx", + "abACc", + "abACce", + "abACcde", + "abABCcde", + "abABcde", + "abAcde", + "abcde", + "abcd", + "abc", + "ab", + "a", + "", + ), t) + } + + @ParameterizedTest + @ValueSource(ints = [16, 64, 2 * 1024 * 1024]) + fun undoRedoSimpleInsertDeleteChunkStringWithInitialString(chunkSize: Int) { + val initial = "0123456789112345678921234567893123456789" + val t = BigTextImpl(chunkSize = chunkSize).apply { + append(initial) + isUndoEnabled = true + } + t.delete(14 .. 19) + t.recordCurrentChangeSequenceIntoUndoHistory() + t.insertAt(13, "abcdefg") + t.recordCurrentChangeSequenceIntoUndoHistory() + t.append("xyz") + t.recordCurrentChangeSequenceIntoUndoHistory() + t.insertAt(29, "ABCDEFGH") + t.recordCurrentChangeSequenceIntoUndoHistory() + t.delete(31 .. 33) + t.recordCurrentChangeSequenceIntoUndoHistory() + t.delete(0 .. 5) +// assertEquals("0123456789112abcdefg321234567ABCDEFGH893123456789xyz", t.buildString()) + assertEquals("6789112abcdefg321234567ABFGH893123456789xyz", t.buildString()) + + assertUndoRedoUndo(listOf( + "6789112abcdefg321234567ABFGH893123456789xyz", + "0123456789112abcdefg321234567ABFGH893123456789xyz", + "0123456789112abcdefg321234567ABCDEFGH893123456789xyz", + "0123456789112abcdefg321234567893123456789xyz", + "0123456789112abcdefg321234567893123456789", + "0123456789112321234567893123456789", + "0123456789112345678921234567893123456789", + ), t) + } + + @ParameterizedTest + @ValueSource(ints = [16, 15, 64, 2 * 1024 * 1024]) + fun undoRedoInsertDeleteLongStringWithInitialString(chunkSize: Int) { + val initial = "abcd" + val t = BigTextImpl(chunkSize = chunkSize).apply { + append(initial) + isUndoEnabled = true + } + t.insertAt(0, "0123456789112345678921234567893123456789412345678951234567896123") + t.recordCurrentChangeSequenceIntoUndoHistory() + t.delete(1..64) + t.recordCurrentChangeSequenceIntoUndoHistory() + t.insertAt(3, "ABCD") + t.recordCurrentChangeSequenceIntoUndoHistory() + t.append("xyz") + t.recordCurrentChangeSequenceIntoUndoHistory() + assertUndoRedoUndo(listOf( + "0bcABCDdxyz", + "0bcABCDd", + "0bcd", + "0123456789112345678921234567893123456789412345678951234567896123abcd", + initial + ), t) + } + + @Test + fun noRedoAfterMakingChanges() { + val t = BigTextImpl(chunkSize = 256).apply { + append("") + isUndoEnabled = true + } + t.append("abcd") + t.recordCurrentChangeSequenceIntoUndoHistory() + t.append("efg") + t.recordCurrentChangeSequenceIntoUndoHistory() + t.delete(6 .. 6) + assertEquals("abcdef", t.buildString()) + listOf("abcdefg", "abcd").forEach { expected -> + assertEquals(true, t.undo()) + assertEquals(expected, t.buildString()) + } + + // no redo after making a change + t.append("x") + assertEquals("abcdx", t.buildString()) + (1..10).forEach { + assertEquals(false, t.redo()) + assertEquals("abcdx", t.buildString()) + } + + assertUndoRedoUndo(listOf("abcdx", "abcd", ""), t) + } + + fun assertUndoRedoUndo(reversedExpectedStrings: List, t: BigTextImpl) { + assertEquals(reversedExpectedStrings.first(), t.buildString()) + reversedExpectedStrings.stream().skip(1).forEach { expected -> + assertEquals(true, t.undo()) + assertEquals(expected, t.buildString()) + } + (1..3).forEach { + assertEquals(false, t.undo()) + assertEquals(reversedExpectedStrings.last(), t.buildString()) + } + reversedExpectedStrings.asReversed().stream().skip(1).forEach { expected -> + assertEquals(true, t.redo()) + assertEquals(expected, t.buildString()) + } + (1..10).forEach { + assertEquals(false, t.redo()) + assertEquals(reversedExpectedStrings.first(), t.buildString()) + } + reversedExpectedStrings.stream().skip(1).forEach { expected -> + assertEquals(true, t.undo()) + assertEquals(expected, t.buildString()) + } + (1..10).forEach { + assertEquals(false, t.undo()) + assertEquals(reversedExpectedStrings.last(), t.buildString()) + } + } +} diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/CircularListTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/CircularListTest.kt new file mode 100644 index 00000000..538f58d6 --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/CircularListTest.kt @@ -0,0 +1,214 @@ +package com.sunnychung.application.multiplatform.hellohttp.test.util + +import com.sunnychung.application.multiplatform.hellohttp.util.CircularList +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class CircularListTest { + + @Test + fun pushAndRemoveHead() { + val l = CircularList(100) + + assertEquals(0, l.size) + + (0 until 80).forEach { + l.push(it) + println("h = ${l.head}") + assertEquals(it + 1, l.size) + } + + (79 downTo 0).forEach { + val removed = l.removeHead() + println("h = ${l.head}") + assertEquals(it, removed) + assertEquals(it, l.size) + } + + assertEquals(null, l.removeHead()) + assertEquals(0, l.size) + println("h = ${l.head}") + + (80 until 90).forEach { + l.push(it) + println("h = ${l.head}") + assertEquals(it + 1 - 80, l.size) + } + + (89 downTo 80).forEach { + val removed = l.removeHead() + assertEquals(it, removed) + assertEquals(it - 80, l.size) + } + + assertEquals(null, l.removeHead()) + assertEquals(0, l.size) + } + + @Test + fun pushAndRemoveTail() { + val l = CircularList(100) + + assertEquals(0, l.size) + + (0 until 80).forEach { + l.push(it) + assertEquals(it + 1, l.size) + } + + (0 until 80).forEach { + val removed = l.removeTail() + assertEquals(it, removed) + assertEquals(79 - it, l.size) + } + + assertEquals(null, l.removeTail()) + assertEquals(0, l.size) + + (80 until 90).forEach { + l.push(it) + assertEquals(it + 1 - 80, l.size) + } + + (80 until 90).forEach { + val removed = l.removeTail() + assertEquals(it, removed) + assertEquals(89 - it, l.size) + } + + assertEquals(null, l.removeTail()) + assertEquals(0, l.size) + } + + @Test + fun pushAndRemoveHeadRotate() { + val l = CircularList(100) + + assertEquals(0, l.size) + + (0 until 280).forEach { + l.push(it) + println("h = ${l.head}, t = ${l.tail}") + assertEquals(minOf(100, it + 1), l.size) + } + + (279 downTo 180).forEach { + val removed = l.removeHead() + assertEquals(it, removed) + assertEquals(it - 180, l.size) + } + + assertEquals(null, l.removeHead()) + assertEquals(0, l.size) + + (280 until 330).forEach { + l.push(it) + assertEquals(it + 1 - 280, l.size) + } + + (329 downTo 280).forEach { + val removed = l.removeHead() + assertEquals(it, removed) + assertEquals(it - 280, l.size) + } + + assertEquals(null, l.removeHead()) + assertEquals(0, l.size) + } + + @Test + fun pushAndRemoveTailRotate() { + val l = CircularList(100) + + assertEquals(0, l.size) + + (0 until 280).forEach { + l.push(it) + assertEquals(minOf(100, it + 1), l.size) + } + + (180 until 280).forEach { + val removed = l.removeTail() + assertEquals(it, removed) + assertEquals(279 - it, l.size) + } + + assertEquals(null, l.removeTail()) + assertEquals(0, l.size) + + (280 until 330).forEach { + l.push(it) + assertEquals(it + 1 - 280, l.size) + } + + (280 until 330).forEach { + val removed = l.removeTail() + assertEquals(it, removed) + assertEquals(329 - it, l.size) + } + + assertEquals(null, l.removeTail()) + assertEquals(0, l.size) + } + + @Test + fun mixed() { + val l = CircularList(100) + + assertEquals(0, l.size) + + (0 until 70).forEach { + l.push(it) + assertEquals(minOf(100, it + 1), l.size) + } + + (69 downTo 40).forEach { + val removed = l.removeHead() + assertEquals(it, removed) + assertEquals(it, l.size) + } + + (100 until 160).forEach { + l.push(it) + assertEquals(minOf(100, it - 60 + 1), l.size) + } + + (0 until 20).forEach { + val removed = l.removeTail() + assertEquals(it, removed) + assertEquals(99 - it, l.size) + } + + (159 downTo 150).forEach { + val removed = l.removeHead() + assertEquals(it, removed) + assertEquals(it - 80, l.size) + } + + (200 until 230).forEach { + l.push(it) + assertEquals(minOf(100, it - 200 + 70 + 1), l.size) + } + + assertEquals(229, l.removeHead()) + assertEquals(99, l.size) + + assertEquals(20, l.removeTail()) + assertEquals(98, l.size) + + val random = Random(100) + + (97 downTo 0).forEach { + if (random.nextBoolean()) { + l.removeHead() + } else { + l.removeTail() + } + assertEquals(it, l.size) + } + + assertEquals(null, l.removeTail()) + assertEquals(0, l.size) + } +} From eb6a32f184a2d9b02e4232b9d9d454f7703fd1cd Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 27 Oct 2024 21:34:34 +0800 Subject: [PATCH 140/195] update log level to reduce log --- .../multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 4eeb2d15..75fda373 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -405,7 +405,7 @@ open class BigTextImpl( bufferCharIndexes = range.start .. range.endInclusive, positions = position until position + chunkedString.length ).also { - log.i { "Record change for undo: $it" } + log.v { "Record change for undo: $it" } } clearRedoHistory() } @@ -941,7 +941,7 @@ open class BigTextImpl( until nodeRange.start + splitEndAt, ).also { - log.i { "Record change for undo: $it" } + log.v { "Record change for undo: $it" } } clearRedoHistory() } From ce4ae5108ee38726a898d73389650fd1942b5ce0 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 27 Oct 2024 21:38:41 +0800 Subject: [PATCH 141/195] fix memory leak --- .../application/multiplatform/hellohttp/util/CircularList.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt index fa4761f2..03e0523c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt @@ -19,7 +19,7 @@ class CircularList(val capacity: Int) { val isEmpty: Boolean get() = size <= 0 - private val store = ArrayList(capacity) + private var store = ArrayList() fun push(item: T) { val oldSize = size @@ -70,5 +70,6 @@ class CircularList(val capacity: Int) { fun clear() { head = -1 tail = -1 + store = ArrayList() } } From 4123680c71a27b0c6c274fbffb0ec118f6259e58 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 27 Oct 2024 22:23:02 +0800 Subject: [PATCH 142/195] fix IndexOutOfBoundsException --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 8 ++++++-- .../hellohttp/ux/bigtext/BigTextViewState.kt | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 7bd3fb68..3e5fa6e0 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -423,8 +423,12 @@ private fun CoreBigMonospaceText( } rememberLast(viewState.selection.start, viewState.selection.last, textTransformation) { - viewState.transformedSelection = transformedText.findTransformedPositionByOriginalPosition(viewState.selection.start) .. - transformedText.findTransformedPositionByOriginalPosition(maxOf(0, viewState.selection.last)) + viewState.transformedSelection = if (viewState.hasSelection()) { + transformedText.findTransformedPositionByOriginalPosition(viewState.selection.start) .. + transformedText.findTransformedPositionByOriginalPosition(maxOf(0, viewState.selection.last)) + } else { + IntRange.EMPTY + } } val coroutineScope = rememberCoroutineScope() // for scrolling diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt index 30b367f9..5ed2489e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt @@ -30,7 +30,7 @@ class BigTextViewState { var selection: IntRange by mutableStateOf(0..-1) - fun hasSelection(): Boolean = transformedSelection.start >= 0 && !transformedSelection.isEmpty() + fun hasSelection(): Boolean = !selection.isEmpty() && transformedSelection.start >= 0 && !transformedSelection.isEmpty() internal fun updateSelectionByTransformedSelection(transformedText: TransformedText) { selection = transformedText.offsetMapping.transformedToOriginal(transformedSelection.first) .. From a78dc538f6967f73e6c703eb3c1882ca29a3ece2 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 27 Oct 2024 22:23:44 +0800 Subject: [PATCH 143/195] add delete selection to BigMonospaceText --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 19 +++++++++++++++++-- .../hellohttp/ux/bigtext/BigTextViewState.kt | 2 ++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 3e5fa6e0..ed75bb9b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -563,6 +563,23 @@ private fun CoreBigMonospaceText( fun onDelete(direction: TextFBDirection): Boolean { val cursor = viewState.cursorIndex + + if (viewState.hasSelection()) { + val start = viewState.selection.start + val endExclusive = viewState.selection.endInclusive + 1 + onValuePreChange(BigTextChangeEventType.Delete, start, endExclusive) + text.delete(start, endExclusive) + text.recordCurrentChangeSequenceIntoUndoHistory() + onValuePostChange(BigTextChangeEventType.Delete, start, endExclusive) + + viewState.selection = EMPTY_SELECTION_RANGE // cannot use IntRange.EMPTY as `viewState.selection.start` is in use + viewState.cursorIndex = start + viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex + updateViewState() + return true + } + when (direction) { TextFBDirection.Forward -> { if (cursor + 1 <= text.length) { @@ -570,7 +587,6 @@ private fun CoreBigMonospaceText( text.delete(cursor, cursor + 1) text.recordCurrentChangeSequenceIntoUndoHistory() onValuePostChange(BigTextChangeEventType.Delete, cursor, cursor + 1) -// (transformedText as BigTextImpl).layout() // FIXME remove updateViewState() if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("transformedText onDelete $direction") @@ -584,7 +600,6 @@ private fun CoreBigMonospaceText( text.delete(cursor - 1, cursor) text.recordCurrentChangeSequenceIntoUndoHistory() onValuePostChange(BigTextChangeEventType.Delete, cursor - 1, cursor) -// (transformedText as BigTextImpl).layout() // FIXME remove updateViewState() if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("transformedText onDelete $direction") diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt index 5ed2489e..db97a54a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt @@ -5,6 +5,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TransformedText +val EMPTY_SELECTION_RANGE = 0 .. -1 + class BigTextViewState { /** * A unique value that changes when the BigText string value is changed. From a017099613603d9917251e358959ee3799bebf90 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 27 Oct 2024 22:49:41 +0800 Subject: [PATCH 144/195] fix IndexOutOfBoundsException --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 10 ++++++---- .../multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt | 6 ++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index ed75bb9b..95e061bf 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -198,7 +198,7 @@ fun BigMonospaceTextField( textTransformation: IncrementalTextTransformation<*>? = null, textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), - viewState: BigTextViewState = remember { BigTextViewState() }, + viewState: BigTextViewState = remember(text) { BigTextViewState() }, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) = CoreBigMonospaceText( modifier = modifier, @@ -232,7 +232,7 @@ private fun CoreBigMonospaceText( textTransformation: IncrementalTextTransformation<*>? = null, textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), - viewState: BigTextViewState = remember { BigTextViewState() }, + viewState: BigTextViewState = remember(text) { BigTextViewState() }, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, onTransformInit: ((BigTextTransformed) -> Unit)? = null, ) { @@ -835,8 +835,10 @@ private fun CoreBigMonospaceText( /* selection */ it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.A -> { // Hit Ctrl-A or Cmd-A to select all - viewState.selection = 0 .. text.lastIndex - viewState.updateTransformedSelectionBySelection(transformedText) + if (text.isNotEmpty) { + viewState.selection = 0..text.lastIndex + viewState.updateTransformedSelectionBySelection(transformedText) + } true } it.type == KeyEventType.KeyDown && it.key in listOf(Key.ShiftLeft, Key.ShiftRight) -> { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 75fda373..0822347e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -645,6 +645,12 @@ open class BigTextImpl( val lastIndex: Int get() = length - 1 + val isEmpty: Boolean + get() = length <= 0 + + val isNotEmpty: Boolean + get() = length > 0 + override fun buildString(): String { return tree.joinToString("") { it.buffer.subSequence(it.renderBufferStart, it.renderBufferEndExclusive) From 30979f29bd2d3d8f08cc95540e3a2d2375d6c1e7 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 27 Oct 2024 23:04:45 +0800 Subject: [PATCH 145/195] fix BigMonospaceText replacement causes malformed transformation and decoration --- .../hellohttp/ux/CodeEditorView.kt | 4 +-- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 36 +++++++++++-------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 2d8ec966..8fe85df3 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -523,8 +523,8 @@ fun CodeEditorView( ) val syntaxHighlightDecorator = rememberLast(bigTextFieldState, themeColours) { -// JsonSyntaxHighlightDecorator(themeColours) - KotlinSyntaxHighlightSlowDecorator(themeColours) + JsonSyntaxHighlightDecorator(themeColours) +// KotlinSyntaxHighlightSlowDecorator(themeColours) } if (isReadOnly) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 95e061bf..78593c44 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -536,13 +536,29 @@ private fun CoreBigMonospaceText( onTextChange(event) } + fun deleteSelection(isSaveUndoSnapshot: Boolean) { + if (viewState.hasSelection()) { + val start = viewState.selection.start + val endExclusive = viewState.selection.endInclusive + 1 + onValuePreChange(BigTextChangeEventType.Delete, start, endExclusive) + text.delete(start, endExclusive) + if (isSaveUndoSnapshot) { + text.recordCurrentChangeSequenceIntoUndoHistory() + } + onValuePostChange(BigTextChangeEventType.Delete, start, endExclusive) + + viewState.selection = EMPTY_SELECTION_RANGE // cannot use IntRange.EMPTY as `viewState.selection.start` is in use + viewState.transformedSelection = EMPTY_SELECTION_RANGE + viewState.cursorIndex = start + viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex + } + } + fun onType(textInput: String) { log.v { "key in '$textInput'" } if (viewState.hasSelection()) { - text.delete(viewState.selection.start, minOf(text.length, viewState.selection.endInclusive + 1)) - viewState.cursorIndex = viewState.selection.start - viewState.selection = IntRange.EMPTY - viewState.transformedSelection = IntRange.EMPTY + deleteSelection(isSaveUndoSnapshot = false) } val insertPos = viewState.cursorIndex onValuePreChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) @@ -565,17 +581,7 @@ private fun CoreBigMonospaceText( val cursor = viewState.cursorIndex if (viewState.hasSelection()) { - val start = viewState.selection.start - val endExclusive = viewState.selection.endInclusive + 1 - onValuePreChange(BigTextChangeEventType.Delete, start, endExclusive) - text.delete(start, endExclusive) - text.recordCurrentChangeSequenceIntoUndoHistory() - onValuePostChange(BigTextChangeEventType.Delete, start, endExclusive) - - viewState.selection = EMPTY_SELECTION_RANGE // cannot use IntRange.EMPTY as `viewState.selection.start` is in use - viewState.cursorIndex = start - viewState.updateTransformedCursorIndexByOriginal(transformedText) - viewState.transformedSelectionStart = viewState.transformedCursorIndex + deleteSelection(isSaveUndoSnapshot = true) updateViewState() return true } From fead6966ef4c074c11fa53993b6fc1290e8a02d4 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 27 Oct 2024 23:18:41 +0800 Subject: [PATCH 146/195] add Cut function to BigMonospaceText --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 78593c44..8d610a4e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -660,6 +660,26 @@ private fun CoreBigMonospaceText( onUndoRedo { text.redo(it) } } + fun copySelection() { + if (!viewState.hasSelection()) { + return + } + + val textToCopy = text.substring( + viewState.selection.first.. viewState.selection.last + ) + clipboardManager.setText(textToCopy.annotatedString()) + } + + fun cutSelection() { + if (!viewState.hasSelection()) { + return + } + + copySelection() + deleteSelection(isSaveUndoSnapshot = true) + } + val tv = remember { TextFieldValue() } // this value is not used LaunchedEffect(transformedText) { @@ -809,10 +829,13 @@ private fun CoreBigMonospaceText( it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.C && !viewState.transformedSelection.isEmpty() -> { // Hit Ctrl-C or Cmd-C to copy log.d { "BigMonospaceText hit copy" } - val textToCopy = text.substring( - viewState.selection.first.. viewState.selection.last - ) - clipboardManager.setText(textToCopy.annotatedString()) + copySelection() + true + } + it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.X && !viewState.transformedSelection.isEmpty() -> { + // Hit Ctrl-X or Cmd-X to cut + log.d { "BigMonospaceText hit cut" } + cutSelection() true } isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.V -> { From ca54799bf8fd8896aa6b85f8753c43eaa87d94fc Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 28 Oct 2024 21:15:34 +0800 Subject: [PATCH 147/195] add context menu to BigMonospaceText --- .../hellohttp/util/CircularList.kt | 3 + .../hellohttp/ux/DropDownView.kt | 137 +++++++++++++----- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 79 ++++++++-- .../hellohttp/ux/bigtext/BigTextImpl.kt | 4 + .../hellohttp/ux/local/AppFont.kt | 4 + 5 files changed, 182 insertions(+), 45 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt index 03e0523c..7eeb8295 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/CircularList.kt @@ -19,6 +19,9 @@ class CircularList(val capacity: Int) { val isEmpty: Boolean get() = size <= 0 + val isNotEmpty: Boolean + get() = size > 0 + private var store = ArrayList() fun push(item: T) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/DropDownView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/DropDownView.kt index 6c8373d1..d2255cd5 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/DropDownView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/DropDownView.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.CursorDropdownMenu import androidx.compose.runtime.Composable @@ -22,7 +23,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.sunnychung.application.multiplatform.hellohttp.util.emptyToNull +import com.sunnychung.application.multiplatform.hellohttp.ux.local.AppColor import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor +import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalFont /** * @param onClickItem return true to dismiss menu @@ -71,9 +74,86 @@ fun DropDownView( var isShowContextMenu by remember { mutableStateOf(false) } + ContextMenuView( + isShowContextMenu = isShowContextMenu, + onDismissRequest = { isShowContextMenu = false }, + colors = colors, + testTagParts = testTagParts, + populatedItems = populatedItems, + onClickItem = onClickItem, + contentView = contentView, + selectedItem = selectedItem, + isClickable = isClickable + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.run { + if (isClickable) { + clickable(role = Role.Button) { + isShowContextMenu = !isShowContextMenu + } + } else { + this + } + }.run { + if (testTagParts != null) { + testTag(buildTestTag(*testTagParts, TestTagPart.DropdownButton)!!) + } else { + this + } + } + ) { + if (isShowLabel) { + contentView(selectedItem, true, false, isClickable) + } + AppImage( + resource = iconResource, + size = iconSize, + color = if (isClickable) colors.primary else colors.disabled, + modifier = Modifier.padding(arrowPadding), + ) + } +} + +@Composable +fun ContextMenuView( + isShowContextMenu: Boolean, + onDismissRequest: () -> Unit, + colors: AppColor, + testTagParts: Array?, + populatedItems: List, + onClickItem: (T) -> Boolean, + contentView: @Composable (RowScope.(it: T?, isLabel: Boolean, isSelected: Boolean, isClickable: Boolean) -> Unit) = {it, isLabel, isSelected, isClickable -> + AppText( + text = it?.displayText.emptyToNull() ?: "--", + color = if (!isLabel && isSelected) { + colors.highlight + } else if (isClickable) { + colors.primary + } else { + colors.disabled + }, + fontSize = LocalFont.current.contextMenuFontSize, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + .padding(horizontal = 2.dp) + .run { + if (isLabel && testTagParts != null) { + testTag(buildTestTag(*testTagParts, TestTagPart.DropdownLabel)!!.also { println(">>> Dropdown Use TTag: $it") }) + } else { + this + } + } + ) + }, + selectedItem: T?, + isClickable: Boolean +) { CursorDropdownMenu( expanded = isShowContextMenu, - onDismissRequest = { isShowContextMenu = false }, + onDismissRequest = onDismissRequest, modifier = Modifier.background(colors.backgroundContextMenu) .run { if (testTagParts != null) { @@ -84,10 +164,20 @@ fun DropDownView( } ) { populatedItems.forEach { item -> + if (item is DropDownDivider) { + Column(modifier = Modifier + .padding(vertical = 4.dp, horizontal = 4.dp) + .background(colors.line) + .fillMaxWidth() + .height(1.dp) + ) {} + return@forEach + } + Column(modifier = Modifier .clickable { if (onClickItem(item)) { - isShowContextMenu = false + onDismissRequest() } } .padding(horizontal = 8.dp, vertical = 4.dp) @@ -101,45 +191,18 @@ fun DropDownView( } ) { Row { - contentView(item, false, item.key == selectedItem?.key, isClickable) + contentView(item, false, item.key == selectedItem?.key, isClickable && item.isEnabled) } } } } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier.run { - if (isClickable) { - clickable(role = Role.Button) { - isShowContextMenu = !isShowContextMenu - } - } else { - this - } - }.run { - if (testTagParts != null) { - testTag(buildTestTag(*testTagParts, TestTagPart.DropdownButton)!!) - } else { - this - } - } - ) { - if (isShowLabel) { - contentView(selectedItem, true, false, isClickable) - } - AppImage( - resource = iconResource, - size = iconSize, - color = if (isClickable) colors.primary else colors.disabled, - modifier = Modifier.padding(arrowPadding), - ) - } } interface DropDownable { val key: Any? val displayText: String + val isEnabled: Boolean + get() = true } data class DropDownValue(override val displayText: String) : DropDownable { @@ -147,7 +210,15 @@ data class DropDownValue(override val displayText: String) : DropDownable { get() = displayText } -data class DropDownKeyValue(override val key: T, override val displayText: String) : DropDownable +data class DropDownKeyValue(override val key: T, override val displayText: String, override val isEnabled: Boolean = true) : DropDownable + +object DropDownDivider : DropDownable { + override val key: Any? + get() = "_divider" + + override val displayText: String + get() = "" +} data class DropDownMap(private val values: List>) { private val mapByKey = values.associateBy { it.key } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 8d610a4e..f8ee142d 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.focus.FocusRequester @@ -48,6 +49,7 @@ import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.LayoutCoordinates @@ -82,6 +84,9 @@ import com.sunnychung.application.multiplatform.hellohttp.util.ComposeUnicodeCha import com.sunnychung.application.multiplatform.hellohttp.util.annotatedString import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.util.string +import com.sunnychung.application.multiplatform.hellohttp.ux.ContextMenuView +import com.sunnychung.application.multiplatform.hellohttp.ux.DropDownDivider +import com.sunnychung.application.multiplatform.hellohttp.ux.DropDownKeyValue 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 @@ -217,7 +222,7 @@ fun BigMonospaceTextField( onTextLayout = onTextLayout, ) -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable private fun CoreBigMonospaceText( modifier: Modifier = Modifier, @@ -443,6 +448,8 @@ private fun CoreBigMonospaceText( var isHoldingShiftKey by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) } + var isShowContextMenu by remember { mutableStateOf(false) } + val viewportTop = scrollState.value.toFloat() fun getTransformedCharIndex(x: Float, y: Float, mode: ResolveCharPositionMode): Int { @@ -680,6 +687,23 @@ private fun CoreBigMonospaceText( deleteSelection(isSaveUndoSnapshot = true) } + fun paste(): Boolean { + val textToPaste = clipboardManager.getText()?.text + return if (!textToPaste.isNullOrEmpty()) { + onType(textToPaste) + true + } else { + false + } + } + + fun selectAll() { + if (text.isNotEmpty) { + viewState.selection = 0..text.lastIndex + viewState.updateTransformedSelectionBySelection(transformedText) + } + } + val tv = remember { TextFieldValue() } // this value is not used LaunchedEffect(transformedText) { @@ -753,6 +777,12 @@ private fun CoreBigMonospaceText( PointerEventType.Press -> { val position = event.changes.first().position log.v { "press ${position.x} ${position.y} shift=$isHoldingShiftKey" } + + if (event.button == PointerButton.Secondary) { + isShowContextMenu = !isShowContextMenu + continue + } + if (isHoldingShiftKey) { val selectionStart = viewState.transformedSelectionStart selectionEnd = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Selection) @@ -841,13 +871,7 @@ private fun CoreBigMonospaceText( isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.V -> { // Hit Ctrl-V or Cmd-V to paste log.d { "BigMonospaceTextField hit paste" } - val textToPaste = clipboardManager.getText()?.text - if (!textToPaste.isNullOrEmpty()) { - onType(textToPaste) - true - } else { - false - } + paste() } isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && !it.isShiftPressed && it.key == Key.Z -> { // Hit Ctrl-Z or Cmd-Z to undo @@ -864,10 +888,7 @@ private fun CoreBigMonospaceText( /* selection */ it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.A -> { // Hit Ctrl-A or Cmd-A to select all - if (text.isNotEmpty) { - viewState.selection = 0..text.lastIndex - viewState.updateTransformedSelectionBySelection(transformedText) - } + selectAll() true } it.type == KeyEventType.KeyDown && it.key in listOf(Key.ShiftLeft, Key.ShiftRight) -> { @@ -1045,6 +1066,36 @@ private fun CoreBigMonospaceText( val endInstant = KInstant.now() log.d { "Declare BigText content for render took ${endInstant - startInstant}" } } + + ContextMenuView( + isShowContextMenu = isShowContextMenu, + onDismissRequest = { isShowContextMenu = false }, + colors = LocalColor.current, + testTagParts = null, + populatedItems = listOf( + DropDownKeyValue(ContextMenuItem.Copy, "Copy", viewState.hasSelection()), + DropDownKeyValue(ContextMenuItem.Paste, "Paste", clipboardManager.hasText()), + DropDownKeyValue(ContextMenuItem.Cut, "Cut", viewState.hasSelection()), + DropDownDivider, + DropDownKeyValue(ContextMenuItem.Undo, "Undo", text.isUndoable()), + DropDownKeyValue(ContextMenuItem.Redo, "Redo", text.isRedoable()), + DropDownDivider, + DropDownKeyValue(ContextMenuItem.SelectAll, "Select All", text.isNotEmpty), + ), + onClickItem = { + when (it.key as ContextMenuItem) { + ContextMenuItem.Copy -> copySelection() + ContextMenuItem.Paste -> paste() + ContextMenuItem.Cut -> cutSelection() + ContextMenuItem.Undo -> undo() + ContextMenuItem.Redo -> redo() + ContextMenuItem.SelectAll -> selectAll() + } + true + }, + selectedItem = null, + isClickable = true, + ) } } @@ -1059,3 +1110,7 @@ enum class TextFBDirection { enum class CursorAdjustDirection { Forward, Backward, Bidirectional } + +private enum class ContextMenuItem { + Copy, Paste, Cut, Undo, Redo, SelectAll +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 0822347e..9830b592 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -1119,6 +1119,10 @@ open class BigTextImpl( return true } + fun isUndoable(): Boolean = isUndoEnabled && (currentChanges.isNotEmpty() || undoHistory.isNotEmpty) + + fun isRedoable(): Boolean = isUndoEnabled && currentChanges.isEmpty() && redoHistory.isNotEmpty + fun charIndexRangeOfNode(node: RedBlackTree.Node): IntRange { val start = findPositionStart(node) return start until start + node.value.bufferLength diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppFont.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppFont.kt index f3ab0520..cc5b6c87 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppFont.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/local/AppFont.kt @@ -16,6 +16,8 @@ data class AppFont( val createLabelSize: TextUnit, val largeInfoSize: TextUnit, + + val contextMenuFontSize: TextUnit, ) val LocalFont = compositionLocalOf { regularFont() } @@ -30,4 +32,6 @@ internal fun regularFont() = AppFont( createLabelSize = 20.sp, largeInfoSize = 29.sp, + + contextMenuFontSize = 13.sp, ) From cf3fb181a2e90a5bd2a9afa3a91fe01c6dec8947 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Tue, 29 Oct 2024 23:16:17 +0800 Subject: [PATCH 148/195] add Cmd + Left/Right (macOS only) text navigation to BigMonospaceText --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index f8ee142d..4b4ce2a1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -80,6 +80,8 @@ import co.touchlab.kermit.Severity import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.isCtrlOrCmdPressed import com.sunnychung.application.multiplatform.hellohttp.extension.toTextInput +import com.sunnychung.application.multiplatform.hellohttp.platform.MacOS +import com.sunnychung.application.multiplatform.hellohttp.platform.currentOS import com.sunnychung.application.multiplatform.hellohttp.util.ComposeUnicodeCharMeasurer import com.sunnychung.application.multiplatform.hellohttp.util.annotatedString import com.sunnychung.application.multiplatform.hellohttp.util.log @@ -854,7 +856,7 @@ private fun CoreBigMonospaceText( } } .onPreviewKeyEvent { - log.v { "BigMonospaceText onPreviewKeyEvent" } + log.v { "BigMonospaceText onPreviewKeyEvent ${it.key}" } when { it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.C && !viewState.transformedSelection.isEmpty() -> { // Hit Ctrl-C or Cmd-C to copy @@ -921,6 +923,28 @@ private fun CoreBigMonospaceText( it.key == Key.Delete -> { onDelete(TextFBDirection.Forward) } + /* text navigation */ + currentOS() == MacOS && it.isMetaPressed && it.key in listOf(Key.DirectionLeft, Key.DirectionRight) -> { + // use `transformedText` as basis because `text` does not perform layout + val currentRowIndex = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) + val newTransformedPosition = if (it.key == Key.DirectionLeft) { + // home -> move to start of row + log.d { "move to start of row $currentRowIndex" } + transformedText.findRowPositionStartIndexByRowIndex(currentRowIndex) + } else { + // end -> move to end of row + log.d { "move to end of row $currentRowIndex" } + if (currentRowIndex + 1 <= transformedText.lastRowIndex) { + transformedText.findRowPositionStartIndexByRowIndex(currentRowIndex + 1) - /* the '\n' char */ 1 + } else { + transformedText.length + } + } + viewState.transformedCursorIndex = newTransformedPosition + viewState.updateCursorIndexByTransformed(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex + true + } it.key in listOf(Key.DirectionLeft, Key.DirectionRight) -> { val delta = if (it.key == Key.DirectionRight) 1 else -1 viewState.transformedSelection = IntRange.EMPTY // TODO handle Shift key From f2c02a61f41de36f802530850cf620357d398ace Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 30 Oct 2024 21:35:37 +0800 Subject: [PATCH 149/195] update BigMonospaceText navigation shortcut to include Home and End keys --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 4b4ce2a1..b99a1189 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerButton @@ -856,7 +857,7 @@ private fun CoreBigMonospaceText( } } .onPreviewKeyEvent { - log.v { "BigMonospaceText onPreviewKeyEvent ${it.key}" } + log.v { "BigMonospaceText onPreviewKeyEvent ${it.type} ${it.key} ${it.key.nativeKeyCode} ${it.key.keyCode}" } when { it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.C && !viewState.transformedSelection.isEmpty() -> { // Hit Ctrl-C or Cmd-C to copy @@ -924,10 +925,11 @@ private fun CoreBigMonospaceText( onDelete(TextFBDirection.Forward) } /* text navigation */ - currentOS() == MacOS && it.isMetaPressed && it.key in listOf(Key.DirectionLeft, Key.DirectionRight) -> { + (currentOS() == MacOS && it.isMetaPressed && it.key in listOf(Key.DirectionLeft, Key.DirectionRight)) || + it.key in listOf(Key.MoveHome, Key.MoveEnd) -> { // use `transformedText` as basis because `text` does not perform layout val currentRowIndex = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) - val newTransformedPosition = if (it.key == Key.DirectionLeft) { + val newTransformedPosition = if (it.key in listOf(Key.DirectionLeft, Key.MoveHome)) { // home -> move to start of row log.d { "move to start of row $currentRowIndex" } transformedText.findRowPositionStartIndexByRowIndex(currentRowIndex) From 873a33e2ef2150839489674a458179c71df50737 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Thu, 31 Oct 2024 23:24:16 +0800 Subject: [PATCH 150/195] add word text navigation to BigMonospaceText --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index b99a1189..465bb43b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -707,6 +707,38 @@ private fun CoreBigMonospaceText( } } + fun findPreviousWordBoundaryPositionFromCursor(): Int { + val currentRowIndex = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) + val transformedRowStart = transformedText.findRowPositionStartIndexByRowIndex(currentRowIndex) + val rowStart = transformedText.findOriginalPositionByTransformedPosition(transformedRowStart) + val substringFromRowStartToCursor = text.substring(rowStart, viewState.cursorIndex) + if (substringFromRowStartToCursor.isEmpty()) { + return maxOf(0, rowStart - 1) + } + val wordBoundaryAt = "\\b".toRegex().findAll(substringFromRowStartToCursor) + .filter { it.range.start < substringFromRowStartToCursor.length } + .lastOrNull()?.range?.start ?: 0 + return rowStart + wordBoundaryAt + } + + fun findNextWordBoundaryPositionFromCursor(): Int { + val currentRowIndex = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) + val transformedRowEnd = if (currentRowIndex + 1 <= transformedText.lastRowIndex) { + transformedText.findRowPositionStartIndexByRowIndex(currentRowIndex + 1) + } else { + transformedText.length + } + val rowEnd = transformedText.findOriginalPositionByTransformedPosition(transformedRowEnd) + val substringFromCursorToRowEnd = text.substring(viewState.cursorIndex, rowEnd) + if (substringFromCursorToRowEnd.isEmpty()) { + return minOf(text.length, rowEnd) + } + val wordBoundaryAt = "\\b".toRegex().findAll(substringFromCursorToRowEnd) + .filter { it.range.start > 0 } + .firstOrNull()?.range?.start ?: substringFromCursorToRowEnd.length + return viewState.cursorIndex + wordBoundaryAt + } + val tv = remember { TextFieldValue() } // this value is not used LaunchedEffect(transformedText) { @@ -947,6 +979,26 @@ private fun CoreBigMonospaceText( viewState.transformedSelectionStart = viewState.transformedCursorIndex true } + it.key == Key.DirectionLeft && ( + (currentOS() == MacOS && it.isAltPressed) || + (currentOS() != MacOS && it.isCtrlPressed) + ) -> { + val newPosition = findPreviousWordBoundaryPositionFromCursor() + viewState.cursorIndex = newPosition + viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex + true + } + it.key == Key.DirectionRight && ( + (currentOS() == MacOS && it.isAltPressed) || + (currentOS() != MacOS && it.isCtrlPressed) + ) -> { + val newPosition = findNextWordBoundaryPositionFromCursor() + viewState.cursorIndex = newPosition + viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex + true + } it.key in listOf(Key.DirectionLeft, Key.DirectionRight) -> { val delta = if (it.key == Key.DirectionRight) 1 else -1 viewState.transformedSelection = IntRange.EMPTY // TODO handle Shift key From ce977561286b8076d457304bc994d874299d4b48 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Thu, 31 Oct 2024 23:46:07 +0800 Subject: [PATCH 151/195] add "move to start/end of text" to BigMonospaceText --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 465bb43b..514d14ca 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -957,6 +957,20 @@ private fun CoreBigMonospaceText( onDelete(TextFBDirection.Forward) } /* text navigation */ + (currentOS() == MacOS && it.isMetaPressed && it.key == Key.DirectionUp) || + (currentOS() != MacOS && it.isCtrlPressed && it.key == Key.MoveHome) -> { + viewState.cursorIndex = 0 // TODO scroll to new position + viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex + true + } + (currentOS() == MacOS && it.isMetaPressed && it.key == Key.DirectionDown) || + (currentOS() != MacOS && it.isCtrlPressed && it.key == Key.MoveEnd) -> { + viewState.cursorIndex = text.length // TODO scroll to new position + viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex + true + } (currentOS() == MacOS && it.isMetaPressed && it.key in listOf(Key.DirectionLeft, Key.DirectionRight)) || it.key in listOf(Key.MoveHome, Key.MoveEnd) -> { // use `transformedText` as basis because `text` does not perform layout From a34455f0944ccc5edab815ef481a8ca205eeaea7 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 2 Nov 2024 14:29:53 +0800 Subject: [PATCH 152/195] add "shift + text navigation shortcut" to select text in BigMonospaceText --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 87 +++++++++++++------ 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 514d14ca..ac0e051a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -739,6 +739,43 @@ private fun CoreBigMonospaceText( return viewState.cursorIndex + wordBoundaryAt } + fun updateOriginalCursorOrSelection(newPosition: Int, isSelection: Boolean) { + val oldCursorPosition = viewState.cursorIndex + viewState.cursorIndex = newPosition // TODO scroll to new position + viewState.updateTransformedCursorIndexByOriginal(transformedText) + if (isSelection) { + val selectionStart = if (viewState.hasSelection()) { + transformedText.findOriginalPositionByTransformedPosition(viewState.transformedSelectionStart) + } else { + oldCursorPosition + } + viewState.selection = minOf(selectionStart, newPosition) until maxOf(selectionStart, newPosition) + viewState.updateTransformedSelectionBySelection(transformedText) + } else { + viewState.transformedSelectionStart = viewState.transformedCursorIndex + viewState.transformedSelection = IntRange.EMPTY + } + } + + fun updateTransformedCursorOrSelection(newTransformedPosition: Int, isSelection: Boolean) { + val oldTransformedCursorPosition = viewState.transformedCursorIndex + viewState.transformedCursorIndex = newTransformedPosition + viewState.updateCursorIndexByTransformed(transformedText) + if (isSelection) { + val selectionTransformedStart = if (viewState.hasSelection()) { + viewState.transformedSelectionStart + } else { + oldTransformedCursorPosition + } + log.d { "select T $selectionTransformedStart ~ $newTransformedPosition" } + viewState.transformedSelection = minOf(selectionTransformedStart, newTransformedPosition) until maxOf(selectionTransformedStart, newTransformedPosition) + viewState.updateSelectionByTransformedSelection(transformedText) + } else { + viewState.transformedSelectionStart = viewState.transformedCursorIndex + viewState.transformedSelection = IntRange.EMPTY + } + } + val tv = remember { TextFieldValue() } // this value is not used LaunchedEffect(transformedText) { @@ -829,6 +866,7 @@ private fun CoreBigMonospaceText( viewState.updateSelectionByTransformedSelection(transformedText) } else { viewState.transformedSelection = IntRange.EMPTY + viewState.selection = EMPTY_SELECTION_RANGE // focusRequester.freeFocus() } @@ -959,16 +997,12 @@ private fun CoreBigMonospaceText( /* text navigation */ (currentOS() == MacOS && it.isMetaPressed && it.key == Key.DirectionUp) || (currentOS() != MacOS && it.isCtrlPressed && it.key == Key.MoveHome) -> { - viewState.cursorIndex = 0 // TODO scroll to new position - viewState.updateTransformedCursorIndexByOriginal(transformedText) - viewState.transformedSelectionStart = viewState.transformedCursorIndex + updateOriginalCursorOrSelection(newPosition = 0, isSelection = it.isShiftPressed) true } (currentOS() == MacOS && it.isMetaPressed && it.key == Key.DirectionDown) || (currentOS() != MacOS && it.isCtrlPressed && it.key == Key.MoveEnd) -> { - viewState.cursorIndex = text.length // TODO scroll to new position - viewState.updateTransformedCursorIndexByOriginal(transformedText) - viewState.transformedSelectionStart = viewState.transformedCursorIndex + updateOriginalCursorOrSelection(newPosition = text.length, isSelection = it.isShiftPressed) true } (currentOS() == MacOS && it.isMetaPressed && it.key in listOf(Key.DirectionLeft, Key.DirectionRight)) || @@ -988,9 +1022,10 @@ private fun CoreBigMonospaceText( transformedText.length } } - viewState.transformedCursorIndex = newTransformedPosition - viewState.updateCursorIndexByTransformed(transformedText) - viewState.transformedSelectionStart = viewState.transformedCursorIndex + updateTransformedCursorOrSelection( + newTransformedPosition = newTransformedPosition, + isSelection = it.isShiftPressed, + ) true } it.key == Key.DirectionLeft && ( @@ -998,9 +1033,7 @@ private fun CoreBigMonospaceText( (currentOS() != MacOS && it.isCtrlPressed) ) -> { val newPosition = findPreviousWordBoundaryPositionFromCursor() - viewState.cursorIndex = newPosition - viewState.updateTransformedCursorIndexByOriginal(transformedText) - viewState.transformedSelectionStart = viewState.transformedCursorIndex + updateOriginalCursorOrSelection(newPosition = newPosition, isSelection = it.isShiftPressed) true } it.key == Key.DirectionRight && ( @@ -1008,23 +1041,22 @@ private fun CoreBigMonospaceText( (currentOS() != MacOS && it.isCtrlPressed) ) -> { val newPosition = findNextWordBoundaryPositionFromCursor() - viewState.cursorIndex = newPosition - viewState.updateTransformedCursorIndexByOriginal(transformedText) - viewState.transformedSelectionStart = viewState.transformedCursorIndex + updateOriginalCursorOrSelection(newPosition = newPosition, isSelection = it.isShiftPressed) true } it.key in listOf(Key.DirectionLeft, Key.DirectionRight) -> { val delta = if (it.key == Key.DirectionRight) 1 else -1 - viewState.transformedSelection = IntRange.EMPTY // TODO handle Shift key if (viewState.transformedCursorIndex + delta in 0 .. transformedText.length) { - viewState.transformedCursorIndex += delta - if (delta > 0) { - viewState.roundTransformedCursorIndex(CursorAdjustDirection.Forward, transformedText, viewState.transformedCursorIndex - delta, false) + var newTransformedPosition = viewState.transformedCursorIndex + delta + newTransformedPosition = if (delta > 0) { + viewState.roundedTransformedCursorIndex(newTransformedPosition, CursorAdjustDirection.Forward, transformedText, viewState.transformedCursorIndex /* FIXME IndexOutOfBoundsException */, false) } else { - viewState.roundTransformedCursorIndex(CursorAdjustDirection.Backward, transformedText, viewState.transformedCursorIndex, true) + viewState.roundedTransformedCursorIndex(newTransformedPosition, CursorAdjustDirection.Backward, transformedText, newTransformedPosition, true) } - viewState.updateCursorIndexByTransformed(transformedText) - viewState.transformedSelectionStart = viewState.transformedCursorIndex + updateTransformedCursorOrSelection( + newTransformedPosition = newTransformedPosition, + isSelection = it.isShiftPressed, + ) log.v { "set cursor pos LR => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } } true @@ -1033,8 +1065,7 @@ private fun CoreBigMonospaceText( // val row = layoutResult.rowStartCharIndices.binarySearchForMaxIndexOfValueAtMost(viewState.transformedCursorIndex) val row = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) val newRow = row + if (it.key == Key.DirectionDown) 1 else -1 - viewState.transformedSelection = IntRange.EMPTY // TODO handle Shift key - viewState.transformedCursorIndex = Unit.let { + var newTransformedPosition = Unit.let { if (newRow < 0) { 0 } else if (newRow > transformedText.lastRowIndex) { @@ -1053,9 +1084,11 @@ private fun CoreBigMonospaceText( } } } - viewState.roundTransformedCursorIndex(CursorAdjustDirection.Bidirectional, transformedText, viewState.transformedCursorIndex, true) - viewState.updateCursorIndexByTransformed(transformedText) - viewState.transformedSelectionStart = viewState.transformedCursorIndex + newTransformedPosition = viewState.roundedTransformedCursorIndex(newTransformedPosition, CursorAdjustDirection.Bidirectional, transformedText, viewState.transformedCursorIndex, true) + updateTransformedCursorOrSelection( + newTransformedPosition = newTransformedPosition, + isSelection = it.isShiftPressed, + ) true } else -> false From 64ad84f78463c7aa0ec1533afb18431b0e14792e Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 2 Nov 2024 15:01:17 +0800 Subject: [PATCH 153/195] add scrolling to BigMonospaceText navigation --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 27 ++++++++++++++++++- .../ux/bigtext/BigTextLayoutResult.kt | 6 +++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index ac0e051a..673105eb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -78,6 +78,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import co.touchlab.kermit.Severity +import com.sunnychung.application.multiplatform.hellohttp.extension.contains import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.isCtrlOrCmdPressed import com.sunnychung.application.multiplatform.hellohttp.extension.toTextInput @@ -296,6 +297,8 @@ private fun CoreBigMonospaceText( // } // } + var layoutResult by remember(textLayouter, width) { mutableStateOf(null) } + val transformedText: BigTextTransformed = remember(text, textTransformation) { log.d { "CoreBigMonospaceText recreate BigTextTransformed" } BigTextTransformerImpl(text).also { @@ -317,7 +320,7 @@ private fun CoreBigMonospaceText( // text = text, text = transformedText, // layout is only performed in `transformedText` rowHeight = lineHeight, - )) + ).also { layoutResult = it }) } } @@ -739,6 +742,26 @@ private fun CoreBigMonospaceText( return viewState.cursorIndex + wordBoundaryAt } + fun scrollToCursor() { + val layoutResult = layoutResult ?: return + + // scroll to cursor position if out of visible range + val visibleVerticalRange = scrollState.value .. scrollState.value + height + val row = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) + val rowVerticalRange = layoutResult.getTopOfRow(row).toInt() .. layoutResult.getBottomOfRow(row).toInt() + if (rowVerticalRange !in visibleVerticalRange) { + val scrollToPosition = if (rowVerticalRange.start < visibleVerticalRange.start) { + rowVerticalRange.start + } else { + // scroll to a position that includes the bottom of the row + a little space + minOf(layoutResult.bottom.toInt(), maxOf(0, rowVerticalRange.endInclusive + layoutResult.rowHeight.toInt() - height)) + } + coroutineScope.launch { + scrollState.animateScrollTo(scrollToPosition) + } + } + } + fun updateOriginalCursorOrSelection(newPosition: Int, isSelection: Boolean) { val oldCursorPosition = viewState.cursorIndex viewState.cursorIndex = newPosition // TODO scroll to new position @@ -755,6 +778,7 @@ private fun CoreBigMonospaceText( viewState.transformedSelectionStart = viewState.transformedCursorIndex viewState.transformedSelection = IntRange.EMPTY } + scrollToCursor() } fun updateTransformedCursorOrSelection(newTransformedPosition: Int, isSelection: Boolean) { @@ -774,6 +798,7 @@ private fun CoreBigMonospaceText( viewState.transformedSelectionStart = viewState.transformedCursorIndex viewState.transformedSelection = IntRange.EMPTY } + scrollToCursor() } val tv = remember { TextFieldValue() } // this value is not used diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt index 1d79cdc3..692a78b4 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextLayoutResult.kt @@ -34,4 +34,10 @@ class BigTextSimpleLayoutResult( ) { fun getTopOfRow(rowIndex: Int): Float = rowIndex * rowHeight fun getBottomOfRow(rowIndex: Int): Float = (rowIndex + 1) * rowHeight + + val top: Float + get() = 0f + + val bottom: Float + get() = getBottomOfRow(text.lastRowIndex) } From b28e48ebeef3181a7957a2d1d775164c02c52f22 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 2 Nov 2024 15:13:21 +0800 Subject: [PATCH 154/195] fix tests cannot load lib again --- .../application/multiplatform/hellohttp/Main.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt index b4a800d0..3a86abb1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt @@ -174,10 +174,14 @@ fun loadNativeLibraries() { println("Loading native lib $libFileName") val dest = File(File(AppContext.dataDir, "lib"), libFileName) dest.parentFile.mkdirs() - enclosingClazz.javaClass.classLoader.getResourceAsStream(libFileName).use { `is` -> - `is` ?: throw RuntimeException("Lib $libFileName not found") - FileOutputStream(dest).use { os -> - `is`.copyTo(os) + if (!dest.canWrite()) { + println("Warning: ${dest.path} is not writable. Skip exporting lib.") + } else { + enclosingClazz.javaClass.classLoader.getResourceAsStream(libFileName).use { `is` -> + `is` ?: throw RuntimeException("Lib $libFileName not found") + FileOutputStream(dest).use { os -> + `is`.copyTo(os) + } } } System.load(dest.absolutePath) From f1096280d3523e21307e56a5d42dc1680e1d1b55 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 2 Nov 2024 15:25:08 +0800 Subject: [PATCH 155/195] update scrolling parameter --- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 673105eb..c12bc246 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -754,7 +754,7 @@ private fun CoreBigMonospaceText( rowVerticalRange.start } else { // scroll to a position that includes the bottom of the row + a little space - minOf(layoutResult.bottom.toInt(), maxOf(0, rowVerticalRange.endInclusive + layoutResult.rowHeight.toInt() - height)) + minOf(layoutResult.bottom.toInt(), maxOf(0, rowVerticalRange.endInclusive + maxOf(2, (layoutResult.rowHeight * 0.5).toInt()) - height)) } coroutineScope.launch { scrollState.animateScrollTo(scrollToPosition) From a60f50b45cc3dc8bf093055dd5de52dca7263467 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 2 Nov 2024 15:49:57 +0800 Subject: [PATCH 156/195] fix test server ssl dependencies --- test-server/build.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test-server/build.gradle.kts b/test-server/build.gradle.kts index e7d7fc1a..ec9c65be 100644 --- a/test-server/build.gradle.kts +++ b/test-server/build.gradle.kts @@ -49,6 +49,10 @@ dependencies { implementation("io.grpc:grpc-protobuf:$grpcVersion") implementation("io.grpc:grpc-kotlin-stub:$grpcKotlinVersion") implementation("com.google.protobuf:protobuf-kotlin:$protocVersion") + + // for ssl/mtls + implementation("org.bouncycastle:bcprov-jdk18on:1.79") + implementation("org.bouncycastle:bcpkix-jdk18on:1.79") } tasks.withType { From 51030e9e696bffd5ee61bd4be2cfc68325e7455f Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 2 Nov 2024 16:06:34 +0800 Subject: [PATCH 157/195] fix tests cannot load native lib --- .../com/sunnychung/application/multiplatform/hellohttp/Main.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt index 3a86abb1..5bd33935 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt @@ -174,7 +174,7 @@ fun loadNativeLibraries() { println("Loading native lib $libFileName") val dest = File(File(AppContext.dataDir, "lib"), libFileName) dest.parentFile.mkdirs() - if (!dest.canWrite()) { + if (dest.isFile && !dest.canWrite()) { println("Warning: ${dest.path} is not writable. Skip exporting lib.") } else { enclosingClazz.javaClass.classLoader.getResourceAsStream(libFileName).use { `is` -> From e9b2eb49b18e05898850231cafef0a1bdadcfb73 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 2 Nov 2024 16:39:31 +0800 Subject: [PATCH 158/195] fix tests cannot load native lib --- .../sunnychung/application/multiplatform/hellohttp/Main.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt index 5bd33935..a877ed89 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/Main.kt @@ -41,6 +41,7 @@ import net.harawata.appdirs.AppDirsFactory import java.awt.Dimension import java.io.File import java.io.FileOutputStream +import java.io.IOException import java.util.concurrent.atomic.AtomicInteger import kotlin.system.exitProcess @@ -174,15 +175,15 @@ fun loadNativeLibraries() { println("Loading native lib $libFileName") val dest = File(File(AppContext.dataDir, "lib"), libFileName) dest.parentFile.mkdirs() - if (dest.isFile && !dest.canWrite()) { - println("Warning: ${dest.path} is not writable. Skip exporting lib.") - } else { + try { enclosingClazz.javaClass.classLoader.getResourceAsStream(libFileName).use { `is` -> `is` ?: throw RuntimeException("Lib $libFileName not found") FileOutputStream(dest).use { os -> `is`.copyTo(os) } } + } catch (e: IOException) { + println("Warning: ${dest.path} is not writable. Skip exporting lib. Exception: ${e.message}") } System.load(dest.absolutePath) } From 4f3358dd4258062c9f8ddfc82f52ab7228cf9e93 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 2 Nov 2024 18:45:41 +0800 Subject: [PATCH 159/195] refactor to remove VisualTransformation from CodeEditorView and BigMonospaceText --- .../hellohttp/model/SyntaxHighlight.kt | 5 + .../hellohttp/ux/CodeEditorView.kt | 138 +++++++----------- .../hellohttp/ux/KotliteCodeEditorView.kt | 10 +- .../hellohttp/ux/RequestEditorView.kt | 23 ++- .../hellohttp/ux/ResponseViewerView.kt | 10 +- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 22 --- 6 files changed, 72 insertions(+), 136 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/SyntaxHighlight.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/SyntaxHighlight.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/SyntaxHighlight.kt new file mode 100644 index 00000000..3f5691c3 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/SyntaxHighlight.kt @@ -0,0 +1,5 @@ +package com.sunnychung.application.multiplatform.hellohttp.model + +enum class SyntaxHighlight { + None, Json, Graphql, Kotlin +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 8fe85df3..fb883820 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -52,7 +52,6 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize @@ -62,10 +61,12 @@ import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryAp import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForInsertionPoint import com.sunnychung.application.multiplatform.hellohttp.extension.contains import com.sunnychung.application.multiplatform.hellohttp.extension.insert +import com.sunnychung.application.multiplatform.hellohttp.model.SyntaxHighlight import com.sunnychung.application.multiplatform.hellohttp.util.TreeRangeMaps import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult @@ -79,15 +80,11 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDe 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 -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.CollapseTransformation -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.EnvironmentVariableTransformation -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.FunctionTransformation -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.MultipleVisualTransformation -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.SearchHighlightTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.CollapseIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.EnvironmentVariableIncrementalTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.FunctionIncrementalTransformation +import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.GraphqlSyntaxHighlightDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.JsonSyntaxHighlightDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.KotlinSyntaxHighlightSlowDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleIncrementalTransformation @@ -115,7 +112,7 @@ fun CodeEditorView( collapsableLines: List = emptyList(), collapsableChars: List = emptyList(), textColor: Color = LocalColor.current.text, - transformations: List = emptyList(), + syntaxHighlight: SyntaxHighlight, isEnableVariables: Boolean = false, knownVariables: Set = setOf(), testTag: String? = null, @@ -340,23 +337,41 @@ fun CodeEditorView( "" } - var visualTransformations = transformations + - if (isEnableVariables) { - listOf( - EnvironmentVariableTransformation( - themeColors = themeColours, - knownVariables = knownVariables - ), - FunctionTransformation(themeColours), - ) - } else { - emptyList() - } + - if (isReadOnly) { - listOf(CollapseTransformation(themeColours, collapsedChars.values.toList())) - } else { - emptyList() - } + val variableTransformations = remember(bigTextFieldState, themeColours, isEnableVariables) { + if (isEnableVariables) { + listOf( + EnvironmentVariableIncrementalTransformation(), + FunctionIncrementalTransformation(themeColours) + ) + } else { + emptyList() + } + } + + val variableDecorators = remember(bigTextFieldState, themeColours, isEnableVariables, knownVariables) { + if (isEnableVariables) { + listOf( + EnvironmentVariableDecorator(themeColours, knownVariables), + ) + } else { + emptyList() + } + } + + val syntaxHighlightDecorators = rememberLast(bigTextFieldState, themeColours) { + when (syntaxHighlight) { + SyntaxHighlight.None -> emptyList() + SyntaxHighlight.Json -> listOf(JsonSyntaxHighlightDecorator(themeColours)) + SyntaxHighlight.Graphql -> listOf(GraphqlSyntaxHighlightDecorator(themeColours)) + SyntaxHighlight.Kotlin -> listOf(KotlinSyntaxHighlightSlowDecorator(themeColours)) + } + } + + val searchDecorators = rememberLast(bigTextFieldState, themeColours, searchResultRangeTree, searchResultViewIndex) { + listOf( + SearchHighlightDecorator(searchResultRangeTree ?: TreeRangeMap.create(), searchResultViewIndex, themeColours), + ) + } textLayoutResult?.let { tl -> (0..minOf(10, tl.lineCount - 1)).forEach { @@ -365,14 +380,6 @@ fun CodeEditorView( } if (isSearchVisible) { - if (!searchResultRanges.isNullOrEmpty() && searchPattern != null) { - visualTransformations += SearchHighlightTransformation( - searchPattern = searchPattern!!, - currentIndex = searchResultViewIndex, - colours = themeColours, - ) - } - if (lastSearchResultViewIndex != searchResultViewIndex && layoutResult != null && textFieldSize != null && searchResultRanges != null) { lastSearchResultViewIndex = searchResultViewIndex searchResultRanges!!.getOrNull(searchResultViewIndex)?.start?.let { position -> @@ -403,32 +410,6 @@ fun CodeEditorView( searchResultViewIndex = (searchResultViewIndex - 1 + size) % size } - val visualTransformationToUse = visualTransformations.let { - if (newText.length > 1 * 1024 * 1024 /* 1 MB */) { - // disable all styles to avoid hanging - return@let VisualTransformation.None - } - if (it.size > 1) { - MultipleVisualTransformation(it) - } else if (it.size == 1) { - it.first() - } else { - VisualTransformation.None - } - } - - log.d { "lineTops ${lineTops != null}, textLayoutResult ${textLayoutResult != null}" } - - if (lineTops == null && textLayoutResult != null) { - log.d { "lineTops recalc start" } - val charOffsetMapping = visualTransformationToUse.filter(AnnotatedString(textValue.text)).offsetMapping - val lineOffsets = listOf(0) + "\n".toRegex().findAll(textValue.text).map { charOffsetMapping.originalToTransformed(it.range.endInclusive + 1) } - log.v { "lineOffsets = $lineOffsets" } - lineTops = lineOffsets.map { textLayoutResult!!.getLineTop(textLayoutResult!!.getLineForOffset(it)) } + // O(l * L * 1) - (Float.POSITIVE_INFINITY) - log.d { "lineTops recalc end" } - } - Column(modifier = modifier.onPreviewKeyEvent { if (it.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false if (it.key == Key.F && (it.isMetaPressed || it.isCtrlPressed)) { @@ -522,11 +503,6 @@ fun CodeEditorView( modifier = Modifier.fillMaxHeight(), ) - val syntaxHighlightDecorator = rememberLast(bigTextFieldState, themeColours) { - JsonSyntaxHighlightDecorator(themeColours) -// KotlinSyntaxHighlightSlowDecorator(themeColours) - } - if (isReadOnly) { val collapseIncrementalTransformation = remember(bigTextFieldState) { CollapseIncrementalTransformation(themeColours, collapsedChars.values.toList()) @@ -540,19 +516,15 @@ fun CodeEditorView( BigMonospaceText( text = bigTextValue as BigTextImpl, padding = PaddingValues(4.dp), - visualTransformation = visualTransformationToUse, - textTransformation = rememberLast(bigTextFieldState) { + textTransformation = rememberLast(bigTextFieldState, collapseIncrementalTransformation) { MultipleIncrementalTransformation(listOf( -// JsonSyntaxHighlightIncrementalTransformation(themeColours), collapseIncrementalTransformation, )) }, - textDecorator = rememberLast(bigTextFieldState, themeColours, searchResultRangeTree, searchResultViewIndex) { - MultipleTextDecorator(listOf( - syntaxHighlightDecorator, - SearchHighlightDecorator(searchResultRangeTree ?: TreeRangeMap.create(), searchResultViewIndex, themeColours), - )) - }, + textDecorator = //rememberLast(bigTextFieldState, syntaxHighlightDecorators, searchDecorators) { + MultipleTextDecorator(syntaxHighlightDecorators + searchDecorators) + //}, + , fontSize = LocalFont.current.codeEditorBodyFontSize, isSelectable = true, scrollState = scrollState, @@ -660,21 +632,15 @@ fun CodeEditorView( BigMonospaceTextField( textFieldState = bigTextFieldState, - visualTransformation = visualTransformationToUse, - textTransformation = remember { - MultipleIncrementalTransformation(listOf( -// JsonSyntaxHighlightIncrementalTransformation(themeColours), - EnvironmentVariableIncrementalTransformation(), - FunctionIncrementalTransformation(themeColours) - )) - }, // TODO replace this testing transformation - textDecorator = rememberLast(bigTextFieldState, themeColours, knownVariables, searchResultRangeTree, searchResultViewIndex) { - MultipleTextDecorator(listOf( - syntaxHighlightDecorator, - EnvironmentVariableDecorator(themeColours, knownVariables), - SearchHighlightDecorator(searchResultRangeTree ?: TreeRangeMap.create(), searchResultViewIndex, themeColours), - )) + textTransformation = remember(variableTransformations) { + MultipleIncrementalTransformation( + variableTransformations + ) }, + textDecorator = //rememberLast(bigTextFieldState, themeColours, searchResultRangeTree, searchResultViewIndex, syntaxHighlightDecorator) { + MultipleTextDecorator(syntaxHighlightDecorators + variableDecorators + searchDecorators) + //}, + , fontSize = LocalFont.current.codeEditorBodyFontSize, // textStyle = LocalTextStyle.current.copy( // fontFamily = FontFamily.Monospace, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt index 0dc68f73..6cccee7f 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KotliteCodeEditorView.kt @@ -3,9 +3,8 @@ package com.sunnychung.application.multiplatform.hellohttp.ux import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.VisualTransformation +import com.sunnychung.application.multiplatform.hellohttp.model.SyntaxHighlight import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.KotlinSyntaxHighlightTransformation @Composable fun KotliteCodeEditorView( @@ -14,7 +13,6 @@ fun KotliteCodeEditorView( isEnabled: Boolean = true, text: String, onTextChange: ((String) -> Unit)? = null, - transformations: List = emptyList(), testTag: String? = null, ) { val textColor: Color = if (isEnabled) { @@ -28,11 +26,7 @@ fun KotliteCodeEditorView( text = text, onTextChange = onTextChange, textColor = textColor, - transformations = transformations + if (isEnabled) { - listOf(KotlinSyntaxHighlightTransformation(LocalColor.current)) - } else { - emptyList() - }, + syntaxHighlight = if (isEnabled) SyntaxHighlight.Kotlin else SyntaxHighlight.None, testTag = testTag, ) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt index 4c2786ae..670ee6ee 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt @@ -50,7 +50,6 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -65,6 +64,7 @@ import com.sunnychung.application.multiplatform.hellohttp.model.MultipartBody import com.sunnychung.application.multiplatform.hellohttp.model.PayloadExample import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication import com.sunnychung.application.multiplatform.hellohttp.model.StringBody +import com.sunnychung.application.multiplatform.hellohttp.model.SyntaxHighlight import com.sunnychung.application.multiplatform.hellohttp.model.UserGrpcRequest import com.sunnychung.application.multiplatform.hellohttp.model.UserKeyValuePair import com.sunnychung.application.multiplatform.hellohttp.model.UserRequestExample @@ -84,8 +84,6 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLas import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalFont import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.EnvironmentVariableTransformation -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.GraphqlQuerySyntaxHighlightTransformation -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.JsonSyntaxHighlightTransformation import com.sunnychung.application.multiplatform.hellohttp.ux.viewmodel.EditNameViewModel import com.sunnychung.application.multiplatform.hellohttp.ux.viewmodel.rememberFileDialogState import graphql.language.OperationDefinition @@ -1133,11 +1131,7 @@ private fun RequestBodyEditor( body = StringBody(it) ) }, - transformations = if (selectedContentType == ContentType.Json) { - listOf(JsonSyntaxHighlightTransformation(colours = colors)) - } else { - emptyList() - }, + syntaxHighlight = if (selectedContentType == ContentType.Json) SyntaxHighlight.Json else SyntaxHighlight.None, modifier = remainModifier, ) } @@ -1246,7 +1240,7 @@ private fun RequestBodyEditor( ) ) }, - transformations = listOf(GraphqlQuerySyntaxHighlightTransformation(colours = colors)), + syntaxHighlight = SyntaxHighlight.Graphql, testTag = TestTag.RequestGraphqlDocumentTextField.name, modifier = Modifier.fillMaxWidth().weight(0.62f), ) @@ -1280,7 +1274,7 @@ private fun RequestBodyEditor( ) ) }, - transformations = listOf(JsonSyntaxHighlightTransformation(colours = colors)), // FIXME + syntaxHighlight = SyntaxHighlight.Json, testTag = TestTag.RequestGraphqlVariablesTextField.name, modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 200.dp).weight(0.38f), ) @@ -1330,7 +1324,7 @@ private fun RequestBodyTextEditor( overridePredicate: (UserRequestExample.Overrides?) -> Boolean, translateToText: (UserRequestExample) -> String?, translateTextChangeToNewUserRequestExample: (String) -> UserRequestExample, - transformations: List, + syntaxHighlight: SyntaxHighlight, testTag: String? = null, ) { val colors = LocalColor.current @@ -1352,7 +1346,7 @@ private fun RequestBodyTextEditor( ) ) }, - transformations = transformations, + syntaxHighlight = syntaxHighlight, testTag = testTag ?: TestTag.RequestStringBodyTextField.name, ) } else { @@ -1363,7 +1357,8 @@ private fun RequestBodyTextEditor( knownVariables = environmentVariableKeys, text = translateToText(baseExample) ?: "", onTextChange = {}, - textColor = colors.placeholder, // intended to have no syntax highlighting + textColor = colors.placeholder, + syntaxHighlight = SyntaxHighlight.None // intended to have no syntax highlighting ) } } @@ -1560,7 +1555,7 @@ fun StreamingPayloadEditorView( ) ) }, - transformations = listOf(JsonSyntaxHighlightTransformation(colours = colors)), + syntaxHighlight = SyntaxHighlight.Json, testTag = TestTag.RequestPayloadTextField.name, ) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt index 8b7baa4d..9ccce814 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt @@ -55,6 +55,7 @@ import com.sunnychung.application.multiplatform.hellohttp.model.PayloadMessage import com.sunnychung.application.multiplatform.hellohttp.model.PrettifyResult import com.sunnychung.application.multiplatform.hellohttp.model.ProtocolApplication import com.sunnychung.application.multiplatform.hellohttp.model.RawExchange +import com.sunnychung.application.multiplatform.hellohttp.model.SyntaxHighlight import com.sunnychung.application.multiplatform.hellohttp.model.UserResponse import com.sunnychung.application.multiplatform.hellohttp.model.describeApplicationLayer import com.sunnychung.application.multiplatform.hellohttp.model.hasSomethingToCopy @@ -64,7 +65,6 @@ import com.sunnychung.application.multiplatform.hellohttp.util.log 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 -import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.JsonSyntaxHighlightTransformation import com.sunnychung.lib.multiplatform.kdatetime.KDateTimeFormat import com.sunnychung.lib.multiplatform.kdatetime.KDuration import com.sunnychung.lib.multiplatform.kdatetime.KFixedTimeUnit @@ -545,11 +545,7 @@ fun BodyViewerView( text = prettifyResult.prettyString, collapsableLines = prettifyResult.collapsableLineRange, collapsableChars = prettifyResult.collapsableCharRange, - transformations = if (selectedView.prettifier!!.formatName.contains("JSON")) { - listOf(JsonSyntaxHighlightTransformation(colours = colours)) - } else { - emptyList() - }, + syntaxHighlight = if (selectedView.prettifier!!.formatName.contains("JSON")) SyntaxHighlight.Json else SyntaxHighlight.None, testTag = TestTag.ResponseBody.name, ) } @@ -560,6 +556,7 @@ fun BodyViewerView( isReadOnly = true, text = text, textColor = colours.warning, + syntaxHighlight = SyntaxHighlight.None, testTag = TestTag.ResponseError.name, ) } @@ -632,6 +629,7 @@ fun ResponseBodyView(response: UserResponse) { isReadOnly = true, text = response.postFlightErrorMessage ?: "", textColor = LocalColor.current.warning, + syntaxHighlight = SyntaxHighlight.None, modifier = Modifier.fillMaxWidth().height(100.dp), ) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index c12bc246..d8f8a4ec 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -73,7 +73,6 @@ import androidx.compose.ui.text.input.CommitTextCommand import androidx.compose.ui.text.input.ImeOptions import androidx.compose.ui.text.input.SetComposingTextCommand import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp @@ -110,7 +109,6 @@ fun BigMonospaceText( fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, isSelectable: Boolean = false, - visualTransformation: VisualTransformation, textTransformation: IncrementalTextTransformation<*>? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, @@ -124,7 +122,6 @@ fun BigMonospaceText( isSelectable = isSelectable, isEditable = false, onTextChange = {}, - visualTransformation = visualTransformation, textTransformation = textTransformation, scrollState = scrollState, viewState = viewState, @@ -139,7 +136,6 @@ fun BigMonospaceText( fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, isSelectable: Boolean = false, - visualTransformation: VisualTransformation, textTransformation: IncrementalTextTransformation<*>? = null, textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), @@ -155,7 +151,6 @@ fun BigMonospaceText( isSelectable = isSelectable, isEditable = false, onTextChange = {}, - visualTransformation = visualTransformation, textTransformation = textTransformation, textDecorator = textDecorator, scrollState = scrollState, @@ -171,7 +166,6 @@ fun BigMonospaceTextField( padding: PaddingValues = PaddingValues(4.dp), fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, - visualTransformation: VisualTransformation, textTransformation: IncrementalTextTransformation<*>? = null, textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), @@ -186,7 +180,6 @@ fun BigMonospaceTextField( onTextChange = { textFieldState.emitValueChange(it.changeId) }, - visualTransformation = visualTransformation, textTransformation = textTransformation, textDecorator = textDecorator, scrollState = scrollState, @@ -203,7 +196,6 @@ fun BigMonospaceTextField( fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, onTextChange: (BigTextChangeEvent) -> Unit, - visualTransformation: VisualTransformation, textTransformation: IncrementalTextTransformation<*>? = null, textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), @@ -218,7 +210,6 @@ fun BigMonospaceTextField( isSelectable = true, isEditable = true, onTextChange = onTextChange, - visualTransformation = visualTransformation, textTransformation = textTransformation, textDecorator = textDecorator, scrollState = scrollState, @@ -237,7 +228,6 @@ private fun CoreBigMonospaceText( isSelectable: Boolean = false, isEditable: Boolean = false, onTextChange: (BigTextChangeEvent) -> Unit, - visualTransformation: VisualTransformation, textTransformation: IncrementalTextTransformation<*>? = null, textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), @@ -352,13 +342,6 @@ private fun CoreBigMonospaceText( } } -// val visualTransformationToUse = visualTransformation -// val transformedText = rememberLast(text.length, text.hashCode(), visualTransformationToUse) { -// visualTransformationToUse.filter(AnnotatedString(text.buildString())).also { -// log.v { "transformed text = `$it`" } -// } -// } - // val layoutResult = rememberLast(transformedText.text.length, transformedText.hashCode(), textStyle, lineHeight, contentWidth, textLayouter) { // textLayouter.layout( // text = text.fullString(), @@ -389,11 +372,6 @@ private fun CoreBigMonospaceText( } } -// rememberLast(viewState.selection.start, viewState.selection.last, visualTransformation) { -// viewState.transformedSelection = transformedText.offsetMapping.originalToTransformed(viewState.selection.start) .. -// transformedText.offsetMapping.originalToTransformed(viewState.selection.last) -// } - val transformedState = remember(text, textTransformation) { log.v { "CoreBigMonospaceText text = |${text.buildString()}|" } if (textTransformation != null) { From d358b1be78ef93e66076e986b284ef1f3a1ddf52 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 2 Nov 2024 21:51:51 +0800 Subject: [PATCH 160/195] fix the text input to 2nd text field would clear the 1st text field in GraphQL and gRPC UX tests --- .../application/multiplatform/hellohttp/ux/CodeEditorView.kt | 4 +++- .../multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt | 2 +- .../multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index fb883820..22d5e386 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -74,6 +74,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextSimp import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformed 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.rememberAnnotatedBigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldColors import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDefaults @@ -615,13 +616,14 @@ fun CodeEditorView( // var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(text)) } // FIXME performance - LaunchedEffect(bigTextFieldState) { + LaunchedEffect(bigTextFieldState, onTextChange) { bigTextFieldState.valueChangesFlow .debounce(200.milliseconds().toMilliseconds()) .collect { log.d { "bigTextFieldState change ${it.changeId}" } onTextChange?.let { onTextChange -> val string = it.bigText.buildCharSequence() as AnnotatedString + log.v { "${bigTextFieldState.text} : ${it.bigText} onTextChange(${string.text.abbr()})" } onTextChange(string.text) secondCacheKey.value = string.text } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index d8f8a4ec..40dceff3 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -547,7 +547,7 @@ private fun CoreBigMonospaceText( } fun onType(textInput: String) { - log.v { "key in '$textInput'" } + log.v { "$text key in '$textInput' ${viewState.hasSelection()}" } if (viewState.hasSelection()) { deleteSelection(isSaveUndoSnapshot = false) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt index 34480891..bd20627c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt @@ -54,7 +54,7 @@ fun rememberAnnotatedBigTextFieldState(initialValue: String = ""): Pair 20) { substring(0 .. 19) } else { From a323f28407acb73d8b4f86267bf09f0ec6e8eea2 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 2 Nov 2024 23:23:09 +0800 Subject: [PATCH 161/195] fix incorrect implementation of BigTextTransformerImpl#findFirstRowIndexByOriginalLineIndex, leading to wrong line number display after inputting a '\n' after a long line --- .../ux/bigtext/BigTextTransformerImpl.kt | 4 +-- .../transform/BigTextTransformerLayoutTest.kt | 36 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt index 96d2ea19..192edbdb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextTransformerImpl.kt @@ -751,10 +751,10 @@ class BigTextTransformerImpl(internal val delegate: BigTextImpl) : BigTextImpl( val lineOffsets = originalNode.value.buffer.lineOffsetStarts val lineOffsetStartIndex = lineOffsets.binarySearchForMinIndexOfValueAtLeast(originalNode.value.bufferOffsetStart) require(lineOffsetStartIndex >= 0) - lineOffsets[lineOffsetStartIndex + lineBreakIndex] - originalNode.value.bufferOffsetStart + lineOffsets[lineOffsetStartIndex + lineBreakIndex] - originalNode.value.bufferOffsetStart + /* find the position just after the '\n' char */ 1 } else { 0 - } + 1 + } val transformedPosition = findTransformedPositionByOriginalPosition(lineOriginalPositionStart) return findRowIndexByPosition(transformedPosition) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt index 9e79cfdb..f023ae59 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/transform/BigTextTransformerLayoutTest.kt @@ -644,7 +644,7 @@ class BigTextTransformerLayoutTest { @ParameterizedTest @ValueSource(ints = [256, 64, 16, 65536, 1 * 1024 * 1024]) - fun findFirstRowIndexByOriginalLineIndex(chunkSize: Int) { + fun findFirstRowIndexByOriginalLineIndex1(chunkSize: Int) { listOf(100, 10, 37, 1000, 10000).forEach { softWrapAt -> val t = BigTextImpl(chunkSize = chunkSize).apply { append("{\"a\":\"bcd\${{abc\nde}}ef}\"}\n\n\${{asd\n\nf}}\n\n1234567890223456789032345678904234567890\n\n") @@ -668,6 +668,40 @@ class BigTextTransformerLayoutTest { } } + @ParameterizedTest + @ValueSource(ints = [43, 16, 200]) + fun findFirstRowIndexByOriginalLineIndex2(softWrapAt: Int) { + val initial = "{\n" + + " \"asffgsdfggsdgsdgsdsdh\": [\"adfd\", \"Sgfsfg\", \"sfgdsfg\", [\"gsafg\", \"sfg\", \"kkk\"]]\n" + + "}" + + val t = BigTextImpl(chunkSize = 256).apply { + append(initial) + } + val tt = BigTextTransformerImpl(t).apply { + setLayouter(MonospaceTextLayouter(FixedWidthCharMeasurer(16f))) + setContentWidth(16f * softWrapAt + 1.23f) + } + val expectedRowPosStarts0 = when (softWrapAt) { + 43 -> listOf(0, 1, 3) + 16 -> listOf(0, 1, 7) + else -> listOf(0, 1, 2) + } + expectedRowPosStarts0.forEachIndexed { i, expected -> + assertEquals(expected, tt.findFirstRowIndexByOriginalLineIndex(i), "before change. softWrapAt $softWrapAt, line $i") + } + + t.insertAt(84, "\n") + val expectedRowPosStarts = when (softWrapAt) { + 43 -> listOf(0, 1, 3, 4) + 16 -> listOf(0, 1, 7, 8) + else -> listOf(0, 1, 2, 3) + } + expectedRowPosStarts.forEachIndexed { i, expected -> + assertEquals(expected, tt.findFirstRowIndexByOriginalLineIndex(i), "after change. softWrapAt $softWrapAt, line $i") + } + } + @ParameterizedTest @ValueSource(ints = [1048576, 64, 16]) fun deleteOriginal(chunkSize: Int) { From b10ad04eeb6aec34f3b9df204e4076f93c777f0f Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 3 Nov 2024 10:16:49 +0800 Subject: [PATCH 162/195] fix the flow collecting BigMonospaceText changes never runs after fast typing and switching to another Payload which causes cancellation of the flow --- .../hellohttp/util/TimeChunkedLatestFlow.kt | 70 +++++++++++ .../hellohttp/ux/CodeEditorView.kt | 7 +- .../test/util/ChunkedLatestFlowTest.kt | 119 ++++++++++++++++++ 3 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TimeChunkedLatestFlow.kt create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TimeChunkedLatestFlow.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TimeChunkedLatestFlow.kt new file mode 100644 index 00000000..b3f9ea1b --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/TimeChunkedLatestFlow.kt @@ -0,0 +1,70 @@ +package com.sunnychung.application.multiplatform.hellohttp.util + +import com.sunnychung.lib.multiplatform.kdatetime.KDuration +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.cancellation.CancellationException + +// Modified from https://blog.shreyaspatil.dev/collecting-items-from-the-flow-in-chunks +private class TimeChunkedLatestFlow( + private val upstream: Flow, + private val duration: KDuration +) : Flow { + override suspend fun collect(collector: FlowCollector) = coroutineScope { + val mutex = Mutex() + + // Holds the un-emitted items + var latestValue: T? = null + var hasValue = false + + // Flag to know the status of upstream flow whether it has been completed or not + var isFlowCompleted = false + + launch { + try { + while (true) { + delay(duration.toMilliseconds()) + mutex.withLock { + // If the upstream flow has been completed and there are no values + // pending to emit in the collector, just break this loop. + if (isFlowCompleted && !hasValue) { + return@launch + } + if (hasValue) { + collector.emit(latestValue!!) + hasValue = false + } + } + } + } catch (e: CancellationException) { + mutex.withLock { + if (hasValue) { + collector.emit(latestValue!!) + hasValue = false + } + } + throw e + } + } + + // Collect the upstream flow and add the items to the above `values` list + upstream.collect { + mutex.withLock { + latestValue = it + hasValue = true + } + } + + // If we reach here it means the upstream flow has been completed and won't + // produce any values anymore. So set the flag as flow is completed so that + // child coroutine will break its loop + isFlowCompleted = true + } +} + +fun Flow.chunkedLatest(duration: KDuration): Flow = TimeChunkedLatestFlow(this, duration) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 22d5e386..eac5c56a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -63,6 +63,7 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.contains import com.sunnychung.application.multiplatform.hellohttp.extension.insert import com.sunnychung.application.multiplatform.hellohttp.model.SyntaxHighlight import com.sunnychung.application.multiplatform.hellohttp.util.TreeRangeMaps +import com.sunnychung.application.multiplatform.hellohttp.util.chunkedLatest import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField @@ -618,12 +619,12 @@ fun CodeEditorView( LaunchedEffect(bigTextFieldState, onTextChange) { bigTextFieldState.valueChangesFlow - .debounce(200.milliseconds().toMilliseconds()) + .chunkedLatest(200.milliseconds()) .collect { - log.d { "bigTextFieldState change ${it.changeId}" } + log.d { "bigTextFieldState change ${it.changeId} ${it.bigText.buildString()}" } onTextChange?.let { onTextChange -> val string = it.bigText.buildCharSequence() as AnnotatedString - log.v { "${bigTextFieldState.text} : ${it.bigText} onTextChange(${string.text.abbr()})" } + log.d { "${bigTextFieldState.text} : ${it.bigText} onTextChange(${string.text.abbr()})" } onTextChange(string.text) secondCacheKey.value = string.text } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt new file mode 100644 index 00000000..d45818d0 --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt @@ -0,0 +1,119 @@ +package com.sunnychung.application.multiplatform.hellohttp.test.util + +import com.sunnychung.application.multiplatform.hellohttp.util.chunkedLatest +import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import java.util.Collections +import kotlin.test.Test +import kotlin.test.assertEquals + +class ChunkedLatestFlowTest { + + @Test + fun receiveOnlyLatestValues() { + runBlocking { + val results = Collections.synchronizedList(mutableListOf()) + + coroutineScope { + flow { + (0..10).forEach { + emit(it) + delay(145) + } + } + .chunkedLatest(500.milliseconds()) + .onEach { results += it } + .launchIn(this) + } + + assertEquals(listOf(3, 6, 10), results) + } + } + + @Test + fun receiveValuesEmittedAtCompletion1() { + runBlocking { + val results = Collections.synchronizedList(mutableListOf()) + + coroutineScope { + flow { + (0..10).forEach { + emit(it) + delay(145) + } + emit(11) + emit(12) + } + .chunkedLatest(500.milliseconds()) + .onEach { results += it } + .launchIn(this) + } + + assertEquals(listOf(3, 6, 10, 12), results) + } + } + + @Test + fun receiveValuesEmittedAtCompletion2() { + runBlocking { + val results = Collections.synchronizedList(mutableListOf()) + + coroutineScope { + flow { + (0..12).forEach { + emit(it) + delay(145) + } + } + .chunkedLatest(500.milliseconds()) + .onEach { results += it } + .launchIn(this) + } + + assertEquals(listOf(3, 6, 10, 12), results) + } + } + + @Test + fun emptyFlow() { + runBlocking { + val results = Collections.synchronizedList(mutableListOf()) + + coroutineScope { + flow { + delay(1000) + } + .chunkedLatest(500.milliseconds()) + .onEach { results += it } + .launchIn(this) + } + + assertEquals(listOf(), results) + } + } + + @Test + fun singleValueWithoutDelay() { + runBlocking { + val results = Collections.synchronizedList(mutableListOf()) + + coroutineScope { + flow { + emit(10) + } + .chunkedLatest(500.milliseconds()) + .onEach { results += it } + .launchIn(this) + } + + assertEquals(listOf(10), results) + } + } +} From 2fb924efa056a6f2e86e6d2f9a728b0266226820 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 3 Nov 2024 10:46:29 +0800 Subject: [PATCH 163/195] add BigTextInputFilter --- .../multiplatform/hellohttp/ux/CodeEditorView.kt | 5 +++++ .../hellohttp/ux/bigtext/BigMonospaceText.kt | 12 +++++++++++- .../hellohttp/ux/bigtext/BigTextInputFilter.kt | 6 ++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputFilter.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index eac5c56a..568580c9 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -70,6 +70,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospac import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextInputFilter import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextSimpleLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformed @@ -142,6 +143,8 @@ fun CodeEditorView( } } + val inputFilter = BigTextInputFilter { it.replace("\r\n".toRegex(), "\n") } + var layoutResult by remember { mutableStateOf(null) } var textLayoutResult by rememberLast(newText) { mutableStateOf(null) } @@ -518,6 +521,7 @@ fun CodeEditorView( BigMonospaceText( text = bigTextValue as BigTextImpl, padding = PaddingValues(4.dp), + inputFilter = inputFilter, textTransformation = rememberLast(bigTextFieldState, collapseIncrementalTransformation) { MultipleIncrementalTransformation(listOf( collapseIncrementalTransformation, @@ -635,6 +639,7 @@ fun CodeEditorView( BigMonospaceTextField( textFieldState = bigTextFieldState, + inputFilter = inputFilter, textTransformation = remember(variableTransformations) { MultipleIncrementalTransformation( variableTransformations diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 40dceff3..1e277fdb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -109,6 +109,7 @@ fun BigMonospaceText( fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, isSelectable: Boolean = false, + inputFilter: BigTextInputFilter? = null, textTransformation: IncrementalTextTransformation<*>? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, @@ -122,6 +123,7 @@ fun BigMonospaceText( isSelectable = isSelectable, isEditable = false, onTextChange = {}, + inputFilter = inputFilter, textTransformation = textTransformation, scrollState = scrollState, viewState = viewState, @@ -136,6 +138,7 @@ fun BigMonospaceText( fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, isSelectable: Boolean = false, + inputFilter: BigTextInputFilter? = null, textTransformation: IncrementalTextTransformation<*>? = null, textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), @@ -151,6 +154,7 @@ fun BigMonospaceText( isSelectable = isSelectable, isEditable = false, onTextChange = {}, + inputFilter = inputFilter, textTransformation = textTransformation, textDecorator = textDecorator, scrollState = scrollState, @@ -166,6 +170,7 @@ fun BigMonospaceTextField( padding: PaddingValues = PaddingValues(4.dp), fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, + inputFilter: BigTextInputFilter? = null, textTransformation: IncrementalTextTransformation<*>? = null, textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), @@ -180,6 +185,7 @@ fun BigMonospaceTextField( onTextChange = { textFieldState.emitValueChange(it.changeId) }, + inputFilter = inputFilter, textTransformation = textTransformation, textDecorator = textDecorator, scrollState = scrollState, @@ -196,6 +202,7 @@ fun BigMonospaceTextField( fontSize: TextUnit = LocalFont.current.bodyFontSize, color: Color = LocalColor.current.text, onTextChange: (BigTextChangeEvent) -> Unit, + inputFilter: BigTextInputFilter? = null, textTransformation: IncrementalTextTransformation<*>? = null, textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), @@ -210,6 +217,7 @@ fun BigMonospaceTextField( isSelectable = true, isEditable = true, onTextChange = onTextChange, + inputFilter = inputFilter, textTransformation = textTransformation, textDecorator = textDecorator, scrollState = scrollState, @@ -228,6 +236,7 @@ private fun CoreBigMonospaceText( isSelectable: Boolean = false, isEditable: Boolean = false, onTextChange: (BigTextChangeEvent) -> Unit, + inputFilter: BigTextInputFilter? = null, textTransformation: IncrementalTextTransformation<*>? = null, textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), @@ -552,6 +561,7 @@ private fun CoreBigMonospaceText( deleteSelection(isSaveUndoSnapshot = false) } val insertPos = viewState.cursorIndex + val textInput = inputFilter?.filter(textInput) ?: textInput onValuePreChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) text.insertAt(insertPos, textInput) text.recordCurrentChangeSequenceIntoUndoHistory() @@ -559,7 +569,7 @@ private fun CoreBigMonospaceText( // (transformedText as BigTextImpl).layout() // FIXME remove updateViewState() if (log.config.minSeverity <= Severity.Verbose) { - (transformedText as BigTextImpl).printDebug("transformedText onType '${textInput.replace("\n", "\\n")}'") + (transformedText as BigTextImpl).printDebug("transformedText onType '${textInput.string().replace("\n", "\\n")}'") } // update cursor after invoking listeners, because a transformation or change may take place viewState.cursorIndex = minOf(text.length, insertPos + textInput.length) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputFilter.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputFilter.kt new file mode 100644 index 00000000..640839f3 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputFilter.kt @@ -0,0 +1,6 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +fun interface BigTextInputFilter { + + fun filter(input: CharSequence): CharSequence +} From 3bf594c6e01973430425dee0317c1ea1364a0d26 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 3 Nov 2024 15:37:09 +0800 Subject: [PATCH 164/195] refactor to restore "on hit enter key to add indent space" function in CodeEditorView --- .../hellohttp/ux/CodeEditorView.kt | 80 ++-- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 430 ++++++++++-------- .../hellohttp/ux/bigtext/BigTextImpl.kt | 20 + .../bigtext/BigTextKeyboardInputProcessor.kt | 10 + .../ux/bigtext/BigTextManipulator.kt | 16 + 5 files changed, 338 insertions(+), 218 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextKeyboardInputProcessor.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextManipulator.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 568580c9..5238c398 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isAltPressed import androidx.compose.ui.input.key.isCtrlPressed @@ -67,11 +68,12 @@ import com.sunnychung.application.multiplatform.hellohttp.util.chunkedLatest import com.sunnychung.application.multiplatform.hellohttp.util.log import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField -import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextInputFilter +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextKeyboardInputProcessor import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextManipulator import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextSimpleLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformed import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl @@ -176,20 +178,18 @@ fun CodeEditorView( log.d { "CodeEditorView recompose" } - fun onPressEnterAddIndent() { - val cursorPos = textValue.selection.min - assert(textValue.selection.length == 0) + fun onPressEnterAddIndent(textManipulator: BigTextManipulator) { +// val cursorPos = textValue.selection.min +// assert(textValue.selection.length == 0) log.d { "onPressEnterAddIndent" } - val text = textValue.text - var lastLineStart = getLineStart(text, cursorPos) - var spacesMatch = "^(\\s+)".toRegex().matchAt(text.substring(lastLineStart, cursorPos), 0) + val lineIndex = bigTextValue.findLineAndColumnFromRenderPosition(bigTextFieldState.viewState.cursorIndex).first +// val lineStartPosition = bigTextValue.findPositionStartOfLine(lineIndex) + val previousLineString = bigTextValue.findLineString(lineIndex) // as '\n' is not yet inputted, current line is the "previous line" + var spacesMatch = "^(\\s+)".toRegex().matchAt(previousLineString, 0) val newSpaces = "\n" + (spacesMatch?.groups?.get(1)?.value ?: "") - log.d { "onPressEnterAddIndent add ${newSpaces.length} spaces. current cursor $cursorPos" } -// textValue = textValue.copy(selection = TextRange(cursorPos + newSpaces.length)) // no use - cursorDelta += newSpaces.length - onTextChange?.invoke(text.insert(cursorPos, newSpaces)) + textManipulator.replaceAtCursor(newSpaces) } log.v { "cursor at ${textValue.selection}" } @@ -657,41 +657,41 @@ fun CodeEditorView( // colors = colors, scrollState = scrollState, onTextLayout = { layoutResult = it }, - modifier = Modifier.fillMaxSize() - .focusRequester(textFieldFocusRequester) - .run { - if (!isReadOnly) { - this.onPreviewKeyEvent { - if (it.type == KeyEventType.KeyDown) { - when (it.key) { - Key.Enter -> { - if (!it.isShiftPressed - && !it.isAltPressed - && !it.isCtrlPressed - && !it.isMetaPressed && false // FIXME - ) { - onPressEnterAddIndent() - true - } else { - false - } - } - - Key.Tab -> { - onPressTab(it.isShiftPressed) - true - } - - else -> false + keyboardInputProcessor = object : BigTextKeyboardInputProcessor { + override fun beforeProcessInput( + it: KeyEvent, + viewState: BigTextViewState, + textManipulator: BigTextManipulator + ): Boolean { + return if (it.type == KeyEventType.KeyDown) { + when (it.key) { + Key.Enter -> { + if (!it.isShiftPressed + && !it.isAltPressed + && !it.isCtrlPressed + && !it.isMetaPressed + ) { + onPressEnterAddIndent(textManipulator) + true + } else { + false } - } else { - false } + + Key.Tab -> { + onPressTab(it.isShiftPressed) + true + } + + else -> false } } else { - this + false } } + }, + modifier = Modifier.fillMaxSize() + .focusRequester(textFieldFocusRequester) .run { if (testTag != null) { testTag(testTag) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 1e277fdb..e322ad1b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isAltPressed import androidx.compose.ui.input.key.isCtrlPressed @@ -174,6 +175,7 @@ fun BigMonospaceTextField( textTransformation: IncrementalTextTransformation<*>? = null, textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), + keyboardInputProcessor: BigTextKeyboardInputProcessor? = null, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) { BigMonospaceTextField( @@ -190,6 +192,7 @@ fun BigMonospaceTextField( textDecorator = textDecorator, scrollState = scrollState, viewState = textFieldState.viewState, + keyboardInputProcessor = keyboardInputProcessor, onTextLayout = onTextLayout ) } @@ -207,6 +210,7 @@ fun BigMonospaceTextField( textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember(text) { BigTextViewState() }, + keyboardInputProcessor: BigTextKeyboardInputProcessor? = null, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) = CoreBigMonospaceText( modifier = modifier, @@ -222,6 +226,7 @@ fun BigMonospaceTextField( textDecorator = textDecorator, scrollState = scrollState, viewState = viewState, + keyboardInputProcessor = keyboardInputProcessor, onTextLayout = onTextLayout, ) @@ -241,6 +246,7 @@ private fun CoreBigMonospaceText( textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember(text) { BigTextViewState() }, + keyboardInputProcessor: BigTextKeyboardInputProcessor? = null, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, onTransformInit: ((BigTextTransformed) -> Unit)? = null, ) { @@ -536,16 +542,20 @@ private fun CoreBigMonospaceText( onTextChange(event) } + fun delete(start: Int, endExclusive: Int) { + onValuePreChange(BigTextChangeEventType.Delete, start, endExclusive) + text.delete(start, endExclusive) + onValuePostChange(BigTextChangeEventType.Delete, start, endExclusive) + } + fun deleteSelection(isSaveUndoSnapshot: Boolean) { if (viewState.hasSelection()) { val start = viewState.selection.start val endExclusive = viewState.selection.endInclusive + 1 - onValuePreChange(BigTextChangeEventType.Delete, start, endExclusive) - text.delete(start, endExclusive) + delete(start, endExclusive) if (isSaveUndoSnapshot) { text.recordCurrentChangeSequenceIntoUndoHistory() } - onValuePostChange(BigTextChangeEventType.Delete, start, endExclusive) viewState.selection = EMPTY_SELECTION_RANGE // cannot use IntRange.EMPTY as `viewState.selection.start` is in use viewState.transformedSelection = EMPTY_SELECTION_RANGE @@ -555,18 +565,23 @@ private fun CoreBigMonospaceText( } } - fun onType(textInput: String) { - log.v { "$text key in '$textInput' ${viewState.hasSelection()}" } - if (viewState.hasSelection()) { - deleteSelection(isSaveUndoSnapshot = false) - } - val insertPos = viewState.cursorIndex + fun insertAt(insertPos: Int, textInput: CharSequence) { val textInput = inputFilter?.filter(textInput) ?: textInput onValuePreChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) text.insertAt(insertPos, textInput) - text.recordCurrentChangeSequenceIntoUndoHistory() onValuePostChange(BigTextChangeEventType.Insert, insertPos, insertPos + textInput.length) -// (transformedText as BigTextImpl).layout() // FIXME remove + } + + fun onType(textInput: CharSequence, isSaveUndoSnapshot: Boolean = true) { + log.i { "$text key in '$textInput' ${viewState.hasSelection()}" } + if (viewState.hasSelection()) { + deleteSelection(isSaveUndoSnapshot = false) + } + val insertPos = viewState.cursorIndex + insertAt(insertPos, textInput) + if (isSaveUndoSnapshot) { + text.recordCurrentChangeSequenceIntoUndoHistory() + } updateViewState() if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("transformedText onType '${textInput.string().replace("\n", "\\n")}'") @@ -789,6 +804,231 @@ private fun CoreBigMonospaceText( scrollToCursor() } + fun processKeyboardInput(it: KeyEvent): Boolean { + return when { + it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.C && !viewState.transformedSelection.isEmpty() -> { + // Hit Ctrl-C or Cmd-C to copy + log.d { "BigMonospaceText hit copy" } + copySelection() + true + } + it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.X && !viewState.transformedSelection.isEmpty() -> { + // Hit Ctrl-X or Cmd-X to cut + log.d { "BigMonospaceText hit cut" } + cutSelection() + true + } + isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.V -> { + // Hit Ctrl-V or Cmd-V to paste + log.d { "BigMonospaceTextField hit paste" } + paste() + } + isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && !it.isShiftPressed && it.key == Key.Z -> { + // Hit Ctrl-Z or Cmd-Z to undo + log.d { "BigMonospaceTextField hit undo" } + undo() + true + } + isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.isShiftPressed && it.key == Key.Z -> { + // Hit Ctrl-Shift-Z or Cmd-Shift-Z to redo + log.d { "BigMonospaceTextField hit redo" } + redo() + true + } + /* selection */ + it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.A -> { + // Hit Ctrl-A or Cmd-A to select all + selectAll() + true + } + it.type == KeyEventType.KeyDown && it.key in listOf(Key.ShiftLeft, Key.ShiftRight) -> { + isHoldingShiftKey = true + false + } + it.type == KeyEventType.KeyUp && it.key in listOf(Key.ShiftLeft, Key.ShiftRight) -> { + isHoldingShiftKey = false + false + } + /* text input */ + isEditable && it.isTypedEvent -> { + log.v { "key type '${it.key}'" } + val textInput = it.toTextInput() + if (textInput != null) { + onType(textInput) + true + } else { + false + } + } + isEditable && it.type == KeyEventType.KeyDown -> when { + it.key == Key.Enter && !it.isShiftPressed && !it.isCtrlPressed && !it.isAltPressed && !it.isMetaPressed -> { + onType("\n") + true + } + it.key == Key.Backspace -> { + onDelete(TextFBDirection.Backward) + } + it.key == Key.Delete -> { + onDelete(TextFBDirection.Forward) + } + /* text navigation */ + (currentOS() == MacOS && it.isMetaPressed && it.key == Key.DirectionUp) || + (currentOS() != MacOS && it.isCtrlPressed && it.key == Key.MoveHome) -> { + updateOriginalCursorOrSelection(newPosition = 0, isSelection = it.isShiftPressed) + true + } + (currentOS() == MacOS && it.isMetaPressed && it.key == Key.DirectionDown) || + (currentOS() != MacOS && it.isCtrlPressed && it.key == Key.MoveEnd) -> { + updateOriginalCursorOrSelection(newPosition = text.length, isSelection = it.isShiftPressed) + true + } + (currentOS() == MacOS && it.isMetaPressed && it.key in listOf(Key.DirectionLeft, Key.DirectionRight)) || + it.key in listOf(Key.MoveHome, Key.MoveEnd) -> { + // use `transformedText` as basis because `text` does not perform layout + val currentRowIndex = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) + val newTransformedPosition = if (it.key in listOf(Key.DirectionLeft, Key.MoveHome)) { + // home -> move to start of row + log.d { "move to start of row $currentRowIndex" } + transformedText.findRowPositionStartIndexByRowIndex(currentRowIndex) + } else { + // end -> move to end of row + log.d { "move to end of row $currentRowIndex" } + if (currentRowIndex + 1 <= transformedText.lastRowIndex) { + transformedText.findRowPositionStartIndexByRowIndex(currentRowIndex + 1) - /* the '\n' char */ 1 + } else { + transformedText.length + } + } + updateTransformedCursorOrSelection( + newTransformedPosition = newTransformedPosition, + isSelection = it.isShiftPressed, + ) + true + } + it.key == Key.DirectionLeft && ( + (currentOS() == MacOS && it.isAltPressed) || + (currentOS() != MacOS && it.isCtrlPressed) + ) -> { + val newPosition = findPreviousWordBoundaryPositionFromCursor() + updateOriginalCursorOrSelection(newPosition = newPosition, isSelection = it.isShiftPressed) + true + } + it.key == Key.DirectionRight && ( + (currentOS() == MacOS && it.isAltPressed) || + (currentOS() != MacOS && it.isCtrlPressed) + ) -> { + val newPosition = findNextWordBoundaryPositionFromCursor() + updateOriginalCursorOrSelection(newPosition = newPosition, isSelection = it.isShiftPressed) + true + } + it.key in listOf(Key.DirectionLeft, Key.DirectionRight) -> { + val delta = if (it.key == Key.DirectionRight) 1 else -1 + if (viewState.transformedCursorIndex + delta in 0 .. transformedText.length) { + var newTransformedPosition = viewState.transformedCursorIndex + delta + newTransformedPosition = if (delta > 0) { + viewState.roundedTransformedCursorIndex(newTransformedPosition, CursorAdjustDirection.Forward, transformedText, viewState.transformedCursorIndex /* FIXME IndexOutOfBoundsException */, false) + } else { + viewState.roundedTransformedCursorIndex(newTransformedPosition, CursorAdjustDirection.Backward, transformedText, newTransformedPosition, true) + } + updateTransformedCursorOrSelection( + newTransformedPosition = newTransformedPosition, + isSelection = it.isShiftPressed, + ) + log.v { "set cursor pos LR => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } + } + true + } + it.key in listOf(Key.DirectionUp, Key.DirectionDown) -> { +// val row = layoutResult.rowStartCharIndices.binarySearchForMaxIndexOfValueAtMost(viewState.transformedCursorIndex) + val row = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) + val newRow = row + if (it.key == Key.DirectionDown) 1 else -1 + var newTransformedPosition = Unit.let { + if (newRow < 0) { + 0 + } else if (newRow > transformedText.lastRowIndex) { + transformedText.length + } else { + val col = viewState.transformedCursorIndex - transformedText.findRowPositionStartIndexByRowIndex(row) + val newRowLength = if (newRow + 1 <= transformedText.lastRowIndex) { + transformedText.findRowPositionStartIndexByRowIndex(newRow + 1) - 1 + } else { + transformedText.length + } - transformedText.findRowPositionStartIndexByRowIndex(newRow) + if (col <= newRowLength) { + transformedText.findRowPositionStartIndexByRowIndex(newRow) + col + } else { + transformedText.findRowPositionStartIndexByRowIndex(newRow) + newRowLength + } + } + } + newTransformedPosition = viewState.roundedTransformedCursorIndex(newTransformedPosition, CursorAdjustDirection.Bidirectional, transformedText, viewState.transformedCursorIndex, true) + updateTransformedCursorOrSelection( + newTransformedPosition = newTransformedPosition, + isSelection = it.isShiftPressed, + ) + true + } + else -> false + } + else -> false + } + } + + fun onProcessKeyboardInput(keyEvent: KeyEvent): Boolean { + var hasManipulatedText = false + val textManipulator = object : BigTextManipulator { + override fun append(text: CharSequence) { + hasManipulatedText = true + insertAt(text.length, text) + } + + override fun insertAt(pos: Int, text: CharSequence) { + hasManipulatedText = true + insertAt(pos, text) + } + + override fun replaceAtCursor(text: CharSequence) { + hasManipulatedText = true + onType(text, isSaveUndoSnapshot = false) // save undo snapshot at the end + } + + override fun delete(range: IntRange) { + hasManipulatedText = true + delete(range.start, range.endInclusive + 1) + } + + override fun replace(range: IntRange, text: CharSequence) { + hasManipulatedText = true + delete(range.start, range.endInclusive + 1) + insertAt(range.start, text) + } + + override fun setCursorPosition(position: Int) { + require(position in 0 .. text.length) { "Cursor position $position is out of range. Text length: ${text.length}" } + viewState.cursorIndex = position + viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex + } + } + + try { + if (keyboardInputProcessor?.beforeProcessInput(keyEvent, viewState, textManipulator) == true) { + return true + } + var result = processKeyboardInput(keyEvent) + if (keyboardInputProcessor?.afterProcessInput(keyEvent, viewState, textManipulator) == true) { + result = true + } + return result + + } finally { + if (hasManipulatedText) { + updateViewState() + text.recordCurrentChangeSequenceIntoUndoHistory() + } + } + } + val tv = remember { TextFieldValue() } // this value is not used LaunchedEffect(transformedText) { @@ -941,173 +1181,7 @@ private fun CoreBigMonospaceText( } .onPreviewKeyEvent { log.v { "BigMonospaceText onPreviewKeyEvent ${it.type} ${it.key} ${it.key.nativeKeyCode} ${it.key.keyCode}" } - when { - it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.C && !viewState.transformedSelection.isEmpty() -> { - // Hit Ctrl-C or Cmd-C to copy - log.d { "BigMonospaceText hit copy" } - copySelection() - true - } - it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.X && !viewState.transformedSelection.isEmpty() -> { - // Hit Ctrl-X or Cmd-X to cut - log.d { "BigMonospaceText hit cut" } - cutSelection() - true - } - isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.V -> { - // Hit Ctrl-V or Cmd-V to paste - log.d { "BigMonospaceTextField hit paste" } - paste() - } - isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && !it.isShiftPressed && it.key == Key.Z -> { - // Hit Ctrl-Z or Cmd-Z to undo - log.d { "BigMonospaceTextField hit undo" } - undo() - true - } - isEditable && it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.isShiftPressed && it.key == Key.Z -> { - // Hit Ctrl-Shift-Z or Cmd-Shift-Z to redo - log.d { "BigMonospaceTextField hit redo" } - redo() - true - } - /* selection */ - it.type == KeyEventType.KeyDown && it.isCtrlOrCmdPressed() && it.key == Key.A -> { - // Hit Ctrl-A or Cmd-A to select all - selectAll() - true - } - it.type == KeyEventType.KeyDown && it.key in listOf(Key.ShiftLeft, Key.ShiftRight) -> { - isHoldingShiftKey = true - false - } - it.type == KeyEventType.KeyUp && it.key in listOf(Key.ShiftLeft, Key.ShiftRight) -> { - isHoldingShiftKey = false - false - } - /* text input */ - isEditable && it.isTypedEvent -> { - log.v { "key type '${it.key}'" } - val textInput = it.toTextInput() - if (textInput != null) { - onType(textInput) - true - } else { - false - } - } - isEditable && it.type == KeyEventType.KeyDown -> when { - it.key == Key.Enter && !it.isShiftPressed && !it.isCtrlPressed && !it.isAltPressed && !it.isMetaPressed -> { - onType("\n") - true - } - it.key == Key.Backspace -> { - onDelete(TextFBDirection.Backward) - } - it.key == Key.Delete -> { - onDelete(TextFBDirection.Forward) - } - /* text navigation */ - (currentOS() == MacOS && it.isMetaPressed && it.key == Key.DirectionUp) || - (currentOS() != MacOS && it.isCtrlPressed && it.key == Key.MoveHome) -> { - updateOriginalCursorOrSelection(newPosition = 0, isSelection = it.isShiftPressed) - true - } - (currentOS() == MacOS && it.isMetaPressed && it.key == Key.DirectionDown) || - (currentOS() != MacOS && it.isCtrlPressed && it.key == Key.MoveEnd) -> { - updateOriginalCursorOrSelection(newPosition = text.length, isSelection = it.isShiftPressed) - true - } - (currentOS() == MacOS && it.isMetaPressed && it.key in listOf(Key.DirectionLeft, Key.DirectionRight)) || - it.key in listOf(Key.MoveHome, Key.MoveEnd) -> { - // use `transformedText` as basis because `text` does not perform layout - val currentRowIndex = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) - val newTransformedPosition = if (it.key in listOf(Key.DirectionLeft, Key.MoveHome)) { - // home -> move to start of row - log.d { "move to start of row $currentRowIndex" } - transformedText.findRowPositionStartIndexByRowIndex(currentRowIndex) - } else { - // end -> move to end of row - log.d { "move to end of row $currentRowIndex" } - if (currentRowIndex + 1 <= transformedText.lastRowIndex) { - transformedText.findRowPositionStartIndexByRowIndex(currentRowIndex + 1) - /* the '\n' char */ 1 - } else { - transformedText.length - } - } - updateTransformedCursorOrSelection( - newTransformedPosition = newTransformedPosition, - isSelection = it.isShiftPressed, - ) - true - } - it.key == Key.DirectionLeft && ( - (currentOS() == MacOS && it.isAltPressed) || - (currentOS() != MacOS && it.isCtrlPressed) - ) -> { - val newPosition = findPreviousWordBoundaryPositionFromCursor() - updateOriginalCursorOrSelection(newPosition = newPosition, isSelection = it.isShiftPressed) - true - } - it.key == Key.DirectionRight && ( - (currentOS() == MacOS && it.isAltPressed) || - (currentOS() != MacOS && it.isCtrlPressed) - ) -> { - val newPosition = findNextWordBoundaryPositionFromCursor() - updateOriginalCursorOrSelection(newPosition = newPosition, isSelection = it.isShiftPressed) - true - } - it.key in listOf(Key.DirectionLeft, Key.DirectionRight) -> { - val delta = if (it.key == Key.DirectionRight) 1 else -1 - if (viewState.transformedCursorIndex + delta in 0 .. transformedText.length) { - var newTransformedPosition = viewState.transformedCursorIndex + delta - newTransformedPosition = if (delta > 0) { - viewState.roundedTransformedCursorIndex(newTransformedPosition, CursorAdjustDirection.Forward, transformedText, viewState.transformedCursorIndex /* FIXME IndexOutOfBoundsException */, false) - } else { - viewState.roundedTransformedCursorIndex(newTransformedPosition, CursorAdjustDirection.Backward, transformedText, newTransformedPosition, true) - } - updateTransformedCursorOrSelection( - newTransformedPosition = newTransformedPosition, - isSelection = it.isShiftPressed, - ) - log.v { "set cursor pos LR => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } - } - true - } - it.key in listOf(Key.DirectionUp, Key.DirectionDown) -> { -// val row = layoutResult.rowStartCharIndices.binarySearchForMaxIndexOfValueAtMost(viewState.transformedCursorIndex) - val row = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) - val newRow = row + if (it.key == Key.DirectionDown) 1 else -1 - var newTransformedPosition = Unit.let { - if (newRow < 0) { - 0 - } else if (newRow > transformedText.lastRowIndex) { - transformedText.length - } else { - val col = viewState.transformedCursorIndex - transformedText.findRowPositionStartIndexByRowIndex(row) - val newRowLength = if (newRow + 1 <= transformedText.lastRowIndex) { - transformedText.findRowPositionStartIndexByRowIndex(newRow + 1) - 1 - } else { - transformedText.length - } - transformedText.findRowPositionStartIndexByRowIndex(newRow) - if (col <= newRowLength) { - transformedText.findRowPositionStartIndexByRowIndex(newRow) + col - } else { - transformedText.findRowPositionStartIndexByRowIndex(newRow) + newRowLength - } - } - } - newTransformedPosition = viewState.roundedTransformedCursorIndex(newTransformedPosition, CursorAdjustDirection.Bidirectional, transformedText, viewState.transformedCursorIndex, true) - updateTransformedCursorOrSelection( - newTransformedPosition = newTransformedPosition, - isSelection = it.isShiftPressed, - ) - true - } - else -> false - } - else -> false - } + onProcessKeyboardInput(it) } // .then(BigTextInputModifierElement(1)) .focusable(isSelectable) // `focusable` should be after callback modifiers that use focus diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 9830b592..aa6a781b 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -195,6 +195,26 @@ open class BigTextImpl( } } + fun findPositionStartOfLine(lineIndex: Int): Int { + val (node, lineIndexStart) = tree.findNodeByLineBreaks(lineIndex) + ?: throw IndexOutOfBoundsException("Cannot find node for line $lineIndex") + val positionStart = findPositionStart(node) + val lineBreakIndex = lineIndex - lineIndexStart - 1 + + val positionStartOffsetOfLine = if (lineBreakIndex >= 0) { + val lineOffsets = node.value.buffer.lineOffsetStarts + val lineOffsetStartIndex = lineOffsets.binarySearchForMinIndexOfValueAtLeast(node.value.renderBufferStart) + require(lineOffsetStartIndex >= 0) + lineOffsets[lineOffsetStartIndex + lineBreakIndex] - + node.value.renderBufferStart + + /* find the position just after the '\n' char */ 1 + } else { + 0 + } + + return positionStart + positionStartOffsetOfLine + } + /** * @param rowIndex 0-based * @return 0-based diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextKeyboardInputProcessor.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextKeyboardInputProcessor.kt new file mode 100644 index 00000000..0265c4e8 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextKeyboardInputProcessor.kt @@ -0,0 +1,10 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import androidx.compose.ui.input.key.KeyEvent + +interface BigTextKeyboardInputProcessor { + + fun beforeProcessInput(keyEvent: KeyEvent, viewState: BigTextViewState, textManipulator: BigTextManipulator): Boolean = false + + fun afterProcessInput(keyEvent: KeyEvent, viewState: BigTextViewState, textManipulator: BigTextManipulator): Boolean = false +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextManipulator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextManipulator.kt new file mode 100644 index 00000000..8ffdc402 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextManipulator.kt @@ -0,0 +1,16 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +interface BigTextManipulator { + + fun append(text: CharSequence) + + fun insertAt(pos: Int, text: CharSequence) + + fun replaceAtCursor(text: CharSequence) + + fun delete(range: IntRange) + + fun replace(range: IntRange, text: CharSequence) + + fun setCursorPosition(position: Int) +} From 126343470a053af486d82a65e870bc9a874704bb Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 3 Nov 2024 15:55:13 +0800 Subject: [PATCH 165/195] fix crash while deleting the last character --- .../AbstractSyntaxHighlightDecorator.kt | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt index 7b83ed2e..5b416ee1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/AbstractSyntaxHighlightDecorator.kt @@ -23,6 +23,7 @@ import io.github.treesitter.ktreesitter.Tree abstract class AbstractSyntaxHighlightDecorator(language: Language) : CacheableBigTextDecorator() { protected val parser: Parser = Parser(language) protected lateinit var ast: Tree + protected var oldEndPoint: Point? = null override fun doInitialize(text: BigText) { val s = text.buildString() @@ -54,25 +55,34 @@ abstract class AbstractSyntaxHighlightDecorator(language: Language) : CacheableB } } - protected fun createInputEdit(event: BigTextChangeEvent, startOffset: Int, oldEndOffset: Int, newEndOffset: Int): InputEdit { - fun toPoint(offset: Int): Point { - return event.bigText.findLineAndColumnFromRenderPosition(offset) - .also { - require(it.first >= 0 && it.second >= 0) { - (event.bigText as BigTextImpl).printDebug("[ERROR]") - "convert out of range. i=$offset, lc=$it, s = |${event.bigText.buildString()}|" - } + protected fun toPoint(text: BigText, offset: Int): Point { + return text.findLineAndColumnFromRenderPosition(offset) + .also { + require(it.first >= 0 && it.second >= 0) { + (text as BigTextImpl).printDebug("[ERROR]") + "convert out of range. i=$offset, lc=$it, s = |${text.buildString()}|" } - .toPoint() + } + .toPoint() + } + + override fun beforeTextChange(event: BigTextChangeEvent) { + oldEndPoint = if (event.eventType == BigTextChangeEventType.Delete) { + toPoint(event.bigText, event.changeEndExclusiveIndex) + } else { + null } + } + + protected fun createInputEdit(event: BigTextChangeEvent, startOffset: Int, oldEndOffset: Int, newEndOffset: Int): InputEdit { return InputEdit( startOffset.toUInt(), oldEndOffset.toUInt(), newEndOffset.toUInt(), - toPoint(startOffset), - toPoint(oldEndOffset), - toPoint(newEndOffset), + toPoint(event.bigText, startOffset), + oldEndPoint ?: toPoint(event.bigText, oldEndOffset), // store oldEndPoint before deletion to avoid crash or miscalculation + toPoint(event.bigText, newEndOffset), ).also { log.d { "AST InputEdit ${it.startByte} ${it.oldEndByte} ${it.newEndByte} ${it.startPoint} ${it.oldEndPoint} ${it.newEndPoint}" } } From db8948880e76eaf69ad2a32976652825e5f20c0b Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 3 Nov 2024 18:40:26 +0800 Subject: [PATCH 166/195] fix incorrect implementation of RedBlackTree2.findNodeByLineBreaksExact and findPositionStartOfLine --- .../hellohttp/ux/bigtext/BigTextImpl.kt | 6 +-- .../test/bigtext/BigTextImplQueryTest.kt | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index aa6a781b..703c0bba 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -108,9 +108,9 @@ open class BigTextImpl( return findNode { index when (find) { - in Int.MIN_VALUE until it.value.leftNumOfLineBreaks -> if (it.left.isNotNil()) -1 else 0 + in Int.MIN_VALUE .. it.value.leftNumOfLineBreaks -> if (it.left.isNotNil()) -1 else 0 // it.value.leftNumOfLineBreaks -> if (it.left.isNotNil()) -1 else 0 - in it.value.leftNumOfLineBreaks .. it.value.leftNumOfLineBreaks + it.value.renderNumLineBreaksInRange -> 0 + in it.value.leftNumOfLineBreaks + 1 .. it.value.leftNumOfLineBreaks + it.value.renderNumLineBreaksInRange -> 0 in it.value.leftNumOfLineBreaks + it.value.renderNumLineBreaksInRange + 1 until Int.MAX_VALUE -> (if (it.right.isNotNil()) 1 else 0).also { compareResult -> val isTurnRight = compareResult > 0 if (isTurnRight) { @@ -196,7 +196,7 @@ open class BigTextImpl( } fun findPositionStartOfLine(lineIndex: Int): Int { - val (node, lineIndexStart) = tree.findNodeByLineBreaks(lineIndex) + val (node, lineIndexStart) = tree.findNodeByLineBreaksExact(lineIndex) ?: throw IndexOutOfBoundsException("Cannot find node for line $lineIndex") val positionStart = findPositionStart(node) val lineBreakIndex = lineIndex - lineIndexStart - 1 diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt index 6c049e53..cb16b6c5 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextImplQueryTest.kt @@ -265,6 +265,43 @@ class BigTextImplQueryTest { t.assertLineAndColumn(it, lineIndex, columnIndex) } } + + @ParameterizedTest + @ValueSource(ints = [2 * 1024 * 1024, 16, 64]) + fun findPositionStartOfLine(chunkSize: Int) { + val t = BigTextImpl(chunkSize = chunkSize) + t.append("fx\n" + + " sdf a\n" + + " esr\n" + + " dfgh \n" + + "\n" + + "atr\n" + + "\n" + + "ar\n") + run { + t.printDebug("after append") + val linePositionStarts = (0..8).map { t.findPositionStartOfLine(it) } + assertEquals(listOf(0, 3, 16, 29, 42, 43, 47, 48, 51), linePositionStarts) + } + + t.delete(29 .. 32) + t.delete(16 .. 19) + t.delete(3 .. 6) + run { + t.printDebug("after dec") + val linePositionStarts = (0..8).map { t.findPositionStartOfLine(it) } + assertEquals(listOf(0, 3, 12, 21, 30, 31, 35, 36, 39), linePositionStarts) + } + + t.insertAt(21, " ".repeat(4)) + t.insertAt(12, " ".repeat(4)) + t.insertAt(3, " ".repeat(4)) + run { + t.printDebug("after inc") + val linePositionStarts = (0..8).map { t.findPositionStartOfLine(it) } + assertEquals(listOf(0, 3, 16, 29, 42, 43, 47, 48, 51), linePositionStarts) + } + } } private fun BigTextVerifyImpl.verifyAllLines() { From 3d0f7de216486c284879c0dd0338ba824707ac6a Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 3 Nov 2024 18:42:58 +0800 Subject: [PATCH 167/195] update CodeEditorView to restore "on hit tab key to add/decrease indent" function --- .../hellohttp/ux/CodeEditorView.kt | 120 +++++++++--------- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 9 ++ .../ux/bigtext/BigTextManipulator.kt | 2 + 3 files changed, 68 insertions(+), 63 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 5238c398..527fd3a1 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -62,6 +62,8 @@ import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryAp import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForInsertionPoint import com.sunnychung.application.multiplatform.hellohttp.extension.contains import com.sunnychung.application.multiplatform.hellohttp.extension.insert +import com.sunnychung.application.multiplatform.hellohttp.extension.intersect +import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.model.SyntaxHighlight import com.sunnychung.application.multiplatform.hellohttp.util.TreeRangeMaps import com.sunnychung.application.multiplatform.hellohttp.util.chunkedLatest @@ -179,9 +181,6 @@ fun CodeEditorView( log.d { "CodeEditorView recompose" } fun onPressEnterAddIndent(textManipulator: BigTextManipulator) { -// val cursorPos = textValue.selection.min -// assert(textValue.selection.length == 0) - log.d { "onPressEnterAddIndent" } val lineIndex = bigTextValue.findLineAndColumnFromRenderPosition(bigTextFieldState.viewState.cursorIndex).first @@ -193,72 +192,67 @@ fun CodeEditorView( } log.v { "cursor at ${textValue.selection}" } - fun onPressTab(isShiftPressed: Boolean) { - val selection = textValue.selection - val text = textValue.text - if (selection.length == 0) { - val cursorPos = selection.min - val newSpaces = " ".repeat(4) - textValue = textValue.copy(selection = TextRange(cursorPos + newSpaces.length)) - onTextChange?.invoke(text.insert(cursorPos, newSpaces)) - } else if (!isShiftPressed) { // select text and press tab to insert 1-level indent to lines - val lineStarts = getAllLineStartsInRegion( - text = text, - from = selection.min, - to = selection.max - 1, - ) - log.v { "lineStarts = $lineStarts" } + fun onPressTab(textManipulator: BigTextManipulator, isShiftPressed: Boolean) { + val vs = bigTextFieldState.viewState + val text = bigTextFieldState.text + if (!isShiftPressed && !vs.hasSelection()) { val newSpaces = " ".repeat(4) - var s = text - for (i in lineStarts.size - 1 downTo 0) { - val it = lineStarts[i] - s = s.insert(it, newSpaces) - } + textManipulator.replaceAtCursor(newSpaces) + return + } - val (minOffset, maxOffset) = Pair(newSpaces.length, newSpaces.length * lineStarts.size) - log.d { "off = $minOffset, $maxOffset" } - textValue = textValue.copy( - text = s, - selection = TextRange( - start = selection.start + if (!selection.reversed) minOffset else maxOffset, - end = selection.end + if (!selection.reversed) maxOffset else minOffset, - ) - ) + val lineRange = if (vs.hasSelection()) { + text.findLineAndColumnFromRenderPosition(vs.selection.start).first .. + text.findLineAndColumnFromRenderPosition(vs.selection.endInclusive).first + } else { + val currentLineIndex = text.findLineAndColumnFromRenderPosition(vs.cursorIndex).first + currentLineIndex .. currentLineIndex + } - onTextChange?.invoke(s) - } else { // select text and press shift+tab to remove 1-level indent from lines - val lineStarts = getAllLineStartsInRegion( - text = text, - from = selection.min, - to = selection.max - 1, - ) - log.v { "lineStarts R = $lineStarts" } - var s = text - var firstLineSpaces = 0 - var numSpaces = 0 - for (i in lineStarts.size - 1 downTo 0) { - val it = lineStarts[i] - // at most remove 4 spaces - val spaceRange = "^ {1,4}".toRegex().matchAt(s.substring(it, minOf(it + 4, s.length)), 0)?.range - if (spaceRange != null) { - s = s.removeRange(it + spaceRange.start..it + spaceRange.endInclusive) - val spaceLength = spaceRange.endInclusive + 1 - spaceRange.start - numSpaces += spaceLength - if (i == 0) firstLineSpaces = spaceLength + val isCursorAtSelectionStart = !vs.hasSelection() || vs.cursorIndex == vs.selection.start + var selectionStartChange = 0 + var selectionEndChange = 0 + + log.d { "tab line range = $lineRange" } + + lineRange.reversed().forEach { + val linePosStart = text.findPositionStartOfLine(it) + log.d { "tab line $it pos $linePosStart" } + if (!isShiftPressed) { // increase indent + val newSpaces = " ".repeat(4) + selectionStartChange = newSpaces.length + selectionEndChange += newSpaces.length + textManipulator.insertAt(linePosStart, newSpaces) + } else { // decrease indent + val line = text.findLineString(it) + val textToBeDeleted = "^( {1,4}|\t)".toRegex().matchAt(line, 0) ?: return@forEach + val rangeToBeDeleted = linePosStart until linePosStart + textToBeDeleted.groups[1]!!.range.length + textManipulator.delete(rangeToBeDeleted) + + if (it == lineRange.first) { + selectionStartChange -= textToBeDeleted.groups[1]!!.range.length + } else { + val intersectionWithSelectionRange = vs.selection intersect rangeToBeDeleted + log.d { "tab selectionEndChange -= ${intersectionWithSelectionRange.length}" } + selectionEndChange -= intersectionWithSelectionRange.length } } + } - val (minOffset, maxOffset) = Pair(- firstLineSpaces, - numSpaces) - log.d { "off = $minOffset, $maxOffset" } - textValue = textValue.copy( - text = s, - selection = TextRange( - start = maxOf(0, selection.start + if (!selection.reversed) minOffset else maxOffset), - end = selection.end + if (!selection.reversed) maxOffset else minOffset, - ) - ) + if (isShiftPressed) { + selectionEndChange += selectionStartChange + } - onTextChange?.invoke(s) + log.d { "tab ∆sel = $selectionStartChange, $selectionEndChange" } + if (selectionStartChange != 0 || selectionEndChange != 0) { + if (vs.hasSelection()) { + textManipulator.setSelection(vs.selection.start + selectionStartChange..vs.selection.endInclusive + selectionEndChange) + } + if (isCursorAtSelectionStart) { + textManipulator.setCursorPosition(vs.cursorIndex + selectionStartChange) + } else { + textManipulator.setCursorPosition(vs.cursorIndex + selectionEndChange) + } } } @@ -679,7 +673,7 @@ fun CodeEditorView( } Key.Tab -> { - onPressTab(it.isShiftPressed) + onPressTab(textManipulator, it.isShiftPressed) true } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index e322ad1b..d5ea425e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -1008,6 +1008,15 @@ private fun CoreBigMonospaceText( viewState.cursorIndex = position viewState.updateTransformedCursorIndexByOriginal(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex + scrollToCursor() + } + + override fun setSelection(range: IntRange) { + require(range.start in 0 .. text.length) { "Range start ${range.start} is out of range. Text length: ${text.length}" } + require(range.endInclusive + 1 in 0 .. text.length) { "Range end ${range.endInclusive} is out of range. Text length: ${text.length}" } + + viewState.selection = range + viewState.updateTransformedSelectionBySelection(transformedText) } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextManipulator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextManipulator.kt index 8ffdc402..5d1d8765 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextManipulator.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextManipulator.kt @@ -13,4 +13,6 @@ interface BigTextManipulator { fun replace(range: IntRange, text: CharSequence) fun setCursorPosition(position: Int) + + fun setSelection(range: IntRange) } From fd484ccebb354c1587bec52988e74e9f37c49291 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 3 Nov 2024 19:15:29 +0800 Subject: [PATCH 168/195] refactor CodeEditorView and BigMonospaceText to remove unused code --- CHANGELOG.md | 3 +- .../hellohttp/ux/CodeEditorView.kt | 253 +----------------- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 62 +---- 3 files changed, 8 insertions(+), 310 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16bdabb9..0f6000e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -Nothing yet. +### Removed +- Text fields and response body viewer now do not trim content over 4 MB (but other limits still apply) ## [1.6.0] - 2024-07-22 diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 527fd3a1..551a436a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -124,59 +124,20 @@ fun CodeEditorView( knownVariables: Set = setOf(), testTag: String? = null, ) { - val colors: TextFieldColors = TextFieldDefaults.textFieldColors( - textColor = textColor, - placeholderColor = LocalColor.current.placeholder, - cursorColor = LocalColor.current.cursor, - backgroundColor = LocalColor.current.backgroundInputField - ) val themeColours = LocalColor.current val coroutineScope = rememberCoroutineScope() - // Replace "\r\n" by "\n" because to workaround the issue: - // https://github.com/JetBrains/compose-multiplatform/issues/3877 - fun String.filterForTextField() = replace("\r\n", "\n") - - var textValue by remember { mutableStateOf(TextFieldValue(text = text.filterForTextField())) } - var cursorDelta by remember { mutableStateOf(0) } - val newText = text.filterForTextField().let { - if (isReadOnly && it.length > MAX_TEXT_FIELD_LENGTH) { - it.substring(0 .. MAX_TEXT_FIELD_LENGTH - 1) + "\n... (trimmed. total ${it.length} bytes)" - } else { - it - } - } - val inputFilter = BigTextInputFilter { it.replace("\r\n".toRegex(), "\n") } var layoutResult by remember { mutableStateOf(null) } - var textLayoutResult by rememberLast(newText) { mutableStateOf(null) } - var lineTops by rememberLast(newText, textLayoutResult) { mutableStateOf?>(null) } - log.d { "len newText ${newText.length}, textValue.text ${textValue.text.length}, text ${text.length}" } - if (newText != textValue.text) { - log.d { "CodeEditorView replace text len ${textValue.text.length} -> ${newText.length}" } - textValue = textValue.copy(text = newText) - lineTops = null // recalculate - textLayoutResult = null - } - if (cursorDelta > 0) { - textValue = textValue.copy( - selection = TextRange( - textValue.selection.start + cursorDelta, - textValue.selection.end + cursorDelta - ) - ) - cursorDelta = 0 - } - - val (secondCacheKey, bigTextFieldMutableState) = rememberAnnotatedBigTextFieldState(initialValue = textValue.text) + val (secondCacheKey, bigTextFieldMutableState) = rememberAnnotatedBigTextFieldState(initialValue = text) val bigTextFieldState: BigTextFieldState = bigTextFieldMutableState.value val bigTextValue: BigTextImpl = bigTextFieldState.text - var bigTextValueId by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(Random.nextLong()) } + var bigTextValueId by remember(bigTextFieldState) { mutableStateOf(Random.nextLong()) } - var collapsedLines = rememberLast(newText) { mutableStateMapOf() } - var collapsedChars = rememberLast(newText) { mutableStateMapOf() } + var collapsedLines = rememberLast(bigTextFieldState) { mutableStateMapOf() } + var collapsedChars = rememberLast(bigTextFieldState) { mutableStateMapOf() } log.d { "CodeEditorView recompose" } @@ -184,14 +145,12 @@ fun CodeEditorView( log.d { "onPressEnterAddIndent" } val lineIndex = bigTextValue.findLineAndColumnFromRenderPosition(bigTextFieldState.viewState.cursorIndex).first -// val lineStartPosition = bigTextValue.findPositionStartOfLine(lineIndex) val previousLineString = bigTextValue.findLineString(lineIndex) // as '\n' is not yet inputted, current line is the "previous line" var spacesMatch = "^(\\s+)".toRegex().matchAt(previousLineString, 0) val newSpaces = "\n" + (spacesMatch?.groups?.get(1)?.value ?: "") textManipulator.replaceAtCursor(newSpaces) } - log.v { "cursor at ${textValue.selection}" } fun onPressTab(textManipulator: BigTextManipulator, isShiftPressed: Boolean) { val vs = bigTextFieldState.viewState val text = bigTextFieldState.text @@ -265,7 +224,6 @@ fun CodeEditorView( )) } var searchPattern by rememberLast(searchText, searchOptions) { mutableStateOf(null) } val searchPatternLatest by rememberUpdatedState(searchPattern) -// var searchPattern4 by rememberUpdatedMutableState(searchPatternState) // for use in LaunchedEffect val scrollState = rememberScrollState() val searchBarFocusRequester = remember { FocusRequester() } val textFieldFocusRequester = remember { FocusRequester() } @@ -307,8 +265,6 @@ fun CodeEditorView( .debounce(210L) .filter { isSearchVisible } .collectLatest { -// log.d { "search triggered ${searchPattern?.pattern} ${searchPattern0?.pattern} ${searchPattern1?.pattern} ${searchPattern2?.pattern} ${searchPattern3?.pattern}" } -// log.d { "search triggered ${searchPattern2?.pattern} ${searchPattern3?.pattern}" } log.d { "search triggered ${searchPatternLatest?.pattern}" } if (searchPatternLatest != null) { try { @@ -372,11 +328,6 @@ fun CodeEditorView( ) } - textLayoutResult?.let { tl -> - (0..minOf(10, tl.lineCount - 1)).forEach { - log.d { "> TL Line $it top=${tl.getLineTop(it)} bottom=${tl.getLineBottom(it)} h=${tl.getLineBottom(it) - tl.getLineTop(it)}" } - } - } if (isSearchVisible) { if (lastSearchResultViewIndex != searchResultViewIndex && layoutResult != null && textFieldSize != null && searchResultRanges != null) { @@ -465,7 +416,6 @@ fun CodeEditorView( } } Box(modifier = Modifier.weight(1f).onGloballyPositioned { textFieldSize = it.size }) { -// log.v { "CodeEditorView text=$text" } Row { val onCollapseLine = { i: Int -> val index = collapsableLines.indexOfFirst { it.start == i } @@ -478,17 +428,6 @@ fun CodeEditorView( collapsedChars -= collapsableChars[index] } -// BigLineNumbersView( -// scrollState = scrollState, -// bigTextViewState = bigTextViewState, -// textLayout = layoutResult, -// collapsableLines = collapsableLines, -// collapsedLines = collapsedLines.values.toList(), -// onCollapseLine = onCollapseLine, -// onExpandLine = onExpandLine, -// modifier = Modifier.fillMaxHeight(), -// ) - BigTextLineNumbersView( scrollState = scrollState, bigTextViewState = bigTextFieldState.viewState, @@ -543,78 +482,6 @@ fun CodeEditorView( ) // return@Row // compose bug: return here would crash } else { - /*LineNumbersView( - scrollState = scrollState, - textLayoutResult = textLayoutResult, - lineTops = lineTops, - collapsableLines = collapsableLines, - collapsedLines = collapsedLines.values.toList(), - onCollapseLine = onCollapseLine, - onExpandLine = onExpandLine, - modifier = Modifier.fillMaxHeight(), - ) - - AppTextField( - value = textValue, - onValueChange = { - textValue = it - log.d { "CEV sel ${textValue.selection.start}" } - onTextChange?.invoke(it.text) - }, - visualTransformation = visualTransformationToUse, - readOnly = isReadOnly, - textStyle = LocalTextStyle.current.copy( - fontFamily = FontFamily.Monospace, - fontSize = LocalFont.current.codeEditorBodyFontSize, - ), - colors = colors, - onTextLayout = { textLayoutResult = it }, - modifier = Modifier.fillMaxSize().verticalScroll(scrollState) - .focusRequester(textFieldFocusRequester) - .run { - if (!isReadOnly) { - this.onPreviewKeyEvent { - if (it.type == KeyEventType.KeyDown) { - when (it.key) { - Key.Enter -> { - if (!it.isShiftPressed - && !it.isAltPressed - && !it.isCtrlPressed - && !it.isMetaPressed - ) { - onPressEnterAddIndent() - true - } else { - false - } - } - - Key.Tab -> { - onPressTab(it.isShiftPressed) - true - } - - else -> false - } - } else { - false - } - } - } else { - this - } - } - .run { - if (testTag != null) { - testTag(testTag) - } else { - this - } - } - )*/ - -// var bigTextValue by remember(textValue.text.length, textValue.text.hashCode()) { mutableStateOf(BigText.createFromLargeString(text)) } // FIXME performance - LaunchedEffect(bigTextFieldState, onTextChange) { bigTextFieldState.valueChangesFlow .chunkedLatest(200.milliseconds()) @@ -644,11 +511,6 @@ fun CodeEditorView( //}, , fontSize = LocalFont.current.codeEditorBodyFontSize, -// textStyle = LocalTextStyle.current.copy( -// fontFamily = FontFamily.Monospace, -// fontSize = LocalFont.current.codeEditorBodyFontSize, -// ), -// colors = colors, scrollState = scrollState, onTextLayout = { layoutResult = it }, keyboardInputProcessor = object : BigTextKeyboardInputProcessor { @@ -770,110 +632,6 @@ data class SearchOptions( val isWholeWord: Boolean, // ignore if isRegex is true ) -@Composable -fun LineNumbersView( - modifier: Modifier = Modifier, - scrollState: ScrollState, - textLayoutResult: TextLayoutResult?, - lineTops: List?, - collapsableLines: List, - collapsedLines: List, - onCollapseLine: (Int) -> Unit, - onExpandLine: (Int) -> Unit, -) = with(LocalDensity.current) { - val colours = LocalColor.current - val fonts = LocalFont.current - var size by remember { mutableStateOf(null) } - val textStyle = LocalTextStyle.current.copy( - fontSize = fonts.codeEditorLineNumberFontSize, - fontFamily = FontFamily.Monospace, - color = colours.unimportant, - ) - log.v { "LineNumbersView ${size != null} && ${textLayoutResult != null} && ${lineTops != null}" } - var lastTextLayoutResult by remember { mutableStateOf(textLayoutResult) } - var lastLineTops by remember { mutableStateOf(lineTops) } - - val textLayoutResult = textLayoutResult ?: lastTextLayoutResult - val lineTops = lineTops ?: lastLineTops - - lastTextLayoutResult = textLayoutResult - lastLineTops = lineTops - - val collapsedLinesState = CollapsedLinesState(collapsableLines = collapsableLines, collapsedLines = collapsedLines) - - var lineHeight = 0f - val viewportTop = scrollState.value.toFloat() - val (firstLine, lastLine) = if (size != null && textLayoutResult != null && lineTops != null) { - val viewportBottom = viewportTop + size!!.height - log.d { "LineNumbersView before calculation" } - // 0-based line index - // include the partially visible line before the first line that is entirely visible - val firstLine = maxOf(0, lineTops.binarySearchForInsertionPoint { if (it >= viewportTop) 1 else -1 } - 1) - val lastLine = lineTops.binarySearchForInsertionPoint { if (it > viewportBottom) 1 else -1 } - log.v { "LineNumbersView $firstLine ~ <$lastLine / $viewportTop ~ $viewportBottom" } - log.v { "lineTops = $lineTops" } - log.v { "collapsedLines = $collapsedLines" } - log.d { "LineNumbersView after calculation" } - lineHeight = textLayoutResult.getLineBottom(0) - textLayoutResult.getLineTop(0) - - firstLine to lastLine - } else { - 0 to -1 - } - CoreLineNumbersView( - firstLine = firstLine, - lastLine = minOf(lastLine, (lineTops?.size ?: 0) - 1), - totalLines = lineTops?.size ?: 1, - lineHeight = lineHeight.toDp(), - getLineOffset = { (lineTops!![it] - viewportTop).toDp() }, - textStyle = textStyle, - collapsedLinesState = collapsedLinesState, - onCollapseLine = onCollapseLine, - onExpandLine = onExpandLine, - modifier = modifier.onGloballyPositioned { size = it.size } - ) -} - -@OptIn(TemporaryApi::class) -@Composable -fun BigLineNumbersView( - modifier: Modifier = Modifier, - bigTextViewState: BigTextViewState, - textLayout: BigTextLayoutResult?, - scrollState: ScrollState, - collapsableLines: List, - collapsedLines: List, - onCollapseLine: (Int) -> Unit, - onExpandLine: (Int) -> Unit, -) = with(LocalDensity.current) { - val colours = LocalColor.current - val fonts = LocalFont.current - - val textStyle = LocalTextStyle.current.copy( - fontSize = fonts.codeEditorLineNumberFontSize, - fontFamily = FontFamily.Monospace, - color = colours.unimportant, - ) - val collapsedLinesState = CollapsedLinesState(collapsableLines = collapsableLines, collapsedLines = collapsedLines) - - val viewportTop = scrollState.value - val firstLine = textLayout?.findLineNumberByRowNumber(bigTextViewState.firstVisibleRow) ?: 0 - val lastLine = (textLayout?.findLineNumberByRowNumber(bigTextViewState.lastVisibleRow) ?: -100) + 1 - log.v { "lastVisibleRow = ${bigTextViewState.lastVisibleRow} (L $lastLine); totalLines = ${textLayout?.totalLinesBeforeTransformation}" } - CoreLineNumbersView( - firstLine = firstLine, - lastLine = minOf(lastLine, textLayout?.totalLinesBeforeTransformation ?: 1), - totalLines = textLayout?.totalLinesBeforeTransformation ?: 1, - lineHeight = (textLayout?.rowHeight ?: 0f).toDp(), - getLineOffset = { (textLayout!!.getLineTop(it) - viewportTop).toDp() }, - textStyle = textStyle, - collapsedLinesState = collapsedLinesState, - onCollapseLine = onCollapseLine, - onExpandLine = onExpandLine, - modifier = modifier - ) -} - /** * The purpose of this class is to avoid unnecessary heavy computations of cache keys. * It must be wrapped by another @Composable with collapsableLines and collapsedLines as parameters. @@ -923,7 +681,6 @@ fun BigTextLineNumbersView( lastLine = minOf(lastLine, layoutText?.numOfOriginalLines ?: 1), totalLines = layoutText?.numOfOriginalLines ?: 1, lineHeight = (rowHeight).toDp(), -// getLineOffset = { (textLayout!!.getLineTop(it) - viewportTop).toDp() }, getLineOffset = { ((layoutText?.findFirstRowIndexByOriginalLineIndex(it).also { r -> log.v { "layoutText.findFirstRowIndexOfLine($it) = $r" } @@ -947,8 +704,6 @@ private fun CoreLineNumbersView( lineHeight: Dp, getLineOffset: (Int) -> Dp, textStyle: TextStyle, -// collapsableLines: List, -// collapsedLines: List, collapsedLinesState: CollapsedLinesState, onCollapseLine: (Int) -> Unit, onExpandLine: (Int) -> Unit, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index d5ea425e..ba8b72bb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -283,25 +283,6 @@ private fun CoreBigMonospaceText( (padding.calculateStartPadding(LayoutDirection.Ltr) + padding.calculateEndPadding(LayoutDirection.Ltr)).toPx() } var lineHeight by remember { mutableStateOf(0f) } -// var charWidth by remember { mutableStateOf(0f) } -// val numOfCharsPerLine = rememberLast(density.density, density.fontScale, fontSize, width) { -// if (width > 0) { -// Paragraph( -// text = "0".repeat(1000), -// style = textStyle, -// constraints = Constraints(maxWidth = contentWidth.toInt()), -// density = density, -// fontFamilyResolver = fontFamilyResolver, -// ).let { -// lineHeight = it.getLineTop(1) - it.getLineTop(0) -// charWidth = it.width / it.getLineEnd(0) -// it.getLineEnd(0) -// } -// } else { -// 0 -// } -// } - var layoutResult by remember(textLayouter, width) { mutableStateOf(null) } val transformedText: BigTextTransformed = remember(text, textTransformation) { @@ -322,7 +303,6 @@ private fun CoreBigMonospaceText( lineHeight = (textLayouter.charMeasurer as ComposeUnicodeCharMeasurer).getRowHeight() onTextLayout?.let { callback -> callback(BigTextSimpleLayoutResult( -// text = text, text = transformedText, // layout is only performed in `transformedText` rowHeight = lineHeight, ).also { layoutResult = it }) @@ -331,11 +311,6 @@ private fun CoreBigMonospaceText( if (width > 0) { log.d { "CoreBigMonospaceText set contentWidth = $contentWidth" } -// text.onLayoutCallback = { -// fireOnLayout() -// } -// text.setLayouter(textLayouter) -// text.setContentWidth(contentWidth) val startInstant = KInstant.now() @@ -357,20 +332,6 @@ private fun CoreBigMonospaceText( } } -// val layoutResult = rememberLast(transformedText.text.length, transformedText.hashCode(), textStyle, lineHeight, contentWidth, textLayouter) { -// textLayouter.layout( -// text = text.fullString(), -// transformedText = transformedText, -// lineHeight = lineHeight, -// contentWidth = contentWidth, -// ).also { -// if (onTextLayout != null) { -// onTextLayout(it) -// } -// } -// } -// val rowStartCharIndices = layoutResult.rowStartCharIndices - rememberLast(height, transformedText.numOfRows, lineHeight) { scrollState::class.declaredMemberProperties.first { it.name == "maxValue" } .apply { @@ -394,7 +355,6 @@ private fun CoreBigMonospaceText( textTransformation.initialize(text, transformedText).also { val endInstant = KInstant.now() log.d { "CoreBigMonospaceText init transformedState ${it.hashCode()} took ${endInstant - startInstant}" } -// (transformedText as BigTextImpl).layout() // FIXME remove if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("init transformedState") } @@ -454,26 +414,11 @@ private fun CoreBigMonospaceText( fun getTransformedCharIndex(x: Float, y: Float, mode: ResolveCharPositionMode): Int { val row = ((viewportTop + y) / lineHeight).toInt() val maxIndex = maxOf(0, transformedText.length - if (mode == ResolveCharPositionMode.Selection) 1 else 0) -// val col = (x / charWidth).toInt() if (row > transformedText.lastRowIndex) { return maxIndex } else if (row < 0) { return 0 } -// val numCharsInThisRow = if (row + 1 <= layoutResult.rowStartCharIndices.lastIndex) { -// layoutResult.rowStartCharIndices[row + 1] - layoutResult.rowStartCharIndices[row] - 1 -// } else { -// maxOf(0, transformedText.text.length - layoutResult.rowStartCharIndices[row] - if (mode == ResolveCharPositionMode.Selection) 1 else 0) -// } -// val charIndex = (layoutResult.rowStartCharIndices[row] .. layoutResult.rowStartCharIndices[row] + numCharsInThisRow).let { range -> -// var accumWidth = 0f -// range.first { -// if (it < range.last) { -// accumWidth += layoutResult.findCharWidth(transformedText.text.substring(it..it)) -// } -// return@first (x < accumWidth || it >= range.last) -// } -// } val rowString = transformedText.findRowString(row) val rowPositionStart = transformedText.findRowPositionStartIndexByRowIndex(row) @@ -482,7 +427,7 @@ private fun CoreBigMonospaceText( accumWidth += textLayouter.charMeasurer.findCharWidth(it.toString()) x < accumWidth }.takeIf { it >= 0 } - ?: rowString.length - if (rowString.endsWith('\n')) 1 else 0 + ?: (rowString.length - if (rowString.endsWith('\n')) 1 else 0) return minOf(maxIndex, rowPositionStart + charIndex) } @@ -939,7 +884,6 @@ private fun CoreBigMonospaceText( true } it.key in listOf(Key.DirectionUp, Key.DirectionDown) -> { -// val row = layoutResult.rowStartCharIndices.binarySearchForMaxIndexOfValueAtMost(viewState.transformedCursorIndex) val row = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) val newRow = row + if (it.key == Key.DirectionDown) 1 else -1 var newTransformedPosition = Unit.let { @@ -1148,7 +1092,7 @@ private fun CoreBigMonospaceText( } } .onFocusChanged { - log.v { "BigMonospaceText onFocusChanged ${it.isFocused}" } + log.v { "BigMonospaceText onFocusChanged ${it.isFocused} ${it.hasFocus} ${it.isCaptured}" } isFocused = it.isFocused if (isEditable) { if (it.isFocused) { @@ -1197,7 +1141,6 @@ private fun CoreBigMonospaceText( .semantics { log.d { "semantic lambda" } if (isEditable) { -// editableText = AnnotatedString(text.buildString(), transformedText.text.spanStyles) editableText = AnnotatedString(transformedText.buildString()) setText { viewState.selection = 0 .. text.lastIndex @@ -1209,7 +1152,6 @@ private fun CoreBigMonospaceText( true } } else { -// this.text = AnnotatedString(text.buildString(), transformedText.text.spanStyles) this.text = AnnotatedString(transformedText.buildString()) setText { false } insertTextAtCursor { false } From 635b6afc79f07bc992fd59c4dfa410e67cd914eb Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sun, 3 Nov 2024 20:38:31 +0800 Subject: [PATCH 169/195] fix infinite loop --- .../multiplatform/hellohttp/util/ObjectRef.kt | 8 ++++++++ .../hellohttp/ux/CodeEditorView.kt | 3 ++- .../hellohttp/ux/bigtext/BigTextFieldState.kt | 19 +++++++++++++------ 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ObjectRef.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ObjectRef.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ObjectRef.kt new file mode 100644 index 00000000..55347802 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/ObjectRef.kt @@ -0,0 +1,8 @@ +package com.sunnychung.application.multiplatform.hellohttp.util + +class ObjectRef(val ref: T) { + override fun equals(other: Any?): Boolean { + if (other !is ObjectRef<*>) return false + return ref === other.ref + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 551a436a..0ad1e4aa 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -65,6 +65,7 @@ import com.sunnychung.application.multiplatform.hellohttp.extension.insert import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.model.SyntaxHighlight +import com.sunnychung.application.multiplatform.hellohttp.util.ObjectRef import com.sunnychung.application.multiplatform.hellohttp.util.TreeRangeMaps import com.sunnychung.application.multiplatform.hellohttp.util.chunkedLatest import com.sunnychung.application.multiplatform.hellohttp.util.log @@ -491,7 +492,7 @@ fun CodeEditorView( val string = it.bigText.buildCharSequence() as AnnotatedString log.d { "${bigTextFieldState.text} : ${it.bigText} onTextChange(${string.text.abbr()})" } onTextChange(string.text) - secondCacheKey.value = string.text + secondCacheKey.value = ObjectRef(string.text) } bigTextValueId = it.changeId searchTrigger.trySend(Unit) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt index bd20627c..0cf85d52 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.text.AnnotatedString +import com.sunnychung.application.multiplatform.hellohttp.util.ObjectRef import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -40,16 +41,22 @@ fun rememberAnnotatedBigTextFieldState(initialValue: AnnotatedString = Annotated } @Composable -fun rememberAnnotatedBigTextFieldState(initialValue: String = ""): Pair, MutableState> { - val secondCacheKey = rememberSaveable { mutableStateOf(initialValue) } +fun rememberAnnotatedBigTextFieldState(initialValue: String = ""): Pair>, MutableState> { + val secondCacheKey = rememberSaveable { mutableStateOf(ObjectRef(initialValue)) } val state = rememberSaveable { - log.d { "cache miss 1" } + log.i { "cache miss 1" } mutableStateOf(BigTextFieldState(BigText.createFromLargeAnnotatedString(AnnotatedString(initialValue)), BigTextViewState())) } - if (initialValue !== secondCacheKey.value) { - log.d { "cache miss. old key2 = ${secondCacheKey.value.abbr()}; new key2 = ${initialValue.abbr()}" } - secondCacheKey.value = initialValue + if (ObjectRef(initialValue) != secondCacheKey.value) { + log.i { "cache miss. old key2 = ${secondCacheKey.value.ref.abbr()}; new key2 = ${initialValue.abbr()}" } + +// // set a value different from initialValue, otherwise the value 'equals' to the old value and would not be set to secondCacheKey.value +// secondCacheKey.value = if (initialValue.isNotEmpty()) "" else " " +// secondCacheKey.value = initialValue + + secondCacheKey.value = ObjectRef(initialValue) state.value = BigTextFieldState(BigText.createFromLargeAnnotatedString(AnnotatedString(initialValue)), BigTextViewState()) +// log.i { "new view state = ${state.value.viewState}" } } return secondCacheKey to state } From 6d13c792699770920cdf68676d80d8c9a16220c0 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 4 Nov 2024 00:17:24 +0800 Subject: [PATCH 170/195] fix undo/redo in BigMonospaceText should restore selection as well --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 84 ++++++++++++------- .../hellohttp/ux/bigtext/BigTextImpl.kt | 49 ++++++++--- .../ux/bigtext/BigTextInputChangeOperation.kt | 12 ++- .../ux/bigtext/BigTextUndoMetadata.kt | 6 ++ .../hellohttp/ux/bigtext/BigTextViewState.kt | 8 +- .../test/bigtext/BigTextUndoRedoTest.kt | 16 ++-- 6 files changed, 120 insertions(+), 55 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextUndoMetadata.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index ba8b72bb..3a317c97 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -299,6 +299,15 @@ private fun CoreBigMonospaceText( // log.v { "text = |${text.buildString()}|" } // log.v { "transformedText = |${transformedText.buildString()}|" } + remember(text, viewState) { + text.undoMetadataSupplier = { + BigTextUndoMetadata( + cursor = viewState.cursorIndex, + selection = viewState.selection, + ) + } + } + fun fireOnLayout() { lineHeight = (textLayouter.charMeasurer as ComposeUnicodeCharMeasurer).getRowHeight() onTextLayout?.let { callback -> @@ -455,6 +464,26 @@ private fun CoreBigMonospaceText( ) } + fun scrollToCursor() { + val layoutResult = layoutResult ?: return + + // scroll to cursor position if out of visible range + val visibleVerticalRange = scrollState.value .. scrollState.value + height + val row = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) + val rowVerticalRange = layoutResult.getTopOfRow(row).toInt() .. layoutResult.getBottomOfRow(row).toInt() + if (rowVerticalRange !in visibleVerticalRange) { + val scrollToPosition = if (rowVerticalRange.start < visibleVerticalRange.start) { + rowVerticalRange.start + } else { + // scroll to a position that includes the bottom of the row + a little space + minOf(layoutResult.bottom.toInt(), maxOf(0, rowVerticalRange.endInclusive + maxOf(2, (layoutResult.rowHeight * 0.5).toInt()) - height)) + } + coroutineScope.launch { + scrollState.animateScrollTo(scrollToPosition) + } + } + } + fun updateViewState() { viewState.lastVisibleRow = minOf(viewState.lastVisibleRow, transformedText.lastRowIndex) log.d { "lastVisibleRow = ${viewState.lastVisibleRow}, lastRowIndex = ${transformedText.lastRowIndex}" } @@ -498,15 +527,16 @@ private fun CoreBigMonospaceText( val start = viewState.selection.start val endExclusive = viewState.selection.endInclusive + 1 delete(start, endExclusive) - if (isSaveUndoSnapshot) { - text.recordCurrentChangeSequenceIntoUndoHistory() - } viewState.selection = EMPTY_SELECTION_RANGE // cannot use IntRange.EMPTY as `viewState.selection.start` is in use viewState.transformedSelection = EMPTY_SELECTION_RANGE viewState.cursorIndex = start viewState.updateTransformedCursorIndexByOriginal(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex + + if (isSaveUndoSnapshot) { + text.recordCurrentChangeSequenceIntoUndoHistory() + } } } @@ -524,9 +554,6 @@ private fun CoreBigMonospaceText( } val insertPos = viewState.cursorIndex insertAt(insertPos, textInput) - if (isSaveUndoSnapshot) { - text.recordCurrentChangeSequenceIntoUndoHistory() - } updateViewState() if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("transformedText onType '${textInput.string().replace("\n", "\\n")}'") @@ -536,6 +563,9 @@ private fun CoreBigMonospaceText( viewState.updateTransformedCursorIndexByOriginal(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex log.v { "set cursor pos 2 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } + if (isSaveUndoSnapshot) { + text.recordCurrentChangeSequenceIntoUndoHistory() + } } fun onDelete(direction: TextFBDirection): Boolean { @@ -552,12 +582,12 @@ private fun CoreBigMonospaceText( if (cursor + 1 <= text.length) { onValuePreChange(BigTextChangeEventType.Delete, cursor, cursor + 1) text.delete(cursor, cursor + 1) - text.recordCurrentChangeSequenceIntoUndoHistory() onValuePostChange(BigTextChangeEventType.Delete, cursor, cursor + 1) updateViewState() if (log.config.minSeverity <= Severity.Verbose) { (transformedText as BigTextImpl).printDebug("transformedText onDelete $direction") } + text.recordCurrentChangeSequenceIntoUndoHistory() return true } } @@ -565,7 +595,6 @@ private fun CoreBigMonospaceText( if (cursor - 1 >= 0) { onValuePreChange(BigTextChangeEventType.Delete, cursor - 1, cursor) text.delete(cursor - 1, cursor) - text.recordCurrentChangeSequenceIntoUndoHistory() onValuePostChange(BigTextChangeEventType.Delete, cursor - 1, cursor) updateViewState() if (log.config.minSeverity <= Severity.Verbose) { @@ -576,6 +605,7 @@ private fun CoreBigMonospaceText( viewState.updateTransformedCursorIndexByOriginal(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex log.v { "set cursor pos 3 => ${viewState.cursorIndex} t ${viewState.transformedCursorIndex}" } + text.recordCurrentChangeSequenceIntoUndoHistory() return true } } @@ -583,9 +613,9 @@ private fun CoreBigMonospaceText( return false } - fun onUndoRedo(operation: (BigTextChangeCallback) -> Unit) { + fun onUndoRedo(operation: (BigTextChangeCallback) -> Pair) { var lastChangeEnd = -1 - operation(object : BigTextChangeCallback { + val stateToBeRestored = operation(object : BigTextChangeCallback { override fun onValuePreChange( eventType: BigTextChangeEventType, changeStartIndex: Int, @@ -606,7 +636,17 @@ private fun CoreBigMonospaceText( } } }) - if (lastChangeEnd >= 0) { + updateViewState() + (stateToBeRestored.second as? BigTextUndoMetadata)?.let { state -> + viewState.selection = state.selection + viewState.updateTransformedSelectionBySelection(transformedText) + viewState.cursorIndex = state.cursor + viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex + scrollToCursor() + return + } + if (lastChangeEnd >= 0) { // this `if` should never execute viewState.cursorIndex = lastChangeEnd viewState.updateTransformedCursorIndexByOriginal(transformedText) viewState.transformedSelectionStart = viewState.transformedCursorIndex @@ -690,29 +730,9 @@ private fun CoreBigMonospaceText( return viewState.cursorIndex + wordBoundaryAt } - fun scrollToCursor() { - val layoutResult = layoutResult ?: return - - // scroll to cursor position if out of visible range - val visibleVerticalRange = scrollState.value .. scrollState.value + height - val row = transformedText.findRowIndexByPosition(viewState.transformedCursorIndex) - val rowVerticalRange = layoutResult.getTopOfRow(row).toInt() .. layoutResult.getBottomOfRow(row).toInt() - if (rowVerticalRange !in visibleVerticalRange) { - val scrollToPosition = if (rowVerticalRange.start < visibleVerticalRange.start) { - rowVerticalRange.start - } else { - // scroll to a position that includes the bottom of the row + a little space - minOf(layoutResult.bottom.toInt(), maxOf(0, rowVerticalRange.endInclusive + maxOf(2, (layoutResult.rowHeight * 0.5).toInt()) - height)) - } - coroutineScope.launch { - scrollState.animateScrollTo(scrollToPosition) - } - } - } - fun updateOriginalCursorOrSelection(newPosition: Int, isSelection: Boolean) { val oldCursorPosition = viewState.cursorIndex - viewState.cursorIndex = newPosition // TODO scroll to new position + viewState.cursorIndex = newPosition viewState.updateTransformedCursorIndexByOriginal(transformedText) if (isSelection) { val selectionStart = if (viewState.hasSelection()) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt index 703c0bba..457af955 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextImpl.kt @@ -71,7 +71,12 @@ open class BigTextImpl( var decorator: BigTextDecorator? = null + var undoMetadataSupplier: (() -> Any?)? = null + + private var currentUndoMetadata: Any? = null + private var currentRedoMetadata: Any? = null var currentChanges: MutableList = mutableListOf() + private set val undoHistory = CircularList(undoHistoryCapacity) val redoHistory = CircularList(undoHistoryCapacity) @@ -419,6 +424,7 @@ open class BigTextImpl( leftStringLength = 0 } if (isUndoEnabled) { + recordCurrentUndoMetadata() currentChanges += BigTextInputChange( type = BigTextChangeEventType.Insert, buffer = buffer, @@ -957,6 +963,7 @@ open class BigTextImpl( isD = true } if (isUndoEnabled) { + recordCurrentUndoMetadata() currentChanges += BigTextInputChange( type = BigTextChangeEventType.Delete, buffer = node.value.buffer, @@ -1031,9 +1038,18 @@ open class BigTextImpl( } if (currentChanges.isNotEmpty()) { - undoHistory.push(BigTextInputOperation(currentChanges.toList())) + undoHistory.push(BigTextInputOperation(currentChanges.toList(), currentUndoMetadata, undoMetadataSupplier?.invoke())) currentChanges = mutableListOf() + recordCurrentUndoMetadata() + } + } + + protected fun recordCurrentUndoMetadata() { + if (currentChanges.isEmpty()) { + currentUndoMetadata = undoMetadataSupplier?.invoke() } + currentRedoMetadata = undoMetadataSupplier?.invoke() + log.d { "reset um = $currentUndoMetadata, rm = $currentRedoMetadata" } } protected fun clearRedoHistory() { @@ -1110,33 +1126,42 @@ open class BigTextImpl( } } - fun undo(callback: BigTextChangeCallback? = null): Boolean { + fun undo(callback: BigTextChangeCallback? = null): Pair { if (!isUndoEnabled) { - return false + return false to null } if (currentChanges.isNotEmpty()) { + val undoMetadata = currentUndoMetadata + val redoMetadata = currentRedoMetadata applyReverseChangeSequence(currentChanges, callback) - redoHistory.push(BigTextInputOperation(currentChanges.toList())) + redoHistory.push(BigTextInputOperation(currentChanges.toList(), undoMetadata, redoMetadata)) currentChanges = mutableListOf() - return true + recordCurrentUndoMetadata() + return true to undoMetadata } - val lastOperation = undoHistory.removeHead() ?: return false + val lastOperation = undoHistory.removeHead() ?: return false to null applyReverseChangeSequence(lastOperation.changes, callback) + currentUndoMetadata = lastOperation.undoMetadata + currentRedoMetadata = lastOperation.redoMetadata + log.d { "undo set um = $currentUndoMetadata, rm = $currentRedoMetadata" } redoHistory.push(lastOperation) - return true + return true to lastOperation.undoMetadata } - fun redo(callback: BigTextChangeCallback? = null): Boolean { + fun redo(callback: BigTextChangeCallback? = null): Pair { if (!isUndoEnabled) { - return false + return false to null } if (currentChanges.isNotEmpty()) { // should not happen - return false + return false to null } - val lastOperation = redoHistory.removeHead() ?: return false + val lastOperation = redoHistory.removeHead() ?: return false to null applyChangeSequence(lastOperation.changes, callback) + currentUndoMetadata = lastOperation.undoMetadata + currentRedoMetadata = lastOperation.redoMetadata + log.d { "undo set um = $currentUndoMetadata, rm = $currentRedoMetadata" } undoHistory.push(lastOperation) - return true + return true to lastOperation.redoMetadata } fun isUndoable(): Boolean = isUndoEnabled && (currentChanges.isNotEmpty() || undoHistory.isNotEmpty) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputChangeOperation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputChangeOperation.kt index 47be6a73..dae9542e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputChangeOperation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextInputChangeOperation.kt @@ -3,7 +3,17 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import com.sunnychung.application.multiplatform.hellohttp.extension.length data class BigTextInputOperation( - val changes: List + val changes: List, + + /** + * Metadata to apply if changes of this operation have been reversed. + */ + val undoMetadata: Any?, + + /** + * Metadata to apply if changes of this operation have been applied. + */ + val redoMetadata: Any?, ) data class BigTextInputChange( diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextUndoMetadata.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextUndoMetadata.kt new file mode 100644 index 00000000..dabb1f1f --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextUndoMetadata.kt @@ -0,0 +1,6 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +data class BigTextUndoMetadata( + val cursor: Int, + val selection: IntRange, +) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt index db97a54a..7d1e372e 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextViewState.kt @@ -50,8 +50,12 @@ class BigTextViewState { } internal fun updateTransformedSelectionBySelection(transformedText: BigTextTransformed) { - transformedSelection = transformedText.findTransformedPositionByOriginalPosition(selection.first) .. - transformedText.findTransformedPositionByOriginalPosition(selection.last) + transformedSelection = if (!selection.isEmpty()) { + transformedText.findTransformedPositionByOriginalPosition(selection.first) .. + transformedText.findTransformedPositionByOriginalPosition(selection.last) + } else { + IntRange.EMPTY + } } internal var transformedCursorIndex by mutableStateOf(0) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextUndoRedoTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextUndoRedoTest.kt index 21db5622..76f93eca 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextUndoRedoTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextUndoRedoTest.kt @@ -121,7 +121,7 @@ class BigTextUndoRedoTest { t.delete(6 .. 6) assertEquals("abcdef", t.buildString()) listOf("abcdefg", "abcd").forEach { expected -> - assertEquals(true, t.undo()) + assertEquals(true, t.undo().first) assertEquals(expected, t.buildString()) } @@ -129,7 +129,7 @@ class BigTextUndoRedoTest { t.append("x") assertEquals("abcdx", t.buildString()) (1..10).forEach { - assertEquals(false, t.redo()) + assertEquals(false, t.redo().first) assertEquals("abcdx", t.buildString()) } @@ -139,27 +139,27 @@ class BigTextUndoRedoTest { fun assertUndoRedoUndo(reversedExpectedStrings: List, t: BigTextImpl) { assertEquals(reversedExpectedStrings.first(), t.buildString()) reversedExpectedStrings.stream().skip(1).forEach { expected -> - assertEquals(true, t.undo()) + assertEquals(true, t.undo().first) assertEquals(expected, t.buildString()) } (1..3).forEach { - assertEquals(false, t.undo()) + assertEquals(false, t.undo().first) assertEquals(reversedExpectedStrings.last(), t.buildString()) } reversedExpectedStrings.asReversed().stream().skip(1).forEach { expected -> - assertEquals(true, t.redo()) + assertEquals(true, t.redo().first) assertEquals(expected, t.buildString()) } (1..10).forEach { - assertEquals(false, t.redo()) + assertEquals(false, t.redo().first) assertEquals(reversedExpectedStrings.first(), t.buildString()) } reversedExpectedStrings.stream().skip(1).forEach { expected -> - assertEquals(true, t.undo()) + assertEquals(true, t.undo().first) assertEquals(expected, t.buildString()) } (1..10).forEach { - assertEquals(false, t.undo()) + assertEquals(false, t.undo().first) assertEquals(reversedExpectedStrings.last(), t.buildString()) } } From 528b33df0abb5f2222945b90ec6cfbb2175146f9 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 4 Nov 2024 23:46:42 +0800 Subject: [PATCH 171/195] update compose multiplatform version from 1.6.2 to 1.6.11 in attempt to fix low chance to pass all UX test cases in CI runners --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 02bd1761..9a89a351 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ kotlin.code.style=official kotlin.version=1.8.0 agp.version=7.3.0 -compose.version=1.6.2 +compose.version=1.6.11 From 8f7a11e1e5021b3e096465a896863e80463f98a1 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Mon, 4 Nov 2024 23:51:30 +0800 Subject: [PATCH 172/195] fix the flow collecting BigMonospaceText changes never run, and fix response body did not reuse BigMonospaceText and its states --- .../hellohttp/util/MutableObjectRef.kt | 12 ++++++ .../hellohttp/ux/CodeEditorView.kt | 7 +++- .../hellohttp/ux/ResponseViewerView.kt | 16 ++++---- .../hellohttp/ux/bigtext/BigTextFieldState.kt | 40 ++++++++++++------- .../test/util/ChunkedLatestFlowTest.kt | 27 +++++++++++++ 5 files changed, 79 insertions(+), 23 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/MutableObjectRef.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/MutableObjectRef.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/MutableObjectRef.kt new file mode 100644 index 00000000..a585f294 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/MutableObjectRef.kt @@ -0,0 +1,12 @@ +package com.sunnychung.application.multiplatform.hellohttp.util + +class MutableObjectRef(var value: T) { + override fun equals(other: Any?): Boolean { + if (other !is MutableObjectRef<*>) return false + return value === other.value + } + +// override fun hashCode(): Int { +// return ref.hashCode() +// } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 0ad1e4aa..2496eabb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -104,6 +104,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import java.util.regex.Pattern @@ -484,7 +485,9 @@ fun CodeEditorView( // return@Row // compose bug: return here would crash } else { LaunchedEffect(bigTextFieldState, onTextChange) { + 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 ${it.changeId} ${it.bigText.buildString()}" } @@ -492,10 +495,12 @@ fun CodeEditorView( val string = it.bigText.buildCharSequence() as AnnotatedString log.d { "${bigTextFieldState.text} : ${it.bigText} onTextChange(${string.text.abbr()})" } onTextChange(string.text) - secondCacheKey.value = ObjectRef(string.text) + secondCacheKey.value = string.text } bigTextValueId = it.changeId searchTrigger.trySend(Unit) + + bigTextFieldState.markConsumed(it.sequence) } } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt index 9ccce814..1644cf05 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/ResponseViewerView.kt @@ -529,14 +529,16 @@ fun BodyViewerView( } isJsonPathError = hasError - val prettifyResult = try { - if (isRaw) { - selectedView.prettifier!!.prettify(contentToUse) - } else { - PrettifyResult(contentToUse.decodeToString()) + val prettifyResult = remember(contentToUse) { + try { + if (isRaw) { + selectedView.prettifier!!.prettify(contentToUse) + } else { + PrettifyResult(contentToUse.decodeToString()) + } + } catch (e: Throwable) { + PrettifyResult(contentToUse.decodeToString() ?: "") } - } catch (e: Throwable) { - PrettifyResult(contentToUse.decodeToString() ?: "") } CopyableContentContainer(textToCopy = prettifyResult.prettyString, modifier = modifier) { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt index 0cf85d52..03c2b12f 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigTextFieldState.kt @@ -2,13 +2,15 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.text.AnnotatedString -import com.sunnychung.application.multiplatform.hellohttp.util.ObjectRef +import com.sunnychung.application.multiplatform.hellohttp.util.MutableObjectRef import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.filter @Composable fun rememberBigTextFieldState(initialValue: String = ""): Pair, MutableState> { @@ -41,20 +43,16 @@ fun rememberAnnotatedBigTextFieldState(initialValue: AnnotatedString = Annotated } @Composable -fun rememberAnnotatedBigTextFieldState(initialValue: String = ""): Pair>, MutableState> { - val secondCacheKey = rememberSaveable { mutableStateOf(ObjectRef(initialValue)) } +fun rememberAnnotatedBigTextFieldState(initialValue: String = ""): Pair, MutableState> { + val secondCacheKey by rememberSaveable { mutableStateOf(MutableObjectRef(initialValue)) } val state = rememberSaveable { log.i { "cache miss 1" } mutableStateOf(BigTextFieldState(BigText.createFromLargeAnnotatedString(AnnotatedString(initialValue)), BigTextViewState())) } - if (ObjectRef(initialValue) != secondCacheKey.value) { - log.i { "cache miss. old key2 = ${secondCacheKey.value.ref.abbr()}; new key2 = ${initialValue.abbr()}" } - -// // set a value different from initialValue, otherwise the value 'equals' to the old value and would not be set to secondCacheKey.value -// secondCacheKey.value = if (initialValue.isNotEmpty()) "" else " " -// secondCacheKey.value = initialValue + if (initialValue !== secondCacheKey.value) { + log.i { "cache miss. old key2 = ${secondCacheKey.value.abbr()}; new key2 = ${initialValue.abbr()}" } - secondCacheKey.value = ObjectRef(initialValue) + secondCacheKey.value = initialValue state.value = BigTextFieldState(BigText.createFromLargeAnnotatedString(AnnotatedString(initialValue)), BigTextViewState()) // log.i { "new view state = ${state.value.viewState}" } } @@ -70,18 +68,30 @@ fun CharSequence.abbr(): CharSequence { } class BigTextFieldState(val text: BigTextImpl, val viewState: BigTextViewState) { + private var lastSequence = -1 + private var lastConsumedSequence = -1 + private val valueChangesMutableFlow = MutableSharedFlow( - replay = 0, + replay = 1, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) - val valueChangesFlow: SharedFlow = valueChangesMutableFlow + val valueChangesFlow: Flow = valueChangesMutableFlow + .filter { it.sequence > lastConsumedSequence } internal fun emitValueChange(changeId: Long) { logV.v { "BigTextFieldState emitValueChange A $changeId" } // logV.v { "BigTextFieldState emitValueChange B $changeId" } - valueChangesMutableFlow.tryEmit(BigTextChangeWithoutDetail(changeId = changeId, bigText = text)) + valueChangesMutableFlow.tryEmit(BigTextChangeWithoutDetail(changeId = changeId, bigText = text, sequence = ++lastSequence)).let { isSuccess -> + if (!isSuccess) { + logV.w { "BigTextFieldState emitValueChange fail. #Subscribers = ${valueChangesMutableFlow.subscriptionCount.value}" } + } + } + } + + fun markConsumed(sequence: Int) { + lastConsumedSequence = maxOf(lastConsumedSequence, sequence) } } -class BigTextChangeWithoutDetail(val changeId: Long, val bigText: BigTextImpl) +class BigTextChangeWithoutDetail(val changeId: Long, val bigText: BigTextImpl, val sequence: Int) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt index d45818d0..9a92980c 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.Collections import kotlin.test.Test @@ -116,4 +117,30 @@ class ChunkedLatestFlowTest { assertEquals(listOf(10), results) } } + + @Test + fun collectAfterCancel() { + runBlocking { + val results = Collections.synchronizedList(mutableListOf()) + + coroutineScope { + val flow = flow { + (0..12).forEach { + emit(it) + delay(145) + } + } + .chunkedLatest(500.milliseconds()) + .onEach { results += it } + .launchIn(this) + + launch { + delay(410) + flow.cancel() + } + } + + assertEquals(listOf(2), results) + } + } } From 0296d668a86a7ef5e7e44ba642e8b51d13b481b9 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Tue, 5 Nov 2024 00:30:30 +0800 Subject: [PATCH 173/195] add Option-Backspace or Ctrl-Backspace to BigMonospaceText to delete previous word --- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 3a317c97..4eff52cb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -513,6 +513,7 @@ private fun CoreBigMonospaceText( transformedState ) textDecorator?.afterTextChange(event) + log.d { "call onTextChange for ${event.changeId}" } onTextChange(event) } @@ -830,8 +831,24 @@ private fun CoreBigMonospaceText( onType("\n") true } - it.key == Key.Backspace -> { - onDelete(TextFBDirection.Backward) + it.key == Key.Backspace -> when { + (currentOS() == MacOS && it.isAltPressed) || + (currentOS() != MacOS && it.isCtrlPressed) -> { + // delete previous word + val previousWordPosition = findPreviousWordBoundaryPositionFromCursor() + if (previousWordPosition >= viewState.cursorIndex) { + return false + } + delete(previousWordPosition, viewState.cursorIndex) + updateViewState() + // update cursor after invoking listeners, because a transformation or change may take place + viewState.cursorIndex = previousWordPosition + viewState.updateTransformedCursorIndexByOriginal(transformedText) + viewState.transformedSelectionStart = viewState.transformedCursorIndex + text.recordCurrentChangeSequenceIntoUndoHistory() + true + } + else -> onDelete(TextFBDirection.Backward) } it.key == Key.Delete -> { onDelete(TextFBDirection.Forward) From 6dc103a40fe46089bed8e58b51b991e5d47f7142 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Tue, 5 Nov 2024 00:50:15 +0800 Subject: [PATCH 174/195] update CI test execution to exclude BigText tests as it is too slow to execute --- build.gradle.kts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index b2cff95d..20331655 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -163,6 +163,11 @@ tasks.withType { showStandardStreams = true exceptionFormat = TestExceptionFormat.FULL } + if (project.hasProperty("isCI") && project.property("isCI").toString().toBoolean()) { + filter { + excludeTestsMatching("com.sunnychung.application.multiplatform.hellohttp.test.bigtext.**") + } + } } compose.desktop { From e50c1fdd7026700023c43714f73463739dcff36b Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Tue, 5 Nov 2024 01:10:48 +0800 Subject: [PATCH 175/195] update CI to always generate test reports --- .github/workflows/run-test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-test.yaml b/.github/workflows/run-test.yaml index 8f5f93bd..f479f65a 100644 --- a/.github/workflows/run-test.yaml +++ b/.github/workflows/run-test.yaml @@ -28,9 +28,9 @@ jobs: with: name: ux-test-result_${{ matrix.os }} path: ux-and-transport-test/build/reports/tests/test - if: ${{ !cancelled() }} + if: ${{ always() }} - uses: actions/upload-artifact@v3 with: name: unit-test-result_${{ matrix.os }} path: build/reports/tests - if: ${{ !cancelled() }} + if: ${{ always() }} From 9abbfef490f42b7e06437d166690ba045f8636e3 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Tue, 5 Nov 2024 21:30:29 +0800 Subject: [PATCH 176/195] update all performTextInput() calls in UX tests to work around Compose test's bug: Detected multithreaded access to SnapshotStateObserver: previousThreadId=708), currentThread={id=942, name=Thread-510 @coroutine#850669}. Note that observation on multiple threads in layout/draw is not supported. Make sure your measure/layout/draw for each Owner (AndroidComposeView) is executed on the same thread. https://issuetracker.google.com/issues/319395743 --- .../test/GraphqlRequestResponseTest.kt | 4 +- .../hellohttp/test/GrpcRequestResponseTest.kt | 2 +- .../hellohttp/test/RequestResponseTestUtil.kt | 39 ++++++++++++------- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GraphqlRequestResponseTest.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GraphqlRequestResponseTest.kt index dea07945..33b862bb 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GraphqlRequestResponseTest.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GraphqlRequestResponseTest.kt @@ -323,12 +323,12 @@ suspend fun ComposeUiTest.createGraphqlRequest(request: UserRequestTemplate, env val body = request.examples.first().body as GraphqlBody onNodeWithTag(TestTag.RequestGraphqlDocumentTextField.name) - .performTextInput(body.document) + .performTextInput(this, body.document) delayShort() // needed, otherwise document text field sometimes have no text inputted onNodeWithTag(TestTag.RequestGraphqlVariablesTextField.name) - .performTextInput(body.variables) + .performTextInput(this, body.variables) if (body.operationName != null) { delayShort() diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GrpcRequestResponseTest.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GrpcRequestResponseTest.kt index 737b563d..3dd2caec 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GrpcRequestResponseTest.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GrpcRequestResponseTest.kt @@ -351,7 +351,7 @@ class GrpcRequestResponseTest(testName: String, isSsl: Boolean, isMTls: Boolean) if (request.examples.first().body is StringBody) { onNodeWithTag(TestTag.RequestStringBodyTextField.name) .assertIsDisplayedWithRetry(this) - .performTextInput((request.examples.first().body as StringBody).value) + .performTextInput(this, (request.examples.first().body as StringBody).value) delayShort() } } diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt index 0cd135b4..645a28fa 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt @@ -127,7 +127,7 @@ suspend fun ComposeUiTest.createProjectIfNeeded() { .performClickWithRetry(this) waitUntilExactlyOneExists(hasTestTag(TestTag.ProjectNameAndSubprojectNameDialogTextField.name), 1500L) onNodeWithTag(TestTag.ProjectNameAndSubprojectNameDialogTextField.name) - .performTextInput("Test Project ${KZonedInstant.nowAtLocalZoneOffset().format("HH:mm:ss")}") + .performTextInput(this, "Test Project ${KZonedInstant.nowAtLocalZoneOffset().format("HH:mm:ss")}") waitForIdle() onNodeWithTag(TestTag.ProjectNameAndSubprojectNameDialogDoneButton.name) .performClickWithRetry(this) @@ -140,7 +140,7 @@ suspend fun ComposeUiTest.createProjectIfNeeded() { .performClickWithRetry(this) waitUntilExactlyOneExists(hasTestTag(TestTag.ProjectNameAndSubprojectNameDialogTextField.name), 1500L) onNodeWithTag(TestTag.ProjectNameAndSubprojectNameDialogTextField.name) - .performTextInput("Test Subproject") + .performTextInput(this, "Test Subproject") waitForIdle() onNodeWithTag(TestTag.ProjectNameAndSubprojectNameDialogDoneButton.name) .assertIsDisplayedWithRetry(this) @@ -428,7 +428,7 @@ suspend fun ComposeUiTest.createEnvironmentInEnvDialog(name: String) { waitForIdle() onNodeWithTag(TestTag.EnvironmentDialogEnvNameTextField.name) - .performTextInput(name) + .performTextInput(this, name) waitUntil(3.seconds().millis) { // one in list view and one in text field @@ -512,7 +512,7 @@ suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environmen onNodeWithTag(TestTag.RequestUrlTextField.name) .assertIsDisplayedWithRetry(this) - .performTextInput(request.url) + .performTextInput(this, request.url) delayShort() @@ -541,7 +541,7 @@ suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environmen if (body.isNotEmpty()) { onNodeWithTag(TestTag.RequestStringBodyTextField.name) .assertIsDisplayedWithRetry(this) - .performTextInput(body) + .performTextInput(this, body) delayShort() } } @@ -580,7 +580,7 @@ suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environmen ) ) .assertIsDisplayedWithRetry(this) - .performTextInput(it.key) + .performTextInput(this, it.key) delayShort() onNode( hasTestTag( @@ -608,7 +608,7 @@ suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environmen ) ) .assertIsDisplayedWithRetry(this) - .performTextInput(it.value) + .performTextInput(this, it.value) delayShort() } @@ -729,7 +729,7 @@ suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environmen ) ) .assertIsDisplayedWithRetry(this) - .performTextInput(it.key) + .performTextInput(this, it.key) delayShort() onNode( @@ -755,7 +755,7 @@ suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environmen ) ) .assertIsDisplayedWithRetry(this) - .performTextInput(it.value) + .performTextInput(this, it.value) delayShort() } } @@ -817,7 +817,7 @@ suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environmen ) ) .assertIsDisplayedWithRetry(this) - .performTextInput(it.key) + .performTextInput(this, it.key) delayShort() onNode( hasTestTag( @@ -842,7 +842,7 @@ suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environmen ) ) .assertIsDisplayedWithRetry(this) - .performTextInput(it.value) + .performTextInput(this, it.value) delayShort() } } @@ -874,11 +874,11 @@ suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environmen ) onNode(hasTestTag(buildTestTag(TestTagPart.RequestHeader, TestTagPart.Current, TestTagPart.Key, index)!!)) .assertIsDisplayedWithRetry(this) - .performTextInput(it.key) + .performTextInput(this, it.key) delayShort() onNode(hasTestTag(buildTestTag(TestTagPart.RequestHeader, TestTagPart.Current, TestTagPart.Value, index)!!)) .assertIsDisplayedWithRetry(this) - .performTextInput(it.value) + .performTextInput(this, it.value) delayShort() } } @@ -892,7 +892,7 @@ suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environmen onNode(hasTestTag(TestTag.RequestPreFlightScriptTextField.name)) .assertIsDisplayedWithRetry(this) - .performTextInput(baseExample.preFlight.executeCode) + .performTextInput(this, baseExample.preFlight.executeCode) waitUntil { onNode(hasTestTag(TestTag.RequestPreFlightScriptTextField.name)) @@ -1042,7 +1042,7 @@ suspend fun ComposeUiTest.sendPayload(payload: String, isCreatePayloadExample: B onNodeWithTag(TestTag.RequestPayloadTextField.name) .assertIsDisplayedWithRetry(this) - .performTextInput(payload) + .performTextInput(this, payload) delayShort() @@ -1185,3 +1185,12 @@ fun SemanticsNodeInteractionCollection.fetchSemanticsNodesWithRetry(host: Compos } } } + +/** + * To work around the bug: https://issuetracker.google.com/issues/319395743 + */ +fun SemanticsNodeInteraction.performTextInput(host: ComposeUiTest, s: String) { + host.runOnUiThread { + performTextInput(s) + } +} From 1bb652a7043ee2283e80cde851b6b84f74ea8d86 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 6 Nov 2024 20:01:30 +0800 Subject: [PATCH 177/195] fix consistent test fail with following reasons: - ComposeTimeoutException: Condition still not satisfied after 3000 ms - AssertionError: No node found that matches TestTag = 'EnvironmentDropdown/DropdownItem/*' in scrollable container --- .../hellohttp/test/RequestResponseTestUtil.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt index 645a28fa..a205e029 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt @@ -1169,7 +1169,9 @@ fun SemanticsNode.getTexts(): List { fun SemanticsNodeInteraction.fetchSemanticsNodeWithRetry(host: ComposeUiTest): SemanticsNode { while (true) { try { - return fetchSemanticsNode() + return host.runOnUiThread { + fetchSemanticsNode() + } } catch (e: IllegalArgumentException) { host.waitForIdle() } @@ -1179,7 +1181,9 @@ fun SemanticsNodeInteraction.fetchSemanticsNodeWithRetry(host: ComposeUiTest): S fun SemanticsNodeInteractionCollection.fetchSemanticsNodesWithRetry(host: ComposeUiTest): List { while (true) { try { - return fetchSemanticsNodes() + return host.runOnUiThread { + fetchSemanticsNodes() + } } catch (e: IllegalArgumentException) { host.waitForIdle() } From 05b442611e7b55e524b30d870baef682fda54601 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 6 Nov 2024 20:59:37 +0800 Subject: [PATCH 178/195] try to work around Compose test's issue: AssertionError: No node found that matches TestTag = 'EnvironmentDropdown/DropdownItem/*' in scrollable container --- .../multiplatform/hellohttp/test/RequestResponseTestUtil.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt index a205e029..e5da351f 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt @@ -1149,7 +1149,9 @@ fun SemanticsNodeInteraction.performClickWithRetry(host: ComposeUiTest): Semanti fun SemanticsNodeInteraction.assertIsDisplayedWithRetry(host: ComposeUiTest): SemanticsNodeInteraction { while (true) { try { - assertIsDisplayed() + host.runOnUiThread { + assertIsDisplayed() + } return this } catch (e: IllegalArgumentException) { host.waitForIdle() From a00d2a2b3975ff5e8803ac3ab1c9e96e61473308 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 6 Nov 2024 22:38:04 +0800 Subject: [PATCH 179/195] debug test exception: AssertionError: No node found that matches TestTag = 'EnvironmentDropdown/DropdownItem/*' in scrollable container --- .../multiplatform/hellohttp/ux/DropDownView.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/DropDownView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/DropDownView.kt index d2255cd5..02e664a5 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/DropDownView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/DropDownView.kt @@ -19,6 +19,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -155,6 +158,9 @@ fun ContextMenuView( expanded = isShowContextMenu, onDismissRequest = onDismissRequest, modifier = Modifier.background(colors.backgroundContextMenu) + .semantics { + set(DropDownDisplayTexts, populatedItems.map { it.displayText }) + } .run { if (testTagParts != null) { testTag(buildTestTag(*testTagParts, TestTagPart.DropdownMenu)!!) @@ -228,3 +234,10 @@ data class DropDownMap(private val values: List>) { operator fun get(key: T) = mapByKey[key] } + +val DropDownDisplayTexts = SemanticsPropertyKey>( + name = "DropDownDisplayTexts", + mergePolicy = { parentValue, childValue -> + parentValue ?: childValue + } +) From 6ba95c26712bbb3aab492f5bd24960e4df61ef91 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 6 Nov 2024 23:22:50 +0800 Subject: [PATCH 180/195] try to work around Compose test's issue: AssertionError: No node found that matches TestTag = 'EnvironmentDropdown/DropdownItem/*' in scrollable container --- .../hellohttp/test/RequestResponseTestUtil.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt index e5da351f..2ecaea5a 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt @@ -48,6 +48,7 @@ import com.sunnychung.application.multiplatform.hellohttp.test.payload.Parameter import com.sunnychung.application.multiplatform.hellohttp.test.payload.RequestData import com.sunnychung.application.multiplatform.hellohttp.util.executeWithTimeout import com.sunnychung.application.multiplatform.hellohttp.ux.AppView +import com.sunnychung.application.multiplatform.hellohttp.ux.DropDownDisplayTexts import com.sunnychung.application.multiplatform.hellohttp.ux.TestTag import com.sunnychung.application.multiplatform.hellohttp.ux.TestTagPart import com.sunnychung.application.multiplatform.hellohttp.ux.buildTestTag @@ -430,7 +431,11 @@ suspend fun ComposeUiTest.createEnvironmentInEnvDialog(name: String) { onNodeWithTag(TestTag.EnvironmentDialogEnvNameTextField.name) .performTextInput(this, name) + delayShort() + waitUntil(3.seconds().millis) { + waitForIdle() + // one in list view and one in text field onAllNodesWithText(name).fetchSemanticsNodesWithRetry(this).size == 2 } @@ -460,6 +465,13 @@ fun ComposeUiTest.selectDropdownItem(testTagPart: String, itemDisplayText: Strin .size == 1 } + println("DropdownMenu items: ${ + onNodeWithTag(buildTestTag(testTagPart, TestTagPart.DropdownMenu)!!) + .fetchSemanticsNodeWithRetry(this) + .config + .getOrNull(DropDownDisplayTexts) + }") + onNodeWithTag(buildTestTag(testTagPart, TestTagPart.DropdownMenu)!!) .performScrollToNode(hasTestTag(itemTag)) From 93e37cec22420d48a50c4e2ef2aca2b26890c4e6 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 6 Nov 2024 23:52:10 +0800 Subject: [PATCH 181/195] try to work around Compose test's issue: - ComposeTimeoutException: Condition still not satisfied after 3000 ms - AssertionError: No node found that matches TestTag = 'EnvironmentDropdown/DropdownItem/*' in scrollable container --- .../multiplatform/hellohttp/test/RequestResponseTestUtil.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt index 2ecaea5a..f6d5359e 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt @@ -432,8 +432,9 @@ suspend fun ComposeUiTest.createEnvironmentInEnvDialog(name: String) { .performTextInput(this, name) delayShort() + waitForIdle() - waitUntil(3.seconds().millis) { + waitUntil(30.seconds().millis) { // easy to fail waitForIdle() // one in list view and one in text field From 14a959c8b4c2633b329117e9b2c17ed9af88ea3b Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Thu, 7 Nov 2024 00:01:40 +0800 Subject: [PATCH 182/195] add mouse hover variable to show a tooltip for its value (if exists) in Code Editor --- CHANGELOG.md | 3 + .../hellohttp/util/AnnotatedStringBuilder.kt | 398 ++++++++++++++++++ .../multiplatform/hellohttp/ux/AppUX.kt | 5 + .../hellohttp/ux/CodeEditorView.kt | 135 +++--- .../hellohttp/ux/KeyValueEditorView.kt | 6 +- .../hellohttp/ux/RequestEditorView.kt | 37 +- .../hellohttp/ux/bigtext/BigMonospaceText.kt | 21 +- .../ux/bigtext/JetpackComposeBigText.kt | 7 +- .../EnvironmentVariableDecorator.kt | 2 +- 9 files changed, 528 insertions(+), 86 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/AnnotatedStringBuilder.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppUX.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6000e1..07bacae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- Mouse hovering variable placeholders in Body Editor to show a tooltip for its value (if exists) + ### Removed - Text fields and response body viewer now do not trim content over 4 MB (but other limits still apply) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/AnnotatedStringBuilder.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/AnnotatedStringBuilder.kt new file mode 100644 index 00000000..64b62618 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/util/AnnotatedStringBuilder.kt @@ -0,0 +1,398 @@ +package com.sunnychung.application.multiplatform.hellohttp.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.AnnotatedString.Builder +import androidx.compose.ui.text.AnnotatedString.Range +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TtsAnnotation +import androidx.compose.ui.text.UrlAnnotation +//import androidx.compose.ui.text.getLocalAnnotations +//import androidx.compose.ui.text.getLocalParagraphStyles +//import androidx.compose.ui.text.getLocalSpanStyles +//import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMap + +class AnnotatedStringBuilder(capacity: Int = 16) : Appendable { + + private data class MutableRange( + val item: T, + val start: Int, + var end: Int = Int.MIN_VALUE, + val tag: String = "" + ) { + /** + * Create an immutable [Range] object. + * + * @param defaultEnd if the end is not set yet, it will be set to this value. + */ + fun toRange(defaultEnd: Int = Int.MIN_VALUE): Range { + val end = if (end == Int.MIN_VALUE) defaultEnd else end + check(end != Int.MIN_VALUE) { "Item.end should be set first" } + return Range(item = item, start = start, end = end, tag = tag) + } + } + + private val text: StringBuilder = StringBuilder(capacity) + private val spanStyles: MutableList> = mutableListOf() + private val paragraphStyles: MutableList> = mutableListOf() + // commented because we cannot construct an AnnotatedString with annotations. the constructor's visibility is internal. why!? +// private val annotations: MutableList> = mutableListOf() + private val styleStack: MutableList> = mutableListOf() + + /** + * Create an [Builder] instance using the given [String]. + */ + constructor(text: String) : this() { + append(text) + } + + /** + * Create an [Builder] instance using the given [AnnotatedString]. + */ + constructor(text: AnnotatedString) : this() { + append(text) + } + + /** + * Returns the length of the [String]. + */ + val length: Int get() = text.length + + /** + * Appends the given [String] to this [Builder]. + * + * @param text the text to append + */ + fun append(text: String) { + this.text.append(text) + } + + @Deprecated( + message = "Replaced by the append(Char) method that returns an Appendable. " + + "This method must be kept around for binary compatibility.", + level = DeprecationLevel.HIDDEN + ) + @Suppress("FunctionName", "unused") + // Set the JvmName to preserve compatibility with bytecode that expects a void return type. + @JvmName("append") + fun deprecated_append_returning_void(char: Char) { + append(char) + } + + /** + * Appends [text] to this [Builder] if non-null, and returns this [Builder]. + * + * If [text] is an [AnnotatedString], all spans and annotations will be copied over as well. + * No other subtypes of [CharSequence] will be treated specially. For example, any + * platform-specific types, such as `SpannedString` on Android, will only have their text + * copied and any other information held in the sequence, such as Android `Span`s, will be + * dropped. + */ + @Suppress("BuilderSetStyle") + override fun append(text: CharSequence?): AnnotatedStringBuilder { // modified + if (text is AnnotatedString) { + append(text) + } else { + this.text.append(text) + } + return this + } + + /** + * Appends the range of [text] between [start] (inclusive) and [end] (exclusive) to this + * [Builder] if non-null, and returns this [Builder]. + * + * If [text] is an [AnnotatedString], all spans and annotations from [text] between + * [start] and [end] will be copied over as well. + * No other subtypes of [CharSequence] will be treated specially. For example, any + * platform-specific types, such as `SpannedString` on Android, will only have their text + * copied and any other information held in the sequence, such as Android `Span`s, will be + * dropped. + * + * @param start The index of the first character in [text] to copy over (inclusive). + * @param end The index after the last character in [text] to copy over (exclusive). + */ + @Suppress("BuilderSetStyle") + override fun append(text: CharSequence?, start: Int, end: Int): AnnotatedStringBuilder { // modified + if (text is AnnotatedString) { + append(text, start, end) + } else { + this.text.append(text, start, end) + } + return this + } + + // Kdoc comes from interface method. + override fun append(char: Char): AnnotatedStringBuilder { // modified + this.text.append(char) + return this + } + + /** + * Appends the given [AnnotatedString] to this [Builder]. + * + * @param text the text to append + */ + fun append(text: AnnotatedString) { + val start = this.text.length + this.text.append(text.text) + // offset every style with start and add to the builder + text.spanStyles.forEach { // modified + addStyle(it.item, start + it.start, start + it.end, it.tag) // modified + } + text.paragraphStyles.forEach { // modified + addStyle(it.item, start + it.start, start + it.end, it.tag) // modified + } + + // modified because of no access to annotations +// text.annotations.forEach { +// annotations.add( +// MutableRange(it.item, start + it.start, start + it.end, it.tag) +// ) +// } + } + + /** + * Appends the range of [text] between [start] (inclusive) and [end] (exclusive) to this + * [Builder]. All spans and annotations from [text] between [start] and [end] will be copied + * over as well. + * + * @param start The index of the first character in [text] to copy over (inclusive). + * @param end The index after the last character in [text] to copy over (exclusive). + */ + @Suppress("BuilderSetStyle") +// fun append(text: AnnotatedString, start: Int, end: Int) { +// val insertionStart = this.text.length +// this.text.append(text.text, start, end) +// // offset every style with insertionStart and add to the builder +// text.getLocalSpanStyles(start, end)?.fastForEach { +// addStyle(it.item, insertionStart + it.start, insertionStart + it.end) +// } +// text.getLocalParagraphStyles(start, end)?.fastForEach { +// addStyle(it.item, insertionStart + it.start, insertionStart + it.end) +// } +// +// text.getLocalAnnotations(start, end)?.fastForEach { +// annotations.add( +// MutableRange( +// it.item, +// insertionStart + it.start, +// insertionStart + it.end, +// it.tag +// ) +// ) +// } +// } + + /** + * Set a [SpanStyle] for the given [range]. + * + * @param style [SpanStyle] to be applied + * @param start the inclusive starting offset of the range + * @param end the exclusive end offset of the range + */ + fun addStyle(style: SpanStyle, start: Int, end: Int, tag: String) { // modified + spanStyles.add(MutableRange(item = style, start = start, end = end, tag = tag)) // modified + } + + /** + * Set a [ParagraphStyle] for the given [range]. When a [ParagraphStyle] is applied to the + * [AnnotatedString], it will be rendered as a separate paragraph. + * + * @param style [ParagraphStyle] to be applied + * @param start the inclusive starting offset of the range + * @param end the exclusive end offset of the range + */ + fun addStyle(style: ParagraphStyle, start: Int, end: Int, tag: String) { // modified + paragraphStyles.add(MutableRange(item = style, start = start, end = end, tag = tag)) // modified + } + + /** + * Set an Annotation for the given [range]. + * + * @param tag the tag used to distinguish annotations + * @param annotation the string annotation that is attached + * @param start the inclusive starting offset of the range + * @param end the exclusive end offset of the range + * @see getStringAnnotations + * @sample androidx.compose.ui.text.samples.AnnotatedStringAddStringAnnotationSample + */ +// fun addStringAnnotation(tag: String, annotation: String, start: Int, end: Int) { +// annotations.add(MutableRange(annotation, start, end, tag)) +// } + + /** + * Set a [TtsAnnotation] for the given [range]. + * + * @param ttsAnnotation an object that stores text to speech metadata that intended for the + * TTS engine. + * @param start the inclusive starting offset of the range + * @param end the exclusive end offset of the range + * @see getStringAnnotations + * @sample androidx.compose.ui.text.samples.AnnotatedStringAddStringAnnotationSample + */ +// @ExperimentalTextApi +// @Suppress("SetterReturnsThis") +// fun addTtsAnnotation(ttsAnnotation: TtsAnnotation, start: Int, end: Int) { +// annotations.add(MutableRange(ttsAnnotation, start, end)) +// } + + /** + * Set a [UrlAnnotation] for the given [range]. URLs may be treated specially by screen + * readers, including being identified while reading text with an audio icon or being + * summarized in a links menu. + * + * @param urlAnnotation A [UrlAnnotation] object that stores the URL being linked to. + * @param start the inclusive starting offset of the range + * @param end the exclusive end offset of the range + * @see getStringAnnotations + * @sample androidx.compose.ui.text.samples.AnnotatedStringAddStringAnnotationSample + */ +// @ExperimentalTextApi +// @Suppress("SetterReturnsThis") +// fun addUrlAnnotation(urlAnnotation: UrlAnnotation, start: Int, end: Int) { +// annotations.add(MutableRange(urlAnnotation, start, end)) +// } + + /** + * Applies the given [SpanStyle] to any appended text until a corresponding [pop] is + * called. + * + * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderPushSample + * + * @param style SpanStyle to be applied + */ + fun pushStyle(style: SpanStyle): Int { + MutableRange(item = style, start = text.length).also { + styleStack.add(it) + spanStyles.add(it) + } + return styleStack.size - 1 + } + + /** + * Applies the given [ParagraphStyle] to any appended text until a corresponding [pop] + * is called. + * + * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderPushParagraphStyleSample + * + * @param style ParagraphStyle to be applied + */ + fun pushStyle(style: ParagraphStyle): Int { + MutableRange(item = style, start = text.length).also { + styleStack.add(it) + paragraphStyles.add(it) + } + return styleStack.size - 1 + } + + /** + * Attach the given [annotation] to any appended text until a corresponding [pop] + * is called. + * + * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderPushStringAnnotationSample + * + * @param tag the tag used to distinguish annotations + * @param annotation the string annotation attached on this AnnotatedString + * @see getStringAnnotations + * @see Range + */ +// fun pushStringAnnotation(tag: String, annotation: String): Int { +// MutableRange(item = annotation, start = text.length, tag = tag).also { +// styleStack.add(it) +// annotations.add(it) +// } +// return styleStack.size - 1 +// } + + /** + * Attach the given [ttsAnnotation] to any appended text until a corresponding [pop] + * is called. + * + * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderPushStringAnnotationSample + * + * @param ttsAnnotation an object that stores text to speech metadata that intended for the + * TTS engine. + * @see getStringAnnotations + * @see Range + */ +// fun pushTtsAnnotation(ttsAnnotation: TtsAnnotation): Int { +// MutableRange(item = ttsAnnotation, start = text.length).also { +// styleStack.add(it) +// annotations.add(it) +// } +// return styleStack.size - 1 +// } + + /** + * Attach the given [UrlAnnotation] to any appended text until a corresponding [pop] + * is called. + * + * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderPushStringAnnotationSample + * + * @param urlAnnotation A [UrlAnnotation] object that stores the URL being linked to. + * @see getStringAnnotations + * @see Range + */ +// @Suppress("BuilderSetStyle") +// @ExperimentalTextApi +// fun pushUrlAnnotation(urlAnnotation: UrlAnnotation): Int { +// MutableRange(item = urlAnnotation, start = text.length).also { +// styleStack.add(it) +// annotations.add(it) +// } +// return styleStack.size - 1 +// } + + /** + * Ends the style or annotation that was added via a push operation before. + * + * @see pushStyle + * @see pushStringAnnotation + */ + fun pop() { + check(styleStack.isNotEmpty()) { "Nothing to pop." } + // pop the last element + val item = styleStack.removeAt(styleStack.size - 1) + item.end = text.length + } + + /** + * Ends the styles or annotation up to and `including` the [pushStyle] or + * [pushStringAnnotation] that returned the given index. + * + * @param index the result of the a previous [pushStyle] or [pushStringAnnotation] in order + * to pop to + * + * @see pop + * @see pushStyle + * @see pushStringAnnotation + */ + fun pop(index: Int) { + check(index < styleStack.size) { "$index should be less than ${styleStack.size}" } + while ((styleStack.size - 1) >= index) { + pop() + } + } + + /** + * Constructs an [AnnotatedString] based on the configurations applied to the [Builder]. + */ + fun toAnnotatedString(): AnnotatedString { + // modified + return AnnotatedString( + text = text.toString(), + spanStyles = spanStyles + .fastMap { it.toRange(text.length) } + , + paragraphStyles = paragraphStyles + .fastMap { it.toRange(text.length) } + , +// annotations = annotations +// .fastMap { it.toRange(text.length) } +// .ifEmpty { null } + ) + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppUX.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppUX.kt new file mode 100644 index 00000000..0d0ed512 --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/AppUX.kt @@ -0,0 +1,5 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux + +object AppUX { + const val ENV_VAR_VALUE_MAX_DISPLAY_LENGTH = 120 +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index 2496eabb..9cfbca7c 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -48,34 +48,27 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.google.common.collect.TreeRangeMap -import com.sunnychung.application.multiplatform.hellohttp.annotation.TemporaryApi -import com.sunnychung.application.multiplatform.hellohttp.extension.binarySearchForInsertionPoint import com.sunnychung.application.multiplatform.hellohttp.extension.contains -import com.sunnychung.application.multiplatform.hellohttp.extension.insert import com.sunnychung.application.multiplatform.hellohttp.extension.intersect import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.model.SyntaxHighlight -import com.sunnychung.application.multiplatform.hellohttp.util.ObjectRef import com.sunnychung.application.multiplatform.hellohttp.util.TreeRangeMaps import com.sunnychung.application.multiplatform.hellohttp.util.chunkedLatest import com.sunnychung.application.multiplatform.hellohttp.util.log +import com.sunnychung.application.multiplatform.hellohttp.ux.AppUX.ENV_VAR_VALUE_MAX_DISPLAY_LENGTH import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigMonospaceTextField import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextInputFilter import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextKeyboardInputProcessor -import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextManipulator import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextSimpleLayoutResult import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformed @@ -83,8 +76,6 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTran 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.rememberAnnotatedBigTextFieldState -import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldColors -import com.sunnychung.application.multiplatform.hellohttp.ux.compose.TextFieldDefaults 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 @@ -123,7 +114,7 @@ fun CodeEditorView( textColor: Color = LocalColor.current.text, syntaxHighlight: SyntaxHighlight, isEnableVariables: Boolean = false, - knownVariables: Set = setOf(), + knownVariables: Map = mutableMapOf(), testTag: String? = null, ) { val themeColours = LocalColor.current @@ -308,7 +299,7 @@ fun CodeEditorView( val variableDecorators = remember(bigTextFieldState, themeColours, isEnableVariables, knownVariables) { if (isEnableVariables) { listOf( - EnvironmentVariableDecorator(themeColours, knownVariables), + EnvironmentVariableDecorator(themeColours, knownVariables.keys), ) } else { emptyList() @@ -504,64 +495,86 @@ fun CodeEditorView( } } - BigMonospaceTextField( - textFieldState = bigTextFieldState, - inputFilter = inputFilter, - textTransformation = remember(variableTransformations) { - MultipleIncrementalTransformation( - variableTransformations - ) - }, - textDecorator = //rememberLast(bigTextFieldState, themeColours, searchResultRangeTree, searchResultViewIndex, syntaxHighlightDecorator) { + var mouseHoverVariable by remember(bigTextFieldState) { mutableStateOf(null) } + AppTooltipArea( + isVisible = mouseHoverVariable != null && mouseHoverVariable in knownVariables, + tooltipText = mouseHoverVariable?.let { + val s = knownVariables[it] ?: return@let null + if (s.length > ENV_VAR_VALUE_MAX_DISPLAY_LENGTH) { + s.substring(0, ENV_VAR_VALUE_MAX_DISPLAY_LENGTH) + " ..." + } else { + s + } + } ?: "", + modifier = Modifier.fillMaxSize(), + ) { + BigMonospaceTextField( + textFieldState = bigTextFieldState, + inputFilter = inputFilter, + textTransformation = remember(variableTransformations) { + MultipleIncrementalTransformation( + variableTransformations + ) + }, + textDecorator = //rememberLast(bigTextFieldState, themeColours, searchResultRangeTree, searchResultViewIndex, syntaxHighlightDecorator) { MultipleTextDecorator(syntaxHighlightDecorators + variableDecorators + searchDecorators) - //}, - , - fontSize = LocalFont.current.codeEditorBodyFontSize, - scrollState = scrollState, - onTextLayout = { layoutResult = it }, - keyboardInputProcessor = object : BigTextKeyboardInputProcessor { - override fun beforeProcessInput( - it: KeyEvent, - viewState: BigTextViewState, - textManipulator: BigTextManipulator - ): Boolean { - return if (it.type == KeyEventType.KeyDown) { - when (it.key) { - Key.Enter -> { - if (!it.isShiftPressed - && !it.isAltPressed - && !it.isCtrlPressed - && !it.isMetaPressed - ) { - onPressEnterAddIndent(textManipulator) + //}, + , + fontSize = LocalFont.current.codeEditorBodyFontSize, + scrollState = scrollState, + onTextLayout = { layoutResult = it }, + keyboardInputProcessor = object : BigTextKeyboardInputProcessor { + override fun beforeProcessInput( + it: KeyEvent, + viewState: BigTextViewState, + textManipulator: BigTextManipulator + ): Boolean { + return if (it.type == KeyEventType.KeyDown) { + when (it.key) { + Key.Enter -> { + if (!it.isShiftPressed + && !it.isAltPressed + && !it.isCtrlPressed + && !it.isMetaPressed + ) { + onPressEnterAddIndent(textManipulator) + true + } else { + false + } + } + + Key.Tab -> { + onPressTab(textManipulator, it.isShiftPressed) true - } else { - false } - } - Key.Tab -> { - onPressTab(textManipulator, it.isShiftPressed) - true + else -> false } - - else -> false + } else { + false } - } else { - false } - } - }, - modifier = Modifier.fillMaxSize() - .focusRequester(textFieldFocusRequester) - .run { - if (testTag != null) { - testTag(testTag) + }, + onPointerEvent = { event, tag -> + log.v { "onPointerEventOnAnnotatedTag $tag $event" } + mouseHoverVariable = if (tag?.startsWith(EnvironmentVariableIncrementalTransformation.TAG_PREFIX) == true) { + tag.replaceFirst(EnvironmentVariableIncrementalTransformation.TAG_PREFIX, "") } else { - this + null } - } - ) + }, + modifier = Modifier.fillMaxSize() + .focusRequester(textFieldFocusRequester) + .run { + if (testTag != null) { + testTag(testTag) + } else { + this + } + } + ) + } } } VerticalScrollbar( diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KeyValueEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KeyValueEditorView.kt index ebb24561..699cb7c5 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KeyValueEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/KeyValueEditorView.kt @@ -47,7 +47,7 @@ fun KeyValueEditorView( disabledIds: Set, isSupportFileValue: Boolean = false, isSupportVariables: Boolean = false, - knownVariables: Set = emptySet(), + knownVariables: Map = emptyMap(), onItemChange: (index: Int, item: UserKeyValuePair) -> Unit, onItemAddLast: (item: UserKeyValuePair) -> Unit, onItemDelete: (index: Int) -> Unit, @@ -128,7 +128,7 @@ fun KeyValueEditorView( MultipleVisualTransformation(listOf( EnvironmentVariableTransformation( themeColors = colors, - knownVariables = knownVariables + knownVariables = knownVariables.keys ), FunctionTransformation(themeColors = colors), )) @@ -169,7 +169,7 @@ fun KeyValueEditorView( MultipleVisualTransformation(listOf( EnvironmentVariableTransformation( themeColors = colors, - knownVariables = knownVariables + knownVariables = knownVariables.keys ), FunctionTransformation(themeColors = colors), )) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt index 670ee6ee..4f29cf7a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt @@ -127,7 +127,8 @@ fun RequestEditorView( var selectedRequestTabIndex by remember { mutableStateOf(0) } - val environmentVariableKeys = environment?.variables?.filter { it.isEnabled }?.map { it.key }?.toSet() ?: emptySet() + val environmentVariables = environment?.variables?.filter { it.isEnabled }?.map { it.key to it.value }?.toMap() ?: emptyMap() + val environmentVariableKeys = environmentVariables.keys val currentGraphqlOperation = if (request.application == ProtocolApplication.Graphql) { (selectedExample.body as? GraphqlBody)?.getOperation(isThrowError = false) @@ -566,7 +567,7 @@ fun RequestEditorView( request = request, onRequestModified = onRequestModified, selectedExample = selectedExample, - environmentVariableKeys = environmentVariableKeys, + environmentVariables = environmentVariables, currentGraphqlOperation = currentGraphqlOperation, ) @@ -597,7 +598,7 @@ fun RequestEditorView( ) ) }, - knownVariables = environmentVariableKeys, + knownVariables = environmentVariables, isSupportFileValue = false, testTagPart = TestTagPart.RequestHeader, modifier = Modifier.fillMaxWidth(), @@ -630,7 +631,7 @@ fun RequestEditorView( ) ) }, - knownVariables = environmentVariableKeys, + knownVariables = environmentVariables, isSupportFileValue = false, testTagPart = TestTagPart.RequestQueryParameter, modifier = Modifier.fillMaxWidth(), @@ -679,7 +680,7 @@ fun RequestEditorView( ) ) }, - knownVariables = environmentVariableKeys, + knownVariables = environmentVariables, isSupportFileValue = false, modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp), ) @@ -718,7 +719,7 @@ fun RequestEditorView( ) ) }, - knownVariables = environmentVariableKeys, + knownVariables = environmentVariables, isSupportFileValue = false, modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp), ) @@ -734,7 +735,7 @@ fun RequestEditorView( selectedPayloadExampleId = selectedPayloadExampleId!!, onSelectExample = { selectedPayloadExampleId = it.id }, hasCompleteButton = request.application == ProtocolApplication.Grpc && currentGrpcMethod?.isClientStreaming == true, - knownVariables = environmentVariableKeys, + knownVariables = environmentVariables, onClickSendPayload = onClickSendPayload, onClickCompleteStream = onClickCompleteStream, connectionStatus = connectionStatus, @@ -917,7 +918,7 @@ private fun RequestKeyValueEditorView( value: List?, baseValue: List?, baseDisabledIds: Set, - knownVariables: Set, + knownVariables: Map, onValueUpdate: (List) -> Unit, onDisableUpdate: (Set) -> Unit, isSupportFileValue: Boolean, @@ -986,7 +987,7 @@ private fun RequestBodyEditor( request: UserRequestTemplate, onRequestModified: (UserRequestTemplate?) -> Unit, selectedExample: UserRequestExample, - environmentVariableKeys: Set, + environmentVariables: Map, currentGraphqlOperation: OperationDefinition?, ) { val colors = LocalColor.current @@ -1121,7 +1122,7 @@ private fun RequestBodyEditor( RequestBodyTextEditor( request = request, onRequestModified = onRequestModified, - environmentVariableKeys = environmentVariableKeys, + environmentVariables = environmentVariables, selectedExample = selectedExample, overridePredicate = { it?.isOverrideBody != false }, translateToText = { (it.body as? StringBody)?.value }, @@ -1164,7 +1165,7 @@ private fun RequestBodyEditor( ) ) }, - knownVariables = environmentVariableKeys, + knownVariables = environmentVariables, isSupportFileValue = false, testTagPart = TestTagPart.RequestBodyFormUrlEncodedForm, modifier = remainModifier, @@ -1198,7 +1199,7 @@ private fun RequestBodyEditor( ) ) }, - knownVariables = environmentVariableKeys, + knownVariables = environmentVariables, isSupportFileValue = true, testTagPart = TestTagPart.RequestBodyMultipartForm, modifier = remainModifier, @@ -1229,7 +1230,7 @@ private fun RequestBodyEditor( RequestBodyTextEditor( request = request, onRequestModified = onRequestModified, - environmentVariableKeys = environmentVariableKeys, + environmentVariables = environmentVariables, selectedExample = selectedExample, overridePredicate = { it?.isOverrideBodyContent != false }, translateToText = { (it.body as? GraphqlBody)?.document }, @@ -1263,7 +1264,7 @@ private fun RequestBodyEditor( RequestBodyTextEditor( request = request, onRequestModified = onRequestModified, - environmentVariableKeys = environmentVariableKeys, + environmentVariables = environmentVariables, selectedExample = selectedExample, overridePredicate = { it?.isOverrideBodyVariables != false }, translateToText = { (it.body as? GraphqlBody)?.variables }, @@ -1319,7 +1320,7 @@ private fun RequestBodyTextEditor( modifier: Modifier, request: UserRequestTemplate, onRequestModified: (UserRequestTemplate?) -> Unit, - environmentVariableKeys: Set, + environmentVariables: Map, selectedExample: UserRequestExample, overridePredicate: (UserRequestExample.Overrides?) -> Boolean, translateToText: (UserRequestExample) -> String?, @@ -1335,7 +1336,7 @@ private fun RequestBodyTextEditor( modifier = modifier, isReadOnly = false, isEnableVariables = true, - knownVariables = environmentVariableKeys, + knownVariables = environmentVariables, text = translateToText(selectedExample) ?: "", onTextChange = { onRequestModified( @@ -1354,7 +1355,7 @@ private fun RequestBodyTextEditor( modifier = modifier, isReadOnly = true, isEnableVariables = true, - knownVariables = environmentVariableKeys, + knownVariables = environmentVariables, text = translateToText(baseExample) ?: "", onTextChange = {}, textColor = colors.placeholder, @@ -1416,7 +1417,7 @@ fun StreamingPayloadEditorView( selectedPayloadExampleId: String, onSelectExample: (PayloadExample) -> Unit, hasCompleteButton: Boolean, - knownVariables: Set, + knownVariables: Map, onClickSendPayload: (String) -> Unit, onClickCompleteStream: () -> Unit, connectionStatus: ConnectionStatus, diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt index 4eff52cb..50a43deb 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigMonospaceText.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.input.key.nativeKeyCode import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.LayoutCoordinates @@ -144,6 +145,7 @@ fun BigMonospaceText( textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember { BigTextViewState() }, + onPointerEvent: ((event: PointerEvent, tag: String?) -> Unit)? = null, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, onTransformInit: ((BigTextTransformed) -> Unit)? = null, ) = CoreBigMonospaceText( @@ -160,6 +162,7 @@ fun BigMonospaceText( textDecorator = textDecorator, scrollState = scrollState, viewState = viewState, + onPointerEvent = onPointerEvent, onTextLayout = onTextLayout, onTransformInit = onTransformInit, ) @@ -176,6 +179,7 @@ fun BigMonospaceTextField( textDecorator: BigTextDecorator? = null, scrollState: ScrollState = rememberScrollState(), keyboardInputProcessor: BigTextKeyboardInputProcessor? = null, + onPointerEvent: ((event: PointerEvent, tag: String?) -> Unit)? = null, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) { BigMonospaceTextField( @@ -193,6 +197,7 @@ fun BigMonospaceTextField( scrollState = scrollState, viewState = textFieldState.viewState, keyboardInputProcessor = keyboardInputProcessor, + onPointerEvent = onPointerEvent, onTextLayout = onTextLayout ) } @@ -211,6 +216,7 @@ fun BigMonospaceTextField( scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember(text) { BigTextViewState() }, keyboardInputProcessor: BigTextKeyboardInputProcessor? = null, + onPointerEvent: ((event: PointerEvent, tag: String?) -> Unit)? = null, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, ) = CoreBigMonospaceText( modifier = modifier, @@ -227,6 +233,7 @@ fun BigMonospaceTextField( scrollState = scrollState, viewState = viewState, keyboardInputProcessor = keyboardInputProcessor, + onPointerEvent = onPointerEvent, onTextLayout = onTextLayout, ) @@ -247,6 +254,7 @@ private fun CoreBigMonospaceText( scrollState: ScrollState = rememberScrollState(), viewState: BigTextViewState = remember(text) { BigTextViewState() }, keyboardInputProcessor: BigTextKeyboardInputProcessor? = null, + onPointerEvent: ((event: PointerEvent, tag: String?) -> Unit)? = null, onTextLayout: ((BigTextSimpleLayoutResult) -> Unit)? = null, onTransformInit: ((BigTextTransformed) -> Unit)? = null, ) { @@ -1084,10 +1092,21 @@ private fun CoreBigMonospaceText( viewState.updateCursorIndexByTransformed(transformedText) } ) - .pointerInput(isEditable, text, transformedText.hasLayouted, viewState, viewportTop, lineHeight, contentWidth, transformedText.length, transformedText.hashCode()) { + .pointerInput(isEditable, text, transformedText.hasLayouted, viewState, viewportTop, lineHeight, contentWidth, transformedText.length, transformedText.hashCode(), onPointerEvent) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() + + if (onPointerEvent != null) { + val position = event.changes.first().position + val transformedCharIndex = getTransformedCharIndex(x = position.x, y = position.y, mode = ResolveCharPositionMode.Cursor) + val tag = if (transformedCharIndex in 0 .. transformedText.lastIndex) { + val charSequenceUnderPointer = transformedText.subSequence(transformedCharIndex, transformedCharIndex + 1) + (charSequenceUnderPointer as? AnnotatedString)?.spanStyles?.firstOrNull { it.tag.isNotEmpty() }?.tag + } else null + onPointerEvent(event, tag) + } + when (event.type) { PointerEventType.Press -> { val position = event.changes.first().position diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt index a7a05728..5a1ade81 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/JetpackComposeBigText.kt @@ -1,11 +1,14 @@ package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext import androidx.compose.ui.text.AnnotatedString +import com.sunnychung.application.multiplatform.hellohttp.util.AnnotatedStringBuilder fun BigText.Companion.createFromLargeAnnotatedString(initialContent: AnnotatedString) = BigTextImpl( textBufferFactory = { AnnotatedStringTextBuffer(it) }, - charSequenceBuilderFactory = { AnnotatedString.Builder(it) }, - charSequenceFactory = { (it as AnnotatedString.Builder).toAnnotatedString() }, +// charSequenceBuilderFactory = { AnnotatedString.Builder(it) }, +// charSequenceFactory = { (it as AnnotatedString.Builder).toAnnotatedString() }, + charSequenceBuilderFactory = { AnnotatedStringBuilder(it) }, + charSequenceFactory = { (it as AnnotatedStringBuilder).toAnnotatedString() }, ).apply { log.d { "createFromLargeAnnotatedString ${initialContent.length}" } append(initialContent) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt index 5d5159cf..162e6dfa 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/EnvironmentVariableDecorator.kt @@ -31,7 +31,7 @@ class EnvironmentVariableDecorator(themeColors: AppColor, val knownVariables: Se } else { unknownVariableStyle } - AnnotatedString.Range(style, tagRange.start, tagRange.end) + AnnotatedString.Range(style, tagRange.start, tagRange.end, tagRange.tag) } return AnnotatedString(text.text, previousSpanStyles + newSpanStyles, text.paragraphStyles) } From 1cc9105212d66d853f840797277ecb70bab39e33 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Thu, 7 Nov 2024 00:24:51 +0800 Subject: [PATCH 183/195] try to fix ComposeTimeoutException: Condition still not satisfied after 30000 ms --- .../multiplatform/hellohttp/test/RequestResponseTestUtil.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt index f6d5359e..c8217717 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt @@ -438,7 +438,7 @@ suspend fun ComposeUiTest.createEnvironmentInEnvDialog(name: String) { waitForIdle() // one in list view and one in text field - onAllNodesWithText(name).fetchSemanticsNodesWithRetry(this).size == 2 + onAllNodesWithText(name, useUnmergedTree = true).fetchSemanticsNodesWithRetry(this).size == 2 } waitForIdle() From 4f84d4480716f49f077db4dc4631ca5d60738b96 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Thu, 7 Nov 2024 21:47:53 +0800 Subject: [PATCH 184/195] debug test exception: ComposeTimeoutException: Condition still not satisfied after 30000 ms --- .github/workflows/run-test.yaml | 5 ++ .../test/GraphqlRequestResponseTest.kt | 7 ++- .../hellohttp/test/GrpcRequestResponseTest.kt | 7 ++- .../hellohttp/test/RequestResponseTestUtil.kt | 61 +++++++++++-------- .../test/WebSocketRequestResponseTest.kt | 5 +- 5 files changed, 53 insertions(+), 32 deletions(-) diff --git a/.github/workflows/run-test.yaml b/.github/workflows/run-test.yaml index f479f65a..8fcfef5a 100644 --- a/.github/workflows/run-test.yaml +++ b/.github/workflows/run-test.yaml @@ -34,3 +34,8 @@ jobs: name: unit-test-result_${{ matrix.os }} path: build/reports/tests if: ${{ always() }} + - uses: actions/upload-artifact@v3 + with: + name: ux-test-error_${{ matrix.os }} + path: test-error.png + if: ${{ always() }} diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GraphqlRequestResponseTest.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GraphqlRequestResponseTest.kt index 33b862bb..f9593d83 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GraphqlRequestResponseTest.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GraphqlRequestResponseTest.kt @@ -3,6 +3,7 @@ package com.sunnychung.application.multiplatform.hellohttp.test import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.DesktopComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals @@ -303,7 +304,7 @@ class GraphqlRequestResponseTest(testName: String, httpVersion: HttpConfig.HttpP } } -suspend fun ComposeUiTest.createGraphqlRequest(request: UserRequestTemplate, environment: TestEnvironment) { +suspend fun DesktopComposeUiTest.createGraphqlRequest(request: UserRequestTemplate, environment: TestEnvironment) { createRequest(request = request, environment = environment) selectRequestMethod("GraphQL") delayShort() @@ -343,7 +344,7 @@ suspend fun ComposeUiTest.createGraphqlRequest(request: UserRequestTemplate, env } } -suspend fun ComposeUiTest.createAndSendGraphqlRequest(request: UserRequestTemplate, timeout: KDuration = 2500.milliseconds(), environment: TestEnvironment) { +suspend fun DesktopComposeUiTest.createAndSendGraphqlRequest(request: UserRequestTemplate, timeout: KDuration = 2500.milliseconds(), environment: TestEnvironment) { createGraphqlRequest(request = request, environment = environment) delayShort() @@ -359,7 +360,7 @@ suspend fun ComposeUiTest.createAndSendGraphqlRequest(request: UserRequestTempla waitUntil(maxOf(1L, timeout.millis)) { onAllNodesWithText("Communicating").fetchSemanticsNodes().isEmpty() } } -fun ComposeUiTest.assertHttpSuccessResponseAndGetResponseBody(isSubscriptionRequest: Boolean = false): String? { +fun DesktopComposeUiTest.assertHttpSuccessResponseAndGetResponseBody(isSubscriptionRequest: Boolean = false): String? { onNodeWithTag(TestTag.ResponseStatus.name).assertTextEquals(if (!isSubscriptionRequest) { "200 OK" } else { diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GrpcRequestResponseTest.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GrpcRequestResponseTest.kt index 3dd2caec..0f88fe8b 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GrpcRequestResponseTest.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/GrpcRequestResponseTest.kt @@ -4,6 +4,7 @@ package com.sunnychung.application.multiplatform.hellohttp.test import androidx.compose.ui.semantics.Role import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.DesktopComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertTextEquals @@ -261,7 +262,7 @@ class GrpcRequestResponseTest(testName: String, isSsl: Boolean, isMTls: Boolean) assertSuccessStatus() } - suspend fun ComposeUiTest.createGrpcRequest(environment: TestEnvironment, requestDecorator: (UserRequestTemplate) -> UserRequestTemplate) { + suspend fun DesktopComposeUiTest.createGrpcRequest(environment: TestEnvironment, requestDecorator: (UserRequestTemplate) -> UserRequestTemplate) { val request = UserRequestTemplate( id = uuidString(), application = ProtocolApplication.Grpc, @@ -357,11 +358,11 @@ class GrpcRequestResponseTest(testName: String, isSsl: Boolean, isMTls: Boolean) } } -fun ComposeUiTest.assertStatus(expected: String) { +fun DesktopComposeUiTest.assertStatus(expected: String) { onNodeWithTag(TestTag.ResponseStatus.name) .assertTextEquals(expected) } -fun ComposeUiTest.assertSuccessStatus() { +fun DesktopComposeUiTest.assertSuccessStatus() { assertStatus("0 OK") } diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt index c8217717..94bca1da 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt @@ -2,12 +2,14 @@ package com.sunnychung.application.multiplatform.hellohttp.test +import androidx.compose.ui.graphics.asSkiaBitmap import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.DesktopComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.SemanticsNodeInteractionCollection @@ -27,6 +29,7 @@ import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.runDesktopComposeUiTest import androidx.compose.ui.test.waitUntilExactlyOneExists import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window @@ -60,16 +63,20 @@ import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds import com.sunnychung.lib.multiplatform.kdatetime.extension.seconds import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image +import org.jetbrains.skiko.toBufferedImage +import org.jetbrains.skiko.toImage import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import java.awt.Dimension import java.io.File import java.net.URL -fun runTest(testBlock: suspend ComposeUiTest.() -> Unit) = +fun runTest(testBlock: suspend DesktopComposeUiTest.() -> Unit) = executeWithTimeout(120.seconds()) { try { - runComposeUiTest { + runDesktopComposeUiTest { setContent { Window( title = "Hello HTTP", @@ -121,7 +128,7 @@ enum class TestEnvironment(val displayName: String) { LocalMTls("Local mTLS"), } -suspend fun ComposeUiTest.createProjectIfNeeded() { +suspend fun DesktopComposeUiTest.createProjectIfNeeded() { if (onAllNodesWithTag(TestTag.FirstTimeCreateProjectButton.name).fetchSemanticsNodesWithRetry(this).isNotEmpty()) { // create first project onNodeWithTag(TestTag.FirstTimeCreateProjectButton.name) @@ -361,12 +368,12 @@ suspend fun ComposeUiTest.createProjectIfNeeded() { // } } -fun ComposeUiTest.mockChosenFile(file: File): File { +fun DesktopComposeUiTest.mockChosenFile(file: File): File { testChooseFile = file return file } -suspend fun ComposeUiTest.selectEnvironment(environment: TestEnvironment) { +suspend fun DesktopComposeUiTest.selectEnvironment(environment: TestEnvironment) { if (onNodeWithTag(buildTestTag(TestTagPart.EnvironmentDropdown, TestTagPart.DropdownLabel)!!, useUnmergedTree = true) .assertIsDisplayedWithRetry(this) .fetchSemanticsNodeWithRetry(this) @@ -384,7 +391,7 @@ suspend fun ComposeUiTest.selectEnvironment(environment: TestEnvironment) { /** * @param name A unique name. */ -suspend fun ComposeUiTest.createEnvironmentInEnvDialog(name: String) { +suspend fun DesktopComposeUiTest.createEnvironmentInEnvDialog(name: String) { println("createEnvironmentInEnvDialog start '$name'") var retryAttempt = 0 @@ -434,11 +441,17 @@ suspend fun ComposeUiTest.createEnvironmentInEnvDialog(name: String) { delayShort() waitForIdle() - waitUntil(30.seconds().millis) { // easy to fail - waitForIdle() + try { + waitUntil(30.seconds().millis) { // easy to fail + waitForIdle() - // one in list view and one in text field - onAllNodesWithText(name, useUnmergedTree = true).fetchSemanticsNodesWithRetry(this).size == 2 + // one in list view and one in text field + onAllNodesWithText(name, useUnmergedTree = true).fetchSemanticsNodesWithRetry(this).size == 2 + } + } catch (e: ComposeTimeoutException) { + val screenshot = captureToImage().asSkiaBitmap().toBufferedImage().toImage() + File("test-error.png").writeBytes(screenshot.encodeToData(EncodedImageFormat.PNG)!!.bytes) + throw e } waitForIdle() @@ -447,12 +460,12 @@ suspend fun ComposeUiTest.createEnvironmentInEnvDialog(name: String) { println("createEnvironmentInEnvDialog '$name' list ${onAllNodesWithText(name).fetchSemanticsNodesWithRetry(this).joinToString("|") { it.config.toString() }}") } -fun ComposeUiTest.selectRequestMethod(itemDisplayText: String) { +fun DesktopComposeUiTest.selectRequestMethod(itemDisplayText: String) { // TODO support custom method selectDropdownItem(TestTagPart.RequestMethodDropdown.name, itemDisplayText) } -fun ComposeUiTest.selectDropdownItem(testTagPart: String, itemDisplayText: String, assertDisplayText: String = itemDisplayText) { +fun DesktopComposeUiTest.selectDropdownItem(testTagPart: String, itemDisplayText: String, assertDisplayText: String = itemDisplayText) { val itemTag = buildTestTag(testTagPart, TestTagPart.DropdownItem, itemDisplayText)!! // if drop down menu is expanded, click the item directly; otherwise, open the menu first. if (onAllNodesWithTag(itemTag, useUnmergedTree = true).fetchSemanticsNodesWithRetry(this).isEmpty()) { @@ -498,7 +511,7 @@ fun ComposeUiTest.selectDropdownItem(testTagPart: String, itemDisplayText: Strin } } -suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environment: TestEnvironment?) { +suspend fun DesktopComposeUiTest.createRequest(request: UserRequestTemplate, environment: TestEnvironment?) { createProjectIfNeeded() if (environment != null) { selectEnvironment(environment) @@ -916,7 +929,7 @@ suspend fun ComposeUiTest.createRequest(request: UserRequestTemplate, environmen } } -suspend fun ComposeUiTest.createAndSendHttpRequest(request: UserRequestTemplate, timeout: KDuration = 2500.milliseconds(), isOneOffRequest: Boolean = true, isExpectResponseBody: Boolean = false, renderResponseTimeout: KDuration = 1500.milliseconds(), environment: TestEnvironment?) { +suspend fun DesktopComposeUiTest.createAndSendHttpRequest(request: UserRequestTemplate, timeout: KDuration = 2500.milliseconds(), isOneOffRequest: Boolean = true, isExpectResponseBody: Boolean = false, renderResponseTimeout: KDuration = 1500.milliseconds(), environment: TestEnvironment?) { createRequest(request = request, environment = environment) waitForIdle() @@ -946,7 +959,7 @@ suspend fun ComposeUiTest.createAndSendHttpRequest(request: UserRequestTemplate, } } -suspend fun ComposeUiTest.createAndSendRestEchoRequestAndAssertResponse(request: UserRequestTemplate, timeout: KDuration = 2500.milliseconds(), environment: TestEnvironment?, ignoreAssertQueryParameters: Set = emptySet()): RequestData { +suspend fun DesktopComposeUiTest.createAndSendRestEchoRequestAndAssertResponse(request: UserRequestTemplate, timeout: KDuration = 2500.milliseconds(), environment: TestEnvironment?, ignoreAssertQueryParameters: Set = emptySet()): RequestData { val baseExample = request.examples.first() val isAssertBodyContent = request.url.endsWith("/rest/echo") createAndSendHttpRequest(request = request, timeout = timeout, environment = environment, isExpectResponseBody = true) @@ -1025,7 +1038,7 @@ suspend fun ComposeUiTest.createAndSendRestEchoRequestAndAssertResponse(request: return resp } -suspend fun ComposeUiTest.sendPayload(payload: String, isCreatePayloadExample: Boolean = true) { +suspend fun DesktopComposeUiTest.sendPayload(payload: String, isCreatePayloadExample: Boolean = true) { fun getStreamPayloadLatestTimeString(): String { waitForIdle() return (onAllNodesWithTag(TestTag.ResponseStreamLogItemTime.name, useUnmergedTree = true) @@ -1068,7 +1081,7 @@ suspend fun ComposeUiTest.sendPayload(payload: String, isCreatePayloadExample: B waitUntil(600.milliseconds().millis) { getStreamPayloadLatestTimeString() != streamCountBeforeSend } } -suspend fun ComposeUiTest.fireRequest(timeout: KDuration = 1.seconds(), isClientStreaming: Boolean = false, isServerStreaming: Boolean = false) { +suspend fun DesktopComposeUiTest.fireRequest(timeout: KDuration = 1.seconds(), isClientStreaming: Boolean = false, isServerStreaming: Boolean = false) { onNodeWithTag(TestTag.RequestFireOrDisconnectButton.name) .assertIsDisplayedWithRetry(this) .assertTextEquals(if (isClientStreaming) "Connect" else "Send") @@ -1085,13 +1098,13 @@ suspend fun ComposeUiTest.fireRequest(timeout: KDuration = 1.seconds(), isClient } } -suspend fun ComposeUiTest.completeRequest() { +suspend fun DesktopComposeUiTest.completeRequest() { onNodeWithTag(TestTag.RequestCompleteStreamButton.name) .assertIsDisplayedWithRetry(this) .performClickWithRetry(this) } -fun ComposeUiTest.disconnect() { +fun DesktopComposeUiTest.disconnect() { onNodeWithTag(TestTag.RequestFireOrDisconnectButton.name) .assertIsDisplayedWithRetry(this) .assertTextEquals("Disconnect") @@ -1105,28 +1118,28 @@ fun ComposeUiTest.disconnect() { } } -suspend fun ComposeUiTest.delayShort() { +suspend fun DesktopComposeUiTest.delayShort() { wait(250L) } -suspend fun ComposeUiTest.wait(duration: KDuration) { +suspend fun DesktopComposeUiTest.wait(duration: KDuration) { wait(duration.toMilliseconds()) } -suspend fun ComposeUiTest.wait(ms: Long) { +suspend fun DesktopComposeUiTest.wait(ms: Long) { mainClock.advanceTimeBy(ms) delay(ms) waitForIdle() } -fun ComposeUiTest.getResponseBody(): String? { +fun DesktopComposeUiTest.getResponseBody(): String? { val responseBody = onNodeWithTag(TestTag.ResponseBody.name).fetchSemanticsNodeWithRetry(this) .getTexts() .singleOrNull() return responseBody } -fun ComposeUiTest.retryForUnresponsiveBuggyComposeTest(testContent: () -> Unit) { +fun DesktopComposeUiTest.retryForUnresponsiveBuggyComposeTest(testContent: () -> Unit) { var retryAttempt = 0 while (true) { // add this loop because the click is often not performed waitForIdle() diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/WebSocketRequestResponseTest.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/WebSocketRequestResponseTest.kt index 7980fb42..84bc73a7 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/WebSocketRequestResponseTest.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/WebSocketRequestResponseTest.kt @@ -3,6 +3,7 @@ package com.sunnychung.application.multiplatform.hellohttp.test import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.DesktopComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onAllNodesWithText @@ -125,7 +126,7 @@ class WebSocketRequestResponseTest(testName: String, isSsl: Boolean, isMTls: Boo disconnect() } - suspend fun ComposeUiTest.createAndFireWebSocketRequest( + suspend fun DesktopComposeUiTest.createAndFireWebSocketRequest( environment: TestEnvironment, requestDecorator: (UserRequestTemplate) -> UserRequestTemplate = { it } ): UserRequestTemplate { @@ -164,7 +165,7 @@ class WebSocketRequestResponseTest(testName: String, isSsl: Boolean, isMTls: Boo } } -fun ComposeUiTest.assertHttpStatus() { +fun DesktopComposeUiTest.assertHttpStatus() { onNodeWithTag(TestTag.ResponseStatus.name) .assertTextEquals("101 Switching Protocols") } From 605ec1d44ef7ed4f1dc4bff109891b7a2b15800c Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 8 Nov 2024 21:26:58 +0800 Subject: [PATCH 185/195] fix test debug screenshot was not captured successfully --- .../hellohttp/test/RequestResponseTestUtil.kt | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt index 94bca1da..57a87af1 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt @@ -76,22 +76,22 @@ import java.net.URL fun runTest(testBlock: suspend DesktopComposeUiTest.() -> Unit) = executeWithTimeout(120.seconds()) { try { - runDesktopComposeUiTest { + runDesktopComposeUiTest(1024, 560) { setContent { - Window( - title = "Hello HTTP", - onCloseRequest = {}, - state = rememberWindowState(width = 1024.dp, height = 560.dp) - ) { - with(LocalDensity.current) { - window.minimumSize = if (isMacOs()) { - Dimension(800, 450) - } else { - Dimension(800.dp.roundToPx(), 450.dp.roundToPx()) - } - } +// Window( +// title = "Hello HTTP", +// onCloseRequest = {}, +// state = rememberWindowState(width = 1024.dp, height = 560.dp) +// ) { +// with(LocalDensity.current) { +// window.minimumSize = if (isMacOs()) { +// Dimension(800, 450) +// } else { +// Dimension(800.dp.roundToPx(), 450.dp.roundToPx()) +// } +// } AppView() - } +// } } runBlocking { // don't use Dispatchers.Main, or most tests would fail with ComposeTimeoutException testBlock() From 76ac1c34ee764402b5577d619c3f6f2b8f05bac3 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 8 Nov 2024 22:08:25 +0800 Subject: [PATCH 186/195] fix missing change --- .github/workflows/run-test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-test.yaml b/.github/workflows/run-test.yaml index 8fcfef5a..759c31a3 100644 --- a/.github/workflows/run-test.yaml +++ b/.github/workflows/run-test.yaml @@ -37,5 +37,5 @@ jobs: - uses: actions/upload-artifact@v3 with: name: ux-test-error_${{ matrix.os }} - path: test-error.png + path: ux-and-transport-test/test-error.png if: ${{ always() }} From 6c9aca4b70652bedf116de5808c7e1758376a38f Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 8 Nov 2024 22:22:38 +0800 Subject: [PATCH 187/195] fix excessive log --- .../multiplatform/hellohttp/model/ImportedFile.kt | 6 +++++- .../multiplatform/hellohttp/model/RawExchange.kt | 2 +- .../hellohttp/serializer/SynchronizedListSerializer.kt | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/ImportedFile.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/ImportedFile.kt index 9283f14e..6a31642a 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/ImportedFile.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/ImportedFile.kt @@ -14,4 +14,8 @@ data class ImportedFile( val createdWhen: KInstant, val isEnabled: Boolean, val content: ByteArray, -) : Identifiable +) : Identifiable { + override fun toString(): String { + return "ImportedFile(id='$id', name='$name', originalFilename='$originalFilename', createdWhen=$createdWhen, isEnabled=$isEnabled, content={size=${content.size}})" + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/RawExchange.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/RawExchange.kt index 797a427d..679bfd91 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/RawExchange.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/model/RawExchange.kt @@ -59,7 +59,7 @@ data class RawExchange( consumePayloadBuilder(isComplete = true) } } - log.d { "unsafe write ${bytes.size} => $payloadSize" } + log.v { "unsafe write ${bytes.size} => $payloadSize" } } override fun equals(other: Any?): Boolean { diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/serializer/SynchronizedListSerializer.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/serializer/SynchronizedListSerializer.kt index 5b21c308..7c534e72 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/serializer/SynchronizedListSerializer.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/serializer/SynchronizedListSerializer.kt @@ -13,14 +13,14 @@ class SynchronizedListSerializer(elementSerializer: KSerializer) : KSerial override val descriptor: SerialDescriptor = listSerializer.descriptor override fun serialize(encoder: Encoder, value: MutableList) { - log.d { "SynchronizedListSerializer serialize" } +// log.v { "SynchronizedListSerializer serialize" } synchronized(value) { listSerializer.serialize(encoder, value) } } override fun deserialize(decoder: Decoder): MutableList { - log.d { "SynchronizedListSerializer deserialize" } +// log.v { "SynchronizedListSerializer deserialize" } return listSerializer.deserialize(decoder).toMutableList().let { Collections.synchronizedList(it) } From 64dbe4ac057433c1f37509476ee670f39ea72acd Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 8 Nov 2024 22:51:06 +0800 Subject: [PATCH 188/195] revert relying on Compose util to capture screen (as tests were hanging without an actual Window), and change to use awt to capture --- .../hellohttp/test/RequestResponseTestUtil.kt | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt index 57a87af1..7761ae7c 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt @@ -70,28 +70,31 @@ import org.jetbrains.skiko.toImage import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import java.awt.Dimension +import java.awt.Rectangle +import java.awt.Robot +import java.awt.Toolkit import java.io.File import java.net.URL fun runTest(testBlock: suspend DesktopComposeUiTest.() -> Unit) = executeWithTimeout(120.seconds()) { try { - runDesktopComposeUiTest(1024, 560) { + runDesktopComposeUiTest { setContent { -// Window( -// title = "Hello HTTP", -// onCloseRequest = {}, -// state = rememberWindowState(width = 1024.dp, height = 560.dp) -// ) { -// with(LocalDensity.current) { -// window.minimumSize = if (isMacOs()) { -// Dimension(800, 450) -// } else { -// Dimension(800.dp.roundToPx(), 450.dp.roundToPx()) -// } -// } + Window( + title = "Hello HTTP", + onCloseRequest = {}, + state = rememberWindowState(width = 1024.dp, height = 560.dp) + ) { + with(LocalDensity.current) { + window.minimumSize = if (isMacOs()) { + Dimension(800, 450) + } else { + Dimension(800.dp.roundToPx(), 450.dp.roundToPx()) + } + } AppView() -// } + } } runBlocking { // don't use Dispatchers.Main, or most tests would fail with ComposeTimeoutException testBlock() @@ -449,8 +452,9 @@ suspend fun DesktopComposeUiTest.createEnvironmentInEnvDialog(name: String) { onAllNodesWithText(name, useUnmergedTree = true).fetchSemanticsNodesWithRetry(this).size == 2 } } catch (e: ComposeTimeoutException) { - val screenshot = captureToImage().asSkiaBitmap().toBufferedImage().toImage() - File("test-error.png").writeBytes(screenshot.encodeToData(EncodedImageFormat.PNG)!!.bytes) +// val screenshot = captureToImage().asSkiaBitmap().toBufferedImage().toImage() +// File("test-error.png").writeBytes(screenshot.encodeToData(EncodedImageFormat.PNG)!!.bytes) + captureScreenToFile() throw e } @@ -1226,3 +1230,8 @@ fun SemanticsNodeInteraction.performTextInput(host: ComposeUiTest, s: String) { performTextInput(s) } } + +fun captureScreenToFile() { + val image = Robot().createScreenCapture(Rectangle(Toolkit.getDefaultToolkit().screenSize)) + File("test-error.png").writeBytes(image.toImage().encodeToData(EncodedImageFormat.PNG)!!.bytes) +} From 0c02f504e528515f25ff44308b32b050a191c46c Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Fri, 8 Nov 2024 23:38:34 +0800 Subject: [PATCH 189/195] try to work around the Compose test issue that `performTextClearance()` is not always working --- .../hellohttp/test/RequestResponseTestUtil.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt index 7761ae7c..0e1e3f48 100644 --- a/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt +++ b/ux-and-transport-test/src/test/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/RequestResponseTestUtil.kt @@ -431,12 +431,25 @@ suspend fun DesktopComposeUiTest.createEnvironmentInEnvDialog(name: String) { break } - onNodeWithTag(TestTag.EnvironmentDialogEnvNameTextField.name) - .assertIsDisplayedWithRetry(this) - .performTextClearance() + do { + onNodeWithTag(TestTag.EnvironmentDialogEnvNameTextField.name) + .assertIsDisplayedWithRetry(this) + .performTextClearance() // not always working - delayShort() - waitForIdle() + delayShort() + waitForIdle() +// println("Env Text: [${ +// onNodeWithTag(TestTag.EnvironmentDialogEnvNameTextField.name) +// .fetchSemanticsNodeWithRetry(this) +// .getTexts() +// }]") + } while( + onNodeWithTag(TestTag.EnvironmentDialogEnvNameTextField.name) + .fetchSemanticsNodeWithRetry(this) + .getTexts() + .filter { it != "Environment Name" } + .isNotEmpty() + ) onNodeWithTag(TestTag.EnvironmentDialogEnvNameTextField.name) .performTextInput(this, name) From ed7083c760481395a5bca77f9e59eeca67a498de Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 9 Nov 2024 00:30:20 +0800 Subject: [PATCH 190/195] fix ChunkedLatestFlowTest was failing in some runner --- .../hellohttp/test/util/ChunkedLatestFlowTest.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt index 9a92980c..7d075d93 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt @@ -2,8 +2,6 @@ package com.sunnychung.application.multiplatform.hellohttp.test.util import com.sunnychung.application.multiplatform.hellohttp.util.chunkedLatest import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow @@ -26,10 +24,10 @@ class ChunkedLatestFlowTest { flow { (0..10).forEach { emit(it) - delay(145) + delay(1450) } } - .chunkedLatest(500.milliseconds()) + .chunkedLatest(5000.milliseconds()) .onEach { results += it } .launchIn(this) } @@ -47,12 +45,12 @@ class ChunkedLatestFlowTest { flow { (0..10).forEach { emit(it) - delay(145) + delay(1450) } emit(11) emit(12) } - .chunkedLatest(500.milliseconds()) + .chunkedLatest(5000.milliseconds()) .onEach { results += it } .launchIn(this) } @@ -70,10 +68,10 @@ class ChunkedLatestFlowTest { flow { (0..12).forEach { emit(it) - delay(145) + delay(1450) } } - .chunkedLatest(500.milliseconds()) + .chunkedLatest(5000.milliseconds()) .onEach { results += it } .launchIn(this) } From 17b5ff3d3ca5f0824454c77cb1ae8373c7b7f445 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 9 Nov 2024 01:14:19 +0800 Subject: [PATCH 191/195] fix ChunkedLatestFlowTest was failing in some runner --- .../test/util/ChunkedLatestFlowTest.kt | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt index 7d075d93..186158f8 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt @@ -22,12 +22,15 @@ class ChunkedLatestFlowTest { coroutineScope { flow { + val startTime = System.currentTimeMillis() (0..10).forEach { emit(it) - delay(1450) + val currTime = System.currentTimeMillis() + println("t=${currTime - startTime}: $it") + delay((it + 1) * 145 - (currTime - startTime)) } } - .chunkedLatest(5000.milliseconds()) + .chunkedLatest(500.milliseconds()) .onEach { results += it } .launchIn(this) } @@ -43,14 +46,17 @@ class ChunkedLatestFlowTest { coroutineScope { flow { + val startTime = System.currentTimeMillis() (0..10).forEach { emit(it) - delay(1450) + val currTime = System.currentTimeMillis() + println("t=${currTime - startTime}: $it") + delay((it + 1) * 145 - (currTime - startTime)) } emit(11) emit(12) } - .chunkedLatest(5000.milliseconds()) + .chunkedLatest(500.milliseconds()) .onEach { results += it } .launchIn(this) } @@ -66,12 +72,15 @@ class ChunkedLatestFlowTest { coroutineScope { flow { + val startTime = System.currentTimeMillis() (0..12).forEach { emit(it) - delay(1450) + val currTime = System.currentTimeMillis() + println("t=${currTime - startTime}: $it") + delay((it + 1) * 145 - (currTime - startTime)) } } - .chunkedLatest(5000.milliseconds()) + .chunkedLatest(500.milliseconds()) .onEach { results += it } .launchIn(this) } From 10ded51d9e2073f053b9cbc72603434f1f3b540a Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 9 Nov 2024 10:26:14 +0800 Subject: [PATCH 192/195] try to fix ChunkedLatestFlowTest in some runner where `delay()` was not accurate --- .../hellohttp/test/util/ChunkedLatestFlowTest.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt index 186158f8..edd82e42 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt @@ -2,6 +2,7 @@ package com.sunnychung.application.multiplatform.hellohttp.test.util import com.sunnychung.application.multiplatform.hellohttp.util.chunkedLatest import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow @@ -17,7 +18,7 @@ class ChunkedLatestFlowTest { @Test fun receiveOnlyLatestValues() { - runBlocking { + runBlocking(Dispatchers.IO) { val results = Collections.synchronizedList(mutableListOf()) coroutineScope { @@ -41,7 +42,7 @@ class ChunkedLatestFlowTest { @Test fun receiveValuesEmittedAtCompletion1() { - runBlocking { + runBlocking(Dispatchers.IO) { val results = Collections.synchronizedList(mutableListOf()) coroutineScope { @@ -67,7 +68,7 @@ class ChunkedLatestFlowTest { @Test fun receiveValuesEmittedAtCompletion2() { - runBlocking { + runBlocking(Dispatchers.IO) { val results = Collections.synchronizedList(mutableListOf()) coroutineScope { From e88df004b4a766f061cc7995ec3abb2234b20d3c Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 9 Nov 2024 12:47:45 +0800 Subject: [PATCH 193/195] try to fix ChunkedLatestFlowTest in some runner where `delay()` was not accurate --- .github/workflows/run-debug-test.yaml | 37 +++++++++++++++++++ .../test/util/ChunkedLatestFlowTest.kt | 11 +++--- 2 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/run-debug-test.yaml diff --git a/.github/workflows/run-debug-test.yaml b/.github/workflows/run-debug-test.yaml new file mode 100644 index 00000000..5774189e --- /dev/null +++ b/.github/workflows/run-debug-test.yaml @@ -0,0 +1,37 @@ +name: Debug Verification Tests +on: + push: + branches: + - '**' +jobs: + test: + strategy: + matrix: + # macos-12 for Intel Mac, macos-14 for Apple Chips Mac + os: [ubuntu-20.04, windows-2019, windows-2022, macos-12, macos-14] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v3 + with: + distribution: 'corretto' + java-version: '17' + - run: | + export GRADLE_OPTS='-Xmx64m -Dorg.gradle.daemon=false -Dorg.gradle.jvmargs="-Xmx3072m -XX:+HeapDumpOnOutOfMemoryError"' + ./gradlew jvmTest --tests 'com.sunnychung.application.multiplatform.hellohttp.test.util.ChunkedLatestFlowTest' + shell: bash # let Windows use bash + - uses: actions/upload-artifact@v3 + with: + name: ux-test-result_${{ matrix.os }} + path: ux-and-transport-test/build/reports/tests/test + if: ${{ always() }} + - uses: actions/upload-artifact@v3 + with: + name: unit-test-result_${{ matrix.os }} + path: build/reports/tests + if: ${{ always() }} + - uses: actions/upload-artifact@v3 + with: + name: ux-test-error_${{ matrix.os }} + path: ux-and-transport-test/test-error.png + if: ${{ always() }} diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt index edd82e42..e295a405 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt @@ -2,7 +2,7 @@ package com.sunnychung.application.multiplatform.hellohttp.test.util import com.sunnychung.application.multiplatform.hellohttp.util.chunkedLatest import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.Collections +import java.util.concurrent.Executors import kotlin.test.Test import kotlin.test.assertEquals @@ -18,7 +19,7 @@ class ChunkedLatestFlowTest { @Test fun receiveOnlyLatestValues() { - runBlocking(Dispatchers.IO) { + runBlocking(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) { val results = Collections.synchronizedList(mutableListOf()) coroutineScope { @@ -42,7 +43,7 @@ class ChunkedLatestFlowTest { @Test fun receiveValuesEmittedAtCompletion1() { - runBlocking(Dispatchers.IO) { + runBlocking(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) { val results = Collections.synchronizedList(mutableListOf()) coroutineScope { @@ -68,7 +69,7 @@ class ChunkedLatestFlowTest { @Test fun receiveValuesEmittedAtCompletion2() { - runBlocking(Dispatchers.IO) { + runBlocking(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) { val results = Collections.synchronizedList(mutableListOf()) coroutineScope { @@ -128,7 +129,7 @@ class ChunkedLatestFlowTest { @Test fun collectAfterCancel() { - runBlocking { + runBlocking(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) { val results = Collections.synchronizedList(mutableListOf()) coroutineScope { From 6316be226e26d8f1e76c52f013ca22c4ade1f832 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 9 Nov 2024 13:49:29 +0800 Subject: [PATCH 194/195] update to disable ChunkedLatestFlowTest in GitHub Actions due to unstable coroutine performance --- build.gradle.kts | 1 + .../hellohttp/test/util/ChunkedLatestFlowTest.kt | 10 ++++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 20331655..121a7ce8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -166,6 +166,7 @@ tasks.withType { if (project.hasProperty("isCI") && project.property("isCI").toString().toBoolean()) { filter { excludeTestsMatching("com.sunnychung.application.multiplatform.hellohttp.test.bigtext.**") + excludeTestsMatching("com.sunnychung.**.ChunkedLatestFlowTest") // The latencies of `delay()` are unstable on GitHub macOS runners } } } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt index e295a405..186158f8 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/util/ChunkedLatestFlowTest.kt @@ -2,7 +2,6 @@ package com.sunnychung.application.multiplatform.hellohttp.test.util import com.sunnychung.application.multiplatform.hellohttp.util.chunkedLatest import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds -import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow @@ -11,7 +10,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.Collections -import java.util.concurrent.Executors import kotlin.test.Test import kotlin.test.assertEquals @@ -19,7 +17,7 @@ class ChunkedLatestFlowTest { @Test fun receiveOnlyLatestValues() { - runBlocking(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) { + runBlocking { val results = Collections.synchronizedList(mutableListOf()) coroutineScope { @@ -43,7 +41,7 @@ class ChunkedLatestFlowTest { @Test fun receiveValuesEmittedAtCompletion1() { - runBlocking(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) { + runBlocking { val results = Collections.synchronizedList(mutableListOf()) coroutineScope { @@ -69,7 +67,7 @@ class ChunkedLatestFlowTest { @Test fun receiveValuesEmittedAtCompletion2() { - runBlocking(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) { + runBlocking { val results = Collections.synchronizedList(mutableListOf()) coroutineScope { @@ -129,7 +127,7 @@ class ChunkedLatestFlowTest { @Test fun collectAfterCancel() { - runBlocking(Executors.newSingleThreadExecutor().asCoroutineDispatcher()) { + runBlocking { val results = Collections.synchronizedList(mutableListOf()) coroutineScope { From 654cc104b10574d7306986f0154a62e3c073a028 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Sat, 9 Nov 2024 16:08:02 +0800 Subject: [PATCH 195/195] merge --- .../application/multiplatform/hellohttp/ux/RequestEditorView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt index 4466d64b..a9289fdc 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/RequestEditorView.kt @@ -1372,7 +1372,7 @@ private fun RequestBodyTextEditor( CodeEditorView( isReadOnly = false, isEnableVariables = true, - knownVariables = environmentVariableKeys, + knownVariables = environmentVariables, text = content, onTextChange = changeText, syntaxHighlight = syntaxHighlight,