Răsfoiți Sursa

custom surface

milan jurkulák 2 ani în urmă
părinte
comite
1f2ad86b7b
18 a modificat fișierele cu 1486 adăugiri și 7 ștergeri
  1. 252 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/Surface.kt
  2. 61 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/base/Border.kt
  3. 50 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceBorder.kt
  4. 64 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceColors.kt
  5. 183 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceDefaults.kt
  6. 41 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceGlow.kt
  7. 60 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceScale.kt
  8. 49 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceShape.kt
  9. 46 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/base/Glow.kt
  10. 55 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/base/ShapeTokens.kt
  11. 175 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/base/SurfaceGlow.kt
  12. 66 0
      src/main/kotlin/eu/mjdev/desktop/components/surface/base/SurfaceShapeOutlineCache.kt
  13. 42 0
      src/main/kotlin/eu/mjdev/desktop/extensions/ColorUtils.kt
  14. 160 7
      src/main/kotlin/eu/mjdev/desktop/extensions/Compose.kt
  15. 5 0
      src/main/kotlin/eu/mjdev/desktop/extensions/Custom.kt
  16. 19 0
      src/main/kotlin/eu/mjdev/desktop/helpers/compose/FocusHelper.kt
  17. 14 0
      src/main/kotlin/eu/mjdev/desktop/helpers/compose/Gravity.kt
  18. 144 0
      src/main/kotlin/eu/mjdev/desktop/helpers/compose/SurfaceBorder.kt

+ 252 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/Surface.kt

@@ -0,0 +1,252 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface
+
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.TweenSpec
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.*
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.material3.LocalAbsoluteTonalElevation
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.surfaceColorAtElevation
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import eu.mjdev.desktop.components.surface.base.*
+import eu.mjdev.desktop.extensions.Compose.onMouseLongPress
+import eu.mjdev.desktop.extensions.Compose.onMousePress
+import eu.mjdev.desktop.extensions.Modifier.conditional
+import eu.mjdev.desktop.helpers.compose.surfaceBorder
+import eu.mjdev.desktop.helpers.compose.zIndex
+
+@Suppress("SameParameterValue")
+@Composable
+fun Surface(
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    onLongClick: (() -> Unit)? = null,
+    enabled: Boolean = true,
+    tonalElevation: Dp = 0.dp,
+    shape: ClickableSurfaceShape = ClickableSurfaceDefaults.shape(),
+    colors: ClickableSurfaceColors = ClickableSurfaceDefaults.colors(),
+    scale: ClickableSurfaceScale = ClickableSurfaceDefaults.scale(),
+    border: ClickableSurfaceBorder = ClickableSurfaceDefaults.border(),
+    glow: ClickableSurfaceGlow = ClickableSurfaceDefaults.glow(),
+    interactionSource: MutableInteractionSource? = null,
+    content: @Composable (BoxScope.() -> Unit)
+) {
+    @Suppress("NAME_SHADOWING")
+    val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
+    val focused by interactionSource.collectIsFocusedAsState()
+    val pressed by interactionSource.collectIsPressedAsState()
+    SurfaceImpl(
+        modifier = modifier
+            .onMousePress {
+                if (enabled) onClick()
+            }
+            .onMouseLongPress {
+                if (enabled) onLongClick?.invoke()
+            },
+        selected = false,
+        enabled = enabled,
+        tonalElevation = tonalElevation,
+        shape =
+            ClickableSurfaceDefaults.shape(
+                enabled = enabled,
+                focused = focused,
+                pressed = pressed,
+                shape = shape
+            ),
+        color =
+            ClickableSurfaceDefaults.containerColor(
+                enabled = enabled,
+                focused = focused,
+                pressed = pressed,
+                colors = colors
+            ),
+        contentColor =
+            ClickableSurfaceDefaults.contentColor(
+                enabled = enabled,
+                focused = focused,
+                pressed = pressed,
+                colors = colors
+            ),
+        scale =
+            ClickableSurfaceDefaults.scale(
+                enabled = enabled,
+                focused = focused,
+                pressed = pressed,
+                scale = scale
+            ),
+        border =
+            ClickableSurfaceDefaults.border(
+                enabled = enabled,
+                focused = focused,
+                pressed = pressed,
+                border = border
+            ),
+        glow =
+            ClickableSurfaceDefaults.glow(
+                enabled = enabled,
+                focused = focused,
+                pressed = pressed,
+                glow = glow
+            ),
+        interactionSource = interactionSource,
+        content = content
+    )
+}
+
+@Suppress("SameParameterValue")
+@Composable
+private fun SurfaceImpl(
+    modifier: Modifier,
+    selected: Boolean,
+    enabled: Boolean,
+    shape: Shape,
+    color: Color,
+    contentColor: Color,
+    scale: Float,
+    border: Border,
+    glow: Glow,
+    tonalElevation: Dp,
+    interactionSource: MutableInteractionSource? = null,
+    content: @Composable (BoxScope.() -> Unit)
+) {
+    @Suppress("NAME_SHADOWING")
+    val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
+    val focused by interactionSource.collectIsFocusedAsState()
+    val pressed by interactionSource.collectIsPressedAsState()
+    val surfaceAlpha = stateAlpha(enabled = enabled, focused = focused, pressed = pressed, selected = selected)
+    val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
+    CompositionLocalProvider(
+        LocalContentColor provides contentColor,
+        LocalAbsoluteTonalElevation provides absoluteElevation
+    ) {
+        val zIndex by
+        animateFloatAsState(
+            targetValue = if (focused) FocusedZIndex else NonFocusedZIndex,
+            label = "zIndex"
+        )
+        val backgroundColorByState =
+            surfaceColorAtElevation(color = color, elevation = LocalAbsoluteTonalElevation.current)
+        Box(
+            modifier =
+                modifier
+                    .tvSurfaceScale(
+                        scale = scale,
+                        interactionSource = interactionSource,
+                    )
+                    .surfaceGlow(shape, glow)
+                    .zIndex(zIndex)
+                    .conditional(border != Border.None) {
+                        surfaceBorder(shape, border)
+                    }
+                    .background(backgroundColorByState, shape)
+                    .graphicsLayer {
+                        this.alpha = surfaceAlpha
+                        this.shape = shape
+                        this.clip = true
+                        this.compositingStrategy = CompositingStrategy.Offscreen
+                    },
+            propagateMinConstraints = true
+        ) {
+            Box(
+                modifier =
+                    Modifier.graphicsLayer {
+                        this.alpha = if (!enabled) DisabledContentAlpha else EnabledContentAlpha
+                    },
+                content = content
+            )
+        }
+    }
+}
+
+@Composable
+fun surfaceColorAtElevation(color: Color, elevation: Dp): Color {
+    return if (color == MaterialTheme.colorScheme.surface) {
+        MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
+    } else {
+        color
+    }
+}
+
+@Suppress("ConstPropertyName")
+object SurfaceScaleTokens {
+    const val focusDuration: Int = 300
+    const val unFocusDuration: Int = 500
+    const val pressedDuration: Int = 120
+    const val releaseDuration: Int = 300
+    val enterEasing = CubicBezierEasing(0f, 0f, 0.2f, 1f)
+}
+
+@Composable
+fun Modifier.tvSurfaceScale(
+    scale: Float,
+    interactionSource: MutableInteractionSource,
+): Modifier {
+    val interaction by interactionSource.interactions.collectAsState(initial = FocusInteraction.Focus())
+    val animationSpec = defaultScaleAnimationSpec(interaction)
+    val animatedScale by animateFloatAsState(
+        targetValue = scale,
+        animationSpec = animationSpec,
+        label = "tv-surface-scale"
+    )
+    return this.graphicsLayer(scaleX = animatedScale, scaleY = animatedScale)
+}
+
+fun defaultScaleAnimationSpec(interaction: Interaction): TweenSpec<Float> =
+    tween(
+        durationMillis =
+            when (interaction) {
+                is FocusInteraction.Focus -> SurfaceScaleTokens.focusDuration
+                is FocusInteraction.Unfocus -> SurfaceScaleTokens.unFocusDuration
+                is PressInteraction.Press -> SurfaceScaleTokens.pressedDuration
+                is PressInteraction.Release -> SurfaceScaleTokens.releaseDuration
+                is PressInteraction.Cancel -> SurfaceScaleTokens.releaseDuration
+                else -> SurfaceScaleTokens.releaseDuration
+            },
+        easing = SurfaceScaleTokens.enterEasing
+    )
+
+fun stateAlpha(
+    enabled: Boolean,
+    focused: Boolean,
+    pressed: Boolean,
+    selected: Boolean
+): Float {
+    return when {
+        !enabled && pressed -> DisabledPressedStateAlpha
+        !enabled && focused -> DisabledFocusedStateAlpha
+        !enabled && selected -> DisabledSelectedStateAlpha
+        enabled -> EnabledContentAlpha
+        else -> DisabledDefaultStateAlpha
+    }
+}
+
+private const val DisabledPressedStateAlpha = 0.8f
+private const val DisabledFocusedStateAlpha = 0.8f
+private const val DisabledSelectedStateAlpha = 0.8f
+private const val DisabledDefaultStateAlpha = 0.6f
+
+private const val DisabledContentAlpha = 0.8f
+internal const val EnabledContentAlpha = 1f
+
+private const val FocusedZIndex = 0.5f
+private const val NonFocusedZIndex = 0f

