Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[*.{kt,kts}]
max_line_length=120
ij_kotlin_imports_layout=*
ij_kotlin_name_count_to_use_star_import = 2147483647
ij_kotlin_name_count_to_use_star_import_for_members = 2147483647
insert_final_newline = true
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ dependencies {
exclude(group = "org.ow2.asm", module = "asm-debug-all")
}
testLibs(libs.mixinExtras.common)
implementation(libs.jgraphx)

implementation(libs.mappingIo)
implementation(libs.bundles.asm)
Expand Down
3 changes: 2 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ changelog-plugin = { module = "org.jetbrains.changelog:org.jetbrains.changelog.g
coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" }

mappingIo = "net.fabricmc:mapping-io:0.2.1"
mixinExtras-expressions = "io.github.llamalad7:mixinextras-expressions:0.0.4"
mixinExtras-expressions = "io.github.llamalad7:mixinextras-expressions:0.0.5"
jgraphx = "com.github.vlsi.mxgraph:jgraphx:4.2.2"

# GrammarKit
jflex-lib = "org.jetbrains.idea:jflex:1.7.0-b7f882a"
Expand Down
347 changes: 347 additions & 0 deletions src/main/kotlin/platform/mixin/expression/gui/FlowDiagram.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
/*
* Minecraft Development for IntelliJ
*
* https://mcdev.io/
*
* Copyright (C) 2025 minecraft-dev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published
* by the Free Software Foundation, version 3.0 only.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

package com.demonwav.mcdev.platform.mixin.expression.gui

import com.intellij.openapi.application.EDT
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.colors.EditorFontType
import com.intellij.openapi.progress.checkCanceled
import com.intellij.openapi.project.Project
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.mxgraph.layout.hierarchical.mxHierarchicalLayout
import com.mxgraph.model.mxCell
import com.mxgraph.swing.mxGraphComponent
import com.mxgraph.util.mxConstants
import com.mxgraph.util.mxEvent
import com.mxgraph.util.mxRectangle
import com.mxgraph.view.mxGraph
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Dimension
import java.awt.Rectangle
import java.util.SortedMap
import javax.swing.JButton
import javax.swing.JLabel
import javax.swing.JPanel
import javax.swing.JTextField
import javax.swing.JToolBar
import javax.swing.event.DocumentEvent
import javax.swing.event.DocumentListener
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.objectweb.asm.tree.ClassNode
import org.objectweb.asm.tree.MethodNode

private const val OUTER_PADDING = 30.0
private const val INTER_GROUP_SPACING = 75
private const val INTRA_GROUP_SPACING = 75
private const val LINE_NUMBER_STYLE = "LINE_NUMBER"
private const val HIGHLIGHT_STYLE = "HIGHLIGHT"

class FlowDiagram(
val method: MethodNode,
val panel: JPanel,
val scrollToLine: (Int) -> Unit,
) {
companion object {
suspend fun create(project: Project, clazz: ClassNode, method: MethodNode): FlowDiagram? {
val flowGraph = FlowGraph.parse(project, clazz, method) ?: return null
return buildPanel(flowGraph, method)
}
}
}

private suspend fun buildPanel(flowGraph: FlowGraph, method: MethodNode): FlowDiagram {
val graph = MxFlowGraph()
setupStyles(graph)
val groupedCells = addGraphContent(graph, flowGraph)
val lineNumberNodes = sortedMapOf<Int, mxCell>()
val calculateBounds = layOutGraph(graph, groupedCells, lineNumberNodes)

val panel: JPanel
val scrollToLine = withContext(Dispatchers.EDT) {
panel = JPanel(BorderLayout())
displayGraphComponent(graph, panel, calculateBounds, lineNumberNodes)
}
return FlowDiagram(method, panel, scrollToLine)
}

private fun displayGraphComponent(
graph: mxGraph,
panel: JPanel,
calculateBounds: () -> Dimension,
lineNumberNodes: SortedMap<Int, mxCell>
): (Int) -> Unit {
val comp = mxGraphComponent(graph)
fun fixBounds() {
comp.graphControl.preferredSize = calculateBounds()
}

graph.view.addListener(mxEvent.SCALE_AND_TRANSLATE) { _, _ ->
fixBounds()
}
fixBounds()
configureGraphComponent(comp)

val toolbar = createToolbar(comp, ::fixBounds)
panel.add(toolbar, BorderLayout.NORTH)
panel.add(comp, BorderLayout.CENTER)

return { lineNumber ->
lineNumberNodes.tailMap(lineNumber).firstEntry()?.let { (_, node) ->
scrollCellToVisible(comp, node)
}
}
}

private fun scrollCellToVisible(comp: mxGraphComponent, node: mxCell) {
// Scrolls the cell to the top of the screen if possible
val graph = comp.graph
val state = graph.view.getState(node) ?: return
val cellBounds = state.rectangle
val viewRect = comp.viewport.viewRect
val targetRect = Rectangle(
cellBounds.x, cellBounds.y,
1, viewRect.height
)
comp.graphControl.scrollRectToVisible(targetRect)
}

private fun createToolbar(comp: mxGraphComponent, fixBounds: () -> Unit): JToolBar {
val toolbar = JToolBar()
toolbar.isFloatable = false
val zoomInButton = JButton("+")
zoomInButton.toolTipText = "Zoom In"
zoomInButton.addActionListener {
comp.zoomIn()
}
val zoomOutButton = JButton("−")
zoomOutButton.toolTipText = "Zoom Out"
zoomOutButton.addActionListener {
comp.zoomOut()
}
toolbar.add(zoomInButton)
toolbar.add(zoomOutButton)
toolbar.addSeparator(Dimension(20, 0))
toolbar.add(JLabel("Search: "))
toolbar.add(createSearchField(comp, fixBounds))
return toolbar
}

private fun createSearchField(comp: mxGraphComponent, fixBounds: () -> Unit): JTextField {
val graph = comp.graph
val searchField = JTextField()
searchField.document.addDocumentListener(object : DocumentListener {
override fun insertUpdate(e: DocumentEvent) = updateHighlight()

override fun removeUpdate(e: DocumentEvent) = updateHighlight()

override fun changedUpdate(e: DocumentEvent) = updateHighlight()

private fun updateHighlight() {
val searchText = searchField.text.lowercase()
graph.update {
val vertices = graph.getChildVertices(graph.defaultParent)
var scrolled = false

for (cell in vertices) {
cell as mxCell
if (cell.style == LINE_NUMBER_STYLE) {
continue
}
val texts = listOf(
graph.convertValueToString(cell),
graph.getToolTipForCell(cell),
)

if (searchText.isNotEmpty() && texts.any { searchText in it.lowercase() }) {
graph.setCellStyle(HIGHLIGHT_STYLE, arrayOf(cell))
if (!scrolled) {
comp.scrollCellToVisible(cell, true)
comp.zoomTo(1.2, true)
graph.selectionCell = cell
scrolled = true
}
} else {
graph.model.setStyle(cell, null)
}
}
}
comp.refresh()
fixBounds()
}
})
return searchField
}

private class MxFlowGraph : mxGraph() {
override fun getToolTipForCell(cell: Any?): String {
val flow = (cell as? mxCell)?.value as? FlowNode ?: return super.getToolTipForCell(cell)
return flow.longText
}

override fun convertValueToString(cell: Any?): String {
val flow = (cell as? mxCell)?.value as? FlowNode ?: return super.convertValueToString(cell)
return flow.shortText
}
}

private suspend fun addGraphContent(
graph: mxGraph,
flowGraph: FlowGraph
): SortedMap<FlowGroup, List<mxCell>> {
val groupedCells = sortedMapOf<FlowGroup, List<mxCell>>()
graph.update {
fun addFlow(flow: FlowNode, parent: mxCell?, out: (mxCell) -> Unit) {
val node = graph.insertVertex(null, null, flow, 0.0, 0.0, 0.0, 0.0) as mxCell
graph.updateCellSize(node, true)
if (parent != null) {
out(graph.insertEdge(null, null, null, node, parent) as mxCell)
}
for (input in flow.inputs) {
addFlow(input, node, out)
}
out(node)
}

for (group in flowGraph) {
@Suppress("UnstableApiUsage")
checkCanceled()
val cells = mutableListOf<mxCell>()
addFlow(group.root, null, cells::add)
groupedCells[group] = cells
}
}
return groupedCells
}

private suspend fun layOutGraph(
graph: mxGraph,
groupedCells: SortedMap<FlowGroup, List<mxCell>>,
lineNumberNodes: SortedMap<Int, mxCell>
): () -> Dimension {
val layout = mxHierarchicalLayout(graph)
var lastBounds = mxRectangle(0.0, 0.0, 0.0, 0.0)
var maxX = 0.0
var maxY = 0.0
var lastLine: Int? = null
for ((group, list) in groupedCells) {
@Suppress("UnstableApiUsage")
checkCanceled()

val (targetLeft, targetTop) = if (group.lineNumber == lastLine) {
(lastBounds.x + lastBounds.width + INTRA_GROUP_SPACING) to (lastBounds.y)
} else {
val label = graph.insertVertex(
null, null,
"Line ${group.lineNumber}:",
OUTER_PADDING / 2,
maxY + INTER_GROUP_SPACING / 2,
0.0, 0.0,
LINE_NUMBER_STYLE
) as mxCell
lineNumberNodes[group.lineNumber] = label
graph.updateCellSize(label, true)
graph.moveCells(arrayOf(label), 0.0, -graph.view.getState(label).height / 2)
(OUTER_PADDING) to (maxY + INTER_GROUP_SPACING)
}
layout.execute(graph.getDefaultParent(), list)
val cells = list.toTypedArray()
val bounds = graph.view.getBounds(cells)
graph.moveCells(cells, -bounds.x + targetLeft, -bounds.y + targetTop)
lastBounds = mxRectangle(targetLeft, targetTop, bounds.width, bounds.height)
maxX = maxOf(maxX, lastBounds.x + lastBounds.width)
maxY = maxOf(maxY, lastBounds.y + lastBounds.height)
lastLine = group.lineNumber
}

return {
Dimension(
((maxX + OUTER_PADDING) * graph.view.scale).toInt(),
((maxY + OUTER_PADDING) * graph.view.scale).toInt()
)
}
}

private fun setupStyles(graph: mxGraph) {
val colorScheme = EditorColorsManager.getInstance().globalScheme
graph.stylesheet.defaultVertexStyle.let {
it[mxConstants.STYLE_FONTFAMILY] = colorScheme.getFont(EditorFontType.PLAIN).family
it[mxConstants.STYLE_ROUNDED] = true
it[mxConstants.STYLE_FILLCOLOR] = JBUI.CurrentTheme.Button.buttonColorStart().hexString
it[mxConstants.STYLE_FONTCOLOR] = UIUtil.getLabelForeground().hexString
it[mxConstants.STYLE_STROKECOLOR] = JBUI.CurrentTheme.Button.buttonOutlineColorStart(false).hexString
it[mxConstants.STYLE_ALIGN] = mxConstants.ALIGN_CENTER
it[mxConstants.STYLE_VERTICAL_ALIGN] = mxConstants.ALIGN_TOP
it[mxConstants.STYLE_SHAPE] = mxConstants.SHAPE_LABEL
it[mxConstants.STYLE_SPACING] = 5
it[mxConstants.STYLE_SPACING_TOP] = 3
}

graph.stylesheet.defaultEdgeStyle.let {
it[mxConstants.STYLE_STROKECOLOR] = UIUtil.getFocusedBorderColor().hexString
}

graph.stylesheet.putCellStyle(
LINE_NUMBER_STYLE,
mapOf(
mxConstants.STYLE_FONTSIZE to "16",
mxConstants.STYLE_STROKECOLOR to "none",
mxConstants.STYLE_FILLCOLOR to "none",
)
)
graph.stylesheet.putCellStyle(
HIGHLIGHT_STYLE,
mapOf(
mxConstants.STYLE_STROKECOLOR to UIUtil.getFocusedBorderColor().hexString,
mxConstants.STYLE_STROKEWIDTH to "2",
)
)
}

private fun configureGraphComponent(comp: mxGraphComponent) {
val graph = comp.graph
graph.isCellsSelectable = false
graph.isCellsEditable = false
comp.isConnectable = false
comp.isPanning = true
comp.setToolTips(true)
comp.viewport.setOpaque(true)
comp.viewport.setBackground(EditorColorsManager.getInstance().globalScheme.defaultBackground)

comp.zoomAndCenter()
comp.graphControl.isDoubleBuffered = false
comp.graphControl.setOpaque(false)
comp.verticalScrollBar.setUnitIncrement(16)
comp.horizontalScrollBar.setUnitIncrement(16)
}

private val Color.hexString get() = "#%06X".format(rgb)

private inline fun <T> mxGraph.update(routine: () -> T): T {
model.beginUpdate()
try {
return routine()
} finally {
model.endUpdate()
}
}
Loading