mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix(jetbrains): repair diagram viewer controls
This commit is contained in:
+2
@@ -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)
|
||||
|
||||
+172
@@ -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()
|
||||
}
|
||||
}
|
||||
+60
@@ -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
-48
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-18
@@ -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)
|
||||
}
|
||||
|
||||
+159
@@ -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)
|
||||
}
|
||||
}
|
||||
+145
@@ -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)
|
||||
}
|
||||
+14
@@ -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...
|
||||
|
||||
+2
-1
@@ -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())
|
||||
|
||||
-11
@@ -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()
|
||||
|
||||
+294
@@ -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),
|
||||
)
|
||||
}
|
||||
+111
@@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user