Skip to content

Commit d680735

Browse files
committed
Add support for custom toolbar and figure default interactions
1 parent e21fec7 commit d680735

12 files changed

Lines changed: 397 additions & 55 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright (c) 2026 JetBrains s.r.o.
3+
* Use of this source code is governed by the MIT license that can be found in the LICENSE file.
4+
*/
5+
6+
package demo.plot.interact
7+
8+
import androidx.compose.foundation.layout.*
9+
import androidx.compose.material.Checkbox
10+
import androidx.compose.material.MaterialTheme
11+
import androidx.compose.material.Text
12+
import androidx.compose.runtime.*
13+
import androidx.compose.ui.Alignment
14+
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.unit.dp
16+
import androidx.compose.ui.window.Window
17+
import androidx.compose.ui.window.application
18+
import org.jetbrains.letsPlot.compose.PlotFigureModel
19+
import org.jetbrains.letsPlot.compose.PlotPanel
20+
import org.jetbrains.letsPlot.compose.sandbox.SandboxToolbarCmp
21+
import org.jetbrains.letsPlot.core.interact.InteractionSpec
22+
import org.jetbrains.letsPlot.interact.ggtb
23+
import plotSpec.AutoSpec
24+
25+
/**
26+
* Demo showing:
27+
* 1. Custom external toolbar using SandboxToolbar
28+
* 2. Default interactions that can be toggled on/off
29+
*/
30+
fun main() = application {
31+
Window(
32+
onCloseRequest = ::exitApplication,
33+
title = "Custom Toolbar & Default Interactions (Compose Desktop)"
34+
) {
35+
MaterialTheme {
36+
Column(
37+
modifier = Modifier.fillMaxSize().padding(10.dp)
38+
) {
39+
val figureModel = remember { PlotFigureModel() }
40+
var enableDefaultInteractions by remember { mutableStateOf(false) }
41+
42+
// Controls section
43+
Row(
44+
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
45+
verticalAlignment = Alignment.CenterVertically
46+
) {
47+
Text("Default Interactions:", modifier = Modifier.padding(end = 8.dp))
48+
Checkbox(
49+
checked = enableDefaultInteractions,
50+
onCheckedChange = { enabled ->
51+
enableDefaultInteractions = enabled
52+
if (enabled) {
53+
figureModel.setDefaultInteractions(
54+
listOf(
55+
InteractionSpec(
56+
InteractionSpec.Name.WHEEL_ZOOM,
57+
keyModifiers = listOf(
58+
InteractionSpec.KeyModifier.CTRL,
59+
InteractionSpec.KeyModifier.SHIFT
60+
)
61+
),
62+
InteractionSpec(
63+
InteractionSpec.Name.DRAG_PAN,
64+
keyModifiers = listOf(
65+
InteractionSpec.KeyModifier.CTRL,
66+
InteractionSpec.KeyModifier.SHIFT
67+
)
68+
)
69+
)
70+
)
71+
} else {
72+
figureModel.setDefaultInteractions(emptyList())
73+
}
74+
}
75+
)
76+
Text(
77+
text = "(Ctrl+Shift+Drag to pan, Ctrl+Shift+Wheel to zoom)",
78+
modifier = Modifier.padding(start = 8.dp)
79+
)
80+
}
81+
82+
// Custom external toolbar
83+
SandboxToolbarCmp(
84+
figureModel = figureModel,
85+
modifier = Modifier.fillMaxWidth()
86+
)
87+
88+
// Plot
89+
val plot = remember { AutoSpec().scatter() + ggtb() }
90+
PlotPanel(
91+
figure = plot,
92+
figureModel = figureModel,
93+
preserveAspectRatio = false,
94+
modifier = Modifier.fillMaxSize()
95+
) { computationMessages ->
96+
computationMessages.forEach { println("[DEMO] $it") }
97+
}
98+
}
99+
}
100+
}
101+
}

