lets-plot-compose/src/desktopMain/kotlin/org/jetbrains/letsPlot/compose/PlotPanelComposeCanvas.kt (189 lines of code) (raw):

/* * Copyright (c) 2025 JetBrains s.r.o. * Use of this source code is governed by the MIT license that can be found in the LICENSE file. */ package org.jetbrains.letsPlot.compose import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import org.jetbrains.letsPlot.commons.geometry.DoubleVector import org.jetbrains.letsPlot.commons.logging.PortableLogging import org.jetbrains.letsPlot.commons.registration.CompositeRegistration import org.jetbrains.letsPlot.commons.registration.Registration import org.jetbrains.letsPlot.compose.canvas.SkiaCanvasPeer import org.jetbrains.letsPlot.compose.canvas.SkiaContext2d import org.jetbrains.letsPlot.compose.canvas.SkiaFontManager import org.jetbrains.letsPlot.core.plot.builder.interact.tools.FigureModelHelper import org.jetbrains.letsPlot.core.spec.Option.Meta.Kind.GG_TOOLBAR import org.jetbrains.letsPlot.core.spec.config.PlotConfig import org.jetbrains.letsPlot.core.spec.front.SpecOverrideUtil.applySpecOverride import org.jetbrains.letsPlot.core.util.MonolithicCommon.processRawSpecs import org.jetbrains.letsPlot.core.util.PlotThemeHelper import org.jetbrains.letsPlot.core.util.sizing.SizingPolicy.Companion.fitContainerSize import org.jetbrains.letsPlot.raster.view.PlotCanvasFigure2 import java.awt.Cursor import java.awt.Desktop import java.net.URI //import org.jetbrains.letsPlot.compose.util.NaiveLogger //private val LOG = NaiveLogger("PlotPanel") private val LOG = PortableLogging.logger(name = "[PlotPanelRaw2]") private const val logRecompositions = false @Suppress("FunctionName") @Composable fun PlotPanelComposeCanvas( rawSpec: MutableMap<String, Any>, preserveAspectRatio: Boolean, modifier: Modifier, errorTextStyle: TextStyle, errorModifier: Modifier, computationMessagesHandler: (List<String>) -> Unit ) { if (logRecompositions) { println("PlotPanelRaw: recomposition") } val density = LocalDensity.current.density val skiaFontManager = remember { SkiaFontManager() } val composeMouseEventMapper = remember { ComposeMouseEventMapper() } // Update density on each recomposition to handle monitor DPI changes (e.g., drag between HIDPI/regular monitor) // Cache processed plot spec to avoid reprocessing the same raw spec on every recomposition. // Note: Use remember(rawSpec.hashCode()), to bypass the equality check and use the content hash directly. // The issue was that remember(rawSpec) uses some kind of comparison (equals()?) which somehow not working for `MutableMap`. val processedPlotSpec = remember(rawSpec.hashCode()) { processRawSpecs(rawSpec, frontendOnly = false) } var panelSize by remember { mutableStateOf(DoubleVector.ZERO) } var plotPosition by remember { mutableStateOf(DoubleVector.ZERO) } var dispatchComputationMessages by remember { mutableStateOf(true) } var specOverrideList by remember { mutableStateOf(emptyList<Map<String, Any>>()) } var plotFigureModel by remember { mutableStateOf<PlotFigureModel?>(null) } var errorMessage: String? by remember(processedPlotSpec, panelSize) { mutableStateOf(null) } var redrawTrigger by remember { mutableStateOf(0) } val skiaCanvasPeer = remember { SkiaCanvasPeer() } // Reset the old plot on error to prevent blinking // We can't reset PlotContainer using updateViewmodel(), so we create a new one. val plotCanvasFigure2 = remember(errorMessage) { PlotCanvasFigure2().apply { eventPeer.addEventSource(composeMouseEventMapper) } } val reg = remember(plotCanvasFigure2) { plotCanvasFigure2.onHrefClick(::browseLink) CompositeRegistration( // trigger recomposition on repaint request plotCanvasFigure2.onRepaintRequested { redrawTrigger++ }, plotCanvasFigure2.mapToCanvas(skiaCanvasPeer), Registration.onRemove { plotCanvasFigure2.onHrefClick(handler = {}) } ) } // Background val finalModifier = if (errorMessage != null) { modifier.background(Color.LightGray) } else { if (containsBackground(modifier)) { // Do not change the user-defined background modifier } else { // Use background color from the plot theme val lpColor = PlotThemeHelper.plotBackground(processedPlotSpec) val lpBackground = Color(lpColor.red, lpColor.green, lpColor.blue, lpColor.alpha) modifier.background(lpBackground) } } DisposableEffect(reg) { onDispose { // Try/catch to ensure that any exception in dispose() does not break the Composable lifecycle // Otherwise, the app window gets unclosable. try { reg.dispose() //plotCanvasFigure2.dispose() } catch (e: Exception) { LOG.error(e) { "reg.dispose() failed" } } } } Column(modifier = finalModifier) { if (plotFigureModel != null && GG_TOOLBAR in processedPlotSpec) { PlotToolbar(plotFigureModel!!) } Box( modifier = finalModifier .weight(1f) // Take the remaining vertical space .fillMaxWidth() // Fill available width .onSizeChanged { newSize -> // Convert logical pixels (from Compose layout) to physical pixels (plot SVG pixels) panelSize = DoubleVector(newSize.width / density, newSize.height / density) } ) { val errMsg = errorMessage if (errMsg != null) { // Show error message BasicTextField( value = errMsg, onValueChange = { }, readOnly = true, textStyle = errorTextStyle, modifier = errorModifier ) } else { // Render the plot LaunchedEffect(panelSize, processedPlotSpec, specOverrideList, preserveAspectRatio) { if (PlotConfig.isFailure(processedPlotSpec)) { errorMessage = PlotConfig.getErrorMessage(processedPlotSpec) return@LaunchedEffect } runCatching { if (panelSize != DoubleVector.ZERO) { val plotSpec = applySpecOverride(processedPlotSpec, specOverrideList).toMutableMap() plotCanvasFigure2.update(plotSpec, fitContainerSize(preserveAspectRatio)) { messages -> if (dispatchComputationMessages) { // do once dispatchComputationMessages = false computationMessagesHandler(messages) } } if (plotFigureModel == null) { plotFigureModel = PlotFigureModel( onUpdateView = { specOverride -> specOverrideList = FigureModelHelper.updateSpecOverrideList( specOverrideList = specOverrideList, newSpecOverride = specOverride ) } ) } plotFigureModel!!.toolEventDispatcher = plotCanvasFigure2.toolEventDispatcher val plotWidth = plotCanvasFigure2.size.x val plotHeight = plotCanvasFigure2.size.y // Calculate centering position in physical pixels // Both panelSize and plot dimensions are in physical pixels plotPosition = DoubleVector( maxOf(0.0, (panelSize.x - plotWidth) / 2.0), maxOf(0.0, (panelSize.y - plotHeight) / 2.0) ) composeMouseEventMapper.setOffset(plotPosition.x.toFloat(), plotPosition.y.toFloat()) redrawTrigger++ // trigger repaint } }.getOrElse { e -> errorMessage = "${e.message}" return@LaunchedEffect } } Canvas( modifier = modifier .fillMaxSize() .pointerHoverIcon(PointerIcon(Cursor(Cursor.CROSSHAIR_CURSOR))) .onSizeChanged { size -> // Convert canvas logical pixels (from Compose layout) to physical pixels (plot SVG pixels) plotCanvasFigure2.resize(size.width / density, size.height / density) } .pointerInput(composeMouseEventMapper, composeMouseEventMapper) ) { // By reading redrawTrigger here, Compose knows to recompose // this Canvas block whenever it changes. redrawTrigger val ctx = SkiaContext2d(drawContext.canvas.nativeCanvas, skiaFontManager) ctx.scale(density.toDouble(), density.toDouble()) // logical → physical pixels ctx.translate(plotPosition.x, plotPosition.y) plotCanvasFigure2.paint(ctx) ctx.dispose() } } } } } private fun browseLink(string: String) { try { val uri = URI(string) Desktop.getDesktop().browse(uri) } catch (e: Exception) { LOG.error(e) { "Failed to open link: $string (${e.message})" } } }