[JEWEL-1322] Default Markdown block renderer to the composition local

The Markdown composables defaulted their blockRenderer parameter to a
bare DefaultMarkdownBlockRenderer with no renderer extensions, ignoring
LocalMarkdownBlockRenderer. Any renderer set up via ProvideMarkdownStyling
(e.g., with the GFM tables or alerts extensions) was silently dropped,
and extension blocks were skipped during rendering. LazyMarkdown already
read the composition local; now all three composables behave the same:
explicit parameter first, then LocalMarkdownBlockRenderer.

Also make the bridge styling isLightTheme helper re-read JBColor
brightness on every access instead of caching the value at class-load
time, so table stripe and alert colors follow IDE theme switches.

closes https://github.com/JetBrains/intellij-community/pull/3591

(cherry picked from commit c539e6f7d4e5fff4f4d3a71ddd6bf45a96c394e3)


(cherry picked from commit 19486a12a8ec7e9fddfa8a4738b8a9989bd662ed)

IJ-MR-220560

GitOrigin-RevId: f6a060b88ed77660771500beafac6ee8f310df40
This commit is contained in:
Daniel Bertoldi
2026-09-07 22:29:10 +00:00
committed by intellij-monorepo-bot
parent be88372f97
commit 53328934b1
7 changed files with 557 additions and 47 deletions
@@ -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
@@ -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<? extends org.jetbrains.jewel.markdown.MarkdownBlock> 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<? super java.lang.String,kotlin.Unit> 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<? super java.lang.String,kotlin.Unit> 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<? extends org.jetbrains.jewel.markdown.MarkdownBlock> markdownBlocks, String markdown, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean selectable, optional kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> 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<? extends org.jetbrains.jewel.markdown.MarkdownBlock> 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<? super java.lang.String,kotlin.Unit> 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<? extends org.jetbrains.jewel.markdown.MarkdownBlock> 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<? super java.lang.String,kotlin.Unit> 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<? super java.lang.String,kotlin.Unit> 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<? super java.lang.String,kotlin.Unit> 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<? extends org.jetbrains.jewel.markdown.MarkdownBlock> markdownBlocks, String markdown, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean selectable, optional kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> 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<? extends org.jetbrains.jewel.markdown.MarkdownBlock> markdownBlocks, String markdown, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional boolean selectable, optional kotlin.jvm.functions.Function1<? super java.lang.String,kotlin.Unit> 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 {
@@ -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<MarkdownBlock>()) }
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<MarkdownBlock>,
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<MarkdownBlock>,
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<MarkdownBlock>,
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) }
@@ -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<MarkdownRendererExtension>?,
inlineRenderer: InlineMarkdownRenderer?,
): MarkdownBlockRenderer {
copyCount++
return super.createCopy(rootStyling, rendererExtensions, inlineRenderer)
}
}
@@ -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 =
@@ -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()
@@ -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(