diff --git a/platform/jewel/markdown/core/BUILD.bazel b/platform/jewel/markdown/core/BUILD.bazel index 82a87b267760b..aad2056475978 100644 --- a/platform/jewel/markdown/core/BUILD.bazel +++ b/platform/jewel/markdown/core/BUILD.bazel @@ -30,7 +30,7 @@ jvm_library( name = "core", module_name = "intellij.platform.jewel.markdown.core", visibility = ["//visibility:public"], - srcs = glob(["src/main/kotlin/**/*.kt", "src/main/kotlin/**/*.java", "src/main/kotlin/**/*.form"], allow_empty = True), + srcs = glob(["src/main/kotlin/**/*.kt", "src/main/kotlin/**/*.java", "src/main/kotlin/**/*.form", "src/testFixtures/kotlin/**/*.kt", "src/testFixtures/kotlin/**/*.java", "src/testFixtures/kotlin/**/*.form"], allow_empty = True), resources = [":core_resources"], kotlinc_opts = ":custom_core", deps = [ diff --git a/platform/jewel/markdown/core/api-dump-experimental.txt b/platform/jewel/markdown/core/api-dump-experimental.txt index 7bab313a47f4f..8cd1f82513eb4 100644 --- a/platform/jewel/markdown/core/api-dump-experimental.txt +++ b/platform/jewel/markdown/core/api-dump-experimental.txt @@ -226,8 +226,39 @@ f:org.jetbrains.jewel.markdown.SemanticsKt - a:getInlineContent():java.util.List *:org.jetbrains.jewel.markdown.WithTextContent - a:getContent():java.lang.String +*a:org.jetbrains.jewel.markdown.extensions.ImageRenderResult +- sf:$stable:I +*f:org.jetbrains.jewel.markdown.extensions.ImageRenderResult$Failed +- org.jetbrains.jewel.markdown.extensions.ImageRenderResult +- sf:$stable:I +- sf:INSTANCE:org.jetbrains.jewel.markdown.extensions.ImageRenderResult$Failed +- equals(java.lang.Object):Z +- hashCode():I +*f:org.jetbrains.jewel.markdown.extensions.ImageRenderResult$Loading +- org.jetbrains.jewel.markdown.extensions.ImageRenderResult +- sf:$stable:I +- ():V +- (androidx.compose.foundation.text.InlineTextContent):V +- b:(androidx.compose.foundation.text.InlineTextContent,I,kotlin.jvm.internal.DefaultConstructorMarker):V +- f:component1():androidx.compose.foundation.text.InlineTextContent +- f:copy(androidx.compose.foundation.text.InlineTextContent):org.jetbrains.jewel.markdown.extensions.ImageRenderResult$Loading +- bs:copy$default(org.jetbrains.jewel.markdown.extensions.ImageRenderResult$Loading,androidx.compose.foundation.text.InlineTextContent,I,java.lang.Object):org.jetbrains.jewel.markdown.extensions.ImageRenderResult$Loading +- equals(java.lang.Object):Z +- f:getContent():androidx.compose.foundation.text.InlineTextContent +- hashCode():I +*f:org.jetbrains.jewel.markdown.extensions.ImageRenderResult$Success +- org.jetbrains.jewel.markdown.extensions.ImageRenderResult +- sf:$stable:I +- (androidx.compose.foundation.text.InlineTextContent):V +- f:component1():androidx.compose.foundation.text.InlineTextContent +- f:copy(androidx.compose.foundation.text.InlineTextContent):org.jetbrains.jewel.markdown.extensions.ImageRenderResult$Success +- bs:copy$default(org.jetbrains.jewel.markdown.extensions.ImageRenderResult$Success,androidx.compose.foundation.text.InlineTextContent,I,java.lang.Object):org.jetbrains.jewel.markdown.extensions.ImageRenderResult$Success +- equals(java.lang.Object):Z +- f:getContent():androidx.compose.foundation.text.InlineTextContent +- hashCode():I *:org.jetbrains.jewel.markdown.extensions.ImageRendererExtension -- a:renderImageContent(org.jetbrains.jewel.markdown.InlineMarkdown$Image,androidx.compose.runtime.Composer,I):androidx.compose.foundation.text.InlineTextContent +- renderImage(org.jetbrains.jewel.markdown.InlineMarkdown$Image,androidx.compose.runtime.Composer,I):org.jetbrains.jewel.markdown.extensions.ImageRenderResult +- renderImageContent(org.jetbrains.jewel.markdown.InlineMarkdown$Image,androidx.compose.runtime.Composer,I):androidx.compose.foundation.text.InlineTextContent *:org.jetbrains.jewel.markdown.extensions.MarkdownBlockProcessorExtension - a:canProcess(org.commonmark.node.CustomBlock):Z - a:processMarkdownBlock(org.commonmark.node.CustomBlock,org.jetbrains.jewel.markdown.processing.MarkdownProcessor):org.jetbrains.jewel.markdown.MarkdownBlock$CustomBlock diff --git a/platform/jewel/markdown/core/api-dump.txt b/platform/jewel/markdown/core/api-dump.txt index 11f782b4fb369..4d71ea3ca3dc7 100644 --- a/platform/jewel/markdown/core/api-dump.txt +++ b/platform/jewel/markdown/core/api-dump.txt @@ -2,6 +2,10 @@ f:org.jetbrains.jewel.markdown.MarkdownKt f:org.jetbrains.jewel.markdown.MarkdownModeKt f:org.jetbrains.jewel.markdown.MarkdownTextKt f:org.jetbrains.jewel.markdown.SemanticsKt +f:org.jetbrains.jewel.markdown.TestThemeKt +- sf:CODE_TEXT_SIZE:I +- sf:createMarkdownStyling():org.jetbrains.jewel.markdown.rendering.MarkdownStyling +- sf:createThemeDefinition():org.jetbrains.jewel.foundation.theme.ThemeDefinition f:org.jetbrains.jewel.markdown.extensions.MarkdownKt f:org.jetbrains.jewel.markdown.processing.ProcessingUtilKt f:org.jetbrains.jewel.markdown.rendering.ImageSourceResolverKt diff --git a/platform/jewel/markdown/core/build.gradle.kts b/platform/jewel/markdown/core/build.gradle.kts index 4cfcd8d310818..3ad6b1c996b53 100644 --- a/platform/jewel/markdown/core/build.gradle.kts +++ b/platform/jewel/markdown/core/build.gradle.kts @@ -1,6 +1,7 @@ plugins { jewel `jewel-check-public-api` + `java-test-fixtures` alias(libs.plugins.composeDesktop) alias(libs.plugins.compose.compiler) } @@ -10,6 +11,9 @@ dependencies { api(libs.commonmark.core) api(libs.jsoup) + testFixturesImplementation(projects.foundation) + + testImplementation(testFixtures(project)) testImplementation(compose.desktop.uiTestJUnit4) testImplementation(projects.ui) 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..81f2da70b19aa 100644 --- a/platform/jewel/markdown/core/intellij.platform.jewel.markdown.core.iml +++ b/platform/jewel/markdown/core/intellij.platform.jewel.markdown.core.iml @@ -26,6 +26,7 @@ + diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/WithInlineMarkdown.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/WithInlineMarkdown.kt index afd0a7e51b905..fcf0ba94d6fc2 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/WithInlineMarkdown.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/WithInlineMarkdown.kt @@ -1,11 +1,13 @@ package org.jetbrains.jewel.markdown +import androidx.compose.runtime.Stable import org.jetbrains.annotations.ApiStatus import org.jetbrains.jewel.foundation.ExperimentalJewelApi /** An inline Markdown node that contains other [InlineMarkdown] nodes. */ @ApiStatus.Experimental @ExperimentalJewelApi +@Stable public interface WithInlineMarkdown { /** Child inline Markdown nodes. */ public val inlineContent: List diff --git a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/ImageRendererExtension.kt b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/ImageRendererExtension.kt index 9a32bcf2617b3..72d88d2c2042b 100644 --- a/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/ImageRendererExtension.kt +++ b/platform/jewel/markdown/core/src/main/kotlin/org/jetbrains/jewel/markdown/extensions/ImageRendererExtension.kt @@ -2,6 +2,7 @@ package org.jetbrains.jewel.markdown.extensions import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import org.jetbrains.annotations.ApiStatus import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.markdown.InlineMarkdown @@ -12,7 +13,7 @@ import org.jetbrains.jewel.markdown.InlineMarkdown * Implement this interface to provide a custom renderer for images. This is useful for handling image loading from * different sources (e.g., network, local files) or for applying custom visual treatments to images. * - * The [renderImageContent] function will be called for each image found in the Markdown content. + * The [renderImage] function will be called for each image found in the Markdown content. */ @ApiStatus.Experimental @ExperimentalJewelApi @@ -21,7 +22,61 @@ public interface ImageRendererExtension { * Renders an image from a Markdown document. * * @param image The image data, containing information like the image URL and alt text. - * @return An [InlineTextContent] that will be embedded in the text flow, which will be used to display the image. + * @return An [InlineTextContent] that will be embedded in the text flow, which will be used to display the image, + * or `null` if the image could not be loaded. + * @see renderImage */ - @Composable public fun renderImageContent(image: InlineMarkdown.Image): InlineTextContent? + @Deprecated( + message = "Use renderImage instead, which provides explicit loading/success/failed states", + replaceWith = ReplaceWith("renderImage(image)"), + ) + @Composable + public fun renderImageContent(image: InlineMarkdown.Image): InlineTextContent? = null + + /** + * Renders an image from a Markdown document. + * + * Override this function to provide custom image rendering with explicit state handling. The default implementation + * delegates to the deprecated [renderImageContent] for backward compatibility. + * + * @param image The image data, containing information like the image URL and alt text. + * @return An [ImageRenderResult] indicating the current state of the image: [ImageRenderResult.Loading] while the + * image is being fetched, [ImageRenderResult.Success] with the content when loaded, or [ImageRenderResult.Failed] + * if loading failed. + */ + @Composable + public fun renderImage(image: InlineMarkdown.Image): ImageRenderResult { + @Suppress("DEPRECATION") val content = renderImageContent(image) + return if (content != null) { + ImageRenderResult.Success(content) + } else { + ImageRenderResult.Failed + } + } +} + +/** + * Represents the result of rendering an image in Markdown. + * + * This sealed class allows callers to distinguish between loading, success, and failure states, enabling appropriate UI + * handling for each case (e.g., showing a loading indicator during loading, the image on success, or a fallback link on + * failure). + */ +@ApiStatus.Experimental +@ExperimentalJewelApi +@Immutable +public sealed class ImageRenderResult { + /** + * The image is currently loading. + * + * @param content Optional inline content to display while loading (e.g., a loading indicator). If null, the + * placeholder text from the markdown will be shown. + */ + public data class Loading(val content: InlineTextContent? = null) : ImageRenderResult() + + /** The image loaded successfully. */ + public data class Success(val content: InlineTextContent) : ImageRenderResult() + + /** The image failed to load. */ + public data object Failed : ImageRenderResult() } 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..3694aa520c497 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 @@ -5,13 +5,11 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.text.InlineTextContent @@ -20,10 +18,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.withCompositionLocal import androidx.compose.ui.Alignment @@ -36,13 +38,26 @@ import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.onFirstVisible import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection.Ltr import androidx.compose.ui.unit.dp import org.jetbrains.annotations.ApiStatus @@ -69,6 +84,7 @@ import org.jetbrains.jewel.markdown.MarkdownBlock.ListItem import org.jetbrains.jewel.markdown.MarkdownBlock.Paragraph import org.jetbrains.jewel.markdown.MarkdownBlock.ThematicBreak import org.jetbrains.jewel.markdown.WithInlineMarkdown +import org.jetbrains.jewel.markdown.extensions.ImageRenderResult import org.jetbrains.jewel.markdown.extensions.MarkdownRendererExtension import org.jetbrains.jewel.markdown.rendering.MarkdownStyling.List.Ordered.NumberFormatStyles import org.jetbrains.jewel.markdown.rendering.MarkdownStyling.List.Unordered.BulletCharStyles @@ -145,6 +161,7 @@ public open class DefaultMarkdownBlockRenderer( ThematicBreak -> RenderThematicBreak(rootStyling.thematicBreak, enabled, modifier) is MarkdownBlock.HtmlBlockWithAttributes -> RenderHtmlBlockWithAttributes(block, enabled, onUrlClick, modifier) + is CustomBlock -> { rendererExtensions .find { it.blockRenderer?.canRender(block) == true } @@ -206,31 +223,48 @@ public open class DefaultMarkdownBlockRenderer( softWrap: Boolean, maxLines: Int, ) { - val onlyImages = remember(block) { block.inlineContent.all { it is InlineMarkdown.Image } } - val images = renderedImages(block) + RenderBlockWithInlines( + block, + styling.inlinesStyling, + enabled, + onUrlClick, + onTextLayout, + modifier, + overflow, + softWrap, + maxLines, + ) + } - if (onlyImages) { - RenderImages(images, modifier) - } else { - val renderedContent = rememberRenderedContent(block, styling.inlinesStyling, enabled, onUrlClick) - val textColor = - styling.inlinesStyling.textStyle.color - .takeOrElse { LocalContentColor.current } - .takeOrElse { styling.inlinesStyling.textStyle.color } - val mergedStyle = styling.inlinesStyling.textStyle.merge(TextStyle(color = textColor)) + @Composable + private fun RenderBlockWithInlines( + block: WithInlineMarkdown, + inlinesStyling: InlinesStyling, + enabled: Boolean, + onUrlClick: (String) -> Unit, + onTextLayout: (TextLayoutResult) -> Unit, + modifier: Modifier = Modifier, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + ) { + val (loadedImages, renderedContent) = rememberRenderedContent(block, inlinesStyling, enabled, onUrlClick) + val textColor = inlinesStyling.textStyle.color.takeOrElse { LocalContentColor.current } + val mergedStyle = inlinesStyling.textStyle.merge(TextStyle(color = textColor)) + val density = LocalDensity.current - Text( - modifier = modifier, - text = renderedContent, - overflow = overflow, - softWrap = softWrap, - maxLines = maxLines, - onTextLayout = onTextLayout, - inlineContent = images, - style = mergedStyle, - textAlign = LocalTextAlignment.current, - ) - } + TextWithScalableInlineContent( + text = renderedContent, + inlineContent = loadedImages, + density = density, + modifier = modifier, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + onTextLayout = onTextLayout, + style = mergedStyle, + textAlign = LocalTextAlignment.current, + ) } @Composable @@ -284,30 +318,15 @@ public open class DefaultMarkdownBlockRenderer( onUrlClick: (String) -> Unit, modifier: Modifier, ) { - val onlyImages = remember(block) { block.inlineContent.all { it is InlineMarkdown.Image } } - val images = renderedImages(block) - - if (onlyImages && images.isEmpty()) return - Column(modifier = modifier.padding(styling.padding)) { - if (onlyImages) { - RenderImages(images) - } else { - val renderedContent = rememberRenderedContent(block, styling.inlinesStyling, enabled, onUrlClick) - - val textColor = - styling.inlinesStyling.textStyle.color.takeOrElse { - LocalContentColor.current.takeOrElse { styling.inlinesStyling.textStyle.color } - } - val mergedStyle = styling.inlinesStyling.textStyle.merge(TextStyle(color = textColor)) - Text( - text = renderedContent, - style = mergedStyle, - modifier = Modifier.focusProperties { this.canFocus = false }, - inlineContent = images, - textAlign = LocalTextAlignment.current, - ) - } + RenderBlockWithInlines( + block, + styling.inlinesStyling, + enabled, + onUrlClick, + {}, + Modifier.focusProperties { this.canFocus = false }, + ) if (styling.underlineWidth > 0.dp && styling.underlineColor.isSpecified) { Spacer(Modifier.height(styling.underlineGap)) @@ -751,27 +770,61 @@ public open class DefaultMarkdownBlockRenderer( styling: InlinesStyling, enabled: Boolean, onUrlClick: ((String) -> Unit)? = null, - ) = - remember(block.inlineContent, styling, enabled) { - inlineRenderer.renderAsAnnotatedString(block.inlineContent, styling, enabled, onUrlClick) - } + ): Pair, AnnotatedString> { + val (originalImages, failedImages) = resolveImages(block) + val original = + remember(block.inlineContent, styling, enabled, onUrlClick) { + inlineRenderer.renderAsAnnotatedString(block.inlineContent, styling, enabled, onUrlClick) + } + // Note: failedImages is a SnapshotStateSet, so we use derivedStateOf to properly track + // changes to its contents. + val renderedContent by + remember(original, block, styling, enabled, onUrlClick) { + derivedStateOf { + if (failedImages.isEmpty()) { + original + } else { + rebuildWithFailedImagesAsLinks(original, failedImages, block, styling, enabled, onUrlClick) + } + } + } + return originalImages to renderedContent + } @Composable - private fun renderedImages(blockInlineContent: WithInlineMarkdown): Map { + private fun resolveImages(blockInlineContent: WithInlineMarkdown): ResolvedImages { val map = remember(blockInlineContent) { mutableStateMapOf() } - + val failedSources = remember(blockInlineContent) { mutableStateSetOf() } val imagesRenderer = rendererExtensions.firstNotNullOfOrNull { it.imageRendererExtension } + val images = remember(blockInlineContent) { getImages(blockInlineContent) } + + for (image in images) { + val imageSource = image.source + when (val result = imagesRenderer?.renderImage(image)) { + is ImageRenderResult.Success -> { + map[imageSource] = result.content + failedSources.remove(imageSource) + } - for (image in getImages(blockInlineContent)) { - val renderedImage = imagesRenderer?.renderImageContent(image) - if (renderedImage == null) { - map.remove(image.source) - } else { - map[image.source] = renderedImage + is ImageRenderResult.Loading -> { + // Show loading indicator if provided + if (result.content != null) { + map[imageSource] = result.content + } + } + + is ImageRenderResult.Failed -> { + failedSources.add(imageSource) + map.remove(imageSource) + } + + null -> { + // No renderer available, skip + } } } - return map + return map to failedSources } @Composable @@ -820,23 +873,149 @@ public open class DefaultMarkdownBlockRenderer( } } + /** Scales the inline content placeholders by the given scale factor. */ + private fun scaleInlineContent( + content: Map, + availableWidth: Int, + density: Density, + ): Map { + return content.mapValues { (_, inlineContent) -> + val width = with(density) { inlineContent.placeholder.width.roundToPx() } + if (width == 0 || availableWidth >= width) { + inlineContent + } else { + val scale = availableWidth.toFloat() / width + InlineTextContent( + placeholder = + Placeholder( + width = inlineContent.placeholder.width * scale, + height = inlineContent.placeholder.height * scale, + placeholderVerticalAlign = inlineContent.placeholder.placeholderVerticalAlign, + ), + children = inlineContent.children, + ) + } + } + } + + /** + * A Text composable that automatically scales inline content when there's insufficient horizontal space. Uses + * TextMeasurer to pre-measure and determine the appropriate scale factor. + */ @Composable - private fun RenderImages(images: Map, modifier: Modifier = Modifier) { - if (images.isEmpty()) return + private fun TextWithScalableInlineContent( + text: AnnotatedString, + inlineContent: Map, + density: Density, + modifier: Modifier = Modifier, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = TextStyle.Default, + textAlign: TextAlign = LocalTextAlignment.current, + ) { + val placeholderWidths = inlineContent.values.map { it.placeholder.width.value } - val density = LocalDensity.current - FlowRow(modifier) { - images.map { (text, inlineBlock) -> - Box( - modifier = - with(density) { - Modifier.size(inlineBlock.placeholder.width.toDp(), inlineBlock.placeholder.height.toDp()) + if (placeholderWidths.sum() <= 0.01f) { + Text( + text = text, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + onTextLayout = onTextLayout, + style = style, + textAlign = textAlign, + ) + return + } + + var maxAvailableWidth by remember { mutableStateOf(Int.MAX_VALUE) } + + val scaledContent = + remember(placeholderWidths, maxAvailableWidth) { + scaleInlineContent(inlineContent, maxAvailableWidth, density) + } + + // Measure text with unscaled inline content to get intrinsic width + // (useful for grid-based containers to preserve proportions, i.e., tables) + val textMeasurer = rememberTextMeasurer() + + val intrinsicWidth = + remember(text, placeholderWidths, style, textMeasurer) { + val mergedStyle = style.merge(TextStyle(textAlign = textAlign)) + + val placeholders = + text.getStringAnnotations(0, text.length).mapNotNull { annotation -> + inlineContent[annotation.item]?.let { content -> + AnnotatedString.Range(content.placeholder, annotation.start, annotation.end) } - ) { - inlineBlock.children(text) + } + + // Measure with unconstrained width + val measured = + textMeasurer.measure( + text = text, + style = mergedStyle, + softWrap = false, + maxLines = 1, + placeholders = placeholders, + ) + + measured.size.width + } + + val measurePolicy = + remember(intrinsicWidth) { + object : MeasurePolicy { + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints, + ): MeasureResult { + // Note that this causes recomposition. It's not recommended to use this trick deliberately, + // but I couldn't find a better way to update the available width dynamically. + // The text layout phase happens before measurement, so, to lay the text out properly, + // placeholders are designed to have a static, fixed (hardcoded, if you will) size, + // defined during composition. + // This leaves us with (seemingly) no other option but to scale the placeholder manually, + // and, to know the scale factor, we must pass the measured width back to composition. + if (constraints.maxWidth != maxAvailableWidth && constraints.maxWidth != Constraints.Infinity) { + maxAvailableWidth = constraints.maxWidth + } + + val placeable = measurables.firstOrNull()?.measure(constraints) + + return layout( + width = placeable?.width ?: constraints.minWidth, + height = placeable?.height ?: constraints.minHeight, + ) { + placeable?.place(0, 0) + } + } + + override fun IntrinsicMeasureScope.maxIntrinsicWidth( + measurables: List, + height: Int, + ): Int = intrinsicWidth } } - } + + Layout( + modifier = modifier, + measurePolicy = measurePolicy, + content = { + Text( + text = text, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + onTextLayout = onTextLayout, + inlineContent = scaledContent, + style = style, + textAlign = textAlign, + ) + }, + ) } public override fun createCopy( @@ -884,6 +1063,60 @@ private fun getImages(input: WithInlineMarkdown): List = b collectImagesRecursively(input.inlineContent) } +internal const val INLINE_CONTENT_TAG = "androidx.compose.foundation.text.inlineContent" + +/** Unicode picture frame character followed by a non-breaking space, used to indicate a failed image link. */ +internal const val BROKEN_IMAGE_INDICATOR = "\uD83D\uDDBC\u00A0" // 🖼 + NBSP + +private fun rebuildWithFailedImagesAsLinks( + old: AnnotatedString, + failedImages: Set, + block: WithInlineMarkdown, + styling: InlinesStyling, + enabled: Boolean, + onUrlClick: ((String) -> Unit)?, +): AnnotatedString { + val imagesBySource = getImages(block).associateBy { it.source } + val inlineContentAnnotations = old.getStringAnnotations(0, old.length).filter { it.tag == INLINE_CONTENT_TAG } + val failedImageRanges = inlineContentAnnotations.filter { it.item in failedImages }.sortedBy { it.start } + + if (failedImageRanges.isEmpty()) { + return old + } + + return buildAnnotatedString { + var currentPos = 0 + for (annotation in failedImageRanges) { + if (currentPos < annotation.start) { + append(old.subSequence(currentPos, annotation.start)) + } + val image = imagesBySource[annotation.item] + val imageSource = image?.source.orEmpty() + val altText = image?.alt?.ifEmpty { null } ?: imageSource + + val index = + if (enabled) { + val link = + LinkAnnotation.Clickable( + tag = imageSource, + linkInteractionListener = { onUrlClick?.invoke(imageSource) }, + styles = styling.textLinkStyles, + ) + pushLink(link) + } else { + pushStyle(styling.linkDisabled) + } + append(BROKEN_IMAGE_INDICATOR) + append(altText) + pop(index) + currentPos = annotation.end + } + if (currentPos < old.length) { + append(old.subSequence(currentPos, old.length)) + } + } +} + @Deprecated( message = "The MimeType class is deprecated in favor of using the code block info strings (e.g., \"kt\", \"python\"). " + @@ -935,3 +1168,5 @@ private fun MimeType.Known.fromMimeTypeString(mimeType: String): MimeType = else -> UNKNOWN } + +private typealias ResolvedImages = Pair, Set> 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..cee55679b7e35 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 @@ -7,21 +7,14 @@ 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 @@ -38,18 +31,17 @@ 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.CODE_TEXT_SIZE import org.jetbrains.jewel.markdown.MarkdownBlock import org.jetbrains.jewel.markdown.MarkdownMode +import org.jetbrains.jewel.markdown.createMarkdownStyling import org.jetbrains.jewel.markdown.extensions.LocalMarkdownBlockRenderer import org.jetbrains.jewel.markdown.extensions.LocalMarkdownMode import org.jetbrains.jewel.markdown.extensions.LocalMarkdownProcessor import org.jetbrains.jewel.markdown.extensions.LocalMarkdownStyling import org.jetbrains.jewel.markdown.processing.MarkdownProcessor import org.jetbrains.jewel.markdown.rendering.DefaultInlineMarkdownRenderer -import org.jetbrains.jewel.markdown.rendering.InlinesStyling import org.jetbrains.jewel.markdown.rendering.MarkdownStyling -import org.jetbrains.jewel.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 @@ -723,6 +715,7 @@ public class ScrollingSynchronizerTest { } } + @Suppress("SameParameterValue") private fun assertSameDistance(distance: Int, vararg elements: Int) { assertTrue(elements.size > 1) for (i in 0..(null) } - var hasError by remember { mutableStateOf(false) } + var hasError by remember(resolvedImageSource) { mutableStateOf(false) } + + // Track whether we've previously seen a failure to prevent flickering when typing + // invalid URLs -- we keep showing Failed instead of Loading indicator until Coil + // gives us a definitive result for the new source. + var previouslyFailed by remember(resolvedImageSource) { mutableStateOf(false) } + + // Remember the ImageRequest model to prevent Coil from restarting loading unnecessarily + // when the composition recomposes but the source doesn't change. + val platformContext = LocalPlatformContext.current + val model = + remember(resolvedImageSource, platformContext) { + ImageRequest.Builder(platformContext) + .data(resolvedImageSource) + // make sure image doesn't get downscaled to the placeholder size + .size(Size.ORIGINAL) + .build() + } val painter = rememberAsyncImagePainter( - model = - ImageRequest.Builder(LocalPlatformContext.current) - .data(resolvedImageSource) - // make sure image doesn't get downscaled to the placeholder size - .size(Size.ORIGINAL) - .build(), + model = model, imageLoader = imageLoader, onLoading = { hasError = false }, onSuccess = { successState -> @@ -71,34 +92,62 @@ internal class Coil3ImageRendererExtensionImpl(private val imageLoader: ImageLoa }, onError = { error -> hasError = true - JewelLogger.getInstance(this.javaClass).warn("AsyncImage loading failed.", error.result.throwable) + val message = "Failed to load AsyncImage from $resolvedImageSource:" + JewelLogger.getInstance(this.javaClass).warn(message, error.result.throwable) }, ) if (hasError) { - return null + previouslyFailed = true + return ImageRenderResult.Failed + } + + val result = imageResult + if (result == null) { + // Anti-flickering when typing: no loading indication for an image previously failed to load + return if (previouslyFailed) { + ImageRenderResult.Failed + } else { + ImageRenderResult.Loading(createLoadingIndicator()) + } } + previouslyFailed = false + val placeholder = - imageResult?.let { - val imageSize = it.image - with(LocalDensity.current) { - // `toSp` ensures that the placeholder size matches the original image size in pixels. - // This approach doesn't allow images from appearing larger with different screen scaling, - // but simply maintains behavior consistent with standalone AsyncImage rendering. - Placeholder( - width = imageSize.width.toSp(), - height = imageSize.height.toSp(), - placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom, - ) - } + with(LocalDensity.current) { + val imageSize = result.image + // `toSp` ensures that the placeholder size matches the original image size in pixels. + // This approach doesn't allow images from appearing larger with different screen scaling, + // but simply maintains behavior consistent with standalone AsyncImage rendering. + Placeholder( + width = imageSize.width.toSp(), + height = imageSize.height.toSp(), + placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom, + ) + } + + val content = + InlineTextContent(placeholder) { + Image(painter = painter, contentDescription = image.title, modifier = Modifier.fillMaxSize()) } - ?: run { - Placeholder(width = 0.sp, height = 1.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.Bottom) - } + return ImageRenderResult.Success(content) + } - return InlineTextContent(placeholder) { - Image(painter = painter, contentDescription = image.title, modifier = Modifier.fillMaxSize()) + private fun createLoadingIndicator(): InlineTextContent = + InlineTextContent( + Placeholder(width = 16.sp, height = 16.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.Center) + ) { + Box( + modifier = Modifier.fillMaxSize().semantics { contentDescription = LOADING_INDICATOR_DESCRIPTION }, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(style = DefaultLoadingIndicatorStyle) + } } + + internal companion object { + const val LOADING_INDICATOR_DESCRIPTION = "Image loading indicator" + val DefaultLoadingIndicatorStyle = CircularProgressStyle(frameTime = 125.milliseconds, color = Color.Gray) } } diff --git a/platform/jewel/markdown/extensions/images/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImplTest.kt b/platform/jewel/markdown/extensions/images/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImplTest.kt index d3a7e54671aaf..9f322d17ce6f8 100644 --- a/platform/jewel/markdown/extensions/images/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImplTest.kt +++ b/platform/jewel/markdown/extensions/images/src/test/kotlin/org/jetbrains/jewel/markdown/extensions/images/Coil3ImageRendererExtensionImplTest.kt @@ -1,18 +1,17 @@ // 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.images -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertHeightIsEqualTo -import androidx.compose.ui.test.assertWidthIsAtLeast import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp import coil3.ColorImage import coil3.ImageLoader @@ -25,16 +24,25 @@ import coil3.test.FakeImageLoaderEngine import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.test.StandardTestDispatcher +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.markdown.InlineMarkdown +import org.jetbrains.jewel.markdown.MarkdownBlock.Paragraph +import org.jetbrains.jewel.markdown.createMarkdownStyling +import org.jetbrains.jewel.markdown.createThemeDefinition +import org.jetbrains.jewel.markdown.rendering.DefaultInlineMarkdownRenderer +import org.jetbrains.jewel.markdown.rendering.DefaultMarkdownBlockRenderer import org.junit.Rule import org.junit.Test -@OptIn(ExperimentalCoilApi::class) +@OptIn(ExperimentalCoilApi::class, ExperimentalJewelApi::class) public class Coil3ImageRendererExtensionImplTest { @get:Rule public val composeTestRule: ComposeContentTestRule = createComposeRule() private val platformContext: PlatformContext = PlatformContext.INSTANCE - private val imageUrl = "https://example.com/image.png" + private val loadingImageUrl = "https://example.com/image.png" + private val failingImageUrl = "https://example.com/nonexistent.png" + private val loadingImageUrl2 = "https://example.com/image2.png" @Test public fun `image renders with correct size on success`() { @@ -42,15 +50,14 @@ public class Coil3ImageRendererExtensionImplTest { val fakeImageHeight = 100 val fakeImage = ColorImage(Color.Red.toArgb(), width = fakeImageWidth, height = fakeImageHeight) - val engine = FakeImageLoaderEngine.Builder().intercept({ it == imageUrl }, fakeImage).build() + val engine = FakeImageLoaderEngine.Builder().intercept({ it == loadingImageUrl }, fakeImage).build() val imageLoaderWithFakeEngine = ImageLoader.Builder(platformContext).components { add(engine) }.build() - val extension = Coil3ImageRendererExtensionImpl(imageLoaderWithFakeEngine) val imageMarkdown = - InlineMarkdown.Image(source = imageUrl, alt = "Alt text", title = "Image loaded successfully") + InlineMarkdown.Image(source = loadingImageUrl, alt = "Alt text", title = "Image loaded successfully") - setContent(extension, imageMarkdown) + setContent(imageLoaderWithFakeEngine, imageMarkdown) composeTestRule .onNodeWithContentDescription("Image loaded successfully") @@ -59,78 +66,394 @@ public class Coil3ImageRendererExtensionImplTest { .assertHeightIsEqualTo(fakeImageHeight.dp) } + @OptIn(ExperimentalCoroutinesApi::class) @Test - public fun `placeholder remains small on error`() { + public fun `loading indicator shows during loading state then image appears on success`() { + val testDispatcher = StandardTestDispatcher() + + val fakeImageWidth = 50 + val fakeImageHeight = 50 + val fakeImage = ColorImage(Color.Red.toArgb(), width = fakeImageWidth, height = fakeImageHeight) val engine = FakeImageLoaderEngine.Builder() .intercept( - predicate = { it == imageUrl }, - interceptor = { ErrorResult(null, it.request, IllegalStateException("Failed to load")) }, + predicate = { it == loadingImageUrl }, + interceptor = { + delay(500) // simulating network delay + + SuccessResult(fakeImage, it.request, DataSource.MEMORY) + }, ) .build() - val imageLoaderWithFakeEngine = ImageLoader.Builder(platformContext).components { add(engine) }.build() + val imageLoader = + ImageLoader.Builder(platformContext).components { add(engine) }.coroutineContext(testDispatcher).build() + + val imageMarkdown = InlineMarkdown.Image(source = loadingImageUrl, alt = "Alt text", title = "A loading image") - val extension = Coil3ImageRendererExtensionImpl(imageLoaderWithFakeEngine) - val imageMarkdown = InlineMarkdown.Image(source = imageUrl, alt = "Alt text", title = "Failed to load image") + setContent(imageLoader, imageMarkdown) + + // fast forwarding coil's internal dispatcher + testDispatcher.scheduler.advanceTimeBy(250) + testDispatcher.scheduler.runCurrent() + + // The actual image with its content description doesn't exist yet + composeTestRule.onNodeWithContentDescription("A loading image").assertDoesNotExist() + + // Verify loading indicator is shown + composeTestRule + .onNodeWithContentDescription(Coil3ImageRendererExtensionImpl.LOADING_INDICATOR_DESCRIPTION) + .assertExists() - setContent(extension, imageMarkdown) + // fast forwarding coil's internal dispatcher + testDispatcher.scheduler.advanceTimeBy(251) + testDispatcher.scheduler.runCurrent() - composeTestRule.onNodeWithContentDescription("Failed to load image").assertDoesNotExist() + // After loading completes, image should appear and loading indicator should disappear + composeTestRule.onNodeWithContentDescription("A loading image").assertExists() + composeTestRule + .onNodeWithContentDescription(Coil3ImageRendererExtensionImpl.LOADING_INDICATOR_DESCRIPTION) + .assertDoesNotExist() } @OptIn(ExperimentalCoroutinesApi::class) @Test - public fun `placeholder remains small during loading state then changes to image size`() { + public fun `loading indicator shows during loading state then disappears on failure`() { val testDispatcher = StandardTestDispatcher() - val fakeImageWidth = 150 - val fakeImageHeight = 150 - val fakeImage = ColorImage(Color.Red.toArgb(), width = fakeImageWidth, height = fakeImageHeight) val engine = FakeImageLoaderEngine.Builder() .intercept( - predicate = { it == imageUrl }, + predicate = { it == failingImageUrl }, interceptor = { delay(500) // simulating network delay - SuccessResult(fakeImage, it.request, DataSource.MEMORY) + ErrorResult(null, it.request, IllegalStateException("Not found")) }, ) .build() val imageLoader = ImageLoader.Builder(platformContext).components { add(engine) }.coroutineContext(testDispatcher).build() + val altText = "Missing image" + val failingImageMarkdown = InlineMarkdown.Image(source = failingImageUrl, alt = altText, title = null) + + setContent(imageLoader, failingImageMarkdown) - val extension = Coil3ImageRendererExtensionImpl(imageLoader) - val imageMarkdown = InlineMarkdown.Image(source = imageUrl, alt = "Alt text", title = "A loading image") + // fast forwarding coil's internal dispatcher + testDispatcher.scheduler.advanceTimeBy(250) + testDispatcher.scheduler.runCurrent() - setContent(extension, imageMarkdown) + // The actual image with its content description doesn't exist yet + composeTestRule.onNodeWithContentDescription(altText).assertDoesNotExist() + // Verify loading indicator is shown composeTestRule - .onNodeWithContentDescription("A loading image") + .onNodeWithContentDescription(Coil3ImageRendererExtensionImpl.LOADING_INDICATOR_DESCRIPTION) .assertExists() - .assertWidthIsEqualTo(0.dp) - .assertHeightIsEqualTo(1.dp) // fast forwarding coil's internal dispatcher - testDispatcher.scheduler.advanceTimeBy(501) + testDispatcher.scheduler.advanceTimeBy(251) testDispatcher.scheduler.runCurrent() composeTestRule - .onNodeWithContentDescription("A loading image") - .assertWidthIsAtLeast(fakeImageWidth.dp) - .assertHeightIsAtLeast(fakeImageHeight.dp) + .onNodeWithContentDescription(Coil3ImageRendererExtensionImpl.LOADING_INDICATOR_DESCRIPTION) + .assertDoesNotExist() + + // After loading completes, image should appear and loading indicator should disappear + assertFailedLinkExists(altText) + } + + @Test + public fun `failed image is rendered as link with alt text`() { + val engine = + FakeImageLoaderEngine.Builder() + .intercept( + predicate = { it == failingImageUrl }, + interceptor = { ErrorResult(null, it.request, IllegalStateException("Not found")) }, + ) + .build() + + val imageLoader = ImageLoader.Builder(platformContext).components { add(engine) }.build() + val altText = "Missing image" + val failingImageMarkdown = InlineMarkdown.Image(source = failingImageUrl, alt = altText, title = null) + + setContent(imageLoader, failingImageMarkdown) + + // Image should not be rendered + composeTestRule + .onNodeWithContentDescription(Coil3ImageRendererExtensionImpl.LOADING_INDICATOR_DESCRIPTION) + .assertDoesNotExist() + + // Failed image should be rendered as a link with alt text + assertFailedLinkExists(altText) + } + + @Test + public fun `single image wider than container is downscaled to fit available width`() { + val fakeImageWidth = 300 + val fakeImageHeight = 200 + val containerWidth = 100 + val fakeImage = ColorImage(Color.Blue.toArgb(), width = fakeImageWidth, height = fakeImageHeight) + + val engine = FakeImageLoaderEngine.Builder().intercept({ it == loadingImageUrl }, fakeImage).build() + val imageLoader = ImageLoader.Builder(platformContext).components { add(engine) }.build() + + val paragraph = + Paragraph(InlineMarkdown.Image(source = loadingImageUrl, alt = "Alt text", title = "HUGE image")) + + setConstrainedContentWithParagraph(imageLoader, paragraph, containerWidth) + + // The image should be scaled down to fit the container width + // Scale factor = 100 / 300 = 0.333... + // Expected height = 200 * 0.333... = 66.67 + val expectedHeight = (fakeImageHeight * containerWidth / fakeImageWidth) + composeTestRule + .onNodeWithContentDescription("HUGE image") + .assertExists() + .assertWidthIsEqualTo(containerWidth.dp) + .assertHeightIsEqualTo(expectedHeight.dp) + } + + @Test + public fun `single image narrower than container is not upscaled`() { + val fakeImageWidth = 50 + val fakeImageHeight = 30 + val containerWidth = 200 + val fakeImage = ColorImage(Color.Green.toArgb(), width = fakeImageWidth, height = fakeImageHeight) + + val engine = FakeImageLoaderEngine.Builder().intercept({ it == loadingImageUrl }, fakeImage).build() + val imageLoader = ImageLoader.Builder(platformContext).components { add(engine) }.build() + + val paragraph = + Paragraph(InlineMarkdown.Image(source = loadingImageUrl, alt = "Alt text", title = "smol image")) + + setConstrainedContentWithParagraph(imageLoader, paragraph, containerWidth) + + // The image should remain at its original size, not upscaled + composeTestRule + .onNodeWithContentDescription("smol image") + .assertExists() + .assertWidthIsEqualTo(fakeImageWidth.dp) + .assertHeightIsEqualTo(fakeImageHeight.dp) + } + + @Test + public fun `text with leading image is downscaled when container is narrow`() { + val fakeImageWidth = 200 + val fakeImageHeight = 100 + val containerWidth = 100 + val fakeImage = ColorImage(Color.Red.toArgb(), width = fakeImageWidth, height = fakeImageHeight) + + val engine = FakeImageLoaderEngine.Builder().intercept({ it == loadingImageUrl }, fakeImage).build() + val imageLoader = ImageLoader.Builder(platformContext).components { add(engine) }.build() + + val paragraph = + Paragraph( + InlineMarkdown.Image(source = loadingImageUrl, alt = "Alt text", title = "HUGE image"), + InlineMarkdown.Text(" and then some!"), + ) + + setConstrainedContentWithParagraph(imageLoader, paragraph, containerWidth) + + val expectedHeight = (fakeImageHeight * containerWidth / fakeImageWidth) + composeTestRule + .onNodeWithContentDescription("HUGE image") + .assertExists() + .assertWidthIsEqualTo(containerWidth.dp) + .assertHeightIsEqualTo(expectedHeight.dp) + } + + @Test + public fun `text with trailing image is downscaled when container is narrow`() { + val fakeImageWidth = 200 + val fakeImageHeight = 100 + val containerWidth = 100 + val fakeImage = ColorImage(Color.Blue.toArgb(), width = fakeImageWidth, height = fakeImageHeight) + + val engine = FakeImageLoaderEngine.Builder().intercept({ it == loadingImageUrl }, fakeImage).build() + val imageLoader = ImageLoader.Builder(platformContext).components { add(engine) }.build() + + val paragraph = + Paragraph( + InlineMarkdown.Text("Behold the "), + InlineMarkdown.Image(source = loadingImageUrl, alt = "Alt text", title = "HUGE image"), + ) + + setConstrainedContentWithParagraph(imageLoader, paragraph, containerWidth) + + val expectedHeight = (fakeImageHeight * containerWidth / fakeImageWidth) + composeTestRule + .onNodeWithContentDescription("HUGE image") + .assertExists() + .assertWidthIsEqualTo(containerWidth.dp) + .assertHeightIsEqualTo(expectedHeight.dp) + } + + @Test + public fun `image between text is downscaled when container is narrow`() { + val fakeImageWidth = 200 + val fakeImageHeight = 100 + val containerWidth = 100 + val fakeImage = ColorImage(Color.Green.toArgb(), width = fakeImageWidth, height = fakeImageHeight) + + val engine = FakeImageLoaderEngine.Builder().intercept({ it == loadingImageUrl }, fakeImage).build() + val imageLoader = ImageLoader.Builder(platformContext).components { add(engine) }.build() + + val paragraph = + Paragraph( + InlineMarkdown.Text("Behold, the "), + InlineMarkdown.Image(source = loadingImageUrl, alt = "Alt text", title = "HUGE image"), + InlineMarkdown.Text("!!!"), + ) + + setConstrainedContentWithParagraph(imageLoader, paragraph, containerWidth) + + val expectedHeight = (fakeImageHeight * containerWidth / fakeImageWidth) + composeTestRule + .onNodeWithContentDescription("HUGE image") + .assertExists() + .assertWidthIsEqualTo(containerWidth.dp) + .assertHeightIsEqualTo(expectedHeight.dp) + } + + @Test + public fun `two images in paragraph are both downscaled when container is narrow`() { + val fakeImage1Width = 200 + val fakeImage1Height = 100 + val fakeImage2Width = 300 + val fakeImage2Height = 150 + val containerWidth = 100 + + val fakeImage1 = ColorImage(Color.Red.toArgb(), width = fakeImage1Width, height = fakeImage1Height) + val fakeImage2 = ColorImage(Color.Blue.toArgb(), width = fakeImage2Width, height = fakeImage2Height) + + val engine = + FakeImageLoaderEngine.Builder() + .intercept({ it == loadingImageUrl }, fakeImage1) + .intercept({ it == loadingImageUrl2 }, fakeImage2) + .build() + val imageLoader = ImageLoader.Builder(platformContext).components { add(engine) }.build() + + val paragraph = + Paragraph( + InlineMarkdown.Image(source = loadingImageUrl, alt = "Alt text 1", title = "First image"), + InlineMarkdown.Text(" text between "), + InlineMarkdown.Image(source = loadingImageUrl2, alt = "Alt text 2", title = "Second image"), + ) + + setConstrainedContentWithParagraph(imageLoader, paragraph, containerWidth) + + val expectedHeight1 = (fakeImage1Height * containerWidth / fakeImage1Width) + val expectedHeight2 = (fakeImage2Height * containerWidth / fakeImage2Width) + + composeTestRule + .onNodeWithContentDescription("First image") + .assertExists() + .assertWidthIsEqualTo(containerWidth.dp) + .assertHeightIsEqualTo(expectedHeight1.dp) + + composeTestRule + .onNodeWithContentDescription("Second image") + .assertExists() + .assertWidthIsEqualTo(containerWidth.dp) + .assertHeightIsEqualTo(expectedHeight2.dp) + } + + @Test + public fun `failed image without alt text shows URL as link text`() { + val engine = + FakeImageLoaderEngine.Builder() + .intercept( + predicate = { it == failingImageUrl }, + interceptor = { ErrorResult(null, it.request, IllegalStateException("Not found")) }, + ) + .build() + + val imageLoader = ImageLoader.Builder(platformContext).components { add(engine) }.build() + val failingImageMarkdown = InlineMarkdown.Image(source = failingImageUrl, alt = "", title = null) + + setContent(imageLoader, failingImageMarkdown) + + // Failed image without alt text should show URL as link text + assertFailedLinkExists(failingImageUrl) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + public fun `previously failed image shows link instead of loading indicator on retry`() { + val testDispatcher = StandardTestDispatcher() + + val engine = + FakeImageLoaderEngine.Builder() + .intercept( + predicate = { it == failingImageUrl }, + interceptor = { + delay(100) + ErrorResult(null, it.request, IllegalStateException("Failed")) + }, + ) + .build() + + val imageLoader = + ImageLoader.Builder(platformContext).components { add(engine) }.coroutineContext(testDispatcher).build() + val altText = "Failing image" + val failingImageMarkdown = InlineMarkdown.Image(source = failingImageUrl, alt = altText, title = null) + + setContent(imageLoader, failingImageMarkdown) + + // Initially loading indicator should be shown + composeTestRule + .onNodeWithContentDescription(Coil3ImageRendererExtensionImpl.LOADING_INDICATOR_DESCRIPTION) + .assertExists() + + // Complete first request - should fail and show link + testDispatcher.scheduler.advanceTimeBy(101) + testDispatcher.scheduler.runCurrent() + + // Wait for link to appear, then verify loading indicator is gone + assertFailedLinkExists(altText) + composeTestRule + .onNodeWithContentDescription(Coil3ImageRendererExtensionImpl.LOADING_INDICATOR_DESCRIPTION) + .assertDoesNotExist() + } + + private fun assertFailedLinkExists(altText: String) { + composeTestRule.waitUntil(timeoutMillis = 5000) { + composeTestRule.onAllNodes(hasText(altText, substring = true)).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun setContent(imageLoader: ImageLoader, vararg images: InlineMarkdown.Image) { + val paragraph = Paragraph(*images) + setConstrainedContentWithParagraph(imageLoader, paragraph, containerWidthDp = Int.MAX_VALUE) } - private fun setContent(extension: Coil3ImageRendererExtensionImpl, image: InlineMarkdown.Image) { + private fun setConstrainedContentWithParagraph( + imageLoader: ImageLoader, + paragraph: Paragraph, + containerWidthDp: Int, + ) { composeTestRule.setContent { - val inlineContent = buildMap { extension.renderImageContent(image)?.let { put("inlineTextContent", it) } } - val annotatedString = buildAnnotatedString { - append("Rendered inline text image: ") - appendInlineContent("inlineTextContent", "[rendered image]") + JewelTheme(createThemeDefinition()) { + val imageExtension = Coil3ImageRendererExtension(imageLoader) + val markdownStyling = createMarkdownStyling() + val blockRenderer = + DefaultMarkdownBlockRenderer( + rootStyling = markdownStyling, + rendererExtensions = listOf(imageExtension), + inlineRenderer = DefaultInlineMarkdownRenderer(listOf(imageExtension)), + ) + Box(modifier = Modifier.width(containerWidthDp.dp)) { + blockRenderer.RenderParagraph( + block = paragraph, + styling = markdownStyling.paragraph, + enabled = true, + onUrlClick = {}, + modifier = Modifier, + ) + } } - BasicText(text = annotatedString, inlineContent = inlineContent) } } }