diff --git a/platform/jewel/markdown/core/api-dump-experimental.txt b/platform/jewel/markdown/core/api-dump-experimental.txt index cfb9a4c55e5f..639390f45286 100644 --- a/platform/jewel/markdown/core/api-dump-experimental.txt +++ b/platform/jewel/markdown/core/api-dump-experimental.txt @@ -214,8 +214,11 @@ - equals(java.lang.Object):Z - hashCode():I f:org.jetbrains.jewel.markdown.MarkdownKt +- *sf:LazyMarkdown(java.util.List,androidx.compose.ui.Modifier,androidx.compose.foundation.layout.PaddingValues,androidx.compose.foundation.lazy.LazyListState,Z,Z,kotlin.jvm.functions.Function1,org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer,androidx.compose.runtime.Composer,I,I):V - *sf:LazyMarkdown(java.util.List,androidx.compose.ui.Modifier,androidx.compose.foundation.layout.PaddingValues,androidx.compose.foundation.lazy.LazyListState,Z,Z,kotlin.jvm.functions.Function1,org.jetbrains.jewel.markdown.rendering.MarkdownStyling,org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer,androidx.compose.runtime.Composer,I,I):V +- *sf:Markdown(java.lang.String,androidx.compose.ui.Modifier,Z,Z,kotlinx.coroutines.CoroutineDispatcher,kotlin.jvm.functions.Function1,org.jetbrains.jewel.markdown.processing.MarkdownProcessor,org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer,androidx.compose.runtime.Composer,I,I):V - *sf:Markdown(java.lang.String,androidx.compose.ui.Modifier,Z,Z,kotlinx.coroutines.CoroutineDispatcher,kotlin.jvm.functions.Function1,org.jetbrains.jewel.markdown.rendering.MarkdownStyling,org.jetbrains.jewel.markdown.processing.MarkdownProcessor,org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer,androidx.compose.runtime.Composer,I,I):V +- *sf:Markdown(java.util.List,java.lang.String,androidx.compose.ui.Modifier,Z,Z,kotlin.jvm.functions.Function1,org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer,androidx.compose.runtime.Composer,I,I):V - *sf:Markdown(java.util.List,java.lang.String,androidx.compose.ui.Modifier,Z,Z,kotlin.jvm.functions.Function1,org.jetbrains.jewel.markdown.rendering.MarkdownStyling,org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer,androidx.compose.runtime.Composer,I,I):V *:org.jetbrains.jewel.markdown.MarkdownMode *f:org.jetbrains.jewel.markdown.MarkdownMode$EditorPreview diff --git a/platform/jewel/markdown/core/metalava/core-api-0.41.0.txt b/platform/jewel/markdown/core/metalava/core-api-0.41.0.txt index c7387a27e076..a382d3e5aeba 100644 --- a/platform/jewel/markdown/core/metalava/core-api-0.41.0.txt +++ b/platform/jewel/markdown/core/metalava/core-api-0.41.0.txt @@ -203,9 +203,12 @@ package org.jetbrains.jewel.markdown { } public final class MarkdownKt { - method @SuppressCompatibility @androidx.compose.runtime.Composable @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public static void LazyMarkdown(java.util.List blocks, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.lazy.LazyListState state, optional boolean enabled, optional boolean selectable, optional kotlin.jvm.functions.Function1 onUrlClick, optional org.jetbrains.jewel.markdown.rendering.MarkdownStyling markdownStyling, optional org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer blockRenderer); - method @SuppressCompatibility @androidx.compose.runtime.Composable @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public static void Markdown(@org.intellij.lang.annotations.Language("Markdown") String markdown, optional androidx.compose.ui.Modifier modifier, optional boolean selectable, optional boolean enabled, optional kotlinx.coroutines.CoroutineDispatcher processingDispatcher, optional kotlin.jvm.functions.Function1 onUrlClick, optional org.jetbrains.jewel.markdown.rendering.MarkdownStyling markdownStyling, optional org.jetbrains.jewel.markdown.processing.MarkdownProcessor processor, optional org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer blockRenderer); - method @SuppressCompatibility @androidx.compose.runtime.Composable @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public static void Markdown(java.util.List markdownBlocks, String markdown, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean selectable, optional kotlin.jvm.functions.Function1 onUrlClick, optional org.jetbrains.jewel.markdown.rendering.MarkdownStyling markdownStyling, optional org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer blockRenderer); + method @SuppressCompatibility @androidx.compose.runtime.Composable @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public static void LazyMarkdown(java.util.List blocks, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.lazy.LazyListState state, optional boolean enabled, optional boolean selectable, optional kotlin.jvm.functions.Function1 onUrlClick, optional org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer blockRenderer); + method @Deprecated @SuppressCompatibility @androidx.compose.runtime.Composable @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public static void LazyMarkdown(java.util.List blocks, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.lazy.LazyListState state, optional boolean enabled, optional boolean selectable, optional kotlin.jvm.functions.Function1 onUrlClick, optional org.jetbrains.jewel.markdown.rendering.MarkdownStyling markdownStyling, optional org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer blockRenderer); + method @SuppressCompatibility @androidx.compose.runtime.Composable @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public static void Markdown(@org.intellij.lang.annotations.Language("Markdown") String markdown, optional androidx.compose.ui.Modifier modifier, optional boolean selectable, optional boolean enabled, optional kotlinx.coroutines.CoroutineDispatcher processingDispatcher, optional kotlin.jvm.functions.Function1 onUrlClick, optional org.jetbrains.jewel.markdown.processing.MarkdownProcessor processor, optional org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer blockRenderer); + method @Deprecated @SuppressCompatibility @androidx.compose.runtime.Composable @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public static void Markdown(@org.intellij.lang.annotations.Language("Markdown") String markdown, optional androidx.compose.ui.Modifier modifier, optional boolean selectable, optional boolean enabled, optional kotlinx.coroutines.CoroutineDispatcher processingDispatcher, optional kotlin.jvm.functions.Function1 onUrlClick, optional org.jetbrains.jewel.markdown.rendering.MarkdownStyling markdownStyling, optional org.jetbrains.jewel.markdown.processing.MarkdownProcessor processor, optional org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer blockRenderer); + method @SuppressCompatibility @androidx.compose.runtime.Composable @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public static void Markdown(java.util.List markdownBlocks, String markdown, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean selectable, optional kotlin.jvm.functions.Function1 onUrlClick, optional org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer blockRenderer); + method @Deprecated @SuppressCompatibility @androidx.compose.runtime.Composable @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public static void Markdown(java.util.List markdownBlocks, String markdown, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean selectable, optional kotlin.jvm.functions.Function1 onUrlClick, optional org.jetbrains.jewel.markdown.rendering.MarkdownStyling markdownStyling, optional org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer blockRenderer); } @SuppressCompatibility @org.jetbrains.annotations.ApiStatus.Experimental @org.jetbrains.jewel.foundation.ExperimentalJewelApi public sealed interface MarkdownMode { diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/Markdown.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/Markdown.kt index b9cfaeba9124..0e3c09806a1d 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/Markdown.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/Markdown.kt @@ -31,26 +31,31 @@ import org.jetbrains.jewel.markdown.extensions.markdownBlockRenderer import org.jetbrains.jewel.markdown.extensions.markdownProcessor import org.jetbrains.jewel.markdown.extensions.markdownStyling import org.jetbrains.jewel.markdown.processing.MarkdownProcessor -import org.jetbrains.jewel.markdown.rendering.DefaultMarkdownBlockRenderer import org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer import org.jetbrains.jewel.markdown.rendering.MarkdownStyling +private const val STYLING_FROM_RENDERER_MESSAGE = + "The block renderer now provides the styling to use, including the block spacing. Provide a renderer with the " + + "desired styling instead, e.g. by using ProvideMarkdownStyling." + /** * A Composable that renders a Markdown string. * * For large amounts of Markdown, such as documents, you can consider using [LazyMarkdown] instead to get better * performance. * + * Both the styling of the rendered blocks and the vertical spacing between them come from the [blockRenderer]'s + * [rootStyling][MarkdownBlockRenderer.rootStyling]. + * * @param markdown The Markdown string to render. * @param modifier The modifier to apply to this layout node. * @param selectable Whether the text can be selected. * @param enabled Whether the rendered content is enabled. * @param processingDispatcher The dispatcher to use for processing the Markdown. * @param onUrlClick The callback to be invoked when a URL is clicked. - * @param markdownStyling The styling to use for the rendered Markdown. * @param processor The processor to use for parsing the Markdown. - * @param blockRenderer The renderer to use for rendering the Markdown blocks. - * @see Markdown + * @param blockRenderer The renderer to use for rendering the Markdown blocks, and the source of the styling to use. + * @see LazyMarkdown */ @ApiStatus.Experimental @ExperimentalJewelApi @@ -62,9 +67,8 @@ public fun Markdown( enabled: Boolean = true, processingDispatcher: CoroutineDispatcher = Dispatchers.Default, onUrlClick: (String) -> Unit = {}, - markdownStyling: MarkdownStyling = JewelTheme.markdownStyling, processor: MarkdownProcessor = JewelTheme.markdownProcessor, - blockRenderer: MarkdownBlockRenderer = DefaultMarkdownBlockRenderer(markdownStyling), + blockRenderer: MarkdownBlockRenderer = JewelTheme.markdownBlockRenderer, ) { var markdownBlocks by remember { mutableStateOf(emptyList()) } LaunchedEffect(markdown, processor) { @@ -78,26 +82,77 @@ public fun Markdown( selectable = selectable, enabled = enabled, onUrlClick = onUrlClick, - markdownStyling = markdownStyling, blockRenderer = blockRenderer, ) } +/** + * A Composable that renders a Markdown string. + * + * @param markdown The Markdown string to render. + * @param modifier The modifier to apply to this layout node. + * @param selectable Whether the text can be selected. + * @param enabled Whether the rendered content is enabled. + * @param processingDispatcher The dispatcher to use for processing the Markdown. + * @param onUrlClick The callback to be invoked when a URL is clicked. + * @param markdownStyling The styling to apply to the [blockRenderer] before rendering. Note that renderer extensions + * keep the styling they were created with, so custom blocks are not affected by this. + * @param processor The processor to use for parsing the Markdown. + * @param blockRenderer The renderer to use for rendering the Markdown blocks. + * @see Markdown + */ +@Deprecated( + STYLING_FROM_RENDERER_MESSAGE, + ReplaceWith( + "Markdown(markdown, modifier, selectable, enabled, processingDispatcher, onUrlClick, processor, " + + "blockRenderer.createCopy(rootStyling = markdownStyling))" + ), +) +@ApiStatus.Experimental +@ExperimentalJewelApi +@Composable +public fun Markdown( + @Language("Markdown") markdown: String, + modifier: Modifier = Modifier, + selectable: Boolean = false, + enabled: Boolean = true, + processingDispatcher: CoroutineDispatcher = Dispatchers.Default, + onUrlClick: (String) -> Unit = {}, + markdownStyling: MarkdownStyling = JewelTheme.markdownStyling, + processor: MarkdownProcessor = JewelTheme.markdownProcessor, + blockRenderer: MarkdownBlockRenderer = JewelTheme.markdownBlockRenderer, +) { + val styledRenderer = remember(blockRenderer, markdownStyling) { blockRenderer.withStyling(markdownStyling) } + + Markdown( + markdown = markdown, + modifier = modifier, + selectable = selectable, + enabled = enabled, + processingDispatcher = processingDispatcher, + onUrlClick = onUrlClick, + processor = processor, + blockRenderer = styledRenderer, + ) +} + /** * A Composable that renders a list of [MarkdownBlock]s in a column. * * For large amounts of Markdown, such as documents, you can consider using [LazyMarkdown] instead to get better * performance. * + * Both the styling of the rendered blocks and the vertical spacing between them come from the [blockRenderer]'s + * [rootStyling][MarkdownBlockRenderer.rootStyling]. + * * @param markdownBlocks The list of Markdown blocks to render. * @param markdown The original Markdown string. * @param modifier The modifier to apply to this layout node. * @param enabled Whether the rendered content is enabled. * @param selectable Whether the text can be selected. * @param onUrlClick The callback to be invoked when a URL is clicked. - * @param markdownStyling The styling to use for the rendered Markdown. - * @param blockRenderer The renderer to use for rendering the Markdown blocks. - * @see Markdown + * @param blockRenderer The renderer to use for rendering the Markdown blocks, and the source of the styling to use. + * @see LazyMarkdown */ @ApiStatus.Experimental @ExperimentalJewelApi @@ -109,15 +164,14 @@ public fun Markdown( enabled: Boolean = true, selectable: Boolean = false, onUrlClick: (String) -> Unit = {}, - markdownStyling: MarkdownStyling = JewelTheme.markdownStyling, - blockRenderer: MarkdownBlockRenderer = DefaultMarkdownBlockRenderer(markdownStyling), + blockRenderer: MarkdownBlockRenderer = JewelTheme.markdownBlockRenderer, ) { // We keep the existing behavior in terms of where the rawMarkdown semantic is applied to MaybeSelectable(selectable, Modifier.thenIf(selectable) { semantics { rawMarkdown = markdown } }) { @Suppress("ModifierNotUsedAtRoot") // Intentional Column( modifier.thenIf(!selectable) { semantics { rawMarkdown = markdown } }, - verticalArrangement = Arrangement.spacedBy(markdownStyling.blockVerticalSpacing), + verticalArrangement = Arrangement.spacedBy(blockRenderer.rootStyling.blockVerticalSpacing), ) { for (block in markdownBlocks) { blockRenderer.RenderBlock(block, enabled, onUrlClick, Modifier) @@ -126,12 +180,62 @@ public fun Markdown( } } +/** + * A Composable that renders a list of [MarkdownBlock]s in a column. + * + * @param markdownBlocks The list of Markdown blocks to render. + * @param markdown The original Markdown string. + * @param modifier The modifier to apply to this layout node. + * @param enabled Whether the rendered content is enabled. + * @param selectable Whether the text can be selected. + * @param onUrlClick The callback to be invoked when a URL is clicked. + * @param markdownStyling The styling to apply to the [blockRenderer] before rendering. Note that renderer extensions + * keep the styling they were created with, so custom blocks are not affected by this. + * @param blockRenderer The renderer to use for rendering the Markdown blocks. + * @see Markdown + */ +@Deprecated( + STYLING_FROM_RENDERER_MESSAGE, + ReplaceWith( + "Markdown(markdownBlocks, markdown, modifier, enabled, selectable, onUrlClick, " + + "blockRenderer.createCopy(rootStyling = markdownStyling))" + ), +) +@ApiStatus.Experimental +@ExperimentalJewelApi +@Composable +public fun Markdown( + markdownBlocks: List, + markdown: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + selectable: Boolean = false, + onUrlClick: (String) -> Unit = {}, + markdownStyling: MarkdownStyling = JewelTheme.markdownStyling, + blockRenderer: MarkdownBlockRenderer = JewelTheme.markdownBlockRenderer, +) { + val styledRenderer = remember(blockRenderer, markdownStyling) { blockRenderer.withStyling(markdownStyling) } + + Markdown( + markdownBlocks = markdownBlocks, + markdown = markdown, + modifier = modifier, + enabled = enabled, + selectable = selectable, + onUrlClick = onUrlClick, + blockRenderer = styledRenderer, + ) +} + /** * A Composable that renders a list of [MarkdownBlock]s in a lazy-loading column. * * For small amounts of Markdown, such as UI text, you should consider using [Markdown] instead to get better * performance. * + * Both the styling of the rendered blocks and the vertical spacing between them come from the [blockRenderer]'s + * [rootStyling][MarkdownBlockRenderer.rootStyling]. + * * @param blocks The list of Markdown blocks to render. * @param modifier The modifier to apply to this layout node. * @param contentPadding The padding to apply to the content. @@ -139,12 +243,77 @@ public fun Markdown( * @param enabled Whether the rendered content is enabled. * @param selectable Whether the text can be selected. * @param onUrlClick The callback to be invoked when a URL is clicked. - * @param markdownStyling The styling to use for the rendered Markdown. - * @param blockRenderer The renderer to use for rendering the Markdown blocks. + * @param blockRenderer The renderer to use for rendering the Markdown blocks, and the source of the styling to use. + * @see Markdown */ @ApiStatus.Experimental @ExperimentalJewelApi @Composable +public fun LazyMarkdown( + blocks: List, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + state: LazyListState = rememberLazyListState(), + enabled: Boolean = true, + selectable: Boolean = false, + onUrlClick: (String) -> Unit = {}, + blockRenderer: MarkdownBlockRenderer = JewelTheme.markdownBlockRenderer, +) { + val blockVerticalSpacing = blockRenderer.rootStyling.blockVerticalSpacing + + MaybeSelectable(selectable, modifier) { + LazyColumn(state = state, contentPadding = contentPadding) { + itemsIndexed(blocks) { index, block -> + blockRenderer.RenderBlock( + block = block, + enabled = enabled, + onUrlClick = onUrlClick, + modifier = + Modifier.padding( + top = + if (index == 0) { + 0.dp + } else { + (blockVerticalSpacing / 2) + }, + bottom = + if (index == blocks.lastIndex) { + 0.dp + } else { + (blockVerticalSpacing / 2) + }, + ), + ) + } + } + } +} + +/** + * A Composable that renders a list of [MarkdownBlock]s in a lazy-loading column. + * + * @param blocks The list of Markdown blocks to render. + * @param modifier The modifier to apply to this layout node. + * @param contentPadding The padding to apply to the content. + * @param state The state of the lazy list. + * @param enabled Whether the rendered content is enabled. + * @param selectable Whether the text can be selected. + * @param onUrlClick The callback to be invoked when a URL is clicked. + * @param markdownStyling The styling to apply to the [blockRenderer] before rendering. Note that renderer extensions + * keep the styling they were created with, so custom blocks are not affected by this. + * @param blockRenderer The renderer to use for rendering the Markdown blocks. + * @see LazyMarkdown + */ +@Deprecated( + STYLING_FROM_RENDERER_MESSAGE, + ReplaceWith( + "LazyMarkdown(blocks, modifier, contentPadding, state, enabled, selectable, onUrlClick, " + + "blockRenderer.createCopy(rootStyling = markdownStyling))" + ), +) +@ApiStatus.Experimental +@ExperimentalJewelApi +@Composable public fun LazyMarkdown( blocks: List, modifier: Modifier = Modifier, @@ -156,34 +325,30 @@ public fun LazyMarkdown( markdownStyling: MarkdownStyling = JewelTheme.markdownStyling, blockRenderer: MarkdownBlockRenderer = JewelTheme.markdownBlockRenderer, ) { - MaybeSelectable(selectable, modifier) { - LazyColumn(state = state, contentPadding = contentPadding) { - itemsIndexed(blocks) { index, block -> - blockRenderer.RenderBlock( - block = block, - enabled = enabled, - onUrlClick = onUrlClick, - modifier = - Modifier.padding( - top = - if (index == 0) { - 0.dp - } else { - (markdownStyling.blockVerticalSpacing / 2) - }, - bottom = - if (index == blocks.lastIndex) { - 0.dp - } else { - (markdownStyling.blockVerticalSpacing / 2) - }, - ), - ) - } - } - } + val styledRenderer = remember(blockRenderer, markdownStyling) { blockRenderer.withStyling(markdownStyling) } + + LazyMarkdown( + blocks = blocks, + modifier = modifier, + contentPadding = contentPadding, + state = state, + enabled = enabled, + selectable = selectable, + onUrlClick = onUrlClick, + blockRenderer = styledRenderer, + ) } +/** + * Returns a [MarkdownBlockRenderer] that uses [styling] as its [MarkdownBlockRenderer.rootStyling], reusing this + * instance when it already does, to avoid pointless copies in the common case. + * + * Note that renderer extensions capture their own styling when they are created, so custom blocks keep rendering with + * the styling their extension was built with. + */ +private fun MarkdownBlockRenderer.withStyling(styling: MarkdownStyling): MarkdownBlockRenderer = + if (rootStyling == styling) this else createCopy(rootStyling = styling) + @Composable private fun MaybeSelectable(selectable: Boolean, modifier: Modifier = Modifier, content: @Composable () -> Unit) { val movableContent = remember { movableContentOf(content) } diff --git a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/MarkdownStylingSourceTest.kt b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/MarkdownStylingSourceTest.kt new file mode 100644 index 000000000000..1125aeafcaf0 --- /dev/null +++ b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/MarkdownStylingSourceTest.kt @@ -0,0 +1,300 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.markdown + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownBlockRenderer +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownProcessor +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownStyling +import org.jetbrains.jewel.markdown.extensions.MarkdownRendererExtension +import org.jetbrains.jewel.markdown.processing.MarkdownProcessor +import org.jetbrains.jewel.markdown.rendering.DefaultMarkdownBlockRenderer +import org.jetbrains.jewel.markdown.rendering.InlineMarkdownRenderer +import org.jetbrains.jewel.markdown.rendering.MarkdownBlockRenderer +import org.jetbrains.jewel.markdown.rendering.MarkdownStyling +import org.jetbrains.jewel.markdown.testing.MarkdownTestTheme +import org.jetbrains.jewel.markdown.testing.createMarkdownTestStyling +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Tests that the styling used when rendering comes from the [MarkdownBlockRenderer], and not from a second, independent + * source that could disagree with it. + * + * The observable proxy for "which styling was used" is the vertical gap between two blocks, since + * `blockVerticalSpacing` is the one styling value these composables read directly. + */ +@OptIn(ExperimentalTestApi::class) +public class MarkdownStylingSourceTest { + @Test + public fun `Markdown takes block spacing from the renderer, not from the styling composition local`() { + runComposeUiTest { + val blocks = MarkdownProcessor().processMarkdownDocument(TWO_PARAGRAPHS) + // The local styling and the renderer's styling deliberately disagree, so the gap tells us which one won. + val renderer = DefaultMarkdownBlockRenderer(createMarkdownTestStyling(blockVerticalSpacing = RENDERER_GAP)) + val localStyling = createMarkdownTestStyling(blockVerticalSpacing = LOCAL_GAP) + + setContent { + MarkdownTestTheme { + CompositionLocalProvider( + LocalMarkdownStyling provides localStyling, + LocalMarkdownProcessor provides MarkdownProcessor(), + LocalMarkdownBlockRenderer provides renderer, + ) { + Markdown(markdownBlocks = blocks, markdown = TWO_PARAGRAPHS) + } + } + } + + waitForIdle() + assertGapBetweenParagraphs(RENDERER_GAP) + } + } + + @Test + public fun `LazyMarkdown takes block spacing from the renderer, not from the styling composition local`() { + runComposeUiTest { + val blocks = MarkdownProcessor().processMarkdownDocument(TWO_PARAGRAPHS) + val renderer = DefaultMarkdownBlockRenderer(createMarkdownTestStyling(blockVerticalSpacing = RENDERER_GAP)) + val localStyling = createMarkdownTestStyling(blockVerticalSpacing = LOCAL_GAP) + + setContent { + MarkdownTestTheme { + CompositionLocalProvider( + LocalMarkdownStyling provides localStyling, + LocalMarkdownBlockRenderer provides renderer, + ) { + LazyMarkdown(blocks = blocks) + } + } + } + + waitForIdle() + // LazyMarkdown splits the spacing into half above and half below each item, so the total gap still matches. + assertGapBetweenParagraphs(RENDERER_GAP) + } + } + + @Test + public fun `Markdown uses the styling of an explicitly passed renderer`() { + runComposeUiTest { + val blocks = MarkdownProcessor().processMarkdownDocument(TWO_PARAGRAPHS) + val renderer = DefaultMarkdownBlockRenderer(createMarkdownTestStyling(blockVerticalSpacing = RENDERER_GAP)) + val localStyling = createMarkdownTestStyling(blockVerticalSpacing = LOCAL_GAP) + + setContent { + MarkdownTestTheme { + CompositionLocalProvider(LocalMarkdownStyling provides localStyling) { + // No styling argument, so this binds to the overload without one: the renderer decides. + Markdown(markdownBlocks = blocks, markdown = TWO_PARAGRAPHS, blockRenderer = renderer) + } + } + } + + waitForIdle() + assertGapBetweenParagraphs(RENDERER_GAP) + } + } + + @Test + @Suppress("DEPRECATION") // Testing the deprecated overload's behavior on purpose + public fun `deprecated Markdown overload applies the explicit styling to the renderer`() { + runComposeUiTest { + val blocks = MarkdownProcessor().processMarkdownDocument(TWO_PARAGRAPHS) + // The renderer starts with the "wrong" spacing: only re-styling it can produce EXPLICIT_GAP below. + val renderer = DefaultMarkdownBlockRenderer(createMarkdownTestStyling(blockVerticalSpacing = RENDERER_GAP)) + val explicitStyling = createMarkdownTestStyling(blockVerticalSpacing = EXPLICIT_GAP) + + setContent { + MarkdownTestTheme { + Markdown( + markdownBlocks = blocks, + markdown = TWO_PARAGRAPHS, + markdownStyling = explicitStyling, + blockRenderer = renderer, + ) + } + } + + waitForIdle() + assertGapBetweenParagraphs(EXPLICIT_GAP) + } + } + + @Test + // DEPRECATION: testing the deprecated overload's behavior on purpose. InjectDispatcher: the dispatcher is the point + // here, as it is what keeps the parsing synchronous and the test deterministic. + @Suppress("DEPRECATION", "InjectDispatcher") + public fun `deprecated Markdown string overload applies the explicit styling to the renderer`() { + runComposeUiTest { + val renderer = DefaultMarkdownBlockRenderer(createMarkdownTestStyling(blockVerticalSpacing = RENDERER_GAP)) + val explicitStyling = createMarkdownTestStyling(blockVerticalSpacing = EXPLICIT_GAP) + + setContent { + MarkdownTestTheme { + CompositionLocalProvider(LocalMarkdownProcessor provides MarkdownProcessor()) { + Markdown( + markdown = TWO_PARAGRAPHS, + // Unconfined keeps the parsing synchronous, so the blocks are ready once we are idle. + processingDispatcher = Dispatchers.Unconfined, + markdownStyling = explicitStyling, + blockRenderer = renderer, + ) + } + } + } + + waitForIdle() + assertGapBetweenParagraphs(EXPLICIT_GAP) + } + } + + @Test + @Suppress("DEPRECATION") // Testing the deprecated overload's behavior on purpose + public fun `deprecated LazyMarkdown overload applies the explicit styling to the renderer`() { + runComposeUiTest { + val blocks = MarkdownProcessor().processMarkdownDocument(TWO_PARAGRAPHS) + val renderer = DefaultMarkdownBlockRenderer(createMarkdownTestStyling(blockVerticalSpacing = RENDERER_GAP)) + val explicitStyling = createMarkdownTestStyling(blockVerticalSpacing = EXPLICIT_GAP) + + setContent { + MarkdownTestTheme { + LazyMarkdown(blocks = blocks, markdownStyling = explicitStyling, blockRenderer = renderer) + } + } + + waitForIdle() + assertGapBetweenParagraphs(EXPLICIT_GAP) + } + } + + @Test + @Suppress("DEPRECATION") // Testing the deprecated overload's behavior on purpose + public fun `the renderer is not copied when the explicit styling already matches it`() { + runComposeUiTest { + val blocks = MarkdownProcessor().processMarkdownDocument(TWO_PARAGRAPHS) + val styling = createMarkdownTestStyling(blockVerticalSpacing = RENDERER_GAP) + val renderer = CopyCountingBlockRenderer(styling) + + setContent { + MarkdownTestTheme { + // The very same styling instance the renderer already holds: there is nothing to re-style. + Markdown( + markdownBlocks = blocks, + markdown = TWO_PARAGRAPHS, + markdownStyling = styling, + blockRenderer = renderer, + ) + } + } + + waitForIdle() + assertEquals("The renderer should have been reused as-is", 0, renderer.copyCount) + assertGapBetweenParagraphs(RENDERER_GAP) + } + } + + @Test + @Suppress("DEPRECATION") // Testing the deprecated overload's behavior on purpose + public fun `the renderer is copied once when the explicit styling differs`() { + runComposeUiTest { + val blocks = MarkdownProcessor().processMarkdownDocument(TWO_PARAGRAPHS) + val renderer = CopyCountingBlockRenderer(createMarkdownTestStyling(blockVerticalSpacing = RENDERER_GAP)) + val explicitStyling = createMarkdownTestStyling(blockVerticalSpacing = EXPLICIT_GAP) + + setContent { + MarkdownTestTheme { + Markdown( + markdownBlocks = blocks, + markdown = TWO_PARAGRAPHS, + markdownStyling = explicitStyling, + blockRenderer = renderer, + ) + } + } + + waitForIdle() + assertEquals("The renderer should have been copied exactly once", 1, renderer.copyCount) + assertGapBetweenParagraphs(EXPLICIT_GAP) + } + } + + @Test + @Suppress("DEPRECATION") // Testing the deprecated overload's behavior on purpose + public fun `the styled renderer is reused across recompositions`() { + runComposeUiTest { + val blocks = MarkdownProcessor().processMarkdownDocument(TWO_PARAGRAPHS) + val renderer = CopyCountingBlockRenderer(createMarkdownTestStyling(blockVerticalSpacing = RENDERER_GAP)) + val explicitStyling = createMarkdownTestStyling(blockVerticalSpacing = EXPLICIT_GAP) + var enabled by mutableStateOf(true) + + setContent { + MarkdownTestTheme { + Markdown( + markdownBlocks = blocks, + markdown = TWO_PARAGRAPHS, + enabled = enabled, + markdownStyling = explicitStyling, + blockRenderer = renderer, + ) + } + } + + waitForIdle() + assertEquals("The first composition should copy the renderer once", 1, renderer.copyCount) + + // Force a recomposition: without a remember, the styled copy would be rebuilt every time, which would both + // churn allocations and hand a brand new renderer identity to everything downstream. + enabled = false + waitForIdle() + + assertEquals("Recomposing should not rebuild the styled renderer", 1, renderer.copyCount) + } + } + + private fun ComposeUiTest.assertGapBetweenParagraphs(expected: Dp) { + val first = onNodeWithText(FIRST_PARAGRAPH).getBoundsInRoot() + val second = onNodeWithText(SECOND_PARAGRAPH).getBoundsInRoot() + val actual = second.top - first.bottom + + assertEquals("Unexpected gap between the two paragraphs", expected.value, actual.value, TOLERANCE) + } + + private companion object { + private const val FIRST_PARAGRAPH = "Alpha" + private const val SECOND_PARAGRAPH = "Bravo" + private const val TWO_PARAGRAPHS = "$FIRST_PARAGRAPH\n\n$SECOND_PARAGRAPH" + + /** Rounding when converting between Dp and pixels can cost a fraction of a Dp. */ + private const val TOLERANCE = 1f + + private val LOCAL_GAP = 7.dp + private val RENDERER_GAP = 31.dp + private val EXPLICIT_GAP = 53.dp + } +} + +/** A [DefaultMarkdownBlockRenderer] that counts how many times it has been copied via [createCopy]. */ +private class CopyCountingBlockRenderer(styling: MarkdownStyling) : DefaultMarkdownBlockRenderer(styling) { + var copyCount: Int = 0 + private set + + override fun createCopy( + rootStyling: MarkdownStyling?, + rendererExtensions: List?, + inlineRenderer: InlineMarkdownRenderer?, + ): MarkdownBlockRenderer { + copyCount++ + return super.createCopy(rootStyling, rendererExtensions, inlineRenderer) + } +} diff --git a/platform/jewel/markdown/extensions/gfm-tables/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRendererRememberKeysTest.kt b/platform/jewel/markdown/extensions/gfm-tables/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRendererRememberKeysTest.kt index cbca494e9c7b..e3bed2a79d76 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRendererRememberKeysTest.kt +++ b/platform/jewel/markdown/extensions/gfm-tables/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRendererRememberKeysTest.kt @@ -3,6 +3,7 @@ package org.jetbrains.jewel.markdown.extensions.github.tables import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -15,6 +16,10 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.markdown.Markdown +import org.jetbrains.jewel.markdown.extensions.LocalMarkdownBlockRenderer +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.DefaultMarkdownBlockRenderer import org.jetbrains.jewel.markdown.testing.MarkdownTestTheme @@ -115,6 +120,34 @@ public class GitHubTableBlockRendererRememberKeysTest { } } + @Test + public fun `Markdown picks up block renderer from composition local`() { + runComposeUiTest { + val styling = createMarkdownTestStyling() + val processor = MarkdownProcessor(listOf(GitHubTableProcessorExtension)) + val renderer = + DefaultMarkdownBlockRenderer( + styling, + listOf(GitHubTableRendererExtension(createTableStyling(), styling)), + ) + + setContent { + MarkdownTestTheme { + CompositionLocalProvider( + LocalMarkdownStyling provides styling, + LocalMarkdownProcessor provides processor, + LocalMarkdownBlockRenderer provides renderer, + ) { + Markdown("| a | b |\n|---|---|\n| c | d |") + } + } + } + + waitForIdle() + onNodeWithText("c").assertExists() + } + } + private fun createTableStyling() = GfmTableStyling( colors = diff --git a/platform/jewel/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/styling/Utils.kt b/platform/jewel/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/styling/Utils.kt index 64923dd1cdd7..3f0f29cbbc16 100644 --- a/platform/jewel/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/styling/Utils.kt +++ b/platform/jewel/markdown/ide-laf-bridge-styling/src/main/kotlin/org/jetbrains/jewel/intui/markdown/bridge/styling/Utils.kt @@ -2,4 +2,5 @@ package org.jetbrains.jewel.intui.markdown.bridge.styling import com.intellij.ui.JBColor -internal val isLightTheme = JBColor.isBright() +internal val isLightTheme: Boolean + get() = JBColor.isBright() diff --git a/platform/jewel/markdown/testing/src/main/kotlin/org/jetbrains/jewel/markdown/testing/MarkdownTestTheme.kt b/platform/jewel/markdown/testing/src/main/kotlin/org/jetbrains/jewel/markdown/testing/MarkdownTestTheme.kt index 1804b042a178..4b1a6cc19f60 100644 --- a/platform/jewel/markdown/testing/src/main/kotlin/org/jetbrains/jewel/markdown/testing/MarkdownTestTheme.kt +++ b/platform/jewel/markdown/testing/src/main/kotlin/org/jetbrains/jewel/markdown/testing/MarkdownTestTheme.kt @@ -16,6 +16,7 @@ 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.dp import kotlin.time.Duration.Companion.milliseconds import org.jetbrains.jewel.foundation.BorderColors @@ -128,7 +129,11 @@ fun createMarkdownTestThemeDefinition(): ThemeDefinition = disabledAppearanceValues = DisabledAppearanceValues(brightness = 33, contrast = -35, alpha = 100), ) -fun createMarkdownTestStyling(codeEditorTextStyle: TextStyle = TextStyle.Default): MarkdownStyling { +/** Creates a minimal [MarkdownStyling] with stub span styles for use in Markdown rendering tests. */ +fun createMarkdownTestStyling( + codeEditorTextStyle: TextStyle = TextStyle.Default, + blockVerticalSpacing: Dp = 8.dp, +): MarkdownStyling { val mockSpanStyle = SpanStyle(Color.Black) val inlinesStyling = InlinesStyling( @@ -145,7 +150,7 @@ fun createMarkdownTestStyling(codeEditorTextStyle: TextStyle = TextStyle.Default inlineHtml = mockSpanStyle, ) return MarkdownStyling( - blockVerticalSpacing = 8.dp, + blockVerticalSpacing = blockVerticalSpacing, paragraph = MarkdownStyling.Paragraph(inlinesStyling), heading = MarkdownStyling.Heading(