+ 61 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/base/Border.kt

@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface.base
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+@Immutable
+class Border(
+    val border: BorderStroke,
+    val inset: Dp = 0.dp,
+    val shape: Shape = ShapeTokens.BorderDefaultShape
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other == null || this::class != other::class) return false
+        other as Border
+        if (border != other.border) return false
+        if (inset != other.inset) return false
+        if (shape != other.shape) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = border.hashCode()
+        result = 31 * result + inset.hashCode()
+        result = 31 * result + shape.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "Border(border=$border, inset=$inset, shape=$shape)"
+    }
+
+    fun copy(border: BorderStroke? = null, inset: Dp? = null, shape: Shape? = null): Border =
+        Border(
+            border = border ?: this.border,
+            inset = inset ?: this.inset,
+            shape = shape ?: this.shape
+        )
+
+    companion object {
+        val None =
+            Border(
+                border = BorderStroke(width = 0.dp, color = Color.Transparent),
+                inset = 0.dp,
+                shape = RectangleShape
+            )
+    }
+}

+ 50 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceBorder.kt

@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface.base
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+class ClickableSurfaceBorder
+internal constructor(
+    internal val border: Border,
+    internal val focusedBorder: Border,
+    internal val pressedBorder: Border,
+    internal val disabledBorder: Border,
+    internal val focusedDisabledBorder: Border
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other == null || this::class != other::class) return false
+        other as ClickableSurfaceBorder
+        if (border != other.border) return false
+        if (focusedBorder != other.focusedBorder) return false
+        if (pressedBorder != other.pressedBorder) return false
+        if (disabledBorder != other.disabledBorder) return false
+        if (focusedDisabledBorder != other.focusedDisabledBorder) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = border.hashCode()
+        result = 31 * result + focusedBorder.hashCode()
+        result = 31 * result + pressedBorder.hashCode()
+        result = 31 * result + disabledBorder.hashCode()
+        result = 31 * result + focusedDisabledBorder.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "ClickableSurfaceBorder(border=$border, " +
+                "focusedBorder=$focusedBorder, " +
+                "pressedBorder=$pressedBorder, " +
+                "disabledBorder=$disabledBorder, " +
+                "focusedDisabledBorder=$focusedDisabledBorder)"
+    }
+}

