package cc.unitmesh.devins.idea.renderer.markdown import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.renderer.markdown.MarkdownInlineRenderer.appendMarkdownChildren import cc.unitmesh.devins.idea.theme.IdeaAutoDevColors import cc.unitmesh.markdown.MarkdownTextParser import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.ast.getTextInNode import org.intellij.markdown.flavours.gfm.GFMElementTypes import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.intellij.markdown.flavours.gfm.GFMTokenTypes import org.intellij.markdown.parser.MarkdownParser import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Text import java.awt.Desktop import java.net.URI /** * Full-featured Jewel-themed Markdown renderer using JetBrains' intellij-markdown parser. * Supports: * - Headers (H1-H6) * - Paragraphs with inline formatting (bold, italic, strikethrough, code) * - Code blocks and fenced code with language detection * - Block quotes * - Ordered and unordered lists (with nesting) * - Links (inline and auto-detected) * - Tables (GFM) * - Horizontal rules * - Checkboxes (GFM task lists) */ @Composable fun JewelMarkdownRenderer( content: String, modifier: Modifier = Modifier.fillMaxWidth(), onLinkClick: ((String) -> Unit)? = null ) { val flavour = remember { GFMFlavourDescriptor() } val parser = remember { MarkdownParser(flavour) } val tree = remember(content) { parser.buildMarkdownTreeFromString(content) } Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { RenderNode(node = tree, content = content, onLinkClick = onLinkClick) } } @Composable private fun RenderNode( node: ASTNode, content: String, listDepth: Int = 0, onLinkClick: ((String) -> Unit)? = null ) { when (node.type) { MarkdownElementTypes.MARKDOWN_FILE -> { node.children.forEach { child -> RenderNode(node = child, content = content, listDepth = listDepth, onLinkClick = onLinkClick) } } MarkdownElementTypes.PARAGRAPH -> { MarkdownParagraph(node = node, content = content, onLinkClick = onLinkClick) } MarkdownElementTypes.ATX_1 -> { MarkdownHeader(node = node, content = content, level = 1) } MarkdownElementTypes.ATX_2 -> { MarkdownHeader(node = node, content = content, level = 2) } MarkdownElementTypes.ATX_3 -> { MarkdownHeader(node = node, content = content, level = 3) } MarkdownElementTypes.ATX_4 -> { MarkdownHeader(node = node, content = content, level = 4) } MarkdownElementTypes.ATX_5 -> { MarkdownHeader(node = node, content = content, level = 5) } MarkdownElementTypes.ATX_6 -> { MarkdownHeader(node = node, content = content, level = 6) } MarkdownElementTypes.SETEXT_1 -> { MarkdownHeader(node = node, content = content, level = 1) } MarkdownElementTypes.SETEXT_2 -> { MarkdownHeader(node = node, content = content, level = 2) } MarkdownElementTypes.CODE_FENCE -> { MarkdownCodeFence(node = node, content = content) } MarkdownElementTypes.CODE_BLOCK -> { MarkdownCodeBlock(node = node, content = content) } MarkdownElementTypes.BLOCK_QUOTE -> { MarkdownBlockQuote(node = node, content = content, onLinkClick = onLinkClick) } MarkdownElementTypes.UNORDERED_LIST -> { MarkdownUnorderedList(node = node, content = content, depth = listDepth, onLinkClick = onLinkClick) } MarkdownElementTypes.ORDERED_LIST -> { MarkdownOrderedList(node = node, content = content, depth = listDepth, onLinkClick = onLinkClick) } MarkdownTokenTypes.HORIZONTAL_RULE -> { MarkdownHorizontalRule() } GFMElementTypes.TABLE -> { MarkdownTable(node = node, content = content) } GFMElementTypes.STRIKETHROUGH -> { // Handled inline } else -> { // For other node types, try to render children if (node.children.isNotEmpty()) { node.children.forEach { child -> RenderNode(node = child, content = content, listDepth = listDepth, onLinkClick = onLinkClick) } } } } } @Composable private fun MarkdownHeader(node: ASTNode, content: String, level: Int) { val text = MarkdownTextParser.extractHeaderText(node, content) val (fontSize, fontWeight) = when (level) { 1 -> 24.sp to FontWeight.Bold 2 -> 20.sp to FontWeight.Bold 3 -> 18.sp to FontWeight.SemiBold 4 -> 16.sp to FontWeight.SemiBold 5 -> 14.sp to FontWeight.Medium else -> 13.sp to FontWeight.Medium } val verticalPadding = when (level) { 1 -> 8.dp 2 -> 6.dp else -> 4.dp } Text( text = text, style = JewelTheme.defaultTextStyle.copy( fontSize = fontSize, fontWeight = fontWeight ), modifier = Modifier.padding(vertical = verticalPadding) ) } @Composable private fun MarkdownParagraph( node: ASTNode, content: String, onLinkClick: ((String) -> Unit)? = null ) { // Capture the color in composable context val codeBackground = JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f) val annotatedString = buildAnnotatedString { appendMarkdownChildren(node, content, codeBackground) } if (annotatedString.getStringAnnotations("URL", 0, annotatedString.length).isNotEmpty()) { @Suppress("DEPRECATION") ClickableText( text = annotatedString, style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), modifier = Modifier.padding(vertical = 2.dp), onClick = { offset -> annotatedString.getStringAnnotations("URL", offset, offset) .firstOrNull()?.let { annotation -> if (onLinkClick != null) { onLinkClick(annotation.item) } else { try { Desktop.getDesktop().browse(URI(annotation.item)) } catch (e: Exception) { // Ignore } } } } ) } else { Text( text = annotatedString, style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), modifier = Modifier.padding(vertical = 2.dp) ) } } // ============ Code Block Components ============ @Composable private fun MarkdownCodeFence(node: ASTNode, content: String) { val language = MarkdownTextParser.extractCodeFenceLanguage(node, content) val codeText = MarkdownTextParser.extractCodeFenceContent(node, content) Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) .background( JewelTheme.globalColors.panelBackground.copy(alpha = 0.6f), RoundedCornerShape(6.dp) ) .border( 1.dp, JewelTheme.globalColors.borders.normal, RoundedCornerShape(6.dp) ) ) { // Language header if present if (!language.isNullOrBlank()) { Row( modifier = Modifier .fillMaxWidth() .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f)) .padding(horizontal = 12.dp, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( text = language, style = JewelTheme.defaultTextStyle.copy( fontSize = 11.sp, fontWeight = FontWeight.Medium, color = JewelTheme.globalColors.text.info ) ) } Box( modifier = Modifier .fillMaxWidth() .height(1.dp) .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.5f)) ) } Box( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) .padding(12.dp) ) { Text( text = codeText, style = JewelTheme.defaultTextStyle.copy( fontFamily = FontFamily.Monospace, fontSize = 12.sp, lineHeight = 18.sp ) ) } } } @Composable private fun MarkdownCodeBlock(node: ASTNode, content: String) { val codeText = node.getTextInNode(content).toString() Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) .background( JewelTheme.globalColors.panelBackground.copy(alpha = 0.6f), RoundedCornerShape(6.dp) ) .border( 1.dp, JewelTheme.globalColors.borders.normal, RoundedCornerShape(6.dp) ) .padding(12.dp) .horizontalScroll(rememberScrollState()) ) { Text( text = codeText, style = JewelTheme.defaultTextStyle.copy( fontFamily = FontFamily.Monospace, fontSize = 12.sp, lineHeight = 18.sp ) ) } } @Composable private fun MarkdownBlockQuote( node: ASTNode, content: String, onLinkClick: ((String) -> Unit)? = null ) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) .background( JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), RoundedCornerShape(4.dp) ) .padding(8.dp) ) { Box( modifier = Modifier .width(3.dp) .fillMaxHeight() .background(IdeaAutoDevColors.Blue.c400, RoundedCornerShape(2.dp)) ) Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { node.children.forEach { child -> if (child.type != MarkdownTokenTypes.BLOCK_QUOTE) { RenderNode(node = child, content = content, onLinkClick = onLinkClick) } } } } } @Composable private fun MarkdownUnorderedList( node: ASTNode, content: String, depth: Int = 0, onLinkClick: ((String) -> Unit)? = null ) { val bulletChar = when (depth % 3) { 0 -> "\u2022" // • 1 -> "\u25E6" // ◦ else -> "\u25AA" // ▪ } val indent = (depth * 16).dp Column(modifier = Modifier.padding(start = indent, top = 2.dp, bottom = 2.dp)) { node.children.forEach { child -> if (child.type == MarkdownElementTypes.LIST_ITEM) { MarkdownListItem( node = child, content = content, bullet = "$bulletChar ", depth = depth, onLinkClick = onLinkClick ) } } } } @Composable private fun MarkdownOrderedList( node: ASTNode, content: String, depth: Int = 0, onLinkClick: ((String) -> Unit)? = null ) { val indent = (depth * 16).dp var index = 1 Column(modifier = Modifier.padding(start = indent, top = 2.dp, bottom = 2.dp)) { node.children.forEach { child -> if (child.type == MarkdownElementTypes.LIST_ITEM) { MarkdownListItem( node = child, content = content, bullet = "${index++}. ", depth = depth, onLinkClick = onLinkClick ) } } } } @Composable private fun MarkdownListItem( node: ASTNode, content: String, bullet: String, depth: Int, onLinkClick: ((String) -> Unit)? = null ) { // Check for GFM checkbox val hasCheckbox = node.children.any { it.type == GFMTokenTypes.CHECK_BOX || it.children.any { c -> c.type == GFMTokenTypes.CHECK_BOX } } Row( modifier = Modifier.padding(vertical = 1.dp), verticalAlignment = Alignment.Top ) { if (!hasCheckbox) { Text( text = bullet, style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), modifier = Modifier.width(20.dp) ) } Column(modifier = Modifier.weight(1f)) { node.children.forEach { child -> when (child.type) { MarkdownElementTypes.PARAGRAPH -> { MarkdownParagraph(node = child, content = content, onLinkClick = onLinkClick) } MarkdownElementTypes.UNORDERED_LIST -> { MarkdownUnorderedList(node = child, content = content, depth = depth + 1, onLinkClick = onLinkClick) } MarkdownElementTypes.ORDERED_LIST -> { MarkdownOrderedList(node = child, content = content, depth = depth + 1, onLinkClick = onLinkClick) } GFMTokenTypes.CHECK_BOX -> { val isChecked = child.getTextInNode(content).toString().contains("x", ignoreCase = true) Row(verticalAlignment = Alignment.CenterVertically) { Text( text = if (isChecked) "\u2611 " else "\u2610 ", // ☑ or ☐ style = JewelTheme.defaultTextStyle.copy(fontSize = 14.sp) ) } } else -> { RenderNode(node = child, content = content, listDepth = depth + 1, onLinkClick = onLinkClick) } } } } } } @Composable private fun MarkdownHorizontalRule() { Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp) .height(1.dp) .background(JewelTheme.globalColors.borders.normal) ) }