From 1a24e1ef848981fe24ff28e77c093e41c3487d6e Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Fri, 17 Apr 2026 12:28:03 +0200 Subject: [PATCH 01/22] feat: stub initial new UI for Alchemist --- SPEC.md | 109 +++ alchemist-composeui/build.gradle.kts | 5 + .../unibo/alchemist/boundary/composeui/App.kt | 35 +- .../alchemist/boundary/composeui/UiModel.kt | 142 +++ .../alchemist/boundary/composeui/UiScreen.kt | 815 ++++++++++++++++++ .../alchemist/boundary/composeui/UiStore.kt | 231 +++++ .../src/commonMain/resources/index.html | 18 +- .../composeui/SimulationControlsStateTest.kt | 50 ++ .../boundary/composeui/ComposeMonitor.kt | 32 +- .../src/jvmMain/resources/composeui-demo.yml | 18 + 10 files changed, 1418 insertions(+), 37 deletions(-) create mode 100644 SPEC.md create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt create mode 100644 alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt create mode 100644 alchemist-composeui/src/jvmMain/resources/composeui-demo.yml diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000000..de88764066 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,109 @@ +# Alchemist Compose UI + +## Goals +- Provide a modern Compose-based simulator UI centered on the rendered environment. +- Keep the visual structure common across targets, with platform modules only adapting simulator state and commands. +- Establish a living specification that can grow with future iterations without forcing a redesign of the document. + +## Design Principles +- Canvas first: the simulation viewport is the dominant surface and receives the strongest visual emphasis. +- Common by default: layout, presentation logic, theming, and interaction shell live in `commonMain`. +- Thin platform adapters: JVM, JS, and WASM modules only translate simulator state into common UI models and dispatch user actions back to the host runtime. +- Progressive disclosure: node details stay out of the main viewport until a selection occurs. +- Low-chrome modernity: restrained translucent panels, clear hierarchy, and motion only where it explains state change. + +## Information Architecture +### Primary regions +- Central viewport: renders nodes and, when available, map-oriented backdrops. +- Bottom control dock: hosts transport controls, status, time, step, and progress. +- Contextual inspector: reveals information for the selected node. + +### Secondary overlays +- Viewport summary chips: node count, dimensions, and scene backdrop. +- Empty/loading/error copy: rendered inside the viewport shell without changing the platform adapter contract. +- Compact inspector shell: becomes a drawer or bottom sheet on narrow layouts. + +## Layouts +### Wide layout +- Viewport fills the remaining space. +- Bottom control dock is anchored to the bottom center. +- Inspector is anchored to the right edge and remains visible while a node is selected. + +### Compact layout +- Viewport stays full-width above the bottom dock. +- Inspector becomes an overlay sheet above the controls. +- The scrim dismisses the inspector when the user clicks outside it. + +## Components +### Viewport +- Compose canvas with a shared visual shell and background treatment. +- Supports node highlighting, node hit-testing, drag-to-pan, mouse-wheel zoom, and summary overlays. +- Accepts normalized viewport nodes from the platform adapter rather than raw simulator entities. + +### Control dock +- Play, pause, and step actions. +- Status pill with color-coded simulator state. +- Time and step metric blocks. +- Progress bar with determinate mode when completion is known, indeterminate otherwise. + +### Node inspector +- Header with node identifier and dismiss action. +- Position section. +- Concentrations section. +- Metadata section for simulator-provided details already available or straightforward to expose. + +## State Model +- `AlchemistUiState`: top-level UI state for the screen. +- `ViewportScene`: viewport payload including nodes, dimensions, backdrop, summary chips, and viewport copy. +- `SimulationControlsState`: simulator status, time label, step count, and progress descriptor. +- `NodeInspectorState`: visible node details for the selected node. +- `ComposeUiStateStore`: thread-safe holder updated by platform monitors/adapters. + +## Platform Integration Boundaries +- `commonMain` owns: + - theming + - layout + - viewport rendering shell + - node selection interactions + - control dock presentation + - inspector presentation +- `jvmMain`, `jsMain`, and `wasmJsMain` own: + - lifecycle bootstrapping + - mapping simulator/runtime state into common UI models + - dispatching user actions such as play, pause, and step + - platform-specific transport or monitor glue + +## Interaction Patterns +- Clicking a node opens the inspector for that node. +- Clicking outside a selected node dismisses the inspector. +- Holding the middle mouse button and dragging pans the camera. +- Using the mouse wheel zooms the viewport in and out around the pointer position. +- Panning is unbounded in every direction, with no camera clamp (CAD-like navigation). +- Control buttons reflect simulator availability: + - `Play` is enabled in ready/paused states. + - `Pause` is enabled while running. + - `Step` is enabled in ready/paused states. +- The step action is part of the UI contract even if some targets still need dedicated wiring. + +## Visual Tokens +- Palette: deep blue/slate surfaces with warm amber and cool cyan accents. +- Shapes: large rounded panels for the dock, viewport shell, and inspector. +- Typography: + - serif headings for emphasis + - monospace metrics for time, steps, and machine-like values +- Motion: + - inspector slide/fade transitions + - no decorative motion in the viewport beyond state-relevant highlighting + +## Accessibility +- Controls use text labels instead of icon-only affordances. +- Status is encoded with both text and color. +- Important values remain visible in compact mode. +- Inspector dismissal is possible both from an explicit button and by clicking the compact-mode scrim. + +## Open Questions / Future Iterations +- Dedicated map tile or raster/vector map underlay support. +- Fit-to-scene and reset-camera shortcuts. +- Additional inspector sections for reactions, neighborhood members, and domain-specific node properties. +- Multi-selection and aggregate inspector views. +- Richer progress semantics for simulations that expose completion estimates. diff --git a/alchemist-composeui/build.gradle.kts b/alchemist-composeui/build.gradle.kts index 45863e6941..904f491aa7 100644 --- a/alchemist-composeui/build.gradle.kts +++ b/alchemist-composeui/build.gradle.kts @@ -31,6 +31,11 @@ kotlin { implementation(libs.bundles.compose) } } + val jvmMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + } + } } } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt index 53ccbf338b..8f5e7d4a53 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2010-2025, Danilo Pianini and contributors + * Copyright (C) 2010-2026, Danilo Pianini and contributors * listed, for each module, in the respective subproject's build.gradle.kts file. * * This file is part of Alchemist, and is distributed under the terms of the @@ -9,37 +9,16 @@ package it.unibo.alchemist.boundary.composeui -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.Button -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.runtime.Composable -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 /** - * Application entry point, this will be rendered the same in all the platforms. + * Application entry point, rendered consistently across supported platforms. */ @Composable -fun app() { - MaterialTheme { - var showContent by remember { mutableStateOf(false) } - Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Button(onClick = { showContent = !showContent }) { - Text("Click me!") - } - AnimatedVisibility(showContent) { - val greeting = remember { getPlatform() } - Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Text("Compose: $greeting") - } - } - } - } +fun app(controller: ComposeUiController = remember { demoController() }) { + AlchemistUiRoot( + state = controller.store.state, + callbacks = controller.callbacks, + ) } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt new file mode 100644 index 0000000000..1df504bae4 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt @@ -0,0 +1,142 @@ +@file:Suppress("UndocumentedPublicFunction", "UndocumentedPublicProperty") + +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +/** + * High-level simulation status mirrored in the Compose UI. + */ +enum class SimulationStatus { + INIT, + READY, + PAUSED, + RUNNING, + TERMINATED, +} + +/** + * Background treatment of the central viewport. + */ +enum class ViewportBackdrop { + SPACE, + MAP, +} + +/** + * A simple label/value pair for inspector and summary sections. + */ +data class InfoField(val label: String, val value: String) + +/** + * A node projected in the central viewport. + */ +data class ViewportNode( + val id: Int, + val coordinates: List, + val accent: Float = 0.5f, + val metadata: List = emptyList(), + val concentrations: List = emptyList(), +) { + init { + require(coordinates.size >= 2) { + "Viewport nodes require at least two coordinates." + } + } +} + +/** + * State of the central scene area. + */ +data class ViewportScene( + val nodes: List = emptyList(), + val dimensions: Int = 2, + val backdrop: ViewportBackdrop = ViewportBackdrop.SPACE, + val summary: List = emptyList(), + val message: String = "Waiting for simulation data", +) + +/** + * State for the bottom control dock progress section. + */ +data class SimulationProgress(val fraction: Float? = null, val label: String = "Progress unavailable") { + init { + require(fraction == null || fraction in 0f..1f) { + "Progress fraction must be null or within [0, 1]." + } + } +} + +/** + * State for transport controls and simulator metrics. + */ +data class SimulationControlsState( + val status: SimulationStatus = SimulationStatus.INIT, + val timeLabel: String = "0", + val step: Long = 0L, + val progress: SimulationProgress = SimulationProgress(), +) { + val canPlay: Boolean = status == SimulationStatus.READY || status == SimulationStatus.PAUSED + val canPause: Boolean = status == SimulationStatus.RUNNING + val canStep: Boolean = status == SimulationStatus.READY || status == SimulationStatus.PAUSED + val statusLabel: String = status.name.lowercase().replaceFirstChar(Char::uppercaseChar) +} + +/** + * State for the node inspector panel. + */ +data class NodeInspectorState( + val nodeId: Int, + val title: String = "Node $nodeId", + val subtitle: String, + val position: List, + val concentrations: List, + val metadata: List, +) + +/** + * Top-level state consumed by the Compose UI shell. + */ +data class AlchemistUiState( + val scene: ViewportScene = ViewportScene(), + val controls: SimulationControlsState = SimulationControlsState(), + val selectedNodeId: Int? = null, + val inspector: NodeInspectorState? = null, +) + +/** + * Interaction contract expected by the common Compose UI. + */ +interface AlchemistUiCallbacks { + fun onPlay() + + fun onPause() + + fun onStep() + + fun onNodeSelected(nodeId: Int) + + fun onInspectorDismiss() +} + +/** + * Shared no-op callback implementation. + */ +object NoOpUiCallbacks : AlchemistUiCallbacks { + override fun onPlay() = Unit + + override fun onPause() = Unit + + override fun onStep() = Unit + + override fun onNodeSelected(nodeId: Int) = Unit + + override fun onInspectorDismiss() = Unit +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt new file mode 100644 index 0000000000..ebd25c7e21 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt @@ -0,0 +1,815 @@ +@file:Suppress("MagicNumber") + +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.ExperimentalComposeUiApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min + +private val Midnight = Color(0xFF07111D) +private val DeepSea = Color(0xFF10253B) +private val Ink = Color(0xFF16293C) +private val Panel = Color(0xF0132237) +private val PanelStrong = Color(0xF70D1A2A) +private val Outline = Color(0xFF426988) +private val Accent = Color(0xFFF0B35A) +private val AccentCool = Color(0xFF6AC3FF) +private val Positive = Color(0xFF6ED39C) +private val TextPrimary = Color(0xFFF4F0E8) +private val TextSecondary = Color(0xFFDCE7F2) +private val TextMuted = Color(0xFFC1D0DE) +private val Danger = Color(0xFFD98B8B) +private const val MinZoom = 0.65f +private const val MaxZoom = 2.4f +private const val ZoomStep = 1.12f +private const val NodeHitRadius = 22f +private const val SelectedNodeRadius = 18f +private const val SelectedNodeInnerRadius = 12f +private const val NodeRadius = 7f +private const val GridVerticalDivisions = 8 +private const val GridHorizontalDivisions = 6 + +/** + * Main shared screen for the simulator UI. + */ +@Composable +fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { + MaterialTheme( + colors = MaterialTheme.colors.copy( + primary = Accent, + primaryVariant = AccentCool, + secondary = AccentCool, + background = Midnight, + surface = Panel, + onPrimary = Midnight, + onSecondary = Midnight, + onBackground = TextPrimary, + onSurface = TextPrimary, + ), + typography = MaterialTheme.typography.copy( + h4 = MaterialTheme.typography.h4.copy( + fontFamily = FontFamily.Serif, + fontWeight = FontWeight.SemiBold, + ), + h6 = MaterialTheme.typography.h6.copy( + fontFamily = FontFamily.Serif, + fontWeight = FontWeight.SemiBold, + ), + subtitle1 = MaterialTheme.typography.subtitle1.copy( + fontWeight = FontWeight.Medium, + color = TextPrimary, + ), + body2 = MaterialTheme.typography.body2.copy( + color = TextSecondary, + ), + caption = MaterialTheme.typography.caption.copy( + fontFamily = FontFamily.Monospace, + color = TextSecondary, + ), + button = MaterialTheme.typography.button.copy( + fontWeight = FontWeight.SemiBold, + ), + ), + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf(Midnight, DeepSea, Ink), + ), + ), + ) { + val compactLayout = maxWidth < 980.dp + val inspectorVisible = state.inspector != null + val inspectorWidth = 324.dp + val bottomBarHeight = 112.dp + Box( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding( + end = if (!compactLayout && inspectorVisible) inspectorWidth + 20.dp else 0.dp, + bottom = bottomBarHeight, + ), + ) { + ViewportSurface( + scene = state.scene, + selectedNodeId = state.selectedNodeId, + callbacks = callbacks, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + } + ControlDock( + controls = state.controls, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(if (compactLayout) 1f else 0.84f) + .wrapContentHeight(), + callbacks = callbacks, + ) + if (!compactLayout) { + AnimatedVisibility( + visible = inspectorVisible, + enter = slideInHorizontally(initialOffsetX = { it / 2 }) + fadeIn(), + exit = slideOutHorizontally(targetOffsetX = { it / 2 }) + fadeOut(), + modifier = Modifier + .align(Alignment.TopEnd) + .fillMaxHeight() + .width(inspectorWidth), + ) { + state.inspector?.let { + NodeInspector( + inspector = it, + onDismiss = callbacks::onInspectorDismiss, + ) + } + } + } else if (inspectorVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x66050A11)) + .clickable(onClick = callbacks::onInspectorDismiss), + ) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomBarHeight + 12.dp) + .fillMaxWidth(), + ) { + NodeInspector( + inspector = requireNotNull(state.inspector), + onDismiss = callbacks::onInspectorDismiss, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun ViewportSurface( + scene: ViewportScene, + selectedNodeId: Int?, + callbacks: AlchemistUiCallbacks, + modifier: Modifier = Modifier, +) { + var viewportSize by remember { mutableStateOf(IntSize.Zero) } + var camera by remember { mutableStateOf(ViewportCameraState()) } + var middleDragAnchor by remember { mutableStateOf(null) } + val baseNodes = remember(scene.nodes, viewportSize) { renderNodes(scene, viewportSize) } + val renderedNodes = remember(baseNodes, viewportSize, camera) { + baseNodes.map { node -> + node.copy(center = node.center.toScreenPosition(viewportSize, camera)) + } + } + val density = androidx.compose.ui.platform.LocalDensity.current + val tapThresholdPx = with(density) { NodeHitRadius.dp.toPx() } + Surface( + modifier = modifier, + color = Panel, + contentColor = TextPrimary, + shape = RoundedCornerShape(28.dp), + elevation = 0.dp, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .border( + width = 1.dp, + color = Outline.copy(alpha = 0.8f), + shape = RoundedCornerShape(28.dp), + ) + .background( + Brush.radialGradient( + colors = listOf(DeepSea.copy(alpha = 0.55f), Midnight), + radius = 1600f, + ), + ), + ) { + Canvas( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> viewportSize = coordinates.size } + .pointerInput(renderedNodes, selectedNodeId) { + detectTapGestures { tapOffset -> + val hit = renderedNodes + .minByOrNull { node -> node.center.distanceTo(tapOffset) } + ?.takeIf { node -> node.center.distanceTo(tapOffset) <= tapThresholdPx } + if (hit != null) { + callbacks.onNodeSelected(hit.node.id) + } else { + callbacks.onInspectorDismiss() + } + } + } + .onPointerEvent(PointerEventType.Press) { event -> + val change = event.changes.firstOrNull() ?: return@onPointerEvent + middleDragAnchor = if (event.buttons.isTertiaryPressed) { + change.position + } else { + null + } + } + .onPointerEvent(PointerEventType.Move) { event -> + val change = event.changes.firstOrNull() ?: return@onPointerEvent + if (event.buttons.isTertiaryPressed) { + val previous = middleDragAnchor ?: change.position + val delta = change.position - previous + if (delta != Offset.Zero) { + camera = camera.panBy(delta) + } + middleDragAnchor = change.position + } else { + middleDragAnchor = null + } + } + .onPointerEvent(PointerEventType.Release) { + middleDragAnchor = null + } + .onPointerEvent(PointerEventType.Scroll) { event -> + val pointerChange = event.changes.firstOrNull() ?: return@onPointerEvent + val scroll = pointerChange.scrollDelta + if (scroll != Offset.Zero && viewportSize.width > 0 && viewportSize.height > 0) { + camera = camera.zoomBy( + viewportSize = viewportSize, + pivot = pointerChange.position, + scrollDelta = scroll.y, + ) + } + }, + ) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf(DeepSea.copy(alpha = 0.55f), Midnight), + ), + ) + drawGrid(size) + renderedNodes.forEach { rendered -> + val isSelected = rendered.node.id == selectedNodeId + val nodeColor = lerp(AccentCool, Accent, rendered.node.accent) + if (isSelected) { + drawCircle( + color = nodeColor.copy(alpha = 0.20f), + radius = SelectedNodeRadius.dp.toPx(), + center = rendered.center, + ) + drawCircle( + color = Accent, + radius = SelectedNodeInnerRadius.dp.toPx(), + center = rendered.center, + style = Stroke(width = 2.dp.toPx()), + ) + } + drawCircle( + brush = Brush.radialGradient( + colors = listOf(nodeColor, nodeColor.copy(alpha = 0.45f)), + center = rendered.center, + radius = SelectedNodeRadius.dp.toPx(), + ), + radius = NodeRadius.dp.toPx(), + center = rendered.center, + ) + } + } + Column( + modifier = Modifier + .align(Alignment.TopStart) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Alchemist Simulator", + style = MaterialTheme.typography.h4, + ) + Text( + text = scene.message, + style = MaterialTheme.typography.body2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + SummaryRail(summary = scene.summary) + } + Surface( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(20.dp), + color = PanelStrong.copy(alpha = 0.88f), + shape = RoundedCornerShape(18.dp), + elevation = 0.dp, + ) { + Text( + text = if (scene.nodes.isEmpty()) { + "No nodes to display" + } else { + "Click to inspect · middle-drag to pan · wheel to zoom" + }, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + style = MaterialTheme.typography.caption, + ) + } + } + } +} + +@Composable +private fun SummaryRail(summary: List) { + if (summary.isEmpty()) { + return + } + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + summary.forEach { item -> + Surface( + color = PanelStrong.copy(alpha = 0.82f), + shape = RoundedCornerShape(999.dp), + elevation = 0.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = item.label.uppercase(), + style = MaterialTheme.typography.caption, + color = TextSecondary, + ) + Text( + text = item.value, + style = MaterialTheme.typography.subtitle1, + ) + } + } + } + } +} + +@Composable +private fun ControlDock( + controls: SimulationControlsState, + callbacks: AlchemistUiCallbacks, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = PanelStrong, + shape = RoundedCornerShape(28.dp), + elevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TransportButton(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = callbacks::onPlay) + TransportButton(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = callbacks::onPause) + TransportButton(label = "Step", enabled = controls.canStep, accent = Accent, onClick = callbacks::onStep) + } + StatusPill(controls = controls) + MetricBlock(label = "Time", value = controls.timeLabel) + MetricBlock(label = "Step", value = controls.step.toString()) + ProgressSection( + progress = controls.progress, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun TransportButton(label: String, enabled: Boolean, accent: Color, onClick: () -> Unit) { + val buttonContentColor = if (accent.luminance() > 0.35f) Midnight else TextPrimary + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(18.dp), + elevation = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = accent.copy(alpha = if (enabled) 0.92f else 0.28f), + contentColor = buttonContentColor, + disabledBackgroundColor = Outline.copy(alpha = 0.65f), + disabledContentColor = TextSecondary, + ), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp), + ) { + Text(text = label) + } +} + +@Composable +private fun StatusPill(controls: SimulationControlsState) { + val color = + when (controls.status) { + SimulationStatus.RUNNING -> Positive + SimulationStatus.PAUSED -> Accent + SimulationStatus.TERMINATED -> Danger + else -> AccentCool + } + Surface( + color = color.copy(alpha = 0.14f), + shape = RoundedCornerShape(999.dp), + elevation = 0.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = Modifier + .size(10.dp) + .background(color = color, shape = CircleShape), + ) + Text( + text = controls.statusLabel, + style = MaterialTheme.typography.subtitle1, + ) + } + } +} + +@Composable +private fun MetricBlock(label: String, value: String) { + Surface( + color = Panel.copy(alpha = 0.78f), + shape = RoundedCornerShape(18.dp), + elevation = 0.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.caption, + color = AccentCool, + ) + Text( + text = value, + style = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily.Monospace), + ) + } + } +} + +@Composable +private fun ProgressSection(progress: SimulationProgress, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Simulation progress", + style = MaterialTheme.typography.subtitle1, + ) + Text( + text = progress.label, + style = MaterialTheme.typography.caption, + color = TextSecondary, + ) + } + if (progress.fraction == null) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + color = Accent, + backgroundColor = Outline.copy(alpha = 0.55f), + ) + } else { + LinearProgressIndicator( + progress = progress.fraction, + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + color = Accent, + backgroundColor = Outline.copy(alpha = 0.55f), + ) + } + } +} + +@Composable +private fun NodeInspector(inspector: NodeInspectorState, onDismiss: () -> Unit, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + color = PanelStrong, + shape = RoundedCornerShape(28.dp), + elevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(22.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = inspector.title, + style = MaterialTheme.typography.h6, + ) + Text( + text = inspector.subtitle, + style = MaterialTheme.typography.body2, + color = TextSecondary, + ) + } + TransportButton( + label = "Close", + enabled = true, + accent = Outline, + onClick = onDismiss, + ) + } + InspectorSection( + title = "Position", + description = "Coordinates in simulator space.", + fields = inspector.position, + ) + InspectorSection( + title = "Concentrations", + description = "Live contents currently stored in the selected node.", + fields = inspector.concentrations.ifEmpty { + listOf(InfoField("No molecules", "This node exposes no concentrations")) + }, + ) + InspectorSection( + title = "Metadata", + description = "Simulator-provided details exposed by the current adapter.", + fields = inspector.metadata.ifEmpty { + listOf(InfoField("Unavailable", "No extra metadata available")) + }, + ) + } + } +} + +@Composable +private fun InspectorSection(title: String, description: String, fields: List) { + Surface( + color = Panel.copy(alpha = 0.72f), + shape = RoundedCornerShape(22.dp), + elevation = 0.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = title, + style = MaterialTheme.typography.subtitle1, + ) + Text( + text = description, + style = MaterialTheme.typography.body2, + ) + } + fields.forEachIndexed { index, field -> + if (index > 0) { + Divider(color = Outline.copy(alpha = 0.42f)) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = field.label, + style = MaterialTheme.typography.body2, + color = TextSecondary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = field.value, + style = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily.Monospace), + ) + } + } + } + } +} + +private fun renderNodes(scene: ViewportScene, viewportSize: IntSize): List { + if (scene.nodes.isEmpty() || viewportSize.width == 0 || viewportSize.height == 0) { + return emptyList() + } + val xs = scene.nodes.map { it.coordinates[0] } + val ys = scene.nodes.map { it.coordinates[1] } + val minX = xs.minOrNull() ?: 0.0 + val maxX = xs.maxOrNull() ?: 0.0 + val minY = ys.minOrNull() ?: 0.0 + val maxY = ys.maxOrNull() ?: 0.0 + val xSpan = max(1e-6, maxX - minX) + val ySpan = max(1e-6, maxY - minY) + val safeWidth = viewportSize.width.toFloat() + val safeHeight = viewportSize.height.toFloat() + val marginX = safeWidth * 0.12f + val marginY = safeHeight * 0.14f + return scene.nodes.map { node -> + val normalizedX = ((node.coordinates[0] - minX) / xSpan).toFloat() + val normalizedY = ((node.coordinates[1] - minY) / ySpan).toFloat() + val x = marginX + normalizedX * (safeWidth - marginX * 2) + val y = safeHeight - marginY - normalizedY * (safeHeight - marginY * 2) + RenderedNode(node = node, center = Offset(x, y)) + } +} + +private data class RenderedNode(val node: ViewportNode, val center: Offset) + +private data class ViewportCameraState( + val pan: Offset = Offset.Zero, + val zoom: Float = 1f, +) + +private fun Offset.distanceTo(other: Offset): Float { + val dx = x - other.x + val dy = y - other.y + return kotlin.math.sqrt(dx * dx + dy * dy) +} + +private fun Offset.toScreenPosition( + viewportSize: IntSize, + camera: ViewportCameraState, +): Offset { + val center = viewportSize.center + return center + ((this - center) * camera.zoom) + camera.pan +} + +private fun ViewportCameraState.panBy(delta: Offset): ViewportCameraState = copy(pan = pan + delta) + +private fun ViewportCameraState.zoomBy( + viewportSize: IntSize, + pivot: Offset, + scrollDelta: Float, +): ViewportCameraState { + val zoomFactor = when { + scrollDelta < 0f -> ZoomStep + scrollDelta > 0f -> 1f / ZoomStep + else -> 1f + } + val targetZoom = (zoom * zoomFactor).coerceIn(MinZoom, MaxZoom) + if (targetZoom == zoom) { + return this + } + val center = viewportSize.center + val worldPoint = pivot.toWorldPosition(viewportSize, this) + val newPan = pivot - center - ((worldPoint - center) * targetZoom) + return copy(zoom = targetZoom, pan = newPan) +} + +private fun Offset.toWorldPosition( + viewportSize: IntSize, + camera: ViewportCameraState, +): Offset { + val center = viewportSize.center + return center + ((this - center - camera.pan) / camera.zoom) +} + +private val IntSize.center: Offset + get() = Offset(width / 2f, height / 2f) + +private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawGrid(canvasSize: Size) { + val stepX = canvasSize.width / GridVerticalDivisions.toFloat() + val stepY = canvasSize.height / GridHorizontalDivisions.toFloat() + for (column in 1 until GridVerticalDivisions) { + drawLine( + color = Outline.copy(alpha = 0.32f), + start = Offset(stepX * column, 0f), + end = Offset(stepX * column, canvasSize.height), + strokeWidth = 1f, + ) + } + for (row in 1 until GridHorizontalDivisions) { + drawLine( + color = Outline.copy(alpha = 0.28f), + start = Offset(0f, stepY * row), + end = Offset(canvasSize.width, stepY * row), + strokeWidth = 1f, + ) + } + drawLine( + color = Outline.copy(alpha = 0.7f), + start = Offset(canvasSize.width / 2f, 0f), + end = Offset(canvasSize.width / 2f, canvasSize.height), + strokeWidth = 1.6f, + cap = StrokeCap.Round, + ) + drawLine( + color = Outline.copy(alpha = 0.7f), + start = Offset(0f, canvasSize.height / 2f), + end = Offset(canvasSize.width, canvasSize.height / 2f), + strokeWidth = 1.6f, + cap = StrokeCap.Round, + ) +} + +private fun lerp(start: Color, end: Color, amount: Float): Color { + val clamped = min(1f, max(0f, amount)) + return Color( + red = start.red + (end.red - start.red) * clamped, + green = start.green + (end.green - start.green) * clamped, + blue = start.blue + (end.blue - start.blue) * clamped, + alpha = start.alpha + (end.alpha - start.alpha) * clamped, + ) +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt new file mode 100644 index 0000000000..ea98b043c4 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt @@ -0,0 +1,231 @@ +@file:Suppress("MagicNumber", "UndocumentedPublicProperty") + +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot + +/** + * Thread-safe holder for the UI state observed by Compose. + */ +class ComposeUiStateStore(initialState: AlchemistUiState) { + var state: AlchemistUiState by mutableStateOf(initialState) + private set + + /** + * Replace the current state. + */ + fun set(newState: AlchemistUiState) { + Snapshot.withMutableSnapshot { + state = newState + } + } + + /** + * Mutate the current state atomically. + */ + fun update(transform: (AlchemistUiState) -> AlchemistUiState) { + Snapshot.withMutableSnapshot { + state = transform(state) + } + } +} + +/** + * Bundles state storage and user callbacks for platform entrypoints. + */ +data class ComposeUiController(val store: ComposeUiStateStore, val callbacks: AlchemistUiCallbacks) + +/** + * Demo controller used by JS/WASM entrypoints and as a fallback shell. + */ +fun demoController(): ComposeUiController { + val store = ComposeUiStateStore(sampleUiState()) + val callbacks = + object : AlchemistUiCallbacks { + override fun onPlay() { + store.update { + it.copy( + controls = it.controls.copy(status = SimulationStatus.RUNNING), + ) + } + } + + override fun onPause() { + store.update { + it.copy( + controls = it.controls.copy(status = SimulationStatus.PAUSED), + ) + } + } + + override fun onStep() { + store.update { + val nextStep = it.controls.step + 1 + it.copy( + controls = it.controls.copy( + status = SimulationStatus.PAUSED, + step = nextStep, + timeLabel = formatDemoTime(nextStep), + progress = SimulationProgress( + fraction = (nextStep % 100).toFloat() / 100f, + label = "Scenario exploration", + ), + ), + ) + } + } + + override fun onNodeSelected(nodeId: Int) { + val node = store.state.scene.nodes.firstOrNull { it.id == nodeId } ?: return + store.update { + it.copy( + selectedNodeId = nodeId, + inspector = node.toInspectorState(), + ) + } + } + + override fun onInspectorDismiss() { + store.update { + it.copy(selectedNodeId = null, inspector = null) + } + } + } + return ComposeUiController(store, callbacks) +} + +private fun sampleUiState(): AlchemistUiState { + val nodes = listOf( + ViewportNode( + id = 1, + coordinates = listOf(-3.5, 1.7), + accent = 0.15f, + metadata = listOf( + InfoField("Neighbors", "4"), + InfoField("Reactions", "3"), + InfoField("Properties", "2"), + ), + concentrations = listOf( + InfoField("signal", "0.91"), + InfoField("gradient", "0.42"), + ), + ), + ViewportNode( + id = 2, + coordinates = listOf(-1.2, 0.3), + accent = 0.33f, + metadata = listOf( + InfoField("Neighbors", "5"), + InfoField("Reactions", "2"), + InfoField("Properties", "1"), + ), + concentrations = listOf( + InfoField("source", "true"), + InfoField("gradient", "0.68"), + ), + ), + ViewportNode( + id = 3, + coordinates = listOf(0.8, 2.2), + accent = 0.55f, + metadata = listOf( + InfoField("Neighbors", "3"), + InfoField("Reactions", "4"), + InfoField("Properties", "2"), + ), + concentrations = listOf( + InfoField("signal", "0.77"), + InfoField("temperature", "296 K"), + ), + ), + ViewportNode( + id = 4, + coordinates = listOf(2.1, -0.8), + accent = 0.74f, + metadata = listOf( + InfoField("Neighbors", "6"), + InfoField("Reactions", "2"), + InfoField("Properties", "3"), + ), + concentrations = listOf( + InfoField("gradient", "0.18"), + InfoField("payload", "ready"), + ), + ), + ViewportNode( + id = 5, + coordinates = listOf(3.9, 1.4), + accent = 0.92f, + metadata = listOf( + InfoField("Neighbors", "2"), + InfoField("Reactions", "1"), + InfoField("Properties", "1"), + ), + concentrations = listOf( + InfoField("goal", "true"), + InfoField("signal", "0.12"), + ), + ), + ) + return AlchemistUiState( + scene = ViewportScene( + nodes = nodes, + dimensions = 2, + backdrop = ViewportBackdrop.SPACE, + summary = listOf( + InfoField("Nodes", nodes.size.toString()), + InfoField("Dimensions", "2D"), + InfoField("Backdrop", "Procedural field"), + ), + message = "Attach a simulation monitor to replace this demo scenario.", + ), + controls = SimulationControlsState( + status = SimulationStatus.PAUSED, + timeLabel = formatDemoTime(42), + step = 42, + progress = SimulationProgress( + fraction = 0.42f, + label = "Scenario exploration", + ), + ), + ) +} + +internal fun ViewportNode.toInspectorState(): NodeInspectorState = NodeInspectorState( + nodeId = id, + subtitle = "Live node snapshot", + position = coordinates.take(2).mapIndexed { index, coordinate -> + InfoField(if (index == 0) "X" else "Y", coordinate.formatFixed(3)) + }, + concentrations = concentrations, + metadata = metadata, +) + +internal fun Double.formatFixed(decimals: Int): String { + val safeDecimals = decimals.coerceAtLeast(0) + val factor = (1..safeDecimals).fold(1.0) { acc, _ -> acc * 10.0 } + val rounded = kotlin.math.round(this * factor) / factor + val raw = rounded.toString() + return when { + safeDecimals == 0 -> raw.substringBefore('.') + '.' !in raw -> raw + "." + "0".repeat(safeDecimals) + else -> { + val fractional = raw.substringAfter('.') + raw + "0".repeat((safeDecimals - fractional.length).coerceAtLeast(0)) + } + } +} + +private fun formatDemoTime(step: Long): String = (step / 10.0).formatFixed(2) diff --git a/alchemist-composeui/src/commonMain/resources/index.html b/alchemist-composeui/src/commonMain/resources/index.html index 81c2bc5a20..f78b770885 100644 --- a/alchemist-composeui/src/commonMain/resources/index.html +++ b/alchemist-composeui/src/commonMain/resources/index.html @@ -4,10 +4,26 @@ Alchemist Compose UI + - diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt new file mode 100644 index 0000000000..b41d11b6e4 --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SimulationControlsStateTest { + @Test + fun `play is available when ready or paused`() { + assertTrue(SimulationControlsState(status = SimulationStatus.READY).canPlay) + assertTrue(SimulationControlsState(status = SimulationStatus.PAUSED).canPlay) + assertFalse(SimulationControlsState(status = SimulationStatus.RUNNING).canPlay) + } + + @Test + fun `pause is only available while running`() { + assertTrue(SimulationControlsState(status = SimulationStatus.RUNNING).canPause) + assertFalse(SimulationControlsState(status = SimulationStatus.PAUSED).canPause) + assertFalse(SimulationControlsState(status = SimulationStatus.TERMINATED).canPause) + } + + @Test + fun `step is available only in controllable idle states`() { + assertTrue(SimulationControlsState(status = SimulationStatus.READY).canStep) + assertTrue(SimulationControlsState(status = SimulationStatus.PAUSED).canStep) + assertFalse(SimulationControlsState(status = SimulationStatus.RUNNING).canStep) + assertFalse(SimulationControlsState(status = SimulationStatus.TERMINATED).canStep) + } + + @Test + fun `store updates atomically`() { + val store = ComposeUiStateStore(AlchemistUiState()) + store.update { + it.copy( + controls = it.controls.copy(step = 7L), + ) + } + assertEquals(7L, store.state.controls.step) + } +} diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt index 77f47406b5..170ede37c2 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt @@ -12,19 +12,35 @@ package it.unibo.alchemist.boundary.composeui import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import it.unibo.alchemist.boundary.OutputMonitor +import it.unibo.alchemist.model.Actionable import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.Time +import java.util.concurrent.atomic.AtomicBoolean /** * Monitor extension that uses JVM Compose UI to display the simulation. */ -class ComposeMonitor : OutputMonitor { - override fun initialized(environment: Environment) { - application { - Window( - onCloseRequest = { }, - title = "Alchemist", - ) { - app() +class ComposeMonitor> : OutputMonitor { + private val windowStarted = AtomicBoolean(false) + + override fun initialized(environment: Environment) { + ensureWindow() + } + + override fun stepDone(environment: Environment, reaction: Actionable?, time: Time, step: Long) = Unit + + override fun finished(environment: Environment, time: Time, step: Long) = Unit + + private fun ensureWindow() { + if (windowStarted.compareAndSet(false, true)) { + application { + Window( + onCloseRequest = { exitApplication() }, + title = "Alchemist", + ) { + app() + } } } } diff --git a/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml new file mode 100644 index 0000000000..df5e81d47f --- /dev/null +++ b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml @@ -0,0 +1,18 @@ +incarnation: sapere + +monitors: + type: ComposeMonitor + +network-model: + type: ConnectWithinDistance + parameters: [30] + +deployments: + - type: Rectangle + parameters: [100, 62, 15, 95, 200] + contents: + - molecule: "source" + concentration: true + in: + type: Circle + parameters: [107.96487911806524, 102.49167432603535, 10] \ No newline at end of file From e55592e218c2467ba1b8b1c267215aecd485807c Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Fri, 17 Apr 2026 16:19:29 +0200 Subject: [PATCH 02/22] feat: start integrating the simulation commands in UI --- alchemist-composeui/build.gradle.kts | 3 ++ .../alchemist/boundary/composeui/UiModel.kt | 20 ++++---- .../alchemist/boundary/composeui/UiScreen.kt | 21 ++++---- .../alchemist/boundary/composeui/UiStore.kt | 31 +++++++++--- .../boundary/composeui/ComposeMonitor.kt | 44 +++++++++++++---- .../composeui/DesktopAlchemistUiCallback.kt | 48 +++++++++++++++++++ .../composeui/adapter/AlchemistNodeAdapter.kt | 44 +++++++++++++++++ .../src/jvmMain/resources/composeui-demo.yml | 31 +++++++----- 8 files changed, 195 insertions(+), 47 deletions(-) create mode 100644 alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt create mode 100644 alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt diff --git a/alchemist-composeui/build.gradle.kts b/alchemist-composeui/build.gradle.kts index 904f491aa7..2b29c6f6eb 100644 --- a/alchemist-composeui/build.gradle.kts +++ b/alchemist-composeui/build.gradle.kts @@ -7,6 +7,7 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ +import Libs.alchemist import it.unibo.alchemist.build.devServer import it.unibo.alchemist.build.webCommonConfiguration import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl @@ -33,7 +34,9 @@ kotlin { } val jvmMain by getting { dependencies { + implementation(alchemist("euclidean-geometry")) implementation(compose.desktop.currentOs) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.10.2") } } } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt index 1df504bae4..539750ad95 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt @@ -115,28 +115,28 @@ data class AlchemistUiState( * Interaction contract expected by the common Compose UI. */ interface AlchemistUiCallbacks { - fun onPlay() + suspend fun onPlay() - fun onPause() + suspend fun onPause() - fun onStep() + suspend fun onStep() - fun onNodeSelected(nodeId: Int) + suspend fun onNodeSelected(nodeId: Int) - fun onInspectorDismiss() + suspend fun onInspectorDismiss() } /** * Shared no-op callback implementation. */ object NoOpUiCallbacks : AlchemistUiCallbacks { - override fun onPlay() = Unit + override suspend fun onPlay() = Unit - override fun onPause() = Unit + override suspend fun onPause() = Unit - override fun onStep() = Unit + override suspend fun onStep() = Unit - override fun onNodeSelected(nodeId: Int) = Unit + override suspend fun onNodeSelected(nodeId: Int) = Unit - override fun onInspectorDismiss() = Unit + override suspend fun onInspectorDismiss() = Unit } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt index ebd25c7e21..b998ec673a 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -75,6 +76,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import kotlin.math.max import kotlin.math.min +import kotlinx.coroutines.launch private val Midnight = Color(0xFF07111D) private val DeepSea = Color(0xFF10253B) @@ -104,6 +106,7 @@ private const val GridHorizontalDivisions = 6 */ @Composable fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { + val coroutineScope = rememberCoroutineScope() MaterialTheme( colors = MaterialTheme.colors.copy( primary = Accent, @@ -197,7 +200,7 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { state.inspector?.let { NodeInspector( inspector = it, - onDismiss = callbacks::onInspectorDismiss, + onDismiss = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, ) } } @@ -206,7 +209,7 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { modifier = Modifier .fillMaxSize() .background(Color(0x66050A11)) - .clickable(onClick = callbacks::onInspectorDismiss), + .clickable(onClick = { coroutineScope.launch { callbacks.onInspectorDismiss() } }), ) Box( modifier = Modifier @@ -216,7 +219,7 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { ) { NodeInspector( inspector = requireNotNull(state.inspector), - onDismiss = callbacks::onInspectorDismiss, + onDismiss = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, modifier = Modifier.fillMaxWidth(), ) } @@ -252,6 +255,7 @@ private fun ViewportSurface( shape = RoundedCornerShape(28.dp), elevation = 0.dp, ) { + val coroutineScope = rememberCoroutineScope() Box( modifier = Modifier .fillMaxSize() @@ -277,9 +281,9 @@ private fun ViewportSurface( .minByOrNull { node -> node.center.distanceTo(tapOffset) } ?.takeIf { node -> node.center.distanceTo(tapOffset) <= tapThresholdPx } if (hit != null) { - callbacks.onNodeSelected(hit.node.id) + coroutineScope.launch { callbacks.onNodeSelected(hit.node.id) } } else { - callbacks.onInspectorDismiss() + coroutineScope.launch { callbacks.onInspectorDismiss() } } } } @@ -433,6 +437,7 @@ private fun ControlDock( callbacks: AlchemistUiCallbacks, modifier: Modifier = Modifier, ) { + val coroutineScope = rememberCoroutineScope() Surface( modifier = modifier, color = PanelStrong, @@ -450,9 +455,9 @@ private fun ControlDock( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - TransportButton(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = callbacks::onPlay) - TransportButton(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = callbacks::onPause) - TransportButton(label = "Step", enabled = controls.canStep, accent = Accent, onClick = callbacks::onStep) + TransportButton(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = { coroutineScope.launch { callbacks.onPlay() }}) + TransportButton(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = { coroutineScope.launch { callbacks.onPause() }}) + TransportButton(label = "Step", enabled = controls.canStep, accent = Accent, onClick = { coroutineScope.launch { callbacks.onStep() }}) } StatusPill(controls = controls) MetricBlock(label = "Time", value = controls.timeLabel) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt index ea98b043c4..f34a771e1a 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.snapshots.SnapshotApplyConflictException /** * Thread-safe holder for the UI state observed by Compose. @@ -27,7 +28,7 @@ class ComposeUiStateStore(initialState: AlchemistUiState) { * Replace the current state. */ fun set(newState: AlchemistUiState) { - Snapshot.withMutableSnapshot { + mutateState { state = newState } } @@ -36,10 +37,26 @@ class ComposeUiStateStore(initialState: AlchemistUiState) { * Mutate the current state atomically. */ fun update(transform: (AlchemistUiState) -> AlchemistUiState) { - Snapshot.withMutableSnapshot { + mutateState { state = transform(state) } } + + /** + * Compose snapshots are optimistic: concurrent writers may race, and the loser must retry. + */ + private fun mutateState(mutation: () -> Unit) { + runCatching { + Snapshot.withMutableSnapshot { + mutation() + } + }.getOrElse { error -> + when (error) { + is SnapshotApplyConflictException -> mutateState(mutation) + else -> throw error + } + } + } } /** @@ -54,7 +71,7 @@ fun demoController(): ComposeUiController { val store = ComposeUiStateStore(sampleUiState()) val callbacks = object : AlchemistUiCallbacks { - override fun onPlay() { + override suspend fun onPlay() { store.update { it.copy( controls = it.controls.copy(status = SimulationStatus.RUNNING), @@ -62,7 +79,7 @@ fun demoController(): ComposeUiController { } } - override fun onPause() { + override suspend fun onPause() { store.update { it.copy( controls = it.controls.copy(status = SimulationStatus.PAUSED), @@ -70,7 +87,7 @@ fun demoController(): ComposeUiController { } } - override fun onStep() { + override suspend fun onStep() { store.update { val nextStep = it.controls.step + 1 it.copy( @@ -87,7 +104,7 @@ fun demoController(): ComposeUiController { } } - override fun onNodeSelected(nodeId: Int) { + override suspend fun onNodeSelected(nodeId: Int) { val node = store.state.scene.nodes.firstOrNull { it.id == nodeId } ?: return store.update { it.copy( @@ -97,7 +114,7 @@ fun demoController(): ComposeUiController { } } - override fun onInspectorDismiss() { + override suspend fun onInspectorDismiss() { store.update { it.copy(selectedNodeId = null, inspector = null) } diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt index 170ede37c2..4da9fa3afa 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt @@ -9,9 +9,13 @@ package it.unibo.alchemist.boundary.composeui +import androidx.compose.runtime.remember import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import it.unibo.alchemist.boundary.OutputMonitor +import it.unibo.alchemist.boundary.composeui.adapter.toSimulationStatus +import it.unibo.alchemist.boundary.composeui.adapter.toViewport +import it.unibo.alchemist.core.Status import it.unibo.alchemist.model.Actionable import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Position @@ -23,25 +27,45 @@ import java.util.concurrent.atomic.AtomicBoolean */ class ComposeMonitor> : OutputMonitor { private val windowStarted = AtomicBoolean(false) + private val currentUiState by lazy { ComposeUiStateStore(AlchemistUiState()) } override fun initialized(environment: Environment) { - ensureWindow() + ensureWindow(environment) + currentUiState.update { it.copy(controls = it.controls.copy(status = environment.simulation.toSimulationStatus() )) } } - override fun stepDone(environment: Environment, reaction: Actionable?, time: Time, step: Long) = Unit + override fun stepDone(environment: Environment, reaction: Actionable?, time: Time, step: Long) { + currentUiState.update { + it.copy( + scene = environment.toViewport(), + controls = it.controls.copy( + timeLabel = time.toString(), + step = step, + status = environment.simulation.toSimulationStatus(), + ) + ) + } + } override fun finished(environment: Environment, time: Time, step: Long) = Unit - private fun ensureWindow() { + private fun ensureWindow(environment: Environment) { if (windowStarted.compareAndSet(false, true)) { - application { - Window( - onCloseRequest = { exitApplication() }, - title = "Alchemist", - ) { - app() + Thread { + application { + Window( + onCloseRequest = { exitApplication() }, + title = "Alchemist", + ) { + app(remember { + alchemistDesktopController(environment) + }) + } } - } + }.start() } } + + private fun alchemistDesktopController(environment: Environment): ComposeUiController = + ComposeUiController(currentUiState, DesktopAlchemistUiCallback(environment.simulation, currentUiState)) } diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt new file mode 100644 index 0000000000..4b29d756dc --- /dev/null +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import it.unibo.alchemist.core.Simulation +import it.unibo.alchemist.model.Position +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.future.await +import kotlinx.coroutines.withContext + +class DesktopAlchemistUiCallback>( + private val simulation: Simulation, + private val store: ComposeUiStateStore +) : AlchemistUiCallbacks { + override suspend fun onPlay() = coroutineScope { + simulation.play().await() + withContext(Dispatchers.Main) { + store.update { it.copy(controls = it.controls.copy(status = SimulationStatus.RUNNING)) } + } + } + + override suspend fun onPause() = coroutineScope { + simulation.pause().await() + withContext(Dispatchers.Main) { + store.update { it.copy(controls = it.controls.copy(status = SimulationStatus.PAUSED)) } + } + } + + override suspend fun onStep() { + TODO("Not yet implemented") + } + + override suspend fun onNodeSelected(nodeId: Int) { + TODO("Not yet implemented") + } + + override suspend fun onInspectorDismiss() { + TODO("Not yet implemented") + } +} diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt new file mode 100644 index 0000000000..5fc69d11a9 --- /dev/null +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui.adapter + +import it.unibo.alchemist.boundary.composeui.InfoField +import it.unibo.alchemist.boundary.composeui.SimulationStatus +import it.unibo.alchemist.boundary.composeui.ViewportNode +import it.unibo.alchemist.boundary.composeui.ViewportScene +import it.unibo.alchemist.core.Simulation +import it.unibo.alchemist.core.Status +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.environments.Continuous2DEnvironment + +fun > Node.toViewport(environment: Environment): ViewportNode = ViewportNode( + id = id, + coordinates = environment.getPosition(this).coordinates.toList(), + concentrations = this.contents.map { InfoField(it.key.toString(), it.value.toString()) } +) + +fun > Environment.toViewport(): ViewportScene = ViewportScene( + nodes = nodes.map { it.toViewport(this) }, + dimensions = when (this) { + // TODO: add the other environments + is Continuous2DEnvironment -> 2 + else -> 2 + } +) + +fun > Simulation.toSimulationStatus(): SimulationStatus = when (this.status) { + Status.INIT -> SimulationStatus.INIT + Status.READY -> SimulationStatus.READY + Status.PAUSED -> SimulationStatus.PAUSED + Status.RUNNING -> SimulationStatus.RUNNING + Status.TERMINATED -> SimulationStatus.TERMINATED +} diff --git a/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml index df5e81d47f..3890abae7a 100644 --- a/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml +++ b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml @@ -1,18 +1,25 @@ incarnation: sapere -monitors: - type: ComposeMonitor - network-model: type: ConnectWithinDistance - parameters: [30] + parameters: [0.5] + +monitors: + type: ComposeMonitor deployments: - - type: Rectangle - parameters: [100, 62, 15, 95, 200] - contents: - - molecule: "source" - concentration: true - in: - type: Circle - parameters: [107.96487911806524, 102.49167432603535, 10] \ No newline at end of file + type: Grid + parameters: [-5, -5, 5, 5, 0.25, 0.25, 0.1, 0.1] + contents: + - in: + type: Rectangle + parameters: [-0.5, -0.5, 1, 1] + molecule: token + programs: # A list of the sets of reactions programming the node + - + - time-distribution: 1 # Frequency. If the class is not specified, the implementation to use is chosen by the incarnation. The SAPERE incarnation automatically loads ExponentialTime, which takes a number representing the Markovian rate. + # program lets the incarnation choose the class implementing Reaction, and passes down a string that, when parsed, produces the program + program: > # ">" begins a multiline string (quote mode) + {token} --> {firing} + # If the time distribution is unspecified, the SAPERE incarnation assumes a "ASAP" behavior (rate = Infinity) + - program: "{firing} --> +{token}" \ No newline at end of file From beb19a5734938b4a770cbe9d3a63179376548957 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Fri, 17 Apr 2026 17:57:05 +0200 Subject: [PATCH 03/22] feat: add link connection and fix start/stop button --- .../unibo/alchemist/boundary/composeui/App.kt | 5 +- .../alchemist/boundary/composeui/UiModel.kt | 17 ++++++ .../alchemist/boundary/composeui/UiScreen.kt | 57 +++++++++++++++++-- .../alchemist/boundary/composeui/UiStore.kt | 54 ++++++++---------- .../composeui/SimulationControlsStateTest.kt | 31 ++++++++++ .../boundary/composeui/ComposeMonitor.kt | 4 +- .../composeui/DesktopAlchemistUiCallback.kt | 51 +++++++++++++---- .../composeui/adapter/AlchemistNodeAdapter.kt | 16 ++++++ .../src/jvmMain/resources/composeui-demo.yml | 5 +- .../adapter/AlchemistNodeAdapterTest.kt | 35 ++++++++++++ 10 files changed, 227 insertions(+), 48 deletions(-) create mode 100644 alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt index 8f5e7d4a53..411b655e94 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt @@ -10,6 +10,8 @@ package it.unibo.alchemist.boundary.composeui import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember /** @@ -17,8 +19,9 @@ import androidx.compose.runtime.remember */ @Composable fun app(controller: ComposeUiController = remember { demoController() }) { + val state by controller.store.stateFlow.collectAsState() AlchemistUiRoot( - state = controller.store.state, + state = state, callbacks = controller.callbacks, ) } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt index 539750ad95..99ad563f8e 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt @@ -52,11 +52,24 @@ data class ViewportNode( } } +/** + * An undirected edge projected in the central viewport. + */ +data class ViewportEdge(val fromNodeId: Int, val toNodeId: Int) { + init { + require(fromNodeId != toNodeId) { + "Viewport edges require two distinct endpoints." + } + } +} + /** * State of the central scene area. */ data class ViewportScene( val nodes: List = emptyList(), + val edges: List = emptyList(), + val showLinks: Boolean = false, val dimensions: Int = 2, val backdrop: ViewportBackdrop = ViewportBackdrop.SPACE, val summary: List = emptyList(), @@ -124,6 +137,8 @@ interface AlchemistUiCallbacks { suspend fun onNodeSelected(nodeId: Int) suspend fun onInspectorDismiss() + + suspend fun onToggleLinks() } /** @@ -139,4 +154,6 @@ object NoOpUiCallbacks : AlchemistUiCallbacks { override suspend fun onNodeSelected(nodeId: Int) = Unit override suspend fun onInspectorDismiss() = Unit + + override suspend fun onToggleLinks() = Unit } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt index b998ec673a..186479c517 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt @@ -98,6 +98,7 @@ private const val NodeHitRadius = 22f private const val SelectedNodeRadius = 18f private const val SelectedNodeInnerRadius = 12f private const val NodeRadius = 7f +private const val LinkStrokeWidth = 1.5f private const val GridVerticalDivisions = 8 private const val GridHorizontalDivisions = 6 @@ -246,6 +247,7 @@ private fun ViewportSurface( node.copy(center = node.center.toScreenPosition(viewportSize, camera)) } } + val renderedEdges = remember(scene.edges, renderedNodes) { renderEdges(scene.edges, renderedNodes) } val density = androidx.compose.ui.platform.LocalDensity.current val tapThresholdPx = with(density) { NodeHitRadius.dp.toPx() } Surface( @@ -329,6 +331,17 @@ private fun ViewportSurface( ), ) drawGrid(size) + if (scene.showLinks) { + renderedEdges.forEach { edge -> + drawLine( + color = Outline.copy(alpha = 0.42f), + start = edge.start, + end = edge.end, + strokeWidth = LinkStrokeWidth.dp.toPx(), + cap = StrokeCap.Round, + ) + } + } renderedNodes.forEach { rendered -> val isSelected = rendered.node.id == selectedNodeId val nodeColor = lerp(AccentCool, Accent, rendered.node.accent) @@ -372,7 +385,11 @@ private fun ViewportSurface( maxLines = 2, overflow = TextOverflow.Ellipsis, ) - SummaryRail(summary = scene.summary) + SummaryRail( + summary = scene.summary, + showLinks = scene.showLinks, + onToggleLinks = { coroutineScope.launch { callbacks.onToggleLinks() } }, + ) } Surface( modifier = Modifier @@ -397,10 +414,7 @@ private fun ViewportSurface( } @Composable -private fun SummaryRail(summary: List) { - if (summary.isEmpty()) { - return - } +private fun SummaryRail(summary: List, showLinks: Boolean, onToggleLinks: () -> Unit) { Row( modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -428,6 +442,28 @@ private fun SummaryRail(summary: List) { } } } + Surface( + modifier = Modifier.clickable(onClick = onToggleLinks), + color = if (showLinks) AccentCool.copy(alpha = 0.2f) else PanelStrong.copy(alpha = 0.82f), + shape = RoundedCornerShape(999.dp), + elevation = 0.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "LINKS", + style = MaterialTheme.typography.caption, + color = if (showLinks) AccentCool else TextSecondary, + ) + Text( + text = if (showLinks) "ON" else "OFF", + style = MaterialTheme.typography.subtitle1, + ) + } + } } } @@ -720,8 +756,19 @@ private fun renderNodes(scene: ViewportScene, viewportSize: IntSize): List, renderedNodes: List): List { + val nodesById = renderedNodes.associateBy { it.node.id } + return edges.mapNotNull { edge -> + val from = nodesById[edge.fromNodeId] ?: return@mapNotNull null + val to = nodesById[edge.toNodeId] ?: return@mapNotNull null + RenderedEdge(start = from.center, end = to.center) + } +} + private data class RenderedNode(val node: ViewportNode, val center: Offset) +private data class RenderedEdge(val start: Offset, val end: Offset) + private data class ViewportCameraState( val pan: Offset = Offset.Zero, val zoom: Float = 1f, diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt index f34a771e1a..435255c59c 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt @@ -11,51 +11,32 @@ package it.unibo.alchemist.boundary.composeui -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.runtime.snapshots.SnapshotApplyConflictException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update /** * Thread-safe holder for the UI state observed by Compose. */ class ComposeUiStateStore(initialState: AlchemistUiState) { - var state: AlchemistUiState by mutableStateOf(initialState) - private set + private val mutableState = MutableStateFlow(initialState) + val state: AlchemistUiState + get() = mutableState.value + val stateFlow: StateFlow = mutableState.asStateFlow() /** * Replace the current state. */ fun set(newState: AlchemistUiState) { - mutateState { - state = newState - } + mutableState.value = newState } /** * Mutate the current state atomically. */ fun update(transform: (AlchemistUiState) -> AlchemistUiState) { - mutateState { - state = transform(state) - } - } - - /** - * Compose snapshots are optimistic: concurrent writers may race, and the loser must retry. - */ - private fun mutateState(mutation: () -> Unit) { - runCatching { - Snapshot.withMutableSnapshot { - mutation() - } - }.getOrElse { error -> - when (error) { - is SnapshotApplyConflictException -> mutateState(mutation) - else -> throw error - } - } + mutableState.update(transform) } } @@ -119,6 +100,14 @@ fun demoController(): ComposeUiController { it.copy(selectedNodeId = null, inspector = null) } } + + override suspend fun onToggleLinks() { + store.update { + it.copy( + scene = it.scene.copy(showLinks = !it.scene.showLinks), + ) + } + } } return ComposeUiController(store, callbacks) } @@ -199,6 +188,13 @@ private fun sampleUiState(): AlchemistUiState { return AlchemistUiState( scene = ViewportScene( nodes = nodes, + edges = listOf( + ViewportEdge(1, 2), + ViewportEdge(2, 3), + ViewportEdge(3, 4), + ViewportEdge(4, 5), + ViewportEdge(1, 3), + ), dimensions = 2, backdrop = ViewportBackdrop.SPACE, summary = listOf( diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt index b41d11b6e4..64d8cecb5d 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt @@ -13,6 +13,9 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.coroutines.Continuation +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine class SimulationControlsStateTest { @Test @@ -47,4 +50,32 @@ class SimulationControlsStateTest { } assertEquals(7L, store.state.controls.step) } + + @Test + fun `viewport links are hidden by default`() { + assertFalse(ViewportScene().showLinks) + } + + @Test + fun `demo controller toggles links without changing selection`() { + val controller = demoController() + controller.store.update { it.copy(selectedNodeId = 3) } + + runSuspend { controller.callbacks.onToggleLinks() } + + assertTrue(controller.store.state.scene.showLinks) + assertEquals(3, controller.store.state.selectedNodeId) + } +} + +private fun runSuspend(block: suspend () -> Unit) { + block.startCoroutine( + object : Continuation { + override val context = EmptyCoroutineContext + + override fun resumeWith(result: Result) { + result.getOrThrow() + } + }, + ) } diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt index 4da9fa3afa..6110632126 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.window.application import it.unibo.alchemist.boundary.OutputMonitor import it.unibo.alchemist.boundary.composeui.adapter.toSimulationStatus import it.unibo.alchemist.boundary.composeui.adapter.toViewport -import it.unibo.alchemist.core.Status import it.unibo.alchemist.model.Actionable import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Position @@ -36,8 +35,9 @@ class ComposeMonitor> : OutputMonitor { override fun stepDone(environment: Environment, reaction: Actionable?, time: Time, step: Long) { currentUiState.update { + val viewport = environment.toViewport() it.copy( - scene = environment.toViewport(), + scene = viewport.copy(showLinks = it.scene.showLinks), controls = it.controls.copy( timeLabel = time.toString(), step = step, diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt index 4b29d756dc..1e7896dfe2 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt @@ -9,10 +9,10 @@ package it.unibo.alchemist.boundary.composeui +import it.unibo.alchemist.boundary.composeui.adapter.toSimulationStatus import it.unibo.alchemist.core.Simulation import it.unibo.alchemist.model.Position import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.future.await import kotlinx.coroutines.withContext @@ -20,29 +20,60 @@ class DesktopAlchemistUiCallback>( private val simulation: Simulation, private val store: ComposeUiStateStore ) : AlchemistUiCallbacks { - override suspend fun onPlay() = coroutineScope { + override suspend fun onPlay() { simulation.play().await() - withContext(Dispatchers.Main) { - store.update { it.copy(controls = it.controls.copy(status = SimulationStatus.RUNNING)) } + updateState { + it.copy(controls = it.controls.copy(status = simulation.toSimulationStatus())) } } - override suspend fun onPause() = coroutineScope { + override suspend fun onPause() { simulation.pause().await() - withContext(Dispatchers.Main) { - store.update { it.copy(controls = it.controls.copy(status = SimulationStatus.PAUSED)) } + updateState { + it.copy(controls = it.controls.copy(status = simulation.toSimulationStatus())) } } override suspend fun onStep() { - TODO("Not yet implemented") + val nextStep = simulation.step + 1 + val stepCompletion = simulation.goToStep(nextStep) + simulation.play().await() + stepCompletion.await() + updateState { + it.copy(controls = it.controls.copy(status = simulation.toSimulationStatus())) + } } override suspend fun onNodeSelected(nodeId: Int) { - TODO("Not yet implemented") + updateState { currentState -> + val node = currentState.scene.nodes.firstOrNull { it.id == nodeId } ?: return@updateState currentState + currentState.copy( + selectedNodeId = nodeId, + inspector = node.toInspectorState(), + ) + } } override suspend fun onInspectorDismiss() { - TODO("Not yet implemented") + updateState { + it.copy( + selectedNodeId = null, + inspector = null, + ) + } + } + + override suspend fun onToggleLinks() { + updateState { + it.copy( + scene = it.scene.copy(showLinks = !it.scene.showLinks), + ) + } + } + + private suspend fun updateState(transform: (AlchemistUiState) -> AlchemistUiState) { + withContext(Dispatchers.Main.immediate) { + store.update(transform) + } } } diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt index 5fc69d11a9..edc01fe965 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt @@ -11,6 +11,7 @@ package it.unibo.alchemist.boundary.composeui.adapter import it.unibo.alchemist.boundary.composeui.InfoField import it.unibo.alchemist.boundary.composeui.SimulationStatus +import it.unibo.alchemist.boundary.composeui.ViewportEdge import it.unibo.alchemist.boundary.composeui.ViewportNode import it.unibo.alchemist.boundary.composeui.ViewportScene import it.unibo.alchemist.core.Simulation @@ -28,6 +29,7 @@ fun > Node.toViewport(environment: Environment): Vie fun > Environment.toViewport(): ViewportScene = ViewportScene( nodes = nodes.map { it.toViewport(this) }, + edges = extractEdges(), dimensions = when (this) { // TODO: add the other environments is Continuous2DEnvironment -> 2 @@ -42,3 +44,17 @@ fun > Simulation.toSimulationStatus(): SimulationStatus Status.RUNNING -> SimulationStatus.RUNNING Status.TERMINATED -> SimulationStatus.TERMINATED } + +private fun > Environment.extractEdges(): List = buildSet { + nodes.forEach { node -> + getNeighborhood(node).forEach { neighbor -> + canonicalEdge(node.id, neighbor.id)?.let(::add) + } + } +}.toList() + +internal fun canonicalEdge(firstNodeId: Int, secondNodeId: Int): ViewportEdge? = when { + firstNodeId == secondNodeId -> null + firstNodeId < secondNodeId -> ViewportEdge(firstNodeId, secondNodeId) + else -> ViewportEdge(secondNodeId, firstNodeId) +} diff --git a/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml index 3890abae7a..10958e7f49 100644 --- a/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml +++ b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml @@ -22,4 +22,7 @@ deployments: program: > # ">" begins a multiline string (quote mode) {token} --> {firing} # If the time distribution is unspecified, the SAPERE incarnation assumes a "ASAP" behavior (rate = Infinity) - - program: "{firing} --> +{token}" \ No newline at end of file + - program: "{firing} --> +{token}" + - type: Event + time-distribution: 1 + actions: { type: BrownianMove, parameters: [ 0.4 ] } \ No newline at end of file diff --git a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt new file mode 100644 index 0000000000..ed2717d7c3 --- /dev/null +++ b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui.adapter + +import it.unibo.alchemist.boundary.composeui.ViewportEdge +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AlchemistNodeAdapterTest { + @Test + fun `canonical edge sorts endpoints`() { + assertEquals(ViewportEdge(2, 5), canonicalEdge(5, 2)) + } + + @Test + fun `canonical edge ignores self loops`() { + assertNull(canonicalEdge(4, 4)) + } + + @Test + fun `canonical edge deduplicates mutual links`() { + val uniqueEdges = setOfNotNull(canonicalEdge(1, 3), canonicalEdge(3, 1)) + assertEquals(1, uniqueEdges.size) + assertTrue(uniqueEdges.contains(ViewportEdge(1, 3))) + } +} From d61ee47304dbce235b6257ed26ff7cb9141660a6 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Sat, 18 Apr 2026 00:13:03 +0200 Subject: [PATCH 04/22] refactor: revise a bit the layout --- .../alchemist/boundary/composeui/UiScreen.kt | 183 +++++++++++------- .../boundary/composeui/ViewportProjection.kt | 68 +++++++ .../composeui/ViewportCameraMathTest.kt | 34 ++++ .../composeui/ViewportProjectionTest.kt | 43 ++++ 4 files changed, 259 insertions(+), 69 deletions(-) create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjection.kt create mode 100644 alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportCameraMathTest.kt create mode 100644 alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt index 186479c517..ec36c7fb9c 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt @@ -1,5 +1,3 @@ -@file:Suppress("MagicNumber") - /* * Copyright (C) 2010-2026, Danilo Pianini and contributors * listed, for each module, in the respective subproject's build.gradle.kts file. @@ -49,6 +47,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -91,8 +90,6 @@ private val TextPrimary = Color(0xFFF4F0E8) private val TextSecondary = Color(0xFFDCE7F2) private val TextMuted = Color(0xFFC1D0DE) private val Danger = Color(0xFFD98B8B) -private const val MinZoom = 0.65f -private const val MaxZoom = 2.4f private const val ZoomStep = 1.12f private const val NodeHitRadius = 22f private const val SelectedNodeRadius = 18f @@ -158,43 +155,62 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { val inspectorVisible = state.inspector != null val inspectorWidth = 324.dp val bottomBarHeight = 112.dp - Box( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - ) { - Column( + val layoutSpacing = 20.dp + if (compactLayout) { + Box( modifier = Modifier .fillMaxSize() - .padding( - end = if (!compactLayout && inspectorVisible) inspectorWidth + 20.dp else 0.dp, - bottom = bottomBarHeight, - ), + .padding(layoutSpacing), ) { - ViewportSurface( - scene = state.scene, - selectedNodeId = state.selectedNodeId, + SimulationPrimaryPane( + state = state, callbacks = callbacks, - modifier = Modifier - .fillMaxWidth() - .weight(1f), + dockWidthFraction = 1f, + spacing = layoutSpacing, + modifier = Modifier.fillMaxSize(), ) + if (inspectorVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x66050A11)) + .clickable(onClick = { coroutineScope.launch { callbacks.onInspectorDismiss() } }), + ) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomBarHeight + 12.dp) + .fillMaxWidth(), + ) { + NodeInspector( + inspector = requireNotNull(state.inspector), + onDismiss = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, + modifier = Modifier.fillMaxWidth(), + ) + } + } } - ControlDock( - controls = state.controls, + } else { + Row( modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth(if (compactLayout) 1f else 0.84f) - .wrapContentHeight(), - callbacks = callbacks, - ) - if (!compactLayout) { + .fillMaxSize() + .padding(layoutSpacing), + horizontalArrangement = Arrangement.spacedBy(layoutSpacing), + ) { + SimulationPrimaryPane( + state = state, + callbacks = callbacks, + dockWidthFraction = 0.84f, + spacing = layoutSpacing, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) AnimatedVisibility( visible = inspectorVisible, enter = slideInHorizontally(initialOffsetX = { it / 2 }) + fadeIn(), exit = slideOutHorizontally(targetOffsetX = { it / 2 }) + fadeOut(), modifier = Modifier - .align(Alignment.TopEnd) .fillMaxHeight() .width(inspectorWidth), ) { @@ -202,34 +218,52 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { NodeInspector( inspector = it, onDismiss = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, + modifier = Modifier.fillMaxHeight(), ) } } - } else if (inspectorVisible) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color(0x66050A11)) - .clickable(onClick = { coroutineScope.launch { callbacks.onInspectorDismiss() } }), - ) - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = bottomBarHeight + 12.dp) - .fillMaxWidth(), - ) { - NodeInspector( - inspector = requireNotNull(state.inspector), - onDismiss = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, - modifier = Modifier.fillMaxWidth(), - ) - } } } } } } +@Composable +private fun SimulationPrimaryPane( + state: AlchemistUiState, + callbacks: AlchemistUiCallbacks, + dockWidthFraction: Float, + spacing: androidx.compose.ui.unit.Dp, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(spacing), + ) { + ViewportSurface( + scene = state.scene, + selectedNodeId = state.selectedNodeId, + callbacks = callbacks, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + ControlDock( + controls = state.controls, + modifier = Modifier + .fillMaxWidth(dockWidthFraction) + .wrapContentHeight(), + callbacks = callbacks, + ) + } + } +} + @OptIn(ExperimentalComposeUiApi::class) @Composable private fun ViewportSurface( @@ -240,8 +274,16 @@ private fun ViewportSurface( ) { var viewportSize by remember { mutableStateOf(IntSize.Zero) } var camera by remember { mutableStateOf(ViewportCameraState()) } + var fixedProjection by remember { mutableStateOf(null) } var middleDragAnchor by remember { mutableStateOf(null) } - val baseNodes = remember(scene.nodes, viewportSize) { renderNodes(scene, viewportSize) } + val candidateProjection = remember(scene.nodes, viewportSize) { scene.createViewportProjection(viewportSize) } + val projection = fixedProjection ?: candidateProjection + LaunchedEffect(candidateProjection) { + if (fixedProjection == null && candidateProjection != null) { + fixedProjection = candidateProjection + } + } + val baseNodes = remember(scene.nodes, viewportSize, projection) { renderNodes(scene, viewportSize, projection) } val renderedNodes = remember(baseNodes, viewportSize, camera) { baseNodes.map { node -> node.copy(center = node.center.toScreenPosition(viewportSize, camera)) @@ -629,6 +671,7 @@ private fun NodeInspector(inspector: NodeInspectorState, onDismiss: () -> Unit, Surface( modifier = modifier, color = PanelStrong, + contentColor = TextPrimary, shape = RoundedCornerShape(28.dp), elevation = 0.dp, ) { @@ -689,6 +732,7 @@ private fun NodeInspector(inspector: NodeInspectorState, onDismiss: () -> Unit, private fun InspectorSection(title: String, description: String, fields: List) { Surface( color = Panel.copy(alpha = 0.72f), + contentColor = TextPrimary, shape = RoundedCornerShape(22.dp), elevation = 0.dp, ) { @@ -731,28 +775,16 @@ private fun InspectorSection(title: String, description: String, fields: List { - if (scene.nodes.isEmpty() || viewportSize.width == 0 || viewportSize.height == 0) { +private fun renderNodes( + scene: ViewportScene, + viewportSize: IntSize, + projection: ViewportProjection?, +): List { + if (projection == null || scene.nodes.isEmpty() || viewportSize.width == 0 || viewportSize.height == 0) { return emptyList() } - val xs = scene.nodes.map { it.coordinates[0] } - val ys = scene.nodes.map { it.coordinates[1] } - val minX = xs.minOrNull() ?: 0.0 - val maxX = xs.maxOrNull() ?: 0.0 - val minY = ys.minOrNull() ?: 0.0 - val maxY = ys.maxOrNull() ?: 0.0 - val xSpan = max(1e-6, maxX - minX) - val ySpan = max(1e-6, maxY - minY) - val safeWidth = viewportSize.width.toFloat() - val safeHeight = viewportSize.height.toFloat() - val marginX = safeWidth * 0.12f - val marginY = safeHeight * 0.14f return scene.nodes.map { node -> - val normalizedX = ((node.coordinates[0] - minX) / xSpan).toFloat() - val normalizedY = ((node.coordinates[1] - minY) / ySpan).toFloat() - val x = marginX + normalizedX * (safeWidth - marginX * 2) - val y = safeHeight - marginY - normalizedY * (safeHeight - marginY * 2) - RenderedNode(node = node, center = Offset(x, y)) + RenderedNode(node = node, center = node.toViewportPosition(viewportSize, projection)) } } @@ -800,7 +832,7 @@ private fun ViewportCameraState.zoomBy( scrollDelta > 0f -> 1f / ZoomStep else -> 1f } - val targetZoom = (zoom * zoomFactor).coerceIn(MinZoom, MaxZoom) + val targetZoom = applyInfiniteZoomFactor(zoom, zoomFactor) if (targetZoom == zoom) { return this } @@ -810,6 +842,19 @@ private fun ViewportCameraState.zoomBy( return copy(zoom = targetZoom, pan = newPan) } +internal fun applyInfiniteZoomFactor(currentZoom: Float, zoomFactor: Float): Float { + if (currentZoom <= 0f || !currentZoom.isFinite() || zoomFactor <= 0f || !zoomFactor.isFinite()) { + return currentZoom + } + val targetZoom = currentZoom * zoomFactor + return when { + targetZoom.isNaN() -> currentZoom + targetZoom == 0f -> Float.MIN_VALUE + targetZoom == Float.POSITIVE_INFINITY -> Float.MAX_VALUE + else -> targetZoom + } +} + private fun Offset.toWorldPosition( viewportSize: IntSize, camera: ViewportCameraState, diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjection.kt new file mode 100644 index 0000000000..88a5a758f4 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjection.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import kotlin.math.max +import kotlin.math.min + +internal data class ViewportProjection( + val worldCenterX: Double, + val worldCenterY: Double, + val pixelsPerUnit: Float, +) { + init { + require(pixelsPerUnit > 0f) { + "Viewport projection requires a positive pixels-per-unit ratio." + } + } +} + +internal fun ViewportScene.createViewportProjection(viewportSize: IntSize): ViewportProjection? { + if (nodes.isEmpty() || viewportSize.width <= 0 || viewportSize.height <= 0) { + return null + } + val xs = nodes.map { it.coordinates[0] } + val ys = nodes.map { it.coordinates[1] } + val minX = xs.minOrNull() ?: return null + val maxX = xs.maxOrNull() ?: return null + val minY = ys.minOrNull() ?: return null + val maxY = ys.maxOrNull() ?: return null + val xSpan = max(MinWorldSpan, maxX - minX) + val ySpan = max(MinWorldSpan, maxY - minY) + val safeWidth = viewportSize.width.toFloat() + val safeHeight = viewportSize.height.toFloat() + val availableWidth = safeWidth * (1f - ViewportHorizontalMargin * 2) + val availableHeight = safeHeight * (1f - ViewportVerticalMargin * 2) + val pixelsPerUnit = min(availableWidth / xSpan.toFloat(), availableHeight / ySpan.toFloat()) + .coerceAtLeast(MinPixelsPerUnit) + return ViewportProjection( + worldCenterX = (minX + maxX) / 2, + worldCenterY = (minY + maxY) / 2, + pixelsPerUnit = pixelsPerUnit, + ) +} + +internal fun ViewportNode.toViewportPosition( + viewportSize: IntSize, + projection: ViewportProjection, +): Offset { + val center = Offset(viewportSize.width / 2f, viewportSize.height / 2f) + return Offset( + x = center.x + ((coordinates[0] - projection.worldCenterX) * projection.pixelsPerUnit).toFloat(), + y = center.y - ((coordinates[1] - projection.worldCenterY) * projection.pixelsPerUnit).toFloat(), + ) +} + +private const val ViewportHorizontalMargin = 0.12f +private const val ViewportVerticalMargin = 0.14f +private const val MinWorldSpan = 1.0 +private const val MinPixelsPerUnit = 1f diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportCameraMathTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportCameraMathTest.kt new file mode 100644 index 0000000000..fecc2ae655 --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportCameraMathTest.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertTrue + +class ViewportCameraMathTest { + @Test + fun `zoom is not clamped while finite`() { + val zoomIn = applyInfiniteZoomFactor(10f, 1.12f) + val zoomOut = applyInfiniteZoomFactor(0.0112f, 1f / 1.12f) + assertTrue(abs(zoomIn - 11.2f) < 1e-4f) + assertTrue(abs(zoomOut - 0.01f) < 1e-4f) + } + + @Test + fun `zoom saturates at float bounds instead of becoming invalid`() { + val maxedZoom = applyInfiniteZoomFactor(Float.MAX_VALUE, 1.12f) + val minedZoom = applyInfiniteZoomFactor(Float.MIN_VALUE, 1f / 1.12f) + assertTrue(maxedZoom.isFinite()) + assertTrue(maxedZoom >= Float.MAX_VALUE / 2) + assertTrue(minedZoom > 0f) + assertTrue(minedZoom <= 1e-30f) + } +} diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt new file mode 100644 index 0000000000..b70dfb0483 --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.ui.unit.IntSize +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ViewportProjectionTest { + @Test + fun `projection is unavailable without nodes or viewport size`() { + assertNull(ViewportScene().createViewportProjection(IntSize(1280, 720))) + assertNull(sampleScene().createViewportProjection(IntSize.Zero)) + } + + @Test + fun `projection stays fixed when nodes move beyond initial bounds`() { + val viewportSize = IntSize(1000, 500) + val projection = assertNotNull(sampleScene().createViewportProjection(viewportSize)) + + val movedNode = ViewportNode(id = 3, coordinates = listOf(20.0, 20.0)) + val movedPosition = movedNode.toViewportPosition(viewportSize, projection) + + assertTrue(movedPosition.x > viewportSize.width) + assertTrue(movedPosition.y < 0f) + } +} + +private fun sampleScene(): ViewportScene = ViewportScene( + nodes = listOf( + ViewportNode(id = 1, coordinates = listOf(0.0, 0.0)), + ViewportNode(id = 2, coordinates = listOf(10.0, 10.0)), + ), +) From e718d8a35796cb7a68ca89a93261e35f72a2a7f9 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Sat, 18 Apr 2026 00:22:24 +0200 Subject: [PATCH 05/22] refactor: minor layout improvements --- .../it/unibo/alchemist/boundary/composeui/UiScreen.kt | 9 +++++++-- .../unibo/alchemist/boundary/composeui/ComposeMonitor.kt | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt index ec36c7fb9c..6e5017b269 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt @@ -96,6 +96,7 @@ private const val SelectedNodeRadius = 18f private const val SelectedNodeInnerRadius = 12f private const val NodeRadius = 7f private const val LinkStrokeWidth = 1.5f +private val StatusPillWidth = 132.dp private const val GridVerticalDivisions = 8 private const val GridHorizontalDivisions = 6 @@ -578,20 +579,24 @@ private fun StatusPill(controls: SimulationControlsState) { else -> AccentCool } Surface( + modifier = Modifier.width(StatusPillWidth), color = color.copy(alpha = 0.14f), shape = RoundedCornerShape(999.dp), elevation = 0.dp, ) { Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.Center, ) { Box( modifier = Modifier .size(10.dp) .background(color = color, shape = CircleShape), ) + Spacer(modifier = Modifier.width(10.dp)) Text( text = controls.statusLabel, style = MaterialTheme.typography.subtitle1, diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt index 6110632126..6efc6cd86a 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt @@ -36,10 +36,11 @@ class ComposeMonitor> : OutputMonitor { override fun stepDone(environment: Environment, reaction: Actionable?, time: Time, step: Long) { currentUiState.update { val viewport = environment.toViewport() + val displayedTime = time.toComposeUiLabel() it.copy( scene = viewport.copy(showLinks = it.scene.showLinks), controls = it.controls.copy( - timeLabel = time.toString(), + timeLabel = displayedTime, step = step, status = environment.simulation.toSimulationStatus(), ) @@ -69,3 +70,7 @@ class ComposeMonitor> : OutputMonitor { private fun alchemistDesktopController(environment: Environment): ComposeUiController = ComposeUiController(currentUiState, DesktopAlchemistUiCallback(environment.simulation, currentUiState)) } + +private fun Time.toComposeUiLabel(): String = toDouble().formatFixed(DISPLAYED_TIME_DECIMALS) + +private const val DISPLAYED_TIME_DECIMALS = 2 From 1c787f3722ec4651c6a5ac6d52c443de903a76cb Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Sat, 18 Apr 2026 09:51:43 +0200 Subject: [PATCH 06/22] refactor: code refactor in UI compose --- .../boundary/composeui/AlchemistUiRoot.kt | 207 ++++ .../boundary/composeui/ControlDock.kt | 123 +++ .../boundary/composeui/InspectorSection.kt | 125 +++ .../boundary/composeui/MetricBlock.kt | 102 ++ .../boundary/composeui/NodeInspector.kt | 140 +++ .../boundary/composeui/ProgressSection.kt | 120 +++ .../composeui/SimulationPrimaryPane.kt | 114 +++ .../boundary/composeui/StatusPill.kt | 114 +++ .../boundary/composeui/SummaryRail.kt | 132 +++ .../alchemist/boundary/composeui/Theme.kt | 112 +++ .../boundary/composeui/TransportButton.kt | 98 ++ .../alchemist/boundary/composeui/UiScreen.kt | 917 ------------------ .../boundary/composeui/ViewportProjection.kt | 12 +- .../boundary/composeui/ViewportRendering.kt | 162 ++++ .../boundary/composeui/ViewportSurface.kt | 303 ++++++ .../composeui/SimulationControlsStateTest.kt | 6 +- .../boundary/composeui/ComposeMonitor.kt | 14 +- .../composeui/DesktopAlchemistUiCallback.kt | 2 +- .../composeui/adapter/AlchemistNodeAdapter.kt | 4 +- 19 files changed, 1870 insertions(+), 937 deletions(-) create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/InspectorSection.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SummaryRail.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt delete mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportRendering.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt new file mode 100644 index 0000000000..b1e5c680c8 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +/** + * Main shared screen for the simulator UI. + */ +@Composable +fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { + val coroutineScope = rememberCoroutineScope() + MaterialTheme( + colors = MaterialTheme.colors.copy( + primary = Accent, + primaryVariant = AccentCool, + secondary = AccentCool, + background = Midnight, + surface = Panel, + onPrimary = Midnight, + onSecondary = Midnight, + onBackground = TextPrimary, + onSurface = TextPrimary, + ), + typography = MaterialTheme.typography.copy( + h4 = MaterialTheme.typography.h4.copy( + fontFamily = FontFamily.Serif, + fontWeight = FontWeight.SemiBold, + ), + h6 = MaterialTheme.typography.h6.copy( + fontFamily = FontFamily.Serif, + fontWeight = FontWeight.SemiBold, + ), + subtitle1 = MaterialTheme.typography.subtitle1.copy( + fontWeight = FontWeight.Medium, + color = TextPrimary, + ), + body2 = MaterialTheme.typography.body2.copy( + color = TextSecondary, + ), + caption = MaterialTheme.typography.caption.copy( + fontFamily = FontFamily.Monospace, + color = TextSecondary, + ), + button = MaterialTheme.typography.button.copy( + fontWeight = FontWeight.SemiBold, + ), + ), + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf(Midnight, DeepSea, Ink), + ), + ), + ) { + val compactLayout = maxWidth < 980.dp + val inspectorVisible = state.inspector != null + val inspectorWidth = 324.dp + val bottomBarHeight = 112.dp + val layoutSpacing = 20.dp + if (compactLayout) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(layoutSpacing), + ) { + SimulationPrimaryPane( + state = state, + callbacks = callbacks, + dockWidthFraction = 1f, + spacing = layoutSpacing, + modifier = Modifier.fillMaxSize(), + ) + if (inspectorVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0x66050A11)) + .clickable(onClick = { coroutineScope.launch { callbacks.onInspectorDismiss() } }), + ) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomBarHeight + 12.dp) + .fillMaxWidth(), + ) { + NodeInspector( + inspector = requireNotNull(state.inspector), + onDismiss = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } else { + Row( + modifier = Modifier + .fillMaxSize() + .padding(layoutSpacing), + horizontalArrangement = Arrangement.spacedBy(layoutSpacing), + ) { + SimulationPrimaryPane( + state = state, + callbacks = callbacks, + dockWidthFraction = 0.84f, + spacing = layoutSpacing, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + AnimatedVisibility( + visible = inspectorVisible, + enter = slideInHorizontally(initialOffsetX = { it / 2 }) + fadeIn(), + exit = slideOutHorizontally(targetOffsetX = { it / 2 }) + fadeOut(), + modifier = Modifier + .fillMaxHeight() + .width(inspectorWidth), + ) { + state.inspector?.let { + NodeInspector( + inspector = it, + onDismiss = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, + modifier = Modifier.fillMaxHeight(), + ) + } + } + } + } + } + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt new file mode 100644 index 0000000000..af682a1483 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +@Composable +internal fun ControlDock( + controls: SimulationControlsState, + callbacks: AlchemistUiCallbacks, + modifier: Modifier = Modifier, +) { + val coroutineScope = rememberCoroutineScope() + Surface( + modifier = modifier, + color = PanelStrong, + shape = RoundedCornerShape(28.dp), + elevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TransportButton(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = { + coroutineScope.launch { callbacks.onPlay() } + }) + TransportButton(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = { + coroutineScope.launch { callbacks.onPause() } + }) + TransportButton(label = "Step", enabled = controls.canStep, accent = Accent, onClick = { + coroutineScope.launch { callbacks.onStep() } + }) + } + StatusPill(controls = controls) + MetricBlock(label = "Time", value = controls.timeLabel) + MetricBlock(label = "Step", value = controls.step.toString()) + ProgressSection( + progress = controls.progress, + modifier = Modifier.weight(1f), + ) + } + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/InspectorSection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/InspectorSection.kt new file mode 100644 index 0000000000..5c7f3b11b2 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/InspectorSection.kt @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +@Composable +internal fun InspectorSection(title: String, description: String, fields: List) { + Surface( + color = Panel.copy(alpha = 0.72f), + contentColor = TextPrimary, + shape = RoundedCornerShape(22.dp), + elevation = 0.dp, + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = title, + style = MaterialTheme.typography.subtitle1, + ) + Text( + text = description, + style = MaterialTheme.typography.body2, + ) + } + fields.forEachIndexed { index, field -> + if (index > 0) { + Divider(color = Outline.copy(alpha = 0.42f)) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = field.label, + style = MaterialTheme.typography.body2, + color = TextSecondary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = field.value, + style = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily.Monospace), + ) + } + } + } + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt new file mode 100644 index 0000000000..610f5a72d6 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +@Composable +internal fun MetricBlock(label: String, value: String) { + Surface( + color = Panel.copy(alpha = 0.78f), + shape = RoundedCornerShape(18.dp), + elevation = 0.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.caption, + color = AccentCool, + ) + Text( + text = value, + style = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily.Monospace), + ) + } + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt new file mode 100644 index 0000000000..8e15590ef9 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +@Composable +internal fun NodeInspector(inspector: NodeInspectorState, onDismiss: () -> Unit, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + color = PanelStrong, + contentColor = TextPrimary, + shape = RoundedCornerShape(28.dp), + elevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(22.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = inspector.title, + style = MaterialTheme.typography.h6, + ) + Text( + text = inspector.subtitle, + style = MaterialTheme.typography.body2, + color = TextSecondary, + ) + } + TransportButton( + label = "Close", + enabled = true, + accent = Outline, + onClick = onDismiss, + ) + } + InspectorSection( + title = "Position", + description = "Coordinates in simulator space.", + fields = inspector.position, + ) + InspectorSection( + title = "Concentrations", + description = "Live contents currently stored in the selected node.", + fields = inspector.concentrations.ifEmpty { + listOf(InfoField("No molecules", "This node exposes no concentrations")) + }, + ) + InspectorSection( + title = "Metadata", + description = "Simulator-provided details exposed by the current adapter.", + fields = inspector.metadata.ifEmpty { + listOf(InfoField("Unavailable", "No extra metadata available")) + }, + ) + } + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt new file mode 100644 index 0000000000..2b01cc4759 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +@Composable +internal fun ProgressSection(progress: SimulationProgress, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Simulation progress", + style = MaterialTheme.typography.subtitle1, + ) + Text( + text = progress.label, + style = MaterialTheme.typography.caption, + color = TextSecondary, + ) + } + if (progress.fraction == null) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + color = Accent, + backgroundColor = Outline.copy(alpha = 0.55f), + ) + } else { + LinearProgressIndicator( + progress = progress.fraction, + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + color = Accent, + backgroundColor = Outline.copy(alpha = 0.55f), + ) + } + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt new file mode 100644 index 0000000000..fc66fd4f6e --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +@Composable +internal fun SimulationPrimaryPane( + state: AlchemistUiState, + callbacks: AlchemistUiCallbacks, + dockWidthFraction: Float, + spacing: androidx.compose.ui.unit.Dp, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(spacing), + ) { + ViewportSurface( + scene = state.scene, + selectedNodeId = state.selectedNodeId, + callbacks = callbacks, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + ControlDock( + controls = state.controls, + modifier = Modifier + .fillMaxWidth(dockWidthFraction) + .wrapContentHeight(), + callbacks = callbacks, + ) + } + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt new file mode 100644 index 0000000000..f27da48471 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +@Composable +internal fun StatusPill(controls: SimulationControlsState) { + val color = + when (controls.status) { + SimulationStatus.RUNNING -> Positive + SimulationStatus.PAUSED -> Accent + SimulationStatus.TERMINATED -> Danger + else -> AccentCool + } + Surface( + modifier = Modifier.width(StatusPillWidth), + color = color.copy(alpha = 0.14f), + shape = RoundedCornerShape(999.dp), + elevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(10.dp) + .background(color = color, shape = CircleShape), + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = controls.statusLabel, + style = MaterialTheme.typography.subtitle1, + ) + } + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SummaryRail.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SummaryRail.kt new file mode 100644 index 0000000000..7bede2bccc --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SummaryRail.kt @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +@Composable +internal fun SummaryRail(summary: List, showLinks: Boolean, onToggleLinks: () -> Unit) { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + summary.forEach { item -> + Surface( + color = PanelStrong.copy(alpha = 0.82f), + shape = RoundedCornerShape(999.dp), + elevation = 0.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = item.label.uppercase(), + style = MaterialTheme.typography.caption, + color = TextSecondary, + ) + Text( + text = item.value, + style = MaterialTheme.typography.subtitle1, + ) + } + } + } + Surface( + modifier = Modifier.clickable(onClick = onToggleLinks), + color = if (showLinks) AccentCool.copy(alpha = 0.2f) else PanelStrong.copy(alpha = 0.82f), + shape = RoundedCornerShape(999.dp), + elevation = 0.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "LINKS", + style = MaterialTheme.typography.caption, + color = if (showLinks) AccentCool else TextSecondary, + ) + Text( + text = if (showLinks) "ON" else "OFF", + style = MaterialTheme.typography.subtitle1, + ) + } + } + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt new file mode 100644 index 0000000000..58c3cd6ac4 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming") + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +internal val Midnight = Color(0xFF07111D) +internal val DeepSea = Color(0xFF10253B) +internal val Ink = Color(0xFF16293C) +internal val Panel = Color(0xF0132237) +internal val PanelStrong = Color(0xF70D1A2A) +internal val Outline = Color(0xFF426988) +internal val Accent = Color(0xFFF0B35A) +internal val AccentCool = Color(0xFF6AC3FF) +internal val Positive = Color(0xFF6ED39C) +internal val TextPrimary = Color(0xFFF4F0E8) +internal val TextSecondary = Color(0xFFDCE7F2) +internal val TextMuted = Color(0xFFC1D0DE) +internal val Danger = Color(0xFFD98B8B) +internal const val ZoomStep = 1.12f +internal const val NodeHitRadius = 22f +internal const val SelectedNodeRadius = 18f +internal const val SelectedNodeInnerRadius = 12f +internal const val NodeRadius = 7f +internal const val LinkStrokeWidth = 1.5f +internal val StatusPillWidth = 132.dp +internal const val GridVerticalDivisions = 8 +internal const val GridHorizontalDivisions = 6 +internal fun lerp(start: Color, end: Color, amount: Float): Color { + val clamped = min(1f, max(0f, amount)) + return Color( + red = start.red + (end.red - start.red) * clamped, + green = start.green + (end.green - start.green) * clamped, + blue = start.blue + (end.blue - start.blue) * clamped, + alpha = start.alpha + (end.alpha - start.alpha) * clamped, + ) +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt new file mode 100644 index 0000000000..8cc31577c3 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +@Composable +internal fun TransportButton(label: String, enabled: Boolean, accent: Color, onClick: () -> Unit) { + val buttonContentColor = if (accent.luminance() > 0.35f) Midnight else TextPrimary + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(18.dp), + elevation = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = accent.copy(alpha = if (enabled) 0.92f else 0.28f), + contentColor = buttonContentColor, + disabledBackgroundColor = Outline.copy(alpha = 0.65f), + disabledContentColor = TextSecondary, + ), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp), + ) { + Text(text = label) + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt deleted file mode 100644 index 6e5017b269..0000000000 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiScreen.kt +++ /dev/null @@ -1,917 +0,0 @@ -/* - * Copyright (C) 2010-2026, Danilo Pianini and contributors - * listed, for each module, in the respective subproject's build.gradle.kts file. - * - * This file is part of Alchemist, and is distributed under the terms of the - * GNU General Public License, with a linking exception, - * as described in the file LICENSE in the Alchemist distribution's top directory. - */ - -package it.unibo.alchemist.boundary.composeui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.launch - -private val Midnight = Color(0xFF07111D) -private val DeepSea = Color(0xFF10253B) -private val Ink = Color(0xFF16293C) -private val Panel = Color(0xF0132237) -private val PanelStrong = Color(0xF70D1A2A) -private val Outline = Color(0xFF426988) -private val Accent = Color(0xFFF0B35A) -private val AccentCool = Color(0xFF6AC3FF) -private val Positive = Color(0xFF6ED39C) -private val TextPrimary = Color(0xFFF4F0E8) -private val TextSecondary = Color(0xFFDCE7F2) -private val TextMuted = Color(0xFFC1D0DE) -private val Danger = Color(0xFFD98B8B) -private const val ZoomStep = 1.12f -private const val NodeHitRadius = 22f -private const val SelectedNodeRadius = 18f -private const val SelectedNodeInnerRadius = 12f -private const val NodeRadius = 7f -private const val LinkStrokeWidth = 1.5f -private val StatusPillWidth = 132.dp -private const val GridVerticalDivisions = 8 -private const val GridHorizontalDivisions = 6 - -/** - * Main shared screen for the simulator UI. - */ -@Composable -fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { - val coroutineScope = rememberCoroutineScope() - MaterialTheme( - colors = MaterialTheme.colors.copy( - primary = Accent, - primaryVariant = AccentCool, - secondary = AccentCool, - background = Midnight, - surface = Panel, - onPrimary = Midnight, - onSecondary = Midnight, - onBackground = TextPrimary, - onSurface = TextPrimary, - ), - typography = MaterialTheme.typography.copy( - h4 = MaterialTheme.typography.h4.copy( - fontFamily = FontFamily.Serif, - fontWeight = FontWeight.SemiBold, - ), - h6 = MaterialTheme.typography.h6.copy( - fontFamily = FontFamily.Serif, - fontWeight = FontWeight.SemiBold, - ), - subtitle1 = MaterialTheme.typography.subtitle1.copy( - fontWeight = FontWeight.Medium, - color = TextPrimary, - ), - body2 = MaterialTheme.typography.body2.copy( - color = TextSecondary, - ), - caption = MaterialTheme.typography.caption.copy( - fontFamily = FontFamily.Monospace, - color = TextSecondary, - ), - button = MaterialTheme.typography.button.copy( - fontWeight = FontWeight.SemiBold, - ), - ), - ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background( - Brush.linearGradient( - colors = listOf(Midnight, DeepSea, Ink), - ), - ), - ) { - val compactLayout = maxWidth < 980.dp - val inspectorVisible = state.inspector != null - val inspectorWidth = 324.dp - val bottomBarHeight = 112.dp - val layoutSpacing = 20.dp - if (compactLayout) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(layoutSpacing), - ) { - SimulationPrimaryPane( - state = state, - callbacks = callbacks, - dockWidthFraction = 1f, - spacing = layoutSpacing, - modifier = Modifier.fillMaxSize(), - ) - if (inspectorVisible) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color(0x66050A11)) - .clickable(onClick = { coroutineScope.launch { callbacks.onInspectorDismiss() } }), - ) - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = bottomBarHeight + 12.dp) - .fillMaxWidth(), - ) { - NodeInspector( - inspector = requireNotNull(state.inspector), - onDismiss = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - } else { - Row( - modifier = Modifier - .fillMaxSize() - .padding(layoutSpacing), - horizontalArrangement = Arrangement.spacedBy(layoutSpacing), - ) { - SimulationPrimaryPane( - state = state, - callbacks = callbacks, - dockWidthFraction = 0.84f, - spacing = layoutSpacing, - modifier = Modifier - .weight(1f) - .fillMaxHeight(), - ) - AnimatedVisibility( - visible = inspectorVisible, - enter = slideInHorizontally(initialOffsetX = { it / 2 }) + fadeIn(), - exit = slideOutHorizontally(targetOffsetX = { it / 2 }) + fadeOut(), - modifier = Modifier - .fillMaxHeight() - .width(inspectorWidth), - ) { - state.inspector?.let { - NodeInspector( - inspector = it, - onDismiss = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, - modifier = Modifier.fillMaxHeight(), - ) - } - } - } - } - } - } -} - -@Composable -private fun SimulationPrimaryPane( - state: AlchemistUiState, - callbacks: AlchemistUiCallbacks, - dockWidthFraction: Float, - spacing: androidx.compose.ui.unit.Dp, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(spacing), - ) { - ViewportSurface( - scene = state.scene, - selectedNodeId = state.selectedNodeId, - callbacks = callbacks, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) - Box( - modifier = Modifier - .fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - ControlDock( - controls = state.controls, - modifier = Modifier - .fillMaxWidth(dockWidthFraction) - .wrapContentHeight(), - callbacks = callbacks, - ) - } - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun ViewportSurface( - scene: ViewportScene, - selectedNodeId: Int?, - callbacks: AlchemistUiCallbacks, - modifier: Modifier = Modifier, -) { - var viewportSize by remember { mutableStateOf(IntSize.Zero) } - var camera by remember { mutableStateOf(ViewportCameraState()) } - var fixedProjection by remember { mutableStateOf(null) } - var middleDragAnchor by remember { mutableStateOf(null) } - val candidateProjection = remember(scene.nodes, viewportSize) { scene.createViewportProjection(viewportSize) } - val projection = fixedProjection ?: candidateProjection - LaunchedEffect(candidateProjection) { - if (fixedProjection == null && candidateProjection != null) { - fixedProjection = candidateProjection - } - } - val baseNodes = remember(scene.nodes, viewportSize, projection) { renderNodes(scene, viewportSize, projection) } - val renderedNodes = remember(baseNodes, viewportSize, camera) { - baseNodes.map { node -> - node.copy(center = node.center.toScreenPosition(viewportSize, camera)) - } - } - val renderedEdges = remember(scene.edges, renderedNodes) { renderEdges(scene.edges, renderedNodes) } - val density = androidx.compose.ui.platform.LocalDensity.current - val tapThresholdPx = with(density) { NodeHitRadius.dp.toPx() } - Surface( - modifier = modifier, - color = Panel, - contentColor = TextPrimary, - shape = RoundedCornerShape(28.dp), - elevation = 0.dp, - ) { - val coroutineScope = rememberCoroutineScope() - Box( - modifier = Modifier - .fillMaxSize() - .border( - width = 1.dp, - color = Outline.copy(alpha = 0.8f), - shape = RoundedCornerShape(28.dp), - ) - .background( - Brush.radialGradient( - colors = listOf(DeepSea.copy(alpha = 0.55f), Midnight), - radius = 1600f, - ), - ), - ) { - Canvas( - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { coordinates -> viewportSize = coordinates.size } - .pointerInput(renderedNodes, selectedNodeId) { - detectTapGestures { tapOffset -> - val hit = renderedNodes - .minByOrNull { node -> node.center.distanceTo(tapOffset) } - ?.takeIf { node -> node.center.distanceTo(tapOffset) <= tapThresholdPx } - if (hit != null) { - coroutineScope.launch { callbacks.onNodeSelected(hit.node.id) } - } else { - coroutineScope.launch { callbacks.onInspectorDismiss() } - } - } - } - .onPointerEvent(PointerEventType.Press) { event -> - val change = event.changes.firstOrNull() ?: return@onPointerEvent - middleDragAnchor = if (event.buttons.isTertiaryPressed) { - change.position - } else { - null - } - } - .onPointerEvent(PointerEventType.Move) { event -> - val change = event.changes.firstOrNull() ?: return@onPointerEvent - if (event.buttons.isTertiaryPressed) { - val previous = middleDragAnchor ?: change.position - val delta = change.position - previous - if (delta != Offset.Zero) { - camera = camera.panBy(delta) - } - middleDragAnchor = change.position - } else { - middleDragAnchor = null - } - } - .onPointerEvent(PointerEventType.Release) { - middleDragAnchor = null - } - .onPointerEvent(PointerEventType.Scroll) { event -> - val pointerChange = event.changes.firstOrNull() ?: return@onPointerEvent - val scroll = pointerChange.scrollDelta - if (scroll != Offset.Zero && viewportSize.width > 0 && viewportSize.height > 0) { - camera = camera.zoomBy( - viewportSize = viewportSize, - pivot = pointerChange.position, - scrollDelta = scroll.y, - ) - } - }, - ) { - drawRect( - brush = Brush.verticalGradient( - colors = listOf(DeepSea.copy(alpha = 0.55f), Midnight), - ), - ) - drawGrid(size) - if (scene.showLinks) { - renderedEdges.forEach { edge -> - drawLine( - color = Outline.copy(alpha = 0.42f), - start = edge.start, - end = edge.end, - strokeWidth = LinkStrokeWidth.dp.toPx(), - cap = StrokeCap.Round, - ) - } - } - renderedNodes.forEach { rendered -> - val isSelected = rendered.node.id == selectedNodeId - val nodeColor = lerp(AccentCool, Accent, rendered.node.accent) - if (isSelected) { - drawCircle( - color = nodeColor.copy(alpha = 0.20f), - radius = SelectedNodeRadius.dp.toPx(), - center = rendered.center, - ) - drawCircle( - color = Accent, - radius = SelectedNodeInnerRadius.dp.toPx(), - center = rendered.center, - style = Stroke(width = 2.dp.toPx()), - ) - } - drawCircle( - brush = Brush.radialGradient( - colors = listOf(nodeColor, nodeColor.copy(alpha = 0.45f)), - center = rendered.center, - radius = SelectedNodeRadius.dp.toPx(), - ), - radius = NodeRadius.dp.toPx(), - center = rendered.center, - ) - } - } - Column( - modifier = Modifier - .align(Alignment.TopStart) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = "Alchemist Simulator", - style = MaterialTheme.typography.h4, - ) - Text( - text = scene.message, - style = MaterialTheme.typography.body2, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - SummaryRail( - summary = scene.summary, - showLinks = scene.showLinks, - onToggleLinks = { coroutineScope.launch { callbacks.onToggleLinks() } }, - ) - } - Surface( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(20.dp), - color = PanelStrong.copy(alpha = 0.88f), - shape = RoundedCornerShape(18.dp), - elevation = 0.dp, - ) { - Text( - text = if (scene.nodes.isEmpty()) { - "No nodes to display" - } else { - "Click to inspect · middle-drag to pan · wheel to zoom" - }, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - style = MaterialTheme.typography.caption, - ) - } - } - } -} - -@Composable -private fun SummaryRail(summary: List, showLinks: Boolean, onToggleLinks: () -> Unit) { - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - summary.forEach { item -> - Surface( - color = PanelStrong.copy(alpha = 0.82f), - shape = RoundedCornerShape(999.dp), - elevation = 0.dp, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = item.label.uppercase(), - style = MaterialTheme.typography.caption, - color = TextSecondary, - ) - Text( - text = item.value, - style = MaterialTheme.typography.subtitle1, - ) - } - } - } - Surface( - modifier = Modifier.clickable(onClick = onToggleLinks), - color = if (showLinks) AccentCool.copy(alpha = 0.2f) else PanelStrong.copy(alpha = 0.82f), - shape = RoundedCornerShape(999.dp), - elevation = 0.dp, - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "LINKS", - style = MaterialTheme.typography.caption, - color = if (showLinks) AccentCool else TextSecondary, - ) - Text( - text = if (showLinks) "ON" else "OFF", - style = MaterialTheme.typography.subtitle1, - ) - } - } - } -} - -@Composable -private fun ControlDock( - controls: SimulationControlsState, - callbacks: AlchemistUiCallbacks, - modifier: Modifier = Modifier, -) { - val coroutineScope = rememberCoroutineScope() - Surface( - modifier = modifier, - color = PanelStrong, - shape = RoundedCornerShape(28.dp), - elevation = 0.dp, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(18.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TransportButton(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = { coroutineScope.launch { callbacks.onPlay() }}) - TransportButton(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = { coroutineScope.launch { callbacks.onPause() }}) - TransportButton(label = "Step", enabled = controls.canStep, accent = Accent, onClick = { coroutineScope.launch { callbacks.onStep() }}) - } - StatusPill(controls = controls) - MetricBlock(label = "Time", value = controls.timeLabel) - MetricBlock(label = "Step", value = controls.step.toString()) - ProgressSection( - progress = controls.progress, - modifier = Modifier.weight(1f), - ) - } - } -} - -@Composable -private fun TransportButton(label: String, enabled: Boolean, accent: Color, onClick: () -> Unit) { - val buttonContentColor = if (accent.luminance() > 0.35f) Midnight else TextPrimary - Button( - onClick = onClick, - enabled = enabled, - shape = RoundedCornerShape(18.dp), - elevation = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = accent.copy(alpha = if (enabled) 0.92f else 0.28f), - contentColor = buttonContentColor, - disabledBackgroundColor = Outline.copy(alpha = 0.65f), - disabledContentColor = TextSecondary, - ), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp), - ) { - Text(text = label) - } -} - -@Composable -private fun StatusPill(controls: SimulationControlsState) { - val color = - when (controls.status) { - SimulationStatus.RUNNING -> Positive - SimulationStatus.PAUSED -> Accent - SimulationStatus.TERMINATED -> Danger - else -> AccentCool - } - Surface( - modifier = Modifier.width(StatusPillWidth), - color = color.copy(alpha = 0.14f), - shape = RoundedCornerShape(999.dp), - elevation = 0.dp, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Box( - modifier = Modifier - .size(10.dp) - .background(color = color, shape = CircleShape), - ) - Spacer(modifier = Modifier.width(10.dp)) - Text( - text = controls.statusLabel, - style = MaterialTheme.typography.subtitle1, - ) - } - } -} - -@Composable -private fun MetricBlock(label: String, value: String) { - Surface( - color = Panel.copy(alpha = 0.78f), - shape = RoundedCornerShape(18.dp), - elevation = 0.dp, - ) { - Column( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = label.uppercase(), - style = MaterialTheme.typography.caption, - color = AccentCool, - ) - Text( - text = value, - style = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily.Monospace), - ) - } - } -} - -@Composable -private fun ProgressSection(progress: SimulationProgress, modifier: Modifier = Modifier) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Simulation progress", - style = MaterialTheme.typography.subtitle1, - ) - Text( - text = progress.label, - style = MaterialTheme.typography.caption, - color = TextSecondary, - ) - } - if (progress.fraction == null) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(8.dp), - color = Accent, - backgroundColor = Outline.copy(alpha = 0.55f), - ) - } else { - LinearProgressIndicator( - progress = progress.fraction, - modifier = Modifier - .fillMaxWidth() - .height(8.dp), - color = Accent, - backgroundColor = Outline.copy(alpha = 0.55f), - ) - } - } -} - -@Composable -private fun NodeInspector(inspector: NodeInspectorState, onDismiss: () -> Unit, modifier: Modifier = Modifier) { - Surface( - modifier = modifier, - color = PanelStrong, - contentColor = TextPrimary, - shape = RoundedCornerShape(28.dp), - elevation = 0.dp, - ) { - Column( - modifier = Modifier - .fillMaxHeight() - .verticalScroll(rememberScrollState()) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = inspector.title, - style = MaterialTheme.typography.h6, - ) - Text( - text = inspector.subtitle, - style = MaterialTheme.typography.body2, - color = TextSecondary, - ) - } - TransportButton( - label = "Close", - enabled = true, - accent = Outline, - onClick = onDismiss, - ) - } - InspectorSection( - title = "Position", - description = "Coordinates in simulator space.", - fields = inspector.position, - ) - InspectorSection( - title = "Concentrations", - description = "Live contents currently stored in the selected node.", - fields = inspector.concentrations.ifEmpty { - listOf(InfoField("No molecules", "This node exposes no concentrations")) - }, - ) - InspectorSection( - title = "Metadata", - description = "Simulator-provided details exposed by the current adapter.", - fields = inspector.metadata.ifEmpty { - listOf(InfoField("Unavailable", "No extra metadata available")) - }, - ) - } - } -} - -@Composable -private fun InspectorSection(title: String, description: String, fields: List) { - Surface( - color = Panel.copy(alpha = 0.72f), - contentColor = TextPrimary, - shape = RoundedCornerShape(22.dp), - elevation = 0.dp, - ) { - Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = title, - style = MaterialTheme.typography.subtitle1, - ) - Text( - text = description, - style = MaterialTheme.typography.body2, - ) - } - fields.forEachIndexed { index, field -> - if (index > 0) { - Divider(color = Outline.copy(alpha = 0.42f)) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = field.label, - style = MaterialTheme.typography.body2, - color = TextSecondary, - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = field.value, - style = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily.Monospace), - ) - } - } - } - } -} - -private fun renderNodes( - scene: ViewportScene, - viewportSize: IntSize, - projection: ViewportProjection?, -): List { - if (projection == null || scene.nodes.isEmpty() || viewportSize.width == 0 || viewportSize.height == 0) { - return emptyList() - } - return scene.nodes.map { node -> - RenderedNode(node = node, center = node.toViewportPosition(viewportSize, projection)) - } -} - -private fun renderEdges(edges: List, renderedNodes: List): List { - val nodesById = renderedNodes.associateBy { it.node.id } - return edges.mapNotNull { edge -> - val from = nodesById[edge.fromNodeId] ?: return@mapNotNull null - val to = nodesById[edge.toNodeId] ?: return@mapNotNull null - RenderedEdge(start = from.center, end = to.center) - } -} - -private data class RenderedNode(val node: ViewportNode, val center: Offset) - -private data class RenderedEdge(val start: Offset, val end: Offset) - -private data class ViewportCameraState( - val pan: Offset = Offset.Zero, - val zoom: Float = 1f, -) - -private fun Offset.distanceTo(other: Offset): Float { - val dx = x - other.x - val dy = y - other.y - return kotlin.math.sqrt(dx * dx + dy * dy) -} - -private fun Offset.toScreenPosition( - viewportSize: IntSize, - camera: ViewportCameraState, -): Offset { - val center = viewportSize.center - return center + ((this - center) * camera.zoom) + camera.pan -} - -private fun ViewportCameraState.panBy(delta: Offset): ViewportCameraState = copy(pan = pan + delta) - -private fun ViewportCameraState.zoomBy( - viewportSize: IntSize, - pivot: Offset, - scrollDelta: Float, -): ViewportCameraState { - val zoomFactor = when { - scrollDelta < 0f -> ZoomStep - scrollDelta > 0f -> 1f / ZoomStep - else -> 1f - } - val targetZoom = applyInfiniteZoomFactor(zoom, zoomFactor) - if (targetZoom == zoom) { - return this - } - val center = viewportSize.center - val worldPoint = pivot.toWorldPosition(viewportSize, this) - val newPan = pivot - center - ((worldPoint - center) * targetZoom) - return copy(zoom = targetZoom, pan = newPan) -} - -internal fun applyInfiniteZoomFactor(currentZoom: Float, zoomFactor: Float): Float { - if (currentZoom <= 0f || !currentZoom.isFinite() || zoomFactor <= 0f || !zoomFactor.isFinite()) { - return currentZoom - } - val targetZoom = currentZoom * zoomFactor - return when { - targetZoom.isNaN() -> currentZoom - targetZoom == 0f -> Float.MIN_VALUE - targetZoom == Float.POSITIVE_INFINITY -> Float.MAX_VALUE - else -> targetZoom - } -} - -private fun Offset.toWorldPosition( - viewportSize: IntSize, - camera: ViewportCameraState, -): Offset { - val center = viewportSize.center - return center + ((this - center - camera.pan) / camera.zoom) -} - -private val IntSize.center: Offset - get() = Offset(width / 2f, height / 2f) - -private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawGrid(canvasSize: Size) { - val stepX = canvasSize.width / GridVerticalDivisions.toFloat() - val stepY = canvasSize.height / GridHorizontalDivisions.toFloat() - for (column in 1 until GridVerticalDivisions) { - drawLine( - color = Outline.copy(alpha = 0.32f), - start = Offset(stepX * column, 0f), - end = Offset(stepX * column, canvasSize.height), - strokeWidth = 1f, - ) - } - for (row in 1 until GridHorizontalDivisions) { - drawLine( - color = Outline.copy(alpha = 0.28f), - start = Offset(0f, stepY * row), - end = Offset(canvasSize.width, stepY * row), - strokeWidth = 1f, - ) - } - drawLine( - color = Outline.copy(alpha = 0.7f), - start = Offset(canvasSize.width / 2f, 0f), - end = Offset(canvasSize.width / 2f, canvasSize.height), - strokeWidth = 1.6f, - cap = StrokeCap.Round, - ) - drawLine( - color = Outline.copy(alpha = 0.7f), - start = Offset(0f, canvasSize.height / 2f), - end = Offset(canvasSize.width, canvasSize.height / 2f), - strokeWidth = 1.6f, - cap = StrokeCap.Round, - ) -} - -private fun lerp(start: Color, end: Color, amount: Float): Color { - val clamped = min(1f, max(0f, amount)) - return Color( - red = start.red + (end.red - start.red) * clamped, - green = start.green + (end.green - start.green) * clamped, - blue = start.blue + (end.blue - start.blue) * clamped, - alpha = start.alpha + (end.alpha - start.alpha) * clamped, - ) -} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjection.kt index 88a5a758f4..728f3e0e6c 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjection.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjection.kt @@ -1,3 +1,4 @@ +@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming") /* * Copyright (C) 2010-2026, Danilo Pianini and contributors * listed, for each module, in the respective subproject's build.gradle.kts file. @@ -14,11 +15,7 @@ import androidx.compose.ui.unit.IntSize import kotlin.math.max import kotlin.math.min -internal data class ViewportProjection( - val worldCenterX: Double, - val worldCenterY: Double, - val pixelsPerUnit: Float, -) { +internal data class ViewportProjection(val worldCenterX: Double, val worldCenterY: Double, val pixelsPerUnit: Float) { init { require(pixelsPerUnit > 0f) { "Viewport projection requires a positive pixels-per-unit ratio." @@ -51,10 +48,7 @@ internal fun ViewportScene.createViewportProjection(viewportSize: IntSize): View ) } -internal fun ViewportNode.toViewportPosition( - viewportSize: IntSize, - projection: ViewportProjection, -): Offset { +internal fun ViewportNode.toViewportPosition(viewportSize: IntSize, projection: ViewportProjection): Offset { val center = Offset(viewportSize.width / 2f, viewportSize.height / 2f) return Offset( x = center.x + ((coordinates[0] - projection.worldCenterX) * projection.pixelsPerUnit).toFloat(), diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportRendering.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportRendering.kt new file mode 100644 index 0000000000..5f1927610c --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportRendering.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming") + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +internal fun renderNodes( + scene: ViewportScene, + viewportSize: IntSize, + projection: ViewportProjection?, +): List { + if (projection == null || scene.nodes.isEmpty() || viewportSize.width == 0 || viewportSize.height == 0) { + return emptyList() + } + return scene.nodes.map { node -> + RenderedNode(node = node, center = node.toViewportPosition(viewportSize, projection)) + } +} + +internal fun renderEdges(edges: List, renderedNodes: List): List { + val nodesById = renderedNodes.associateBy { it.node.id } + return edges.mapNotNull { edge -> + val from = nodesById[edge.fromNodeId] ?: return@mapNotNull null + val to = nodesById[edge.toNodeId] ?: return@mapNotNull null + RenderedEdge(start = from.center, end = to.center) + } +} + +internal data class RenderedNode(val node: ViewportNode, val center: Offset) + +internal data class RenderedEdge(val start: Offset, val end: Offset) + +internal data class ViewportCameraState(val pan: Offset = Offset.Zero, val zoom: Float = 1f) + +internal fun Offset.distanceTo(other: Offset): Float { + val dx = x - other.x + val dy = y - other.y + return kotlin.math.sqrt(dx * dx + dy * dy) +} + +internal fun Offset.toScreenPosition(viewportSize: IntSize, camera: ViewportCameraState): Offset { + val center = viewportSize.center + return center + ((this - center) * camera.zoom) + camera.pan +} + +internal fun ViewportCameraState.panBy(delta: Offset): ViewportCameraState = copy(pan = pan + delta) + +internal fun ViewportCameraState.zoomBy( + viewportSize: IntSize, + pivot: Offset, + scrollDelta: Float, +): ViewportCameraState { + val zoomFactor = when { + scrollDelta < 0f -> ZoomStep + scrollDelta > 0f -> 1f / ZoomStep + else -> 1f + } + val targetZoom = applyInfiniteZoomFactor(zoom, zoomFactor) + if (targetZoom == zoom) { + return this + } + val center = viewportSize.center + val worldPoint = pivot.toWorldPosition(viewportSize, this) + val newPan = pivot - center - ((worldPoint - center) * targetZoom) + return copy(zoom = targetZoom, pan = newPan) +} + +internal fun applyInfiniteZoomFactor(currentZoom: Float, zoomFactor: Float): Float { + if (currentZoom <= 0f || !currentZoom.isFinite() || zoomFactor <= 0f || !zoomFactor.isFinite()) { + return currentZoom + } + val targetZoom = currentZoom * zoomFactor + return when { + targetZoom.isNaN() -> currentZoom + targetZoom == 0f -> Float.MIN_VALUE + targetZoom == Float.POSITIVE_INFINITY -> Float.MAX_VALUE + else -> targetZoom + } +} + +internal fun Offset.toWorldPosition(viewportSize: IntSize, camera: ViewportCameraState): Offset { + val center = viewportSize.center + return center + ((this - center - camera.pan) / camera.zoom) +} + +internal val IntSize.center: Offset + get() = Offset(width / 2f, height / 2f) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt new file mode 100644 index 0000000000..cac82080ec --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt @@ -0,0 +1,303 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.launch + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun ViewportSurface( + scene: ViewportScene, + selectedNodeId: Int?, + callbacks: AlchemistUiCallbacks, + modifier: Modifier = Modifier, +) { + var viewportSize by remember { mutableStateOf(IntSize.Zero) } + var camera by remember { mutableStateOf(ViewportCameraState()) } + var fixedProjection by remember { mutableStateOf(null) } + var middleDragAnchor by remember { mutableStateOf(null) } + val candidateProjection = remember(scene.nodes, viewportSize) { scene.createViewportProjection(viewportSize) } + val projection = fixedProjection ?: candidateProjection + LaunchedEffect(candidateProjection) { + if (fixedProjection == null && candidateProjection != null) { + fixedProjection = candidateProjection + } + } + val baseNodes = remember(scene.nodes, viewportSize, projection) { renderNodes(scene, viewportSize, projection) } + val renderedNodes = remember(baseNodes, viewportSize, camera) { + baseNodes.map { node -> + node.copy(center = node.center.toScreenPosition(viewportSize, camera)) + } + } + val renderedEdges = remember(scene.edges, renderedNodes) { renderEdges(scene.edges, renderedNodes) } + val density = androidx.compose.ui.platform.LocalDensity.current + val tapThresholdPx = with(density) { NodeHitRadius.dp.toPx() } + Surface( + modifier = modifier, + color = Panel, + contentColor = TextPrimary, + shape = RoundedCornerShape(28.dp), + elevation = 0.dp, + ) { + val coroutineScope = rememberCoroutineScope() + Box( + modifier = Modifier + .fillMaxSize() + .border( + width = 1.dp, + color = Outline.copy(alpha = 0.8f), + shape = RoundedCornerShape(28.dp), + ) + .background( + Brush.radialGradient( + colors = listOf(DeepSea.copy(alpha = 0.55f), Midnight), + radius = 1600f, + ), + ), + ) { + Canvas( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> viewportSize = coordinates.size } + .pointerInput(renderedNodes, selectedNodeId) { + detectTapGestures { tapOffset -> + val hit = renderedNodes + .minByOrNull { node -> node.center.distanceTo(tapOffset) } + ?.takeIf { node -> node.center.distanceTo(tapOffset) <= tapThresholdPx } + if (hit != null) { + coroutineScope.launch { callbacks.onNodeSelected(hit.node.id) } + } else { + coroutineScope.launch { callbacks.onInspectorDismiss() } + } + } + } + .onPointerEvent(PointerEventType.Press) { event -> + val change = event.changes.firstOrNull() ?: return@onPointerEvent + middleDragAnchor = if (event.buttons.isTertiaryPressed) { + change.position + } else { + null + } + } + .onPointerEvent(PointerEventType.Move) { event -> + val change = event.changes.firstOrNull() ?: return@onPointerEvent + if (event.buttons.isTertiaryPressed) { + val previous = middleDragAnchor ?: change.position + val delta = change.position - previous + if (delta != Offset.Zero) { + camera = camera.panBy(delta) + } + middleDragAnchor = change.position + } else { + middleDragAnchor = null + } + } + .onPointerEvent(PointerEventType.Release) { + middleDragAnchor = null + } + .onPointerEvent(PointerEventType.Scroll) { event -> + val pointerChange = event.changes.firstOrNull() ?: return@onPointerEvent + val scroll = pointerChange.scrollDelta + if (scroll != Offset.Zero && viewportSize.width > 0 && viewportSize.height > 0) { + camera = camera.zoomBy( + viewportSize = viewportSize, + pivot = pointerChange.position, + scrollDelta = scroll.y, + ) + } + }, + ) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf(DeepSea.copy(alpha = 0.55f), Midnight), + ), + ) + drawGrid(size) + if (scene.showLinks) { + renderedEdges.forEach { edge -> + drawLine( + color = Outline.copy(alpha = 0.42f), + start = edge.start, + end = edge.end, + strokeWidth = LinkStrokeWidth.dp.toPx(), + cap = StrokeCap.Round, + ) + } + } + renderedNodes.forEach { rendered -> + val isSelected = rendered.node.id == selectedNodeId + val nodeColor = lerp(AccentCool, Accent, rendered.node.accent) + if (isSelected) { + drawCircle( + color = nodeColor.copy(alpha = 0.20f), + radius = SelectedNodeRadius.dp.toPx(), + center = rendered.center, + ) + drawCircle( + color = Accent, + radius = SelectedNodeInnerRadius.dp.toPx(), + center = rendered.center, + style = Stroke(width = 2.dp.toPx()), + ) + } + drawCircle( + brush = Brush.radialGradient( + colors = listOf(nodeColor, nodeColor.copy(alpha = 0.45f)), + center = rendered.center, + radius = SelectedNodeRadius.dp.toPx(), + ), + radius = NodeRadius.dp.toPx(), + center = rendered.center, + ) + } + } + Column( + modifier = Modifier + .align(Alignment.TopStart) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Alchemist Simulator", + style = MaterialTheme.typography.h4, + ) + Text( + text = scene.message, + style = MaterialTheme.typography.body2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + SummaryRail( + summary = scene.summary, + showLinks = scene.showLinks, + onToggleLinks = { coroutineScope.launch { callbacks.onToggleLinks() } }, + ) + } + Surface( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(20.dp), + color = PanelStrong.copy(alpha = 0.88f), + shape = RoundedCornerShape(18.dp), + elevation = 0.dp, + ) { + Text( + text = if (scene.nodes.isEmpty()) { + "No nodes to display" + } else { + "Click to inspect · middle-drag to pan · wheel to zoom" + }, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + style = MaterialTheme.typography.caption, + ) + } + } + } +} +internal fun androidx.compose.ui.graphics.drawscope.DrawScope.drawGrid(canvasSize: Size) { + val stepX = canvasSize.width / GridVerticalDivisions.toFloat() + val stepY = canvasSize.height / GridHorizontalDivisions.toFloat() + for (column in 1 until GridVerticalDivisions) { + drawLine( + color = Outline.copy(alpha = 0.32f), + start = Offset(stepX * column, 0f), + end = Offset(stepX * column, canvasSize.height), + strokeWidth = 1f, + ) + } + for (row in 1 until GridHorizontalDivisions) { + drawLine( + color = Outline.copy(alpha = 0.28f), + start = Offset(0f, stepY * row), + end = Offset(canvasSize.width, stepY * row), + strokeWidth = 1f, + ) + } + drawLine( + color = Outline.copy(alpha = 0.7f), + start = Offset(canvasSize.width / 2f, 0f), + end = Offset(canvasSize.width / 2f, canvasSize.height), + strokeWidth = 1.6f, + cap = StrokeCap.Round, + ) + drawLine( + color = Outline.copy(alpha = 0.7f), + start = Offset(0f, canvasSize.height / 2f), + end = Offset(canvasSize.width, canvasSize.height / 2f), + strokeWidth = 1.6f, + cap = StrokeCap.Round, + ) +} diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt index 64d8cecb5d..18e1be707e 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt @@ -9,13 +9,13 @@ package it.unibo.alchemist.boundary.composeui +import kotlin.coroutines.Continuation +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -import kotlin.coroutines.Continuation -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.coroutines.startCoroutine class SimulationControlsStateTest { @Test diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt index 6efc6cd86a..2ca9aff258 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt @@ -30,7 +30,9 @@ class ComposeMonitor> : OutputMonitor { override fun initialized(environment: Environment) { ensureWindow(environment) - currentUiState.update { it.copy(controls = it.controls.copy(status = environment.simulation.toSimulationStatus() )) } + currentUiState.update { + it.copy(controls = it.controls.copy(status = environment.simulation.toSimulationStatus())) + } } override fun stepDone(environment: Environment, reaction: Actionable?, time: Time, step: Long) { @@ -43,7 +45,7 @@ class ComposeMonitor> : OutputMonitor { timeLabel = displayedTime, step = step, status = environment.simulation.toSimulationStatus(), - ) + ), ) } } @@ -58,9 +60,11 @@ class ComposeMonitor> : OutputMonitor { onCloseRequest = { exitApplication() }, title = "Alchemist", ) { - app(remember { - alchemistDesktopController(environment) - }) + app( + remember { + alchemistDesktopController(environment) + }, + ) } } }.start() diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt index 1e7896dfe2..62e60c392a 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.withContext class DesktopAlchemistUiCallback>( private val simulation: Simulation, - private val store: ComposeUiStateStore + private val store: ComposeUiStateStore, ) : AlchemistUiCallbacks { override suspend fun onPlay() { simulation.play().await() diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt index edc01fe965..d76dcdcd96 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt @@ -24,7 +24,7 @@ import it.unibo.alchemist.model.environments.Continuous2DEnvironment fun > Node.toViewport(environment: Environment): ViewportNode = ViewportNode( id = id, coordinates = environment.getPosition(this).coordinates.toList(), - concentrations = this.contents.map { InfoField(it.key.toString(), it.value.toString()) } + concentrations = this.contents.map { InfoField(it.key.toString(), it.value.toString()) }, ) fun > Environment.toViewport(): ViewportScene = ViewportScene( @@ -34,7 +34,7 @@ fun > Environment.toViewport(): ViewportScene = Viewpor // TODO: add the other environments is Continuous2DEnvironment -> 2 else -> 2 - } + }, ) fun > Simulation.toSimulationStatus(): SimulationStatus = when (this.status) { From 656347bd5d032f647beee696379992d59ab7988c Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Sat, 18 Apr 2026 10:16:53 +0200 Subject: [PATCH 07/22] perf: optimize rendering and simulation bindings --- .../boundary/composeui/AlchemistUiRoot.kt | 8 +- .../boundary/composeui/ControlDock.kt | 17 ++-- .../composeui/SimulationPrimaryPane.kt | 15 ++-- .../alchemist/boundary/composeui/UiModel.kt | 10 +++ .../boundary/composeui/ViewportRendering.kt | 69 +-------------- .../boundary/composeui/ViewportSurface.kt | 84 ++++++++----------- .../boundary/composeui/ComposeMonitor.kt | 23 ++++- .../composeui/adapter/AlchemistNodeAdapter.kt | 19 ++--- 8 files changed, 98 insertions(+), 147 deletions(-) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt index b1e5c680c8..a2875ee35f 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt @@ -141,7 +141,9 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { .padding(layoutSpacing), ) { SimulationPrimaryPane( - state = state, + scene = state.scene, + controls = state.controls, + selectedNodeId = state.selectedNodeId, callbacks = callbacks, dockWidthFraction = 1f, spacing = layoutSpacing, @@ -176,7 +178,9 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { horizontalArrangement = Arrangement.spacedBy(layoutSpacing), ) { SimulationPrimaryPane( - state = state, + scene = state.scene, + controls = state.controls, + selectedNodeId = state.selectedNodeId, callbacks = callbacks, dockWidthFraction = 0.84f, spacing = layoutSpacing, diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt index af682a1483..f3890127ce 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt @@ -80,10 +80,11 @@ import kotlinx.coroutines.launch @Composable internal fun ControlDock( controls: SimulationControlsState, - callbacks: AlchemistUiCallbacks, + onPlay: () -> Unit, + onPause: () -> Unit, + onStep: () -> Unit, modifier: Modifier = Modifier, ) { - val coroutineScope = rememberCoroutineScope() Surface( modifier = modifier, color = PanelStrong, @@ -101,15 +102,9 @@ internal fun ControlDock( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - TransportButton(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = { - coroutineScope.launch { callbacks.onPlay() } - }) - TransportButton(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = { - coroutineScope.launch { callbacks.onPause() } - }) - TransportButton(label = "Step", enabled = controls.canStep, accent = Accent, onClick = { - coroutineScope.launch { callbacks.onStep() } - }) + TransportButton(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = onPlay) + TransportButton(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = onPause) + TransportButton(label = "Step", enabled = controls.canStep, accent = Accent, onClick = onStep) } StatusPill(controls = controls) MetricBlock(label = "Time", value = controls.timeLabel) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt index fc66fd4f6e..0194c3b7aa 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt @@ -79,7 +79,9 @@ import kotlinx.coroutines.launch @Composable internal fun SimulationPrimaryPane( - state: AlchemistUiState, + scene: ViewportScene, + controls: SimulationControlsState, + selectedNodeId: Int?, callbacks: AlchemistUiCallbacks, dockWidthFraction: Float, spacing: androidx.compose.ui.unit.Dp, @@ -90,8 +92,8 @@ internal fun SimulationPrimaryPane( verticalArrangement = Arrangement.spacedBy(spacing), ) { ViewportSurface( - scene = state.scene, - selectedNodeId = state.selectedNodeId, + scene = scene, + selectedNodeId = selectedNodeId, callbacks = callbacks, modifier = Modifier .fillMaxWidth() @@ -102,12 +104,15 @@ internal fun SimulationPrimaryPane( .fillMaxWidth(), contentAlignment = Alignment.Center, ) { + val coroutineScope = rememberCoroutineScope() ControlDock( - controls = state.controls, + controls = controls, + onPlay = { coroutineScope.launch { callbacks.onPlay() } }, + onPause = { coroutineScope.launch { callbacks.onPause() } }, + onStep = { coroutineScope.launch { callbacks.onStep() } }, modifier = Modifier .fillMaxWidth(dockWidthFraction) .wrapContentHeight(), - callbacks = callbacks, ) } } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt index 99ad563f8e..3d32b7c2c0 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt @@ -11,6 +11,8 @@ package it.unibo.alchemist.boundary.composeui +import androidx.compose.runtime.Immutable + /** * High-level simulation status mirrored in the Compose UI. */ @@ -33,11 +35,13 @@ enum class ViewportBackdrop { /** * A simple label/value pair for inspector and summary sections. */ +@Immutable data class InfoField(val label: String, val value: String) /** * A node projected in the central viewport. */ +@Immutable data class ViewportNode( val id: Int, val coordinates: List, @@ -55,6 +59,7 @@ data class ViewportNode( /** * An undirected edge projected in the central viewport. */ +@Immutable data class ViewportEdge(val fromNodeId: Int, val toNodeId: Int) { init { require(fromNodeId != toNodeId) { @@ -66,6 +71,7 @@ data class ViewportEdge(val fromNodeId: Int, val toNodeId: Int) { /** * State of the central scene area. */ +@Immutable data class ViewportScene( val nodes: List = emptyList(), val edges: List = emptyList(), @@ -79,6 +85,7 @@ data class ViewportScene( /** * State for the bottom control dock progress section. */ +@Immutable data class SimulationProgress(val fraction: Float? = null, val label: String = "Progress unavailable") { init { require(fraction == null || fraction in 0f..1f) { @@ -90,6 +97,7 @@ data class SimulationProgress(val fraction: Float? = null, val label: String = " /** * State for transport controls and simulator metrics. */ +@Immutable data class SimulationControlsState( val status: SimulationStatus = SimulationStatus.INIT, val timeLabel: String = "0", @@ -105,6 +113,7 @@ data class SimulationControlsState( /** * State for the node inspector panel. */ +@Immutable data class NodeInspectorState( val nodeId: Int, val title: String = "Node $nodeId", @@ -117,6 +126,7 @@ data class NodeInspectorState( /** * Top-level state consumed by the Compose UI shell. */ +@Immutable data class AlchemistUiState( val scene: ViewportScene = ViewportScene(), val controls: SimulationControlsState = SimulationControlsState(), diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportRendering.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportRendering.kt index 5f1927610c..c86c218394 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportRendering.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportRendering.kt @@ -11,73 +11,9 @@ package it.unibo.alchemist.boundary.composeui -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier +import androidx.compose.runtime.Immutable import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.launch internal fun renderNodes( scene: ViewportScene, @@ -101,10 +37,13 @@ internal fun renderEdges(edges: List, renderedNodes: List - node.copy(center = node.center.toScreenPosition(viewportSize, camera)) - } - } - val renderedEdges = remember(scene.edges, renderedNodes) { renderEdges(scene.edges, renderedNodes) } val density = androidx.compose.ui.platform.LocalDensity.current val tapThresholdPx = with(density) { NodeHitRadius.dp.toPx() } Surface( @@ -132,11 +98,23 @@ internal fun ViewportSurface( modifier = Modifier .fillMaxSize() .onGloballyPositioned { coordinates -> viewportSize = coordinates.size } - .pointerInput(renderedNodes, selectedNodeId) { + .pointerInput(baseNodes, selectedNodeId) { detectTapGestures { tapOffset -> - val hit = renderedNodes - .minByOrNull { node -> node.center.distanceTo(tapOffset) } - ?.takeIf { node -> node.center.distanceTo(tapOffset) <= tapThresholdPx } + val worldTap = tapOffset.toWorldPosition(viewportSize, camera) + // We need to map base nodes to screen space to check hits accurately against radius + // Alternatively we can map the tap back to base node space (which is screen space with camera pan=0, zoom=1) + val tapInBaseSpace = tapOffset.toWorldPosition( + viewportSize, + camera, + ).toScreenPosition(viewportSize, ViewportCameraState()) + // Wait, baseNodes are already in the "camera at 0, zoom 1" screen space. + // So if we take the tapOffset, we just need to convert it to that same space to measure distance! + val hit = baseNodes + .minByOrNull { node -> node.center.distanceTo(tapInBaseSpace) } + ?.takeIf { node -> + node.center.distanceTo(tapInBaseSpace) <= + tapThresholdPx / camera.zoom + } if (hit != null) { coroutineScope.launch { callbacks.onNodeSelected(hit.node.id) } } else { @@ -186,8 +164,16 @@ internal fun ViewportSurface( ), ) drawGrid(size) + + // Map the base nodes and edges down here in the Draw phase + val currentCamera = camera + val mappedNodes = baseNodes.map { node -> + node.copy(center = node.center.toScreenPosition(viewportSize, currentCamera)) + } + val mappedEdges = renderEdges(scene.edges, mappedNodes) + if (scene.showLinks) { - renderedEdges.forEach { edge -> + mappedEdges.forEach { edge -> drawLine( color = Outline.copy(alpha = 0.42f), start = edge.start, @@ -197,29 +183,33 @@ internal fun ViewportSurface( ) } } - renderedNodes.forEach { rendered -> + mappedNodes.forEach { rendered -> val isSelected = rendered.node.id == selectedNodeId val nodeColor = lerp(AccentCool, Accent, rendered.node.accent) + val screenRadius = NodeRadius.dp.toPx() * currentCamera.zoom + val screenSelectedRadius = SelectedNodeRadius.dp.toPx() * currentCamera.zoom + val screenSelectedInnerRadius = SelectedNodeInnerRadius.dp.toPx() * currentCamera.zoom + if (isSelected) { drawCircle( color = nodeColor.copy(alpha = 0.20f), - radius = SelectedNodeRadius.dp.toPx(), + radius = screenSelectedRadius, center = rendered.center, ) drawCircle( color = Accent, - radius = SelectedNodeInnerRadius.dp.toPx(), + radius = screenSelectedInnerRadius, center = rendered.center, - style = Stroke(width = 2.dp.toPx()), + style = Stroke(width = 2.dp.toPx() * currentCamera.zoom), ) } drawCircle( brush = Brush.radialGradient( colors = listOf(nodeColor, nodeColor.copy(alpha = 0.45f)), center = rendered.center, - radius = SelectedNodeRadius.dp.toPx(), + radius = max(1f, screenSelectedRadius), ), - radius = NodeRadius.dp.toPx(), + radius = screenRadius, center = rendered.center, ) } @@ -267,7 +257,7 @@ internal fun ViewportSurface( } } } -internal fun androidx.compose.ui.graphics.drawscope.DrawScope.drawGrid(canvasSize: Size) { +internal fun DrawScope.drawGrid(canvasSize: Size) { val stepX = canvasSize.width / GridVerticalDivisions.toFloat() val stepY = canvasSize.height / GridHorizontalDivisions.toFloat() for (column in 1 until GridVerticalDivisions) { diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt index 2ca9aff258..4e934c7edb 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt @@ -23,8 +23,10 @@ import java.util.concurrent.atomic.AtomicBoolean /** * Monitor extension that uses JVM Compose UI to display the simulation. + * @param targetFps The target frames per second for the UI updates. Defaults to 30. */ -class ComposeMonitor> : OutputMonitor { +class ComposeMonitor> @JvmOverloads constructor(targetFps: Int = 30) : OutputMonitor { + private val throttleMs = 1000L / targetFps.coerceAtLeast(1) private val windowStarted = AtomicBoolean(false) private val currentUiState by lazy { ComposeUiStateStore(AlchemistUiState()) } @@ -35,7 +37,21 @@ class ComposeMonitor> : OutputMonitor { } } + private var lastUpdate: Long = 0L + override fun stepDone(environment: Environment, reaction: Actionable?, time: Time, step: Long) { + val now = System.currentTimeMillis() + if (now - lastUpdate >= throttleMs) { + lastUpdate = now + updateUiState(environment, time, step) + } + } + + override fun finished(environment: Environment, time: Time, step: Long) { + updateUiState(environment, time, step) + } + + private fun updateUiState(environment: Environment, time: Time, step: Long) { currentUiState.update { val viewport = environment.toViewport() val displayedTime = time.toComposeUiLabel() @@ -50,8 +66,6 @@ class ComposeMonitor> : OutputMonitor { } } - override fun finished(environment: Environment, time: Time, step: Long) = Unit - private fun ensureWindow(environment: Environment) { if (windowStarted.compareAndSet(false, true)) { Thread { @@ -67,6 +81,9 @@ class ComposeMonitor> : OutputMonitor { ) } } + }.apply { + isDaemon = true + name = "Alchemist Compose UI" }.start() } } diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt index d76dcdcd96..c1a7e1cc29 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt @@ -19,7 +19,6 @@ import it.unibo.alchemist.core.Status import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Node import it.unibo.alchemist.model.Position -import it.unibo.alchemist.model.environments.Continuous2DEnvironment fun > Node.toViewport(environment: Environment): ViewportNode = ViewportNode( id = id, @@ -30,11 +29,7 @@ fun > Node.toViewport(environment: Environment): Vie fun > Environment.toViewport(): ViewportScene = ViewportScene( nodes = nodes.map { it.toViewport(this) }, edges = extractEdges(), - dimensions = when (this) { - // TODO: add the other environments - is Continuous2DEnvironment -> 2 - else -> 2 - }, + dimensions = dimensions, ) fun > Simulation.toSimulationStatus(): SimulationStatus = when (this.status) { @@ -45,16 +40,12 @@ fun > Simulation.toSimulationStatus(): SimulationStatus Status.TERMINATED -> SimulationStatus.TERMINATED } -private fun > Environment.extractEdges(): List = buildSet { +private fun > Environment.extractEdges(): List = buildList { nodes.forEach { node -> getNeighborhood(node).forEach { neighbor -> - canonicalEdge(node.id, neighbor.id)?.let(::add) + if (node.id < neighbor.id) { + add(ViewportEdge(node.id, neighbor.id)) + } } } -}.toList() - -internal fun canonicalEdge(firstNodeId: Int, secondNodeId: Int): ViewportEdge? = when { - firstNodeId == secondNodeId -> null - firstNodeId < secondNodeId -> ViewportEdge(firstNodeId, secondNodeId) - else -> ViewportEdge(secondNodeId, firstNodeId) } From 65e53ee62018a46d92338cff5491ceb182621934 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Sat, 18 Apr 2026 10:38:30 +0200 Subject: [PATCH 08/22] feat: use light theme --- .../boundary/composeui/AlchemistUiRoot.kt | 18 ++++++------- .../boundary/composeui/ControlDock.kt | 6 ++--- .../boundary/composeui/InspectorSection.kt | 4 +-- .../boundary/composeui/MetricBlock.kt | 6 ++--- .../boundary/composeui/NodeInspector.kt | 4 +-- .../boundary/composeui/ProgressSection.kt | 4 +-- .../boundary/composeui/StatusPill.kt | 4 +-- .../boundary/composeui/SummaryRail.kt | 6 ++--- .../alchemist/boundary/composeui/Theme.kt | 26 +++++++++---------- .../boundary/composeui/TransportButton.kt | 4 +-- .../boundary/composeui/ViewportSurface.kt | 18 ++++++------- 11 files changed, 49 insertions(+), 51 deletions(-) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt index a2875ee35f..a8fd9e5827 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt @@ -85,23 +85,21 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { val coroutineScope = rememberCoroutineScope() MaterialTheme( colors = MaterialTheme.colors.copy( - primary = Accent, - primaryVariant = AccentCool, - secondary = AccentCool, - background = Midnight, - surface = Panel, - onPrimary = Midnight, - onSecondary = Midnight, + primary = PrimaryAccent, + primaryVariant = SecondaryAccent, + secondary = SecondaryAccent, + background = Background, + surface = Surface, + onPrimary = Background, + onSecondary = Background, onBackground = TextPrimary, onSurface = TextPrimary, ), typography = MaterialTheme.typography.copy( h4 = MaterialTheme.typography.h4.copy( - fontFamily = FontFamily.Serif, fontWeight = FontWeight.SemiBold, ), h6 = MaterialTheme.typography.h6.copy( - fontFamily = FontFamily.Serif, fontWeight = FontWeight.SemiBold, ), subtitle1 = MaterialTheme.typography.subtitle1.copy( @@ -125,7 +123,7 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { .fillMaxSize() .background( Brush.linearGradient( - colors = listOf(Midnight, DeepSea, Ink), + colors = listOf(Background, BackgroundVariant, BackgroundGradientEnd), ), ), ) { diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt index f3890127ce..d68956b762 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt @@ -87,8 +87,8 @@ internal fun ControlDock( ) { Surface( modifier = modifier, - color = PanelStrong, - shape = RoundedCornerShape(28.dp), + color = SurfaceStrong, + shape = RoundedCornerShape(12.dp), elevation = 0.dp, ) { Row( @@ -104,7 +104,7 @@ internal fun ControlDock( ) { TransportButton(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = onPlay) TransportButton(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = onPause) - TransportButton(label = "Step", enabled = controls.canStep, accent = Accent, onClick = onStep) + TransportButton(label = "Step", enabled = controls.canStep, accent = PrimaryAccent, onClick = onStep) } StatusPill(controls = controls) MetricBlock(label = "Time", value = controls.timeLabel) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/InspectorSection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/InspectorSection.kt index 5c7f3b11b2..9ccb54a046 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/InspectorSection.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/InspectorSection.kt @@ -80,9 +80,9 @@ import kotlinx.coroutines.launch @Composable internal fun InspectorSection(title: String, description: String, fields: List) { Surface( - color = Panel.copy(alpha = 0.72f), + color = Surface.copy(alpha = 0.72f), contentColor = TextPrimary, - shape = RoundedCornerShape(22.dp), + shape = RoundedCornerShape(8.dp), elevation = 0.dp, ) { Column( diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt index 610f5a72d6..c6472eb910 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt @@ -80,8 +80,8 @@ import kotlinx.coroutines.launch @Composable internal fun MetricBlock(label: String, value: String) { Surface( - color = Panel.copy(alpha = 0.78f), - shape = RoundedCornerShape(18.dp), + color = Surface.copy(alpha = 0.78f), + shape = RoundedCornerShape(8.dp), elevation = 0.dp, ) { Column( @@ -91,7 +91,7 @@ internal fun MetricBlock(label: String, value: String) { Text( text = label.uppercase(), style = MaterialTheme.typography.caption, - color = AccentCool, + color = SecondaryAccent, ) Text( text = value, diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt index 8e15590ef9..f43c0fd508 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt @@ -81,9 +81,9 @@ import kotlinx.coroutines.launch internal fun NodeInspector(inspector: NodeInspectorState, onDismiss: () -> Unit, modifier: Modifier = Modifier) { Surface( modifier = modifier, - color = PanelStrong, + color = SurfaceStrong, contentColor = TextPrimary, - shape = RoundedCornerShape(28.dp), + shape = RoundedCornerShape(12.dp), elevation = 0.dp, ) { Column( diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt index 2b01cc4759..f19981053a 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt @@ -103,7 +103,7 @@ internal fun ProgressSection(progress: SimulationProgress, modifier: Modifier = modifier = Modifier .fillMaxWidth() .height(8.dp), - color = Accent, + color = PrimaryAccent, backgroundColor = Outline.copy(alpha = 0.55f), ) } else { @@ -112,7 +112,7 @@ internal fun ProgressSection(progress: SimulationProgress, modifier: Modifier = modifier = Modifier .fillMaxWidth() .height(8.dp), - color = Accent, + color = PrimaryAccent, backgroundColor = Outline.copy(alpha = 0.55f), ) } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt index f27da48471..8ee6fd7185 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt @@ -82,9 +82,9 @@ internal fun StatusPill(controls: SimulationControlsState) { val color = when (controls.status) { SimulationStatus.RUNNING -> Positive - SimulationStatus.PAUSED -> Accent + SimulationStatus.PAUSED -> PrimaryAccent SimulationStatus.TERMINATED -> Danger - else -> AccentCool + else -> SecondaryAccent } Surface( modifier = Modifier.width(StatusPillWidth), diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SummaryRail.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SummaryRail.kt index 7bede2bccc..7e1a969a6d 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SummaryRail.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SummaryRail.kt @@ -85,7 +85,7 @@ internal fun SummaryRail(summary: List, showLinks: Boolean, onToggleL ) { summary.forEach { item -> Surface( - color = PanelStrong.copy(alpha = 0.82f), + color = SurfaceStrong.copy(alpha = 0.82f), shape = RoundedCornerShape(999.dp), elevation = 0.dp, ) { @@ -108,7 +108,7 @@ internal fun SummaryRail(summary: List, showLinks: Boolean, onToggleL } Surface( modifier = Modifier.clickable(onClick = onToggleLinks), - color = if (showLinks) AccentCool.copy(alpha = 0.2f) else PanelStrong.copy(alpha = 0.82f), + color = if (showLinks) SecondaryAccent.copy(alpha = 0.2f) else SurfaceStrong.copy(alpha = 0.82f), shape = RoundedCornerShape(999.dp), elevation = 0.dp, ) { @@ -120,7 +120,7 @@ internal fun SummaryRail(summary: List, showLinks: Boolean, onToggleL Text( text = "LINKS", style = MaterialTheme.typography.caption, - color = if (showLinks) AccentCool else TextSecondary, + color = if (showLinks) SecondaryAccent else TextSecondary, ) Text( text = if (showLinks) "ON" else "OFF", diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt index 58c3cd6ac4..4230352c8f 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt @@ -79,19 +79,19 @@ import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.launch -internal val Midnight = Color(0xFF07111D) -internal val DeepSea = Color(0xFF10253B) -internal val Ink = Color(0xFF16293C) -internal val Panel = Color(0xF0132237) -internal val PanelStrong = Color(0xF70D1A2A) -internal val Outline = Color(0xFF426988) -internal val Accent = Color(0xFFF0B35A) -internal val AccentCool = Color(0xFF6AC3FF) -internal val Positive = Color(0xFF6ED39C) -internal val TextPrimary = Color(0xFFF4F0E8) -internal val TextSecondary = Color(0xFFDCE7F2) -internal val TextMuted = Color(0xFFC1D0DE) -internal val Danger = Color(0xFFD98B8B) +internal val Background = Color(0xFFF8F9FA) +internal val BackgroundVariant = Color(0xFFE9ECEF) +internal val BackgroundGradientEnd = Color(0xFFDEE2E6) +internal val Surface = Color(0xFFFFFFFF) +internal val SurfaceStrong = Color(0xFFF1F3F5) +internal val Outline = Color(0xFFCED4DA) +internal val PrimaryAccent = Color(0xFF0D6EFD) +internal val SecondaryAccent = Color(0xFF0A58CA) +internal val Positive = Color(0xFF198754) +internal val TextPrimary = Color(0xFF212529) +internal val TextSecondary = Color(0xFF495057) +internal val TextMuted = Color(0xFF6C757D) +internal val Danger = Color(0xFFDC3545) internal const val ZoomStep = 1.12f internal const val NodeHitRadius = 22f internal const val SelectedNodeRadius = 18f diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt index 8cc31577c3..e4f0af9009 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt @@ -79,11 +79,11 @@ import kotlinx.coroutines.launch @Composable internal fun TransportButton(label: String, enabled: Boolean, accent: Color, onClick: () -> Unit) { - val buttonContentColor = if (accent.luminance() > 0.35f) Midnight else TextPrimary + val buttonContentColor = if (accent.luminance() > 0.35f) TextPrimary else Surface Button( onClick = onClick, enabled = enabled, - shape = RoundedCornerShape(18.dp), + shape = RoundedCornerShape(8.dp), elevation = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp), colors = ButtonDefaults.buttonColors( backgroundColor = accent.copy(alpha = if (enabled) 0.92f else 0.28f), diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt index 99308ab0ea..394458c34b 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt @@ -73,9 +73,9 @@ internal fun ViewportSurface( val tapThresholdPx = with(density) { NodeHitRadius.dp.toPx() } Surface( modifier = modifier, - color = Panel, + color = Surface, contentColor = TextPrimary, - shape = RoundedCornerShape(28.dp), + shape = RoundedCornerShape(12.dp), elevation = 0.dp, ) { val coroutineScope = rememberCoroutineScope() @@ -85,11 +85,11 @@ internal fun ViewportSurface( .border( width = 1.dp, color = Outline.copy(alpha = 0.8f), - shape = RoundedCornerShape(28.dp), + shape = RoundedCornerShape(12.dp), ) .background( Brush.radialGradient( - colors = listOf(DeepSea.copy(alpha = 0.55f), Midnight), + colors = listOf(BackgroundVariant.copy(alpha = 0.55f), Background), radius = 1600f, ), ), @@ -160,7 +160,7 @@ internal fun ViewportSurface( ) { drawRect( brush = Brush.verticalGradient( - colors = listOf(DeepSea.copy(alpha = 0.55f), Midnight), + colors = listOf(BackgroundVariant.copy(alpha = 0.55f), Background), ), ) drawGrid(size) @@ -185,7 +185,7 @@ internal fun ViewportSurface( } mappedNodes.forEach { rendered -> val isSelected = rendered.node.id == selectedNodeId - val nodeColor = lerp(AccentCool, Accent, rendered.node.accent) + val nodeColor = lerp(SecondaryAccent, PrimaryAccent, rendered.node.accent) val screenRadius = NodeRadius.dp.toPx() * currentCamera.zoom val screenSelectedRadius = SelectedNodeRadius.dp.toPx() * currentCamera.zoom val screenSelectedInnerRadius = SelectedNodeInnerRadius.dp.toPx() * currentCamera.zoom @@ -197,7 +197,7 @@ internal fun ViewportSurface( center = rendered.center, ) drawCircle( - color = Accent, + color = PrimaryAccent, radius = screenSelectedInnerRadius, center = rendered.center, style = Stroke(width = 2.dp.toPx() * currentCamera.zoom), @@ -240,8 +240,8 @@ internal fun ViewportSurface( modifier = Modifier .align(Alignment.BottomStart) .padding(20.dp), - color = PanelStrong.copy(alpha = 0.88f), - shape = RoundedCornerShape(18.dp), + color = SurfaceStrong.copy(alpha = 0.88f), + shape = RoundedCornerShape(8.dp), elevation = 0.dp, ) { Text( From 5b910bd0a9d5752b6aba0566c36fb8a7c966c533 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Sat, 18 Apr 2026 10:52:23 +0200 Subject: [PATCH 09/22] feat: stat simulator with 3/4 measure size --- .../alchemist/boundary/composeui/ComposeMonitor.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt index 4e934c7edb..e2035fb19b 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt @@ -10,8 +10,11 @@ package it.unibo.alchemist.boundary.composeui import androidx.compose.runtime.remember +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState import it.unibo.alchemist.boundary.OutputMonitor import it.unibo.alchemist.boundary.composeui.adapter.toSimulationStatus import it.unibo.alchemist.boundary.composeui.adapter.toViewport @@ -19,6 +22,7 @@ import it.unibo.alchemist.model.Actionable import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Position import it.unibo.alchemist.model.Time +import java.awt.Toolkit import java.util.concurrent.atomic.AtomicBoolean /** @@ -69,10 +73,17 @@ class ComposeMonitor> @JvmOverloads constructor(targetFps: In private fun ensureWindow(environment: Environment) { if (windowStarted.compareAndSet(false, true)) { Thread { + val screenSize = Toolkit.getDefaultToolkit().screenSize application { Window( onCloseRequest = { exitApplication() }, title = "Alchemist", + state = rememberWindowState( + size = DpSize( + width = (screenSize.width * 3 / 4).dp, + height = (screenSize.height * 3 / 4).dp, + ), + ), ) { app( remember { From fabc634fe07ec31fc82c2ee55d87ca2b34394f2f Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Mon, 20 Apr 2026 10:17:46 +0200 Subject: [PATCH 10/22] feat: umprove UI --- .../boundary/composeui/ViewportSurface.kt | 225 +++++++++++++++--- 1 file changed, 197 insertions(+), 28 deletions(-) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt index 394458c34b..732987c7b2 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt @@ -16,8 +16,10 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -100,15 +102,16 @@ internal fun ViewportSurface( .onGloballyPositioned { coordinates -> viewportSize = coordinates.size } .pointerInput(baseNodes, selectedNodeId) { detectTapGestures { tapOffset -> - val worldTap = tapOffset.toWorldPosition(viewportSize, camera) // We need to map base nodes to screen space to check hits accurately against radius - // Alternatively we can map the tap back to base node space (which is screen space with camera pan=0, zoom=1) + // Alternatively we can map the tap back to base node space + // (which is screen space with camera pan=0, zoom=1) val tapInBaseSpace = tapOffset.toWorldPosition( viewportSize, camera, ).toScreenPosition(viewportSize, ViewportCameraState()) // Wait, baseNodes are already in the "camera at 0, zoom 1" screen space. - // So if we take the tapOffset, we just need to convert it to that same space to measure distance! + // So if we take the tapOffset, we just need to convert it + // to that same space to measure distance! val hit = baseNodes .minByOrNull { node -> node.center.distanceTo(tapInBaseSpace) } ?.takeIf { node -> @@ -163,10 +166,10 @@ internal fun ViewportSurface( colors = listOf(BackgroundVariant.copy(alpha = 0.55f), Background), ), ) - drawGrid(size) + val currentCamera = camera + drawGrid(size, currentCamera) // Map the base nodes and edges down here in the Draw phase - val currentCamera = camera val mappedNodes = baseNodes.map { node -> node.copy(center = node.center.toScreenPosition(viewportSize, currentCamera)) } @@ -254,40 +257,206 @@ internal fun ViewportSurface( style = MaterialTheme.typography.caption, ) } + val gridLegend = remember(viewportSize, camera.zoom, projection) { + if (viewportSize.width == 0 || viewportSize.height == 0 || projection == null) return@remember null + var stepX = (viewportSize.width / GridVerticalDivisions.toFloat()) * camera.zoom + var stepY = (viewportSize.height / GridHorizontalDivisions.toFloat()) * camera.zoom + var s = 1.0 + while (stepX < 10f || stepY < 10f) { + stepX *= 2f + stepY *= 2f + s *= 2.0 + } + while (stepX > 100f || stepY > 100f) { + stepX /= 2f + stepY /= 2f + s /= 2.0 + } + val worldX = (viewportSize.width / GridVerticalDivisions.toDouble() * s) / projection.pixelsPerUnit + val worldY = (viewportSize.height / GridHorizontalDivisions.toDouble() * s) / projection.pixelsPerUnit + GridLegendData(worldX, worldY, stepX, stepY) + } + if (gridLegend != null) { + Surface( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(20.dp), + color = SurfaceStrong.copy(alpha = 0.88f), + shape = RoundedCornerShape(8.dp), + elevation = 0.dp, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(12.dp), + ) { + Text( + text = gridLegend.worldY.formatFixed(2), + style = MaterialTheme.typography.caption, + color = TextPrimary, + modifier = Modifier.padding(end = 6.dp), + ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = gridLegend.worldX.formatFixed(2), + style = MaterialTheme.typography.caption, + color = TextPrimary, + modifier = Modifier.padding(bottom = 4.dp), + ) + val density = androidx.compose.ui.platform.LocalDensity.current + val canvasWidth = with(density) { gridLegend.stepX.toDp().coerceIn(20.dp, 120.dp) } + val canvasHeight = with(density) { gridLegend.stepY.toDp().coerceIn(20.dp, 120.dp) } + Canvas(modifier = Modifier.size(canvasWidth, canvasHeight)) { + val strokeWidth = 1.5.dp.toPx() + val arrowSize = 4.dp.toPx() + val color = TextPrimary.copy(alpha = 0.7f) + + val originX = arrowSize + val originY = size.height - arrowSize + val endX = size.width - arrowSize + val endY = arrowSize + + // Draw horizontal arrow + drawLine( + color = color, + start = Offset(originX, originY), + end = Offset(endX, originY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(endX - arrowSize, originY - arrowSize), + end = Offset(endX, originY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(endX - arrowSize, originY + arrowSize), + end = Offset(endX, originY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(originX + arrowSize, originY - arrowSize), + end = Offset(originX, originY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(originX + arrowSize, originY + arrowSize), + end = Offset(originX, originY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + + // Draw vertical arrow + drawLine( + color = color, + start = Offset(originX, originY), + end = Offset(originX, endY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(originX - arrowSize, endY + arrowSize), + end = Offset(originX, endY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(originX + arrowSize, endY + arrowSize), + end = Offset(originX, endY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(originX - arrowSize, originY - arrowSize), + end = Offset(originX, originY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(originX + arrowSize, originY - arrowSize), + end = Offset(originX, originY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } + } + } + } + } } } } -internal fun DrawScope.drawGrid(canvasSize: Size) { - val stepX = canvasSize.width / GridVerticalDivisions.toFloat() - val stepY = canvasSize.height / GridHorizontalDivisions.toFloat() - for (column in 1 until GridVerticalDivisions) { +internal fun DrawScope.drawGrid(canvasSize: Size, camera: ViewportCameraState) { + val center = Offset(canvasSize.width / 2f, canvasSize.height / 2f) + val origin = center + camera.pan + + var stepX = (canvasSize.width / GridVerticalDivisions.toFloat()) * camera.zoom + var stepY = (canvasSize.height / GridHorizontalDivisions.toFloat()) * camera.zoom + + // Prevent the grid from becoming too dense when zoomed out + while (stepX < 10f || stepY < 10f) { + stepX *= 2f + stepY *= 2f + } + // Prevent the grid from becoming too sparse when zoomed in + while (stepX > 100f || stepY > 100f) { + stepX /= 2f + stepY /= 2f + } + + val startX = (origin.x % stepX) - stepX + var currentX = startX + while (currentX < canvasSize.width) { drawLine( color = Outline.copy(alpha = 0.32f), - start = Offset(stepX * column, 0f), - end = Offset(stepX * column, canvasSize.height), + start = Offset(currentX, 0f), + end = Offset(currentX, canvasSize.height), strokeWidth = 1f, ) + currentX += stepX } - for (row in 1 until GridHorizontalDivisions) { + + val startY = (origin.y % stepY) - stepY + var currentY = startY + while (currentY < canvasSize.height) { drawLine( color = Outline.copy(alpha = 0.28f), - start = Offset(0f, stepY * row), - end = Offset(canvasSize.width, stepY * row), + start = Offset(0f, currentY), + end = Offset(canvasSize.width, currentY), strokeWidth = 1f, ) + currentY += stepY + } + + // Draw origin axes if they are visible + if (origin.x in 0f..canvasSize.width) { + drawLine( + color = Outline.copy(alpha = 0.7f), + start = Offset(origin.x, 0f), + end = Offset(origin.x, canvasSize.height), + strokeWidth = 1.6f, + cap = StrokeCap.Round, + ) + } + if (origin.y in 0f..canvasSize.height) { + drawLine( + color = Outline.copy(alpha = 0.7f), + start = Offset(0f, origin.y), + end = Offset(canvasSize.width, origin.y), + strokeWidth = 1.6f, + cap = StrokeCap.Round, + ) } - drawLine( - color = Outline.copy(alpha = 0.7f), - start = Offset(canvasSize.width / 2f, 0f), - end = Offset(canvasSize.width / 2f, canvasSize.height), - strokeWidth = 1.6f, - cap = StrokeCap.Round, - ) - drawLine( - color = Outline.copy(alpha = 0.7f), - start = Offset(0f, canvasSize.height / 2f), - end = Offset(canvasSize.width, canvasSize.height / 2f), - strokeWidth = 1.6f, - cap = StrokeCap.Round, - ) } + +private data class GridLegendData(val worldX: Double, val worldY: Double, val stepX: Float, val stepY: Float) From 5b4a9948c325550410f73fd1bac03212bafaaecc Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Mon, 20 Apr 2026 13:04:55 +0200 Subject: [PATCH 11/22] feat: simplify grid dimension layout --- .../boundary/composeui/ViewportSurface.kt | 187 ++++++------------ 1 file changed, 65 insertions(+), 122 deletions(-) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt index 732987c7b2..67f4b45c7f 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import kotlin.math.max +import kotlin.math.min import kotlinx.coroutines.launch @OptIn(ExperimentalComposeUiApi::class) @@ -259,22 +260,22 @@ internal fun ViewportSurface( } val gridLegend = remember(viewportSize, camera.zoom, projection) { if (viewportSize.width == 0 || viewportSize.height == 0 || projection == null) return@remember null - var stepX = (viewportSize.width / GridVerticalDivisions.toFloat()) * camera.zoom - var stepY = (viewportSize.height / GridHorizontalDivisions.toFloat()) * camera.zoom + val baseStep = min( + viewportSize.width / GridVerticalDivisions.toFloat(), + viewportSize.height / GridHorizontalDivisions.toFloat(), + ) + var step = baseStep * camera.zoom var s = 1.0 - while (stepX < 10f || stepY < 10f) { - stepX *= 2f - stepY *= 2f + while (step < 10f) { + step *= 2f s *= 2.0 } - while (stepX > 100f || stepY > 100f) { - stepX /= 2f - stepY /= 2f + while (step > 100f) { + step /= 2f s /= 2.0 } - val worldX = (viewportSize.width / GridVerticalDivisions.toDouble() * s) / projection.pixelsPerUnit - val worldY = (viewportSize.height / GridHorizontalDivisions.toDouble() * s) / projection.pixelsPerUnit - GridLegendData(worldX, worldY, stepX, stepY) + val worldStep = (baseStep * s) / projection.pixelsPerUnit + GridLegendData(worldStep, worldStep, step, step) } if (gridLegend != null) { Surface( @@ -285,110 +286,51 @@ internal fun ViewportSurface( shape = RoundedCornerShape(8.dp), elevation = 0.dp, ) { - Row( - verticalAlignment = Alignment.CenterVertically, + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(12.dp), ) { Text( - text = gridLegend.worldY.formatFixed(2), + text = gridLegend.worldX.formatFixed(2), style = MaterialTheme.typography.caption, color = TextPrimary, - modifier = Modifier.padding(end = 6.dp), + modifier = Modifier.padding(bottom = 4.dp), ) - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = gridLegend.worldX.formatFixed(2), - style = MaterialTheme.typography.caption, - color = TextPrimary, - modifier = Modifier.padding(bottom = 4.dp), - ) - val density = androidx.compose.ui.platform.LocalDensity.current - val canvasWidth = with(density) { gridLegend.stepX.toDp().coerceIn(20.dp, 120.dp) } - val canvasHeight = with(density) { gridLegend.stepY.toDp().coerceIn(20.dp, 120.dp) } - Canvas(modifier = Modifier.size(canvasWidth, canvasHeight)) { - val strokeWidth = 1.5.dp.toPx() - val arrowSize = 4.dp.toPx() - val color = TextPrimary.copy(alpha = 0.7f) - - val originX = arrowSize - val originY = size.height - arrowSize - val endX = size.width - arrowSize - val endY = arrowSize - - // Draw horizontal arrow - drawLine( - color = color, - start = Offset(originX, originY), - end = Offset(endX, originY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(endX - arrowSize, originY - arrowSize), - end = Offset(endX, originY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(endX - arrowSize, originY + arrowSize), - end = Offset(endX, originY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(originX + arrowSize, originY - arrowSize), - end = Offset(originX, originY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(originX + arrowSize, originY + arrowSize), - end = Offset(originX, originY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) + val density = androidx.compose.ui.platform.LocalDensity.current + val canvasWidth = with(density) { gridLegend.stepX.toDp().coerceIn(20.dp, 120.dp) } + Canvas(modifier = Modifier.size(canvasWidth, 10.dp)) { + val strokeWidth = 1.5.dp.toPx() + val color = TextPrimary.copy(alpha = 0.7f) - // Draw vertical arrow - drawLine( - color = color, - start = Offset(originX, originY), - end = Offset(originX, endY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(originX - arrowSize, endY + arrowSize), - end = Offset(originX, endY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(originX + arrowSize, endY + arrowSize), - end = Offset(originX, endY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(originX - arrowSize, originY - arrowSize), - end = Offset(originX, originY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(originX + arrowSize, originY - arrowSize), - end = Offset(originX, originY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - } + val startX = 0f + val endX = size.width + val centerY = size.height / 2f + val tickHeight = 4.dp.toPx() + + // Draw horizontal line + drawLine( + color = color, + start = Offset(startX, centerY), + end = Offset(endX, centerY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + // Draw left tick + drawLine( + color = color, + start = Offset(startX + strokeWidth / 2, centerY - tickHeight), + end = Offset(startX + strokeWidth / 2, centerY + tickHeight), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + // Draw right tick + drawLine( + color = color, + start = Offset(endX - strokeWidth / 2, centerY - tickHeight), + end = Offset(endX - strokeWidth / 2, centerY + tickHeight), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) } } } @@ -399,22 +341,23 @@ internal fun ViewportSurface( internal fun DrawScope.drawGrid(canvasSize: Size, camera: ViewportCameraState) { val center = Offset(canvasSize.width / 2f, canvasSize.height / 2f) val origin = center + camera.pan - - var stepX = (canvasSize.width / GridVerticalDivisions.toFloat()) * camera.zoom - var stepY = (canvasSize.height / GridHorizontalDivisions.toFloat()) * camera.zoom - + + val baseStep = min( + canvasSize.width / GridVerticalDivisions.toFloat(), + canvasSize.height / GridHorizontalDivisions.toFloat(), + ) + var step = baseStep * camera.zoom + // Prevent the grid from becoming too dense when zoomed out - while (stepX < 10f || stepY < 10f) { - stepX *= 2f - stepY *= 2f + while (step < 10f) { + step *= 2f } // Prevent the grid from becoming too sparse when zoomed in - while (stepX > 100f || stepY > 100f) { - stepX /= 2f - stepY /= 2f + while (step > 100f) { + step /= 2f } - val startX = (origin.x % stepX) - stepX + val startX = (origin.x % step) - step var currentX = startX while (currentX < canvasSize.width) { drawLine( @@ -423,10 +366,10 @@ internal fun DrawScope.drawGrid(canvasSize: Size, camera: ViewportCameraState) { end = Offset(currentX, canvasSize.height), strokeWidth = 1f, ) - currentX += stepX + currentX += step } - val startY = (origin.y % stepY) - stepY + val startY = (origin.y % step) - step var currentY = startY while (currentY < canvasSize.height) { drawLine( @@ -435,7 +378,7 @@ internal fun DrawScope.drawGrid(canvasSize: Size, camera: ViewportCameraState) { end = Offset(canvasSize.width, currentY), strokeWidth = 1f, ) - currentY += stepY + currentY += step } // Draw origin axes if they are visible From 9c09f124838f402c69a7e9a8d8b50e6dda510ede Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Tue, 21 Apr 2026 16:42:37 +0200 Subject: [PATCH 12/22] feat: new dock bar --- alchemist-composeui/AI_CONTEXT.md | 249 ++++++++++++++++++ .../boundary/composeui/AlchemistUiRoot.kt | 18 +- .../boundary/composeui/ControlDock.kt | 204 +++++++++----- .../composeui/SimulationControlsConfig.kt | 18 ++ .../composeui/SimulationPrimaryPane.kt | 7 + .../alchemist/boundary/composeui/UiModel.kt | 66 +++++ .../alchemist/boundary/composeui/UiStore.kt | 120 ++++++++- .../composeui/SimulationControlsStateTest.kt | 47 ++++ .../boundary/composeui/ComposeMonitor.kt | 69 ++++- .../composeui/DesktopAlchemistUiCallback.kt | 111 +++++++- .../composeui/adapter/AlchemistNodeAdapter.kt | 10 +- 11 files changed, 832 insertions(+), 87 deletions(-) create mode 100644 alchemist-composeui/AI_CONTEXT.md create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsConfig.kt diff --git a/alchemist-composeui/AI_CONTEXT.md b/alchemist-composeui/AI_CONTEXT.md new file mode 100644 index 0000000000..7426d106cc --- /dev/null +++ b/alchemist-composeui/AI_CONTEXT.md @@ -0,0 +1,249 @@ +# `alchemist-composeui` — AI Maintenance Guide + +## Intent + +`alchemist-composeui` provides the Compose-based user interface for Alchemist. The module exists to help users and agentic maintainers: + +- inspect a running simulation visually, +- control playback with play/pause/step, jump, FPS, and pacing actions, +- inspect nodes in detail, +- toggle link visibility, +- run the same UI shell across JVM desktop and browser targets. + +This document is intentionally **intent-driven**: it describes why the module exists, what users expect from it, and what invariants future changes must preserve. + +## Primary jobs to be done + +1. Render a live simulation viewport with nodes and optional edges. +2. Expose transport controls plus direct jump and pacing controls. +3. Show a node inspector when a node is selected. +4. Keep the UI state reactive and thread-safe. +5. Bridge the simulation engine to the Compose UI on JVM. +6. Provide a demo/fallback shell for non-simulation entrypoints. + +## Current architecture + +### Module structure + +- `src/commonMain`: shared UI, state, and rendering logic. +- `src/jvmMain`: desktop monitor, simulation bridge, JVM-specific platform entrypoint. +- `src/wasmJsMain`: browser entrypoint and platform glue. + +### Runtime entrypoints + +- `App.kt` renders the shared UI shell. +- `AlchemistUiRoot.kt` composes the page layout and chooses compact vs wide layout. +- `ComposeMonitor.kt` integrates the UI with Alchemist's `OutputMonitor` on JVM. +- `DesktopAlchemistUiCallback.kt` forwards UI actions to the running simulation. +- `Main.kt` under `wasmJsMain` launches the browser demo entrypoint. + +### State model + +Defined in `UiModel.kt`: + +- `AlchemistUiState` is the top-level UI snapshot. +- `ViewportScene` models the center canvas. +- `SimulationControlsState` models play/pause/step plus: + - current time and step labels, + - `To Time` and `To Step` text input values, + - editable UI FPS, + - event-rate slider state, + - modal validation errors. +- `NodeInspectorState` models the selected node panel. +- `AlchemistUiCallbacks` is the interaction contract. +- `NoOpUiCallbacks` is the fallback implementation. + +### State storage + +Defined in `UiStore.kt`: + +- `ComposeUiStateStore` wraps a `MutableStateFlow`. +- `ComposeUiController` bundles the store and callbacks. +- `demoController()` provides the browser/demo state and behavior. + +### Rendering components + +Shared UI is decomposed into focused composables: + +- `ViewportSurface.kt`: central interactive canvas, pan/zoom, hit detection. +- `SimulationPrimaryPane.kt`: viewport + bottom control dock. +- `ControlDock.kt`: transport controls, metrics, jump inputs, FPS input, and event-rate slider. +- `NodeInspector.kt`: selected node details panel. +- `SummaryRail.kt`: summary chips and link toggle. +- `InspectorSection.kt`, `MetricBlock.kt`, `StatusPill.kt`, `TransportButton.kt`, `ProgressSection.kt`: small UI primitives. +- `Theme.kt`: colors and visual constants. +- `ViewportProjection.kt` and `ViewportRendering.kt`: coordinate transforms and drawing helpers. + +### JVM data bridge + +Defined in `adapter/AlchemistNodeAdapter.kt` and used by `ComposeMonitor.kt`: + +- simulation nodes are projected into `ViewportNode` objects, +- environment edges are converted into `ViewportEdge`, +- simulation status is mapped into `SimulationStatus`. + +## User-facing behavior + +### What the user should see + +- A simulation window titled `Alchemist` on desktop. +- A viewport centered on the simulation data. +- Nodes rendered with stable positions and accent-based coloring. +- Optional link rendering controlled by the UI. +- A bottom transport dock with: + - Play, + - Pause, + - Step, + - time label, + - step counter, + - `To Time` text box, + - `To Step` text box, + - `FPS` text box, + - events/second slider ending in `Max` for full throttle. +- A node inspector when a node is selected. +- A modal popup when a jump target or numeric input is invalid. + +### Interaction model + +- Left-click a node to inspect it. +- Click empty space to dismiss the inspector. +- Middle-drag to pan the viewport. +- Wheel to zoom. +- Toggle links from the summary rail. +- Use play/pause/step to control the simulation when the UI is attached to a live engine. +- Press Enter in `To Time` / `To Step` to fast-forward to the requested target. +- `To Time` / `To Step` reject backward targets and leave the simulator paused at the requested target. +- Use `FPS` to control UI refresh frequency, clamped between `5` and the detected monitor refresh rate, or `60` if detection is unavailable. +- Use the event-rate slider to pace the simulation thread; the terminal `Max` value disables pacing and runs full throttle. + +## Important invariants + +1. **UI state must remain single-sourced** + - The view layer observes `ComposeUiStateStore.stateFlow`. + - Updates should go through the store, not through ad-hoc mutable globals. + +2. **Callbacks are the only interaction boundary** + - Composables should call `AlchemistUiCallbacks` and stay agnostic of the simulation backend. + +3. **Viewport projection must remain stable enough for inspection** + - `ViewportProjection` fixes the mapping once a valid viewport exists. + - The selection and zoom/pan logic assume a consistent mapping between world and screen space. + +4. **Live monitor updates must preserve UI-only toggles** + - `ComposeMonitor.updateUiState` intentionally preserves `scene.showLinks` while refreshing the scene from the simulation. + +5. **Validation feedback must remain store-driven** + - Invalid jump/FPS input is surfaced via `SimulationControlsState.dialog`. + - Composables only render and dismiss the dialog through callbacks. + +6. **Demo state should remain usable without a live simulation** + - `demoController()` is the browser-friendly fallback and should continue to demonstrate the UI shell. + +7. **Small composables should stay small** + - Maintain the current composition pattern: one responsibility per file when possible. + +## Known implementation details + +### `ComposeMonitor` + +- Starts a desktop Compose window lazily and only once. +- Throttles UI updates according to the current FPS stored in `SimulationControlsState`. +- Detects monitor refresh rate on JVM and falls back to `60` when unavailable. +- Paces the simulation thread according to the selected events/second slider value. +- Uses `Toolkit.getDefaultToolkit().screenSize` to size the window. +- Bridges simulation state into the UI state store. + +### `DesktopAlchemistUiCallback` + +- Executes simulation actions on JVM. +- Synchronizes store updates on `Dispatchers.Main.immediate`. +- Uses the simulation object as the source of truth for play/pause/step/jump state. +- Validates `To Time`, `To Step`, and `FPS` submissions before mutating simulator state. + +### `ViewportSurface` + +- Manages viewport size, camera pan, and zoom. +- Uses projection data derived from the current scene. +- Keeps the first valid projection fixed until the layout becomes valid. +- Requires node selection hit tests to respect camera zoom. + +### `demoController()` + +- Builds a sample scene with nodes, edges, summary data, and a mock progress state. +- Supports play/pause/step and link toggling without a live simulation. + +## Requirements for future changes + +When changing this module, preserve the following: + +- Keep `commonMain` free of JVM-only dependencies. +- Keep the UI reactive through `StateFlow`. +- Preserve browser entrypoint usability. +- Preserve the JVM monitor contract with `OutputMonitor`. +- Preserve node inspection, transport controls, and link toggling. +- Preserve jump validation semantics: backward `To Time` / `To Step` targets must fail with a popup. +- Preserve the constants governing FPS and event-rate ranges. +- Add or update tests when changing projection math, selection logic, or state transitions. + +## Preferred change strategy + +1. Identify the user intent first. +2. Locate the smallest composable or state object that owns that behavior. +3. Keep shared UI logic in `commonMain`. +4. Add platform-specific code only in the relevant source set. +5. Verify that the demo shell still works after the change. +6. Verify that the JVM monitor still attaches to the simulation without breaking state updates. + +## Test and verification checklist + +Before considering a change complete, check: + +- `ViewportProjectionTest` still passes. +- `SimulationControlsState` behavior still matches the expected play/pause/step rules. +- Jump/FPS validation still matches the expected dialog behavior. +- Adapter tests still cover canonical edge and viewport conversion behavior. +- The UI compiles in both `commonMain` and `jvmMain`. +- The desktop monitor still opens a window and updates the UI state. +- The browser entrypoint still renders the shared `app()` shell. + +## What not to change casually + +- The top-level `AlchemistUiState` shape. +- The callback contract in `AlchemistUiCallbacks`. +- The preservation of `scene.showLinks` across live updates. +- The viewport math without corresponding tests. +- The separation between shared UI and platform entrypoints. + +## Suggested maintainer workflow + +For future modifications, prefer this order: + +1. Read this file. +2. Inspect `UiModel.kt` and the relevant composable. +3. Check platform-specific behavior in `ComposeMonitor.kt` or `Main.kt` if needed. +4. Update tests near the affected logic. +5. Re-run the module validation tasks. + +## Quick map of key files + +- `src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt` +- `src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt` +- `src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt` +- `src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt` +- `src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt` +- `src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt` +- `src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt` +- `src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt` +- `src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt` +- `src/wasmJsMain/kotlin/it/unibo/alchemist/boundary/composeui/Main.kt` + +## Short version + +If you are an AI agent and you need to work on `alchemist-composeui`, remember: + +- preserve the shared state contract, +- keep platform code separated, +- test projection and interaction math, +- do not break the demo shell, +- do not overwrite UI-only toggles when refreshing live simulation data, +- keep control validation and popup state inside the shared store model. diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt index a8fd9e5827..473ce3420b 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt @@ -118,6 +118,22 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { ), ), ) { + state.controls.dialog?.let { dialog -> + androidx.compose.material.AlertDialog( + onDismissRequest = { coroutineScope.launch { callbacks.onDialogDismiss() } }, + title = { Text(dialog.title) }, + text = { Text(dialog.message) }, + confirmButton = { + androidx.compose.material.TextButton( + onClick = { coroutineScope.launch { callbacks.onDialogDismiss() } }, + ) { + Text("OK") + } + }, + backgroundColor = SurfaceStrong, + contentColor = TextPrimary, + ) + } BoxWithConstraints( modifier = Modifier .fillMaxSize() @@ -130,7 +146,7 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { val compactLayout = maxWidth < 980.dp val inspectorVisible = state.inspector != null val inspectorWidth = 324.dp - val bottomBarHeight = 112.dp + val bottomBarHeight = 152.dp val layoutSpacing = 20.dp if (compactLayout) { Box( diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt index d68956b762..ecb99b788e 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt @@ -9,73 +9,31 @@ package it.unibo.alchemist.boundary.composeui -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Slider import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.launch @Composable internal fun ControlDock( @@ -83,6 +41,13 @@ internal fun ControlDock( onPlay: () -> Unit, onPause: () -> Unit, onStep: () -> Unit, + onToTimeInputChanged: (String) -> Unit, + onToTimeSubmit: () -> Unit, + onToStepInputChanged: (String) -> Unit, + onToStepSubmit: () -> Unit, + onFpsInputChanged: (String) -> Unit, + onFpsSubmit: () -> Unit, + onEventRateChanged: (Float) -> Unit, modifier: Modifier = Modifier, ) { Surface( @@ -94,25 +59,134 @@ internal fun ControlDock( Row( modifier = Modifier .fillMaxWidth() + .horizontalScroll(rememberScrollState()) .padding(horizontal = 18.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(18.dp), ) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TransportButton(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = onPlay) - TransportButton(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = onPause) - TransportButton(label = "Step", enabled = controls.canStep, accent = PrimaryAccent, onClick = onStep) - } - StatusPill(controls = controls) + TransportButton(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = onPlay) + TransportButton(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = onPause) + TransportButton(label = "Step", enabled = controls.canStep, accent = PrimaryAccent, onClick = onStep) MetricBlock(label = "Time", value = controls.timeLabel) MetricBlock(label = "Step", value = controls.step.toString()) - ProgressSection( - progress = controls.progress, - modifier = Modifier.weight(1f), + DockTextField( + label = "To Time", + value = controls.toTimeInput, + caption = "Enter to jump", + onValueChange = onToTimeInputChanged, + onSubmit = onToTimeSubmit, + ) + DockTextField( + label = "To Step", + value = controls.toStepInput, + caption = "Enter to jump", + onValueChange = onToStepInputChanged, + onSubmit = onToStepSubmit, + ) + DockTextField( + label = "FPS", + value = controls.fpsInput, + caption = controls.fpsRangeLabel, + onValueChange = onFpsInputChanged, + onSubmit = onFpsSubmit, + ) + EventRateSlider( + controls = controls, + onValueChange = onEventRateChanged, + ) + } + } +} + +@Composable +private fun DockTextField( + label: String, + value: String, + caption: String, + onValueChange: (String) -> Unit, + onSubmit: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .width(DockTextFieldWidth) + .onPreviewKeyEvent { + val isEnter = it.key == Key.Enter || it.key == Key.NumPadEnter + if (it.type == KeyEventType.KeyUp && isEnter) { + onSubmit() + true + } else { + false + } + }, + label = { Text(label) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = TextPrimary, + focusedBorderColor = PrimaryAccent, + unfocusedBorderColor = Outline, + focusedLabelColor = PrimaryAccent, + unfocusedLabelColor = TextSecondary, + cursorColor = PrimaryAccent, + ), + ) + Text( + text = caption, + style = MaterialTheme.typography.caption, + color = TextSecondary, + ) + } +} + +@Composable +private fun EventRateSlider(controls: SimulationControlsState, onValueChange: (Float) -> Unit) { + Column( + modifier = Modifier.width(EventRateControlWidth), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Events / second", + style = MaterialTheme.typography.caption, + color = SecondaryAccent, + ) + Slider( + value = controls.eventRateSliderValue.toFloat(), + onValueChange = onValueChange, + valueRange = + MIN_SIMULATION_EVENTS_PER_SECOND.toFloat()..controls.maxEventRateSliderValue.toFloat(), + steps = controls.maxEventRateSliderValue - MIN_SIMULATION_EVENTS_PER_SECOND - 1, + colors = androidx.compose.material.SliderDefaults.colors( + thumbColor = PrimaryAccent, + activeTrackColor = PrimaryAccent, + inactiveTrackColor = Outline, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${MIN_SIMULATION_EVENTS_PER_SECOND} evt/s", + style = MaterialTheme.typography.caption, + color = TextSecondary, + ) + Text( + text = controls.eventRateLabel, + style = MaterialTheme.typography.caption, + color = TextPrimary, + ) + Text( + text = FULL_THROTTLE_LABEL, + style = MaterialTheme.typography.caption, + color = if (controls.isFullThrottle) PrimaryAccent else TextSecondary, ) } } } + +private val DockTextFieldWidth = 132.dp +private val EventRateControlWidth = 240.dp diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsConfig.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsConfig.kt new file mode 100644 index 0000000000..5fcbb04f0e --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsConfig.kt @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +internal const val MIN_UI_FPS = 5 +internal const val DEFAULT_UI_FPS = 30 +internal const val DEFAULT_MAX_UI_FPS = 60 +internal const val MIN_SIMULATION_EVENTS_PER_SECOND = 1 +internal const val DEFAULT_MAX_SIMULATION_EVENTS_PER_SECOND = 120 +internal const val FULL_THROTTLE_LABEL = "Max" +internal const val DISPLAYED_TIME_DECIMALS = 2 diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt index 0194c3b7aa..4112508089 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt @@ -110,6 +110,13 @@ internal fun SimulationPrimaryPane( onPlay = { coroutineScope.launch { callbacks.onPlay() } }, onPause = { coroutineScope.launch { callbacks.onPause() } }, onStep = { coroutineScope.launch { callbacks.onStep() } }, + onToTimeInputChanged = { coroutineScope.launch { callbacks.onToTimeInputChanged(it) } }, + onToTimeSubmit = { coroutineScope.launch { callbacks.onToTimeSubmit() } }, + onToStepInputChanged = { coroutineScope.launch { callbacks.onToStepInputChanged(it) } }, + onToStepSubmit = { coroutineScope.launch { callbacks.onToStepSubmit() } }, + onFpsInputChanged = { coroutineScope.launch { callbacks.onFpsInputChanged(it) } }, + onFpsSubmit = { coroutineScope.launch { callbacks.onFpsSubmit() } }, + onEventRateChanged = { coroutineScope.launch { callbacks.onEventRateChanged(it) } }, modifier = Modifier .fillMaxWidth(dockWidthFraction) .wrapContentHeight(), diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt index 3d32b7c2c0..fe73e2ea9d 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt @@ -94,6 +94,12 @@ data class SimulationProgress(val fraction: Float? = null, val label: String = " } } +/** + * Modal error state shown by the shared UI. + */ +@Immutable +data class ControlDialogState(val title: String, val message: String) + /** * State for transport controls and simulator metrics. */ @@ -103,11 +109,39 @@ data class SimulationControlsState( val timeLabel: String = "0", val step: Long = 0L, val progress: SimulationProgress = SimulationProgress(), + val toTimeInput: String = "", + val toStepInput: String = "", + val fpsInput: String = DEFAULT_UI_FPS.toString(), + val uiFps: Int = DEFAULT_UI_FPS, + val maxUiFps: Int = DEFAULT_MAX_UI_FPS, + val eventRateSliderValue: Int = MIN_SIMULATION_EVENTS_PER_SECOND, + val maxEventRateSliderValue: Int = DEFAULT_MAX_SIMULATION_EVENTS_PER_SECOND, + val dialog: ControlDialogState? = null, ) { + init { + require(maxUiFps >= MIN_UI_FPS) { + "Maximum UI fps must be at least $MIN_UI_FPS." + } + require(uiFps in MIN_UI_FPS..maxUiFps) { + "UI fps must remain within [$MIN_UI_FPS, $maxUiFps]." + } + require(eventRateSliderValue in MIN_SIMULATION_EVENTS_PER_SECOND..maxEventRateSliderValue) { + "Slider value must remain within [$MIN_SIMULATION_EVENTS_PER_SECOND, $maxEventRateSliderValue]." + } + require(maxEventRateSliderValue > MIN_SIMULATION_EVENTS_PER_SECOND) { + "Maximum slider value must exceed the minimum event rate." + } + } + val canPlay: Boolean = status == SimulationStatus.READY || status == SimulationStatus.PAUSED val canPause: Boolean = status == SimulationStatus.RUNNING val canStep: Boolean = status == SimulationStatus.READY || status == SimulationStatus.PAUSED val statusLabel: String = status.name.lowercase().replaceFirstChar(Char::uppercaseChar) + val isFullThrottle: Boolean = eventRateSliderValue == maxEventRateSliderValue + val effectiveEventsPerSecond: Int? = eventRateSliderValue.takeUnless { isFullThrottle } + val eventRateLabel: String = + effectiveEventsPerSecond?.let { "$it evt/s" } ?: FULL_THROTTLE_LABEL + val fpsRangeLabel: String = "$MIN_UI_FPS-$maxUiFps FPS" } /** @@ -144,11 +178,27 @@ interface AlchemistUiCallbacks { suspend fun onStep() + suspend fun onToTimeInputChanged(value: String) + + suspend fun onToTimeSubmit() + + suspend fun onToStepInputChanged(value: String) + + suspend fun onToStepSubmit() + + suspend fun onFpsInputChanged(value: String) + + suspend fun onFpsSubmit() + + suspend fun onEventRateChanged(value: Float) + suspend fun onNodeSelected(nodeId: Int) suspend fun onInspectorDismiss() suspend fun onToggleLinks() + + suspend fun onDialogDismiss() } /** @@ -161,9 +211,25 @@ object NoOpUiCallbacks : AlchemistUiCallbacks { override suspend fun onStep() = Unit + override suspend fun onToTimeInputChanged(value: String) = Unit + + override suspend fun onToTimeSubmit() = Unit + + override suspend fun onToStepInputChanged(value: String) = Unit + + override suspend fun onToStepSubmit() = Unit + + override suspend fun onFpsInputChanged(value: String) = Unit + + override suspend fun onFpsSubmit() = Unit + + override suspend fun onEventRateChanged(value: Float) = Unit + override suspend fun onNodeSelected(nodeId: Int) = Unit override suspend fun onInspectorDismiss() = Unit override suspend fun onToggleLinks() = Unit + + override suspend fun onDialogDismiss() = Unit } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt index 435255c59c..f211b2a224 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt @@ -11,6 +11,8 @@ package it.unibo.alchemist.boundary.composeui +import kotlin.math.ceil +import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -71,17 +73,69 @@ fun demoController(): ComposeUiController { override suspend fun onStep() { store.update { val nextStep = it.controls.step + 1 - it.copy( - controls = it.controls.copy( - status = SimulationStatus.PAUSED, - step = nextStep, - timeLabel = formatDemoTime(nextStep), - progress = SimulationProgress( - fraction = (nextStep % 100).toFloat() / 100f, - label = "Scenario exploration", - ), - ), - ) + it.withStepProgress(nextStep) + } + } + + override suspend fun onToTimeInputChanged(value: String) { + store.update { + it.copy(controls = it.controls.copy(toTimeInput = value)) + } + } + + override suspend fun onToTimeSubmit() { + store.update { state -> + val target = state.controls.toTimeInput.toDoubleOrNull() + ?: return@update state.withDialog("Invalid time", "Insert a valid numeric time.") + val currentTime = state.controls.timeLabel.toDoubleOrNull() ?: 0.0 + if (target < currentTime) { + return@update state.withDialog( + title = "Invalid time", + message = "Target time $target cannot be lower than current time $currentTime.", + ) + } + val targetStep = ceil(target * DEMO_TIME_SCALE).toLong() + state.withStepProgress(targetStep, timeLabel = target.formatFixed(DISPLAYED_TIME_DECIMALS)) + } + } + + override suspend fun onToStepInputChanged(value: String) { + store.update { + it.copy(controls = it.controls.copy(toStepInput = value)) + } + } + + override suspend fun onToStepSubmit() { + store.update { state -> + val target = state.controls.toStepInput.toLongOrNull() + ?: return@update state.withDialog("Invalid step", "Insert a valid integer step.") + if (target < state.controls.step) { + return@update state.withDialog( + title = "Invalid step", + message = "Target step $target cannot be lower than current step ${state.controls.step}.", + ) + } + state.withStepProgress(target) + } + } + + override suspend fun onFpsInputChanged(value: String) { + store.update { + it.copy(controls = it.controls.copy(fpsInput = value)) + } + } + + override suspend fun onFpsSubmit() { + store.update { state -> + val target = state.controls.fpsInput.toIntOrNull() + ?: return@update state.withDialog("Invalid FPS", "Insert a valid integer FPS value.") + state.copy(controls = state.controls.withUiFps(target)) + } + } + + override suspend fun onEventRateChanged(value: Float) { + store.update { state -> + state.copy(controls = state.controls.withEventRateSliderValue(value.roundToInt())) } } @@ -108,6 +162,12 @@ fun demoController(): ComposeUiController { ) } } + + override suspend fun onDialogDismiss() { + store.update { + it.copy(controls = it.controls.copy(dialog = null)) + } + } } return ComposeUiController(store, callbacks) } @@ -216,6 +276,42 @@ private fun sampleUiState(): AlchemistUiState { ) } +internal fun AlchemistUiState.withDialog(title: String, message: String): AlchemistUiState = copy( + controls = controls.copy( + dialog = ControlDialogState(title, message), + ), +) + +internal fun AlchemistUiState.withStepProgress( + step: Long, + timeLabel: String = formatDemoTime(step), +): AlchemistUiState = copy( + controls = controls.copy( + status = SimulationStatus.PAUSED, + step = step, + timeLabel = timeLabel, + progress = SimulationProgress( + fraction = (step % 100).toFloat() / 100f, + label = "Scenario exploration", + ), + dialog = null, + ), +) + +internal fun SimulationControlsState.withUiFps(target: Int): SimulationControlsState { + val coerced = target.coerceIn(MIN_UI_FPS, maxUiFps) + return copy( + fpsInput = coerced.toString(), + uiFps = coerced, + dialog = null, + ) +} + +internal fun SimulationControlsState.withEventRateSliderValue(target: Int): SimulationControlsState = copy( + eventRateSliderValue = target.coerceIn(MIN_SIMULATION_EVENTS_PER_SECOND, maxEventRateSliderValue), + dialog = null, +) + internal fun ViewportNode.toInspectorState(): NodeInspectorState = NodeInspectorState( nodeId = id, subtitle = "Live node snapshot", @@ -242,3 +338,5 @@ internal fun Double.formatFixed(decimals: Int): String { } private fun formatDemoTime(step: Long): String = (step / 10.0).formatFixed(2) + +private const val DEMO_TIME_SCALE = 10.0 diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt index 18e1be707e..f638d7dce2 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt @@ -15,6 +15,8 @@ import kotlin.coroutines.startCoroutine import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class SimulationControlsStateTest { @@ -66,6 +68,51 @@ class SimulationControlsStateTest { assertTrue(controller.store.state.scene.showLinks) assertEquals(3, controller.store.state.selectedNodeId) } + + @Test + fun `ui fps is clamped to the configured range`() { + val controls = SimulationControlsState(maxUiFps = 45).withUiFps(120) + + assertEquals(45, controls.uiFps) + assertEquals("45", controls.fpsInput) + } + + @Test + fun `full throttle is represented by the max slider value`() { + val controls = + SimulationControlsState(maxEventRateSliderValue = 50).withEventRateSliderValue(50) + + assertTrue(controls.isFullThrottle) + assertNull(controls.effectiveEventsPerSecond) + assertEquals(FULL_THROTTLE_LABEL, controls.eventRateLabel) + } + + @Test + fun `demo controller rejects backward time jumps`() { + val controller = demoController() + + runSuspend { + controller.callbacks.onToTimeInputChanged("1.5") + controller.callbacks.onToTimeSubmit() + } + + val dialog = controller.store.state.controls.dialog + assertNotNull(dialog) + assertEquals("Invalid time", dialog.title) + } + + @Test + fun `demo controller updates fps on submit`() { + val controller = demoController() + + runSuspend { + controller.callbacks.onFpsInputChanged("120") + controller.callbacks.onFpsSubmit() + } + + assertEquals(DEFAULT_MAX_UI_FPS, controller.store.state.controls.uiFps) + assertEquals(DEFAULT_MAX_UI_FPS.toString(), controller.store.state.controls.fpsInput) + } } private fun runSuspend(block: suspend () -> Unit) { diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt index e2035fb19b..f11a344801 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt @@ -22,36 +22,62 @@ import it.unibo.alchemist.model.Actionable import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Position import it.unibo.alchemist.model.Time +import java.awt.GraphicsEnvironment import java.awt.Toolkit import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.TimeUnit +import kotlin.math.max +import kotlin.math.roundToLong /** * Monitor extension that uses JVM Compose UI to display the simulation. - * @param targetFps The target frames per second for the UI updates. Defaults to 30. + * @param targetFps The initial target frames per second for the UI updates. Defaults to 30. */ class ComposeMonitor> @JvmOverloads constructor(targetFps: Int = 30) : OutputMonitor { - private val throttleMs = 1000L / targetFps.coerceAtLeast(1) + private val maxUiFps = detectMonitorRefreshRate() ?: DEFAULT_MAX_UI_FPS + private val initialUiFps = targetFps.coerceIn(MIN_UI_FPS, maxUiFps) private val windowStarted = AtomicBoolean(false) - private val currentUiState by lazy { ComposeUiStateStore(AlchemistUiState()) } + private val currentUiState by lazy { + ComposeUiStateStore( + AlchemistUiState( + controls = SimulationControlsState( + uiFps = initialUiFps, + fpsInput = initialUiFps.toString(), + maxUiFps = maxUiFps, + ), + ), + ) + } override fun initialized(environment: Environment) { + nextEventReleaseNs = 0L ensureWindow(environment) currentUiState.update { - it.copy(controls = it.controls.copy(status = environment.simulation.toSimulationStatus())) + it.copy( + controls = it.controls.copy( + status = environment.simulation.toSimulationStatus(), + uiFps = it.controls.uiFps.coerceIn(MIN_UI_FPS, maxUiFps), + fpsInput = it.controls.uiFps.coerceIn(MIN_UI_FPS, maxUiFps).toString(), + maxUiFps = maxUiFps, + ), + ) } } private var lastUpdate: Long = 0L + private var nextEventReleaseNs: Long = 0L override fun stepDone(environment: Environment, reaction: Actionable?, time: Time, step: Long) { val now = System.currentTimeMillis() - if (now - lastUpdate >= throttleMs) { + if (now - lastUpdate >= uiThrottleMs()) { lastUpdate = now updateUiState(environment, time, step) } + throttleSimulation() } override fun finished(environment: Environment, time: Time, step: Long) { + nextEventReleaseNs = 0L updateUiState(environment, time, step) } @@ -70,6 +96,30 @@ class ComposeMonitor> @JvmOverloads constructor(targetFps: In } } + private fun uiThrottleMs(): Long = + (1000.0 / currentUiState.state.controls.uiFps.coerceIn(MIN_UI_FPS, maxUiFps)) + .roundToLong() + .coerceAtLeast(1L) + + private fun throttleSimulation() { + val controls = currentUiState.state.controls + val eventsPerSecond = controls.effectiveEventsPerSecond ?: run { + nextEventReleaseNs = 0L + return + } + val intervalNs = (1_000_000_000.0 / eventsPerSecond).roundToLong().coerceAtLeast(1L) + val now = System.nanoTime() + nextEventReleaseNs = max(now, nextEventReleaseNs) + intervalNs + val remainingNs = nextEventReleaseNs - System.nanoTime() + if (remainingNs > 0L) { + try { + TimeUnit.NANOSECONDS.sleep(remainingNs) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + } + private fun ensureWindow(environment: Environment) { if (windowStarted.compareAndSet(false, true)) { Thread { @@ -105,4 +155,11 @@ class ComposeMonitor> @JvmOverloads constructor(targetFps: In private fun Time.toComposeUiLabel(): String = toDouble().formatFixed(DISPLAYED_TIME_DECIMALS) -private const val DISPLAYED_TIME_DECIMALS = 2 +private fun detectMonitorRefreshRate(): Int? = runCatching { + GraphicsEnvironment + .getLocalGraphicsEnvironment() + .defaultScreenDevice + .displayMode + .refreshRate + .takeIf { it > 0 } +}.getOrNull() diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt index 62e60c392a..9a2097f18d 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt @@ -12,9 +12,11 @@ package it.unibo.alchemist.boundary.composeui import it.unibo.alchemist.boundary.composeui.adapter.toSimulationStatus import it.unibo.alchemist.core.Simulation import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.times.DoubleTime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.future.await import kotlinx.coroutines.withContext +import kotlin.math.roundToInt class DesktopAlchemistUiCallback>( private val simulation: Simulation, @@ -39,8 +41,82 @@ class DesktopAlchemistUiCallback>( val stepCompletion = simulation.goToStep(nextStep) simulation.play().await() stepCompletion.await() + syncSimulationState() + } + + override suspend fun onToTimeInputChanged(value: String) { updateState { - it.copy(controls = it.controls.copy(status = simulation.toSimulationStatus())) + it.copy(controls = it.controls.copy(toTimeInput = value)) + } + } + + override suspend fun onToTimeSubmit() { + val currentState = store.state.controls + val target = currentState.toTimeInput.toDoubleOrNull() + ?: return showDialog("Invalid time", "Insert a valid numeric time.") + val currentTime = simulation.time.toDouble() + if (target < currentTime) { + return showDialog( + title = "Invalid time", + message = "Target time $target cannot be lower than current time ${currentTime.formatFixed(DISPLAYED_TIME_DECIMALS)}.", + ) + } + if (target == currentTime) { + ensurePaused() + syncSimulationState() + return + } + val jumpCompletion = simulation.goToTime(DoubleTime(target)) + simulation.play().await() + jumpCompletion.await() + syncSimulationState() + } + + override suspend fun onToStepInputChanged(value: String) { + updateState { + it.copy(controls = it.controls.copy(toStepInput = value)) + } + } + + override suspend fun onToStepSubmit() { + val currentState = store.state.controls + val target = currentState.toStepInput.toLongOrNull() + ?: return showDialog("Invalid step", "Insert a valid integer step.") + if (target < simulation.step) { + return showDialog( + title = "Invalid step", + message = "Target step $target cannot be lower than current step ${simulation.step}.", + ) + } + if (target == simulation.step) { + ensurePaused() + syncSimulationState() + return + } + val jumpCompletion = simulation.goToStep(target) + simulation.play().await() + jumpCompletion.await() + syncSimulationState() + } + + override suspend fun onFpsInputChanged(value: String) { + updateState { + it.copy(controls = it.controls.copy(fpsInput = value)) + } + } + + override suspend fun onFpsSubmit() { + val currentState = store.state.controls + val target = currentState.fpsInput.toIntOrNull() + ?: return showDialog("Invalid FPS", "Insert a valid integer FPS value.") + updateState { + it.copy(controls = it.controls.withUiFps(target)) + } + } + + override suspend fun onEventRateChanged(value: Float) { + updateState { + it.copy(controls = it.controls.withEventRateSliderValue(value.roundToInt())) } } @@ -71,9 +147,42 @@ class DesktopAlchemistUiCallback>( } } + override suspend fun onDialogDismiss() { + updateState { + it.copy(controls = it.controls.copy(dialog = null)) + } + } + private suspend fun updateState(transform: (AlchemistUiState) -> AlchemistUiState) { withContext(Dispatchers.Main.immediate) { store.update(transform) } } + + private suspend fun syncSimulationState() { + updateState { + it.copy( + controls = it.controls.copy( + status = simulation.toSimulationStatus(), + timeLabel = simulation.time.toDouble().formatFixed(DISPLAYED_TIME_DECIMALS), + step = simulation.step, + dialog = null, + ), + ) + } + } + + private suspend fun ensurePaused() { + if (simulation.status == it.unibo.alchemist.core.Status.RUNNING) { + simulation.pause().await() + } + } + + private suspend fun showDialog(title: String, message: String) { + updateState { + it.copy( + controls = it.controls.copy(dialog = ControlDialogState(title, message)), + ) + } + } } diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt index c1a7e1cc29..95f0d582ce 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt @@ -43,9 +43,13 @@ fun > Simulation.toSimulationStatus(): SimulationStatus private fun > Environment.extractEdges(): List = buildList { nodes.forEach { node -> getNeighborhood(node).forEach { neighbor -> - if (node.id < neighbor.id) { - add(ViewportEdge(node.id, neighbor.id)) - } + canonicalEdge(node.id, neighbor.id)?.let(::add) } } +}.distinct() + +internal fun canonicalEdge(firstNodeId: Int, secondNodeId: Int): ViewportEdge? = when { + firstNodeId == secondNodeId -> null + firstNodeId < secondNodeId -> ViewportEdge(firstNodeId, secondNodeId) + else -> ViewportEdge(secondNodeId, firstNodeId) } From 5044305f2bd3943c021760700e6f30b6db6767a9 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Tue, 21 Apr 2026 17:48:08 +0200 Subject: [PATCH 13/22] refactor: improve ui --- alchemist-composeui/AI_CONTEXT.md | 5 +- .../alchemist/boundary/composeui/UiStore.kt | 16 +- .../composeui/SimulationControlsStateTest.kt | 16 ++ .../composeui/DesktopAlchemistUiCallback.kt | 37 ++-- .../DesktopAlchemistUiCallbackTest.kt | 200 ++++++++++++++++++ 5 files changed, 250 insertions(+), 24 deletions(-) create mode 100644 alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallbackTest.kt diff --git a/alchemist-composeui/AI_CONTEXT.md b/alchemist-composeui/AI_CONTEXT.md index 7426d106cc..ff8eef2b94 100644 --- a/alchemist-composeui/AI_CONTEXT.md +++ b/alchemist-composeui/AI_CONTEXT.md @@ -112,7 +112,8 @@ Defined in `adapter/AlchemistNodeAdapter.kt` and used by `ComposeMonitor.kt`: - Toggle links from the summary rail. - Use play/pause/step to control the simulation when the UI is attached to a live engine. - Press Enter in `To Time` / `To Step` to fast-forward to the requested target. -- `To Time` / `To Step` reject backward targets and leave the simulator paused at the requested target. +- `To Time` / `To Step` reject backward targets. +- If the simulation was already running, jump actions resume it immediately after the target is reached. - Use `FPS` to control UI refresh frequency, clamped between `5` and the detected monitor refresh rate, or `60` if detection is unavailable. - Use the event-rate slider to pace the simulation thread; the terminal `Max` value disables pacing and runs full throttle. @@ -159,6 +160,7 @@ Defined in `adapter/AlchemistNodeAdapter.kt` and used by `ComposeMonitor.kt`: - Synchronizes store updates on `Dispatchers.Main.immediate`. - Uses the simulation object as the source of truth for play/pause/step/jump state. - Validates `To Time`, `To Step`, and `FPS` submissions before mutating simulator state. +- Restores running state after a successful jump when the simulation was already running. ### `ViewportSurface` @@ -171,6 +173,7 @@ Defined in `adapter/AlchemistNodeAdapter.kt` and used by `ComposeMonitor.kt`: - Builds a sample scene with nodes, edges, summary data, and a mock progress state. - Supports play/pause/step and link toggling without a live simulation. +- Mirrors the running-state jump behavior used on JVM. ## Requirements for future changes diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt index f211b2a224..0b2decb255 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt @@ -88,6 +88,7 @@ fun demoController(): ComposeUiController { val target = state.controls.toTimeInput.toDoubleOrNull() ?: return@update state.withDialog("Invalid time", "Insert a valid numeric time.") val currentTime = state.controls.timeLabel.toDoubleOrNull() ?: 0.0 + val wasRunning = state.controls.status == SimulationStatus.RUNNING if (target < currentTime) { return@update state.withDialog( title = "Invalid time", @@ -95,7 +96,11 @@ fun demoController(): ComposeUiController { ) } val targetStep = ceil(target * DEMO_TIME_SCALE).toLong() - state.withStepProgress(targetStep, timeLabel = target.formatFixed(DISPLAYED_TIME_DECIMALS)) + state.withStepProgress( + targetStep, + timeLabel = target.formatFixed(DISPLAYED_TIME_DECIMALS), + status = if (wasRunning) SimulationStatus.RUNNING else SimulationStatus.PAUSED, + ) } } @@ -109,13 +114,17 @@ fun demoController(): ComposeUiController { store.update { state -> val target = state.controls.toStepInput.toLongOrNull() ?: return@update state.withDialog("Invalid step", "Insert a valid integer step.") + val wasRunning = state.controls.status == SimulationStatus.RUNNING if (target < state.controls.step) { return@update state.withDialog( title = "Invalid step", message = "Target step $target cannot be lower than current step ${state.controls.step}.", ) } - state.withStepProgress(target) + state.withStepProgress( + target, + status = if (wasRunning) SimulationStatus.RUNNING else SimulationStatus.PAUSED, + ) } } @@ -285,9 +294,10 @@ internal fun AlchemistUiState.withDialog(title: String, message: String): Alchem internal fun AlchemistUiState.withStepProgress( step: Long, timeLabel: String = formatDemoTime(step), + status: SimulationStatus = SimulationStatus.PAUSED, ): AlchemistUiState = copy( controls = controls.copy( - status = SimulationStatus.PAUSED, + status = status, step = step, timeLabel = timeLabel, progress = SimulationProgress( diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt index f638d7dce2..f86b9629b8 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt @@ -113,6 +113,22 @@ class SimulationControlsStateTest { assertEquals(DEFAULT_MAX_UI_FPS, controller.store.state.controls.uiFps) assertEquals(DEFAULT_MAX_UI_FPS.toString(), controller.store.state.controls.fpsInput) } + + @Test + fun `demo controller preserves running state after jump`() { + val controller = demoController() + controller.store.update { + it.copy(controls = it.controls.copy(status = SimulationStatus.RUNNING)) + } + + runSuspend { + controller.callbacks.onToStepInputChanged("99") + controller.callbacks.onToStepSubmit() + } + + assertEquals(SimulationStatus.RUNNING, controller.store.state.controls.status) + assertEquals(99L, controller.store.state.controls.step) + } } private fun runSuspend(block: suspend () -> Unit) { diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt index 9a2097f18d..f2e797325b 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt @@ -11,6 +11,7 @@ package it.unibo.alchemist.boundary.composeui import it.unibo.alchemist.boundary.composeui.adapter.toSimulationStatus import it.unibo.alchemist.core.Simulation +import it.unibo.alchemist.core.Status import it.unibo.alchemist.model.Position import it.unibo.alchemist.model.times.DoubleTime import kotlinx.coroutines.Dispatchers @@ -55,20 +56,21 @@ class DesktopAlchemistUiCallback>( val target = currentState.toTimeInput.toDoubleOrNull() ?: return showDialog("Invalid time", "Insert a valid numeric time.") val currentTime = simulation.time.toDouble() + val wasRunning = simulation.status == Status.RUNNING if (target < currentTime) { return showDialog( title = "Invalid time", message = "Target time $target cannot be lower than current time ${currentTime.formatFixed(DISPLAYED_TIME_DECIMALS)}.", ) } - if (target == currentTime) { - ensurePaused() - syncSimulationState() - return + if (target > currentTime) { + val jumpCompletion = simulation.goToTime(DoubleTime(target)) + simulation.play().await() + jumpCompletion.await() + if (wasRunning) { + simulation.play().await() + } } - val jumpCompletion = simulation.goToTime(DoubleTime(target)) - simulation.play().await() - jumpCompletion.await() syncSimulationState() } @@ -82,20 +84,21 @@ class DesktopAlchemistUiCallback>( val currentState = store.state.controls val target = currentState.toStepInput.toLongOrNull() ?: return showDialog("Invalid step", "Insert a valid integer step.") + val wasRunning = simulation.status == Status.RUNNING if (target < simulation.step) { return showDialog( title = "Invalid step", message = "Target step $target cannot be lower than current step ${simulation.step}.", ) } - if (target == simulation.step) { - ensurePaused() - syncSimulationState() - return + if (target > simulation.step) { + val jumpCompletion = simulation.goToStep(target) + simulation.play().await() + jumpCompletion.await() + if (wasRunning) { + simulation.play().await() + } } - val jumpCompletion = simulation.goToStep(target) - simulation.play().await() - jumpCompletion.await() syncSimulationState() } @@ -172,12 +175,6 @@ class DesktopAlchemistUiCallback>( } } - private suspend fun ensurePaused() { - if (simulation.status == it.unibo.alchemist.core.Status.RUNNING) { - simulation.pause().await() - } - } - private suspend fun showDialog(title: String, message: String) { updateState { it.copy( diff --git a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallbackTest.kt b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallbackTest.kt new file mode 100644 index 0000000000..6acdcb7e90 --- /dev/null +++ b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallbackTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import it.unibo.alchemist.core.Simulation +import it.unibo.alchemist.core.Status +import it.unibo.alchemist.model.Actionable +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Neighborhood +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.Reaction +import it.unibo.alchemist.model.Time +import it.unibo.alchemist.model.times.DoubleTime +import java.util.Optional +import java.util.concurrent.CompletableFuture +import kotlin.coroutines.Continuation +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.jooq.lambda.fi.lang.CheckedRunnable + +class DesktopAlchemistUiCallbackTest { + @Test + fun `to step resumes if the simulation was running`() { + val simulation = RecordingSimulation( + Status.RUNNING, + DoubleTime(1.0), + 1L, + ) + val store = ComposeUiStateStore( + AlchemistUiState( + controls = SimulationControlsState(status = SimulationStatus.RUNNING), + ), + ) + val callback = DesktopAlchemistUiCallback(simulation, store) + + store.update { it.copy(controls = it.controls.copy(toStepInput = "5")) } + runSuspend { + callback.onToStepSubmit() + } + + assertEquals(2, simulation.playCalls) + assertEquals(1, simulation.goToStepCalls) + assertEquals(Status.RUNNING, simulation.statusValue) + assertEquals(5L, simulation.stepValue) + assertTrue(store.state.controls.status == SimulationStatus.RUNNING) + assertEquals(5L, store.state.controls.step) + } + + @Test + fun `to time resumes if the simulation was running`() { + val simulation = RecordingSimulation( + Status.RUNNING, + DoubleTime(1.0), + 1L, + ) + val store = ComposeUiStateStore( + AlchemistUiState( + controls = SimulationControlsState(status = SimulationStatus.RUNNING), + ), + ) + val callback = DesktopAlchemistUiCallback(simulation, store) + + store.update { it.copy(controls = it.controls.copy(toTimeInput = "4.5")) } + runSuspend { + callback.onToTimeSubmit() + } + + assertEquals(2, simulation.playCalls) + assertEquals(1, simulation.goToTimeCalls) + assertEquals(Status.RUNNING, simulation.statusValue) + assertEquals(DoubleTime(4.5), simulation.timeValue) + assertTrue(store.state.controls.status == SimulationStatus.RUNNING) + assertEquals("4.50", store.state.controls.timeLabel) + } +} + +private class RecordingSimulation( + initialStatus: Status, + initialTime: Time, + initialStep: Long, +) : Simulation { + var statusValue: Status = initialStatus + var timeValue: Time = initialTime + var stepValue: Long = initialStep + var playCalls: Int = 0 + var pauseCalls: Int = 0 + var goToTimeCalls: Int = 0 + var goToStepCalls: Int = 0 + + override fun addOutputMonitor(op: it.unibo.alchemist.boundary.OutputMonitor) = Unit + + override fun getEnvironment(): Environment = error("not used") + + override fun getError(): Optional = Optional.empty() + + override fun getStatus(): Status = statusValue + + override fun getStep(): Long = stepValue + + override fun getTime(): Time = timeValue + + override fun goToStep(step: Long): CompletableFuture { + goToStepCalls++ + stepValue = step + statusValue = Status.PAUSED + return completed() + } + + override fun goToTime(t: Time): CompletableFuture { + goToTimeCalls++ + timeValue = t + statusValue = Status.PAUSED + return completed() + } + + override fun neighborAdded(node: Node, n: Node) = Unit + + override fun neighborRemoved(node: Node, n: Node) = Unit + + override fun nodeAdded(node: Node) = Unit + + override fun nodeMoved(node: Node) = Unit + + override fun nodeRemoved(node: Node, oldNeighborhood: Neighborhood) = Unit + + override fun pause(): CompletableFuture { + pauseCalls++ + statusValue = Status.PAUSED + return completed() + } + + override fun play(): CompletableFuture { + playCalls++ + statusValue = Status.RUNNING + return completed() + } + + override fun reactionAdded(reactionToAdd: Actionable) = Unit + + override fun reactionRemoved(reactionToRemove: Actionable) = Unit + + override fun removeOutputMonitor(op: it.unibo.alchemist.boundary.OutputMonitor) = Unit + + override fun schedule(r: CheckedRunnable) = error("not used") + + override fun terminate(): CompletableFuture { + statusValue = Status.TERMINATED + return completed() + } + + override fun waitFor(s: Status, timeout: Long, timeunit: java.util.concurrent.TimeUnit): Status { + statusValue = s + return statusValue + } + + override fun getOutputMonitors(): List> = emptyList() + + override fun run() = Unit + + private fun completed(): CompletableFuture = CompletableFuture.completedFuture(Unit) +} + +private data class StubPosition(private val coordinate: Double = 0.0) : Position { + override fun boundingBox(range: Double): List = listOf(this) + + override val coordinates: DoubleArray = doubleArrayOf(coordinate) + + override fun getCoordinate(dimension: Int): Double = coordinate + + override val dimensions: Int = 1 + + override fun distanceTo(other: StubPosition): Double = kotlin.math.abs(coordinate - other.coordinate) + + override operator fun plus(other: DoubleArray): StubPosition = copy(coordinate = coordinate + (other.firstOrNull() ?: 0.0)) + + override operator fun minus(other: DoubleArray): StubPosition = copy(coordinate = coordinate - (other.firstOrNull() ?: 0.0)) +} + +private fun runSuspend(block: suspend () -> Unit) { + block.startCoroutine( + object : Continuation { + override val context = EmptyCoroutineContext + + override fun resumeWith(result: Result) { + result.getOrThrow() + } + }, + ) +} From e8acc1b00d3c667a76d0e0f76454132d2541e813 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Wed, 22 Apr 2026 15:48:44 +0200 Subject: [PATCH 14/22] feat: implement right-click pan and fixed problem with opening right panel animation --- .../boundary/composeui/AlchemistUiRoot.kt | 55 +++++++++++++++---- .../boundary/composeui/ViewportSurface.kt | 18 +++--- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt index 473ce3420b..ba38a1a64b 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt @@ -10,6 +10,10 @@ package it.unibo.alchemist.boundary.composeui import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateDp import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally @@ -148,6 +152,24 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { val inspectorWidth = 324.dp val bottomBarHeight = 152.dp val layoutSpacing = 20.dp + val inspectorTransition = updateTransition(targetState = inspectorVisible, label = "inspector") + val animatedInspectorWidth by inspectorTransition.animateDp( + transitionSpec = { tween(durationMillis = 320) }, + label = "inspector-width", + ) { visible -> + if (visible) { + inspectorWidth + } else { + 0.dp + } + } + var displayedInspector by remember { mutableStateOf(state.inspector) } + if (state.inspector != null) { + displayedInspector = state.inspector + } + if (!inspectorTransition.currentState && !inspectorTransition.targetState && displayedInspector != null) { + displayedInspector = null + } if (compactLayout) { Box( modifier = Modifier @@ -200,22 +222,33 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { spacing = layoutSpacing, modifier = Modifier .weight(1f) + .animateContentSize(animationSpec = tween(durationMillis = 320)) .fillMaxHeight(), ) - AnimatedVisibility( - visible = inspectorVisible, - enter = slideInHorizontally(initialOffsetX = { it / 2 }) + fadeIn(), - exit = slideOutHorizontally(targetOffsetX = { it / 2 }) + fadeOut(), + Box( modifier = Modifier .fillMaxHeight() - .width(inspectorWidth), + .width(animatedInspectorWidth), ) { - state.inspector?.let { - NodeInspector( - inspector = it, - onDismiss = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, - modifier = Modifier.fillMaxHeight(), - ) + androidx.compose.animation.AnimatedVisibility( + visible = inspectorVisible, + enter = slideInHorizontally( + animationSpec = tween(durationMillis = 320), + initialOffsetX = { it / 3 }, + ) + fadeIn(animationSpec = tween(durationMillis = 220)), + exit = slideOutHorizontally( + animationSpec = tween(durationMillis = 320), + targetOffsetX = { it / 3 }, + ) + fadeOut(animationSpec = tween(durationMillis = 180)), + modifier = Modifier.fillMaxHeight(), + ) { + displayedInspector?.let { + NodeInspector( + inspector = it, + onDismiss = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, + modifier = Modifier.fillMaxHeight(), + ) + } } } } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt index 67f4b45c7f..d2391e66ec 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned @@ -63,7 +63,7 @@ internal fun ViewportSurface( var viewportSize by remember { mutableStateOf(IntSize.Zero) } var camera by remember { mutableStateOf(ViewportCameraState()) } var fixedProjection by remember { mutableStateOf(null) } - var middleDragAnchor by remember { mutableStateOf(null) } + var rightDragAnchor by remember { mutableStateOf(null) } val candidateProjection = remember(scene.nodes, viewportSize) { scene.createViewportProjection(viewportSize) } val projection = fixedProjection ?: candidateProjection LaunchedEffect(candidateProjection) { @@ -128,7 +128,7 @@ internal fun ViewportSurface( } .onPointerEvent(PointerEventType.Press) { event -> val change = event.changes.firstOrNull() ?: return@onPointerEvent - middleDragAnchor = if (event.buttons.isTertiaryPressed) { + rightDragAnchor = if (event.buttons.isSecondaryPressed) { change.position } else { null @@ -136,19 +136,19 @@ internal fun ViewportSurface( } .onPointerEvent(PointerEventType.Move) { event -> val change = event.changes.firstOrNull() ?: return@onPointerEvent - if (event.buttons.isTertiaryPressed) { - val previous = middleDragAnchor ?: change.position + if (event.buttons.isSecondaryPressed) { + val previous = rightDragAnchor ?: change.position val delta = change.position - previous if (delta != Offset.Zero) { camera = camera.panBy(delta) } - middleDragAnchor = change.position + rightDragAnchor = change.position } else { - middleDragAnchor = null + rightDragAnchor = null } } .onPointerEvent(PointerEventType.Release) { - middleDragAnchor = null + rightDragAnchor = null } .onPointerEvent(PointerEventType.Scroll) { event -> val pointerChange = event.changes.firstOrNull() ?: return@onPointerEvent @@ -252,7 +252,7 @@ internal fun ViewportSurface( text = if (scene.nodes.isEmpty()) { "No nodes to display" } else { - "Click to inspect · middle-drag to pan · wheel to zoom" + "Click to inspect · right-drag to pan · wheel to zoom" }, modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), style = MaterialTheme.typography.caption, From d0e2511fbe8fec1c13ab9fb96a441a85f912cf91 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Wed, 22 Apr 2026 16:07:16 +0200 Subject: [PATCH 15/22] feat: implement group selection --- .../boundary/composeui/AlchemistUiRoot.kt | 4 +- .../boundary/composeui/NodeInspector.kt | 178 +++++++++--------- .../composeui/SimulationPrimaryPane.kt | 4 +- .../alchemist/boundary/composeui/UiModel.kt | 30 ++- .../alchemist/boundary/composeui/UiStore.kt | 64 ++++++- .../boundary/composeui/ViewportSurface.kt | 121 ++++++++---- .../composeui/GroupInspectorStateTest.kt | 75 ++++++++ .../composeui/SimulationControlsStateTest.kt | 18 +- .../boundary/composeui/ComposeMonitor.kt | 2 +- .../composeui/DesktopAlchemistUiCallback.kt | 17 +- 10 files changed, 361 insertions(+), 152 deletions(-) create mode 100644 alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt index ba38a1a64b..b11cb4a6ec 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt @@ -179,7 +179,7 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { SimulationPrimaryPane( scene = state.scene, controls = state.controls, - selectedNodeId = state.selectedNodeId, + selectedNodeIds = state.selectedNodeIds, callbacks = callbacks, dockWidthFraction = 1f, spacing = layoutSpacing, @@ -216,7 +216,7 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { SimulationPrimaryPane( scene = state.scene, controls = state.controls, - selectedNodeId = state.selectedNodeId, + selectedNodeIds = state.selectedNodeIds, callbacks = callbacks, dockWidthFraction = 0.84f, spacing = layoutSpacing, diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt index f43c0fd508..9bb475da45 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt @@ -9,81 +9,29 @@ package it.unibo.alchemist.boundary.composeui -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.launch @Composable -internal fun NodeInspector(inspector: NodeInspectorState, onDismiss: () -> Unit, modifier: Modifier = Modifier) { +internal fun NodeInspector(inspector: InspectorState, onDismiss: () -> Unit, modifier: Modifier = Modifier) { Surface( modifier = modifier, color = SurfaceStrong, contentColor = TextPrimary, - shape = RoundedCornerShape(12.dp), + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), elevation = 0.dp, ) { Column( @@ -93,48 +41,92 @@ internal fun NodeInspector(inspector: NodeInspectorState, onDismiss: () -> Unit, .padding(22.dp), verticalArrangement = Arrangement.spacedBy(18.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = inspector.title, - style = MaterialTheme.typography.h6, - ) - Text( - text = inspector.subtitle, - style = MaterialTheme.typography.body2, - color = TextSecondary, - ) - } - TransportButton( - label = "Close", - enabled = true, - accent = Outline, - onClick = onDismiss, - ) - } - InspectorSection( - title = "Position", - description = "Coordinates in simulator space.", - fields = inspector.position, - ) - InspectorSection( - title = "Concentrations", - description = "Live contents currently stored in the selected node.", - fields = inspector.concentrations.ifEmpty { - listOf(InfoField("No molecules", "This node exposes no concentrations")) + InspectorHeader( + title = when (inspector) { + is GroupInspectorState -> inspector.title + is NodeInspectorState -> inspector.title }, - ) - InspectorSection( - title = "Metadata", - description = "Simulator-provided details exposed by the current adapter.", - fields = inspector.metadata.ifEmpty { - listOf(InfoField("Unavailable", "No extra metadata available")) + subtitle = when (inspector) { + is GroupInspectorState -> inspector.subtitle + is NodeInspectorState -> inspector.subtitle }, + onDismiss = onDismiss, + ) + when (inspector) { + is NodeInspectorState -> SingleNodeInspector(inspector) + is GroupInspectorState -> GroupNodeInspector(inspector) + } + } + } +} + +@Composable +private fun InspectorHeader(title: String, subtitle: String, onDismiss: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = title, + style = MaterialTheme.typography.h6, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.body2, + color = TextSecondary, ) } + TransportButton( + label = "Close", + enabled = true, + accent = Outline, + onClick = onDismiss, + ) } } + +@Composable +private fun SingleNodeInspector(inspector: NodeInspectorState) { + InspectorSection( + title = "Position", + description = "Coordinates in simulator space.", + fields = inspector.position, + ) + InspectorSection( + title = "Concentrations", + description = "Live contents currently stored in the selected node.", + fields = inspector.concentrations.ifEmpty { + listOf(InfoField("No molecules", "This node exposes no concentrations")) + }, + ) + InspectorSection( + title = "Metadata", + description = "Simulator-provided details exposed by the current adapter.", + fields = inspector.metadata.ifEmpty { + listOf(InfoField("Unavailable", "No extra metadata available")) + }, + ) +} + +@Composable +private fun GroupNodeInspector(inspector: GroupInspectorState) { + InspectorSection( + title = "Position", + description = "Group bounds in simulator space.", + fields = inspector.position, + ) + InspectorSection( + title = "Selected Nodes", + description = "IDs currently captured by the selection box.", + fields = listOf(InfoField("IDs", inspector.nodeIds.joinToString(", "))), + ) + InspectorSection( + title = "Concentrations", + description = "Shared molecule values across the selected nodes. Mixed means values differ or are missing.", + fields = inspector.concentrations.ifEmpty { + listOf(InfoField("No molecules", "The selected nodes expose no concentrations")) + }, + ) +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt index 4112508089..47f851c79d 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt @@ -81,7 +81,7 @@ import kotlinx.coroutines.launch internal fun SimulationPrimaryPane( scene: ViewportScene, controls: SimulationControlsState, - selectedNodeId: Int?, + selectedNodeIds: List, callbacks: AlchemistUiCallbacks, dockWidthFraction: Float, spacing: androidx.compose.ui.unit.Dp, @@ -93,7 +93,7 @@ internal fun SimulationPrimaryPane( ) { ViewportSurface( scene = scene, - selectedNodeId = selectedNodeId, + selectedNodeIds = selectedNodeIds, callbacks = callbacks, modifier = Modifier .fillMaxWidth() diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt index fe73e2ea9d..1bf098e414 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt @@ -145,7 +145,13 @@ data class SimulationControlsState( } /** - * State for the node inspector panel. + * State for the right-side inspector panel. + */ +@Immutable +sealed interface InspectorState + +/** + * Inspector state for a single node. */ @Immutable data class NodeInspectorState( @@ -155,7 +161,19 @@ data class NodeInspectorState( val position: List, val concentrations: List, val metadata: List, -) +) : InspectorState + +/** + * Inspector state for a group of selected nodes. + */ +@Immutable +data class GroupInspectorState( + val nodeIds: List, + val title: String = "Selected Nodes", + val subtitle: String = "${nodeIds.size} nodes selected", + val position: List, + val concentrations: List, +) : InspectorState /** * Top-level state consumed by the Compose UI shell. @@ -164,8 +182,8 @@ data class NodeInspectorState( data class AlchemistUiState( val scene: ViewportScene = ViewportScene(), val controls: SimulationControlsState = SimulationControlsState(), - val selectedNodeId: Int? = null, - val inspector: NodeInspectorState? = null, + val selectedNodeIds: List = emptyList(), + val inspector: InspectorState? = null, ) /** @@ -194,6 +212,8 @@ interface AlchemistUiCallbacks { suspend fun onNodeSelected(nodeId: Int) + suspend fun onNodesSelected(nodeIds: List) + suspend fun onInspectorDismiss() suspend fun onToggleLinks() @@ -227,6 +247,8 @@ object NoOpUiCallbacks : AlchemistUiCallbacks { override suspend fun onNodeSelected(nodeId: Int) = Unit + override suspend fun onNodesSelected(nodeIds: List) = Unit + override suspend fun onInspectorDismiss() = Unit override suspend fun onToggleLinks() = Unit diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt index 0b2decb255..5cf1d5211f 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt @@ -149,18 +149,20 @@ fun demoController(): ComposeUiController { } override suspend fun onNodeSelected(nodeId: Int) { - val node = store.state.scene.nodes.firstOrNull { it.id == nodeId } ?: return store.update { - it.copy( - selectedNodeId = nodeId, - inspector = node.toInspectorState(), - ) + it.withSelection(listOf(nodeId)) + } + } + + override suspend fun onNodesSelected(nodeIds: List) { + store.update { + it.withSelection(nodeIds) } } override suspend fun onInspectorDismiss() { store.update { - it.copy(selectedNodeId = null, inspector = null) + it.withSelection(emptyList()) } } @@ -322,6 +324,32 @@ internal fun SimulationControlsState.withEventRateSliderValue(target: Int): Simu dialog = null, ) +internal fun AlchemistUiState.withSelection(nodeIds: List): AlchemistUiState { + val selectedIds = scene.sanitizeSelection(nodeIds) + return copy( + selectedNodeIds = selectedIds, + inspector = scene.toInspectorState(selectedIds), + ) +} + +internal fun ViewportScene.sanitizeSelection(nodeIds: List): List { + val availableNodeIds = nodes.mapTo(linkedSetOf()) { it.id } + return nodeIds.distinct().filter(availableNodeIds::contains) +} + +internal fun ViewportScene.toInspectorState(selectedNodeIds: List): InspectorState? { + if (selectedNodeIds.isEmpty()) { + return null + } + val selectedNodesById = nodes.associateBy { it.id } + val selectedNodes = selectedNodeIds.mapNotNull(selectedNodesById::get) + return when (selectedNodes.size) { + 0 -> null + 1 -> selectedNodes.single().toInspectorState() + else -> selectedNodes.toGroupInspectorState() + } +} + internal fun ViewportNode.toInspectorState(): NodeInspectorState = NodeInspectorState( nodeId = id, subtitle = "Live node snapshot", @@ -332,6 +360,29 @@ internal fun ViewportNode.toInspectorState(): NodeInspectorState = NodeInspector metadata = metadata, ) +internal fun List.toGroupInspectorState(): GroupInspectorState { + val xs = map { it.coordinates[0] } + val ys = map { it.coordinates[1] } + val moleculeNames = flatMap { node -> node.concentrations.map(InfoField::label) }.distinct().sorted() + val concentrations = moleculeNames.map { molecule -> + val values = map { node -> node.concentrations.firstOrNull { it.label == molecule }?.value } + val sharedValue = values.firstOrNull()?.takeIf { firstValue -> + values.all { it == firstValue } + } + InfoField(molecule, sharedValue ?: MIXED_CONCENTRATION_PLACEHOLDER) + } + return GroupInspectorState( + nodeIds = map(ViewportNode::id), + position = listOf( + InfoField("Min X", xs.minOrNull()?.formatFixed(3).orEmpty()), + InfoField("Max X", xs.maxOrNull()?.formatFixed(3).orEmpty()), + InfoField("Min Y", ys.minOrNull()?.formatFixed(3).orEmpty()), + InfoField("Max Y", ys.maxOrNull()?.formatFixed(3).orEmpty()), + ), + concentrations = concentrations, + ) +} + internal fun Double.formatFixed(decimals: Int): String { val safeDecimals = decimals.coerceAtLeast(0) val factor = (1..safeDecimals).fold(1.0) { acc, _ -> acc * 10.0 } @@ -350,3 +401,4 @@ internal fun Double.formatFixed(decimals: Int): String { private fun formatDemoTime(step: Long): String = (step / 10.0).formatFixed(2) private const val DEMO_TIME_SCALE = 10.0 +internal const val MIXED_CONCENTRATION_PLACEHOLDER = "Mixed" diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt index d2391e66ec..27892e1526 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.StrokeCap @@ -43,7 +44,6 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntSize @@ -56,7 +56,7 @@ import kotlinx.coroutines.launch @Composable internal fun ViewportSurface( scene: ViewportScene, - selectedNodeId: Int?, + selectedNodeIds: List, callbacks: AlchemistUiCallbacks, modifier: Modifier = Modifier, ) { @@ -64,6 +64,8 @@ internal fun ViewportSurface( var camera by remember { mutableStateOf(ViewportCameraState()) } var fixedProjection by remember { mutableStateOf(null) } var rightDragAnchor by remember { mutableStateOf(null) } + var primaryDragAnchor by remember { mutableStateOf(null) } + var primaryDragCurrent by remember { mutableStateOf(null) } val candidateProjection = remember(scene.nodes, viewportSize) { scene.createViewportProjection(viewportSize) } val projection = fixedProjection ?: candidateProjection LaunchedEffect(candidateProjection) { @@ -74,6 +76,11 @@ internal fun ViewportSurface( val baseNodes = remember(scene.nodes, viewportSize, projection) { renderNodes(scene, viewportSize, projection) } val density = androidx.compose.ui.platform.LocalDensity.current val tapThresholdPx = with(density) { NodeHitRadius.dp.toPx() } + val dragThresholdPx = with(density) { 6.dp.toPx() } + val selectionRect = primaryDragAnchor?.let { anchor -> + val current = primaryDragCurrent ?: anchor + createSelectionRect(anchor, current).takeIf { anchor.distanceTo(current) >= dragThresholdPx } + } Surface( modifier = modifier, color = Surface, @@ -101,37 +108,16 @@ internal fun ViewportSurface( modifier = Modifier .fillMaxSize() .onGloballyPositioned { coordinates -> viewportSize = coordinates.size } - .pointerInput(baseNodes, selectedNodeId) { - detectTapGestures { tapOffset -> - // We need to map base nodes to screen space to check hits accurately against radius - // Alternatively we can map the tap back to base node space - // (which is screen space with camera pan=0, zoom=1) - val tapInBaseSpace = tapOffset.toWorldPosition( - viewportSize, - camera, - ).toScreenPosition(viewportSize, ViewportCameraState()) - // Wait, baseNodes are already in the "camera at 0, zoom 1" screen space. - // So if we take the tapOffset, we just need to convert it - // to that same space to measure distance! - val hit = baseNodes - .minByOrNull { node -> node.center.distanceTo(tapInBaseSpace) } - ?.takeIf { node -> - node.center.distanceTo(tapInBaseSpace) <= - tapThresholdPx / camera.zoom - } - if (hit != null) { - coroutineScope.launch { callbacks.onNodeSelected(hit.node.id) } - } else { - coroutineScope.launch { callbacks.onInspectorDismiss() } - } - } - } .onPointerEvent(PointerEventType.Press) { event -> val change = event.changes.firstOrNull() ?: return@onPointerEvent - rightDragAnchor = if (event.buttons.isSecondaryPressed) { - change.position + if (event.buttons.isSecondaryPressed) { + rightDragAnchor = change.position + primaryDragAnchor = null + primaryDragCurrent = null } else { - null + rightDragAnchor = null + primaryDragAnchor = change.position + primaryDragCurrent = change.position } } .onPointerEvent(PointerEventType.Move) { event -> @@ -145,9 +131,43 @@ internal fun ViewportSurface( rightDragAnchor = change.position } else { rightDragAnchor = null + if (primaryDragAnchor != null) { + primaryDragCurrent = change.position + } } } - .onPointerEvent(PointerEventType.Release) { + .onPointerEvent(PointerEventType.Release) { event -> + val releasePosition = event.changes.firstOrNull()?.position + val anchor = primaryDragAnchor + val current = primaryDragCurrent ?: releasePosition + if (anchor != null && current != null) { + if (anchor.distanceTo(current) >= dragThresholdPx) { + val mappedNodes = baseNodes.map { node -> + node.copy(center = node.center.toScreenPosition(viewportSize, camera)) + } + val selectedIds = mappedNodes + .filter { selectionNode -> createSelectionRect(anchor, current).contains(selectionNode.center) } + .map { selectionNode -> selectionNode.node.id } + coroutineScope.launch { callbacks.onNodesSelected(selectedIds) } + } else { + val hit = findHitNode( + baseNodes = baseNodes, + viewportSize = viewportSize, + camera = camera, + tapOffset = current, + tapThresholdPx = tapThresholdPx, + ) + coroutineScope.launch { + if (hit != null) { + callbacks.onNodeSelected(hit.node.id) + } else { + callbacks.onInspectorDismiss() + } + } + } + } + primaryDragAnchor = null + primaryDragCurrent = null rightDragAnchor = null } .onPointerEvent(PointerEventType.Scroll) { event -> @@ -188,7 +208,7 @@ internal fun ViewportSurface( } } mappedNodes.forEach { rendered -> - val isSelected = rendered.node.id == selectedNodeId + val isSelected = rendered.node.id in selectedNodeIds val nodeColor = lerp(SecondaryAccent, PrimaryAccent, rendered.node.accent) val screenRadius = NodeRadius.dp.toPx() * currentCamera.zoom val screenSelectedRadius = SelectedNodeRadius.dp.toPx() * currentCamera.zoom @@ -217,6 +237,19 @@ internal fun ViewportSurface( center = rendered.center, ) } + selectionRect?.let { selection -> + drawRect( + color = PrimaryAccent.copy(alpha = 0.14f), + topLeft = selection.topLeft, + size = selection.size, + ) + drawRect( + color = PrimaryAccent.copy(alpha = 0.75f), + topLeft = selection.topLeft, + size = selection.size, + style = Stroke(width = 1.5.dp.toPx()), + ) + } } Column( modifier = Modifier @@ -252,7 +285,7 @@ internal fun ViewportSurface( text = if (scene.nodes.isEmpty()) { "No nodes to display" } else { - "Click to inspect · right-drag to pan · wheel to zoom" + "Click to inspect · drag to select · right-drag to pan · wheel to zoom" }, modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), style = MaterialTheme.typography.caption, @@ -403,3 +436,25 @@ internal fun DrawScope.drawGrid(canvasSize: Size, camera: ViewportCameraState) { } private data class GridLegendData(val worldX: Double, val worldY: Double, val stepX: Float, val stepY: Float) + +private fun createSelectionRect(anchor: Offset, current: Offset): Rect = Rect( + left = min(anchor.x, current.x), + top = min(anchor.y, current.y), + right = max(anchor.x, current.x), + bottom = max(anchor.y, current.y), +) + +private fun findHitNode( + baseNodes: List, + viewportSize: IntSize, + camera: ViewportCameraState, + tapOffset: Offset, + tapThresholdPx: Float, +): RenderedNode? { + val tapInBaseSpace = tapOffset + .toWorldPosition(viewportSize, camera) + .toScreenPosition(viewportSize, ViewportCameraState()) + return baseNodes + .minByOrNull { node -> node.center.distanceTo(tapInBaseSpace) } + ?.takeIf { node -> node.center.distanceTo(tapInBaseSpace) <= tapThresholdPx / camera.zoom } +} diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt new file mode 100644 index 0000000000..a04f053409 --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class GroupInspectorStateTest { + @Test + fun `group inspector aggregates bounds and concentrations`() { + val scene = ViewportScene( + nodes = listOf( + ViewportNode( + id = 10, + coordinates = listOf(-2.0, 5.0), + concentrations = listOf( + InfoField("shared", "1"), + InfoField("variant", "A"), + ), + ), + ViewportNode( + id = 20, + coordinates = listOf(4.0, -1.0), + concentrations = listOf( + InfoField("shared", "1"), + InfoField("variant", "B"), + InfoField("partial", "yes"), + ), + ), + ), + ) + + val inspector = assertIs(scene.toInspectorState(listOf(10, 20))) + + assertEquals(listOf(10, 20), inspector.nodeIds) + assertEquals("-2.000", inspector.position.first { it.label == "Min X" }.value) + assertEquals("4.000", inspector.position.first { it.label == "Max X" }.value) + assertEquals("-1.000", inspector.position.first { it.label == "Min Y" }.value) + assertEquals("5.000", inspector.position.first { it.label == "Max Y" }.value) + assertEquals("1", inspector.concentrations.first { it.label == "shared" }.value) + assertEquals(MIXED_CONCENTRATION_PLACEHOLDER, inspector.concentrations.first { it.label == "variant" }.value) + assertEquals(MIXED_CONCENTRATION_PLACEHOLDER, inspector.concentrations.first { it.label == "partial" }.value) + } + + @Test + fun `selection is cleared when all selected nodes disappear from the scene`() { + val state = AlchemistUiState( + scene = ViewportScene( + nodes = listOf(ViewportNode(id = 1, coordinates = listOf(0.0, 0.0))), + ), + selectedNodeIds = listOf(1), + inspector = NodeInspectorState( + nodeId = 1, + subtitle = "Live node snapshot", + position = emptyList(), + concentrations = emptyList(), + metadata = emptyList(), + ), + ) + + val refreshed = state.copy(scene = ViewportScene()).withSelection(state.selectedNodeIds) + + assertEquals(emptyList(), refreshed.selectedNodeIds) + assertNull(refreshed.inspector) + } +} diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt index f86b9629b8..24b2860fd6 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt @@ -61,12 +61,12 @@ class SimulationControlsStateTest { @Test fun `demo controller toggles links without changing selection`() { val controller = demoController() - controller.store.update { it.copy(selectedNodeId = 3) } + controller.store.update { it.withSelection(listOf(3)) } runSuspend { controller.callbacks.onToggleLinks() } assertTrue(controller.store.state.scene.showLinks) - assertEquals(3, controller.store.state.selectedNodeId) + assertEquals(listOf(3), controller.store.state.selectedNodeIds) } @Test @@ -129,6 +129,20 @@ class SimulationControlsStateTest { assertEquals(SimulationStatus.RUNNING, controller.store.state.controls.status) assertEquals(99L, controller.store.state.controls.step) } + + @Test + fun `demo controller builds a group inspector for multi selection`() { + val controller = demoController() + + runSuspend { + controller.callbacks.onNodesSelected(listOf(1, 3)) + } + + assertEquals(listOf(1, 3), controller.store.state.selectedNodeIds) + val inspector = controller.store.state.inspector as GroupInspectorState + assertEquals(listOf(1, 3), inspector.nodeIds) + assertEquals("Mixed", inspector.concentrations.first { it.label == "signal" }.value) + } } private fun runSuspend(block: suspend () -> Unit) { diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt index f11a344801..b223f42318 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt @@ -92,7 +92,7 @@ class ComposeMonitor> @JvmOverloads constructor(targetFps: In step = step, status = environment.simulation.toSimulationStatus(), ), - ) + ).withSelection(it.selectedNodeIds) } } diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt index f2e797325b..0ce2cbd846 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt @@ -125,20 +125,19 @@ class DesktopAlchemistUiCallback>( override suspend fun onNodeSelected(nodeId: Int) { updateState { currentState -> - val node = currentState.scene.nodes.firstOrNull { it.id == nodeId } ?: return@updateState currentState - currentState.copy( - selectedNodeId = nodeId, - inspector = node.toInspectorState(), - ) + currentState.withSelection(listOf(nodeId)) + } + } + + override suspend fun onNodesSelected(nodeIds: List) { + updateState { currentState -> + currentState.withSelection(nodeIds) } } override suspend fun onInspectorDismiss() { updateState { - it.copy( - selectedNodeId = null, - inspector = null, - ) + it.withSelection(emptyList()) } } From 66cf1ab82bb23f362c1a90b27270e56d07297693 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Wed, 22 Apr 2026 20:18:42 +0200 Subject: [PATCH 16/22] refactor: move UI code into appropriate packages and refactor throttling --- .../boundary/composeui/MetricBlock.kt | 102 --------------- .../boundary/composeui/ProgressSection.kt | 120 ------------------ .../composeui/SimulationControlsConfig.kt | 13 +- .../boundary/composeui/StatusPill.kt | 114 ----------------- .../alchemist/boundary/composeui/Theme.kt | 112 ---------------- .../boundary/composeui/TransportButton.kt | 98 -------------- .../alchemist/boundary/composeui/UiStore.kt | 26 +++- .../model/SimulationEventThrottling.kt | 38 ++++++ .../boundary/composeui/{ => model}/UiModel.kt | 40 +++--- .../boundary/composeui/{ => view}/App.kt | 5 +- .../composeui/view/components/MetricBlock.kt | 48 +++++++ .../composeui/view/components/StatusPill.kt | 72 +++++++++++ .../view/components/TransportButton.kt | 46 +++++++ .../{ => view/controls}/ControlDock.kt | 45 +++++-- .../{ => view/inspector}/InspectorSection.kt | 57 +-------- .../{ => view/inspector}/NodeInspector.kt | 11 +- .../{ => view/root}/AlchemistUiRoot.kt | 57 +++------ .../{ => view/root}/SimulationPrimaryPane.kt | 64 +--------- .../boundary/composeui/view/theme/Theme.kt | 55 ++++++++ .../{ => view/viewport}/SummaryRail.kt | 59 +-------- .../{ => view/viewport}/ViewportProjection.kt | 11 +- .../{ => view/viewport}/ViewportRendering.kt | 6 +- .../{ => view/viewport}/ViewportSurface.kt | 27 +++- .../composeui/GroupInspectorStateTest.kt | 6 + .../composeui/SimulationControlsStateTest.kt | 9 +- .../composeui/ViewportCameraMathTest.kt | 1 + .../composeui/ViewportProjectionTest.kt | 4 + .../alchemist/boundary/composeui/Main.kt | 1 + .../boundary/composeui/ComposeMonitor.kt | 16 ++- .../composeui/DesktopAlchemistUiCallback.kt | 6 +- .../composeui/adapter/AlchemistNodeAdapter.kt | 10 +- .../DesktopAlchemistUiCallbackTest.kt | 4 +- .../adapter/AlchemistNodeAdapterTest.kt | 2 +- .../alchemist/boundary/composeui/Main.kt | 1 + 34 files changed, 477 insertions(+), 809 deletions(-) delete mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt delete mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt delete mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt delete mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt delete mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/SimulationEventThrottling.kt rename alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/{ => model}/UiModel.kt (79%) rename alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/{ => view}/App.kt (77%) create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/MetricBlock.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/StatusPill.kt create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/TransportButton.kt rename alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/{ => view/controls}/ControlDock.kt (75%) rename alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/{ => view/inspector}/InspectorSection.kt (53%) rename alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/{ => view/inspector}/NodeInspector.kt (87%) rename alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/{ => view/root}/AlchemistUiRoot.kt (82%) rename alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/{ => view/root}/SimulationPrimaryPane.kt (50%) create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/theme/Theme.kt rename alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/{ => view/viewport}/SummaryRail.kt (54%) rename alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/{ => view/viewport}/ViewportProjection.kt (88%) rename alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/{ => view/viewport}/ViewportRendering.kt (91%) rename alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/{ => view/viewport}/ViewportSurface.kt (92%) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt deleted file mode 100644 index c6472eb910..0000000000 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/MetricBlock.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (C) 2010-2026, Danilo Pianini and contributors - * listed, for each module, in the respective subproject's build.gradle.kts file. - * - * This file is part of Alchemist, and is distributed under the terms of the - * GNU General Public License, with a linking exception, - * as described in the file LICENSE in the Alchemist distribution's top directory. - */ - -package it.unibo.alchemist.boundary.composeui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.launch - -@Composable -internal fun MetricBlock(label: String, value: String) { - Surface( - color = Surface.copy(alpha = 0.78f), - shape = RoundedCornerShape(8.dp), - elevation = 0.dp, - ) { - Column( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = label.uppercase(), - style = MaterialTheme.typography.caption, - color = SecondaryAccent, - ) - Text( - text = value, - style = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily.Monospace), - ) - } - } -} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt deleted file mode 100644 index f19981053a..0000000000 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ProgressSection.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (C) 2010-2026, Danilo Pianini and contributors - * listed, for each module, in the respective subproject's build.gradle.kts file. - * - * This file is part of Alchemist, and is distributed under the terms of the - * GNU General Public License, with a linking exception, - * as described in the file LICENSE in the Alchemist distribution's top directory. - */ - -package it.unibo.alchemist.boundary.composeui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.launch - -@Composable -internal fun ProgressSection(progress: SimulationProgress, modifier: Modifier = Modifier) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Simulation progress", - style = MaterialTheme.typography.subtitle1, - ) - Text( - text = progress.label, - style = MaterialTheme.typography.caption, - color = TextSecondary, - ) - } - if (progress.fraction == null) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(8.dp), - color = PrimaryAccent, - backgroundColor = Outline.copy(alpha = 0.55f), - ) - } else { - LinearProgressIndicator( - progress = progress.fraction, - modifier = Modifier - .fillMaxWidth() - .height(8.dp), - color = PrimaryAccent, - backgroundColor = Outline.copy(alpha = 0.55f), - ) - } - } -} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsConfig.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsConfig.kt index 5fcbb04f0e..bd472d9078 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsConfig.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsConfig.kt @@ -9,10 +9,9 @@ package it.unibo.alchemist.boundary.composeui -internal const val MIN_UI_FPS = 5 -internal const val DEFAULT_UI_FPS = 30 -internal const val DEFAULT_MAX_UI_FPS = 60 -internal const val MIN_SIMULATION_EVENTS_PER_SECOND = 1 -internal const val DEFAULT_MAX_SIMULATION_EVENTS_PER_SECOND = 120 -internal const val FULL_THROTTLE_LABEL = "Max" -internal const val DISPLAYED_TIME_DECIMALS = 2 +object SimulationControlsConfig { + const val MIN_UI_FPS = 5 + const val DEFAULT_UI_FPS = 30 + const val DEFAULT_MAX_UI_FPS = 60 + const val DISPLAYED_TIME_DECIMALS = 2 +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt deleted file mode 100644 index 8ee6fd7185..0000000000 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/StatusPill.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (C) 2010-2026, Danilo Pianini and contributors - * listed, for each module, in the respective subproject's build.gradle.kts file. - * - * This file is part of Alchemist, and is distributed under the terms of the - * GNU General Public License, with a linking exception, - * as described in the file LICENSE in the Alchemist distribution's top directory. - */ - -package it.unibo.alchemist.boundary.composeui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.launch - -@Composable -internal fun StatusPill(controls: SimulationControlsState) { - val color = - when (controls.status) { - SimulationStatus.RUNNING -> Positive - SimulationStatus.PAUSED -> PrimaryAccent - SimulationStatus.TERMINATED -> Danger - else -> SecondaryAccent - } - Surface( - modifier = Modifier.width(StatusPillWidth), - color = color.copy(alpha = 0.14f), - shape = RoundedCornerShape(999.dp), - elevation = 0.dp, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Box( - modifier = Modifier - .size(10.dp) - .background(color = color, shape = CircleShape), - ) - Spacer(modifier = Modifier.width(10.dp)) - Text( - text = controls.statusLabel, - style = MaterialTheme.typography.subtitle1, - ) - } - } -} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt deleted file mode 100644 index 4230352c8f..0000000000 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/Theme.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (C) 2010-2026, Danilo Pianini and contributors - * listed, for each module, in the respective subproject's build.gradle.kts file. - * - * This file is part of Alchemist, and is distributed under the terms of the - * GNU General Public License, with a linking exception, - * as described in the file LICENSE in the Alchemist distribution's top directory. - */ - -@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming") - -package it.unibo.alchemist.boundary.composeui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.launch - -internal val Background = Color(0xFFF8F9FA) -internal val BackgroundVariant = Color(0xFFE9ECEF) -internal val BackgroundGradientEnd = Color(0xFFDEE2E6) -internal val Surface = Color(0xFFFFFFFF) -internal val SurfaceStrong = Color(0xFFF1F3F5) -internal val Outline = Color(0xFFCED4DA) -internal val PrimaryAccent = Color(0xFF0D6EFD) -internal val SecondaryAccent = Color(0xFF0A58CA) -internal val Positive = Color(0xFF198754) -internal val TextPrimary = Color(0xFF212529) -internal val TextSecondary = Color(0xFF495057) -internal val TextMuted = Color(0xFF6C757D) -internal val Danger = Color(0xFFDC3545) -internal const val ZoomStep = 1.12f -internal const val NodeHitRadius = 22f -internal const val SelectedNodeRadius = 18f -internal const val SelectedNodeInnerRadius = 12f -internal const val NodeRadius = 7f -internal const val LinkStrokeWidth = 1.5f -internal val StatusPillWidth = 132.dp -internal const val GridVerticalDivisions = 8 -internal const val GridHorizontalDivisions = 6 -internal fun lerp(start: Color, end: Color, amount: Float): Color { - val clamped = min(1f, max(0f, amount)) - return Color( - red = start.red + (end.red - start.red) * clamped, - green = start.green + (end.green - start.green) * clamped, - blue = start.blue + (end.blue - start.blue) * clamped, - alpha = start.alpha + (end.alpha - start.alpha) * clamped, - ) -} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt deleted file mode 100644 index e4f0af9009..0000000000 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/TransportButton.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (C) 2010-2026, Danilo Pianini and contributors - * listed, for each module, in the respective subproject's build.gradle.kts file. - * - * This file is part of Alchemist, and is distributed under the terms of the - * GNU General Public License, with a linking exception, - * as described in the file LICENSE in the Alchemist distribution's top directory. - */ - -package it.unibo.alchemist.boundary.composeui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.launch - -@Composable -internal fun TransportButton(label: String, enabled: Boolean, accent: Color, onClick: () -> Unit) { - val buttonContentColor = if (accent.luminance() > 0.35f) TextPrimary else Surface - Button( - onClick = onClick, - enabled = enabled, - shape = RoundedCornerShape(8.dp), - elevation = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = accent.copy(alpha = if (enabled) 0.92f else 0.28f), - contentColor = buttonContentColor, - disabledBackgroundColor = Outline.copy(alpha = 0.65f), - disabledContentColor = TextSecondary, - ), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp), - ) { - Text(text = label) - } -} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt index 5cf1d5211f..9f5416ff0d 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt @@ -11,6 +11,22 @@ package it.unibo.alchemist.boundary.composeui +import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.DISPLAYED_TIME_DECIMALS +import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.MIN_UI_FPS +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiCallbacks +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiState +import it.unibo.alchemist.boundary.composeui.model.ControlDialogState +import it.unibo.alchemist.boundary.composeui.model.GroupInspectorState +import it.unibo.alchemist.boundary.composeui.model.InfoField +import it.unibo.alchemist.boundary.composeui.model.InspectorState +import it.unibo.alchemist.boundary.composeui.model.NodeInspectorState +import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState +import it.unibo.alchemist.boundary.composeui.model.SimulationProgress +import it.unibo.alchemist.boundary.composeui.model.SimulationStatus +import it.unibo.alchemist.boundary.composeui.model.ViewportBackdrop +import it.unibo.alchemist.boundary.composeui.model.ViewportEdge +import it.unibo.alchemist.boundary.composeui.model.ViewportNode +import it.unibo.alchemist.boundary.composeui.model.ViewportScene import kotlin.math.ceil import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableStateFlow @@ -144,7 +160,7 @@ fun demoController(): ComposeUiController { override suspend fun onEventRateChanged(value: Float) { store.update { state -> - state.copy(controls = state.controls.withEventRateSliderValue(value.roundToInt())) + state.copy(controls = state.controls.updateEventThrottling(value.roundToInt())) } } @@ -319,10 +335,10 @@ internal fun SimulationControlsState.withUiFps(target: Int): SimulationControlsS ) } -internal fun SimulationControlsState.withEventRateSliderValue(target: Int): SimulationControlsState = copy( - eventRateSliderValue = target.coerceIn(MIN_SIMULATION_EVENTS_PER_SECOND, maxEventRateSliderValue), - dialog = null, -) +//internal fun SimulationControlsState.withEventRateSliderValue(target: Int): SimulationControlsState = copy( +// eventRateSliderValue = target.coerceIn(MIN_SIMULATION_EVENTS_PER_SECOND, simulationEventThrottling.value), +// dialog = null, +//) internal fun AlchemistUiState.withSelection(nodeIds: List): AlchemistUiState { val selectedIds = scene.sanitizeSelection(nodeIds) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/SimulationEventThrottling.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/SimulationEventThrottling.kt new file mode 100644 index 0000000000..cebd43187b --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/SimulationEventThrottling.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui.model + +sealed class SimulationEventThrottling(val value: Int) { + init { + require(value >= MIN_SIMULATION_EVENTS_PER_SECOND) { + "Simulation events per second cannot be less than $MIN_SIMULATION_EVENTS_PER_SECOND, but was $value." + } + } + + companion object { + const val MIN_SIMULATION_EVENTS_PER_SECOND: Int = 1 + const val MAX_SIMULATION_EVENTS_PER_SECOND: Int = 2000 + fun toEventThrottling(value: Int): SimulationEventThrottling = when { + value >= MAX_SIMULATION_EVENTS_PER_SECOND -> FullThrottle + value in MIN_SIMULATION_EVENTS_PER_SECOND.. EventsPerSecond(value) + else -> + error("Simulation events per second must be at least $MIN_SIMULATION_EVENTS_PER_SECOND, but was $value.") + } + } + + fun update(newValue: Int): SimulationEventThrottling = toEventThrottling(newValue) + + fun toLabel(): String = when (this) { + is FullThrottle -> "Max" + is EventsPerSecond -> "$value" + } +} +class EventsPerSecond(eventsPerSecond: Int) : SimulationEventThrottling(eventsPerSecond) +data object FullThrottle : SimulationEventThrottling(Int.MAX_VALUE) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt similarity index 79% rename from alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt rename to alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt index 1bf098e414..4a346c5da7 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiModel.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt @@ -9,9 +9,12 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ -package it.unibo.alchemist.boundary.composeui +package it.unibo.alchemist.boundary.composeui.model import androidx.compose.runtime.Immutable +import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.DEFAULT_MAX_UI_FPS +import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.DEFAULT_UI_FPS +import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.MIN_UI_FPS /** * High-level simulation status mirrored in the Compose UI. @@ -114,34 +117,39 @@ data class SimulationControlsState( val fpsInput: String = DEFAULT_UI_FPS.toString(), val uiFps: Int = DEFAULT_UI_FPS, val maxUiFps: Int = DEFAULT_MAX_UI_FPS, - val eventRateSliderValue: Int = MIN_SIMULATION_EVENTS_PER_SECOND, - val maxEventRateSliderValue: Int = DEFAULT_MAX_SIMULATION_EVENTS_PER_SECOND, + val simulationEventThrottling: SimulationEventThrottling = FullThrottle, +// val eventRateSliderValue: Int = MIN_SIMULATION_EVENTS_PER_SECOND, +// val maxEventRateSliderValue: Int = MAX_SIMULATION_EVENTS_PER_SECOND, val dialog: ControlDialogState? = null, ) { init { require(maxUiFps >= MIN_UI_FPS) { - "Maximum UI fps must be at least $MIN_UI_FPS." + "Maximum UI fps must be at least ${MIN_UI_FPS}." } require(uiFps in MIN_UI_FPS..maxUiFps) { - "UI fps must remain within [$MIN_UI_FPS, $maxUiFps]." - } - require(eventRateSliderValue in MIN_SIMULATION_EVENTS_PER_SECOND..maxEventRateSliderValue) { - "Slider value must remain within [$MIN_SIMULATION_EVENTS_PER_SECOND, $maxEventRateSliderValue]." - } - require(maxEventRateSliderValue > MIN_SIMULATION_EVENTS_PER_SECOND) { - "Maximum slider value must exceed the minimum event rate." + "UI fps must remain within [${MIN_UI_FPS}, $maxUiFps]." } +// require(eventRateSliderValue in MIN_SIMULATION_EVENTS_PER_SECOND..maxEventRateSliderValue) { +// "Slider value must remain within [${MIN_SIMULATION_EVENTS_PER_SECOND}, $maxEventRateSliderValue]." +// } +// require(maxEventRateSliderValue > MIN_SIMULATION_EVENTS_PER_SECOND) { +// "Maximum slider value must exceed the minimum event rate." +// } } val canPlay: Boolean = status == SimulationStatus.READY || status == SimulationStatus.PAUSED val canPause: Boolean = status == SimulationStatus.RUNNING val canStep: Boolean = status == SimulationStatus.READY || status == SimulationStatus.PAUSED val statusLabel: String = status.name.lowercase().replaceFirstChar(Char::uppercaseChar) - val isFullThrottle: Boolean = eventRateSliderValue == maxEventRateSliderValue - val effectiveEventsPerSecond: Int? = eventRateSliderValue.takeUnless { isFullThrottle } - val eventRateLabel: String = - effectiveEventsPerSecond?.let { "$it evt/s" } ?: FULL_THROTTLE_LABEL - val fpsRangeLabel: String = "$MIN_UI_FPS-$maxUiFps FPS" + val isFullThrottle: Boolean = simulationEventThrottling is FullThrottle +// val effectiveEventsPerSecond: Int? = eventRateSliderValue.takeUnless { isFullThrottle } +// val eventRateLabel: String = +// effectiveEventsPerSecond?.let { "$it evt/s" } ?: "∞" + val fpsRangeLabel: String = "${MIN_UI_FPS}-$maxUiFps FPS" + + fun updateEventThrottling(newValue: Int): SimulationControlsState = copy( + simulationEventThrottling = simulationEventThrottling.update(newValue), + ) } /** diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/App.kt similarity index 77% rename from alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt rename to alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/App.kt index 411b655e94..6332008c55 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/App.kt @@ -7,12 +7,15 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ -package it.unibo.alchemist.boundary.composeui +package it.unibo.alchemist.boundary.composeui.view import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import it.unibo.alchemist.boundary.composeui.ComposeUiController +import it.unibo.alchemist.boundary.composeui.demoController +import it.unibo.alchemist.boundary.composeui.view.root.AlchemistUiRoot /** * Application entry point, rendered consistently across supported platforms. diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/MetricBlock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/MetricBlock.kt new file mode 100644 index 0000000000..5468450906 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/MetricBlock.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui.view.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import it.unibo.alchemist.boundary.composeui.view.theme.SecondaryAccent +import it.unibo.alchemist.boundary.composeui.view.theme.Surface + +@Composable +internal fun MetricBlock(label: String, value: String) { + Surface( + color = Surface.copy(alpha = 0.78f), + shape = RoundedCornerShape(8.dp), + elevation = 0.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.caption, + color = SecondaryAccent, + ) + Text( + text = value, + style = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily.Monospace), + ) + } + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/StatusPill.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/StatusPill.kt new file mode 100644 index 0000000000..f75e5a93f1 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/StatusPill.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui.view.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState +import it.unibo.alchemist.boundary.composeui.model.SimulationStatus +import it.unibo.alchemist.boundary.composeui.view.theme.Danger +import it.unibo.alchemist.boundary.composeui.view.theme.Positive +import it.unibo.alchemist.boundary.composeui.view.theme.PrimaryAccent +import it.unibo.alchemist.boundary.composeui.view.theme.SecondaryAccent +import it.unibo.alchemist.boundary.composeui.view.theme.StatusPillWidth + +@Composable +internal fun StatusPill(controls: SimulationControlsState) { + val color = + when (controls.status) { + SimulationStatus.RUNNING -> Positive + SimulationStatus.PAUSED -> PrimaryAccent + SimulationStatus.TERMINATED -> Danger + else -> SecondaryAccent + } + Surface( + modifier = Modifier.width(StatusPillWidth), + color = color.copy(alpha = 0.14f), + shape = RoundedCornerShape(999.dp), + elevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(10.dp) + .background(color = color, shape = CircleShape), + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = controls.statusLabel, + style = MaterialTheme.typography.subtitle1, + ) + } + } +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/TransportButton.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/TransportButton.kt new file mode 100644 index 0000000000..e419476574 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/TransportButton.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui.view.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.dp +import it.unibo.alchemist.boundary.composeui.view.theme.Outline +import it.unibo.alchemist.boundary.composeui.view.theme.Surface +import it.unibo.alchemist.boundary.composeui.view.theme.TextPrimary +import it.unibo.alchemist.boundary.composeui.view.theme.TextSecondary + +@Composable +internal fun TransportButton(label: String, enabled: Boolean, accent: Color, onClick: () -> Unit) { + val buttonContentColor = if (accent.luminance() > ACCENT_LUMINANCE_THRESHOLD) TextPrimary else Surface + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(8.dp), + elevation = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = accent.copy(alpha = if (enabled) 0.92f else 0.28f), + contentColor = buttonContentColor, + disabledBackgroundColor = Outline.copy(alpha = 0.65f), + disabledContentColor = TextSecondary, + ), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp), + ) { + Text(text = label) + } +} + +private const val ACCENT_LUMINANCE_THRESHOLD = 0.35f diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt similarity index 75% rename from alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt rename to alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt index ecb99b788e..ca58df887f 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ControlDock.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt @@ -7,7 +7,7 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ -package it.unibo.alchemist.boundary.composeui +package it.unibo.alchemist.boundary.composeui.view.controls import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -22,6 +22,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Slider +import androidx.compose.material.SliderDefaults import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults @@ -32,8 +33,25 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import it.unibo.alchemist.boundary.composeui.model.EventsPerSecond +import it.unibo.alchemist.boundary.composeui.model.FullThrottle +import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState +import it.unibo.alchemist.boundary.composeui.model.SimulationEventThrottling.Companion.MAX_SIMULATION_EVENTS_PER_SECOND +import it.unibo.alchemist.boundary.composeui.model.SimulationEventThrottling.Companion.MIN_SIMULATION_EVENTS_PER_SECOND +import it.unibo.alchemist.boundary.composeui.view.components.MetricBlock +import it.unibo.alchemist.boundary.composeui.view.components.TransportButton +import it.unibo.alchemist.boundary.composeui.view.theme.Danger +import it.unibo.alchemist.boundary.composeui.view.theme.Outline +import it.unibo.alchemist.boundary.composeui.view.theme.Positive +import it.unibo.alchemist.boundary.composeui.view.theme.PrimaryAccent +import it.unibo.alchemist.boundary.composeui.view.theme.SecondaryAccent +import it.unibo.alchemist.boundary.composeui.view.theme.SurfaceStrong +import it.unibo.alchemist.boundary.composeui.view.theme.TextPrimary +import it.unibo.alchemist.boundary.composeui.view.theme.TextSecondary @Composable internal fun ControlDock( @@ -154,12 +172,12 @@ private fun EventRateSlider(controls: SimulationControlsState, onValueChange: (F color = SecondaryAccent, ) Slider( - value = controls.eventRateSliderValue.toFloat(), + value = controls.simulationEventThrottling.value.toFloat(), onValueChange = onValueChange, valueRange = - MIN_SIMULATION_EVENTS_PER_SECOND.toFloat()..controls.maxEventRateSliderValue.toFloat(), - steps = controls.maxEventRateSliderValue - MIN_SIMULATION_EVENTS_PER_SECOND - 1, - colors = androidx.compose.material.SliderDefaults.colors( + MIN_SIMULATION_EVENTS_PER_SECOND.toFloat()..MAX_SIMULATION_EVENTS_PER_SECOND.toFloat(), + steps = MAX_SIMULATION_EVENTS_PER_SECOND - MIN_SIMULATION_EVENTS_PER_SECOND - 1, + colors = SliderDefaults.colors( thumbColor = PrimaryAccent, activeTrackColor = PrimaryAccent, inactiveTrackColor = Outline, @@ -170,17 +188,20 @@ private fun EventRateSlider(controls: SimulationControlsState, onValueChange: (F horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "${MIN_SIMULATION_EVENTS_PER_SECOND} evt/s", + text = EventsPerSecond(MIN_SIMULATION_EVENTS_PER_SECOND).toLabel(), style = MaterialTheme.typography.caption, color = TextSecondary, ) + if (!controls.isFullThrottle) { + Text( + text = controls.simulationEventThrottling.toLabel(), + style = MaterialTheme.typography.caption, + color = TextPrimary, + ) + } Text( - text = controls.eventRateLabel, - style = MaterialTheme.typography.caption, - color = TextPrimary, - ) - Text( - text = FULL_THROTTLE_LABEL, + text = FullThrottle.toLabel(), + fontWeight = if (controls.isFullThrottle) FontWeight.Bold else FontWeight.Normal, style = MaterialTheme.typography.caption, color = if (controls.isFullThrottle) PrimaryAccent else TextSecondary, ) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/InspectorSection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/InspectorSection.kt similarity index 53% rename from alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/InspectorSection.kt rename to alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/InspectorSection.kt index 9ccb54a046..a3f66680dc 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/InspectorSection.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/InspectorSection.kt @@ -7,75 +7,30 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ -package it.unibo.alchemist.boundary.composeui +package it.unibo.alchemist.boundary.composeui.view.inspector -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.launch +import it.unibo.alchemist.boundary.composeui.model.InfoField +import it.unibo.alchemist.boundary.composeui.view.theme.Outline +import it.unibo.alchemist.boundary.composeui.view.theme.Surface +import it.unibo.alchemist.boundary.composeui.view.theme.TextPrimary +import it.unibo.alchemist.boundary.composeui.view.theme.TextSecondary @Composable internal fun InspectorSection(title: String, description: String, fields: List) { diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/NodeInspector.kt similarity index 87% rename from alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt rename to alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/NodeInspector.kt index 9bb475da45..fc8c87024c 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/NodeInspector.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/NodeInspector.kt @@ -7,7 +7,7 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ -package it.unibo.alchemist.boundary.composeui +package it.unibo.alchemist.boundary.composeui.view.inspector import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -24,6 +24,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import it.unibo.alchemist.boundary.composeui.model.GroupInspectorState +import it.unibo.alchemist.boundary.composeui.model.InfoField +import it.unibo.alchemist.boundary.composeui.model.InspectorState +import it.unibo.alchemist.boundary.composeui.model.NodeInspectorState +import it.unibo.alchemist.boundary.composeui.view.components.TransportButton +import it.unibo.alchemist.boundary.composeui.view.theme.Outline +import it.unibo.alchemist.boundary.composeui.view.theme.SurfaceStrong +import it.unibo.alchemist.boundary.composeui.view.theme.TextPrimary +import it.unibo.alchemist.boundary.composeui.view.theme.TextSecondary @Composable internal fun NodeInspector(inspector: InspectorState, onDismiss: () -> Unit, modifier: Modifier = Modifier) { diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt similarity index 82% rename from alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt rename to alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt index b11cb4a6ec..76de74ecff 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/AlchemistUiRoot.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt @@ -7,9 +7,8 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ -package it.unibo.alchemist.boundary.composeui +package it.unibo.alchemist.boundary.composeui.view.root -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.animation.animateContentSize @@ -18,67 +17,45 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiCallbacks +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiState +import it.unibo.alchemist.boundary.composeui.view.inspector.NodeInspector +import it.unibo.alchemist.boundary.composeui.view.theme.Background +import it.unibo.alchemist.boundary.composeui.view.theme.BackgroundGradientEnd +import it.unibo.alchemist.boundary.composeui.view.theme.BackgroundVariant +import it.unibo.alchemist.boundary.composeui.view.theme.InspectorScrim +import it.unibo.alchemist.boundary.composeui.view.theme.PrimaryAccent +import it.unibo.alchemist.boundary.composeui.view.theme.SecondaryAccent +import it.unibo.alchemist.boundary.composeui.view.theme.Surface +import it.unibo.alchemist.boundary.composeui.view.theme.SurfaceStrong +import it.unibo.alchemist.boundary.composeui.view.theme.TextPrimary +import it.unibo.alchemist.boundary.composeui.view.theme.TextSecondary import kotlinx.coroutines.launch /** @@ -189,8 +166,10 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { Box( modifier = Modifier .fillMaxSize() - .background(Color(0x66050A11)) - .clickable(onClick = { coroutineScope.launch { callbacks.onInspectorDismiss() } }), + .background(InspectorScrim) + .clickable( + onClick = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, + ), ) Box( modifier = Modifier diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt similarity index 50% rename from alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt rename to alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt index 47f851c79d..21b87a8e84 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationPrimaryPane.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt @@ -7,74 +7,22 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ -package it.unibo.alchemist.boundary.composeui +package it.unibo.alchemist.boundary.composeui.view.root -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiCallbacks +import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState +import it.unibo.alchemist.boundary.composeui.model.ViewportScene +import it.unibo.alchemist.boundary.composeui.view.controls.ControlDock +import it.unibo.alchemist.boundary.composeui.view.viewport.ViewportSurface import kotlinx.coroutines.launch @Composable diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/theme/Theme.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/theme/Theme.kt new file mode 100644 index 0000000000..a5a6e3d4ce --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/theme/Theme.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +@file:Suppress( + "MagicNumber", + "TopLevelPropertyNaming", + "ktlint:standard:property-naming", + "ktlint:standard:function-naming", +) + +package it.unibo.alchemist.boundary.composeui.view.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlin.math.max +import kotlin.math.min + +internal val Background = Color(0xFFF8F9FA) +internal val BackgroundVariant = Color(0xFFE9ECEF) +internal val BackgroundGradientEnd = Color(0xFFDEE2E6) +internal val Surface = Color(0xFFFFFFFF) +internal val SurfaceStrong = Color(0xFFF1F3F5) +internal val Outline = Color(0xFFCED4DA) +internal val PrimaryAccent = Color(0xFF0D6EFD) +internal val SecondaryAccent = Color(0xFF0A58CA) +internal val Positive = Color(0xFF198754) +internal val TextPrimary = Color(0xFF212529) +internal val TextSecondary = Color(0xFF495057) +internal val TextMuted = Color(0xFF6C757D) +internal val Danger = Color(0xFFDC3545) +internal val InspectorScrim = Color(0x66050A11) +internal const val ZoomStep = 1.12f +internal const val NodeHitRadius = 22f +internal const val SelectedNodeRadius = 18f +internal const val SelectedNodeInnerRadius = 12f +internal const val NodeRadius = 7f +internal const val LinkStrokeWidth = 1.5f +internal val StatusPillWidth = 132.dp +internal const val GridVerticalDivisions = 8 +internal const val GridHorizontalDivisions = 6 +internal fun lerp(start: Color, end: Color, amount: Float): Color { + val clamped = min(1f, max(0f, amount)) + return Color( + red = start.red + (end.red - start.red) * clamped, + green = start.green + (end.green - start.green) * clamped, + blue = start.blue + (end.blue - start.blue) * clamped, + alpha = start.alpha + (end.alpha - start.alpha) * clamped, + ) +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SummaryRail.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt similarity index 54% rename from alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SummaryRail.kt rename to alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt index 7e1a969a6d..297d50b40b 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SummaryRail.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt @@ -7,75 +7,26 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ -package it.unibo.alchemist.boundary.composeui +package it.unibo.alchemist.boundary.composeui.view.viewport -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Divider -import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.isTertiaryPressed -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import kotlin.math.max -import kotlin.math.min -import kotlinx.coroutines.launch +import it.unibo.alchemist.boundary.composeui.model.InfoField +import it.unibo.alchemist.boundary.composeui.view.theme.SecondaryAccent +import it.unibo.alchemist.boundary.composeui.view.theme.SurfaceStrong +import it.unibo.alchemist.boundary.composeui.view.theme.TextSecondary @Composable internal fun SummaryRail(summary: List, showLinks: Boolean, onToggleLinks: () -> Unit) { diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportProjection.kt similarity index 88% rename from alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjection.kt rename to alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportProjection.kt index 728f3e0e6c..54e67e5458 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjection.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportProjection.kt @@ -1,4 +1,9 @@ -@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming") +@file:Suppress( + "ReturnCount", + "TopLevelPropertyNaming", + "ktlint:standard:property-naming", + "ktlint:standard:function-naming", +) /* * Copyright (C) 2010-2026, Danilo Pianini and contributors * listed, for each module, in the respective subproject's build.gradle.kts file. @@ -8,10 +13,12 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ -package it.unibo.alchemist.boundary.composeui +package it.unibo.alchemist.boundary.composeui.view.viewport import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.IntSize +import it.unibo.alchemist.boundary.composeui.model.ViewportNode +import it.unibo.alchemist.boundary.composeui.model.ViewportScene import kotlin.math.max import kotlin.math.min diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportRendering.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt similarity index 91% rename from alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportRendering.kt rename to alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt index c86c218394..42ab57ba3c 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportRendering.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt @@ -9,11 +9,15 @@ @file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming") -package it.unibo.alchemist.boundary.composeui +package it.unibo.alchemist.boundary.composeui.view.viewport import androidx.compose.runtime.Immutable import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.IntSize +import it.unibo.alchemist.boundary.composeui.model.ViewportEdge +import it.unibo.alchemist.boundary.composeui.model.ViewportNode +import it.unibo.alchemist.boundary.composeui.model.ViewportScene +import it.unibo.alchemist.boundary.composeui.view.theme.ZoomStep internal fun renderNodes( scene: ViewportScene, diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt similarity index 92% rename from alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt rename to alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt index 27892e1526..ecc4a88bd9 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/ViewportSurface.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt @@ -7,16 +7,14 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ -package it.unibo.alchemist.boundary.composeui +package it.unibo.alchemist.boundary.composeui.view.viewport import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -48,6 +46,25 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import it.unibo.alchemist.boundary.composeui.formatFixed +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiCallbacks +import it.unibo.alchemist.boundary.composeui.model.ViewportScene +import it.unibo.alchemist.boundary.composeui.view.theme.Background +import it.unibo.alchemist.boundary.composeui.view.theme.BackgroundVariant +import it.unibo.alchemist.boundary.composeui.view.theme.GridHorizontalDivisions +import it.unibo.alchemist.boundary.composeui.view.theme.GridVerticalDivisions +import it.unibo.alchemist.boundary.composeui.view.theme.LinkStrokeWidth +import it.unibo.alchemist.boundary.composeui.view.theme.NodeHitRadius +import it.unibo.alchemist.boundary.composeui.view.theme.NodeRadius +import it.unibo.alchemist.boundary.composeui.view.theme.Outline +import it.unibo.alchemist.boundary.composeui.view.theme.PrimaryAccent +import it.unibo.alchemist.boundary.composeui.view.theme.SecondaryAccent +import it.unibo.alchemist.boundary.composeui.view.theme.SelectedNodeInnerRadius +import it.unibo.alchemist.boundary.composeui.view.theme.SelectedNodeRadius +import it.unibo.alchemist.boundary.composeui.view.theme.Surface +import it.unibo.alchemist.boundary.composeui.view.theme.SurfaceStrong +import it.unibo.alchemist.boundary.composeui.view.theme.TextPrimary +import it.unibo.alchemist.boundary.composeui.view.theme.lerp import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.launch @@ -146,7 +163,9 @@ internal fun ViewportSurface( node.copy(center = node.center.toScreenPosition(viewportSize, camera)) } val selectedIds = mappedNodes - .filter { selectionNode -> createSelectionRect(anchor, current).contains(selectionNode.center) } + .filter { selectionNode -> + createSelectionRect(anchor, current).contains(selectionNode.center) + } .map { selectionNode -> selectionNode.node.id } coroutineScope.launch { callbacks.onNodesSelected(selectedIds) } } else { diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt index a04f053409..8e2eb1dd17 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt @@ -9,6 +9,12 @@ package it.unibo.alchemist.boundary.composeui +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiState +import it.unibo.alchemist.boundary.composeui.model.GroupInspectorState +import it.unibo.alchemist.boundary.composeui.model.InfoField +import it.unibo.alchemist.boundary.composeui.model.NodeInspectorState +import it.unibo.alchemist.boundary.composeui.model.ViewportNode +import it.unibo.alchemist.boundary.composeui.model.ViewportScene import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt index 24b2860fd6..da885c41f4 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt @@ -9,6 +9,11 @@ package it.unibo.alchemist.boundary.composeui +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiState +import it.unibo.alchemist.boundary.composeui.model.GroupInspectorState +import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState +import it.unibo.alchemist.boundary.composeui.model.SimulationStatus +import it.unibo.alchemist.boundary.composeui.model.ViewportScene import kotlin.coroutines.Continuation import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.startCoroutine @@ -118,7 +123,9 @@ class SimulationControlsStateTest { fun `demo controller preserves running state after jump`() { val controller = demoController() controller.store.update { - it.copy(controls = it.controls.copy(status = SimulationStatus.RUNNING)) + it.copy( + controls = it.controls.copy(status = SimulationStatus.RUNNING), + ) } runSuspend { diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportCameraMathTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportCameraMathTest.kt index fecc2ae655..cb0512c17e 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportCameraMathTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportCameraMathTest.kt @@ -9,6 +9,7 @@ package it.unibo.alchemist.boundary.composeui +import it.unibo.alchemist.boundary.composeui.view.viewport.applyInfiniteZoomFactor import kotlin.math.abs import kotlin.test.Test import kotlin.test.assertTrue diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt index b70dfb0483..1d74befbaf 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt @@ -10,6 +10,10 @@ package it.unibo.alchemist.boundary.composeui import androidx.compose.ui.unit.IntSize +import it.unibo.alchemist.boundary.composeui.model.ViewportNode +import it.unibo.alchemist.boundary.composeui.model.ViewportScene +import it.unibo.alchemist.boundary.composeui.view.viewport.createViewportProjection +import it.unibo.alchemist.boundary.composeui.view.viewport.toViewportPosition import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertNull diff --git a/alchemist-composeui/src/jsMain/kotlin/it/unibo/alchemist/boundary/composeui/Main.kt b/alchemist-composeui/src/jsMain/kotlin/it/unibo/alchemist/boundary/composeui/Main.kt index 9811caadc1..d1c23876f4 100644 --- a/alchemist-composeui/src/jsMain/kotlin/it/unibo/alchemist/boundary/composeui/Main.kt +++ b/alchemist-composeui/src/jsMain/kotlin/it/unibo/alchemist/boundary/composeui/Main.kt @@ -11,6 +11,7 @@ package it.unibo.alchemist.boundary.composeui import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport +import it.unibo.alchemist.boundary.composeui.view.app import kotlinx.browser.document import org.jetbrains.skiko.wasm.onWasmReady diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt index b223f42318..4a7571b76c 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt @@ -16,8 +16,15 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import it.unibo.alchemist.boundary.OutputMonitor +import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.DEFAULT_MAX_UI_FPS +import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.MIN_UI_FPS +import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.DISPLAYED_TIME_DECIMALS import it.unibo.alchemist.boundary.composeui.adapter.toSimulationStatus import it.unibo.alchemist.boundary.composeui.adapter.toViewport +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiState +import it.unibo.alchemist.boundary.composeui.model.FullThrottle +import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState +import it.unibo.alchemist.boundary.composeui.view.app import it.unibo.alchemist.model.Actionable import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Position @@ -103,9 +110,12 @@ class ComposeMonitor> @JvmOverloads constructor(targetFps: In private fun throttleSimulation() { val controls = currentUiState.state.controls - val eventsPerSecond = controls.effectiveEventsPerSecond ?: run { - nextEventReleaseNs = 0L - return + val eventsPerSecond = when (val throttling = controls.simulationEventThrottling) { + is FullThrottle -> { + nextEventReleaseNs = 0 + return + } + else -> throttling.value } val intervalNs = (1_000_000_000.0 / eventsPerSecond).roundToLong().coerceAtLeast(1L) val now = System.nanoTime() diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt index 0ce2cbd846..394ef71255 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt @@ -9,7 +9,11 @@ package it.unibo.alchemist.boundary.composeui +import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.DISPLAYED_TIME_DECIMALS import it.unibo.alchemist.boundary.composeui.adapter.toSimulationStatus +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiCallbacks +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiState +import it.unibo.alchemist.boundary.composeui.model.ControlDialogState import it.unibo.alchemist.core.Simulation import it.unibo.alchemist.core.Status import it.unibo.alchemist.model.Position @@ -119,7 +123,7 @@ class DesktopAlchemistUiCallback>( override suspend fun onEventRateChanged(value: Float) { updateState { - it.copy(controls = it.controls.withEventRateSliderValue(value.roundToInt())) + it.copy(controls = it.controls.updateEventThrottling(value.roundToInt())) } } diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt index 95f0d582ce..5a6ce97fbc 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt @@ -9,11 +9,11 @@ package it.unibo.alchemist.boundary.composeui.adapter -import it.unibo.alchemist.boundary.composeui.InfoField -import it.unibo.alchemist.boundary.composeui.SimulationStatus -import it.unibo.alchemist.boundary.composeui.ViewportEdge -import it.unibo.alchemist.boundary.composeui.ViewportNode -import it.unibo.alchemist.boundary.composeui.ViewportScene +import it.unibo.alchemist.boundary.composeui.model.InfoField +import it.unibo.alchemist.boundary.composeui.model.SimulationStatus +import it.unibo.alchemist.boundary.composeui.model.ViewportEdge +import it.unibo.alchemist.boundary.composeui.model.ViewportNode +import it.unibo.alchemist.boundary.composeui.model.ViewportScene import it.unibo.alchemist.core.Simulation import it.unibo.alchemist.core.Status import it.unibo.alchemist.model.Environment diff --git a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallbackTest.kt b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallbackTest.kt index 6acdcb7e90..77a17613d8 100644 --- a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallbackTest.kt +++ b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallbackTest.kt @@ -9,6 +9,9 @@ package it.unibo.alchemist.boundary.composeui +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiState +import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState +import it.unibo.alchemist.boundary.composeui.model.SimulationStatus import it.unibo.alchemist.core.Simulation import it.unibo.alchemist.core.Status import it.unibo.alchemist.model.Actionable @@ -16,7 +19,6 @@ import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Neighborhood import it.unibo.alchemist.model.Node import it.unibo.alchemist.model.Position -import it.unibo.alchemist.model.Reaction import it.unibo.alchemist.model.Time import it.unibo.alchemist.model.times.DoubleTime import java.util.Optional diff --git a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt index ed2717d7c3..f109c2be2f 100644 --- a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt +++ b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt @@ -9,7 +9,7 @@ package it.unibo.alchemist.boundary.composeui.adapter -import it.unibo.alchemist.boundary.composeui.ViewportEdge +import it.unibo.alchemist.boundary.composeui.model.ViewportEdge import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull diff --git a/alchemist-composeui/src/wasmJsMain/kotlin/it/unibo/alchemist/boundary/composeui/Main.kt b/alchemist-composeui/src/wasmJsMain/kotlin/it/unibo/alchemist/boundary/composeui/Main.kt index 76a83dea20..d89da7f017 100644 --- a/alchemist-composeui/src/wasmJsMain/kotlin/it/unibo/alchemist/boundary/composeui/Main.kt +++ b/alchemist-composeui/src/wasmJsMain/kotlin/it/unibo/alchemist/boundary/composeui/Main.kt @@ -11,6 +11,7 @@ package it.unibo.alchemist.boundary.composeui import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport +import it.unibo.alchemist.boundary.composeui.view.app import kotlinx.browser.document /** From af36dd033eb5c9579ce92207d87dd1b9921c9e7d Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Thu, 23 Apr 2026 15:25:33 +0200 Subject: [PATCH 17/22] feat: implement drag-and-drop feature for the nodes --- alchemist-composeui/AI_CONTEXT.md | 25 ++- .../alchemist/boundary/composeui/UiStore.kt | 50 +++++ .../boundary/composeui/model/UiModel.kt | 16 ++ .../view/viewport/ViewportSurface.kt | 169 +++++++++++---- .../composeui/ViewportNodeMovementTest.kt | 77 +++++++ .../composeui/DesktopAlchemistUiCallback.kt | 38 +++- .../src/jvmMain/resources/composeui-demo.yml | 2 + .../DesktopAlchemistUiNodeMoveTest.kt | 205 ++++++++++++++++++ 8 files changed, 535 insertions(+), 47 deletions(-) create mode 100644 alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportNodeMovementTest.kt create mode 100644 alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt diff --git a/alchemist-composeui/AI_CONTEXT.md b/alchemist-composeui/AI_CONTEXT.md index ff8eef2b94..9536f45628 100644 --- a/alchemist-composeui/AI_CONTEXT.md +++ b/alchemist-composeui/AI_CONTEXT.md @@ -7,6 +7,7 @@ - inspect a running simulation visually, - control playback with play/pause/step, jump, FPS, and pacing actions, - inspect nodes in detail, +- reposition selected nodes with `Ctrl + left drag`, - toggle link visibility, - run the same UI shell across JVM desktop and browser targets. @@ -17,9 +18,10 @@ This document is intentionally **intent-driven**: it describes why the module ex 1. Render a live simulation viewport with nodes and optional edges. 2. Expose transport controls plus direct jump and pacing controls. 3. Show a node inspector when a node is selected. -4. Keep the UI state reactive and thread-safe. -5. Bridge the simulation engine to the Compose UI on JVM. -6. Provide a demo/fallback shell for non-simulation entrypoints. +4. Move selected nodes from the viewport and commit the new positions to the simulator. +5. Keep the UI state reactive and thread-safe. +6. Bridge the simulation engine to the Compose UI on JVM. +7. Provide a demo/fallback shell for non-simulation entrypoints. ## Current architecture @@ -107,6 +109,10 @@ Defined in `adapter/AlchemistNodeAdapter.kt` and used by `ComposeMonitor.kt`: - Left-click a node to inspect it. - Click empty space to dismiss the inspector. +- Drag a selection box with left-drag. +- Hold `Ctrl` and left-drag from a selected node to translate the full selection while preserving inter-node distances. +- `Ctrl + left drag` commits the new node positions to the simulator only when the pointer is released. +- If `Ctrl + left drag` starts on empty space or an unselected node, keep the normal click/selection behavior. - Middle-drag to pan the viewport. - Wheel to zoom. - Toggle links from the summary rail. @@ -128,7 +134,7 @@ Defined in `adapter/AlchemistNodeAdapter.kt` and used by `ComposeMonitor.kt`: 3. **Viewport projection must remain stable enough for inspection** - `ViewportProjection` fixes the mapping once a valid viewport exists. - - The selection and zoom/pan logic assume a consistent mapping between world and screen space. + - The selection, drag translation, and zoom/pan logic assume a consistent mapping between world and screen space. 4. **Live monitor updates must preserve UI-only toggles** - `ComposeMonitor.updateUiState` intentionally preserves `scene.showLinks` while refreshing the scene from the simulation. @@ -140,6 +146,11 @@ Defined in `adapter/AlchemistNodeAdapter.kt` and used by `ComposeMonitor.kt`: 6. **Demo state should remain usable without a live simulation** - `demoController()` is the browser-friendly fallback and should continue to demonstrate the UI shell. +7. **Node dragging is preview-first and simulator-authoritative** + - The viewport may preview translated node positions locally during `Ctrl + drag`. + - JVM simulator state must be mutated only on drag release, then the UI must resync from the environment snapshot. + - Only X/Y are translated from the viewport gesture; any higher coordinates must remain unchanged. + 7. **Small composables should stay small** - Maintain the current composition pattern: one responsibility per file when possible. @@ -161,6 +172,7 @@ Defined in `adapter/AlchemistNodeAdapter.kt` and used by `ComposeMonitor.kt`: - Uses the simulation object as the source of truth for play/pause/step/jump state. - Validates `To Time`, `To Step`, and `FPS` submissions before mutating simulator state. - Restores running state after a successful jump when the simulation was already running. +- Commits dragged-node positions through `simulation.schedule { ... }`, `environment.moveNodeToPosition(...)`, and `simulation.nodeMoved(...)`. ### `ViewportSurface` @@ -168,6 +180,8 @@ Defined in `adapter/AlchemistNodeAdapter.kt` and used by `ComposeMonitor.kt`: - Uses projection data derived from the current scene. - Keeps the first valid projection fixed until the layout becomes valid. - Requires node selection hit tests to respect camera zoom. +- Supports selection-box drag and `Ctrl + left drag` translation for already selected nodes. +- Must preserve existing selection behavior when a `Ctrl` gesture does not start on a selected node. ### `demoController()` @@ -184,9 +198,10 @@ When changing this module, preserve the following: - Preserve browser entrypoint usability. - Preserve the JVM monitor contract with `OutputMonitor`. - Preserve node inspection, transport controls, and link toggling. +- Preserve selection-box, pan, and zoom interactions while adding node dragging. - Preserve jump validation semantics: backward `To Time` / `To Step` targets must fail with a popup. - Preserve the constants governing FPS and event-rate ranges. -- Add or update tests when changing projection math, selection logic, or state transitions. +- Add or update tests when changing projection math, selection logic, node dragging, or state transitions. ## Preferred change strategy diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt index 9f5416ff0d..300e6a5917 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt @@ -19,6 +19,7 @@ import it.unibo.alchemist.boundary.composeui.model.ControlDialogState import it.unibo.alchemist.boundary.composeui.model.GroupInspectorState import it.unibo.alchemist.boundary.composeui.model.InfoField import it.unibo.alchemist.boundary.composeui.model.InspectorState +import it.unibo.alchemist.boundary.composeui.model.NodePositionUpdate import it.unibo.alchemist.boundary.composeui.model.NodeInspectorState import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState import it.unibo.alchemist.boundary.composeui.model.SimulationProgress @@ -176,6 +177,14 @@ fun demoController(): ComposeUiController { } } + override suspend fun onNodesMoved(nodePositions: List) { + store.update { currentState -> + currentState + .copy(scene = currentState.scene.withMovedNodes(nodePositions)) + .withSelection(currentState.selectedNodeIds) + } + } + override suspend fun onInspectorDismiss() { store.update { it.withSelection(emptyList()) @@ -353,6 +362,33 @@ internal fun ViewportScene.sanitizeSelection(nodeIds: List): List { return nodeIds.distinct().filter(availableNodeIds::contains) } +internal fun ViewportScene.withMovedNodes(nodePositions: List): ViewportScene { + if (nodePositions.isEmpty()) { + return this + } + val coordinatesByNodeId = nodePositions.associate { it.nodeId to it.coordinates } + return copy( + nodes = nodes.map { node -> + coordinatesByNodeId[node.id]?.let(node::withCoordinates) ?: node + }, + ) +} + +internal fun ViewportScene.translateSelectedNodes( + nodeIds: Collection, + deltaX: Double, + deltaY: Double, +): ViewportScene { + if (nodeIds.isEmpty() || (deltaX == 0.0 && deltaY == 0.0)) { + return this + } + val selectedIds = nodeIds.toHashSet() + val translatedNodes = nodes.mapNotNull { node -> + node.takeIf { it.id in selectedIds }?.translate(deltaX, deltaY)?.toPositionUpdate() + } + return withMovedNodes(translatedNodes) +} + internal fun ViewportScene.toInspectorState(selectedNodeIds: List): InspectorState? { if (selectedNodeIds.isEmpty()) { return null @@ -376,6 +412,20 @@ internal fun ViewportNode.toInspectorState(): NodeInspectorState = NodeInspector metadata = metadata, ) +internal fun ViewportNode.translate(deltaX: Double, deltaY: Double): ViewportNode = copy( + coordinates = coordinates.mapIndexed { index, coordinate -> + when (index) { + 0 -> coordinate + deltaX + 1 -> coordinate + deltaY + else -> coordinate + } + }, +) + +internal fun ViewportNode.withCoordinates(newCoordinates: List): ViewportNode = copy(coordinates = newCoordinates) + +internal fun ViewportNode.toPositionUpdate(): NodePositionUpdate = NodePositionUpdate(id, coordinates) + internal fun List.toGroupInspectorState(): GroupInspectorState { val xs = map { it.coordinates[0] } val ys = map { it.coordinates[1] } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt index 4a346c5da7..f1a4daeda8 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt @@ -59,6 +59,18 @@ data class ViewportNode( } } +/** + * Final node coordinates produced by a viewport drag interaction. + */ +@Immutable +data class NodePositionUpdate(val nodeId: Int, val coordinates: List) { + init { + require(coordinates.size >= 2) { + "Moved nodes require at least two coordinates." + } + } +} + /** * An undirected edge projected in the central viewport. */ @@ -222,6 +234,8 @@ interface AlchemistUiCallbacks { suspend fun onNodesSelected(nodeIds: List) + suspend fun onNodesMoved(nodePositions: List) + suspend fun onInspectorDismiss() suspend fun onToggleLinks() @@ -257,6 +271,8 @@ object NoOpUiCallbacks : AlchemistUiCallbacks { override suspend fun onNodesSelected(nodeIds: List) = Unit + override suspend fun onNodesMoved(nodePositions: List) = Unit + override suspend fun onInspectorDismiss() = Unit override suspend fun onToggleLinks() = Unit diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt index ecc4a88bd9..9977ea8400 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isCtrlPressed import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.layout.onGloballyPositioned @@ -47,6 +48,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import it.unibo.alchemist.boundary.composeui.formatFixed +import it.unibo.alchemist.boundary.composeui.toPositionUpdate +import it.unibo.alchemist.boundary.composeui.translateSelectedNodes import it.unibo.alchemist.boundary.composeui.model.AlchemistUiCallbacks import it.unibo.alchemist.boundary.composeui.model.ViewportScene import it.unibo.alchemist.boundary.composeui.view.theme.Background @@ -81,8 +84,11 @@ internal fun ViewportSurface( var camera by remember { mutableStateOf(ViewportCameraState()) } var fixedProjection by remember { mutableStateOf(null) } var rightDragAnchor by remember { mutableStateOf(null) } - var primaryDragAnchor by remember { mutableStateOf(null) } - var primaryDragCurrent by remember { mutableStateOf(null) } + var selectionDragAnchor by remember { mutableStateOf(null) } + var selectionDragCurrent by remember { mutableStateOf(null) } + var nodeDragAnchor by remember { mutableStateOf(null) } + var nodeDragCurrent by remember { mutableStateOf(null) } + var draggedNodeIds by remember { mutableStateOf>(emptyList()) } val candidateProjection = remember(scene.nodes, viewportSize) { scene.createViewportProjection(viewportSize) } val projection = fixedProjection ?: candidateProjection LaunchedEffect(candidateProjection) { @@ -90,12 +96,22 @@ internal fun ViewportSurface( fixedProjection = candidateProjection } } - val baseNodes = remember(scene.nodes, viewportSize, projection) { renderNodes(scene, viewportSize, projection) } + val previewScene = remember(scene, draggedNodeIds, nodeDragAnchor, nodeDragCurrent, viewportSize, camera, projection) { + val anchor = nodeDragAnchor + val current = nodeDragCurrent + if (anchor == null || current == null || projection == null) { + scene + } else { + val (deltaX, deltaY) = screenDeltaToWorldDelta(anchor, current, viewportSize, camera, projection) + scene.translateSelectedNodes(draggedNodeIds, deltaX, deltaY) + } + } + val baseNodes = remember(previewScene.nodes, viewportSize, projection) { renderNodes(previewScene, viewportSize, projection) } val density = androidx.compose.ui.platform.LocalDensity.current val tapThresholdPx = with(density) { NodeHitRadius.dp.toPx() } val dragThresholdPx = with(density) { 6.dp.toPx() } - val selectionRect = primaryDragAnchor?.let { anchor -> - val current = primaryDragCurrent ?: anchor + val selectionRect = selectionDragAnchor?.let { anchor -> + val current = selectionDragCurrent ?: anchor createSelectionRect(anchor, current).takeIf { anchor.distanceTo(current) >= dragThresholdPx } } Surface( @@ -129,12 +145,37 @@ internal fun ViewportSurface( val change = event.changes.firstOrNull() ?: return@onPointerEvent if (event.buttons.isSecondaryPressed) { rightDragAnchor = change.position - primaryDragAnchor = null - primaryDragCurrent = null + selectionDragAnchor = null + selectionDragCurrent = null + nodeDragAnchor = null + nodeDragCurrent = null + draggedNodeIds = emptyList() } else { rightDragAnchor = null - primaryDragAnchor = change.position - primaryDragCurrent = change.position + val hit = findHitNode( + baseNodes = baseNodes, + viewportSize = viewportSize, + camera = camera, + tapOffset = change.position, + tapThresholdPx = tapThresholdPx, + ) + val shouldDragSelection = + event.keyboardModifiers.isCtrlPressed && + hit != null && + hit.node.id in selectedNodeIds + if (shouldDragSelection) { + selectionDragAnchor = null + selectionDragCurrent = null + nodeDragAnchor = change.position + nodeDragCurrent = change.position + draggedNodeIds = selectedNodeIds + } else { + nodeDragAnchor = null + nodeDragCurrent = null + draggedNodeIds = emptyList() + selectionDragAnchor = change.position + selectionDragCurrent = change.position + } } } .onPointerEvent(PointerEventType.Move) { event -> @@ -148,45 +189,72 @@ internal fun ViewportSurface( rightDragAnchor = change.position } else { rightDragAnchor = null - if (primaryDragAnchor != null) { - primaryDragCurrent = change.position + if (nodeDragAnchor != null) { + nodeDragCurrent = change.position + } else if (selectionDragAnchor != null) { + selectionDragCurrent = change.position } } } .onPointerEvent(PointerEventType.Release) { event -> val releasePosition = event.changes.firstOrNull()?.position - val anchor = primaryDragAnchor - val current = primaryDragCurrent ?: releasePosition - if (anchor != null && current != null) { - if (anchor.distanceTo(current) >= dragThresholdPx) { - val mappedNodes = baseNodes.map { node -> - node.copy(center = node.center.toScreenPosition(viewportSize, camera)) + val moveAnchor = nodeDragAnchor + val moveCurrent = nodeDragCurrent ?: releasePosition + if (moveAnchor != null && moveCurrent != null) { + if (moveAnchor.distanceTo(moveCurrent) >= dragThresholdPx && projection != null) { + val (deltaX, deltaY) = screenDeltaToWorldDelta( + moveAnchor, + moveCurrent, + viewportSize, + camera, + projection, + ) + val movedNodes = scene + .translateSelectedNodes(draggedNodeIds, deltaX, deltaY) + .nodes + .filter { it.id in draggedNodeIds } + .map { it.toPositionUpdate() } + if (movedNodes.isNotEmpty()) { + coroutineScope.launch { callbacks.onNodesMoved(movedNodes) } } - val selectedIds = mappedNodes - .filter { selectionNode -> - createSelectionRect(anchor, current).contains(selectionNode.center) + } + } else { + val anchor = selectionDragAnchor + val current = selectionDragCurrent ?: releasePosition + if (anchor != null && current != null) { + if (anchor.distanceTo(current) >= dragThresholdPx) { + val mappedNodes = baseNodes.map { node -> + node.copy(center = node.center.toScreenPosition(viewportSize, camera)) } - .map { selectionNode -> selectionNode.node.id } - coroutineScope.launch { callbacks.onNodesSelected(selectedIds) } - } else { - val hit = findHitNode( - baseNodes = baseNodes, - viewportSize = viewportSize, - camera = camera, - tapOffset = current, - tapThresholdPx = tapThresholdPx, - ) - coroutineScope.launch { - if (hit != null) { - callbacks.onNodeSelected(hit.node.id) - } else { - callbacks.onInspectorDismiss() + val selectedIds = mappedNodes + .filter { selectionNode -> + createSelectionRect(anchor, current).contains(selectionNode.center) + } + .map { selectionNode -> selectionNode.node.id } + coroutineScope.launch { callbacks.onNodesSelected(selectedIds) } + } else { + val hit = findHitNode( + baseNodes = baseNodes, + viewportSize = viewportSize, + camera = camera, + tapOffset = current, + tapThresholdPx = tapThresholdPx, + ) + coroutineScope.launch { + if (hit != null) { + callbacks.onNodeSelected(hit.node.id) + } else { + callbacks.onInspectorDismiss() + } } } } } - primaryDragAnchor = null - primaryDragCurrent = null + selectionDragAnchor = null + selectionDragCurrent = null + nodeDragAnchor = null + nodeDragCurrent = null + draggedNodeIds = emptyList() rightDragAnchor = null } .onPointerEvent(PointerEventType.Scroll) { event -> @@ -213,9 +281,9 @@ internal fun ViewportSurface( val mappedNodes = baseNodes.map { node -> node.copy(center = node.center.toScreenPosition(viewportSize, currentCamera)) } - val mappedEdges = renderEdges(scene.edges, mappedNodes) + val mappedEdges = renderEdges(previewScene.edges, mappedNodes) - if (scene.showLinks) { + if (previewScene.showLinks) { mappedEdges.forEach { edge -> drawLine( color = Outline.copy(alpha = 0.42f), @@ -304,7 +372,7 @@ internal fun ViewportSurface( text = if (scene.nodes.isEmpty()) { "No nodes to display" } else { - "Click to inspect · drag to select · right-drag to pan · wheel to zoom" + "Click to inspect · drag to select · Ctrl-drag selected nodes · right-drag to pan · wheel to zoom" }, modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), style = MaterialTheme.typography.caption, @@ -477,3 +545,24 @@ private fun findHitNode( .minByOrNull { node -> node.center.distanceTo(tapInBaseSpace) } ?.takeIf { node -> node.center.distanceTo(tapInBaseSpace) <= tapThresholdPx / camera.zoom } } + +private fun screenDeltaToWorldDelta( + anchor: Offset, + current: Offset, + viewportSize: IntSize, + camera: ViewportCameraState, + projection: ViewportProjection, +): Pair { + val anchorInBaseSpace = anchor + .toWorldPosition(viewportSize, camera) + .toScreenPosition(viewportSize, ViewportCameraState()) + val currentInBaseSpace = current + .toWorldPosition(viewportSize, camera) + .toScreenPosition(viewportSize, ViewportCameraState()) + val baseDelta = currentInBaseSpace - anchorInBaseSpace + return ( + baseDelta.x / projection.pixelsPerUnit + ).toDouble() to ( + -baseDelta.y / projection.pixelsPerUnit + ).toDouble() +} diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportNodeMovementTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportNodeMovementTest.kt new file mode 100644 index 0000000000..d127bb6527 --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportNodeMovementTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiState +import it.unibo.alchemist.boundary.composeui.model.GroupInspectorState +import it.unibo.alchemist.boundary.composeui.model.NodePositionUpdate +import it.unibo.alchemist.boundary.composeui.model.ViewportNode +import it.unibo.alchemist.boundary.composeui.model.ViewportScene +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ViewportNodeMovementTest { + @Test + fun `translate selected nodes applies one delta and preserves trailing coordinates`() { + val scene = ViewportScene( + nodes = listOf( + ViewportNode(id = 1, coordinates = listOf(0.0, 1.0, 7.0)), + ViewportNode(id = 2, coordinates = listOf(3.0, -2.0, 9.0)), + ViewportNode(id = 3, coordinates = listOf(10.0, 10.0, 11.0)), + ), + ) + + val moved = scene.translateSelectedNodes(listOf(1, 2), deltaX = 1.5, deltaY = -0.5) + + assertEquals(listOf(1.5, 0.5, 7.0), moved.nodes.first { it.id == 1 }.coordinates) + assertEquals(listOf(4.5, -2.5, 9.0), moved.nodes.first { it.id == 2 }.coordinates) + assertEquals(listOf(10.0, 10.0, 11.0), moved.nodes.first { it.id == 3 }.coordinates) + } + + @Test + fun `with moved nodes replaces only targeted coordinates`() { + val scene = ViewportScene( + nodes = listOf( + ViewportNode(id = 1, coordinates = listOf(0.0, 0.0)), + ViewportNode(id = 2, coordinates = listOf(1.0, 1.0)), + ), + ) + + val moved = scene.withMovedNodes(listOf(NodePositionUpdate(nodeId = 2, coordinates = listOf(8.0, -3.0)))) + + assertEquals(listOf(0.0, 0.0), moved.nodes.first { it.id == 1 }.coordinates) + assertEquals(listOf(8.0, -3.0), moved.nodes.first { it.id == 2 }.coordinates) + } + + @Test + fun `selection inspector reflects moved group bounds after commit`() { + val state = AlchemistUiState( + scene = ViewportScene( + nodes = listOf( + ViewportNode(id = 1, coordinates = listOf(0.0, 1.0)), + ViewportNode(id = 2, coordinates = listOf(4.0, 3.0)), + ), + ), + selectedNodeIds = listOf(1, 2), + ) + + val moved = state + .copy(scene = state.scene.translateSelectedNodes(state.selectedNodeIds, deltaX = 2.0, deltaY = -1.5)) + .withSelection(state.selectedNodeIds) + + val inspector = assertIs(moved.inspector) + assertEquals(listOf(1, 2), moved.selectedNodeIds) + assertEquals("2.000", inspector.position.first { it.label == "Min X" }.value) + assertEquals("6.000", inspector.position.first { it.label == "Max X" }.value) + assertEquals("-0.500", inspector.position.first { it.label == "Min Y" }.value) + assertEquals("1.500", inspector.position.first { it.label == "Max Y" }.value) + } +} diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt index 394ef71255..d1c0f1cb78 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt @@ -11,9 +11,11 @@ package it.unibo.alchemist.boundary.composeui import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.DISPLAYED_TIME_DECIMALS import it.unibo.alchemist.boundary.composeui.adapter.toSimulationStatus +import it.unibo.alchemist.boundary.composeui.adapter.toViewport import it.unibo.alchemist.boundary.composeui.model.AlchemistUiCallbacks import it.unibo.alchemist.boundary.composeui.model.AlchemistUiState import it.unibo.alchemist.boundary.composeui.model.ControlDialogState +import it.unibo.alchemist.boundary.composeui.model.NodePositionUpdate import it.unibo.alchemist.core.Simulation import it.unibo.alchemist.core.Status import it.unibo.alchemist.model.Position @@ -22,6 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.future.await import kotlinx.coroutines.withContext import kotlin.math.roundToInt +import kotlin.runCatching class DesktopAlchemistUiCallback>( private val simulation: Simulation, @@ -139,6 +142,31 @@ class DesktopAlchemistUiCallback>( } } + override suspend fun onNodesMoved(nodePositions: List) { + if (nodePositions.isEmpty()) { + return + } + val environment = simulation.environment + val completion = java.util.concurrent.CompletableFuture() + simulation.schedule { + runCatching { + nodePositions.forEach { nodePosition -> + val node = environment.getNodeByID(nodePosition.nodeId) + val newPosition = environment.makePosition(nodePosition.coordinates) + environment.moveNodeToPosition(node, newPosition) + simulation.nodeMoved(node) + } + }.onSuccess { + completion.complete(Unit) + }.onFailure { error -> + completion.completeExceptionally(error) + throw error + } + } + completion.await() + syncSimulationState(refreshScene = true) + } + override suspend fun onInspectorDismiss() { updateState { it.withSelection(emptyList()) @@ -165,16 +193,22 @@ class DesktopAlchemistUiCallback>( } } - private suspend fun syncSimulationState() { + private suspend fun syncSimulationState(refreshScene: Boolean = false) { updateState { + val nextScene = if (refreshScene) { + simulation.environment.toViewport().copy(showLinks = it.scene.showLinks) + } else { + it.scene + } it.copy( + scene = nextScene, controls = it.controls.copy( status = simulation.toSimulationStatus(), timeLabel = simulation.time.toDouble().formatFixed(DISPLAYED_TIME_DECIMALS), step = simulation.step, dialog = null, ), - ) + ).withSelection(it.selectedNodeIds) } } diff --git a/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml index 10958e7f49..e6ef6f71df 100644 --- a/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml +++ b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml @@ -10,6 +10,8 @@ monitors: deployments: type: Grid parameters: [-5, -5, 5, 5, 0.25, 0.25, 0.1, 0.1] +# type: Rectangle +# parameters: [10000, -5, -5, 10, 10] contents: - in: type: Rectangle diff --git a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt new file mode 100644 index 0000000000..bc6429d293 --- /dev/null +++ b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui + +import it.unibo.alchemist.boundary.composeui.adapter.toViewport +import it.unibo.alchemist.boundary.composeui.model.AlchemistUiState +import it.unibo.alchemist.boundary.composeui.model.NodePositionUpdate +import it.unibo.alchemist.core.Simulation +import it.unibo.alchemist.core.Status +import it.unibo.alchemist.model.Action +import it.unibo.alchemist.model.Actionable +import it.unibo.alchemist.model.Condition +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Incarnation +import it.unibo.alchemist.model.Molecule +import it.unibo.alchemist.model.Neighborhood +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.Reaction +import it.unibo.alchemist.model.Time +import it.unibo.alchemist.model.TimeDistribution +import it.unibo.alchemist.model.environments.Continuous2DEnvironment +import it.unibo.alchemist.model.molecules.SimpleMolecule +import it.unibo.alchemist.model.nodes.GenericNode +import it.unibo.alchemist.model.positions.Euclidean2DPosition +import it.unibo.alchemist.model.times.DoubleTime +import java.util.Optional +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import kotlin.coroutines.Continuation +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine +import kotlin.test.Test +import kotlin.test.assertEquals +import org.apache.commons.math3.random.RandomGenerator +import org.jooq.lambda.fi.lang.CheckedRunnable + +class DesktopAlchemistUiNodeMoveTest { + @Test + fun `node move commits translated positions through the simulator and refreshes the scene`() { + val environment = Continuous2DEnvironment(TestIncarnation()) + val firstNode = GenericNode(environment) + val secondNode = GenericNode(environment) + environment.addNode(firstNode, environment.makePosition(0.0, 0.0)) + environment.addNode(secondNode, environment.makePosition(2.0, 3.0)) + val simulation = RecordingMoveSimulation(environment) + val store = ComposeUiStateStore( + AlchemistUiState( + scene = environment.toViewport(), + selectedNodeIds = listOf(firstNode.id, secondNode.id), + ).withSelection(listOf(firstNode.id, secondNode.id)), + ) + val callback = DesktopAlchemistUiCallback(simulation, store) + + runSuspend { + callback.onNodesMoved( + listOf( + NodePositionUpdate(firstNode.id, listOf(1.5, -2.0)), + NodePositionUpdate(secondNode.id, listOf(3.5, 1.0)), + ), + ) + } + + assertEquals(1, simulation.scheduleCalls) + assertEquals(listOf(firstNode.id, secondNode.id), simulation.movedNodeIds) + assertEquals(listOf(1.5, -2.0), environment.getPosition(firstNode).coordinates.toList()) + assertEquals(listOf(3.5, 1.0), environment.getPosition(secondNode).coordinates.toList()) + assertEquals(listOf(firstNode.id, secondNode.id), store.state.selectedNodeIds) + assertEquals( + listOf(1.5, -2.0), + store.state.scene.nodes.first { it.id == firstNode.id }.coordinates, + ) + assertEquals( + listOf(3.5, 1.0), + store.state.scene.nodes.first { it.id == secondNode.id }.coordinates, + ) + } +} + +private class RecordingMoveSimulation( + private val environmentValue: Environment, +) : Simulation { + var statusValue: Status = Status.PAUSED + var timeValue: Time = DoubleTime(0.0) + var stepValue: Long = 0L + var scheduleCalls: Int = 0 + val movedNodeIds = mutableListOf() + + override fun addOutputMonitor(op: it.unibo.alchemist.boundary.OutputMonitor) = Unit + + override fun getEnvironment(): Environment = environmentValue + + override fun getError(): Optional = Optional.empty() + + override fun getStatus(): Status = statusValue + + override fun getStep(): Long = stepValue + + override fun getTime(): Time = timeValue + + override fun goToStep(step: Long): CompletableFuture = CompletableFuture.completedFuture(Unit) + + override fun goToTime(t: Time): CompletableFuture = CompletableFuture.completedFuture(Unit) + + override fun neighborAdded(node: Node, n: Node) = Unit + + override fun neighborRemoved(node: Node, n: Node) = Unit + + override fun nodeAdded(node: Node) = Unit + + override fun nodeMoved(node: Node) { + movedNodeIds += node.id + } + + override fun nodeRemoved(node: Node, oldNeighborhood: Neighborhood) = Unit + + override fun pause(): CompletableFuture = CompletableFuture.completedFuture(Unit) + + override fun play(): CompletableFuture = CompletableFuture.completedFuture(Unit) + + override fun reactionAdded(reactionToAdd: Actionable) = Unit + + override fun reactionRemoved(reactionToRemove: Actionable) = Unit + + override fun removeOutputMonitor(op: it.unibo.alchemist.boundary.OutputMonitor) = Unit + + override fun schedule(r: CheckedRunnable) { + scheduleCalls++ + r.run() + } + + override fun terminate(): CompletableFuture = CompletableFuture.completedFuture(Unit) + + override fun waitFor(s: Status, timeout: Long, timeunit: TimeUnit): Status = s + + override fun getOutputMonitors(): List> = + emptyList() + + override fun run() = Unit +} + +private class TestIncarnation : Incarnation { + override fun getProperty(node: Node, molecule: Molecule, property: String): Double = Double.NaN + + override fun createMolecule(s: String): Molecule = SimpleMolecule(s) + + override fun createConcentration(descriptor: Any?): Any = descriptor ?: Unit + + override fun createConcentration(): Any = Unit + + override fun createNode( + randomGenerator: RandomGenerator, + environment: Environment, + parameter: Any?, + ): Node = GenericNode(environment) + + override fun createTimeDistribution( + randomGenerator: RandomGenerator, + environment: Environment, + node: Node?, + parameter: Any?, + ): TimeDistribution = error("not used") + + override fun createReaction( + randomGenerator: RandomGenerator, + environment: Environment, + node: Node, + timeDistribution: TimeDistribution, + parameter: Any?, + ): Reaction = error("not used") + + override fun createCondition( + randomGenerator: RandomGenerator, + environment: Environment, + node: Node?, + actionable: Actionable, + additionalParameters: Any?, + ): Condition = error("not used") + + override fun createAction( + randomGenerator: RandomGenerator, + environment: Environment, + node: Node?, + actionable: Actionable, + additionalParameters: Any?, + ): Action = error("not used") +} + +private fun runSuspend(block: suspend () -> Unit) { + block.startCoroutine( + object : Continuation { + override val context = EmptyCoroutineContext + + override fun resumeWith(result: Result) { + result.getOrThrow() + } + }, + ) +} From a828d1eb3701376f6ff923160bd58be9dbcc3e55 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Thu, 23 Apr 2026 15:35:02 +0200 Subject: [PATCH 18/22] fix: minor fix on rendering updated positions --- .../alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt | 1 - .../boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt index d1c0f1cb78..858bcd68c5 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt @@ -154,7 +154,6 @@ class DesktopAlchemistUiCallback>( val node = environment.getNodeByID(nodePosition.nodeId) val newPosition = environment.makePosition(nodePosition.coordinates) environment.moveNodeToPosition(node, newPosition) - simulation.nodeMoved(node) } }.onSuccess { completion.complete(Unit) diff --git a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt index bc6429d293..a01eef19f7 100644 --- a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt +++ b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt @@ -50,6 +50,7 @@ class DesktopAlchemistUiNodeMoveTest { environment.addNode(firstNode, environment.makePosition(0.0, 0.0)) environment.addNode(secondNode, environment.makePosition(2.0, 3.0)) val simulation = RecordingMoveSimulation(environment) + environment.simulation = simulation val store = ComposeUiStateStore( AlchemistUiState( scene = environment.toViewport(), From f2a9f9ebf916435c6881c603e1be5295b4d3d6db Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Fri, 24 Apr 2026 17:06:40 +0200 Subject: [PATCH 19/22] perf: some optimizations --- .../alchemist/boundary/composeui/UiStore.kt | 54 ++++- .../boundary/composeui/model/UiModel.kt | 56 +++++ .../composeui/view/viewport/SummaryRail.kt | 30 ++- .../view/viewport/ViewportProjection.kt | 11 +- .../view/viewport/ViewportRendering.kt | 212 ++++++++++++++++-- .../view/viewport/ViewportSurface.kt | 78 +++---- .../composeui/SimulationControlsStateTest.kt | 9 +- .../viewport/ViewportRenderingPolicyTest.kt | 72 ++++++ .../boundary/composeui/ComposeMonitor.kt | 2 +- .../composeui/DesktopAlchemistUiCallback.kt | 7 +- .../composeui/adapter/AlchemistNodeAdapter.kt | 134 +++++++++-- .../src/jvmMain/resources/composeui-demo.yml | 8 +- .../adapter/AlchemistNodeAdapterTest.kt | 30 +++ 13 files changed, 605 insertions(+), 98 deletions(-) create mode 100644 alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRenderingPolicyTest.kt diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt index 300e6a5917..377737dc77 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt @@ -28,6 +28,7 @@ import it.unibo.alchemist.boundary.composeui.model.ViewportBackdrop import it.unibo.alchemist.boundary.composeui.model.ViewportEdge import it.unibo.alchemist.boundary.composeui.model.ViewportNode import it.unibo.alchemist.boundary.composeui.model.ViewportScene +import it.unibo.alchemist.boundary.composeui.model.ViewportWorldBounds import kotlin.math.ceil import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableStateFlow @@ -367,10 +368,12 @@ internal fun ViewportScene.withMovedNodes(nodePositions: List + coordinatesByNodeId[node.id]?.let(node::withCoordinates) ?: node + } return copy( - nodes = nodes.map { node -> - coordinatesByNodeId[node.id]?.let(node::withCoordinates) ?: node - }, + nodes = updatedNodes, + worldBounds = updatedNodes.toWorldBounds(), ) } @@ -383,10 +386,17 @@ internal fun ViewportScene.translateSelectedNodes( return this } val selectedIds = nodeIds.toHashSet() - val translatedNodes = nodes.mapNotNull { node -> - node.takeIf { it.id in selectedIds }?.translate(deltaX, deltaY)?.toPositionUpdate() + val updatedNodes = nodes.map { node -> + if (node.id in selectedIds) { + node.translate(deltaX, deltaY) + } else { + node + } } - return withMovedNodes(translatedNodes) + return copy( + nodes = updatedNodes, + worldBounds = updatedNodes.toWorldBounds(), + ) } internal fun ViewportScene.toInspectorState(selectedNodeIds: List): InspectorState? { @@ -426,6 +436,38 @@ internal fun ViewportNode.withCoordinates(newCoordinates: List): Viewpor internal fun ViewportNode.toPositionUpdate(): NodePositionUpdate = NodePositionUpdate(id, coordinates) +private fun List.toWorldBounds(): ViewportWorldBounds? { + if (isEmpty()) { + return null + } + var minX = Double.POSITIVE_INFINITY + var maxX = Double.NEGATIVE_INFINITY + var minY = Double.POSITIVE_INFINITY + var maxY = Double.NEGATIVE_INFINITY + forEach { node -> + val x = node.coordinates[0] + val y = node.coordinates[1] + if (x < minX) { + minX = x + } + if (x > maxX) { + maxX = x + } + if (y < minY) { + minY = y + } + if (y > maxY) { + maxY = y + } + } + return ViewportWorldBounds( + minX = minX, + maxX = maxX, + minY = minY, + maxY = maxY, + ) +} + internal fun List.toGroupInspectorState(): GroupInspectorState { val xs = map { it.coordinates[0] } val ys = map { it.coordinates[1] } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt index f1a4daeda8..6f2c1f3e07 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt @@ -83,6 +83,26 @@ data class ViewportEdge(val fromNodeId: Int, val toNodeId: Int) { } } +/** + * The world-space bounds of the viewport scene. + */ +@Immutable +data class ViewportWorldBounds( + val minX: Double, + val maxX: Double, + val minY: Double, + val maxY: Double, +) + +/** + * Effective link rendering strategy used for the current scene snapshot. + */ +enum class LinkRenderMode { + FULL, + SAMPLED, + HIDDEN, +} + /** * State of the central scene area. */ @@ -90,8 +110,12 @@ data class ViewportEdge(val fromNodeId: Int, val toNodeId: Int) { data class ViewportScene( val nodes: List = emptyList(), val edges: List = emptyList(), + val edgeCount: Int = edges.size, val showLinks: Boolean = false, + val linkRenderMode: LinkRenderMode = LinkRenderMode.FULL, + val linkRenderNotice: String? = null, val dimensions: Int = 2, + val worldBounds: ViewportWorldBounds? = nodes.toWorldBounds(), val backdrop: ViewportBackdrop = ViewportBackdrop.SPACE, val summary: List = emptyList(), val message: String = "Waiting for simulation data", @@ -206,6 +230,38 @@ data class AlchemistUiState( val inspector: InspectorState? = null, ) +private fun List.toWorldBounds(): ViewportWorldBounds? { + if (isEmpty()) { + return null + } + var minX = Double.POSITIVE_INFINITY + var maxX = Double.NEGATIVE_INFINITY + var minY = Double.POSITIVE_INFINITY + var maxY = Double.NEGATIVE_INFINITY + forEach { node -> + val x = node.coordinates[0] + val y = node.coordinates[1] + if (x < minX) { + minX = x + } + if (x > maxX) { + maxX = x + } + if (y < minY) { + minY = y + } + if (y > maxY) { + maxY = y + } + } + return ViewportWorldBounds( + minX = minX, + maxX = maxX, + minY = minY, + maxY = maxY, + ) +} + /** * Interaction contract expected by the common Compose UI. */ diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt index 297d50b40b..d1547eaa8c 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt @@ -29,7 +29,12 @@ import it.unibo.alchemist.boundary.composeui.view.theme.SurfaceStrong import it.unibo.alchemist.boundary.composeui.view.theme.TextSecondary @Composable -internal fun SummaryRail(summary: List, showLinks: Boolean, onToggleLinks: () -> Unit) { +internal fun SummaryRail( + summary: List, + showLinks: Boolean, + onToggleLinks: () -> Unit, + linkRenderNotice: String? = null, +) { Row( modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -57,6 +62,29 @@ internal fun SummaryRail(summary: List, showLinks: Boolean, onToggleL } } } + linkRenderNotice?.let { notice -> + Surface( + color = SecondaryAccent.copy(alpha = 0.12f), + shape = RoundedCornerShape(999.dp), + elevation = 0.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "ADAPTIVE", + style = MaterialTheme.typography.caption, + color = SecondaryAccent, + ) + Text( + text = notice, + style = MaterialTheme.typography.subtitle1, + ) + } + } + } Surface( modifier = Modifier.clickable(onClick = onToggleLinks), color = if (showLinks) SecondaryAccent.copy(alpha = 0.2f) else SurfaceStrong.copy(alpha = 0.82f), diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportProjection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportProjection.kt index 54e67e5458..efbf4bea9f 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportProjection.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportProjection.kt @@ -34,12 +34,11 @@ internal fun ViewportScene.createViewportProjection(viewportSize: IntSize): View if (nodes.isEmpty() || viewportSize.width <= 0 || viewportSize.height <= 0) { return null } - val xs = nodes.map { it.coordinates[0] } - val ys = nodes.map { it.coordinates[1] } - val minX = xs.minOrNull() ?: return null - val maxX = xs.maxOrNull() ?: return null - val minY = ys.minOrNull() ?: return null - val maxY = ys.maxOrNull() ?: return null + val bounds = worldBounds ?: return null + val minX = bounds.minX + val maxX = bounds.maxX + val minY = bounds.minY + val maxY = bounds.maxY val xSpan = max(MinWorldSpan, maxX - minX) val ySpan = max(MinWorldSpan, maxY - minY) val safeWidth = viewportSize.width.toFloat() diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt index 42ab57ba3c..37d1c75ad7 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt @@ -13,39 +13,197 @@ package it.unibo.alchemist.boundary.composeui.view.viewport import androidx.compose.runtime.Immutable import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.unit.IntSize +import it.unibo.alchemist.boundary.composeui.model.LinkRenderMode import it.unibo.alchemist.boundary.composeui.model.ViewportEdge import it.unibo.alchemist.boundary.composeui.model.ViewportNode import it.unibo.alchemist.boundary.composeui.model.ViewportScene +import it.unibo.alchemist.boundary.composeui.view.theme.NodeHitRadius import it.unibo.alchemist.boundary.composeui.view.theme.ZoomStep +import kotlin.math.floor +import kotlin.math.sqrt -internal fun renderNodes( +internal const val FullEdgeRenderLimit = 20_000 +internal const val SampledEdgeRenderLimit = 200_000 +internal const val MaxDrawnEdgesPerFrame = 5_000 +private const val SpatialIndexThreshold = 2_000 +private const val SpatialIndexCellSize = 64f + +internal fun buildViewportSceneCache( scene: ViewportScene, viewportSize: IntSize, projection: ViewportProjection?, -): List { +): ViewportSceneCache { if (projection == null || scene.nodes.isEmpty() || viewportSize.width == 0 || viewportSize.height == 0) { - return emptyList() + return ViewportSceneCache(scene = scene) + } + val baseCenters = scene.nodes.map { node -> node.toViewportPosition(viewportSize, projection) } + val nodeIndexById = scene.nodes.withIndex().associate { (index, node) -> node.id to index } + val indexedEdges = scene.edges.mapNotNull { edge -> + val fromIndex = nodeIndexById[edge.fromNodeId] ?: return@mapNotNull null + val toIndex = nodeIndexById[edge.toNodeId] ?: return@mapNotNull null + IndexedEdge(fromIndex = fromIndex, toIndex = toIndex) + } + val spatialIndex = if (scene.nodes.size >= SpatialIndexThreshold) { + NodeSpatialIndex(baseCenters) + } else { + null + } + return ViewportSceneCache( + scene = scene, + baseCenters = baseCenters, + indexedEdges = indexedEdges, + spatialIndex = spatialIndex, + ) +} + +internal fun buildViewportFrame( + cache: ViewportSceneCache, + viewportSize: IntSize, + camera: ViewportCameraState, +): ViewportFrame { + if (cache.baseCenters.isEmpty() || viewportSize.width == 0 || viewportSize.height == 0) { + return ViewportFrame.empty(cache.baseCenters.size) + } + val visibleBaseRect = viewportSize + .toBaseRect(camera) + .inflate(NodeHitRadius / camera.zoom.coerceAtLeast(1e-6f)) + val visibleNodeIndices = cache.queryNodeIndices(visibleBaseRect) + val screenPositions = arrayOfNulls(cache.baseCenters.size) + visibleNodeIndices.forEach { nodeIndex -> + screenPositions[nodeIndex] = cache.baseCenters[nodeIndex].toScreenPosition(viewportSize, camera) } - return scene.nodes.map { node -> - RenderedNode(node = node, center = node.toViewportPosition(viewportSize, projection)) + val visibleEdges = if (!cache.scene.showLinks || cache.scene.linkRenderMode == LinkRenderMode.HIDDEN) { + emptyList() + } else { + val edgeBudget = when (cache.scene.linkRenderMode) { + LinkRenderMode.FULL -> cache.indexedEdges.size + LinkRenderMode.SAMPLED -> MaxDrawnEdgesPerFrame + LinkRenderMode.HIDDEN -> 0 + } + buildList(minOf(cache.indexedEdges.size, edgeBudget)) { + for (edge in cache.indexedEdges) { + if (size >= edgeBudget) { + break + } + if (screenPositions[edge.fromIndex] != null && screenPositions[edge.toIndex] != null) { + add(edge) + } + } + } + } + return ViewportFrame( + screenPositions = screenPositions, + visibleNodeIndices = visibleNodeIndices, + visibleEdges = visibleEdges, + ) +} + +internal fun ViewportSceneCache.findHitNode( + viewportSize: IntSize, + camera: ViewportCameraState, + tapOffset: Offset, + tapThresholdPx: Float, +): ViewportNode? { + if (baseCenters.isEmpty()) { + return null + } + val tapInBaseSpace = tapOffset.toBasePosition(viewportSize, camera) + val tapRadius = tapThresholdPx / camera.zoom.coerceAtLeast(1e-6f) + val candidateIndices = queryNodeIndices( + Rect( + left = tapInBaseSpace.x - tapRadius, + top = tapInBaseSpace.y - tapRadius, + right = tapInBaseSpace.x + tapRadius, + bottom = tapInBaseSpace.y + tapRadius, + ), + ) + val bestMatch = candidateIndices + .minByOrNull { nodeIndex -> baseCenters[nodeIndex].distanceTo(tapInBaseSpace) } + ?.takeIf { nodeIndex -> baseCenters[nodeIndex].distanceTo(tapInBaseSpace) <= tapRadius } + return bestMatch?.let(scene.nodes::get) +} + +internal fun ViewportSceneCache.selectNodes( + viewportSize: IntSize, + camera: ViewportCameraState, + selectionRect: Rect, +): List { + if (baseCenters.isEmpty()) { + return emptyList() } + val selectionInBaseSpace = selectionRect.toBaseRect(viewportSize, camera) + return queryNodeIndices(selectionInBaseSpace) + .filter { nodeIndex -> selectionInBaseSpace.contains(baseCenters[nodeIndex]) } + .map { nodeIndex -> scene.nodes[nodeIndex].id } } -internal fun renderEdges(edges: List, renderedNodes: List): List { - val nodesById = renderedNodes.associateBy { it.node.id } - return edges.mapNotNull { edge -> - val from = nodesById[edge.fromNodeId] ?: return@mapNotNull null - val to = nodesById[edge.toNodeId] ?: return@mapNotNull null - RenderedEdge(start = from.center, end = to.center) +@Immutable +internal data class ViewportSceneCache( + val scene: ViewportScene, + val baseCenters: List = emptyList(), + val indexedEdges: List = emptyList(), + val spatialIndex: NodeSpatialIndex? = null, +) { + fun queryNodeIndices(rect: Rect): List = spatialIndex?.query(rect) ?: baseCenters.indices.filter { index -> + rect.contains(baseCenters[index]) } } @Immutable -internal data class RenderedNode(val node: ViewportNode, val center: Offset) +internal data class ViewportFrame( + val screenPositions: Array, + val visibleNodeIndices: List, + val visibleEdges: List, +) { + companion object { + fun empty(nodeCount: Int): ViewportFrame = ViewportFrame( + screenPositions = arrayOfNulls(nodeCount), + visibleNodeIndices = emptyList(), + visibleEdges = emptyList(), + ) + } +} @Immutable -internal data class RenderedEdge(val start: Offset, val end: Offset) +internal data class IndexedEdge(val fromIndex: Int, val toIndex: Int) + +internal class NodeSpatialIndex(positions: List) { + private val cells = mutableMapOf>() + + init { + positions.forEachIndexed { index, position -> + cells.getOrPut(cellKey(position)) { mutableListOf() }.add(index) + } + } + + fun query(rect: Rect): List { + if (rect.isEmpty) { + return emptyList() + } + val minCellX = floor(rect.left / SpatialIndexCellSize).toInt() + val maxCellX = floor(rect.right / SpatialIndexCellSize).toInt() + val minCellY = floor(rect.top / SpatialIndexCellSize).toInt() + val maxCellY = floor(rect.bottom / SpatialIndexCellSize).toInt() + val matches = mutableListOf() + for (cellX in minCellX..maxCellX) { + for (cellY in minCellY..maxCellY) { + cells[cellKey(cellX, cellY)]?.let(matches::addAll) + } + } + return matches + } + + private fun cellKey(position: Offset): Long = + cellKey( + floor(position.x / SpatialIndexCellSize).toInt(), + floor(position.y / SpatialIndexCellSize).toInt(), + ) + + private fun cellKey(cellX: Int, cellY: Int): Long = + (cellX.toLong() shl 32) xor (cellY.toLong() and 0xffffffffL) +} @Immutable internal data class ViewportCameraState(val pan: Offset = Offset.Zero, val zoom: Float = 1f) @@ -53,7 +211,7 @@ internal data class ViewportCameraState(val pan: Offset = Offset.Zero, val zoom: internal fun Offset.distanceTo(other: Offset): Float { val dx = x - other.x val dy = y - other.y - return kotlin.math.sqrt(dx * dx + dy * dy) + return sqrt(dx * dx + dy * dy) } internal fun Offset.toScreenPosition(viewportSize: IntSize, camera: ViewportCameraState): Offset { @@ -101,5 +259,31 @@ internal fun Offset.toWorldPosition(viewportSize: IntSize, camera: ViewportCamer return center + ((this - center - camera.pan) / camera.zoom) } +internal fun Offset.toBasePosition(viewportSize: IntSize, camera: ViewportCameraState): Offset = + toWorldPosition(viewportSize, camera).toScreenPosition(viewportSize, ViewportCameraState()) + +internal fun Rect.toBaseRect(viewportSize: IntSize, camera: ViewportCameraState): Rect { + val topLeft = topLeft.toBasePosition(viewportSize, camera) + val bottomRight = bottomRight.toBasePosition(viewportSize, camera) + return Rect( + left = minOf(topLeft.x, bottomRight.x), + top = minOf(topLeft.y, bottomRight.y), + right = maxOf(topLeft.x, bottomRight.x), + bottom = maxOf(topLeft.y, bottomRight.y), + ) +} + +private fun IntSize.toBaseRect(camera: ViewportCameraState): Rect = Rect( + topLeft = Offset.Zero.toBasePosition(this, camera), + bottomRight = Offset(width.toFloat(), height.toFloat()).toBasePosition(this, camera), +) + +private fun Rect.inflate(amount: Float): Rect = Rect( + left = left - amount, + top = top - amount, + right = right + amount, + bottom = bottom + amount, +) + internal val IntSize.center: Offset get() = Offset(width / 2f, height / 2f) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt index 9977ea8400..39cbecd120 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt @@ -106,7 +106,13 @@ internal fun ViewportSurface( scene.translateSelectedNodes(draggedNodeIds, deltaX, deltaY) } } - val baseNodes = remember(previewScene.nodes, viewportSize, projection) { renderNodes(previewScene, viewportSize, projection) } + val sceneCache = remember(previewScene, viewportSize, projection) { + buildViewportSceneCache(previewScene, viewportSize, projection) + } + val viewportFrame = remember(sceneCache, viewportSize, camera) { + buildViewportFrame(sceneCache, viewportSize, camera) + } + val selectedNodeIdSet = remember(selectedNodeIds) { selectedNodeIds.toHashSet() } val density = androidx.compose.ui.platform.LocalDensity.current val tapThresholdPx = with(density) { NodeHitRadius.dp.toPx() } val dragThresholdPx = with(density) { 6.dp.toPx() } @@ -152,8 +158,7 @@ internal fun ViewportSurface( draggedNodeIds = emptyList() } else { rightDragAnchor = null - val hit = findHitNode( - baseNodes = baseNodes, + val hit = sceneCache.findHitNode( viewportSize = viewportSize, camera = camera, tapOffset = change.position, @@ -162,7 +167,7 @@ internal fun ViewportSurface( val shouldDragSelection = event.keyboardModifiers.isCtrlPressed && hit != null && - hit.node.id in selectedNodeIds + hit.id in selectedNodeIdSet if (shouldDragSelection) { selectionDragAnchor = null selectionDragCurrent = null @@ -223,18 +228,14 @@ internal fun ViewportSurface( val current = selectionDragCurrent ?: releasePosition if (anchor != null && current != null) { if (anchor.distanceTo(current) >= dragThresholdPx) { - val mappedNodes = baseNodes.map { node -> - node.copy(center = node.center.toScreenPosition(viewportSize, camera)) - } - val selectedIds = mappedNodes - .filter { selectionNode -> - createSelectionRect(anchor, current).contains(selectionNode.center) - } - .map { selectionNode -> selectionNode.node.id } + val selectedIds = sceneCache.selectNodes( + viewportSize = viewportSize, + camera = camera, + selectionRect = createSelectionRect(anchor, current), + ) coroutineScope.launch { callbacks.onNodesSelected(selectedIds) } } else { - val hit = findHitNode( - baseNodes = baseNodes, + val hit = sceneCache.findHitNode( viewportSize = viewportSize, camera = camera, tapOffset = current, @@ -242,7 +243,7 @@ internal fun ViewportSurface( ) coroutineScope.launch { if (hit != null) { - callbacks.onNodeSelected(hit.node.id) + callbacks.onNodeSelected(hit.id) } else { callbacks.onInspectorDismiss() } @@ -276,27 +277,24 @@ internal fun ViewportSurface( ) val currentCamera = camera drawGrid(size, currentCamera) - - // Map the base nodes and edges down here in the Draw phase - val mappedNodes = baseNodes.map { node -> - node.copy(center = node.center.toScreenPosition(viewportSize, currentCamera)) - } - val mappedEdges = renderEdges(previewScene.edges, mappedNodes) - if (previewScene.showLinks) { - mappedEdges.forEach { edge -> + viewportFrame.visibleEdges.forEach { edge -> + val start = viewportFrame.screenPositions[edge.fromIndex] ?: return@forEach + val end = viewportFrame.screenPositions[edge.toIndex] ?: return@forEach drawLine( color = Outline.copy(alpha = 0.42f), - start = edge.start, - end = edge.end, + start = start, + end = end, strokeWidth = LinkStrokeWidth.dp.toPx(), cap = StrokeCap.Round, ) } } - mappedNodes.forEach { rendered -> - val isSelected = rendered.node.id in selectedNodeIds - val nodeColor = lerp(SecondaryAccent, PrimaryAccent, rendered.node.accent) + viewportFrame.visibleNodeIndices.forEach { nodeIndex -> + val node = previewScene.nodes[nodeIndex] + val center = viewportFrame.screenPositions[nodeIndex] ?: return@forEach + val isSelected = node.id in selectedNodeIdSet + val nodeColor = lerp(SecondaryAccent, PrimaryAccent, node.accent) val screenRadius = NodeRadius.dp.toPx() * currentCamera.zoom val screenSelectedRadius = SelectedNodeRadius.dp.toPx() * currentCamera.zoom val screenSelectedInnerRadius = SelectedNodeInnerRadius.dp.toPx() * currentCamera.zoom @@ -305,23 +303,23 @@ internal fun ViewportSurface( drawCircle( color = nodeColor.copy(alpha = 0.20f), radius = screenSelectedRadius, - center = rendered.center, + center = center, ) drawCircle( color = PrimaryAccent, radius = screenSelectedInnerRadius, - center = rendered.center, + center = center, style = Stroke(width = 2.dp.toPx() * currentCamera.zoom), ) } drawCircle( brush = Brush.radialGradient( colors = listOf(nodeColor, nodeColor.copy(alpha = 0.45f)), - center = rendered.center, + center = center, radius = max(1f, screenSelectedRadius), ), radius = screenRadius, - center = rendered.center, + center = center, ) } selectionRect?.let { selection -> @@ -358,6 +356,7 @@ internal fun ViewportSurface( summary = scene.summary, showLinks = scene.showLinks, onToggleLinks = { coroutineScope.launch { callbacks.onToggleLinks() } }, + linkRenderNotice = scene.linkRenderNotice.takeIf { scene.showLinks }, ) } Surface( @@ -531,21 +530,6 @@ private fun createSelectionRect(anchor: Offset, current: Offset): Rect = Rect( bottom = max(anchor.y, current.y), ) -private fun findHitNode( - baseNodes: List, - viewportSize: IntSize, - camera: ViewportCameraState, - tapOffset: Offset, - tapThresholdPx: Float, -): RenderedNode? { - val tapInBaseSpace = tapOffset - .toWorldPosition(viewportSize, camera) - .toScreenPosition(viewportSize, ViewportCameraState()) - return baseNodes - .minByOrNull { node -> node.center.distanceTo(tapInBaseSpace) } - ?.takeIf { node -> node.center.distanceTo(tapInBaseSpace) <= tapThresholdPx / camera.zoom } -} - private fun screenDeltaToWorldDelta( anchor: Offset, current: Offset, diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt index da885c41f4..db311ebc2a 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt @@ -9,7 +9,9 @@ package it.unibo.alchemist.boundary.composeui +import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.DEFAULT_MAX_UI_FPS import it.unibo.alchemist.boundary.composeui.model.AlchemistUiState +import it.unibo.alchemist.boundary.composeui.model.FullThrottle import it.unibo.alchemist.boundary.composeui.model.GroupInspectorState import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState import it.unibo.alchemist.boundary.composeui.model.SimulationStatus @@ -84,12 +86,11 @@ class SimulationControlsStateTest { @Test fun `full throttle is represented by the max slider value`() { - val controls = - SimulationControlsState(maxEventRateSliderValue = 50).withEventRateSliderValue(50) + val controls = SimulationControlsState().updateEventThrottling(Int.MAX_VALUE) assertTrue(controls.isFullThrottle) - assertNull(controls.effectiveEventsPerSecond) - assertEquals(FULL_THROTTLE_LABEL, controls.eventRateLabel) + assertEquals(FullThrottle, controls.simulationEventThrottling) + assertEquals("Max", controls.simulationEventThrottling.toLabel()) } @Test diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRenderingPolicyTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRenderingPolicyTest.kt new file mode 100644 index 0000000000..ff316c8757 --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRenderingPolicyTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui.view.viewport + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import it.unibo.alchemist.boundary.composeui.model.LinkRenderMode +import it.unibo.alchemist.boundary.composeui.model.ViewportEdge +import it.unibo.alchemist.boundary.composeui.model.ViewportNode +import it.unibo.alchemist.boundary.composeui.model.ViewportScene +import kotlin.test.Test +import kotlin.test.assertEquals + +class ViewportRenderingPolicyTest { + @Test + fun `frame culls off-screen nodes and edges`() { + val scene = ViewportScene( + nodes = listOf( + ViewportNode(id = 1, coordinates = listOf(0.0, 0.0)), + ViewportNode(id = 2, coordinates = listOf(1.0, 1.0)), + ViewportNode(id = 3, coordinates = listOf(2.0, 2.0)), + ), + showLinks = true, + ) + val cache = ViewportSceneCache( + scene = scene, + baseCenters = listOf( + Offset(50f, 50f), + Offset(400f, 400f), + Offset(80f, 80f), + ), + indexedEdges = listOf( + IndexedEdge(fromIndex = 0, toIndex = 2), + IndexedEdge(fromIndex = 0, toIndex = 1), + ), + ) + + val frame = buildViewportFrame(cache, IntSize(120, 120), ViewportCameraState()) + + assertEquals(listOf(0, 2), frame.visibleNodeIndices) + assertEquals(listOf(IndexedEdge(fromIndex = 0, toIndex = 2)), frame.visibleEdges) + } + + @Test + fun `sampled mode caps the number of visible edges per frame`() { + val scene = ViewportScene( + nodes = listOf( + ViewportNode(id = 1, coordinates = listOf(0.0, 0.0)), + ViewportNode(id = 2, coordinates = listOf(1.0, 1.0)), + ), + edges = List(MaxDrawnEdgesPerFrame + 12) { ViewportEdge(1, 2) }, + showLinks = true, + linkRenderMode = LinkRenderMode.SAMPLED, + ) + val cache = ViewportSceneCache( + scene = scene, + baseCenters = listOf(Offset(20f, 20f), Offset(80f, 80f)), + indexedEdges = List(MaxDrawnEdgesPerFrame + 12) { IndexedEdge(fromIndex = 0, toIndex = 1) }, + ) + + val frame = buildViewportFrame(cache, IntSize(120, 120), ViewportCameraState()) + + assertEquals(MaxDrawnEdgesPerFrame, frame.visibleEdges.size) + } +} diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt index 4a7571b76c..c629a284ba 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/ComposeMonitor.kt @@ -90,7 +90,7 @@ class ComposeMonitor> @JvmOverloads constructor(targetFps: In private fun updateUiState(environment: Environment, time: Time, step: Long) { currentUiState.update { - val viewport = environment.toViewport() + val viewport = environment.toViewport(renderLinks = it.scene.showLinks) val displayedTime = time.toComposeUiLabel() it.copy( scene = viewport.copy(showLinks = it.scene.showLinks), diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt index 858bcd68c5..171d29d023 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt @@ -174,8 +174,11 @@ class DesktopAlchemistUiCallback>( override suspend fun onToggleLinks() { updateState { + val nextShowLinks = !it.scene.showLinks it.copy( - scene = it.scene.copy(showLinks = !it.scene.showLinks), + scene = simulation.environment + .toViewport(renderLinks = nextShowLinks) + .copy(showLinks = nextShowLinks), ) } } @@ -195,7 +198,7 @@ class DesktopAlchemistUiCallback>( private suspend fun syncSimulationState(refreshScene: Boolean = false) { updateState { val nextScene = if (refreshScene) { - simulation.environment.toViewport().copy(showLinks = it.scene.showLinks) + simulation.environment.toViewport(renderLinks = it.scene.showLinks).copy(showLinks = it.scene.showLinks) } else { it.scene } diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt index 5a6ce97fbc..9d9205ed5d 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt @@ -10,15 +10,20 @@ package it.unibo.alchemist.boundary.composeui.adapter import it.unibo.alchemist.boundary.composeui.model.InfoField +import it.unibo.alchemist.boundary.composeui.model.LinkRenderMode import it.unibo.alchemist.boundary.composeui.model.SimulationStatus import it.unibo.alchemist.boundary.composeui.model.ViewportEdge import it.unibo.alchemist.boundary.composeui.model.ViewportNode import it.unibo.alchemist.boundary.composeui.model.ViewportScene +import it.unibo.alchemist.boundary.composeui.view.viewport.FullEdgeRenderLimit +import it.unibo.alchemist.boundary.composeui.view.viewport.MaxDrawnEdgesPerFrame +import it.unibo.alchemist.boundary.composeui.view.viewport.SampledEdgeRenderLimit import it.unibo.alchemist.core.Simulation import it.unibo.alchemist.core.Status import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Node import it.unibo.alchemist.model.Position +import java.util.PriorityQueue fun > Node.toViewport(environment: Environment): ViewportNode = ViewportNode( id = id, @@ -26,11 +31,18 @@ fun > Node.toViewport(environment: Environment): Vie concentrations = this.contents.map { InfoField(it.key.toString(), it.value.toString()) }, ) -fun > Environment.toViewport(): ViewportScene = ViewportScene( - nodes = nodes.map { it.toViewport(this) }, - edges = extractEdges(), - dimensions = dimensions, -) +fun > Environment.toViewport(renderLinks: Boolean = false): ViewportScene { + val viewportNodes = nodes.map { it.toViewport(this) } + val edgeSnapshot = extractEdgeSnapshot(renderLinks) + return ViewportScene( + nodes = viewportNodes, + edges = edgeSnapshot.edges, + edgeCount = edgeSnapshot.edgeCount, + linkRenderMode = edgeSnapshot.renderMode, + linkRenderNotice = edgeSnapshot.notice, + dimensions = dimensions, + ) +} fun > Simulation.toSimulationStatus(): SimulationStatus = when (this.status) { Status.INIT -> SimulationStatus.INIT @@ -40,16 +52,112 @@ fun > Simulation.toSimulationStatus(): SimulationStatus Status.TERMINATED -> SimulationStatus.TERMINATED } -private fun > Environment.extractEdges(): List = buildList { - nodes.forEach { node -> - getNeighborhood(node).forEach { neighbor -> - canonicalEdge(node.id, neighbor.id)?.let(::add) +private fun > Environment.extractEdgeSnapshot(renderLinks: Boolean): EdgeSnapshot { + if (!renderLinks) { + return EdgeSnapshot() + } + return collectEdgeSnapshot( + edgePairs = sequence { + for (node in nodes) { + for (neighbor in getNeighborhood(node)) { + yield(node.id to neighbor.id) + } + } + }, + ) +} + +internal fun collectEdgeSnapshot(edgePairs: Sequence>, renderLinks: Boolean = true): EdgeSnapshot { + if (!renderLinks) { + return EdgeSnapshot() + } + val seenEdges = HashSet() + val fullEdges = ArrayList(FullEdgeRenderLimit) + val sampledEdges = PriorityQueue( + MaxDrawnEdgesPerFrame, + compareByDescending { it.score }, + ) + var uniqueEdges = 0 + for ((firstNodeId, secondNodeId) in edgePairs) { + val edgeKey = canonicalEdgeKey(firstNodeId, secondNodeId) ?: continue + if (!seenEdges.add(edgeKey)) { + continue } + uniqueEdges++ + if (uniqueEdges <= FullEdgeRenderLimit) { + fullEdges += edgeKey.toViewportEdge() + } + sampledEdges.consider(edgeKey) + if (uniqueEdges > SampledEdgeRenderLimit) { + return EdgeSnapshot( + renderMode = LinkRenderMode.HIDDEN, + edgeCount = uniqueEdges, + notice = "links hidden above ${SampledEdgeRenderLimit.toReadableCount()}", + ) + } + } + return when { + uniqueEdges <= FullEdgeRenderLimit -> EdgeSnapshot( + edges = fullEdges, + edgeCount = uniqueEdges, + renderMode = LinkRenderMode.FULL, + ) + else -> EdgeSnapshot( + edges = sampledEdges + .toList() + .sortedBy(SampledViewportEdge::score) + .map(SampledViewportEdge::edge), + edgeCount = uniqueEdges, + renderMode = LinkRenderMode.SAMPLED, + notice = "showing ${MaxDrawnEdgesPerFrame.toReadableCount()} sampled links", + ) + } +} + +private fun PriorityQueue.consider(edgeKey: Long) { + val candidate = SampledViewportEdge(score = edgeKey.sampleScore(), edge = edgeKey.toViewportEdge()) + if (size < MaxDrawnEdgesPerFrame) { + add(candidate) + return } -}.distinct() + val largestScore = peek() ?: return + if (candidate.score < largestScore.score) { + poll() + add(candidate) + } +} + +internal data class EdgeSnapshot( + val edges: List = emptyList(), + val edgeCount: Int = 0, + val renderMode: LinkRenderMode = LinkRenderMode.FULL, + val notice: String? = null, +) + +private data class SampledViewportEdge(val score: Long, val edge: ViewportEdge) + +private fun Int.toReadableCount(): String = "%,d".format(this) -internal fun canonicalEdge(firstNodeId: Int, secondNodeId: Int): ViewportEdge? = when { +private fun Long.sampleScore(): Long { + var value = this + value = (value xor (value ushr 33)) * -0xae502812aa7333L + value = (value xor (value ushr 33)) * -0x3b314601e57a13adL + return value xor (value ushr 33) +} + +private fun Long.toViewportEdge(): ViewportEdge = ViewportEdge( + fromNodeId = (this ushr 32).toInt(), + toNodeId = this.toInt(), +) + +private fun canonicalEdgeKey(firstNodeId: Int, secondNodeId: Int): Long? = when { firstNodeId == secondNodeId -> null - firstNodeId < secondNodeId -> ViewportEdge(firstNodeId, secondNodeId) - else -> ViewportEdge(secondNodeId, firstNodeId) + firstNodeId < secondNodeId -> edgeKey(firstNodeId, secondNodeId) + else -> edgeKey(secondNodeId, firstNodeId) } + +private fun edgeKey(firstNodeId: Int, secondNodeId: Int): Long = + (firstNodeId.toLong() shl 32) or (secondNodeId.toLong() and 0xffffffffL) + +internal fun canonicalEdge(firstNodeId: Int, secondNodeId: Int): ViewportEdge? = + canonicalEdgeKey(firstNodeId, secondNodeId)?.toViewportEdge() diff --git a/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml index e6ef6f71df..4fcf1a6f95 100644 --- a/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml +++ b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml @@ -8,10 +8,10 @@ monitors: type: ComposeMonitor deployments: - type: Grid - parameters: [-5, -5, 5, 5, 0.25, 0.25, 0.1, 0.1] -# type: Rectangle -# parameters: [10000, -5, -5, 10, 10] +# type: Grid +# parameters: [-5, -5, 5, 5, 0.25, 0.25, 0.1, 0.1] + type: Rectangle + parameters: [10000, -5, -5, 10, 10] contents: - in: type: Rectangle diff --git a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt index f109c2be2f..ead88805ad 100644 --- a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt +++ b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt @@ -10,6 +10,10 @@ package it.unibo.alchemist.boundary.composeui.adapter import it.unibo.alchemist.boundary.composeui.model.ViewportEdge +import it.unibo.alchemist.boundary.composeui.model.LinkRenderMode +import it.unibo.alchemist.boundary.composeui.view.viewport.FullEdgeRenderLimit +import it.unibo.alchemist.boundary.composeui.view.viewport.MaxDrawnEdgesPerFrame +import it.unibo.alchemist.boundary.composeui.view.viewport.SampledEdgeRenderLimit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -32,4 +36,30 @@ class AlchemistNodeAdapterTest { assertEquals(1, uniqueEdges.size) assertTrue(uniqueEdges.contains(ViewportEdge(1, 3))) } + + @Test + fun `edge snapshot samples medium-sized graphs`() { + val snapshot = collectEdgeSnapshot( + edgePairs = (1..(FullEdgeRenderLimit + 50)).asSequence().map { edgeId -> + edgeId to (edgeId + 1) + }, + ) + + assertEquals(LinkRenderMode.SAMPLED, snapshot.renderMode) + assertEquals(FullEdgeRenderLimit + 50, snapshot.edgeCount) + assertEquals(MaxDrawnEdgesPerFrame, snapshot.edges.size) + } + + @Test + fun `edge snapshot hides very large graphs`() { + val snapshot = collectEdgeSnapshot( + edgePairs = (1..(SampledEdgeRenderLimit + 1)).asSequence().map { edgeId -> + edgeId to (edgeId + 1) + }, + ) + + assertEquals(LinkRenderMode.HIDDEN, snapshot.renderMode) + assertTrue(snapshot.edges.isEmpty()) + assertTrue(snapshot.edgeCount > SampledEdgeRenderLimit) + } } From bdb89ac271b599f67392550542789b7967fc77f1 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Mon, 27 Apr 2026 19:05:18 +0200 Subject: [PATCH 20/22] refactor: improve code --- .../view/components/ComponentChrome.kt | 43 +++++ .../composeui/view/components/MetricBlock.kt | 15 +- .../composeui/view/components/StatusPill.kt | 43 +++-- .../view/components/TransportButton.kt | 22 ++- .../composeui/view/controls/ControlDock.kt | 172 ++++++++++++++---- .../composeui/view/inspector/NodeInspector.kt | 24 ++- .../composeui/view/root/AlchemistUiRoot.kt | 3 +- .../view/root/SimulationPrimaryPane.kt | 35 ++-- .../src/jvmMain/resources/composeui-demo.yml | 2 +- 9 files changed, 267 insertions(+), 92 deletions(-) create mode 100644 alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/ComponentChrome.kt diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/ComponentChrome.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/ComponentChrome.kt new file mode 100644 index 0000000000..56c5a9ba80 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/ComponentChrome.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.composeui.view.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Surface as ComposeSurface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import it.unibo.alchemist.boundary.composeui.view.theme.Surface as SurfaceColor + +@Composable +internal fun ComponentSurface( + color: Color = SurfaceColor.copy(alpha = componentSurfaceAlpha), + modifier: Modifier = Modifier, + shape: Shape = componentShape, + content: @Composable () -> Unit, +) { + ComposeSurface( + modifier = modifier, + color = color, + shape = shape, + elevation = 0.dp, + ) { + content() + } +} + +internal val componentShape = RoundedCornerShape(8.dp) +internal val pillShape = RoundedCornerShape(999.dp) +internal val componentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp) + +private const val componentSurfaceAlpha = 0.78f diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/MetricBlock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/MetricBlock.kt index 5468450906..3683d463d5 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/MetricBlock.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/MetricBlock.kt @@ -12,27 +12,20 @@ package it.unibo.alchemist.boundary.composeui.view.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import it.unibo.alchemist.boundary.composeui.view.theme.SecondaryAccent -import it.unibo.alchemist.boundary.composeui.view.theme.Surface @Composable internal fun MetricBlock(label: String, value: String) { - Surface( - color = Surface.copy(alpha = 0.78f), - shape = RoundedCornerShape(8.dp), - elevation = 0.dp, - ) { + ComponentSurface { Column( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(componentPadding), + verticalArrangement = Arrangement.spacedBy(metricBlockSpacing), ) { Text( text = label.uppercase(), @@ -46,3 +39,5 @@ internal fun MetricBlock(label: String, value: String) { } } } + +private val metricBlockSpacing = 4.dp diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/StatusPill.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/StatusPill.kt index f75e5a93f1..e92c7c19bc 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/StatusPill.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/StatusPill.kt @@ -19,13 +19,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState import it.unibo.alchemist.boundary.composeui.model.SimulationStatus @@ -37,36 +36,46 @@ import it.unibo.alchemist.boundary.composeui.view.theme.StatusPillWidth @Composable internal fun StatusPill(controls: SimulationControlsState) { - val color = - when (controls.status) { - SimulationStatus.RUNNING -> Positive - SimulationStatus.PAUSED -> PrimaryAccent - SimulationStatus.TERMINATED -> Danger - else -> SecondaryAccent - } - Surface( + val presentation = controls.toStatusPillPresentation() + ComponentSurface( modifier = Modifier.width(StatusPillWidth), - color = color.copy(alpha = 0.14f), - shape = RoundedCornerShape(999.dp), - elevation = 0.dp, + color = presentation.color.copy(alpha = StatusPillAlpha), + shape = pillShape, ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), + .padding(componentPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { Box( modifier = Modifier .size(10.dp) - .background(color = color, shape = CircleShape), + .background(color = presentation.color, shape = CircleShape), ) - Spacer(modifier = Modifier.width(10.dp)) + Spacer(modifier = Modifier.width(statusIndicatorSpacing)) Text( - text = controls.statusLabel, + text = presentation.label, style = MaterialTheme.typography.subtitle1, ) } } } + +private data class StatusPillPresentation(val label: String, val color: Color) + +private fun SimulationControlsState.toStatusPillPresentation(): StatusPillPresentation = + StatusPillPresentation( + label = statusLabel, + color = + when (status) { + SimulationStatus.RUNNING -> Positive + SimulationStatus.PAUSED -> PrimaryAccent + SimulationStatus.TERMINATED -> Danger + else -> SecondaryAccent + }, + ) + +private const val StatusPillAlpha = 0.14f +private val statusIndicatorSpacing = 10.dp diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/TransportButton.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/TransportButton.kt index e419476574..f88b6c9a4b 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/TransportButton.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/TransportButton.kt @@ -10,7 +10,6 @@ package it.unibo.alchemist.boundary.composeui.view.components import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Text @@ -25,22 +24,33 @@ import it.unibo.alchemist.boundary.composeui.view.theme.TextSecondary @Composable internal fun TransportButton(label: String, enabled: Boolean, accent: Color, onClick: () -> Unit) { - val buttonContentColor = if (accent.luminance() > ACCENT_LUMINANCE_THRESHOLD) TextPrimary else Surface + val colors = accent.toTransportButtonColors(enabled) Button( onClick = onClick, enabled = enabled, - shape = RoundedCornerShape(8.dp), + shape = componentShape, elevation = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = accent.copy(alpha = if (enabled) 0.92f else 0.28f), - contentColor = buttonContentColor, + backgroundColor = colors.background, + contentColor = colors.content, disabledBackgroundColor = Outline.copy(alpha = 0.65f), disabledContentColor = TextSecondary, ), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp), + contentPadding = transportButtonPadding, ) { Text(text = label) } } +private data class TransportButtonPalette(val background: Color, val content: Color) + +private fun Color.toTransportButtonColors(enabled: Boolean): TransportButtonPalette = + TransportButtonPalette( + background = copy(alpha = if (enabled) EnabledButtonAlpha else DisabledButtonAlpha), + content = if (luminance() > ACCENT_LUMINANCE_THRESHOLD) TextPrimary else Surface, + ) + private const val ACCENT_LUMINANCE_THRESHOLD = 0.35f +private const val EnabledButtonAlpha = 0.92f +private const val DisabledButtonAlpha = 0.28f +private val transportButtonPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt index ca58df887f..698947b0f5 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt @@ -9,8 +9,10 @@ package it.unibo.alchemist.boundary.composeui.view.controls +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -27,13 +29,14 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp @@ -43,12 +46,14 @@ import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState import it.unibo.alchemist.boundary.composeui.model.SimulationEventThrottling.Companion.MAX_SIMULATION_EVENTS_PER_SECOND import it.unibo.alchemist.boundary.composeui.model.SimulationEventThrottling.Companion.MIN_SIMULATION_EVENTS_PER_SECOND import it.unibo.alchemist.boundary.composeui.view.components.MetricBlock +import it.unibo.alchemist.boundary.composeui.view.components.StatusPill import it.unibo.alchemist.boundary.composeui.view.components.TransportButton import it.unibo.alchemist.boundary.composeui.view.theme.Danger import it.unibo.alchemist.boundary.composeui.view.theme.Outline import it.unibo.alchemist.boundary.composeui.view.theme.Positive import it.unibo.alchemist.boundary.composeui.view.theme.PrimaryAccent import it.unibo.alchemist.boundary.composeui.view.theme.SecondaryAccent +import it.unibo.alchemist.boundary.composeui.view.theme.Surface as SurfaceColor import it.unibo.alchemist.boundary.composeui.view.theme.SurfaceStrong import it.unibo.alchemist.boundary.composeui.view.theme.TextPrimary import it.unibo.alchemist.boundary.composeui.view.theme.TextSecondary @@ -76,41 +81,106 @@ internal fun ControlDock( ) { Row( modifier = Modifier - .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(horizontal = 18.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(18.dp), + .padding(horizontal = dockHorizontalPadding, vertical = dockVerticalPadding), + horizontalArrangement = Arrangement.spacedBy(dockSectionSpacing), + verticalAlignment = Alignment.Top, ) { - TransportButton(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = onPlay) - TransportButton(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = onPause) - TransportButton(label = "Step", enabled = controls.canStep, accent = PrimaryAccent, onClick = onStep) - MetricBlock(label = "Time", value = controls.timeLabel) - MetricBlock(label = "Step", value = controls.step.toString()) - DockTextField( - label = "To Time", - value = controls.toTimeInput, - caption = "Enter to jump", - onValueChange = onToTimeInputChanged, - onSubmit = onToTimeSubmit, - ) - DockTextField( - label = "To Step", - value = controls.toStepInput, - caption = "Enter to jump", - onValueChange = onToStepInputChanged, - onSubmit = onToStepSubmit, - ) - DockTextField( - label = "FPS", - value = controls.fpsInput, - caption = controls.fpsRangeLabel, - onValueChange = onFpsInputChanged, - onSubmit = onFpsSubmit, - ) - EventRateSlider( - controls = controls, - onValueChange = onEventRateChanged, + DockSection(title = "Transport") { + Row( + horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + StatusPill(controls) + transportActions(controls, onPlay, onPause, onStep).forEach { action -> + TransportButton( + label = action.label, + enabled = action.enabled, + accent = action.accent, + onClick = action.onClick, + ) + } + } + } + DockSection(title = "Metrics") { + Row( + horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing), + ) { + metrics(controls).forEach { metric -> + MetricBlock( + label = metric.label, + value = metric.value, + ) + } + } + } + DockSection(title = "Jump") { + Row( + horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing), + verticalAlignment = Alignment.Top, + ) { + DockTextField( + label = "To Time", + value = controls.toTimeInput, + caption = "Enter to jump", + onValueChange = onToTimeInputChanged, + onSubmit = onToTimeSubmit, + ) + DockTextField( + label = "To Step", + value = controls.toStepInput, + caption = "Enter to jump", + onValueChange = onToStepInputChanged, + onSubmit = onToStepSubmit, + ) + } + } + DockSection(title = "Pacing") { + Row( + horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing), + verticalAlignment = Alignment.Top, + ) { + DockTextField( + label = "FPS", + value = controls.fpsInput, + caption = controls.fpsRangeLabel, + onValueChange = onFpsInputChanged, + onSubmit = onFpsSubmit, + ) + EventRateSlider( + controls = controls, + onValueChange = onEventRateChanged, + ) + } + } + } + } +} + +@Composable +private fun DockSection(title: String, content: @Composable () -> Unit) { + Surface( + color = SurfaceColor.copy(alpha = dockSectionSurfaceAlpha), + shape = RoundedCornerShape(dockSectionCornerRadius), + border = BorderStroke(dockSectionBorderWidth, Outline.copy(alpha = dockSectionBorderAlpha)), + elevation = 0.dp, + ) { + Column( + modifier = Modifier.padding( + horizontal = dockSectionHorizontalPadding, + vertical = dockSectionVerticalPadding, + ), + verticalArrangement = Arrangement.spacedBy(dockSectionContentSpacing), + ) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.caption, + color = SecondaryAccent, + fontWeight = FontWeight.SemiBold, ) + Box(contentAlignment = Alignment.CenterStart) { + content() + } } } } @@ -211,3 +281,39 @@ private fun EventRateSlider(controls: SimulationControlsState, onValueChange: (F private val DockTextFieldWidth = 132.dp private val EventRateControlWidth = 240.dp +private val dockHorizontalPadding = 18.dp +private val dockVerticalPadding = 16.dp +private val dockSectionSpacing = 14.dp +private val dockSectionCornerRadius = 12.dp +private val dockSectionBorderWidth = 1.dp +private val dockSectionHorizontalPadding = 14.dp +private val dockSectionVerticalPadding = 12.dp +private val dockSectionContentSpacing = 10.dp +private val sectionItemSpacing = 12.dp +private const val dockSectionSurfaceAlpha = 0.78f +private const val dockSectionBorderAlpha = 0.7f + +private data class TransportAction( + val label: String, + val enabled: Boolean, + val accent: Color, + val onClick: () -> Unit, +) + +private data class MetricValue(val label: String, val value: String) + +private fun transportActions( + controls: SimulationControlsState, + onPlay: () -> Unit, + onPause: () -> Unit, + onStep: () -> Unit, +): List = listOf( + TransportAction(label = "Play", enabled = controls.canPlay, accent = Positive, onClick = onPlay), + TransportAction(label = "Pause", enabled = controls.canPause, accent = Danger, onClick = onPause), + TransportAction(label = "Step", enabled = controls.canStep, accent = PrimaryAccent, onClick = onStep), +) + +private fun metrics(controls: SimulationControlsState): List = listOf( + MetricValue(label = "Time", value = controls.timeLabel), + MetricValue(label = "Step", value = controls.step.toString()), +) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/NodeInspector.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/NodeInspector.kt index fc8c87024c..ead9c574e5 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/NodeInspector.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/NodeInspector.kt @@ -51,14 +51,8 @@ internal fun NodeInspector(inspector: InspectorState, onDismiss: () -> Unit, mod verticalArrangement = Arrangement.spacedBy(18.dp), ) { InspectorHeader( - title = when (inspector) { - is GroupInspectorState -> inspector.title - is NodeInspectorState -> inspector.title - }, - subtitle = when (inspector) { - is GroupInspectorState -> inspector.subtitle - is NodeInspectorState -> inspector.subtitle - }, + title = inspector.title, + subtitle = inspector.subtitle, onDismiss = onDismiss, ) when (inspector) { @@ -139,3 +133,17 @@ private fun GroupNodeInspector(inspector: GroupInspectorState) { }, ) } + +private val InspectorState.title: String + get() = + when (this) { + is GroupInspectorState -> title + is NodeInspectorState -> title + } + +private val InspectorState.subtitle: String + get() = + when (this) { + is GroupInspectorState -> subtitle + is NodeInspectorState -> subtitle + } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt index 76de74ecff..4927c29d04 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt @@ -129,6 +129,7 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { val inspectorWidth = 324.dp val bottomBarHeight = 152.dp val layoutSpacing = 20.dp + val paneSpacing = if (inspectorVisible) 8.dp else layoutSpacing val inspectorTransition = updateTransition(targetState = inspectorVisible, label = "inspector") val animatedInspectorWidth by inspectorTransition.animateDp( transitionSpec = { tween(durationMillis = 320) }, @@ -190,7 +191,7 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { modifier = Modifier .fillMaxSize() .padding(layoutSpacing), - horizontalArrangement = Arrangement.spacedBy(layoutSpacing), + horizontalArrangement = Arrangement.spacedBy(paneSpacing), ) { SimulationPrimaryPane( scene = state.scene, diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt index 21b87a8e84..9a96cd0d4a 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt @@ -53,22 +53,25 @@ internal fun SimulationPrimaryPane( contentAlignment = Alignment.Center, ) { val coroutineScope = rememberCoroutineScope() - ControlDock( - controls = controls, - onPlay = { coroutineScope.launch { callbacks.onPlay() } }, - onPause = { coroutineScope.launch { callbacks.onPause() } }, - onStep = { coroutineScope.launch { callbacks.onStep() } }, - onToTimeInputChanged = { coroutineScope.launch { callbacks.onToTimeInputChanged(it) } }, - onToTimeSubmit = { coroutineScope.launch { callbacks.onToTimeSubmit() } }, - onToStepInputChanged = { coroutineScope.launch { callbacks.onToStepInputChanged(it) } }, - onToStepSubmit = { coroutineScope.launch { callbacks.onToStepSubmit() } }, - onFpsInputChanged = { coroutineScope.launch { callbacks.onFpsInputChanged(it) } }, - onFpsSubmit = { coroutineScope.launch { callbacks.onFpsSubmit() } }, - onEventRateChanged = { coroutineScope.launch { callbacks.onEventRateChanged(it) } }, - modifier = Modifier - .fillMaxWidth(dockWidthFraction) - .wrapContentHeight(), - ) + Box( + modifier = Modifier.fillMaxWidth(dockWidthFraction), + contentAlignment = Alignment.Center, + ) { + ControlDock( + controls = controls, + onPlay = { coroutineScope.launch { callbacks.onPlay() } }, + onPause = { coroutineScope.launch { callbacks.onPause() } }, + onStep = { coroutineScope.launch { callbacks.onStep() } }, + onToTimeInputChanged = { coroutineScope.launch { callbacks.onToTimeInputChanged(it) } }, + onToTimeSubmit = { coroutineScope.launch { callbacks.onToTimeSubmit() } }, + onToStepInputChanged = { coroutineScope.launch { callbacks.onToStepInputChanged(it) } }, + onToStepSubmit = { coroutineScope.launch { callbacks.onToStepSubmit() } }, + onFpsInputChanged = { coroutineScope.launch { callbacks.onFpsInputChanged(it) } }, + onFpsSubmit = { coroutineScope.launch { callbacks.onFpsSubmit() } }, + onEventRateChanged = { coroutineScope.launch { callbacks.onEventRateChanged(it) } }, + modifier = Modifier.wrapContentHeight(), + ) + } } } } diff --git a/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml index 4fcf1a6f95..71517dd398 100644 --- a/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml +++ b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml @@ -11,7 +11,7 @@ deployments: # type: Grid # parameters: [-5, -5, 5, 5, 0.25, 0.25, 0.1, 0.1] type: Rectangle - parameters: [10000, -5, -5, 10, 10] + parameters: [1000, -5, -5, 10, 10] contents: - in: type: Rectangle From 0f9c8bfeb73024e63b03e9def28a8af0347e1804 Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Fri, 22 May 2026 11:19:31 +0200 Subject: [PATCH 21/22] perf: optimize data structures with immutable ones --- alchemist-composeui/build.gradle.kts | 1 + .../alchemist/boundary/composeui/UiStore.kt | 62 ++++++++++--------- .../boundary/composeui/model/UiModel.kt | 32 +++++----- .../composeui/view/root/AlchemistUiRoot.kt | 3 + .../composeui/view/viewport/SummaryRail.kt | 15 ++++- .../view/viewport/ViewportRendering.kt | 46 +++++++------- .../composeui/GroupInspectorStateTest.kt | 22 ++++--- .../composeui/ViewportNodeMovementTest.kt | 39 ++++++++---- .../composeui/ViewportProjectionTest.kt | 10 +-- .../viewport/ViewportRenderingPolicyTest.kt | 27 ++++---- .../composeui/adapter/AlchemistNodeAdapter.kt | 16 +++-- .../DesktopAlchemistUiNodeMoveTest.kt | 7 ++- gradle/libs.versions.toml | 2 + 13 files changed, 168 insertions(+), 114 deletions(-) diff --git a/alchemist-composeui/build.gradle.kts b/alchemist-composeui/build.gradle.kts index 2b29c6f6eb..9737df806e 100644 --- a/alchemist-composeui/build.gradle.kts +++ b/alchemist-composeui/build.gradle.kts @@ -30,6 +30,7 @@ kotlin { val commonMain by getting { dependencies { implementation(libs.bundles.compose) + implementation(libs.kotlinx.collections.immutable) } } val jvmMain by getting { diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt index 377737dc77..3e5199b2a3 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt @@ -31,6 +31,9 @@ import it.unibo.alchemist.boundary.composeui.model.ViewportScene import it.unibo.alchemist.boundary.composeui.model.ViewportWorldBounds import kotlin.math.ceil import kotlin.math.roundToInt +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -210,73 +213,73 @@ fun demoController(): ComposeUiController { } private fun sampleUiState(): AlchemistUiState { - val nodes = listOf( + val nodes = persistentListOf( ViewportNode( id = 1, - coordinates = listOf(-3.5, 1.7), + coordinates = persistentListOf(-3.5, 1.7), accent = 0.15f, - metadata = listOf( + metadata = persistentListOf( InfoField("Neighbors", "4"), InfoField("Reactions", "3"), InfoField("Properties", "2"), ), - concentrations = listOf( + concentrations = persistentListOf( InfoField("signal", "0.91"), InfoField("gradient", "0.42"), ), ), ViewportNode( id = 2, - coordinates = listOf(-1.2, 0.3), + coordinates = persistentListOf(-1.2, 0.3), accent = 0.33f, - metadata = listOf( + metadata = persistentListOf( InfoField("Neighbors", "5"), InfoField("Reactions", "2"), InfoField("Properties", "1"), ), - concentrations = listOf( + concentrations = persistentListOf( InfoField("source", "true"), InfoField("gradient", "0.68"), ), ), ViewportNode( id = 3, - coordinates = listOf(0.8, 2.2), + coordinates = persistentListOf(0.8, 2.2), accent = 0.55f, - metadata = listOf( + metadata = persistentListOf( InfoField("Neighbors", "3"), InfoField("Reactions", "4"), InfoField("Properties", "2"), ), - concentrations = listOf( + concentrations = persistentListOf( InfoField("signal", "0.77"), InfoField("temperature", "296 K"), ), ), ViewportNode( id = 4, - coordinates = listOf(2.1, -0.8), + coordinates = persistentListOf(2.1, -0.8), accent = 0.74f, - metadata = listOf( + metadata = persistentListOf( InfoField("Neighbors", "6"), InfoField("Reactions", "2"), InfoField("Properties", "3"), ), - concentrations = listOf( + concentrations = persistentListOf( InfoField("gradient", "0.18"), InfoField("payload", "ready"), ), ), ViewportNode( id = 5, - coordinates = listOf(3.9, 1.4), + coordinates = persistentListOf(3.9, 1.4), accent = 0.92f, - metadata = listOf( + metadata = persistentListOf( InfoField("Neighbors", "2"), InfoField("Reactions", "1"), InfoField("Properties", "1"), ), - concentrations = listOf( + concentrations = persistentListOf( InfoField("goal", "true"), InfoField("signal", "0.12"), ), @@ -285,7 +288,7 @@ private fun sampleUiState(): AlchemistUiState { return AlchemistUiState( scene = ViewportScene( nodes = nodes, - edges = listOf( + edges = persistentListOf( ViewportEdge(1, 2), ViewportEdge(2, 3), ViewportEdge(3, 4), @@ -294,7 +297,7 @@ private fun sampleUiState(): AlchemistUiState { ), dimensions = 2, backdrop = ViewportBackdrop.SPACE, - summary = listOf( + summary = persistentListOf( InfoField("Nodes", nodes.size.toString()), InfoField("Dimensions", "2D"), InfoField("Backdrop", "Procedural field"), @@ -358,9 +361,9 @@ internal fun AlchemistUiState.withSelection(nodeIds: List): AlchemistUiStat ) } -internal fun ViewportScene.sanitizeSelection(nodeIds: List): List { +internal fun ViewportScene.sanitizeSelection(nodeIds: List): ImmutableList { val availableNodeIds = nodes.mapTo(linkedSetOf()) { it.id } - return nodeIds.distinct().filter(availableNodeIds::contains) + return nodeIds.distinct().filter(availableNodeIds::contains).toImmutableList() } internal fun ViewportScene.withMovedNodes(nodePositions: List): ViewportScene { @@ -370,7 +373,7 @@ internal fun ViewportScene.withMovedNodes(nodePositions: List coordinatesByNodeId[node.id]?.let(node::withCoordinates) ?: node - } + }.toImmutableList() return copy( nodes = updatedNodes, worldBounds = updatedNodes.toWorldBounds(), @@ -392,7 +395,7 @@ internal fun ViewportScene.translateSelectedNodes( } else { node } - } + }.toImmutableList() return copy( nodes = updatedNodes, worldBounds = updatedNodes.toWorldBounds(), @@ -417,7 +420,7 @@ internal fun ViewportNode.toInspectorState(): NodeInspectorState = NodeInspector subtitle = "Live node snapshot", position = coordinates.take(2).mapIndexed { index, coordinate -> InfoField(if (index == 0) "X" else "Y", coordinate.formatFixed(3)) - }, + }.toImmutableList(), concentrations = concentrations, metadata = metadata, ) @@ -429,14 +432,15 @@ internal fun ViewportNode.translate(deltaX: Double, deltaY: Double): ViewportNod 1 -> coordinate + deltaY else -> coordinate } - }, + }.toImmutableList(), ) -internal fun ViewportNode.withCoordinates(newCoordinates: List): ViewportNode = copy(coordinates = newCoordinates) +internal fun ViewportNode.withCoordinates(newCoordinates: List): ViewportNode = + copy(coordinates = newCoordinates.toImmutableList()) internal fun ViewportNode.toPositionUpdate(): NodePositionUpdate = NodePositionUpdate(id, coordinates) -private fun List.toWorldBounds(): ViewportWorldBounds? { +private fun ImmutableList.toWorldBounds(): ViewportWorldBounds? { if (isEmpty()) { return null } @@ -478,10 +482,10 @@ internal fun List.toGroupInspectorState(): GroupInspectorState { values.all { it == firstValue } } InfoField(molecule, sharedValue ?: MIXED_CONCENTRATION_PLACEHOLDER) - } + }.toImmutableList() return GroupInspectorState( - nodeIds = map(ViewportNode::id), - position = listOf( + nodeIds = map(ViewportNode::id).toImmutableList(), + position = persistentListOf( InfoField("Min X", xs.minOrNull()?.formatFixed(3).orEmpty()), InfoField("Max X", xs.maxOrNull()?.formatFixed(3).orEmpty()), InfoField("Min Y", ys.minOrNull()?.formatFixed(3).orEmpty()), diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt index 6f2c1f3e07..37926ee3be 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt @@ -15,6 +15,8 @@ import androidx.compose.runtime.Immutable import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.DEFAULT_MAX_UI_FPS import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.DEFAULT_UI_FPS import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.MIN_UI_FPS +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf /** * High-level simulation status mirrored in the Compose UI. @@ -47,10 +49,10 @@ data class InfoField(val label: String, val value: String) @Immutable data class ViewportNode( val id: Int, - val coordinates: List, + val coordinates: ImmutableList, val accent: Float = 0.5f, - val metadata: List = emptyList(), - val concentrations: List = emptyList(), + val metadata: ImmutableList = persistentListOf(), + val concentrations: ImmutableList = persistentListOf(), ) { init { require(coordinates.size >= 2) { @@ -63,7 +65,7 @@ data class ViewportNode( * Final node coordinates produced by a viewport drag interaction. */ @Immutable -data class NodePositionUpdate(val nodeId: Int, val coordinates: List) { +data class NodePositionUpdate(val nodeId: Int, val coordinates: ImmutableList) { init { require(coordinates.size >= 2) { "Moved nodes require at least two coordinates." @@ -108,8 +110,8 @@ enum class LinkRenderMode { */ @Immutable data class ViewportScene( - val nodes: List = emptyList(), - val edges: List = emptyList(), + val nodes: ImmutableList = persistentListOf(), + val edges: ImmutableList = persistentListOf(), val edgeCount: Int = edges.size, val showLinks: Boolean = false, val linkRenderMode: LinkRenderMode = LinkRenderMode.FULL, @@ -117,7 +119,7 @@ data class ViewportScene( val dimensions: Int = 2, val worldBounds: ViewportWorldBounds? = nodes.toWorldBounds(), val backdrop: ViewportBackdrop = ViewportBackdrop.SPACE, - val summary: List = emptyList(), + val summary: ImmutableList = persistentListOf(), val message: String = "Waiting for simulation data", ) @@ -202,9 +204,9 @@ data class NodeInspectorState( val nodeId: Int, val title: String = "Node $nodeId", val subtitle: String, - val position: List, - val concentrations: List, - val metadata: List, + val position: ImmutableList, + val concentrations: ImmutableList, + val metadata: ImmutableList, ) : InspectorState /** @@ -212,11 +214,11 @@ data class NodeInspectorState( */ @Immutable data class GroupInspectorState( - val nodeIds: List, + val nodeIds: ImmutableList, val title: String = "Selected Nodes", val subtitle: String = "${nodeIds.size} nodes selected", - val position: List, - val concentrations: List, + val position: ImmutableList, + val concentrations: ImmutableList, ) : InspectorState /** @@ -226,11 +228,11 @@ data class GroupInspectorState( data class AlchemistUiState( val scene: ViewportScene = ViewportScene(), val controls: SimulationControlsState = SimulationControlsState(), - val selectedNodeIds: List = emptyList(), + val selectedNodeIds: ImmutableList = persistentListOf(), val inspector: InspectorState? = null, ) -private fun List.toWorldBounds(): ViewportWorldBounds? { +private fun ImmutableList.toWorldBounds(): ViewportWorldBounds? { if (isEmpty()) { return null } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt index 4927c29d04..6f089acaa3 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -169,6 +170,8 @@ fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { .fillMaxSize() .background(InspectorScrim) .clickable( + onClickLabel = "Dismiss inspector", + role = Role.Button, onClick = { coroutineScope.launch { callbacks.onInspectorDismiss() } }, ), ) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt index d1547eaa8c..c8373b9be0 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt @@ -9,12 +9,12 @@ package it.unibo.alchemist.boundary.composeui.view.viewport -import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -22,6 +22,9 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import it.unibo.alchemist.boundary.composeui.model.InfoField import it.unibo.alchemist.boundary.composeui.view.theme.SecondaryAccent @@ -86,7 +89,15 @@ internal fun SummaryRail( } } Surface( - modifier = Modifier.clickable(onClick = onToggleLinks), + modifier = Modifier + .toggleable( + value = showLinks, + role = Role.Switch, + onValueChange = { onToggleLinks() }, + ) + .semantics { + stateDescription = if (showLinks) "Links visible" else "Links hidden" + }, color = if (showLinks) SecondaryAccent.copy(alpha = 0.2f) else SurfaceStrong.copy(alpha = 0.82f), shape = RoundedCornerShape(999.dp), elevation = 0.dp, diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt index 37d1c75ad7..776a41db1b 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt @@ -23,6 +23,9 @@ import it.unibo.alchemist.boundary.composeui.view.theme.NodeHitRadius import it.unibo.alchemist.boundary.composeui.view.theme.ZoomStep import kotlin.math.floor import kotlin.math.sqrt +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList internal const val FullEdgeRenderLimit = 20_000 internal const val SampledEdgeRenderLimit = 200_000 @@ -38,13 +41,13 @@ internal fun buildViewportSceneCache( if (projection == null || scene.nodes.isEmpty() || viewportSize.width == 0 || viewportSize.height == 0) { return ViewportSceneCache(scene = scene) } - val baseCenters = scene.nodes.map { node -> node.toViewportPosition(viewportSize, projection) } + val baseCenters = scene.nodes.map { node -> node.toViewportPosition(viewportSize, projection) }.toImmutableList() val nodeIndexById = scene.nodes.withIndex().associate { (index, node) -> node.id to index } val indexedEdges = scene.edges.mapNotNull { edge -> val fromIndex = nodeIndexById[edge.fromNodeId] ?: return@mapNotNull null val toIndex = nodeIndexById[edge.toNodeId] ?: return@mapNotNull null IndexedEdge(fromIndex = fromIndex, toIndex = toIndex) - } + }.toImmutableList() val spatialIndex = if (scene.nodes.size >= SpatialIndexThreshold) { NodeSpatialIndex(baseCenters) } else { @@ -70,12 +73,13 @@ internal fun buildViewportFrame( .toBaseRect(camera) .inflate(NodeHitRadius / camera.zoom.coerceAtLeast(1e-6f)) val visibleNodeIndices = cache.queryNodeIndices(visibleBaseRect) - val screenPositions = arrayOfNulls(cache.baseCenters.size) + val mutableScreenPositions = MutableList(cache.baseCenters.size) { null } visibleNodeIndices.forEach { nodeIndex -> - screenPositions[nodeIndex] = cache.baseCenters[nodeIndex].toScreenPosition(viewportSize, camera) + mutableScreenPositions[nodeIndex] = cache.baseCenters[nodeIndex].toScreenPosition(viewportSize, camera) } + val screenPositions = mutableScreenPositions.toImmutableList() val visibleEdges = if (!cache.scene.showLinks || cache.scene.linkRenderMode == LinkRenderMode.HIDDEN) { - emptyList() + persistentListOf() } else { val edgeBudget = when (cache.scene.linkRenderMode) { LinkRenderMode.FULL -> cache.indexedEdges.size @@ -91,7 +95,7 @@ internal fun buildViewportFrame( add(edge) } } - } + }.toImmutableList() } return ViewportFrame( screenPositions = screenPositions, @@ -139,29 +143,29 @@ internal fun ViewportSceneCache.selectNodes( .map { nodeIndex -> scene.nodes[nodeIndex].id } } -@Immutable internal data class ViewportSceneCache( val scene: ViewportScene, - val baseCenters: List = emptyList(), - val indexedEdges: List = emptyList(), + val baseCenters: ImmutableList = persistentListOf(), + val indexedEdges: ImmutableList = persistentListOf(), val spatialIndex: NodeSpatialIndex? = null, ) { - fun queryNodeIndices(rect: Rect): List = spatialIndex?.query(rect) ?: baseCenters.indices.filter { index -> - rect.contains(baseCenters[index]) - } + fun queryNodeIndices(rect: Rect): ImmutableList = + spatialIndex?.query(rect) ?: baseCenters.indices.filter { index -> + rect.contains(baseCenters[index]) + }.toImmutableList() } @Immutable internal data class ViewportFrame( - val screenPositions: Array, - val visibleNodeIndices: List, - val visibleEdges: List, + val screenPositions: ImmutableList, + val visibleNodeIndices: ImmutableList, + val visibleEdges: ImmutableList, ) { companion object { fun empty(nodeCount: Int): ViewportFrame = ViewportFrame( - screenPositions = arrayOfNulls(nodeCount), - visibleNodeIndices = emptyList(), - visibleEdges = emptyList(), + screenPositions = List(nodeCount) { null }.toImmutableList(), + visibleNodeIndices = persistentListOf(), + visibleEdges = persistentListOf(), ) } } @@ -178,9 +182,9 @@ internal class NodeSpatialIndex(positions: List) { } } - fun query(rect: Rect): List { + fun query(rect: Rect): ImmutableList { if (rect.isEmpty) { - return emptyList() + return persistentListOf() } val minCellX = floor(rect.left / SpatialIndexCellSize).toInt() val maxCellX = floor(rect.right / SpatialIndexCellSize).toInt() @@ -192,7 +196,7 @@ internal class NodeSpatialIndex(positions: List) { cells[cellKey(cellX, cellY)]?.let(matches::addAll) } } - return matches + return matches.toImmutableList() } private fun cellKey(position: Offset): Long = diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt index 8e2eb1dd17..de38810137 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt @@ -19,6 +19,8 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList class GroupInspectorStateTest { @Test @@ -27,22 +29,22 @@ class GroupInspectorStateTest { nodes = listOf( ViewportNode( id = 10, - coordinates = listOf(-2.0, 5.0), - concentrations = listOf( + coordinates = persistentListOf(-2.0, 5.0), + concentrations = persistentListOf( InfoField("shared", "1"), InfoField("variant", "A"), ), ), ViewportNode( id = 20, - coordinates = listOf(4.0, -1.0), - concentrations = listOf( + coordinates = persistentListOf(4.0, -1.0), + concentrations = persistentListOf( InfoField("shared", "1"), InfoField("variant", "B"), InfoField("partial", "yes"), ), ), - ), + ).toImmutableList(), ) val inspector = assertIs(scene.toInspectorState(listOf(10, 20))) @@ -61,15 +63,15 @@ class GroupInspectorStateTest { fun `selection is cleared when all selected nodes disappear from the scene`() { val state = AlchemistUiState( scene = ViewportScene( - nodes = listOf(ViewportNode(id = 1, coordinates = listOf(0.0, 0.0))), + nodes = persistentListOf(ViewportNode(id = 1, coordinates = persistentListOf(0.0, 0.0))), ), - selectedNodeIds = listOf(1), + selectedNodeIds = persistentListOf(1), inspector = NodeInspectorState( nodeId = 1, subtitle = "Live node snapshot", - position = emptyList(), - concentrations = emptyList(), - metadata = emptyList(), + position = persistentListOf(), + concentrations = persistentListOf(), + metadata = persistentListOf(), ), ) diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportNodeMovementTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportNodeMovementTest.kt index d127bb6527..c128786119 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportNodeMovementTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportNodeMovementTest.kt @@ -17,16 +17,18 @@ import it.unibo.alchemist.boundary.composeui.model.ViewportScene import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList class ViewportNodeMovementTest { @Test fun `translate selected nodes applies one delta and preserves trailing coordinates`() { val scene = ViewportScene( nodes = listOf( - ViewportNode(id = 1, coordinates = listOf(0.0, 1.0, 7.0)), - ViewportNode(id = 2, coordinates = listOf(3.0, -2.0, 9.0)), - ViewportNode(id = 3, coordinates = listOf(10.0, 10.0, 11.0)), - ), + ViewportNode(id = 1, coordinates = persistentListOf(0.0, 1.0, 7.0)), + ViewportNode(id = 2, coordinates = persistentListOf(3.0, -2.0, 9.0)), + ViewportNode(id = 3, coordinates = persistentListOf(10.0, 10.0, 11.0)), + ).toImmutableList(), ) val moved = scene.translateSelectedNodes(listOf(1, 2), deltaX = 1.5, deltaY = -0.5) @@ -40,12 +42,14 @@ class ViewportNodeMovementTest { fun `with moved nodes replaces only targeted coordinates`() { val scene = ViewportScene( nodes = listOf( - ViewportNode(id = 1, coordinates = listOf(0.0, 0.0)), - ViewportNode(id = 2, coordinates = listOf(1.0, 1.0)), - ), + ViewportNode(id = 1, coordinates = persistentListOf(0.0, 0.0)), + ViewportNode(id = 2, coordinates = persistentListOf(1.0, 1.0)), + ).toImmutableList(), ) - val moved = scene.withMovedNodes(listOf(NodePositionUpdate(nodeId = 2, coordinates = listOf(8.0, -3.0)))) + val moved = scene.withMovedNodes( + listOf(NodePositionUpdate(nodeId = 2, coordinates = persistentListOf(8.0, -3.0))), + ) assertEquals(listOf(0.0, 0.0), moved.nodes.first { it.id == 1 }.coordinates) assertEquals(listOf(8.0, -3.0), moved.nodes.first { it.id == 2 }.coordinates) @@ -56,11 +60,11 @@ class ViewportNodeMovementTest { val state = AlchemistUiState( scene = ViewportScene( nodes = listOf( - ViewportNode(id = 1, coordinates = listOf(0.0, 1.0)), - ViewportNode(id = 2, coordinates = listOf(4.0, 3.0)), - ), + ViewportNode(id = 1, coordinates = persistentListOf(0.0, 1.0)), + ViewportNode(id = 2, coordinates = persistentListOf(4.0, 3.0)), + ).toImmutableList(), ), - selectedNodeIds = listOf(1, 2), + selectedNodeIds = persistentListOf(1, 2), ) val moved = state @@ -74,4 +78,15 @@ class ViewportNodeMovementTest { assertEquals("-0.500", inspector.position.first { it.label == "Min Y" }.value) assertEquals("1.500", inspector.position.first { it.label == "Max Y" }.value) } + + @Test + fun `mutable coordinate inputs are copied before entering viewport state`() { + val mutableCoordinates = mutableListOf(1.0, 2.0) + val node = ViewportNode(id = 1, coordinates = persistentListOf(0.0, 0.0)) + .withCoordinates(mutableCoordinates) + + mutableCoordinates[0] = 99.0 + + assertEquals(persistentListOf(1.0, 2.0), node.coordinates) + } } diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt index 1d74befbaf..ef6bda9cc3 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt @@ -18,6 +18,8 @@ import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList class ViewportProjectionTest { @Test @@ -31,7 +33,7 @@ class ViewportProjectionTest { val viewportSize = IntSize(1000, 500) val projection = assertNotNull(sampleScene().createViewportProjection(viewportSize)) - val movedNode = ViewportNode(id = 3, coordinates = listOf(20.0, 20.0)) + val movedNode = ViewportNode(id = 3, coordinates = persistentListOf(20.0, 20.0)) val movedPosition = movedNode.toViewportPosition(viewportSize, projection) assertTrue(movedPosition.x > viewportSize.width) @@ -41,7 +43,7 @@ class ViewportProjectionTest { private fun sampleScene(): ViewportScene = ViewportScene( nodes = listOf( - ViewportNode(id = 1, coordinates = listOf(0.0, 0.0)), - ViewportNode(id = 2, coordinates = listOf(10.0, 10.0)), - ), + ViewportNode(id = 1, coordinates = persistentListOf(0.0, 0.0)), + ViewportNode(id = 2, coordinates = persistentListOf(10.0, 10.0)), + ).toImmutableList(), ) diff --git a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRenderingPolicyTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRenderingPolicyTest.kt index ff316c8757..d5859e2353 100644 --- a/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRenderingPolicyTest.kt +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRenderingPolicyTest.kt @@ -17,16 +17,18 @@ import it.unibo.alchemist.boundary.composeui.model.ViewportNode import it.unibo.alchemist.boundary.composeui.model.ViewportScene import kotlin.test.Test import kotlin.test.assertEquals +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList class ViewportRenderingPolicyTest { @Test fun `frame culls off-screen nodes and edges`() { val scene = ViewportScene( nodes = listOf( - ViewportNode(id = 1, coordinates = listOf(0.0, 0.0)), - ViewportNode(id = 2, coordinates = listOf(1.0, 1.0)), - ViewportNode(id = 3, coordinates = listOf(2.0, 2.0)), - ), + ViewportNode(id = 1, coordinates = persistentListOf(0.0, 0.0)), + ViewportNode(id = 2, coordinates = persistentListOf(1.0, 1.0)), + ViewportNode(id = 3, coordinates = persistentListOf(2.0, 2.0)), + ).toImmutableList(), showLinks = true, ) val cache = ViewportSceneCache( @@ -35,11 +37,11 @@ class ViewportRenderingPolicyTest { Offset(50f, 50f), Offset(400f, 400f), Offset(80f, 80f), - ), + ).toImmutableList(), indexedEdges = listOf( IndexedEdge(fromIndex = 0, toIndex = 2), IndexedEdge(fromIndex = 0, toIndex = 1), - ), + ).toImmutableList(), ) val frame = buildViewportFrame(cache, IntSize(120, 120), ViewportCameraState()) @@ -52,17 +54,18 @@ class ViewportRenderingPolicyTest { fun `sampled mode caps the number of visible edges per frame`() { val scene = ViewportScene( nodes = listOf( - ViewportNode(id = 1, coordinates = listOf(0.0, 0.0)), - ViewportNode(id = 2, coordinates = listOf(1.0, 1.0)), - ), - edges = List(MaxDrawnEdgesPerFrame + 12) { ViewportEdge(1, 2) }, + ViewportNode(id = 1, coordinates = persistentListOf(0.0, 0.0)), + ViewportNode(id = 2, coordinates = persistentListOf(1.0, 1.0)), + ).toImmutableList(), + edges = List(MaxDrawnEdgesPerFrame + 12) { ViewportEdge(1, 2) }.toImmutableList(), showLinks = true, linkRenderMode = LinkRenderMode.SAMPLED, ) val cache = ViewportSceneCache( scene = scene, - baseCenters = listOf(Offset(20f, 20f), Offset(80f, 80f)), - indexedEdges = List(MaxDrawnEdgesPerFrame + 12) { IndexedEdge(fromIndex = 0, toIndex = 1) }, + baseCenters = persistentListOf(Offset(20f, 20f), Offset(80f, 80f)), + indexedEdges = List(MaxDrawnEdgesPerFrame + 12) { IndexedEdge(fromIndex = 0, toIndex = 1) } + .toImmutableList(), ) val frame = buildViewportFrame(cache, IntSize(120, 120), ViewportCameraState()) diff --git a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt index 9d9205ed5d..fd99f47a61 100644 --- a/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt @@ -24,15 +24,18 @@ import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Node import it.unibo.alchemist.model.Position import java.util.PriorityQueue +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList fun > Node.toViewport(environment: Environment): ViewportNode = ViewportNode( id = id, - coordinates = environment.getPosition(this).coordinates.toList(), - concentrations = this.contents.map { InfoField(it.key.toString(), it.value.toString()) }, + coordinates = environment.getPosition(this).coordinates.toList().toImmutableList(), + concentrations = this.contents.map { InfoField(it.key.toString(), it.value.toString()) }.toImmutableList(), ) fun > Environment.toViewport(renderLinks: Boolean = false): ViewportScene { - val viewportNodes = nodes.map { it.toViewport(this) } + val viewportNodes = nodes.map { it.toViewport(this) }.toImmutableList() val edgeSnapshot = extractEdgeSnapshot(renderLinks) return ViewportScene( nodes = viewportNodes, @@ -98,7 +101,7 @@ internal fun collectEdgeSnapshot(edgePairs: Sequence>, renderLink } return when { uniqueEdges <= FullEdgeRenderLimit -> EdgeSnapshot( - edges = fullEdges, + edges = fullEdges.toImmutableList(), edgeCount = uniqueEdges, renderMode = LinkRenderMode.FULL, ) @@ -106,7 +109,8 @@ internal fun collectEdgeSnapshot(edgePairs: Sequence>, renderLink edges = sampledEdges .toList() .sortedBy(SampledViewportEdge::score) - .map(SampledViewportEdge::edge), + .map(SampledViewportEdge::edge) + .toImmutableList(), edgeCount = uniqueEdges, renderMode = LinkRenderMode.SAMPLED, notice = "showing ${MaxDrawnEdgesPerFrame.toReadableCount()} sampled links", @@ -128,7 +132,7 @@ private fun PriorityQueue.consider(edgeKey: Long) { } internal data class EdgeSnapshot( - val edges: List = emptyList(), + val edges: ImmutableList = persistentListOf(), val edgeCount: Int = 0, val renderMode: LinkRenderMode = LinkRenderMode.FULL, val notice: String? = null, diff --git a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt index a01eef19f7..f3b28cf405 100644 --- a/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt +++ b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.kt @@ -38,6 +38,7 @@ import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.startCoroutine import kotlin.test.Test import kotlin.test.assertEquals +import kotlinx.collections.immutable.persistentListOf import org.apache.commons.math3.random.RandomGenerator import org.jooq.lambda.fi.lang.CheckedRunnable @@ -54,7 +55,7 @@ class DesktopAlchemistUiNodeMoveTest { val store = ComposeUiStateStore( AlchemistUiState( scene = environment.toViewport(), - selectedNodeIds = listOf(firstNode.id, secondNode.id), + selectedNodeIds = persistentListOf(firstNode.id, secondNode.id), ).withSelection(listOf(firstNode.id, secondNode.id)), ) val callback = DesktopAlchemistUiCallback(simulation, store) @@ -62,8 +63,8 @@ class DesktopAlchemistUiNodeMoveTest { runSuspend { callback.onNodesMoved( listOf( - NodePositionUpdate(firstNode.id, listOf(1.5, -2.0)), - NodePositionUpdate(secondNode.id, listOf(3.5, 1.0)), + NodePositionUpdate(firstNode.id, persistentListOf(1.5, -2.0)), + NodePositionUpdate(secondNode.id, persistentListOf(3.5, 1.0)), ), ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 495abc654e..bae4e4cd66 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ kotest = "6.1.11" kotlin = "2.3.20" ksp = "2.3.6" kotlinx-coroutines = "1.10.2" +kotlinx-collections-immutable = "0.4.0" ktor = "3.4.2" mockito = "5.23.0" protelis = "18.7.0" @@ -82,6 +83,7 @@ kotest-runner = { module = "io.kotest:kotest-runner-junit5-jvm", version.ref = " kotlin-cli = "org.jetbrains.kotlinx:kotlinx-cli:0.3.6" kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } kotlin-jvm-plugin = { module = "org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin", version.ref = "kotlin" } kotlin-multiplatform-plugin = { module = "org.jetbrains.kotlin.multiplatform:org.jetbrains.kotlin.multiplatform.gradle.plugin", version.ref = "kotlin" } kotlin-power-assert-plugin = { module = "org.jetbrains.kotlin.plugin.power-assert:org.jetbrains.kotlin.plugin.power-assert.gradle.plugin", version.ref = "kotlin" } From e57282f11f11d9af3618d180a54454ff24fd7d7d Mon Sep 17 00:00:00 2001 From: Nicolas Farabegoli Date: Fri, 22 May 2026 15:14:49 +0200 Subject: [PATCH 22/22] chore: improve accessibility --- .../composeui/view/controls/ControlDock.kt | 342 +++++++++++---- .../view/root/SimulationPrimaryPane.kt | 1 + .../view/viewport/ViewportSurface.kt | 399 +++++++++++++----- 3 files changed, 567 insertions(+), 175 deletions(-) diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt index 698947b0f5..505e5c32e2 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import it.unibo.alchemist.boundary.composeui.SimulationControlsConfig.MIN_UI_FPS import it.unibo.alchemist.boundary.composeui.model.EventsPerSecond import it.unibo.alchemist.boundary.composeui.model.FullThrottle import it.unibo.alchemist.boundary.composeui.model.SimulationControlsState @@ -71,92 +72,250 @@ internal fun ControlDock( onFpsInputChanged: (String) -> Unit, onFpsSubmit: () -> Unit, onEventRateChanged: (Float) -> Unit, + compact: Boolean = false, modifier: Modifier = Modifier, ) { + val validation = controls.validationMessages() Surface( modifier = modifier, color = SurfaceStrong, shape = RoundedCornerShape(12.dp), elevation = 0.dp, ) { + if (compact) { + CompactDockContent( + controls = controls, + validation = validation, + onPlay = onPlay, + onPause = onPause, + onStep = onStep, + onToTimeInputChanged = onToTimeInputChanged, + onToTimeSubmit = onToTimeSubmit, + onToStepInputChanged = onToStepInputChanged, + onToStepSubmit = onToStepSubmit, + onFpsInputChanged = onFpsInputChanged, + onFpsSubmit = onFpsSubmit, + onEventRateChanged = onEventRateChanged, + ) + } else { + WideDockContent( + controls = controls, + validation = validation, + onPlay = onPlay, + onPause = onPause, + onStep = onStep, + onToTimeInputChanged = onToTimeInputChanged, + onToTimeSubmit = onToTimeSubmit, + onToStepInputChanged = onToStepInputChanged, + onToStepSubmit = onToStepSubmit, + onFpsInputChanged = onFpsInputChanged, + onFpsSubmit = onFpsSubmit, + onEventRateChanged = onEventRateChanged, + ) + } + } +} + +@Composable +private fun WideDockContent( + controls: SimulationControlsState, + validation: DockValidationMessages, + onPlay: () -> Unit, + onPause: () -> Unit, + onStep: () -> Unit, + onToTimeInputChanged: (String) -> Unit, + onToTimeSubmit: () -> Unit, + onToStepInputChanged: (String) -> Unit, + onToStepSubmit: () -> Unit, + onFpsInputChanged: (String) -> Unit, + onFpsSubmit: () -> Unit, + onEventRateChanged: (Float) -> Unit, +) { + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = dockHorizontalPadding, vertical = dockVerticalPadding), + horizontalArrangement = Arrangement.spacedBy(dockSectionSpacing), + verticalAlignment = Alignment.Top, + ) { + TransportSection(controls, onPlay, onPause, onStep) + MetricsSection(controls) + JumpSection( + controls = controls, + validation = validation, + onToTimeInputChanged = onToTimeInputChanged, + onToTimeSubmit = onToTimeSubmit, + onToStepInputChanged = onToStepInputChanged, + onToStepSubmit = onToStepSubmit, + ) + PacingSection( + controls = controls, + validation = validation, + onFpsInputChanged = onFpsInputChanged, + onFpsSubmit = onFpsSubmit, + onEventRateChanged = onEventRateChanged, + ) + } +} + +@Composable +private fun CompactDockContent( + controls: SimulationControlsState, + validation: DockValidationMessages, + onPlay: () -> Unit, + onPause: () -> Unit, + onStep: () -> Unit, + onToTimeInputChanged: (String) -> Unit, + onToTimeSubmit: () -> Unit, + onToStepInputChanged: (String) -> Unit, + onToStepSubmit: () -> Unit, + onFpsInputChanged: (String) -> Unit, + onFpsSubmit: () -> Unit, + onEventRateChanged: (Float) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dockHorizontalPadding, vertical = dockVerticalPadding), + verticalArrangement = Arrangement.spacedBy(dockSectionSpacing), + ) { + CompactDockRow { + TransportSection(controls, onPlay, onPause, onStep) + MetricsSection(controls) + } + CompactDockRow { + JumpSection( + controls = controls, + validation = validation, + onToTimeInputChanged = onToTimeInputChanged, + onToTimeSubmit = onToTimeSubmit, + onToStepInputChanged = onToStepInputChanged, + onToStepSubmit = onToStepSubmit, + ) + PacingSection( + controls = controls, + validation = validation, + onFpsInputChanged = onFpsInputChanged, + onFpsSubmit = onFpsSubmit, + onEventRateChanged = onEventRateChanged, + ) + } + } +} + +@Composable +private fun CompactDockRow(content: @Composable () -> Unit) { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(dockSectionSpacing), + verticalAlignment = Alignment.Top, + ) { + content() + } +} + +@Composable +private fun TransportSection( + controls: SimulationControlsState, + onPlay: () -> Unit, + onPause: () -> Unit, + onStep: () -> Unit, +) { + DockSection(title = "Transport") { Row( - modifier = Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = dockHorizontalPadding, vertical = dockVerticalPadding), - horizontalArrangement = Arrangement.spacedBy(dockSectionSpacing), - verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing), + verticalAlignment = Alignment.CenterVertically, ) { - DockSection(title = "Transport") { - Row( - horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing), - verticalAlignment = Alignment.CenterVertically, - ) { - StatusPill(controls) - transportActions(controls, onPlay, onPause, onStep).forEach { action -> - TransportButton( - label = action.label, - enabled = action.enabled, - accent = action.accent, - onClick = action.onClick, - ) - } - } - } - DockSection(title = "Metrics") { - Row( - horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing), - ) { - metrics(controls).forEach { metric -> - MetricBlock( - label = metric.label, - value = metric.value, - ) - } - } - } - DockSection(title = "Jump") { - Row( - horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing), - verticalAlignment = Alignment.Top, - ) { - DockTextField( - label = "To Time", - value = controls.toTimeInput, - caption = "Enter to jump", - onValueChange = onToTimeInputChanged, - onSubmit = onToTimeSubmit, - ) - DockTextField( - label = "To Step", - value = controls.toStepInput, - caption = "Enter to jump", - onValueChange = onToStepInputChanged, - onSubmit = onToStepSubmit, - ) - } + StatusPill(controls) + transportActions(controls, onPlay, onPause, onStep).forEach { action -> + TransportButton( + label = action.label, + enabled = action.enabled, + accent = action.accent, + onClick = action.onClick, + ) } - DockSection(title = "Pacing") { - Row( - horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing), - verticalAlignment = Alignment.Top, - ) { - DockTextField( - label = "FPS", - value = controls.fpsInput, - caption = controls.fpsRangeLabel, - onValueChange = onFpsInputChanged, - onSubmit = onFpsSubmit, - ) - EventRateSlider( - controls = controls, - onValueChange = onEventRateChanged, - ) - } + } + } +} + +@Composable +private fun MetricsSection(controls: SimulationControlsState) { + DockSection(title = "Metrics") { + Row(horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing)) { + metrics(controls).forEach { metric -> + MetricBlock( + label = metric.label, + value = metric.value, + ) } } } } +@Composable +private fun JumpSection( + controls: SimulationControlsState, + validation: DockValidationMessages, + onToTimeInputChanged: (String) -> Unit, + onToTimeSubmit: () -> Unit, + onToStepInputChanged: (String) -> Unit, + onToStepSubmit: () -> Unit, +) { + DockSection(title = "Jump") { + Row( + horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing), + verticalAlignment = Alignment.Top, + ) { + DockTextField( + label = "To Time", + value = controls.toTimeInput, + caption = validation.toTime ?: "Enter to jump", + isError = validation.toTime != null, + onValueChange = onToTimeInputChanged, + onSubmit = onToTimeSubmit, + ) + DockTextField( + label = "To Step", + value = controls.toStepInput, + caption = validation.toStep ?: "Enter to jump", + isError = validation.toStep != null, + onValueChange = onToStepInputChanged, + onSubmit = onToStepSubmit, + ) + } + } +} + +@Composable +private fun PacingSection( + controls: SimulationControlsState, + validation: DockValidationMessages, + onFpsInputChanged: (String) -> Unit, + onFpsSubmit: () -> Unit, + onEventRateChanged: (Float) -> Unit, +) { + DockSection(title = "Pacing") { + Row( + horizontalArrangement = Arrangement.spacedBy(sectionItemSpacing), + verticalAlignment = Alignment.Top, + ) { + DockTextField( + label = "FPS", + value = controls.fpsInput, + caption = validation.fps ?: controls.fpsRangeLabel, + isError = validation.fps != null, + onValueChange = onFpsInputChanged, + onSubmit = onFpsSubmit, + ) + EventRateSlider( + controls = controls, + onValueChange = onEventRateChanged, + ) + } + } +} + @Composable private fun DockSection(title: String, content: @Composable () -> Unit) { Surface( @@ -190,6 +349,7 @@ private fun DockTextField( label: String, value: String, caption: String, + isError: Boolean, onValueChange: (String) -> Unit, onSubmit: () -> Unit, ) { @@ -212,11 +372,15 @@ private fun DockTextField( }, label = { Text(label) }, singleLine = true, + isError = isError, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), colors = TextFieldDefaults.outlinedTextFieldColors( textColor = TextPrimary, focusedBorderColor = PrimaryAccent, unfocusedBorderColor = Outline, + errorBorderColor = Danger, + errorLabelColor = Danger, + errorCursorColor = Danger, focusedLabelColor = PrimaryAccent, unfocusedLabelColor = TextSecondary, cursorColor = PrimaryAccent, @@ -225,7 +389,7 @@ private fun DockTextField( Text( text = caption, style = MaterialTheme.typography.caption, - color = TextSecondary, + color = if (isError) Danger else TextSecondary, ) } } @@ -242,7 +406,7 @@ private fun EventRateSlider(controls: SimulationControlsState, onValueChange: (F color = SecondaryAccent, ) Slider( - value = controls.simulationEventThrottling.value.toFloat(), + value = controls.eventRateSliderValue, onValueChange = onValueChange, valueRange = MIN_SIMULATION_EVENTS_PER_SECOND.toFloat()..MAX_SIMULATION_EVENTS_PER_SECOND.toFloat(), @@ -302,6 +466,12 @@ private data class TransportAction( private data class MetricValue(val label: String, val value: String) +private data class DockValidationMessages( + val toTime: String? = null, + val toStep: String? = null, + val fps: String? = null, +) + private fun transportActions( controls: SimulationControlsState, onPlay: () -> Unit, @@ -317,3 +487,31 @@ private fun metrics(controls: SimulationControlsState): List = list MetricValue(label = "Time", value = controls.timeLabel), MetricValue(label = "Step", value = controls.step.toString()), ) + +private val SimulationControlsState.eventRateSliderValue: Float + get() = simulationEventThrottling.value + .coerceAtMost(MAX_SIMULATION_EVENTS_PER_SECOND) + .coerceAtLeast(MIN_SIMULATION_EVENTS_PER_SECOND) + .toFloat() + +private fun SimulationControlsState.validationMessages(): DockValidationMessages = DockValidationMessages( + toTime = toTimeInput.numericValidationError("Use a numeric time"), + toStep = toStepValidationError(), + fps = fpsValidationError(), +) + +private fun String.numericValidationError(parseError: String): String? = takeIf { it.isNotBlank() }?.let { + if (it.toDoubleOrNull() == null) parseError else null +} + +private fun SimulationControlsState.toStepValidationError(): String? = toStepInput.takeIf { + it.isNotBlank() +}?.let { input -> + val targetStep = input.toLongOrNull() ?: return@let "Use an integer step" + if (targetStep < step) "Target is before current step" else null +} + +private fun SimulationControlsState.fpsValidationError(): String? = fpsInput.takeIf { it.isNotBlank() }?.let { + val fps = it.toIntOrNull() ?: return@let "Use an integer FPS" + if (fps in MIN_UI_FPS..maxUiFps) null else "Use ${MIN_UI_FPS}-$maxUiFps FPS" +} diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt index 9a96cd0d4a..05bceb5bd0 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt @@ -69,6 +69,7 @@ internal fun SimulationPrimaryPane( onFpsInputChanged = { coroutineScope.launch { callbacks.onFpsInputChanged(it) } }, onFpsSubmit = { coroutineScope.launch { callbacks.onFpsSubmit() } }, onEventRateChanged = { coroutineScope.launch { callbacks.onEventRateChanged(it) } }, + compact = dockWidthFraction >= 1f, modifier = Modifier.wrapContentHeight(), ) } diff --git a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt index 39cbecd120..e435dbc727 100644 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt @@ -12,6 +12,7 @@ package it.unibo.alchemist.boundary.composeui.view.viewport import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -39,11 +40,23 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.isCtrlPressed import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -96,23 +109,18 @@ internal fun ViewportSurface( fixedProjection = candidateProjection } } - val previewScene = remember(scene, draggedNodeIds, nodeDragAnchor, nodeDragCurrent, viewportSize, camera, projection) { - val anchor = nodeDragAnchor - val current = nodeDragCurrent - if (anchor == null || current == null || projection == null) { - scene - } else { - val (deltaX, deltaY) = screenDeltaToWorldDelta(anchor, current, viewportSize, camera, projection) - scene.translateSelectedNodes(draggedNodeIds, deltaX, deltaY) - } - } - val sceneCache = remember(previewScene, viewportSize, projection) { - buildViewportSceneCache(previewScene, viewportSize, projection) + val sceneCache = remember(scene, viewportSize, projection) { + buildViewportSceneCache(scene, viewportSize, projection) } val viewportFrame = remember(sceneCache, viewportSize, camera) { buildViewportFrame(sceneCache, viewportSize, camera) } val selectedNodeIdSet = remember(selectedNodeIds) { selectedNodeIds.toHashSet() } + val draggedNodeIdSet = remember(draggedNodeIds) { draggedNodeIds.toHashSet() } + val nodeDragScreenDelta = nodeDragAnchor?.let { anchor -> + val current = nodeDragCurrent ?: anchor + current - anchor + } ?: Offset.Zero val density = androidx.compose.ui.platform.LocalDensity.current val tapThresholdPx = with(density) { NodeHitRadius.dp.toPx() } val dragThresholdPx = with(density) { 6.dp.toPx() } @@ -147,6 +155,99 @@ internal fun ViewportSurface( modifier = Modifier .fillMaxSize() .onGloballyPositioned { coordinates -> viewportSize = coordinates.size } + .focusable() + .semantics { + contentDescription = viewportContentDescription( + nodeCount = scene.nodes.size, + selectedNodeCount = selectedNodeIds.size, + showLinks = scene.showLinks, + ) + stateDescription = viewportStateDescription( + camera = camera, + selectedNodeCount = selectedNodeIds.size, + ) + role = Role.Image + customActions = buildViewportAccessibilityActions( + hasNodes = scene.nodes.isNotEmpty(), + hasSelection = selectedNodeIds.isNotEmpty(), + onSelectFirstNode = { + scene.nodes.firstOrNull()?.let { node -> + coroutineScope.launch { callbacks.onNodeSelected(node.id) } + true + } ?: false + }, + onClearSelection = { + if (selectedNodeIds.isEmpty()) { + false + } else { + coroutineScope.launch { callbacks.onInspectorDismiss() } + true + } + }, + onZoomIn = { + camera = camera.zoomIn(viewportSize) + true + }, + onZoomOut = { + camera = camera.zoomOut(viewportSize) + true + }, + onPanLeft = { + camera = camera.panBy(Offset(KEYBOARD_PAN_STEP_PX, 0f)) + true + }, + onPanRight = { + camera = camera.panBy(Offset(-KEYBOARD_PAN_STEP_PX, 0f)) + true + }, + onPanUp = { + camera = camera.panBy(Offset(0f, KEYBOARD_PAN_STEP_PX)) + true + }, + onPanDown = { + camera = camera.panBy(Offset(0f, -KEYBOARD_PAN_STEP_PX)) + true + }, + ) + } + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } + when (event.key) { + Key.DirectionLeft -> { + camera = camera.panBy(Offset(KEYBOARD_PAN_STEP_PX, 0f)) + true + } + Key.DirectionRight -> { + camera = camera.panBy(Offset(-KEYBOARD_PAN_STEP_PX, 0f)) + true + } + Key.DirectionUp -> { + camera = camera.panBy(Offset(0f, KEYBOARD_PAN_STEP_PX)) + true + } + Key.DirectionDown -> { + camera = camera.panBy(Offset(0f, -KEYBOARD_PAN_STEP_PX)) + true + } + Key.Enter, Key.NumPadEnter -> { + scene.nodes.firstOrNull()?.let { node -> + coroutineScope.launch { callbacks.onNodeSelected(node.id) } + true + } ?: false + } + Key.Backspace -> { + if (selectedNodeIds.isEmpty()) { + false + } else { + coroutineScope.launch { callbacks.onInspectorDismiss() } + true + } + } + else -> false + } + } .onPointerEvent(PointerEventType.Press) { event -> val change = event.changes.firstOrNull() ?: return@onPointerEvent if (event.buttons.isSecondaryPressed) { @@ -277,10 +378,17 @@ internal fun ViewportSurface( ) val currentCamera = camera drawGrid(size, currentCamera) - if (previewScene.showLinks) { + fun shiftedCenter(nodeIndex: Int): Offset? { + val center = viewportFrame.screenPositions[nodeIndex] + val node = scene.nodes.getOrNull(nodeIndex) + return center?.let { + if (node?.id in draggedNodeIdSet) it + nodeDragScreenDelta else it + } + } + if (scene.showLinks) { viewportFrame.visibleEdges.forEach { edge -> - val start = viewportFrame.screenPositions[edge.fromIndex] ?: return@forEach - val end = viewportFrame.screenPositions[edge.toIndex] ?: return@forEach + val start = shiftedCenter(edge.fromIndex) ?: return@forEach + val end = shiftedCenter(edge.toIndex) ?: return@forEach drawLine( color = Outline.copy(alpha = 0.42f), start = start, @@ -291,8 +399,8 @@ internal fun ViewportSurface( } } viewportFrame.visibleNodeIndices.forEach { nodeIndex -> - val node = previewScene.nodes[nodeIndex] - val center = viewportFrame.screenPositions[nodeIndex] ?: return@forEach + val node = scene.nodes[nodeIndex] + val center = shiftedCenter(nodeIndex) ?: return@forEach val isSelected = node.id in selectedNodeIdSet val nodeColor = lerp(SecondaryAccent, PrimaryAccent, node.accent) val screenRadius = NodeRadius.dp.toPx() * currentCamera.zoom @@ -336,47 +444,19 @@ internal fun ViewportSurface( ) } } - Column( + ViewportChrome( + scene = scene, + onToggleLinks = { coroutineScope.launch { callbacks.onToggleLinks() } }, modifier = Modifier .align(Alignment.TopStart) .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = "Alchemist Simulator", - style = MaterialTheme.typography.h4, - ) - Text( - text = scene.message, - style = MaterialTheme.typography.body2, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - SummaryRail( - summary = scene.summary, - showLinks = scene.showLinks, - onToggleLinks = { coroutineScope.launch { callbacks.onToggleLinks() } }, - linkRenderNotice = scene.linkRenderNotice.takeIf { scene.showLinks }, - ) - } - Surface( + ) + ViewportGestureHint( + hasNodes = scene.nodes.isNotEmpty(), modifier = Modifier .align(Alignment.BottomStart) .padding(20.dp), - color = SurfaceStrong.copy(alpha = 0.88f), - shape = RoundedCornerShape(8.dp), - elevation = 0.dp, - ) { - Text( - text = if (scene.nodes.isEmpty()) { - "No nodes to display" - } else { - "Click to inspect · drag to select · Ctrl-drag selected nodes · right-drag to pan · wheel to zoom" - }, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - style = MaterialTheme.typography.caption, - ) - } + ) val gridLegend = remember(viewportSize, camera.zoom, projection) { if (viewportSize.width == 0 || viewportSize.height == 0 || projection == null) return@remember null val baseStep = min( @@ -397,66 +477,120 @@ internal fun ViewportSurface( GridLegendData(worldStep, worldStep, step, step) } if (gridLegend != null) { - Surface( + ViewportGridLegend( + gridLegend = gridLegend, modifier = Modifier .align(Alignment.BottomEnd) .padding(20.dp), - color = SurfaceStrong.copy(alpha = 0.88f), - shape = RoundedCornerShape(8.dp), - elevation = 0.dp, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(12.dp), - ) { - Text( - text = gridLegend.worldX.formatFixed(2), - style = MaterialTheme.typography.caption, - color = TextPrimary, - modifier = Modifier.padding(bottom = 4.dp), - ) - val density = androidx.compose.ui.platform.LocalDensity.current - val canvasWidth = with(density) { gridLegend.stepX.toDp().coerceIn(20.dp, 120.dp) } - Canvas(modifier = Modifier.size(canvasWidth, 10.dp)) { - val strokeWidth = 1.5.dp.toPx() - val color = TextPrimary.copy(alpha = 0.7f) + ) + } + } + } +} - val startX = 0f - val endX = size.width - val centerY = size.height / 2f - val tickHeight = 4.dp.toPx() +@Composable +private fun ViewportChrome( + scene: ViewportScene, + onToggleLinks: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Alchemist Simulator", + style = MaterialTheme.typography.h4, + ) + Text( + text = scene.message, + style = MaterialTheme.typography.body2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + SummaryRail( + summary = scene.summary, + showLinks = scene.showLinks, + onToggleLinks = onToggleLinks, + linkRenderNotice = scene.linkRenderNotice.takeIf { scene.showLinks }, + ) + } +} - // Draw horizontal line - drawLine( - color = color, - start = Offset(startX, centerY), - end = Offset(endX, centerY), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - // Draw left tick - drawLine( - color = color, - start = Offset(startX + strokeWidth / 2, centerY - tickHeight), - end = Offset(startX + strokeWidth / 2, centerY + tickHeight), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - // Draw right tick - drawLine( - color = color, - start = Offset(endX - strokeWidth / 2, centerY - tickHeight), - end = Offset(endX - strokeWidth / 2, centerY + tickHeight), - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - } - } - } +@Composable +private fun ViewportGestureHint(hasNodes: Boolean, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + color = SurfaceStrong.copy(alpha = 0.88f), + shape = RoundedCornerShape(8.dp), + elevation = 0.dp, + ) { + Text( + text = if (hasNodes) { + "Click to inspect · drag to select · Ctrl-drag selected nodes · right-drag to pan · wheel to zoom" + } else { + "No nodes to display" + }, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + style = MaterialTheme.typography.caption, + ) + } +} + +@Composable +private fun ViewportGridLegend(gridLegend: GridLegendData, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + color = SurfaceStrong.copy(alpha = 0.88f), + shape = RoundedCornerShape(8.dp), + elevation = 0.dp, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(12.dp), + ) { + Text( + text = gridLegend.worldX.formatFixed(2), + style = MaterialTheme.typography.caption, + color = TextPrimary, + modifier = Modifier.padding(bottom = 4.dp), + ) + val density = androidx.compose.ui.platform.LocalDensity.current + val canvasWidth = with(density) { gridLegend.stepX.toDp().coerceIn(20.dp, 120.dp) } + Canvas(modifier = Modifier.size(canvasWidth, 10.dp)) { + drawGridLegendScale() } } } } + +private fun DrawScope.drawGridLegendScale() { + val strokeWidth = 1.5.dp.toPx() + val color = TextPrimary.copy(alpha = 0.7f) + val centerY = size.height / 2f + val tickHeight = 4.dp.toPx() + drawLine( + color = color, + start = Offset(0f, centerY), + end = Offset(size.width, centerY), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(strokeWidth / 2, centerY - tickHeight), + end = Offset(strokeWidth / 2, centerY + tickHeight), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(size.width - strokeWidth / 2, centerY - tickHeight), + end = Offset(size.width - strokeWidth / 2, centerY + tickHeight), + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) +} internal fun DrawScope.drawGrid(canvasSize: Size, camera: ViewportCameraState) { val center = Offset(canvasSize.width / 2f, canvasSize.height / 2f) val origin = center + camera.pan @@ -523,6 +657,63 @@ internal fun DrawScope.drawGrid(canvasSize: Size, camera: ViewportCameraState) { private data class GridLegendData(val worldX: Double, val worldY: Double, val stepX: Float, val stepY: Float) +private fun viewportContentDescription( + nodeCount: Int, + selectedNodeCount: Int, + showLinks: Boolean, +): String = buildString { + append("Simulation viewport with ") + append(nodeCount) + append(if (nodeCount == 1) " node" else " nodes") + append(". ") + append(selectedNodeCount) + append(if (selectedNodeCount == 1) " node selected. " else " nodes selected. ") + append(if (showLinks) "Links are visible." else "Links are hidden.") +} + +private fun viewportStateDescription(camera: ViewportCameraState, selectedNodeCount: Int): String = + "Zoom ${camera.zoom.toDouble().formatFixed(2)}, $selectedNodeCount selected" + +private fun buildViewportAccessibilityActions( + hasNodes: Boolean, + hasSelection: Boolean, + onSelectFirstNode: () -> Boolean, + onClearSelection: () -> Boolean, + onZoomIn: () -> Boolean, + onZoomOut: () -> Boolean, + onPanLeft: () -> Boolean, + onPanRight: () -> Boolean, + onPanUp: () -> Boolean, + onPanDown: () -> Boolean, +): List = buildList { + if (hasNodes) { + add(CustomAccessibilityAction("Select first node") { onSelectFirstNode() }) + } + if (hasSelection) { + add(CustomAccessibilityAction("Clear node selection") { onClearSelection() }) + } + add(CustomAccessibilityAction("Zoom in") { onZoomIn() }) + add(CustomAccessibilityAction("Zoom out") { onZoomOut() }) + add(CustomAccessibilityAction("Pan left") { onPanLeft() }) + add(CustomAccessibilityAction("Pan right") { onPanRight() }) + add(CustomAccessibilityAction("Pan up") { onPanUp() }) + add(CustomAccessibilityAction("Pan down") { onPanDown() }) +} + +private fun ViewportCameraState.zoomIn(viewportSize: IntSize): ViewportCameraState = + if (viewportSize.width > 0 && viewportSize.height > 0) { + zoomBy(viewportSize = viewportSize, pivot = viewportSize.center, scrollDelta = -1f) + } else { + this + } + +private fun ViewportCameraState.zoomOut(viewportSize: IntSize): ViewportCameraState = + if (viewportSize.width > 0 && viewportSize.height > 0) { + zoomBy(viewportSize = viewportSize, pivot = viewportSize.center, scrollDelta = 1f) + } else { + this + } + private fun createSelectionRect(anchor: Offset, current: Offset): Rect = Rect( left = min(anchor.x, current.x), top = min(anchor.y, current.y), @@ -550,3 +741,5 @@ private fun screenDeltaToWorldDelta( -baseDelta.y / projection.pixelsPerUnit ).toDouble() } + +private const val KEYBOARD_PAN_STEP_PX = 48f