From 65e3f14cff74f3f5f70d2f3d4be0a9c57f8fea99 Mon Sep 17 00:00:00 2001 From: Alexander Kuznetsov Date: Mon, 18 Nov 2024 15:04:35 +0100 Subject: [PATCH 1/5] Markdown: synchronize scrolling between editor and preview (#690) Only ScrollState is supported because of its natural ability to scroll the view to an arbitrary coordinate, LazyListState doesn't allow this, and it only gives an opportunity to scroll to an item in a LazyColumn list and then to a position within the item. So, it requires a different approach (which can hopefully be adjusted to the proposed ScrollingSynchronizer API). Note that the change only enables auto-scrolling in a preview to match the position in the source, it doesn't work the other way around. --- markdown/core/api/core.api | 50 +++- .../jetbrains/jewel/markdown/MarkdownMode.kt | 28 ++ .../jewel/markdown/extensions/Markdown.kt | 8 + .../markdown/processing/MarkdownProcessor.kt | 105 ++++++- .../markdown/rendering/AutoScrollingUtil.kt | 110 ++++++++ .../rendering/DefaultMarkdownBlockRenderer.kt | 138 +++++---- .../scrolling/ScrollingSynchronizer.kt | 263 ++++++++++++++++++ .../MarkdownProcessorOptimizeEditsTest.kt | 25 +- .../github/alerts/GitHubAlertBlockRenderer.kt | 84 +++--- .../api/ide-laf-bridge-styling.api | 4 +- .../bridge/BridgeProvideMarkdownStyling.kt | 10 +- .../api/int-ui-standalone-styling.api | 4 +- .../standalone/IntUiProvideMarkdownStyling.kt | 8 +- .../samples/standalone/view/MarkdownView.kt | 12 +- .../view/markdown/MarkdownPreview.kt | 5 +- 15 files changed, 720 insertions(+), 134 deletions(-) create mode 100644 markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/MarkdownMode.kt create mode 100644 markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt create mode 100644 markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt diff --git a/markdown/core/api/core.api b/markdown/core/api/core.api index 14376ac741..f79f859206 100644 --- a/markdown/core/api/core.api +++ b/markdown/core/api/core.api @@ -222,6 +222,29 @@ public final class org/jetbrains/jewel/markdown/MarkdownKt { public static final fun Markdown (Ljava/util/List;Ljava/lang/String;Landroidx/compose/ui/Modifier;ZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Landroidx/compose/runtime/Composer;II)V } +public abstract interface class org/jetbrains/jewel/markdown/MarkdownMode { + public abstract fun getScrollingSynchronizer ()Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer; + public abstract fun getWithEditor ()Z +} + +public final class org/jetbrains/jewel/markdown/MarkdownMode$PreviewOnly : org/jetbrains/jewel/markdown/MarkdownMode { + public static final field $stable I + public static final field INSTANCE Lorg/jetbrains/jewel/markdown/MarkdownMode$PreviewOnly; + public fun getScrollingSynchronizer ()Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer; + public fun getWithEditor ()Z +} + +public final class org/jetbrains/jewel/markdown/MarkdownMode$WithEditor : org/jetbrains/jewel/markdown/MarkdownMode { + public static final field $stable I + public fun (Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer;)V + public fun getScrollingSynchronizer ()Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer; + public fun getWithEditor ()Z +} + +public final class org/jetbrains/jewel/markdown/MarkdownModeKt { + public static final fun WithMarkdownMode (Lorg/jetbrains/jewel/markdown/MarkdownMode;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V +} + public final class org/jetbrains/jewel/markdown/SemanticsKt { public static final fun getRawMarkdown ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; public static final fun getRawMarkdown (Landroidx/compose/ui/semantics/SemanticsPropertyReceiver;)Ljava/lang/String; @@ -258,9 +281,11 @@ public abstract interface class org/jetbrains/jewel/markdown/extensions/Markdown public final class org/jetbrains/jewel/markdown/extensions/MarkdownKt { public static final fun getLocalMarkdownBlockRenderer ()Landroidx/compose/runtime/ProvidableCompositionLocal; + public static final fun getLocalMarkdownMode ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getLocalMarkdownProcessor ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getLocalMarkdownStyling ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getMarkdownBlockRenderer (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer; + public static final fun getMarkdownMode (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/markdown/MarkdownMode; public static final fun getMarkdownProcessor (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor; public static final fun getMarkdownStyling (Lorg/jetbrains/jewel/foundation/theme/JewelTheme$Companion;Landroidx/compose/runtime/Composer;I)Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling; } @@ -299,12 +324,17 @@ public final class org/jetbrains/jewel/markdown/processing/MarkdownParserFactory public final class org/jetbrains/jewel/markdown/processing/MarkdownProcessor { public static final field $stable I public fun ()V - public fun (Ljava/util/List;ZLorg/commonmark/parser/Parser;)V - public synthetic fun (Ljava/util/List;ZLorg/commonmark/parser/Parser;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/List;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/commonmark/parser/Parser;)V + public synthetic fun (Ljava/util/List;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/commonmark/parser/Parser;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun processChildren (Lorg/commonmark/node/Node;)Ljava/util/List; public final fun processMarkdownDocument (Ljava/lang/String;)Ljava/util/List; } +public final class org/jetbrains/jewel/markdown/rendering/AutoScrollingUtilKt { + public static final fun AutoScrollableBlock (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun AutoScrollableText-JAgEBs0 (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Landroidx/compose/ui/text/AnnotatedString;Landroidx/compose/ui/Modifier;JJLandroidx/compose/ui/text/font/FontStyle;Landroidx/compose/ui/text/font/FontWeight;Landroidx/compose/ui/text/font/FontFamily;JLandroidx/compose/ui/text/style/TextDecoration;IJIZILjava/util/Map;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/runtime/Composer;III)V +} + public class org/jetbrains/jewel/markdown/rendering/DefaultInlineMarkdownRenderer : org/jetbrains/jewel/markdown/rendering/InlineMarkdownRenderer { public static final field $stable I public static final field Companion Lorg/jetbrains/jewel/markdown/rendering/DefaultInlineMarkdownRenderer$Companion; @@ -751,3 +781,19 @@ public abstract interface class org/jetbrains/jewel/markdown/rendering/WithUnder public abstract fun getUnderlineWidth-D9Ej5fM ()F } +public abstract class org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer { + public static final field $stable I + public static final field Companion Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer$Companion; + public fun ()V + public abstract fun acceptBlockSpans (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Lkotlin/ranges/IntRange;)V + public abstract fun acceptGlobalPosition (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Landroidx/compose/ui/layout/LayoutCoordinates;)V + public abstract fun acceptTextLayout (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Landroidx/compose/ui/text/TextLayoutResult;)V + public abstract fun afterProcessing ()V + public abstract fun beforeProcessing ()V + public abstract fun scrollToLine (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer$Companion { + public final fun create (Landroidx/compose/foundation/gestures/ScrollableState;)Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer; +} + diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/MarkdownMode.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/MarkdownMode.kt new file mode 100644 index 0000000000..424c87dfbb --- /dev/null +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/MarkdownMode.kt @@ -0,0 +1,28 @@ +package org.jetbrains.jewel.markdown + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode +import org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer + +@ExperimentalJewelApi +public sealed interface MarkdownMode { + public val withEditor: Boolean + public val scrollingSynchronizer: ScrollingSynchronizer? + + public object PreviewOnly : MarkdownMode { + override val withEditor: Boolean = false + override val scrollingSynchronizer: ScrollingSynchronizer? = null + } + + public class WithEditor(public override val scrollingSynchronizer: ScrollingSynchronizer?) : MarkdownMode { + override val withEditor: Boolean = true + } +} + +@ExperimentalJewelApi +@Composable +public fun WithMarkdownMode(mode: MarkdownMode, content: @Composable () -> Unit) { + CompositionLocalProvider(LocalMarkdownMode provides mode) { content() } +} diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/Markdown.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/Markdown.kt index e42195c241..12d3c33d14 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/Markdown.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/Markdown.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.staticCompositionLocalOf import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.markdown.MarkdownMode import org.jetbrains.jewel.markdown.processing.MarkdownProcessor import org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer import org.jetbrains.jewel.markdown.rendering.MarkdownStyling @@ -28,3 +29,10 @@ public val LocalMarkdownBlockRenderer: ProvidableCompositionLocal = staticCompositionLocalOf { + MarkdownMode.PreviewOnly +} + +public val JewelTheme.Companion.markdownMode: MarkdownMode + @Composable get() = LocalMarkdownMode.current diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt index cd526d0ae9..d119fe0020 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessor.kt @@ -14,6 +14,7 @@ import org.commonmark.node.ListItem import org.commonmark.node.Node import org.commonmark.node.OrderedList import org.commonmark.node.Paragraph +import org.commonmark.node.SourceSpan import org.commonmark.node.ThematicBreak import org.commonmark.parser.Parser import org.intellij.lang.annotations.Language @@ -26,40 +27,44 @@ import org.jetbrains.jewel.markdown.InlineMarkdown import org.jetbrains.jewel.markdown.MarkdownBlock import org.jetbrains.jewel.markdown.MarkdownBlock.CodeBlock import org.jetbrains.jewel.markdown.MarkdownBlock.ListBlock +import org.jetbrains.jewel.markdown.MarkdownMode import org.jetbrains.jewel.markdown.extensions.MarkdownProcessorExtension import org.jetbrains.jewel.markdown.rendering.DefaultInlineMarkdownRenderer +import org.jetbrains.jewel.markdown.scrolling.ScrollingSynchronizer /** * Reads raw Markdown strings and processes them into a list of [MarkdownBlock]. * * @param extensions Extensions to use when processing the Markdown (e.g., to support parsing custom block-level * Markdown). - * @param editorMode Indicates whether the processor should be optimized for an editor/preview scenario, where it + * @param markdownMode Indicates whether the processor should be optimized for an editor/preview scenario, where it * assumes small incremental changes as performed by a user typing. This means it will only update the changed blocks * by keeping state in memory. * * Default is `false`; set this to `true` if this parser will be used in an editor scenario, where the raw Markdown is * only ever going to change slightly but frequently (e.g., as the user types). * - * **Attention:** do **not** reuse or share an instance of [MarkdownProcessor] that is in [editorMode]. Processing + * **Attention:** do **not** reuse or share an instance of [MarkdownProcessor] that is in [markdownMode]. Processing * entirely different Markdown strings will defeat the purpose of the optimization. When in editor mode, the instance * of [MarkdownProcessor] is **not** thread-safe! * * @param commonMarkParser The CommonMark [Parser] used to parse the Markdown. By default it's a vanilla instance * provided by the [MarkdownParserFactory], but you can provide your own if you need to customize the parser — e.g., - * to ignore certain tags. If [optimizeEdits] is `true`, make sure you set + * to ignore certain tags. If [markdownMode] is `MarkdownMode.WithEditor`, make sure you set * `includeSourceSpans(IncludeSourceSpans.BLOCKS)` on the parser. */ @ExperimentalJewelApi public class MarkdownProcessor( private val extensions: List = emptyList(), - private val editorMode: Boolean = false, - private val commonMarkParser: Parser = MarkdownParserFactory.create(editorMode, extensions), + private val markdownMode: MarkdownMode = MarkdownMode.PreviewOnly, + private val commonMarkParser: Parser = MarkdownParserFactory.create(markdownMode.withEditor, extensions), ) { private var currentState = State(emptyList(), emptyList(), emptyList()) @TestOnly internal fun getCurrentIndexesInTest() = currentState.indexes + private val scrollingSynchronizer: ScrollingSynchronizer? = markdownMode.scrollingSynchronizer + /** * Parses a Markdown document, translating from CommonMark 0.31.2 to a list of [MarkdownBlock]. Inline Markdown in * leaf nodes is contained in [InlineMarkdown], which can be rendered to an @@ -69,14 +74,19 @@ public class MarkdownProcessor( * @see DefaultInlineMarkdownRenderer */ public fun processMarkdownDocument(@Language("Markdown") rawMarkdown: String): List { - val blocks = - if (editorMode) { - processWithQuickEdits(rawMarkdown) - } else { - parseRawMarkdown(rawMarkdown) - } + scrollingSynchronizer?.beforeProcessing() + return try { + val blocks = + if (markdownMode.withEditor) { + processWithQuickEdits(rawMarkdown) + } else { + parseRawMarkdown(rawMarkdown) + } - return blocks.mapNotNull { child -> child.tryProcessMarkdownBlock() } + blocks.mapNotNull { child -> child.tryProcessMarkdownBlock() } + } finally { + scrollingSynchronizer?.afterProcessing() + } } @VisibleForTesting @@ -154,6 +164,60 @@ public class MarkdownProcessor( previousBlocks.subList(lastBlock, previousBlocks.size) val newIndexes = previousIndexes.subList(0, firstBlock) + updatedIndexes + suffixIndexes + + // Processor only re-parses the changed part of the document, which has two outcomes: + // 1. sourceSpans in updatedBlocks start from line index 0, not from the actual line + // the update part starts in the document; + // 2. sourceSpans in blocks after the changed part remain unchanged + // (therefore irrelevant too). + // + // Addressing the second outcome is easy, as all the lines there were just shifted by + // nLinesDelta. + + for (i in lastBlock until newBlocks.size) { + newBlocks[i].traverseAll { node -> + node.sourceSpans = + node.sourceSpans.map { span -> + SourceSpan.of(span.lineIndex + nLinesDelta, span.columnIndex, span.inputIndex, span.length) + } + } + } + + // The first outcome is a bit trickier. Consider a fresh new block with the following + // structure: + // + // indexes spans + // Block A [10-20] (0-10) + // block A1 [ n/a ] (0-2) + // block A2 [ n/a ] (3-10) + // Block B [21-30] (11-20) + // block B1 [ n/a ] (11-16) + // block B2 [ n/a ] (17-20) + // + // There are two updated blocks with two children each. + // Note that at this point the indexes are updated, yet they only exist for the topmost + // blocks. + // So, to calculate actual spans for, for example, block B2 (B2s), we need to also take into + // account + // the first index of the block B (Bi) and the first span of the block B (Bs) and use the + // formula + // B2s = (B2s - Bs) + Bi + for ((block, indexes) in updatedBlocks.zip(updatedIndexes)) { + val firstSpanLineIndex = block.sourceSpans.firstOrNull()?.lineIndex ?: continue + val firstIndex = indexes.first + block.traverseAll { node -> + node.sourceSpans = + node.sourceSpans.map { span -> + SourceSpan.of( + span.lineIndex - firstSpanLineIndex + firstIndex, + span.columnIndex, + span.inputIndex, + span.length, + ) + } + } + } + currentState = State(newLines, newBlocks, newIndexes) return newBlocks @@ -186,8 +250,20 @@ public class MarkdownProcessor( } else -> null + }.also { block -> + if (scrollingSynchronizer != null && this is Block && block != null) { + postProcess(scrollingSynchronizer, this, block) + } } + private fun postProcess(scrollingSynchronizer: ScrollingSynchronizer, block: Block, mdBlock: MarkdownBlock) { + val spans = + block.sourceSpans.ifEmpty { + return + } + scrollingSynchronizer.acceptBlockSpans(mdBlock, spans.first().lineIndex..spans.last().lineIndex) + } + private fun Paragraph.toMarkdownParagraph(): MarkdownBlock.Paragraph = MarkdownBlock.Paragraph(readInlineContent().toList()) @@ -257,6 +333,11 @@ public class MarkdownProcessor( } } + private fun Node.traverseAll(action: (Node) -> Unit) { + action(this) + forEachChild { child -> child.traverseAll(action) } + } + private fun HtmlBlock.toMarkdownHtmlBlockOrNull(): MarkdownBlock.HtmlBlock? { if (literal.isBlank()) return null return MarkdownBlock.HtmlBlock(literal.trimEnd('\n')) diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt new file mode 100644 index 0000000000..b974a5198b --- /dev/null +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt @@ -0,0 +1,110 @@ +package org.jetbrains.jewel.markdown.rendering + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.runtime.Composable +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import kotlin.math.abs +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.markdown.MarkdownBlock +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode +import org.jetbrains.jewel.markdown.extensions.markdownMode +import org.jetbrains.jewel.ui.component.Text + +/** + * Use this composable as a wrapper to an actual block composable to enable scrolling to the block in an editor+preview + * combined mode with scrolling synchronization. + * + * @see [DefaultMarkdownBlockRenderer] + */ +@ExperimentalJewelApi +@Composable +public fun AutoScrollableBlock( + block: MarkdownBlock, + modifier: Modifier = Modifier, + content: @Composable (Modifier) -> Unit, +) { + val synchronizer = JewelTheme.markdownMode.scrollingSynchronizer + if (synchronizer == null) { + return content(modifier) + } + + var previousPosition by remember { mutableStateOf(Offset.Zero) } + + content( + modifier.onGloballyPositioned { coordinates -> + val newPosition = coordinates.positionInRoot() + if (abs(previousPosition.y - newPosition.y) > 1.0) { + previousPosition = newPosition + synchronizer.acceptGlobalPosition(block, coordinates) + } + } + ) +} + +/** + * Use this composable if you want to have auto-scrolling within atomic text blocks (such as code blocks) in an + * editor+preview combined mode with scrolling synchronization. + * + * @see [DefaultMarkdownBlockRenderer.CodeText] + */ +@ExperimentalJewelApi +@Composable +public fun AutoScrollableText( + block: MarkdownBlock, + text: AnnotatedString, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign = TextAlign.Unspecified, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + inlineContent: Map = emptyMap(), + style: TextStyle = JewelTheme.defaultTextStyle, +) { + with(LocalMarkdownMode.current) { + Text( + text = text, + modifier = modifier, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + inlineContent = inlineContent, + onTextLayout = { layout -> scrollingSynchronizer?.acceptTextLayout(block, layout) }, + style = style, + ) + } +} diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt index ecb8f107b4..f4177feaef 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -119,13 +120,16 @@ public open class DefaultMarkdownBlockRenderer( val mergedStyle = styling.inlinesStyling.textStyle.merge(TextStyle(color = textColor)) val interactionSource = remember { MutableInteractionSource() } - Text( - modifier = - Modifier.focusProperties { canFocus = false } - .clickable(interactionSource = interactionSource, indication = null, onClick = onTextClick), - text = renderedContent, - style = mergedStyle, - ) + AutoScrollableBlock(block) { modifier -> + Text( + modifier = + modifier + .focusProperties { canFocus = false } + .clickable(interactionSource = interactionSource, indication = null, onClick = onTextClick), + text = renderedContent, + style = mergedStyle, + ) + } } @Composable @@ -156,18 +160,22 @@ public open class DefaultMarkdownBlockRenderer( onTextClick: () -> Unit, ) { val renderedContent = rememberRenderedContent(block, styling.inlinesStyling, enabled, onUrlClick) - Heading( - renderedContent, - styling.inlinesStyling.textStyle, - styling.padding, - styling.underlineWidth, - styling.underlineColor, - styling.underlineGap, - ) + AutoScrollableBlock(block) { modifier -> + Heading( + modifier, + renderedContent, + styling.inlinesStyling.textStyle, + styling.padding, + styling.underlineWidth, + styling.underlineColor, + styling.underlineGap, + ) + } } @Composable private fun Heading( + modifier: Modifier, renderedContent: AnnotatedString, textStyle: TextStyle, paddingValues: PaddingValues, @@ -175,7 +183,7 @@ public open class DefaultMarkdownBlockRenderer( underlineColor: Color, underlineGap: Dp, ) { - Column(modifier = Modifier.padding(paddingValues)) { + Column(modifier = modifier.padding(paddingValues)) { val textColor = textStyle.color.takeOrElse { LocalContentColor.current.takeOrElse { textStyle.color } } val mergedStyle = textStyle.merge(TextStyle(color = textColor)) Text(text = renderedContent, style = mergedStyle, modifier = Modifier.focusProperties { canFocus = false }) @@ -252,16 +260,19 @@ public open class DefaultMarkdownBlockRenderer( for ((index, item) in block.children.withIndex()) { Row { val number = block.startFrom + index - Text( - text = "$number${block.delimiter}", - style = styling.numberStyle, - color = styling.numberStyle.color.takeOrElse { LocalContentColor.current }, - modifier = - Modifier.focusProperties { canFocus = false } - .widthIn(min = styling.numberMinWidth) - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - textAlign = styling.numberTextAlign, - ) + AutoScrollableBlock(block) { modifier -> + Text( + text = "$number${block.delimiter}", + style = styling.numberStyle, + color = styling.numberStyle.color.takeOrElse { LocalContentColor.current }, + modifier = + modifier + .focusProperties { canFocus = false } + .widthIn(min = styling.numberMinWidth) + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + textAlign = styling.numberTextAlign, + ) + } Spacer(Modifier.width(styling.numberContentGap)) @@ -289,14 +300,17 @@ public open class DefaultMarkdownBlockRenderer( Column(modifier = Modifier.padding(styling.padding), verticalArrangement = Arrangement.spacedBy(itemSpacing)) { for (item in block.children) { Row { - Text( - text = styling.bullet.toString(), - style = styling.bulletStyle, - color = styling.bulletStyle.color.takeOrElse { LocalContentColor.current }, - modifier = - Modifier.focusProperties { canFocus = false } - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) + AutoScrollableBlock(block) { modifier -> + Text( + text = styling.bullet.toString(), + style = styling.bulletStyle, + color = styling.bulletStyle.color.takeOrElse { LocalContentColor.current }, + modifier = + modifier + .focusProperties { canFocus = false } + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) + } Spacer(Modifier.width(styling.bulletContentGap)) @@ -329,15 +343,19 @@ public open class DefaultMarkdownBlockRenderer( .border(styling.borderWidth, styling.borderColor, styling.shape) .then(if (styling.fillWidth) Modifier.fillMaxWidth() else Modifier), ) { - Text( - text = block.content, - style = styling.editorTextStyle, - color = styling.editorTextStyle.color.takeOrElse { LocalContentColor.current }, - modifier = - Modifier.focusProperties { canFocus = false } - .padding(styling.padding) - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) + AutoScrollableBlock(block) { modifier -> + AutoScrollableText( + block = block, + text = AnnotatedString(block.content), + style = styling.editorTextStyle, + color = styling.editorTextStyle.color.takeOrElse { LocalContentColor.current }, + modifier = + modifier + .focusProperties { canFocus = false } + .padding(styling.padding) + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) + } } } @@ -361,7 +379,7 @@ public open class DefaultMarkdownBlockRenderer( ) } - Code(block.content, mimeType, styling) + Code(block, mimeType, styling) if (styling.infoPosition.verticalAlignment == Alignment.Bottom) { FencedBlockInfo( @@ -377,21 +395,29 @@ public open class DefaultMarkdownBlockRenderer( } @Composable - private fun Code(content: String, mimeType: MimeType, styling: MarkdownStyling.Code.Fenced) { - val annotatedCode by - LocalCodeHighlighter.current.highlight(content, mimeType).collectAsState(AnnotatedString(content)) - CodeText(annotatedCode, styling) + private fun Code(block: FencedCodeBlock, mimeType: MimeType, styling: MarkdownStyling.Code.Fenced) { + key(block) { + val annotatedCode by + LocalCodeHighlighter.current + .highlight(block.content, mimeType) + .collectAsState(AnnotatedString(block.content)) + CodeText(block, annotatedCode, styling) + } } @Composable - private fun CodeText(annotatedCode: AnnotatedString, styling: MarkdownStyling.Code.Fenced) { - Text( - text = annotatedCode, - style = styling.editorTextStyle, - modifier = - Modifier.focusProperties { canFocus = false } - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) + private fun CodeText(block: MarkdownBlock, annotatedCode: AnnotatedString, styling: MarkdownStyling.Code.Fenced) { + AutoScrollableBlock(block) { modifier -> + AutoScrollableText( + block = block, + text = annotatedCode, + style = styling.editorTextStyle, + modifier = + modifier + .focusProperties { canFocus = false } + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) + } } @Composable diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt new file mode 100644 index 0000000000..a38f44d10c --- /dev/null +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt @@ -0,0 +1,263 @@ +package org.jetbrains.jewel.markdown.scrolling + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.TextLayoutResult +import java.util.TreeMap +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.util.myLogger +import org.jetbrains.jewel.markdown.MarkdownBlock +import org.jetbrains.jewel.markdown.processing.MarkdownProcessor +import org.jetbrains.jewel.markdown.rendering.AutoScrollableBlock +import org.jetbrains.jewel.markdown.rendering.AutoScrollableText + +/** + * To support synchronized scrolling between source and preview, we need to establish a mapping between source lines and + * coordinates of their presentation. + * + * For simplicity, let's suppose that the source code is immutable. [MarkdownProcessor] parses it and yields a list of + * [MarkdownBlock]s. Unfortunately, it doesn't contain any information about the source lines, as the need to keep them + * and reserve more heap is not strong enough (the hypothesis is that most users just need to read the .md file and not + * to edit it). + * + * However, [MarkdownProcessor] uses commonmark inside and takes the blocks this library returns to build + * [MarkdownBlock]s, and in the editor mode, commonmark blocks still hold the information about source lines. + * [acceptBlockSpans] can be implemented the way that remembers mappings between [MarkdownBlock]s and source lines these + * blocks span over. + * + * Next, Compose provides the callback [onGloballyPositioned] with precalculated global layout. [acceptGlobalPosition] + * can be implemented to remember mappings between [MarkdownBlock]s and global coordinates these blocks are rendered on. + * + * These two mappings are enough to make the synchronizer work. When a source code is scrolled to a line, an + * implementation can find a block containing the line (or the next one if there are no blocks on the line), then find + * this block's global layout and, finally, tell Compose to scroll to the topmost coordinate of the layout. This way, a + * user can observe the whole block in the preview, even if only a part of it is visible in the source view. + * + * For some blocks, however, it makes sense to scroll within their content. Code blocks make for a perfect example of + * it. They can contain a lot of lines, and at the same time, they're not soft-wrapped in a preview, every source line + * is mapped 1:1 to the preview, so scrolling inside a code block would be preferable (and natural) to support. + * [acceptTextLayout] serves the purpose of calculation every line's position within the composable. This information + * may, in turn, be used together with global positioning of the composable to compute the absolute position of a + * certain line in the preview. + * + * # Editing + * + * [MarkdownProcessor] always yields all the blocks that are present in the source, even in optimized mode, so + * [acceptBlockSpans] is not really affected by editing. [acceptGlobalPosition] is trickier, as it is not triggered on + * blocks preceding the change. [acceptTextLayout] is even more intricate, as it may or may not be triggered on blocks + * following the change. It implies that mappings should be adjusted accordingly. [beforeProcessing] and + * [afterProcessing] can help with that, as they're invoked before and after every re-parse, i.e. every change in the + * file. See [PerLine] as one of the possible implementations for [ScrollState]. + * + * # Keep in mind + * - [acceptBlockSpans] accepts blocks in the **depth-first order**. + * - Between [beforeProcessing] and [afterProcessing] every single block is processed, [acceptBlockSpans] is triggered + * for every one of them. + * - [acceptGlobalPosition] is **always** triggered on the changed block and the blocks that follow the change. + * - [acceptTextLayout] is **always** triggered on the changed block, but **not always** on those located below the + * changed block. It's **not triggered** on blocks located above the change. + * - [acceptTextLayout] is triggered **before** [acceptGlobalPosition] for the same block. + * + * @see [MarkdownProcessor] + * @see [AutoScrollableBlock] + * @see [AutoScrollableText] + * @see [PerLine] + */ +@ExperimentalJewelApi +public abstract class ScrollingSynchronizer { + /** Scroll the preview to the position that match the given [sourceLine] the best. */ + public abstract suspend fun scrollToLine(sourceLine: Int) + + /** Called before [MarkdownProcessor] starts processing the raw markdown text. */ + public abstract fun beforeProcessing() + + /** Called after [MarkdownProcessor] starts processing the raw markdown text. */ + public abstract fun afterProcessing() + + /** + * Accept mapping between the markdown [block] and the [sourceRange] of lines containing this block. Called on every + * block after it was (re)parsed. + */ + public abstract fun acceptBlockSpans(block: MarkdownBlock, sourceRange: IntRange) + + /** + * Accept mapping between the markdown [block] and the global [coordinates] of lines containing this block. Called + * on all blocks that require (re)positioning: on first composition, on a changed block, on unchanged blocks that + * are positioned below the changed block. + */ + public abstract fun acceptGlobalPosition(block: MarkdownBlock, coordinates: LayoutCoordinates) + + /** + * Accept mapping between the markdown [block] and the [textLayout] of the text this block comprises. Called on all + * blocks that require adjusting text layout: on first composition, on a block with the changed text, and may be + * called on unchanged blocks that are positioned below the changed block. + */ + public abstract fun acceptTextLayout(block: MarkdownBlock, textLayout: TextLayoutResult) + + public companion object { + public fun create(scrollState: ScrollableState): ScrollingSynchronizer? = + when (scrollState) { + is ScrollState -> PerLine(scrollState) + is LazyListState -> { + myLogger().warn("Synchronization for LazyListState is not supported yet") + null + } + + else -> null + } + } + + private class PerLine(private val scrollState: ScrollState) : ScrollingSynchronizer() { + private val lines2Blocks = TreeMap() + private var blocks2LineRanges = mutableMapOf() + private val blocks2Top = mutableMapOf() + private val previousPositions = mutableMapOf() + + // Only used to clean up obsolete keys in the maps above; + // otherwise stale MarkdownBlocks will keep piling up on each typed key + private val actualBlocks = mutableSetOf() + + // It'd be a bit more performant if there were a map mapping lines to offsets, + // and that was the initial approach, + // but this structure would be hard to maintain because of optimizations in Compose. + // Namely, text offsets may not be recalculated even if the block was repositioned. + // For example, if contents of one item in a Column change, it only causes relayout + // of the changed item, and not the items that follow, even though they are to be + // repositioned globally. + // Thus, even if lines that a block occupies change, + // relative offsets within the block can remain the same. + // But here, given there's guaranteed 1:1 source to preview lines mapping, + // the rules holds that, if a block hasn't changed, text offsets remain unchanged too, + // so this map always keeps relevant information. + private val blocks2TextOffsets = mutableMapOf>() + + override suspend fun scrollToLine(sourceLine: Int) { + val block = findBestBlockForLine(sourceLine) ?: return + val y = blocks2Top[block] ?: return + if (y < 0) return + val lineRange = blocks2LineRanges[block] ?: return + val textOffsets = blocks2TextOffsets[block] + // The line may be empty and represent no block, + // in this case scroll to the first line of the first block positioned after the line + val lineIndexInBlock = maxOf(0, sourceLine - lineRange.start) + val lineOffset = textOffsets?.get(lineIndexInBlock) ?: 0 + scrollState.animateScrollTo(y + lineOffset) + } + + private fun findBestBlockForLine(line: Int): MarkdownBlock? { + // The best block is the one **below** the line if there is no block that covers the + // line. + // Otherwise, when scrolling down the source, on empty lines preview will scroll in the + // opposite direction + val sm = lines2Blocks.subMap(line, Int.MAX_VALUE) + if (sm.isEmpty()) return null + // TODO use firstEntry() after switching to JDK 21 + return sm.getValue(sm.firstKey()) + } + + override fun beforeProcessing() { + // acceptBlockSpans works on ALL the nodes, including those unchanged, + // so it will be fully rebuilt during processing anyway + lines2Blocks.clear() + blocks2LineRanges.clear() + } + + override fun afterProcessing() { + blocks2LineRanges.keys.retainAll(actualBlocks) + blocks2Top.keys.retainAll(actualBlocks) + blocks2TextOffsets.keys.retainAll(actualBlocks) + previousPositions.keys.retainAll(actualBlocks) + actualBlocks.clear() + } + + override fun acceptBlockSpans(block: MarkdownBlock, sourceRange: IntRange) { + for (line in sourceRange) { + // DFS -- keep the innermost block for the given line + lines2Blocks.putIfAbsent(line, block) + } + blocks2LineRanges[block] = sourceRange + actualBlocks += block + } + + override fun acceptGlobalPosition(block: MarkdownBlock, coordinates: LayoutCoordinates) { + // coordinates are relative to the current viewport + // (which also means onPositionedGlobally is triggered when scrolling); + // to get the real absolute coordinates we need to consider scroll state + val y = coordinates.positionInRoot().y.toInt() + scrollState.value + + // let's not recalculate internal structures on the preview scrolling -- more safety + val oldY = previousPositions[block] + if (oldY == null || y != oldY) { + blocks2Top[block] = y + previousPositions[block] = y + } + } + + override fun acceptTextLayout(block: MarkdownBlock, textLayout: TextLayoutResult) { + if (block !is MarkdownBlock.CodeBlock) return + val sourceLines = blocks2LineRanges[block] ?: return + + var y = 0 + val list = mutableListOf() + + if (block is MarkdownBlock.CodeBlock.FencedCodeBlock) { + // All source lines in the fenced code block, + // beside the first and the last ones, are mapped 1:1 onto preview + // code block: + // + // | source: | preview: + // __________________________________|_________________ + // (first line) | ```language | + // | | + // | | + // | | + // | | + // | | + // (last line) | ``` | + // + // Some of the lines might be empty, and thus there are no spans for them. + // However, every empty line follows the 1:1 mapping rule, + // which means all of the lines in the range [first line + 1; last line - 1] + // have their counterparts in the preview, regardless of the content. + + val openingLine = sourceLines.first() + val firstSourceLine = openingLine + 1 + val closingLine = sourceLines.last() + // map the line with opening triple backticks + // to the topmost point of the block in the preview + list += y + for (i in firstSourceLine.. | + // | | + // | | + // | | + // (last line) | | + for (i in sourceLines) { + list += y + val lineHeight = + textLayout.getLineBottom(i - sourceLines.first) - textLayout.getLineTop(i - sourceLines.first) + y += lineHeight.toInt() + } + } + blocks2TextOffsets[block] = list + } + } +} diff --git a/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessorOptimizeEditsTest.kt b/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessorOptimizeEditsTest.kt index 3bbf7236fa..030adb1daa 100644 --- a/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessorOptimizeEditsTest.kt +++ b/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/processing/MarkdownProcessorOptimizeEditsTest.kt @@ -7,6 +7,7 @@ import org.commonmark.parser.IncludeSourceSpans import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import org.intellij.lang.annotations.Language +import org.jetbrains.jewel.markdown.MarkdownMode import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNotSame @@ -46,7 +47,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `first blocks stay the same`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -83,7 +84,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `first block edited`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -141,7 +142,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `last block edited`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -202,7 +203,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `middle block edited`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -265,7 +266,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `blocks merged`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -324,7 +325,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `blocks split`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -384,7 +385,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `blocks deleted`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits( @@ -438,7 +439,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `blocks added`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondDocument = """ @@ -505,7 +506,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `no changes`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits(rawMarkdown) assertHtmlEquals( @@ -538,7 +539,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `empty line added`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) val firstRun = processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits("\n" + rawMarkdown) assertHtmlEquals( @@ -573,7 +574,7 @@ class MarkdownProcessorOptimizeEditsTest { /** Regression https://github.com/JetBrains/jewel/issues/344 */ @Test fun `content if empty`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) processor.processWithQuickEdits(rawMarkdown) val secondRun = processor.processWithQuickEdits("") assertHtmlEquals( @@ -587,7 +588,7 @@ class MarkdownProcessorOptimizeEditsTest { @Test fun `chained changes`() { - val processor = MarkdownProcessor(editorMode = true) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(null)) processor.processWithQuickEdits( """ # Header 0 diff --git a/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt b/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt index 51638ae6c8..6a9f5a458f 100644 --- a/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt +++ b/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.takeOrElse @@ -23,6 +22,7 @@ import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Important import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Note import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Tip import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Warning +import org.jetbrains.jewel.markdown.rendering.AutoScrollableBlock import org.jetbrains.jewel.markdown.rendering.InlineMarkdownRenderer import org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer import org.jetbrains.jewel.markdown.rendering.MarkdownStyling @@ -66,50 +66,56 @@ public class GitHubAlertBlockRenderer(private val styling: AlertStyling, private onUrlClick: (String) -> Unit, onTextClick: () -> Unit, ) { - Column( - Modifier.drawBehind { - val isLtr = layoutDirection == Ltr - val lineWidthPx = styling.lineWidth.toPx() - val x = if (isLtr) lineWidthPx / 2 else size.width - lineWidthPx / 2 + AutoScrollableBlock(block) { modifier -> + Column( + modifier + .drawBehind { + val isLtr = layoutDirection == Ltr + val lineWidthPx = styling.lineWidth.toPx() + val x = if (isLtr) lineWidthPx / 2 else size.width - lineWidthPx / 2 - drawLine( - color = styling.lineColor, - start = Offset(x, 0f), - end = Offset(x, size.height), - strokeWidth = lineWidthPx, - cap = styling.strokeCap, - pathEffect = styling.pathEffect, - ) - } - .padding(styling.padding), - verticalArrangement = Arrangement.spacedBy(rootStyling.blockVerticalSpacing), - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - val titleIconKey = styling.titleIconKey - if (titleIconKey != null) { - Icon( - key = titleIconKey, - contentDescription = null, - iconClass = AlertStyling::class.java, - tint = styling.titleIconTint, - ) - } + drawLine( + color = styling.lineColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = lineWidthPx, + cap = styling.strokeCap, + pathEffect = styling.pathEffect, + ) + } + .padding(styling.padding), + verticalArrangement = Arrangement.spacedBy(rootStyling.blockVerticalSpacing), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val titleIconKey = styling.titleIconKey + if (titleIconKey != null) { + Icon( + key = titleIconKey, + contentDescription = null, + iconClass = AlertStyling::class.java, + tint = styling.titleIconTint, + ) + } + CompositionLocalProvider( + LocalContentColor provides styling.titleTextStyle.color.takeOrElse { LocalContentColor.current } + ) { + Text( + text = block.javaClass.simpleName, + style = styling.titleTextStyle, + modifier = modifier.pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) + } + } CompositionLocalProvider( - LocalContentColor provides styling.titleTextStyle.color.takeOrElse { LocalContentColor.current } + LocalContentColor provides styling.textColor.takeOrElse { LocalContentColor.current } ) { - Text( - text = block.javaClass.simpleName, - style = styling.titleTextStyle, - modifier = Modifier.pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) + blockRenderer.render(block.content, enabled, onUrlClick, onTextClick) } } - CompositionLocalProvider( - LocalContentColor provides styling.textColor.takeOrElse { LocalContentColor.current } - ) { - blockRenderer.render(block.content, enabled, onUrlClick, onTextClick) - } } } } diff --git a/markdown/ide-laf-bridge-styling/api/ide-laf-bridge-styling.api b/markdown/ide-laf-bridge-styling/api/ide-laf-bridge-styling.api index c139b91712..f6d7cfbc72 100644 --- a/markdown/ide-laf-bridge-styling/api/ide-laf-bridge-styling.api +++ b/markdown/ide-laf-bridge-styling/api/ide-laf-bridge-styling.api @@ -4,8 +4,8 @@ public final class org/jetbrains/jewel/intui/markdown/bridge/BridgeMarkdownBlock } public final class org/jetbrains/jewel/intui/markdown/bridge/BridgeProvideMarkdownStylingKt { - public static final fun ProvideMarkdownStyling (Lcom/intellij/openapi/project/Project;Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V - public static final fun ProvideMarkdownStyling (Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun ProvideMarkdownStyling (Lcom/intellij/openapi/project/Project;Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun ProvideMarkdownStyling (Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V } public final class org/jetbrains/jewel/intui/markdown/bridge/styling/BridgeMarkdownStylingKt { diff --git a/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/BridgeProvideMarkdownStyling.kt b/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/BridgeProvideMarkdownStyling.kt index 31d04c9b7a..815aba45d8 100644 --- a/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/BridgeProvideMarkdownStyling.kt +++ b/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/BridgeProvideMarkdownStyling.kt @@ -12,7 +12,9 @@ import org.jetbrains.jewel.foundation.code.highlighting.LocalCodeHighlighter import org.jetbrains.jewel.foundation.code.highlighting.NoOpCodeHighlighter import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.intui.markdown.bridge.styling.create +import org.jetbrains.jewel.markdown.MarkdownMode import org.jetbrains.jewel.markdown.extensions.LocalMarkdownBlockRenderer +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode import org.jetbrains.jewel.markdown.extensions.LocalMarkdownProcessor import org.jetbrains.jewel.markdown.extensions.LocalMarkdownStyling import org.jetbrains.jewel.markdown.processing.MarkdownProcessor @@ -24,7 +26,8 @@ import org.jetbrains.jewel.markdown.rendering.MarkdownStyling public fun ProvideMarkdownStyling( themeName: String = JewelTheme.name, markdownStyling: MarkdownStyling = remember(themeName) { MarkdownStyling.create() }, - markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor() }, + markdownMode: MarkdownMode = remember { MarkdownMode.PreviewOnly }, + markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor(markdownMode = markdownMode) }, markdownBlockRenderer: MarkdownBlockRenderer = remember(markdownStyling) { MarkdownBlockRenderer.create(markdownStyling) }, codeHighlighter: CodeHighlighter = remember { NoOpCodeHighlighter }, @@ -32,6 +35,7 @@ public fun ProvideMarkdownStyling( ) { CompositionLocalProvider( LocalMarkdownStyling provides markdownStyling, + LocalMarkdownMode provides markdownMode, LocalMarkdownProcessor provides markdownProcessor, LocalMarkdownBlockRenderer provides markdownBlockRenderer, LocalCodeHighlighter provides codeHighlighter, @@ -46,7 +50,8 @@ public fun ProvideMarkdownStyling( project: Project, themeName: String = JewelTheme.name, markdownStyling: MarkdownStyling = remember(themeName) { MarkdownStyling.create() }, - markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor() }, + markdownMode: MarkdownMode = remember { MarkdownMode.PreviewOnly }, + markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor(markdownMode = markdownMode) }, markdownBlockRenderer: MarkdownBlockRenderer = remember(markdownStyling) { MarkdownBlockRenderer.create(markdownStyling) }, content: @Composable () -> Unit, @@ -56,6 +61,7 @@ public fun ProvideMarkdownStyling( ProvideMarkdownStyling( themeName = themeName, markdownStyling = markdownStyling, + markdownMode = markdownMode, markdownProcessor = markdownProcessor, markdownBlockRenderer = markdownBlockRenderer, codeHighlighter = codeHighlighter, diff --git a/markdown/int-ui-standalone-styling/api/int-ui-standalone-styling.api b/markdown/int-ui-standalone-styling/api/int-ui-standalone-styling.api index d1453f58fa..4e4693c68c 100644 --- a/markdown/int-ui-standalone-styling/api/int-ui-standalone-styling.api +++ b/markdown/int-ui-standalone-styling/api/int-ui-standalone-styling.api @@ -6,8 +6,8 @@ public final class org/jetbrains/jewel/intui/markdown/standalone/IntUiMarkdownBl } public final class org/jetbrains/jewel/intui/markdown/standalone/IntUiProvideMarkdownStylingKt { - public static final fun ProvideMarkdownStyling (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V - public static final fun ProvideMarkdownStyling (ZLorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun ProvideMarkdownStyling (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun ProvideMarkdownStyling (ZLorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Lorg/jetbrains/jewel/markdown/MarkdownMode;Lorg/jetbrains/jewel/markdown/processing/MarkdownProcessor;Lorg/jetbrains/jewel/markdown/rendering/MarkdownBlockRenderer;Lorg/jetbrains/jewel/foundation/code/highlighting/CodeHighlighter;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V } public final class org/jetbrains/jewel/intui/markdown/standalone/styling/IntUiMarkdownStylingKt { diff --git a/markdown/int-ui-standalone-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/standalone/IntUiProvideMarkdownStyling.kt b/markdown/int-ui-standalone-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/standalone/IntUiProvideMarkdownStyling.kt index c62e396671..66dd206a44 100644 --- a/markdown/int-ui-standalone-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/standalone/IntUiProvideMarkdownStyling.kt +++ b/markdown/int-ui-standalone-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/standalone/IntUiProvideMarkdownStyling.kt @@ -10,7 +10,9 @@ import org.jetbrains.jewel.foundation.code.highlighting.NoOpCodeHighlighter import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.intui.markdown.standalone.styling.dark import org.jetbrains.jewel.intui.markdown.standalone.styling.light +import org.jetbrains.jewel.markdown.MarkdownMode import org.jetbrains.jewel.markdown.extensions.LocalMarkdownBlockRenderer +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode import org.jetbrains.jewel.markdown.extensions.LocalMarkdownProcessor import org.jetbrains.jewel.markdown.extensions.LocalMarkdownStyling import org.jetbrains.jewel.markdown.processing.MarkdownProcessor @@ -29,6 +31,7 @@ public fun ProvideMarkdownStyling( MarkdownStyling.light() } }, + markdownMode: MarkdownMode = remember { MarkdownMode.PreviewOnly }, markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor() }, markdownBlockRenderer: MarkdownBlockRenderer = remember(markdownStyling) { @@ -43,6 +46,7 @@ public fun ProvideMarkdownStyling( ) { CompositionLocalProvider( LocalMarkdownStyling provides markdownStyling, + LocalMarkdownMode provides markdownMode, LocalMarkdownProcessor provides markdownProcessor, LocalMarkdownBlockRenderer provides markdownBlockRenderer, LocalCodeHighlighter provides codeHighlighter, @@ -57,11 +61,13 @@ public fun ProvideMarkdownStyling( markdownStyling: MarkdownStyling, markdownBlockRenderer: MarkdownBlockRenderer, codeHighlighter: CodeHighlighter, - markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor() }, + markdownMode: MarkdownMode = remember { MarkdownMode.PreviewOnly }, + markdownProcessor: MarkdownProcessor = remember { MarkdownProcessor(markdownMode = markdownMode) }, content: @Composable () -> Unit, ) { CompositionLocalProvider( LocalMarkdownStyling provides markdownStyling, + LocalMarkdownMode provides markdownMode, LocalMarkdownProcessor provides markdownProcessor, LocalMarkdownBlockRenderer provides markdownBlockRenderer, LocalCodeHighlighter provides codeHighlighter, diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/MarkdownView.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/MarkdownView.kt index e35cfa48b4..49d045a26d 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/MarkdownView.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/MarkdownView.kt @@ -9,6 +9,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import org.jetbrains.jewel.foundation.modifier.trackActivation import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.markdown.MarkdownMode +import org.jetbrains.jewel.markdown.WithMarkdownMode import org.jetbrains.jewel.samples.standalone.view.markdown.JewelReadme import org.jetbrains.jewel.samples.standalone.view.markdown.MarkdownEditor import org.jetbrains.jewel.samples.standalone.view.markdown.MarkdownPreview @@ -18,11 +20,13 @@ import org.jetbrains.jewel.ui.component.Divider @Composable fun MarkdownDemo() { Row(Modifier.trackActivation().fillMaxSize().background(JewelTheme.globalColors.panelBackground)) { - val editorState = rememberTextFieldState(JewelReadme) - MarkdownEditor(state = editorState, modifier = Modifier.fillMaxHeight().weight(1f)) + WithMarkdownMode(MarkdownMode.WithEditor(scrollingSynchronizer = null)) { + val editorState = rememberTextFieldState(JewelReadme) + MarkdownEditor(state = editorState, modifier = Modifier.fillMaxHeight().weight(1f)) - Divider(Orientation.Vertical, Modifier.fillMaxHeight()) + Divider(Orientation.Vertical, Modifier.fillMaxHeight()) - MarkdownPreview(modifier = Modifier.fillMaxHeight().weight(1f), rawMarkdown = editorState.text) + MarkdownPreview(modifier = Modifier.fillMaxHeight().weight(1f), rawMarkdown = editorState.text) + } } } diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/MarkdownPreview.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/MarkdownPreview.kt index 40a796138b..ce782d8a2c 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/MarkdownPreview.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/markdown/MarkdownPreview.kt @@ -28,6 +28,7 @@ import org.jetbrains.jewel.intui.markdown.standalone.styling.light import org.jetbrains.jewel.markdown.LazyMarkdown import org.jetbrains.jewel.markdown.MarkdownBlock import org.jetbrains.jewel.markdown.extension.autolink.AutolinkProcessorExtension +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode import org.jetbrains.jewel.markdown.extensions.github.alerts.AlertStyling import org.jetbrains.jewel.markdown.extensions.github.alerts.GitHubAlertProcessorExtension import org.jetbrains.jewel.markdown.extensions.github.alerts.GitHubAlertRendererExtension @@ -45,11 +46,11 @@ internal fun MarkdownPreview(modifier: Modifier = Modifier, rawMarkdown: CharSeq var markdownBlocks by remember { mutableStateOf(emptyList()) } val extensions = remember { listOf(GitHubAlertProcessorExtension, AutolinkProcessorExtension) } - + val markdownMode = LocalMarkdownMode.current // We are doing this here for the sake of simplicity. // In a real-world scenario you would be doing this outside your Composables, // potentially involving ViewModels, dependency injection, etc. - val processor = remember { MarkdownProcessor(extensions, editorMode = true) } + val processor = remember { MarkdownProcessor(extensions, markdownMode = markdownMode) } LaunchedEffect(rawMarkdown) { // TODO you may want to debounce or drop on backpressure, in real usages. You should also From 83a993deb943295e68966186c226b16a286c6397 Mon Sep 17 00:00:00 2001 From: Alexander Kuznetsov Date: Tue, 19 Nov 2024 12:41:46 +0100 Subject: [PATCH 2/5] Markdown: move padding out of the AutoScrollable block Otherwise, line offsets will be calculated starting from the outer border of the padding, and not the border of the padded text, so they will just be incorrect. --- .../jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt | 4 ++++ .../jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt index b974a5198b..204aa0c114 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt @@ -63,6 +63,10 @@ public fun AutoScrollableBlock( * Use this composable if you want to have auto-scrolling within atomic text blocks (such as code blocks) in an * editor+preview combined mode with scrolling synchronization. * + * Note: if you use it inside a [AutoScrollableBlock], make sure padding modifiers are hoisted outside of it! Otherwise, + * line offsets will be calculated starting from the outer border of the padding, and not the border of the padded text, + * so they will just be incorrect. + * * @see [DefaultMarkdownBlockRenderer.CodeText] */ @ExperimentalJewelApi diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt index f4177feaef..9881e1a20e 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt @@ -343,7 +343,7 @@ public open class DefaultMarkdownBlockRenderer( .border(styling.borderWidth, styling.borderColor, styling.shape) .then(if (styling.fillWidth) Modifier.fillMaxWidth() else Modifier), ) { - AutoScrollableBlock(block) { modifier -> + AutoScrollableBlock(block, Modifier.padding(styling.padding)) { modifier -> AutoScrollableText( block = block, text = AnnotatedString(block.content), @@ -352,7 +352,6 @@ public open class DefaultMarkdownBlockRenderer( modifier = modifier .focusProperties { canFocus = false } - .padding(styling.padding) .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), ) } From 21c59b3c892c1706effdacb46e55093c7d5b4a04 Mon Sep 17 00:00:00 2001 From: Alexander Kuznetsov Date: Fri, 22 Nov 2024 18:03:34 +0100 Subject: [PATCH 3/5] Move scrolling logic out of DefaultMarkdownBlockRenderer - Create a subclass that handles all the scrolling sync stuff - Fix highlighting flickering along the way (introduced by changes in this branch) --- markdown/core/api/core.api | 20 ++- .../markdown/rendering/AutoScrollingUtil.kt | 114 -------------- .../rendering/DefaultMarkdownBlockRenderer.kt | 141 +++++++----------- .../markdown/scrolling/AutoScrollingUtil.kt | 43 ++++++ .../ScrollSyncMarkdownBlockRenderer.kt | 128 ++++++++++++++++ .../scrolling/ScrollingSynchronizer.kt | 3 - .../github/alerts/GitHubAlertBlockRenderer.kt | 84 +++++------ 7 files changed, 281 insertions(+), 252 deletions(-) delete mode 100644 markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt create mode 100644 markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/AutoScrollingUtil.kt create mode 100644 markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollSyncMarkdownBlockRenderer.kt diff --git a/markdown/core/api/core.api b/markdown/core/api/core.api index f79f859206..d0518e0968 100644 --- a/markdown/core/api/core.api +++ b/markdown/core/api/core.api @@ -330,11 +330,6 @@ public final class org/jetbrains/jewel/markdown/processing/MarkdownProcessor { public final fun processMarkdownDocument (Ljava/lang/String;)Ljava/util/List; } -public final class org/jetbrains/jewel/markdown/rendering/AutoScrollingUtilKt { - public static final fun AutoScrollableBlock (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V - public static final fun AutoScrollableText-JAgEBs0 (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Landroidx/compose/ui/text/AnnotatedString;Landroidx/compose/ui/Modifier;JJLandroidx/compose/ui/text/font/FontStyle;Landroidx/compose/ui/text/font/FontWeight;Landroidx/compose/ui/text/font/FontFamily;JLandroidx/compose/ui/text/style/TextDecoration;IJIZILjava/util/Map;Landroidx/compose/ui/text/TextStyle;Landroidx/compose/runtime/Composer;III)V -} - public class org/jetbrains/jewel/markdown/rendering/DefaultInlineMarkdownRenderer : org/jetbrains/jewel/markdown/rendering/InlineMarkdownRenderer { public static final field $stable I public static final field Companion Lorg/jetbrains/jewel/markdown/rendering/DefaultInlineMarkdownRenderer$Companion; @@ -349,6 +344,7 @@ public class org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer public static final field $stable I public fun (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Ljava/util/List;Lorg/jetbrains/jewel/markdown/rendering/InlineMarkdownRenderer;)V public synthetic fun (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Ljava/util/List;Lorg/jetbrains/jewel/markdown/rendering/InlineMarkdownRenderer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + protected final fun MaybeScrollingContainer (ZLandroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V public fun render (Ljava/util/List;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$BlockQuote;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$BlockQuote;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$CodeBlock$FencedCodeBlock;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Code$Fenced;Landroidx/compose/runtime/Composer;I)V @@ -363,6 +359,7 @@ public class org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$ListItem;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$Paragraph;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Paragraph;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public fun render-EWr_ITI (Lorg/jetbrains/jewel/markdown/MarkdownBlock$CodeBlock$FencedCodeBlock;Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Code$Fenced;Landroidx/compose/runtime/Composer;I)V public fun renderThematicBreak (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$ThematicBreak;Landroidx/compose/runtime/Composer;I)V } @@ -781,6 +778,19 @@ public abstract interface class org/jetbrains/jewel/markdown/rendering/WithUnder public abstract fun getUnderlineWidth-D9Ej5fM ()F } +public final class org/jetbrains/jewel/markdown/scrolling/AutoScrollingUtilKt { + public static final fun AutoScrollableBlock (Lorg/jetbrains/jewel/markdown/MarkdownBlock;Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + +public class org/jetbrains/jewel/markdown/scrolling/ScrollSyncMarkdownBlockRenderer : org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer { + public static final field $stable I + public fun (Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling;Ljava/util/List;Lorg/jetbrains/jewel/markdown/rendering/InlineMarkdownRenderer;)V + public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$CodeBlock$IndentedCodeBlock;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Code$Indented;Landroidx/compose/runtime/Composer;I)V + public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$Heading;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Heading$HN;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public fun render (Lorg/jetbrains/jewel/markdown/MarkdownBlock$Paragraph;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Paragraph;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V + public fun render-EWr_ITI (Lorg/jetbrains/jewel/markdown/MarkdownBlock$CodeBlock$FencedCodeBlock;Ljava/lang/String;Lorg/jetbrains/jewel/markdown/rendering/MarkdownStyling$Code$Fenced;Landroidx/compose/runtime/Composer;I)V +} + public abstract class org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer { public static final field $stable I public static final field Companion Lorg/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer$Companion; diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt deleted file mode 100644 index 204aa0c114..0000000000 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/AutoScrollingUtil.kt +++ /dev/null @@ -1,114 +0,0 @@ -package org.jetbrains.jewel.markdown.rendering - -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.runtime.Composable -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.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.TextUnit -import kotlin.math.abs -import org.jetbrains.jewel.foundation.ExperimentalJewelApi -import org.jetbrains.jewel.foundation.theme.JewelTheme -import org.jetbrains.jewel.markdown.MarkdownBlock -import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode -import org.jetbrains.jewel.markdown.extensions.markdownMode -import org.jetbrains.jewel.ui.component.Text - -/** - * Use this composable as a wrapper to an actual block composable to enable scrolling to the block in an editor+preview - * combined mode with scrolling synchronization. - * - * @see [DefaultMarkdownBlockRenderer] - */ -@ExperimentalJewelApi -@Composable -public fun AutoScrollableBlock( - block: MarkdownBlock, - modifier: Modifier = Modifier, - content: @Composable (Modifier) -> Unit, -) { - val synchronizer = JewelTheme.markdownMode.scrollingSynchronizer - if (synchronizer == null) { - return content(modifier) - } - - var previousPosition by remember { mutableStateOf(Offset.Zero) } - - content( - modifier.onGloballyPositioned { coordinates -> - val newPosition = coordinates.positionInRoot() - if (abs(previousPosition.y - newPosition.y) > 1.0) { - previousPosition = newPosition - synchronizer.acceptGlobalPosition(block, coordinates) - } - } - ) -} - -/** - * Use this composable if you want to have auto-scrolling within atomic text blocks (such as code blocks) in an - * editor+preview combined mode with scrolling synchronization. - * - * Note: if you use it inside a [AutoScrollableBlock], make sure padding modifiers are hoisted outside of it! Otherwise, - * line offsets will be calculated starting from the outer border of the padding, and not the border of the padded text, - * so they will just be incorrect. - * - * @see [DefaultMarkdownBlockRenderer.CodeText] - */ -@ExperimentalJewelApi -@Composable -public fun AutoScrollableText( - block: MarkdownBlock, - text: AnnotatedString, - modifier: Modifier = Modifier, - color: Color = Color.Unspecified, - fontSize: TextUnit = TextUnit.Unspecified, - fontStyle: FontStyle? = null, - fontWeight: FontWeight? = null, - fontFamily: FontFamily? = null, - letterSpacing: TextUnit = TextUnit.Unspecified, - textDecoration: TextDecoration? = null, - textAlign: TextAlign = TextAlign.Unspecified, - lineHeight: TextUnit = TextUnit.Unspecified, - overflow: TextOverflow = TextOverflow.Clip, - softWrap: Boolean = true, - maxLines: Int = Int.MAX_VALUE, - inlineContent: Map = emptyMap(), - style: TextStyle = JewelTheme.defaultTextStyle, -) { - with(LocalMarkdownMode.current) { - Text( - text = text, - modifier = modifier, - color = color, - fontSize = fontSize, - fontStyle = fontStyle, - fontWeight = fontWeight, - fontFamily = fontFamily, - letterSpacing = letterSpacing, - textDecoration = textDecoration, - textAlign = textAlign, - lineHeight = lineHeight, - overflow = overflow, - softWrap = softWrap, - maxLines = maxLines, - inlineContent = inlineContent, - onTextLayout = { layout -> scrollingSynchronizer?.acceptTextLayout(block, layout) }, - style = style, - ) - } -} diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt index 9881e1a20e..dd55105104 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -120,16 +119,13 @@ public open class DefaultMarkdownBlockRenderer( val mergedStyle = styling.inlinesStyling.textStyle.merge(TextStyle(color = textColor)) val interactionSource = remember { MutableInteractionSource() } - AutoScrollableBlock(block) { modifier -> - Text( - modifier = - modifier - .focusProperties { canFocus = false } - .clickable(interactionSource = interactionSource, indication = null, onClick = onTextClick), - text = renderedContent, - style = mergedStyle, - ) - } + Text( + modifier = + Modifier.focusProperties { canFocus = false } + .clickable(interactionSource = interactionSource, indication = null, onClick = onTextClick), + text = renderedContent, + style = mergedStyle, + ) } @Composable @@ -160,22 +156,18 @@ public open class DefaultMarkdownBlockRenderer( onTextClick: () -> Unit, ) { val renderedContent = rememberRenderedContent(block, styling.inlinesStyling, enabled, onUrlClick) - AutoScrollableBlock(block) { modifier -> - Heading( - modifier, - renderedContent, - styling.inlinesStyling.textStyle, - styling.padding, - styling.underlineWidth, - styling.underlineColor, - styling.underlineGap, - ) - } + Heading( + renderedContent, + styling.inlinesStyling.textStyle, + styling.padding, + styling.underlineWidth, + styling.underlineColor, + styling.underlineGap, + ) } @Composable private fun Heading( - modifier: Modifier, renderedContent: AnnotatedString, textStyle: TextStyle, paddingValues: PaddingValues, @@ -183,7 +175,7 @@ public open class DefaultMarkdownBlockRenderer( underlineColor: Color, underlineGap: Dp, ) { - Column(modifier = modifier.padding(paddingValues)) { + Column(modifier = Modifier.padding(paddingValues)) { val textColor = textStyle.color.takeOrElse { LocalContentColor.current.takeOrElse { textStyle.color } } val mergedStyle = textStyle.merge(TextStyle(color = textColor)) Text(text = renderedContent, style = mergedStyle, modifier = Modifier.focusProperties { canFocus = false }) @@ -260,19 +252,16 @@ public open class DefaultMarkdownBlockRenderer( for ((index, item) in block.children.withIndex()) { Row { val number = block.startFrom + index - AutoScrollableBlock(block) { modifier -> - Text( - text = "$number${block.delimiter}", - style = styling.numberStyle, - color = styling.numberStyle.color.takeOrElse { LocalContentColor.current }, - modifier = - modifier - .focusProperties { canFocus = false } - .widthIn(min = styling.numberMinWidth) - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - textAlign = styling.numberTextAlign, - ) - } + Text( + text = "$number${block.delimiter}", + style = styling.numberStyle, + color = styling.numberStyle.color.takeOrElse { LocalContentColor.current }, + modifier = + Modifier.focusProperties { canFocus = false } + .widthIn(min = styling.numberMinWidth) + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + textAlign = styling.numberTextAlign, + ) Spacer(Modifier.width(styling.numberContentGap)) @@ -300,17 +289,14 @@ public open class DefaultMarkdownBlockRenderer( Column(modifier = Modifier.padding(styling.padding), verticalArrangement = Arrangement.spacedBy(itemSpacing)) { for (item in block.children) { Row { - AutoScrollableBlock(block) { modifier -> - Text( - text = styling.bullet.toString(), - style = styling.bulletStyle, - color = styling.bulletStyle.color.takeOrElse { LocalContentColor.current }, - modifier = - modifier - .focusProperties { canFocus = false } - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) - } + Text( + text = styling.bullet.toString(), + style = styling.bulletStyle, + color = styling.bulletStyle.color.takeOrElse { LocalContentColor.current }, + modifier = + Modifier.focusProperties { canFocus = false } + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) Spacer(Modifier.width(styling.bulletContentGap)) @@ -343,18 +329,15 @@ public open class DefaultMarkdownBlockRenderer( .border(styling.borderWidth, styling.borderColor, styling.shape) .then(if (styling.fillWidth) Modifier.fillMaxWidth() else Modifier), ) { - AutoScrollableBlock(block, Modifier.padding(styling.padding)) { modifier -> - AutoScrollableText( - block = block, - text = AnnotatedString(block.content), - style = styling.editorTextStyle, - color = styling.editorTextStyle.color.takeOrElse { LocalContentColor.current }, - modifier = - modifier - .focusProperties { canFocus = false } - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) - } + Text( + text = block.content, + style = styling.editorTextStyle, + color = styling.editorTextStyle.color.takeOrElse { LocalContentColor.current }, + modifier = + Modifier.focusProperties { canFocus = false } + .padding(styling.padding) + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) } } @@ -378,7 +361,7 @@ public open class DefaultMarkdownBlockRenderer( ) } - Code(block, mimeType, styling) + render(block, mimeType, styling) if (styling.infoPosition.verticalAlignment == Alignment.Bottom) { FencedBlockInfo( @@ -394,29 +377,17 @@ public open class DefaultMarkdownBlockRenderer( } @Composable - private fun Code(block: FencedCodeBlock, mimeType: MimeType, styling: MarkdownStyling.Code.Fenced) { - key(block) { - val annotatedCode by - LocalCodeHighlighter.current - .highlight(block.content, mimeType) - .collectAsState(AnnotatedString(block.content)) - CodeText(block, annotatedCode, styling) - } - } - - @Composable - private fun CodeText(block: MarkdownBlock, annotatedCode: AnnotatedString, styling: MarkdownStyling.Code.Fenced) { - AutoScrollableBlock(block) { modifier -> - AutoScrollableText( - block = block, - text = annotatedCode, - style = styling.editorTextStyle, - modifier = - modifier - .focusProperties { canFocus = false } - .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) - } + public open fun render(block: FencedCodeBlock, mimeType: MimeType, styling: MarkdownStyling.Code.Fenced) { + val content = block.content + val highlightedCode by + LocalCodeHighlighter.current.highlight(content, mimeType).collectAsState(AnnotatedString(content)) + Text( + text = highlightedCode, + style = styling.editorTextStyle, + modifier = + Modifier.focusProperties { canFocus = false } + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) } @Composable @@ -460,7 +431,7 @@ public open class DefaultMarkdownBlockRenderer( } @Composable - private fun MaybeScrollingContainer( + protected fun MaybeScrollingContainer( isScrollable: Boolean, modifier: Modifier = Modifier, content: @Composable () -> Unit, diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/AutoScrollingUtil.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/AutoScrollingUtil.kt new file mode 100644 index 0000000000..8c5fca452c --- /dev/null +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/AutoScrollingUtil.kt @@ -0,0 +1,43 @@ +package org.jetbrains.jewel.markdown.scrolling + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +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.geometry.Offset +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import kotlin.math.abs +import org.jetbrains.jewel.markdown.MarkdownBlock + +/** + * Use this composable as a wrapper to an actual block composable to enable scrolling to the block in an editor+preview + * combined mode with scrolling synchronization. + * + * @see [ScrollSyncMarkdownBlockRenderer] + */ +@Composable +public fun AutoScrollableBlock( + block: MarkdownBlock, + synchronizer: ScrollingSynchronizer, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + var previousPosition by remember(block) { mutableStateOf(Offset.Zero) } + + Box( + modifier = + modifier.onGloballyPositioned { coordinates -> + val newPosition = coordinates.positionInRoot() + if (abs(previousPosition.y - newPosition.y) > 1.0) { + previousPosition = newPosition + synchronizer.acceptGlobalPosition(block, coordinates) + } + } + ) { + content() + } +} diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollSyncMarkdownBlockRenderer.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollSyncMarkdownBlockRenderer.kt new file mode 100644 index 0000000000..b508915de6 --- /dev/null +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollSyncMarkdownBlockRenderer.kt @@ -0,0 +1,128 @@ +package org.jetbrains.jewel.markdown.scrolling + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.AnnotatedString +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.code.MimeType +import org.jetbrains.jewel.foundation.code.highlighting.LocalCodeHighlighter +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.foundation.theme.LocalContentColor +import org.jetbrains.jewel.markdown.MarkdownBlock +import org.jetbrains.jewel.markdown.MarkdownBlock.CodeBlock.FencedCodeBlock +import org.jetbrains.jewel.markdown.MarkdownBlock.CodeBlock.IndentedCodeBlock +import org.jetbrains.jewel.markdown.extensions.MarkdownRendererExtension +import org.jetbrains.jewel.markdown.extensions.markdownMode +import org.jetbrains.jewel.markdown.rendering.DefaultMarkdownBlockRenderer +import org.jetbrains.jewel.markdown.rendering.InlineMarkdownRenderer +import org.jetbrains.jewel.markdown.rendering.MarkdownStyling +import org.jetbrains.jewel.ui.component.Text + +@Suppress("unused") // used in intellij +@ExperimentalJewelApi +public open class ScrollSyncMarkdownBlockRenderer( + rootStyling: MarkdownStyling, + renderingExtensions: List, + inlineRenderer: InlineMarkdownRenderer, +) : DefaultMarkdownBlockRenderer(rootStyling, renderingExtensions, inlineRenderer) { + @Composable + override fun render( + block: MarkdownBlock.Paragraph, + styling: MarkdownStyling.Paragraph, + enabled: Boolean, + onUrlClick: (String) -> Unit, + onTextClick: () -> Unit, + ) { + val synchronizer = + JewelTheme.markdownMode.scrollingSynchronizer + ?: run { + super.render(block, styling, enabled, onUrlClick, onTextClick) + return + } + AutoScrollableBlock(block, synchronizer) { super.render(block, styling, enabled, onUrlClick, onTextClick) } + } + + @Composable + override fun render( + block: MarkdownBlock.Heading, + styling: MarkdownStyling.Heading.HN, + enabled: Boolean, + onUrlClick: (String) -> Unit, + onTextClick: () -> Unit, + ) { + val synchronizer = + JewelTheme.markdownMode.scrollingSynchronizer + ?: run { + super.render(block, styling, enabled, onUrlClick, onTextClick) + return + } + AutoScrollableBlock(block, synchronizer) { super.render(block, styling, enabled, onUrlClick, onTextClick) } + } + + @Composable + override fun render(block: FencedCodeBlock, mimeType: MimeType, styling: MarkdownStyling.Code.Fenced) { + val synchronizer = + JewelTheme.markdownMode.scrollingSynchronizer + ?: run { + super.render(block, mimeType, styling) + return + } + + val content = block.content + val highlightedCode by + LocalCodeHighlighter.current.highlight(content, mimeType).collectAsState(AnnotatedString(content)) + val actualBlock by rememberUpdatedState(block) + + AutoScrollableBlock(actualBlock, synchronizer) { + Text( + text = highlightedCode, + style = styling.editorTextStyle, + modifier = + Modifier.focusProperties { canFocus = false } + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + onTextLayout = { textLayoutResult -> synchronizer.acceptTextLayout(actualBlock, textLayoutResult) }, + ) + } + } + + @Composable + override fun render(block: IndentedCodeBlock, styling: MarkdownStyling.Code.Indented) { + val scrollingSynchronizer = + JewelTheme.markdownMode.scrollingSynchronizer + ?: run { + super.render(block, styling) + return + } + MaybeScrollingContainer( + isScrollable = styling.scrollsHorizontally, + Modifier.background(styling.background, styling.shape) + .border(styling.borderWidth, styling.borderColor, styling.shape) + .then(if (styling.fillWidth) Modifier.fillMaxWidth() else Modifier), + ) { + AutoScrollableBlock(block, scrollingSynchronizer, Modifier.padding(styling.padding)) { + Text( + text = block.content, + style = styling.editorTextStyle, + color = styling.editorTextStyle.color.takeOrElse { LocalContentColor.current }, + modifier = + Modifier.focusProperties { canFocus = false } + .pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + onTextLayout = { textLayoutResult -> + scrollingSynchronizer.acceptTextLayout(block, textLayoutResult) + }, + ) + } + } + } +} diff --git a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt index a38f44d10c..63ebaec695 100644 --- a/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt +++ b/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizer.kt @@ -12,8 +12,6 @@ import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.foundation.util.myLogger import org.jetbrains.jewel.markdown.MarkdownBlock import org.jetbrains.jewel.markdown.processing.MarkdownProcessor -import org.jetbrains.jewel.markdown.rendering.AutoScrollableBlock -import org.jetbrains.jewel.markdown.rendering.AutoScrollableText /** * To support synchronized scrolling between source and preview, we need to establish a mapping between source lines and @@ -64,7 +62,6 @@ import org.jetbrains.jewel.markdown.rendering.AutoScrollableText * * @see [MarkdownProcessor] * @see [AutoScrollableBlock] - * @see [AutoScrollableText] * @see [PerLine] */ @ExperimentalJewelApi diff --git a/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt b/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt index 6a9f5a458f..51638ae6c8 100644 --- a/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt +++ b/markdown/extension/gfm-alerts/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/alerts/GitHubAlertBlockRenderer.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.takeOrElse @@ -22,7 +23,6 @@ import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Important import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Note import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Tip import org.jetbrains.jewel.markdown.extensions.github.alerts.Alert.Warning -import org.jetbrains.jewel.markdown.rendering.AutoScrollableBlock import org.jetbrains.jewel.markdown.rendering.InlineMarkdownRenderer import org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer import org.jetbrains.jewel.markdown.rendering.MarkdownStyling @@ -66,56 +66,50 @@ public class GitHubAlertBlockRenderer(private val styling: AlertStyling, private onUrlClick: (String) -> Unit, onTextClick: () -> Unit, ) { - AutoScrollableBlock(block) { modifier -> - Column( - modifier - .drawBehind { - val isLtr = layoutDirection == Ltr - val lineWidthPx = styling.lineWidth.toPx() - val x = if (isLtr) lineWidthPx / 2 else size.width - lineWidthPx / 2 + Column( + Modifier.drawBehind { + val isLtr = layoutDirection == Ltr + val lineWidthPx = styling.lineWidth.toPx() + val x = if (isLtr) lineWidthPx / 2 else size.width - lineWidthPx / 2 - drawLine( - color = styling.lineColor, - start = Offset(x, 0f), - end = Offset(x, size.height), - strokeWidth = lineWidthPx, - cap = styling.strokeCap, - pathEffect = styling.pathEffect, - ) - } - .padding(styling.padding), - verticalArrangement = Arrangement.spacedBy(rootStyling.blockVerticalSpacing), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val titleIconKey = styling.titleIconKey - if (titleIconKey != null) { - Icon( - key = titleIconKey, - contentDescription = null, - iconClass = AlertStyling::class.java, - tint = styling.titleIconTint, - ) - } - - CompositionLocalProvider( - LocalContentColor provides styling.titleTextStyle.color.takeOrElse { LocalContentColor.current } - ) { - Text( - text = block.javaClass.simpleName, - style = styling.titleTextStyle, - modifier = modifier.pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), - ) - } + drawLine( + color = styling.lineColor, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = lineWidthPx, + cap = styling.strokeCap, + pathEffect = styling.pathEffect, + ) + } + .padding(styling.padding), + verticalArrangement = Arrangement.spacedBy(rootStyling.blockVerticalSpacing), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + val titleIconKey = styling.titleIconKey + if (titleIconKey != null) { + Icon( + key = titleIconKey, + contentDescription = null, + iconClass = AlertStyling::class.java, + tint = styling.titleIconTint, + ) } + CompositionLocalProvider( - LocalContentColor provides styling.textColor.takeOrElse { LocalContentColor.current } + LocalContentColor provides styling.titleTextStyle.color.takeOrElse { LocalContentColor.current } ) { - blockRenderer.render(block.content, enabled, onUrlClick, onTextClick) + Text( + text = block.javaClass.simpleName, + style = styling.titleTextStyle, + modifier = Modifier.pointerHoverIcon(PointerIcon.Default, overrideDescendants = true), + ) } } + CompositionLocalProvider( + LocalContentColor provides styling.textColor.takeOrElse { LocalContentColor.current } + ) { + blockRenderer.render(block.content, enabled, onUrlClick, onTextClick) + } } } } From 09789e7c03288edde6b9d4af35258cc64f386fe2 Mon Sep 17 00:00:00 2001 From: Alexander Kuznetsov Date: Wed, 11 Dec 2024 15:47:27 +0100 Subject: [PATCH 4/5] Add sanity tests --- .../scrolling/ScrollingSynchronizerTest.kt | 893 ++++++++++++++++++ 1 file changed, 893 insertions(+) create mode 100644 markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt diff --git a/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt b/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt new file mode 100644 index 0000000000..40c0ec9f53 --- /dev/null +++ b/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt @@ -0,0 +1,893 @@ +package org.jetbrains.jewel.markdown.scrolling + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import org.jetbrains.jewel.foundation.BorderColors +import org.jetbrains.jewel.foundation.GlobalColors +import org.jetbrains.jewel.foundation.GlobalMetrics +import org.jetbrains.jewel.foundation.OutlineColors +import org.jetbrains.jewel.foundation.TextColors +import org.jetbrains.jewel.foundation.code.highlighting.LocalCodeHighlighter +import org.jetbrains.jewel.foundation.code.highlighting.NoOpCodeHighlighter +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.foundation.theme.ThemeColorPalette +import org.jetbrains.jewel.foundation.theme.ThemeDefinition +import org.jetbrains.jewel.foundation.theme.ThemeIconData +import org.jetbrains.jewel.markdown.MarkdownBlock +import org.jetbrains.jewel.markdown.MarkdownMode +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownBlockRenderer +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownProcessor +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownStyling +import org.jetbrains.jewel.markdown.processing.MarkdownProcessor +import org.jetbrains.jewel.markdown.rendering.DefaultInlineMarkdownRenderer +import org.jetbrains.jewel.markdown.rendering.InlinesStyling +import org.jetbrains.jewel.markdown.rendering.MarkdownStyling +import org.jetbrains.jewel.ui.component.styling.DividerMetrics +import org.jetbrains.jewel.ui.component.styling.DividerStyle +import org.jetbrains.jewel.ui.component.styling.LocalDividerStyle +import org.jetbrains.jewel.ui.component.styling.LocalScrollbarStyle +import org.jetbrains.jewel.ui.component.styling.ScrollbarColors +import org.jetbrains.jewel.ui.component.styling.ScrollbarMetrics +import org.jetbrains.jewel.ui.component.styling.ScrollbarStyle +import org.jetbrains.jewel.ui.component.styling.ScrollbarVisibility +import org.jetbrains.jewel.ui.component.styling.TrackClickBehavior +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@Suppress("LargeClass") +class ScrollingSynchronizerTest { + @OptIn(ExperimentalTestApi::class) + @Test + fun headings() { + val markdown = + """ + # Heading 1 + ## Heading 2 + ### Heading 3 + """ + .trimIndent() + doTest(markdown) { scrollState, synchronizer -> + synchronizer.scrollToLine(0) + assertEquals(0, scrollState.value) + + synchronizer.scrollToLine(1) + val h2Top = scrollState.value + assertTrue(h2Top > 0) + + synchronizer.scrollToLine(2) + val h3Top = scrollState.value + assertTrue(h3Top > h2Top) + + synchronizer.scrollToLine(1) + assertEquals(h2Top, scrollState.value) + + synchronizer.scrollToLine(0) + synchronizer.scrollToLine(2) + assertEquals(h3Top, scrollState.value) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun paragraphs() { + val markdown = + """ + p1 + + p2 + + p3 + """ + .trimIndent() + doTest(markdown) { scrollState, synchronizer -> + synchronizer.scrollToLine(1) + val p2Top = scrollState.value + assertTrue(p2Top > 0) + + synchronizer.scrollToLine(2) + assertEquals(p2Top, scrollState.value) + + synchronizer.scrollToLine(3) + val p3Top = scrollState.value + assertTrue(p3Top > p2Top) + + synchronizer.scrollToLine(4) + assertEquals(p3Top, scrollState.value) + + synchronizer.scrollToLine(1) + assertEquals(p2Top, scrollState.value) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `empty spaces`() { + val markdown = + """ + # Heading 1 + + + # Heading 2 + + + ## Heading 3 + + + """ + .trimIndent() + doTest(markdown) { scrollState, synchronizer -> + synchronizer.scrollToLine(1) + val h2Top = scrollState.value + assertTrue(h2Top > 0) + + synchronizer.scrollToLine(2) + assertEquals(h2Top, scrollState.value) + + synchronizer.scrollToLine(3) + assertEquals(h2Top, scrollState.value) + + synchronizer.scrollToLine(4) + val h3Top = scrollState.value + assertTrue(h3Top > h2Top) + + synchronizer.scrollToLine(5) + assertEquals(h3Top, scrollState.value) + + synchronizer.scrollToLine(6) + assertEquals(h3Top, scrollState.value) + + synchronizer.scrollToLine(7) + assertEquals(h3Top, scrollState.value) + + synchronizer.scrollToLine(8) + assertEquals(h3Top, scrollState.value) + + synchronizer.scrollToLine(1) + assertEquals(h2Top, scrollState.value) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `unordered list`() { + val markdown = + """ + Items: + - item 1 + - subitem A + - item 2 + - item 3 + """ + .trimIndent() + doTest(markdown) { scrollState, synchronizer -> + synchronizer.scrollToLine(1) + val i1Top = scrollState.value + assertTrue(i1Top > 0) + + synchronizer.scrollToLine(2) + val siATop = scrollState.value + assertTrue(siATop > i1Top) + + synchronizer.scrollToLine(3) + val i2Top = scrollState.value + assertTrue(i2Top > siATop) + + synchronizer.scrollToLine(4) + val i3Top = scrollState.value + assertTrue(i3Top > i2Top) + + synchronizer.scrollToLine(2) + assertEquals(siATop, scrollState.value) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `ordered list`() { + val markdown = + """ + Items: + 1. item 1 + 1. subitem A + 2. item 2 + 3. item 3 + """ + .trimIndent() + doTest(markdown) { scrollState, synchronizer -> + synchronizer.scrollToLine(1) + val i1Top = scrollState.value + assertTrue(i1Top > 0) + + synchronizer.scrollToLine(2) + val siATop = scrollState.value + assertTrue(siATop > i1Top) + + synchronizer.scrollToLine(3) + val i2Top = scrollState.value + assertTrue(i2Top > siATop) + + synchronizer.scrollToLine(4) + val i3Top = scrollState.value + assertTrue(i3Top > i2Top) + + synchronizer.scrollToLine(2) + assertEquals(siATop, scrollState.value) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `fenced code block`() { + val markdown = + """ + ```kotlin + package my.awesome.pkg + + fun main() { + println("Hello world") + } + ``` + """ + .trimIndent() + doTest(markdown) { scrollState, synchronizer -> + synchronizer.scrollToLine(1) + val packageTop = scrollState.value + assertTrue(packageTop > 0) + + synchronizer.scrollToLine(2) + val emptyLineTop = scrollState.value + assertTrue(emptyLineTop > packageTop) + + synchronizer.scrollToLine(3) + val mainTop = scrollState.value + assertTrue(mainTop > emptyLineTop) + + synchronizer.scrollToLine(4) + val printlnTop = scrollState.value + assertTrue(printlnTop > mainTop) + + synchronizer.scrollToLine(5) + val rBracketTop = scrollState.value + assertTrue(rBracketTop > printlnTop) + + synchronizer.scrollToLine(2) + assertEquals(emptyLineTop, scrollState.value) + + assertSameDistance( + distance = CODE_TEXT_SIZE + 2, + packageTop, + emptyLineTop, + mainTop, + printlnTop, + rBracketTop, + ) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `indented code block`() { + val markdown = + """ + Here starts the indented code block. + + package my.awesome.pkg + + fun main() { + println("Hello world") + } + """ + .trimIndent() + doTest(markdown) { scrollState, synchronizer -> + synchronizer.scrollToLine(2) + val packageTop = scrollState.value + assertTrue(packageTop > 0) + + synchronizer.scrollToLine(3) + val emptyLineTop = scrollState.value + assertTrue(emptyLineTop > packageTop) + + synchronizer.scrollToLine(4) + val mainTop = scrollState.value + assertTrue(mainTop > emptyLineTop) + + synchronizer.scrollToLine(5) + val printlnTop = scrollState.value + assertTrue(printlnTop > mainTop) + + synchronizer.scrollToLine(6) + val rBracketTop = scrollState.value + assertTrue(rBracketTop > printlnTop) + + synchronizer.scrollToLine(3) + assertEquals(emptyLineTop, scrollState.value) + + assertSameDistance( + distance = CODE_TEXT_SIZE + 2, + packageTop, + emptyLineTop, + mainTop, + printlnTop, + rBracketTop, + ) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `add a block`() { + val firstRun = + """ + ```kotlin + package my.awesome.pkg + + fun main() { + println("Hello world") + } + ``` + """ + .trimIndent() + + val secondRun = + """ + **CHANGE** + + ```kotlin + package my.awesome.pkg + + fun main() { + println("Hello world") + } + ``` + """ + .trimIndent() + + doTest(firstRun, secondRun) { scrollState, synchronizer -> + synchronizer.scrollToLine(3) + val packageTop = scrollState.value + assertTrue(packageTop > 0) + + synchronizer.scrollToLine(4) + val emptyLineTop = scrollState.value + assertTrue(emptyLineTop > packageTop) + + synchronizer.scrollToLine(5) + val mainTop = scrollState.value + assertTrue(mainTop > emptyLineTop) + + synchronizer.scrollToLine(6) + val printlnTop = scrollState.value + assertTrue(printlnTop > mainTop) + + synchronizer.scrollToLine(7) + val rBracketTop = scrollState.value + assertTrue(rBracketTop > printlnTop) + + synchronizer.scrollToLine(4) + assertEquals(emptyLineTop, scrollState.value) + + assertSameDistance( + distance = CODE_TEXT_SIZE + 2, + packageTop, + emptyLineTop, + mainTop, + printlnTop, + rBracketTop, + ) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `remove a block`() { + val firstRun = + """ + **CHANGE** + + ```kotlin + package my.awesome.pkg + + fun main() { + println("Hello world") + } + ``` + """ + .trimIndent() + + val secondRun = + """ + ```kotlin + package my.awesome.pkg + + fun main() { + println("Hello world") + } + ``` + """ + .trimIndent() + + doTest(firstRun, secondRun) { scrollState, synchronizer -> + synchronizer.scrollToLine(1) + val packageTop = scrollState.value + assertTrue(packageTop > 0) + + synchronizer.scrollToLine(2) + val emptyLineTop = scrollState.value + assertTrue(emptyLineTop > packageTop) + + synchronizer.scrollToLine(3) + val mainTop = scrollState.value + assertTrue(mainTop > emptyLineTop) + + synchronizer.scrollToLine(4) + val printlnTop = scrollState.value + assertTrue(printlnTop > mainTop) + + synchronizer.scrollToLine(5) + val rBracketTop = scrollState.value + assertTrue(rBracketTop > printlnTop) + + synchronizer.scrollToLine(2) + assertEquals(emptyLineTop, scrollState.value) + + assertSameDistance( + distance = CODE_TEXT_SIZE + 2, + packageTop, + emptyLineTop, + mainTop, + printlnTop, + rBracketTop, + ) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `change a block`() { + val firstRun = + """ + ```kotlin + package my.awesome.pkg + + fun main() { + println("Hello world") + } + ``` + """ + .trimIndent() + + val secondRun = + """ + ```kotlin + package my.awesome.pkg + + fun main() { + val name = "Steve" + println("Hello " + name) + } + ``` + """ + .trimIndent() + + doTest(firstRun, secondRun) { scrollState, synchronizer -> + synchronizer.scrollToLine(1) + val packageTop = scrollState.value + assertTrue(packageTop > 0) + + synchronizer.scrollToLine(2) + val emptyLineTop = scrollState.value + assertTrue(emptyLineTop > packageTop) + + synchronizer.scrollToLine(3) + val mainTop = scrollState.value + assertTrue(mainTop > emptyLineTop) + + synchronizer.scrollToLine(4) + val valTop = scrollState.value + assertTrue(valTop > mainTop) + + synchronizer.scrollToLine(5) + val printlnTop = scrollState.value + assertTrue(printlnTop > mainTop) + + synchronizer.scrollToLine(6) + val rBracketTop = scrollState.value + assertTrue(rBracketTop > printlnTop) + + synchronizer.scrollToLine(2) + assertEquals(emptyLineTop, scrollState.value) + + assertSameDistance( + distance = CODE_TEXT_SIZE + 2, + packageTop, + emptyLineTop, + mainTop, + valTop, + printlnTop, + rBracketTop, + ) + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `merge code blocks`() { + val firstRun = + """ + ```kotlin + package my.awesome.pkg + + fun main() { + println("Hello world") + } + ``` + + ```kotlin + fun foo() { + println("Foo") + } + ``` + """ + .trimIndent() + + val secondRun = + """ + ```kotlin + package my.awesome.pkg + + fun main() { + println("Hello world") + } + + fun foo() { + println("Foo") + } + ``` + """ + .trimIndent() + + doTest(firstRun, secondRun) { scrollState, synchronizer -> + synchronizer.scrollToLine(1) + val packageTop = scrollState.value + assertTrue(packageTop > 0) + + synchronizer.scrollToLine(2) + val emptyLine1Top = scrollState.value + assertTrue(emptyLine1Top > packageTop) + + synchronizer.scrollToLine(3) + val mainTop = scrollState.value + assertTrue(mainTop > emptyLine1Top) + + synchronizer.scrollToLine(4) + val println1Top = scrollState.value + assertTrue(println1Top > mainTop) + + synchronizer.scrollToLine(5) + val rBracket1Top = scrollState.value + assertTrue(rBracket1Top > println1Top) + + synchronizer.scrollToLine(6) + val emptyLine2Top = scrollState.value + assertTrue(emptyLine2Top > rBracket1Top) + + synchronizer.scrollToLine(7) + val fooTop = scrollState.value + assertTrue(fooTop > emptyLine2Top) + + synchronizer.scrollToLine(8) + val println2Top = scrollState.value + assertTrue(println2Top > fooTop) + + synchronizer.scrollToLine(9) + val rBracket2Top = scrollState.value + assertTrue(rBracket2Top > println2Top) + + synchronizer.scrollToLine(2) + assertEquals(emptyLine1Top, scrollState.value) + + assertSameDistance( + distance = CODE_TEXT_SIZE + 2, + packageTop, + emptyLine1Top, + mainTop, + println1Top, + rBracket1Top, + emptyLine2Top, + fooTop, + println2Top, + rBracket2Top, + ) + } + } + + private fun assertSameDistance(distance: Int, vararg elements: Int) { + assertTrue(elements.size > 1) + for (i in 0.. Unit, + ) { + doTest( + yieldBlocks = { + processMarkdownDocument(firstRun) + processMarkdownDocument(secondRun) + }, + action = action, + ) + } + + private fun doTest(markdown: String, action: suspend (ScrollState, ScrollingSynchronizer) -> Unit) { + doTest(yieldBlocks = { processMarkdownDocument(markdown) }, action = action) + } + + @OptIn(ExperimentalTestApi::class) + private fun doTest( + yieldBlocks: MarkdownProcessor.() -> List, + action: suspend (ScrollState, ScrollingSynchronizer) -> Unit, + ) = runComposeUiTest { + val scrollState = ScrollState(0) + val synchronizer = ScrollingSynchronizer.create(scrollState)!! + val markdownStyling: MarkdownStyling = createMarkdownStyling() + val renderer = + ScrollSyncMarkdownBlockRenderer(markdownStyling, emptyList(), DefaultInlineMarkdownRenderer(emptyList())) + val processor = MarkdownProcessor(markdownMode = MarkdownMode.WithEditor(synchronizer)) + + runBlocking { + suspendCancellableCoroutine { cont -> + setContent { + CompositionLocalProvider( + LocalMarkdownStyling provides markdownStyling, + LocalMarkdownMode provides MarkdownMode.WithEditor(synchronizer), + LocalMarkdownProcessor provides processor, + LocalMarkdownBlockRenderer provides renderer, + LocalCodeHighlighter provides NoOpCodeHighlighter, + LocalDividerStyle provides createDividerStyle(), + LocalScrollbarStyle provides createScrollbarStyle(), + LocalDensity provides createDensity(), + ) { + JewelTheme(createThemeDefinition()) { + val blocks = processor.yieldBlocks() + renderer.render(blocks, true, {}, {}) + } + } + // Can't test animateScrollTo without acquiring a composable scope; + val scope = rememberCoroutineScope() + scope.launch(Dispatchers.Default) { + runOnIdle { + scope.launch { + try { + action(scrollState, synchronizer) + } finally { + cont.resumeWith(Result.success(Unit)) + } + } + } + } + } + } + } + } + + private fun createDividerStyle() = + DividerStyle(color = Color.Black, metrics = DividerMetrics(thickness = 1.dp, startIndent = 0.dp)) + + private fun createScrollbarStyle() = + ScrollbarStyle( + colors = + ScrollbarColors( + thumbBackground = Color.Black, + thumbBorderActive = Color.Black, + thumbBackgroundActive = Color.Black, + thumbOpaqueBackground = Color.Black, + thumbOpaqueBackgroundHovered = Color.Black, + thumbBorder = Color.Black, + thumbOpaqueBorder = Color.Black, + thumbOpaqueBorderHovered = Color.Black, + trackBackground = Color.Black, + trackBackgroundExpanded = Color.Black, + trackOpaqueBackground = Color.Black, + trackOpaqueBackgroundHovered = Color.Black, + ), + metrics = ScrollbarMetrics(thumbCornerSize = CornerSize(1.dp), minThumbLength = 1.dp), + trackClickBehavior = TrackClickBehavior.NextPage, + scrollbarVisibility = + ScrollbarVisibility.AlwaysVisible( + trackThickness = 1.dp, + trackPadding = PaddingValues(1.dp), + trackPaddingWithBorder = PaddingValues(1.dp), + thumbColorAnimationDuration = 500.milliseconds, + trackColorAnimationDuration = 500.milliseconds, + scrollbarBackgroundColorLight = Color.White, + scrollbarBackgroundColorDark = Color.White, + ), + ) + + private fun createDensity() = Density(1f) + + private fun createThemeDefinition(): ThemeDefinition { + return ThemeDefinition( + name = "Test", + isDark = false, + globalColors = + GlobalColors( + borders = BorderColors(normal = Color.Black, focused = Color.Black, disabled = Color.Black), + outlines = + OutlineColors( + focused = Color.Black, + focusedWarning = Color.Black, + focusedError = Color.Black, + warning = Color.Black, + error = Color.Black, + ), + text = + TextColors( + normal = Color.Black, + selected = Color.Black, + disabled = Color.Black, + info = Color.Black, + error = Color.Black, + ), + panelBackground = Color.White, + ), + globalMetrics = GlobalMetrics(outlineWidth = 10.dp, rowHeight = 24.dp), + defaultTextStyle = TextStyle.Default, + editorTextStyle = TextStyle.Default, + consoleTextStyle = TextStyle.Default, + contentColor = Color.Black, + colorPalette = ThemeColorPalette.Empty, + iconData = ThemeIconData.Empty, + ) + } + + private fun createMarkdownStyling(): MarkdownStyling { + val mockSpanStyle = SpanStyle(Color.Black) + val inlinesStyling = + InlinesStyling( + textStyle = TextStyle.Default, + inlineCode = mockSpanStyle, + link = mockSpanStyle, + linkDisabled = mockSpanStyle, + linkFocused = mockSpanStyle, + linkHovered = mockSpanStyle, + linkPressed = mockSpanStyle, + linkVisited = mockSpanStyle, + emphasis = mockSpanStyle, + strongEmphasis = mockSpanStyle, + inlineHtml = mockSpanStyle, + renderInlineHtml = false, + ) + return MarkdownStyling( + blockVerticalSpacing = 8.dp, + paragraph = MarkdownStyling.Paragraph(inlinesStyling), + heading = + MarkdownStyling.Heading( + h1 = MarkdownStyling.Heading.H1(inlinesStyling, 1.dp, Color.Black, 2.dp, PaddingValues(4.dp)), + h2 = MarkdownStyling.Heading.H2(inlinesStyling, 1.dp, Color.Black, 2.dp, PaddingValues(4.dp)), + h3 = MarkdownStyling.Heading.H3(inlinesStyling, 1.dp, Color.Black, 2.dp, PaddingValues(4.dp)), + h4 = MarkdownStyling.Heading.H4(inlinesStyling, 1.dp, Color.Black, 2.dp, PaddingValues(4.dp)), + h5 = MarkdownStyling.Heading.H5(inlinesStyling, 1.dp, Color.Black, 2.dp, PaddingValues(4.dp)), + h6 = MarkdownStyling.Heading.H6(inlinesStyling, 1.dp, Color.Black, 2.dp, PaddingValues(4.dp)), + ), + blockQuote = + MarkdownStyling.BlockQuote( + padding = PaddingValues(4.dp), + lineWidth = 2.dp, + lineColor = Color.Gray, + pathEffect = null, + strokeCap = StrokeCap.Square, + textColor = Color.Black, + ), + code = + MarkdownStyling.Code( + indented = + MarkdownStyling.Code.Indented( + editorTextStyle = TextStyle.Default.copy(fontSize = CODE_TEXT_SIZE.sp), + padding = PaddingValues(4.dp), + shape = RectangleShape, + background = Color.LightGray, + borderWidth = 0.dp, + borderColor = Color.DarkGray, + fillWidth = true, + scrollsHorizontally = true, + ), + fenced = + MarkdownStyling.Code.Fenced( + editorTextStyle = TextStyle.Default.copy(fontSize = CODE_TEXT_SIZE.sp), + padding = PaddingValues(4.dp), + shape = RectangleShape, + background = Color.LightGray, + borderWidth = 0.dp, + borderColor = Color.DarkGray, + fillWidth = true, + scrollsHorizontally = true, + infoTextStyle = TextStyle.Default, + infoPadding = PaddingValues(2.dp), + infoPosition = MarkdownStyling.Code.Fenced.InfoPosition.TopStart, + ), + ), + list = + MarkdownStyling.List( + ordered = + MarkdownStyling.List.Ordered( + numberStyle = TextStyle.Default, + numberContentGap = 1.dp, + numberMinWidth = 2.dp, + numberTextAlign = TextAlign.Start, + itemVerticalSpacing = 4.dp, + itemVerticalSpacingTight = 2.dp, + padding = PaddingValues(4.dp), + ), + unordered = + MarkdownStyling.List.Unordered( + bullet = '.', + bulletStyle = TextStyle.Default, + bulletContentGap = 1.dp, + itemVerticalSpacing = 4.dp, + itemVerticalSpacingTight = 2.dp, + padding = PaddingValues(4.dp), + ), + ), + image = + MarkdownStyling.Image( + alignment = Alignment.Center, + contentScale = ContentScale.Crop, + padding = PaddingValues(8.dp), + shape = RectangleShape, + background = Color.Transparent, + borderWidth = 1.dp, + borderColor = Color.LightGray, + ), + thematicBreak = + MarkdownStyling.ThematicBreak( + padding = PaddingValues(4.dp), + lineWidth = 2.dp, + lineColor = Color.DarkGray, + ), + htmlBlock = + MarkdownStyling.HtmlBlock( + textStyle = TextStyle.Default, + padding = PaddingValues(4.dp), + shape = RectangleShape, + background = Color.White, + borderWidth = 1.dp, + borderColor = Color.Gray, + fillWidth = true, + ), + ) + } + + companion object { + private const val CODE_TEXT_SIZE = 10 + } +} From 0ad93b62b978e6e68ea4241fc53fb04437910a64 Mon Sep 17 00:00:00 2001 From: Alexander Kuznetsov Date: Wed, 11 Dec 2024 17:08:09 +0100 Subject: [PATCH 5/5] Add sanity tests -- add missing dependency Fixes tests that fail because of the missing native library. --- markdown/core/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/markdown/core/build.gradle.kts b/markdown/core/build.gradle.kts index 151e991cb7..135428bcd8 100644 --- a/markdown/core/build.gradle.kts +++ b/markdown/core/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { testImplementation(compose.desktop.uiTestJUnit4) testImplementation(projects.ui) + testImplementation(compose.desktop.currentOs) } publicApiValidation {