Skip to content

Commit 87b1de7

Browse files
authored
Provide access to semanticsOwners in desktop entry points (#2358)
1 parent 1066f42 commit 87b1de7

File tree

9 files changed

+320
-12
lines changed

9 files changed

+320
-12
lines changed

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import androidx.compose.ui.awt.RenderSettings.SkiaSurface
2424
import androidx.compose.ui.awt.RenderSettings.SwingGraphics
2525
import androidx.compose.ui.focus.FocusDirection
2626
import androidx.compose.ui.scene.ComposeContainer
27+
import androidx.compose.ui.semantics.SemanticsOwner
2728
import androidx.compose.ui.window.WindowExceptionHandler
2829
import androidx.savedstate.SavedState
2930
import java.awt.Color
@@ -213,13 +214,24 @@ class ComposePanel @ExperimentalComposeUiApi constructor(
213214
_composeContainer?.windowContainer = value
214215
}
215216

216-
override fun add(component: Component): Component {
217-
return super.add(component)
218-
}
217+
/**
218+
* Returns the [SemanticsOwner]s corresponding to the roots of the semantics trees in this
219+
* [ComposePanel].
220+
*
221+
* This is backed by snapshot state, so reading this property in a restartable function (e.g., a
222+
* composable function) will cause the function to restart when set of semantics owners changes.
223+
*/
224+
@ExperimentalComposeUiApi
225+
val semanticsOwners: Collection<SemanticsOwner>
226+
get() = _composeContainer?.semanticsOwners ?: emptyList()
219227

220-
override fun remove(component: Component) {
221-
super.remove(component)
222-
}
228+
// Needed to preserve binary compatibility
229+
@Suppress("RedundantOverride")
230+
override fun add(component: Component): Component = super.add(component)
231+
232+
// Needed to preserve binary compatibility
233+
@Suppress("RedundantOverride")
234+
override fun remove(component: Component) = super.remove(component)
223235

224236
override fun addNotify() {
225237
super.addNotify()

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
2121
import androidx.compose.ui.Modifier
2222
import androidx.compose.ui.input.key.KeyEvent
2323
import androidx.compose.ui.layout.layoutId
24+
import androidx.compose.ui.semantics.SemanticsOwner
2425
import androidx.compose.ui.unit.Dp
2526
import androidx.compose.ui.window.FrameWindowScope
2627
import androidx.compose.ui.window.UndecoratedWindowResizer
@@ -81,6 +82,17 @@ class ComposeWindow @ExperimentalComposeUiApi constructor(
8182
internal val windowAccessible: Accessible
8283
get() = composePanel.windowAccessible
8384

85+
/**
86+
* Returns the [SemanticsOwner]s corresponding to the roots of the semantics trees in this
87+
* [ComposeWindow].
88+
*
89+
* This is backed by snapshot state, so reading this property in a restartable function (e.g., a
90+
* composable function) will cause the function to restart when set of semantics owners changes.
91+
*/
92+
@ExperimentalComposeUiApi
93+
val semanticsOwners: Collection<SemanticsOwner>
94+
get() = composePanel.semanticsOwners
95+
8496
init {
8597
contentPane.add(composePanel)
8698
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ internal class ComposeWindowPanel(
8080
var exceptionHandler by composeContainer::exceptionHandler
8181
val windowHandle by composeContainer::windowHandle
8282
val renderApi by composeContainer::renderApi
83+
val semanticsOwners by composeContainer::semanticsOwners
8384

8485
var isWindowTransparent: Boolean = false
8586
set(value) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ internal class ComposeContainer(
190190
val windowHandle by mediator::windowHandle
191191
val renderApi by mediator::renderApi
192192
val preferredSize by mediator::preferredSize
193+
val semanticsOwners by mediator::semanticsOwners
193194

194195
override val lifecycle = LifecycleRegistry(this)
195196

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

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

1919
import androidx.compose.runtime.Composable
2020
import androidx.compose.runtime.CompositionLocalContext
21+
import androidx.compose.runtime.mutableStateSetOf
2122
import androidx.compose.ui.ComposeFeatureFlags
2223
import androidx.compose.ui.awt.AwtEventListener
2324
import androidx.compose.ui.awt.AwtEventListeners
@@ -143,6 +144,7 @@ internal class ComposeSceneMediator(
143144
var fullscreen by skiaLayerComponent::fullscreen
144145
val windowHandle by skiaLayerComponent::windowHandle
145146
val renderApi by skiaLayerComponent::renderApi
147+
val semanticsOwners: Collection<SemanticsOwner> by semanticsOwnerListener::semanticsOwners
146148

147149
/**
148150
* @see ComposeFeatureFlags.useInteropBlending
@@ -667,6 +669,8 @@ internal class ComposeSceneMediator(
667669
private val _accessibilityControllers = linkedMapOf<SemanticsOwner, AccessibilityController>()
668670
val accessibilityControllers get() = _accessibilityControllers.values.reversed()
669671

672+
val semanticsOwners = mutableStateSetOf<SemanticsOwner>()
673+
670674
override fun onSemanticsOwnerAppended(semanticsOwner: SemanticsOwner) {
671675
check(semanticsOwner !in _accessibilityControllers)
672676
_accessibilityControllers[semanticsOwner] = AccessibilityController(
@@ -678,10 +682,12 @@ internal class ComposeSceneMediator(
678682
).also {
679683
it.launchSyncLoop(coroutineContext)
680684
}
685+
semanticsOwners.add(semanticsOwner)
681686
}
682687

683688
override fun onSemanticsOwnerRemoved(semanticsOwner: SemanticsOwner) {
684689
_accessibilityControllers.remove(semanticsOwner)?.dispose()
690+
semanticsOwners.remove(semanticsOwner)
685691
}
686692

687693
override fun onSemanticsChange(semanticsOwner: SemanticsOwner) {
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.compose.ui
18+
19+
import androidx.compose.foundation.layout.Arrangement
20+
import androidx.compose.foundation.layout.Column
21+
import androidx.compose.foundation.layout.padding
22+
import androidx.compose.foundation.text.input.rememberTextFieldState
23+
import androidx.compose.material.OutlinedTextField
24+
import androidx.compose.material.Text
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.runtime.getValue
27+
import androidx.compose.runtime.mutableStateOf
28+
import androidx.compose.runtime.setValue
29+
import androidx.compose.runtime.snapshotFlow
30+
import androidx.compose.runtime.snapshots.Snapshot
31+
import androidx.compose.ui.awt.ComposePanel
32+
import androidx.compose.ui.semantics.SemanticsNode
33+
import androidx.compose.ui.semantics.SemanticsOwner
34+
import androidx.compose.ui.semantics.SemanticsProperties
35+
import androidx.compose.ui.semantics.getOrNull
36+
import androidx.compose.ui.text.AnnotatedString
37+
import androidx.compose.ui.unit.dp
38+
import androidx.compose.ui.window.Popup
39+
import androidx.compose.ui.window.WindowTestScope
40+
import androidx.compose.ui.window.runApplicationTest
41+
import java.awt.BorderLayout
42+
import javax.swing.JFrame
43+
import kotlin.test.Test
44+
import kotlin.test.assertContentEquals
45+
import kotlin.test.assertEquals
46+
import kotlinx.coroutines.CoroutineScope
47+
import kotlinx.coroutines.cancel
48+
import kotlinx.coroutines.launch
49+
import kotlinx.coroutines.runBlocking
50+
import kotlinx.coroutines.test.StandardTestDispatcher
51+
52+
class SemanticsOwnersProviderTest {
53+
54+
private fun semanticsOwnersProvidedBy(provider: SemanticsOwnersTestContext) = provider.runTest(
55+
content = {
56+
Column(
57+
verticalArrangement = Arrangement.spacedBy(10.dp),
58+
modifier = Modifier.padding(64.dp)
59+
) {
60+
Text("Hello")
61+
OutlinedTextField(rememberTextFieldState("World"))
62+
}
63+
}
64+
) {
65+
val strings = semanticsOwners.collectText().map { it.text }
66+
assertContentEquals(listOf("Hello", "World"), strings)
67+
}
68+
69+
@Test
70+
fun semanticsOwnersProvidedInComposeWindow() =
71+
semanticsOwnersProvidedBy(ComposeWindowSemanticOwnersTestContext())
72+
73+
@Test
74+
fun semanticsOwnersProvidedInVisibleComposePanel() =
75+
semanticsOwnersProvidedBy(ComposePanelSemanticOwnersTestContext(visible = true))
76+
77+
@Test
78+
fun semanticsOwnersProvidedInInvisibleComposePanel() =
79+
semanticsOwnersProvidedBy(ComposePanelSemanticOwnersTestContext(visible = false))
80+
81+
@Test
82+
fun semanticsOwnersProvidedInImageComposeScene() =
83+
semanticsOwnersProvidedBy(ImageComposeSceneSemanticOwnersTestContext())
84+
85+
private fun semanticsOwnersIsSnapshotStateBy(provider: SemanticsOwnersTestContext) {
86+
var latestSemanticsOwners: Collection<SemanticsOwner> = emptyList()
87+
val dispatcher = StandardTestDispatcher()
88+
val coroutineScope = CoroutineScope(dispatcher)
89+
coroutineScope.launch {
90+
snapshotFlow { provider.semanticsOwners }.collect {
91+
latestSemanticsOwners = it
92+
}
93+
}
94+
var showPopup by mutableStateOf(false)
95+
provider.runTest(
96+
content = {
97+
Text("Hello")
98+
if (showPopup) {
99+
println("Showing popup")
100+
Popup {
101+
Text("World")
102+
}
103+
}
104+
}
105+
) {
106+
try {
107+
dispatcher.scheduler.advanceUntilIdle()
108+
assertEquals(1, latestSemanticsOwners.size)
109+
showPopup = true
110+
awaitIdle()
111+
dispatcher.scheduler.advanceUntilIdle()
112+
assertEquals(2, latestSemanticsOwners.size)
113+
} finally {
114+
coroutineScope.cancel()
115+
}
116+
}
117+
}
118+
119+
// @Test
120+
// fun semanticsOwnersIsSnapshotStateInComposeWindow() =
121+
// semanticsOwnersIsSnapshotStateBy(ComposeWindowSemanticOwnersTestContext())
122+
123+
// @Test
124+
// fun semanticsOwnersIsSnapshotStateInComposePanel() =
125+
// semanticsOwnersIsSnapshotStateBy(ComposePanelSemanticOwnersTestContext())
126+
127+
// @Test
128+
// fun semanticsOwnersIsSnapshotStateInImageComposeScene() =
129+
// semanticsOwnersIsSnapshotStateBy(ImageComposeSceneSemanticOwnersTestContext())
130+
}
131+
132+
private interface SemanticsOwnersTestContext {
133+
val semanticsOwners: Collection<SemanticsOwner>
134+
135+
fun runTest(
136+
content: @Composable () -> Unit,
137+
test: suspend SemanticsOwnersTestContext.() -> Unit
138+
)
139+
140+
suspend fun awaitIdle()
141+
}
142+
143+
private class ImageComposeSceneSemanticOwnersTestContext : SemanticsOwnersTestContext {
144+
private val scene: ImageComposeScene = ImageComposeScene(800, 600)
145+
private var time = 0L
146+
147+
override val semanticsOwners: Collection<SemanticsOwner>
148+
get() = scene.semanticsOwners
149+
150+
override fun runTest(
151+
content: @Composable () -> Unit,
152+
test: suspend SemanticsOwnersTestContext.() -> Unit
153+
) {
154+
scene.setContent(content)
155+
scene.render(time)
156+
runBlocking {
157+
test()
158+
}
159+
}
160+
161+
override suspend fun awaitIdle() {
162+
Snapshot.sendApplyNotifications()
163+
while (scene.hasInvalidations()) {
164+
time += 16L
165+
scene.render(time)
166+
Snapshot.sendApplyNotifications()
167+
}
168+
}
169+
}
170+
171+
private class ComposeWindowSemanticOwnersTestContext : SemanticsOwnersTestContext {
172+
private lateinit var testScope: WindowTestScope
173+
174+
override val semanticsOwners: Collection<SemanticsOwner>
175+
get() = testScope.window.semanticsOwners
176+
177+
override fun runTest(
178+
content: @Composable (() -> Unit),
179+
test: suspend SemanticsOwnersTestContext.() -> Unit
180+
) = runApplicationTest {
181+
testScope = this
182+
launchTestWindowApplication {
183+
content()
184+
}
185+
awaitIdle()
186+
test()
187+
}
188+
189+
override suspend fun awaitIdle() = testScope.awaitIdle()
190+
}
191+
192+
private class ComposePanelSemanticOwnersTestContext(
193+
val visible: Boolean = true
194+
) : SemanticsOwnersTestContext {
195+
private lateinit var testScope: WindowTestScope
196+
private lateinit var composePanel: ComposePanel
197+
198+
override val semanticsOwners: Collection<SemanticsOwner>
199+
get() = composePanel.semanticsOwners
200+
201+
override fun runTest(
202+
content: @Composable (() -> Unit),
203+
test: suspend SemanticsOwnersTestContext.() -> Unit
204+
) = runApplicationTest {
205+
testScope = this
206+
val window = JFrame()
207+
try {
208+
composePanel = ComposePanel()
209+
composePanel.setContent {
210+
content()
211+
}
212+
composePanel.isVisible = visible
213+
214+
window.contentPane.add(composePanel, BorderLayout.CENTER)
215+
window.pack()
216+
window.isVisible = true
217+
218+
awaitIdle()
219+
test()
220+
} finally {
221+
window.dispose()
222+
}
223+
}
224+
225+
override suspend fun awaitIdle() = testScope.awaitIdle()
226+
}
227+
228+
private fun Collection<SemanticsOwner>.collectText(): List<AnnotatedString> {
229+
val result = mutableListOf<AnnotatedString>()
230+
forEach {
231+
it.rootSemanticsNode.collectTextRecursive(result)
232+
}
233+
return result
234+
}
235+
236+
private fun SemanticsNode.collectTextRecursive(result: MutableList<AnnotatedString>) {
237+
result.addAll(config.getOrNull(SemanticsProperties.Text) ?: emptyList())
238+
config.getOrNull(SemanticsProperties.EditableText)?.let {
239+
result.add(it)
240+
}
241+
for (child in children) {
242+
child.collectTextRecursive(result)
243+
}
244+
}

compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/awt/ComplexApplicationTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -693,8 +693,8 @@ class ComplexApplicationTest {
693693

694694
Truth
695695
.assertWithMessage("Memory is increased more than 15% after opening multiple windows")
696-
.that(newMemory < 1.15 * oldMemory)
697-
.isTrue()
696+
.that(newMemory.toDouble()/oldMemory)
697+
.isLessThan(1.15)
698698
}
699699

700700
@Test

0 commit comments

Comments
 (0)