diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80ed6c5b..b12cfb13 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,6 +32,9 @@ jobs: # - name: debug # run: echo "${{ toJSON(github.event)}}" + - name: Gradle Wrapper Validation + uses: gradle/actions/wrapper-validation@v3 + - name: Set up JDK uses: actions/setup-java@v4 with: diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index de882167..4268f143 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -21,6 +21,7 @@ repositories { } dependencies { + implementation(libs.gradlePlugin.kotlin.jvm) implementation(libs.gradlePlugin.bnd) implementation(libs.gradlePlugin.semver) implementation(libs.gradlePlugin.testLogger) diff --git a/build-logic/src/main/kotlin/fireplace.application.gradle.kts b/build-logic/src/main/kotlin/fireplace.application.gradle.kts index 06f20329..7fdf5c94 100644 --- a/build-logic/src/main/kotlin/fireplace.application.gradle.kts +++ b/build-logic/src/main/kotlin/fireplace.application.gradle.kts @@ -11,15 +11,20 @@ plugins { application id("fireplace.tests") + id("org.jetbrains.kotlin.jvm") } -val javaVersion = 21 +val javaVersion = 22 java { toolchain { languageVersion.set(JavaLanguageVersion.of(javaVersion)) } } +kotlin { + jvmToolchain(javaVersion) +} + repositories { mavenCentral() maven { diff --git a/fireplace-app/build.gradle.kts b/fireplace-app/build.gradle.kts index 241cda68..5a097d69 100644 --- a/fireplace-app/build.gradle.kts +++ b/fireplace-app/build.gradle.kts @@ -14,7 +14,6 @@ plugins { // https://github.com/johnrengelman/shadow/pull/876 // https://github.com/johnrengelman/shadow/issues/908 id("io.github.goooler.shadow") version "8.1.7" - kotlin("jvm") version "2.0.0" } description = "Opens a JFR file to inspect its content." diff --git a/fireplace-app/src/main/java/io/github/bric3/fireplace/ui/debug/EventDispatchThreadHangMonitor.java b/fireplace-app/src/main/java/io/github/bric3/fireplace/ui/debug/EventDispatchThreadHangMonitor.java index fbe043de..7f1a0b69 100644 --- a/fireplace-app/src/main/java/io/github/bric3/fireplace/ui/debug/EventDispatchThreadHangMonitor.java +++ b/fireplace-app/src/main/java/io/github/bric3/fireplace/ui/debug/EventDispatchThreadHangMonitor.java @@ -194,7 +194,7 @@ private static class DispatchInfo { private final Thread eventDispatchThread = Thread.currentThread(); // The last time in milliseconds at which we saw a dispatch on the above thread. - private long lastDispatchTimeMillis = System.currentTimeMillis(); + private long lastDispatchTimeNanos = System.nanoTime(); DispatchInfo() { // All initialization is done by the field initializers. @@ -272,7 +272,7 @@ private static boolean stacksEqual(StackTraceElement[] a, StackTraceElement[] b) * Returns how long this dispatch has been going on (in milliseconds). */ private long timeSoFar() { - return (System.currentTimeMillis() - lastDispatchTimeMillis); + return (System.nanoTime() - lastDispatchTimeNanos) / 1000000; } public void dispose() { @@ -349,7 +349,7 @@ private synchronized void postDispatchEvent() { var currentEventDispatchThread = Thread.currentThread(); for (var dispatchInfo : dispatches) { if (dispatchInfo.eventDispatchThread == currentEventDispatchThread) { - dispatchInfo.lastDispatchTimeMillis = System.currentTimeMillis(); + dispatchInfo.lastDispatchTimeNanos = System.nanoTime(); } } } diff --git a/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/Utils.kt b/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/Utils.kt index 20f738f8..d54ec8c4 100644 --- a/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/Utils.kt +++ b/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/Utils.kt @@ -66,12 +66,12 @@ object Utils { return block() } - val start: Long = System.currentTimeMillis() + val start: Long = System.nanoTime() try { return block() } finally { - val elapsed: Long = System.currentTimeMillis() - start + val elapsed: Long = (System.nanoTime() - start) / 1_000_000 // ns -> ms println("[${Thread.currentThread().name}] $name took $elapsed ms") } } diff --git a/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/FlamegraphPane.kt b/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/FlamegraphPane.kt index 96f20122..1dd0faba 100644 --- a/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/FlamegraphPane.kt +++ b/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/FlamegraphPane.kt @@ -9,14 +9,17 @@ */ package io.github.bric3.fireplace.ui +import com.formdev.flatlaf.FlatClientProperties import io.github.bric3.fireplace.Utils import io.github.bric3.fireplace.core.ui.Colors import io.github.bric3.fireplace.core.ui.Colors.Palette import io.github.bric3.fireplace.core.ui.LightDarkColor import io.github.bric3.fireplace.core.ui.SwingUtils import io.github.bric3.fireplace.flamegraph.ColorMapper +import io.github.bric3.fireplace.flamegraph.DefaultFrameRenderer import io.github.bric3.fireplace.flamegraph.DimmingFrameColorProvider import io.github.bric3.fireplace.flamegraph.FlamegraphView +import io.github.bric3.fireplace.flamegraph.FlamegraphView.FrameClickAction.EXPAND_FRAME import io.github.bric3.fireplace.flamegraph.FlamegraphView.HoverListener import io.github.bric3.fireplace.flamegraph.FrameBox import io.github.bric3.fireplace.flamegraph.FrameFontProvider @@ -77,6 +80,7 @@ class FlamegraphPane : JPanel(BorderLayout()) { ) ) jfrFlamegraphView.frameColorProvider = DimmingFrameColorProvider(frameBoxColorFunction) + .withDimNonFocusedFlame(false) jfrFlamegraphView.requestRepaint() }.also { colorPaletteJComboBox.addActionListener(it) @@ -114,6 +118,9 @@ class FlamegraphPane : JPanel(BorderLayout()) { addActionListener { jfrFlamegraphView.resetZoom() } } val searchField = JTextField("").apply { + putClientProperty(FlatClientProperties.PLACEHOLDER_TEXT, "Search") + putClientProperty(FlatClientProperties.TEXT_FIELD_SHOW_CLEAR_BUTTON, true) + addActionListener { val searched = text if (searched.isEmpty()) { @@ -240,33 +247,40 @@ class FlamegraphPane : JPanel(BorderLayout()) { companion object { private val defaultColorPalette = Colors.Palette.DATADOG private val defaultFrameColorMode = BY_PACKAGE - private const val defaultPaintFrameBorder = true + private const val defaultPaintHoveredFrameBorder = false private const val defaultShowMinimap = true private const val defaultIcicleMode = true + private const val defaultRoundedFrame = true private fun getJfrFlamegraphView(): FlamegraphView { val flamegraphView = FlamegraphView() - flamegraphView.setRenderConfiguration( - FrameTextsProvider.of( - Function { frame -> if (frame.isRoot) "root" else frame.actualNode.frame.humanReadableShortString }, - Function { frame -> - if (frame.isRoot) "" else FormatToolkit.getHumanReadable( - frame.actualNode.frame.method, - false, - false, - false, - false, - true, - false + flamegraphView.frameClickAction = EXPAND_FRAME + flamegraphView.setFrameRender( + DefaultFrameRenderer( + FrameTextsProvider.of( + Function { frame -> if (frame.isRoot) "root" else frame.actualNode.frame.humanReadableShortString }, + Function { frame -> + if (frame.isRoot) "" else FormatToolkit.getHumanReadable( + frame.actualNode.frame.method, + false, + false, + false, + false, + true, + false + ) + }, + Function { frame -> if (frame.isRoot) "" else frame.actualNode.frame.method.methodName } + ), + DimmingFrameColorProvider( + defaultFrameColorMode.colorMapperUsing( + ColorMapper.ofObjectHashUsing(*defaultColorPalette.colors()) ) - }, - Function { frame -> if (frame.isRoot) "" else frame.actualNode.frame.method.methodName } - ), - DimmingFrameColorProvider( - defaultFrameColorMode.colorMapperUsing( - ColorMapper.ofObjectHashUsing(*defaultColorPalette.colors()) - ) - ), - FrameFontProvider.defaultFontProvider() + ).withDimNonFocusedFlame(false), + FrameFontProvider.defaultFontProvider() + ).apply { + isPaintHoveredFrameBorder = defaultPaintHoveredFrameBorder + isRoundedFrame = defaultRoundedFrame + } ) val ref = AtomicReference>() diff --git a/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/ThreadFlamegraphView.kt b/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/ThreadFlamegraphView.kt index 1491bdc2..0fc33cee 100644 --- a/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/ThreadFlamegraphView.kt +++ b/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/ThreadFlamegraphView.kt @@ -26,6 +26,7 @@ import java.awt.event.MouseEvent import java.util.concurrent.CompletableFuture import java.util.function.Supplier import javax.swing.* +import javax.swing.border.EmptyBorder import kotlin.collections.Map.Entry abstract class ThreadFlamegraphView(protected val jfrBinder: JFRLoaderBinder) : ViewPanel { @@ -126,7 +127,13 @@ abstract class ThreadFlamegraphView(protected val jfrBinder: JFRLoaderBinder) : } ) - JSplitPane(JSplitPane.HORIZONTAL_SPLIT, JScrollPane(threadList), charts).apply { + JSplitPane( + JSplitPane.HORIZONTAL_SPLIT, + JScrollPane(threadList).apply { + border = EmptyBorder(0, 0, 0, 0) + }, + charts + ).apply { autoSize(0.2) } } diff --git a/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/toolkit/FollowingTipService.kt b/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/toolkit/FollowingTipService.kt index 50dfa385..c10404a3 100644 --- a/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/toolkit/FollowingTipService.kt +++ b/fireplace-app/src/main/kotlin/io/github/bric3/fireplace/ui/toolkit/FollowingTipService.kt @@ -40,7 +40,7 @@ object FollowingTipService { } fun disableFor(component: JComponent) { - followingTip.deinstall(component) + followingTip.uninstall(component) } } @@ -69,6 +69,11 @@ private class FollowingTip { val component: Component when (e.id) { MOUSE_ENTERED, MOUSE_MOVED, MOUSE_DRAGGED, MOUSE_WHEEL -> { + // Don't bother to show tip if the owner window is not focused or active + if (!ownerWindow.isActive || !ownerWindow.isFocused) { + tipWindow.isVisible = false + return@AWTEventListener + } event = e as MouseEvent component = e.component if (ownerWindow.isAncestorOf(component) && component is JComponent) { @@ -85,7 +90,7 @@ private class FollowingTip { } val content = contentProvider?.invoke(component, event) - if (content == null) { + if (content == null || !ownerWindow.isActive || !ownerWindow.isFocused) { tipWindow.isVisible = false return@AWTEventListener } @@ -101,7 +106,7 @@ private class FollowingTip { event = e as MouseEvent component = e.component val p = SwingUtilities.convertPoint(component, event.point, ownerWindow) - if (!ownerWindow.contains(p)) { + if (!ownerWindow.contains(p) || !ownerWindow.isActive || !ownerWindow.isFocused) { tipWindow.isVisible = false } } @@ -138,7 +143,7 @@ private class FollowingTip { } } - fun deinstall(component: JComponent) { + fun uninstall(component: JComponent) { val location = tipWindow.locationOnScreen.apply { SwingUtilities.convertPointFromScreen(this, component) } diff --git a/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/DimmingFrameColorProvider.java b/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/DimmingFrameColorProvider.java index 858dbccd..8768251b 100644 --- a/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/DimmingFrameColorProvider.java +++ b/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/DimmingFrameColorProvider.java @@ -72,6 +72,7 @@ public class DimmingFrameColorProvider implements FrameColorProvider<@NotNull private Color rootBackGroundColor = ROOT_BACKGROUND_COLOR; private Color dimmedTextColor = DIMMED_TEXT_COLOR; + private boolean dimmedNonFocusedFlames = false; /** * Builds a basic frame color provider. @@ -106,7 +107,7 @@ public ColorModel getColors(@NotNull FrameBox<@NotNull T> frame, int flags) { ); } - boolean shouldDimFocusedFlame = isFocusing(flags) && isInFocusedFlame(flags) && !isHighlightedFrame(flags); + var shouldDimFocusedFlame = shouldDimFocusedFlame(flags); if (!rootNode && shouldDim(flags) && !shouldDimFocusedFlame) { backgroundColor = dimmedBackground(baseBackgroundColor); foreground = dimmedTextColor; @@ -173,6 +174,19 @@ public ColorModel getColors(@NotNull FrameBox<@NotNull T> frame, int flags) { return halfDimmedColorCache.computeIfAbsent(backgroundColor, Colors::halfDim); } + /** + * Should dim the frame if it's in the focused flame. + * + * @param flags + * @return + */ + private boolean shouldDimFocusedFlame(int flags) { + return dimmedNonFocusedFlames + && isFocusing(flags) + && isInFocusedFlame(flags) + && !isHighlightedFrame(flags); + } + /** * Dim only if not highlighted or not focused *

