Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ composenativetray = "2.1.0"
composewebview = "1.0.1"
detekt = "2.0.0-alpha.6"
downloadTask = "5.7.0"
filekit = "0.15.0"
filekit = "0.16.0"
graalvmNative = "1.1.3"
# Must match the hot-reload version bundled by the Compose Gradle plugin (which auto-applies
# hot-reload to every Compose module): TaoHotReloadBridgeImpl compiles against these artifacts
Expand Down
5 changes: 5 additions & 0 deletions nucleus-application/api/nucleus-application.api
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ public final class dev/nucleusframework/application/DefaultNucleusWindowHost : d
public fun Window-rOktWo0 (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V
}

public final class dev/nucleusframework/application/FileKitDialogsKt {
public static final fun withFileKitDialogSettings (Ldev/nucleusframework/application/NucleusWindow;Lio/github/vinceglb/filekit/dialogs/FileKitDialogSettings;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun withFileKitDialogSettings$default (Ldev/nucleusframework/application/NucleusWindow;Lio/github/vinceglb/filekit/dialogs/FileKitDialogSettings;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
}

public final class dev/nucleusframework/application/NucleusApplicationKt {
public static final fun nucleusApplication ([Ljava/lang/String;ZLjava/util/Locale;ZZZLkotlin/jvm/functions/Function3;)V
public static synthetic fun nucleusApplication$default ([Ljava/lang/String;ZLjava/util/Locale;ZZZLkotlin/jvm/functions/Function3;ILjava/lang/Object;)V
Expand Down
5 changes: 4 additions & 1 deletion nucleus-application/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@ dependencies {
api(project(":decorated-window-tao"))

// compileOnly: nucleusApplication initializes FileKit only when the app
// ships it (see FileKitIntegration.kt); never forced on consumers.
// ships it (see FileKitIntegration.kt), and withFileKitDialogSettings is
// only callable by an app that has filekit-dialogs; never forced on consumers.
compileOnly(libs.filekit.core)
compileOnly(libs.filekit.dialogs)

testImplementation(libs.junit)
testImplementation(libs.filekit.core)
testImplementation(libs.filekit.dialogs)
testImplementation(compose.desktop.currentOs)
testImplementation("org.jetbrains.compose.ui:ui-test-junit4:${libs.versions.compose.get()}")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package dev.nucleusframework.application

import dev.nucleusframework.core.runtime.Platform
import dev.nucleusframework.window.tao.TaoWindow
import dev.nucleusframework.window.tao.XdgPortalParent
import io.github.vinceglb.filekit.dialogs.FileKitDialogParent
import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
* Runs [block] with [settings] parented to this window, so the FileKit dialog it opens is attached
* to the window instead of floating free:
*
* - **Windows**: the window's HWND becomes the dialog's owner.
* - **Linux X11 / XWayland**: the portal gets `x11:<xid>`.
* - **Linux Wayland**: the window is exported through `xdg_foreign` for the duration of [block]
* and unexported when it returns, which is the lifetime the portal requires.
* - **macOS**: left unparented — FileKit's `runModal` panel is already app-modal (it runs
* `NSApplication.runModal(for:)`), so no other window can take it over.
*
* A [settings] that already carries a parent is passed through untouched, and so is every
* setting when the window exposes no platform identity (not realized yet, native bridge missing).
*
* ```kotlin
* val window = LocalNucleusWindow.current
* scope.launch {
* val file = window.withFileKitDialogSettings { settings ->
* FileKit.openFilePicker(dialogSettings = settings)
* }
* }
* ```
*
* Requires `filekit-dialogs` on the app's classpath; `nucleus-application` never ships it.
*/
public suspend fun <T> NucleusWindow.withFileKitDialogSettings(
settings: FileKitDialogSettings = FileKitDialogSettings.createDefault(),
block: suspend (FileKitDialogSettings) -> T,
): T = withDialogParent(settings, { unsafe.taoWindow?.fileKitDialogParent() }, block)

/** A dialog parent plus whatever keeps it valid (the Wayland export), released after the dialog. */
internal class BorrowedDialogParent(
val parent: FileKitDialogParent,
private val lease: AutoCloseable? = null,
) : AutoCloseable {
override fun close() {
lease?.close()
}
}

internal suspend fun <T> withDialogParent(
settings: FileKitDialogSettings,
resolveParent: () -> BorrowedDialogParent?,
block: suspend (FileKitDialogSettings) -> T,
): T {
if (settings.parent != null) return block(settings)
// The Wayland export blocks until the compositor answers, so keep it off the UI thread.
val borrowed = withContext(Dispatchers.IO) { resolveParent() } ?: return block(settings)
return borrowed.use { block(settings.copy(parent = it.parent)) }
}

private fun TaoWindow.fileKitDialogParent(): BorrowedDialogParent? =
when (Platform.Current) {
Platform.Windows -> {
val hwnd = nativeHandle
if (hwnd == 0L) null else BorrowedDialogParent(FileKitDialogParent.windows(hwnd))
}
Platform.Linux ->
when (val portalParent = xdgPortalParent()) {
is XdgPortalParent.X11 -> BorrowedDialogParent(FileKitDialogParent.x11(portalParent.xid))
is XdgPortalParent.Wayland ->
BorrowedDialogParent(FileKitDialogParent.wayland(portalParent.handle), lease = portalParent)
null -> null
}
// runModal is already app-modal on macOS; FileKit also rejects any non-AWT parent there.
else -> null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package dev.nucleusframework.application

import io.github.vinceglb.filekit.dialogs.FileKitDialogParent
import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test

class FileKitDialogsTest {
private val windowParent = FileKitDialogParent.windows(0x42)

@Test
fun `parents the settings and releases the lease after the dialog`() =
runBlocking {
var released = false
val settings = FileKitDialogSettings(title = "Open")
val seen =
withDialogParent(settings, { BorrowedDialogParent(windowParent) { released = true } }) {
assertFalse("lease released before the dialog finished", released)
it
}
assertSame(windowParent, seen.parent)
assertEquals("Open", seen.title)
assertTrue(released)
}

@Test
fun `releases the lease when the dialog throws`() {
var released = false
runCatching {
runBlocking {
withDialogParent(FileKitDialogSettings(), { BorrowedDialogParent(windowParent) { released = true } }) {
error("picker failed")
}
}
}
assertTrue(released)
}

@Test
fun `keeps a parent the caller already chose`() =
runBlocking {
val chosen = FileKitDialogSettings(parent = FileKitDialogParent.x11(7))
val seen = withDialogParent(chosen, { error("must not resolve") }) { it }
assertSame(chosen, seen)
}

@Test
fun `leaves the settings unparented without a platform identity`() =
runBlocking {
val settings = FileKitDialogSettings()
assertSame(settings, withDialogParent(settings, { null }) { it })
}
}
Loading