Skip to content

Commit 8f5e82e

Browse files
authored
Implement the heatmap window (#121)
1 parent 0e7d426 commit 8f5e82e

File tree

7 files changed

+380
-235
lines changed

7 files changed

+380
-235
lines changed

compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/cascade/CascadePanel.kt

Lines changed: 1 addition & 226 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,10 @@ import com.intellij.openapi.ui.SimpleToolWindowPanel
3232
import com.intellij.openapi.vfs.LocalFileSystem
3333
import com.intellij.psi.PsiDocumentManager
3434
import com.intellij.psi.PsiManager
35-
import com.intellij.ui.OnePixelSplitter
3635
import com.intellij.ui.ScrollPaneFactory
37-
import com.intellij.ui.components.JBLabel
38-
import com.intellij.ui.components.JBPanel
39-
import com.intellij.ui.components.JBScrollPane
4036
import com.intellij.ui.treeStructure.Tree
41-
import com.intellij.util.ui.JBUI
42-
import com.intellij.util.ui.UIUtil
43-
import com.skydoves.compose.stability.idea.settings.StabilitySettingsState
44-
import com.skydoves.compose.stability.runtime.ParameterStability
4537
import org.jetbrains.kotlin.psi.KtNamedFunction
46-
import java.awt.Color
47-
import java.awt.Font
48-
import java.awt.GridBagConstraints
49-
import java.awt.GridBagLayout
5038
import javax.swing.JComponent
51-
import javax.swing.JPanel
5239
import javax.swing.tree.DefaultMutableTreeNode
5340
import javax.swing.tree.DefaultTreeModel
5441

@@ -58,13 +45,9 @@ import javax.swing.tree.DefaultTreeModel
5845
*/
5946
internal class CascadePanel(private val project: Project) {
6047

61-
private val settings: StabilitySettingsState
62-
get() = StabilitySettingsState.getInstance()
63-
6448
private val tree: Tree
6549
private val treeModel: DefaultTreeModel
6650
private val rootNode: DefaultMutableTreeNode
67-
private val detailsPanel: JPanel
6851
private var currentFunction: KtNamedFunction? = null
6952

7053
init {
@@ -76,14 +59,6 @@ internal class CascadePanel(private val project: Project) {
7659
tree.isRootVisible = false
7760
tree.showsRootHandles = true
7861

79-
detailsPanel = createDetailsPanel()
80-
81-
// Selection listener for details
82-
tree.addTreeSelectionListener { event ->
83-
val node = event.path.lastPathComponent as? DefaultMutableTreeNode
84-
updateDetailsPanel(node)
85-
}
86-
8762
// Double-click to navigate to source
8863
tree.addMouseListener(object : java.awt.event.MouseAdapter() {
8964
override fun mouseClicked(e: java.awt.event.MouseEvent) {
@@ -108,12 +83,7 @@ internal class CascadePanel(private val project: Project) {
10883
val toolbar = createToolbar()
10984
mainPanel.toolbar = toolbar
11085

111-
// Create split pane with tree and details
112-
val splitter = OnePixelSplitter(false, 0.65f)
113-
splitter.firstComponent = ScrollPaneFactory.createScrollPane(tree)
114-
splitter.secondComponent = JBScrollPane(detailsPanel)
115-
116-
mainPanel.setContent(splitter)
86+
mainPanel.setContent(ScrollPaneFactory.createScrollPane(tree))
11787

11888
return mainPanel
11989
}
@@ -232,200 +202,6 @@ internal class CascadePanel(private val project: Project) {
232202
}
233203
}
234204

235-
private fun createDetailsPanel(): JPanel {
236-
val panel = JBPanel<JBPanel<*>>(GridBagLayout())
237-
panel.border = JBUI.Borders.empty(10)
238-
239-
val gbc = GridBagConstraints()
240-
gbc.gridx = 0
241-
gbc.gridy = 0
242-
gbc.anchor = GridBagConstraints.NORTHWEST
243-
gbc.fill = GridBagConstraints.HORIZONTAL
244-
gbc.weightx = 1.0
245-
246-
val label = JBLabel("Select a composable to view details")
247-
label.foreground = UIUtil.getInactiveTextColor()
248-
panel.add(label, gbc)
249-
250-
return panel
251-
}
252-
253-
private fun updateDetailsPanel(node: DefaultMutableTreeNode?) {
254-
detailsPanel.removeAll()
255-
detailsPanel.layout = GridBagLayout()
256-
257-
val gbc = GridBagConstraints()
258-
gbc.gridx = 0
259-
gbc.gridy = 0
260-
gbc.anchor = GridBagConstraints.NORTHWEST
261-
gbc.fill = GridBagConstraints.HORIZONTAL
262-
gbc.weightx = 1.0
263-
gbc.insets = JBUI.insets(5)
264-
265-
when (val data = node?.userObject) {
266-
is CascadeTreeNodeData.Composable -> {
267-
val info = data.node.stabilityInfo
268-
val hasUnstable = info.parameters.any {
269-
it.stability == ParameterStability.UNSTABLE
270-
}
271-
val hasRuntime = info.parameters.any {
272-
it.stability == ParameterStability.RUNTIME
273-
}
274-
val isRuntimeOnly = !info.isSkippable && !hasUnstable && hasRuntime
275-
276-
// Title
277-
addDetailRow(detailsPanel, gbc, "Composable:", info.name, bold = true)
278-
279-
// Stability status
280-
val statusText = when {
281-
info.isSkippable -> "STABLE (Skippable)"
282-
isRuntimeOnly -> "RUNTIME (Determined at runtime)"
283-
else -> "UNSTABLE (Non-skippable)"
284-
}
285-
val statusColor = when {
286-
info.isSkippable -> Color(settings.stableGutterColorRGB)
287-
isRuntimeOnly -> Color(settings.runtimeGutterColorRGB)
288-
else -> Color(settings.unstableGutterColorRGB)
289-
}
290-
addDetailRow(detailsPanel, gbc, "Status:", statusText, color = statusColor)
291-
292-
// Restartable
293-
addDetailRow(
294-
detailsPanel,
295-
gbc,
296-
"Restartable:",
297-
if (info.isRestartable) "Yes" else "No",
298-
)
299-
300-
// Depth
301-
addDetailRow(detailsPanel, gbc, "Depth:", "${data.node.depth}")
302-
303-
// Parameters
304-
if (info.parameters.isNotEmpty()) {
305-
gbc.gridy++
306-
val headerLabel = JBLabel("Parameters:")
307-
headerLabel.font = headerLabel.font.deriveFont(Font.BOLD)
308-
detailsPanel.add(headerLabel, gbc)
309-
310-
info.parameters.forEach { param ->
311-
gbc.gridy++
312-
val paramText = "${param.name}: ${param.type}"
313-
val paramStatus = when (param.stability) {
314-
ParameterStability.STABLE -> "Stable"
315-
ParameterStability.RUNTIME -> "Runtime"
316-
ParameterStability.UNSTABLE -> "Unstable"
317-
}
318-
val paramColor = when (param.stability) {
319-
ParameterStability.STABLE -> Color(settings.stableHintColorRGB)
320-
ParameterStability.RUNTIME -> Color(settings.runtimeHintColorRGB)
321-
ParameterStability.UNSTABLE -> Color(settings.unstableHintColorRGB)
322-
}
323-
addDetailRow(detailsPanel, gbc, " $paramText", paramStatus, color = paramColor)
324-
325-
// Show reason if available
326-
if (param.reason != null) {
327-
gbc.gridy++
328-
val reasonLabel = JBLabel(" Reason: ${param.reason}")
329-
reasonLabel.foreground = UIUtil.getInactiveTextColor()
330-
detailsPanel.add(reasonLabel, gbc)
331-
}
332-
}
333-
}
334-
335-
// Location
336-
gbc.gridy++
337-
val fileName = data.node.filePath.substringAfterLast('/')
338-
addDetailRow(detailsPanel, gbc, "File:", "$fileName:${data.node.line}")
339-
340-
// Navigation hint
341-
gbc.gridy++
342-
val navigateLabel = JBLabel("Double-click to navigate to source")
343-
navigateLabel.foreground = UIUtil.getInactiveTextColor()
344-
detailsPanel.add(navigateLabel, gbc)
345-
}
346-
347-
is CascadeTreeNodeData.Summary -> {
348-
addDetailRow(
349-
detailsPanel,
350-
gbc,
351-
"Root Composable:",
352-
data.composableName,
353-
bold = true,
354-
)
355-
addDetailRow(
356-
detailsPanel,
357-
gbc,
358-
"Total Downstream:",
359-
"${data.summary.totalCount}",
360-
)
361-
addDetailRow(
362-
detailsPanel,
363-
gbc,
364-
"Skippable:",
365-
"${data.summary.skippableCount}",
366-
color = Color(settings.stableGutterColorRGB),
367-
)
368-
addDetailRow(
369-
detailsPanel,
370-
gbc,
371-
"Unskippable:",
372-
"${data.summary.unskippableCount}",
373-
color = Color(settings.unstableGutterColorRGB),
374-
)
375-
addDetailRow(
376-
detailsPanel,
377-
gbc,
378-
"Max Depth:",
379-
"${data.summary.maxDepth}",
380-
)
381-
if (data.summary.hasTruncatedBranches) {
382-
addDetailRow(
383-
detailsPanel,
384-
gbc,
385-
"Note:",
386-
"Some branches were truncated",
387-
color = UIUtil.getInactiveTextColor(),
388-
)
389-
}
390-
}
391-
392-
else -> {
393-
val label = JBLabel("Select a composable to view details")
394-
label.foreground = UIUtil.getInactiveTextColor()
395-
detailsPanel.add(label, gbc)
396-
}
397-
}
398-
399-
// Filler at the bottom
400-
gbc.gridy++
401-
gbc.weighty = 1.0
402-
detailsPanel.add(JPanel(), gbc)
403-
404-
detailsPanel.revalidate()
405-
detailsPanel.repaint()
406-
}
407-
408-
private fun addDetailRow(
409-
panel: JPanel,
410-
gbc: GridBagConstraints,
411-
label: String,
412-
value: String,
413-
bold: Boolean = false,
414-
color: Color? = null,
415-
) {
416-
gbc.gridy++
417-
418-
val fullLabel = JBLabel("$label $value")
419-
if (bold) {
420-
fullLabel.font = fullLabel.font.deriveFont(Font.BOLD)
421-
}
422-
if (color != null) {
423-
fullLabel.foreground = color
424-
}
425-
426-
panel.add(fullLabel, gbc)
427-
}
428-
429205
/**
430206
* Refresh action - re-analyzes the current function.
431207
*/
@@ -457,7 +233,6 @@ internal class CascadePanel(private val project: Project) {
457233
override fun actionPerformed(e: AnActionEvent) {
458234
currentFunction = null
459235
showEmptyState()
460-
updateDetailsPanel(null)
461236
}
462237
}
463238
}

compose-stability-analyzer-idea/src/main/kotlin/com/skydoves/compose/stability/idea/heatmap/HeatmapInlayManager.kt

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@ import com.intellij.openapi.editor.EditorCustomElementRenderer
2525
import com.intellij.openapi.editor.EditorFactory
2626
import com.intellij.openapi.editor.Inlay
2727
import com.intellij.openapi.editor.colors.EditorFontType
28+
import com.intellij.openapi.editor.event.EditorMouseEvent
29+
import com.intellij.openapi.editor.event.EditorMouseListener
2830
import com.intellij.openapi.editor.markup.TextAttributes
2931
import com.intellij.openapi.project.Project
32+
import com.intellij.openapi.wm.ToolWindowManager
3033
import com.intellij.psi.PsiDocumentManager
3134
import com.intellij.psi.util.PsiTreeUtil
3235
import com.intellij.util.ui.JBUI
@@ -74,7 +77,41 @@ internal class HeatmapInlayManager(
7477
@Volatile
7578
private var refreshTask: ScheduledFuture<*>? = null
7679

80+
@Volatile
81+
private var clickListenerRegistered = false
82+
83+
/**
84+
* Mouse listener that detects clicks on heatmap block inlays
85+
* and opens the Heatmap tab to show recomposition logs.
86+
*/
87+
private val clickListener = object : EditorMouseListener {
88+
override fun mouseClicked(event: EditorMouseEvent) {
89+
if (event.mouseEvent.clickCount != 1) return
90+
val editor = event.editor
91+
if (editor.project != project) return
92+
93+
val editorKey = System.identityHashCode(editor)
94+
val entries = editorState[editorKey] ?: return
95+
96+
val point = event.mouseEvent.point
97+
for ((name, entry) in entries) {
98+
if (!entry.inlay.isValid) continue
99+
val bounds = entry.inlay.bounds ?: continue
100+
if (bounds.contains(point)) {
101+
openHeatmapPanel(name)
102+
return
103+
}
104+
}
105+
}
106+
}
107+
77108
fun start() {
109+
if (!clickListenerRegistered) {
110+
clickListenerRegistered = true
111+
EditorFactory.getInstance().eventMulticaster
112+
.addEditorMouseListener(clickListener, this)
113+
}
114+
78115
refreshTask = com.intellij.util.concurrency.AppExecutorUtil
79116
.getAppScheduledExecutorService()
80117
.scheduleWithFixedDelay(
@@ -264,7 +301,7 @@ internal class HeatmapInlayManager(
264301
val editorFont = editor.colorsScheme.getFont(EditorFontType.PLAIN)
265302
val smallFont = editorFont.deriveFont(
266303
Font.PLAIN,
267-
editor.colorsScheme.editorFontSize2D.toFloat(),
304+
editor.colorsScheme.editorFontSize2D,
268305
)
269306
g.font = smallFont
270307
val fm = g.fontMetrics
@@ -279,6 +316,25 @@ internal class HeatmapInlayManager(
279316
}
280317
}
281318

319+
/**
320+
* Opens the Heatmap tab in the tool window and displays
321+
* recomposition data for the given composable.
322+
*/
323+
private fun openHeatmapPanel(composableName: String) {
324+
ApplicationManager.getApplication().invokeLater {
325+
val toolWindow = ToolWindowManager.getInstance(project)
326+
.getToolWindow("Compose Stability Analyzer") ?: return@invokeLater
327+
toolWindow.show {
328+
val heatmapContent = toolWindow.contentManager
329+
.findContent("Heatmap") ?: return@show
330+
toolWindow.contentManager.setSelectedContent(heatmapContent)
331+
val panel = heatmapContent.component
332+
.getClientProperty(HeatmapPanel::class.java) as? HeatmapPanel ?: return@show
333+
panel.showComposableData(composableName)
334+
}
335+
}
336+
}
337+
282338
companion object {
283339
private const val REFRESH_INTERVAL_MS = 1000L
284340
fun getInstance(project: Project): HeatmapInlayManager =

0 commit comments

Comments
 (0)