Skip to content

Commit 76d1dde

Browse files
committed
Use SVG painter to render toolbar icons.
1 parent 167c8e6 commit 76d1dde

5 files changed

Lines changed: 171 additions & 65 deletions

File tree

demo/plot/compose-desktop/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ val letsPlotKotlinVersion = extra["letsPlotKotlin.version"] as String
1010

1111
dependencies {
1212
implementation(compose.desktop.currentOs)
13+
implementation(compose.components.resources)
1314

1415
implementation("org.jetbrains.lets-plot:lets-plot-kotlin-kernel:$letsPlotKotlinVersion")
1516
implementation("org.jetbrains.lets-plot:lets-plot-common:$letsPlotVersion")

future_changes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,7 @@ All artifacts were built with the following versions of dependencies:
1111

1212
### Changed
1313

14+
- New required dependency in desktop: `implementation(compose.components.resources)`
15+
1416
### Fixed
1517

lets-plot-compose/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ kotlin {
4848
compileOnly(compose.runtime)
4949
compileOnly(compose.ui)
5050
compileOnly(compose.desktop.currentOs)
51+
compileOnly(compose.components.resources)
5152
compileOnly("org.jetbrains.skiko:skiko:${skikoVersion}")
5253
api(project(":platf-skia"))
5354
compileOnly("io.github.microutils:kotlin-logging-jvm:$kotlinLoggingVersion")

lets-plot-compose/src/desktopMain/kotlin/org/jetbrains/letsPlot/compose/PlotToolbar.kt

Lines changed: 111 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55

66
package org.jetbrains.letsPlot.compose
77

8-
import androidx.compose.foundation.Image
9-
import androidx.compose.foundation.clickable
8+
import androidx.compose.foundation.*
9+
import androidx.compose.foundation.interaction.MutableInteractionSource
10+
import androidx.compose.foundation.interaction.collectIsHoveredAsState
1011
import androidx.compose.foundation.layout.*
12+
import androidx.compose.foundation.shape.RoundedCornerShape
1113
import androidx.compose.runtime.*
14+
import androidx.compose.ui.Alignment
1215
import androidx.compose.ui.Modifier
13-
import androidx.compose.ui.graphics.ImageBitmap
14-
import androidx.compose.ui.graphics.toComposeImageBitmap
16+
import androidx.compose.ui.draw.clip
17+
import androidx.compose.ui.graphics.Color
1518
import androidx.compose.ui.unit.dp
1619
import org.jetbrains.letsPlot.commons.registration.Registration
1720
import org.jetbrains.letsPlot.core.plot.builder.interact.tools.DefaultFigureToolsController
@@ -20,10 +23,7 @@ import org.jetbrains.letsPlot.core.plot.builder.interact.tools.ToggleToolModel
2023
import org.jetbrains.letsPlot.core.plot.builder.interact.tools.ToolSpecs.BBOX_ZOOM_TOOL_SPEC
2124
import org.jetbrains.letsPlot.core.plot.builder.interact.tools.ToolSpecs.CBOX_ZOOM_TOOL_SPEC
2225
import org.jetbrains.letsPlot.core.plot.builder.interact.tools.ToolSpecs.PAN_TOOL_SPEC
23-
import java.awt.image.BufferedImage
24-
import java.io.ByteArrayInputStream
25-
import java.util.*
26-
import javax.imageio.ImageIO
26+
import org.jetbrains.letsPlot.core.plot.builder.interact.tools.res.ToolbarIcons
2727

2828

2929
@Suppress("FunctionName")
@@ -76,75 +76,121 @@ fun PlotToolbar(figureModel: PlotFigureModel) {
7676
}
7777
}
7878

79+
// Toolbar container - matches PlotPanelToolbar.kt (Swing)
80+
//
81+
// Expected height: 33px
82+
// (org.jetbrains.letsPlot.core.plot.builder.presentation.Defaults.TOOLBAR_HEIGHT)
7983
Row(
80-
modifier = Modifier.fillMaxWidth(),
81-
horizontalArrangement = Arrangement.Center
84+
modifier = Modifier
85+
.fillMaxWidth()
86+
.height(33.dp),
87+
horizontalArrangement = Arrangement.Center,
88+
verticalAlignment = Alignment.CenterVertically
8289
) {
83-
IconButton(
84-
icon = if (panToolState) PAN_ACTIVE_ICON else PAN_ICON,
85-
modifier = Modifier.padding(3.dp).size(24.dp),
86-
onClick = { panToolModel.action() }
87-
)
88-
89-
IconButton(
90-
icon = if (bboxZoomToolState) BBOX_ZOOM_ACTIVE_ICON else BBOX_ZOOM_ICON,
91-
modifier = Modifier.padding(3.dp).size(24.dp),
92-
onClick = { bboxZoomToolModel.action() }
93-
)
94-
95-
IconButton(
96-
icon = if (cboxZoomToolState) CBOX_ZOOM_ACTIVE_ICON else CBOX_ZOOM_ICON,
97-
modifier = Modifier.padding(3.dp).size(24.dp),
98-
onClick = { cboxZoomToolModel.action() }
99-
)
100-
101-
IconButton(
102-
icon = RESET_ICON,
103-
modifier = Modifier.padding(3.dp).size(24.dp),
104-
onClick = { controller.resetFigure(deactiveTools = true) }
105-
)
90+
Row(
91+
modifier = Modifier
92+
.clip(RoundedCornerShape(8.dp))
93+
.background(C_BACKGR_TRANSPARENT)
94+
.border(
95+
BorderStroke(1.dp, Color(200, 200, 200)),
96+
RoundedCornerShape(8.dp)
97+
)
98+
.padding(horizontal = 5.dp, vertical = 2.dp),
99+
horizontalArrangement = Arrangement.spacedBy(6.dp)
100+
) {
101+
SvgIconButton(
102+
svgString = ToolbarIcons.PAN_TOOL,
103+
isSelected = panToolState,
104+
onClick = { panToolModel.action() },
105+
contentDescription = "Pan"
106+
)
107+
108+
SvgIconButton(
109+
svgString = ToolbarIcons.ZOOM_CORNER,
110+
isSelected = bboxZoomToolState,
111+
onClick = { bboxZoomToolModel.action() },
112+
contentDescription = "Rubber Band Zoom"
113+
)
114+
115+
SvgIconButton(
116+
svgString = ToolbarIcons.ZOOM_CENTER,
117+
isSelected = cboxZoomToolState,
118+
onClick = { cboxZoomToolModel.action() },
119+
contentDescription = "Centerpoint Zoom"
120+
)
121+
122+
SvgIconButton(
123+
svgString = ToolbarIcons.RESET,
124+
isSelected = false,
125+
onClick = { controller.resetFigure(deactiveTools = true) },
126+
contentDescription = "Reset"
127+
)
128+
}
106129
}
107130
}
108131