@@ -192,10 +206,11 @@ private boolean shouldDim(int flags) { var inFocusedFlame = isInFocusedFlame(flags); var dimmedForHighlighting = highlighting && !highlightedFrame; - var dimmedForFocus = focusing && !inFocusedFlame; + var dimmedForFocus = dimmedNonFocusedFlames && focusing && !inFocusedFlame; + var dimmedInFocusedFlame = dimmedNonFocusedFlames && focusing && inFocusedFlame; return (dimmedForHighlighting || dimmedForFocus) - && !(focusing && inFocusedFlame) // don't dim frames that are in focused flame + && !dimmedInFocusedFlame // don't dim frames that are in focused flame // && !(highlighting && highlightedFrame) // this dim highlighted that are not in focused flame ; } @@ -211,4 +226,10 @@ public DimmingFrameColorProvider withDimmedTextColor(@NotNull Color dimmedTex this.dimmedTextColor = Objects.requireNonNull(dimmedTextColor); return this; } + + @NotNull + public DimmingFrameColorProvider withDimNonFocusedFlame(boolean dimmedNonFocusedFlames) { + this.dimmedNonFocusedFlames = dimmedNonFocusedFlames; + return this; + } } diff --git a/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/FlamegraphRenderEngine.java b/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/FlamegraphRenderEngine.java index 51ee1b4b..4c1f29c9 100644 --- a/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/FlamegraphRenderEngine.java +++ b/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/FlamegraphRenderEngine.java @@ -86,22 +86,22 @@ public int getVisibleDepth() { * Cache for the pre-computed depth given the canvas width. * *

- * This cache leverages the WeakHashMap to cleanup keys that - * are not anymore referenced, this is is useful when the canvas - * changes width to avoid re-computation (i.e traversing the framebox - * list again). + * This cache leverages the WeakHashMap to cleanup keys that + * are not anymore referenced, this is is useful when the canvas + * changes width to avoid re-computation (i.e traversing the framebox + * list again). *

*

- * Note about {@link Integer} cache: - * The default Integer cache goes from -128 to 127 by default (this is tunable), - * these entries won't be reclaimed by GC ! However his code assumes the - * canvas width will be higher, or way higher, in practice than 127. - * If a width in the Integer cache range is entered this means it's - * value won't be reclaimed as well, this is might be acceptable given the - * size of the value, an Integer. - * Currently there's no contingency plan if this get a problem, but if - * if is we might want to look at VM params like {@code -XX:AutoBoxCacheMax} - * and/or {@code java.lang.Integer.IntegerCache.high} property. + * Note about {@link Integer} cache: + * The default Integer cache goes from -128 to 127 by default (this is tunable), + * these entries won't be reclaimed by GC ! However his code assumes the + * canvas width will be higher, or way higher, in practice than 127. + * If a width in the Integer cache range is entered this means it's + * value won't be reclaimed as well, this is might be acceptable given the + * size of the value, an Integer. + * Currently there's no contingency plan if this get a problem, but if + * if is we might want to look at VM params like {@code -XX:AutoBoxCacheMax} + * and/or {@code java.lang.Integer.IntegerCache.high} property. *

*/ private final WeakHashMap visibleDepthCache = new WeakHashMap<>(); @@ -186,11 +186,11 @@ public int computeVisibleFlamegraphMinimapHeight(int thumbnailWidth) { * and this depends on the font metrics). * *

- * This methods don't update internal fields. + * This methods don't update internal fields. *

* - * @param g2 the graphics target ({@code null} not permitted), used for font metrics. - * @param canvasWidth the current canvas width + * @param g2 the graphics target ({@code null} not permitted), used for font metrics. + * @param canvasWidth the current canvas width * @return The height of the visible frames in this flamegraph */ public int computeVisibleFlamegraphHeight( @@ -204,9 +204,9 @@ public int computeVisibleFlamegraphHeight( * Computes the dimensions of the flamegraph for the specified width (just the height needs calculating, * and this depends on the font metrics). * - * @param g2 the graphics target ({@code null} not permitted), used for font metrics. - * @param canvasWidth the current canvas width - * @param update whether to update the internal fields. + * @param g2 the graphics target ({@code null} not permitted), used for font metrics. + * @param canvasWidth the current canvas width + * @param update whether to update the internal fields. * @return The height of the visible frames in this flamegraph */ public int computeVisibleFlamegraphHeight( @@ -244,24 +244,24 @@ public int computeVisibleFlamegraphHeight( * Draws the subset of the flame graph that fits within {@code viewRect} assuming that the whole * flame graph is being rendered within the specified {@code bounds}. * - * @param g2 the graphics target ({@code null} not permitted). - * @param bounds the flame graph bounds ({@code null} not permitted). - * @param viewRect the subset that is being viewed/rendered ({@code null} not permitted). + * @param g2 the graphics target ({@code null} not permitted). + * @param canvasBounds the flame graph canvas bounds ({@code null} not permitted). + * @param viewRect the subset that is being viewed/rendered ({@code null} not permitted). */ public void paint( @NotNull Graphics2D g2, - @NotNull Rectangle2D bounds, + @NotNull Rectangle2D canvasBounds, @NotNull Rectangle2D viewRect ) { - internalPaint(g2, bounds, viewRect, false, icicle); + internalPaint(g2, canvasBounds, viewRect, false, icicle); } /** * Draws the subset of the flame graph that fits within {@code viewRect} assuming that the whole * flame graph is being rendered within the specified {@code bounds}. * - * @param g2 the graphics target ({@code null} not permitted). - * @param size the flame graph bounds ({@code null} not permitted). + * @param g2 the graphics target ({@code null} not permitted). + * @param size the flame graph bounds ({@code null} not permitted). */ public void paintToImage( @NotNull Graphics2D g2, @@ -274,19 +274,19 @@ public void paintToImage( /** * Paints the minimap (always the entire flame graph). * - * @param g2 the graphics target ({@code null} not permitted). - * @param bounds the bounds ({@code null} not permitted). + * @param g2 the graphics target ({@code null} not permitted). + * @param canvasBounds the bounds ({@code null} not permitted). */ public void paintMinimap( @NotNull Graphics2D g2, - @NotNull Rectangle2D bounds + @NotNull Rectangle2D canvasBounds ) { - internalPaint(g2, bounds, bounds, true, icicle); + internalPaint(g2, canvasBounds, canvasBounds, true, icicle); } private void internalPaint( @NotNull Graphics2D g2, - @NotNull Rectangle2D bounds, + @NotNull Rectangle2D canvasBounds, @NotNull Rectangle2D viewRect, boolean minimapMode, boolean icicle @@ -296,12 +296,12 @@ private void internalPaint( } Objects.requireNonNull(g2); - Objects.requireNonNull(bounds); + Objects.requireNonNull(canvasBounds); Objects.requireNonNull(viewRect); Graphics2D g2d = (Graphics2D) g2.create(); identifyDisplayScale(g2d); var frameBoxHeight = minimapMode ? minimapFrameBoxHeight : frameRenderer.getFrameBoxHeight(g2); - var flameGraphWidth = minimapMode ? viewRect.getWidth() : bounds.getWidth(); + var flameGraphWidth = minimapMode ? viewRect.getWidth() : canvasBounds.getWidth(); var frameRect = new Rectangle2D.Double(); // reusable rectangle var frames = frameModel.frames; @@ -313,7 +313,7 @@ private void internalPaint( int internalPadding = 0; // Remove ? frameRect.x = (int) (flameGraphWidth * rootFrame.startX) + internalPadding; frameRect.width = ((int) (flameGraphWidth * rootFrame.endX)) - frameRect.x - internalPadding; - frameRect.y = computeFrameRectY(bounds, frameBoxHeight, rootFrame.stackDepth, icicle); + frameRect.y = computeFrameRectY(canvasBounds, frameBoxHeight, rootFrame.stackDepth, icicle); frameRect.height = frameBoxHeight; rootFrameShape.setFrame(frameRect); @@ -350,7 +350,7 @@ private void internalPaint( continue; } - frameRect.y = computeFrameRectY(bounds, frameBoxHeight, frame.stackDepth, icicle); + frameRect.y = computeFrameRectY(canvasBounds, frameBoxHeight, frame.stackDepth, icicle); frameRect.height = frameBoxHeight; frameShape.setFrame(frameRect); @@ -383,18 +383,39 @@ private void internalPaint( } private static int computeFrameRectY( - @NotNull Rectangle2D bounds, + @NotNull Rectangle2D canvasBounds, int frameBoxHeight, int stackDepth, boolean icicle ) { + // TODO model root box height + @SuppressWarnings("UnnecessaryLocalVariable") + var rootBoxHeight = frameBoxHeight; if (icicle) { - return frameBoxHeight * stackDepth; + // In Icicle, the y increases from 0 in the flamegraph canvas. + // The formula is: adding the root bow height, then the frame box height times the stack depth + // then subtracting the frame box height, as this code returns the y coordinate + // + // | root + // | f1 + // | f2 + // | f3 ↖ y = 3 x frameBoxHeight + return /* 0 + */ rootBoxHeight + (frameBoxHeight * stackDepth - frameBoxHeight); } - var flamegraphHeight = bounds.getHeight(); - - return (int) (flamegraphHeight - frameBoxHeight) - (frameBoxHeight * stackDepth); + var flamegraphHeight = canvasBounds.getHeight(); + + // In flamegraph, the y decreases from the top of the flamegraph canvas. + // The formula is: canvas height minus root box height minus the size + // of the frame box times the stack depth + // + // the bottom y of the frame box is + // the flamegraph height minus the size of the frame box times the stack depth + // | f3 ↖ y = flamegraph height - root box height - (3 x frameBoxHeight) + // | f2 + // | f1 + // | root + return (int) flamegraphHeight - rootBoxHeight - (frameBoxHeight * stackDepth); } private void checkReady() { @@ -589,6 +610,32 @@ public void stopHover( }); } + /** + * As for {@link #calculateZoomTargetForFrameAt(Graphics2D, Rectangle2D, Rectangle2D, Point)} but + * only adjusts the horizontal zoom. + * + * @param g2 the graphics target ({@code null} not permitted). + * @param bounds the bounds within which the flame graph is currently rendered. + * @param viewRect the subset of the bounds that is actually visible + * @param point the coordinates at which to look for a frame. + * @return An optional zoom target. + */ + public Optional> calculateHorizontalZoomTargetForFrameAt( + Graphics2D g2, + Rectangle2D bounds, + Rectangle2D viewRect, + Point point + ) { + if (frameModel.frames.isEmpty()) { + return Optional.empty(); + } + + return getFrameAt(g2, bounds, point).map(frame -> { + this.selectedFrame = frame; + return calculateZoomTargetFrame(g2, bounds, viewRect, frame, -1, 0); + }); + } + /** * Compute the {@code ZoomTarget} for the passed frame. *

@@ -599,7 +646,7 @@ public void stopHover( * @param bounds the bounds within which the flame graph is currently rendered. * @param viewRect the subset of the bounds that is actually visible * @param frame the frame. - * @param contextBefore number of contextual parents + * @param contextBefore number of contextual parents, if -1 don't relocate vertically * @param contextLeftRight the contextual frames on the left and right (unused at this time) * @return A zoom target. */ @@ -611,7 +658,7 @@ public void stopHover( @NotNull Rectangle2D viewRect, @NotNull FrameBox<@NotNull T> frame, int contextBefore, - int contextLeftRight + int contextLeftRight // TODO for future left right "context" padding ) { checkReady(); @@ -619,8 +666,9 @@ public void stopHover( var frameBoxHeight = frameRenderer.getFrameBoxHeight(g2); var factor = getScaleFactor(viewRect.getWidth(), bounds.getWidth(), frameWidthX); - // Change offset to center the flame from this frame + // calculate the new width so the current frame will occupy the full width of the view var newCanvasWidth = (int) (bounds.getWidth() * factor); + // Change offset to center the flame from this frame var newCanvasHeight = computeVisibleFlamegraphHeight( g2, newCanvasWidth @@ -632,21 +680,31 @@ public void stopHover( newCanvasWidth, newCanvasHeight ); + + int frameDepthAtTopOfView = icicle ? + (int) (viewRect.getY() / frameBoxHeight) : + (int) ((bounds.getHeight() - viewRect.getMaxY()) / frameBoxHeight); + + int newFrameDepthAtTopOfView = contextBefore >= 0 ? + Math.max(frame.stackDepth - contextBefore, 0) : + frameDepthAtTopOfView; // contextBefore is -1, so keep the same vertical location + var frameY = computeFrameRectY( newDimension, frameBoxHeight, - Math.max(frame.stackDepth - contextBefore, 0), icicle + newFrameDepthAtTopOfView, + icicle ); var viewLocationY = icicle ? - Math.max(0, frameY) : + Math.max(0, frameY) : // icicle mode, don't go negative, use frame Math.min( (int) (newCanvasHeight - viewRect.getHeight()), (int) (frameY + frameBoxHeight - viewRect.getHeight()) ); return new ZoomTarget<>( - - (int) (frame.startX * newCanvasWidth), - - viewLocationY, + -(int) (frame.startX * newCanvasWidth), + -viewLocationY, newCanvasWidth, newCanvasHeight, frame @@ -663,7 +721,7 @@ public void stopHover( * factor = ---------------------------- * frameWidthX * bounds.width * - * + *

* Note that to retrieve the zoom factor one should use {@code 1 / factor}. */ protected static double getScaleFactor(double visibleWidth, double canvasWidth, double frameWidthX) { diff --git a/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/FlamegraphView.java b/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/FlamegraphView.java index ef612c06..57eeefd0 100644 --- a/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/FlamegraphView.java +++ b/fireplace-swing/src/main/java/io/github/bric3/fireplace/flamegraph/FlamegraphView.java @@ -16,6 +16,7 @@ import org.jetbrains.annotations.Nullable; import javax.swing.*; +import javax.swing.border.EmptyBorder; import javax.swing.event.MouseInputAdapter; import javax.swing.event.MouseInputListener; import java.awt.*; @@ -58,10 +59,12 @@ *


  * var flamegraphView = new FlamegraphView<MyNode>();
  * flamegraphView.setShowMinimap(false);
- * flamegraphView.setRenderConfiguration(
- *     frameTextProvider,           // string representation candidates
- *     frameColorProvider,          // color the frame
- *     frameFontProvider,           // returns a given font for a frame
+ * flamegraphView.setFrameRender(
+ *     new DefaultFrameRenderer(
+ *         frameTextProvider,           // string representation candidates
+ *         frameColorProvider,          // color the frame
+ *         frameFontProvider,           // returns a given font for a frame
+ *     )
  * );
  * flamegraphView.setTooltipTextFunction(
  *     frameToToolTipTextFunction   // text tooltip function
@@ -95,6 +98,7 @@
  * @see FlamegraphRenderEngine
  * @see DefaultFrameRenderer
  */
+@SuppressWarnings("unused")
 public class FlamegraphView {
     /**
      * Internal key to get the Flamegraph from the component.
@@ -134,6 +138,11 @@ public enum Mode {
         FLAMEGRAPH, ICICLEGRAPH
     }
 
+    public enum FrameClickAction {
+        EXPAND_FRAME,
+        SELECT_FRAME,
+    }
+
     /**
      * Represents a custom action when zooming.
      */
@@ -263,10 +272,12 @@ static Point getPointLeveledToFrameDepth(@NotNull MouseEvent mouseEvent, @NotNul
     public FlamegraphView() {
         canvas = new FlamegraphCanvas<>(this);
         // default configuration
-        setRenderConfiguration(
-                FrameTextsProvider.of(frameBox -> frameBox.actualNode.toString()),
-                FrameColorProvider.defaultColorProvider(f -> UIManager.getColor("Button.background")),
-                FrameFontProvider.defaultFontProvider()
+        setFrameRender(
+                new DefaultFrameRenderer<>(
+                        FrameTextsProvider.of(frameBox -> frameBox.actualNode.toString()),
+                        FrameColorProvider.defaultColorProvider(f -> UIManager.getColor("Button.background")),
+                        FrameFontProvider.defaultFontProvider()
+                )
         );
         canvas.putClientProperty(OWNER_KEY, this);
         scrollPaneListener = new FlamegraphHoveringScrollPaneMouseListener<>(canvas);
@@ -301,7 +312,7 @@ public FlamegraphView() {
         });
 
         component = wrap(layeredScrollPane, bg -> {
-            scrollPane.setBorder(null);
+            scrollPane.setBorder(new EmptyBorder(0, 0, 0, 0));
             scrollPane.setBackground(bg);
             scrollPane.getVerticalScrollBar().setBackground(bg);
             scrollPane.getHorizontalScrollBar().setBackground(bg);
@@ -341,30 +352,57 @@ public void layoutContainer(Container parent) {
                         // The view location is also updated.
 
                         var vp = (JViewport) parent;
-                        var canvas = (FlamegraphCanvas) vp.getView();
+                        var view = vp.getView();
+                        if (!(view instanceof FlamegraphCanvas)) {
+                            // failsafe in case this layout is used elsewhere
+                            super.layoutContainer(parent);
+                        }
+
+                        var canvas = (FlamegraphCanvas) view;
                         int oldVpWidth = oldViewPortSize.width;
                         var vpSize = vp.getSize(oldViewPortSize);
+                        var oldFlamegraphHeight = flamegraphSize.height;
 
-                        // Never show the horizontal scrollbar when the scale factor is 1.0
-                        // Only change it when necessary
-                        int horizontalScrollBarPolicy = jScrollPane.getHorizontalScrollBarPolicy();
                         double lastScaleFactor = canvas.zoomModel.getLastScaleFactor();
-                        int newPolicy = lastScaleFactor == 1.0 ?
-                                        ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER :
-                                        ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED;
-                        if (horizontalScrollBarPolicy != newPolicy) {
-                            jScrollPane.setHorizontalScrollBarPolicy(newPolicy);
-                        }
 
                         // view port has been resized
                         if (vpSize.width != oldVpWidth) {
+                            // Computes the dimension as if a vertical scrollbar was needed.
+                            //
+                            // Otherwise, the layout can enter a loop:
+                            // Because the view port width is called once with full width,
+                            // which computes a canvas with a taller dimension than viewport.
+                            // This triggers the horizontal scrollbar to be added, which
+                            // triggers another layout.
+                            // In this layout, the view port width is shorter by the scrollbar width,
+                            // which makes the canvas fitting in the view port,
+                            // which then triggers annoter layout that removes the vertical scrollbar,
+                            // and then starts again.
+                            //
+                            // Note the scrollbar visibility is updated at the end of this control block
+
+                            var vsb = jScrollPane.getVerticalScrollBar();
+                            int currentVpWidth = vpSize.width - (vsb.isVisible() ? 0 : vsb.getWidth());
+
                             // scale the fg size with the new viewport width
                             canvas.updateFlamegraphDimension(
                                     flamegraphSize,
-                                    (int) (((double) vpSize.width) / lastScaleFactor)
+                                    (int) (((double) currentVpWidth) / lastScaleFactor)
                             );
+                            // Ensure the canvas take up the whole height (helps when drawing the minimap)
+                            flamegraphSize.height = Math.max(vpSize.height, flamegraphSize.height);
                             vp.setViewSize(flamegraphSize);
 
+                            // Handles the view location change when the flamegraph is changing its height,
+                            // i.e., there are less or more frames visible
+                            // First compute the last y offset from the bottom
+                            int flamegraphYFromBottom = oldFlamegraphHeight - Math.abs(flamegraphLocation.y);
+                            // then compute the new y offset from the bottom using the new flamegraph height
+                            int yLocation = canvas.getMode() == Mode.FLAMEGRAPH ?
+                                            flamegraphSize.height - flamegraphYFromBottom :
+                                            flamegraphLocation.y;
+                            flamegraphLocation.y = Math.abs(yLocation);
+
                             // if view position X > 0
                             //   the fg is zoomed
                             //   => get the latest position ratio resulting from user interaction
@@ -374,10 +412,9 @@ public void layoutContainer(Container parent) {
                                 double positionRatio = canvas.zoomModel.getLastUserInteractionStartX();
 
                                 flamegraphLocation.x = Math.abs((int) (positionRatio * flamegraphSize.width));
-                                flamegraphLocation.y = Math.abs(flamegraphLocation.y);
-
-                                vp.setViewPosition(flamegraphLocation);
                             }
+
+                            vp.setViewPosition(flamegraphLocation);
                         } else {
                             super.layoutContainer(parent);
                             // capture the sizes
@@ -385,6 +422,35 @@ public void layoutContainer(Container parent) {
                             canvas.getSize(flamegraphSize);
                             canvas.getLocation(flamegraphLocation);
                         }
+                        
+                        {
+                            // Never show the vertical scrollbar when the flamegraph fits in the vp
+                            int newPolicy = flamegraphSize.height <= vpSize.height ?
+                                            ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER :
+                                            ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS;
+                            // Only change it when necessary
+                            if (jScrollPane.getVerticalScrollBarPolicy() != newPolicy) {
+                                jScrollPane.setVerticalScrollBarPolicy(newPolicy);
+                            }
+
+                            // show the horizontal scrollbar if the flamegraph is wider than the viewport
+                            jScrollPane.getVerticalScrollBar().setVisible(flamegraphSize.height > vpSize.height);
+                        }
+
+                        {
+                            // Never show the horizontal scrollbar when the scale factor is 1.0
+                            int newPolicy = lastScaleFactor == 1.0 ?
+                                            ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER :
+                                            ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED;
+                            // Only change it when necessary
+                            if (jScrollPane.getHorizontalScrollBarPolicy() != newPolicy) {
+                                jScrollPane.setHorizontalScrollBarPolicy(newPolicy);
+                            }
+
+                            // show the horizontal scrollbar if the flamegraph is wider than the viewport
+                            jScrollPane.getHorizontalScrollBar()
+                                       .setVisible(lastScaleFactor != 1.0 && oldVpWidth < flamegraphSize.width);
+                        }
                     }
                 };
             }
@@ -621,6 +687,24 @@ public FlamegraphView.Mode getMode() {
         return canvas.getMode();
     }
 
+    /**
+     * Sets the frame click action.
+     *
+     * @param frameClickAction The zoom action.
+     */
+    public void setFrameClickAction(@NotNull FlamegraphView.FrameClickAction frameClickAction) {
+        canvas.setFrameClickBehavior(frameClickAction);
+    }
+
+    /**
+     * Returns the current frame click action.
+     *
+     * @return the current frame click action.
+     */
+    public @NotNull FlamegraphView.FrameClickAction getFrameClickAction() {
+        return canvas.getFrameClickBehavior();
+    }
+
     /**
      * Replaces the default tooltip component.
      *
@@ -855,7 +939,7 @@ public void overrideZoomAction(@NotNull FlamegraphView.ZoomAction zoomActionOver
      * Reset the zoom to 1:1.
      */
     public void resetZoom() {
-        zoom(canvas, canvas.getResetZoomTarget());
+        zoom(canvas, canvas.getResetZoomTarget(false));
     }
 
     /**
@@ -885,20 +969,7 @@ private static  void zoom(@NotNull FlamegraphCanvas<@NotNull T> canvas, @Null
             return;
         }
 
-        // adjust zoom target location for horizontal scrollbar height if canvas bigger than viewRect
-        if (canvas.getMode() == Mode.FLAMEGRAPH) {
-            var visibleRect = canvas.getVisibleRect();
-            var viewPort = (JViewport) SwingUtilities.getUnwrappedParent(canvas);
-            var scrollPane = (JScrollPane) viewPort.getParent();
-
-            var hsb = scrollPane.getHorizontalScrollBar();
-            if (!hsb.isVisible() && visibleRect.getWidth() < zoomTarget.getWidth()) {
-                var modifiedRect = zoomTarget.getTargetBounds();
-                modifiedRect.y -= hsb.getPreferredSize().height;
-
-                zoomTarget = new ZoomTarget<>(modifiedRect, zoomTarget.targetFrame);
-            }
-        }
+        zoomTarget = canvas.adjustedZoomTargetForHsbVisibility(zoomTarget, false);
 
         // Set the zoom model to the Zoom Target
         canvas.zoomModel.recordLastPositionFromZoomTarget(canvas, zoomTarget);
@@ -955,6 +1026,8 @@ static class FlamegraphCanvas extends JPanel implements ZoomableComponent
         private boolean showMinimap = true;
         @Nullable
         private Supplier<@NotNull JToolTip> tooltipComponentSupplier;
+        @NotNull
+        public FrameClickAction frameClickBehavior = FrameClickAction.SELECT_FRAME;
         @Nullable
         private ZoomAction zoomActionOverride;
         @Nullable
@@ -963,6 +1036,7 @@ static class FlamegraphCanvas extends JPanel implements ZoomableComponent
         private BiConsumer<@NotNull FrameBox<@NotNull T>, @NotNull MouseEvent> selectedFrameConsumer;
         @NotNull
         private final FlamegraphView<@NotNull T> flamegraphView;
+        @NotNull
         private final ZoomModel zoomModel = new ZoomModel<>();
 
         private long lastDrawTime;
@@ -990,7 +1064,7 @@ public void addNotify() {
             // from appearing on first display, see #96.
             // Since a scrollbar is made visible once, this listener is called only once,
             // which is the intended behavior (otherwise it affects zooming).
-            var parent = SwingUtilities.getUnwrappedParent(fgCanvas);
+            var parent = fgCanvas.getParent();
             if (parent instanceof JViewport) {
                 var viewport = (JViewport) parent;
                 var scrollPane = (JScrollPane) viewport.getParent();
@@ -1028,12 +1102,19 @@ public void componentShown(ComponentEvent e) {
                     }
                 });
 
-                installMinimapTriggers(fgCanvas, vsb);
-                installVerticalScrollBarListeners(fgCanvas, vsb);
+                installMinimapTriggers(fgCanvas);
+
+                var hsb = scrollPane.getHorizontalScrollBar();
+                installGraphModeListener(fgCanvas, scrollPane, vsb, hsb);
             }
         }
 
-        private void installVerticalScrollBarListeners(FlamegraphCanvas fgCanvas, JScrollBar vsb) {
+        private void installGraphModeListener(
+                FlamegraphCanvas fgCanvas,
+                JScrollPane scrollPane,
+                JScrollBar vsb,
+                JScrollBar hsb
+        ) {
             fgCanvas.addPropertyChangeListener(GRAPH_MODE_PROPERTY, evt -> SwingUtilities.invokeLater(() -> {
                 var value = vsb.getValue();
                 var bounds = fgCanvas.getBounds();
@@ -1042,6 +1123,8 @@ private void installVerticalScrollBarListeners(FlamegraphCanvas fgCanvas, JSc
                 // This computes the new view location based on the current view location
                 switch (fgCanvas.getMode()) {
                     case ICICLEGRAPH:
+                        // use the component add rather than setHorizontalScrollBar which does more things
+                        scrollPane.add(hsb, ScrollPaneConstants.HORIZONTAL_SCROLLBAR);
                         vsb.setValue(
                                 value == vsb.getMaximum() ?
                                 vsb.getMinimum() :
@@ -1049,6 +1132,7 @@ private void installVerticalScrollBarListeners(FlamegraphCanvas fgCanvas, JSc
                         );
                         break;
                     case FLAMEGRAPH:
+                        scrollPane.setColumnHeaderView(hsb);
                         vsb.setValue(
                                 value == vsb.getMinimum() ?
                                 vsb.getMaximum() :
@@ -1059,7 +1143,7 @@ private void installVerticalScrollBarListeners(FlamegraphCanvas fgCanvas, JSc
             }));
         }
 
-        private void installMinimapTriggers(FlamegraphCanvas fgCanvas, JScrollBar vsb) {
+        private void installMinimapTriggers(FlamegraphCanvas fgCanvas) {
             PropertyChangeListener triggerMinimapOnPropertyChange = evt -> {
                 var propertyName = evt.getPropertyName();
                 if (!propertyName.equals("preferredSize")
@@ -1127,29 +1211,45 @@ protected Dimension updateFlamegraphDimension(@NotNull Dimension dimension, int
             return dimension;
         }
 
+
+        private final Rectangle reusableMinimapRect = new Rectangle();
+        private final Rectangle reusableVisibleRect = new Rectangle();
+
+        private Rectangle computeMinimapRect() {
+            computeVisibleRect(reusableVisibleRect);
+            reusableMinimapRect.setBounds(
+                    reusableVisibleRect.x + minimapBounds.x,
+                    reusableVisibleRect.y + (getMode() == Mode.ICICLEGRAPH ? reusableVisibleRect.height - minimapBounds.height - minimapBounds.y : minimapBounds.y),
+                    minimapBounds.width + (2 * minimapInset),
+                    minimapBounds.height + (2 * minimapInset)
+            );
+
+            return reusableMinimapRect;
+        }
+
         @Override
         protected void paintComponent(@NotNull Graphics g) {
-            long start = System.currentTimeMillis();
+            long startNanos = System.nanoTime();
 
             super.paintComponent(g);
             var g2 = (Graphics2D) g.create();
-            var visibleRect = getVisibleRect();
+            computeVisibleRect(reusableVisibleRect);
             if (flamegraphRenderEngine == null) {
                 String message = "No data to display";
                 var font = g2.getFont();
                 // calculate center position
                 var bounds = g2.getFontMetrics(font).getStringBounds(message, g2);
-                int xx = visibleRect.x + (int) ((visibleRect.width - bounds.getWidth()) / 2.0);
-                int yy = visibleRect.y + (int) ((visibleRect.height + bounds.getHeight()) / 2.0);
+                int xx = reusableVisibleRect.x + (int) ((reusableVisibleRect.width - bounds.getWidth()) / 2.0);
+                int yy = reusableVisibleRect.y + (int) ((reusableVisibleRect.height + bounds.getHeight()) / 2.0);
                 g2.drawString(message, xx, yy);
                 g2.dispose();
                 return;
             }
 
-            flamegraphRenderEngine.paint(g2, getBounds(), visibleRect);
-            paintMinimap(g2, visibleRect);
+            flamegraphRenderEngine.paint(g2, getBounds(), reusableVisibleRect);
+            paintMinimap(g2, reusableVisibleRect);
 
-            lastDrawTime = System.currentTimeMillis() - start;
+            lastDrawTime = (System.nanoTime() - startNanos) / 1_000_000;
             paintDetails(g2);
             g2.dispose();
         }
@@ -1186,13 +1286,14 @@ private void paintDetails(@NotNull Graphics2D g2) {
             }
         }
 
-        private void paintMinimap(@NotNull Graphics g, @NotNull Rectangle visibleRect) {
+        private void paintMinimap(@NotNull Graphics g, Rectangle visibleRect) {
             if (showMinimap && minimap != null) {
+                var minimapRect = computeMinimapRect();
                 var g2 = (Graphics2D) g.create(
-                        visibleRect.x + minimapBounds.x,
-                        visibleRect.y + visibleRect.height - minimapBounds.height - minimapBounds.y,
-                        minimapBounds.width + minimapInset * 2,
-                        minimapBounds.height + minimapInset * 2
+                        minimapRect.x,
+                        minimapRect.y,
+                        minimapRect.width,
+                        minimapRect.height
                 );
 
                 g2.setColor(getBackground());
@@ -1263,15 +1364,8 @@ public boolean isInsideMinimap(@NotNull Point point) {
             if (!showMinimap) {
                 return false;
             }
-            var visibleRect = getVisibleRect();
-            var rectangle = new Rectangle(
-                    visibleRect.x + minimapBounds.y,
-                    visibleRect.y + visibleRect.height - minimapBounds.height - minimapBounds.y,
-                    minimapBounds.width + 2 * minimapInset,
-                    minimapBounds.height + 2 * minimapInset
-            );
 
-            return rectangle.contains(point);
+            return computeMinimapRect().contains(point);
         }
 
         public void setToolTipText(FrameBox frame) {
@@ -1339,11 +1433,7 @@ private void setMinimapImage(@NotNull BufferedImage minimapImage) {
         }
 
         private void repaintMinimapArea() {
-            var visibleRect = getVisibleRect();
-            repaint(visibleRect.x + minimapBounds.x,
-                    visibleRect.y + visibleRect.height - minimapBounds.height - minimapBounds.y,
-                    minimapBounds.width + minimapInset * 2,
-                    minimapBounds.height + minimapInset * 2);
+            repaint(computeMinimapRect());
         }
 
         public void setupListeners(@NotNull JScrollPane scrollPane) {
@@ -1407,18 +1497,18 @@ private void processMinimapMouseEvent(@NotNull MouseEvent e) {
                     if (!(e.getComponent() instanceof FlamegraphView.FlamegraphCanvas)) {
                         return;
                     }
+                    var canvas = (FlamegraphCanvas) e.getComponent();
 
-                    var visibleRect = ((FlamegraphCanvas) e.getComponent()).getVisibleRect();
 
                     double zoomZoneScaleX = (double) minimapBounds.width / flamegraphDimension.width;
                     double zoomZoneScaleY = (double) minimapBounds.height / flamegraphDimension.height;
+                    var minimapRect = canvas.computeMinimapRect();
 
-                    var h = (pt.x - (visibleRect.x + minimapBounds.x)) / zoomZoneScaleX;
+                    var h = (pt.x - minimapRect.x) / zoomZoneScaleX;
                     var horizontalBarModel = scrollPane.getHorizontalScrollBar().getModel();
                     horizontalBarModel.setValue((int) h - horizontalBarModel.getExtent());
 
-
-                    var v = (pt.y - (visibleRect.y + visibleRect.height - minimapBounds.height - minimapBounds.y)) / zoomZoneScaleY;
+                    var v = (pt.y - minimapRect.y) / zoomZoneScaleY;
                     var verticalBarModel = scrollPane.getVerticalScrollBar().getModel();
                     verticalBarModel.setValue((int) v - verticalBarModel.getExtent());
                 }
@@ -1532,24 +1622,37 @@ public void setSelectedFrameConsumer(
             return selectedFrameConsumer;
         }
 
+        public void setFrameClickBehavior(@NotNull FrameClickAction frameClickBehavior) {
+            this.frameClickBehavior = frameClickBehavior;
+        }
+
+        @NotNull
+        public FrameClickAction getFrameClickBehavior() {
+            return frameClickBehavior;
+        }
+
         @Nullable
-        public ZoomTarget<@NotNull T> getResetZoomTarget() {
+        public ZoomTarget<@NotNull T> getResetZoomTarget(boolean resetHorizontalOnly) {
             var graphics = (Graphics2D) getGraphics();
             if (graphics == null) {
                 return null;
             }
 
             var visibleRect = getVisibleRect();
-            var bounds = getBounds();
+            var canvasBounds = getBounds();
 
             var newHeight = flamegraphRenderEngine.computeVisibleFlamegraphHeight(
                     graphics,
                     visibleRect.width
             );
 
+            boolean isFlameGraph = getMode() == Mode.FLAMEGRAPH;
+            int newY = resetHorizontalOnly ?
+                       (isFlameGraph ? -(newHeight - (canvasBounds.height - Math.abs(canvasBounds.y))) : canvasBounds.y) :
+                       (isFlameGraph ? -(canvasBounds.height - visibleRect.height) : 0);
             return new ZoomTarget<>(
                     0,
-                    getMode() == Mode.FLAMEGRAPH ? -(bounds.height - visibleRect.height) : 0,
+                    newY,
                     visibleRect.width,
                     newHeight,
                     null
@@ -1584,8 +1687,36 @@ public void zoom(@NotNull ZoomTarget<@NotNull T> zoomTarget) {
             // Changing the size triggers a revalidation, which triggers a layout
             // Not calling setBounds from the Timeline may provoke EDT violations
             // however calling invokeLater makes the animation out of order, and not smooth.
+
             setBounds(zoomTarget.getTargetBounds());
         }
+
+
+        /**
+         * Adjust the zoom target location for horizontal scrollbar height if canvas bigger than viewRect.
+         * This only applies to flamegraph mode.
+         *
+         * @param zoomTarget The zoom target.
+         * @return An adjusted zoom target instance, or the passed zoom target if no adjustment is needed.
+         */
+        private ZoomTarget<@NotNull T> adjustedZoomTargetForHsbVisibility(
+                @NotNull ZoomTarget<@NotNull T> zoomTarget,
+                boolean ignoreVisibility
+        ) {
+            if (this.getMode() == Mode.FLAMEGRAPH) {
+                var visibleRect = this.getVisibleRect();
+                var scrollPane = (JScrollPane) this.getParent().getParent();
+
+                var hsb = scrollPane.getHorizontalScrollBar();
+                if ((ignoreVisibility || !hsb.isVisible()) && visibleRect.getWidth() < zoomTarget.getWidth()) {
+                    var modifiedRect = zoomTarget.getTargetBounds();
+                    modifiedRect.y += hsb.getPreferredSize().height;
+
+                    zoomTarget = new ZoomTarget<>(modifiedRect, zoomTarget.targetFrame);
+                }
+            }
+            return zoomTarget;
+        }
     }
 
     /**
@@ -1668,32 +1799,64 @@ public void mouseClicked(@NotNull MouseEvent e) {
                 return;
             }
 
-            var flamegraphView = FlamegraphView.from(canvas).get();
-
-            if (e.getClickCount() == 2) {
-                // find zoom target then do an animated transition
-                canvas.getFlamegraphRenderEngine().calculateZoomTargetForFrameAt(
-                        (Graphics2D) canvas.getGraphics(),
-                        canvas.getBounds(tmpBounds),
-                        canvas.getVisibleRect(),
-                        latestMouseLocation
-                ).ifPresent(zoomTarget -> {
-                    if (Objects.equals(canvas.getBounds(), zoomTarget.getTargetBounds())) {
-                        flamegraphView.resetZoom();
-                    } else {
-                        zoom(canvas, zoomTarget);
+            var canvasBounds = canvas.getBounds(tmpBounds);
+            var fgre = canvas.getFlamegraphRenderEngine();
+            switch (canvas.frameClickBehavior) {
+                case EXPAND_FRAME:
+                    if (e.getClickCount() == 1) {
+                        fgre.toggleSelectedFrameAt(
+                                (Graphics2D) viewPort.getView().getGraphics(),
+                                canvasBounds,
+                                latestMouseLocation,
+                                (frame, r) -> canvas.repaint()
+                        );
+                        // TODO weird behavior on iciclegraph, both expand and shrink
+                        // this appear to be related to the horizontal scrollbar
+                        fgre.calculateHorizontalZoomTargetForFrameAt(
+                                (Graphics2D) canvas.getGraphics(),
+                                canvasBounds,
+                                canvas.getVisibleRect(),
+                                latestMouseLocation
+                        ).ifPresent(zoomTarget -> {
+                            var targetBounds = zoomTarget.getTargetBounds();
+                            // Don't include height as the view rect might be taller that the flamegraph height
+                            if (canvasBounds.x == targetBounds.x && canvasBounds.y == targetBounds.y
+                                && canvasBounds.width == targetBounds.width) {
+                                zoom(canvas, canvas.getResetZoomTarget(true));
+                            } else {
+                                zoom(canvas, zoomTarget);
+                            }
+                        });
+                    }
+                    break;
+                case SELECT_FRAME:
+                    if (e.getClickCount() == 2) {
+                        // find zoom target then do an animated transition
+                        fgre.calculateZoomTargetForFrameAt(
+                                (Graphics2D) canvas.getGraphics(),
+                                canvasBounds,
+                                canvas.getVisibleRect(),
+                                latestMouseLocation
+                        ).ifPresent(zoomTarget -> {
+                            if (Objects.equals(canvasBounds, zoomTarget.getTargetBounds())) {
+                                zoom(canvas, canvas.getResetZoomTarget(false));
+                            } else {
+                                zoom(canvas, zoomTarget);
+                            }
+                        });
+                        return;
+                    }
+
+                    if (e.getClickCount() == 1) {
+                        fgre.toggleSelectedFrameAt(
+                                (Graphics2D) viewPort.getView().getGraphics(),
+                                canvasBounds,
+                                latestMouseLocation,
+                                (frame, r) -> canvas.repaint()
+                        );
                     }
-                });
-                return;
+                    break;
             }
-
-            canvas.getFlamegraphRenderEngine()
-                  .toggleSelectedFrameAt(
-                          (Graphics2D) viewPort.getView().getGraphics(),
-                          canvas.getBounds(tmpBounds),
-                          latestMouseLocation,
-                          (frame, r) -> canvas.repaint()
-                  );
         }
 
 
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index b82aa23a..09523c0e 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
 networkTimeout=10000
 validateDistributionUrl=true
 zipStoreBase=GRADLE_USER_HOME
diff --git a/settings.gradle.kts b/settings.gradle.kts
index dbd3537c..e0ca0fe7 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -8,7 +8,7 @@
  * file, You can obtain one at https://mozilla.org/MPL/2.0/.
  */
 plugins {
-    `gradle-enterprise`
+    id("com.gradle.develocity") version "3.17.6"
     id("org.gradle.toolchains.foojay-resolver-convention") version ("0.8.0")
 }
 
@@ -23,58 +23,63 @@ include(
 )
 enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
 
-gradleEnterprise {
-    if (providers.environmentVariable("CI").isPresent) {
-        println("CI")
-        buildScan {
-            termsOfServiceUrl = "https://gradle.com/terms-of-service"
-            termsOfServiceAgree = "yes"
-            publishAlways()
-            tag("CI")
+develocity {
+    buildScan {
+        termsOfUseUrl = "https://gradle.com/terms-of-service"
+        termsOfUseAgree = "yes"
+        // publishAlways()
+        val isCI = providers.environmentVariable("CI").isPresent
+        publishing.onlyIf { isCI }
+        tag("CI")
 
-            if (providers.environmentVariable("GITHUB_ACTIONS").isPresent) {
-                link("GitHub Repository", "https://github.com/" + System.getenv("GITHUB_REPOSITORY"))
-                link(
-                    "GitHub Commit",
-                    "https://github.com/" + System.getenv("GITHUB_REPOSITORY") + "/commits/" + System.getenv("GITHUB_SHA")
-                )
+        buildScanPublished {
+            File("build-scan.txt").printWriter().use { writer ->
+                writer.println(buildScanUri)
+            }
+        }
 
+        if (providers.environmentVariable("GITHUB_ACTIONS").isPresent) {
+            link("GitHub Repository", "https://github.com/" + System.getenv("GITHUB_REPOSITORY"))
+            link(
+                "GitHub Commit",
+                "https://github.com/" + System.getenv("GITHUB_REPOSITORY") + "/commits/" + System.getenv("GITHUB_SHA")
+            )
 
-                listOf(
-                    "GITHUB_ACTION_REPOSITORY",
-                    "GITHUB_EVENT_NAME",
-                    "GITHUB_ACTOR",
-                    "GITHUB_BASE_REF",
-                    "GITHUB_HEAD_REF",
-                    "GITHUB_JOB",
-                    "GITHUB_REF",
-                    "GITHUB_REF_NAME",
-                    "GITHUB_REPOSITORY",
-                    "GITHUB_RUN_ID",
-                    "GITHUB_RUN_NUMBER",
-                    "GITHUB_SHA",
-                    "GITHUB_WORKFLOW"
-                ).forEach { e ->
-                    val v = System.getenv(e)
-                    if (v != null) {
-                        value(e, v)
-                    }
+
+            listOf(
+                "GITHUB_ACTION_REPOSITORY",
+                "GITHUB_EVENT_NAME",
+                "GITHUB_ACTOR",
+                "GITHUB_BASE_REF",
+                "GITHUB_HEAD_REF",
+                "GITHUB_JOB",
+                "GITHUB_REF",
+                "GITHUB_REF_NAME",
+                "GITHUB_REPOSITORY",
+                "GITHUB_RUN_ID",
+                "GITHUB_RUN_NUMBER",
+                "GITHUB_SHA",
+                "GITHUB_WORKFLOW"
+            ).forEach { e ->
+                val v = System.getenv(e)
+                if (v != null) {
+                    value(e, v)
                 }
+            }
 
-                providers.environmentVariable("GITHUB_SERVER_URL").orNull?.let { ghUrl ->
-                    val ghRepo = System.getenv("GITHUB_REPOSITORY")
-                    val ghRunId = System.getenv("GITHUB_RUN_ID")
-                    link("Summary", "$ghUrl/$ghRepo/actions/runs/$ghRunId")
-                    link("PRs", "$ghUrl/$ghRepo/pulls")
+            providers.environmentVariable("GITHUB_SERVER_URL").orNull?.let { ghUrl ->
+                val ghRepo = System.getenv("GITHUB_REPOSITORY")
+                val ghRunId = System.getenv("GITHUB_RUN_ID")
+                link("Summary", "$ghUrl/$ghRepo/actions/runs/$ghRunId")
+                link("PRs", "$ghUrl/$ghRepo/pulls")
 
-                    // see .github/workflows/build.yaml
-                    providers.environmentVariable("GITHUB_PR_NUMBER")
-                        .orNull
-                        .takeUnless { it.isNullOrBlank() }
-                        .let { prNumber ->
-                            link("PR", "$ghUrl/$ghRepo/pulls/$prNumber")
-                        }
-                }
+                // see .github/workflows/build.yaml
+                providers.environmentVariable("GITHUB_PR_NUMBER")
+                    .orNull
+                    .takeUnless { it.isNullOrBlank() }
+                    .let { prNumber ->
+                        link("PR", "$ghUrl/$ghRepo/pulls/$prNumber")
+                    }
             }
         }
     }