fix(jetbrains): repair diagram viewer controls

This commit is contained in:
kirillk
2026-08-27 13:30:31 -04:00
parent dedd439bef
commit 54b7225c4d
15 changed files with 1058 additions and 78 deletions
@@ -3,6 +3,7 @@ package ai.kilocode.client.plugin
import ai.kilocode.KiloPlugin
import ai.kilocode.client.agentManager.worktree.unregisterWorktreeSessionEditorKind
import ai.kilocode.client.session.ui.attachment.unregisterAttachmentEditorKind
import ai.kilocode.client.ui.diagram.ui.DiagramWindows
import ai.kilocode.client.ui.diagram.ui.unregisterDiagramEditorKind
import ai.kilocode.client.vfs.KiloEditorKindRegistry
import ai.kilocode.client.vfs.KiloVirtualFileSystem
@@ -30,6 +31,7 @@ object KiloFrontendUnloadCleanup {
runEdt {
ProjectManager.getInstance().openProjects.forEach { project ->
if (project.isDisposed) return@forEach
project.getServiceIfCreated(DiagramWindows::class.java)?.closeAll()
ToolWindowManager.getInstance(project).getToolWindow("Kilo Code")
?.contentManager
?.removeAllContents(true)
@@ -0,0 +1,172 @@
package ai.kilocode.client.ui.diagram.ui
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.diagram.Art
import ai.kilocode.client.ui.diagram.Painters
import ai.kilocode.client.ui.diagram.Palette
import com.intellij.ui.components.Magnificator
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.Point
import java.awt.Rectangle
import java.awt.RenderingHints
import javax.swing.JComponent
import javax.swing.JViewport
import javax.swing.Scrollable
import kotlin.math.roundToInt
/**
* Scrollable diagram surface for the diagram viewer.
*
* Two states: fit (no explicit factor) tracks the viewport on both axes so the whole diagram is
* visible without scrollbars, while an explicit factor reports the scaled art as its preferred size
* so the enclosing scroll pane can scroll it. The fit scale is derived from the **viewport** extent,
* never from this component's own bounds, so sizing cannot feed back into itself.
*/
internal class DiagramCanvas(private var palette: Palette) : JComponent(), Scrollable {
private var art: Art? = null
private var factor: Double? = null
init {
// Trackpad pinch: JBViewport reads this off its view and drives it through ZoomingDelegate,
// which does the scrolling itself from the returned point, so no anchoring here.
putClientProperty(
Magnificator.CLIENT_PROPERTY_KEY,
Magnificator { scale, at ->
zoom(this.scale() * scale)
Point((at.x * scale).roundToInt(), (at.y * scale).roundToInt())
},
)
}
@RequiresEdt
fun art(value: Art) {
art = value
revalidate()
repaint()
}
@RequiresEdt
fun palette(value: Palette) {
palette = value
repaint()
}
/**
* Sets an explicit scale, or restores fit when [value] is null.
*
* [at] is a point in **viewport** coordinates that should stay put across the zoom.
*/
@RequiresEdt
fun zoom(value: Double?, at: Point? = null) {
val before = scale()
factor = value?.coerceIn(MIN, maxOf(MAX, fitScale() * FIT_ZOOM))
// Size the view up front so the viewport clamps the anchored position against the new bounds.
if (factor != null) size = preferredSize
revalidate()
repaint()
if (at != null) anchor(at, before, scale())
}
@RequiresEdt
fun fit() {
zoom(null)
}
@RequiresEdt
fun scale(): Double = factor ?: fitScale()
override fun getPreferredSize(): Dimension {
if (factor == null) return Dimension(0, 0)
val value = art ?: return Dimension(0, 0)
val size = Painters.of(value).size(value)
val scale = scale()
return Dimension(
(size.w * scale).roundToInt() + pad() * 2,
(size.h * scale).roundToInt() + pad() * 2,
)
}
override fun paintComponent(g: Graphics) {
background?.let {
g.color = it
g.fillRect(0, 0, width, height)
}
val value = art ?: return
val size = Painters.of(value).size(value)
val scale = scale()
val x = ((width - size.w * scale) / 2).roundToInt().coerceAtLeast(pad())
val y = ((height - size.h * scale) / 2).roundToInt().coerceAtLeast(pad())
paintDiagram(g, value, palette, scale, x, y)
}
override fun getPreferredScrollableViewportSize(): Dimension = preferredSize
override fun getScrollableUnitIncrement(visibleRect: Rectangle, orientation: Int, direction: Int) = step()
override fun getScrollableBlockIncrement(visibleRect: Rectangle, orientation: Int, direction: Int) = step()
override fun getScrollableTracksViewportWidth(): Boolean = tracks { extent -> preferredSize.width <= extent.width }
override fun getScrollableTracksViewportHeight(): Boolean = tracks { extent -> preferredSize.height <= extent.height }
private fun tracks(fits: (Dimension) -> Boolean): Boolean {
if (factor == null) return true
val viewport = parent as? JViewport ?: return false
return fits(viewport.extentSize)
}
private fun anchor(at: Point, before: Double, after: Double) {
if (before <= 0.0) return
val viewport = parent as? JViewport ?: return
val ratio = after / before
val pos = viewport.viewPosition
val x = ((pos.x + at.x) * ratio - at.x).roundToInt()
val y = ((pos.y + at.y) * ratio - at.y).roundToInt()
viewport.viewPosition = clamped(viewport, Point(x, y))
}
private fun fitScale(): Double {
val value = art ?: return 1.0
val size = Painters.of(value).size(value)
if (size.w <= 0.0 || size.h <= 0.0) return 1.0
val extent = (parent as? JViewport)?.extentSize ?: Dimension(width, height)
val w = (extent.width - pad() * 2).coerceAtLeast(1)
val h = (extent.height - pad() * 2).coerceAtLeast(1)
return minOf(w / size.w, h / size.h).coerceAtLeast(MIN)
}
private fun pad() = JBUI.scale(SessionUiStyle.View.Diagram.PADDING)
private fun step() = JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT)
private companion object {
const val MIN = 0.1
const val MAX = 4.0
const val FIT_ZOOM = 4.0
}
}
/** Keeps a viewport position inside the scrollable range of its view. */
internal fun clamped(viewport: JViewport, at: Point): Point {
val view = viewport.view ?: return at
val x = (view.width - viewport.extentSize.width).coerceAtLeast(0)
val y = (view.height - viewport.extentSize.height).coerceAtLeast(0)
return Point(at.x.coerceIn(0, x), at.y.coerceIn(0, y))
}
/** Paints [art] scaled by [scale] with its top-left corner at ([x], [y]). */
internal fun paintDiagram(g: Graphics, art: Art, palette: Palette, scale: Double, x: Int, y: Int) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.translate(x, y)
g2.scale(scale, scale)
Painters.of(art).paint(g2, art, palette)
} finally {
g2.dispose()
}
}
@@ -0,0 +1,60 @@
package ai.kilocode.client.ui.diagram.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.diagram.Out
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.colors.EditorColorsListener
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import javax.swing.JComponent
import javax.swing.JPanel
@RequiresEdt
internal fun diagramContent(source: String, parent: Disposable): JComponent {
val root = JPanel(BorderLayout())
val label = JBLabel().apply {
border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad())
isVisible = false
}
val viewer = DiagramViewer(diagramPalette(SessionEditorStyle.current()))
root.add(viewer, BorderLayout.CENTER)
root.add(label, BorderLayout.SOUTH)
fun render() {
val style = SessionEditorStyle.current()
viewer.surface(SessionUiStyle.Colors.codeBlockBackground())
viewer.palette(diagramPalette(style))
label.text = KiloBundle.message("diagram.rendering")
label.foreground = SessionUiStyle.Text.Secondary.foreground()
label.isVisible = true
service<Diagrams>().render(source, diagramSpec(style), parent) { out ->
when (out) {
is Out.Ok -> {
viewer.art(out.art)
label.isVisible = false
}
is Out.Err -> {
label.text = KiloBundle.message("diagram.error", out.message)
label.foreground = UiStyle.Colors.errorLabelForeground()
label.isVisible = true
}
}
root.revalidate()
root.repaint()
}
}
render()
ApplicationManager.getApplication().messageBus.connect(parent)
.subscribe(EditorColorsManager.TOPIC, EditorColorsListener { ApplicationManager.getApplication().invokeLater(::render) })
return root
}
@@ -1,12 +1,8 @@
package ai.kilocode.client.ui.diagram.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.CodeViewField
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.codeViewScroll
import ai.kilocode.client.ui.diagram.Out
import ai.kilocode.client.ui.md.hybrid.MdLanguage
import ai.kilocode.client.vfs.KiloEditorKind
import ai.kilocode.client.vfs.KiloEditorKindRegistry
@@ -16,12 +12,9 @@ import ai.kilocode.client.vfs.KiloVirtualFile
import com.intellij.ide.DataManager
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.colors.EditorColorsListener
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.fileTypes.FileType
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
@@ -29,12 +22,9 @@ import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.Centerizer
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.security.MessageDigest
import java.util.Collections
import javax.swing.JComponent
import javax.swing.JPanel
private const val TOKEN = "token"
@@ -101,44 +91,7 @@ internal object DiagramEditorKind : KiloEditorKind {
@RequiresEdt
override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent {
val text = source(file.path.params) ?: return center(KiloBundle.message("diagram.missing"))
val root = JPanel(BorderLayout())
val label = JBLabel().apply {
border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad())
isVisible = false
}
val panel = DiagramPanel(diagramPalette(SessionEditorStyle.current()), fit = true)
root.add(panel, BorderLayout.CENTER)
root.add(label, BorderLayout.SOUTH)
fun render() {
val style = SessionEditorStyle.current()
panel.background = SessionUiStyle.Colors.codeBlockBackground()
panel.palette(diagramPalette(style))
label.text = KiloBundle.message("diagram.rendering")
label.foreground = SessionUiStyle.Text.Secondary.foreground()
label.isVisible = true
service<Diagrams>().render(text, diagramSpec(style), parent) { out ->
when (out) {
is Out.Ok -> {
panel.art(out.art)
label.isVisible = false
}
is Out.Err -> {
label.text = KiloBundle.message("diagram.error", out.message)
label.foreground = UiStyle.Colors.errorLabelForeground()
label.isVisible = true
}
}
root.revalidate()
root.repaint()
}
}
render()
ApplicationManager.getApplication().messageBus.connect(parent)
.subscribe(EditorColorsManager.TOPIC, EditorColorsListener { ApplicationManager.getApplication().invokeLater(::render) })
return root
return diagramContent(text, parent)
}
}
@@ -14,7 +14,7 @@ import java.awt.RenderingHints
import javax.swing.JComponent
import kotlin.math.roundToInt
internal class DiagramPanel(private var palette: Palette, private val fit: Boolean = false) : JComponent() {
internal class DiagramPanel(private var palette: Palette) : JComponent() {
private var art: Art? = null
private var last = Dimension(0, 0)
@@ -31,19 +31,15 @@ internal class DiagramPanel(private var palette: Palette, private val fit: Boole
repaint()
}
override fun getPreferredSize() = if (fit) Dimension(0, 0) else fitSize()
override fun getPreferredSize() = fitSize()
override fun getMinimumSize() = if (fit) Dimension(0, 0) else fitSize()
override fun getMinimumSize() = fitSize()
override fun getMaximumSize() = if (fit) Dimension(Int.MAX_VALUE, Int.MAX_VALUE) else Dimension(Int.MAX_VALUE, fitSize().height)
override fun getMaximumSize() = Dimension(Int.MAX_VALUE, fitSize().height)
override fun setBounds(x: Int, y: Int, width: Int, height: Int) {
val before = fitSize()
super.setBounds(x, y, width, height)
if (fit) {
repaint()
return
}
if (before.height != fitSize().height) resize()
}
@@ -58,17 +54,9 @@ internal class DiagramPanel(private var palette: Palette, private val fit: Boole
g2.dispose()
}
val value = art ?: return
val painter = Painters.of(value)
val scale = scale(value)
SessionSurface.clipped(g, width, height) { clipped ->
val inner = clipped.create() as Graphics2D
try {
inner.translate(pad(), pad())
inner.scale(scale, scale)
painter.paint(inner, value, palette)
} finally {
inner.dispose()
}
paintDiagram(clipped, value, palette, scale, pad(), pad())
}
}
@@ -90,7 +78,7 @@ internal class DiagramPanel(private var palette: Palette, private val fit: Boole
val size = Painters.of(value).size(value)
val avail = (width.takeIf { it > 0 } ?: parent?.width ?: 0) - pad() * 2
val byWidth = if (avail > 0) minOf(1.0, avail / size.w) else 1.0
val max = if (fit) height - pad() * 2 else JBUI.scale(SessionUiStyle.View.Diagram.MAX_HEIGHT) - pad() * 2
val max = JBUI.scale(SessionUiStyle.View.Diagram.MAX_HEIGHT) - pad() * 2
val byHeight = if (size.h > 0.0) minOf(1.0, max / size.h) else 1.0
return minOf(byWidth, byHeight).coerceAtLeast(0.1)
}
@@ -0,0 +1,159 @@
package ai.kilocode.client.ui.diagram.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.ToolbarButtonAction
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.diagram.Art
import ai.kilocode.client.ui.diagram.Palette
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.toolbarButton
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLayeredPane
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.Cursor
import java.awt.Point
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseWheelEvent
import java.awt.event.MouseWheelListener
import javax.swing.Icon
import javax.swing.ScrollPaneConstants
import javax.swing.SwingUtilities
/**
* Reusable zoomable diagram surface: a scrollable [DiagramCanvas] with floating zoom controls.
*
* Shared by the diagram editor tab and the detached diagram window. Zoom comes from three sources:
* trackpad pinch (via the canvas [com.intellij.ui.components.Magnificator]), Ctrl/Cmd + wheel, and
* the overlay buttons. Dragging pans whenever the scaled diagram overflows the viewport.
*/
internal class DiagramViewer(palette: Palette) : JBLayeredPane() {
private val canvas = DiagramCanvas(palette)
private val scroll = JBScrollPane(canvas).apply {
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
// Built by chaining rather than `apply`, so these lambdas cannot bind to Stack's own fit().
private val controls = Stack.horizontal(UiStyle.Gap.xs())
.next(control(AllIcons.General.ZoomIn, "diagram.zoom.in") { zoomIn() })
.next(control(AllIcons.General.ZoomOut, "diagram.zoom.out") { zoomOut() })
.next(control(AllIcons.General.FitContent, "diagram.zoom.fit") { fit() })
private val wheel = Wheel()
private val drag = Drag()
init {
// Layer first, then add: add(Component, Int) binds to Container.add(comp, index) from Kotlin,
// so the layer would be taken as an insertion index and both children would end up in the
// default layer, with the scroll pane painting over the controls and swallowing their clicks.
setLayer(scroll, DEFAULT_LAYER)
setLayer(controls, PALETTE_LAYER)
add(scroll)
add(controls)
scroll.addMouseWheelListener(wheel)
canvas.addMouseListener(drag)
canvas.addMouseMotionListener(drag)
}
@RequiresEdt
fun art(value: Art) {
canvas.art(value)
}
@RequiresEdt
fun palette(value: Palette) {
canvas.palette(value)
}
/** Paints the diagram surface (canvas and viewport) with [color]. */
@RequiresEdt
fun surface(color: Color) {
background = color
scroll.background = color
scroll.viewport.background = color
canvas.background = color
}
@RequiresEdt
fun zoomIn(at: Point? = null) {
canvas.zoom(canvas.scale() * STEP, at)
}
@RequiresEdt
fun zoomOut(at: Point? = null) {
canvas.zoom(canvas.scale() / STEP, at)
}
@RequiresEdt
fun fit() {
canvas.fit()
}
override fun doLayout() {
scroll.setBounds(0, 0, width, height)
val size = controls.preferredSize
controls.setBounds(width - size.width - UiStyle.Gap.pad(), UiStyle.Gap.pad(), size.width, size.height)
controls.doLayout()
}
private inner class Wheel : MouseWheelListener {
override fun mouseWheelMoved(e: MouseWheelEvent) {
if (!e.isControlDown && !e.isMetaDown) return
val at = SwingUtilities.convertPoint(e.component, e.point, scroll.viewport)
if (e.wheelRotation < 0) zoomIn(at)
if (e.wheelRotation > 0) zoomOut(at)
e.consume()
}
}
private inner class Drag : MouseAdapter() {
private var from: Point? = null
private var origin: Point? = null
override fun mousePressed(e: MouseEvent) {
if (e.button != MouseEvent.BUTTON1 || !overflows()) return
from = e.point
origin = scroll.viewport.viewPosition
canvas.cursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR)
}
override fun mouseDragged(e: MouseEvent) {
val start = from ?: return
val base = origin ?: return
val at = Point(base.x + start.x - e.x, base.y + start.y - e.y)
scroll.viewport.viewPosition = clamped(scroll.viewport, at)
}
override fun mouseReleased(e: MouseEvent) {
release()
}
override fun mouseExited(e: MouseEvent) {
release()
}
private fun release() {
if (from == null) return
from = null
origin = null
canvas.cursor = Cursor.getDefaultCursor()
}
private fun overflows(): Boolean {
val viewport = scroll.viewport
val view = viewport.view ?: return false
return view.width > viewport.extentSize.width || view.height > viewport.extentSize.height
}
}
private companion object {
const val STEP = 1.25
fun control(icon: Icon, key: String, handler: () -> Unit) =
toolbarButton(ToolbarButtonAction(icon, KiloBundle.message(key), handler), fill = true)
}
}
@@ -0,0 +1,145 @@
package ai.kilocode.client.ui.diagram.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.telemetry.Telemetry
import com.intellij.ide.DataManager
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.FrameWrapper
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.WindowState
import com.intellij.openapi.wm.WindowManager
import com.intellij.util.concurrency.annotations.RequiresEdt
import java.awt.Rectangle
import java.util.function.BooleanSupplier
import javax.swing.JComponent
import javax.swing.RootPaneContainer
private const val DIMENSION_KEY = "ai.kilocode.DiagramViewer"
private const val SHARE = 0.75
internal fun diagramWindowBounds(frame: Rectangle): Rectangle {
val w = (frame.width * SHARE).toInt().coerceAtLeast(1)
val h = (frame.height * SHARE).toInt().coerceAtLeast(1)
val x = frame.x + (frame.width - w) / 2
val y = frame.y + (frame.height - h) / 2
return Rectangle(x, y, w, h)
}
internal interface DiagramHandle : Disposable {
fun show()
fun focus()
}
@Service(Service.Level.PROJECT)
internal class DiagramWindows internal constructor(
project: Project,
private val factory: (String) -> DiagramHandle,
private val send: (String, Map<String, String>) -> Unit,
) {
constructor(project: Project) : this(project, { source -> FrameHandle(project, source) }, Telemetry::send)
private val windows = mutableMapOf<String, DiagramHandle>()
@RequiresEdt
fun open(source: String): Boolean {
val token = service<DiagramStore>().put(source)
val handle = windows[token]
if (handle != null) {
handle.focus()
track(true)
return true
}
val next = factory(source)
windows[token] = next
Disposer.register(next) {
if (windows[token] === next) windows.remove(token)
}
next.show()
track(false)
return true
}
@RequiresEdt
fun closeAll() {
val all = windows.values.toList()
windows.clear()
all.forEach(Disposer::dispose)
}
private fun track(reused: Boolean) {
send(
"Diagram Viewer Opened",
mapOf(
"surface" to "session",
"reused" to reused.toString(),
),
)
}
}
private class FrameHandle(project: Project, source: String) : DiagramHandle {
private val frame = DiagramFrame(project).apply {
component = diagramContent(source, this)
preferredFocusedComponent = component
closeOnEsc()
setOnCloseHandler(BooleanSupplier {
Disposer.dispose(this@FrameHandle)
false
})
}
override fun show() {
frame.show(true)
}
override fun focus() {
val window = frame.getFrame()
window.toFront()
window.requestFocus()
}
override fun dispose() {
if (!frame.isDisposed) Disposer.dispose(frame)
}
}
/**
* A frame rather than a dialog on purpose.
*
* The viewer is a document surface, so it belongs in the Window menu and should live on its own
* instead of floating over the IDE frame. It also keeps the window closer to the editor tab, which
* matters for trackpad zoom: magnification is routed per window by the platform's
* [com.intellij.openapi.actionSystem.impl.MouseGestureManager], and the editor tab (a plain IDE
* frame) is the surface where that routing is known to reach our canvas.
*/
private class DiagramFrame(private val project: Project) : FrameWrapper(
project,
DIMENSION_KEY,
false,
KiloBundle.message("diagram.title"),
) {
override fun loadFrameState(state: WindowState?) {
if (state != null) {
super.loadFrameState(state)
return
}
val base = WindowManager.getInstance().getFrame(project)?.bounds
if (base == null) {
super.loadFrameState(null)
return
}
getFrame().bounds = diagramWindowBounds(base)
(getFrame() as RootPaneContainer).rootPane.revalidate()
}
}
@RequiresEdt
internal fun openDiagramWindow(anchor: JComponent, source: String): Boolean {
val ctx = DataManager.getInstance().getDataContext(anchor)
val project = CommonDataKeys.PROJECT.getData(ctx) ?: return false
return project.service<DiagramWindows>().open(source)
}
@@ -13,6 +13,7 @@ import ai.kilocode.client.ui.diagram.ui.DiagramPanel
import ai.kilocode.client.ui.diagram.ui.Diagrams
import ai.kilocode.client.ui.diagram.ui.diagramPalette
import ai.kilocode.client.ui.diagram.ui.diagramSpec
import ai.kilocode.client.ui.diagram.ui.openDiagramWindow
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.md.MdCodeBlockBorder
import ai.kilocode.client.ui.md.MdCodeBlockFactory
@@ -43,6 +44,7 @@ import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Font
import java.awt.Graphics
@@ -50,6 +52,7 @@ import java.awt.Graphics2D
import java.awt.Point
import java.awt.RenderingHints
import java.awt.event.HierarchyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.Box
import javax.swing.BoxLayout
@@ -972,10 +975,21 @@ internal open class MdViewHybrid(
private var hash = 0
private var gen = 0
private var font = spec().font
private val click = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (e.button != MouseEvent.BUTTON1 || e.clickCount != 1) return
if (!panel.isVisible) return
openDiagramWindow(panel, (this@DiagramView.desc as Desc.Code).text)
}
}
init {
panel.background = opts().preBg
panel.isVisible = false
panel.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
panel.toolTipText = KiloBundle.message("diagram.viewer.hint")
panel.addMouseListener(click)
Disposer.register(disposable) { panel.removeMouseListener(click) }
root.next(panel).next(codePane).next(label)
root.text = { (this.desc as Desc.Code).text }
kick()
@@ -12,6 +12,10 @@ diagram.missing=Diagram source is no longer available.
diagram.open=Open in Editor
diagram.path=Kilo / Diagrams / {0}
diagram.rendering=Rendering diagram...
diagram.viewer.hint=Open diagram viewer
diagram.zoom.fit=Fit to Window
diagram.zoom.in=Zoom In
diagram.zoom.out=Zoom Out
session.action.cancel=Cancel
session.connection.connecting=Loading...
@@ -105,7 +105,8 @@ class DiagramEditorKindTest : BasePlatformTestCase() {
try {
assertEquals(KiloBundle.message("diagram.title"), main.name)
assertEquals(KiloBundle.message("diagram.source"), source.name)
assertNotNull(main.component)
// The tab hosts the same zoomable viewer the diagram window uses.
assertEquals(1, descendants(main.component).filterIsInstance<DiagramViewer>().size)
val field = descendants(source.component).filterIsInstance<CodeViewField>().single()
assertEquals(flow.trim(), field.text.trim())
@@ -32,17 +32,6 @@ class DiagramPanelTest {
assertTrue(panel.preferredSize.height <= 520)
}
@Test
fun `test fit mode scales to the component bounds instead of the transcript cap`() {
val panel = DiagramPanel(palette(), fit = true)
panel.setSize(1_000, 1_000)
panel.art(scene(100.0, 2_000.0))
// The transcript cap (480) no longer applies; the panel fills whatever the tab gives it.
assertEquals(0, panel.preferredSize.height)
assertTrue(panel.maximumSize.height > 520)
}
@Test
fun `test block copies fence text and offers copy plus open in editor`() {
val block = DiagramBlock()
@@ -0,0 +1,294 @@
package ai.kilocode.client.ui.diagram.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.diagram.Mark
import ai.kilocode.client.ui.diagram.Palette
import ai.kilocode.client.ui.diagram.Rect
import ai.kilocode.client.ui.diagram.Role
import ai.kilocode.client.ui.diagram.Scene
import ai.kilocode.client.ui.diagram.Size
import ai.kilocode.client.ui.diagram.Type
import ai.kilocode.client.util.edtWait
import com.intellij.icons.AllIcons
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.Magnificator
import java.awt.Color
import java.awt.Container
import java.awt.Font
import java.awt.Point
import java.awt.event.MouseEvent
import java.awt.event.MouseWheelEvent
import javax.swing.AbstractButton
import javax.swing.JComponent
import javax.swing.JViewport
import javax.swing.SwingUtilities
class DiagramViewerTest : BasePlatformTestCase() {
fun `test fit tracks the viewport and needs no scrolling`() = edtWait {
val viewer = viewer(400, 300)
viewer.art(scene(2_000.0, 1_000.0))
layout(viewer)
assertTrue(scale(viewer) < 1.0)
val canvas = canvas(viewer)
assertTrue(canvas.getScrollableTracksViewportWidth())
assertTrue(canvas.getScrollableTracksViewportHeight())
}
fun `test fit upscales a diagram smaller than the viewport`() = edtWait {
val viewer = viewer(800, 600)
viewer.art(scene(100.0, 100.0))
layout(viewer)
assertTrue("fit should fill the window, not cap at native size", scale(viewer) > 1.0)
}
fun `test zooming in leaves fit and lets the scroll pane scroll`() = edtWait {
val viewer = viewer(400, 300)
viewer.art(scene(2_000.0, 1_000.0))
layout(viewer)
val fit = scale(viewer)
viewer.zoomIn()
layout(viewer)
assertEquals(fit * 1.25, scale(viewer), 1e-6)
val canvas = canvas(viewer)
assertTrue(canvas.preferredSize.width > 0)
assertFalse(canvas.getScrollableTracksViewportWidth())
}
fun `test zoom out then fit restores viewport tracking`() = edtWait {
val viewer = viewer(400, 300)
viewer.art(scene(2_000.0, 1_000.0))
layout(viewer)
val fit = scale(viewer)
viewer.zoomOut()
assertEquals(fit / 1.25, scale(viewer), 1e-6)
viewer.fit()
layout(viewer)
assertEquals(fit, scale(viewer), 1e-6)
assertEquals(0, canvas(viewer).preferredSize.width)
}
fun `test fit refits after the viewport is resized`() = edtWait {
val viewer = viewer(400, 300)
viewer.art(scene(2_000.0, 1_000.0))
layout(viewer)
val narrow = scale(viewer)
viewer.setSize(800, 600)
layout(viewer)
assertTrue(scale(viewer) > narrow)
}
fun `test zoom clamps to the supported range`() = edtWait {
val viewer = viewer(400, 300)
viewer.art(scene(400.0, 300.0))
layout(viewer)
repeat(30) { viewer.zoomIn() }
assertEquals(4.0, scale(viewer), 1e-6)
repeat(60) { viewer.zoomOut() }
assertEquals(0.1, scale(viewer), 1e-6)
}
fun `test canvas exposes a magnificator that scales and reports the anchor`() = edtWait {
val viewer = viewer(400, 300)
viewer.art(scene(400.0, 300.0))
layout(viewer)
val canvas = canvas(viewer)
val magnificator = canvas.getClientProperty(Magnificator.CLIENT_PROPERTY_KEY) as Magnificator
val before = scale(viewer)
val at = magnificator.magnify(2.0, Point(30, 40))
assertEquals(before * 2.0, scale(viewer), 1e-6)
assertEquals(Point(60, 80), at)
}
fun `test control wheel zooms and consumes while a plain wheel scrolls`() = edtWait {
val viewer = viewer(400, 300)
viewer.art(scene(2_000.0, 1_000.0))
layout(viewer)
val fit = scale(viewer)
val plain = wheel(viewer, control = false, rotation = -1)
viewport(viewer).parent.dispatchEvent(plain)
assertFalse(plain.isConsumed)
assertEquals(fit, scale(viewer), 1e-6)
val zoom = wheel(viewer, control = true, rotation = -1)
viewport(viewer).parent.dispatchEvent(zoom)
assertTrue(zoom.isConsumed)
assertEquals(fit * 1.25, scale(viewer), 1e-6)
}
fun `test dragging pans the viewport and clamps at the edges`() = edtWait {
val viewer = viewer(400, 300)
viewer.art(scene(2_000.0, 1_000.0))
layout(viewer)
repeat(4) { viewer.zoomIn() }
layout(viewer)
val canvas = canvas(viewer)
canvas.dispatchEvent(mouse(canvas, MouseEvent.MOUSE_PRESSED, 200, 150))
canvas.dispatchEvent(mouse(canvas, MouseEvent.MOUSE_DRAGGED, 150, 120))
assertEquals(Point(50, 30), viewport(viewer).viewPosition)
canvas.dispatchEvent(mouse(canvas, MouseEvent.MOUSE_DRAGGED, 400, 350))
canvas.dispatchEvent(mouse(canvas, MouseEvent.MOUSE_RELEASED, 400, 350))
assertEquals(Point(0, 0), viewport(viewer).viewPosition)
}
fun `test overlay offers zoom in zoom out and fit`() = edtWait {
val viewer = viewer(400, 300)
val buttons = buttons(viewer)
assertEquals(3, buttons.size)
assertEquals(
listOf(AllIcons.General.ZoomIn, AllIcons.General.ZoomOut, AllIcons.General.FitContent),
buttons.map { it.icon },
)
assertEquals(
listOf(
KiloBundle.message("diagram.zoom.in"),
KiloBundle.message("diagram.zoom.out"),
KiloBundle.message("diagram.zoom.fit"),
),
buttons.map { it.toolTipText },
)
}
fun `test overlay floats above the scroll pane and receives its own clicks`() = edtWait {
val viewer = viewer(400, 300)
viewer.art(scene(2_000.0, 1_000.0))
layout(viewer)
val zoom = buttons(viewer).first()
val at = SwingUtilities.convertPoint(zoom, zoom.width / 2, zoom.height / 2, viewer)
assertSame("the scroll pane must not cover the controls", zoom, SwingUtilities.getDeepestComponentAt(viewer, at.x, at.y))
assertFalse("overlapping layers cannot use optimized drawing", viewer.isOptimizedDrawingEnabled)
}
fun `test overlay buttons drive the zoom`() = edtWait {
val viewer = viewer(400, 300)
viewer.art(scene(2_000.0, 1_000.0))
layout(viewer)
val fit = scale(viewer)
val buttons = buttons(viewer)
buttons[0].doClick()
assertEquals(fit * 1.25, scale(viewer), 1e-6)
buttons[1].doClick()
assertEquals(fit, scale(viewer), 1e-6)
buttons[0].doClick()
buttons[2].doClick()
assertEquals(fit, scale(viewer), 1e-6)
assertEquals(0, canvas(viewer).preferredSize.width)
}
private fun viewer(width: Int, height: Int) = DiagramViewer(palette()).apply {
setSize(width, height)
surface(Color.WHITE)
}
private fun layout(viewer: DiagramViewer) {
viewer.doLayout()
layout(viewer as Container)
}
private fun layout(root: Container) {
root.doLayout()
root.components.filterIsInstance<Container>().forEach(::layout)
}
private fun viewport(viewer: DiagramViewer): JViewport = descendants(viewer)
.filterIsInstance<JBScrollPane>()
.single()
.viewport
private fun canvas(viewer: DiagramViewer) = viewport(viewer).view as DiagramCanvas
private fun scale(viewer: DiagramViewer) = canvas(viewer).scale()
private fun wheel(viewer: DiagramViewer, control: Boolean, rotation: Int): MouseWheelEvent {
val scroll = viewport(viewer).parent
return MouseWheelEvent(
scroll,
MouseEvent.MOUSE_WHEEL,
System.currentTimeMillis(),
if (control) MouseEvent.CTRL_DOWN_MASK else 0,
10,
10,
0,
false,
MouseWheelEvent.WHEEL_UNIT_SCROLL,
1,
rotation,
)
}
private fun mouse(target: JComponent, id: Int, x: Int, y: Int) = MouseEvent(
target,
id,
System.currentTimeMillis(),
MouseEvent.BUTTON1_DOWN_MASK,
x,
y,
1,
false,
MouseEvent.BUTTON1,
)
private fun buttons(root: Container): List<AbstractButton> {
val out = mutableListOf<AbstractButton>()
for (comp in root.components) {
if (comp is AbstractButton) out.add(comp)
if (comp is Container) out.addAll(buttons(comp))
}
return out
}
private fun descendants(root: Container): List<java.awt.Component> {
val out = mutableListOf<java.awt.Component>()
for (comp in root.components) {
out.add(comp)
if (comp is Container) out.addAll(descendants(comp))
}
return out
}
private fun scene(w: Double, h: Double) = Scene(
Type.Flowchart,
listOf(Mark.Box(Rect(0.0, 0.0, w, h), 4.0, Role.Surface, Role.Border)),
Size(w, h),
)
private fun palette() = Palette(
surface = Color.WHITE,
border = Color.BLACK,
text = Color.BLACK,
muted = Color.GRAY,
accent = Color.BLUE,
note = Color.YELLOW,
cluster = Color.LIGHT_GRAY,
line = Color.DARK_GRAY,
font = Font(Font.SANS_SERIF, Font.PLAIN, 12),
bold = Font(Font.SANS_SERIF, Font.BOLD, 12),
)
}
@@ -0,0 +1,111 @@
package ai.kilocode.client.ui.diagram.ui
import ai.kilocode.client.util.edtWait
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Rectangle
/**
* Covers the window bookkeeping without opening a real window: [DiagramWindows] takes its handle
* factory as a dependency, so the reuse, dispose and telemetry paths are exercised against fakes
* while the [com.intellij.openapi.ui.FrameWrapper] wiring stays out of the test.
*/
class DiagramWindowTest : BasePlatformTestCase() {
private val events = mutableListOf<Pair<String, Map<String, String>>>()
private val handles = mutableListOf<FakeHandle>()
fun `test bounds take three quarters of the frame and stay centred`() {
val bounds = diagramWindowBounds(Rectangle(100, 50, 1000, 800))
assertEquals(Rectangle(225, 150, 750, 600), bounds)
}
fun `test bounds survive a degenerate frame`() {
val bounds = diagramWindowBounds(Rectangle(0, 0, 1, 1))
assertEquals(Rectangle(0, 0, 1, 1), bounds)
}
fun `test the same source reuses one window and a different source opens another`() = edtWait {
val windows = windows()
assertTrue(windows.open("flowchart TD\nA-->B"))
assertEquals(1, handles.size)
assertEquals(1, handles.single().shown)
assertEquals(0, handles.single().focused)
assertTrue(windows.open("flowchart TD\nA-->B"))
assertEquals(1, handles.size)
assertEquals(1, handles.single().shown)
assertEquals(1, handles.single().focused)
assertTrue(windows.open("flowchart TD\nA-->C"))
assertEquals(2, handles.size)
assertEquals(listOf(1, 1), handles.map { it.shown })
}
fun `test disposing a window drops it so the next click opens a fresh one`() = edtWait {
val windows = windows()
windows.open("flowchart TD\nA-->B")
Disposer.dispose(handles.single())
windows.open("flowchart TD\nA-->B")
assertEquals(2, handles.size)
assertEquals(0, handles.last().focused)
assertEquals(1, handles.last().shown)
}
fun `test closeAll disposes every open window`() = edtWait {
val windows = windows()
windows.open("flowchart TD\nA-->B")
windows.open("flowchart TD\nA-->C")
windows.closeAll()
windows.open("flowchart TD\nA-->B")
assertEquals(listOf(1, 1, 0), handles.map { it.disposed })
assertEquals(3, handles.size)
}
fun `test opening reports whether the window was reused`() = edtWait {
val windows = windows()
windows.open("flowchart TD\nA-->B")
windows.open("flowchart TD\nA-->B")
assertEquals(listOf("Diagram Viewer Opened", "Diagram Viewer Opened"), events.map { it.first })
assertEquals(listOf("false", "true"), events.map { it.second["reused"] })
assertEquals(listOf("session", "session"), events.map { it.second["surface"] })
}
private fun windows() = DiagramWindows(
project,
{ FakeHandle().also(handles::add) },
{ event, props -> events.add(event to props) },
)
private class FakeHandle : DiagramHandle {
var shown = 0
private set
var focused = 0
private set
var disposed = 0
private set
override fun show() {
shown++
}
override fun focus() {
focused++
}
override fun dispose() {
disposed++
}
}
}
@@ -13,15 +13,24 @@ import ai.kilocode.client.ui.diagram.Size
import ai.kilocode.client.ui.diagram.Spec
import ai.kilocode.client.ui.diagram.Type
import ai.kilocode.client.ui.diagram.ui.DiagramBlock
import ai.kilocode.client.ui.diagram.ui.DiagramHandle
import ai.kilocode.client.ui.diagram.ui.DiagramPanel
import ai.kilocode.client.ui.diagram.ui.DiagramWindows
import ai.kilocode.client.ui.diagram.ui.Diagrams
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.testFramework.replaceService
import com.intellij.util.ui.UIUtil
import java.awt.Cursor
import java.awt.Point
import java.awt.event.MouseEvent
import javax.swing.JComponent
import javax.swing.JPanel
@Suppress("UnstableApiUsage")
@@ -81,6 +90,34 @@ class MdViewDiagramTest : BasePlatformTestCase() {
assertSame(block().copyToolbar, (target as SessionCopyTarget).copyToolbar)
}
fun `test clicking a rendered diagram opens the viewer window`() {
val opened = windows()
view.set("```mermaid\nflowchart TD\nA-->B\n```")
drain()
attach()
click(diagram())
assertEquals(listOf("flowchart TD\nA-->B\n"), opened)
assertEquals(Cursor.HAND_CURSOR, diagram().cursor.type)
}
fun `test the streaming source fallback is not a viewer trigger`() {
// Only the rendered diagram opens the window, so the source pane shown while a fence streams
// (and after an engine error) keeps its plain text behaviour.
val opened = windows()
view.append("```mermaid\nflowchart TD\n")
drain()
attach()
click(codePane() as JComponent)
click(diagram())
assertTrue(codePane().isVisible)
assertFalse(diagram().isVisible)
assertTrue(opened.isEmpty())
}
fun `test engine error keeps source visible`() {
engine.out = Out.Err(ai.kilocode.client.ui.diagram.Fault.Syntax, "bad syntax")
@@ -134,6 +171,38 @@ class MdViewDiagramTest : BasePlatformTestCase() {
private fun drain() = coroutines.drain()
/** Records the sources the transcript hands to the viewer window instead of opening one. */
private fun windows(): List<String> {
val opened = mutableListOf<String>()
val service = DiagramWindows(project, { source -> opened.add(source); NoopHandle() }, { _, _ -> })
project.replaceService(DiagramWindows::class.java, service, testRootDisposable)
return opened
}
/** Puts the transcript under a project data provider so the click can resolve the project. */
private fun attach() {
val panel = DataPanel(project)
panel.add(root())
panel.setSize(400, 400)
panel.doLayout()
}
private fun click(target: JComponent) {
target.dispatchEvent(
MouseEvent(
target,
MouseEvent.MOUSE_CLICKED,
System.currentTimeMillis(),
0,
1,
1,
1,
false,
MouseEvent.BUTTON1,
),
)
}
private fun root() = view.component as JPanel
private fun block() = descendants(root()).filterIsInstance<DiagramBlock>().single()
@@ -155,6 +224,20 @@ class MdViewDiagramTest : BasePlatformTestCase() {
return out
}
private class DataPanel(private val project: Project) : JPanel(), UiDataProvider {
override fun uiDataSnapshot(sink: DataSink) {
sink[CommonDataKeys.PROJECT] = project
}
}
private class NoopHandle : DiagramHandle {
override fun show() = Unit
override fun focus() = Unit
override fun dispose() = Unit
}
private class FakeEngine : Engine {
var calls = 0
var out: Out? = null