+ 64 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceColors.kt

@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface.base
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.graphics.Color
+
+@Immutable
+class ClickableSurfaceColors
+    (
+    val containerColor: Color,
+    val contentColor: Color,
+    val focusedContainerColor: Color,
+    val focusedContentColor: Color,
+    val pressedContainerColor: Color,
+    val pressedContentColor: Color,
+    val disabledContainerColor: Color,
+    val disabledContentColor: Color
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other == null || this::class != other::class) return false
+        other as ClickableSurfaceColors
+        if (containerColor != other.containerColor) return false
+        if (contentColor != other.contentColor) return false
+        if (focusedContainerColor != other.focusedContainerColor) return false
+        if (focusedContentColor != other.focusedContentColor) return false
+        if (pressedContainerColor != other.pressedContainerColor) return false
+        if (pressedContentColor != other.pressedContentColor) return false
+        if (disabledContainerColor != other.disabledContainerColor) return false
+        if (disabledContentColor != other.disabledContentColor) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = containerColor.hashCode()
+        result = 31 * result + contentColor.hashCode()
+        result = 31 * result + focusedContainerColor.hashCode()
+        result = 31 * result + focusedContentColor.hashCode()
+        result = 31 * result + pressedContainerColor.hashCode()
+        result = 31 * result + pressedContentColor.hashCode()
+        result = 31 * result + disabledContainerColor.hashCode()
+        result = 31 * result + disabledContentColor.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "ClickableSurfaceColors(" +
+                "containerColor=$containerColor, " +
+                "contentColor=$contentColor, " +
+                "focusedContainerColor=$focusedContainerColor, " +
+                "focusedContentColor=$focusedContentColor, " +
+                "pressedContainerColor=$pressedContainerColor, " +
+                "pressedContentColor=$pressedContentColor, " +
+                "disabledContainerColor=$disabledContainerColor, " +
+                "disabledContentColor=$disabledContentColor)"
+    }
+}

+ 183 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceDefaults.kt

@@ -0,0 +1,183 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface.base
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ShapeDefaults
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.dp
+
+object ClickableSurfaceDefaults {
+    internal fun shape(
+        enabled: Boolean,
+        focused: Boolean,
+        pressed: Boolean,
+        shape: ClickableSurfaceShape
+    ): Shape {
+        return when {
+            pressed && enabled -> shape.pressedShape
+            focused && enabled -> shape.focusedShape
+            focused && !enabled -> shape.focusedDisabledShape
+            enabled -> shape.shape
+            else -> shape.disabledShape
+        }
+    }
+
+    @ReadOnlyComposable
+    @Composable
+    fun shape(
+        shape: Shape = MaterialTheme.shapes.medium,
+        focusedShape: Shape = shape,
+        pressedShape: Shape = shape,
+        disabledShape: Shape = shape,
+        focusedDisabledShape: Shape = disabledShape
+    ) = ClickableSurfaceShape(
+        shape = shape,
+        focusedShape = focusedShape,
+        pressedShape = pressedShape,
+        disabledShape = disabledShape,
+        focusedDisabledShape = focusedDisabledShape
+    )
+
+    internal fun containerColor(
+        enabled: Boolean,
+        focused: Boolean,
+        pressed: Boolean,
+        colors: ClickableSurfaceColors
+    ): Color = when {
+        pressed && enabled -> colors.pressedContainerColor
+        focused && enabled -> colors.focusedContainerColor
+        enabled -> colors.containerColor
+        else -> colors.disabledContainerColor
+    }
+
+    internal fun contentColor(
+        enabled: Boolean,
+        focused: Boolean,
+        pressed: Boolean,
+        colors: ClickableSurfaceColors
+    ): Color = when {
+        pressed && enabled -> colors.pressedContentColor
+        focused && enabled -> colors.focusedContentColor
+        enabled -> colors.contentColor
+        else -> colors.disabledContentColor
+    }
+
+    @Suppress("ConstPropertyName")
+    private const val DisabledContainerAlpha = 0.5f
+
+    @ReadOnlyComposable
+    @Composable
+    fun colors(
+        containerColor: Color = MaterialTheme.colorScheme.surface,
+        contentColor: Color = contentColorFor(containerColor),
+        focusedContainerColor: Color = MaterialTheme.colorScheme.inverseSurface,
+        focusedContentColor: Color = contentColorFor(focusedContainerColor),
+        pressedContainerColor: Color = focusedContainerColor,
+        pressedContentColor: Color = contentColorFor(pressedContainerColor),
+        disabledContainerColor: Color =
+            MaterialTheme.colorScheme.surfaceVariant.copy(alpha = DisabledContainerAlpha),
+        disabledContentColor: Color = MaterialTheme.colorScheme.onSurface
+    ) = ClickableSurfaceColors(
+        containerColor = containerColor,
+        contentColor = contentColor,
+        focusedContainerColor = focusedContainerColor,
+        focusedContentColor = focusedContentColor,
+        pressedContainerColor = pressedContainerColor,
+        pressedContentColor = pressedContentColor,
+        disabledContainerColor = disabledContainerColor,
+        disabledContentColor = disabledContentColor
+    )
+
+    internal fun scale(
+        enabled: Boolean,
+        focused: Boolean,
+        pressed: Boolean,
+        scale: ClickableSurfaceScale
+    ): Float = when {
+        pressed && enabled -> scale.pressedScale
+        focused && enabled -> scale.focusedScale
+        focused && !enabled -> scale.focusedDisabledScale
+        enabled -> scale.scale
+        else -> scale.disabledScale
+    }
+
+    fun scale(
+        scale: Float = 1f,
+        focusedScale: Float = 1.1f,
+        pressedScale: Float = scale,
+        disabledScale: Float = scale,
+        focusedDisabledScale: Float = disabledScale
+    ) = ClickableSurfaceScale(
+        scale = scale,
+        focusedScale = focusedScale,
+        pressedScale = pressedScale,
+        disabledScale = disabledScale,
+        focusedDisabledScale = focusedDisabledScale
+    )
+
+    internal fun border(
+        enabled: Boolean,
+        focused: Boolean,
+        pressed: Boolean,
+        border: ClickableSurfaceBorder
+    ): Border = when {
+        pressed && enabled -> border.pressedBorder
+        focused && enabled -> border.focusedBorder
+        focused && !enabled -> border.focusedDisabledBorder
+        enabled -> border.border
+        else -> border.disabledBorder
+    }
+
+    @ReadOnlyComposable
+    @Composable
+    fun border(
+        border: Border = Border.None,
+        focusedBorder: Border = border,
+        pressedBorder: Border = focusedBorder,
+        disabledBorder: Border = border,
+        focusedDisabledBorder: Border = Border(
+            border = BorderStroke(width = 2.dp, color = Color.White), // todo theme
+            inset = 0.dp,
+            shape = ShapeDefaults.Small
+        )
+    ) = ClickableSurfaceBorder(
+        border = border,
+        focusedBorder = focusedBorder,
+        pressedBorder = pressedBorder,
+        disabledBorder = disabledBorder,
+        focusedDisabledBorder = focusedDisabledBorder
+    )
+
+    internal fun glow(
+        enabled: Boolean,
+        focused: Boolean,
+        pressed: Boolean,
+        glow: ClickableSurfaceGlow
+    ): Glow = if (enabled) {
+        when {
+            pressed -> glow.pressedGlow
+            focused -> glow.focusedGlow
+            else -> glow.glow
+        }
+    } else {
+        Glow.None
+    }
+
+    fun glow(
+        glow: Glow = Glow.None,
+        focusedGlow: Glow = glow,
+        pressedGlow: Glow = glow
+    ) = ClickableSurfaceGlow(glow = glow, focusedGlow = focusedGlow, pressedGlow = pressedGlow)
+}

