From bc9861900e312894ebdcf750db4cf3e0d4708715 Mon Sep 17 00:00:00 2001 From: jay-tux Date: Sun, 30 Aug 2026 21:11:18 +0200 Subject: [PATCH] UI clients: fix narrow vs wide UI --- .../com/jaytux/phoebench/clients/App.kt | 3 + .../phoebench/clients/data/ProjectVM.kt | 5 +- .../phoebench/clients/theme/Constants.kt | 21 +- .../phoebench/clients/ui/NarrowHandler.kt | 168 +++++ .../phoebench/clients/ui/ProjectView.kt | 643 +++++++++++------- .../com/jaytux/phoebench/clients/Main.kt | 63 +- .../phoebench/clients/PlatformAPI.desktop.kt | 2 +- .../phoebench/clients/PlatformAPI.wasmJs.kt | 2 +- 8 files changed, 644 insertions(+), 263 deletions(-) create mode 100644 clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NarrowHandler.kt diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt index 33442df..db98c4c 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/App.kt @@ -3,7 +3,10 @@ package com.jaytux.phoebench.clients import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.dp import com.jaytux.phoebench.clients.ui.RootView @Composable diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt index 3f8572e..9165dcb 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/data/ProjectVM.kt @@ -133,7 +133,10 @@ class ProjectVM( ProjectEvent.Deleted -> back() is ProjectEvent.EntryDeleted -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmarkId }) { it.remove(event.id) } is ProjectEvent.VersionChanged -> _labels.value += (event.label.id to Version.fromResponse(event.label)) - is ProjectEvent.VersionDeleted -> _labels.value = _labels.value.filter { it.value.id != event.id } + is ProjectEvent.VersionDeleted -> { + _labels.value = _labels.value.filter { it.value.id != event.id } + _benchmarks.value = _benchmarks.value.map { it.copy(entries = it.entries.filter { e -> e.label.id != event.id }) } + } is ProjectEvent.NewEntry -> { _benchmarks.value = _benchmarks.value.replace({ it.id == event.entry.benchmarkId }) { it.add(Entry.fromResponse(event.entry, _labels.value, { input -> _inputs.value += input }, { hw -> _hardware.value += hw })) } diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt index 8dc5b45..2ccda1c 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/theme/Constants.kt @@ -1,5 +1,24 @@ package com.jaytux.phoebench.clients.theme +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp -val linkColor = Color(0xFF64B5F6) \ No newline at end of file +val linkColor = Color(0xFF64B5F6) + +enum class UiMode(val minWidth: Dp) { + MOBILE(0.dp), + NARROW(0.dp), + WIDE(2000.dp); + + companion object { + fun fromDp(width: Dp) = when { + width < NARROW.minWidth -> MOBILE + width < WIDE.minWidth -> NARROW + else -> WIDE + } + } +} + +val GlobalUiMode = compositionLocalOf { UiMode.MOBILE } \ No newline at end of file diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NarrowHandler.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NarrowHandler.kt new file mode 100644 index 0000000..46db23e --- /dev/null +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/NarrowHandler.kt @@ -0,0 +1,168 @@ +package com.jaytux.phoebench.clients.ui + +import androidx.annotation.FloatRange +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.PanelRightClose +import com.composables.icons.lucide.PanelRightOpen +import com.jaytux.phoebench.clients.app + +@DslMarker annotation class NarrowDslMarker +@DslMarker annotation class TabPanelDslMarker + +@NarrowDslMarker +interface INarrowDsl { + fun mainContent(content: @Composable ((isNarrow: Boolean, toggleSide: () -> Unit) -> Unit)) + fun sideContent(onSurface: Boolean, content: @Composable (isNarrow: Boolean, toggleSide: () -> Unit) -> Unit) +} + +internal class NarrowDslImpl : INarrowDsl { + var main: @Composable (isNarrow: Boolean, toggleSide: () -> Unit) -> Unit = { _, _ -> } + private set + var side: @Composable (isNarrow: Boolean, toggleSide: () -> Unit) -> Unit = { _, _ -> } + private set + var sideSurface: Boolean = true + private set + + override fun mainContent(content: @Composable ((isNarrow: Boolean, toggleSide: () -> Unit) -> Unit)) { main = content } + override fun sideContent(onSurface: Boolean, content: @Composable ((isNarrow: Boolean, toggleSide: () -> Unit) -> Unit)) { sideSurface = onSurface; side = content } +} + +@Composable +fun RowOrPanel(useRow: Boolean, modifier: Modifier = Modifier, @FloatRange(0.0, 1.0) defaultWeight: Float = 0.5f, builder: INarrowDsl.() -> Unit) { + val content = NarrowDslImpl().also { it.builder() } + + if(!useRow) { + var isOpen by remember { mutableStateOf(false) } + Box(modifier) { + content.main(true) { isOpen = !isOpen } + if(isOpen) { + CompositionLocalProvider(LocalIndication provides NoFeedbackIndication()) { + Box(Modifier.matchParentSize().background(Color.Black.copy(alpha = 0.5f)).clickable { isOpen = false }) + } + val shape = MaterialTheme.shapes.medium.copy(topEnd = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)) + Box(Modifier.matchParentSize()) { + if(content.sideSurface) { + Surface(Modifier.align(Alignment.CenterEnd).fillMaxHeight().fillMaxWidth(0.9f), shape = shape, shadowElevation = 3.dp) { + Box(Modifier.padding(10.dp)) { + content.side(true) { isOpen = false } + } + } + } + else { + Box(Modifier.align(Alignment.CenterEnd)) { + content.side(true) { isOpen = false } + } + } + } + } + } + } + else { + Row(modifier) { + Box(Modifier.weight(defaultWeight)) { content.main(false) {} } + Box(Modifier.weight(1.0f - defaultWeight)) { content.side(false) {} } + } + } +} + +class TabPanelShape(val tabWidth: Dp, val tabHeight: Dp = tabWidth, val cornerRadius: CornerSize) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val (tabWpx, tabHpx, rpx) = with(density) { + tabWidth.toPx() to tabHeight.toPx() app cornerRadius.toPx(size, density) + } + + val radius = CornerRadius(rpx, rpx) + + val shape = Path().apply { + addRoundRect(RoundRect( + Rect(offset = Offset(x = tabWpx, y = 0f), size = Size(width = size.width - tabWpx, height = size.height)), + topLeft = CornerRadius.Zero, topRight = CornerRadius.Zero, + bottomLeft = radius, bottomRight = CornerRadius.Zero + )) + + addRoundRect(RoundRect( + Rect(offset = Offset(x = 0f, y = 0f), size = Size(size.width, tabHpx)), + topLeft = radius, topRight = CornerRadius.Zero, + bottomLeft = radius, bottomRight = CornerRadius.Zero + )) + } + return Outline.Generic(shape) + } +} + +@Composable +fun TabPanel(tabWidth: Dp, tabHeight: Dp = tabWidth, modifier: Modifier = Modifier, + cornerRadius: CornerSize = MaterialTheme.shapes.medium.topEnd, + color: Color = MaterialTheme.colorScheme.surface, contentColor: Color = contentColorFor(color), + tonalElevation: Dp = 0.dp, shadowElevation: Dp = 0.dp, border: BorderStroke? = null, + tabContent: @Composable () -> Unit, + content: @Composable () -> Unit +) { + Surface(modifier, + shape = TabPanelShape(tabWidth, tabHeight, cornerRadius), color = color, contentColor = contentColor, + tonalElevation = tonalElevation, shadowElevation = shadowElevation, border = border + ) { + Box(Modifier.fillMaxSize()) { + Box(Modifier.size(width = tabWidth, height = tabHeight), propagateMinConstraints = true) { tabContent() } + Box(Modifier.padding(start = tabWidth)) { content() } + } + } +} + +@Composable +fun TabPanel(tabWidth: Dp, modifier: Modifier, tabHeight: Dp = tabWidth, + cornerRadius: CornerSize = MaterialTheme.shapes.medium.topEnd, + color: Color = MaterialTheme.colorScheme.surface, contentColor: Color = contentColorFor(color), + tonalElevation: Dp = 0.dp, shadowElevation: Dp = 0.dp, border: BorderStroke? = null, + tabContent: @Composable () -> Unit, content: @Composable () -> Unit) = + TabPanel(tabWidth, tabHeight, modifier, cornerRadius, color, contentColor, tonalElevation, shadowElevation, border, tabContent, content) + +@Composable +fun ExpandableSidePanel( + modifier: Modifier = Modifier, tabWidth: Dp = 40.dp, tabHeight: Dp = tabWidth, + radius: CornerSize = MaterialTheme.shapes.medium.topEnd, elevation: Dp = 5.dp, + color: Color = MaterialTheme.colorScheme.surface, toggleSide: () -> Unit +) = TabPanel(tabWidth, tabHeight, modifier, radius, color, shadowElevation = elevation, tonalElevation = elevation, + tabContent = { + Surface(Modifier.padding(5.dp), shape = MaterialTheme.shapes.medium) { + Icon(Lucide.PanelRightOpen, "Open side panel", + Modifier.aspectRatio(1f).fillMaxSize(0.66f).clickable { toggleSide() }) + } + } + ) {} + +@Composable +fun ExpandedSidePanel( + modifier: Modifier = Modifier, tabWidth: Dp = 40.dp, tabHeight: Dp = tabWidth, + radius: CornerSize = MaterialTheme.shapes.medium.topEnd, elevation: Dp = 5.dp, + color: Color = MaterialTheme.colorScheme.surface, toggleSide: () -> Unit, content: @Composable () -> Unit +) = TabPanel(tabWidth, tabHeight, modifier, radius, color, shadowElevation = elevation, tonalElevation = elevation, + tabContent = { + Surface(Modifier.padding(5.dp), shape = MaterialTheme.shapes.medium) { + Icon(Lucide.PanelRightClose, "Close side panel", + Modifier.aspectRatio(1f).fillMaxSize(0.66f).clickable { toggleSide() }) + } + }, content = content + ) \ No newline at end of file diff --git a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt index 356c17d..c87c11e 100644 --- a/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt +++ b/clients/compose/src/commonMain/kotlin/com/jaytux/phoebench/clients/ui/ProjectView.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -29,6 +30,9 @@ import com.jaytux.phoebench.clients.* import com.jaytux.phoebench.clients.data.ProjectVM import com.jaytux.phoebench.clients.data.mutableStateSetFrom import com.jaytux.phoebench.clients.data.mutableStateSetOf +import com.jaytux.phoebench.clients.theme.GlobalUiMode +import com.jaytux.phoebench.clients.theme.UiMode +import com.jaytux.phoebench.clients.theme.linkColor import com.jaytux.phoebench.common.* import com.kborowy.colorpicker.KolorPicker import io.github.koalaplot.core.bar.DefaultBar @@ -150,7 +154,7 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map() } val enabledInputs = remember(allInputs) { mutableStateSetOf() } - LaunchedEffect(entries, allInputs, allHardware, timeFilter, timeMin, timeMax, versionFilterRev, inputFilterRev, filterHardware) { + LaunchedEffect( + entries, + allInputs, + allHardware, + timeFilter, + timeMin, + timeMax, + versionFilterRev, + inputFilterRev, + filterHardware + ) { val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive) enabledHardware.clear() enabledInputs.clear() filteredEntries = entries.filter { entry -> - if(entry.label !in versionFilter) return@filter false - if(!(entry.timeStamp inRange timeRange)) return@filter false - if(entry.input !in inputFilter) return@filter false - if(filterHardware != null && entry.hardware != filterHardware) return@filter false + if (entry.label !in versionFilter) return@filter false + if (!(entry.timeStamp inRange timeRange)) return@filter false + if (entry.input !in inputFilter) return@filter false + if (filterHardware != null && entry.hardware != filterHardware) return@filter false enabledHardware.add(entry.hardware) enabledInputs.add(entry.input) @@ -582,7 +592,7 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) { var maxY = 0f renderableEntries = filteredEntries.map { entry -> - val use = if(displayWarmups) entry.warmups else entry.measurements + val use = if (displayWarmups) entry.warmups else entry.measurements maxX = maxOf(maxX, use.size.toFloat()) use.mapIndexed { idx, it -> val converted = entry.nativeUnit.convertTo(currentUnit, it) @@ -617,15 +627,23 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) { var max = 0.0f baselineComparison = baseline?.let { bl -> class MeanAccumulator(var sum: Float = 0.0f, var count: Float = 0.0f) { - fun add(f: Float) { sum += f; count += 1.0f } - fun mean() = if(count == 0.0f) 0.0f else sum / count + fun add(f: Float) { + sum += f; count += 1.0f + } + + fun add(f: Float, countsAs: Int) { + sum += f; count += countsAs.toFloat() + } + + fun mean() = if (count == 0.0f) 0.0f else (sum / count) } + val perVersion = mutableMapOf>() filteredEntries.forEach { entry -> val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum() perVersion.getOrPut(entry.label) { mutableMapOf() } .getOrPut(entry.input) { MeanAccumulator() } - .add(corrected) + .add(corrected, entry.measurements.size) } val subMeans = perVersion.mapValues { (_, v) -> v.mapValues { (_, acc) -> acc.mean() } } @@ -656,260 +674,317 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) { verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor) ) - Row(Modifier.fillMaxSize().padding(20.dp)) { - Row(Modifier.weight(0.66f).fillMaxHeight()) { - if(entries.isEmpty()) { - Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { - Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) - } - } - else { - Column { - Text("Measurements", style = MaterialTheme.typography.headlineSmall) - Row { - Row(Modifier.weight(0.66f), verticalAlignment = Alignment.CenterVertically) { - Text("View as: ", fontWeight = FontWeight.Bold) - Spacer(Modifier.width(5.dp)) - SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { - TimeUnit.entries.forEachIndexed { idx, it -> - SegmentedButton( - currentUnit == it, - { currentUnit = it }, - shape = SegmentedButtonDefaults.itemShape(idx, TimeUnit.entries.size) - ) { - Text(it.disp) - } - } + val uiMode = GlobalUiMode.current + var showOptions by remember(uiMode) { mutableStateOf(uiMode == UiMode.WIDE) } + + RowOrPanel(useRow = GlobalUiMode.current == UiMode.WIDE, Modifier.fillMaxSize()) { + mainContent { isNarrow, toggleSide -> + Box { + Row(Modifier.padding(end = if (isNarrow) 25.dp else 0.dp)) { + if (entries.isEmpty()) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f).padding(35.dp)) { + Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) + } + } else { + Column(Modifier.padding(35.dp)) { + Row { + Text("Measurements", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.width(10.dp)) + Text( + if (showOptions) "(hide options)" else "(show options)", + Modifier.clickable { showOptions = !showOptions }.align(Alignment.Bottom), + color = linkColor, + textDecoration = TextDecoration.Underline + ) } - } - Spacer(Modifier.width(50.dp)) - - Row(Modifier.weight(0.33f), verticalAlignment = Alignment.CenterVertically) { - Text("Display: ", fontWeight = FontWeight.Bold) - Spacer(Modifier.width(5.dp)) - SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { - SegmentedButton(!displayWarmups, { displayWarmups = false }, - shape = SegmentedButtonDefaults.itemShape(0, 2) - ) { - Text("(Steady-state) measurements") - } - - SegmentedButton(displayWarmups, { displayWarmups = true }, - shape = SegmentedButtonDefaults.itemShape(1, 2) - ) { - Text("Warmup measurements") - } - } - } - } - Spacer(Modifier.height(5.dp)) - Column(Modifier.fillMaxWidth()) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Filter by timestamp: ", fontWeight = FontWeight.Bold) - Spacer(Modifier.width(5.dp)) - RangeSlider(timeFilter, { timeFilter = it }, Modifier.fillMaxWidth(), valueRange = 0f..1f) - } - Text(timeFilterString, Modifier.align(Alignment.End)) - } - Spacer(Modifier.height(15.dp)) - - XYGraph( - rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()), - rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()), - xAxisContent = AxisContent( - labels = { AxisLabel(format(it)) }, - title = {}, - style = style - ), - yAxisContent = AxisContent( - labels = { AxisLabel(format(it)) }, - title = {}, - style = style - ), - modifier = Modifier.weight(1f), - gridStyle = gridStyle - ) { - renderableEntries.forEach { (data, color) -> - LinePlot(data, lineStyle = LineStyle(SolidColor(color), strokeWidth = 3.dp)) - } - } - } - } - } - - Spacer(Modifier.width(20.dp)) - - Column(Modifier.weight(0.33f).fillMaxHeight()) { - Row { - Column(Modifier.weight(0.5f)) { - Text("Versions", style = MaterialTheme.typography.headlineSmall) - Text("Click a version to toggle its visibility.", fontStyle = FontStyle.Italic, - color = LocalContentColor.current.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall) - Spacer(Modifier.height(10.dp)) - if(versions.isEmpty()) { - Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { - Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) - } - } - else { - LazyColumn(Modifier.padding(start = 5.dp)) { - items(versions.toList().sortedBy { it.second.name }) { (_, lbl) -> - Box(Modifier.fillMaxWidth().clickable { - baseline = null - if(versionFilter.size == versions.size) { - versionFilter.clear() - versionFilter.add(lbl) - } - else { - versionFilter.toggle(lbl) - if(versionFilter.isEmpty()) versionFilter.addAll(versions.values) - } - }) { - Row { - Box(Modifier.weight(1f)) { - QuickVersion(lbl, lbl !in versionFilter) - - if (lbl !in versionFilter) { - Box(Modifier.matchParentSize()) { - HorizontalDivider( - Modifier.fillMaxWidth().align(Alignment.Center), - thickness = 3.dp, - color = LocalContentColor.current + if (showOptions) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("View as: ", fontWeight = FontWeight.Bold) + Spacer(Modifier.width(5.dp)) + SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { + TimeUnit.entries.forEachIndexed { idx, it -> + SegmentedButton( + currentUnit == it, + { currentUnit = it }, + shape = SegmentedButtonDefaults.itemShape( + idx, + TimeUnit.entries.size ) + ) { + Text(it.disp) } } } - if(editable) { - IconButton({ editingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) { - Icon(Lucide.Pencil, "Edit version") + } + Spacer(Modifier.height(5.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Display: ", fontWeight = FontWeight.Bold) + Spacer(Modifier.width(5.dp)) + SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { + SegmentedButton( + !displayWarmups, { displayWarmups = false }, + shape = SegmentedButtonDefaults.itemShape(0, 2) + ) { + Text("(Steady-state) measurements") } - } - IconButton({ baseline = if(baseline == lbl) null else lbl }, - Modifier.align(Alignment.CenterVertically), enabled = lbl in versionFilter) { - if(baseline == lbl) { - Surface(Modifier.padding(5.dp), color = MaterialTheme.colorScheme.inverseSurface, shape = MaterialTheme.shapes.large) { - Icon(Lucide.RulerDimensionLine, "Use as baseline", Modifier.padding(5.dp)) - } + SegmentedButton( + displayWarmups, { displayWarmups = true }, + shape = SegmentedButtonDefaults.itemShape(1, 2) + ) { + Text("Warmup measurements") } - else Icon(Lucide.RulerDimensionLine, "Use as baseline") } - if(editable) { - IconButton({ deletingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) { - Icon(Lucide.Trash, "Delete version") + } + Spacer(Modifier.height(5.dp)) + Column(Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Filter by timestamp: ", fontWeight = FontWeight.Bold) + Spacer(Modifier.width(5.dp)) + RangeSlider( + timeFilter, + { timeFilter = it }, + Modifier.fillMaxWidth(), + valueRange = 0f..1f + ) + } + Text(timeFilterString, Modifier.align(Alignment.End)) + } + } + } + + Spacer(Modifier.height(15.dp)) + + if (!isNarrow || baselineComparison == null) { + XYGraph( + rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()), + rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()), + xAxisContent = AxisContent( + labels = { AxisLabel(format(it)) }, + title = {}, + style = style + ), + yAxisContent = AxisContent( + labels = { AxisLabel(format(it)) }, + title = {}, + style = style + ), + modifier = Modifier.weight(1f), + gridStyle = gridStyle + ) { + renderableEntries.forEach { (data, color) -> + LinePlot(data, lineStyle = LineStyle(SolidColor(color), strokeWidth = 3.dp)) + } + } + } + else baselineComparison?.let { blc -> + baselineGraph(baseline, baselineMax, style, gridStyle, blc, currentUnit) + } + } + } + } + + if (isNarrow) { + ExpandableSidePanel( + Modifier.width(70.dp).fillMaxHeight().align(Alignment.CenterEnd), + toggleSide = toggleSide, elevation = 10.dp + ) + } + } + + sideContent(false) { isNarrow, toggleSide -> + val wrapper: @Composable (@Composable () -> Unit) -> Unit = if (isNarrow) { it -> + ExpandedSidePanel(Modifier.fillMaxWidth(0.8f), toggleSide = toggleSide) { + Box(Modifier.padding(10.dp)) { it() } + } + } else { it -> it() } + + wrapper { + Column(Modifier.fillMaxHeight()) { + Row(Modifier.weight(0.33f)) { + Column(Modifier.weight(0.5f)) { + Text("Versions", style = MaterialTheme.typography.headlineSmall) + Text( + "Click a version to toggle its visibility.", + fontStyle = FontStyle.Italic, + color = LocalContentColor.current.copy(alpha = 0.5f), + style = MaterialTheme.typography.bodySmall + ) + Spacer(Modifier.height(10.dp)) + if (versions.isEmpty()) { + Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { + Text( + "No versions yet.", + Modifier.align(Alignment.Center), + fontStyle = FontStyle.Italic + ) + } + } else { + LazyColumn(Modifier.padding(start = 5.dp)) { + items(versions.toList().sortedBy { it.second.name }) { (_, lbl) -> + Box(Modifier.fillMaxWidth().clickable { + baseline = null + if (versionFilter.size == versions.size) { + versionFilter.clear() + versionFilter.add(lbl) + } else { + versionFilter.toggle(lbl) + if (versionFilter.isEmpty()) versionFilter.addAll(versions.values) + } + }) { + Row { + Box(Modifier.weight(1f)) { + QuickVersion(lbl, lbl !in versionFilter) + + if (lbl !in versionFilter) { + Box(Modifier.matchParentSize()) { + HorizontalDivider( + Modifier.fillMaxWidth().align(Alignment.Center), + thickness = 3.dp, + color = LocalContentColor.current + ) + } + } + } + if (editable) { + IconButton( + { editingVersion = lbl }, + Modifier.align(Alignment.CenterVertically) + ) { + Icon(Lucide.Pencil, "Edit version") + } + } + + IconButton( + { baseline = if (baseline == lbl) null else lbl }, + Modifier.align(Alignment.CenterVertically), + enabled = lbl in versionFilter + ) { + if (baseline == lbl) { + Surface( + Modifier.padding(5.dp), + color = MaterialTheme.colorScheme.inverseSurface, + shape = MaterialTheme.shapes.large + ) { + Icon( + Lucide.RulerDimensionLine, + "Use as baseline", + Modifier.padding(5.dp) + ) + } + } else Icon(Lucide.RulerDimensionLine, "Use as baseline") + } + if (editable) { + IconButton( + { deletingVersion = lbl }, + Modifier.align(Alignment.CenterVertically) + ) { + Icon(Lucide.Trash, "Delete version") + } + } + } } } } } } + Spacer(Modifier.width(15.dp)) + LazyColumn(Modifier.weight(0.5f)) { + item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) } + item { + Text( + "Click an input to set or clear the filter.", + fontStyle = FontStyle.Italic, + color = LocalContentColor.current.copy(0.5f), + style = MaterialTheme.typography.bodySmall + ) + } + + item { Spacer(Modifier.height(10.dp)) } + + items((visibleInputs intersect inputFilter.toSet()).sorted()) { + Row(Modifier.fillMaxWidth().clickable { + if (inputFilter.size == allInputs.size) { + inputFilter.clear() + inputFilter.add(it) + } else if (inputFilter.size == 1) { + inputFilter.addAll(allInputs.toSet()); inputFilter.remove(it) + } else { + inputFilter.toggle(it) + if (inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet()) + } + }) { + Text( + it, + Modifier.padding(start = 5.dp), + color = LocalContentColor.current.copy(alpha = if (it in enabledInputs) 1.0f else 0.9f) + ) + if (it !in enabledInputs) Text( + " (no data)", + Modifier.align(Alignment.Bottom), + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(0.9f) + ) + } + } + + items((visibleInputs - inputFilter.toSet()).sorted()) { + Row(Modifier.fillMaxWidth().clickable { + if (inputFilter.size == allInputs.size) { + inputFilter.clear() + inputFilter.add(it) + } else { + inputFilter.toggle(it) + if (inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet()) + } + }) { + Text( + it, + Modifier.padding(start = 5.dp), + color = LocalContentColor.current.copy(0.66f) + ) + } + } + + item { Spacer(Modifier.height(10.dp)) } + item { HorizontalDivider(Modifier.height(1.dp)) } + item { Spacer(Modifier.height(10.dp)) } + + item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) } + item { + Text( + "Click an item to set or clear the filter.", + fontStyle = FontStyle.Italic, + color = LocalContentColor.current.copy(0.5f), + style = MaterialTheme.typography.bodySmall + ) + } + + item { Spacer(Modifier.height(10.dp)) } + + items((allHardware intersect enabledHardware.toSet()).toList()) { + Row( + Modifier.fillMaxWidth() + .clickable { filterHardware = if (filterHardware == null) it else null }) { + Text(it, Modifier.padding(start = 5.dp)) + } + } + + items((allHardware - enabledHardware.toSet()).toList()) { + Text( + it, + Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), + color = LocalContentColor.current.copy(0.66f) + ) + } + } } - } - } - Spacer(Modifier.width(15.dp)) - LazyColumn(Modifier.weight(0.5f)) { - item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) } - item { - Text("Click an input to set or clear the filter.", fontStyle = FontStyle.Italic, - color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall) - } - item { Spacer(Modifier.height(10.dp)) } - - items((visibleInputs intersect inputFilter.toSet()).sorted()) { - Row(Modifier.fillMaxWidth().clickable { - if(inputFilter.size == allInputs.size) { - inputFilter.clear() - inputFilter.add(it) - } - else if(inputFilter.size == 1) { - inputFilter.addAll(allInputs.toSet()); inputFilter.remove(it) - } - else { - inputFilter.toggle(it) - if(inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet()) - } - }) { - Text(it, Modifier.padding(start = 5.dp), color = LocalContentColor.current.copy(alpha = if(it in enabledInputs) 1.0f else 0.9f)) - if(it !in enabledInputs) Text(" (no data)", Modifier.align(Alignment.Bottom), - style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(0.9f)) - } - } - - items((visibleInputs - inputFilter.toSet()).sorted()) { - Row(Modifier.fillMaxWidth().clickable { - if(inputFilter.size == allInputs.size) { - inputFilter.clear() - inputFilter.add(it) - } - else { - inputFilter.toggle(it) - if(inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet()) - } - }) { - Text(it, Modifier.padding(start = 5.dp), color = LocalContentColor.current.copy(0.66f)) - } - } - - item { Spacer(Modifier.height(10.dp)) } - item { HorizontalDivider(Modifier.height(1.dp)) } - item { Spacer(Modifier.height(10.dp)) } - - item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) } - item { - Text("Click an item to set or clear the filter.", fontStyle = FontStyle.Italic, - color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall) - } - - item { Spacer(Modifier.height(10.dp)) } - - items((allHardware intersect enabledHardware.toSet()).toList()) { - Row(Modifier.fillMaxWidth().clickable { filterHardware = if(filterHardware == null) it else null }) { - Text(it, Modifier.padding(start = 5.dp)) - } - } - - items((allHardware - enabledHardware.toSet()).toList()) { - Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f)) - } - } - } - - Column(Modifier.weight(1f)) { - baselineComparison?.let { blc -> - Spacer(Modifier.height(10.dp)) - HorizontalDivider(Modifier.height(1.dp)) - Spacer(Modifier.height(10.dp)) - Text("Speedup compared to ${baseline?.name ?: ""}", style = MaterialTheme.typography.headlineSmall) - Spacer(Modifier.height(10.dp)) - - val (xs, ys, colors) = blc.unzip() - - XYGraph( - xAxisModel = remember(blc) { CategoryAxisModel(xs) }, - yAxisModel = rememberFloatLinearAxisModel(0f..baselineMax), - xAxisContent = AxisContent( - labels = { AxisLabel(it) }, - title = {}, - style = style - ), - yAxisContent = AxisContent( - labels = { AxisLabel(format(it)) }, - title = {}, - style = style - ), - modifier = Modifier.weight(1f), - gridStyle = gridStyle - ) { - VerticalBarPlot( - xData = xs, - yData = ys, - bar = { x, _, _ -> DefaultBar(brush = SolidColor(colors[x]), modifier = Modifier.fillMaxWidth()) } - ) - - blc.forEach { (x, y, _, time) -> - XYAnnotation(Point(x, y), AnchorPoint.TopCenter) { - Text("${format(y)}x\n${format(time)} ${currentUnit.disp}", Modifier.padding(top = 5.dp)) + if (!isNarrow) { + baselineComparison?.let { blc -> + baselineGraph(baseline, baselineMax, style, gridStyle, blc, currentUnit) } } } @@ -932,6 +1007,60 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) { } } +@Composable +fun ColumnScope.baselineGraph(baseline: ProjectVM.Version?, baselineMax: Float, style: AxisStyle, gridStyle: GridStyle, + blc: List>, currentUnit: TimeUnit) { + Column(Modifier.weight(0.66f)) { + Spacer(Modifier.height(10.dp)) + HorizontalDivider(Modifier.height(1.dp)) + Spacer(Modifier.height(10.dp)) + Text( + "Speedup compared to ${baseline?.name ?: ""}", + style = MaterialTheme.typography.headlineSmall + ) + Spacer(Modifier.height(10.dp)) + + val (xs, ys, colors) = blc.unzip() + + XYGraph( + xAxisModel = remember(blc) { CategoryAxisModel(xs) }, + yAxisModel = rememberFloatLinearAxisModel(0f..baselineMax), + xAxisContent = AxisContent( + labels = { AxisLabel(it) }, + title = {}, + style = style + ), + yAxisContent = AxisContent( + labels = { AxisLabel(format(it)) }, + title = {}, + style = style + ), + modifier = Modifier.weight(1f), + gridStyle = gridStyle + ) { + VerticalBarPlot( + xData = xs, + yData = ys, + bar = { x, _, _ -> + DefaultBar( + brush = SolidColor(colors[x]), + modifier = Modifier.fillMaxWidth() + ) + } + ) + + blc.forEach { (x, y, _, time) -> + XYAnnotation(Point(x, y), AnchorPoint.TopCenter) { + Text( + "${format(y)}x\n${format(time)} ${currentUnit.disp}", + Modifier.padding(top = 5.dp) + ) + } + } + } + } +} + @Composable fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit, onDelete: () -> Unit) { Dialog(onDismissRequest = onCancel) { @@ -939,7 +1068,7 @@ fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit, Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) { Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.height(10.dp)) - Text("Are you sure you want to delete $version?") + Text("Are you sure you want to delete ${version.name}?") Text("This will also remove all data associated with this version.") Spacer(Modifier.height(10.dp)) CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete") diff --git a/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt b/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt index 037c020..ed28858 100644 --- a/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt +++ b/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/Main.kt @@ -1,10 +1,69 @@ package com.jaytux.phoebench.clients +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.jaytux.phoebench.clients.theme.GlobalUiMode +import com.jaytux.phoebench.clients.theme.UiMode +import java.awt.GraphicsConfiguration +import java.awt.GraphicsEnvironment +import java.awt.Toolkit +import javax.swing.JPanel + +fun windowData(config: GraphicsConfiguration): Pair { + val bounds = config.bounds + val insets = Toolkit.getDefaultToolkit().getScreenInsets(config) + + val availableWidthPx = bounds.width - insets.left - insets.right + val availableHeightPx = bounds.height - insets.top - insets.bottom + + val density = config.defaultTransform.scaleX.toFloat() + val size = DpSize((availableWidthPx / density).dp, (availableHeightPx / density).dp) + val offset = DpOffset((insets.left / density).dp, (insets.top / density).dp) + return size to offset +} fun main() = application { - Window(onCloseRequest = ::exitApplication, title = "PhoeBench") { - App() + val (winSize, winOffset) = windowData(GraphicsEnvironment.getLocalGraphicsEnvironment().defaultScreenDevice.defaultConfiguration) + val state = rememberWindowState(placement = WindowPlacement.Maximized, size = winSize, position = WindowPosition.Aligned(Alignment.Center)) + + var mode by remember { mutableStateOf(UiMode.NARROW) } + + Window(onCloseRequest = ::exitApplication, state, title = "PhoeBench") { + Column(Modifier.fillMaxSize()) { + SwingPanel(modifier = Modifier.size(0.dp), factory = { + JPanel().apply { + addPropertyChangeListener("graphicsConfiguration") { + val (newSize, newOffset) = windowData(this@Window.window.graphicsConfiguration) + state.size = newSize + state.position = WindowPosition.Aligned(Alignment.Center) + mode = UiMode.fromDp(state.size.width) + } + } + }) + + CompositionLocalProvider(GlobalUiMode provides mode) { + App() + } + } } } \ No newline at end of file diff --git a/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt b/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt index 22a1883..86af3da 100644 --- a/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt +++ b/clients/compose/src/desktopMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.desktop.kt @@ -65,7 +65,7 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient = actual suspend fun String.toClipEntry(): ClipEntry = ClipEntry(StringSelection(this)) -actual fun format(f: Float): String = String.format("%.03f", f) +actual fun format(f: Float): String = String.format("%.03f", f).let { it.substring(0, minOf(it.length, 5)) } @Composable actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate) diff --git a/clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt b/clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt index 857c7f5..e8239ff 100644 --- a/clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt +++ b/clients/compose/src/wasmJsMain/kotlin/com/jaytux/phoebench/clients/PlatformAPI.wasmJs.kt @@ -63,7 +63,7 @@ actual suspend fun String.toClipEntry(): ClipEntry = @JsFun("(num) => num.toFixed(3)") private external fun jsFormat(num: Double): String -actual fun format(f: Float): String = jsFormat(f.toDouble()) +actual fun format(f: Float): String = jsFormat(f.toDouble()).let { it.substring(0, minOf(it.length, 5)) } @Serializable data class Config(val server: String)