From caed965a146cb2770663a03f11aefa6e734cb622 Mon Sep 17 00:00:00 2001 From: Gustavo Fao Valvassori Date: Tue, 11 Nov 2025 17:34:08 -0300 Subject: [PATCH] [JEWEL-1137] Added FPS counter to the Jewel sample app - Added a simple way to track the app frame rate to help identify any possible bottlenecks --- .../standalone/components/FpsCounter.kt | 135 ++++++++++++++++++ .../samples/standalone/view/TitleBarView.kt | 5 + .../samples/standalone/view/WelcomeView.kt | 13 +- .../standalone/viewmodel/MainViewModel.kt | 2 + 4 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/components/FpsCounter.kt diff --git a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/components/FpsCounter.kt b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/components/FpsCounter.kt new file mode 100644 index 0000000000000..9846cac301e14 --- /dev/null +++ b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/components/FpsCounter.kt @@ -0,0 +1,135 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.samples.standalone.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.jewel.ui.component.Text + +@Composable +internal fun FpsCounter(modifier: Modifier = Modifier, dispatcher: CoroutineDispatcher = Dispatchers.Default) { + var displayedFPS by remember { mutableIntStateOf(0) } + var fpsCountMethod by remember { mutableStateOf(FPSCountMethod.RealTime) } + var minFps by remember { mutableIntStateOf(240) } + var maxFps by remember { mutableIntStateOf(0) } + + val textContent by remember { + derivedStateOf { + when (fpsCountMethod) { + FPSCountMethod.RealTime -> { + "FPS(Realtime):$displayedFPS" + } + + FPSCountMethod.FixedInterval -> { + "FPS(last ${FPS_UPDATE_DELAY}ms):$displayedFPS" + } + + FPSCountMethod.FixedFrameCount -> { + "FPS(last ${FRAME_COUNT} frames):$displayedFPS" + } + } + } + } + + val minMaxContent by remember { derivedStateOf { "min:$minFps, max:$maxFps" } } + val textColor by remember { derivedStateOf { displayedFPS.fpsCountColor } } + + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = textContent, + modifier = + Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { + fpsCountMethod = + when (fpsCountMethod) { + FPSCountMethod.FixedInterval -> FPSCountMethod.FixedFrameCount + FPSCountMethod.FixedFrameCount -> FPSCountMethod.RealTime + FPSCountMethod.RealTime -> FPSCountMethod.FixedInterval + } + minFps = 240 + maxFps = 0 + }, + color = textColor, + ) + + Text( + text = minMaxContent, + modifier = + Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { + minFps = 240 + maxFps = 0 + }, + ) + } + + LaunchedEffect(Unit) { + val fpsArray = FloatArray(FRAME_COUNT) { 0f } + val fpsCount = AtomicInteger(0) + val writeIndex = AtomicInteger(0) + val lastWriteIndex = AtomicInteger(0) + val lastUpdTime = AtomicLong(withFrameMillis { it }) + + launch(dispatcher) { + while (true) { + delay(FPS_UPDATE_DELAY) + + displayedFPS = + when (fpsCountMethod) { + FPSCountMethod.FixedInterval -> fpsCount.getAndSet(0) * 1000 / FPS_UPDATE_DELAY.toInt() + FPSCountMethod.FixedFrameCount -> fpsArray.average().roundToInt() + FPSCountMethod.RealTime -> fpsArray[lastWriteIndex.get()].roundToInt() + } + if (displayedFPS > 0) { + minFps = minOf(minFps, displayedFPS) + } + maxFps = maxOf(maxFps, displayedFPS) + } + } + + while (true) { + withFrameMillis { frameTimeMillis -> + fpsCount.getAndUpdate { it + 1 } + + fpsArray[writeIndex.get()] = 1000f / (frameTimeMillis - lastUpdTime.getAndSet(frameTimeMillis)) + + lastWriteIndex.set(writeIndex.getAndUpdate { (it + 1) % fpsArray.size }) + } + } + } +} + +private const val FPS_UPDATE_DELAY = 250L +private const val FRAME_COUNT = 20 + +private val Int.fpsCountColor: Color + get() = + when { + this > 55 -> Color.Green + this > 45 -> Color.Yellow + else -> Color.Red + } + +internal enum class FPSCountMethod { + FixedInterval, + FixedFrameCount, + RealTime, +} diff --git a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt index d541dc423cf39..af8ad4e779096 100644 --- a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt +++ b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt @@ -15,6 +15,7 @@ import java.net.URI import org.jetbrains.jewel.samples.showcase.ShowcaseIcons import org.jetbrains.jewel.samples.showcase.views.forCurrentOs import org.jetbrains.jewel.samples.standalone.IntUiThemes +import org.jetbrains.jewel.samples.standalone.components.FpsCounter import org.jetbrains.jewel.samples.standalone.viewmodel.MainViewModel import org.jetbrains.jewel.ui.component.Dropdown import org.jetbrains.jewel.ui.component.Icon @@ -69,6 +70,10 @@ internal fun DecoratedWindowScope.TitleBarView() { Text(title) Row(Modifier.align(Alignment.End)) { + if (MainViewModel.showFPSCount) { + FpsCounter(modifier = Modifier.align(Alignment.CenterVertically)) + } + Tooltip({ Text("Open Jewel Github repository") }) { val jewelGithubLink = "https://github.com/JetBrains/intellij-community/tree/master/platform/jewel" IconButton( diff --git a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/WelcomeView.kt b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/WelcomeView.kt index 327d66f760cc2..c14894ed905d8 100644 --- a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/WelcomeView.kt +++ b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/WelcomeView.kt @@ -24,7 +24,6 @@ import org.jetbrains.jewel.ui.component.CheckboxRow import org.jetbrains.jewel.ui.component.Icon import org.jetbrains.jewel.ui.component.RadioButtonChip import org.jetbrains.jewel.ui.component.Text -import org.jetbrains.jewel.ui.component.styling.LocalCheckboxStyle import org.jetbrains.jewel.ui.icon.IconKey import org.jetbrains.jewel.ui.painter.hints.Selected import org.jetbrains.jewel.ui.typography @@ -71,9 +70,6 @@ internal fun WelcomeView() { text = "Swing compatibility", checked = MainViewModel.swingCompat, onCheckedChange = { MainViewModel.swingCompat = it }, - colors = LocalCheckboxStyle.current.colors, - metrics = LocalCheckboxStyle.current.metrics, - icons = LocalCheckboxStyle.current.icons, ) CheckboxRow( @@ -83,9 +79,12 @@ internal fun WelcomeView() { MainViewModel.useCustomPopupRenderer = it JewelFlags.useCustomPopupRenderer = it }, - colors = LocalCheckboxStyle.current.colors, - metrics = LocalCheckboxStyle.current.metrics, - icons = LocalCheckboxStyle.current.icons, + ) + + CheckboxRow( + text = "Show FPS Count", + checked = MainViewModel.showFPSCount, + onCheckedChange = { MainViewModel.showFPSCount = it }, ) } } diff --git a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/viewmodel/MainViewModel.kt b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/viewmodel/MainViewModel.kt index 7e88d60bb8e84..419cd546d259c 100644 --- a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/viewmodel/MainViewModel.kt +++ b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/viewmodel/MainViewModel.kt @@ -45,6 +45,8 @@ public object MainViewModel { public var theme: IntUiThemes by mutableStateOf(IntUiThemes.Light) + public var showFPSCount: Boolean by mutableStateOf(false) + public var swingCompat: Boolean by mutableStateOf(false) public var useCustomPopupRenderer: Boolean by mutableStateOf(JewelFlags.useCustomPopupRenderer)