Skip to content

Commit

Permalink
Proper clipping of SwingPanel interop (#915)
Browse files Browse the repository at this point in the history
## Proposed Changes

- Adopt an approach that we're using for iOS - using custom blending to
respect clip/shape modifiers and overlapped drawings.
- Fix bounds in window calculation - it should work properly inside
scaled content now.

## Testing

The new behavior is under a feature flag, so you can test it by setting
system property:

```kt
System.setProperty("compose.interop.blending", "true")
```

Currently it supports Metal (macOS), Direct3D (Windows) (requires
JetBrains/skiko#837), and off-screen rendering
(might be enable by another feature flag:
`compose.swing.render.on.graphics`)

### Clipping

```kt
SwingPanel(
    modifier = Modifier.clip(RoundedCornerShape(6.dp))
    ...
)
```
<img width="262" alt="image"
src="https://github.com/JetBrains/compose-multiplatform-core/assets/1836384/a03926f5-8977-4530-8894-d84aedfd5939">

### Overlapping

```kt
Box(modifier = Modifier.fillMaxSize()) {
    SwingPanel(factory = {
        JPanel().also { panel ->
            panel.background = java.awt.Color.red
            panel.add(JButton().also { button ->
                button.text = "JButton"
            })
        }
    })
    Snackbar(
        action = { Button(onClick = {}) { Text("OK") } },
        modifier = Modifier.padding(8.dp).align(Alignment.BottomCenter),
    ) {
        Text("Snackbar")
    }
    Popup(alignment = Alignment.Center) {
        Box(
            modifier = Modifier.size(200.dp, 100.dp).background(Gray),
            contentAlignment = Alignment.Center,
        ) {

            Text("Popup")
        }

    }
}
```
<img width="592" alt="Screenshot 2023-11-27 at 13 57 43"
src="https://github.com/JetBrains/compose-multiplatform-core/assets/1836384/dda1f2d6-6a1e-456c-8763-46a82d9562a8">


## Issues Fixed

Fixes JetBrains/compose-multiplatform#3823
Fixes JetBrains/compose-multiplatform#3739
Fixes JetBrains/compose-multiplatform#3353
Fixes JetBrains/compose-multiplatform#3474
  • Loading branch information
MatkovIvan authored Dec 15, 2023
1 parent b2dd9cb commit 9751714
Show file tree
Hide file tree
Showing 11 changed files with 284 additions and 101 deletions.
1 change: 0 additions & 1 deletion compose/ui/ui/api/desktop/ui.api
Original file line number Diff line number Diff line change
Expand Up @@ -3124,7 +3124,6 @@ public abstract interface class androidx/compose/ui/platform/PlatformContext {
public fun isWindowTransparent ()Z
public fun requestFocus ()Z
public fun setPointerIcon (Landroidx/compose/ui/input/pointer/PointerIcon;)V
public fun setWindowTransparent (Z)V
}

public final class androidx/compose/ui/platform/PlatformContext$Companion {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,14 @@ internal abstract class ComposeBridge(
private var isDisposed = false

private val _invisibleComponent = InvisibleComponent()

abstract val component: JComponent
val invisibleComponent: Component get() = _invisibleComponent

abstract val component: JComponent
abstract val renderApi: GraphicsApi

abstract val interopBlendingSupported: Boolean
abstract val clipComponents: MutableList<ClipRectangle>
private val clipMap = mutableMapOf<Component, ClipComponent>()

// Needed for case when componentLayer is a wrapper for another Component that need to acquire focus events
// e.g. canvas in case of ComposeWindowLayer
Expand Down Expand Up @@ -149,6 +150,7 @@ internal abstract class ComposeBridge(
private val desktopTextInputService = DesktopTextInputService(platformComponent)
protected val platformContext = DesktopPlatformContext()
internal var rootForTestListener: PlatformContext.RootForTestListener? by DelegateRootForTestListener()
internal var isWindowTransparent by platformContext::isWindowTransparent

private val semanticsOwnerListener = DesktopSemanticsOwnerListener()
val sceneAccessible = ComposeSceneAccessible {
Expand Down Expand Up @@ -337,6 +339,18 @@ internal abstract class ComposeBridge(
initContent()
}

fun addClipComponent(component: Component) {
val clipComponent = ClipComponent(component)
clipMap[component] = clipComponent
clipComponents.add(clipComponent)
}

fun removeClipComponent(component: Component) {
clipMap.remove(component)?.let {
clipComponents.remove(it)
}
}

private var _initContent: (() -> Unit)? = null

protected fun initContent() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,9 @@ class ComposeDialog : JDialog {
* `true`, otherwise AWT will throw an exception.
*/
var isTransparent: Boolean
get() = delegate.isTransparent
get() = delegate.isWindowTransparent
set(value) {
delegate.isTransparent = value
delegate.isWindowTransparent = value
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ import javax.swing.JLayeredPane
import javax.swing.SwingUtilities.isEventDispatchThread
import org.jetbrains.skiko.ClipComponent
import org.jetbrains.skiko.GraphicsApi
import org.jetbrains.skiko.OS
import org.jetbrains.skiko.SkiaLayerAnalytics
import org.jetbrains.skiko.hostOs

/**
* ComposePanel is a panel for building UI using Compose for Desktop.
Expand Down Expand Up @@ -86,7 +88,6 @@ class ComposePanel @ExperimentalComposeUiApi constructor(
private var _isFocusable = true
private var _isRequestFocusEnabled = false
private var bridge: ComposeBridge? = null
private val clipMap = mutableMapOf<Component, ClipComponent>()
private var content: (@Composable () -> Unit)? = null

/**
Expand Down Expand Up @@ -171,38 +172,58 @@ class ComposePanel @ExperimentalComposeUiApi constructor(
}

override fun add(component: Component): Component {
if (bridge == null) {
return component
addToLayer(component, componentLayer)
if (!interopBlending) {
bridge?.addClipComponent(component)
}
val clipComponent = ClipComponent(component)
clipMap[component] = clipComponent
bridge!!.clipComponents.add(clipComponent)
return super.add(component, Integer.valueOf(0))
return component
}

override fun remove(component: Component) {
bridge!!.clipComponents.remove(clipMap[component]!!)
clipMap.remove(component)
bridge?.removeClipComponent(component)
super.remove(component)
}

private fun addToLayer(component: Component, layer: Int) {
if (renderApi == GraphicsApi.METAL && bridge !is SwingComposeBridge) {
// Applying layer on macOS makes our bridge non-transparent
// But it draws always on top, so we can just add it as-is
// TODO: Figure out why it makes difference in transparency
super.add(component, 0)
} else {
super.setLayer(component, layer)
super.add(component)
}
}

private val bridgeLayer: Int = 10
private val componentLayer: Int
get() = if (interopBlending) 0 else 20

private val renderOnGraphics: Boolean
get() = System.getProperty("compose.swing.render.on.graphics").toBoolean()
private val _interopBlending: Boolean
get() = System.getProperty("compose.interop.blending").toBoolean()
private val interopBlending: Boolean
get() = _interopBlending &&
(renderOnGraphics || requireNotNull(bridge).interopBlendingSupported)

override fun addNotify() {
super.addNotify()

// After [super.addNotify] is called we can safely initialize the bridge and composable
// content.
if (bridge == null) {
bridge = createComposeBridge()
if (this.bridge == null) {
val bridge = createComposeBridge()
this.bridge = bridge
initContent()
super.add(bridge!!.invisibleComponent, Integer.valueOf(1))
super.add(bridge!!.component, Integer.valueOf(1))
addToLayer(bridge.invisibleComponent, bridgeLayer)
addToLayer(bridge.component, bridgeLayer)
}
}

private fun createComposeBridge(): ComposeBridge {
val renderOnGraphics = System.getProperty("compose.swing.render.on.graphics").toBoolean()
val bridge: ComposeBridge = if (renderOnGraphics) {
// TODO: Add window transparent info
SwingComposeBridge(skiaLayerAnalytics, layoutDirectionFor(this))
} else {
WindowComposeBridge(skiaLayerAnalytics, layoutDirectionFor(this))
Expand Down Expand Up @@ -328,5 +349,5 @@ class ComposePanel @ExperimentalComposeUiApi constructor(
* environment variable.
*/
val renderApi: GraphicsApi
get() = if (bridge != null) bridge!!.renderApi else GraphicsApi.UNKNOWN
get() = bridge?.renderApi ?: GraphicsApi.UNKNOWN
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ class ComposeWindow @ExperimentalComposeUiApi constructor(
* Transparency should be set only if window is not showing and `isUndecorated` is set to
* `true`, otherwise AWT will throw an exception.
*/
var isTransparent: Boolean by delegate::isTransparent
var isTransparent: Boolean by delegate::isWindowTransparent

var placement: WindowPlacement
get() = when {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,30 +60,123 @@ internal class ComposeWindowDelegate(
}
internal val scene: ComposeScene
get() = bridge.scene

internal val windowAccessible: Accessible
get() = bridge.sceneAccessible

internal var rootForTestListener by bridge::rootForTestListener

val undecoratedWindowResizer = UndecoratedWindowResizer(window)

var fullscreen: Boolean
get() = bridge.component.fullscreen
set(value) {
bridge.component.fullscreen = value
}

var compositionLocalContext: CompositionLocalContext?
get() = bridge.compositionLocalContext
set(value) {
bridge.compositionLocalContext = value
}

@ExperimentalComposeUiApi
var exceptionHandler: WindowExceptionHandler?
get() = bridge.exceptionHandler
set(value) {
bridge.exceptionHandler = value
}

val windowHandle: Long
get() = bridge.component.windowHandle

val renderApi: GraphicsApi
get() = bridge.renderApi

private val _interopBlending: Boolean
get() = System.getProperty("compose.interop.blending").toBoolean()
private val interopBlending: Boolean
get() = _interopBlending && bridge.interopBlendingSupported

var isWindowTransparent: Boolean = false
set(value) {
if (field != value) {
check(isUndecorated()) { "Transparent window should be undecorated!" }
check(!window.isDisplayable) {
"Cannot change transparency if window is already displayable."
}
field = value
bridge.isWindowTransparent = value
bridge.transparency = value || interopBlending

/*
* Windows makes clicks on transparent pixels fall through, but it doesn't work
* with GPU accelerated rendering since this check requires having access to pixels from CPU.
*
* JVM doesn't allow override this behaviour with low-level windows methods, so hack this in this way.
* Based on tests, it doesn't affect resulting pixel color.
*
* Note: Do not set isOpaque = false for this container
*/
if (value && hostOs == OS.Windows) {
pane.background = Color(0, 0, 0, 1)
pane.isOpaque = true
} else {
pane.background = null
pane.isOpaque = false
}

window.background = if (value && !skikoTransparentWindowHack) Color(0, 0, 0, 0) else null
}
}

/**
* There is a hack inside skiko OpenGL and Software redrawers for Windows that makes current
* window transparent without setting `background` to JDK's window. It's done by getting native
* component parent and calling `DwmEnableBlurBehindWindow`.
*
* FIXME: Make OpenGL work inside transparent window (background == Color(0, 0, 0, 0)) without this hack.
*
* See `enableTransparentWindow` (skiko/src/awtMain/cpp/windows/window_util.cc)
*/
private val skikoTransparentWindowHack: Boolean
get() = hostOs == OS.Windows && renderApi != GraphicsApi.DIRECT3D

private val _pane = object : JLayeredPane() {
override fun setBounds(x: Int, y: Int, width: Int, height: Int) {
bridge.component.setSize(width, height)
bridge.component.setBounds(0, 0, width, height)
super.setBounds(x, y, width, height)
}

override fun add(component: Component): Component {
val clipComponent = ClipComponent(component)
clipMap[component] = clipComponent
bridge.clipComponents.add(clipComponent)
return add(component, Integer.valueOf(0))
addToLayer(component, componentLayer)
if (!interopBlending) {
bridge.addClipComponent(component)
}
return component
}

override fun remove(component: Component) {
bridge.clipComponents.remove(clipMap[component]!!)
clipMap.remove(component)
bridge.removeClipComponent(component)
super.remove(component)
}

private fun addToLayer(component: Component, layer: Int) {
if (renderApi == GraphicsApi.METAL) {
// Applying layer on macOS makes our bridge non-transparent
// But it draws always on top, so we can just add it as-is
// TODO: Figure out why it makes difference in transparency
super.add(component, 0)
} else {
super.setLayer(component, layer)
super.add(component)
}
}

private val bridgeLayer: Int get() = 10
private val componentLayer: Int
get() = if (interopBlending) 0 else 20

override fun addNotify() {
super.addNotify()
bridge.component.requestFocus()
Expand All @@ -94,8 +187,8 @@ internal class ComposeWindowDelegate(

init {
layout = null
super.add(bridge.invisibleComponent, 1)
super.add(bridge.component, 1)
addToLayer(bridge.invisibleComponent, bridgeLayer)
addToLayer(bridge.component, bridgeLayer)
}

fun dispose() {
Expand All @@ -106,8 +199,6 @@ internal class ComposeWindowDelegate(

val pane get() = _pane

private val clipMap = mutableMapOf<Component, ClipComponent>()

init {
pane.focusTraversalPolicy = object : FocusTraversalPolicy() {
override fun getComponentAfter(aContainer: Container?, aComponent: Component?) = null
Expand All @@ -117,6 +208,7 @@ internal class ComposeWindowDelegate(
override fun getDefaultComponent(aContainer: Container?) = null
}
pane.isFocusCycleRoot = true
bridge.transparency = interopBlending
setContent {}
}

Expand All @@ -128,18 +220,6 @@ internal class ComposeWindowDelegate(
_pane.remove(component)
}

var fullscreen: Boolean
get() = bridge.component.fullscreen
set(value) {
bridge.component.fullscreen = value
}

var compositionLocalContext: CompositionLocalContext?
get() = bridge.compositionLocalContext
set(value) {
bridge.compositionLocalContext = value
}

fun setContent(
onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
onKeyEvent: (KeyEvent) -> Boolean = { false },
Expand Down Expand Up @@ -225,38 +305,6 @@ internal class ComposeWindowDelegate(
}
}

@ExperimentalComposeUiApi
var exceptionHandler: WindowExceptionHandler?
get() = bridge.exceptionHandler
set(value) {
bridge.exceptionHandler = value
}

val windowHandle: Long
get() = bridge.component.windowHandle

val renderApi: GraphicsApi
get() = bridge.renderApi

var isTransparent: Boolean
get() = bridge.transparency
set(value) {
if (value != bridge.transparency) {
check(isUndecorated()) { "Transparent window should be undecorated!" }
check(!window.isDisplayable) {
"Cannot change transparency if window is already displayable."
}
bridge.transparency = value
if (value) {
if (hostOs != OS.Windows) {
window.background = Color(0, 0, 0, 0)
}
} else {
window.background = null
}
}
}

fun addMouseListener(listener: MouseListener) {
bridge.component.addMouseListener(listener)
}
Expand Down
Loading

0 comments on commit 9751714

Please sign in to comment.