Skip to content

Commit 8f4e0c2

Browse files
committed
revert the changes to BigText.substring(), and refactor the lazy view into a new API BigText.subView()
1 parent 78abfe5 commit 8f4e0c2

6 files changed

Lines changed: 157 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ UI
1212

1313
DS
1414
- Emoji sequences support
15+
- `BigText.subView()` -- returning memory-efficient sub-view in `CharSequence` type
1516

1617
### Breaking Change!
1718
UI
@@ -24,10 +25,6 @@ DS
2425
UI
2526
- OOM crash when there is a large text in clipboard (without pasting)
2627

27-
### Optimized
28-
DS
29-
- `BigText.substring()` memory usage
30-
3128

3229
## UI [2.4.0] - 2026-03-01
3330

datastructure/src/jvmMain/kotlin/com/sunnychung/lib/multiplatform/bigtext/core/BigText.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ interface BigText {
5353

5454
fun subSequence(startIndex: Int, endIndex: Int): CharSequence
5555

56+
/**
57+
* Returns a view of the text range.
58+
*
59+
* Use this when you only need to read or scan a range, especially a large range. For example, it is suitable for
60+
* regex searches, counting characters, or passing text to code that only needs the [CharSequence] interface.
61+
*
62+
* Do not use this when your code requires the result to be the custom type produced by
63+
* [charSequenceBuilderFactory] and [charSequenceFactory]. Use [substring] or [subSequence] for that.
64+
*/
65+
fun subView(startIndex: Int, endIndex: Int): CharSequence = subSequence(startIndex, endIndex)
66+
67+
fun subView(range: IntRange): CharSequence = subView(range.start, range.endInclusive + 1)
68+
5669
fun chunkAt(start: Int): String
5770

5871
fun findLineString(lineIndex: Int): CharSequence

datastructure/src/jvmMain/kotlin/com/sunnychung/lib/multiplatform/bigtext/core/BigTextImpl.kt

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ private const val EPS = 1e-4f
5555
private val accumulatedWidthCacheInterval = 64
5656
private val accumulatedWidthCacheHalfInterval = accumulatedWidthCacheInterval / 2
5757
private const val MAX_GRAPHEME_SEQUENCE_LENGTH = 128
58-
private const val LAZY_SUBSTRING_MIN_LENGTH = 8 * 1024 * 1024
58+
private const val SUBVIEW_MIN_LENGTH = 8 * 1024 * 1024
5959

6060
open class BigTextImpl(
6161
override val chunkSize: Int = 2 * 1024 * 1024, // 2 MB
@@ -964,34 +964,8 @@ open class BigTextImpl(
964964
return ""
965965
}
966966

967-
if (decorator == null && endExclusive - start >= LAZY_SUBSTRING_MIN_LENGTH) {
968-
val segments = mutableListOf<BigTextSegment>()
969-
var node = tree.findNodeByRenderCharIndex(start) ?: throw IllegalStateException("Cannot find string node for position $start")
970-
var nodeStartPos = findRenderPositionStart(node)
971-
var numRemainCharsToCopy = endExclusive - start
972-
var copyFromBufferIndex = start - nodeStartPos + node.value.renderBufferStart
973-
while (numRemainCharsToCopy > 0) {
974-
val numCharsToCopy = minOf(endExclusive, nodeStartPos + node.value.currentRenderLength) - maxOf(start, nodeStartPos)
975-
val copyUntilBufferIndex = copyFromBufferIndex + numCharsToCopy
976-
if (numCharsToCopy > 0) {
977-
segments += BigTextSegment(
978-
buffer = node.value.buffer,
979-
start = copyFromBufferIndex,
980-
endExclusive = copyUntilBufferIndex,
981-
)
982-
numRemainCharsToCopy -= numCharsToCopy
983-
}
984-
if (numRemainCharsToCopy > 0) {
985-
nodeStartPos += node.value.currentRenderLength
986-
node = tree.nextNode(node) ?: throw IllegalStateException("Cannot find the next string node. Requested = $start ..< $endExclusive. Remain = $numRemainCharsToCopy")
987-
copyFromBufferIndex = node.value.renderBufferStart
988-
}
989-
}
990-
return BigTextSegmentCharSequence(segments)
991-
}
992-
993967
// val result = charSequenceBuilderFactory(endExclusive - start)
994-
val result = getCharSequenceBuilder()
968+
val result = getCharSequenceBuilder(endExclusive - start)
995969
var node = tree.findNodeByRenderCharIndex(start) ?: throw IllegalStateException("Cannot find string node for position $start")
996970
var nodeStartPos = findRenderPositionStart(node)
997971
var numRemainCharsToCopy = endExclusive - start
@@ -1016,13 +990,46 @@ open class BigTextImpl(
1016990
return charSequenceFactory(result)
1017991
}
1018992

993+
private fun lazySubSequence(start: Int, endExclusive: Int): CharSequence {
994+
val segments = mutableListOf<BigTextSegment>()
995+
var node = tree.findNodeByRenderCharIndex(start) ?: throw IllegalStateException("Cannot find string node for position $start")
996+
var nodeStartPos = findRenderPositionStart(node)
997+
var numRemainCharsToCopy = endExclusive - start
998+
var copyFromBufferIndex = start - nodeStartPos + node.value.renderBufferStart
999+
while (numRemainCharsToCopy > 0) {
1000+
val numCharsToCopy = minOf(endExclusive, nodeStartPos + node.value.currentRenderLength) - maxOf(start, nodeStartPos)
1001+
val copyUntilBufferIndex = copyFromBufferIndex + numCharsToCopy
1002+
if (numCharsToCopy > 0) {
1003+
segments += BigTextSegment(
1004+
buffer = node.value.buffer,
1005+
start = copyFromBufferIndex,
1006+
endExclusive = copyUntilBufferIndex,
1007+
)
1008+
numRemainCharsToCopy -= numCharsToCopy
1009+
}
1010+
if (numRemainCharsToCopy > 0) {
1011+
nodeStartPos += node.value.currentRenderLength
1012+
node = tree.nextNode(node) ?: throw IllegalStateException("Cannot find the next string node. Requested = $start ..< $endExclusive. Remain = $numRemainCharsToCopy")
1013+
copyFromBufferIndex = node.value.renderBufferStart
1014+
}
1015+
}
1016+
return BigTextSegmentCharSequence(segments)
1017+
}
1018+
10191019
private fun getCharSequenceBuilder(): GeneralStringBuilder {
10201020
// possible memory leak
10211021
return charSequenceBuilder
10221022
.getOrSet { charSequenceBuilderFactory(chunkSize) }
10231023
.apply { clear() }
10241024
}
10251025

1026+
private fun getCharSequenceBuilder(capacity: Int): GeneralStringBuilder =
1027+
if (capacity > chunkSize) {
1028+
charSequenceBuilderFactory(capacity)
1029+
} else {
1030+
getCharSequenceBuilder()
1031+
}
1032+
10261033
// TODO: refactor not to duplicate implementation of substring
10271034
override fun subSequence(start: Int, endExclusive: Int): CharSequence {
10281035
require(start <= endExclusive) { "start should be <= endExclusive" }
@@ -1037,7 +1044,7 @@ open class BigTextImpl(
10371044
// log.v { "subSequence start" }
10381045

10391046
// val result = charSequenceBuilderFactory(endExclusive - start)
1040-
val result = getCharSequenceBuilder()
1047+
val result = getCharSequenceBuilder(endExclusive - start)
10411048
var node = tree.findNodeByRenderCharIndex(start) ?: throw IllegalStateException("Cannot find string node for position $start")
10421049
var nodeStartPos = findRenderPositionStart(node)
10431050
var numRemainCharsToCopy = endExclusive - start
@@ -1080,6 +1087,22 @@ open class BigTextImpl(
10801087
}
10811088
}
10821089

1090+
override fun subView(startIndex: Int, endIndex: Int): CharSequence {
1091+
require(startIndex <= endIndex) { "start should be <= endExclusive" }
1092+
require(0 <= startIndex) { "Invalid start" }
1093+
require(endIndex <= length) { "endExclusive $endIndex is out of bound. length = $length" }
1094+
1095+
if (startIndex == endIndex) {
1096+
return ""
1097+
}
1098+
1099+
if (decorator == null && endIndex - startIndex >= SUBVIEW_MIN_LENGTH) {
1100+
return lazySubSequence(startIndex, endIndex)
1101+
}
1102+
1103+
return subSequence(startIndex, endIndex)
1104+
}
1105+
10831106
protected open fun decorate(nodeValue: BigTextNodeValue, text: CharSequence, renderPositions: IntRange) =
10841107
decorator!!.onApplyDecorationOnOriginal(text, renderPositions)
10851108

datastructure/src/jvmMain/kotlin/com/sunnychung/lib/multiplatform/bigtext/core/ConcurrentBigText.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ open class ConcurrentBigText(open val delegate: LockableBigText) : BigText {
6363
override fun substring(start: Int, endExclusive: Int): CharSequence = withReadLock { delegate.substring(start, endExclusive) }
6464

6565
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence = withReadLock { delegate.subSequence(startIndex, endIndex) }
66+
67+
override fun subView(startIndex: Int, endIndex: Int): CharSequence = withReadLock { delegate.subView(startIndex, endIndex) }
68+
6669
override fun chunkAt(start: Int): String = withReadLock { delegate.chunkAt(start) }
6770

6871
override fun findLineString(lineIndex: Int): CharSequence = withReadLock { delegate.findLineString(lineIndex) }

datastructure/src/jvmTest/kotlin/com/sunnychung/lib/multiplatform/bigtext/test/BigTextImplTest.kt

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ package com.sunnychung.lib.multiplatform.bigtext.test
33
import com.sunnychung.lib.multiplatform.bigtext.core.BigTextImpl
44
import com.sunnychung.lib.multiplatform.bigtext.core.isD
55
import com.sunnychung.lib.multiplatform.bigtext.extension.length
6+
import com.sunnychung.lib.multiplatform.bigtext.util.GeneralStringBuilder
67
import org.junit.jupiter.params.ParameterizedTest
78
import org.junit.jupiter.params.provider.MethodSource
89
import org.junit.jupiter.params.provider.ValueSource
910
import kotlin.random.Random
1011
import kotlin.test.Test
1112
import kotlin.test.assertEquals
13+
import kotlin.test.assertFalse
14+
import kotlin.test.assertTrue
1215

1316
/**
1417
* Some cases in this test may look very specific, but they had consistently failed before.
@@ -496,6 +499,44 @@ class BigTextImplTest {
496499
}
497500
}
498501

502+
@Test
503+
fun subViewPreservesCustomCharSequenceTypeForSmallRange() {
504+
val t = BigTextImpl(
505+
chunkSize = 64,
506+
charSequenceBuilderFactory = { CustomStringBuilder(it) },
507+
charSequenceFactory = { CustomCharSequence(it.toString()) },
508+
)
509+
t.append("abcdef")
510+
511+
val view = t.subView(1, 4)
512+
513+
assertTrue(view is CustomCharSequence)
514+
assertEquals("bcd", view.toString())
515+
}
516+
517+
@Test
518+
fun subViewCanReturnNonCustomCharSequenceTypeForLargeRange() {
519+
val text = repeatedAlphabet(SUBVIEW_TEST_LENGTH)
520+
val t = BigTextImpl(
521+
chunkSize = 1024 * 1024,
522+
charSequenceBuilderFactory = { CustomStringBuilder(it) },
523+
charSequenceFactory = { CustomCharSequence(it.toString()) },
524+
)
525+
t.append(text)
526+
527+
val start = 17
528+
val endExclusive = text.length - 19
529+
val view = t.subView(start, endExclusive)
530+
531+
assertFalse(view is CustomCharSequence)
532+
assertEquals(endExclusive - start, view.length)
533+
assertEquals(text[start], view[0])
534+
assertEquals(text[start + 1], view[1])
535+
assertEquals(text[start + 1024 * 1024], view[1024 * 1024])
536+
assertEquals(text[endExclusive - 1], view[view.lastIndex])
537+
assertEquals(text.substring(start + 99, start + 123), view.subSequence(99, 123).toString())
538+
}
539+
499540
/**
500541
* Benchmark:
501542
*
@@ -548,3 +589,48 @@ private fun random(from: Int, toExclusive: Int): Int {
548589
}
549590
return Random.nextInt(from, toExclusive)
550591
}
592+
593+
private const val SUBVIEW_TEST_LENGTH = 8 * 1024 * 1024 + 64
594+
595+
private fun repeatedAlphabet(length: Int): String = buildString(length) {
596+
repeat(length) {
597+
append(('a'.code + it % 26).toChar())
598+
}
599+
}
600+
601+
private class CustomStringBuilder(capacity: Int) : GeneralStringBuilder {
602+
private val delegate = StringBuilder(capacity)
603+
604+
override fun append(value: Char): Appendable {
605+
delegate.append(value)
606+
return this
607+
}
608+
609+
override fun append(value: CharSequence?): Appendable {
610+
delegate.append(value)
611+
return this
612+
}
613+
614+
override fun append(value: CharSequence?, startIndex: Int, endIndex: Int): Appendable {
615+
delegate.append(value, startIndex, endIndex)
616+
return this
617+
}
618+
619+
override fun clear() {
620+
delegate.clear()
621+
}
622+
623+
override fun toString(): String = delegate.toString()
624+
}
625+
626+
private class CustomCharSequence(private val delegate: String) : CharSequence {
627+
override val length: Int
628+
get() = delegate.length
629+
630+
override fun get(index: Int): Char = delegate[index]
631+
632+
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence =
633+
CustomCharSequence(delegate.substring(startIndex, endIndex))
634+
635+
override fun toString(): String = delegate
636+
}

demo-ui-composable/src/jvmMain/kotlin/com/sunnychung/lib/multiplatform/bigtext/demo/VariableIncrementalTransformation.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class VariableIncrementalTransformation : IncrementalTextTransformation<Unit> {
2828
override fun initialize(text: BigText, transformer: BigTextTransformer) {
2929
transformer.disableComputations()
3030

31-
val targets = variableRegex.findAll(text.buildString())
31+
val targets = variableRegex.findAll(text.subView(0, text.length))
3232
targets.forEach {
3333
val name = it.groups[1]!!.value
3434
transformer.replace(it.range, createSpan(name), BigTextTransformOffsetMapping.WholeBlock)
@@ -56,7 +56,7 @@ class VariableIncrementalTransformation : IncrementalTextTransformation<Unit> {
5656

5757
private fun findNearbyPatterns(change: BigTextChangeEvent): Sequence<RangeWithResult<MatchResult>> {
5858
val startOffset = maxOf(0, change.changeStartIndex - processLengthLimit)
59-
val substring = change.bigText.substring(
59+
val substring = change.bigText.subView(
6060
startOffset
6161
until
6262
minOf(change.bigText.length, change.changeEndExclusiveIndex + processLengthLimit)

0 commit comments

Comments
 (0)