Skip to content

Commit 3e64450

Browse files
authored
Implement ComposeDesktopEntryPoint.captureContentToImage (#3155)
1 parent fca104c commit 3e64450

10 files changed

Lines changed: 243 additions & 38 deletions

File tree

compose/ui/ui/api/desktop/ui.api

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ public final class androidx/compose/ui/ComposableSingletons$ImageComposeScene_sk
136136
}
137137

138138
public abstract interface class androidx/compose/ui/ComposeDesktopEntryPoint {
139+
public abstract fun captureContentToImage ()Ljava/awt/image/BufferedImage;
139140
public abstract fun getSemanticsOwners ()Ljava/util/Collection;
140141
}
141142

@@ -564,6 +565,7 @@ public final class androidx/compose/ui/awt/ComposeDialog : javax/swing/JDialog,
564565
public fun addMouseListener (Ljava/awt/event/MouseListener;)V
565566
public fun addMouseMotionListener (Ljava/awt/event/MouseMotionListener;)V
566567
public fun addMouseWheelListener (Ljava/awt/event/MouseWheelListener;)V
568+
public fun captureContentToImage ()Ljava/awt/image/BufferedImage;
567569
public fun dispose ()V
568570
public final fun getCompositionLocalContext ()Landroidx/compose/runtime/CompositionLocalContext;
569571
public fun getPreferredSize ()Ljava/awt/Dimension;
@@ -595,6 +597,7 @@ public final class androidx/compose/ui/awt/ComposePanel : javax/swing/JLayeredPa
595597
public fun add (Ljava/awt/Component;)Ljava/awt/Component;
596598
public fun addFocusListener (Ljava/awt/event/FocusListener;)V
597599
public fun addNotify ()V
600+
public fun captureContentToImage ()Ljava/awt/image/BufferedImage;
598601
public fun getFocusTraversalKeysEnabled ()Z
599602
public fun getMaximumSize ()Ljava/awt/Dimension;
600603
public fun getMinimumSize ()Ljava/awt/Dimension;
@@ -635,6 +638,7 @@ public final class androidx/compose/ui/awt/ComposeWindow : javax/swing/JFrame, a
635638
public fun addMouseListener (Ljava/awt/event/MouseListener;)V
636639
public fun addMouseMotionListener (Ljava/awt/event/MouseMotionListener;)V
637640
public fun addMouseWheelListener (Ljava/awt/event/MouseWheelListener;)V
641+
public fun captureContentToImage ()Ljava/awt/image/BufferedImage;
638642
public fun dispose ()V
639643
public final fun getCompositionLocalContext ()Landroidx/compose/runtime/CompositionLocalContext;
640644
public final fun getPlacement ()Landroidx/compose/ui/window/WindowPlacement;

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/ComposeDesktopEntryPoint.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package androidx.compose.ui
1818

1919
import androidx.compose.runtime.tooling.ComposeToolingApi
2020
import androidx.compose.ui.semantics.SemanticsOwner
21+
import java.awt.image.BufferedImage
2122

