UI clients: fix narrow vs wide UI

This commit is contained in:
jaytux committed 2026-08-30 21:11:18 +02:00
1 parent 1d10802140
commit bc9861900e
8 files changed
+644 -263

No files matched your search

@@ -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
@@ -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 })) }
@@ -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)
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.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<Uuid, ProjectVM
}
}
}
Box(Modifier.fillMaxSize().padding(15.dp)) {
Box(Modifier.fillMaxSize()) {
ProjectPlotArea(vm, currentBenchmark)
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) {
var lblWidth 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))
Column {
Row {
Text(lbl.name)
Text(" ${lbl.colorStr}", Modifier.align(Alignment.Bottom),
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(alpha = 0.75f),
)
}
Text(lbl.name, overflow = TextOverflow.Ellipsis, maxLines = 1)
if(lbl.meta.isNotBlank())
Text(lbl.meta, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(0.75f))
@@ -536,7 +534,9 @@ fun ProjectPlotArea(vm: ProjectVM, benchmark: ProjectVM.Benchmark) {
val allInputs by vm.inputs
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 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 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)
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<ProjectVM.Version, MutableMap<String, MeanAccumulator>>()
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 ?: "<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))
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<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
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")