Skip to content

Commit

Permalink
Fabrizio.scarponi/111 spinning indicator circular progress indicator (#…
Browse files Browse the repository at this point in the history
…134)

* wip

* wip

* Add a customizable circular progress to the UI

This commit adds a flexible circular progress option to the UI that supports both small and large size variants. The base theme has been updated to provide styling to both variants, and new SVG files have been added to provide dynamic views of the circular progress. The circular progress can be incorporated in the UI using the CircularProgress and CircularProgressBig components. This addition provides a more visually engaging way to show progress in the UI.

* Refactor names of circular progress components and styles

* wip

* wip

* Refactor circular progress styles

* Remove unused spinner SVG files

* Remove SvgLoader from CircularProgressStyle

* Remove SvgLoader from CircularProgressStyle

* Improve handling of non-existent colors

Added a `FallbackMarker` in BridgeUtils.kt, improving the named color retrieval process. The `retrieveColorOrNull` method now checks for colors that are not found and returns a specific marker, rather than an inappropriate color. This avoids any confusion and enhances the overall color management within the application."

* Update svgLoader usage for progress bars

* fixes
  • Loading branch information
fscarponi authored Sep 29, 2023
1 parent 551d824 commit 03fb182
Show file tree
Hide file tree
Showing 15 changed files with 466 additions and 50 deletions.
170 changes: 170 additions & 0 deletions core/src/main/kotlin/org/jetbrains/jewel/CircularProgressIndicator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package org.jetbrains.jewel

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.jetbrains.jewel.styling.CircularProgressStyle
import org.jetbrains.jewel.util.toHexString

@Composable
fun CircularProgressIndicator(
svgLoader: SvgLoader,
modifier: Modifier = Modifier,
style: CircularProgressStyle = IntelliJTheme.circularProgressStyle,
) {
CircularProgressIndicatorImpl(
modifier = modifier,
svgLoader = svgLoader,
iconSize = DpSize(16.dp, 16.dp),
style = style,
frameRetriever = { color -> SpinnerProgressIconGenerator.Small.generateSvgFrames(color.toHexString()) },
)
}

@Composable
fun CircularProgressIndicatorBig(
svgLoader: SvgLoader,
modifier: Modifier = Modifier,
style: CircularProgressStyle = IntelliJTheme.circularProgressStyle,
) {
CircularProgressIndicatorImpl(
modifier = modifier,
svgLoader = svgLoader,
iconSize = DpSize(32.dp, 32.dp),
style = style,
frameRetriever = { color -> SpinnerProgressIconGenerator.Big.generateSvgFrames(color.toHexString()) },
)
}

@Composable
private fun CircularProgressIndicatorImpl(
modifier: Modifier = Modifier,
svgLoader: SvgLoader,
iconSize: DpSize,
style: CircularProgressStyle,
frameRetriever: (Color) -> List<String>,
) {
val defaultColor = if (IntelliJTheme.isDark) Color(0xFF6F737A) else Color(0xFFA8ADBD)
var isFrameReady by remember { mutableStateOf(false) }
var currentFrame: Pair<String, Int> by remember { mutableStateOf("" to 0) }

if (!isFrameReady) {
Box(modifier.size(iconSize))
} else {
Icon(
modifier = modifier.size(iconSize),
painter = svgLoader.loadRawSvg(
currentFrame.first,
"circularProgressIndicator_frame_${currentFrame.second}",
),
contentDescription = null,
)
}

LaunchedEffect(style.color) {
val frames = frameRetriever(style.color.takeOrElse { defaultColor })
while (true) {
for (i in 0 until frames.size) {
currentFrame = frames[i] to i
isFrameReady = true
delay(style.frameTime.inWholeMilliseconds)
}
}
}
}

