From 0ba7b74c69f8950a5133a7072a48951f64de6ab4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 13:56:30 -0400 Subject: [PATCH 1/4] feat(jetbrains): add MdView markdown rendering component Add a standalone markdown rendering component for the JetBrains plugin using commonmark-java. This will be used in the chat UI to render assistant messages as formatted markdown instead of plain text. - Abstract MdView class with factory method and private HTML implementation - Configurable styling (font, colors, opacity/transparency) - Link click listeners via inner LinkListener interface - Streaming-friendly set/append/clear API - 44 unit tests covering rendering, styling, links, and transparency --- .../kilo-jetbrains/frontend/build.gradle.kts | 5 + .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 299 +++++++++++++++ .../ai/kilocode/client/ui/md/MdViewTest.kt | 354 ++++++++++++++++++ .../kilo-jetbrains/gradle/libs.versions.toml | 5 + 4 files changed, 663 insertions(+) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index d2408e8549e..735d87eff95 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -19,6 +19,11 @@ dependencies { implementation(project(":shared")) + implementation(libs.commonmark) + implementation(libs.commonmark.autolink) + implementation(libs.commonmark.tables) + implementation(libs.commonmark.strikethrough) + testImplementation(kotlin("test")) testImplementation("junit:junit:4.13.2") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.11.4") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt new file mode 100644 index 00000000000..715662a6fd2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -0,0 +1,299 @@ +package ai.kilocode.client.ui.md + +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import org.commonmark.ext.autolink.AutolinkExtension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.awt.Color +import java.awt.Font +import java.awt.Point +import javax.swing.JComponent +import javax.swing.JEditorPane +import javax.swing.event.HyperlinkEvent +import javax.swing.text.html.HTMLEditorKit + +/** + * Markdown rendering component that hides the concrete rendering strategy + * (HTML, StyledDocument, etc.) behind a uniform API. + * + * Callers interact only with markdown source text and semantic events — + * never with underlying widget types. + * + * Create instances via [MdView.html]. + * All public methods must be called on the EDT. + */ +abstract class MdView private constructor() { + + /** The Swing component to embed in layouts. */ + abstract val component: JComponent + + /** Replace the entire content with new markdown source. */ + abstract fun set(text: String) + + /** Append a streaming delta to the current content. */ + abstract fun append(delta: String) + + /** Reset to empty. */ + abstract fun clear() + + abstract fun addLinkListener(listener: LinkListener) + + abstract fun removeLinkListener(listener: LinkListener) + + // -- styling ---------------------------------------------------------- + + /** Body text font family and size. */ + abstract var font: Font + + /** Body text foreground color. */ + abstract var foreground: Color + + /** Overall background color. */ + abstract var background: Color + + /** Link foreground color. */ + abstract var linkColor: Color + + /** Inline code background color. */ + abstract var codeBg: Color + + /** Code block (pre) background color. */ + abstract var preBg: Color + + /** Code block (pre) foreground color. */ + abstract var preFg: Color + + /** Code / pre font family. */ + abstract var codeFont: String + + /** Blockquote left-border and text color. */ + abstract var quoteBorder: Color + abstract var quoteFg: Color + + /** Table / th / td border color. */ + abstract var tableBorder: Color + + /** + * Whether the component paints its own background. + * When `false`, the body background CSS is omitted and the underlying + * Swing component is set to non-opaque so the parent's background + * shows through. + */ + abstract var opaque: Boolean + + /** + * Semantic event fired when a user clicks a link inside rendered markdown. + */ + data class LinkEvent( + val href: String, + val point: Point? = null, + ) + + /** + * Callback for link activations inside a [MdView]. + */ + fun interface LinkListener { + fun onLink(event: LinkEvent) + } + + // -- test helpers (package-visible) ------------------------------------ + + /** Returns the current raw markdown source. */ + internal abstract fun markdown(): String + + /** Returns the rendered HTML body (not Swing-rewritten). */ + internal abstract fun html(): String + + /** Returns the full wrapped HTML including CSS. */ + internal abstract fun styledHtml(): String + + /** Fire a synthetic link activation for testing. */ + internal abstract fun simulateLink(href: String) + + companion object { + /** Create a [MdView] backed by commonmark-java → HTML → JEditorPane. */ + fun html(): MdView = HtmlImpl() + } + + // -- private HTML implementation --------------------------------------- + + private class HtmlImpl : MdView() { + + private val listeners = mutableListOf() + private val source = StringBuilder() + private var rendered = "" + private var wrapped = "" + private var dirty = false + + private val extensions = listOf( + AutolinkExtension.create(), + TablesExtension.create(), + StrikethroughExtension.create(), + ) + + private val parser: Parser = Parser.builder() + .extensions(extensions) + .build() + + private val renderer: HtmlRenderer = HtmlRenderer.builder() + .extensions(extensions) + .build() + + private val pane: JEditorPane = JEditorPane().apply { + isEditable = false + contentType = "text/html" + editorKit = HTMLEditorKit() + putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true) + border = JBUI.Borders.empty() + + addHyperlinkListener { e -> + if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { + val href = e.description ?: return@addHyperlinkListener + val pt = e.inputEvent?.let { Point(it.component.x, it.component.y) } + val event = LinkEvent(href, pt) + for (l in listeners) { + l.onLink(event) + } + } + } + } + + // -- style fields with defaults ----------------------------------- + + override var font: Font = JBUI.Fonts.label() + set(value) { field = value; markDirty() } + + override var foreground: Color = UIUtil.getLabelForeground() + set(value) { field = value; pane.foreground = value; markDirty() } + + override var background: Color = UIUtil.getPanelBackground() + set(value) { field = value; pane.background = value; markDirty() } + + override var linkColor: Color = Color(0x58, 0x9D, 0xF6) + set(value) { field = value; markDirty() } + + override var codeBg: Color = Color(0x3C, 0x3F, 0x41) + set(value) { field = value; markDirty() } + + override var preBg: Color = Color(0x2B, 0x2B, 0x2B) + set(value) { field = value; markDirty() } + + override var preFg: Color = Color(0xA9, 0xB7, 0xC6) + set(value) { field = value; markDirty() } + + override var codeFont: String = "JetBrains Mono" + set(value) { field = value; markDirty() } + + override var quoteBorder: Color = Color(0x55, 0x55, 0x55) + set(value) { field = value; markDirty() } + + override var quoteFg: Color = Color(0x99, 0x99, 0x99) + set(value) { field = value; markDirty() } + + override var tableBorder: Color = Color(0x55, 0x55, 0x55) + set(value) { field = value; markDirty() } + + override var opaque: Boolean = true + set(value) { + field = value + pane.isOpaque = value + markDirty() + } + + init { + pane.font = font + pane.foreground = foreground + pane.background = background + } + + override val component: JComponent get() = pane + + override fun set(text: String) { + source.clear() + source.append(text) + render() + } + + override fun append(delta: String) { + source.append(delta) + render() + } + + override fun clear() { + source.clear() + rendered = "" + wrapped = "" + pane.text = "" + } + + override fun addLinkListener(listener: LinkListener) { + listeners.add(listener) + } + + override fun removeLinkListener(listener: LinkListener) { + listeners.remove(listener) + } + + override fun markdown(): String = source.toString() + + override fun html(): String = rendered + + override fun styledHtml(): String = wrapped + + override fun simulateLink(href: String) { + val event = LinkEvent(href) + for (l in listeners) { + l.onLink(event) + } + } + + private fun markDirty() { + dirty = true + if (source.isNotEmpty()) render() + } + + private fun render() { + dirty = false + val md = source.toString() + val body = renderer.render(parser.parse(md)) + rendered = body + wrapped = wrap(body) + pane.text = wrapped + pane.caretPosition = 0 + } + + private fun wrap(body: String): String { + val fg = hex(foreground) + val size = font.size + val family = font.family + val codeSize = (size - 1).coerceAtLeast(1) + val bgCss = if (opaque) "background: ${hex(background)}; " else "" + return """ + + + $body + + """.trimIndent() + } + + companion object { + private fun hex(c: Color): String = + String.format("#%02x%02x%02x", c.red, c.green, c.blue) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt new file mode 100644 index 00000000000..675b04168af --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -0,0 +1,354 @@ +package ai.kilocode.client.ui.md + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Font + +/** + * Tests for [MdView] created via [MdView.html] factory. + * + * Uses [BasePlatformTestCase] to get a real IntelliJ Application so that + * Swing/HTMLEditorKit initialisation works correctly. + */ +class MdViewTest : BasePlatformTestCase() { + + private lateinit var view: MdView + + override fun setUp() { + super.setUp() + view = MdView.html() + } + + // ---- set ---- + + fun `test set stores source`() { + view.set("hello **world**") + assertEquals("hello **world**", view.markdown()) + } + + fun `test set replaces previous content`() { + view.set("first") + view.set("second") + assertEquals("second", view.markdown()) + } + + fun `test set renders bold`() { + view.set("hello **world**") + val html = view.html() + assertTrue("Expected in rendered HTML", html.contains("")) + assertTrue("Expected 'world' inside strong", html.contains("world")) + } + + fun `test set renders italic`() { + view.set("hello *world*") + val html = view.html() + assertTrue("Expected in rendered HTML", html.contains("")) + } + + fun `test set renders inline code`() { + view.set("use `foo()` here") + val html = view.html() + assertTrue("Expected in rendered HTML", html.contains("")) + assertTrue("Expected foo() in code", html.contains("foo()")) + } + + fun `test set renders fenced code block`() { + view.set("```kotlin\nval x = 1\n```") + val html = view.html() + assertTrue("Expected
 in rendered HTML", html.contains("
"))
+        assertTrue("Expected  in rendered HTML", html.contains("

")) + } + + fun `test set renders unordered list`() { + view.set("- one\n- two\n- three") + val html = view.html() + assertTrue("Expected
    in rendered HTML", html.contains("
      ")) + assertTrue("Expected
    • in rendered HTML", html.contains("
    • ")) + } + + fun `test set renders ordered list`() { + view.set("1. one\n2. two\n3. three") + val html = view.html() + assertTrue("Expected
        in rendered HTML", html.contains("
          ")) + } + + fun `test set renders blockquote`() { + view.set("> quoted text") + val html = view.html() + assertTrue("Expected
          in rendered HTML", html.contains("
          ")) + } + + fun `test set renders strikethrough`() { + view.set("~~deleted~~") + val html = view.html() + assertTrue("Expected in rendered HTML", html.contains("")) + } + + fun `test set renders table`() { + view.set("| a | b |\n|---|---|\n| 1 | 2 |") + val html = view.html() + assertTrue("Expected in rendered HTML", html.contains("
          ")) + assertTrue("Expected
          in rendered HTML", html.contains("")) + assertTrue("Expected in rendered HTML", html.contains("")) + } + + fun `test set renders autolink`() { + view.set("Visit https://example.com for details") + val html = view.html() + assertTrue("Expected autolinked URL", html.contains(" after append", html.contains("")) + } + + fun `test append after set extends content`() { + view.set("first ") + view.append("second") + assertEquals("first second", view.markdown()) + } + + // ---- clear ---- + + fun `test clear resets source`() { + view.set("some content") + view.clear() + assertEquals("", view.markdown()) + } + + fun `test clear resets rendered html`() { + view.set("some content") + view.clear() + val html = view.html() + assertFalse("Expected no 'some content' after clear", html.contains("some content")) + } + + // ---- link listener ---- + + fun `test link listener receives event on activation`() { + val received = mutableListOf() + view.addLinkListener { received.add(it) } + view.set("[click](https://example.com)") + view.simulateLink("https://example.com") + assertEquals(1, received.size) + assertEquals("https://example.com", received[0].href) + } + + fun `test multiple link listeners all receive event`() { + val first = mutableListOf() + val second = mutableListOf() + view.addLinkListener { first.add(it) } + view.addLinkListener { second.add(it) } + view.simulateLink("https://a.com") + assertEquals(1, first.size) + assertEquals(1, second.size) + } + + fun `test removed listener does not receive events`() { + val received = mutableListOf() + val listener = MdView.LinkListener { received.add(it) } + view.addLinkListener(listener) + view.removeLinkListener(listener) + view.simulateLink("https://example.com") + assertTrue("Removed listener should not fire", received.isEmpty()) + } + + // ---- component ---- + + fun `test component is not null`() { + assertNotNull(view.component) + } + + // ---- complex markdown ---- + + fun `test complex markdown with mixed elements`() { + val md = """ + # Heading + + Some **bold** and *italic* text with `code`. + + - item one + - item two + + ``` + code block + ``` + + > blockquote + + [link](https://example.com) + """.trimIndent() + + view.set(md) + val html = view.html() + assertTrue(html.contains("

          ")) + assertTrue(html.contains("")) + assertTrue(html.contains("")) + assertTrue(html.contains("")) + assertTrue(html.contains("
            ")) + assertTrue(html.contains("
            "))
            +        assertTrue(html.contains("
            ")) + assertTrue(html.contains("")) + assertTrue(html.contains("Done.")) + } + + // ---- styling ---- + + fun `test foreground color appears in CSS`() { + view.foreground = Color(0xAA, 0xBB, 0xCC) + view.set("text") + assertTrue(view.styledHtml().contains("#aabbcc")) + } + + fun `test background color appears in CSS`() { + view.background = Color(0x11, 0x22, 0x33) + view.set("text") + assertTrue(view.styledHtml().contains("#112233")) + } + + fun `test link color appears in CSS`() { + view.linkColor = Color(0xFF, 0x00, 0x77) + view.set("[a](https://x.com)") + assertTrue(view.styledHtml().contains("#ff0077")) + } + + fun `test code background appears in CSS`() { + view.codeBg = Color(0x10, 0x20, 0x30) + view.set("`code`") + assertTrue(view.styledHtml().contains("#102030")) + } + + fun `test pre background and foreground appear in CSS`() { + view.preBg = Color(0x0A, 0x0B, 0x0C) + view.preFg = Color(0xD0, 0xE0, 0xF0) + view.set("```\ncode\n```") + val css = view.styledHtml() + assertTrue(css.contains("#0a0b0c")) + assertTrue(css.contains("#d0e0f0")) + } + + fun `test code font family appears in CSS`() { + view.codeFont = "Fira Code" + view.set("`x`") + assertTrue(view.styledHtml().contains("Fira Code")) + } + + fun `test blockquote colors appear in CSS`() { + view.quoteBorder = Color(0xAA, 0x00, 0x00) + view.quoteFg = Color(0x00, 0xBB, 0x00) + view.set("> quote") + val css = view.styledHtml() + assertTrue(css.contains("#aa0000")) + assertTrue(css.contains("#00bb00")) + } + + fun `test table border color appears in CSS`() { + view.tableBorder = Color(0x12, 0x34, 0x56) + view.set("| a |\n|---|\n| 1 |") + assertTrue(view.styledHtml().contains("#123456")) + } + + fun `test font family appears in CSS`() { + view.font = Font("Courier New", Font.PLAIN, 14) + view.set("text") + assertTrue(view.styledHtml().contains("Courier New")) + } + + fun `test font size appears in CSS`() { + view.font = Font("Arial", Font.PLAIN, 18) + view.set("text") + assertTrue(view.styledHtml().contains("18pt")) + } + + fun `test style change re-renders existing content`() { + view.set("hello") + view.foreground = Color(0xDE, 0xAD, 0x00) + assertTrue(view.styledHtml().contains("#dead00")) + assertTrue(view.styledHtml().contains("hello")) + } + + fun `test style change without content does not crash`() { + view.foreground = Color.RED + view.linkColor = Color.BLUE + view.codeFont = "Monospaced" + // no content set — should not throw + assertEquals("", view.markdown()) + } + + // ---- opaque / transparent ---- + + fun `test opaque true includes background in CSS`() { + view.opaque = true + view.background = Color(0x11, 0x22, 0x33) + view.set("text") + assertTrue(view.styledHtml().contains("background: #112233")) + } + + fun `test opaque false omits background from body CSS`() { + view.opaque = false + view.background = Color(0x11, 0x22, 0x33) + view.set("text") + assertFalse(view.styledHtml().contains("background: #112233")) + } + + fun `test opaque false does not affect pre background`() { + view.opaque = false + view.preBg = Color(0x0A, 0x0B, 0x0C) + view.set("```\ncode\n```") + assertTrue(view.styledHtml().contains("#0a0b0c")) + } + + fun `test opaque toggle re-renders`() { + view.background = Color(0xFE, 0xFE, 0xFE) + view.set("hello") + view.opaque = false + assertFalse(view.styledHtml().contains("background: #fefefe")) + view.opaque = true + assertTrue(view.styledHtml().contains("background: #fefefe")) + } + + fun `test component is not opaque when opaque is false`() { + view.opaque = false + assertFalse(view.component.isOpaque) + } + + fun `test component is opaque when opaque is true`() { + view.opaque = true + assertTrue(view.component.isOpaque) + } +} diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 9d81facbccf..fd3098941a7 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -8,8 +8,13 @@ kotlin-serialization = "1.11.0" okhttp = "4.12.0" openapi-generator = "7.21.0" detekt = "1.23.8" +commonmark = "0.28.0" [libraries] +commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } +commonmark-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } +commonmark-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" } +commonmark-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } From b5aa402355d9dac4a7567eb8cc558de0eb2b3d1a Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 14:18:08 -0400 Subject: [PATCH 2/4] fix(jetbrains): sanitize HTML output and fix link click point - Enable escapeHtml and sanitizeUrls on the commonmark HtmlRenderer to prevent raw HTML injection and dangerous URL schemes - Fix LinkEvent.point to use MouseEvent.point instead of the component's origin coordinates --- .../src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 715662a6fd2..63ef31a20cc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -140,6 +140,8 @@ abstract class MdView private constructor() { private val renderer: HtmlRenderer = HtmlRenderer.builder() .extensions(extensions) + .escapeHtml(true) + .sanitizeUrls(true) .build() private val pane: JEditorPane = JEditorPane().apply { @@ -152,7 +154,7 @@ abstract class MdView private constructor() { addHyperlinkListener { e -> if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { val href = e.description ?: return@addHyperlinkListener - val pt = e.inputEvent?.let { Point(it.component.x, it.component.y) } + val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point val event = LinkEvent(href, pt) for (l in listeners) { l.onLink(event) From a385e6a5fdbba2d651c97da3b3619fb09cadb866 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Apr 2026 14:20:42 -0400 Subject: [PATCH 3/4] chore(jetbrains): remove redundant comments from MdView --- .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 64 +------------------ 1 file changed, 3 insertions(+), 61 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 63ef31a20cc..5b3d6b20f35 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -17,109 +17,55 @@ import javax.swing.text.html.HTMLEditorKit /** * Markdown rendering component that hides the concrete rendering strategy - * (HTML, StyledDocument, etc.) behind a uniform API. + * behind a uniform API. Create instances via [MdView.html]. * - * Callers interact only with markdown source text and semantic events — - * never with underlying widget types. - * - * Create instances via [MdView.html]. * All public methods must be called on the EDT. */ abstract class MdView private constructor() { - /** The Swing component to embed in layouts. */ abstract val component: JComponent - - /** Replace the entire content with new markdown source. */ abstract fun set(text: String) - - /** Append a streaming delta to the current content. */ abstract fun append(delta: String) - - /** Reset to empty. */ abstract fun clear() - abstract fun addLinkListener(listener: LinkListener) - abstract fun removeLinkListener(listener: LinkListener) - // -- styling ---------------------------------------------------------- - - /** Body text font family and size. */ abstract var font: Font - - /** Body text foreground color. */ abstract var foreground: Color - - /** Overall background color. */ abstract var background: Color - - /** Link foreground color. */ abstract var linkColor: Color - - /** Inline code background color. */ abstract var codeBg: Color - - /** Code block (pre) background color. */ abstract var preBg: Color - - /** Code block (pre) foreground color. */ abstract var preFg: Color - - /** Code / pre font family. */ abstract var codeFont: String - - /** Blockquote left-border and text color. */ abstract var quoteBorder: Color abstract var quoteFg: Color - - /** Table / th / td border color. */ abstract var tableBorder: Color /** - * Whether the component paints its own background. - * When `false`, the body background CSS is omitted and the underlying - * Swing component is set to non-opaque so the parent's background - * shows through. + * When `false`, body background CSS is omitted and the Swing component + * is set to non-opaque so the parent's background shows through. */ abstract var opaque: Boolean - /** - * Semantic event fired when a user clicks a link inside rendered markdown. - */ data class LinkEvent( val href: String, val point: Point? = null, ) - /** - * Callback for link activations inside a [MdView]. - */ fun interface LinkListener { fun onLink(event: LinkEvent) } - // -- test helpers (package-visible) ------------------------------------ - - /** Returns the current raw markdown source. */ internal abstract fun markdown(): String - - /** Returns the rendered HTML body (not Swing-rewritten). */ internal abstract fun html(): String - - /** Returns the full wrapped HTML including CSS. */ internal abstract fun styledHtml(): String - - /** Fire a synthetic link activation for testing. */ internal abstract fun simulateLink(href: String) companion object { - /** Create a [MdView] backed by commonmark-java → HTML → JEditorPane. */ fun html(): MdView = HtmlImpl() } - // -- private HTML implementation --------------------------------------- - private class HtmlImpl : MdView() { private val listeners = mutableListOf() @@ -163,8 +109,6 @@ abstract class MdView private constructor() { } } - // -- style fields with defaults ----------------------------------- - override var font: Font = JBUI.Fonts.label() set(value) { field = value; markDirty() } @@ -240,9 +184,7 @@ abstract class MdView private constructor() { } override fun markdown(): String = source.toString() - override fun html(): String = rendered - override fun styledHtml(): String = wrapped override fun simulateLink(href: String) { From da7b600add225a4472cfba98e72887a4d83c58e1 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 19 Apr 2026 14:23:12 -0400 Subject: [PATCH 4/4] refactor(jetbrains): use JBHtmlPane with editor-aware styling in MdView - Replace plain JEditorPane + manual CSS with JBHtmlPane, the IntelliJ platform's flagship HTML component - Font and colours now default to the global editor colour scheme via JBHtmlPane's built-in EditorCssFontResolver and colorSchemeProvider - Code font defaults to _EditorFontNoLigatures_ placeholder resolved by EditorCssFontResolver at render time - All style properties become optional overrides applied via a customStyleSheetProvider; the override sheet is empty until a property is set, so editor defaults always win unless explicitly overridden - Add resetStyles() to revert all overrides back to editor defaults - Transparency (opaque=false) handled by Swing isOpaque + transparent CSS body rule; no manual background injection when transparent - Update tests to use overrideSheet() and component state assertions --- .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 243 +++++++++++------- .../ai/kilocode/client/ui/md/MdViewTest.kt | 235 ++++++++++------- 2 files changed, 290 insertions(+), 188 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 5b3d6b20f35..146075250e5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -1,5 +1,8 @@ package ai.kilocode.client.ui.md +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBHtmlPaneStyleConfiguration import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import org.commonmark.ext.autolink.AutolinkExtension @@ -11,22 +14,27 @@ import java.awt.Color import java.awt.Font import java.awt.Point import javax.swing.JComponent -import javax.swing.JEditorPane import javax.swing.event.HyperlinkEvent -import javax.swing.text.html.HTMLEditorKit +import javax.swing.text.html.StyleSheet /** - * Markdown rendering component that hides the concrete rendering strategy - * behind a uniform API. Create instances via [MdView.html]. + * Markdown rendering component backed by [JBHtmlPane] with editor-aware styling. * - * All public methods must be called on the EDT. + * By default, font and colors are derived from the global editor colour scheme. + * All style properties are optional overrides on top of those defaults. + * Call [resetStyles] to revert to editor defaults after overriding. + * + * Create instances via [MdView.html]. All public methods must be called on the EDT. */ +@Suppress("UnstableApiUsage") abstract class MdView private constructor() { abstract val component: JComponent abstract fun set(text: String) abstract fun append(delta: String) abstract fun clear() + /** Revert all style overrides to editor-derived defaults. */ + abstract fun resetStyles() abstract fun addLinkListener(listener: LinkListener) abstract fun removeLinkListener(listener: LinkListener) @@ -43,8 +51,8 @@ abstract class MdView private constructor() { abstract var tableBorder: Color /** - * When `false`, body background CSS is omitted and the Swing component - * is set to non-opaque so the parent's background shows through. + * When `false`, the component is transparent — the parent's background shows through + * and no background is forced in the CSS body rule. */ abstract var opaque: Boolean @@ -59,20 +67,20 @@ abstract class MdView private constructor() { internal abstract fun markdown(): String internal abstract fun html(): String - internal abstract fun styledHtml(): String + /** Returns the current CSS override rules applied on top of JBHtmlPane's default stylesheet. */ + internal abstract fun overrideSheet(): String internal abstract fun simulateLink(href: String) companion object { fun html(): MdView = HtmlImpl() } + @Suppress("UnstableApiUsage") private class HtmlImpl : MdView() { private val listeners = mutableListOf() private val source = StringBuilder() private var rendered = "" - private var wrapped = "" - private var dirty = false private val extensions = listOf( AutolinkExtension.create(), @@ -80,9 +88,7 @@ abstract class MdView private constructor() { StrikethroughExtension.create(), ) - private val parser: Parser = Parser.builder() - .extensions(extensions) - .build() + private val parser: Parser = Parser.builder().extensions(extensions).build() private val renderer: HtmlRenderer = HtmlRenderer.builder() .extensions(extensions) @@ -90,72 +96,126 @@ abstract class MdView private constructor() { .sanitizeUrls(true) .build() - private val pane: JEditorPane = JEditorPane().apply { + // nullable overrides — null means "use JBHtmlPane / editor default" + private var fontOverride: Font? = null + private var foregroundOverride: Color? = null + private var backgroundOverride: Color? = null + private var linkColorOverride: Color? = null + private var codeBgOverride: Color? = null + private var preBgOverride: Color? = null + private var preFgOverride: Color? = null + private var codeFontOverride: String? = null + private var quoteBorderOverride: Color? = null + private var quoteFgOverride: Color? = null + private var tableBorderOverride: Color? = null + private var opaqueState = true + + private val pane: JBHtmlPane = JBHtmlPane( + JBHtmlPaneStyleConfiguration { + // colorSchemeProvider defaults to EditorColorsManager.getInstance().globalScheme + enableInlineCodeBackground = true + enableCodeBlocksBackground = true + }, + JBHtmlPaneConfiguration { + // fontResolver defaults to EditorCssFontResolver.getGlobalInstance() via JBHtmlPane's ImplService + customStyleSheetProvider { buildOverrideStyleSheet() } + } + ).apply { isEditable = false - contentType = "text/html" - editorKit = HTMLEditorKit() - putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, true) - border = JBUI.Borders.empty() + isOpaque = true + background = UIUtil.getPanelBackground() addHyperlinkListener { e -> if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) { val href = e.description ?: return@addHyperlinkListener val pt = (e.inputEvent as? java.awt.event.MouseEvent)?.point val event = LinkEvent(href, pt) - for (l in listeners) { - l.onLink(event) - } + for (l in listeners) l.onLink(event) } } } - override var font: Font = JBUI.Fonts.label() - set(value) { field = value; markDirty() } + override val component: JComponent get() = pane + + // -- style properties (non-null API backed by nullable overrides) ---- + + override var font: Font + get() = fontOverride ?: JBUI.Fonts.label() + set(value) { fontOverride = value; markDirty() } - override var foreground: Color = UIUtil.getLabelForeground() - set(value) { field = value; pane.foreground = value; markDirty() } + override var foreground: Color + get() = foregroundOverride ?: UIUtil.getLabelForeground() + set(value) { foregroundOverride = value; markDirty() } - override var background: Color = UIUtil.getPanelBackground() - set(value) { field = value; pane.background = value; markDirty() } + override var background: Color + get() = backgroundOverride ?: pane.background + set(value) { + backgroundOverride = value + if (opaqueState) pane.background = value + markDirty() + } - override var linkColor: Color = Color(0x58, 0x9D, 0xF6) - set(value) { field = value; markDirty() } + override var linkColor: Color + get() = linkColorOverride ?: Color(0x58, 0x9D, 0xF6) + set(value) { linkColorOverride = value; markDirty() } - override var codeBg: Color = Color(0x3C, 0x3F, 0x41) - set(value) { field = value; markDirty() } + override var codeBg: Color + get() = codeBgOverride ?: Color(0x3C, 0x3F, 0x41) + set(value) { codeBgOverride = value; markDirty() } - override var preBg: Color = Color(0x2B, 0x2B, 0x2B) - set(value) { field = value; markDirty() } + override var preBg: Color + get() = preBgOverride ?: Color(0x2B, 0x2B, 0x2B) + set(value) { preBgOverride = value; markDirty() } - override var preFg: Color = Color(0xA9, 0xB7, 0xC6) - set(value) { field = value; markDirty() } + override var preFg: Color + get() = preFgOverride ?: Color(0xA9, 0xB7, 0xC6) + set(value) { preFgOverride = value; markDirty() } - override var codeFont: String = "JetBrains Mono" - set(value) { field = value; markDirty() } + override var codeFont: String + // _EditorFontNoLigatures_ is resolved by EditorCssFontResolver to the global editor font + get() = codeFontOverride ?: "_EditorFontNoLigatures_" + set(value) { codeFontOverride = value; markDirty() } - override var quoteBorder: Color = Color(0x55, 0x55, 0x55) - set(value) { field = value; markDirty() } + override var quoteBorder: Color + get() = quoteBorderOverride ?: Color(0x55, 0x55, 0x55) + set(value) { quoteBorderOverride = value; markDirty() } - override var quoteFg: Color = Color(0x99, 0x99, 0x99) - set(value) { field = value; markDirty() } + override var quoteFg: Color + get() = quoteFgOverride ?: Color(0x99, 0x99, 0x99) + set(value) { quoteFgOverride = value; markDirty() } - override var tableBorder: Color = Color(0x55, 0x55, 0x55) - set(value) { field = value; markDirty() } + override var tableBorder: Color + get() = tableBorderOverride ?: Color(0x55, 0x55, 0x55) + set(value) { tableBorderOverride = value; markDirty() } - override var opaque: Boolean = true + override var opaque: Boolean + get() = opaqueState set(value) { - field = value + opaqueState = value pane.isOpaque = value + if (value) pane.background = backgroundOverride ?: UIUtil.getPanelBackground() markDirty() } - init { - pane.font = font - pane.foreground = foreground - pane.background = background + override fun resetStyles() { + fontOverride = null + foregroundOverride = null + backgroundOverride = null + linkColorOverride = null + codeBgOverride = null + preBgOverride = null + preFgOverride = null + codeFontOverride = null + quoteBorderOverride = null + quoteFgOverride = null + tableBorderOverride = null + opaqueState = true + pane.isOpaque = true + pane.background = UIUtil.getPanelBackground() + markDirty() } - override val component: JComponent get() = pane + // -- content API --------------------------------------------------- override fun set(text: String) { source.clear() @@ -171,73 +231,68 @@ abstract class MdView private constructor() { override fun clear() { source.clear() rendered = "" - wrapped = "" pane.text = "" } - override fun addLinkListener(listener: LinkListener) { - listeners.add(listener) - } - - override fun removeLinkListener(listener: LinkListener) { - listeners.remove(listener) - } + override fun addLinkListener(listener: LinkListener) { listeners.add(listener) } + override fun removeLinkListener(listener: LinkListener) { listeners.remove(listener) } override fun markdown(): String = source.toString() override fun html(): String = rendered - override fun styledHtml(): String = wrapped + override fun overrideSheet(): String = buildOverrideRulesString() override fun simulateLink(href: String) { val event = LinkEvent(href) - for (l in listeners) { - l.onLink(event) - } + for (l in listeners) l.onLink(event) } private fun markDirty() { - dirty = true + pane.reloadCssStylesheets() if (source.isNotEmpty()) render() } private fun render() { - dirty = false - val md = source.toString() - val body = renderer.render(parser.parse(md)) + val body = renderer.render(parser.parse(source.toString())) rendered = body - wrapped = wrap(body) - pane.text = wrapped + pane.text = "$body" pane.caretPosition = 0 } - private fun wrap(body: String): String { - val fg = hex(foreground) - val size = font.size - val family = font.family - val codeSize = (size - 1).coerceAtLeast(1) - val bgCss = if (opaque) "background: ${hex(background)}; " else "" - return """ - - - $body - - """.trimIndent() + private fun buildOverrideStyleSheet(): StyleSheet { + val sheet = StyleSheet() + val rules = buildOverrideRulesString() + if (rules.isNotEmpty()) { + try { sheet.addRule(rules) } catch (_: Exception) {} + } + return sheet + } + + private fun buildOverrideRulesString(): String { + val rules = StringBuilder() + + val body = mutableListOf() + foregroundOverride?.let { body.add("color: ${hex(it)}") } + if (!opaqueState) body.add("background: transparent") + fontOverride?.let { + body.add("font-family: '${it.family}', sans-serif") + body.add("font-size: ${it.size}pt") + } + if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") + + linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") } + codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${it}', monospace } ") } + preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") } + preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") } + codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") } + quoteBorderOverride?.let { rules.append("blockquote { border-left-color: ${hex(it)} } ") } + quoteFgOverride?.let { rules.append("blockquote { color: ${hex(it)} } ") } + tableBorderOverride?.let { rules.append("th, td { border-color: ${hex(it)} } ") } + + return rules.toString().trim() } companion object { - private fun hex(c: Color): String = - String.format("#%02x%02x%02x", c.red, c.green, c.blue) + private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index 675b04168af..4fa8ff2d04b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -5,11 +5,12 @@ import java.awt.Color import java.awt.Font /** - * Tests for [MdView] created via [MdView.html] factory. + * Tests for [MdView] created via [MdView.html]. * * Uses [BasePlatformTestCase] to get a real IntelliJ Application so that - * Swing/HTMLEditorKit initialisation works correctly. + * JBHtmlPane initialisation works correctly. */ +@Suppress("UnstableApiUsage") class MdViewTest : BasePlatformTestCase() { private lateinit var view: MdView @@ -34,82 +35,70 @@ class MdViewTest : BasePlatformTestCase() { fun `test set renders bold`() { view.set("hello **world**") - val html = view.html() - assertTrue("Expected in rendered HTML", html.contains("")) - assertTrue("Expected 'world' inside strong", html.contains("world")) + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("world")) } fun `test set renders italic`() { view.set("hello *world*") - val html = view.html() - assertTrue("Expected in rendered HTML", html.contains("")) + assertTrue(view.html().contains("")) } fun `test set renders inline code`() { view.set("use `foo()` here") - val html = view.html() - assertTrue("Expected in rendered HTML", html.contains("")) - assertTrue("Expected foo() in code", html.contains("foo()")) + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("foo()")) } fun `test set renders fenced code block`() { view.set("```kotlin\nval x = 1\n```") - val html = view.html() - assertTrue("Expected
             in rendered HTML", html.contains("
            "))
            -        assertTrue("Expected "))
            +        assertTrue(view.html().contains(" in rendered HTML", html.contains("

            ")) + assertTrue(view.html().contains("

            ")) } fun `test set renders unordered list`() { view.set("- one\n- two\n- three") - val html = view.html() - assertTrue("Expected
              in rendered HTML", html.contains("
                ")) - assertTrue("Expected
              • in rendered HTML", html.contains("
              • ")) + assertTrue(view.html().contains("
                  ")) + assertTrue(view.html().contains("
                • ")) } fun `test set renders ordered list`() { view.set("1. one\n2. two\n3. three") - val html = view.html() - assertTrue("Expected
                    in rendered HTML", html.contains("
                      ")) + assertTrue(view.html().contains("
                        ")) } fun `test set renders blockquote`() { view.set("> quoted text") - val html = view.html() - assertTrue("Expected
                        in rendered HTML", html.contains("
                        ")) + assertTrue(view.html().contains("
                        ")) } fun `test set renders strikethrough`() { view.set("~~deleted~~") - val html = view.html() - assertTrue("Expected in rendered HTML", html.contains("")) + assertTrue(view.html().contains("")) } fun `test set renders table`() { view.set("| a | b |\n|---|---|\n| 1 | 2 |") - val html = view.html() - assertTrue("Expected in rendered HTML", html.contains("
                        ")) - assertTrue("Expected
                        in rendered HTML", html.contains("")) - assertTrue("Expected in rendered HTML", html.contains("")) + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("
                        ")) + assertTrue(view.html().contains("")) } fun `test set renders autolink`() { view.set("Visit https://example.com for details") - val html = view.html() - assertTrue("Expected autolinked URL", html.contains(" after append", html.contains("")) + assertTrue(view.html().contains("")) } fun `test append after set extends content`() { @@ -144,8 +132,7 @@ class MdViewTest : BasePlatformTestCase() { fun `test clear resets rendered html`() { view.set("some content") view.clear() - val html = view.html() - assertFalse("Expected no 'some content' after clear", html.contains("some content")) + assertFalse(view.html().contains("some content")) } // ---- link listener ---- @@ -175,7 +162,7 @@ class MdViewTest : BasePlatformTestCase() { view.addLinkListener(listener) view.removeLinkListener(listener) view.simulateLink("https://example.com") - assertTrue("Removed listener should not fire", received.isEmpty()) + assertTrue(received.isEmpty()) } // ---- component ---- @@ -218,137 +205,197 @@ class MdViewTest : BasePlatformTestCase() { fun `test streaming simulation appends tokens`() { val tokens = listOf("Hello ", "**wor", "ld**", "\n\n", "Done.") - for (token in tokens) { - view.append(token) - } + for (token in tokens) view.append(token) assertEquals("Hello **world**\n\nDone.", view.markdown()) - val html = view.html() - assertTrue(html.contains("")) - assertTrue(html.contains("Done.")) + assertTrue(view.html().contains("")) + assertTrue(view.html().contains("Done.")) } - // ---- styling ---- + // ---- style overrides (empty by default) ---- - fun `test foreground color appears in CSS`() { - view.foreground = Color(0xAA, 0xBB, 0xCC) - view.set("text") - assertTrue(view.styledHtml().contains("#aabbcc")) + fun `test no overrides produces empty override sheet`() { + assertEquals("", view.overrideSheet()) } - fun `test background color appears in CSS`() { - view.background = Color(0x11, 0x22, 0x33) + // ---- style overrides appear in override sheet when set ---- + + fun `test foreground override appears in override sheet`() { + view.foreground = Color(0xAA, 0xBB, 0xCC) view.set("text") - assertTrue(view.styledHtml().contains("#112233")) + assertTrue(view.overrideSheet().contains("#aabbcc")) } - fun `test link color appears in CSS`() { + fun `test link color override appears in override sheet`() { view.linkColor = Color(0xFF, 0x00, 0x77) view.set("[a](https://x.com)") - assertTrue(view.styledHtml().contains("#ff0077")) + assertTrue(view.overrideSheet().contains("#ff0077")) } - fun `test code background appears in CSS`() { + fun `test code bg override appears in override sheet`() { view.codeBg = Color(0x10, 0x20, 0x30) view.set("`code`") - assertTrue(view.styledHtml().contains("#102030")) + assertTrue(view.overrideSheet().contains("#102030")) } - fun `test pre background and foreground appear in CSS`() { + fun `test pre bg and fg overrides appear in override sheet`() { view.preBg = Color(0x0A, 0x0B, 0x0C) view.preFg = Color(0xD0, 0xE0, 0xF0) view.set("```\ncode\n```") - val css = view.styledHtml() - assertTrue(css.contains("#0a0b0c")) - assertTrue(css.contains("#d0e0f0")) + val sheet = view.overrideSheet() + assertTrue(sheet.contains("#0a0b0c")) + assertTrue(sheet.contains("#d0e0f0")) } - fun `test code font family appears in CSS`() { + fun `test code font override appears in override sheet`() { view.codeFont = "Fira Code" view.set("`x`") - assertTrue(view.styledHtml().contains("Fira Code")) + assertTrue(view.overrideSheet().contains("Fira Code")) } - fun `test blockquote colors appear in CSS`() { + fun `test blockquote color overrides appear in override sheet`() { view.quoteBorder = Color(0xAA, 0x00, 0x00) view.quoteFg = Color(0x00, 0xBB, 0x00) view.set("> quote") - val css = view.styledHtml() - assertTrue(css.contains("#aa0000")) - assertTrue(css.contains("#00bb00")) + val sheet = view.overrideSheet() + assertTrue(sheet.contains("#aa0000")) + assertTrue(sheet.contains("#00bb00")) } - fun `test table border color appears in CSS`() { + fun `test table border override appears in override sheet`() { view.tableBorder = Color(0x12, 0x34, 0x56) view.set("| a |\n|---|\n| 1 |") - assertTrue(view.styledHtml().contains("#123456")) + assertTrue(view.overrideSheet().contains("#123456")) } - fun `test font family appears in CSS`() { + fun `test font family override appears in override sheet`() { view.font = Font("Courier New", Font.PLAIN, 14) view.set("text") - assertTrue(view.styledHtml().contains("Courier New")) + assertTrue(view.overrideSheet().contains("Courier New")) } - fun `test font size appears in CSS`() { + fun `test font size override appears in override sheet`() { view.font = Font("Arial", Font.PLAIN, 18) view.set("text") - assertTrue(view.styledHtml().contains("18pt")) + assertTrue(view.overrideSheet().contains("18pt")) } - fun `test style change re-renders existing content`() { + fun `test style change re-renders and override sheet reflects change`() { view.set("hello") view.foreground = Color(0xDE, 0xAD, 0x00) - assertTrue(view.styledHtml().contains("#dead00")) - assertTrue(view.styledHtml().contains("hello")) + assertTrue(view.overrideSheet().contains("#dead00")) + assertTrue(view.html().contains("hello")) } fun `test style change without content does not crash`() { view.foreground = Color.RED view.linkColor = Color.BLUE view.codeFont = "Monospaced" - // no content set — should not throw assertEquals("", view.markdown()) } - // ---- opaque / transparent ---- + // ---- default codeFont uses editor font placeholder ---- - fun `test opaque true includes background in CSS`() { - view.opaque = true + fun `test default codeFont is editor font placeholder`() { + // When no codeFont override is set, the getter returns the editor font placeholder + assertTrue(view.codeFont.contains("_Editor")) + } + + fun `test default override sheet is empty before any set`() { + // Only overrides appear in the sheet; editor defaults are handled by JBHtmlPane + assertEquals("", view.overrideSheet()) + } + + // ---- background sets component background ---- + + fun `test background override sets component background`() { + view.background = Color(0x11, 0x22, 0x33) + assertEquals(Color(0x11, 0x22, 0x33), view.component.background) + } + + fun `test background override does not appear in override sheet`() { + // background is applied to the Swing component, not via CSS override rule view.background = Color(0x11, 0x22, 0x33) view.set("text") - assertTrue(view.styledHtml().contains("background: #112233")) + assertFalse(view.overrideSheet().contains("#112233")) + } + + // ---- opaque / transparent ---- + + fun `test opaque true sets component opaque`() { + view.opaque = true + assertTrue(view.component.isOpaque) } - fun `test opaque false omits background from body CSS`() { + fun `test opaque false sets component non-opaque`() { + view.opaque = false + assertFalse(view.component.isOpaque) + } + + fun `test opaque false adds transparent background to override sheet`() { view.opaque = false - view.background = Color(0x11, 0x22, 0x33) view.set("text") - assertFalse(view.styledHtml().contains("background: #112233")) + assertTrue(view.overrideSheet().contains("transparent")) } - fun `test opaque false does not affect pre background`() { + fun `test opaque true does not add transparent rule`() { + view.opaque = true + view.set("text") + assertFalse(view.overrideSheet().contains("transparent")) + } + + fun `test opaque false does not affect pre background override`() { view.opaque = false view.preBg = Color(0x0A, 0x0B, 0x0C) view.set("```\ncode\n```") - assertTrue(view.styledHtml().contains("#0a0b0c")) + assertTrue(view.overrideSheet().contains("#0a0b0c")) } - fun `test opaque toggle re-renders`() { + fun `test background override applied to component when opaque is true`() { view.background = Color(0xFE, 0xFE, 0xFE) - view.set("hello") - view.opaque = false - assertFalse(view.styledHtml().contains("background: #fefefe")) view.opaque = true - assertTrue(view.styledHtml().contains("background: #fefefe")) + assertEquals(Color(0xFE, 0xFE, 0xFE), view.component.background) } - fun `test component is not opaque when opaque is false`() { + fun `test opaque toggle updates component opacity`() { view.opaque = false assertFalse(view.component.isOpaque) + view.opaque = true + assertTrue(view.component.isOpaque) } - fun `test component is opaque when opaque is true`() { - view.opaque = true + // ---- resetStyles ---- + + fun `test resetStyles clears foreground override`() { + view.foreground = Color.RED + view.resetStyles() + assertEquals("", view.overrideSheet()) + } + + fun `test resetStyles clears all overrides`() { + view.foreground = Color.RED + view.linkColor = Color.BLUE + view.codeBg = Color.GREEN + view.preBg = Color.ORANGE + view.preFg = Color.CYAN + view.codeFont = "Monospaced" + view.quoteBorder = Color.PINK + view.quoteFg = Color.MAGENTA + view.tableBorder = Color.YELLOW + view.font = Font("Arial", Font.PLAIN, 18) + view.resetStyles() + assertEquals("", view.overrideSheet()) + } + + fun `test resetStyles restores opaque to true`() { + view.opaque = false + view.resetStyles() assertTrue(view.component.isOpaque) } + + fun `test resetStyles after set still renders content`() { + view.set("hello **world**") + view.foreground = Color.RED + view.resetStyles() + assertTrue(view.html().contains("")) + } }