+ 41 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceGlow.kt

@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface.base
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+class ClickableSurfaceGlow
+internal constructor(
+    internal val glow: Glow,
+    internal val focusedGlow: Glow,
+    internal val pressedGlow: Glow
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other == null || this::class != other::class) return false
+        other as ClickableSurfaceGlow
+        if (glow != other.glow) return false
+        if (focusedGlow != other.focusedGlow) return false
+        if (pressedGlow != other.pressedGlow) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = glow.hashCode()
+        result = 31 * result + focusedGlow.hashCode()
+        result = 31 * result + pressedGlow.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "ClickableSurfaceGlow(glow=$glow, focusedGlow=$focusedGlow, " +
+                "pressedGlow=$pressedGlow)"
+    }
+}

+ 60 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceScale.kt

@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface.base
+
+import androidx.compose.runtime.Immutable
+
+@Suppress("unused")
+@Immutable
+class ClickableSurfaceScale
+internal constructor(
+    internal val scale: Float,
+    internal val focusedScale: Float,
+    internal val pressedScale: Float,
+    internal val disabledScale: Float,
+    internal val focusedDisabledScale: Float
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other == null || this::class != other::class) return false
+        other as ClickableSurfaceScale
+        if (scale != other.scale) return false
+        if (focusedScale != other.focusedScale) return false
+        if (pressedScale != other.pressedScale) return false
+        if (disabledScale != other.disabledScale) return false
+        if (focusedDisabledScale != other.focusedDisabledScale) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = scale.hashCode()
+        result = 31 * result + focusedScale.hashCode()
+        result = 31 * result + pressedScale.hashCode()
+        result = 31 * result + disabledScale.hashCode()
+        result = 31 * result + focusedDisabledScale.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "ClickableSurfaceScale(scale=$scale, focusedScale=$focusedScale," +
+                "pressedScale=$pressedScale, disabledScale=$disabledScale, " +
+                "focusedDisabledScale=$focusedDisabledScale)"
+    }
+
+    companion object {
+        val None =
+            ClickableSurfaceScale(
+                scale = 1f,
+                focusedScale = 1f,
+                pressedScale = 1f,
+                disabledScale = 1f,
+                focusedDisabledScale = 1f
+            )
+    }
+}

+ 49 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/base/ClickableSurfaceShape.kt

@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface.base
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.graphics.Shape
+
+@Immutable
+class ClickableSurfaceShape
+internal constructor(
+    internal val shape: Shape,
+    internal val focusedShape: Shape,
+    internal val pressedShape: Shape,
+    internal val disabledShape: Shape,
+    internal val focusedDisabledShape: Shape
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other == null || this::class != other::class) return false
+        other as ClickableSurfaceShape
+        if (shape != other.shape) return false
+        if (focusedShape != other.focusedShape) return false
+        if (pressedShape != other.pressedShape) return false
+        if (disabledShape != other.disabledShape) return false
+        if (focusedDisabledShape != other.focusedDisabledShape) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = shape.hashCode()
+        result = 31 * result + focusedShape.hashCode()
+        result = 31 * result + pressedShape.hashCode()
+        result = 31 * result + disabledShape.hashCode()
+        result = 31 * result + focusedDisabledShape.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "ClickableSurfaceShape(shape=$shape, focusedShape=$focusedShape, " +
+                "pressedShape=$pressedShape, disabledShape=$disabledShape, " +
+                "focusedDisabledShape=$focusedDisabledShape)"
+    }
+}

+ 46 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/base/Glow.kt