object SpinnerProgressIconGenerator {

private val opacityList = listOf(1.0f, 0.93f, 0.78f, 0.69f, 0.62f, 0.48f, 0.38f, 0.0f)

private fun StringBuilder.closeRoot() = append("</svg>")
private fun StringBuilder.openRoot(sizePx: Int) = append(
"<svg width=\"$sizePx\" height=\"$sizePx\" viewBox=\"0 0 16 16\" fill=\"none\" " +
"xmlns=\"http://www.w3.org/2000/svg\">",
)

private fun generateSvgIcon(
size: Int,
opacityListShifted: List<Float>,
colorHex: String,
) =
buildString {
openRoot(size)
elements(
colorHex = colorHex,
opacityList = opacityListShifted,
)
closeRoot()
}

private fun StringBuilder.elements(
colorHex: String,
opacityList: List<Float>,
) {
append(
"\n" +
" <rect fill=\"$colorHex\" opacity=\"${opacityList[0]}\" x=\"7\" y=\"1\" width=\"2\" height=\"4\" rx=\"1\"/>\n" +
" <rect fill=\"$colorHex\" opacity=\"${opacityList[1]}\" x=\"2.34961\" y=\"3.76416\" width=\"2\" height=\"4\" rx=\"1\"\n" +
" transform=\"rotate(-45 2.34961 3.76416)\"/>\n" +
" <rect fill=\"$colorHex\" opacity=\"${opacityList[2]}\" x=\"1\" y=\"7\" width=\"4\" height=\"2\" rx=\"1\"/>\n" +
" <rect fill=\"$colorHex\" opacity=\"${opacityList[3]}\" x=\"5.17871\" y=\"9.40991\" width=\"2\" height=\"4\" rx=\"1\"\n" +
" transform=\"rotate(45 5.17871 9.40991)\"/>\n" +
" <rect fill=\"$colorHex\" opacity=\"${opacityList[4]}\" x=\"7\" y=\"11\" width=\"2\" height=\"4\" rx=\"1\"/>\n" +
" <rect fill=\"$colorHex\" opacity=\"${opacityList[5]}\" x=\"9.41016\" y=\"10.8242\" width=\"2\" height=\"4\" rx=\"1\"\n" +
" transform=\"rotate(-45 9.41016 10.8242)\"/>\n" +
" <rect fill=\"$colorHex\" opacity=\"${opacityList[6]}\" x=\"11\" y=\"7\" width=\"4\" height=\"2\" rx=\"1\"/>\n" +
" <rect fill=\"$colorHex\" opacity=\"${opacityList[7]}\" x=\"12.2383\" y=\"2.3501\" width=\"2\" height=\"4\" rx=\"1\"\n" +
" transform=\"rotate(45 12.2383 2.3501)\"/>\n",
)
}

object Small {

fun generateSvgFrames(colorHex: String) = buildList {
val opacityListShifted = opacityList.toMutableList()
repeat(opacityList.count()) {
add(
generateSvgIcon(
size = 16,
colorHex = colorHex,
opacityListShifted = opacityListShifted,
),
)
opacityListShifted.shtr()
}
}
}

object Big {

fun generateSvgFrames(colorHex: String) = buildList {
val opacityListShifted = opacityList.toMutableList()
repeat(opacityList.count()) {
add(
generateSvgIcon(
size = 32,
colorHex = colorHex,
opacityListShifted = opacityListShifted,
),
)
opacityListShifted.shtr()
}
}
}

private fun <T> MutableList<T>.shtr() {
add(first())
removeFirst()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
import org.jetbrains.jewel.styling.ButtonStyle
import org.jetbrains.jewel.styling.CheckboxStyle
import org.jetbrains.jewel.styling.ChipStyle
import org.jetbrains.jewel.styling.CircularProgressStyle
import org.jetbrains.jewel.styling.DropdownStyle
import org.jetbrains.jewel.styling.GroupHeaderStyle
import org.jetbrains.jewel.styling.HorizontalProgressBarStyle
Expand Down Expand Up @@ -36,6 +37,7 @@ class IntelliJComponentStyling(
val scrollbarStyle: ScrollbarStyle,
val textAreaStyle: TextAreaStyle,
val textFieldStyle: TextFieldStyle,
val circularProgressStyle: CircularProgressStyle,
) {

override fun equals(other: Any?): Boolean {
Expand All @@ -61,6 +63,7 @@ class IntelliJComponentStyling(
if (lazyTreeStyle != other.lazyTreeStyle) return false
if (defaultTabStyle != other.defaultTabStyle) return false
if (editorTabStyle != other.editorTabStyle) return false
if (circularProgressStyle != other.circularProgressStyle) return false

return true
}
Expand All @@ -83,6 +86,7 @@ class IntelliJComponentStyling(
result = 31 * result + lazyTreeStyle.hashCode()
result = 31 * result + defaultTabStyle.hashCode()
result = 31 * result + editorTabStyle.hashCode()
result = 31 * result + circularProgressStyle.hashCode()
return result
}

Expand All @@ -93,5 +97,6 @@ class IntelliJComponentStyling(
"horizontalProgressBarStyle=$horizontalProgressBarStyle, labelledTextFieldStyle=$labelledTextFieldStyle, " +
"lazyTreeStyle=$lazyTreeStyle, linkStyle=$linkStyle, menuStyle=$menuStyle, " +
"outlinedButtonStyle=$outlinedButtonStyle, radioButtonStyle=$radioButtonStyle, " +
"scrollbarStyle=$scrollbarStyle, textAreaStyle=$textAreaStyle, textFieldStyle=$textFieldStyle)"
"scrollbarStyle=$scrollbarStyle, textAreaStyle=$textAreaStyle, textFieldStyle=$textFieldStyle" +
"circularProgressStyle=$circularProgressStyle)"
}
7 changes: 7 additions & 0 deletions core/src/main/kotlin/org/jetbrains/jewel/IntelliJTheme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.ui.text.TextStyle
import org.jetbrains.jewel.styling.ButtonStyle
import org.jetbrains.jewel.styling.CheckboxStyle
import org.jetbrains.jewel.styling.ChipStyle
import org.jetbrains.jewel.styling.CircularProgressStyle
import org.jetbrains.jewel.styling.DropdownStyle
import org.jetbrains.jewel.styling.GroupHeaderStyle
import org.jetbrains.jewel.styling.HorizontalProgressBarStyle
Expand All @@ -17,6 +18,7 @@ import org.jetbrains.jewel.styling.LazyTreeStyle
import org.jetbrains.jewel.styling.LinkStyle
import org.jetbrains.jewel.styling.LocalCheckboxStyle
import org.jetbrains.jewel.styling.LocalChipStyle
import org.jetbrains.jewel.styling.LocalCircularProgressStyle
import org.jetbrains.jewel.styling.LocalDefaultButtonStyle
import org.jetbrains.jewel.styling.LocalDefaultTabStyle
import org.jetbrains.jewel.styling.LocalDropdownStyle
Expand Down Expand Up @@ -175,6 +177,11 @@ interface IntelliJTheme {
@Composable
@ReadOnlyComposable
get() = LocalEditorTabStyle.current

val circularProgressStyle: CircularProgressStyle
@Composable
@ReadOnlyComposable
get() = LocalCircularProgressStyle.current
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.jetbrains.jewel.styling

import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import kotlin.time.Duration

interface CircularProgressStyle {

val frameTime: Duration
val color: Color
}

val LocalCircularProgressStyle = staticCompositionLocalOf<CircularProgressStyle> {
error("No CircularProgressIndicatorStyle provided")
}
22 changes: 22 additions & 0 deletions core/src/main/kotlin/org/jetbrains/jewel/util/ColorExtensions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.jetbrains.jewel.util

import androidx.compose.ui.graphics.Color
import kotlin.math.roundToInt

fun Color.toHexString(): String {
val r = Integer.toHexString((red * 255).roundToInt())
val g = Integer.toHexString((green * 255).roundToInt())
val b = Integer.toHexString((blue * 255).roundToInt())

return buildString {
append('#')
append(r.padStart(2, '0'))
append(g.padStart(2, '0'))
append(b.padStart(2, '0'))

if (alpha != 1.0f) {
val a = Integer.toHexString((alpha * 255).roundToInt())
append(a.padStart(2, '0'))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package org.jetbrains.jewel.util

object SpinnerProgressIconGenerator {

private val opacityList = listOf(1.0f, 0.93f, 0.78f, 0.69f, 0.62f, 0.48f, 0.38f, 0.0f)

private val rotations = listOf(0, -45, 0, 45, 0, -45, 0, 45)

// for a 16x16 icon
internal val points = listOf(
7f to 1f,
2.34961f to 3.76416f,
1f to 7f,
5.17871f to 9.40991f,
7f to 11f,
9.41016f to 10.8242f,
11f to 7f,
12.2383f to 2.34961f,
)

private fun StringBuilder.closeTag() = append("</svg>")
private fun StringBuilder.openTag(sizePx: Int) = append(
"<svg width=\"$sizePx\" height=\"$sizePx\" viewBox=\"0 0 $sizePx $sizePx\" fill=\"none\" " +
"xmlns=\"http://www.w3.org/2000/svg\">",
)

private fun getSvgPlainTextIcon(
step: Int,
pointList: List<Pair<Float, Float>>,
colorHex: String,
thickness: Int = 2,
length: Int = 4,
cornerRadius: Int = 1,
) =
buildString {
openTag(16)
appendLine()
for (index in 0..opacityList.lastIndex) {
val currentIndex = (index + step + 1) % opacityList.size
val currentOpacity = opacityList[currentIndex]
if (currentOpacity == 0.0f) continue
drawElement(
colorHex = colorHex,
opacity = currentOpacity,
x = pointList[index].first,
y = pointList[index].second,
width = thickness,
height = length,
rx = cornerRadius,
rotation = rotations[index],
)
}
closeTag()
appendLine()
}

private fun StringBuilder.drawElement(
colorHex: String,
opacity: Float,
x: Float,
y: Float,
width: Int,
height: Int,
rx: Int,
rotation: Int,
) {
append("<rect fill=\"${colorHex}\" opacity=\"$opacity\" x=\"$x\" y=\"$y\" width=\"$width\" height=\"$height\" rx=\"$rx\"")
if (rotation != 0) append(" transform=\"rotate($rotation $x $y)\"")
append("/>\n")
}

internal fun getPlainTextSvgList(colorHex: String, size: Int) = buildList {
val scaleFactor = size / 16f
for (index in 0..opacityList.lastIndex) {
if (size == 16) {
add(getSvgPlainTextIcon(index, points, colorHex))
} else {
add(
getSvgPlainTextIcon(
index,
points.map { it.first * scaleFactor to it.second * scaleFactor },
colorHex,
thickness = (2 * scaleFactor).toInt().coerceAtLeast(1),
length = (4 * scaleFactor).toInt().coerceAtLeast(1),
cornerRadius = (2 * scaleFactor).toInt().coerceAtLeast(1),
),
)
}
}
}

object Small {

fun generateRawSvg(colorHex: String) = getPlainTextSvgList(colorHex = colorHex, size = 16)
}

object Big {

fun generateRawSvg(colorHex: String) =
getPlainTextSvgList(colorHex = colorHex, size = 32)
}
}
Loading

0 comments on commit 03fb182

Please sign in to comment.