diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/MultiThreadView.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/MultiThreadView.kt index a53e8814..e6d11b06 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/MultiThreadView.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/MultiThreadView.kt @@ -36,12 +36,6 @@ import io.github.inductiveautomation.kindling.utils.rowIndices import io.github.inductiveautomation.kindling.utils.selectedRowIndices import io.github.inductiveautomation.kindling.utils.toBodyLine import io.github.inductiveautomation.kindling.utils.transferTo -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.jdesktop.swingx.JXSearchField -import org.jdesktop.swingx.decorator.ColorHighlighter -import org.jdesktop.swingx.table.ColumnControlButton.COLUMN_CONTROL_MARKER import java.awt.Desktop import java.awt.Rectangle import java.nio.file.Files @@ -57,6 +51,12 @@ import kotlin.io.path.inputStream import kotlin.io.path.name import kotlin.io.path.nameWithoutExtension import kotlin.io.path.outputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jdesktop.swingx.JXSearchField +import org.jdesktop.swingx.decorator.ColorHighlighter +import org.jdesktop.swingx.table.ColumnControlButton.COLUMN_CONTROL_MARKER class MultiThreadView( val paths: List, @@ -262,54 +262,61 @@ class MultiThreadView( } } + // Setting the model will fire a selection event. This gets around that. + private var tableModelIsAdjusting = false + private fun updateData() { - BACKGROUND.launch { - val filteredThreadDumps = currentLifespanList.filter { lifespan -> - lifespan.any { - filters.all { threadFilter -> threadFilter.filter(it) } + EDT_SCOPE.launch { + val filteredThreadDumps = withContext(Dispatchers.Default) { + currentLifespanList.filter { lifespan -> + lifespan.any { + filters.all { threadFilter -> threadFilter.filter(it) } + } } } - EDT_SCOPE.launch { - val selectedID = if (!mainTable.selectionModel.isSelectionEmpty) { - /* Maintain selection when model changes */ - val previousSelectedIndex = mainTable.convertRowIndexToModel(mainTable.selectedRow) - mainTable.model[previousSelectedIndex, mainTable.model.columns.id] - } else { - null - } + val selectedID = if (!mainTable.selectionModel.isSelectionEmpty) { + /* Maintain selection when model changes */ + val previousSelectedIndex = mainTable.convertRowIndexToModel(mainTable.selectedRow) + mainTable.model[previousSelectedIndex, mainTable.model.columns.id] + } else { + null + } - val sortedColumnIdentifier = mainTable.sortedColumn?.identifier - val sortOrder = sortedColumnIdentifier?.let(mainTable::getSortOrder) + val sortedColumnIdentifier = mainTable.sortedColumn?.identifier + val sortOrder = sortedColumnIdentifier?.let(mainTable::getSortOrder) - val newModel = ThreadModel(filteredThreadDumps) - mainTable.columnFactory = newModel.columns.toColumnFactory() - mainTable.model = newModel - mainTable.createDefaultColumnsFromModel() - exportMenu.isEnabled = newModel.isSingleContext + val newModel = ThreadModel(filteredThreadDumps) + mainTable.columnFactory = newModel.columns.toColumnFactory() - if (selectedID != null) { - val newSelectedIndex = mainTable.model.threadData.indexOfFirst { lifespan -> - selectedID in lifespan.mapNotNull { thread -> thread?.id } - } - if (newSelectedIndex > -1) { - val newSelectedViewIndex = mainTable.convertRowIndexToView(newSelectedIndex) - mainTable.selectionModel.setSelectionInterval(0, newSelectedViewIndex) - mainTable.scrollRectToVisible(Rectangle(mainTable.getCellRect(newSelectedViewIndex, 0, true))) - } - } + tableModelIsAdjusting = true + mainTable.model = newModel + tableModelIsAdjusting = false - // Set visible and/or sort by previously sorted column - val columnExt = sortedColumnIdentifier?.let(mainTable::getColumnExt) - if (columnExt != null) { - columnExt.isVisible = true - if (sortOrder != null) { - mainTable.setSortOrder(sortedColumnIdentifier, sortOrder) - } + mainTable.createDefaultColumnsFromModel() + exportMenu.isEnabled = newModel.isSingleContext + + if (selectedID != null) { + val newSelectedIndex = mainTable.model.threadData.indexOfFirst { lifespan -> + selectedID in lifespan.mapNotNull { thread -> thread?.id } + } + if (newSelectedIndex > -1) { + val newSelectedViewIndex = mainTable.convertRowIndexToView(newSelectedIndex) + mainTable.selectionModel.setSelectionInterval(0, newSelectedViewIndex) + mainTable.scrollRectToVisible(Rectangle(mainTable.getCellRect(newSelectedViewIndex, 0, true))) } + } - threadCountLabel.visibleThreads = mainTable.model.threadData.flatten().filterNotNull().size + // Set visible and/or sort by previously sorted column + val columnExt = sortedColumnIdentifier?.let(mainTable::getColumnExt) + if (columnExt != null) { + columnExt.isVisible = true + if (sortOrder != null) { + mainTable.setSortOrder(sortedColumnIdentifier, sortOrder) + } } + + threadCountLabel.visibleThreads = mainTable.model.threadData.flatten().filterNotNull().size } } @@ -358,7 +365,7 @@ class MultiThreadView( mainTable.selectionModel.apply { addListSelectionListener { - if (!it.valueIsAdjusting) { + if (!it.valueIsAdjusting && !tableModelIsAdjusting) { val selectedRowIndices = mainTable.selectedRowIndices() if (selectedRowIndices.isNotEmpty()) { comparison.threads = mainTable.model.threadData[selectedRowIndices.first()] @@ -394,7 +401,7 @@ class MultiThreadView( ), comparison, ), - "push, grow, span", + "push, grow, span, wmax 100%", ) sidebar.selectedIndex = 0 @@ -415,8 +422,6 @@ class MultiThreadView( } companion object { - private val BACKGROUND = CoroutineScope(Dispatchers.Default) - private fun List.toLifespanList(): List { val idsToLifespans = mutableMapOf>() forEachIndexed { i, threadDump -> diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/ThreadComparisonPane.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/ThreadComparisonPane.kt index 22c1d1ca..37285d6b 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/ThreadComparisonPane.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/ThreadComparisonPane.kt @@ -12,8 +12,8 @@ import io.github.inductiveautomation.kindling.thread.MultiThreadViewer.ShowEmpty import io.github.inductiveautomation.kindling.thread.MultiThreadViewer.ShowNullThreads import io.github.inductiveautomation.kindling.thread.model.Thread import io.github.inductiveautomation.kindling.thread.model.ThreadLifespan +import io.github.inductiveautomation.kindling.utils.EDT_SCOPE import io.github.inductiveautomation.kindling.utils.FlatScrollPane -import io.github.inductiveautomation.kindling.utils.ScrollingTextPane import io.github.inductiveautomation.kindling.utils.add import io.github.inductiveautomation.kindling.utils.escapeHtml import io.github.inductiveautomation.kindling.utils.getAll @@ -21,22 +21,25 @@ import io.github.inductiveautomation.kindling.utils.jFrame import io.github.inductiveautomation.kindling.utils.style import io.github.inductiveautomation.kindling.utils.tag import io.github.inductiveautomation.kindling.utils.toBodyLine -import net.miginfocom.swing.MigLayout -import org.jdesktop.swingx.JXTaskPane -import org.jdesktop.swingx.JXTaskPaneContainer import java.awt.Color +import java.awt.Dimension import java.awt.Font import java.text.DecimalFormat import java.util.EventListener +import javax.swing.JButton +import javax.swing.JLabel import javax.swing.JPanel +import javax.swing.JTextPane import javax.swing.UIManager import javax.swing.event.EventListenerList import javax.swing.event.HyperlinkEvent import kotlin.properties.Delegates +import kotlinx.coroutines.launch +import net.miginfocom.swing.MigLayout class ThreadComparisonPane( totalThreadDumps: Int, - private val version: String, + version: String, ) : JPanel(MigLayout("fill, ins 0")) { private val listeners = EventListenerList() @@ -44,6 +47,10 @@ class ThreadComparisonPane( updateData() } + private val header = HeaderPanel() + private val body = JPanel((MigLayout("fill, ins 0, hidemode 3"))) + private val footer = if (totalThreadDumps > 3) FooterPanel() else null + private val threadContainers: List = List(totalThreadDumps) { ThreadContainer(version).apply { blockerButton.addActionListener { @@ -55,13 +62,13 @@ class ThreadComparisonPane( } } - private val header = HeaderPanel() - init { ShowNullThreads.addChangeListener { for (container in threadContainers) { container.updateThreadInfo() } + + footer?.reset() } ShowEmptyValues.addChangeListener { for (container in threadContainers) { @@ -76,15 +83,19 @@ class ThreadComparisonPane( add(header, "growx, spanx") add( - FlatScrollPane( - JPanel(MigLayout("fill, hidemode 3, ins 0")).apply { - for (container in threadContainers) { - add(container, "push, grow, sizegroup") - } - }, - ), - "push, grow", + body.apply { + for ((index, container) in threadContainers.withIndex()) { + add(container, "grow, sizegroup, w 33%!") + + if (index > 2) container.isVisible = false + } + }, + "pushy, grow, spanx, w 100%!", ) + + if (footer != null) { + add(footer, "growx") + } } private fun updateData() { @@ -115,8 +126,22 @@ class ThreadComparisonPane( for ((container, thread) in threadContainers.zip(threads)) { container.highlightCpu = highestCpu != null && thread?.cpuUsage == highestCpu container.highlightStacktrace = largestDepth != null && thread?.stacktrace?.size == largestDepth + container.thread = thread } + + footer?.reset() ?: recalculateConstraints() + } + + /* Need to calculate the width of the containers after threads have changed. */ + private fun recalculateConstraints() { + val containerSize = 100 / threadContainers.count { it.isViewable && it.isSelected } + for (container in threadContainers) { + (body.layout as MigLayout).setComponentConstraints( + container, + "grow, sizegroup, w $containerSize%!", + ) + } } fun addBlockerSelectedListener(listener: BlockerSelectedEventListener) { @@ -166,6 +191,81 @@ class ThreadComparisonPane( } } + inner class FooterPanel : JPanel(MigLayout("fill, ins 3")) { + private val nextButton = JButton(nextIcon).apply { + addActionListener { selectNext() } + isEnabled = false + } + + private val previousButton = JButton(previousIcon).apply { + addActionListener { selectPrevious() } + isEnabled = false + } + + private val infoLabel = JLabel().apply { + horizontalAlignment = JLabel.CENTER + } + + private var selectedIndices = emptyList() + set(value) { + field = value + nextButton.isEnabled = canSelectNext + previousButton.isEnabled = canSelectPrevious + infoLabel.text = "Showing threads from dumps " + value.joinToString(", ", postfix = ".") { + (it + 1).toString() + } + + threadContainers.forEachIndexed { index, threadContainer -> + threadContainer.isSelected = index in value + } + + recalculateConstraints() + } + private val canSelectNext: Boolean + get() { + if (selectedIndices.isEmpty()) return false + return threadContainers.indexOfLast { it.isViewable } > selectedIndices.last() + } + + private val canSelectPrevious: Boolean + get() { + if (selectedIndices.isEmpty()) return false + return threadContainers.indexOfFirst { it.isViewable } < selectedIndices.first() + } + + init { + add(previousButton, "west") + add(infoLabel, "growx") + add(nextButton, "east") + } + + fun reset() { + selectedIndices = threadContainers.mapIndexedNotNull { index, threadContainer -> + if (threadContainer.isViewable) index else null + }.take(3).onEach { + threadContainers[it].isSelected = true + } + } + + private fun selectNext() { + val lastSelectedIndex = selectedIndices.lastOrNull() ?: return + val nextIndex = threadContainers.withIndex().find { (index, value) -> + value.isViewable && index > lastSelectedIndex + }?.index ?: return + + selectedIndices = selectedIndices.drop(1) + listOf(nextIndex) + } + + private fun selectPrevious() { + val firstSelectedIndex = selectedIndices.firstOrNull() ?: return + val previousIndex = threadContainers.subList(0, firstSelectedIndex).indexOfLast { it.isViewable } + + if (previousIndex != -1) { + selectedIndices = listOf(previousIndex) + selectedIndices.dropLast(1) + } + } + } + private class BlockerButton : FlatButton() { var blocker: Int? = null set(value) { @@ -181,14 +281,58 @@ class ThreadComparisonPane( } } - private class DetailContainer(val prefix: String) : JXTaskPane() { + private class DetailContainer( + val prefix: String, + collapsed: Boolean = true, + ) : JPanel(MigLayout("ins 0, fill, flowy, hidemode 3")) { + var isCollapsed: Boolean = collapsed + set(value) { + field = value + scrollPane.isVisible = !value + headerButton.icon = if (value) expandIcon else collapseIcon + + // Preferred size needs to be recalculated or something like that + EDT_SCOPE.launch { + revalidate() + repaint() + } + } + var itemCount = 0 set(value) { - title = "$prefix: $value" + headerButton.text = "$prefix: $value" field = value } - private val scrollingTextPane = ScrollingTextPane().apply { + var isHightlighted: Boolean = false + set(value) { + field = value + if (value) { + headerButton.foreground = threadHighlightColor + } else { + headerButton.foreground = UIManager.getColor("Button.foreground") + } + } + + private val headerButton = object : JButton(prefix, if (collapsed) expandIcon else collapseIcon) { + init { + horizontalTextPosition = LEFT + horizontalAlignment = LEFT + + addActionListener { isCollapsed = !isCollapsed } + } + + // This effectively right-aligns the icon + override fun getIconTextGap(): Int { + val fm = getFontMetrics(font) + return size.width - insets.left - insets.right - icon.iconWidth - fm.stringWidth(text) + } + } + + private val textArea = JTextPane().apply { + isEditable = false + contentType = "text/html" + addHyperlinkListener { event -> if (event.eventType == HyperlinkEvent.EventType.ACTIVATED) { HyperlinkStrategy.currentValue.handleEvent(event) @@ -196,17 +340,33 @@ class ThreadComparisonPane( } } - var text: String? by scrollingTextPane::text + private val scrollPane = FlatScrollPane(textArea) { + horizontalScrollBar.preferredSize = Dimension(0, 10) + verticalScrollBar.preferredSize = Dimension(10, 0) + + isVisible = !collapsed + } + + var text: String? + get() = textArea.text + set(value) { + textArea.text = value + } init { - isCollapsed = true - isAnimated = false + add(headerButton, "growx, top, wmax 100%") + add(scrollPane, "push, grow, top, wmax 100%") + } - add(scrollingTextPane) + companion object { + private val collapseIcon = FlatSVGIcon("icons/bx-chevrons-up.svg") + private val expandIcon = FlatSVGIcon("icons/bx-chevrons-down.svg") } } - private class ThreadContainer(private val version: String) : JXTaskPaneContainer() { + private class ThreadContainer( + private val version: String + ) : JPanel(MigLayout("fill, flowy, hidemode 3, gapy 5, ins 0")) { var thread: Thread? by Delegates.observable(null) { _, _, _ -> updateThreadInfo() } @@ -214,6 +374,15 @@ class ThreadComparisonPane( var highlightCpu: Boolean = false var highlightStacktrace: Boolean = true + val isViewable: Boolean + get() = thread != null || ShowNullThreads.currentValue + + var isSelected: Boolean = true + set(value) { + field = value + isVisible = isViewable && value + } + private val titleLabel = FlatLabel() private val detailsButton = FlatButton().apply { icon = detailIcon @@ -231,9 +400,7 @@ class ThreadComparisonPane( private val monitors = DetailContainer("Locked Monitors") private val synchronizers = DetailContainer("Synchronizers") - private val stacktrace = DetailContainer("Stacktrace").apply { - isCollapsed = false - } + private val stacktrace = DetailContainer("Stacktrace", false) init { add( @@ -241,16 +408,17 @@ class ThreadComparisonPane( add(detailsButton) add(titleLabel, "push, grow, gapleft 8") add(blockerButton) - }, + }, "wmax 100%" ) - add(monitors) - add(synchronizers) - add(stacktrace) + // Ensure that the top two don't get pushed below their preferred size. + add(monitors, "grow, h pref:pref:300, top, wmax 100%") + add(synchronizers, "grow, h pref:pref:300, top, wmax 100%") + add(stacktrace, "push, grow, top, wmax 100%") } fun updateThreadInfo() { - isVisible = thread != null || ShowNullThreads.currentValue + isVisible = isViewable && isSelected titleLabel.text = buildString { tag("html") { @@ -326,7 +494,7 @@ class ThreadComparisonPane( stackLine } } - isSpecial = highlightStacktrace + isHightlighted = highlightStacktrace } } } @@ -340,5 +508,8 @@ class ThreadComparisonPane( private val threadHighlightColor: Color get() = UIManager.getColor("Component.warning.focusedBorderColor") + + private val nextIcon = FlatSVGIcon("icons/bx-chevron-right.svg") + private val previousIcon = FlatSVGIcon("icons/bx-chevron-left.svg") } } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/ScrollingTextPane.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/ScrollingTextPane.kt deleted file mode 100644 index 85df4572..00000000 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/ScrollingTextPane.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.github.inductiveautomation.kindling.utils - -import com.formdev.flatlaf.extras.components.FlatScrollPane -import java.awt.Dimension -import java.awt.Rectangle -import javax.swing.JTextPane -import javax.swing.event.HyperlinkListener - -class ScrollingTextPane : FlatScrollPane() { - var text: String? - get() = textPane.text - set(value) { - val lineHeight = 21 - val lineCount = value?.lineSequence()?.count() ?: 1 - val newPrefHeight = ((lineHeight * lineCount) + 2 * horizontalScrollBar.preferredSize.height).coerceAtMost(250) - preferredSize = Dimension(Integer.MAX_VALUE, newPrefHeight) - textPane.text = value - viewport.scrollRectToVisible(Rectangle(0, 0)) - } - - private val textPane = JTextPane().apply { - isEditable = false - contentType = "text/html" - } - - fun addHyperlinkListener(listener: HyperlinkListener) { - textPane.addHyperlinkListener(listener) - } - - init { - setViewportView(textPane) - - horizontalScrollBar.preferredSize = Dimension(0, SCROLLBAR_WIDTH) - verticalScrollBar.preferredSize = Dimension(SCROLLBAR_WIDTH, 0) - - verticalScrollBarPolicy = VERTICAL_SCROLLBAR_ALWAYS - preferredSize = Dimension(Integer.MAX_VALUE, 0) - viewport.scrollRectToVisible(Rectangle(0, 0)) - } - - companion object { - private const val SCROLLBAR_WIDTH = 10 - } -} diff --git a/src/main/resources/icons/bx-chevron-left.svg b/src/main/resources/icons/bx-chevron-left.svg new file mode 100644 index 00000000..23d10731 --- /dev/null +++ b/src/main/resources/icons/bx-chevron-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/icons/bx-chevron-right.svg b/src/main/resources/icons/bx-chevron-right.svg new file mode 100644 index 00000000..21cad9f3 --- /dev/null +++ b/src/main/resources/icons/bx-chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/icons/bx-chevrons-down.svg b/src/main/resources/icons/bx-chevrons-down.svg new file mode 100644 index 00000000..8024d7e7 --- /dev/null +++ b/src/main/resources/icons/bx-chevrons-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/icons/bx-chevrons-up.svg b/src/main/resources/icons/bx-chevrons-up.svg new file mode 100644 index 00000000..f8dc6c7e --- /dev/null +++ b/src/main/resources/icons/bx-chevrons-up.svg @@ -0,0 +1 @@ + \ No newline at end of file