Author SHA1 Message Date
jaytux 8da391b6ce Web UI: width detection 2026-08-30 21:24:56 +02:00
jaytux bc9861900e UI clients: fix narrow vs wide UI 2026-08-30 21:11:18 +02:00
jaytux 1d10802140 CLI: formatting 2026-08-27 15:23:07 +02:00
12 changed files with 665 additions and 268 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ plugins {
} }
group = "com.jaytux.phoebench" group = "com.jaytux.phoebench"
version = rootProject.version as PhoebenchVersion version = PhoebenchVersion(1, 1, 1)
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion)) if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("CLI Client version must be at least as high as protocol/common version") throw GradleException("CLI Client version must be at least as high as protocol/common version")
@@ -78,7 +78,7 @@ object ProjectHandlers {
println("\n${it.benchmarks.size} benchmarks:") println("\n${it.benchmarks.size} benchmarks:")
it.benchmarks.forEach { b -> it.benchmarks.forEach { b ->
println(" - [${b.id}] ${b.name} with ${b.entries.size} entries:") println(" - [${b.id}] BENCHMARK ${b.name} (with ${b.entries.size} entries):")
b.entries.forEach { e -> b.entries.forEach { e ->
println(" - [${e.id}] ENTRY versioned ${map[e.versionId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " + println(" - [${e.id}] ENTRY versioned ${map[e.versionId]?.name ?: "<invalid version>"} at ${e.timestamp.fmt()} " +
"(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})") "(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})")
+1 -1
View File
@@ -13,7 +13,7 @@ plugins {
alias(libs.plugins.buildconfig) alias(libs.plugins.buildconfig)
} }
version = PhoebenchVersion(1, 1, 1) version = PhoebenchVersion(1, 1, 2)
if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion)) if((version as PhoebenchVersion) < (rootProject.version as PhoebenchVersion))
throw GradleException("UI Clients version must be at least as high as protocol/common version") throw GradleException("UI Clients version must be at least as high as protocol/common version")
@@ -3,7 +3,10 @@ package com.jaytux.phoebench.clients
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.dp
import com.jaytux.phoebench.clients.ui.RootView import com.jaytux.phoebench.clients.ui.RootView
@Composable @Composable
@@ -133,7 +133,10 @@ class ProjectVM(
ProjectEvent.Deleted -> back() ProjectEvent.Deleted -> back()
is ProjectEvent.EntryDeleted -> _benchmarks.value = _benchmarks.value.replace({ it.id == event.benchmarkId }) { it.remove(event.id) } 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.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 -> { is ProjectEvent.NewEntry -> {
_benchmarks.value = _benchmarks.value.replace({ it.id == event.entry.benchmarkId }) { it.add(Entry.fromResponse(event.entry, _labels.value, _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 })) } { input -> _inputs.value += input }, { hw -> _hardware.value += hw })) }
@@ -1,5 +1,24 @@
package com.jaytux.phoebench.clients.theme package com.jaytux.phoebench.clients.theme
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
val linkColor = Color(0xFF64B5F6) 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 }
@@ -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
)
@@ -20,6 +20,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog 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.ProjectVM
import com.jaytux.phoebench.clients.data.mutableStateSetFrom import com.jaytux.phoebench.clients.data.mutableStateSetFrom
import com.jaytux.phoebench.clients.data.mutableStateSetOf 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.jaytux.phoebench.common.*
import com.kborowy.colorpicker.KolorPicker import com.kborowy.colorpicker.KolorPicker
import io.github.koalaplot.core.bar.DefaultBar import io.github.koalaplot.core.bar.DefaultBar
@@ -150,7 +154,7 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM
} }
} }
} }
Box(Modifier.fillMaxSize().padding(15.dp)) { Box(Modifier.fillMaxSize()) {
ProjectPlotArea(vm, currentBenchmark) ProjectPlotArea(vm, currentBenchmark)
if(editable) { if(editable) {
@@ -160,7 +164,7 @@ fun ProjectPanel(vm: ProjectVM, editable: Boolean, versions: Map<Uuid, ProjectVM
} }
} }
Column(Modifier.align(Alignment.BottomEnd).padding(25.dp), horizontalAlignment = Alignment.End) { Column(Modifier.align(Alignment.BottomEnd).padding(40.dp), horizontalAlignment = Alignment.End) {
if (addOpen) { if (addOpen) {
var lblWidth by mutableStateOf(1000.dp) var lblWidth by mutableStateOf(1000.dp)
var dataWidth by mutableStateOf(1000.dp) var dataWidth by mutableStateOf(1000.dp)
@@ -349,13 +353,7 @@ fun QuickVersion(lbl: ProjectVM.Version, hollow: Boolean = false) {
} }
Spacer(Modifier.width(15.dp)) Spacer(Modifier.width(15.dp))
Column { Column {
Row { Text(lbl.name, overflow = TextOverflow.Ellipsis, maxLines = 1)
Text(lbl.name)
Text(" ${lbl.colorStr}", Modifier.align(Alignment.Bottom),
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(alpha = 0.75f),
)
}
if(lbl.meta.isNotBlank()) if(lbl.meta.isNotBlank())
Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(0.75f)) color = LocalContentColor.current.copy(0.75f))
@@ -536,7 +534,9 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
val allInputs by vm.inputs val allInputs by vm.inputs
val allHardware by vm.hardware val allHardware by vm.hardware
val visibleInputs by remember(entries, allInputs) { mutableStateOf(allInputs intersect entries.map { it.input }.toSet()) } val visibleInputs by remember(entries, allInputs) {
mutableStateOf(allInputs intersect entries.map { it.input }.toSet())
}
var filteredEntries by remember { mutableStateOf(entries) } var filteredEntries by remember { mutableStateOf(entries) }
var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) } var timeMin by remember { mutableStateOf(Instant.DISTANT_PAST) }
@@ -552,16 +552,26 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
val enabledHardware = remember(allHardware) { mutableStateSetOf<String>() } val enabledHardware = remember(allHardware) { mutableStateSetOf<String>() }
val enabledInputs = remember(allInputs) { mutableStateSetOf<String>() } val enabledInputs = remember(allInputs) { mutableStateSetOf<String>() }
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) val timeRange = dualLerp(timeMin, timeMax, timeFilter.start, timeFilter.endInclusive)
enabledHardware.clear() enabledHardware.clear()
enabledInputs.clear() enabledInputs.clear()
filteredEntries = entries.filter { entry -> filteredEntries = entries.filter { entry ->
if(entry.label !in versionFilter) return@filter false if (entry.label !in versionFilter) return@filter false
if(!(entry.timeStamp inRange timeRange)) return@filter false if (!(entry.timeStamp inRange timeRange)) return@filter false
if(entry.input !in inputFilter) return@filter false if (entry.input !in inputFilter) return@filter false
if(filterHardware != null && entry.hardware != filterHardware) return@filter false if (filterHardware != null && entry.hardware != filterHardware) return@filter false
enabledHardware.add(entry.hardware) enabledHardware.add(entry.hardware)
enabledInputs.add(entry.input) enabledInputs.add(entry.input)
@@ -582,7 +592,7 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
var maxY = 0f var maxY = 0f
renderableEntries = filteredEntries.map { entry -> 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()) maxX = maxOf(maxX, use.size.toFloat())
use.mapIndexed { idx, it -> use.mapIndexed { idx, it ->
val converted = entry.nativeUnit.convertTo(currentUnit, it) val converted = entry.nativeUnit.convertTo(currentUnit, it)
@@ -617,15 +627,23 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
var max = 0.0f var max = 0.0f
baselineComparison = baseline?.let { bl -> baselineComparison = baseline?.let { bl ->
class MeanAccumulator(var sum: Float = 0.0f, var count: Float = 0.0f) { class MeanAccumulator(var sum: Float = 0.0f, var count: Float = 0.0f) {
fun add(f: Float) { sum += f; count += 1.0f } fun add(f: Float) {
fun mean() = if(count == 0.0f) 0.0f else sum / count 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<ProjectVM.Version, MutableMap<String, MeanAccumulator>>() val perVersion = mutableMapOf<ProjectVM.Version, MutableMap<String, MeanAccumulator>>()
filteredEntries.forEach { entry -> filteredEntries.forEach { entry ->
val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum() val corrected = entry.measurements.map { entry.nativeUnit.convertTo(currentUnit, it) }.sum()
perVersion.getOrPut(entry.label) { mutableMapOf() } perVersion.getOrPut(entry.label) { mutableMapOf() }
.getOrPut(entry.input) { MeanAccumulator() } .getOrPut(entry.input) { MeanAccumulator() }
.add(corrected) .add(corrected, entry.measurements.size)
} }
val subMeans = perVersion.mapValues { (_, v) -> v.mapValues { (_, acc) -> acc.mean() } } val subMeans = perVersion.mapValues { (_, v) -> v.mapValues { (_, acc) -> acc.mean() } }
@@ -656,18 +674,33 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor) verticalMinorStyle = KoalaPlotTheme.axis.minorGridlineStyle?.copy(brush = lineColor)
) )
Row(Modifier.fillMaxSize().padding(20.dp)) { val uiMode = GlobalUiMode.current
Row(Modifier.weight(0.66f).fillMaxHeight()) { var showOptions by remember(uiMode) { mutableStateOf(uiMode == UiMode.WIDE) }
if(entries.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { 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) Text("No data yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic)
} }
} } else {
else { Column(Modifier.padding(35.dp)) {
Column {
Text("Measurements", style = MaterialTheme.typography.headlineSmall)
Row { Row {
Row(Modifier.weight(0.66f), verticalAlignment = Alignment.CenterVertically) { 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
)
}
if (showOptions) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("View as: ", fontWeight = FontWeight.Bold) Text("View as: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp)) Spacer(Modifier.width(5.dp))
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
@@ -675,45 +708,56 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
SegmentedButton( SegmentedButton(
currentUnit == it, currentUnit == it,
{ currentUnit = it }, { currentUnit = it },
shape = SegmentedButtonDefaults.itemShape(idx, TimeUnit.entries.size) shape = SegmentedButtonDefaults.itemShape(
idx,
TimeUnit.entries.size
)
) { ) {
Text(it.disp) Text(it.disp)
} }
} }
} }
} }
Spacer(Modifier.height(5.dp))
Spacer(Modifier.width(50.dp)) Row(verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.weight(0.33f), verticalAlignment = Alignment.CenterVertically) {
Text("Display: ", fontWeight = FontWeight.Bold) Text("Display: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp)) Spacer(Modifier.width(5.dp))
SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) { SingleChoiceSegmentedButtonRow(Modifier.weight(1f)) {
SegmentedButton(!displayWarmups, { displayWarmups = false }, SegmentedButton(
!displayWarmups, { displayWarmups = false },
shape = SegmentedButtonDefaults.itemShape(0, 2) shape = SegmentedButtonDefaults.itemShape(0, 2)
) { ) {
Text("(Steady-state) measurements") Text("(Steady-state) measurements")
} }
SegmentedButton(displayWarmups, { displayWarmups = true }, SegmentedButton(
displayWarmups, { displayWarmups = true },
shape = SegmentedButtonDefaults.itemShape(1, 2) shape = SegmentedButtonDefaults.itemShape(1, 2)
) { ) {
Text("Warmup measurements") Text("Warmup measurements")
} }
} }
} }
}
Spacer(Modifier.height(5.dp)) Spacer(Modifier.height(5.dp))
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("Filter by timestamp: ", fontWeight = FontWeight.Bold) Text("Filter by timestamp: ", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(5.dp)) Spacer(Modifier.width(5.dp))
RangeSlider(timeFilter, { timeFilter = it }, Modifier.fillMaxWidth(), valueRange = 0f..1f) RangeSlider(
timeFilter,
{ timeFilter = it },
Modifier.fillMaxWidth(),
valueRange = 0f..1f
)
} }
Text(timeFilterString, Modifier.align(Alignment.End)) Text(timeFilterString, Modifier.align(Alignment.End))
} }
}
}
Spacer(Modifier.height(15.dp)) Spacer(Modifier.height(15.dp))
if (!isNarrow || baselineComparison == null) {
XYGraph( XYGraph(
rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()), rememberFloatLinearAxisModel(listOf(0f, xMax).autoScaleRange()),
rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()), rememberFloatLinearAxisModel(listOf(yMin, yMax).autoScaleRange()),
@@ -735,35 +779,59 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
} }
} }
} }
else baselineComparison?.let { blc ->
baselineGraph(baseline, baselineMax, style, gridStyle, blc, currentUnit)
}
}
} }
} }
Spacer(Modifier.width(20.dp)) if (isNarrow) {
ExpandableSidePanel(
Modifier.width(70.dp).fillMaxHeight().align(Alignment.CenterEnd),
toggleSide = toggleSide, elevation = 10.dp
)
}
}
Column(Modifier.weight(0.33f).fillMaxHeight()) { sideContent(false) { isNarrow, toggleSide ->
Row { 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)) { Column(Modifier.weight(0.5f)) {
Text("Versions", style = MaterialTheme.typography.headlineSmall) Text("Versions", style = MaterialTheme.typography.headlineSmall)
Text("Click a version to toggle its visibility.", fontStyle = FontStyle.Italic, Text(
color = LocalContentColor.current.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall) "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)) Spacer(Modifier.height(10.dp))
if(versions.isEmpty()) { if (versions.isEmpty()) {
Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) { Box(Modifier.fillMaxWidth().fillMaxHeight(0.25f)) {
Text("No versions yet.", Modifier.align(Alignment.Center), fontStyle = FontStyle.Italic) Text(
"No versions yet.",
Modifier.align(Alignment.Center),
fontStyle = FontStyle.Italic
)
} }
} } else {
else {
LazyColumn(Modifier.padding(start = 5.dp)) { LazyColumn(Modifier.padding(start = 5.dp)) {
items(versions.toList().sortedBy { it.second.name }) { (_, lbl) -> items(versions.toList().sortedBy { it.second.name }) { (_, lbl) ->
Box(Modifier.fillMaxWidth().clickable { Box(Modifier.fillMaxWidth().clickable {
baseline = null baseline = null
if(versionFilter.size == versions.size) { if (versionFilter.size == versions.size) {
versionFilter.clear() versionFilter.clear()
versionFilter.add(lbl) versionFilter.add(lbl)
} } else {
else {
versionFilter.toggle(lbl) versionFilter.toggle(lbl)
if(versionFilter.isEmpty()) versionFilter.addAll(versions.values) if (versionFilter.isEmpty()) versionFilter.addAll(versions.values)
} }
}) { }) {
Row { Row {
@@ -780,23 +848,39 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
} }
} }
} }
if(editable) { if (editable) {
IconButton({ editingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) { IconButton(
{ editingVersion = lbl },
Modifier.align(Alignment.CenterVertically)
) {
Icon(Lucide.Pencil, "Edit version") Icon(Lucide.Pencil, "Edit version")
} }
} }
IconButton({ baseline = if(baseline == lbl) null else lbl }, IconButton(
Modifier.align(Alignment.CenterVertically), enabled = lbl in versionFilter) { { baseline = if (baseline == lbl) null else lbl },
if(baseline == lbl) { Modifier.align(Alignment.CenterVertically),
Surface(Modifier.padding(5.dp), color = MaterialTheme.colorScheme.inverseSurface, shape = MaterialTheme.shapes.large) { enabled = lbl in versionFilter
Icon(Lucide.RulerDimensionLine, "Use as baseline", Modifier.padding(5.dp)) ) {
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")
} }
else Icon(Lucide.RulerDimensionLine, "Use as baseline") if (editable) {
} IconButton(
if(editable) { { deletingVersion = lbl },
IconButton({ deletingVersion = lbl }, Modifier.align(Alignment.CenterVertically)) { Modifier.align(Alignment.CenterVertically)
) {
Icon(Lucide.Trash, "Delete version") Icon(Lucide.Trash, "Delete version")
} }
} }
@@ -810,44 +894,57 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
LazyColumn(Modifier.weight(0.5f)) { LazyColumn(Modifier.weight(0.5f)) {
item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) } item { Text("Inputs", style = MaterialTheme.typography.headlineSmall) }
item { item {
Text("Click an input to set or clear the filter.", fontStyle = FontStyle.Italic, Text(
color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall) "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)) } item { Spacer(Modifier.height(10.dp)) }
items((visibleInputs intersect inputFilter.toSet()).sorted()) { items((visibleInputs intersect inputFilter.toSet()).sorted()) {
Row(Modifier.fillMaxWidth().clickable { Row(Modifier.fillMaxWidth().clickable {
if(inputFilter.size == allInputs.size) { if (inputFilter.size == allInputs.size) {
inputFilter.clear() inputFilter.clear()
inputFilter.add(it) inputFilter.add(it)
} } else if (inputFilter.size == 1) {
else if(inputFilter.size == 1) {
inputFilter.addAll(allInputs.toSet()); inputFilter.remove(it) inputFilter.addAll(allInputs.toSet()); inputFilter.remove(it)
} } else {
else {
inputFilter.toggle(it) inputFilter.toggle(it)
if(inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet()) 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)) Text(
if(it !in enabledInputs) Text(" (no data)", Modifier.align(Alignment.Bottom), it,
style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(0.9f)) 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()) { items((visibleInputs - inputFilter.toSet()).sorted()) {
Row(Modifier.fillMaxWidth().clickable { Row(Modifier.fillMaxWidth().clickable {
if(inputFilter.size == allInputs.size) { if (inputFilter.size == allInputs.size) {
inputFilter.clear() inputFilter.clear()
inputFilter.add(it) inputFilter.add(it)
} } else {
else {
inputFilter.toggle(it) inputFilter.toggle(it)
if(inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet()) if (inputFilter.isEmpty()) inputFilter.addAll(allInputs.toSet())
} }
}) { }) {
Text(it, Modifier.padding(start = 5.dp), color = LocalContentColor.current.copy(0.66f)) Text(
it,
Modifier.padding(start = 5.dp),
color = LocalContentColor.current.copy(0.66f)
)
} }
} }
@@ -857,59 +954,37 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) } item { Text("Hardware", style = MaterialTheme.typography.headlineSmall) }
item { item {
Text("Click an item to set or clear the filter.", fontStyle = FontStyle.Italic, Text(
color = LocalContentColor.current.copy(0.5f), style = MaterialTheme.typography.bodySmall) "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)) } item { Spacer(Modifier.height(10.dp)) }
items((allHardware intersect enabledHardware.toSet()).toList()) { items((allHardware intersect enabledHardware.toSet()).toList()) {
Row(Modifier.fillMaxWidth().clickable { filterHardware = if(filterHardware == null) it else null }) { Row(
Modifier.fillMaxWidth()
.clickable { filterHardware = if (filterHardware == null) it else null }) {
Text(it, Modifier.padding(start = 5.dp)) Text(it, Modifier.padding(start = 5.dp))
} }
} }
items((allHardware - enabledHardware.toSet()).toList()) { items((allHardware - enabledHardware.toSet()).toList()) {
Text(it, Modifier.padding(start = 5.dp, top = 2.dp, bottom = 2.dp), color = LocalContentColor.current.copy(0.66f)) 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 ?: "<unknown version>"}", 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) -> if (!isNarrow) {
XYAnnotation(Point(x, y), AnchorPoint.TopCenter) { baselineComparison?.let { blc ->
Text("${format(y)}x\n${format(time)} ${currentUnit.disp}", Modifier.padding(top = 5.dp)) 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<Tuple4<String, Float, Color, Float>>, 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 ?: "<unknown version>"}",
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 @Composable
fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit, onDelete: () -> Unit) { fun ConfirmDeleteVersionDialog(version: ProjectVM.Version, onCancel: () -> Unit, onDelete: () -> Unit) {
Dialog(onDismissRequest = onCancel) { 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)) { Column(Modifier.padding(8.dp).widthIn(min = 250.dp).width(IntrinsicSize.Min)) {
Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium) Text("Confirm deletion", Modifier.align(Alignment.CenterHorizontally), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(10.dp)) 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.") Text("This will also remove all data associated with this version.")
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete") CancelConfirmXRow(onCancel, onDelete, confirmText = "Delete")
@@ -1,10 +1,69 @@
package com.jaytux.phoebench.clients 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.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application 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<DpSize, DpOffset> {
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 { fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "PhoeBench") { 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() App()
} }
}
}
} }
@@ -65,7 +65,7 @@ actual fun platformClient(builder: HttpClientConfig<*>.() -> Unit): HttpClient =
actual suspend fun String.toClipEntry(): ClipEntry = actual suspend fun String.toClipEntry(): ClipEntry =
ClipEntry(StringSelection(this)) 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 @Composable
actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate) actual fun ServerSelect(server: String, onUpdate: (String) -> Unit) = DefaultServerSelect(server, onUpdate)
@@ -1,14 +1,30 @@
package com.jaytux.phoebench.clients package com.jaytux.phoebench.clients
import androidx.compose.runtime.CompositionLocal
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.ComposeViewport import androidx.compose.ui.window.ComposeViewport
import com.jaytux.phoebench.clients.theme.GlobalUiMode
import com.jaytux.phoebench.clients.theme.UiMode
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.browser.window
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
suspend fun main() { suspend fun main() {
config = loadConfig() config = loadConfig()
ComposeViewport(document.body!!) { ComposeViewport(document.body!!) {
val width = LocalWindowInfo.current.containerSize
var mode by remember(width) { mutableStateOf(UiMode.fromDp(width.width.dp)) }
CompositionLocalProvider(GlobalUiMode provides mode) {
App() App()
} }
}
} }
@@ -63,7 +63,7 @@ actual suspend fun String.toClipEntry(): ClipEntry =
@JsFun("(num) => num.toFixed(3)") @JsFun("(num) => num.toFixed(3)")
private external fun jsFormat(num: Double): String 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 @Serializable
data class Config(val server: String) data class Config(val server: String)