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/AI_CONTEXT.md b/alchemist-composeui/AI_CONTEXT.md new file mode 100644 index 0000000000..9536f45628 --- /dev/null +++ b/alchemist-composeui/AI_CONTEXT.md @@ -0,0 +1,267 @@ +# `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, +- reposition selected nodes with `Ctrl + left drag`, +- 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. 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 + +### 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. +- 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. +- 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. +- 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. + +## 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, 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. + +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. **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. + +## 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. +- 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` + +- 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. +- 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()` + +- 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 + +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 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, node dragging, 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/build.gradle.kts b/alchemist-composeui/build.gradle.kts index 45863e6941..9737df806e 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 @@ -29,6 +30,14 @@ kotlin { val commonMain by getting { dependencies { implementation(libs.bundles.compose) + implementation(libs.kotlinx.collections.immutable) + } + } + 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/App.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt deleted file mode 100644 index 53ccbf338b..0000000000 --- a/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/App.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) 2010-2025, 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.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. - */ -@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") - } - } - } - } -} 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..bd472d9078 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsConfig.kt @@ -0,0 +1,17 @@ +/* + * 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 + +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/UiStore.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt new file mode 100644 index 0000000000..3e5199b2a3 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/UiStore.kt @@ -0,0 +1,516 @@ +@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 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.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 +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 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 +import kotlinx.coroutines.flow.update + +/** + * Thread-safe holder for the UI state observed by Compose. + */ +class ComposeUiStateStore(initialState: AlchemistUiState) { + private val mutableState = MutableStateFlow(initialState) + val state: AlchemistUiState + get() = mutableState.value + val stateFlow: StateFlow = mutableState.asStateFlow() + + /** + * Replace the current state. + */ + fun set(newState: AlchemistUiState) { + mutableState.value = newState + } + + /** + * Mutate the current state atomically. + */ + fun update(transform: (AlchemistUiState) -> AlchemistUiState) { + mutableState.update(transform) + } +} + +/** + * 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 suspend fun onPlay() { + store.update { + it.copy( + controls = it.controls.copy(status = SimulationStatus.RUNNING), + ) + } + } + + override suspend fun onPause() { + store.update { + it.copy( + controls = it.controls.copy(status = SimulationStatus.PAUSED), + ) + } + } + + override suspend fun onStep() { + store.update { + val nextStep = it.controls.step + 1 + 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 + val wasRunning = state.controls.status == SimulationStatus.RUNNING + 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), + status = if (wasRunning) SimulationStatus.RUNNING else SimulationStatus.PAUSED, + ) + } + } + + 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.") + 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, + status = if (wasRunning) SimulationStatus.RUNNING else SimulationStatus.PAUSED, + ) + } + } + + 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.updateEventThrottling(value.roundToInt())) + } + } + + override suspend fun onNodeSelected(nodeId: Int) { + store.update { + it.withSelection(listOf(nodeId)) + } + } + + override suspend fun onNodesSelected(nodeIds: List) { + store.update { + it.withSelection(nodeIds) + } + } + + 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()) + } + } + + override suspend fun onToggleLinks() { + store.update { + it.copy( + scene = it.scene.copy(showLinks = !it.scene.showLinks), + ) + } + } + + override suspend fun onDialogDismiss() { + store.update { + it.copy(controls = it.controls.copy(dialog = null)) + } + } + } + return ComposeUiController(store, callbacks) +} + +private fun sampleUiState(): AlchemistUiState { + val nodes = persistentListOf( + ViewportNode( + id = 1, + coordinates = persistentListOf(-3.5, 1.7), + accent = 0.15f, + metadata = persistentListOf( + InfoField("Neighbors", "4"), + InfoField("Reactions", "3"), + InfoField("Properties", "2"), + ), + concentrations = persistentListOf( + InfoField("signal", "0.91"), + InfoField("gradient", "0.42"), + ), + ), + ViewportNode( + id = 2, + coordinates = persistentListOf(-1.2, 0.3), + accent = 0.33f, + metadata = persistentListOf( + InfoField("Neighbors", "5"), + InfoField("Reactions", "2"), + InfoField("Properties", "1"), + ), + concentrations = persistentListOf( + InfoField("source", "true"), + InfoField("gradient", "0.68"), + ), + ), + ViewportNode( + id = 3, + coordinates = persistentListOf(0.8, 2.2), + accent = 0.55f, + metadata = persistentListOf( + InfoField("Neighbors", "3"), + InfoField("Reactions", "4"), + InfoField("Properties", "2"), + ), + concentrations = persistentListOf( + InfoField("signal", "0.77"), + InfoField("temperature", "296 K"), + ), + ), + ViewportNode( + id = 4, + coordinates = persistentListOf(2.1, -0.8), + accent = 0.74f, + metadata = persistentListOf( + InfoField("Neighbors", "6"), + InfoField("Reactions", "2"), + InfoField("Properties", "3"), + ), + concentrations = persistentListOf( + InfoField("gradient", "0.18"), + InfoField("payload", "ready"), + ), + ), + ViewportNode( + id = 5, + coordinates = persistentListOf(3.9, 1.4), + accent = 0.92f, + metadata = persistentListOf( + InfoField("Neighbors", "2"), + InfoField("Reactions", "1"), + InfoField("Properties", "1"), + ), + concentrations = persistentListOf( + InfoField("goal", "true"), + InfoField("signal", "0.12"), + ), + ), + ) + return AlchemistUiState( + scene = ViewportScene( + nodes = nodes, + edges = persistentListOf( + ViewportEdge(1, 2), + ViewportEdge(2, 3), + ViewportEdge(3, 4), + ViewportEdge(4, 5), + ViewportEdge(1, 3), + ), + dimensions = 2, + backdrop = ViewportBackdrop.SPACE, + summary = persistentListOf( + 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 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), + status: SimulationStatus = SimulationStatus.PAUSED, +): AlchemistUiState = copy( + controls = controls.copy( + status = status, + 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, simulationEventThrottling.value), +// 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): ImmutableList { + val availableNodeIds = nodes.mapTo(linkedSetOf()) { it.id } + return nodeIds.distinct().filter(availableNodeIds::contains).toImmutableList() +} + +internal fun ViewportScene.withMovedNodes(nodePositions: List): ViewportScene { + if (nodePositions.isEmpty()) { + return this + } + val coordinatesByNodeId = nodePositions.associate { it.nodeId to it.coordinates } + val updatedNodes = nodes.map { node -> + coordinatesByNodeId[node.id]?.let(node::withCoordinates) ?: node + }.toImmutableList() + return copy( + nodes = updatedNodes, + worldBounds = updatedNodes.toWorldBounds(), + ) +} + +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 updatedNodes = nodes.map { node -> + if (node.id in selectedIds) { + node.translate(deltaX, deltaY) + } else { + node + } + }.toImmutableList() + return copy( + nodes = updatedNodes, + worldBounds = updatedNodes.toWorldBounds(), + ) +} + +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", + position = coordinates.take(2).mapIndexed { index, coordinate -> + InfoField(if (index == 0) "X" else "Y", coordinate.formatFixed(3)) + }.toImmutableList(), + concentrations = concentrations, + 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 + } + }.toImmutableList(), +) + +internal fun ViewportNode.withCoordinates(newCoordinates: List): ViewportNode = + copy(coordinates = newCoordinates.toImmutableList()) + +internal fun ViewportNode.toPositionUpdate(): NodePositionUpdate = NodePositionUpdate(id, coordinates) + +private fun ImmutableList.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] } + 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) + }.toImmutableList() + return GroupInspectorState( + 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()), + 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 } + 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) + +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/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/model/UiModel.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt new file mode 100644 index 0000000000..37926ee3be --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/model/UiModel.kt @@ -0,0 +1,339 @@ +@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.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 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * 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. + */ +@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: ImmutableList, + val accent: Float = 0.5f, + val metadata: ImmutableList = persistentListOf(), + val concentrations: ImmutableList = persistentListOf(), +) { + init { + require(coordinates.size >= 2) { + "Viewport nodes require at least two coordinates." + } + } +} + +/** + * Final node coordinates produced by a viewport drag interaction. + */ +@Immutable +data class NodePositionUpdate(val nodeId: Int, val coordinates: ImmutableList) { + init { + require(coordinates.size >= 2) { + "Moved nodes require at least two coordinates." + } + } +} + +/** + * An undirected edge projected in the central viewport. + */ +@Immutable +data class ViewportEdge(val fromNodeId: Int, val toNodeId: Int) { + init { + require(fromNodeId != toNodeId) { + "Viewport edges require two distinct endpoints." + } + } +} + +/** + * 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. + */ +@Immutable +data class ViewportScene( + val nodes: ImmutableList = persistentListOf(), + val edges: ImmutableList = persistentListOf(), + 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: ImmutableList = persistentListOf(), + val message: String = "Waiting for simulation data", +) + +/** + * 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) { + "Progress fraction must be null or within [0, 1]." + } + } +} + +/** + * 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. + */ +@Immutable +data class SimulationControlsState( + val status: SimulationStatus = SimulationStatus.INIT, + 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 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}." + } + 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 = 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), + ) +} + +/** + * State for the right-side inspector panel. + */ +@Immutable +sealed interface InspectorState + +/** + * Inspector state for a single node. + */ +@Immutable +data class NodeInspectorState( + val nodeId: Int, + val title: String = "Node $nodeId", + val subtitle: String, + val position: ImmutableList, + val concentrations: ImmutableList, + val metadata: ImmutableList, +) : InspectorState + +/** + * Inspector state for a group of selected nodes. + */ +@Immutable +data class GroupInspectorState( + val nodeIds: ImmutableList, + val title: String = "Selected Nodes", + val subtitle: String = "${nodeIds.size} nodes selected", + val position: ImmutableList, + val concentrations: ImmutableList, +) : InspectorState + +/** + * Top-level state consumed by the Compose UI shell. + */ +@Immutable +data class AlchemistUiState( + val scene: ViewportScene = ViewportScene(), + val controls: SimulationControlsState = SimulationControlsState(), + val selectedNodeIds: ImmutableList = persistentListOf(), + val inspector: InspectorState? = null, +) + +private fun ImmutableList.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. + */ +interface AlchemistUiCallbacks { + suspend fun onPlay() + + suspend fun onPause() + + 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 onNodesSelected(nodeIds: List) + + suspend fun onNodesMoved(nodePositions: List) + + suspend fun onInspectorDismiss() + + suspend fun onToggleLinks() + + suspend fun onDialogDismiss() +} + +/** + * Shared no-op callback implementation. + */ +object NoOpUiCallbacks : AlchemistUiCallbacks { + override suspend fun onPlay() = Unit + + override suspend fun onPause() = Unit + + 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 onNodesSelected(nodeIds: List) = Unit + + override suspend fun onNodesMoved(nodePositions: List) = 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/view/App.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/App.kt new file mode 100644 index 0000000000..6332008c55 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/App.kt @@ -0,0 +1,30 @@ +/* + * 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 + +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. + */ +@Composable +fun app(controller: ComposeUiController = remember { demoController() }) { + val state by controller.store.stateFlow.collectAsState() + AlchemistUiRoot( + state = state, + callbacks = controller.callbacks, + ) +} 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 new file mode 100644 index 0000000000..3683d463d5 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/MetricBlock.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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +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 + +@Composable +internal fun MetricBlock(label: String, value: String) { + ComponentSurface { + Column( + modifier = Modifier.padding(componentPadding), + verticalArrangement = Arrangement.spacedBy(metricBlockSpacing), + ) { + Text( + text = label.uppercase(), + style = MaterialTheme.typography.caption, + color = SecondaryAccent, + ) + Text( + text = value, + style = MaterialTheme.typography.subtitle1.copy(fontFamily = FontFamily.Monospace), + ) + } + } +} + +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 new file mode 100644 index 0000000000..e92c7c19bc --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/StatusPill.kt @@ -0,0 +1,81 @@ +/* + * 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.material.MaterialTheme +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 +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 presentation = controls.toStatusPillPresentation() + ComponentSurface( + modifier = Modifier.width(StatusPillWidth), + color = presentation.color.copy(alpha = StatusPillAlpha), + shape = pillShape, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(componentPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(10.dp) + .background(color = presentation.color, shape = CircleShape), + ) + Spacer(modifier = Modifier.width(statusIndicatorSpacing)) + Text( + 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 new file mode 100644 index 0000000000..f88b6c9a4b --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/components/TransportButton.kt @@ -0,0 +1,56 @@ +/* + * 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.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 colors = accent.toTransportButtonColors(enabled) + Button( + onClick = onClick, + enabled = enabled, + shape = componentShape, + elevation = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = colors.background, + contentColor = colors.content, + disabledBackgroundColor = Outline.copy(alpha = 0.65f), + disabledContentColor = TextSecondary, + ), + 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 new file mode 100644 index 0000000000..505e5c32e2 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/controls/ControlDock.kt @@ -0,0 +1,517 @@ +/* + * 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.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 +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +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 +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.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 +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 + +@Composable +internal fun ControlDock( + controls: SimulationControlsState, + onPlay: () -> Unit, + onPause: () -> Unit, + onStep: () -> Unit, + onToTimeInputChanged: (String) -> Unit, + onToTimeSubmit: () -> Unit, + onToStepInputChanged: (String) -> Unit, + onToStepSubmit: () -> Unit, + 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( + 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, + ) + } + } + } +} + +@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( + 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() + } + } + } +} + +@Composable +private fun DockTextField( + label: String, + value: String, + caption: String, + isError: Boolean, + 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, + 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, + ), + ) + Text( + text = caption, + style = MaterialTheme.typography.caption, + color = if (isError) Danger else 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, + onValueChange = onValueChange, + valueRange = + 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, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + 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 = FullThrottle.toLabel(), + fontWeight = if (controls.isFullThrottle) FontWeight.Bold else FontWeight.Normal, + style = MaterialTheme.typography.caption, + color = if (controls.isFullThrottle) PrimaryAccent else TextSecondary, + ) + } + } +} + +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 data class DockValidationMessages( + val toTime: String? = null, + val toStep: String? = null, + val fps: String? = null, +) + +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()), +) + +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/inspector/InspectorSection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/InspectorSection.kt new file mode 100644 index 0000000000..a3f66680dc --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/InspectorSection.kt @@ -0,0 +1,80 @@ +/* + * 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.inspector + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Divider +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.text.font.FontFamily +import androidx.compose.ui.unit.dp +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) { + Surface( + color = Surface.copy(alpha = 0.72f), + contentColor = TextPrimary, + shape = RoundedCornerShape(8.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/view/inspector/NodeInspector.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/NodeInspector.kt new file mode 100644 index 0000000000..ead9c574e5 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/inspector/NodeInspector.kt @@ -0,0 +1,149 @@ +/* + * 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.inspector + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.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) { + Surface( + modifier = modifier, + color = SurfaceStrong, + contentColor = TextPrimary, + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), + elevation = 0.dp, + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(22.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + InspectorHeader( + title = inspector.title, + subtitle = 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")) + }, + ) +} + +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 new file mode 100644 index 0000000000..6f089acaa3 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/AlchemistUiRoot.kt @@ -0,0 +1,241 @@ +/* + * 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.root + +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 +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +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 +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 + +/** + * Main shared screen for the simulator UI. + */ +@Composable +fun AlchemistUiRoot(state: AlchemistUiState, callbacks: AlchemistUiCallbacks) { + val coroutineScope = rememberCoroutineScope() + MaterialTheme( + colors = MaterialTheme.colors.copy( + 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( + fontWeight = FontWeight.SemiBold, + ), + h6 = MaterialTheme.typography.h6.copy( + 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, + ), + ), + ) { + 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() + .background( + Brush.linearGradient( + colors = listOf(Background, BackgroundVariant, BackgroundGradientEnd), + ), + ), + ) { + val compactLayout = maxWidth < 980.dp + val inspectorVisible = state.inspector != null + 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) }, + 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 + .fillMaxSize() + .padding(layoutSpacing), + ) { + SimulationPrimaryPane( + scene = state.scene, + controls = state.controls, + selectedNodeIds = state.selectedNodeIds, + callbacks = callbacks, + dockWidthFraction = 1f, + spacing = layoutSpacing, + modifier = Modifier.fillMaxSize(), + ) + if (inspectorVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .background(InspectorScrim) + .clickable( + onClickLabel = "Dismiss inspector", + role = Role.Button, + 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(paneSpacing), + ) { + SimulationPrimaryPane( + scene = state.scene, + controls = state.controls, + selectedNodeIds = state.selectedNodeIds, + callbacks = callbacks, + dockWidthFraction = 0.84f, + spacing = layoutSpacing, + modifier = Modifier + .weight(1f) + .animateContentSize(animationSpec = tween(durationMillis = 320)) + .fillMaxHeight(), + ) + Box( + modifier = Modifier + .fillMaxHeight() + .width(animatedInspectorWidth), + ) { + 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/view/root/SimulationPrimaryPane.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt new file mode 100644 index 0000000000..05bceb5bd0 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/root/SimulationPrimaryPane.kt @@ -0,0 +1,78 @@ +/* + * 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.root + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 +internal fun SimulationPrimaryPane( + scene: ViewportScene, + controls: SimulationControlsState, + selectedNodeIds: List, + callbacks: AlchemistUiCallbacks, + dockWidthFraction: Float, + spacing: androidx.compose.ui.unit.Dp, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(spacing), + ) { + ViewportSurface( + scene = scene, + selectedNodeIds = selectedNodeIds, + callbacks = callbacks, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val coroutineScope = rememberCoroutineScope() + 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) } }, + compact = dockWidthFraction >= 1f, + modifier = Modifier.wrapContentHeight(), + ) + } + } + } +} 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/view/viewport/SummaryRail.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt new file mode 100644 index 0000000000..c8373b9be0 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/SummaryRail.kt @@ -0,0 +1,122 @@ +/* + * 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.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 +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 +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, + linkRenderNotice: String? = null, +) { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + summary.forEach { item -> + Surface( + color = SurfaceStrong.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, + ) + } + } + } + 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 + .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, + ) { + 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) SecondaryAccent 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/view/viewport/ViewportProjection.kt b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportProjection.kt new file mode 100644 index 0000000000..efbf4bea9f --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportProjection.kt @@ -0,0 +1,68 @@ +@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. + * + * 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.ViewportNode +import it.unibo.alchemist.boundary.composeui.model.ViewportScene +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 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() + 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/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 new file mode 100644 index 0000000000..776a41db1b --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRendering.kt @@ -0,0 +1,293 @@ +/* + * 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.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 +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 +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?, +): ViewportSceneCache { + 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) }.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 { + 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 mutableScreenPositions = MutableList(cache.baseCenters.size) { null } + visibleNodeIndices.forEach { nodeIndex -> + mutableScreenPositions[nodeIndex] = cache.baseCenters[nodeIndex].toScreenPosition(viewportSize, camera) + } + val screenPositions = mutableScreenPositions.toImmutableList() + val visibleEdges = if (!cache.scene.showLinks || cache.scene.linkRenderMode == LinkRenderMode.HIDDEN) { + persistentListOf() + } 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) + } + } + }.toImmutableList() + } + 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 data class ViewportSceneCache( + val scene: ViewportScene, + val baseCenters: ImmutableList = persistentListOf(), + val indexedEdges: ImmutableList = persistentListOf(), + val spatialIndex: NodeSpatialIndex? = null, +) { + fun queryNodeIndices(rect: Rect): ImmutableList = + spatialIndex?.query(rect) ?: baseCenters.indices.filter { index -> + rect.contains(baseCenters[index]) + }.toImmutableList() +} + +@Immutable +internal data class ViewportFrame( + val screenPositions: ImmutableList, + val visibleNodeIndices: ImmutableList, + val visibleEdges: ImmutableList, +) { + companion object { + fun empty(nodeCount: Int): ViewportFrame = ViewportFrame( + screenPositions = List(nodeCount) { null }.toImmutableList(), + visibleNodeIndices = persistentListOf(), + visibleEdges = persistentListOf(), + ) + } +} + +@Immutable +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): ImmutableList { + if (rect.isEmpty) { + return persistentListOf() + } + 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.toImmutableList() + } + + 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) + +internal fun Offset.distanceTo(other: Offset): Float { + val dx = x - other.x + val dy = y - other.y + return 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 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 new file mode 100644 index 0000000000..e435dbc727 --- /dev/null +++ b/alchemist-composeui/src/commonMain/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportSurface.kt @@ -0,0 +1,745 @@ +/* + * 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.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 +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 +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.Rect +import androidx.compose.ui.geometry.Size +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 +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 +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 + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun ViewportSurface( + scene: ViewportScene, + selectedNodeIds: List, + 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 rightDragAnchor 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) { + if (fixedProjection == null && candidateProjection != null) { + fixedProjection = candidateProjection + } + } + 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() } + val selectionRect = selectionDragAnchor?.let { anchor -> + val current = selectionDragCurrent ?: anchor + createSelectionRect(anchor, current).takeIf { anchor.distanceTo(current) >= dragThresholdPx } + } + Surface( + modifier = modifier, + color = Surface, + contentColor = TextPrimary, + shape = RoundedCornerShape(12.dp), + elevation = 0.dp, + ) { + val coroutineScope = rememberCoroutineScope() + Box( + modifier = Modifier + .fillMaxSize() + .border( + width = 1.dp, + color = Outline.copy(alpha = 0.8f), + shape = RoundedCornerShape(12.dp), + ) + .background( + Brush.radialGradient( + colors = listOf(BackgroundVariant.copy(alpha = 0.55f), Background), + radius = 1600f, + ), + ), + ) { + Canvas( + 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) { + rightDragAnchor = change.position + selectionDragAnchor = null + selectionDragCurrent = null + nodeDragAnchor = null + nodeDragCurrent = null + draggedNodeIds = emptyList() + } else { + rightDragAnchor = null + val hit = sceneCache.findHitNode( + viewportSize = viewportSize, + camera = camera, + tapOffset = change.position, + tapThresholdPx = tapThresholdPx, + ) + val shouldDragSelection = + event.keyboardModifiers.isCtrlPressed && + hit != null && + hit.id in selectedNodeIdSet + 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 -> + val change = event.changes.firstOrNull() ?: return@onPointerEvent + if (event.buttons.isSecondaryPressed) { + val previous = rightDragAnchor ?: change.position + val delta = change.position - previous + if (delta != Offset.Zero) { + camera = camera.panBy(delta) + } + rightDragAnchor = change.position + } else { + rightDragAnchor = null + if (nodeDragAnchor != null) { + nodeDragCurrent = change.position + } else if (selectionDragAnchor != null) { + selectionDragCurrent = change.position + } + } + } + .onPointerEvent(PointerEventType.Release) { event -> + val releasePosition = event.changes.firstOrNull()?.position + 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) } + } + } + } else { + val anchor = selectionDragAnchor + val current = selectionDragCurrent ?: releasePosition + if (anchor != null && current != null) { + if (anchor.distanceTo(current) >= dragThresholdPx) { + val selectedIds = sceneCache.selectNodes( + viewportSize = viewportSize, + camera = camera, + selectionRect = createSelectionRect(anchor, current), + ) + coroutineScope.launch { callbacks.onNodesSelected(selectedIds) } + } else { + val hit = sceneCache.findHitNode( + viewportSize = viewportSize, + camera = camera, + tapOffset = current, + tapThresholdPx = tapThresholdPx, + ) + coroutineScope.launch { + if (hit != null) { + callbacks.onNodeSelected(hit.id) + } else { + callbacks.onInspectorDismiss() + } + } + } + } + } + selectionDragAnchor = null + selectionDragCurrent = null + nodeDragAnchor = null + nodeDragCurrent = null + draggedNodeIds = emptyList() + rightDragAnchor = 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(BackgroundVariant.copy(alpha = 0.55f), Background), + ), + ) + val currentCamera = camera + drawGrid(size, currentCamera) + 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 = shiftedCenter(edge.fromIndex) ?: return@forEach + val end = shiftedCenter(edge.toIndex) ?: return@forEach + drawLine( + color = Outline.copy(alpha = 0.42f), + start = start, + end = end, + strokeWidth = LinkStrokeWidth.dp.toPx(), + cap = StrokeCap.Round, + ) + } + } + viewportFrame.visibleNodeIndices.forEach { nodeIndex -> + 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 + val screenSelectedRadius = SelectedNodeRadius.dp.toPx() * currentCamera.zoom + val screenSelectedInnerRadius = SelectedNodeInnerRadius.dp.toPx() * currentCamera.zoom + + if (isSelected) { + drawCircle( + color = nodeColor.copy(alpha = 0.20f), + radius = screenSelectedRadius, + center = center, + ) + drawCircle( + color = PrimaryAccent, + radius = screenSelectedInnerRadius, + center = center, + style = Stroke(width = 2.dp.toPx() * currentCamera.zoom), + ) + } + drawCircle( + brush = Brush.radialGradient( + colors = listOf(nodeColor, nodeColor.copy(alpha = 0.45f)), + center = center, + radius = max(1f, screenSelectedRadius), + ), + radius = screenRadius, + center = 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()), + ) + } + } + ViewportChrome( + scene = scene, + onToggleLinks = { coroutineScope.launch { callbacks.onToggleLinks() } }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(20.dp), + ) + ViewportGestureHint( + hasNodes = scene.nodes.isNotEmpty(), + modifier = Modifier + .align(Alignment.BottomStart) + .padding(20.dp), + ) + val gridLegend = remember(viewportSize, camera.zoom, projection) { + if (viewportSize.width == 0 || viewportSize.height == 0 || projection == null) return@remember null + val baseStep = min( + viewportSize.width / GridVerticalDivisions.toFloat(), + viewportSize.height / GridHorizontalDivisions.toFloat(), + ) + var step = baseStep * camera.zoom + var s = 1.0 + while (step < 10f) { + step *= 2f + s *= 2.0 + } + while (step > 100f) { + step /= 2f + s /= 2.0 + } + val worldStep = (baseStep * s) / projection.pixelsPerUnit + GridLegendData(worldStep, worldStep, step, step) + } + if (gridLegend != null) { + ViewportGridLegend( + gridLegend = gridLegend, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(20.dp), + ) + } + } + } +} + +@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 }, + ) + } +} + +@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 + + 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 (step < 10f) { + step *= 2f + } + // Prevent the grid from becoming too sparse when zoomed in + while (step > 100f) { + step /= 2f + } + + val startX = (origin.x % step) - step + var currentX = startX + while (currentX < canvasSize.width) { + drawLine( + color = Outline.copy(alpha = 0.32f), + start = Offset(currentX, 0f), + end = Offset(currentX, canvasSize.height), + strokeWidth = 1f, + ) + currentX += step + } + + val startY = (origin.y % step) - step + var currentY = startY + while (currentY < canvasSize.height) { + drawLine( + color = Outline.copy(alpha = 0.28f), + start = Offset(0f, currentY), + end = Offset(canvasSize.width, currentY), + strokeWidth = 1f, + ) + currentY += step + } + + // 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, + ) + } +} + +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), + right = max(anchor.x, current.x), + bottom = max(anchor.y, current.y), +) + +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() +} + +private const val KEYBOARD_PAN_STEP_PX = 48f 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/GroupInspectorStateTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt new file mode 100644 index 0000000000..de38810137 --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/GroupInspectorStateTest.kt @@ -0,0 +1,83 @@ +/* + * 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.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 +import kotlin.test.assertNull +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +class GroupInspectorStateTest { + @Test + fun `group inspector aggregates bounds and concentrations`() { + val scene = ViewportScene( + nodes = listOf( + ViewportNode( + id = 10, + coordinates = persistentListOf(-2.0, 5.0), + concentrations = persistentListOf( + InfoField("shared", "1"), + InfoField("variant", "A"), + ), + ), + ViewportNode( + id = 20, + 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))) + + 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 = persistentListOf(ViewportNode(id = 1, coordinates = persistentListOf(0.0, 0.0))), + ), + selectedNodeIds = persistentListOf(1), + inspector = NodeInspectorState( + nodeId = 1, + subtitle = "Live node snapshot", + position = persistentListOf(), + concentrations = persistentListOf(), + metadata = persistentListOf(), + ), + ) + + 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 new file mode 100644 index 0000000000..db311ebc2a --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/SimulationControlsStateTest.kt @@ -0,0 +1,166 @@ +/* + * 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.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 +import it.unibo.alchemist.boundary.composeui.model.ViewportScene +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.assertNotNull +import kotlin.test.assertNull +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) + } + + @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.withSelection(listOf(3)) } + + runSuspend { controller.callbacks.onToggleLinks() } + + assertTrue(controller.store.state.scene.showLinks) + assertEquals(listOf(3), controller.store.state.selectedNodeIds) + } + + @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().updateEventThrottling(Int.MAX_VALUE) + + assertTrue(controls.isFullThrottle) + assertEquals(FullThrottle, controls.simulationEventThrottling) + assertEquals("Max", controls.simulationEventThrottling.toLabel()) + } + + @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) + } + + @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) + } + + @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) { + block.startCoroutine( + object : Continuation { + override val context = EmptyCoroutineContext + + override fun resumeWith(result: Result) { + result.getOrThrow() + } + }, + ) +} 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..cb0512c17e --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportCameraMathTest.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 + +import it.unibo.alchemist.boundary.composeui.view.viewport.applyInfiniteZoomFactor +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/ViewportNodeMovementTest.kt b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportNodeMovementTest.kt new file mode 100644 index 0000000000..c128786119 --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportNodeMovementTest.kt @@ -0,0 +1,92 @@ +/* + * 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 +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 = 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) + + 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 = persistentListOf(0.0, 0.0)), + ViewportNode(id = 2, coordinates = persistentListOf(1.0, 1.0)), + ).toImmutableList(), + ) + + 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) + } + + @Test + fun `selection inspector reflects moved group bounds after commit`() { + val state = AlchemistUiState( + scene = ViewportScene( + nodes = listOf( + ViewportNode(id = 1, coordinates = persistentListOf(0.0, 1.0)), + ViewportNode(id = 2, coordinates = persistentListOf(4.0, 3.0)), + ).toImmutableList(), + ), + selectedNodeIds = persistentListOf(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) + } + + @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 new file mode 100644 index 0000000000..ef6bda9cc3 --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/ViewportProjectionTest.kt @@ -0,0 +1,49 @@ +/* + * 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 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 +import kotlin.test.assertTrue +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +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 = persistentListOf(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 = 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 new file mode 100644 index 0000000000..d5859e2353 --- /dev/null +++ b/alchemist-composeui/src/commonTest/kotlin/it/unibo/alchemist/boundary/composeui/view/viewport/ViewportRenderingPolicyTest.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.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 +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 = 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( + scene = scene, + baseCenters = listOf( + 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()) + + 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 = 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 = 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()) + + assertEquals(MaxDrawnEdgesPerFrame, frame.visibleEdges.size) + } +} 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 77f47406b5..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 @@ -9,23 +9,167 @@ 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.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 +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 initial target frames per second for the UI updates. Defaults to 30. */ -class ComposeMonitor : OutputMonitor { - override fun initialized(environment: Environment) { - application { - Window( - onCloseRequest = { }, - title = "Alchemist", - ) { - app() +class ComposeMonitor> @JvmOverloads constructor(targetFps: Int = 30) : OutputMonitor { + 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( + 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(), + 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 >= uiThrottleMs()) { + lastUpdate = now + updateUiState(environment, time, step) + } + throttleSimulation() + } + + override fun finished(environment: Environment, time: Time, step: Long) { + nextEventReleaseNs = 0L + updateUiState(environment, time, step) + } + + private fun updateUiState(environment: Environment, time: Time, step: Long) { + currentUiState.update { + val viewport = environment.toViewport(renderLinks = it.scene.showLinks) + val displayedTime = time.toComposeUiLabel() + it.copy( + scene = viewport.copy(showLinks = it.scene.showLinks), + controls = it.controls.copy( + timeLabel = displayedTime, + step = step, + status = environment.simulation.toSimulationStatus(), + ), + ).withSelection(it.selectedNodeIds) + } + } + + 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 = 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() + 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 { + 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 { + alchemistDesktopController(environment) + }, + ) + } + } + }.apply { + isDaemon = true + name = "Alchemist Compose UI" + }.start() + } + } + + private fun alchemistDesktopController(environment: Environment): ComposeUiController = + ComposeUiController(currentUiState, DesktopAlchemistUiCallback(environment.simulation, currentUiState)) } + +private fun Time.toComposeUiLabel(): String = toDouble().formatFixed(DISPLAYED_TIME_DECIMALS) + +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 new file mode 100644 index 0000000000..171d29d023 --- /dev/null +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallback.kt @@ -0,0 +1,224 @@ +/* + * 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.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 +import it.unibo.alchemist.model.times.DoubleTime +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, + private val store: ComposeUiStateStore, +) : AlchemistUiCallbacks { + override suspend fun onPlay() { + simulation.play().await() + updateState { + it.copy(controls = it.controls.copy(status = simulation.toSimulationStatus())) + } + } + + override suspend fun onPause() { + simulation.pause().await() + updateState { + it.copy(controls = it.controls.copy(status = simulation.toSimulationStatus())) + } + } + + override suspend fun onStep() { + val nextStep = simulation.step + 1 + val stepCompletion = simulation.goToStep(nextStep) + simulation.play().await() + stepCompletion.await() + syncSimulationState() + } + + override suspend fun onToTimeInputChanged(value: String) { + updateState { + 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() + 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) { + val jumpCompletion = simulation.goToTime(DoubleTime(target)) + simulation.play().await() + jumpCompletion.await() + if (wasRunning) { + simulation.play().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.") + 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) { + val jumpCompletion = simulation.goToStep(target) + simulation.play().await() + jumpCompletion.await() + if (wasRunning) { + simulation.play().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.updateEventThrottling(value.roundToInt())) + } + } + + override suspend fun onNodeSelected(nodeId: Int) { + updateState { currentState -> + currentState.withSelection(listOf(nodeId)) + } + } + + override suspend fun onNodesSelected(nodeIds: List) { + updateState { currentState -> + currentState.withSelection(nodeIds) + } + } + + 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) + } + }.onSuccess { + completion.complete(Unit) + }.onFailure { error -> + completion.completeExceptionally(error) + throw error + } + } + completion.await() + syncSimulationState(refreshScene = true) + } + + override suspend fun onInspectorDismiss() { + updateState { + it.withSelection(emptyList()) + } + } + + override suspend fun onToggleLinks() { + updateState { + val nextShowLinks = !it.scene.showLinks + it.copy( + scene = simulation.environment + .toViewport(renderLinks = nextShowLinks) + .copy(showLinks = nextShowLinks), + ) + } + } + + 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(refreshScene: Boolean = false) { + updateState { + val nextScene = if (refreshScene) { + simulation.environment.toViewport(renderLinks = it.scene.showLinks).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) + } + } + + 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 new file mode 100644 index 0000000000..fd99f47a61 --- /dev/null +++ b/alchemist-composeui/src/jvmMain/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapter.kt @@ -0,0 +1,167 @@ +/* + * 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.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 +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().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) }.toImmutableList() + 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 + Status.READY -> SimulationStatus.READY + Status.PAUSED -> SimulationStatus.PAUSED + Status.RUNNING -> SimulationStatus.RUNNING + Status.TERMINATED -> SimulationStatus.TERMINATED +} + +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.toImmutableList(), + edgeCount = uniqueEdges, + renderMode = LinkRenderMode.FULL, + ) + else -> EdgeSnapshot( + edges = sampledEdges + .toList() + .sortedBy(SampledViewportEdge::score) + .map(SampledViewportEdge::edge) + .toImmutableList(), + 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 + } + val largestScore = peek() ?: return + if (candidate.score < largestScore.score) { + poll() + add(candidate) + } +} + +internal data class EdgeSnapshot( + val edges: ImmutableList = persistentListOf(), + 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) + +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 -> 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 new file mode 100644 index 0000000000..71517dd398 --- /dev/null +++ b/alchemist-composeui/src/jvmMain/resources/composeui-demo.yml @@ -0,0 +1,30 @@ +incarnation: sapere + +network-model: + type: ConnectWithinDistance + parameters: [0.5] + +monitors: + type: ComposeMonitor + +deployments: +# type: Grid +# parameters: [-5, -5, 5, 5, 0.25, 0.25, 0.1, 0.1] + type: Rectangle + parameters: [1000, -5, -5, 10, 10] + 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}" + - 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/DesktopAlchemistUiCallbackTest.kt b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallbackTest.kt new file mode 100644 index 0000000000..77a17613d8 --- /dev/null +++ b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiCallbackTest.kt @@ -0,0 +1,202 @@ +/* + * 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.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 +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.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() + } + }, + ) +} 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..f3b28cf405 --- /dev/null +++ b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/DesktopAlchemistUiNodeMoveTest.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 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 kotlinx.collections.immutable.persistentListOf +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) + environment.simulation = simulation + val store = ComposeUiStateStore( + AlchemistUiState( + scene = environment.toViewport(), + selectedNodeIds = persistentListOf(firstNode.id, secondNode.id), + ).withSelection(listOf(firstNode.id, secondNode.id)), + ) + val callback = DesktopAlchemistUiCallback(simulation, store) + + runSuspend { + callback.onNodesMoved( + listOf( + NodePositionUpdate(firstNode.id, persistentListOf(1.5, -2.0)), + NodePositionUpdate(secondNode.id, persistentListOf(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() + } + }, + ) +} 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..ead88805ad --- /dev/null +++ b/alchemist-composeui/src/jvmTest/kotlin/it/unibo/alchemist/boundary/composeui/adapter/AlchemistNodeAdapterTest.kt @@ -0,0 +1,65 @@ +/* + * 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.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 +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))) + } + + @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) + } +} 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 /** 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" }