132+
@Composable
133+
private fun SvgIconButton(
134+
svgString: String,
135+
isSelected: Boolean,
136+
onClick: () -> Unit,
137+
contentDescription: String,
138+
modifier: Modifier = Modifier
139+
) {
140+
val interactionSource = remember { MutableInteractionSource() }
141+
val isHovered by interactionSource.collectIsHoveredAsState()
142+
143+
val iconColor = when {
144+
isSelected -> C_STROKE_SEL // White when selected
145+
else -> C_STROKE // Gray for normal and hover
146+
}
109147

110-
private fun base64ToImageBitmap(base64: String): ImageBitmap {
111-
val base64Data = base64.substringAfter("base64,")
112-
val decodedBytes = Base64.getDecoder().decode(base64Data)
113-
val inputStream = ByteArrayInputStream(decodedBytes)
114-
val bufferedImage: BufferedImage = ImageIO.read(inputStream)
115-
return bufferedImage.toComposeImageBitmap()
116-
}
148+
val backgroundColor = when {
149+
isSelected -> C_BACKGR_SEL // Blue when selected
150+
isHovered -> C_BACKGR_HOVER // Light gray when hovering
151+
else -> Color.Transparent // Transparent for a normal state
152+
}
153+
154+
val icon = SvgIconUtils.rememberSvgIcon(
155+
svgString = svgString,
156+
iconColor = iconColor
157+
)
117158

118-
@Composable
119-
private fun IconButton(icon: ImageBitmap, modifier: Modifier = Modifier, onClick: () -> Unit) {
120159
Box(
121-
modifier = Modifier
122-
.clickable(onClick = onClick)
123-
.then(modifier)
160+
modifier = modifier
161+
.size(22.dp)
162+
.clip(RoundedCornerShape(4.dp))
163+
.background(backgroundColor)
164+
.hoverable(interactionSource)
165+
.clickable(
166+
interactionSource = interactionSource,
167+
indication = null,
168+
onClick = onClick
169+
),
170+
contentAlignment = Alignment.Center
124171
) {
125172
Image(
126-
bitmap = icon,
127-
contentDescription = null,
128-
modifier = Modifier.size(icon.width.dp, icon.height.dp)
173+
painter = icon,
174+
contentDescription = contentDescription,
175+
modifier = Modifier.size(16.dp)
129176
)
130177
}
131178
}
132179

133-
private val PAN_ICON =
134-
base64ToImageBitmap("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAB2HAAAdhwGP5fFlAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAPNJREFUWIXtlVEOgyAQRGd6h5J6RDxu/ehxph/FhiiILKbUxE32B2XmuS4LJaFn3Lq6twKQFMmmEp67AhfATwBIepKuVpikI+mLL0rKJoARgABMAFziuT4Sq3UX9gjAuOlRALgDeAahF4ChBJDY8zADlCCWALXmuwC2IGIAi/lugBzEDGA1rwJINNcUAcRrq2Y9DCBRiTirvnxOBlG0Xiq1IYnAH0zCo3/BUK13miZE4qghfwx3V8JsHta3BtEuCLP5EsAKYTZPAVggSgB9r+Mg5nOdnQOIIHxJ/zsJLTFPT4WpZonuk/AC6A7Q1IRHRPcKvAGpnEGs1cIJFQAAAABJRU5ErkJggg==")
135-
private val PAN_ACTIVE_ICON =
136-
base64ToImageBitmap("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAB2HAAAdhwGP5fFlAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAi5JREFUWIXNl0FPE0EcxX8zu4lgirQeBDEmeJKLypHQ6IEoeunFLyGNMSbW1n4CUyBNuGj7Gbx4aCKRhIum6lVunGiC2koi25YGMdnd8bCyKUa3O0tKeafNzn/nvZl582ZHKKW4+7Q+hZIFF+YEjNBHKNgD1k3Hya+uTGyK+cy3KaXkRyDeT+J/oGk4zoxEyUJU8rXiGGvFsagC4o6Uz6ULc1F7ODaEuC37veY9cE4OkByA0y8gNTtMPKavMx6TpGaHe9aZgeTJszy6P0IqaZMrWTQ7bmjypXSCyXEThKBS3f9vbeDQ3n0+YKtuMzluUnyY4HwIy4zGJIsLHvn2jk114yCwPrDHVsclV7bYqttcvmCynA4WMRqTLC0kuHLRI8++tNjdC561nkMKKyIKeSgBYUREJQcQd540VKhKjpqr1vC8AfjPtYaeWUEzB5odl2zJ8o15iEPD5ct65NA1A8c4VCJhPvMdOAVJqOUBOGq4bmzv2GRLFrttvSXQmoF4TLKc9shrDdt/X2t4u6PwIKEd26Gr/064fNny27qNGTYxtQT02ue6iaklIGzIRBURWKGbcFFEBLbeujHkGy7zIly8tjouz8qWb8zktaHA+sD/gUp1H5Ti/cYvrYRrdlxyJYub189Q+fAzugCgZwdBIsJ8O/AkHLyAP3e1QaElgfWB0QveinuPv151DOMTJ305FfwwDXtarq5MbBqOMwO8BtonQN1G8Mo07Ok3i5e+/AaZ1ys9OkxDiwAAAABJRU5ErkJggg==")
137-
138-
private val BBOX_ZOOM_ICON =
139-
base64ToImageBitmap("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAB2HAAAdhwGP5fFlAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAANtJREFUWIXtlk0KwjAQRt8n3qJdqisP0Gv1NNobWMSVy/Yi3kHBdutiXJhKLAUX9g/MwBCShrxH0oSRmTFlLCalNwKSEkkHSaWkVNJyTIkEeADmZWZmjJEAeQs+VNbACVi3BYqRBJq8AZEvkHZMugDqeasj4OzWz32BJZC14NuBzjt2jHszpsZCkgGYmb78tD9FmzOPdyAIBIEgEASCQBAIAi5qAEnxUDBv7apLoHTtTlI0EHzvusX7g1cubYAr4xSlqze3o3DMeW1R3+AKOPrwj5pwqpjVLfhPgSdBFZH0qgJ4WwAAAABJRU5ErkJggg==")
140-
private val BBOX_ZOOM_ACTIVE_ICON =
141-
base64ToImageBitmap("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAB2HAAAdhwGP5fFlAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAe1JREFUWIXtl89LFGEYxz/vO7OOKesushEYVATFKhQqgR6iIKxuHcL+gToURCxkB08dgmI9CKt3JQg6VBBCKh2EIqEfkEWHQrt02EzQDXenZmdl33k7LDvrggTBTF7me5p5GObzeXnew/MIrTXnb/1Io2XWgzMC4oQYDTawYCo1Op/rWhbnRlbTWsvXQDJM8A7ZNJQaNNEy230wlrx4qo2OdsnbzxVmFh2UF7pAUkl5z+w+FDs7fr0TQ9aqfUdaOLDPJPe4FLoBQgyJFx/K+nRva+isckXzfmWLqWc23zeUX5fxNhk6HGCPJTh5zGIy00kq0WCa775U6D/a0vRxfr3KlbECWgcnkEpIMsMdDPRYXL0Q5+6DIgByZtFh7k25CX7nfjFQOMBG0WPiiQ3AibTl16XyaLpwl7MFvq1Vg6X7ErXet7eKhkAopH9IJBAJRAKRQCQQCUQCvoDj1iaQVMIIDVb/92+3Me34AktftwDIDMebZrYg4ZlLtZ1naaXi1836w/SszfHDMQZ6LB7e3hu4QD224zE1+8t/94+aX1dcGy/w8qPrtyPIOK7m1SeXGxM/Wd02louhm2ulsPfBv6QogYVdgoPguTSVGgU2dwFeMI3qiJzPdS0bSg0CT4H/sBBSQvDINKq9c2P7838AoiGjAsFi+oIAAAAASUVORK5CYII=")
142-
143-
private val CBOX_ZOOM_ICON =
144-
base64ToImageBitmap("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAB2HAAAdhwGP5fFlAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAARFJREFUWIXtlrFuwjAQhr+/Q14ijMCzpk/Q7p1QxMQIrwNdilSyV9chTmSCJaJTQqiak06RHcf/J/vucjIzprSXSdVnAAAzax1YACVQATawV8AWWF1pdsTPIwh3/QzkKYAyLNjFC4ZyIA97G7BJATTHPrh455QN+G7m1NQBSRZiQo5Q6m1dncmzYAb4mwCSMkmvkk6SjpIKSZmLIEoRq4e90qngtsAUPb+90vECnBIAnx4Abwykmogfz0ZegI+ec/fNeQUZdRwcgxdA5rmCuRTPAE8FUAFIWowlFu19SQEcwvNNUj6S+HsY7tsXUX6ugS8e05Qub/4FUeO4oT6ioYUv1I3vMtZsC9FU9lRZ8D8BfgFquENnSQFiqAAAAABJRU5ErkJggg==")
145-
private val CBOX_ZOOM_ACTIVE_ICON =
146-
base64ToImageBitmap("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAB2HAAAdhwGP5fFlAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAjRJREFUWIXtlz9oE1Ecxz/37sqZpmmKxKVuOjQU/ANG2sHFEP8t0qHqqFvtIAXboZObUkGhGR0iVMFBCi7GVkgHcRAhLeIgtK61CibSJM2/kpfncM2lmqA23CUO/U53X9697/e97+/3eKcppbgw9TWIEjNVCGvgw0UoyAFLhpTTC7P9q9r5yY2gUuId0OemcBNs6lIOC5SY6YA4QJ8U4p6oQrgD4hY0LWLszjzg1xkf6SE0YOIxNUe1imXF8to2sZc5vqRkje41dos/mjqIr1s4KlyDx9Q4c8zkxNEuxh6kSWWqANgGxkd68HUL3n8qE53P2gOcQsAvmBjtZWjQZOyyj7tPMwDYyw0NmACuiAOkMlWi8zlLK2javG2glrkb4nUTVvbeA/X6cifwPWDfQMcNGH8f0uQjXePGJS+RUx4UkEgWmVvMU5GqPQauX/Ry9azXfr8Wtp5j8a09z9VSBOdCnkbudCPnmoFmG11t8fhoyUAiWWzklhu5f0FLNTC3mAcgshNFIlnkyQ7XFgMVqYjFt1oqut/R8XNg38D/Y6BQsro74NddE6vNnS/VTxLbwMrnbQAmRn0E/M5vTMCvM3HFuv+urJVt3m7Dx/Ecx490MTRo8uzOIccN1JArVH9pX3up698lNx+mefOhZMfhJAolxduPJW5Ff7BRv5ajRW5/y7r9P/gHZASw1CFx0HgtDCmngc0OiKcNvTIpFmb7V3Uph4EXQLYN0lk0nht65eSr+4fXfwL+SbOhiKONmwAAAABJRU5ErkJggg==")
147-
148-
private val RESET_ICON =
149-
base64ToImageBitmap("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAB2HAAAdhwGP5fFlAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAj5JREFUWIXN17trVEEUx/HPJPERo4kRjREhELSJ2EtQBEFTJSD4akT/AAtr/Qv8CwSxsBKtBAuxND5ADL4IRgyCxEIxKhGMr8qxuLN6s+xu7m6yGweGs3cev/O9h7nnzIYYo5VsbSvqvRJACCGGEBYNSwihLYTQG0LoWgpAR9GFIYR2jGIMezGINWluFtO4gxsxxsnCBDHGBR0xG/77HHASM6W51H9iDl/LxiPuYn+5dqVeEwC9uJ0TfoYz2IX23J4tGMFFfM6tv4TOhgDQj1fp9wccL/RGrMd5fM9Bb24E4Gmyk9haxHmZzlDaW9LaWC9AxFQt+gIQm1IEIq43AlCx1wnRj09p79GWAyTNE2nvC4SaAM3o6VMuHeiD+bmWpOKYUVxOj2P5uVbWggfJDucHQwpR01sIoRM/MB9j7C6NtzICv5JdnR9cABBCmE/VcEkVrkprw29lBbA8Ah+T7WsCwM7kb6YWwPtkdzQBYHeyU7UAxpMdaQJA6fObWDBaljCGZcnitVy5XYZE1IV52RkYrJoJU0RKFez0MgKcS5rjRWrB4bT4nQbKcAW9AXxLb7+vCEDAzQRxD+uW4LwHz5PWlYpramycThsnsK0B59vxMGk8QXdhgJxAif4LzmJtAccdOCXLKREv0Vd1/SJi3bjq311gTnbxPCa7lvfKUusADuEC3uTWX8OGmj4KhvMA7qtyQanQH2G0iHZd1TCEMIQj2CPLbD1YhVm8TYf2VozxcWHNegCa0f6/P6etbn8Amy2DASz9UqwAAAAASUVORK5CYII=")
180+
private val C_BACKGR = Color(247, 248, 250)
181+
private val C_STROKE = Color(110, 110, 110)
182+
private val C_BACKGR_HOVER = Color(218, 219, 221)
183+
private val C_BACKGR_SEL = Color(69, 114, 232)
184+
private val C_STROKE_SEL = Color.White
185+
186+
private const val ALPHA = 0.8f
187+
188+
// C_BACKGR with an alpha channel which on a white background looks the same as the solid C_BACKGR
189+
// and slightly darkens any darker background.
190+
private val C_BACKGR_TRANSPARENT = Color(
191+
red = (C_BACKGR.red - 1.0f * (1 - ALPHA)) / ALPHA,
192+
green = (C_BACKGR.green - 1.0f * (1 - ALPHA)) / ALPHA,
193+
blue = (C_BACKGR.blue - 1.0f * (1 - ALPHA)) / ALPHA,
194+
alpha = ALPHA
195+
)
150196

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright (c) 2024 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 org.jetbrains.letsPlot.compose
7+
8+
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.remember
10+
import androidx.compose.ui.graphics.Color
11+
import androidx.compose.ui.graphics.painter.Painter
12+
import androidx.compose.ui.platform.LocalDensity
13+
import org.jetbrains.compose.resources.decodeToSvgPainter
14+
15+
object SvgIconUtils {
16+
17+
/**
18+
* Creates a Painter from an SVG string with the specified icon color.
19+
*
20+
* @param svgString The SVG content as a string
21+
* @param iconColor The color to apply to the SVG icon
22+
* @return A Painter that can be used with Image composable
23+
*/
24+
@Composable
25+
fun rememberSvgIcon(
26+
svgString: String,
27+
iconColor: Color
28+
): Painter {
29+
val density = LocalDensity.current
30+
31+
return remember(svgString, iconColor, density) {
32+
createSvgPainter(svgString, iconColor, density)
33+
}
34+
}
35+
36+
private fun createSvgPainter(
37+
svgString: String,
38+
iconColor: Color,
39+
density: androidx.compose.ui.unit.Density
40+
): Painter {
41+
// Apply color transformation to SVG string
42+
val coloredSvg = svgString.replace(
43+
"""stroke="none"""",
44+
"""stroke="none" fill="${colorToHex(iconColor)}""""
45+
)
46+
47+
return coloredSvg.toByteArray().decodeToSvgPainter(density)
48+
}
49+
50+
private fun colorToHex(color: Color): String {
51+
val red = (color.red * 255).toInt()
52+
val green = (color.green * 255).toInt()
53+
val blue = (color.blue * 255).toInt()
54+
return "#%02x%02x%02x".format(red, green, blue)
55+
}
56+
}

0 commit comments

Comments
 (0)