diff --git a/.idea/modules.xml b/.idea/modules.xml index 7cefa28aa68bc..0160f9d713fe3 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -1230,6 +1230,7 @@ + diff --git a/build/bazel-generated-file-list.txt b/build/bazel-generated-file-list.txt index 224f96be8aad8..8a930aee48dc0 100644 --- a/build/bazel-generated-file-list.txt +++ b/build/bazel-generated-file-list.txt @@ -640,6 +640,7 @@ platform/jewel/markdown/extensions/gfm-tables platform/jewel/markdown/extensions/images platform/jewel/markdown/ide-laf-bridge-styling platform/jewel/markdown/int-ui-standalone-styling +platform/jewel/markdown/testing platform/jewel/samples/showcase platform/jewel/samples/standalone platform/jewel/ui diff --git a/platform/jewel/markdown/core/BUILD.bazel b/platform/jewel/markdown/core/BUILD.bazel index 82a87b267760b..69a082abe8ae4 100644 --- a/platform/jewel/markdown/core/BUILD.bazel +++ b/platform/jewel/markdown/core/BUILD.bazel @@ -73,6 +73,8 @@ jvm_library( "//libraries/compose-runtime-desktop:compose-runtime-desktop_test_lib", "//libraries/compose-foundation-desktop-junit", "//libraries/compose-foundation-desktop-junit:compose-foundation-desktop-junit_test_lib", + "//platform/jewel/markdown/testing", + "//platform/jewel/markdown/testing:testing_test_lib", "//libraries/jsoup", "//libraries/jsoup:jsoup_test_lib", ], diff --git a/platform/jewel/markdown/core/build.gradle.kts b/platform/jewel/markdown/core/build.gradle.kts index 4cfcd8d310818..0c568a9fe9fb8 100644 --- a/platform/jewel/markdown/core/build.gradle.kts +++ b/platform/jewel/markdown/core/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { testImplementation(compose.desktop.uiTestJUnit4) testImplementation(projects.ui) + testImplementation(projects.markdown.testing) testImplementation(compose.desktop.currentOs) } diff --git a/platform/jewel/markdown/core/intellij.platform.jewel.markdown.core.iml b/platform/jewel/markdown/core/intellij.platform.jewel.markdown.core.iml index e9fecaf776d65..5c268262c7b7e 100644 --- a/platform/jewel/markdown/core/intellij.platform.jewel.markdown.core.iml +++ b/platform/jewel/markdown/core/intellij.platform.jewel.markdown.core.iml @@ -60,6 +60,7 @@ + \ No newline at end of file diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt index 5b7b11bcab651..dc478465a212b 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRenderer.kt @@ -752,7 +752,7 @@ public open class DefaultMarkdownBlockRenderer( enabled: Boolean, onUrlClick: ((String) -> Unit)? = null, ) = - remember(block.inlineContent, styling, enabled) { + remember(block.inlineContent, styling, enabled, onUrlClick) { inlineRenderer.renderAsAnnotatedString(block.inlineContent, styling, enabled, onUrlClick) } diff --git a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRendererTest.kt b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRendererTest.kt new file mode 100644 index 0000000000000..7f9e340302ddb --- /dev/null +++ b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/rendering/DefaultMarkdownBlockRendererTest.kt @@ -0,0 +1,139 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the +// Apache 2.0 license. +package org.jetbrains.jewel.markdown.rendering + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import org.jetbrains.jewel.markdown.processing.MarkdownProcessor +import org.jetbrains.jewel.markdown.testing.MarkdownTestTheme +import org.jetbrains.jewel.markdown.testing.createMarkdownTestStyling +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalTestApi::class) +public class DefaultMarkdownBlockRendererTest { + @Test + public fun `onUrlClick callback updates when changed`() { + runComposeUiTest { + val processor = MarkdownProcessor() + val markdown = "[Click me](https://example.com)" + val blocks = processor.processMarkdownDocument(markdown) + + var clickedUrl by mutableStateOf(null) + var onUrlClick by mutableStateOf<(String) -> Unit>({ url -> clickedUrl = "first:$url" }) + + setContent { + MarkdownTestTheme { + val renderer = DefaultMarkdownBlockRenderer(createMarkdownTestStyling(), emptyList()) + renderer.render( + blocks, + enabled = true, + onUrlClick = onUrlClick, + onTextClick = {}, + modifier = Modifier, + ) + } + } + + onNodeWithText("Click me").performClick() + waitForIdle() + assertEquals("first:https://example.com", clickedUrl) + + // Change the callback + clickedUrl = null + onUrlClick = { url -> clickedUrl = "second:$url" } + waitForIdle() + + onNodeWithText("Click me").performClick() + waitForIdle() + assertEquals("second:https://example.com", clickedUrl) + } + } + + @Test + public fun `enabled change updates link rendering`() { + runComposeUiTest { + val processor = MarkdownProcessor() + val markdown = "[Click me](https://example.com)" + val blocks = processor.processMarkdownDocument(markdown) + + var enabled by mutableStateOf(true) + var clickedUrl by mutableStateOf(null) + + setContent { + MarkdownTestTheme { + val renderer = DefaultMarkdownBlockRenderer(createMarkdownTestStyling(), emptyList()) + renderer.render( + blocks, + enabled = enabled, + onUrlClick = { url -> clickedUrl = url }, + onTextClick = {}, + modifier = Modifier, + ) + } + } + + // When enabled, clicking the link should invoke the callback + onNodeWithText("Click me").performClick() + waitForIdle() + assertEquals("https://example.com", clickedUrl) + + // Disable and verify the callback is no longer invoked + clickedUrl = null + enabled = false + waitForIdle() + + onNodeWithText("Click me").performClick() + waitForIdle() + // When disabled, links should not be clickable + assertTrue("Link should not be clickable when disabled", clickedUrl == null) + } + } + + @Test + public fun `rendered content updates when both enabled and onUrlClick change simultaneously`() { + runComposeUiTest { + val processor = MarkdownProcessor() + val markdown = "[Click me](https://example.com)" + val blocks = processor.processMarkdownDocument(markdown) + + var enabled by mutableStateOf(false) + var clickedUrl by mutableStateOf(null) + var onUrlClick by mutableStateOf<(String) -> Unit>({ url -> clickedUrl = "first:$url" }) + + setContent { + MarkdownTestTheme { + val renderer = DefaultMarkdownBlockRenderer(createMarkdownTestStyling(), emptyList()) + renderer.render( + blocks, + enabled = enabled, + onUrlClick = onUrlClick, + onTextClick = {}, + modifier = Modifier, + ) + } + } + + // Initially disabled - click should not work + onNodeWithText("Click me").performClick() + waitForIdle() + assertTrue("Link should not be clickable when disabled", clickedUrl == null) + + // Enable and change callback simultaneously + enabled = true + onUrlClick = { url -> clickedUrl = "second:$url" } + waitForIdle() + + onNodeWithText("Click me").performClick() + waitForIdle() + assertEquals("second:https://example.com", clickedUrl) + } + } +} diff --git a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt index 47e833d607f25..8420fd35b4c7c 100644 --- a/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt +++ b/platform/jewel/markdown/core/src/test/kotlin/org/jetbrains/jewel/markdown/scrolling/ScrollingSynchronizerTest.kt @@ -3,41 +3,21 @@ 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.Modifier -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.CoroutineScope import kotlinx.coroutines.launch import org.intellij.lang.annotations.Language -import org.jetbrains.jewel.foundation.BorderColors -import org.jetbrains.jewel.foundation.DisabledAppearanceValues -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 @@ -46,19 +26,13 @@ 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.markdown.rendering.MarkdownStyling.List.Ordered.NumberFormatStyles.NumberFormatStyle -import org.jetbrains.jewel.markdown.rendering.MarkdownStyling.List.Unordered.BulletCharStyles -import org.jetbrains.jewel.ui.component.styling.DividerMetrics -import org.jetbrains.jewel.ui.component.styling.DividerStyle +import org.jetbrains.jewel.markdown.testing.createMarkdownTestDividerStyle +import org.jetbrains.jewel.markdown.testing.createMarkdownTestScrollbarStyle +import org.jetbrains.jewel.markdown.testing.createMarkdownTestStyling +import org.jetbrains.jewel.markdown.testing.createMarkdownTestThemeDefinition 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 @@ -792,7 +766,8 @@ public class ScrollingSynchronizerTest { ) = runComposeUiTest { val scrollState = ScrollState(0) val synchronizer = ScrollingSynchronizer.create(scrollState)!! - val markdownStyling: MarkdownStyling = createMarkdownStyling() + val markdownStyling: MarkdownStyling = + createMarkdownTestStyling(codeEditorTextStyle = TextStyle.Default.copy(fontSize = CODE_TEXT_SIZE.sp)) val renderer = ScrollSyncMarkdownBlockRenderer(markdownStyling, emptyList(), DefaultInlineMarkdownRenderer(emptyList())) val processor = @@ -807,11 +782,11 @@ public class ScrollingSynchronizerTest { LocalMarkdownProcessor provides processor, LocalMarkdownBlockRenderer provides renderer, LocalCodeHighlighter provides NoOpCodeHighlighter, - LocalDividerStyle provides createDividerStyle(), - LocalScrollbarStyle provides createScrollbarStyle(), - LocalDensity provides createDensity(), + LocalDividerStyle provides createMarkdownTestDividerStyle(), + LocalScrollbarStyle provides createMarkdownTestScrollbarStyle(), + LocalDensity provides Density(1f), ) { - JewelTheme(createThemeDefinition()) { + JewelTheme(createMarkdownTestThemeDefinition()) { val blocks = processor.yieldBlocks() renderer.render(blocks, true, {}, {}, Modifier) } @@ -822,201 +797,6 @@ public class ScrollingSynchronizerTest { waitForIdle() } - 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, - disabledSelected = Color.Black, - info = Color.Black, - error = Color.Black, - warning = Color.Black, - ), - panelBackground = Color.White, - toolwindowBackground = 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, - disabledAppearanceValues = DisabledAppearanceValues(brightness = 33, contrast = -35, alpha = 100), - ) - } - - 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, - ) - 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), - numberFormatStyles = - MarkdownStyling.List.Ordered.NumberFormatStyles(firstLevel = NumberFormatStyle.Decimal), - ), - unordered = - MarkdownStyling.List.Unordered( - bullet = '.', - bulletStyle = TextStyle.Default, - bulletContentGap = 1.dp, - itemVerticalSpacing = 4.dp, - itemVerticalSpacingTight = 2.dp, - padding = PaddingValues(4.dp), - markerMinWidth = 16.dp, - bulletCharStyles = BulletCharStyles(), - ), - ), - 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, - ), - ) - } - public companion object { private const val CODE_TEXT_SIZE = 10 } diff --git a/platform/jewel/markdown/extensions/gfm-tables/BUILD.bazel b/platform/jewel/markdown/extensions/gfm-tables/BUILD.bazel index c75eec00b955a..4d4847e25b5b8 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/BUILD.bazel +++ b/platform/jewel/markdown/extensions/gfm-tables/BUILD.bazel @@ -43,21 +43,42 @@ jvm_library( jvm_library( name = "gfm-tables_test_lib", - module_name = "intellij.platform.jewel.markdown.extensions.gfmTables", visibility = ["//visibility:public"], - srcs = glob([], allow_empty = True), + srcs = glob(["src/test/kotlin/**/*.kt", "src/test/kotlin/**/*.java", "src/test/kotlin/**/*.form"], allow_empty = True), kotlinc_opts = ":custom_gfm-tables", - runtime_deps = [ - ":gfm-tables", + associates = [":gfm-tables"], + deps = [ + "@lib//:kotlin-stdlib", + "//libraries/kotlinx/coroutines/core", "//libraries/kotlinx/coroutines/core:core_test_lib", + "@lib//:jetbrains-annotations", + "//platform/jewel/markdown/core", "//platform/jewel/markdown/core:core_test_lib", + "//platform/jewel/ui", "//platform/jewel/ui:ui_test_lib", + "//platform/jewel/foundation", "//platform/jewel/foundation:foundation_test_lib", + "//libraries/compose-foundation-desktop", "//libraries/compose-foundation-desktop:compose-foundation-desktop_test_lib", + "//libraries/compose-runtime-desktop", "//libraries/compose-runtime-desktop:compose-runtime-desktop_test_lib", + "@lib//:platform-jewel-markdown-extensions-gfm_tables-commonmark-ext-gfm-tables", + "//libraries/junit4", + "//libraries/junit4:junit4_test_lib", "//libraries/compose-foundation-desktop-junit", "//libraries/compose-foundation-desktop-junit:compose-foundation-desktop-junit_test_lib", + "//platform/jewel/markdown/testing", + "//platform/jewel/markdown/testing:testing_test_lib", ], plugins = ["@lib//:compose-plugin"] ) -### auto-generated section `build intellij.platform.jewel.markdown.extensions.gfmTables` end \ No newline at end of file +### auto-generated section `build intellij.platform.jewel.markdown.extensions.gfmTables` end + +### auto-generated section `test intellij.platform.jewel.markdown.extensions.gfmTables` start +load("@community//build:tests-options.bzl", "jps_test") + +jps_test( + name = "gfm-tables_test", + runtime_deps = [":gfm-tables_test_lib"] +) +### auto-generated section `test intellij.platform.jewel.markdown.extensions.gfmTables` end \ No newline at end of file diff --git a/platform/jewel/markdown/extensions/gfm-tables/build.gradle.kts b/platform/jewel/markdown/extensions/gfm-tables/build.gradle.kts index d4d11bd8d5f4c..b97e75d43d317 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/build.gradle.kts +++ b/platform/jewel/markdown/extensions/gfm-tables/build.gradle.kts @@ -12,6 +12,8 @@ dependencies { implementation(libs.commonmark.ext.gfm.tables) testImplementation(compose.desktop.uiTestJUnit4) + testImplementation(projects.markdown.testing) + testImplementation(compose.desktop.currentOs) } publicApiValidation { excludedClassRegexes = setOf("org.jetbrains.jewel.markdown.extensions.github.tables.*") } diff --git a/platform/jewel/markdown/extensions/gfm-tables/intellij.platform.jewel.markdown.extensions.gfmTables.iml b/platform/jewel/markdown/extensions/gfm-tables/intellij.platform.jewel.markdown.extensions.gfmTables.iml index a289c020b4d91..071697c471801 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/intellij.platform.jewel.markdown.extensions.gfmTables.iml +++ b/platform/jewel/markdown/extensions/gfm-tables/intellij.platform.jewel.markdown.extensions.gfmTables.iml @@ -26,6 +26,7 @@ + @@ -57,6 +58,8 @@ + + \ No newline at end of file diff --git a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRenderer.kt b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRenderer.kt index 62ec428e1fab8..b79e6248e6f67 100644 --- a/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRenderer.kt +++ b/platform/jewel/markdown/extensions/gfm-tables/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRenderer.kt @@ -74,7 +74,7 @@ internal class GitHubTableBlockRenderer( val headerRenderer = remember(headerRootStyling) { blockRenderer.createCopy(rootStyling = headerRootStyling) } val rows = - remember(tableBlock, blockRenderer, inlineRenderer, tableStyling) { + remember(tableBlock, blockRenderer, inlineRenderer, tableStyling, enabled, onUrlClick) { val headerCells = tableBlock.header.cells.map Unit> { cell -> { 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 new file mode 100644 index 0000000000000..a05f964a70ad4 --- /dev/null +++ b/platform/jewel/markdown/extensions/gfm-tables/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/github/tables/GitHubTableBlockRendererRememberKeysTest.kt @@ -0,0 +1,143 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the +// Apache 2.0 license. +package org.jetbrains.jewel.markdown.extensions.github.tables + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.markdown.processing.MarkdownProcessor +import org.jetbrains.jewel.markdown.rendering.DefaultMarkdownBlockRenderer +import org.jetbrains.jewel.markdown.testing.MarkdownTestTheme +import org.jetbrains.jewel.markdown.testing.createMarkdownTestStyling +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalTestApi::class) +public class GitHubTableBlockRendererRememberKeysTest { + @Test + public fun `onUrlClick callback updates when changed`() { + runComposeUiTest { + val markdownStyling = createMarkdownTestStyling() + val tableStyling = createTableStyling() + val rendererExtension = GitHubTableRendererExtension(tableStyling, markdownStyling) + val processor = MarkdownProcessor(extensions = listOf(GitHubTableProcessorExtension)) + + val markdown = + """ + | Header | + | ------ | + | [Click me](https://example.com) | + """ + .trimIndent() + val blocks = processor.processMarkdownDocument(markdown) + + var clickedUrl by mutableStateOf(null) + var onUrlClick by mutableStateOf<(String) -> Unit>({ url -> clickedUrl = "first:$url" }) + + setContent { + MarkdownTestTheme { + val renderer = DefaultMarkdownBlockRenderer(markdownStyling, listOf(rendererExtension)) + renderer.render( + blocks, + enabled = true, + onUrlClick = onUrlClick, + onTextClick = {}, + modifier = Modifier, + ) + } + } + + onNodeWithText("Click me").performClick() + waitForIdle() + assertEquals("first:https://example.com", clickedUrl) + + // Change the callback + clickedUrl = null + onUrlClick = { url -> clickedUrl = "second:$url" } + waitForIdle() + + onNodeWithText("Click me").performClick() + waitForIdle() + assertEquals("second:https://example.com", clickedUrl) + } + } + + @Test + public fun `enabled change updates link rendering`() { + runComposeUiTest { + val markdownStyling = createMarkdownTestStyling() + val tableStyling = createTableStyling() + val rendererExtension = GitHubTableRendererExtension(tableStyling, markdownStyling) + val processor = MarkdownProcessor(extensions = listOf(GitHubTableProcessorExtension)) + + val markdown = + """ + | Header | + | ------ | + | [Click me](https://example.com) | + """ + .trimIndent() + val blocks = processor.processMarkdownDocument(markdown) + + var enabled by mutableStateOf(true) + var clickedUrl by mutableStateOf(null) + + setContent { + MarkdownTestTheme { + val renderer = DefaultMarkdownBlockRenderer(markdownStyling, listOf(rendererExtension)) + renderer.render( + blocks, + enabled = enabled, + onUrlClick = { url -> clickedUrl = url }, + onTextClick = {}, + modifier = Modifier, + ) + } + } + + // When enabled, clicking the link should invoke the callback + onNodeWithText("Click me").performClick() + waitForIdle() + assertEquals("https://example.com", clickedUrl) + + // Disable and verify the callback is no longer invoked + clickedUrl = null + enabled = false + waitForIdle() + + onNodeWithText("Click me").performClick() + waitForIdle() + assertTrue("Link should not be clickable when disabled", clickedUrl == null) + } + } + + private fun createTableStyling() = + GfmTableStyling( + colors = + GfmTableColors( + borderColor = Color.Black, + rowBackgroundColor = Color.White, + alternateRowBackgroundColor = Color.LightGray, + rowBackgroundStyle = RowBackgroundStyle.Normal, + ), + metrics = + GfmTableMetrics( + borderWidth = 1.dp, + cellPadding = PaddingValues(4.dp), + defaultCellContentAlignment = Alignment.Start, + headerDefaultCellContentAlignment = Alignment.Start, + ), + headerBaseFontWeight = FontWeight.Bold, + ) +} diff --git a/platform/jewel/markdown/testing/BUILD.bazel b/platform/jewel/markdown/testing/BUILD.bazel new file mode 100644 index 0000000000000..1db073f35ed39 --- /dev/null +++ b/platform/jewel/markdown/testing/BUILD.bazel @@ -0,0 +1,50 @@ +### auto-generated section `build intellij.platform.jewel.markdown.testing` start +load("//build:compiler-options.bzl", "create_kotlinc_options") +load("@rules_jvm//:jvm.bzl", "jvm_library") + +create_kotlinc_options( + name = "custom_testing", + opt_in = [ + "androidx.compose.ui.ExperimentalComposeUiApi", + "androidx.compose.foundation.ExperimentalFoundationApi", + "org.jetbrains.jewel.foundation.ExperimentalJewelApi", + "org.jetbrains.jewel.foundation.InternalJewelApi", + ], + x_context_parameters = True +) + +jvm_library( + name = "testing", + module_name = "intellij.platform.jewel.markdown.testing", + visibility = ["//visibility:public"], + srcs = glob(["src/main/kotlin/**/*.kt", "src/main/kotlin/**/*.java", "src/main/kotlin/**/*.form"], allow_empty = True), + kotlinc_opts = ":custom_testing", + deps = [ + "@lib//:kotlin-stdlib", + "@lib//:jetbrains-annotations", + "//platform/jewel/markdown/core", + "//platform/jewel/ui", + "//platform/jewel/foundation", + "//libraries/compose-foundation-desktop", + "//libraries/compose-runtime-desktop", + ], + plugins = ["@lib//:compose-plugin"] +) + +jvm_library( + name = "testing_test_lib", + module_name = "intellij.platform.jewel.markdown.testing", + visibility = ["//visibility:public"], + srcs = glob([], allow_empty = True), + kotlinc_opts = ":custom_testing", + runtime_deps = [ + ":testing", + "//platform/jewel/markdown/core:core_test_lib", + "//platform/jewel/ui:ui_test_lib", + "//platform/jewel/foundation:foundation_test_lib", + "//libraries/compose-foundation-desktop:compose-foundation-desktop_test_lib", + "//libraries/compose-runtime-desktop:compose-runtime-desktop_test_lib", + ], + plugins = ["@lib//:compose-plugin"] +) +### auto-generated section `build intellij.platform.jewel.markdown.testing` end \ No newline at end of file diff --git a/platform/jewel/markdown/testing/build.gradle.kts b/platform/jewel/markdown/testing/build.gradle.kts new file mode 100644 index 0000000000000..0950df1cea7d2 --- /dev/null +++ b/platform/jewel/markdown/testing/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + jewel + alias(libs.plugins.composeDesktop) + alias(libs.plugins.compose.compiler) +} + +dependencies { + api(projects.markdown.core) +} diff --git a/platform/jewel/markdown/testing/intellij.platform.jewel.markdown.testing.iml b/platform/jewel/markdown/testing/intellij.platform.jewel.markdown.testing.iml new file mode 100644 index 0000000000000..dc0c903d99350 --- /dev/null +++ b/platform/jewel/markdown/testing/intellij.platform.jewel.markdown.testing.iml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + $MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-compose-compiler-plugin/2.3.10-RC/kotlin-compose-compiler-plugin-2.3.10-RC.jar + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platform/jewel/markdown/testing/module-content.yaml b/platform/jewel/markdown/testing/module-content.yaml new file mode 100644 index 0000000000000..ff2558b7e949a --- /dev/null +++ b/platform/jewel/markdown/testing/module-content.yaml @@ -0,0 +1,3 @@ +- name: dist.all/lib/intellij.platform.jewel.markdown.testing.jar + modules: + - name: intellij.platform.jewel.markdown.testing 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 new file mode 100644 index 0000000000000..98fbb2f0285c1 --- /dev/null +++ b/platform/jewel/markdown/testing/src/main/kotlin/org/jetbrains/jewel/markdown/testing/MarkdownTestTheme.kt @@ -0,0 +1,245 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the +// Apache 2.0 license. +package org.jetbrains.jewel.markdown.testing + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.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 kotlin.time.Duration.Companion.milliseconds +import org.jetbrains.jewel.foundation.BorderColors +import org.jetbrains.jewel.foundation.DisabledAppearanceValues +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.rendering.InlinesStyling +import org.jetbrains.jewel.markdown.rendering.MarkdownStyling +import org.jetbrains.jewel.markdown.rendering.MarkdownStyling.List.Ordered.NumberFormatStyles.NumberFormatStyle +import org.jetbrains.jewel.markdown.rendering.MarkdownStyling.List.Unordered.BulletCharStyles +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 + +@Composable +fun MarkdownTestTheme(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalCodeHighlighter provides NoOpCodeHighlighter, + LocalDividerStyle provides createMarkdownTestDividerStyle(), + LocalScrollbarStyle provides createMarkdownTestScrollbarStyle(), + LocalDensity provides Density(1f), + ) { + JewelTheme(createMarkdownTestThemeDefinition()) { content() } + } +} + +fun createMarkdownTestDividerStyle() = + DividerStyle(color = Color.Black, metrics = DividerMetrics(thickness = 1.dp, startIndent = 0.dp)) + +fun createMarkdownTestScrollbarStyle() = + 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, + ), + ) + +fun createMarkdownTestThemeDefinition(): ThemeDefinition = + 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, + disabledSelected = Color.Black, + info = Color.Black, + error = Color.Black, + warning = Color.Black, + ), + panelBackground = Color.White, + toolwindowBackground = 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, + disabledAppearanceValues = DisabledAppearanceValues(brightness = 33, contrast = -35, alpha = 100), + ) + +fun createMarkdownTestStyling(codeEditorTextStyle: TextStyle = TextStyle.Default): 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, + ) + 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 = codeEditorTextStyle, + padding = PaddingValues(4.dp), + shape = RectangleShape, + background = Color.LightGray, + borderWidth = 0.dp, + borderColor = Color.DarkGray, + fillWidth = true, + scrollsHorizontally = true, + ), + fenced = + MarkdownStyling.Code.Fenced( + editorTextStyle = codeEditorTextStyle, + 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), + numberFormatStyles = + MarkdownStyling.List.Ordered.NumberFormatStyles(firstLevel = NumberFormatStyle.Decimal), + ), + unordered = + MarkdownStyling.List.Unordered( + bullet = '.', + bulletStyle = TextStyle.Default, + bulletContentGap = 1.dp, + itemVerticalSpacing = 4.dp, + itemVerticalSpacingTight = 2.dp, + padding = PaddingValues(4.dp), + markerMinWidth = 16.dp, + bulletCharStyles = BulletCharStyles(), + ), + ), + 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, + ), + ) +} diff --git a/platform/jewel/settings.gradle.kts b/platform/jewel/settings.gradle.kts index 2931a57bff13f..53fb03bcc128b 100644 --- a/platform/jewel/settings.gradle.kts +++ b/platform/jewel/settings.gradle.kts @@ -47,6 +47,7 @@ include( ":markdown:extensions:images", ":markdown:int-ui-standalone-styling", ":markdown:ide-laf-bridge-styling", + ":markdown:testing", ":samples:showcase", ":samples:standalone", ":ui",