@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface.base
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+@Immutable
+class Glow(val elevationColor: Color, val elevation: Dp) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other == null || this::class != other::class) return false
+        other as Glow
+        if (elevationColor != other.elevationColor) return false
+        if (elevation != other.elevation) return false
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = elevationColor.hashCode()
+        result = 31 * result + elevation.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "Glow(elevationColor=$elevationColor, elevation=$elevation)"
+    }
+
+    fun copy(glowColor: Color? = null, glowElevation: Dp? = null): Glow =
+        Glow(
+            elevationColor = glowColor ?: this.elevationColor,
+            elevation = glowElevation ?: this.elevation
+        )
+
+    companion object {
+        val None = Glow(elevationColor = Color.Transparent, elevation = 0.dp)
+    }
+}

+ 55 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/base/ShapeTokens.kt

@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface.base
+
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.GenericShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.unit.dp
+
+@Suppress("unused")
+object ShapeTokens {
+    val BorderDefaultShape = GenericShape { _, _ -> close() }
+    val CornerExtraLarge = RoundedCornerShape(28.0.dp)
+    val CornerExtraLargeTop =
+        RoundedCornerShape(
+            topStart = 28.0.dp,
+            topEnd = 28.0.dp,
+            bottomEnd = 0.0.dp,
+            bottomStart = 0.0.dp
+        )
+    val CornerExtraSmall = RoundedCornerShape(4.0.dp)
+    val CornerExtraSmallTop =
+        RoundedCornerShape(
+            topStart = 4.0.dp,
+            topEnd = 4.0.dp,
+            bottomEnd = 0.0.dp,
+            bottomStart = 0.0.dp
+        )
+    val CornerFull = CircleShape
+    val CornerLarge = RoundedCornerShape(16.0.dp)
+    val CornerLargeEnd =
+        RoundedCornerShape(
+            topStart = 0.0.dp,
+            topEnd = 16.0.dp,
+            bottomEnd = 16.0.dp,
+            bottomStart = 0.0.dp
+        )
+    val CornerLargeTop =
+        RoundedCornerShape(
+            topStart = 16.0.dp,
+            topEnd = 16.0.dp,
+            bottomEnd = 0.0.dp,
+            bottomStart = 0.0.dp
+        )
+    val CornerMedium = RoundedCornerShape(12.0.dp)
+    val CornerNone = RectangleShape
+    val CornerSmall = RoundedCornerShape(8.0.dp)
+}

+ 175 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/base/SurfaceGlow.kt

@@ -0,0 +1,175 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface.base
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.*
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.debugInspectorInfo
+import eu.mjdev.desktop.components.surface.surfaceColorAtElevation
+import eu.mjdev.desktop.extensions.ColorUtils.alpha
+
+@Composable
+internal fun Modifier.surfaceGlow(
+    shape: Shape,
+    glow: Glow,
+): Modifier {
+    val color = surfaceColorAtElevation(color = glow.elevationColor, elevation = glow.elevation)
+    val glowBlurRadiusPx = with(LocalDensity.current) { glow.elevation.toPx() }
+    return then(
+        SurfaceGlowElement(
+            shape = shape,
+            glowBlurRadiusPx = glowBlurRadiusPx,
+            color = color,
+            inspectorInfo =
+                debugInspectorInfo {
+                    name = "tvSurfaceGlow"
+                    properties["shape"] = shape
+                    properties["glow"] = glow
+                }
+        )
+    )
+}
+
+private class SurfaceGlowElement(
+    private val shape: Shape,
+    private val glowBlurRadiusPx: Float,
+    private val color: Color,
+    private val inspectorInfo: InspectorInfo.() -> Unit
+) : ModifierNodeElement<SurfaceGlowNode>() {
+    override fun create(): SurfaceGlowNode {
+        return SurfaceGlowNode(
+            shape = shape,
+            glowBlurRadiusPx = glowBlurRadiusPx,
+            color = color,
+        )
+    }
+
+    override fun update(node: SurfaceGlowNode) {
+        node.reactToUpdates(
+            newShape = shape,
+            newGlowBlurRadiusPx = glowBlurRadiusPx,
+            newColor = color
+        )
+    }
+
+    override fun InspectorInfo.inspectableProperties() {
+        inspectorInfo()
+    }
+
+    override fun hashCode(): Int {
+        var result = shape.hashCode()
+        result = 31 * result + glowBlurRadiusPx.hashCode()
+        result = 31 * result + color.hashCode()
+        return result
+    }
+
+    override fun equals(other: Any?): Boolean {
+        val otherTyped = other as? SurfaceGlowElement ?: return false
+        return shape == otherTyped.shape &&
+                glowBlurRadiusPx == otherTyped.glowBlurRadiusPx &&
+                color == otherTyped.color
+    }
+}
+
+private class SurfaceGlowNode(
+    private var shape: Shape,
+    private var glowBlurRadiusPx: Float,
+    private var color: Color,
+) : DrawModifierNode, Modifier.Node() {
+    private var paint: Paint? = null
+    private var frameworkPaint: Paint? = null
+    private var shapeOutlineCache: SurfaceShapeOutlineCache? = null
+
+    fun reactToUpdates(
+        newShape: Shape,
+        newGlowBlurRadiusPx: Float,
+        newColor: Color,
+    ) {
+        shape = newShape
+        glowBlurRadiusPx = newGlowBlurRadiusPx
+        color = newColor
+        if (paint == null) {
+            initializePaint()
+        }
+        setShadowLayer()
+    }
+
+    override fun ContentDrawScope.draw() {
+        drawIntoCanvas { canvas ->
+            if (paint == null) {
+                initializePaint()
+                setShadowLayer()
+            }
+            if (shapeOutlineCache == null) {
+                shapeOutlineCache =
+                    SurfaceShapeOutlineCache(
+                        shape = shape,
+                        size = size,
+                        layoutDirection = layoutDirection,
+                        density = this
+                    )
+            }
+            when (
+                val shapeOutline =
+                    shapeOutlineCache!!.updatedOutline(
+                        shape = shape,
+                        size = size,
+                        layoutDirection = layoutDirection,
+                        density = this
+                    )
+            ) {
+                is Outline.Rectangle -> canvas.drawRect(shapeOutline.rect, paint!!)
+                is Outline.Rounded -> {
+                    val shapeCornerRadiusX = shapeOutline.roundRect.topLeftCornerRadius.x
+                    val shapeCornerRadiusY = shapeOutline.roundRect.topLeftCornerRadius.y
+                    canvas.drawRoundRect(
+                        left = 0f,
+                        top = 0f,
+                        right = size.width,
+                        bottom = size.height,
+                        radiusX = shapeCornerRadiusX,
+                        radiusY = shapeCornerRadiusY,
+                        paint = paint!!
+                    )
+                }
+
+                is Outline.Generic -> canvas.drawPath(shapeOutline.path, paint!!)
+            }
+        }
+        drawContent()
+    }
+
+    private fun initializePaint() {
+        paint = Paint()
+        frameworkPaint = paint
+    }
+
+    private fun setShadowLayer() {
+        val transparentColor = color.alpha(0f)
+        val shadowColor = color
+        frameworkPaint?.color = transparentColor
+        frameworkPaint?.setShadowLayer(
+            /* radius= */ glowBlurRadiusPx,
+            /* dx= */ 0f,
+            /* dy= */ 0f,
+            /* shadowColor= */ shadowColor
+        )
+    }
+
+    fun Paint.setShadowLayer(radius: Float, dx: Float, dy: Float, shadowColor: Color) {
+        setShadowLayer(radius, dx, dy, shadowColor)
+    }
+}

