Skip to content

Commit f9f25a6

Browse files
committed
[JEWEL-1067] Markdown: improve scrolling behavior
1. Ensured scrolling always succeeds. ScrollingSynchronizer API is changed to support this contract. In IJ, scrolling events are sent frequently, so the Markdown preview picks only last ones within a certain timeframe. If `scrollToLine` fails (because the target block is unsupported, for example), then the scrolling stops abruptly. 2. Ensured scrolling to the block on the first line works.
1 parent b5567d2 commit f9f25a6

File tree

2 files changed

+62
-22
lines changed

2 files changed

+62
-22
lines changed

platform/jewel/markdown/core/api-dump-experimental.txt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -736,9 +736,12 @@ f:org.jetbrains.jewel.markdown.scrolling.AutoScrollingUtilKt
736736
- a:acceptTextLayout(org.jetbrains.jewel.markdown.MarkdownBlock,androidx.compose.ui.text.TextLayoutResult):V
737737
- pa:afterProcessing():V
738738
- pa:beforeProcessing():V
739+
- pa:findYCoordinateToScroll(I,kotlin.coroutines.Continuation):java.lang.Object
739740
- f:process(kotlin.jvm.functions.Function0):java.lang.Object
740-
- a:scrollToLine(I,androidx.compose.animation.core.AnimationSpec,kotlin.coroutines.Continuation):java.lang.Object
741-
- bs:scrollToLine$default(org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer,I,androidx.compose.animation.core.AnimationSpec,kotlin.coroutines.Continuation,I,java.lang.Object):java.lang.Object
741+
- pa:scrollToCoordinate(I,androidx.compose.animation.core.AnimationSpec,kotlin.coroutines.Continuation):java.lang.Object
742+
- bs:scrollToCoordinate$default(org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer,I,androidx.compose.animation.core.AnimationSpec,kotlin.coroutines.Continuation,I,java.lang.Object):java.lang.Object
743+
- F:scrollToLine(I,androidx.compose.animation.core.AnimationSpec,kotlin.coroutines.Continuation):java.lang.Object
744+
- bsF:scrollToLine$default(org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer,I,androidx.compose.animation.core.AnimationSpec,kotlin.coroutines.Continuation,I,java.lang.Object):java.lang.Object
742745
*f:org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer$Companion
743746
- f:create(androidx.compose.foundation.gestures.ScrollableState):org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer
744747
*f:org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer$LocatableMarkdownBlock

platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,17 @@ import org.jetbrains.jewel.markdown.processing.MarkdownProcessor
8585
@ApiStatus.Experimental
8686
@ExperimentalJewelApi
8787
public abstract class ScrollingSynchronizer {
88-
/** Scroll the preview to the position that match the given [sourceLine] the best. */
89-
public abstract suspend fun scrollToLine(sourceLine: Int, animationSpec: AnimationSpec<Float> = SpringSpec())
88+
/** Scroll the preview to the position that matches the given [sourceLine] the best. */
89+
@ApiStatus.NonExtendable
90+
public open suspend fun scrollToLine(sourceLine: Int, animationSpec: AnimationSpec<Float> = SpringSpec()) {
91+
scrollToCoordinate(findYCoordinateToScroll(sourceLine), animationSpec)
92+
}
93+
94+
/** Scroll the preview to the given vertical position [y] using given [animationSpec]. */
95+
protected abstract suspend fun scrollToCoordinate(y: Int, animationSpec: AnimationSpec<Float> = SpringSpec())
96+
97+
/** Find the vertical position in the preview that matches the given [sourceLine] the best. */
98+
protected abstract suspend fun findYCoordinateToScroll(sourceLine: Int): Int
9099

91100
/**
92101
* Called when [MarkdownProcessor] processes the raw markdown text. The processing itself is passed as an [action].
@@ -185,28 +194,56 @@ public abstract class ScrollingSynchronizer {
185194
// so this map always keeps relevant information.
186195
private val blocks2TextOffsets = mutableMapOf<MarkdownBlock, List<Int>>()
187196

188-
override suspend fun scrollToLine(sourceLine: Int, animationSpec: AnimationSpec<Float>) {
189-
val block = findBestBlockForLine(sourceLine) ?: return
190-
val y = blocks2Top[block] ?: return
191-
if (y < 0) return
192-
val lineRange = (block as? LocatableMarkdownBlock)?.lines ?: return
193-
val textOffsets = blocks2TextOffsets[block]
197+
override suspend fun scrollToCoordinate(y: Int, animationSpec: AnimationSpec<Float>) {
198+
scrollState.animateScrollTo(y, animationSpec)
199+
}
200+
201+
override suspend fun findYCoordinateToScroll(sourceLine: Int): Int {
202+
blocksSortedByPreference(sourceLine).forEach { block ->
203+
val positionToScroll = block.positionToScroll(sourceLine)
204+
if (positionToScroll != null) {
205+
return positionToScroll
206+
}
207+
}
208+
return 0
209+
}
210+
211+
private fun blocksSortedByPreference(sourceLine: Int) = iterator {
212+
val blockOnLine = lines2Blocks[sourceLine]
213+
if (blockOnLine != null) {
214+
yield(blockOnLine)
215+
} else {
216+
// If there is no block that covers the line,
217+
// the next best block is the one **after** the line.
218+
// Otherwise, when scrolling down the source,
219+
// on empty lines the preview will scroll
220+
// in the opposite direction
221+
val firstBlockAfterLine = lines2Blocks.higherEntry(sourceLine)?.value
222+
if (firstBlockAfterLine != null) {
223+
yield(firstBlockAfterLine)
224+
}
225+
}
226+
// Otherwise, look for the closest block positioned before the line.
227+
// This way, the corresponding preview line will be located
228+
// below the viewport's top point (and the user still has a chance
229+
// to see it in the visible area)
230+
val blocksBeforeLine = lines2Blocks.headMap(sourceLine)
231+
val blocksBeforeLineClosestFirst = blocksBeforeLine.values.reversed()
232+
for (block in blocksBeforeLineClosestFirst) {
233+
yield(block)
234+
}
235+
}
236+
237+
private fun MarkdownBlock.positionToScroll(sourceLine: Int): Int? {
238+
val y = blocks2Top[this] ?: return null
239+
val lineRange = (this as? LocatableMarkdownBlock)?.lines ?: return y
240+
194241
// The line may be empty and represent no block,
195242
// in this case scroll to the first line of the first block positioned after the line
196243
val lineIndexInBlock = maxOf(0, sourceLine - lineRange.first)
197-
val lineOffset = textOffsets?.get(lineIndexInBlock) ?: 0
198-
scrollState.animateScrollTo(y + lineOffset, animationSpec)
199-
}
244+
val textOffsets = blocks2TextOffsets[this]
200245

201-
private fun findBestBlockForLine(line: Int): MarkdownBlock? {
202-
// The best block is the one **below** the line if there is no block that covers the
203-
// line.
204-
// Otherwise, when scrolling down the source, on empty lines preview will scroll in the
205-
// opposite direction
206-
val sm = lines2Blocks.subMap(line, Int.MAX_VALUE)
207-
if (sm.isEmpty()) return null
208-
// TODO use firstEntry() after switching to JDK 21
209-
return sm.getValue(sm.firstKey())
246+
return y + (textOffsets?.getOrNull(lineIndexInBlock) ?: 0)
210247
}
211248

212249
override fun beforeProcessing() {

0 commit comments

Comments
 (0)