Skip to content

Commit

Permalink
Adopt update scheduling strategy on Swing
Browse files Browse the repository at this point in the history
  • Loading branch information
elijah-semyonov committed Aug 8, 2024
1 parent ff72fe1 commit 0da8553
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 46 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ internal class ComposeSceneMediator(
private val _platformContext = DesktopPlatformContext()
val platformContext: PlatformContext get() = _platformContext

private val skiaLayerComponent by lazy { skiaLayerComponentFactory(this) }
private val skiaLayerComponent: SkiaLayerComponent by lazy { skiaLayerComponentFactory(this) }
val contentComponent by skiaLayerComponent::contentComponent
var fullscreen by skiaLayerComponent::fullscreen
val windowHandle by skiaLayerComponent::windowHandle
Expand All @@ -161,7 +161,8 @@ internal class ComposeSceneMediator(
*/
private val interopContainer = SwingInteropContainer(
root = container,
placeInteropAbove = !useInteropBlending || metalOrderHack
placeInteropAbove = !useInteropBlending || metalOrderHack,
requestRedraw = ::onComposeInvalidation
)

private val containerListener = object : ContainerListener {
Expand Down Expand Up @@ -459,6 +460,8 @@ internal class ComposeSceneMediator(

unsubscribe(contentComponent)

// Since rendering can not happen after, we still need to execute scheduled updates
interopContainer.executeScheduledUpdates()
container.removeContainerListener(containerListener)
container.remove(contentComponent)
container.remove(invisibleComponent)
Expand Down Expand Up @@ -550,6 +553,8 @@ internal class ComposeSceneMediator(
}

override fun onRender(canvas: Canvas, width: Int, height: Int, nanoTime: Long) = catchExceptions {
interopContainer.executeScheduledUpdates()

canvas.withSceneOffset {
scene.render(asComposeCanvas(), nanoTime)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,56 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.snapshots.SnapshotStateObserver
import androidx.compose.ui.scene.ComposeSceneMediator
import androidx.compose.ui.util.fastForEach
import java.awt.Component
import javax.swing.SwingUtilities
import javax.swing.SwingUtilities.isEventDispatchThread
import org.jetbrains.skiko.ClipRectangle

typealias ScheduledUpdate = () -> Unit

/**
* A helper class to back-buffer scheduled updates for Swing Interop without allocating
* an array on each frame.
*/
private class ScheduledUpdatesSwapchain {
private var executed = mutableListOf<ScheduledUpdate>()
private var scheduled = mutableListOf<ScheduledUpdate>()
private val lock = Any()

/**
* Schedule an update to be executed on the next frame.
*/
fun scheduleUpdate(action: ScheduledUpdate) = synchronized(lock) {
scheduled.add(action)
}

/**
* Execute all scheduled updates.
*
* @return True if there were any updates to execute. False otherwise.
*/
fun execute(): Boolean {
// Race condition on [executed] is prevented by the fact that this method is called only
// on the AWT EDT. We only need to synchronize [scheduled] across threads using [lock].

synchronized(lock) {
// Swap lists and return the one to be executed
val t = executed
executed = scheduled
scheduled = t
}

val hasAnyUpdates = executed.isNotEmpty()

executed.fastForEach {
it.invoke()
}
executed.clear()

return hasAnyUpdates
}
}

/**
* A container that controls interop views/components.
*
Expand All @@ -32,10 +78,13 @@ import org.jetbrains.skiko.ClipRectangle
*
* @property root The Swing container to add the interop views to.
* @property placeInteropAbove Whether to place interop components above non-interop components.
* @property requestRedraw Function to request a redraw. It's needed because executing scheduled
* updates is tied to the draw loop.
*/
internal class SwingInteropContainer(
override val root: InteropViewGroup,
private val placeInteropAbove: Boolean
private val placeInteropAbove: Boolean,
private val requestRedraw: () -> Unit
) : InteropContainer {
/**
* Map to reverse-lookup of [InteropViewHolder] having an [InteropViewGroup].
Expand All @@ -48,6 +97,8 @@ internal class SwingInteropContainer(
command()
}

private val scheduledUpdatesSwapchain = ScheduledUpdatesSwapchain()

/**
* Index of last interop component in [root].
*
Expand Down Expand Up @@ -90,43 +141,51 @@ internal class SwingInteropContainer(
val awtIndex = lastInteropIndex - countBelow

// Update AWT/Swing hierarchy
if (alreadyAdded) {
holder.changeInteropViewIndex(root, awtIndex)
} else {
holder.insertInteropView(root, awtIndex)
scheduleUpdate {
if (alreadyAdded) {
holder.changeInteropViewIndex(root = root, index = awtIndex)
} else {
holder.insertInteropView(root = root, index = awtIndex)
}
}

// Sometimes Swing displays the rest of interop views in incorrect order after adding,
// so we need to force re-validate it.

root.validate()
root.repaint()
}

override fun unplace(holder: InteropViewHolder) {
holder.removeInteropView(root)
scheduleUpdate {
holder.removeInteropView(root = root)
}

interopComponents.remove(holder.group)

if (interopComponents.isEmpty()) {
snapshotObserver.stop()
}
}

fun executeScheduledUpdates() {
check(isEventDispatchThread())

// Sometimes Swing displays the rest of interop views in incorrect order after removal,
// so we need to force re-validate it.
val hasAnyUpdates = scheduledUpdatesSwapchain.execute()

root.validate()
root.repaint()
if (hasAnyUpdates) {
// Sometimes Swing displays the rest of interop views in incorrect order after an update
// so we need to re-validate and repaint the root component.

root.validate()
root.repaint()
}
}

override fun scheduleUpdate(action: () -> Unit) {
// Swing doesn't need to delay the action. Just execute it synchronously.
action()
scheduledUpdatesSwapchain.scheduleUpdate(action)

requestRedraw()
}

override fun onInteropViewLayoutChange(holder: InteropViewHolder) {
root.validate()
root.repaint()
// No-op.
// On Swing it's called after relayout for specific interop view was requested.
// It means that the validate and repaint will be executed after it.
}

fun getClipRectForComponent(component: Component): ClipRectangle =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,26 +99,25 @@ internal class SwingInteropViewHolder<T : Component>(
.round(density)

clipBounds = clippedBounds // Clipping area for skia canvas
group.isVisible = !clippedBounds.isEmpty // Hide if it's fully clipped
// Swing clips children based on parent's bounds, so use our container for clipping
group.setBounds(
/* x = */ clippedBounds.left,
/* y = */ clippedBounds.top,
/* width = */ clippedBounds.width,
/* height = */ clippedBounds.height
)

// The real size and position should be based on not-clipped bounds
typedInteropView.setBounds(
/* x = */ bounds.left - clippedBounds.left, // Local position relative to container
/* y = */ bounds.top - clippedBounds.top,
/* width = */ bounds.width,
/* height = */ bounds.height
)
// Swing clips children based on parent's bounds, so use our container for clipping
container.scheduleUpdate {
group.isVisible = !clippedBounds.isEmpty // Hide if it's fully clipped

group.setBounds(
/* x = */ clippedBounds.left,
/* y = */ clippedBounds.top,
/* width = */ clippedBounds.width,
/* height = */ clippedBounds.height
)

container.root.apply {
validate()
repaint()
// The real size and position should be based on not-clipped bounds
typedInteropView.setBounds(
/* x = */ bounds.left - clippedBounds.left, // Local position relative to container
/* y = */ bounds.top - clippedBounds.top,
/* width = */ bounds.width,
/* height = */ bounds.height
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ internal interface InteropContainer {
fun unplace(holder: InteropViewHolder)

/**
* Schedule an update to be performed on interop view. Some platforms (like iOS) delay the
* action to synchronize it with compose rendering. Some can execute it synchronously.
* Schedule an update to be performed on interop view. Platforms have their different strategy
* to align the updates with the rendering and threading requirements when modifying
* interop views.
*
* @param action The action to be performed. Could be layout change, or other visual updates
* to the view state, such as background, corner radius, etc.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ internal class UIKitInteropContainer(

if (isAdded) {
scheduleUpdate {
holder.insertInteropView(root, countBelow)
holder.insertInteropView(root = root, index = countBelow)
}
} else {
scheduleUpdate {
holder.changeInteropViewIndex(root, countBelow)
holder.changeInteropViewIndex(root = root, index = countBelow)
}
}
}
Expand All @@ -108,7 +108,7 @@ internal class UIKitInteropContainer(
}

scheduleUpdate {
holder.removeInteropView(root)
holder.removeInteropView(root = root)
}
}

Expand Down

0 comments on commit 0da8553

Please sign in to comment.