+ 66 - 0
src/main/kotlin/eu/mjdev/desktop/components/surface/base/SurfaceShapeOutlineCache.kt

@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.components.surface.base
+
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.LayoutDirection
+
+internal class SurfaceShapeOutlineCache(
+    private var shape: Shape,
+    private var size: Size,
+    private var layoutDirection: LayoutDirection,
+    private var density: Density
+) {
+    private var outline: Outline? = null
+
+    fun updatedOutline(
+        shape: Shape,
+        size: Size,
+        layoutDirection: LayoutDirection,
+        density: Density
+    ): Outline {
+        if (outline == null || hasUpdates(shape, size, layoutDirection, density)) {
+            syncUpdates(shape, size, layoutDirection, density)
+            createNewOutline()
+        }
+        return outline!!
+    }
+
+    private fun createNewOutline() {
+        outline = shape.createOutline(size = size, layoutDirection = layoutDirection, density = density)
+    }
+
+    private fun syncUpdates(
+        shape: Shape,
+        size: Size,
+        layoutDirection: LayoutDirection,
+        density: Density
+    ) {
+        this.shape = shape
+        this.size = size
+        this.layoutDirection = layoutDirection
+        this.density = density
+    }
+
+    private fun hasUpdates(
+        shape: Shape,
+        size: Size,
+        layoutDirection: LayoutDirection,
+        density: Density
+    ): Boolean {
+        if (shape != this.shape) return true
+        if (size != this.size) return true
+        if (layoutDirection != this.layoutDirection) return true
+        if (density != this.density) return true
+        return false
+    }
+}

+ 42 - 0
src/main/kotlin/eu/mjdev/desktop/extensions/ColorUtils.kt

@@ -1,8 +1,11 @@
 package eu.mjdev.desktop.extensions
 
+import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.luminance
+import eu.mjdev.desktop.helpers.compose.Gravity
 import java.util.*
+import kotlin.math.roundToInt
 
 @Suppress("unused", "UnusedReceiverParameter")
 object ColorUtils {
@@ -51,6 +54,18 @@ object ColorUtils {
         )
     }
 
+    fun Color.invert(): Color {
+        val a :Int = alpha.roundToInt()
+        val r :Int = 255 - red.roundToInt()
+        val g :Int = 255 - green.roundToInt()
+        val b :Int = 255 - blue.roundToInt()
+        val color = ((a and 0xFF) shl 24) or
+                ((r and 0xFF) shl 16) or
+                ((g and 0xFF) shl 8) or
+                (b and 0xFF)
+        return Color(color)
+    }
+
     fun Color.lighter(factor: Float) = copy(
         red = red + factor,
         green = green + factor,
@@ -71,4 +86,31 @@ object ColorUtils {
         ).alpha(alpha)
     }
 
+    fun createVerticalColorBrush(
+        color: Color,
+        gravity: Gravity
+    ): Brush = Brush.verticalGradient(
+        when (gravity) {
+            Gravity.TOP -> listOf(
+                color,
+                color,
+                color,
+                color.copy(alpha = 0.8f),
+                color.copy(alpha = 0.5f),
+                Color.Transparent
+            )
+
+            Gravity.BOTTOM -> listOf(
+                Color.Transparent,
+                color.copy(alpha = 0.5f),
+                color.copy(alpha = 0.8f),
+                color,
+                color,
+                color,
+            )
+
+            else -> listOf(color)
+        }
+    )
+
 }

+ 160 - 7
src/main/kotlin/eu/mjdev/desktop/extensions/Compose.kt

@@ -2,13 +2,27 @@
 
 package eu.mjdev.desktop.extensions
 
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.relocation.BringIntoViewResponder
+import androidx.compose.foundation.relocation.bringIntoViewResponder
 import androidx.compose.material.ButtonDefaults
 import androidx.compose.runtime.*
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.FocusState
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.*
 import androidx.compose.ui.input.pointer.*
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.debugInspectorInfo
 import androidx.compose.ui.unit.*
 import coil3.ImageLoader
 import coil3.PlatformContext
