From 45b627041c9c09dfa7e5980b3246b6eeeda3015d Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Fri, 3 Jul 2026 22:29:20 -0400 Subject: [PATCH 01/16] Add LinuxX64 to the skiko-supported Compose platforms Skiko now builds a native linuxX64 artifact, so LinuxX64 moves into ComposePlatforms.SKIKO_SUPPORT. The linux() target helper is narrowed to linuxX64 for now: skiko-linuxarm64 klibs are built, but the compose modules have only been compiled and tested for linuxX64. buildSrc and buildSrc-fork carry the same change because the fork build consumes the -fork copies. --- .../kotlin/androidx/build/AndroidXMultiplatformExtension.kt | 4 ++-- .../kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt | 2 +- .../kotlin/androidx/build/AndroidXMultiplatformExtension.kt | 4 ++-- .../kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/buildSrc-fork/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt b/buildSrc-fork/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt index 93252641162c1..7d357831d841b 100644 --- a/buildSrc-fork/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt +++ b/buildSrc-fork/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt @@ -1,4 +1,4 @@ -/* +/* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -706,7 +706,7 @@ abstract class AndroidXMultiplatformExtension(val project: Project) { @JvmOverloads fun linux(block: Action? = null): List { - return listOfNotNull(linuxArm64(block), linuxX64(block)) + return listOfNotNull(linuxX64(block)) } @JvmOverloads diff --git a/buildSrc-fork/public/src/main/kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt b/buildSrc-fork/public/src/main/kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt index 8c5bbc3589389..53eec84457599 100644 --- a/buildSrc-fork/public/src/main/kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt +++ b/buildSrc-fork/public/src/main/kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt @@ -91,7 +91,7 @@ enum class ComposePlatforms(vararg val alternativeNames: String) { val GENERATE_KLIB = WEB + LINUX_NATIVE + WINDOWS_NATIVE + DARWIN val SKIKO_SUPPORT = - EnumSet.of(KotlinMultiplatform) + JVM_BASED + IOS + MACOS_NATIVE + WEB + EnumSet.of(KotlinMultiplatform) + JVM_BASED + IOS + MACOS_NATIVE + WEB + LinuxX64 val ALL = EnumSet.allOf(ComposePlatforms::class.java) diff --git a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt index 93252641162c1..7d357831d841b 100644 --- a/buildSrc/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt +++ b/buildSrc/private/src/main/kotlin/androidx/build/AndroidXMultiplatformExtension.kt @@ -1,4 +1,4 @@ -/* +/* * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -706,7 +706,7 @@ abstract class AndroidXMultiplatformExtension(val project: Project) { @JvmOverloads fun linux(block: Action? = null): List { - return listOfNotNull(linuxArm64(block), linuxX64(block)) + return listOfNotNull(linuxX64(block)) } @JvmOverloads diff --git a/buildSrc/public/src/main/kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt b/buildSrc/public/src/main/kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt index 8c5bbc3589389..53eec84457599 100644 --- a/buildSrc/public/src/main/kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt +++ b/buildSrc/public/src/main/kotlin/org/jetbrains/androidx/build/ComposePlatforms.kt @@ -91,7 +91,7 @@ enum class ComposePlatforms(vararg val alternativeNames: String) { val GENERATE_KLIB = WEB + LINUX_NATIVE + WINDOWS_NATIVE + DARWIN val SKIKO_SUPPORT = - EnumSet.of(KotlinMultiplatform) + JVM_BASED + IOS + MACOS_NATIVE + WEB + EnumSet.of(KotlinMultiplatform) + JVM_BASED + IOS + MACOS_NATIVE + WEB + LinuxX64 val ALL = EnumSet.allOf(ComposePlatforms::class.java) From b7855351a7fd10ba6a928380a98aff8da9034b1c Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Fri, 3 Jul 2026 22:29:28 -0400 Subject: [PATCH 02/16] Consume locally built skiko 0.0.0-SNAPSHOT from mavenLocal Local development configuration, not intended for upstream: the Linux native SkiaLayer lives in a skiko fork published to mavenLocal as 0.0.0-SNAPSHOT, so the version catalogs point at it and repos.gradle adds mavenLocal(). Drop this commit once a skiko release ships the Linux native support. --- buildSrc-fork/repos.gradle | 1 + buildSrc/repos.gradle | 1 + gradle/libs-fork.versions.toml | 2 +- gradle/libs.versions.toml | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/buildSrc-fork/repos.gradle b/buildSrc-fork/repos.gradle index 1b3f5c756e69b..dfdd1ef0d3b9b 100644 --- a/buildSrc-fork/repos.gradle +++ b/buildSrc-fork/repos.gradle @@ -66,6 +66,7 @@ def addMavenRepositories(RepositoryHandler handler) { } */ if (true /* In JetBrains Fork */) { + handler.mavenLocal() handler.mavenCentral() handler.google { content { diff --git a/buildSrc/repos.gradle b/buildSrc/repos.gradle index 1b3f5c756e69b..dfdd1ef0d3b9b 100644 --- a/buildSrc/repos.gradle +++ b/buildSrc/repos.gradle @@ -66,6 +66,7 @@ def addMavenRepositories(RepositoryHandler handler) { } */ if (true /* In JetBrains Fork */) { + handler.mavenLocal() handler.mavenCentral() handler.google { content { diff --git a/gradle/libs-fork.versions.toml b/gradle/libs-fork.versions.toml index bf3498f8483b3..9090551314d9d 100644 --- a/gradle/libs-fork.versions.toml +++ b/gradle/libs-fork.versions.toml @@ -78,7 +78,7 @@ protobuf = "4.28.2" paparazzi = "1.0.0" paparazziNative = "2022.1.1-canary-f5f9f71" shadow = "8.1.1" -skiko = "0.150.1" +skiko = "0.0.0-SNAPSHOT" spdxGradlePlugin = "0.6.0" sqldelight = "1.3.0" retrofit = "2.12.0" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf3498f8483b3..9090551314d9d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,7 +78,7 @@ protobuf = "4.28.2" paparazzi = "1.0.0" paparazziNative = "2022.1.1-canary-f5f9f71" shadow = "8.1.1" -skiko = "0.150.1" +skiko = "0.0.0-SNAPSHOT" spdxGradlePlugin = "0.6.0" sqldelight = "1.3.0" retrofit = "2.12.0" From 5c609a7240c7bb49415d96ee8a976ec29ad54acd Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Fri, 3 Jul 2026 22:29:57 -0400 Subject: [PATCH 03/16] Build navigationevent from source for linuxX64 The published org.jetbrains.androidx.navigationevent artifacts (1.1.0) ship no linuxx64 variant, so ui and ui-backhandler cannot resolve them when the linux target is enabled. Include :navigationevent:* in the fork settings, enable linuxX64 in navigationevent-compose with a nonAndroidMain/linuxMain source-set chain, and swap the published dependency for the project one in ui and ui-backhandler. --- compose/ui/ui-backhandler/build-fork.gradle | 2 +- compose/ui/ui/build-fork.gradle | 2 +- .../navigationevent-compose/build-fork.gradle | 11 ++++++++++- settings-fork.gradle | 2 ++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui-backhandler/build-fork.gradle b/compose/ui/ui-backhandler/build-fork.gradle index 9123a08b563bb..4241151eb2e93 100644 --- a/compose/ui/ui-backhandler/build-fork.gradle +++ b/compose/ui/ui-backhandler/build-fork.gradle @@ -62,7 +62,7 @@ androidXMultiplatform { jbMain { dependsOn(commonMain) dependencies { - implementation("org.jetbrains.androidx.navigationevent:navigationevent-compose:1.1.0") + implementation(project(":navigationevent:navigationevent-compose")) } } diff --git a/compose/ui/ui/build-fork.gradle b/compose/ui/ui/build-fork.gradle index fba34a989109c..9d0dab0e184ac 100644 --- a/compose/ui/ui/build-fork.gradle +++ b/compose/ui/ui/build-fork.gradle @@ -205,7 +205,7 @@ androidXMultiplatform { api(libs.skiko) implementation(libs.atomicFu) - implementation("org.jetbrains.androidx.navigationevent:navigationevent-compose:1.1.0") + implementation(project(":navigationevent:navigationevent-compose")) implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion") implementation("androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycleVersion") } diff --git a/navigationevent/navigationevent-compose/build-fork.gradle b/navigationevent/navigationevent-compose/build-fork.gradle index d23280d366275..be32ae0cf9ff9 100644 --- a/navigationevent/navigationevent-compose/build-fork.gradle +++ b/navigationevent/navigationevent-compose/build-fork.gradle @@ -39,7 +39,6 @@ androidXMultiplatform { } desktop() mac() - linux() ios() watchos() tvos() @@ -47,6 +46,7 @@ androidXMultiplatform { js() wasmJs() } + linux() defaultPlatform(PlatformIdentifier.ANDROID) @@ -55,6 +55,15 @@ androidXMultiplatform { // Keep direct references to fork versions to correctly resolve // new redirections to Google's artifacts. api("org.jetbrains.compose.runtime:runtime:1.11.0") + api(project(":navigationevent:navigationevent")) + } + + nonAndroidMain { + dependsOn(commonMain) + } + + linuxMain { + dependsOn(nonAndroidMain) } } } diff --git a/settings-fork.gradle b/settings-fork.gradle index e49fef7ca4bef..57bd55346a420 100644 --- a/settings-fork.gradle +++ b/settings-fork.gradle @@ -474,6 +474,8 @@ includeProject(":navigation:navigation-compose") includeProject(":navigation:navigation-runtime") includeProject(":navigation:navigation-testing") includeProject(":navigation3:navigation3-ui") +includeProject(":navigationevent:navigationevent") +includeProject(":navigationevent:navigationevent-testing") includeProject(":navigationevent:navigationevent-compose") includeProject(":savedstate:savedstate") includeProject(":savedstate:savedstate-compose") From 70b6c2c971bae7ecf68a6a331de6d1ff9322691b Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Fri, 3 Jul 2026 22:30:30 -0400 Subject: [PATCH 04/16] Enable linuxX64 in ui-util, ui-unit, ui-geometry, and ui-graphics These modules need no macos-style platform sources; nativeMain and skikoMain already cover them. The only new actuals are the no-op trace/traceValue in ui-util. Everything else is the linux() target declaration. Test: ./gradlew :compose:ui:ui-graphics:compileKotlinLinuxX64 --- compose/ui/ui-geometry/build-fork.gradle | 1 + compose/ui/ui-graphics/build-fork.gradle | 1 + compose/ui/ui-unit/build-fork.gradle | 1 + compose/ui/ui-util/build-fork.gradle | 1 + .../androidx/compose/ui/util/Trace.linux.kt | 24 +++++++++++++++++++ 5 files changed, 28 insertions(+) create mode 100644 compose/ui/ui-util/src/linuxMain/kotlin/androidx/compose/ui/util/Trace.linux.kt diff --git a/compose/ui/ui-geometry/build-fork.gradle b/compose/ui/ui-geometry/build-fork.gradle index 1fa7b22360de5..16c5007fd31a2 100644 --- a/compose/ui/ui-geometry/build-fork.gradle +++ b/compose/ui/ui-geometry/build-fork.gradle @@ -39,6 +39,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/ui/ui-graphics/build-fork.gradle b/compose/ui/ui-graphics/build-fork.gradle index fb10c4cbb5ae8..3146b6f35d4d3 100644 --- a/compose/ui/ui-graphics/build-fork.gradle +++ b/compose/ui/ui-graphics/build-fork.gradle @@ -43,6 +43,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/ui/ui-unit/build-fork.gradle b/compose/ui/ui-unit/build-fork.gradle index 878ebb7f2a973..9ace439e58a45 100644 --- a/compose/ui/ui-unit/build-fork.gradle +++ b/compose/ui/ui-unit/build-fork.gradle @@ -40,6 +40,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/ui/ui-util/build-fork.gradle b/compose/ui/ui-util/build-fork.gradle index 2a17db6cb46f6..8347f9923629c 100644 --- a/compose/ui/ui-util/build-fork.gradle +++ b/compose/ui/ui-util/build-fork.gradle @@ -40,6 +40,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/ui/ui-util/src/linuxMain/kotlin/androidx/compose/ui/util/Trace.linux.kt b/compose/ui/ui-util/src/linuxMain/kotlin/androidx/compose/ui/util/Trace.linux.kt new file mode 100644 index 0000000000000..3c18e041e05be --- /dev/null +++ b/compose/ui/ui-util/src/linuxMain/kotlin/androidx/compose/ui/util/Trace.linux.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.util + +actual inline fun trace(sectionName: String, block: () -> T): T { + return block() +} + +actual fun traceValue(tag: String, value: Long) { +} From 8fd0314ca5289b5bfdfed79168786167747701e4 Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Fri, 3 Jul 2026 22:30:30 -0400 Subject: [PATCH 05/16] Enable linuxX64 in ui-text linuxMain actuals mirror the darwin ones in shape but stay minimal: the default PlatformResolveInterceptor (font loading itself goes through skikoMain's FontMgr.default, backed by fontconfig), platform locale from the posix locale, and a string delegate using the Kotlin stdlib case functions, which ignore the locale argument for now. Test: ./gradlew :compose:ui:ui-text:compileKotlinLinuxX64 --- compose/ui/ui-text/build-fork.gradle | 1 + .../font/FontFamilyResolver.skiko.linux.kt | 20 +++++ .../ui/text/intl/PlatformLocale.linux.kt | 79 +++++++++++++++++++ .../platform/NativeStringDelegate.linux.kt | 37 +++++++++ 4 files changed, 137 insertions(+) create mode 100644 compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/font/FontFamilyResolver.skiko.linux.kt create mode 100644 compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/intl/PlatformLocale.linux.kt create mode 100644 compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/platform/NativeStringDelegate.linux.kt diff --git a/compose/ui/ui-text/build-fork.gradle b/compose/ui/ui-text/build-fork.gradle index ae9ce8472e7c1..a94b01ee240bd 100644 --- a/compose/ui/ui-text/build-fork.gradle +++ b/compose/ui/ui-text/build-fork.gradle @@ -44,6 +44,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/font/FontFamilyResolver.skiko.linux.kt b/compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/font/FontFamilyResolver.skiko.linux.kt new file mode 100644 index 0000000000000..b8ee0617790e2 --- /dev/null +++ b/compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/font/FontFamilyResolver.skiko.linux.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.text.font + +internal actual fun createPlatformResolveInterceptor(): PlatformResolveInterceptor = + PlatformResolveInterceptor.Default diff --git a/compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/intl/PlatformLocale.linux.kt b/compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/intl/PlatformLocale.linux.kt new file mode 100644 index 0000000000000..5a46d91096907 --- /dev/null +++ b/compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/intl/PlatformLocale.linux.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.text.intl + +import androidx.compose.runtime.Immutable +import platform.posix.getenv +import kotlinx.cinterop.toKString +import kotlinx.cinterop.ExperimentalForeignApi + +@Immutable +actual class Locale actual constructor(languageTag: String) { + private val languageTag: String = languageTag.replace('_', '-') + private val parts = this.languageTag.split('-') + + actual val language: String = parts.getOrNull(0)?.lowercase().orEmpty() + + actual val script: String = parts.drop(1).firstOrNull { it.length == 4 && it.all { c -> c.isLetter() } }?.lowercase()?.replaceFirstChar { it.uppercase() }.orEmpty() + + actual val region: String = parts.drop(1).firstOrNull { + (it.length == 2 && it.all { c -> c.isLetter() }) || (it.length == 3 && it.all { c -> c.isDigit() }) + }?.uppercase().orEmpty() + + actual fun toLanguageTag(): String = buildString { + append(language) + if (script.isNotEmpty()) { + append("-") + append(script) + } + if (region.isNotEmpty()) { + append("-") + append(region) + } + } + + actual override operator fun equals(other: Any?): Boolean { + if (other == null) return false + if (other !is Locale) return false + if (this === other) return true + return toLanguageTag() == other.toLanguageTag() + } + + actual override fun hashCode(): Int = toLanguageTag().hashCode() + + actual override fun toString(): String = toLanguageTag() + + actual companion object { + actual val current: Locale + get() = platformLocaleDelegate.current[0] + } +} + +@OptIn(ExperimentalForeignApi::class) +internal actual fun createPlatformLocaleDelegate(): PlatformLocaleDelegate = + object : PlatformLocaleDelegate { + override val current: LocaleList + get() { + val lang = getenv("LANG")?.toKString()?.substringBefore(".")?.replace('_', '-') ?: "en-US" + return LocaleList(listOf(Locale(lang))) + } + } + +internal actual fun Locale.isRtl(): Boolean { + val rtlLanguages = setOf("ar", "he", "iw", "fa", "ur", "yi", "ji", "syr", "dv", "ku") + return rtlLanguages.contains(language) +} diff --git a/compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/platform/NativeStringDelegate.linux.kt b/compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/platform/NativeStringDelegate.linux.kt new file mode 100644 index 0000000000000..cb924fb3c3033 --- /dev/null +++ b/compose/ui/ui-text/src/linuxMain/kotlin/androidx/compose/ui/text/platform/NativeStringDelegate.linux.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.text.platform + +import androidx.compose.ui.text.PlatformStringDelegate +import androidx.compose.ui.text.intl.Locale + +internal class LinuxStringDelegate : PlatformStringDelegate { + override fun toUpperCase(string: String, locale: Locale): String = + string.uppercase() + + override fun toLowerCase(string: String, locale: Locale): String = + string.lowercase() + + override fun capitalize(string: String, locale: Locale): String = + string.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + + override fun decapitalize(string: String, locale: Locale): String = + string.replaceFirstChar { it.lowercase() } +} + +internal actual fun ActualStringDelegate(): PlatformStringDelegate = + LinuxStringDelegate() From e7696cd3d1af4f0ad69185946158609b273be97a Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Fri, 3 Jul 2026 22:31:12 -0400 Subject: [PATCH 06/16] Enable linuxX64 in ui and ui-backhandler The core of the port. linuxMain provides: - window/ComposeWindow.linux.kt: X11 window plus skiko SkiaLayer hosting a CanvasLayersComposeScene, an X11 event loop that pumps events, drains the skiko main-dispatcher queue, and renders on demand, and the Window() entry point. - input/key/Key.linux.kt: X11 keysym to androidx Key map. - platform/: in-memory clipboard stub, UriHandler via xdg-open. - No-op actuals for drag-and-drop, pointer icons, interop views, and focusability, mirroring the macos port's level of support. PostDelayedDispatcher moves from nonJvmMain to per-platform actuals: kotlinx-coroutines has no Dispatchers.Main on linuxX64, so the native actual routes Linux to skiko's SkikoDispatchers.Main, whose queue the X11 event loop drains. Test: ./gradlew :compose:ui:ui:compileKotlinLinuxX64, plus a material3 demo app on linuxX64 (screenshot, synthetic clicks, clean shutdown). --- compose/ui/ui-backhandler/build-fork.gradle | 1 + compose/ui/ui/build-fork.gradle | 1 + .../kotlin/androidx/compose/ui/Actuals.js.kt | 5 +- .../ui/draganddrop/DragAndDrop.linux.kt | 37 ++ .../compose/ui/focus/Focusability.linux.kt | 21 + .../compose/ui/input/key/Key.linux.kt | 583 ++++++++++++++++++ .../ui/input/pointer/PointerIcon.linux.kt | 24 + .../util/PlatformVelocityTracker.linux.kt | 19 + .../ui/platform/PlatformClipboard.linux.kt | 77 +++ .../ui/platform/PlatformUriHandler.linux.kt | 27 + .../ui/viewinterop/InteropView.linux.kt | 21 + .../compose/ui/window/ComposeWindow.linux.kt | 321 ++++++++++ .../androidx/compose/ui/Actuals.native.kt | 7 + .../androidx/compose/ui/Actuals.nonJvm.kt | 2 - .../androidx/compose/ui/Actuals.wasm.kt | 5 +- 15 files changed, 1147 insertions(+), 4 deletions(-) create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.linux.kt create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/focus/Focusability.linux.kt create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/key/Key.linux.kt create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.linux.kt create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.linux.kt create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformClipboard.linux.kt create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformUriHandler.linux.kt create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/viewinterop/InteropView.linux.kt create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt diff --git a/compose/ui/ui-backhandler/build-fork.gradle b/compose/ui/ui-backhandler/build-fork.gradle index 4241151eb2e93..5913f122641eb 100644 --- a/compose/ui/ui-backhandler/build-fork.gradle +++ b/compose/ui/ui-backhandler/build-fork.gradle @@ -30,6 +30,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/ui/ui/build-fork.gradle b/compose/ui/ui/build-fork.gradle index 9d0dab0e184ac..a9ed9bd17552c 100644 --- a/compose/ui/ui/build-fork.gradle +++ b/compose/ui/ui/build-fork.gradle @@ -61,6 +61,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/Actuals.js.kt b/compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/Actuals.js.kt index 1b1e0a9643893..23e70a9e0b5c5 100644 --- a/compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/Actuals.js.kt +++ b/compose/ui/ui/src/jsMain/kotlin/androidx/compose/ui/Actuals.js.kt @@ -26,4 +26,7 @@ internal actual fun areObjectsOfSameType(a: Any, b: Any): Boolean { internal actual fun currentTimeMillis(): Long { return window.performance.now().toLong() -} \ No newline at end of file +} + +internal actual val PostDelayedDispatcher: kotlin.coroutines.CoroutineContext + get() = kotlinx.coroutines.Dispatchers.Main \ No newline at end of file diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.linux.kt new file mode 100644 index 0000000000000..9ddb4c2f031cc --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.linux.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.draganddrop + +import androidx.compose.ui.geometry.Offset + +/** + * A representation of an event sent by the platform during a drag and drop operation on Linux. + */ +actual class DragAndDropEvent private constructor() + +/** + * Returns the position of this [DragAndDropEvent] relative to the root Compose View in the + * layout hierarchy. + */ +internal actual val DragAndDropEvent.positionInRoot: Offset + get() = TODO("Not yet implemented") + +/** + * Definition for a type representing transferable data. It could be a remote URI, + * rich text data on the clip board, a local file, or more. + */ +actual class DragAndDropTransferData private constructor() diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/focus/Focusability.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/focus/Focusability.linux.kt new file mode 100644 index 0000000000000..03924d382689b --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/focus/Focusability.linux.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.focus + +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode + +internal actual fun systemDefinedCanFocus(node: CompositionLocalConsumerModifierNode) = true diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/key/Key.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/key/Key.linux.kt new file mode 100644 index 0000000000000..88e00277616f1 --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/key/Key.linux.kt @@ -0,0 +1,583 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.input.key + +import androidx.compose.ui.input.key.Key.Companion.Number + +/** + * Actual implementation of [Key] for JS and Native on Linux. + * + * @param keyCode an integer code representing the key pressed. Note: This keycode can be used to + * uniquely identify a hardware key. + */ +actual value class Key(val keyCode: Long) { + actual companion object { + /** Unknown key. */ + actual val Unknown = Key(-1) + + /** + * System Home key. + * + * This key is handled by the framework and is never delivered to applications. + */ + @Deprecated( + "`Key.Home` was mapped to the keyboard \"Home\" key in error. It is meant to be the" + + " \"system\" home key on Android, and should never be delivered to applications. " + + "For the keyboard \"Home\" key use `Key.MoveHome`. For the Android system " + + "\"Home\" key (unlikely to be needed), use `Key.SystemHome`", + level = DeprecationLevel.ERROR, + ) + actual val Home = Key(115) + + /** + * System Home key. + * + * This key is handled by the framework and is never delivered to applications. + */ + actual val SystemHome: Key = Key(-1000000207) + + /** + * Up Arrow Key / Directional Pad Up key. + * + * May also be synthesized from trackball motions. + */ + actual val DirectionUp = Key(126) + + /** + * Down Arrow Key / Directional Pad Down key. + * + * May also be synthesized from trackball motions. + */ + actual val DirectionDown = Key(125) + + /** + * Left Arrow Key / Directional Pad Left key. + * + * May also be synthesized from trackball motions. + */ + actual val DirectionLeft = Key(123) + + /** + * Right Arrow Key / Directional Pad Right key. + * + * May also be synthesized from trackball motions. + */ + actual val DirectionRight = Key(124) + + /** '0' key. */ + actual val Zero = Key(29) + + /** '1' key. */ + actual val One = Key(18) + + /** '2' key. */ + actual val Two = Key(19) + + /** '3' key. */ + actual val Three = Key(20) + + /** '4' key. */ + actual val Four = Key(21) + + /** '5' key. */ + actual val Five = Key(23) + + /** '6' key. */ + actual val Six = Key(22) + + /** '7' key. */ + actual val Seven = Key(26) + + /** '8' key. */ + actual val Eight = Key(28) + + /** '9' key. */ + actual val Nine = Key(25) + + /** '-' key. */ + actual val Minus = Key(27) + + /** '=' key. */ + actual val Equals = Key(24) + + /** 'A' key. */ + actual val A = Key(0) + + /** 'B' key. */ + actual val B = Key(11) + + /** 'C' key. */ + actual val C = Key(8) + + /** 'D' key. */ + actual val D = Key(2) + + /** 'E' key. */ + actual val E = Key(14) + + /** 'F' key. */ + actual val F = Key(3) + + /** 'G' key. */ + actual val G = Key(5) + + /** 'H' key. */ + actual val H = Key(4) + + /** 'I' key. */ + actual val I = Key(34) + + /** 'J' key. */ + actual val J = Key(38) + + /** 'K' key. */ + actual val K = Key(40) + + /** 'L' key. */ + actual val L = Key(37) + + /** 'M' key. */ + actual val M = Key(46) + + /** 'N' key. */ + actual val N = Key(45) + + /** 'O' key. */ + actual val O = Key(31) + + /** 'P' key. */ + actual val P = Key(35) + + /** 'Q' key. */ + actual val Q = Key(12) + + /** 'R' key. */ + actual val R = Key(15) + + /** 'S' key. */ + actual val S = Key(1) + + /** 'T' key. */ + actual val T = Key(17) + + /** 'U' key. */ + actual val U = Key(32) + + /** 'V' key. */ + actual val V = Key(9) + + /** 'W' key. */ + actual val W = Key(13) + + /** 'X' key. */ + actual val X = Key(7) + + /** 'Y' key. */ + actual val Y = Key(16) + + /** 'Z' key. */ + actual val Z = Key(6) + + /** ',' key. */ + actual val Comma = Key(43) + + /** '.' key. */ + actual val Period = Key(47) + + /** Left Alt modifier key. */ + actual val AltLeft = Key(58) + + /** Right Alt modifier key. */ + actual val AltRight = Key(61) + + /** Left Shift modifier key. */ + actual val ShiftLeft = Key(56) + + /** Right Shift modifier key. */ + actual val ShiftRight = Key(60) + + /** Tab key. */ + actual val Tab = Key(48) + + /** Space key. */ + actual val Spacebar = Key(49) + + /** Enter key. */ + actual val Enter = Key(36) + + /** + * Backspace key. + * + * Deletes characters before the insertion point, unlike [Delete]. + */ + actual val Backspace = Key(51) + + /** + * Delete key. + * + * Deletes characters ahead of the insertion point, unlike [Backspace]. + */ + actual val Delete = Key(117) + + /** Escape key. */ + actual val Escape = Key(53) + + /** Left Control modifier key. */ + actual val CtrlLeft = Key(59) + + /** Right Control modifier key. */ + actual val CtrlRight = Key(62) + + /** Caps Lock key. */ + actual val CapsLock = Key(57) + + /** Scroll Lock key. */ + actual val ScrollLock = Key(107) + + /** Left Meta modifier key. */ + actual val MetaLeft = Key(55) + + /** Right Meta modifier key. */ + actual val MetaRight = Key(54) + + /** System Request / Print Screen key. */ + actual val PrintScreen = Key(105) + + /** + * Insert key. + * + * Toggles insert / overwrite edit mode. + */ + actual val Insert = Key(114) + + /** '`' (backtick) key. */ + actual val Grave = Key(50) + + /** '[' key. */ + actual val LeftBracket = Key(33) + + /** ']' key. */ + actual val RightBracket = Key(30) + + /** '/' key. */ + actual val Slash = Key(42) + + /** '\' key. */ + actual val Backslash = Key(44) + + /** ';' key. */ + actual val Semicolon = Key(41) + + /** Page Up key. */ + actual val PageUp = Key(116) + + /** Page Down key. */ + actual val PageDown = Key(121) + + /** F1 key. */ + actual val F1 = Key(122) + + /** F2 key. */ + actual val F2 = Key(120) + + /** F3 key. */ + actual val F3 = Key(99) + + /** F4 key. */ + actual val F4 = Key(118) + + /** F5 key. */ + actual val F5 = Key(96) + + /** F6 key. */ + actual val F6 = Key(97) + + /** F7 key. */ + actual val F7 = Key(98) + + /** F8 key. */ + actual val F8 = Key(100) + + /** F9 key. */ + actual val F9 = Key(101) + + /** F10 key. */ + actual val F10 = Key(109) + + /** F11 key. */ + actual val F11 = Key(103) + + /** F12 key. */ + actual val F12 = Key(111) + + /** + * Num Lock key. + * + * This is the Num Lock key; it is different from [Number]. + * This key alters the behavior of other keys on the numeric keypad. + */ + actual val NumLock = Key(71) + + /** Numeric keypad '0' key. */ + actual val NumPad0 = Key(82) + + /** Numeric keypad '1' key. */ + actual val NumPad1 = Key(83) + + /** Numeric keypad '2' key. */ + actual val NumPad2 = Key(84) + + /** Numeric keypad '3' key. */ + actual val NumPad3 = Key(85) + + /** Numeric keypad '4' key. */ + actual val NumPad4 = Key(86) + + /** Numeric keypad '5' key. */ + actual val NumPad5 = Key(87) + + /** Numeric keypad '6' key. */ + actual val NumPad6 = Key(88) + + /** Numeric keypad '7' key. */ + actual val NumPad7 = Key(89) + + /** Numeric keypad '8' key. */ + actual val NumPad8 = Key(91) + + /** Numeric keypad '9' key. */ + actual val NumPad9 = Key(92) + + /** Numeric keypad '/' key (for division). */ + actual val NumPadDivide = Key(75) + + /** Numeric keypad '*' key (for multiplication). */ + actual val NumPadMultiply = Key(67) + + /** Numeric keypad '-' key (for subtraction). */ + actual val NumPadSubtract = Key(78) + + /** Numeric keypad '+' key (for addition). */ + actual val NumPadAdd = Key(69) + + /** Numeric keypad Enter key. */ + actual val NumPadEnter = Key(76) + + actual val MoveHome = Key(115) + + actual val MoveEnd = Key(119) + + // Unsupported Keys + actual val SoftLeft = Key(-1000000001) + actual val SoftRight = Key(-1000000002) + actual val Back = Key(-1000000003) + actual val NavigatePrevious = Key(-1000000004) + actual val NavigateNext = Key(-1000000005) + actual val NavigateIn = Key(-1000000006) + actual val NavigateOut = Key(-1000000007) + actual val SystemNavigationUp = Key(-1000000008) + actual val SystemNavigationDown = Key(-1000000009) + actual val SystemNavigationLeft = Key(-1000000010) + actual val SystemNavigationRight = Key(-1000000011) + actual val Call = Key(-1000000012) + actual val EndCall = Key(-1000000013) + actual val DirectionCenter = Key(-1000000014) + actual val DirectionUpLeft = Key(-1000000015) + actual val DirectionDownLeft = Key(-1000000016) + actual val DirectionUpRight = Key(-1000000017) + actual val DirectionDownRight = Key(-1000000018) + actual val VolumeUp = Key(-1000000019) + actual val VolumeDown = Key(-1000000020) + actual val Power = Key(-1000000021) + actual val Camera = Key(-1000000022) + actual val Clear = Key(-1000000023) + actual val Symbol = Key(-1000000024) + actual val Browser = Key(-1000000025) + actual val Envelope = Key(-1000000026) + actual val Function = Key(-1000000027) + actual val Break = Key(-1000000028) + actual val Number = Key(-1000000031) + actual val HeadsetHook = Key(-1000000032) + actual val Focus = Key(-1000000033) + actual val Menu = Key(-1000000034) + actual val Notification = Key(-1000000035) + actual val Search = Key(-1000000036) + actual val PictureSymbols = Key(-1000000037) + actual val SwitchCharset = Key(-1000000038) + actual val ButtonA = Key(-1000000039) + actual val ButtonB = Key(-1000000040) + actual val ButtonC = Key(-1000000041) + actual val ButtonX = Key(-1000000042) + actual val ButtonY = Key(-1000000043) + actual val ButtonZ = Key(-1000000044) + actual val ButtonL1 = Key(-1000000045) + actual val ButtonR1 = Key(-1000000046) + actual val ButtonL2 = Key(-1000000047) + actual val ButtonR2 = Key(-1000000048) + actual val ButtonThumbLeft = Key(-1000000049) + actual val ButtonThumbRight = Key(-1000000050) + actual val ButtonStart = Key(-1000000051) + actual val ButtonSelect = Key(-1000000052) + actual val ButtonMode = Key(-1000000053) + actual val Button1 = Key(-1000000054) + actual val Button2 = Key(-1000000055) + actual val Button3 = Key(-1000000056) + actual val Button4 = Key(-1000000057) + actual val Button5 = Key(-1000000058) + actual val Button6 = Key(-1000000059) + actual val Button7 = Key(-1000000060) + actual val Button8 = Key(-1000000061) + actual val Button9 = Key(-1000000062) + actual val Button10 = Key(-1000000063) + actual val Button11 = Key(-1000000064) + actual val Button12 = Key(-1000000065) + actual val Button13 = Key(-1000000066) + actual val Button14 = Key(-1000000067) + actual val Button15 = Key(-1000000068) + actual val Button16 = Key(-1000000069) + actual val Forward = Key(-1000000070) + actual val MediaPlay = Key(-1000000071) + actual val MediaPause = Key(-1000000072) + actual val MediaPlayPause = Key(-1000000073) + actual val MediaStop = Key(-1000000074) + actual val MediaRecord = Key(-1000000075) + actual val MediaNext = Key(-1000000076) + actual val MediaPrevious = Key(-1000000077) + actual val MediaRewind = Key(-1000000078) + actual val MediaFastForward = Key(-1000000079) + actual val MediaClose = Key(-1000000080) + actual val MediaAudioTrack = Key(-1000000081) + actual val MediaEject = Key(-1000000082) + actual val MediaTopMenu = Key(-1000000083) + actual val MediaSkipForward = Key(-1000000084) + actual val MediaSkipBackward = Key(-1000000085) + actual val MediaStepForward = Key(-1000000086) + actual val MediaStepBackward = Key(-1000000087) + actual val MicrophoneMute = Key(-1000000088) + actual val VolumeMute = Key(-1000000089) + actual val Info = Key(-1000000090) + actual val ChannelUp = Key(-1000000091) + actual val ChannelDown = Key(-1000000092) + actual val ZoomIn = Key(-1000000093) + actual val ZoomOut = Key(-1000000094) + actual val Tv = Key(-1000000095) + actual val Window = Key(-1000000096) + actual val Guide = Key(-1000000097) + actual val Dvr = Key(-1000000098) + actual val Bookmark = Key(-1000000099) + actual val Captions = Key(-1000000100) + actual val Settings = Key(-1000000101) + actual val TvPower = Key(-1000000102) + actual val TvInput = Key(-1000000103) + actual val SetTopBoxPower = Key(-1000000104) + actual val SetTopBoxInput = Key(-1000000105) + actual val AvReceiverPower = Key(-1000000106) + actual val AvReceiverInput = Key(-1000000107) + actual val ProgramRed = Key(-1000000108) + actual val ProgramGreen = Key(-1000000109) + actual val ProgramYellow = Key(-1000000110) + actual val ProgramBlue = Key(-1000000111) + actual val AppSwitch = Key(-1000000112) + actual val LanguageSwitch = Key(-1000000113) + actual val MannerMode = Key(-1000000114) + actual val Toggle2D3D = Key(-1000000125) + actual val Contacts = Key(-1000000126) + actual val Calendar = Key(-1000000127) + actual val Music = Key(-1000000128) + actual val Calculator = Key(-1000000129) + actual val ZenkakuHankaru = Key(-1000000130) + actual val Eisu = Key(-1000000131) + actual val Muhenkan = Key(-1000000132) + actual val Henkan = Key(-1000000133) + actual val KatakanaHiragana = Key(-1000000134) + actual val Yen = Key(-1000000135) + actual val Ro = Key(-1000000136) + actual val Kana = Key(-1000000137) + actual val Assist = Key(-1000000138) + actual val BrightnessDown = Key(-1000000139) + actual val BrightnessUp = Key(-1000000140) + actual val Sleep = Key(-1000000141) + actual val WakeUp = Key(-1000000142) + actual val SoftSleep = Key(-1000000143) + actual val Pairing = Key(-1000000144) + actual val LastChannel = Key(-1000000145) + actual val TvDataService = Key(-1000000146) + actual val VoiceAssist = Key(-1000000147) + actual val TvRadioService = Key(-1000000148) + actual val TvTeletext = Key(-1000000149) + actual val TvNumberEntry = Key(-1000000150) + actual val TvTerrestrialAnalog = Key(-1000000151) + actual val TvTerrestrialDigital = Key(-1000000152) + actual val TvSatellite = Key(-1000000153) + actual val TvSatelliteBs = Key(-1000000154) + actual val TvSatelliteCs = Key(-1000000155) + actual val TvSatelliteService = Key(-1000000156) + actual val TvNetwork = Key(-1000000157) + actual val TvAntennaCable = Key(-1000000158) + actual val TvInputHdmi1 = Key(-1000000159) + actual val TvInputHdmi2 = Key(-1000000160) + actual val TvInputHdmi3 = Key(-1000000161) + actual val TvInputHdmi4 = Key(-1000000162) + actual val TvInputComposite1 = Key(-1000000163) + actual val TvInputComposite2 = Key(-1000000164) + actual val TvInputComponent1 = Key(-1000000165) + actual val TvInputComponent2 = Key(-1000000166) + actual val TvInputVga1 = Key(-1000000167) + actual val TvAudioDescription = Key(-1000000168) + actual val TvAudioDescriptionMixingVolumeUp = Key(-1000000169) + actual val TvAudioDescriptionMixingVolumeDown = Key(-1000000170) + actual val TvZoomMode = Key(-1000000171) + actual val TvContentsMenu = Key(-1000000172) + actual val TvMediaContextMenu = Key(-1000000173) + actual val TvTimerProgramming = Key(-1000000174) + actual val StemPrimary = Key(-1000000175) + actual val Stem1 = Key(-1000000176) + actual val Stem2 = Key(-1000000177) + actual val Stem3 = Key(-1000000178) + actual val AllApps = Key(-1000000179) + actual val Refresh = Key(-1000000180) + actual val ThumbsUp = Key(-1000000181) + actual val ThumbsDown = Key(-1000000182) + actual val ProfileSwitch = Key(-1000000183) + actual val Help = Key(-1000000184) + actual val Plus = Key(-1000000185) + actual val Multiply = Key(-1000000186) + actual val Pound = Key(-1000000187) + actual val Cut = Key(-1000000188) + actual val Copy = Key(-1000000189) + actual val Paste = Key(-1000000190) + actual val Apostrophe = Key(-1000000191) + actual val At = Key(-10000001902) + actual val NumPadDot = Key(-1000000193) + actual val NumPadComma = Key(-1000000194) + actual val NumPadEquals = Key(-1000000195) + actual val NumPadLeftParenthesis = Key(-1000000196) + actual val NumPadRightParenthesis = Key(-1000000197) + actual val NumPadDirectionUp = Key(-1000000198) + actual val NumPadDirectionDown = Key(-1000000199) + actual val NumPadDirectionLeft = Key(-1000000200) + actual val NumPadDirectionRight = Key(-1000000201) + actual val NumPadMoveHome = Key(-1000000202) + actual val NumPadMoveEnd = Key(-1000000203) + actual val NumPadPageUp = Key(-1000000204) + actual val NumPadPageDown = Key(-1000000205) + actual val NumPadInsert = Key(-1000000206) + actual val NumPadDelete: Key = Key(-1000000208) + } + + actual override fun toString() = "Key keyCode: $keyCode" +} diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.linux.kt new file mode 100644 index 0000000000000..d907ca0280260 --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/pointer/PointerIcon.linux.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.input.pointer + +internal class LinuxCursor(val type: String) : PointerIcon + +internal actual val pointerIconDefault: PointerIcon = LinuxCursor("default") +internal actual val pointerIconCrosshair: PointerIcon = LinuxCursor("crosshair") +internal actual val pointerIconText: PointerIcon = LinuxCursor("text") +internal actual val pointerIconHand: PointerIcon = LinuxCursor("hand") diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.linux.kt new file mode 100644 index 0000000000000..0eef074c1c587 --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.linux.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.input.pointer.util + +internal actual fun PlatformVelocityTracker(): PlatformVelocityTracker = Lsq2VelocityTracker() diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformClipboard.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformClipboard.linux.kt new file mode 100644 index 0000000000000..37bcfc5dbdd10 --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformClipboard.linux.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.text.AnnotatedString + +actual typealias NativeClipboard = Any + +private var memoryClipboardText: String? = null + +private class LinuxPlatformClipboardManager : ClipboardManager { + override fun getText(): AnnotatedString? = + memoryClipboardText?.let { AnnotatedString(it) } + + override fun setText(annotatedString: AnnotatedString) { + memoryClipboardText = annotatedString.text + } + + override fun hasText(): Boolean = !memoryClipboardText.isNullOrEmpty() + + override fun getClip(): ClipEntry? = memoryClipboardText?.let { ClipEntry.withPlainText(it) } + + override fun setClip(clipEntry: ClipEntry?) { + memoryClipboardText = clipEntry?.plainText + } +} + +internal class LinuxPlatformClipboard : Clipboard { + override suspend fun getClipEntry(): ClipEntry? { + val str = memoryClipboardText + if (str.isNullOrEmpty()) return null + return ClipEntry.withPlainText(str) + } + + override suspend fun setClipEntry(clipEntry: ClipEntry?) { + memoryClipboardText = clipEntry?.plainText + } + + override val nativeClipboard: NativeClipboard + get() = Any() +} + +internal actual fun createPlatformClipboardManager(): ClipboardManager = LinuxPlatformClipboardManager() + +internal actual fun createPlatformClipboard(): Clipboard = LinuxPlatformClipboard() + +actual class ClipEntry internal constructor() { + actual val clipMetadata: ClipMetadata + get() = TODO("ClipMetadata is not implemented.") + + internal var plainText: String? = null + + @ExperimentalComposeUiApi + fun getPlainText(): String? = plainText + + companion object { + @ExperimentalComposeUiApi + fun withPlainText(text: String): ClipEntry = ClipEntry().apply { + plainText = text + } + } +} diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformUriHandler.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformUriHandler.linux.kt new file mode 100644 index 0000000000000..d48ab6c3bc3a2 --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformUriHandler.linux.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform + +import platform.posix.system + +private class LinuxUriHandler : UriHandler { + override fun openUri(uri: String) { + system("xdg-open \"$uri\"") + } +} + +internal actual fun createPlatformUriHandler(): UriHandler = LinuxUriHandler() diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/viewinterop/InteropView.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/viewinterop/InteropView.linux.kt new file mode 100644 index 0000000000000..8956c31f2ee9f --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/viewinterop/InteropView.linux.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.viewinterop + +actual typealias InteropView = Any + +internal actual typealias InteropViewGroup = Any diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt new file mode 100644 index 0000000000000..f8ba9aaf192c8 --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt @@ -0,0 +1,321 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.window + +import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.PointerButtons +import androidx.compose.ui.input.pointer.PointerKeyboardModifiers +import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner +import androidx.compose.ui.platform.PlatformContext +import androidx.compose.ui.platform.WindowInfoImpl +import androidx.compose.ui.scene.CanvasLayersComposeScene +import androidx.compose.ui.scene.SingleComposeSceneRenderingScope +import androidx.compose.ui.platform.FrameRecomposer +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toDpSize +import androidx.compose.ui.unit.toSize +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.enableSavedStateHandles +import kotlinx.cinterop.* +import org.jetbrains.skia.Canvas +import org.jetbrains.skia.Color +import org.jetbrains.skiko.SkiaLayer +import org.jetbrains.skiko.SkikoRenderDelegate +import org.jetbrains.skiko.X11Window +import org.jetbrains.skiko.LinuxMainDispatcher +import org.jetbrains.skiko.SkikoDispatchers +import x11gl.* +import platform.posix.* + +interface WindowScope { + val window: X11Window +} + +private val openWindows = mutableListOf() + +private fun runEventLoop() { + val arena = Arena() + try { + val event = arena.alloc() + while (openWindows.isNotEmpty()) { + LinuxMainDispatcher.drain() + val processedCount = openWindows.toList().sumOf { it.pumpPendingEvents(event) } + LinuxMainDispatcher.drain() + if (processedCount == 0) { + waitForEvents(timeoutMs = 10) + } + } + } finally { + arena.clear() + } +} + +private fun waitForEvents(timeoutMs: Int): Int = memScoped { + val firstWindow = openWindows.firstOrNull() ?: return@memScoped 0 + val fd = alloc().apply { + this.fd = XConnectionNumber(firstWindow.x11Window.display) + events = POLLIN.toShort() + } + poll(fd.ptr, 1u, timeoutMs) +} + +fun Window( + title: String = "ComposeWindow", + size: DpSize = DpSize(800.dp, 600.dp), + content: @Composable WindowScope.() -> Unit, +) { + val window = ComposeWindow( + title = title, + size = size, + content = content, + ) + openWindows.add(window) + if (openWindows.size == 1) { + runEventLoop() + } +} + +private class ComposeWindow( + title: String, + size: DpSize, + content: @Composable WindowScope.() -> Unit, +) : WindowScope { + private var isDisposed = false + + val x11Window = X11Window(title, size.width.value.toInt(), size.height.value.toInt()) + override val window: X11Window get() = x11Window + + private val _windowInfo = WindowInfoImpl().apply { + isWindowFocused = true + } + private val archComponentsOwner = DefaultArchitectureComponentsOwner() + + private val frameRecomposer = FrameRecomposer(SkikoDispatchers.Main) { skiaLayer.needRender() } + private val sceneRenderingScope = SingleComposeSceneRenderingScope { skiaLayer.needRender() } + + private val platformContext: PlatformContext = + object : PlatformContext by PlatformContext.Empty() { + override val windowInfo get() = _windowInfo + override val architectureComponentsOwner get() = archComponentsOwner + override fun setPointerIcon(pointerIcon: PointerIcon) { + // Ignore + } + } + + private val skiaLayer = SkiaLayer() + private val scene = CanvasLayersComposeScene( + frameRecomposer = frameRecomposer, + platformContext = platformContext, + invalidateLayout = sceneRenderingScope::onSceneInvalidation, + invalidateDraw = sceneRenderingScope::onSceneInvalidation, + ) + + private val renderDelegate = object : SkikoRenderDelegate { + override fun onRender(canvas: Canvas, width: Int, height: Int, nanoTime: Long) { + val sizeInPx = IntSize(width, height) + _windowInfo.containerSize = sizeInPx + _windowInfo.containerDpSize = sizeInPx.toSize().toDpSize(scene.density) + scene.size = sizeInPx + with(sceneRenderingScope) { + scene.render(frameRecomposer, canvas.asComposeCanvas(), nanoTime) + } + } + } + + init { + skiaLayer.renderDelegate = renderDelegate + skiaLayer.attachTo(x11Window) + x11Window.show() + + scene.density = Density(x11Window.contentScale) + scene.setContent { + content() + } + + archComponentsOwner.enableSavedStateHandles() + archComponentsOwner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + fun dispose() { + if (isDisposed) return + archComponentsOwner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + archComponentsOwner.viewModelStore.clear() + skiaLayer.detach() + scene.close() + frameRecomposer.close() + x11Window.close() + openWindows.remove(this) + isDisposed = true + } + + /** + * Handles every X11 event queued for this window's display and returns how many were + * processed. Stops as soon as the window is disposed: [handleEvent] may close the + * display (WM_DELETE_WINDOW → [dispose]), after which it must not be touched again. + */ + fun pumpPendingEvents(event: XEvent): Int = + generateSequence { + event.takeIf { !isDisposed && XPending(x11Window.display) > 0 }?.also { + XNextEvent(x11Window.display, it.ptr) + handleEvent(it) + } + }.count() + + private fun handleEvent(event: XEvent) { + if (isDisposed) return + when (event.type) { + ClientMessage -> { + if (event.xclient.data.l[0].toULong() == x11Window.wmDeleteWindow) { + dispose() + } + } + + Expose -> skiaLayer.needRender() + + ConfigureNotify -> { + val w = event.xconfigure.width + val h = event.xconfigure.height + x11Window.width = w + x11Window.height = h + skiaLayer.needRender() + } + + MotionNotify -> { + val x = event.xmotion.x.toFloat() + val y = event.xmotion.y.toFloat() + onMouseEvent(PointerEventType.Move, x, y) + } + + ButtonPress -> { + val x = event.xbutton.x.toFloat() + val y = event.xbutton.y.toFloat() + val button = event.xbutton.button.toInt() + handleButton(x, y, button, pressed = true) + } + + ButtonRelease -> { + val x = event.xbutton.x.toFloat() + val y = event.xbutton.y.toFloat() + val button = event.xbutton.button.toInt() + handleButton(x, y, button, pressed = false) + } + + KeyPress -> handleKey(event, KeyEventType.KeyDown) + + KeyRelease -> handleKey(event, KeyEventType.KeyUp) + } + } + + private fun onMouseEvent(eventType: PointerEventType, x: Float, y: Float) { + scene.sendPointerEvent( + eventType = eventType, + position = Offset(x, y) + ) + } + + private fun handleButton(x: Float, y: Float, button: Int, pressed: Boolean) { + when (button) { + 4, 5 -> { + if (pressed) { + val deltaY = if (button == 4) -1f else 1f + scene.sendPointerEvent( + eventType = PointerEventType.Scroll, + position = Offset(x, y), + scrollDelta = Offset(0f, deltaY) + ) + } + } + else -> { + val composeButton = when (button) { + 1 -> PointerButton.Primary + 2 -> PointerButton.Tertiary + 3 -> PointerButton.Secondary + else -> PointerButton(button) + } + val eventType = if (pressed) PointerEventType.Press else PointerEventType.Release + scene.sendPointerEvent( + eventType = eventType, + position = Offset(x, y), + button = composeButton + ) + } + } + } + + private fun handleKey(event: XEvent, type: KeyEventType) = memScoped { + val buffer = allocArray(32) + val keysym = alloc() + val length = XLookupString(event.xkey.ptr, buffer, 32, keysym.ptr, null) + val text = if (type == KeyEventType.KeyDown && length > 0) { + buffer.readBytes(length).decodeToString() + } else { + "" + } + keysymToKey[keysym.value.toLong()]?.let { key -> + scene.sendKeyEvent( + KeyEvent( + key = key, + type = type, + codePoint = text.firstOrNull()?.code ?: 0, + nativeEvent = event, + ) + ) + } + } +} + +private val letterKeys = listOf( + Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J, Key.K, Key.L, Key.M, + Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z, +) + +private val digitKeys = listOf( + Key.Zero, Key.One, Key.Two, Key.Three, Key.Four, + Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine, +) + +/** + * X11 keysym → Compose [Key]. Latin letter keysyms equal their ASCII codes, so both + * cases map to the same key; keysyms absent from this table are ignored. + */ +private val keysymToKey: Map = + mapOf( + 0xff1bL to Key.Escape, + 0xff0dL to Key.Enter, + 0x0020L to Key.Spacebar, + 0xff08L to Key.Backspace, + 0xffffL to Key.Delete, + 0xff52L to Key.DirectionUp, + 0xff54L to Key.DirectionDown, + 0xff51L to Key.DirectionLeft, + 0xff53L to Key.DirectionRight, + ) + + ('a'..'z').zip(letterKeys).flatMap { (char, key) -> + listOf(char.code.toLong() to key, char.uppercaseChar().code.toLong() to key) + } + + ('0'..'9').zip(digitKeys).map { (char, key) -> char.code.toLong() to key } diff --git a/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/Actuals.native.kt b/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/Actuals.native.kt index ddf5ee5930433..14babc3a033a3 100644 --- a/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/Actuals.native.kt +++ b/compose/ui/ui/src/nativeMain/kotlin/androidx/compose/ui/Actuals.native.kt @@ -28,3 +28,10 @@ internal actual fun currentTimeMillis(): Long { return markNow.elapsedNow().inWholeMilliseconds } +internal actual val PostDelayedDispatcher: kotlin.coroutines.CoroutineContext + get() = if (Platform.osFamily == OsFamily.LINUX) { + org.jetbrains.skiko.SkikoDispatchers.Main + } else { + kotlinx.coroutines.Dispatchers.Main + } + diff --git a/compose/ui/ui/src/nonJvmMain/kotlin/androidx/compose/ui/Actuals.nonJvm.kt b/compose/ui/ui/src/nonJvmMain/kotlin/androidx/compose/ui/Actuals.nonJvm.kt index d79080dbde46b..6d961e0075899 100644 --- a/compose/ui/ui/src/nonJvmMain/kotlin/androidx/compose/ui/Actuals.nonJvm.kt +++ b/compose/ui/ui/src/nonJvmMain/kotlin/androidx/compose/ui/Actuals.nonJvm.kt @@ -33,5 +33,3 @@ internal actual fun InspectorInfo.tryPopulateReflectively( ) { } -internal actual val PostDelayedDispatcher: CoroutineContext - get() = Dispatchers.Main diff --git a/compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/Actuals.wasm.kt b/compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/Actuals.wasm.kt index 26aa8ea78e57e..eb66a0580eaed 100644 --- a/compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/Actuals.wasm.kt +++ b/compose/ui/ui/src/wasmJsMain/kotlin/androidx/compose/ui/Actuals.wasm.kt @@ -24,4 +24,7 @@ internal actual fun areObjectsOfSameType(a: Any, b: Any): Boolean { internal actual fun currentTimeMillis(): Long { return window.performance.now().toLong() -} \ No newline at end of file +} + +internal actual val PostDelayedDispatcher: kotlin.coroutines.CoroutineContext + get() = kotlinx.coroutines.Dispatchers.Main \ No newline at end of file From 86fdb36c97d26368b6891771ea922e112f0ad32c Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Fri, 3 Jul 2026 22:31:25 -0400 Subject: [PATCH 07/16] Enable linuxX64 in foundation and foundation-layout foundation's linuxMain actuals mirror the macos ones: platform text selection, context menus, text field internals, lazy list prefetch, scroll config, and clipboard conversion helpers, each at the same stub-or-minimal level the macos port uses. Test: ./gradlew :compose:foundation:foundation:compileKotlinLinuxX64 --- .../foundation-layout/build-fork.gradle | 1 + .../foundation/foundation/build-fork.gradle | 1 + .../LinuxClickableOverscrollActuals.kt | 29 +++++ .../gestures/LinuxScrollableActuals.kt | 52 +++++++++ .../internal/LinuxClipboardUtilsActuals.kt | 46 ++++++++ .../foundation/lazy/LinuxLazyListActuals.kt | 22 ++++ .../text/LinuxContextMenuActuals.kt | 41 +++++++ .../foundation/text/LinuxTextActuals.kt | 104 ++++++++++++++++++ .../internal/LinuxTextFieldInternalActuals.kt | 40 +++++++ .../LinuxTextFieldSelectionActuals.kt | 67 +++++++++++ .../LinuxSelectionControllerActuals.kt | 26 +++++ .../text/selection/LinuxSelectionActuals.kt | 79 +++++++++++++ 12 files changed, 508 insertions(+) create mode 100644 compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/LinuxClickableOverscrollActuals.kt create mode 100644 compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/gestures/LinuxScrollableActuals.kt create mode 100644 compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/internal/LinuxClipboardUtilsActuals.kt create mode 100644 compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/lazy/LinuxLazyListActuals.kt create mode 100644 compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/LinuxContextMenuActuals.kt create mode 100644 compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/LinuxTextActuals.kt create mode 100644 compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/input/internal/LinuxTextFieldInternalActuals.kt create mode 100644 compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/input/internal/selection/LinuxTextFieldSelectionActuals.kt create mode 100644 compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/modifiers/LinuxSelectionControllerActuals.kt create mode 100644 compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/selection/LinuxSelectionActuals.kt diff --git a/compose/foundation/foundation-layout/build-fork.gradle b/compose/foundation/foundation-layout/build-fork.gradle index 3ea4eeb61c965..c6e1dfad80fda 100644 --- a/compose/foundation/foundation-layout/build-fork.gradle +++ b/compose/foundation/foundation-layout/build-fork.gradle @@ -41,6 +41,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/foundation/foundation/build-fork.gradle b/compose/foundation/foundation/build-fork.gradle index 165a949625b70..e5c179929144e 100644 --- a/compose/foundation/foundation/build-fork.gradle +++ b/compose/foundation/foundation/build-fork.gradle @@ -44,6 +44,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/LinuxClickableOverscrollActuals.kt b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/LinuxClickableOverscrollActuals.kt new file mode 100644 index 0000000000000..c3bba5b4d268c --- /dev/null +++ b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/LinuxClickableOverscrollActuals.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalAccessorScope + +internal actual val TapIndicationDelay: Long = 0L + +@Composable +internal actual fun rememberPlatformOverscrollEffect(): OverscrollEffect? = null + +internal actual fun CompositionLocalAccessorScope.defaultOverscrollFactory(): OverscrollFactory? = null + +internal actual fun isRequestFocusOnClickEnabled(): Boolean = true diff --git a/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/gestures/LinuxScrollableActuals.kt b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/gestures/LinuxScrollableActuals.kt new file mode 100644 index 0000000000000..a1a046156f64e --- /dev/null +++ b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/gestures/LinuxScrollableActuals.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.gestures + +import androidx.compose.animation.SplineBasedFloatDecayAnimationSpec +import androidx.compose.animation.core.generateDecayAnimationSpec +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastFold + +internal actual fun platformScrollableDefaultFlingBehavior(): ScrollableDefaultFlingBehavior = + DefaultFlingBehavior( + SplineBasedFloatDecayAnimationSpec(UnityDensity).generateDecayAnimationSpec() + ) + +@Composable +internal actual fun rememberPlatformDefaultFlingBehavior(): FlingBehavior { + val flingSpec = rememberSplineBasedDecay() + return remember(flingSpec) { + DefaultFlingBehavior(flingSpec) + } +} + +internal actual fun CompositionLocalConsumerModifierNode.platformScrollConfig(): ScrollConfig = + LinuxScrollConfig + +private object LinuxScrollConfig : ScrollConfig { + override fun Density.calculateMouseWheelScroll(event: PointerEvent, bounds: IntSize): Offset { + return event.changes.fastFold(Offset.Zero) { acc, c -> acc + c.scrollDelta } * -64.dp.toPx() + } +} diff --git a/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/internal/LinuxClipboardUtilsActuals.kt b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/internal/LinuxClipboardUtilsActuals.kt new file mode 100644 index 0000000000000..270b42fe2f4be --- /dev/null +++ b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/internal/LinuxClipboardUtilsActuals.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalComposeUiApi::class) + +package androidx.compose.foundation.internal + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.NativeClipboard +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.platform.Clipboard + +internal actual suspend fun ClipEntry.readText(): String? = getPlainText() + +internal actual suspend fun ClipEntry.readAnnotatedString(): AnnotatedString? { + val text = getPlainText() ?: return null + return AnnotatedString(text) +} + +internal actual fun AnnotatedString?.toClipEntry(): ClipEntry? { + if (this == null) return null + return ClipEntry.withPlainText(this.text) +} + +internal actual fun ClipEntry?.hasText(): Boolean = this?.getPlainText() != null + +internal actual fun Clipboard.isReadSupported(): Boolean = true +internal actual fun Clipboard.isWriteSupported(): Boolean = true + +internal fun NativeClipboard.hasText(): Boolean { + return true +} diff --git a/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/lazy/LinuxLazyListActuals.kt b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/lazy/LinuxLazyListActuals.kt new file mode 100644 index 0000000000000..8441fff61c316 --- /dev/null +++ b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/lazy/LinuxLazyListActuals.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.lazy + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun defaultLazyListBeyondBoundsItemCount(): Int = 0 diff --git a/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/LinuxContextMenuActuals.kt b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/LinuxContextMenuActuals.kt new file mode 100644 index 0000000000000..1085947301e0f --- /dev/null +++ b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/LinuxContextMenuActuals.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text + +import androidx.compose.foundation.text.input.internal.selection.TextFieldSelectionState +import androidx.compose.foundation.text.selection.SelectionManager +import androidx.compose.foundation.text.selection.TextFieldSelectionManager +import androidx.compose.runtime.Composable + +@Composable +internal actual fun ContextMenuArea( + manager: TextFieldSelectionManager, + content: @Composable () -> Unit +) = CommonContextMenuArea(manager, content) + +@Composable +internal actual fun ContextMenuArea( + selectionState: TextFieldSelectionState, + enabled: Boolean, + content: @Composable () -> Unit +) = CommonContextMenuArea(selectionState, enabled, content) + +@Composable +internal actual fun ContextMenuArea( + manager: SelectionManager, + content: @Composable () -> Unit +) = CommonContextMenuArea(manager, content) diff --git a/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/LinuxTextActuals.kt b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/LinuxTextActuals.kt new file mode 100644 index 0000000000000..7743962543552 --- /dev/null +++ b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/LinuxTextActuals.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.OverscrollEffect +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.utf16CodePoint +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.text.selection.TextFieldSelectionManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Brush + +// CoreTextField.kt +internal actual fun Modifier.textFieldCursor( + state: LegacyTextFieldState, + value: TextFieldValue, + offsetMapping: OffsetMapping, + cursorBrush: Brush, + showCursor: Boolean, +): Modifier = cursor(state, value, offsetMapping, cursorBrush, showCursor) + +internal actual fun Modifier.textFieldDraw( + state: LegacyTextFieldState, + value: TextFieldValue, + offsetMapping: OffsetMapping, +): Modifier = defaultTextFieldDraw(state, value, offsetMapping) + +// KeyMapping.kt +internal actual val platformDefaultKeyMapping: KeyMapping = defaultKeyMapping + +// TextFieldKeyInput.kt +internal actual val KeyEvent.isTypedEvent: Boolean + get() = type == KeyEventType.KeyDown && + !isISOControl(utf16CodePoint) && + !isCtrlPressed + +private fun isISOControl(codePoint: Int): Boolean = + codePoint in 0x00..0x1F || + codePoint in 0x7F..0x9F + +// TextFieldPointerModifier.common.kt +@Composable +internal actual fun Modifier.textFieldPointer( + manager: TextFieldSelectionManager, + enabled: Boolean, + interactionSource: MutableInteractionSource?, + state: LegacyTextFieldState, + focusRequester: FocusRequester, + readOnly: Boolean, + offsetMapping: OffsetMapping +): Modifier = defaultTextFieldPointer( + manager, + enabled, + interactionSource, + state, + focusRequester, + readOnly, + offsetMapping, +) + +// TextFieldScroll.kt +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal actual fun rememberTextFieldOverscrollEffect(): OverscrollEffect? = null + +internal actual fun Modifier.textFieldScroll( + scrollerPosition: TextFieldScrollerPosition, + textFieldValue: TextFieldValue, + visualTransformation: VisualTransformation, + overscrollEffect: OverscrollEffect?, + textLayoutResultProvider: () -> TextLayoutResultProxy? +): Modifier = defaultTextFieldScroll( + scrollerPosition, + textFieldValue, + visualTransformation, + overscrollEffect, + textLayoutResultProvider, +) + +// TouchMode.kt +internal actual val isInTouchMode = false diff --git a/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/input/internal/LinuxTextFieldInternalActuals.kt b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/input/internal/LinuxTextFieldInternalActuals.kt new file mode 100644 index 0000000000000..c97554abb6f27 --- /dev/null +++ b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/input/internal/LinuxTextFieldInternalActuals.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text.input.internal + +import androidx.compose.foundation.text.input.internal.selection.TextFieldSelectionState +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange + +internal actual fun TextFieldCoreModifierNode.drawSelectionHighlight( + scope: DrawScope, + selection: TextRange, + textLayoutResult: TextLayoutResult, +) = drawDefaultSelectionHighlight(scope, selection, textLayoutResult) + +internal actual fun TextFieldCoreModifierNode.drawCursor( + scope: DrawScope, + brush: Brush, + showCursor: Boolean, + cursorAnimation: CursorAnimationState?, + textFieldSelectionState: TextFieldSelectionState, +) = drawDefaultCursor(scope, brush, showCursor, cursorAnimation, textFieldSelectionState) + +internal actual fun createTextFieldKeyEventHandler(): TextFieldKeyEventHandler = + createSkikoTextFieldKeyEventHandler() diff --git a/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/input/internal/selection/LinuxTextFieldSelectionActuals.kt b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/input/internal/selection/LinuxTextFieldSelectionActuals.kt new file mode 100644 index 0000000000000..230a506832fac --- /dev/null +++ b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/input/internal/selection/LinuxTextFieldSelectionActuals.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text.input.internal.selection + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.text.TextDragObserver +import androidx.compose.foundation.text.selection.MouseSelectionObserver +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.platform.Clipboard +import androidx.compose.foundation.text.input.internal.TextLayoutState +import androidx.compose.foundation.text.input.internal.TransformedTextFieldState +import kotlinx.coroutines.CoroutineScope + +internal actual fun textFieldMagnifierNode( + textFieldState: TransformedTextFieldState, + textFieldSelectionState: TextFieldSelectionState, + textLayoutState: TextLayoutState, + visible: Boolean +): TextFieldMagnifierNode { + return object : TextFieldMagnifierNode() { + override fun update( + textFieldState: TransformedTextFieldState, + textFieldSelectionState: TextFieldSelectionState, + textLayoutState: TextLayoutState, + visible: Boolean + ) {} + } +} + +internal actual suspend fun TextFieldSelectionState.detectTextFieldTapGestures( + pointerInputScope: PointerInputScope, + interactionSource: MutableInteractionSource?, + requestFocus: () -> Unit, + showKeyboard: () -> Unit, +) = defaultDetectTextFieldTapGestures(pointerInputScope, interactionSource, requestFocus, showKeyboard) + +internal actual suspend fun TextFieldSelectionState.textFieldSelectionGestures( + pointerInputScope: PointerInputScope, + mouseSelectionObserver: MouseSelectionObserver, + textDragObserver: TextDragObserver +) = pointerInputScope.defaultTextFieldSelectionGestures(mouseSelectionObserver, textDragObserver) + +internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( + state: TextFieldSelectionState, + coroutineScope: CoroutineScope +): Modifier = this + +internal actual class ClipboardPasteState actual constructor(private val clipboard: Clipboard) { + actual val hasText = true + actual val hasClip = true + actual suspend fun update() {} +} diff --git a/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/modifiers/LinuxSelectionControllerActuals.kt b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/modifiers/LinuxSelectionControllerActuals.kt new file mode 100644 index 0000000000000..bcdba588e9164 --- /dev/null +++ b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/modifiers/LinuxSelectionControllerActuals.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text.modifiers + +import androidx.compose.foundation.text.selection.SelectionRegistrar +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LayoutCoordinates + +internal actual fun SelectionRegistrar.makeSelectionModifier( + selectableId: Long, + layoutCoordinatesProvider: () -> LayoutCoordinates? +): Modifier = makeDefaultSelectionModifier(selectableId, layoutCoordinatesProvider) diff --git a/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/selection/LinuxSelectionActuals.kt b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/selection/LinuxSelectionActuals.kt new file mode 100644 index 0000000000000..ab092039ba827 --- /dev/null +++ b/compose/foundation/foundation/src/linuxMain/kotlin/androidx/compose/foundation/text/selection/LinuxSelectionActuals.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text.selection + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.ResolvedTextDirection +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.isCtrlPressed +import kotlinx.coroutines.CoroutineScope + +internal actual val FirstLongPressSelectionAdjustment: SelectionAdjustment + get() = SelectionAdjustment.Word + +@Composable +internal actual fun SelectionHandle( + offsetProvider: OffsetProvider, + isStartHandle: Boolean, + direction: ResolvedTextDirection, + handlesCrossed: Boolean, + minTouchTargetSize: DpSize, + lineHeight: Float, + modifier: Modifier +) = SkikoSelectionHandle( + offsetProvider = offsetProvider, + isStartHandle = isStartHandle, + direction = direction, + handlesCrossed = handlesCrossed, + minTouchTargetSize = minTouchTargetSize, + lineHeight = lineHeight, + modifier = modifier +) + +internal actual fun isCopyKeyEvent(keyEvent: KeyEvent): Boolean = + keyEvent.key == Key.C && keyEvent.isCtrlPressed || keyEvent.key == Key.Copy + +internal actual fun Modifier.selectionMagnifier(manager: SelectionManager): Modifier = this + +internal actual fun Modifier.addSelectionContainerTextContextMenuComponents( + selectionManager: SelectionManager +): Modifier = this + +internal actual fun Modifier.textFieldMagnifier(manager: TextFieldSelectionManager): Modifier = this + +internal actual fun TextFieldSelectionManager.isSelectionHandleInVisibleBound( + isStartHandle: Boolean +): Boolean = isSelectionHandleInVisibleBoundDefault(isStartHandle) + +internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( + manager: TextFieldSelectionManager, + coroutineScope: CoroutineScope, +): Modifier = this + +private val DefaultSelectionColor = Color(0xFF4286F4) + +@Stable +internal actual val DefaultTextSelectionColors = TextSelectionColors( + handleColor = DefaultSelectionColor, + backgroundColor = DefaultSelectionColor.copy(alpha = 0.4f) +) From c7acafc3c9e19fa33ebaaad625a40abd02c0a365 Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Fri, 3 Jul 2026 22:31:25 -0400 Subject: [PATCH 08/16] Enable linuxX64 in animation and animation-core animation-core needs a single linuxMain actual (Expect.linux.kt); animation is target declaration only. Test: ./gradlew :compose:animation:animation:compileKotlinLinuxX64 --- .../animation-core/build-fork.gradle | 1 + .../compose/animation/core/Expect.linux.kt | 24 +++++++++++++++++++ compose/animation/animation/build-fork.gradle | 1 + 3 files changed, 26 insertions(+) create mode 100644 compose/animation/animation-core/src/linuxMain/kotlin/androidx/compose/animation/core/Expect.linux.kt diff --git a/compose/animation/animation-core/build-fork.gradle b/compose/animation/animation-core/build-fork.gradle index 30d250123ed4b..2f04b80295384 100644 --- a/compose/animation/animation-core/build-fork.gradle +++ b/compose/animation/animation-core/build-fork.gradle @@ -42,6 +42,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/animation/animation-core/src/linuxMain/kotlin/androidx/compose/animation/core/Expect.linux.kt b/compose/animation/animation-core/src/linuxMain/kotlin/androidx/compose/animation/core/Expect.linux.kt new file mode 100644 index 0000000000000..12cec4aa3353e --- /dev/null +++ b/compose/animation/animation-core/src/linuxMain/kotlin/androidx/compose/animation/core/Expect.linux.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.animation.core + +import kotlin.native.concurrent.ThreadLocal + +@ThreadLocal +private val threadObject = Any() + +internal actual fun getCurrentThread(): Any = threadObject diff --git a/compose/animation/animation/build-fork.gradle b/compose/animation/animation/build-fork.gradle index d7be85441d6fa..84e1535a6b741 100644 --- a/compose/animation/animation/build-fork.gradle +++ b/compose/animation/animation/build-fork.gradle @@ -47,6 +47,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() From f781d0fd3f062dcc45b6536b077c2d6b3a05d848 Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Fri, 3 Jul 2026 22:31:25 -0400 Subject: [PATCH 09/16] Enable linuxX64 in material, material-ripple, and material3 material3 gets linuxMain actuals for CalendarLocale and PlatformDateFormat built on the posix locale; material and material-ripple are target declaration only. Test: ./gradlew :compose:material3:material3:compileKotlinLinuxX64 --- .../material-ripple/build-fork.gradle | 1 + compose/material/material/build-fork.gradle | 1 + compose/material3/material3/build-fork.gradle | 1 + .../compose/material3/CalendarLocale.linux.kt | 34 ++++++ .../internal/PlatformDateFormat.linux.kt | 104 ++++++++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 compose/material3/material3/src/linuxMain/kotlin/androidx/compose/material3/CalendarLocale.linux.kt create mode 100644 compose/material3/material3/src/linuxMain/kotlin/androidx/compose/material3/internal/PlatformDateFormat.linux.kt diff --git a/compose/material/material-ripple/build-fork.gradle b/compose/material/material-ripple/build-fork.gradle index ac6a1da64d016..98a528b07de2c 100644 --- a/compose/material/material-ripple/build-fork.gradle +++ b/compose/material/material-ripple/build-fork.gradle @@ -41,6 +41,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/material/material/build-fork.gradle b/compose/material/material/build-fork.gradle index b4411603f4e19..81ff1774731c2 100644 --- a/compose/material/material/build-fork.gradle +++ b/compose/material/material/build-fork.gradle @@ -44,6 +44,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/material3/material3/build-fork.gradle b/compose/material3/material3/build-fork.gradle index c8036918e6d1a..99d7462e17db6 100644 --- a/compose/material3/material3/build-fork.gradle +++ b/compose/material3/material3/build-fork.gradle @@ -46,6 +46,7 @@ androidXMultiplatform { } desktop() mac() + linux() ios() js() wasmJs() diff --git a/compose/material3/material3/src/linuxMain/kotlin/androidx/compose/material3/CalendarLocale.linux.kt b/compose/material3/material3/src/linuxMain/kotlin/androidx/compose/material3/CalendarLocale.linux.kt new file mode 100644 index 0000000000000..de05548d33fd9 --- /dev/null +++ b/compose/material3/material3/src/linuxMain/kotlin/androidx/compose/material3/CalendarLocale.linux.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.material3 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.text.intl.Locale + +/** + * Represents a Locale for the calendar on Linux. This locale will be used when formatting dates, + * determining the input format, and more. + */ +actual typealias CalendarLocale = Locale + +/** + * Returns the default [CalendarLocale]. + */ +@Composable +@ReadOnlyComposable +internal actual fun defaultLocale(): CalendarLocale = Locale.current diff --git a/compose/material3/material3/src/linuxMain/kotlin/androidx/compose/material3/internal/PlatformDateFormat.linux.kt b/compose/material3/material3/src/linuxMain/kotlin/androidx/compose/material3/internal/PlatformDateFormat.linux.kt new file mode 100644 index 0000000000000..fa3596793efed --- /dev/null +++ b/compose/material3/material3/src/linuxMain/kotlin/androidx/compose/material3/internal/PlatformDateFormat.linux.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalTime::class) + +package androidx.compose.material3.internal + +import androidx.compose.material3.CalendarLocale +import kotlin.time.ExperimentalTime +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlinx.datetime.format +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern + +internal actual class PlatformDateFormat actual constructor(private val locale: CalendarLocale) { + actual val firstDayOfWeek: Int = 1 // Monday by default on Linux + + actual val weekdayNames: List> = listOf( + "Monday" to "M", + "Tuesday" to "T", + "Wednesday" to "W", + "Thursday" to "T", + "Friday" to "F", + "Saturday" to "S", + "Sunday" to "S" + ) + + @OptIn(FormatStringsInDatetimeFormats::class) + actual fun formatWithPattern( + utcTimeMillis: Long, + pattern: String, + cache: MutableMap + ): String { + val date = Instant + .fromEpochMilliseconds(utcTimeMillis) + .toLocalDateTime(TimeZone.UTC) + // Clean up common patterns that byUnicodePattern doesn't support or needs adjustments for + val normalizedPattern = pattern + .replace("MMMM", "MM") + .replace("MMM", "MM") + .replace("EEEE", "EE") + .replace("EEE", "EE") + return date.format(LocalDateTime.Format { byUnicodePattern(normalizedPattern) }) + } + + actual fun formatWithSkeleton( + utcTimeMillis: Long, + skeleton: String, + cache: MutableMap + ): String { + return formatWithPattern(utcTimeMillis, "yyyy-MM-dd", cache) + } + + @OptIn(FormatStringsInDatetimeFormats::class) + actual fun parse( + date: String, + pattern: String, + locale: CalendarLocale, + cache: MutableMap + ): CalendarDate? { + return try { + val normalizedPattern = pattern + .replace("MMMM", "MM") + .replace("MMM", "MM") + .replace("EEEE", "EE") + .replace("EEE", "EE") + LocalDate.parse( + input = date, + format = LocalDate.Format { + byUnicodePattern(normalizedPattern) + } + ).atTime(Midnight) + .toInstant(TimeZone.UTC) + .toCalendarDate(TimeZone.UTC) + } catch (e: Throwable) { + null + } + } + + actual fun getDateInputFormat(): DateInputFormat { + return datePatternAsInputFormat("yyyy-MM-dd") + } + + actual fun is24HourFormat(): Boolean = true +} From 853b5bdd65fa3b7fa151c4dcccd0c764d928e959 Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Sat, 4 Jul 2026 13:06:17 -0400 Subject: [PATCH 10/16] added demo app In AndroidXForkTargetsExtensions.kt (both in buildSrc and buildSrc-fork ), the script was unconditionally trying to fetch iosSimulatorArm64 from testableTargets via getByName. When iOS compilation target groups are disabled, this would crash configuration. - I replaced getByName with findByName(...) and safely configure iOS instrumented tests only if the target is actually enabled/registered. --- .../build/AndroidXForkTargetsExtensions.kt | 9 +- .../build/AndroidXForkTargetsExtensions.kt | 9 +- compose/mpp/demo/build.gradle.kts | 147 ++++++++++-------- compose/mpp/linux-demo/build.gradle.kts | 55 +++++++ .../linux-demo/src/linuxMain/kotlin/Main.kt | 92 +++++++++++ gradle.properties | 5 +- settings-fork.gradle | 2 + settings.gradle | 2 + 8 files changed, 247 insertions(+), 74 deletions(-) create mode 100644 compose/mpp/linux-demo/build.gradle.kts create mode 100644 compose/mpp/linux-demo/src/linuxMain/kotlin/Main.kt diff --git a/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/AndroidXForkTargetsExtensions.kt b/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/AndroidXForkTargetsExtensions.kt index edad3e5c4c3c2..81f245a29cbc0 100644 --- a/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/AndroidXForkTargetsExtensions.kt +++ b/buildSrc-fork/private/src/main/kotlin/org/jetbrains/androidx/build/AndroidXForkTargetsExtensions.kt @@ -199,10 +199,9 @@ fun addIosInstrumentedTestSourceset(project: Project) { isStatic = true } } - testableTargets.getByName( - "iosSimulatorArm64", - KotlinNativeTargetWithSimulatorTests::class, - KotlinNativeTargetWithSimulatorTests::configureTestRun - ) + val iosSimulatorArm64 = testableTargets.findByName("iosSimulatorArm64") + if (iosSimulatorArm64 is KotlinNativeTargetWithSimulatorTests) { + iosSimulatorArm64.configureTestRun() + } } } diff --git a/buildSrc/private/src/main/kotlin/org/jetbrains/androidx/build/AndroidXForkTargetsExtensions.kt b/buildSrc/private/src/main/kotlin/org/jetbrains/androidx/build/AndroidXForkTargetsExtensions.kt index 1cd582b05fe5b..4e34cb56b827f 100644 --- a/buildSrc/private/src/main/kotlin/org/jetbrains/androidx/build/AndroidXForkTargetsExtensions.kt +++ b/buildSrc/private/src/main/kotlin/org/jetbrains/androidx/build/AndroidXForkTargetsExtensions.kt @@ -202,10 +202,9 @@ fun addIosInstrumentedTestSourceset(project: Project) { isStatic = true } } - testableTargets.getByName( - "iosSimulatorArm64", - KotlinNativeTargetWithSimulatorTests::class, - KotlinNativeTargetWithSimulatorTests::configureTestRun - ) + val iosSimulatorArm64 = testableTargets.findByName("iosSimulatorArm64") + if (iosSimulatorArm64 is KotlinNativeTargetWithSimulatorTests) { + iosSimulatorArm64.configureTestRun() + } } } diff --git a/compose/mpp/demo/build.gradle.kts b/compose/mpp/demo/build.gradle.kts index 6d4dcf7c190b2..febee3aec0c98 100644 --- a/compose/mpp/demo/build.gradle.kts +++ b/compose/mpp/demo/build.gradle.kts @@ -21,6 +21,10 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig +import androidx.build.enableJs +import androidx.build.enableWasmJs +import androidx.build.enableMac + plugins { id("AndroidXComposePlugin") @@ -40,79 +44,86 @@ val unzipTask = tasks.register("unzipWasm", Copy::class) { from(skikoWasm.map { zipTree(it) }) } + kotlin { applyDefaultHierarchyTemplate() jvm("desktop") - js { - outputModuleName = "mpp-demo" - browser { - commonWebpackConfig { - outputFileName = "demo.js" + if (enableJs()) { + js { + outputModuleName = "mpp-demo" + browser { + commonWebpackConfig { + outputFileName = "demo.js" + } } + binaries.executable() } - binaries.executable() } - wasmJs { - outputModuleName = "mpp-demo" - browser { - // https://youtrack.jetbrains.com/issue/KT-68614 - val rootDirPath = project.rootDir.path - val projectDirPath = project.projectDir.path - commonWebpackConfig { - outputFileName = "demo.js" - devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { - open = mapOf( - "app" to mapOf( - "name" to "google-chrome", + if (enableWasmJs()) { + wasmJs { + outputModuleName = "mpp-demo" + browser { + // https://youtrack.jetbrains.com/issue/KT-68614 + val rootDirPath = project.rootDir.path + val projectDirPath = project.projectDir.path + commonWebpackConfig { + outputFileName = "demo.js" + devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply { + open = mapOf( + "app" to mapOf( + "name" to "google-chrome", + ) ) - ) - static = (static ?: mutableListOf()).apply { - // Serve sources to debug inside browser - add(rootDirPath) - add(projectDirPath) + static = (static ?: mutableListOf()).apply { + // Serve sources to debug inside browser + add(rootDirPath) + add(projectDirPath) + } } } } + binaries.executable() } - binaries.executable() } - macosArm64() { - binaries { - executable() { - entryPoint = "androidx.compose.mpp.demo.main" - freeCompilerArgs += listOf( - "-linker-option", "-framework", "-linker-option", "Metal" - ) - // TODO: the current release binary surprises LLVM, so disable checks for now. - freeCompilerArgs += "-Xdisable-phases=VerifyBitcode" + if (enableMac()) { + macosArm64() { + binaries { + executable() { + entryPoint = "androidx.compose.mpp.demo.main" + freeCompilerArgs += listOf( + "-linker-option", "-framework", "-linker-option", "Metal" + ) + // TODO: the current release binary surprises LLVM, so disable checks for now. + freeCompilerArgs += "-Xdisable-phases=VerifyBitcode" + } } } - } - iosArm64("iosArm64") { - binaries { - executable() { - entryPoint = "androidx.compose.mpp.demo.main" - freeCompilerArgs += listOf( - "-linker-option", "-framework", "-linker-option", "Metal", - "-linker-option", "-framework", "-linker-option", "CoreText", - "-linker-option", "-framework", "-linker-option", "CoreGraphics" - ) - // TODO: the current compose binary surprises LLVM, so disable checks for now. - freeCompilerArgs += "-Xdisable-phases=VerifyBitcode" + iosArm64("iosArm64") { + binaries { + executable() { + entryPoint = "androidx.compose.mpp.demo.main" + freeCompilerArgs += listOf( + "-linker-option", "-framework", "-linker-option", "Metal", + "-linker-option", "-framework", "-linker-option", "CoreText", + "-linker-option", "-framework", "-linker-option", "CoreGraphics" + ) + // TODO: the current compose binary surprises LLVM, so disable checks for now. + freeCompilerArgs += "-Xdisable-phases=VerifyBitcode" + } } } - } - iosSimulatorArm64("iosSimArm64") { - binaries { - executable() { - entryPoint = "androidx.compose.mpp.demo.main" - freeCompilerArgs += listOf( - "-linker-option", "-framework", "-linker-option", "Metal", - "-linker-option", "-framework", "-linker-option", "CoreText", - "-linker-option", "-framework", "-linker-option", "CoreGraphics" - ) - // TODO: the current compose binary surprises LLVM, so disable checks for now. - freeCompilerArgs += "-Xdisable-phases=VerifyBitcode" + iosSimulatorArm64("iosSimArm64") { + binaries { + executable() { + entryPoint = "androidx.compose.mpp.demo.main" + freeCompilerArgs += listOf( + "-linker-option", "-framework", "-linker-option", "Metal", + "-linker-option", "-framework", "-linker-option", "CoreText", + "-linker-option", "-framework", "-linker-option", "CoreGraphics" + ) + // TODO: the current compose binary surprises LLVM, so disable checks for now. + freeCompilerArgs += "-Xdisable-phases=VerifyBitcode" + } } } } @@ -169,7 +180,8 @@ kotlin { } } - val webMain by getting { + val webMain = findByName("webMain") + webMain?.run { dependsOn(skikoMain) resources.setSrcDirs(resources.srcDirs) resources.srcDirs(unzipTask.map { it.destinationDir }) @@ -179,16 +191,25 @@ kotlin { } } - val wasmJsMain by getting { + val wasmJsMain = findByName("wasmJsMain") + wasmJsMain?.run { dependencies { api(libs.kotlinXw3c) } } - val nativeMain by getting { dependsOn(skikoMain) } - val darwinMain by creating { dependsOn(nativeMain) } - val macosMain by getting { dependsOn(darwinMain) } - val iosMain by getting { dependsOn(darwinMain) } + val nativeMain = findByName("nativeMain") + nativeMain?.dependsOn(skikoMain) + + val macosMain = findByName("macosMain") + val iosMain = findByName("iosMain") + if (macosMain != null || iosMain != null) { + val darwinMain = create("darwinMain") { + dependsOn(nativeMain!!) + } + macosMain?.dependsOn(darwinMain) + iosMain?.dependsOn(darwinMain) + } } } diff --git a/compose/mpp/linux-demo/build.gradle.kts b/compose/mpp/linux-demo/build.gradle.kts new file mode 100644 index 0000000000000..fe1851f993c8a --- /dev/null +++ b/compose/mpp/linux-demo/build.gradle.kts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("AndroidXComposePlugin") + id("kotlin-multiplatform") +} + +kotlin { + linuxX64() { + binaries { + executable() { + entryPoint = "main" + linkerOpts( + "-L/usr/lib/x86_64-linux-gnu", + "-lstdc++", + "--allow-shlib-undefined", + "--unresolved-symbols=ignore-all" + ) + @OptIn(org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeCacheApi::class) + disableNativeCache( + org.jetbrains.kotlin.gradle.plugin.mpp.DisableCacheInKotlinVersion.`2_3_20`, + "Linker errors" + ) + } + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":compose:runtime:runtime")) + implementation(project(":compose:ui:ui")) + implementation(project(":compose:foundation:foundation")) + implementation(project(":compose:foundation:foundation-layout")) + implementation(project(":compose:material3:material3")) + implementation(libs.skiko) + implementation(libs.kotlinCoroutinesCore) + } + } + } +} diff --git a/compose/mpp/linux-demo/src/linuxMain/kotlin/Main.kt b/compose/mpp/linux-demo/src/linuxMain/kotlin/Main.kt new file mode 100644 index 0000000000000..72a16609b791f --- /dev/null +++ b/compose/mpp/linux-demo/src/linuxMain/kotlin/Main.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Window + +fun main() { + Window(title = "Compose Multiplatform Linux Native Demo") { + var count by remember { mutableStateOf(0) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF1C1B1F)), // Dark background + contentAlignment = Alignment.Center + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = Color(0xFF2B2930) + ), + modifier = Modifier + .width(400.dp) + .padding(16.dp) + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Compose Linux Native", + color = Color(0xFFD0BCFF), + fontSize = 24.sp, + style = MaterialTheme.typography.titleLarge + ) + + Divider(color = Color(0xFF49454F)) + + Text( + text = "Count: $count", + color = Color.White, + fontSize = 36.sp, + style = MaterialTheme.typography.headlineLarge + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { count-- }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF4F378B) + ) + ) { + Text("-", fontSize = 20.sp) + } + + Button( + onClick = { count++ }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF4F378B) + ) + ) { + Text("+", fontSize = 20.sp) + } + } + } + } + } + } +} diff --git a/gradle.properties b/gradle.properties index e0e4b823bbb29..d35ba83d33c00 100644 --- a/gradle.properties +++ b/gradle.properties @@ -129,4 +129,7 @@ kotlinx.atomicfu.enableNativeIrTransformation=true # A special workaround introduced in Kotlin 2.3.20 to support the Compose redirects. # See https://youtrack.jetbrains.com/issue/KT-83917 -kotlin.internal.kmp.allowMatchingByRequestedCoordinatesInMetadataTransformations=true \ No newline at end of file +kotlin.internal.kmp.allowMatchingByRequestedCoordinatesInMetadataTransformations=true + +# Disable targets we do not have local skiko snapshot artifacts for to allow Gradle sync to succeed +androidx.enabled.kmp.target.platforms=-wasm,-js,-mac,-windows,-android_native \ No newline at end of file diff --git a/settings-fork.gradle b/settings-fork.gradle index 57bd55346a420..c3b3e0b70bd57 100644 --- a/settings-fork.gradle +++ b/settings-fork.gradle @@ -367,6 +367,8 @@ includeProject(":compose:desktop:desktop:desktop-samples-material3", "compose/de includeProject(":compose:mpp") includeProject(":compose:mpp:demo") includeProject(":compose:mpp:demo-swiftui") +includeProject(":compose:mpp:linux-demo") + includeProject(":compose:foundation") includeProject(":compose:foundation:foundation") diff --git a/settings.gradle b/settings.gradle index 8bfbcf64d4d55..5fa17a089a896 100644 --- a/settings.gradle +++ b/settings.gradle @@ -443,6 +443,8 @@ includeProject(":compose:desktop:desktop:desktop-samples-material3", "compose/de includeProject(":compose:mpp") includeProject(":compose:mpp:demo") includeProject(":compose:mpp:demo-swiftui") +includeProject(":compose:mpp:linux-demo") + includeProject(":compose:foundation") includeProject(":compose:foundation:foundation") From b53921c9b814f15ab0001492510cbdbb5c219954 Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Sat, 4 Jul 2026 13:19:57 -0400 Subject: [PATCH 11/16] Revert local development snapshot configurations for Skiko --- buildSrc-fork/repos.gradle | 1 - buildSrc/repos.gradle | 1 - gradle.properties | 5 +---- gradle/libs-fork.versions.toml | 2 +- gradle/libs.versions.toml | 2 +- 5 files changed, 3 insertions(+), 8 deletions(-) diff --git a/buildSrc-fork/repos.gradle b/buildSrc-fork/repos.gradle index dfdd1ef0d3b9b..1b3f5c756e69b 100644 --- a/buildSrc-fork/repos.gradle +++ b/buildSrc-fork/repos.gradle @@ -66,7 +66,6 @@ def addMavenRepositories(RepositoryHandler handler) { } */ if (true /* In JetBrains Fork */) { - handler.mavenLocal() handler.mavenCentral() handler.google { content { diff --git a/buildSrc/repos.gradle b/buildSrc/repos.gradle index dfdd1ef0d3b9b..1b3f5c756e69b 100644 --- a/buildSrc/repos.gradle +++ b/buildSrc/repos.gradle @@ -66,7 +66,6 @@ def addMavenRepositories(RepositoryHandler handler) { } */ if (true /* In JetBrains Fork */) { - handler.mavenLocal() handler.mavenCentral() handler.google { content { diff --git a/gradle.properties b/gradle.properties index d35ba83d33c00..e0e4b823bbb29 100644 --- a/gradle.properties +++ b/gradle.properties @@ -129,7 +129,4 @@ kotlinx.atomicfu.enableNativeIrTransformation=true # A special workaround introduced in Kotlin 2.3.20 to support the Compose redirects. # See https://youtrack.jetbrains.com/issue/KT-83917 -kotlin.internal.kmp.allowMatchingByRequestedCoordinatesInMetadataTransformations=true - -# Disable targets we do not have local skiko snapshot artifacts for to allow Gradle sync to succeed -androidx.enabled.kmp.target.platforms=-wasm,-js,-mac,-windows,-android_native \ No newline at end of file +kotlin.internal.kmp.allowMatchingByRequestedCoordinatesInMetadataTransformations=true \ No newline at end of file diff --git a/gradle/libs-fork.versions.toml b/gradle/libs-fork.versions.toml index 9090551314d9d..bf3498f8483b3 100644 --- a/gradle/libs-fork.versions.toml +++ b/gradle/libs-fork.versions.toml @@ -78,7 +78,7 @@ protobuf = "4.28.2" paparazzi = "1.0.0" paparazziNative = "2022.1.1-canary-f5f9f71" shadow = "8.1.1" -skiko = "0.0.0-SNAPSHOT" +skiko = "0.150.1" spdxGradlePlugin = "0.6.0" sqldelight = "1.3.0" retrofit = "2.12.0" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9090551314d9d..bf3498f8483b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,7 +78,7 @@ protobuf = "4.28.2" paparazzi = "1.0.0" paparazziNative = "2022.1.1-canary-f5f9f71" shadow = "8.1.1" -skiko = "0.0.0-SNAPSHOT" +skiko = "0.150.1" spdxGradlePlugin = "0.6.0" sqldelight = "1.3.0" retrofit = "2.12.0" From d61f21bf5e44794a155faa75d50f1c6572f7a81d Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Sat, 4 Jul 2026 15:03:55 -0400 Subject: [PATCH 12/16] Expand Linux keysym mapping and plumb keyboard modifiers Key identity now resolves from the effective keysym with an unshifted fallback, covering punctuation, navigation, function, numpad, and modifier keys; unmapped keysyms fall through as Key.Unknown so typed characters still reach text fields. X11 state masks translate to PointerKeyboardModifiers on key and pointer events, and WindowInfo.keyboardModifiers tracks them. --- .../compose/ui/input/key/KeyEvent.linux.kt | 159 ++++++++++++++++++ .../compose/ui/window/ComposeWindow.linux.kt | 103 ++++++------ 2 files changed, 212 insertions(+), 50 deletions(-) create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/key/KeyEvent.linux.kt diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/key/KeyEvent.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/key/KeyEvent.linux.kt new file mode 100644 index 0000000000000..98202f7f7d108 --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/input/key/KeyEvent.linux.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.input.key + +import androidx.compose.ui.input.pointer.PointerKeyboardModifiers + +// X11 core protocol modifier masks (X.h). Mod1 is Alt and Mod2 is NumLock on +// every stock XKB layout; Mod4 is Super, which Compose calls Meta. +private const val X_SHIFT_MASK = 1L shl 0 +private const val X_LOCK_MASK = 1L shl 1 +private const val X_CONTROL_MASK = 1L shl 2 +private const val X_MOD1_MASK = 1L shl 3 +private const val X_MOD2_MASK = 1L shl 4 +private const val X_MOD4_MASK = 1L shl 6 + +/** + * Translates the X11 `state` mask of a key, button, or motion event into Compose + * keyboard modifiers. + * + * X11 reports the state *before* the event, so for the key event of a modifier key + * itself the mask is off by one transition. Passing the event's own [key] and + * [isKeyDown] corrects that: if [key] is that modifier, its pressed state is taken + * from [isKeyDown] instead of the mask. + */ +internal fun xKeyboardModifiers( + state: Long, + key: Key = Key.Unknown, + isKeyDown: Boolean = false, +): PointerKeyboardModifiers { + fun pressed(mask: Long, left: Key, right: Key): Boolean = + if (key == left || key == right) isKeyDown else state and mask != 0L + return PointerKeyboardModifiers( + isShiftPressed = pressed(X_SHIFT_MASK, Key.ShiftLeft, Key.ShiftRight), + isCtrlPressed = pressed(X_CONTROL_MASK, Key.CtrlLeft, Key.CtrlRight), + isAltPressed = pressed(X_MOD1_MASK, Key.AltLeft, Key.AltRight), + isMetaPressed = pressed(X_MOD4_MASK, Key.MetaLeft, Key.MetaRight), + isCapsLockOn = state and X_LOCK_MASK != 0L, + isNumLockOn = state and X_MOD2_MASK != 0L, + ) +} + +private val letterKeys = listOf( + Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J, Key.K, Key.L, Key.M, + Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z, +) + +private val digitKeys = listOf( + Key.Zero, Key.One, Key.Two, Key.Three, Key.Four, + Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine, +) + +private val functionKeys = listOf( + Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.F6, + Key.F7, Key.F8, Key.F9, Key.F10, Key.F11, Key.F12, +) + +private val numPadDigitKeys = listOf( + Key.NumPad0, Key.NumPad1, Key.NumPad2, Key.NumPad3, Key.NumPad4, + Key.NumPad5, Key.NumPad6, Key.NumPad7, Key.NumPad8, Key.NumPad9, +) + +/** + * X11 keysym → Compose [Key] (keysym values from `X11/keysymdef.h`). + * + * Latin-1 keysyms equal their character codes, so letter keysyms of both cases map to + * the same key. Shifted punctuation keysyms are deliberately absent: callers resolve + * the effective keysym first and fall back to the unshifted one, so e.g. `!` lands on + * [Key.One] the way desktop key codes do. Keysyms absent from this table map to + * [Key.Unknown] but still deliver their typed character via the event's code point. + */ +internal val keysymToKey: Map = + mapOf( + // TTY function keys + 0xff08L to Key.Backspace, + 0xff09L to Key.Tab, + 0xff0dL to Key.Enter, + 0xff13L to Key.Break, + 0xff14L to Key.ScrollLock, + 0xff1bL to Key.Escape, + 0xffffL to Key.Delete, + // Cursor motion + 0xff50L to Key.MoveHome, + 0xff51L to Key.DirectionLeft, + 0xff52L to Key.DirectionUp, + 0xff53L to Key.DirectionRight, + 0xff54L to Key.DirectionDown, + 0xff55L to Key.PageUp, + 0xff56L to Key.PageDown, + 0xff57L to Key.MoveEnd, + // Misc functions + 0xff61L to Key.PrintScreen, + 0xff63L to Key.Insert, + 0xff67L to Key.Menu, + 0xff7fL to Key.NumLock, + // Keypad + 0xff8dL to Key.NumPadEnter, + 0xff95L to Key.NumPadMoveHome, + 0xff96L to Key.NumPadDirectionLeft, + 0xff97L to Key.NumPadDirectionUp, + 0xff98L to Key.NumPadDirectionRight, + 0xff99L to Key.NumPadDirectionDown, + 0xff9aL to Key.NumPadPageUp, + 0xff9bL to Key.NumPadPageDown, + 0xff9cL to Key.NumPadMoveEnd, + 0xff9eL to Key.NumPadInsert, + 0xff9fL to Key.NumPadDelete, + 0xffaaL to Key.NumPadMultiply, + 0xffabL to Key.NumPadAdd, + 0xffacL to Key.NumPadComma, + 0xffadL to Key.NumPadSubtract, + 0xffaeL to Key.NumPadDot, + 0xffafL to Key.NumPadDivide, + 0xffbdL to Key.NumPadEquals, + // Modifier keys (Super_L/Super_R are Compose's Meta) + 0xffe1L to Key.ShiftLeft, + 0xffe2L to Key.ShiftRight, + 0xffe3L to Key.CtrlLeft, + 0xffe4L to Key.CtrlRight, + 0xffe5L to Key.CapsLock, + 0xffe7L to Key.MetaLeft, + 0xffe8L to Key.MetaRight, + 0xffe9L to Key.AltLeft, + 0xffeaL to Key.AltRight, + 0xffebL to Key.MetaLeft, + 0xffecL to Key.MetaRight, + // Latin-1 punctuation (unshifted positions on a US layout) + 0x0020L to Key.Spacebar, + 0x0027L to Key.Apostrophe, + 0x002cL to Key.Comma, + 0x002dL to Key.Minus, + 0x002eL to Key.Period, + 0x002fL to Key.Slash, + 0x003bL to Key.Semicolon, + 0x003dL to Key.Equals, + 0x005bL to Key.LeftBracket, + 0x005cL to Key.Backslash, + 0x005dL to Key.RightBracket, + 0x0060L to Key.Grave, + ) + + ('a'..'z').zip(letterKeys).flatMap { (char, key) -> + listOf(char.code.toLong() to key, char.uppercaseChar().code.toLong() to key) + } + + ('0'..'9').zip(digitKeys).map { (char, key) -> char.code.toLong() to key } + + numPadDigitKeys.mapIndexed { index, key -> 0xffb0L + index to key } + + functionKeys.mapIndexed { index, key -> 0xffbeL + index to key } diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt index f8ba9aaf192c8..61a60e9236bc1 100644 --- a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt @@ -19,9 +19,12 @@ package androidx.compose.ui.window import androidx.compose.runtime.Composable import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.input.key.InternalKeyEvent +import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.keysymToKey +import androidx.compose.ui.input.key.xKeyboardModifiers import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerIcon @@ -208,21 +211,24 @@ private class ComposeWindow( MotionNotify -> { val x = event.xmotion.x.toFloat() val y = event.xmotion.y.toFloat() - onMouseEvent(PointerEventType.Move, x, y) + val modifiers = xKeyboardModifiers(event.xmotion.state.toLong()) + onMouseEvent(PointerEventType.Move, x, y, modifiers) } ButtonPress -> { val x = event.xbutton.x.toFloat() val y = event.xbutton.y.toFloat() val button = event.xbutton.button.toInt() - handleButton(x, y, button, pressed = true) + val modifiers = xKeyboardModifiers(event.xbutton.state.toLong()) + handleButton(x, y, button, modifiers, pressed = true) } ButtonRelease -> { val x = event.xbutton.x.toFloat() val y = event.xbutton.y.toFloat() val button = event.xbutton.button.toInt() - handleButton(x, y, button, pressed = false) + val modifiers = xKeyboardModifiers(event.xbutton.state.toLong()) + handleButton(x, y, button, modifiers, pressed = false) } KeyPress -> handleKey(event, KeyEventType.KeyDown) @@ -231,14 +237,26 @@ private class ComposeWindow( } } - private fun onMouseEvent(eventType: PointerEventType, x: Float, y: Float) { + private fun onMouseEvent( + eventType: PointerEventType, + x: Float, + y: Float, + modifiers: PointerKeyboardModifiers, + ) { scene.sendPointerEvent( eventType = eventType, - position = Offset(x, y) + position = Offset(x, y), + keyboardModifiers = modifiers, ) } - private fun handleButton(x: Float, y: Float, button: Int, pressed: Boolean) { + private fun handleButton( + x: Float, + y: Float, + button: Int, + modifiers: PointerKeyboardModifiers, + pressed: Boolean, + ) { when (button) { 4, 5 -> { if (pressed) { @@ -246,7 +264,8 @@ private class ComposeWindow( scene.sendPointerEvent( eventType = PointerEventType.Scroll, position = Offset(x, y), - scrollDelta = Offset(0f, deltaY) + scrollDelta = Offset(0f, deltaY), + keyboardModifiers = modifiers, ) } } @@ -261,61 +280,45 @@ private class ComposeWindow( scene.sendPointerEvent( eventType = eventType, position = Offset(x, y), - button = composeButton + keyboardModifiers = modifiers, + button = composeButton, ) } } } + /** + * Sends the X11 key event to the scene. The [Key] identity comes from the effective + * keysym (post Shift/NumLock, so keypad digits resolve correctly) with a fallback to + * the unshifted keysym (so shifted punctuation lands on its physical key, e.g. `!` on + * [Key.One]); unmapped keysyms still go through as [Key.Unknown] so their typed + * character reaches text fields via the code point. + */ private fun handleKey(event: XEvent, type: KeyEventType) = memScoped { val buffer = allocArray(32) val keysym = alloc() val length = XLookupString(event.xkey.ptr, buffer, 32, keysym.ptr, null) - val text = if (type == KeyEventType.KeyDown && length > 0) { - buffer.readBytes(length).decodeToString() - } else { - "" - } - keysymToKey[keysym.value.toLong()]?.let { key -> - scene.sendKeyEvent( - KeyEvent( + val key = keysymToKey[keysym.value.toLong()] + ?: keysymToKey[XLookupKeysym(event.xkey.ptr, 0).toLong()] + ?: Key.Unknown + val modifiers = xKeyboardModifiers( + state = event.xkey.state.toLong(), + key = key, + isKeyDown = type == KeyEventType.KeyDown, + ) + _windowInfo.keyboardModifiers = modifiers + // XLookupString emits Latin-1, one byte per character. + val codePoint = if (length > 0) buffer[0].toUByte().toInt() else 0 + scene.sendKeyEvent( + KeyEvent( + nativeKeyEvent = InternalKeyEvent( key = key, type = type, - codePoint = text.firstOrNull()?.code ?: 0, + codePoint = codePoint, + modifiers = modifiers, nativeEvent = event, ) ) - } + ) } } - -private val letterKeys = listOf( - Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J, Key.K, Key.L, Key.M, - Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z, -) - -private val digitKeys = listOf( - Key.Zero, Key.One, Key.Two, Key.Three, Key.Four, - Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine, -) - -/** - * X11 keysym → Compose [Key]. Latin letter keysyms equal their ASCII codes, so both - * cases map to the same key; keysyms absent from this table are ignored. - */ -private val keysymToKey: Map = - mapOf( - 0xff1bL to Key.Escape, - 0xff0dL to Key.Enter, - 0x0020L to Key.Spacebar, - 0xff08L to Key.Backspace, - 0xffffL to Key.Delete, - 0xff52L to Key.DirectionUp, - 0xff54L to Key.DirectionDown, - 0xff51L to Key.DirectionLeft, - 0xff53L to Key.DirectionRight, - ) + - ('a'..'z').zip(letterKeys).flatMap { (char, key) -> - listOf(char.code.toLong() to key, char.uppercaseChar().code.toLong() to key) - } + - ('0'..'9').zip(digitKeys).map { (char, key) -> char.code.toLong() to key } From dead25879556c7b6a7b410f9f23feeebc421e0a5 Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Sat, 4 Jul 2026 15:04:26 -0400 Subject: [PATCH 13/16] Add keyboard-only Linux text input service Mirrors MacosTextInputService: tracks the active input session so text fields get a real PlatformTextInputService, with XIM/ibus composition left as a follow-up. --- .../platform/LinuxTextInputService.linux.kt | 70 +++++++++++++++++++ .../compose/ui/window/ComposeWindow.linux.kt | 4 ++ 2 files changed, 74 insertions(+) create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/LinuxTextInputService.linux.kt diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/LinuxTextInputService.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/LinuxTextInputService.linux.kt new file mode 100644 index 0000000000000..260ebc400e372 --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/LinuxTextInputService.linux.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform + +import androidx.compose.ui.text.input.EditCommand +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.ImeOptions +import androidx.compose.ui.text.input.TextFieldValue + +/** + * Keyboard-only text input service for Linux, mirroring [MacosTextInputService]. + * + * Typed characters reach text fields through the key event path + * (`KeyEvent.utf16CodePoint`); this service tracks the active input session so a + * future X11 input-method (XIM/ibus) integration has a place to commit composed text. + */ +@Suppress("DEPRECATION") // TODO https://youtrack.jetbrains.com/issue/CMP-9858 +internal class LinuxTextInputService : androidx.compose.ui.text.input.PlatformTextInputService { + + data class CurrentInput( + var value: TextFieldValue, + val onEditCommand: ((List) -> Unit), + ) + + private var currentInput: CurrentInput? = null + + override fun startInput( + value: TextFieldValue, + imeOptions: ImeOptions, + onEditCommand: (List) -> Unit, + onImeActionPerformed: (ImeAction) -> Unit + ) { + currentInput = CurrentInput( + value, + onEditCommand + ) + } + + override fun stopInput() { + currentInput = null + } + + override fun showSoftwareKeyboard() { + // No software keyboard on desktop Linux. + } + + override fun hideSoftwareKeyboard() { + // No software keyboard on desktop Linux. + } + + override fun updateState(oldValue: TextFieldValue?, newValue: TextFieldValue) { + currentInput?.let { input -> + input.value = newValue + } + } +} diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt index 61a60e9236bc1..db997fe8a2fd0 100644 --- a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerButtons import androidx.compose.ui.input.pointer.PointerKeyboardModifiers import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner +import androidx.compose.ui.platform.LinuxTextInputService import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.WindowInfoImpl import androidx.compose.ui.scene.CanvasLayersComposeScene @@ -121,10 +122,13 @@ private class ComposeWindow( private val frameRecomposer = FrameRecomposer(SkikoDispatchers.Main) { skiaLayer.needRender() } private val sceneRenderingScope = SingleComposeSceneRenderingScope { skiaLayer.needRender() } + private val linuxTextInputService = LinuxTextInputService() + private val platformContext: PlatformContext = object : PlatformContext by PlatformContext.Empty() { override val windowInfo get() = _windowInfo override val architectureComponentsOwner get() = archComponentsOwner + override val textInputService get() = linuxTextInputService override fun setPointerIcon(pointerIcon: PointerIcon) { // Ignore } From f4291abb7282fcc45bd2ee7e0a9d80563a10187f Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Sat, 4 Jul 2026 15:15:37 -0400 Subject: [PATCH 14/16] Drain main dispatcher between X11 events Work scheduled by one event (gesture coroutines, focus moves) now runs before the next event is handled, matching AWT/AppKit loop ordering. A click-then-type burst previously delivered every key before the tap gesture focused the text field, dropping the typed characters. --- .../androidx/compose/ui/window/ComposeWindow.linux.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt index db997fe8a2fd0..097aa8095a0eb 100644 --- a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt @@ -184,12 +184,18 @@ private class ComposeWindow( * Handles every X11 event queued for this window's display and returns how many were * processed. Stops as soon as the window is disposed: [handleEvent] may close the * display (WM_DELETE_WINDOW → [dispose]), after which it must not be touched again. + * + * The main dispatcher is drained after each event so work it schedules (gesture + * coroutines, focus changes) runs before the next event, as on the AWT/AppKit loops. + * Otherwise a synthetic click-then-type burst delivers every key before the click's + * tap gesture has focused the text field, and the typed characters are dropped. */ fun pumpPendingEvents(event: XEvent): Int = generateSequence { event.takeIf { !isDisposed && XPending(x11Window.display) > 0 }?.also { XNextEvent(x11Window.display, it.ptr) handleEvent(it) + LinuxMainDispatcher.drain() } }.count() From 04a235d20699c61401a8b79f453fe4d9a9417dab Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Sat, 4 Jul 2026 15:18:08 -0400 Subject: [PATCH 15/16] Add unit tests for Linux keysym map and modifier translation :compose:ui:ui:linuxX64Test cannot run them yet: the shared skikoTest sources need ui-test, which is not enabled for linuxx64. The file compiles clean; enabling ui-test for linux is tracked as follow-up. --- .../compose/ui/input/key/KeyEventLinuxTest.kt | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 compose/ui/ui/src/linuxTest/kotlin/androidx/compose/ui/input/key/KeyEventLinuxTest.kt diff --git a/compose/ui/ui/src/linuxTest/kotlin/androidx/compose/ui/input/key/KeyEventLinuxTest.kt b/compose/ui/ui/src/linuxTest/kotlin/androidx/compose/ui/input/key/KeyEventLinuxTest.kt new file mode 100644 index 0000000000000..c43b579a39051 --- /dev/null +++ b/compose/ui/ui/src/linuxTest/kotlin/androidx/compose/ui/input/key/KeyEventLinuxTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.input.key + +import androidx.compose.ui.input.pointer.isAltPressed +import androidx.compose.ui.input.pointer.isCapsLockOn +import androidx.compose.ui.input.pointer.isCtrlPressed +import androidx.compose.ui.input.pointer.isMetaPressed +import androidx.compose.ui.input.pointer.isNumLockOn +import androidx.compose.ui.input.pointer.isShiftPressed +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private const val X_SHIFT_MASK = 1L shl 0 +private const val X_LOCK_MASK = 1L shl 1 +private const val X_CONTROL_MASK = 1L shl 2 +private const val X_MOD1_MASK = 1L shl 3 +private const val X_MOD2_MASK = 1L shl 4 +private const val X_MOD4_MASK = 1L shl 6 + +class KeyEventLinuxTest { + + @Test + fun lettersOfBothCasesMapToTheSameKey() { + assertEquals(Key.A, keysymToKey[0x61L]) + assertEquals(Key.A, keysymToKey[0x41L]) + assertEquals(Key.Z, keysymToKey[0x7aL]) + assertEquals(Key.Z, keysymToKey[0x5aL]) + } + + @Test + fun digitsPunctuationAndNavigationAreMapped() { + assertEquals(Key.Zero, keysymToKey['0'.code.toLong()]) + assertEquals(Key.Nine, keysymToKey['9'.code.toLong()]) + assertEquals(Key.Semicolon, keysymToKey[0x3bL]) + assertEquals(Key.Grave, keysymToKey[0x60L]) + assertEquals(Key.MoveHome, keysymToKey[0xff50L]) + assertEquals(Key.MoveEnd, keysymToKey[0xff57L]) + assertEquals(Key.PageDown, keysymToKey[0xff56L]) + assertEquals(Key.Tab, keysymToKey[0xff09L]) + } + + @Test + fun functionNumpadAndModifierKeysAreMapped() { + assertEquals(Key.F1, keysymToKey[0xffbeL]) + assertEquals(Key.F12, keysymToKey[0xffc9L]) + assertEquals(Key.NumPad0, keysymToKey[0xffb0L]) + assertEquals(Key.NumPad9, keysymToKey[0xffb9L]) + assertEquals(Key.NumPadEnter, keysymToKey[0xff8dL]) + assertEquals(Key.ShiftLeft, keysymToKey[0xffe1L]) + assertEquals(Key.CtrlRight, keysymToKey[0xffe4L]) + assertEquals(Key.AltLeft, keysymToKey[0xffe9L]) + // Super keys are Compose's Meta + assertEquals(Key.MetaLeft, keysymToKey[0xffebL]) + assertEquals(Key.MetaRight, keysymToKey[0xffecL]) + } + + @Test + fun stateMaskTranslatesToModifiers() { + val modifiers = xKeyboardModifiers( + state = X_SHIFT_MASK or X_CONTROL_MASK or X_LOCK_MASK or X_MOD2_MASK, + ) + assertTrue(modifiers.isShiftPressed) + assertTrue(modifiers.isCtrlPressed) + assertTrue(modifiers.isCapsLockOn) + assertTrue(modifiers.isNumLockOn) + assertFalse(modifiers.isAltPressed) + assertFalse(modifiers.isMetaPressed) + } + + @Test + fun altAndMetaComeFromMod1AndMod4() { + val modifiers = xKeyboardModifiers(state = X_MOD1_MASK or X_MOD4_MASK) + assertTrue(modifiers.isAltPressed) + assertTrue(modifiers.isMetaPressed) + assertFalse(modifiers.isShiftPressed) + } + + @Test + fun modifierKeyPressOverridesStaleState() { + // X11 reports the state before the event: pressing Shift carries no ShiftMask yet. + val pressed = xKeyboardModifiers(state = 0L, key = Key.ShiftLeft, isKeyDown = true) + assertTrue(pressed.isShiftPressed) + } + + @Test + fun modifierKeyReleaseOverridesStaleState() { + // Releasing Shift still carries ShiftMask in the pre-event state. + val released = xKeyboardModifiers( + state = X_SHIFT_MASK or X_CONTROL_MASK, + key = Key.ShiftRight, + isKeyDown = false, + ) + assertFalse(released.isShiftPressed) + assertTrue(released.isCtrlPressed) + } +} From 0daa5c041c1a39d12a5f2f586331804edc1b9fbc Mon Sep 17 00:00:00 2001 From: Aaron Chancey Date: Sat, 4 Jul 2026 15:40:06 -0400 Subject: [PATCH 16/16] Implement the X11 CLIPBOARD selection for Linux clipboard support Replaces the in-memory clipboard stub with a real X11 CLIPBOARD selection backend shared by Clipboard and ClipboardManager. Copy claims selection ownership and serves SelectionRequest events (TARGETS, UTF8_STRING, TEXT, STRING) from the window event loop; paste issues XConvertSelection and waits on the display connection for SelectionNotify, preferring UTF8_STRING with a Latin-1 STRING fallback, and short-circuits to in-process text when a window of this process owns the selection. SelectionClear drops stale ownership. INCR (multi-chunk) transfers are not supported yet. --- .../ui/platform/PlatformClipboard.linux.kt | 23 +- .../compose/ui/platform/X11Clipboard.linux.kt | 371 ++++++++++++++++++ .../compose/ui/window/ComposeWindow.linux.kt | 8 + .../ui/platform/X11ClipboardLinuxTest.kt | 51 +++ 4 files changed, 439 insertions(+), 14 deletions(-) create mode 100644 compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/X11Clipboard.linux.kt create mode 100644 compose/ui/ui/src/linuxTest/kotlin/androidx/compose/ui/platform/X11ClipboardLinuxTest.kt diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformClipboard.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformClipboard.linux.kt index 37bcfc5dbdd10..502cc422edad1 100644 --- a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformClipboard.linux.kt +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/PlatformClipboard.linux.kt @@ -21,38 +21,33 @@ import androidx.compose.ui.text.AnnotatedString actual typealias NativeClipboard = Any -private var memoryClipboardText: String? = null - private class LinuxPlatformClipboardManager : ClipboardManager { override fun getText(): AnnotatedString? = - memoryClipboardText?.let { AnnotatedString(it) } + X11Clipboard.getText()?.let { AnnotatedString(it) } override fun setText(annotatedString: AnnotatedString) { - memoryClipboardText = annotatedString.text + X11Clipboard.setText(annotatedString.text) } - override fun hasText(): Boolean = !memoryClipboardText.isNullOrEmpty() + override fun hasText(): Boolean = X11Clipboard.hasText() - override fun getClip(): ClipEntry? = memoryClipboardText?.let { ClipEntry.withPlainText(it) } + override fun getClip(): ClipEntry? = X11Clipboard.getText()?.let { ClipEntry.withPlainText(it) } override fun setClip(clipEntry: ClipEntry?) { - memoryClipboardText = clipEntry?.plainText + X11Clipboard.setText(clipEntry?.plainText) } } internal class LinuxPlatformClipboard : Clipboard { - override suspend fun getClipEntry(): ClipEntry? { - val str = memoryClipboardText - if (str.isNullOrEmpty()) return null - return ClipEntry.withPlainText(str) - } + override suspend fun getClipEntry(): ClipEntry? = + X11Clipboard.getText()?.takeIf { it.isNotEmpty() }?.let { ClipEntry.withPlainText(it) } override suspend fun setClipEntry(clipEntry: ClipEntry?) { - memoryClipboardText = clipEntry?.plainText + X11Clipboard.setText(clipEntry?.plainText) } override val nativeClipboard: NativeClipboard - get() = Any() + get() = X11Clipboard } internal actual fun createPlatformClipboardManager(): ClipboardManager = LinuxPlatformClipboardManager() diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/X11Clipboard.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/X11Clipboard.linux.kt new file mode 100644 index 0000000000000..d97ae7d9c5a63 --- /dev/null +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/platform/X11Clipboard.linux.kt @@ -0,0 +1,371 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform + +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.CPointerVar +import kotlinx.cinterop.IntVar +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.ULongVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.readBytes +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.value +import platform.posix.POLLIN +import platform.posix.poll +import platform.posix.pollfd +import x11gl.Atom +import x11gl.AtomVar +import x11gl.Display +import x11gl.SelectionNotify +import x11gl.Window +import x11gl.XChangeProperty +import x11gl.XCheckTypedWindowEvent +import x11gl.XConnectionNumber +import x11gl.XConvertSelection +import x11gl.XDeleteProperty +import x11gl.XEvent +import x11gl.XFlush +import x11gl.XFree +import x11gl.XGetSelectionOwner +import x11gl.XGetWindowProperty +import x11gl.XInternAtom +import x11gl.XSelectionClearEvent +import x11gl.XSelectionRequestEvent +import x11gl.XSendEvent +import x11gl.XSetSelectionOwner + +/** `((Atom) 4)` in Xatom.h; the cast macro is not exposed by cinterop. */ +private val XA_ATOM: Atom = 4UL + +/** `((Atom) 31)` in Xatom.h; the cast macro is not exposed by cinterop. */ +private val XA_STRING: Atom = 31UL + +/** `None`/`CurrentTime`/`AnyPropertyType` all expand to 0 with X11's resource types. */ +private val NONE: ULong = 0UL + +/** `PropModeReplace` in X.h. */ +private const val PROP_MODE_REPLACE = 0 + +/** How long a paste waits for the selection owner to answer [XConvertSelection]. */ +private val SELECTION_TIMEOUT = 500.milliseconds + +/** + * The X11 `CLIPBOARD` selection, shared backend for [Clipboard] and [ClipboardManager]. + * + * Copy claims selection ownership with [XSetSelectionOwner] and keeps the text in-process; + * the window event loop forwards `SelectionRequest`/`SelectionClear` events here so other + * clients can fetch it ([handleSelectionRequest]) and takeovers drop it + * ([handleSelectionClear]). Paste short-circuits to the in-process text when any registered + * window owns the selection (this also avoids deadlocking on a window served by the same + * event loop); otherwise it issues [XConvertSelection] and blocks on the display connection + * for up to [SELECTION_TIMEOUT] waiting for `SelectionNotify`, trying `UTF8_STRING` first + * and falling back to `STRING` (Latin-1) for legacy owners. + * + * Main-thread confined, like the event loop that drives it. INCR (multi-chunk) transfers + * are not supported: pastes larger than the server's transfer limit (hundreds of KB) fail, + * and copies that large may be refused by the server. + */ +internal object X11Clipboard { + + private class Binding(val display: CPointer, val window: Window) { + val clipboard: Atom = XInternAtom(display, "CLIPBOARD", 0) + val utf8String: Atom = XInternAtom(display, "UTF8_STRING", 0) + val targets: Atom = XInternAtom(display, "TARGETS", 0) + val text: Atom = XInternAtom(display, "TEXT", 0) + val incr: Atom = XInternAtom(display, "INCR", 0) + + /** Destination property on [window] for incoming selection transfers. */ + val transferProperty: Atom = XInternAtom(display, "COMPOSE_CLIPBOARD", 0) + } + + private enum class Conversion { DELIVERED, REFUSED, TIMED_OUT } + + private val bindings = mutableListOf() + + /** The binding whose window currently owns the CLIPBOARD selection, if any. */ + private var owner: Binding? = null + + /** The text served to selection requests while [owner] is non-null. */ + private var ownedText: String? = null + + /** Registers a window's display connection as a clipboard endpoint. */ + fun register(display: CPointer, window: Window) { + bindings.add(Binding(display, window)) + } + + /** + * Forgets [window]. X drops any selection ownership when the window is destroyed, + * so a pending copy dies with it (standard for apps without a clipboard manager). + */ + fun unregister(window: Window) { + bindings.removeAll { it.window == window } + if (owner?.window == window) owner = null + } + + /** Stores [text] and claims the CLIPBOARD selection; `null` releases both. */ + fun setText(text: String?) { + ownedText = text + val binding = bindings.firstOrNull() ?: return + if (text != null) { + XSetSelectionOwner(binding.display, binding.clipboard, binding.window, NONE) + owner = binding.takeIf { + XGetSelectionOwner(it.display, it.clipboard) == it.window + } + } else if (owner != null) { + XSetSelectionOwner(binding.display, binding.clipboard, NONE, NONE) + owner = null + } + XFlush(binding.display) + } + + /** Returns the CLIPBOARD text, or `null` when it is empty or not convertible to text. */ + fun getText(): String? { + val binding = bindings.firstOrNull() ?: return ownedText + val selectionOwner = XGetSelectionOwner(binding.display, binding.clipboard) + return when { + selectionOwner == NONE -> null + bindings.any { it.window == selectionOwner } -> ownedText + else -> fetchRemoteText(binding) + } + } + + /** + * Whether a paste would plausibly yield text. For an external owner this reports `true` + * without fetching TARGETS; it only gates edit menus, so optimistic is fine. + */ + fun hasText(): Boolean { + val binding = bindings.firstOrNull() ?: return !ownedText.isNullOrEmpty() + val selectionOwner = XGetSelectionOwner(binding.display, binding.clipboard) + return when { + selectionOwner == NONE -> false + bindings.any { it.window == selectionOwner } -> !ownedText.isNullOrEmpty() + else -> true + } + } + + /** + * Serves our clipboard text to another client. Supports the `TARGETS` introspection + * target plus `UTF8_STRING`/`TEXT` (UTF-8) and `STRING` (Latin-1); anything else is + * refused by replying with a `None` property, as ICCCM prescribes. + */ + fun handleSelectionRequest(request: XSelectionRequestEvent) { + val binding = bindings.firstOrNull { it.window == request.owner } ?: return + val text = ownedText.takeIf { request.selection == binding.clipboard } + // Obsolete (pre-ICCCM) requestors pass property None, meaning "use the target atom". + val property = if (request.property == NONE) request.target else request.property + val delivered = text != null && when (request.target) { + binding.targets -> writeTargets(binding, request.requestor, property) + binding.utf8String, binding.text -> + writeText(binding, request.requestor, property, binding.utf8String, text.encodeToByteArray()) + XA_STRING -> + writeText(binding, request.requestor, property, XA_STRING, text.encodeLatin1()) + else -> false + } + sendSelectionNotify(binding, request, if (delivered) property else NONE) + } + + /** Another client took the selection; our copy is stale, drop it. */ + fun handleSelectionClear(event: XSelectionClearEvent) { + val binding = owner ?: return + if (event.selection == binding.clipboard && event.window == binding.window) { + owner = null + ownedText = null + } + } + + /** Fetches from an external owner: `UTF8_STRING` first, `STRING` if that is refused. */ + private fun fetchRemoteText(binding: Binding): String? = + when (convertSelection(binding, binding.utf8String)) { + Conversion.DELIVERED -> readTransferProperty(binding) + Conversion.REFUSED -> + when (convertSelection(binding, XA_STRING)) { + Conversion.DELIVERED -> readTransferProperty(binding) + else -> null + } + Conversion.TIMED_OUT -> null + } + + /** + * Asks the selection owner to write [target]-typed data into [Binding.transferProperty] + * and waits for its `SelectionNotify` answer. Only `SelectionNotify` is consumed from + * the queue; all other events stay put for the main event loop. + */ + private fun convertSelection(binding: Binding, target: Atom): Conversion { + XDeleteProperty(binding.display, binding.window, binding.transferProperty) + XConvertSelection( + binding.display, + binding.clipboard, + target, + binding.transferProperty, + binding.window, + NONE, + ) + XFlush(binding.display) + memScoped { + val event = alloc() + val start = TimeSource.Monotonic.markNow() + while (start.elapsedNow() < SELECTION_TIMEOUT) { + val found = XCheckTypedWindowEvent( + binding.display, + binding.window, + SelectionNotify, + event.ptr, + ) + when { + found == 0 -> pollConnection(binding.display, timeoutMs = 10) + event.xselection.selection != binding.clipboard -> {} + event.xselection.property == NONE -> return Conversion.REFUSED + else -> return Conversion.DELIVERED + } + } + } + return Conversion.TIMED_OUT + } + + private fun readTransferProperty(binding: Binding): String? = memScoped { + val actualType = alloc() + val actualFormat = alloc() + val itemCount = alloc() + val bytesLeft = alloc() + val data = alloc>() + val status = XGetWindowProperty( + binding.display, + binding.window, + binding.transferProperty, + 0, + // Length is in 32-bit multiples; this caps a transfer at 4 MiB. + (4 * 1024 * 1024) / 4, + 1, // delete the property once read + NONE, // AnyPropertyType + actualType.ptr, + actualFormat.ptr, + itemCount.ptr, + bytesLeft.ptr, + data.ptr, + ) + val pointer = data.value + val bytes = pointer + ?.takeIf { status == 0 && actualFormat.value == 8 && actualType.value != binding.incr } + ?.readBytes(itemCount.value.toInt()) + pointer?.let { XFree(it) } + when { + bytes == null -> null + actualType.value == XA_STRING -> bytes.decodeLatin1() + else -> bytes.decodeToString() + } + } + + private fun writeTargets(binding: Binding, requestor: Window, property: Atom): Boolean = + memScoped { + val supported = listOf(binding.targets, binding.utf8String, binding.text, XA_STRING) + val atoms = allocArray(supported.size) { index -> + value = supported[index.toInt()] + } + XChangeProperty( + binding.display, + requestor, + property, + XA_ATOM, + 32, + PROP_MODE_REPLACE, + atoms.reinterpret(), + supported.size, + ) + true + } + + private fun writeText( + binding: Binding, + requestor: Window, + property: Atom, + type: Atom, + bytes: ByteArray, + ): Boolean { + if (bytes.isEmpty()) { + XChangeProperty( + binding.display, requestor, property, type, 8, PROP_MODE_REPLACE, null, 0, + ) + } else { + bytes.asUByteArray().usePinned { pinned -> + XChangeProperty( + binding.display, + requestor, + property, + type, + 8, + PROP_MODE_REPLACE, + pinned.addressOf(0), + bytes.size, + ) + } + } + return true + } + + /** Answers a `SelectionRequest`; [property] of `None` means the conversion was refused. */ + private fun sendSelectionNotify( + binding: Binding, + request: XSelectionRequestEvent, + property: Atom, + ): Unit = memScoped { + val reply = alloc() + reply.xselection.apply { + type = SelectionNotify + display = request.display + requestor = request.requestor + selection = request.selection + target = request.target + time = request.time + } + reply.xselection.property = property + XSendEvent(binding.display, request.requestor, 0, 0L, reply.ptr) + XFlush(binding.display) + } + + /** Sleeps until the display connection is readable or [timeoutMs] passes. */ + private fun pollConnection(display: CPointer, timeoutMs: Int): Unit = memScoped { + val fd = alloc().apply { + this.fd = XConnectionNumber(display) + events = POLLIN.toShort() + } + poll(fd.ptr, 1u, timeoutMs) + } +} + +/** + * Decodes X11 `STRING` (Latin-1) bytes, where each byte is the identically numbered + * Unicode code point. + */ +internal fun ByteArray.decodeLatin1(): String = + CharArray(size) { (this[it].toInt() and 0xFF).toChar() }.concatToString() + +/** + * Encodes for the X11 `STRING` (Latin-1) target; code points above U+00FF become `?`, + * matching what legacy requestors can represent. + */ +internal fun String.encodeLatin1(): ByteArray = + ByteArray(length) { index -> + this[index].code.let { if (it < 256) it.toByte() else '?'.code.toByte() } + } diff --git a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt index 097aa8095a0eb..ff9e0716c7728 100644 --- a/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt +++ b/compose/ui/ui/src/linuxMain/kotlin/androidx/compose/ui/window/ComposeWindow.linux.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner import androidx.compose.ui.platform.LinuxTextInputService import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.WindowInfoImpl +import androidx.compose.ui.platform.X11Clipboard import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.scene.SingleComposeSceneRenderingScope import androidx.compose.ui.platform.FrameRecomposer @@ -158,6 +159,7 @@ private class ComposeWindow( skiaLayer.renderDelegate = renderDelegate skiaLayer.attachTo(x11Window) x11Window.show() + X11Clipboard.register(x11Window.display, x11Window.window) scene.density = Density(x11Window.contentScale) scene.setContent { @@ -172,6 +174,7 @@ private class ComposeWindow( if (isDisposed) return archComponentsOwner.lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) archComponentsOwner.viewModelStore.clear() + X11Clipboard.unregister(x11Window.window) skiaLayer.detach() scene.close() frameRecomposer.close() @@ -244,6 +247,11 @@ private class ComposeWindow( KeyPress -> handleKey(event, KeyEventType.KeyDown) KeyRelease -> handleKey(event, KeyEventType.KeyUp) + + // Another client is pasting from us / took clipboard ownership. + SelectionRequest -> X11Clipboard.handleSelectionRequest(event.xselectionrequest) + + SelectionClear -> X11Clipboard.handleSelectionClear(event.xselectionclear) } } diff --git a/compose/ui/ui/src/linuxTest/kotlin/androidx/compose/ui/platform/X11ClipboardLinuxTest.kt b/compose/ui/ui/src/linuxTest/kotlin/androidx/compose/ui/platform/X11ClipboardLinuxTest.kt new file mode 100644 index 0000000000000..2e0c39da8826c --- /dev/null +++ b/compose/ui/ui/src/linuxTest/kotlin/androidx/compose/ui/platform/X11ClipboardLinuxTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class X11ClipboardLinuxTest { + + @Test + fun latin1RoundTripsAsciiText() { + val text = "Hello, X11 clipboard! 123" + assertEquals(text, text.encodeLatin1().decodeLatin1()) + } + + @Test + fun latin1RoundTripsHighBytes() { + val text = "café ü Ø" + assertEquals(text, text.encodeLatin1().decodeLatin1()) + } + + @Test + fun decodeLatin1TreatsBytesAsUnsigned() { + assertEquals("é", byteArrayOf(0xE9.toByte()).decodeLatin1()) + } + + @Test + fun encodeLatin1ReplacesUnrepresentableCodePointsWithQuestionMark() { + assertEquals("?? ok", "€世 ok".encodeLatin1().decodeLatin1()) + } + + @Test + fun encodeLatin1OfEmptyStringIsEmpty() { + assertContentEquals(ByteArray(0), "".encodeLatin1()) + } +}