demo/plot/compose-desktop/src/main/kotlin/demo/plot/median/MedianCompose.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import androidx.compose.ui.window.Window
2020
import androidx.compose.ui.window.application
2121
import demo.plot.median.ui.DemoList
2222
import org.jetbrains.letsPlot.Figure
23+
import org.jetbrains.letsPlot.compose.PlotFigureModel
2324
import org.jetbrains.letsPlot.compose.PlotPanelRaw
25+
import org.jetbrains.letsPlot.compose.sandbox.SandboxToolbarCmp
2426
import org.jetbrains.letsPlot.intern.toSpec
2527
import plotSpec.*
2628

@@ -38,6 +40,7 @@ fun main() = application {
3840

3941
val preserveAspectRatio = remember { mutableStateOf(false) }
4042
val figureIndex = remember { mutableStateOf(0) }
43+
val figureModel = remember { PlotFigureModel() }
4144

4245
MaterialTheme {
4346
Row {
@@ -64,6 +67,12 @@ fun main() = application {
6467
Column(
6568
modifier = Modifier.fillMaxSize().padding(start = 10.dp, top = 10.dp, end = 10.dp, bottom = 10.dp),
6669
) {
70+
// Sandbox toolbar - stays fixed while plots switch
71+
SandboxToolbarCmp(
72+
figureModel = figureModel,
73+
modifier = Modifier.fillMaxWidth()
74+
)
75+
6776
Column(
6877
modifier = Modifier.fillMaxSize()
6978
.padding(start = 10.dp, top = 10.dp, end = 10.dp, bottom = 10.dp),
@@ -80,6 +89,7 @@ fun main() = application {
8089
@Suppress("UNCHECKED_CAST")
8190
PlotPanelRaw(
8291
rawSpec = rawSpec as MutableMap<String, Any>,
92+
figureModel = figureModel,
8393
preserveAspectRatio = preserveAspectRatio.value,
8494
modifier = Modifier.fillMaxSize()
8595
) { computationMessages ->

lets-plot-compose/src/androidMain/kotlin/org/jetbrains/letsPlot/compose/PlotPanelComposeCanvas.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ fun PlotPanelComposeCanvas(
129129
reg.dispose()
130130
//plotCanvasFigure2.dispose()
131131
} catch (e: Exception) {
132-
LOG.error(e) { "reg.dispose() failed" }
132+
LOG.error(e) { "reg.dispose() failed: ${e.message}" }
133133
}
134134
}
135135
}

lets-plot-compose/src/androidMain/kotlin/org/jetbrains/letsPlot/compose/PlotPanelRaw.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import androidx.compose.foundation.layout.Row
1010
import androidx.compose.foundation.layout.fillMaxWidth
1111
import androidx.compose.foundation.text.BasicText
1212
import androidx.compose.runtime.Composable
13+
import androidx.compose.runtime.DisposableEffect
1314
import androidx.compose.runtime.remember
1415
import androidx.compose.ui.Modifier
1516
import androidx.compose.ui.text.TextStyle
16-
import org.jetbrains.letsPlot.core.plot.builder.interact.tools.FigureModel
1717

1818
private const val devRendering = false
1919

@@ -23,7 +23,7 @@ private const val devRendering = false
2323
@Composable
2424
actual fun PlotPanelRaw(
2525
rawSpec: MutableMap<String, Any>,
26-
figureModel: FigureModel?,
26+
figureModel: PlotFigureModel?,
2727
preserveAspectRatio: Boolean,
2828
modifier: Modifier,
2929
errorTextStyle: TextStyle,
@@ -33,6 +33,15 @@ actual fun PlotPanelRaw(
3333
) {
3434
val actualFigureModel = figureModel ?: remember { PlotFigureModel() }
3535

36+
// Dispose internally-created figureModel when this component is removed
37+
if (figureModel == null) {
38+
DisposableEffect(actualFigureModel) {
39+
onDispose {
40+
actualFigureModel.dispose()
41+
}
42+
}
43+
}
44+
3645
Row(modifier = modifier) {
3746
@Suppress("SimplifyBooleanWithConstants", "KotlinConstantConditions")
3847
if (!devRendering && !legacyRendering) {

lets-plot-compose/src/commonMain/kotlin/org/jetbrains/letsPlot/compose/PlotFigureModel.kt

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.jetbrains.letsPlot.compose
22

3-
import androidx.compose.runtime.*
3+
import androidx.compose.runtime.State
4+
import androidx.compose.runtime.mutableStateOf
45
import org.jetbrains.letsPlot.commons.registration.Disposable
56
import org.jetbrains.letsPlot.commons.registration.Registration
67
import org.jetbrains.letsPlot.core.interact.InteractionSpec
@@ -9,27 +10,44 @@ import org.jetbrains.letsPlot.core.plot.builder.interact.tools.FigureModel
910
import org.jetbrains.letsPlot.core.plot.builder.interact.tools.FigureModelHelper
1011

1112
/**
12-
* Creates and remembers a [FigureModel] instance.
13+
* FigureModel implementation for Compose that manages plot interactions and state.
1314
*
14-
* The returned FigureModel can be used to:
15-
* - Control plot interactions programmatically
16-
* - Attach custom external toolbars
17-
* - Observe plot state changes
15+
* This class enables programmatic control of plot interactions (pan, zoom, etc.) and allows
16+
* external toolbars to control plots. The same FigureModel instance can be reused across
17+
* multiple plots - it automatically reconnects when the plot changes.
18+
*
19+
* ## Lifecycle
20+
* - If created externally and passed to PlotPanel/PlotPanelRaw via the `figureModel` parameter,
21+
* the caller is responsible for disposal (or it lives until the window closes).
22+
* - If not provided (null), PlotPanelRaw creates an internal instance and handles disposal automatically.
23+
*
24+
* ## Usage
25+
* ```kotlin
26+
* // External control with toolbar
27+
* val figureModel = remember { PlotFigureModel() }
28+
* SandboxToolbarCmp(figureModel = figureModel)
29+
* PlotPanel(figure = myPlot, figureModel = figureModel, ...)
30+
*
31+
* // Set default interactions (e.g., Ctrl+Shift for pan/zoom)
32+
* figureModel.setDefaultInteractions(listOf(
33+
* InteractionSpec(
34+
* InteractionSpec.Name.WHEEL_ZOOM,
35+
* keyModifiers = listOf(
36+
* InteractionSpec.KeyModifier.CTRL,
37+
* InteractionSpec.KeyModifier.SHIFT
38+
* )
39+
* ),
40+
* InteractionSpec(
41+
* InteractionSpec.Name.DRAG_PAN,
42+
* keyModifiers = listOf(
43+
* InteractionSpec.KeyModifier.CTRL,
44+
* InteractionSpec.KeyModifier.SHIFT
45+
* )
46+
* )
47+
* ))
48+
* ```
1849
*/
19-
@Composable
20-
fun rememberPlotFigureModel(): FigureModel {
21-
val figureModel = remember { PlotFigureModel() }
22-
23-
DisposableEffect(figureModel) {
24-
onDispose {
25-
figureModel.dispose()
26-
}
27-
}
28-
29-
return figureModel
30-
}
31-
32-
internal class PlotFigureModel internal constructor() : FigureModel {
50+
class PlotFigureModel() : FigureModel {
3351
private val toolEventCallbacks = mutableListOf<(Map<String, Any>) -> Unit>()
3452
private val disposableTools = mutableListOf<Disposable>()
3553
private var defaultInteractions: List<InteractionSpec> = emptyList()
@@ -45,8 +63,14 @@ internal class PlotFigureModel internal constructor() : FigureModel {
4563

4664
internal var toolEventDispatcher: ToolEventDispatcher? = null
4765
set(value) {
48-
// De-activate and re-activate ongoing interactions when replacing the dispatcher.
49-
val wereInteractions = field?.deactivateAllSilently() ?: emptyMap()
66+
val wereInteractions = if (value != null) {
67+
// De-activate and re-activate ongoing interactions when replacing the dispatcher.
68+
field?.deactivateAllSilently() ?: emptyMap()
69+
} else {
70+
// Shut down all interactions when the dispatcher is set to null
71+
field?.deactivateAll()
72+
emptyMap()
73+
}
5074
field = value
5175
value?.let { newDispatcher ->
5276
newDispatcher.initToolEventCallback { event ->

lets-plot-compose/src/commonMain/kotlin/org/jetbrains/letsPlot/compose/PlotPanel.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import org.jetbrains.letsPlot.intern.toSpec
2121
*
2222
* @param figure The plot figure to display
2323
* @param figureModel Optional [FigureModel] for controlling plot interactions programmatically.
24-
* Use [rememberPlotFigureModel] to create a model that can be accessed externally.
2524
* @param preserveAspectRatio Whether to preserve the plot's aspect ratio
2625
* @param modifier Modifier for the plot container
2726
* @param errorTextStyle Text style for error messages
@@ -31,7 +30,7 @@ import org.jetbrains.letsPlot.intern.toSpec
3130
*
3231
* Example with external FigureModel:
3332
* ```kotlin
34-
* val figureModel = rememberPlotFigureModel()
33+
* val figureModel = remember { PlotFigureModel() }
3534
*
3635
* PlotPanel(
3736
* figure = myPlot,
@@ -41,7 +40,7 @@ import org.jetbrains.letsPlot.intern.toSpec
4140
* )
4241
*
4342
* // Control the plot programmatically.
44-
* // For exampl, set figure default interactions.
43+
* // For example, set figure default interactions.
4544
* val defaultInteractions = listOf(
4645
* InteractionSpec(
4746
* InteractionSpec.Name.WHEEL_ZOOM,
@@ -66,7 +65,7 @@ import org.jetbrains.letsPlot.intern.toSpec
6665
@Composable
6766
fun PlotPanel(
6867
figure: Figure,
69-
figureModel: FigureModel? = null,
68+
figureModel: PlotFigureModel? = null,
7069
preserveAspectRatio: Boolean = false,
7170
modifier: Modifier,
7271
errorTextStyle: TextStyle = TextStyle(color = Color(0xFF700000)),

lets-plot-compose/src/commonMain/kotlin/org/jetbrains/letsPlot/compose/PlotPanelRaw.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import org.jetbrains.letsPlot.core.plot.builder.interact.tools.FigureModel
1818
*
1919
* @param rawSpec Raw plot specification map
2020
* @param figureModel Optional [FigureModel] for controlling plot interactions programmatically.
21-
* Use [rememberPlotFigureModel] to create a model that can be accessed externally.
2221
* @param preserveAspectRatio Whether to preserve the plot's aspect ratio
2322
* @param modifier Modifier for the plot container
2423
* @param errorTextStyle Text style for error messages
@@ -28,7 +27,7 @@ import org.jetbrains.letsPlot.core.plot.builder.interact.tools.FigureModel
2827
*
2928
* Example with external FigureModel:
3029
* ```kotlin
31-
* val figureModel = rememberPlotFigureModel()
30+
* val figureModel = remember { PlotFigureModel() }
3231
*
3332
* PlotPanelRaw(
3433
* rawSpec = myRawSpec,
@@ -63,7 +62,7 @@ import org.jetbrains.letsPlot.core.plot.builder.interact.tools.FigureModel
6362
@Composable
6463
expect fun PlotPanelRaw(
6564
rawSpec: MutableMap<String, Any>,
66-
figureModel: FigureModel? = null,
65+
figureModel: PlotFigureModel? = null,
6766
preserveAspectRatio: Boolean,
6867
modifier: Modifier,
6968
errorTextStyle: TextStyle = TextStyle(color = Color(0xFF700000)),

0 commit comments

Comments
 (0)