diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/modifier/Activation.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/modifier/Activation.kt index ef65a0fb28798..7948eb52fd193 100644 --- a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/modifier/Activation.kt +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/modifier/Activation.kt @@ -1,26 +1,19 @@ package org.jetbrains.jewel.foundation.modifier import androidx.compose.foundation.focusGroup -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.structuralEqualityPolicy import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.modifier.ModifierLocalConsumer -import androidx.compose.ui.modifier.ModifierLocalProvider -import androidx.compose.ui.modifier.ModifierLocalReadScope +import androidx.compose.ui.focus.FocusEventModifierNode +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.modifier.ModifierLocalModifierNode import androidx.compose.ui.modifier.ProvidableModifierLocal +import androidx.compose.ui.modifier.modifierLocalMapOf import androidx.compose.ui.modifier.modifierLocalOf -import androidx.compose.ui.modifier.modifierLocalProvider +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.ObserverModifierNode +import androidx.compose.ui.node.observeReads import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.platform.InspectorValueInfo -import androidx.compose.ui.platform.debugInspectorInfo import java.awt.Component import java.awt.Window import java.awt.event.FocusEvent @@ -28,133 +21,229 @@ import java.awt.event.FocusListener import java.awt.event.WindowAdapter import java.awt.event.WindowEvent -@Suppress("ModifierComposed") // To fix in JEWEL-921 -public fun Modifier.trackWindowActivation(window: Window): Modifier = - composed( - debugInspectorInfo { - name = "activateRoot" - properties["window"] = window +/** + * Tracks the activation state of the provided AWT [Window]. + * + * This modifier listens to the window's "activated" and "deactivated" events. When the window is active, it provides + * `true` to the [ModifierLocalActivated] local, allowing child modifiers (like [onActivated]) to react to the window's + * state. + * + * @param window The AWT Window to observe. + */ +@Stable +public fun Modifier.trackWindowActivation(window: Window): Modifier = this then TrackWindowActivationModifier(window) + +/** + * Tracks the focus/activation state of a native AWT [Component]. + * + * It listens to the component's focus events and provides the activation state to the Compose hierarchy. + * + * @param awtParent The parent AWT Component to observe for focus events. + */ +@Stable +public fun Modifier.trackComponentActivation(awtParent: Component): Modifier = + this then TrackComponentActivationModifier(awtParent) + +/** + * Tracks activation based on the focus state of this modifier's children. + * + * This modifier applies a [focusGroup] to its content. It considers itself "activated" if the parent is activated AND + * any child within this focus group currently holds focus. + */ +@Stable public fun Modifier.trackActivation(): Modifier = this.focusGroup().then(TrackActivationModifier) + +/** + * A callback modifier that triggers whenever the activation state changes. + * + * This modifier consumes the value provided by [ModifierLocalActivated] (set by [trackWindowActivation], + * [trackComponentActivation] or [trackActivation]). When that value changes, the [onChanged] lambda is invoked. + * + * @param enabled Whether this callback is active. If `false`, the modifier is effectively a no-op. + * @param onChanged A lambda called with the new activation state (`true` for active, `false` for inactive). + */ +@Stable public fun Modifier.onActivated(enabled: Boolean = true, onChanged: (Boolean) -> Unit): Modifier = + if (enabled) { + this then ActivateChangedModifier(onChanged) + } else { + this + } + +@Immutable +private data class TrackWindowActivationModifier(val window: Window) : + ModifierNodeElement() { + override fun create() = TrackWindowActivationNode(window) + + override fun update(node: TrackWindowActivationNode) { + node.update(window) + } + + override fun InspectorInfo.inspectableProperties() { + name = "trackWindowActivation" + properties["window"] = window + } +} + +private class TrackWindowActivationNode(var window: Window) : Modifier.Node(), ModifierLocalModifierNode { + override val providedValues = modifierLocalMapOf(ModifierLocalActivated to false) + + private val listener = + object : WindowAdapter() { + override fun windowActivated(e: WindowEvent?) { + provide(ModifierLocalActivated, true) + } + + override fun windowDeactivated(e: WindowEvent?) { + provide(ModifierLocalActivated, false) + } } - ) { - var parentActivated by remember { mutableStateOf(false) } - - DisposableEffect(window) { - val listener = - object : WindowAdapter() { - override fun windowActivated(e: WindowEvent?) { - parentActivated = true - } - - override fun windowDeactivated(e: WindowEvent?) { - parentActivated = false - } - } + + override fun onAttach() { + super.onAttach() + window.addWindowListener(listener) + provide(ModifierLocalActivated, window.isActive) + } + + override fun onDetach() { + super.onDetach() + window.removeWindowListener(listener) + } + + fun update(newWindow: Window) { + if (window != newWindow) { + window.removeWindowListener(listener) + window = newWindow window.addWindowListener(listener) - onDispose { window.removeWindowListener(listener) } + provide(ModifierLocalActivated, window.isActive) } - Modifier.modifierLocalProvider(ModifierLocalActivated) { parentActivated } } +} -@Suppress("ModifierComposed") // To fix in JEWEL-921 -public fun Modifier.trackComponentActivation(awtParent: Component): Modifier = - composed( - debugInspectorInfo { - name = "activateRoot" - properties["parent"] = awtParent - } - ) { - var parentActivated by remember { mutableStateOf(false) } - - DisposableEffect(awtParent) { - val listener = - object : FocusListener { - override fun focusGained(e: FocusEvent?) { - parentActivated = true - } - - override fun focusLost(e: FocusEvent?) { - parentActivated = false - } - } - awtParent.addFocusListener(listener) - onDispose { awtParent.removeFocusListener(listener) } - } +@Immutable +private data object TrackActivationModifier : ModifierNodeElement() { + override fun create() = TrackActivationNode() - Modifier.modifierLocalProvider(ModifierLocalActivated) { parentActivated } + override fun update(node: TrackActivationNode) { + // no-op } -@Suppress("ModifierComposed") // To fix in JEWEL-921 -@Stable -public fun Modifier.trackActivation(): Modifier = - composed(debugInspectorInfo { name = "trackActivation" }) { - val activatedModifierLocal = remember { ActivatedModifierLocal() } - Modifier.focusGroup() - .onFocusChanged { - if (it.hasFocus) { - activatedModifierLocal.childGainedFocus() - } else { - activatedModifierLocal.childLostFocus() - } - } - .then(activatedModifierLocal) + override fun InspectorInfo.inspectableProperties() { + name = "trackActivation" } +} -private class ActivatedModifierLocal : ModifierLocalProvider, ModifierLocalConsumer { - private var parentActivated: Boolean by mutableStateOf(false) +private class TrackActivationNode : + Modifier.Node(), FocusEventModifierNode, ModifierLocalModifierNode, ObserverModifierNode { + override val providedValues = modifierLocalMapOf(ModifierLocalActivated to false) - private var hasFocus: Boolean by mutableStateOf(false) + private var parentActivated = false + private var hasFocus = false - override fun onModifierLocalsUpdated(scope: ModifierLocalReadScope) { - with(scope) { parentActivated = ModifierLocalActivated.current } + override fun onAttach() { + super.onAttach() + observeReads { fetchParentActivation() } } - override val key: ProvidableModifierLocal = ModifierLocalActivated - override val value: Boolean by derivedStateOf(structuralEqualityPolicy()) { parentActivated && hasFocus } + override fun onObservedReadsChanged() { + observeReads { fetchParentActivation() } + } + + override fun onFocusEvent(focusState: FocusState) { + if (hasFocus != focusState.hasFocus) { + hasFocus = focusState.hasFocus + updateProvidedValue() + } + } - fun childLostFocus() { - hasFocus = false + private fun fetchParentActivation() { + parentActivated = ModifierLocalActivated.current + updateProvidedValue() } - fun childGainedFocus() { - hasFocus = true + private fun updateProvidedValue() { + provide(ModifierLocalActivated, parentActivated && hasFocus) } } -public val ModifierLocalActivated: ProvidableModifierLocal = modifierLocalOf { false } +@Immutable +private data class TrackComponentActivationModifier(val awtParent: Component) : + ModifierNodeElement() { + override fun create() = TrackComponentActivationNode(awtParent) + + override fun update(node: TrackComponentActivationNode) { + node.update(awtParent) + } -public fun Modifier.onActivated(enabled: Boolean = true, onChanged: (Boolean) -> Unit): Modifier = - this then - if (enabled) { - ActivateChangedModifierElement( - onChanged, - debugInspectorInfo { - name = "onActivated" - properties["onChanged"] = onChanged - }, - ) - } else { - Modifier + override fun InspectorInfo.inspectableProperties() { + name = "trackComponentActivation" + properties["awtParent"] = awtParent + } +} + +private class TrackComponentActivationNode(var awtParent: Component) : Modifier.Node(), ModifierLocalModifierNode { + override val providedValues = modifierLocalMapOf(ModifierLocalActivated to false) + + private val listener = + object : FocusListener { + override fun focusGained(e: FocusEvent?) { + provide(ModifierLocalActivated, true) + } + + override fun focusLost(e: FocusEvent?) { + provide(ModifierLocalActivated, false) + } } -private class ActivateChangedModifierElement( - private val onChanged: (Boolean) -> Unit, - inspectorInfo: InspectorInfo.() -> Unit, -) : ModifierLocalConsumer, InspectorValueInfo(inspectorInfo) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ActivateChangedModifierElement) return false + override fun onAttach() { + super.onAttach() + awtParent.addFocusListener(listener) + provide(ModifierLocalActivated, awtParent.hasFocus()) + } + + override fun onDetach() { + super.onDetach() + awtParent.removeFocusListener(listener) + } + + fun update(newAwtParent: Component) { + if (awtParent != newAwtParent) { + awtParent.removeFocusListener(listener) + awtParent = newAwtParent + awtParent.addFocusListener(listener) + provide(ModifierLocalActivated, awtParent.hasFocus()) + } + } +} - if (onChanged != other.onChanged) return false +@Immutable +private data class ActivateChangedModifier(val onChanged: (Boolean) -> Unit) : + ModifierNodeElement() { + override fun create() = ActivateChangedNode(onChanged) - return true + override fun update(node: ActivateChangedNode) { + node.onChanged = onChanged } - override fun hashCode(): Int = onChanged.hashCode() + override fun InspectorInfo.inspectableProperties() { + name = "onActivated" + properties["onChanged"] = onChanged + } +} +private class ActivateChangedNode(var onChanged: (Boolean) -> Unit) : + Modifier.Node(), ModifierLocalModifierNode, ObserverModifierNode { private var currentActivated = false - override fun onModifierLocalsUpdated(scope: ModifierLocalReadScope) { - with(scope) { + override fun onAttach() { + super.onAttach() + fetchActivatedValue() + } + + override fun onObservedReadsChanged() { + fetchActivatedValue() + } + + private fun fetchActivatedValue() { + observeReads { val activated = ModifierLocalActivated.current if (activated != currentActivated) { currentActivated = activated @@ -163,3 +252,5 @@ private class ActivateChangedModifierElement( } } } + +public val ModifierLocalActivated: ProvidableModifierLocal = modifierLocalOf { false } diff --git a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/modifier/Border.kt b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/modifier/Border.kt index 819dece856c10..0f013a41c9de8 100644 --- a/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/modifier/Border.kt +++ b/platform/jewel/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/modifier/Border.kt @@ -2,12 +2,9 @@ package org.jetbrains.jewel.foundation.modifier import androidx.compose.foundation.border import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.remember +import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.draw.CacheDrawScope -import androidx.compose.ui.draw.DrawResult -import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.CacheDrawModifierNode import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush @@ -28,8 +25,12 @@ import androidx.compose.ui.graphics.drawscope.CanvasDrawScope import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.translate -import androidx.compose.ui.node.Ref -import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.SemanticsModifierNode +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -45,6 +46,15 @@ import org.jetbrains.jewel.foundation.shrink public typealias DrawScopeStroke = androidx.compose.ui.graphics.drawscope.Stroke +/** + * Modifies the element to add a border defined by a [Stroke] object. + * + * This convenience method allows applying different types of borders (Solid, Brush, or None) dynamically based on the + * provided [Stroke] configuration. + * + * @param stroke The definition of the stroke, which determines the border's width, alignment, and visual style. + * @param shape The [Shape] of the border. + */ public fun Modifier.border(stroke: Stroke, shape: Shape): Modifier = when (stroke) { is Stroke.None -> this @@ -59,6 +69,17 @@ public fun Modifier.border(stroke: Stroke, shape: Shape): Modifier = ) } +/** + * Modifies the element to add a solid color border with specific alignment and expansion. + * + * @param alignment Determines where the border is drawn relative to the bounds: [Stroke.Alignment.Inside], + * [Stroke.Alignment.Center], or [Stroke.Alignment.Outside]. + * @param width The thickness of the border. + * @param color The [Color] to fill the border with. + * @param shape The [Shape] of the border. Defaults to [RectangleShape]. + * @param expand An optional value to expand (or shrink) the border's path relative to the content bounds. Defaults to + * [Dp.Unspecified], which applies no expansion. + */ public fun Modifier.border( alignment: Stroke.Alignment, width: Dp, @@ -67,6 +88,22 @@ public fun Modifier.border( expand: Dp = Dp.Unspecified, ): Modifier = border(alignment, width, SolidColor(color), shape, expand) +/** + * Modifies the element to add a border using a [Brush] with specific alignment and expansion. + * + * **Optimization Note:** If [alignment] is [Stroke.Alignment.Inside] and [expand] is [Dp.Unspecified], this modifier + * delegates to the native Jetpack Compose [androidx.compose.foundation.border]. This provides the most native + * experience and performance for standard inside borders. In all other cases (Center/Outside alignment or custom + * expansion), a custom drawing modifier is used. + * + * @param alignment Determines where the border is drawn relative to the bounds: [Stroke.Alignment.Inside], + * [Stroke.Alignment.Center], or [Stroke.Alignment.Outside]. + * @param width The thickness of the border. + * @param brush The [Brush] to fill the border with (e.g., a Gradient). + * @param shape The [Shape] of the border. Defaults to [RectangleShape]. + * @param expand An optional value to expand (or shrink) the border's path relative to the content bounds. Defaults to + * [Dp.Unspecified], which applies no expansion. + */ public fun Modifier.border( alignment: Stroke.Alignment, width: Dp, @@ -83,88 +120,296 @@ public fun Modifier.border( drawBorderWithAlignment(alignment, width, brush, shape, expand) } -@Suppress("ModifierComposed") // To fix in JEWEL-921 private fun Modifier.drawBorderWithAlignment( alignment: Stroke.Alignment, width: Dp, brush: Brush, shape: Shape, expand: Dp, -): Modifier = - composed( - factory = { - val borderCacheRef = remember { Ref() } - this then - Modifier.drawWithCache { - onDrawWithContent { - drawContent() - - val strokeWidthPx = - min(if (width == Dp.Hairline) 1f else width.toPx(), size.minDimension / 2).coerceAtLeast(1f) - - val expandWidthPx = expand.takeOrElse { 0.dp }.toPx() - - drawBorderInner( - shape, - borderCacheRef, - alignment, - brush, - strokeWidthPx, - expandWidthPx, - cacheDrawScope = this@drawWithCache, +): Modifier = this then BorderWithAlignmentModifier(alignment, width, brush, shape, expand) + +@Immutable +private data class BorderWithAlignmentModifier( + val alignment: Stroke.Alignment, + val width: Dp, + val brush: Brush, + val shape: Shape, + val expand: Dp, +) : ModifierNodeElement() { + override fun create() = BorderWithAlignmentNode(alignment, width, brush, shape, expand) + + override fun update(node: BorderWithAlignmentNode) { + node.alignment = alignment + node.width = width + node.brush = brush + node.shape = shape + node.expand = expand + } + + override fun InspectorInfo.inspectableProperties() { + name = "drawBorderWithAlignment" + properties["alignment"] = alignment + properties["width"] = width + if (brush is SolidColor) { + properties["color"] = brush.value + value = brush.value + } else { + properties["brush"] = brush + } + properties["shape"] = shape + properties["expand"] = expand + } +} + +private class BorderWithAlignmentNode( + alignmentParameter: Stroke.Alignment, + widthParameter: Dp, + brushParameter: Brush, + shapeParameter: Shape, + expandParameter: Dp, +) : DelegatingNode(), SemanticsModifierNode { + override val shouldAutoInvalidate: Boolean = false + override val isImportantForBounds = false + + private val borderCache = BorderCache() + + var alignment = alignmentParameter + set(value) { + if (field != value) { + field = value + drawWithCacheModifierNode.invalidateDrawCache() + } + } + + var width = widthParameter + set(value) { + if (field != value) { + field = value + drawWithCacheModifierNode.invalidateDrawCache() + } + } + + var brush = brushParameter + set(value) { + if (field != value) { + field = value + drawWithCacheModifierNode.invalidateDrawCache() + } + } + + var shape = shapeParameter + set(value) { + if (field != value) { + field = value + drawWithCacheModifierNode.invalidateDrawCache() + } + } + + var expand = expandParameter + set(value) { + if (field != value) { + field = value + drawWithCacheModifierNode.invalidateDrawCache() + } + } + + private val drawWithCacheModifierNode = + delegate( + CacheDrawModifierNode { + onDrawWithContent { + drawContent() + + val strokeWidthPx = + min(if (width == Dp.Hairline) 1f else width.toPx(), size.minDimension / 2).coerceAtLeast(1f) + val expandWidthPx = expand.takeOrElse { 0.dp }.toPx() + + drawBorderInner(shape, borderCache, alignment, brush, strokeWidthPx, expandWidthPx) + } + } + ) + + private fun ContentDrawScope.drawBorderInner( + shape: Shape, + borderCache: BorderCache, + alignment: Stroke.Alignment, + brush: Brush, + strokeWidthPx: Float, + expandWidthPx: Float, + ) { + when (val outline = shape.createOutline(size, layoutDirection, this)) { + is Outline.Rectangle -> { + when (shape) { + is RoundedCornerShape -> + drawRoundedBorder( + borderCache = borderCache, + alignment = alignment, + outline = Outline.Rounded(RoundRect(outline.rect)), + brush = brush, + strokeWidthPx = strokeWidthPx, + expandWidthPx = expandWidthPx, ) - } + + else -> drawRectBorder(alignment, outline, brush, strokeWidthPx, expandWidthPx) + } + } + + is Outline.Rounded -> + drawRoundedBorder(borderCache, alignment, outline, brush, strokeWidthPx, expandWidthPx) + + is Outline.Generic -> + drawGenericBorder(borderCache, alignment, outline, brush, strokeWidthPx, expandWidthPx) + } + } + + private fun ContentDrawScope.drawRectBorder( + alignment: Stroke.Alignment, + outline: Outline.Rectangle, + brush: Brush, + strokeWidthPx: Float, + expandWidthPx: Float, + ) { + val rect = + when (alignment) { + Stroke.Alignment.Inside -> outline.rect.inflate(expandWidthPx - strokeWidthPx / 2f) + Stroke.Alignment.Center -> outline.rect.inflate(expandWidthPx) + Stroke.Alignment.Outside -> outline.rect.inflate(expandWidthPx + strokeWidthPx / 2f) + } + + drawRect(brush, rect.topLeft, rect.size, style = DrawScopeStroke(strokeWidthPx)) + } + + private fun ContentDrawScope.drawRoundedBorder( + borderCache: BorderCache, + alignment: Stroke.Alignment, + outline: Outline.Rounded, + brush: Brush, + strokeWidthPx: Float, + expandWidthPx: Float, + ) { + val halfStroke = strokeWidthPx / 2f + val roundRect = + when (alignment) { + // Inside: Shift inward by half a stroke so the outer edge matches the boundary + Stroke.Alignment.Inside -> outline.roundRect.grow(expandWidthPx - halfStroke) + // Center: Just apply the expansion + Stroke.Alignment.Center -> outline.roundRect.grow(expandWidthPx) + // Outside: Shift outward by half a stroke + Stroke.Alignment.Outside -> outline.roundRect.grow(expandWidthPx + halfStroke) + } + + if (roundRect.hasAtLeastOneNonRoundedCorner()) { + val borderPath = + borderCache.obtainPath().apply { + reset() + fillType = PathFillType.EvenOdd + addRoundRect(roundRect.shrink(halfStroke)) + addRoundRect(roundRect.grow(halfStroke)) } - }, - inspectorInfo = - debugInspectorInfo { - name = "border" - properties["alignment"] = alignment - properties["width"] = width + drawPath(borderPath, brush) + } else { + drawOutline(Outline.Rounded(roundRect), brush, style = DrawScopeStroke(strokeWidthPx)) + } + } + + private fun ContentDrawScope.drawGenericBorder( + borderCache: BorderCache, + alignment: Stroke.Alignment, + outline: Outline.Generic, + brush: Brush, + strokeWidth: Float, + expandWidthPx: Float, + ) { + // Get the outer border and inner border inflate delta, + // the part between inner and outer is the border that + // needs to be drawn + val (outer, inner) = + when (alignment) { + // Inside border means the outer border inflate delta is 0 + Stroke.Alignment.Inside -> 0f + expandWidthPx to -strokeWidth + expandWidthPx + Stroke.Alignment.Center -> strokeWidth / 2f + expandWidthPx to -strokeWidth / 2f + expandWidthPx + Stroke.Alignment.Outside -> strokeWidth + expandWidthPx to 0f + expandWidthPx + } + + when (outer) { + inner -> return + // Simply draw the outline when abs(outer) and abs(inner) are the same + -inner -> drawOutline(outline, brush, style = DrawScopeStroke(outer * 2f)) + else -> { + val config: ImageBitmapConfig + val colorFilter: ColorFilter? if (brush is SolidColor) { - properties["color"] = brush.value - value = brush.value + config = ImageBitmapConfig.Alpha8 + colorFilter = ColorFilter.tint(brush.value) } else { - properties["brush"] = brush + config = ImageBitmapConfig.Argb8888 + colorFilter = null } - properties["shape"] = shape - properties["expand"] = expand - }, - ) + val pathBounds = outline.path.getBounds().inflate(outer) + val outerMaskPath = + borderCache.obtainPath().apply { + reset() + addRect(pathBounds) + op(this, outline.path, PathOperation.Difference) + } + val cacheImageBitmap: ImageBitmap + val pathBoundsSize = IntSize(ceil(pathBounds.width).toInt(), ceil(pathBounds.height).toInt()) + + with(borderCache) { + cacheImageBitmap = + drawBorderCache(pathBoundsSize, config) { + translate(-pathBounds.left, -pathBounds.top) { + if (inner < 0f && outer > 0f) { + TODO("Not implemented for generic border") + } -private fun ContentDrawScope.drawBorderInner( - shape: Shape, - borderCacheRef: Ref, - alignment: Stroke.Alignment, - brush: Brush, - strokeWidthPx: Float, - expandWidthPx: Float, - cacheDrawScope: CacheDrawScope, -) { - when (val outline = shape.createOutline(size, layoutDirection, cacheDrawScope)) { - is Outline.Rectangle -> { - when (shape) { - is RoundedCornerShape -> - drawRoundedBorder( - borderCacheRef = borderCacheRef, - alignment = alignment, - outline = Outline.Rounded(RoundRect(outline.rect)), - brush = brush, - strokeWidthPx = strokeWidthPx, - expandWidthPx = expandWidthPx, - ) - - else -> drawRectBorder(borderCacheRef, alignment, outline, brush, strokeWidthPx, expandWidthPx) + if (outer > 0f && inner >= 0f) { + drawPath(outline.path, brush, style = DrawScopeStroke(outer * 2f)) + + if (inner > 0f) { + drawPath( + path = outline.path, + brush = brush, + blendMode = BlendMode.Clear, + style = DrawScopeStroke(inner * 2f), + ) + } + + drawPath(path = outline.path, brush = brush, blendMode = BlendMode.Clear) + } + + if (outer <= 0f && inner < 0f) { + drawPath(path = outline.path, brush = brush, style = DrawScopeStroke(-inner * 2f)) + + if (outer < 0f) { + drawPath( + path = outline.path, + brush = brush, + blendMode = BlendMode.Clear, + style = DrawScopeStroke(-outer * 2f), + ) + } + + drawPath(path = outerMaskPath, brush = brush, blendMode = BlendMode.Clear) + } + } + } + } + + translate(pathBounds.left, pathBounds.top) { + drawImage(cacheImageBitmap, srcSize = pathBoundsSize, colorFilter = colorFilter) + } } } + } - is Outline.Rounded -> drawRoundedBorder(borderCacheRef, alignment, outline, brush, strokeWidthPx, expandWidthPx) - - is Outline.Generic -> - cacheDrawScope.drawGenericBorder(borderCacheRef, alignment, outline, brush, strokeWidthPx, expandWidthPx) + override fun SemanticsPropertyReceiver.applySemantics() { + shape = this@BorderWithAlignmentNode.shape } } +/** + * Helper object that handles lazily allocating and re-using objects to render the border into an offscreen ImageBitmap + */ private class BorderCache( private var imageBitmap: ImageBitmap? = null, private var canvas: Canvas? = null, @@ -206,152 +451,3 @@ private class BorderCache( fun obtainPath(): Path = borderPath ?: Path().also { borderPath = it } } - -private fun Ref.obtain(): BorderCache = this.value ?: BorderCache().also { value = it } - -@Suppress("UNUSED_PARAMETER") -private fun ContentDrawScope.drawRectBorder( - borderCacheRef: Ref, - alignment: Stroke.Alignment, - outline: Outline.Rectangle, - brush: Brush, - strokeWidthPx: Float, - expandWidthPx: Float, -) { - val rect = - when (alignment) { - Stroke.Alignment.Inside -> outline.rect.inflate(expandWidthPx - strokeWidthPx / 2f) - Stroke.Alignment.Center -> outline.rect.inflate(expandWidthPx) - Stroke.Alignment.Outside -> outline.rect.inflate(expandWidthPx + strokeWidthPx / 2f) - } - - drawRect(brush, rect.topLeft, rect.size, style = DrawScopeStroke(strokeWidthPx)) -} - -private fun ContentDrawScope.drawRoundedBorder( - borderCacheRef: Ref, - alignment: Stroke.Alignment, - outline: Outline.Rounded, - brush: Brush, - strokeWidthPx: Float, - expandWidthPx: Float, -) { - val roundRect = - when (alignment) { - Stroke.Alignment.Inside -> outline.roundRect.grow(expandWidthPx - strokeWidthPx / 2f) - Stroke.Alignment.Center -> outline.roundRect.grow(expandWidthPx) - Stroke.Alignment.Outside -> outline.roundRect.grow(expandWidthPx + strokeWidthPx / 2f) - } - - if (roundRect.hasAtLeastOneNonRoundedCorner()) { - // Note: why do we need this? The Outline API can handle it just fine - val cache = borderCacheRef.obtain() - val borderPath = - cache.obtainPath().apply { - reset() - fillType = PathFillType.EvenOdd - addRoundRect(roundRect.shrink(strokeWidthPx / 2f)) - addRoundRect(roundRect.grow(strokeWidthPx / 2f)) - } - drawPath(borderPath, brush) - } else { - drawOutline(Outline.Rounded(roundRect), brush, style = DrawScopeStroke(strokeWidthPx)) - } -} - -private fun CacheDrawScope.drawGenericBorder( - borderCacheRef: Ref, - alignment: Stroke.Alignment, - outline: Outline.Generic, - brush: Brush, - strokeWidth: Float, - expandWidthPx: Float, -): DrawResult = onDrawWithContent { - drawContent() - - // Get the outer border and inner border inflate delta, - // the part between inner and outer is the border that - // needs to be drawn - val (outer, inner) = - when (alignment) { - // Inside border means the outer border inflate delta is 0 - Stroke.Alignment.Inside -> 0f + expandWidthPx to -strokeWidth + expandWidthPx - Stroke.Alignment.Center -> strokeWidth / 2f + expandWidthPx to -strokeWidth / 2f + expandWidthPx - Stroke.Alignment.Outside -> strokeWidth + expandWidthPx to 0f + expandWidthPx - } - - when (outer) { - inner -> return@onDrawWithContent - // Simply draw the outline when abs(outer) and abs(inner) are the same - -inner -> drawOutline(outline, brush, style = DrawScopeStroke(outer * 2f)) - else -> { - val config: ImageBitmapConfig - val colorFilter: ColorFilter? - if (brush is SolidColor) { - config = ImageBitmapConfig.Alpha8 - colorFilter = ColorFilter.tint(brush.value) - } else { - config = ImageBitmapConfig.Argb8888 - colorFilter = null - } - val pathBounds = outline.path.getBounds().inflate(outer) - val borderCache = borderCacheRef.obtain() - val outerMaskPath = - borderCache.obtainPath().apply { - reset() - addRect(pathBounds) - op(this, outline.path, PathOperation.Difference) - } - val cacheImageBitmap: ImageBitmap - val pathBoundsSize = IntSize(ceil(pathBounds.width).toInt(), ceil(pathBounds.height).toInt()) - - with(borderCache) { - cacheImageBitmap = - drawBorderCache(pathBoundsSize, config) { - translate(-pathBounds.left, -pathBounds.top) { - if (inner < 0f && outer > 0f) { - TODO("Not implemented for generic border") - } - - if (outer > 0f && inner >= 0f) { - drawPath(outline.path, brush, style = DrawScopeStroke(outer * 2f)) - - if (inner > 0f) { - drawPath( - path = outline.path, - brush = brush, - blendMode = BlendMode.Clear, - style = DrawScopeStroke(inner * 2f), - ) - } - - drawPath(path = outline.path, brush = brush, blendMode = BlendMode.Clear) - } - - if (outer <= 0f && inner < 0f) { - drawPath(path = outline.path, brush = brush, style = DrawScopeStroke(-inner * 2f)) - - if (outer < 0f) { - drawPath( - path = outline.path, - brush = brush, - blendMode = BlendMode.Clear, - style = DrawScopeStroke(-outer * 2f), - ) - } - - drawPath(path = outerMaskPath, brush = brush, blendMode = BlendMode.Clear) - } - } - } - } - - onDrawWithContent { - drawContent() - translate(pathBounds.left, pathBounds.top) { - drawImage(cacheImageBitmap, srcSize = pathBoundsSize, colorFilter = colorFilter) - } - } - } - } -} diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Scrollbar.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Scrollbar.kt index 2cca5ad0d89d5..a891ece1b4b96 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Scrollbar.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Scrollbar.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.v2.ScrollbarAdapter import androidx.compose.foundation.v2.maxScrollOffset import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf @@ -40,7 +41,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset @@ -49,11 +49,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.PointerInputScope -import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.MeasurePolicy import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.hideFromAccessibility @@ -441,6 +444,7 @@ private fun thumbColorTween(showScrollbar: Boolean, visibility: ScrollbarVisibil when { visibility is AlwaysVisible || !showScrollbar -> visibility.thumbColorAnimationDuration.inWholeMilliseconds.toInt() + else -> 0 }, delayMillis = @@ -487,55 +491,18 @@ private fun horizontalMeasurePolicy( layout(constraints.maxWidth, placeable.height) { placeable.place(pixelRange.first, 0) } } -@Suppress("ModifierComposed") // To fix in JEWEL-921 private fun Modifier.scrollbarDrag( interactionSource: MutableInteractionSource, draggedInteraction: MutableState, sliderAdapter: SliderAdapter, -): Modifier = composed { - val currentInteractionSource by rememberUpdatedState(interactionSource) - val currentDraggedInteraction by rememberUpdatedState(draggedInteraction) - val currentSliderAdapter by rememberUpdatedState(sliderAdapter) - - pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - val interaction = DragInteraction.Start() - currentInteractionSource.tryEmit(interaction) - currentDraggedInteraction.value = interaction - currentSliderAdapter.onDragStarted() - val isSuccess = - drag(down.id) { change -> - currentSliderAdapter.onDragDelta(change.positionChange()) - change.consume() - } - val finishInteraction = - if (isSuccess) { - DragInteraction.Stop(interaction) - } else { - DragInteraction.Cancel(interaction) - } - currentInteractionSource.tryEmit(finishInteraction) - currentDraggedInteraction.value = null - } - } -} +): Modifier = this then ScrollbarDragModifier(interactionSource, draggedInteraction, sliderAdapter) -@Suppress("ModifierComposed") // To fix in JEWEL-921 private fun Modifier.scrollOnPressTrack( clickBehavior: TrackClickBehavior, isVertical: Boolean, reverseLayout: Boolean, sliderAdapter: SliderAdapter, -) = composed { - val coroutineScope = rememberCoroutineScope() - val scroller = - remember(sliderAdapter, coroutineScope, reverseLayout, clickBehavior) { - TrackPressScroller(coroutineScope, sliderAdapter, reverseLayout, clickBehavior) - } - - Modifier.pointerInput(scroller) { detectScrollViaTrackGestures(isVertical = isVertical, scroller = scroller) } -} +) = this then ScrollOnPressTrackModifier(clickBehavior, isVertical, reverseLayout, sliderAdapter) /** Responsible for scrolling when the scrollbar track is pressed (outside the thumb). */ private class TrackPressScroller( @@ -674,6 +641,127 @@ internal const val DELAY_BEFORE_SECOND_SCROLL_ON_TRACK_PRESS: Long = 300L /** The delay between each subsequent (after the 2nd) scroll while the scrollbar track is pressed outside the thumb. */ internal const val DELAY_BETWEEN_SCROLLS_ON_TRACK_PRESS: Long = 100L +@Immutable +private data class ScrollbarDragModifier( + val interactionSource: MutableInteractionSource, + val draggedInteraction: MutableState, + val sliderAdapter: SliderAdapter, +) : ModifierNodeElement() { + override fun create() = ScrollbarDragNode(interactionSource, draggedInteraction, sliderAdapter) + + override fun update(node: ScrollbarDragNode) { + node.interactionSource = interactionSource + node.draggedInteraction = draggedInteraction + node.sliderAdapter = sliderAdapter + } + + override fun InspectorInfo.inspectableProperties() { + name = "scrollbarDrag" + properties["interactionSource"] = interactionSource + properties["draggedInteraction"] = draggedInteraction + properties["sliderAdapter"] = sliderAdapter + } +} + +private class ScrollbarDragNode( + var interactionSource: MutableInteractionSource, + var draggedInteraction: MutableState, + var sliderAdapter: SliderAdapter, +) : DelegatingNode() { + init { + delegate( + SuspendingPointerInputModifierNode { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val interaction = DragInteraction.Start() + + interactionSource.tryEmit(interaction) + draggedInteraction.value = interaction + sliderAdapter.onDragStarted() + + val isSuccess = + drag(down.id) { change -> + sliderAdapter.onDragDelta(change.positionChange()) + change.consume() + } + + val finishInteraction = + if (isSuccess) { + DragInteraction.Stop(interaction) + } else { + DragInteraction.Cancel(interaction) + } + + interactionSource.tryEmit(finishInteraction) + draggedInteraction.value = null + } + } + ) + } +} + +@Immutable +private data class ScrollOnPressTrackModifier( + val clickBehavior: TrackClickBehavior, + val isVertical: Boolean, + val reverseLayout: Boolean, + val sliderAdapter: SliderAdapter, +) : ModifierNodeElement() { + override fun create() = ScrollOnPressTrackNode(clickBehavior, isVertical, reverseLayout, sliderAdapter) + + override fun update(node: ScrollOnPressTrackNode) { + node.update(clickBehavior, isVertical, reverseLayout, sliderAdapter) + } + + override fun InspectorInfo.inspectableProperties() { + name = "scrollOnPressTrack" + properties["clickBehavior"] = clickBehavior + properties["isVertical"] = isVertical + properties["reverseLayout"] = reverseLayout + properties["sliderAdapter"] = sliderAdapter + } +} + +private class ScrollOnPressTrackNode( + var clickBehavior: TrackClickBehavior, + var isVertical: Boolean, + var reverseLayout: Boolean, + var sliderAdapter: SliderAdapter, +) : DelegatingNode() { + private val pointerInputNode = + delegate( + SuspendingPointerInputModifierNode { + val scroller = TrackPressScroller(coroutineScope, sliderAdapter, reverseLayout, clickBehavior) + + detectScrollViaTrackGestures(isVertical = isVertical, scroller = scroller) + } + ) + + fun update( + clickBehavior: TrackClickBehavior, + isVertical: Boolean, + reverseLayout: Boolean, + sliderAdapter: SliderAdapter, + ) { + val needsReset = + this.clickBehavior != clickBehavior || + this.isVertical != isVertical || + this.reverseLayout != reverseLayout || + this.sliderAdapter != sliderAdapter + + if (needsReset) { + this.clickBehavior = clickBehavior + this.isVertical = isVertical + this.reverseLayout = reverseLayout + this.sliderAdapter = sliderAdapter + + // This cancels the current block and restarts it (recreating the scroller) + // In other words, this `if` block is the "Key" equivalent + pointerInputNode.resetPointerInputHandler() + } + } +} + internal class SliderAdapter( val adapter: ScrollbarAdapter, private val trackSize: Int, diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Slider.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Slider.kt index da218deb565f0..b74faa8b99b2c 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Slider.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Slider.kt @@ -46,7 +46,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.focusRequester import androidx.compose.ui.geometry.Offset @@ -58,10 +57,12 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.nativeKeyCode import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.setProgress @@ -471,7 +472,6 @@ private fun Modifier.sliderSemantics( .progressSemantics(value, valueRange, steps) } -@Suppress("ModifierComposed") // To fix in JEWEL-921 private fun Modifier.sliderTapModifier( draggableState: DraggableState, interactionSource: MutableInteractionSource, @@ -482,49 +482,85 @@ private fun Modifier.sliderTapModifier( pressOffset: MutableState, enabled: Boolean, ) = - composed( - factory = { - if (enabled) { - val scope = rememberCoroutineScope() - pointerInput(draggableState, interactionSource, maxPx, isRtl) { - detectTapGestures( - onPress = { pos -> - val to = if (isRtl) maxPx - pos.x else pos.x - pressOffset.value = to - rawOffset.value - try { - awaitRelease() - } catch (_: GestureCancellationException) { - pressOffset.value = 0f - } - }, - onTap = { - scope.launch { - draggableState.drag(MutatePriority.UserInput) { - // just trigger animation, press offset will be applied - dragBy(0f) - } - gestureEndAction.value.invoke(0f) + if (enabled) { + this then + SliderTapModifier(draggableState, interactionSource, maxPx, isRtl, rawOffset, gestureEndAction, pressOffset) + } else { + this + } + +@Immutable +private data class SliderTapModifier( + val draggableState: DraggableState, + val interactionSource: MutableInteractionSource, + val maxPx: Float, + val isRtl: Boolean, + val rawOffset: State, + val gestureEndAction: State<(Float) -> Unit>, + val pressOffset: MutableState, +) : ModifierNodeElement() { + override fun create() = + SliderTapNode(draggableState, interactionSource, maxPx, isRtl, rawOffset, gestureEndAction, pressOffset) + + override fun update(node: SliderTapNode) { + node.rawOffset = rawOffset + node.gestureEndAction = gestureEndAction + node.pressOffset = pressOffset + node.draggableState = draggableState + node.interactionSource = interactionSource + node.maxPx = maxPx + node.isRtl = isRtl + } + + override fun InspectorInfo.inspectableProperties() { + name = "sliderTapModifier" + properties["draggableState"] = draggableState + properties["interactionSource"] = interactionSource + properties["maxPx"] = maxPx + properties["isRtl"] = isRtl + properties["rawOffset"] = rawOffset + properties["gestureEndAction"] = gestureEndAction + properties["pressOffset"] = pressOffset + properties["enabled"] = true + } +} + +private class SliderTapNode( + var draggableState: DraggableState, + var interactionSource: MutableInteractionSource, + var maxPx: Float, + var isRtl: Boolean, + var rawOffset: State, + var gestureEndAction: State<(Float) -> Unit>, + var pressOffset: MutableState, +) : DelegatingNode() { + init { + delegate( + SuspendingPointerInputModifierNode { + detectTapGestures( + onPress = { pos -> + val to = if (isRtl) maxPx - pos.x else pos.x + pressOffset.value = to - rawOffset.value + try { + awaitRelease() + } catch (_: GestureCancellationException) { + pressOffset.value = 0f + } + }, + onTap = { + coroutineScope.launch { + draggableState.drag(MutatePriority.UserInput) { + // just trigger animation, press offset will be applied + dragBy(0f) } - }, - ) - } - } else { - this + gestureEndAction.value.invoke(0f) + } + }, + ) } - }, - inspectorInfo = - debugInspectorInfo { - name = "sliderTapModifier" - properties["draggableState"] = draggableState - properties["interactionSource"] = interactionSource - properties["maxPx"] = maxPx - properties["isRtl"] = isRtl - properties["rawOffset"] = rawOffset - properties["gestureEndAction"] = gestureEndAction - properties["pressOffset"] = pressOffset - properties["enabled"] = enabled - }, - ) + ) + } +} private val SliderToTickAnimation = TweenSpec(durationMillis = 100) @@ -533,7 +569,6 @@ private suspend fun animateToTarget(draggableState: DraggableState, current: Flo var latestValue = current Animatable(initialValue = current).animateTo(target, SliderToTickAnimation, velocity) { dragBy(this.value - latestValue) - @Suppress("AssignedValueIsNeverRead") latestValue = this.value } }