2223
/**
2324
* The interface for classes that are an entry point for using Compose on the desktop.
@@ -33,4 +34,13 @@ interface ComposeDesktopEntryPoint {
3334
* changes.
3435
*/
3536
val semanticsOwners: Collection<SemanticsOwner>
37+
38+
/**
39+
* Captures the content of this entry point into an image.
40+
*
41+
* Returns `null` if the entry point is not in a state where it has visual content yet.
42+
*
43+
* May be called only on the event dispatch thread.
44+
*/
45+
fun captureContentToImage(): BufferedImage?
3646
}

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeDialog.desktop.kt

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import java.awt.Window
4444
import java.awt.event.MouseListener
4545
import java.awt.event.MouseMotionListener
4646
import java.awt.event.MouseWheelListener
47+
import java.awt.image.BufferedImage
4748
import java.util.*
4849
import javax.swing.JDialog
4950
import kotlin.coroutines.CoroutineContext
@@ -216,18 +217,6 @@ class ComposeDialog : JDialog, ComposeDesktopEntryPoint {
216217

217218
private val undecoratedWindowResizer = UndecoratedWindowResizer(this)
218219

219-
/**
220-
* Returns the [SemanticsOwner]s corresponding to the roots of the semantics trees in this
221-
* [ComposeDialog].
222-
*
223-
* This is backed by Snapshot state, so reading this property in a restartable function (e.g., a
224-
* composable function) will cause the function to restart when the set of semantics owners
225-
* changes.
226-
*/
227-
@ComposeToolingApi
228-
override val semanticsOwners: Collection<SemanticsOwner>
229-
get() = composePanel.semanticsOwners
230-
231220
override fun add(component: Component) = composePanel.add(component)
232221

233222
override fun remove(component: Component) = composePanel.remove(component)
@@ -455,4 +444,28 @@ class ComposeDialog : JDialog, ComposeDesktopEntryPoint {
455444
internal fun measureContent(constraints: Constraints): IntSize {
456445
return composePanel.measureContent(constraints)
457446
}
447+
448+
/**
449+
* Returns the [SemanticsOwner]s corresponding to the roots of the semantics trees in this
450+
* [ComposeDialog].
451+
*
452+
* This is backed by Snapshot state, so reading this property in a restartable function (e.g., a
453+
* composable function) will cause the function to restart when the set of semantics owners
454+
* changes.
455+
*/
456+
@ComposeToolingApi
457+
override val semanticsOwners: Collection<SemanticsOwner>
458+
get() = composePanel.semanticsOwners
459+
460+
/**
461+
* Captures the content of this dialog into an image.
462+
*
463+
* Returns `null` if the dialog has not been made visible yet.
464+
*
465+
* May be called only on the event dispatching thread.
466+
*/
467+
@ComposeToolingApi
468+
override fun captureContentToImage(): BufferedImage? {
469+
return composePanel.captureContentToImage()
470+
}
458471
}

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposePanel.desktop.kt

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import java.awt.FocusTraversalPolicy
4646
import java.awt.Window
4747
import java.awt.event.FocusEvent
4848
import java.awt.event.FocusListener
49+
import java.awt.image.BufferedImage
4950
import java.util.*
5051
import javax.swing.JLayeredPane
5152
import javax.swing.SwingUtilities
@@ -333,18 +334,6 @@ class ComposePanel @ExperimentalComposeUiApi constructor(
333334
_composeContainer?.windowContainer = value
334335
}
335336

336-
/**
337-
* Returns the [SemanticsOwner]s corresponding to the roots of the semantics trees in this
338-
* [ComposePanel].
339-
*
340-
* This is backed by Snapshot state, so reading this property in a restartable function (e.g., a
341-
* composable function) will cause the function to restart when the set of semantics owners
342-
* changes.
343-
*/
344-
@ComposeToolingApi
345-
override val semanticsOwners: Collection<SemanticsOwner>
346-
get() = _composeContainer?.semanticsOwners ?: emptyList()
347-
348337
// Needed to preserve binary compatibility
349338
@Suppress("RedundantOverride")
350339
override fun add(component: Component): Component = super.add(component)
@@ -520,4 +509,28 @@ class ComposePanel @ExperimentalComposeUiApi constructor(
520509
field = value
521510
_composeContainer?.showLayoutBounds = value
522511
}
512+
513+
/**
514+
* Returns the [SemanticsOwner]s corresponding to the roots of the semantics trees in this
515+
* [ComposePanel].
516+
*
517+
* This is backed by Snapshot state, so reading this property in a restartable function (e.g., a
518+
* composable function) will cause the function to restart when the set of semantics owners
519+
* changes.
520+
*/
521+
@ComposeToolingApi
522+
override val semanticsOwners: Collection<SemanticsOwner>
523+
get() = _composeContainer?.semanticsOwners ?: emptyList()
524+
525+
/**
526+
* Captures the content of this panel into an image.
527+
*
528+
* Returns `null` if the panel has not been made visible yet.
529+
*
530+
* May be called only on the event dispatching thread.
531+
*/
532+
@ComposeToolingApi
533+
override fun captureContentToImage(): BufferedImage? {
534+
return _composeContainer?.captureContentToImage()
535+
}
523536
}

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindow.desktop.kt

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import java.awt.GraphicsConfiguration
4141
import java.awt.event.MouseListener
4242
import java.awt.event.MouseMotionListener
4343
import java.awt.event.MouseWheelListener
44+
import java.awt.image.BufferedImage
4445
import java.util.*
4546
import javax.swing.JFrame
4647
import kotlin.coroutines.CoroutineContext
@@ -86,18 +87,6 @@ class ComposeWindow @ExperimentalComposeUiApi constructor(
8687
internal val windowContext by composePanel::windowContext
8788
internal var rootForTestListener by composePanel::rootForTestListener
8889

89-
/**
90-
* Returns the [SemanticsOwner]s corresponding to the roots of the semantics trees in this
91-
* [ComposeWindow].
92-
*
93-
* This is backed by Snapshot state, so reading this property in a restartable function (e.g., a
94-
* composable function) will cause the function to restart when the set of semantics owners
95-
* changes.
96-
*/
97-
@ComposeToolingApi
98-
override val semanticsOwners: Collection<SemanticsOwner>
99-
get() = composePanel.semanticsOwners
100-
10190
/**
10291
* Controls whether mouse-down on an unfocusable element clears focus.
10392
*/
@@ -378,4 +367,28 @@ class ComposeWindow @ExperimentalComposeUiApi constructor(
378367
internal fun measureContent(constraints: Constraints): IntSize {
379368
return composePanel.measureContent(constraints)
380369
}
370+
371+
/**
372+
* Returns the [SemanticsOwner]s corresponding to the roots of the semantics trees in this
373+
* [ComposeWindow].
374+
*
375+
* This is backed by Snapshot state, so reading this property in a restartable function (e.g., a
376+
* composable function) will cause the function to restart when the set of semantics owners
377+
* changes.
378+
*/
379+
@ComposeToolingApi
380+
override val semanticsOwners: Collection<SemanticsOwner>
381+
get() = composePanel.semanticsOwners
382+
383+
/**
384+
* Captures the content of this window into an image.
385+
*
386+
* Returns `null` if the window has not been made visible yet.
387+
*
388+
* May be called only on the event dispatching thread.
389+
*/
390+
@ComposeToolingApi
391+
override fun captureContentToImage(): BufferedImage? {
392+
return composePanel.captureContentToImage()
393+
}
381394
}

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/ComposeWindowPanel.desktop.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,6 @@ internal class ComposeWindowPanel(
212212
fun actualizeSize(size: Dimension, insets: Insets): Dimension {
213213
return composeContainer.actualizeSize(size, insets)
214214
}
215+
216+
fun captureContentToImage() = composeContainer.captureContentToImage()
215217
}

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeContainer.desktop.kt

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import androidx.compose.ui.unit.IntSize
3636
import androidx.compose.ui.unit.LayoutDirection
3737
import androidx.compose.ui.util.fastAll
3838
import androidx.compose.ui.awt.UNSPECIFIED_DIMENSION_VALUE
39+
import androidx.compose.ui.unit.union
3940
import androidx.compose.ui.util.fastForEach
4041
import androidx.compose.ui.util.fastForEachReversed
4142
import androidx.compose.ui.util.fastRoundToInt
@@ -58,14 +59,14 @@ import java.awt.event.MouseEvent as AwtMouseEvent
5859
import java.awt.event.WindowEvent
5960
import java.awt.event.WindowFocusListener
6061
import java.awt.event.WindowListener
62+
import java.awt.image.BufferedImage
6163
import javax.swing.JLayeredPane
6264
import javax.swing.SwingUtilities
6365
import kotlin.coroutines.AbstractCoroutineContextElement
6466
import kotlin.coroutines.CoroutineContext
6567
import kotlin.coroutines.EmptyCoroutineContext
6668
import kotlin.math.ceil
6769
import kotlinx.coroutines.CoroutineExceptionHandler
68-
import kotlinx.coroutines.Job
6970
import org.jetbrains.annotations.VisibleForTesting
7071
import org.jetbrains.skia.Canvas
7172
import org.jetbrains.skiko.MainUIDispatcher
@@ -621,6 +622,42 @@ internal class ComposeContainer(
621622
override fun shouldSendMouseEvent(event: AwtMouseEvent): Boolean = noBlockingInputLayers
622623
override fun shouldSendKeyEvent(event: AwtKeyEvent): Boolean = noBlockingInputLayers
623624
}
625+
626+
/**
627+
* Captures the content of this container into an image.
628+
*
629+
* Returns `null` if the window has not been made visible yet.
630+
*
631+
* This may be called only on the event dispatch thread.
632+
*/
633+
fun captureContentToImage(): BufferedImage? {
634+
val mainLayerBounds = mediator.boundsOnScreenPx() ?: return null
635+
val layersAndBounds = layers.map {
636+
it to it.boundsOnScreenPx()
637+
}
638+
639+
var resultBounds = mainLayerBounds
640+
for ((_, bounds) in layersAndBounds) {
641+
if (bounds != null) {
642+
resultBounds = resultBounds.union(bounds)
643+
}
644+
}
645+
646+
val x = resultBounds.left
647+
val y = resultBounds.top
648+
val width = resultBounds.width
649+
val height = resultBounds.height
650+
651+
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
652+
mediator.drawContentInto(image, mainLayerBounds.left - x, mainLayerBounds.top - y)
653+
for ((layer, bounds) in layersAndBounds) {
654+
if (bounds == null) continue
655+
// The offset is from mainLayerBounds because layers draw themselves relative to it
656+
layer.drawContentInto(image, mainLayerBounds.left - x, mainLayerBounds.top - y)
657+
}
658+
659+
return image
660+
}
624661
}
625662

626663
/**

0 commit comments

Comments
 (0)