@@ -16,7 +30,11 @@ import coil3.compose.LocalPlatformContext
 import coil3.memory.MemoryCache
 import coil3.request.CachePolicy
 import coil3.svg.SvgDecoder
+import eu.mjdev.desktop.helpers.compose.FocusHelper
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
 object Compose {
 
@@ -43,7 +61,7 @@ object Compose {
     }
 
     fun Color.rgbaToHex(): String {
-        val alpha : Int= (alpha * 255).toInt()
+        val alpha: Int = (alpha * 255).toInt()
         val red = (red * 255).toInt()
         val green = (green * 255).toInt()
         val blue = (blue * 255).toInt()
@@ -82,13 +100,9 @@ object Compose {
 
     @Composable
     fun <T> textFrom(text: T?): String = when (text) {
-        null -> ""
-        is Unit -> ""
-        is Int -> text.toString()
-        is String -> text
         is MutableState<*> -> textFrom(text.value)
         else -> text.toString()
-    }
+    }.toString()
 
     @Composable
     fun ButtonDefaults.noElevation() =
@@ -174,6 +188,19 @@ object Compose {
         onEvent = onEvent
     )
 
+    @Suppress("UNUSED_PARAMETER")
+    fun Modifier.onMouseLongPress(
+        pass: PointerEventPass = PointerEventPass.Main,
+        onEvent: AwaitPointerEventScope.(event: PointerEvent) -> Unit
+    ): Modifier {
+//        return onPointerEvent(
+//            eventType = PointerEventType.Press,
+//            pass = pass,
+//            onEvent = onEvent
+//        )
+        return this
+    }
+
     @OptIn(ExperimentalComposeUiApi::class)
     val AwaitPointerEventScope.isPrimary
         get() = currentEvent.button.isPrimary
@@ -188,12 +215,138 @@ object Compose {
         if (isPrimary) block()
     }
 
-    fun AwaitPointerEventScope.onRigntClick(
+    fun AwaitPointerEventScope.onRightClick(
         block: AwaitPointerEventScope.() -> Unit
     ) {
         if (isSecondary) block()
     }
 
+    fun Modifier.onKey(
+        keyCode: Key,
+        action: KeyEventType = KeyEventType.KeyDown,
+        block: () -> Unit
+    ): Modifier = this then onKeyEvent { ev ->
+        if (ev.type == action) {
+            if (ev.key == keyCode) {
+                block()
+                true
+            } else false
+        } else false
+    }
+
+    @Composable
+    fun Modifier.requestFocusOnTouch(
+        focusRequester: FocusRequester,
+        requestFocus: Boolean = true,
+        scope: CoroutineScope = rememberCoroutineScope(),
+        onTouch: (() -> Unit)? = null
+    ): Modifier = this then focusRequester(
+        focusRequester
+    ).pointerInput(this) {
+        detectTapGestures(
+            onTap = {
+                try {
+                    scope.launch {
+                        onTouch?.invoke()
+                        if (requestFocus) {
+                            focusRequester.requestFocus()
+                        }
+                    }
+                } catch (e: Throwable) {
+                    e.printStackTrace()
+                }
+            }
+        )
+    }
+
+    @OptIn(ExperimentalFoundationApi::class)
+    fun Modifier.bringIntoViewIfChildrenAreFocused(): Modifier = composed(
+        inspectorInfo = debugInspectorInfo { name = "bringIntoViewIfChildrenAreFocused" },
+        factory = {
+            var myRect: Rect = Rect.Zero
+            this.onSizeChanged {
+                myRect = Rect(Offset.Zero, Offset(it.width.toFloat(), it.height.toFloat()))
+            }.bringIntoViewResponder(
+                remember {
+                    object : BringIntoViewResponder {
+                        @ExperimentalFoundationApi
+                        override fun calculateRectForParent(localRect: Rect): Rect = myRect
+
+                        @ExperimentalFoundationApi
+                        override suspend fun bringChildIntoView(localRect: () -> Rect?) {
+                        }
+                    }
+                }
+            )
+        }
+    )
+
+    @Composable
+    fun rememberFocusRequester(
+        key: Any? = Unit
+    ) = remember(key) { FocusRequester() }
+
+    @Composable
+    fun <T> produceStateInCoroutine(
+        initialValue: T,
+        key: Any? = null,
+        block: suspend () -> T
+    ) = produceState(
+        initialValue = initialValue,
+        key1 = key
+    ) {
+        withContext(Dispatchers.IO) {
+            value = block()
+        }
+    }
+
+    @Composable
+    fun <T> rememberDerivedState(
+        key: Any? = Unit,
+        initialValue: T,
+        block: suspend () -> T
+    ) = produceStateInCoroutine(initialValue, key, block)
+
+    @Composable
+    fun <T> rememberDerivedStateOf(
+        function: () -> T,
+        key: Any? = function
+    ): State<T> = remember(key) { derivedStateOf(function) }
+
+    @Composable
+    fun rememberFocusState(
+        initial: FocusState = FocusHelper(false)
+    ) = remember(initial) {
+        mutableStateOf(initial)
+    }
+
+    @Composable
+    fun rememberFocusState(
+        key: Any?,
+        initial: FocusState = FocusHelper(false),
+    ) = remember(key) {
+        mutableStateOf(initial)
+    }
+
+    fun Modifier.focusState(
+        focusState: MutableState<FocusState>
+    ): Modifier = onFocusChanged { state ->
+        focusState.value = state
+    }
+
+    val MutableState<FocusState>.isFocused
+        get() = value.isFocused || value.hasFocus
+
+    val MutableState<FocusState>.isNotFocused
+        get() = !isFocused
+
+    @Suppress("EXTENSION_SHADOWED_BY_MEMBER")
+    val FocusState.isFocused
+        get() = this.isFocused || this.hasFocus
+
+    val FocusState.isNotFocused
+        get() = !isFocused
+
 //    Toolkit.getDefaultToolkit().addAWTEventListener({ event ->
 //        }, AWTEvent.MOUSE_EVENT_MASK or AWTEvent.FOCUS_EVENT_MASK
 //    )

+ 5 - 0
src/main/kotlin/eu/mjdev/desktop/extensions/Custom.kt

@@ -1,6 +1,7 @@
 package eu.mjdev.desktop.extensions
 
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.snapshots.SnapshotStateList
 import androidx.compose.ui.graphics.NativePaint
@@ -143,4 +144,8 @@ object Custom {
 //    operator fun PaddingValues.minus(dp: Dp) =
 //        copy(width = width - dp, height = height - dp)
 
+    fun MutableState<Boolean>.toggle() {
+        value = !value
+    }
+
 }

+ 19 - 0
src/main/kotlin/eu/mjdev/desktop/helpers/compose/FocusHelper.kt

@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.helpers.compose
+
+import androidx.compose.ui.focus.FocusState
+
+class FocusHelper(
+    private var focused: Boolean
+) : FocusState {
+    override val hasFocus: Boolean get() = focused
+    override val isCaptured: Boolean get() = focused
+    override val isFocused: Boolean get() = focused
+}

+ 14 - 0
src/main/kotlin/eu/mjdev/desktop/helpers/compose/Gravity.kt

@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.helpers.compose
+
+@Suppress("unused")
+enum class Gravity {
+    TOP, START, END, BOTTOM
+}

+ 144 - 0
src/main/kotlin/eu/mjdev/desktop/helpers/compose/SurfaceBorder.kt

@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) Milan Jurkulák 2024.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package eu.mjdev.desktop.helpers.compose
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawOutline
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.inset
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.debugInspectorInfo
+import eu.mjdev.desktop.components.surface.base.Border
+import eu.mjdev.desktop.components.surface.base.ShapeTokens
+import eu.mjdev.desktop.components.surface.base.SurfaceShapeOutlineCache
+
+internal fun Modifier.surfaceBorder(
+    shape: Shape,
+    border: Border,
+): Modifier {
+    return then(
+        SurfaceBorderElement(
+            shape = shape,
+            border = border,
+            inspectorInfo =
+                debugInspectorInfo {
+                    name = "tvSurfaceBorder"
+                    properties["shape"] = shape
+                    properties["border"] = border
+                }
+        )
+    )
+}
+
+private class SurfaceBorderElement(
+    private val shape: Shape,
+    private val border: Border,
+    private val inspectorInfo: InspectorInfo.() -> Unit
+) : ModifierNodeElement<SurfaceBorderNode>() {
+    override fun create(): SurfaceBorderNode {
+        return SurfaceBorderNode(
+            shape = shape,
+            border = border,
+        )
+    }
+
+    override fun update(node: SurfaceBorderNode) {
+        node.reactToUpdates(
+            newShape = shape,
+            newBorder = border,
+        )
+    }
+
+    override fun InspectorInfo.inspectableProperties() {
+        inspectorInfo()
+    }
+
+    override fun hashCode(): Int {
+        var result = shape.hashCode()
+        result = 31 * result + border.hashCode()
+        return result
+    }
+
+    override fun equals(other: Any?): Boolean {
+        val otherTyped = other as? SurfaceBorderElement ?: return false
+        return shape == otherTyped.shape && border == otherTyped.border
+    }
+}
+
+private class SurfaceBorderNode(
+    private var shape: Shape,
+    private var border: Border,
+) : DrawModifierNode, Modifier.Node() {
+
+    private var shapeOutlineCache: SurfaceShapeOutlineCache? = null
+    private var outlineStrokeCache: OutlineStrokeCache? = null
+
+    fun reactToUpdates(
+        newShape: Shape,
+        newBorder: Border,
+    ) {
+        shape = newShape
+        border = newBorder
+    }
+
+    override fun ContentDrawScope.draw() {
+        drawContent()
+        val borderStroke = border.border
+        val borderShape = if (border.shape == ShapeTokens.BorderDefaultShape) shape else border.shape
+        if (shapeOutlineCache == null) {
+            shapeOutlineCache =
+                SurfaceShapeOutlineCache(
+                    shape = borderShape,
+                    size = size,
+                    layoutDirection = layoutDirection,
+                    density = this
+                )
+        }
+        if (outlineStrokeCache == null) {
+            outlineStrokeCache = OutlineStrokeCache(widthPx = borderStroke.width.toPx())
+        }
+        inset(inset = -border.inset.toPx()) {
+            val shapeOutline =
+                shapeOutlineCache!!.updatedOutline(
+                    shape = borderShape,
+                    size = size,
+                    layoutDirection = layoutDirection,
+                    density = this
+                )
+            val outlineStroke = outlineStrokeCache!!.updatedOutlineStroke(widthPx = borderStroke.width.toPx())
+            drawOutline(
+                outline = shapeOutline,
+                brush = borderStroke.brush,
+                alpha = 1f,
+                style = outlineStroke
+            )
+        }
+    }
+}
+
+private class OutlineStrokeCache(private var widthPx: Float) {
+    private var outlineStroke: Stroke? = null
+
+    fun updatedOutlineStroke(widthPx: Float): Stroke {
+        if (outlineStroke == null || this.widthPx != widthPx) {
+            this.widthPx = widthPx
+            createOutlineStroke()
+        }
+        return outlineStroke!!
+    }
+
+    private fun createOutlineStroke() {
+        outlineStroke = Stroke(width = widthPx, cap = StrokeCap.Round)
+    }
+}