From 8d7a800e49ef50c54620bd292921908c48892a95 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 27 Jul 2026 19:26:19 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Add=20unified=20JVM=20dialog=20?= =?UTF-8?q?parent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/dialog-settings.mdx | 57 ++++++++- .../dialogs/compose/FileKitCompose.jvm.kt | 5 +- .../dialogs/FileKitDialogParent.jvm.kt | 111 ++++++++++++++++++ .../dialogs/FileKitDialogSettings.jvm.kt | 62 +++++++++- .../dialogs/platform/awt/AwtFilePicker.kt | 5 +- .../dialogs/platform/awt/AwtFileSaver.kt | 8 +- .../dialogs/platform/mac/MacOSFilePicker.kt | 53 +++++---- .../dialogs/platform/swing/SwingFilePicker.kt | 5 +- .../platform/windows/WindowsFilePicker.kt | 29 ++--- .../platform/xdg/XdgFilePickerPortal.kt | 19 ++- .../dialogs/FileKitDialogParentTest.kt | 110 +++++++++++++++++ .../FilePickerDialogSettings.jvm.kt | 2 +- 12 files changed, 400 insertions(+), 66 deletions(-) create mode 100644 filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt diff --git a/docs/dialogs/dialog-settings.mdx b/docs/dialogs/dialog-settings.mdx index 39d9de0b..f2f20c5e 100644 --- a/docs/dialogs/dialog-settings.mdx +++ b/docs/dialogs/dialog-settings.mdx @@ -11,20 +11,20 @@ FileKit allows you to customize dialog behavior with platform-specific settings. On JVM platforms (Windows, macOS, Linux), you can customize: - `title`: Set a custom title for the dialog -- `parentWindow`: Set the parent window for the dialog (useful for modal behavior) +- `parent`: Set the typed parent for the dialog - `macOS`: Configure macOS-specific settings ```kotlin -// Basic settings with title and parent window +// Basic settings with an AWT parent window val settings = FileKitDialogSettings( title = "Select a file", - parentWindow = window + parent = FileKitDialogParent.awt(window) ) // With macOS-specific settings val settings = FileKitDialogSettings( title = "Select a file", - parentWindow = window, + parent = FileKitDialogParent.awt(window), macOS = FileKitMacOSSettings( resolvesAliases = false, canCreateDirectories = true @@ -36,11 +36,56 @@ For desktop applications, it's recommended to pass the window reference: ```kotlin Window(onCloseRequest = ::exitApplication) { - val dialogSettings = FileKitDialogSettings(this.window) + val dialogSettings = FileKitDialogSettings( + parent = FileKitDialogParent.awt(this.window) + ) App(dialogSettings) } ``` +### JVM parent types + +`FileKitDialogParent` makes platform-native ownership explicit. Use only the +factory matching the integration that owns the window: + +```kotlin +val windowsSettings = FileKitDialogSettings( + parent = FileKitDialogParent.windows(hwnd), +) +val x11Settings = FileKitDialogSettings( + parent = FileKitDialogParent.x11(xid), +) +val waylandSettings = FileKitDialogSettings( + parent = FileKitDialogParent.wayland(xdgForeignPortalToken), +) +``` + +Windows dialogs accept AWT windows and native HWNDs. Linux's XDG portal accepts +AWT windows (converted to `x11:`), X11 XIDs, and Wayland +portal tokens. If the portal is unavailable, FileKit's AWT/Swing fallback accepts +only an AWT parent; supplying an X11 or Wayland parent fails rather than silently +showing an unparented dialog. + +`windows(0)` and invalid X11 XIDs are rejected. `wayland(...)` accepts a nonblank, +whitespace-free opaque xdg-portal/xdg-foreign token only. Do not pass a raw +`wl_surface*`, a decimal pointer, or a `0x...` pointer value: FileKit does not +acquire or dereference Wayland surfaces. + +On macOS JVM, FileKit keeps the existing `NSOpenPanel`/`NSSavePanel.runModal()` +implementation. An AWT parent is accepted for API consistency, but FileKit does +not currently bridge it to an `NSWindow` or create an owned sheet; `parent` does +not change that behavior. + +### Migrating from `parentWindow` + +`parentWindow` is deprecated for one release. It remains available as an AWT-only +source-compatibility adapter and normalizes directly into `parent`; there is never +a second stored parent value. Replace it with `parent = +FileKitDialogParent.awt(parentWindow)`. The deprecated `parentWindow` getter +returns a window only when `parent` is an AWT parent; it returns `null` for native +parents. A single constructor or `copy` call accepts either `parent` or +`parentWindow`, never both, so there is no precedence rule to learn. + ### iOS and macOS Settings On iOS and macOS, you can configure: @@ -91,7 +136,7 @@ expect fun createDialogSettings(): FileKitDialogSettings actual fun createDialogSettings(): FileKitDialogSettings { return FileKitDialogSettings( title = "Select a file", - parentWindow = window, + parent = FileKitDialogParent.awt(window), macOS = FileKitMacOSSettings( canCreateDirectories = true ) diff --git a/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt b/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt index 8123857d..201cd8a2 100644 --- a/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt +++ b/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt @@ -5,6 +5,7 @@ package io.github.vinceglb.filekit.dialogs.compose import androidx.compose.runtime.Composable import androidx.compose.ui.window.WindowScope import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitType @@ -62,5 +63,5 @@ private fun injectDialogSettings( dialogSettings: FileKitDialogSettings?, window: Window, ): FileKitDialogSettings = dialogSettings - ?.copy(parentWindow = window) - ?: FileKitDialogSettings(parentWindow = window) + ?.copy(parent = FileKitDialogParent.awt(window)) + ?: FileKitDialogSettings(parent = FileKitDialogParent.awt(window)) diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt new file mode 100644 index 00000000..267be895 --- /dev/null +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt @@ -0,0 +1,111 @@ +package io.github.vinceglb.filekit.dialogs + +import java.awt.Window + +/** + * The JVM window or platform handle which owns a FileKit dialog. + * + * Use a factory matching the integration which owns the window. Native handles are + * deliberately typed by platform: a Wayland token is not a pointer and an X11 XID + * is not a Windows HWND. + */ +public sealed class FileKitDialogParent private constructor() { + internal class Awt( + val window: Window, + ) : FileKitDialogParent() + + internal class Windows( + val hwnd: Long, + ) : FileKitDialogParent() + + internal class X11( + val identifier: String, + ) : FileKitDialogParent() + + internal class Wayland( + val identifier: String, + ) : FileKitDialogParent() + + public companion object { + private val POINTER_SHAPED_TOKEN: Regex = Regex("(?:0x[0-9a-fA-F]+|[0-9]+)") + + /** Uses an AWT window as the dialog parent. */ + public fun awt(window: Window): FileKitDialogParent = Awt(window) + + /** + * Uses a non-zero Windows HWND as the dialog parent. + * + * Negative values are accepted because they can be valid pointer bit patterns. + */ + public fun windows(hwnd: Long): FileKitDialogParent { + require(hwnd != 0L) { "A Windows HWND must not be zero." } + return Windows(hwnd) + } + + /** Uses a non-zero 32-bit X11 XID as the dialog parent. */ + public fun x11(xid: Long): FileKitDialogParent = X11(x11Identifier(xid)) + + /** + * Uses an opaque xdg-portal/xdg-foreign Wayland parent token. + * + * The token must come from the caller's Wayland integration. FileKit never + * accepts or dereferences a raw `wl_surface*` pointer. + */ + public fun wayland(portalToken: String): FileKitDialogParent { + require(portalToken.isNotBlank()) { "A Wayland portal token must not be blank." } + require(portalToken.none(Char::isWhitespace)) { + "A Wayland portal token must not contain whitespace." + } + require(!portalToken.startsWith("wayland:")) { + "Pass the opaque Wayland portal token without the wayland: prefix." + } + require(!portalToken.matches(POINTER_SHAPED_TOKEN)) { + "A Wayland portal token must not be a raw pointer-shaped value." + } + return Wayland("wayland:$portalToken") + } + } +} + +internal fun FileKitDialogParent?.awtWindowOrNull(): Window? = when (this) { + null -> null + is FileKitDialogParent.Awt -> window + else -> null +} + +internal fun FileKitDialogParent?.requireAwtWindowOrNull(adapter: String): Window? = when (this) { + null -> null + is FileKitDialogParent.Awt -> window + else -> throw IllegalArgumentException("$adapter only supports an AWT dialog parent.") +} + +internal fun FileKitDialogParent?.resolveWindowsHandle( + awtWindowHandle: (Window) -> Long, +): Long? = when (this) { + null -> null + is FileKitDialogParent.Awt -> awtWindowHandle(window).requireNonZeroHandle("An AWT window") + is FileKitDialogParent.Windows -> hwnd + else -> throw IllegalArgumentException("Windows dialogs only support AWT or Windows dialog parents.") +} + +internal fun FileKitDialogParent?.resolveXdgPortalParent( + awtWindowXid: (Window) -> Long, +): String = when (this) { + null -> "" + is FileKitDialogParent.Awt -> x11Identifier(awtWindowXid(window)) + is FileKitDialogParent.X11 -> identifier + is FileKitDialogParent.Wayland -> identifier + else -> throw IllegalArgumentException("XDG portal dialogs only support AWT, X11, or Wayland dialog parents.") +} + +private fun x11Identifier(xid: Long): String { + require(xid in 1..X11_XID_MAX) { "An X11 XID must be between 1 and 0xffffffff." } + return "x11:${xid.toString(16)}" +} + +private fun Long.requireNonZeroHandle(label: String): Long { + require(this != 0L) { "$label must resolve to a non-zero native handle." } + return this +} + +private const val X11_XID_MAX: Long = 0xffff_ffffL diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt index 11cf7ff6..7f291451 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt @@ -6,14 +6,72 @@ import java.awt.Window * JVM implementation of [FileKitDialogSettings]. * * @property title The title of the dialog. - * @property parentWindow The parent window for the dialog. + * @property parent The parent for the dialog, when one is available. * @property macOS Specific settings for macOS when running on JVM. */ public actual data class FileKitDialogSettings( public val title: String? = null, - public val parentWindow: Window? = null, + public val parent: FileKitDialogParent? = null, public val macOS: FileKitMacOSSettings = FileKitMacOSSettings(), ) { + /** + * Compatibility adapter for the former AWT-only setting. + * + * This constructor normalizes immediately into [parent], so an instance never + * stores competing AWT and native parents. It intentionally requires + * [parentWindow] to keep `FileKitDialogSettings()` unambiguous. + */ + @Deprecated( + message = "Use parent = parentWindow?.let(FileKitDialogParent::awt) instead.", + replaceWith = ReplaceWith( + "FileKitDialogSettings(title = title, parent = parentWindow?.let(FileKitDialogParent::awt), macOS = macOS)", + ), + ) + public constructor( + title: String? = null, + parentWindow: Window?, + macOS: FileKitMacOSSettings = FileKitMacOSSettings(), + ) : this( + title = title, + parent = parentWindow?.let(FileKitDialogParent::awt), + macOS = macOS, + ) + + /** + * The AWT window represented by [parent], if any. + * + * Native [FileKitDialogParent] values return `null`; callers should migrate to + * [parent] rather than treating that result as an unparented dialog. + */ + @Deprecated( + message = "Use parent instead.", + replaceWith = ReplaceWith("parent"), + ) + public val parentWindow: Window? + get() = parent.awtWindowOrNull() + + /** + * Compatibility adapter for copying a former AWT-only setting. + * + * A canonical and a legacy parent cannot be supplied in the same call: the + * overloads deliberately expose either `parent` or `parentWindow`, never both. + */ + @Deprecated( + message = "Use copy(parent = parentWindow?.let(FileKitDialogParent::awt)) instead.", + replaceWith = ReplaceWith( + "copy(title = title, parent = parentWindow?.let(FileKitDialogParent::awt), macOS = macOS)", + ), + ) + public fun copy( + title: String? = this.title, + parentWindow: Window?, + macOS: FileKitMacOSSettings = this.macOS, + ): FileKitDialogSettings = FileKitDialogSettings( + title = title, + parent = parentWindow?.let(FileKitDialogParent::awt), + macOS = macOS, + ) + public actual companion object { /** * Creates a default instance of [FileKitDialogSettings]. diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt index e196257d..52b6ca9c 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt @@ -3,6 +3,7 @@ package io.github.vinceglb.filekit.dialogs.platform.awt import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker +import io.github.vinceglb.filekit.dialogs.requireAwtWindowOrNull import io.github.vinceglb.filekit.path import kotlinx.coroutines.suspendCancellableCoroutine import java.awt.Dialog @@ -25,7 +26,7 @@ internal class AwtFilePicker : PlatformFilePicker { isMultipleMode = false, fileExtensions = fileExtensions, directory = directory, - parentWindow = dialogSettings.parentWindow, + parentWindow = dialogSettings.parent.requireAwtWindowOrNull("The Linux AWT fallback"), )?.firstOrNull() override suspend fun openFilesPicker( @@ -37,7 +38,7 @@ internal class AwtFilePicker : PlatformFilePicker { isMultipleMode = true, fileExtensions = fileExtensions, directory = directory, - parentWindow = dialogSettings.parentWindow, + parentWindow = dialogSettings.parent.requireAwtWindowOrNull("The Linux AWT fallback"), ) override suspend fun openDirectoryPicker( diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt index c3e7b6a4..953fbcb8 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt @@ -2,6 +2,7 @@ package io.github.vinceglb.filekit.dialogs.platform.awt import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.requireAwtWindowOrNull import io.github.vinceglb.filekit.path import kotlinx.coroutines.suspendCancellableCoroutine import java.awt.Dialog @@ -25,16 +26,17 @@ internal object AwtFileSaver { } } + val parentWindow = dialogSettings?.parent.requireAwtWindowOrNull("The Linux AWT fallback") // Handle parentWindow: Dialog, Frame, or null - val dialog = when (dialogSettings?.parentWindow) { - is Dialog -> object : FileDialog(dialogSettings.parentWindow, "Save dialog", SAVE) { + val dialog = when (parentWindow) { + is Dialog -> object : FileDialog(parentWindow, "Save dialog", SAVE) { override fun setVisible(value: Boolean) { super.setVisible(value) handleResult(value, files) } } - else -> object : FileDialog(dialogSettings?.parentWindow as? Frame, "Save dialog", SAVE) { + else -> object : FileDialog(parentWindow as? Frame, "Save dialog", SAVE) { override fun setVisible(value: Boolean) { super.setVisible(value) handleResult(value, files) diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt index 2fd58cf0..0c25f3c1 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt @@ -7,6 +7,7 @@ import io.github.vinceglb.filekit.dialogs.buildFileSaverAllowedFileTypes import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.Foundation import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.ID +import io.github.vinceglb.filekit.dialogs.requireAwtWindowOrNull import io.github.vinceglb.filekit.path import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -17,36 +18,45 @@ internal class MacOSFilePicker : PlatformFilePicker { fileExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): File? = callNativeMacOSPicker( - mode = MacOSFilePickerMode.SingleFile, - directory = directory, - fileExtensions = fileExtensions, - title = dialogSettings.title, - macOSSettings = dialogSettings.macOS, - ) + ): File? { + dialogSettings.parent.requireAwtWindowOrNull("macOS JVM dialogs") + return callNativeMacOSPicker( + mode = MacOSFilePickerMode.SingleFile, + directory = directory, + fileExtensions = fileExtensions, + title = dialogSettings.title, + macOSSettings = dialogSettings.macOS, + ) + } override suspend fun openFilesPicker( fileExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): List? = callNativeMacOSPicker( - mode = MacOSFilePickerMode.MultipleFiles, - directory = directory, - fileExtensions = fileExtensions, - title = dialogSettings.title, - macOSSettings = dialogSettings.macOS, - ) + ): List? { + dialogSettings.parent.requireAwtWindowOrNull("macOS JVM dialogs") + return callNativeMacOSPicker( + mode = MacOSFilePickerMode.MultipleFiles, + directory = directory, + fileExtensions = fileExtensions, + title = dialogSettings.title, + macOSSettings = dialogSettings.macOS, + ) + } override suspend fun openDirectoryPicker( directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): File? = callNativeMacOSPicker( - mode = MacOSFilePickerMode.Directories, - directory = directory, - fileExtensions = null, - title = dialogSettings.title, - macOSSettings = dialogSettings.macOS, - ) + ): File? { + dialogSettings.parent.requireAwtWindowOrNull("macOS JVM dialogs") + return callNativeMacOSPicker( + mode = MacOSFilePickerMode.Directories, + directory = directory, + fileExtensions = null, + title = dialogSettings.title, + macOSSettings = dialogSettings.macOS, + ) + } override suspend fun openFileSaver( suggestedName: String, @@ -55,6 +65,7 @@ internal class MacOSFilePicker : PlatformFilePicker { directory: PlatformFile?, dialogSettings: FileKitDialogSettings, ): File? = withContext(Dispatchers.IO) { + dialogSettings.parent.requireAwtWindowOrNull("macOS JVM dialogs") val pool = Foundation.NSAutoreleasePool() try { var response: File? = null diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt index 59fbf059..adeb745e 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt @@ -3,6 +3,7 @@ package io.github.vinceglb.filekit.dialogs.platform.swing import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker +import io.github.vinceglb.filekit.dialogs.requireAwtWindowOrNull import io.github.vinceglb.filekit.path import kotlinx.coroutines.suspendCancellableCoroutine import java.io.File @@ -76,7 +77,9 @@ internal class SwingFilePicker : PlatformFilePicker { jFileChooser.dialogTitle = dialogSettings.title } - val returnValue = jFileChooser.showOpenDialog(dialogSettings.parentWindow) + val returnValue = jFileChooser.showOpenDialog( + dialogSettings.parent.requireAwtWindowOrNull("The Linux Swing fallback"), + ) if (returnValue == JFileChooser.APPROVE_OPTION) { continuation.resume( jFileChooser.selectedFiles.toList().takeIf { it.isNotEmpty() } ?: jFileChooser.selectedFile?.let { listOf(it) }, diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt index 88ae3202..65f95d27 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt @@ -1,6 +1,7 @@ package io.github.vinceglb.filekit.dialogs.platform.windows import com.sun.jna.Native +import com.sun.jna.Pointer import com.sun.jna.WString import com.sun.jna.platform.win32.COM.COMUtils.FAILED import com.sun.jna.platform.win32.Guid @@ -33,8 +34,8 @@ import io.github.vinceglb.filekit.dialogs.platform.windows.jna.Shell32 import io.github.vinceglb.filekit.dialogs.platform.windows.jna.ShellItem import io.github.vinceglb.filekit.dialogs.platform.windows.jna.ShellItemArray import io.github.vinceglb.filekit.dialogs.platform.windows.util.GuidFixed +import io.github.vinceglb.filekit.dialogs.resolveWindowsHandle import io.github.vinceglb.filekit.path -import java.awt.Window import java.io.File internal class WindowsFilePicker( @@ -60,7 +61,7 @@ internal class WindowsFilePicker( ?.takeIf { it.isNotEmpty() } ?.let { fileOpenDialog.addFiltersToDialog(it) } - fileOpenDialog.show(dialogSettings.parentWindow) { + fileOpenDialog.show(dialogSettings.resolveWindowsHandle()) { it.getResult(SIGDN_FILESYSPATH) } } @@ -88,7 +89,7 @@ internal class WindowsFilePicker( // Set a flag for multiple options fileOpenDialog.setFlag(FOS_ALLOWMULTISELECT) - fileOpenDialog.show(dialogSettings.parentWindow) { + fileOpenDialog.show(dialogSettings.resolveWindowsHandle()) { it.getResults() } } @@ -111,7 +112,7 @@ internal class WindowsFilePicker( fileOpenDialog.setFlag(FOS_PICKFOLDERS) // Show the dialog to the user - fileOpenDialog.show(dialogSettings.parentWindow) { + fileOpenDialog.show(dialogSettings.resolveWindowsHandle()) { it.getResult(SIGDN_DESKTOPABSOLUTEPARSING) } } @@ -143,7 +144,7 @@ internal class WindowsFilePicker( filterExtensions?.let { fileSaveDialog.addFiltersToDialog(it) } // Show the dialog to the user - fileSaveDialog.show(dialogSettings.parentWindow) { + fileSaveDialog.show(dialogSettings.resolveWindowsHandle()) { it.getResult(SIGDN_FILESYSPATH) } } @@ -260,11 +261,11 @@ internal class WindowsFilePicker( } private fun FD.show( - parentWindow: Window?, + parentHandle: Long?, block: (FD) -> T, ): T? { // Show the dialog to the user - val openDialogResult = this.Show(parentWindow.toHwnd()) + val openDialogResult = this.Show(parentHandle?.let(::toHwnd)) // Valid error code: User canceled the dialog val userCanceledException = Win32Exception(ERROR_CANCELLED) @@ -374,17 +375,11 @@ internal class WindowsFilePicker( } } - private fun Window?.toHwnd(): WinDef.HWND? = when (this) { - null -> { - null - } - - else -> { - Native - .getWindowPointer(this) - .let { WinDef.HWND(it) } - } + private fun FileKitDialogSettings.resolveWindowsHandle(): Long? = parent.resolveWindowsHandle { window -> + Pointer.nativeValue(Native.getWindowPointer(window)) } + + private fun toHwnd(handle: Long): WinDef.HWND = WinDef.HWND(Pointer(handle)) } internal fun requiredFileDialogOptions(options: Int): Int = options or FOS_FORCEFILESYSTEM diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt index 3bd71e94..09da0eae 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt @@ -2,8 +2,10 @@ package io.github.vinceglb.filekit.dialogs.platform.xdg import com.sun.jna.Native import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker +import io.github.vinceglb.filekit.dialogs.resolveXdgPortalParent import io.github.vinceglb.filekit.path import kotlinx.coroutines.CompletableDeferred import org.freedesktop.dbus.DBusMatchRule @@ -21,7 +23,6 @@ import org.freedesktop.dbus.interfaces.Properties import org.freedesktop.dbus.messages.DBusSignal import org.freedesktop.dbus.types.UInt32 import org.freedesktop.dbus.types.Variant -import java.awt.Window import java.io.File import java.lang.reflect.InvocationTargetException import java.net.URI @@ -54,7 +55,7 @@ internal class XdgFilePickerPortal : PlatformFilePicker { directory = directory, fileExtensions = fileExtensions, title = dialogSettings.title, - parentWindow = dialogSettings.parentWindow, + parent = dialogSettings.parent, multiple = false, openDirectory = false, )?.firstOrNull() @@ -67,7 +68,7 @@ internal class XdgFilePickerPortal : PlatformFilePicker { directory = directory, fileExtensions = fileExtensions, title = dialogSettings.title, - parentWindow = dialogSettings.parentWindow, + parent = dialogSettings.parent, multiple = true, openDirectory = false, ) @@ -79,7 +80,7 @@ internal class XdgFilePickerPortal : PlatformFilePicker { directory = directory, fileExtensions = null, title = dialogSettings.title, - parentWindow = dialogSettings.parentWindow, + parent = dialogSettings.parent, multiple = false, openDirectory = true, )?.firstOrNull() @@ -88,7 +89,7 @@ internal class XdgFilePickerPortal : PlatformFilePicker { directory: PlatformFile?, fileExtensions: Set?, title: String?, - parentWindow: Window?, + parent: FileKitDialogParent?, multiple: Boolean, openDirectory: Boolean, ): List? { @@ -103,7 +104,7 @@ internal class XdgFilePickerPortal : PlatformFilePicker { val deferredResult = registerResponseHandler(connection, handleToken) getFileChooserObject(connection).OpenFile( - parentWindow = getWindowIdentifier(parentWindow) ?: "", + parentWindow = parent.resolveXdgPortalParent(Native::getWindowID), title = title ?: "", options = options, ) @@ -135,7 +136,7 @@ internal class XdgFilePickerPortal : PlatformFilePicker { val deferredResult = registerResponseHandler(connection, handleToken) getFileChooserObject(connection).SaveFile( - parentWindow = getWindowIdentifier(dialogSettings.parentWindow) ?: "", + parentWindow = dialogSettings.parent.resolveXdgPortalParent(Native::getWindowID), title = "", options = options, ) @@ -228,10 +229,6 @@ internal class XdgFilePickerPortal : PlatformFilePicker { } } - // awt only supports X11 - private fun getWindowIdentifier(parentWindow: Window?) = - parentWindow?.let { "X11:${Native.getWindowID(it)}" } - private fun getFileChooserObject(connection: DBusConnection) = connection.getRemoteObject( "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt new file mode 100644 index 00000000..6ae458e6 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt @@ -0,0 +1,110 @@ +@file:Suppress("DEPRECATION", "ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import java.awt.Window +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class FileKitDialogParentTest { + @Test + fun FileKitDialogParent_windows_rejectsZeroHandle() { + assertFailsWith { + FileKitDialogParent.windows(0) + } + } + + @Test + fun FileKitDialogParent_windows_preservesNegativePointerBitPatterns() { + assertEquals( + expected = -1L, + actual = FileKitDialogParent.windows(-1).resolveWindowsHandle { error("Unexpected AWT conversion") }, + ) + } + + @Test + fun FileKitDialogParent_x11_formatsLowercasePortalIdentifier() { + assertEquals( + expected = "x11:1a2b", + actual = FileKitDialogParent.x11(0x1a2b).resolveXdgPortalParent { error("Unexpected AWT conversion") }, + ) + } + + @Test + fun FileKitDialogParent_x11_rejectsInvalidXids() { + listOf(0L, 0x1_0000_0000L).forEach { xid -> + assertFailsWith { + FileKitDialogParent.x11(xid) + } + } + } + + @Test + fun FileKitDialogParent_wayland_preservesOpaquePortalToken() { + assertEquals( + expected = "wayland:xdg-foreign-token_42", + actual = FileKitDialogParent.wayland("xdg-foreign-token_42").resolveXdgPortalParent { + error("Unexpected AWT conversion") + }, + ) + } + + @Test + fun FileKitDialogParent_wayland_rejectsUnsafeTokens() { + listOf("", "token with spaces", "wayland:already-prefixed", "12345", "0xdeadbeef").forEach { token -> + assertFailsWith { + FileKitDialogParent.wayland(token) + } + } + } + + @Test + fun FileKitDialogParent_resolvers_rejectPlatformMismatches() { + assertFailsWith { + FileKitDialogParent.windows(42).resolveXdgPortalParent { error("Unexpected AWT conversion") } + } + assertFailsWith { + FileKitDialogParent.x11(42).resolveWindowsHandle { error("Unexpected AWT conversion") } + } + assertFailsWith { + FileKitDialogParent.wayland("portal-token").requireAwtWindowOrNull("The Linux fallback") + } + } + + @Test + fun FileKitDialogParent_nullParentRemainsUnparentedForEveryAdapter() { + val parent: FileKitDialogParent? = null + + assertNull(parent.resolveWindowsHandle { error("Unexpected AWT conversion") }) + assertEquals("", parent.resolveXdgPortalParent { error("Unexpected AWT conversion") }) + assertNull(parent.requireAwtWindowOrNull("The Linux fallback")) + } + + @Test + fun FileKitDialogSettings_legacyAdaptersNormalizeIntoCanonicalParent() { + val legacy = FileKitDialogSettings(title = "Choose", parentWindow = null) + val native = FileKitDialogSettings(parent = FileKitDialogParent.windows(42)) + val legacyCopy = native.copy(parentWindow = null) + + assertNull(legacy.parent) + assertNull(legacy.parentWindow) + assertNull(native.parentWindow) + assertNull(legacyCopy.parent) + } + + @Test + fun FileKitDialogSettings_parentAndParentWindowCannotBeSuppliedTogether() { + val members = FileKitDialogSettings::class.java.declaredConstructors.asList() + + FileKitDialogSettings::class.java.declaredMethods.filter { it.name == "copy" } + + assertFalse( + members.any { member -> + Window::class.java in member.parameterTypes && + FileKitDialogParent::class.java in member.parameterTypes + }, + ) + } +} diff --git a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerDialogSettings.jvm.kt b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerDialogSettings.jvm.kt index acd73f6c..7ff05f7b 100644 --- a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerDialogSettings.jvm.kt +++ b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerDialogSettings.jvm.kt @@ -31,7 +31,7 @@ internal class JvmFilePickerDialogSettingsState : FilePickerDialogSettingsState override fun build(): FileKitDialogSettings = FileKitDialogSettings( title = title.ifBlank { null }, - parentWindow = null, + parent = null, macOS = FileKitMacOSSettings( resolvesAliases = resolvesAliases, canCreateDirectories = canCreateDirectories, From 094722a53020ee5c10a80648f61a866b7a7715c2 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 27 Jul 2026 19:42:33 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20Address=20dialog=20parent=20?= =?UTF-8?q?review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/dialog-settings.mdx | 3 +- .../dialogs/FileKitDialogParent.jvm.kt | 2 +- .../dialogs/FileKitDialogSettings.jvm.kt | 18 +++++++++ .../platform/windows/WindowsFilePicker.kt | 11 ++++-- .../platform/xdg/XdgFilePickerPortal.kt | 7 +++- .../dialogs/FileKitDialogParentTest.kt | 24 +++++++++++- .../windows/WindowsDialogParentTest.kt | 38 +++++++++++++++++++ .../platform/xdg/XdgDialogParentTest.kt | 21 ++++++++++ sample/nucleusApp/build.gradle.kts | 1 + .../vinceglb/filekit/sample/nucleus/Main.kt | 25 +++++++++++- .../vinceglb/filekit/sample/shared/App.kt | 7 +++- .../sample/shared/navigation/AppNavigation.kt | 3 ++ .../ui/screens/filepicker/FilePickerScreen.kt | 14 +++++-- 13 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogParentTest.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgDialogParentTest.kt diff --git a/docs/dialogs/dialog-settings.mdx b/docs/dialogs/dialog-settings.mdx index f2f20c5e..5b0a6d3f 100644 --- a/docs/dialogs/dialog-settings.mdx +++ b/docs/dialogs/dialog-settings.mdx @@ -83,7 +83,8 @@ source-compatibility adapter and normalizes directly into `parent`; there is nev a second stored parent value. Replace it with `parent = FileKitDialogParent.awt(parentWindow)`. The deprecated `parentWindow` getter returns a window only when `parent` is an AWT parent; it returns `null` for native -parents. A single constructor or `copy` call accepts either `parent` or +parents. Existing positional `FileKitDialogSettings(window)` calls also compile +during this deprecation window. A single constructor or `copy` call accepts either `parent` or `parentWindow`, never both, so there is no precedence rule to learn. ### iOS and macOS Settings diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt index 267be895..749f3960 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt @@ -27,7 +27,7 @@ public sealed class FileKitDialogParent private constructor() { ) : FileKitDialogParent() public companion object { - private val POINTER_SHAPED_TOKEN: Regex = Regex("(?:0x[0-9a-fA-F]+|[0-9]+)") + private val POINTER_SHAPED_TOKEN: Regex = Regex("[+-]?(?:0[xX][0-9a-fA-F]+|[0-9]+)") /** Uses an AWT window as the dialog parent. */ public fun awt(window: Window): FileKitDialogParent = Awt(window) diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt index 7f291451..ca6471f0 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt @@ -37,6 +37,24 @@ public actual data class FileKitDialogSettings( macOS = macOS, ) + /** + * Compatibility adapter for the former positional AWT constructor call. + * + * The parameter is intentionally named [window] so existing named + * `parentWindow = ...` calls continue to select the adapter above. + */ + @Deprecated( + message = "Use parent = FileKitDialogParent.awt(window) instead.", + replaceWith = ReplaceWith("FileKitDialogSettings(parent = FileKitDialogParent.awt(window))"), + ) + public constructor( + window: Window?, + macOS: FileKitMacOSSettings = FileKitMacOSSettings(), + ) : this( + parent = window?.let(FileKitDialogParent::awt), + macOS = macOS, + ) + /** * The AWT window represented by [parent], if any. * diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt index 65f95d27..e7ca1941 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt @@ -265,7 +265,7 @@ internal class WindowsFilePicker( block: (FD) -> T, ): T? { // Show the dialog to the user - val openDialogResult = this.Show(parentHandle?.let(::toHwnd)) + val openDialogResult = showWindowsDialog(parentHandle, this::Show) // Valid error code: User canceled the dialog val userCanceledException = Win32Exception(ERROR_CANCELLED) @@ -378,8 +378,13 @@ internal class WindowsFilePicker( private fun FileKitDialogSettings.resolveWindowsHandle(): Long? = parent.resolveWindowsHandle { window -> Pointer.nativeValue(Native.getWindowPointer(window)) } - - private fun toHwnd(handle: Long): WinDef.HWND = WinDef.HWND(Pointer(handle)) } +internal fun showWindowsDialog( + parentHandle: Long?, + show: (WinDef.HWND?) -> T, +): T = show(parentHandle?.let(::toWindowsHwnd)) + +internal fun toWindowsHwnd(handle: Long): WinDef.HWND = WinDef.HWND(Pointer(handle)) + internal fun requiredFileDialogOptions(options: Int): Int = options or FOS_FORCEFILESYSTEM diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt index 09da0eae..916a752e 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt @@ -104,7 +104,7 @@ internal class XdgFilePickerPortal : PlatformFilePicker { val deferredResult = registerResponseHandler(connection, handleToken) getFileChooserObject(connection).OpenFile( - parentWindow = parent.resolveXdgPortalParent(Native::getWindowID), + parentWindow = xdgFileChooserParent(parent), title = title ?: "", options = options, ) @@ -136,7 +136,7 @@ internal class XdgFilePickerPortal : PlatformFilePicker { val deferredResult = registerResponseHandler(connection, handleToken) getFileChooserObject(connection).SaveFile( - parentWindow = dialogSettings.parent.resolveXdgPortalParent(Native::getWindowID), + parentWindow = xdgFileChooserParent(dialogSettings.parent), title = "", options = options, ) @@ -288,3 +288,6 @@ internal fun String.toURI(): URI = .replace("[", "%5B") .replace("]", "%5D") .let { URI(it) } + +internal fun xdgFileChooserParent(parent: FileKitDialogParent?): String = + parent.resolveXdgPortalParent(Native::getWindowID) diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt index 6ae458e6..dd39ff1b 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt @@ -7,6 +7,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull class FileKitDialogParentTest { @@ -54,7 +55,16 @@ class FileKitDialogParentTest { @Test fun FileKitDialogParent_wayland_rejectsUnsafeTokens() { - listOf("", "token with spaces", "wayland:already-prefixed", "12345", "0xdeadbeef").forEach { token -> + listOf( + "", + "token with spaces", + "wayland:already-prefixed", + "12345", + "-42", + "+42", + "0xdeadbeef", + "0XDEADBEEF", + ).forEach { token -> assertFailsWith { FileKitDialogParent.wayland(token) } @@ -95,6 +105,18 @@ class FileKitDialogParentTest { assertNull(legacyCopy.parent) } + @Test + fun FileKitDialogSettings_deprecatedSourceSnippetsCompile() { + val positionalConstructor: (Window) -> FileKitDialogSettings = { window -> FileKitDialogSettings(window) } + val namedConstructor = FileKitDialogSettings(title = "Choose", parentWindow = null) + val legacyCopy = namedConstructor.copy(parentWindow = null) + val recommendedReplacement = FileKitDialogSettings(parent = null).copy(parent = null) + + assertNotNull(positionalConstructor) + assertNull(legacyCopy.parent) + assertNull(recommendedReplacement.parent) + } + @Test fun FileKitDialogSettings_parentAndParentWindowCannotBeSuppliedTogether() { val members = FileKitDialogSettings::class.java.declaredConstructors.asList() + diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogParentTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogParentTest.kt new file mode 100644 index 00000000..cc873101 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogParentTest.kt @@ -0,0 +1,38 @@ +package io.github.vinceglb.filekit.dialogs.platform.windows + +import com.sun.jna.Pointer +import com.sun.jna.platform.win32.WinDef +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.resolveWindowsHandle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WindowsDialogParentTest { + @Test + fun showWindowsDialog_passesResolvedHwndToShow() { + var shownParent: WinDef.HWND? = null + val parentHandle = FileKitDialogParent.windows(0x1234).resolveWindowsHandle { + error("Unexpected AWT conversion") + } + + val result = showWindowsDialog(parentHandle) { hwnd -> + shownParent = hwnd + "shown" + } + + assertEquals("shown", result) + assertEquals(0x1234, Pointer.nativeValue(shownParent?.pointer)) + } + + @Test + fun showWindowsDialog_passesNullWhenUnparented() { + var shownParent: WinDef.HWND? = WinDef.HWND(Pointer(1)) + + showWindowsDialog(parentHandle = null) { hwnd -> + shownParent = hwnd + } + + assertNull(shownParent) + } +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgDialogParentTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgDialogParentTest.kt new file mode 100644 index 00000000..0ebdf6a9 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgDialogParentTest.kt @@ -0,0 +1,21 @@ +package io.github.vinceglb.filekit.dialogs.platform.xdg + +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import kotlin.test.Test +import kotlin.test.assertEquals + +class XdgDialogParentTest { + @Test + fun xdgFileChooserParent_returnsLowercaseX11IdentifierForOpenAndSave() { + val parent = FileKitDialogParent.x11(0x1a2b) + + assertEquals("x11:1a2b", xdgFileChooserParent(parent)) + } + + @Test + fun xdgFileChooserParent_returnsOpaqueWaylandIdentifierForOpenAndSave() { + val parent = FileKitDialogParent.wayland("xdg-foreign-token") + + assertEquals("wayland:xdg-foreign-token", xdgFileChooserParent(parent)) + } +} diff --git a/sample/nucleusApp/build.gradle.kts b/sample/nucleusApp/build.gradle.kts index bab4fc3a..ccaea4ef 100644 --- a/sample/nucleusApp/build.gradle.kts +++ b/sample/nucleusApp/build.gradle.kts @@ -30,4 +30,5 @@ dependencies { // FileKit implementation(projects.filekitCore) + implementation(projects.filekitDialogs) } diff --git a/sample/nucleusApp/src/main/kotlin/io/github/vinceglb/filekit/sample/nucleus/Main.kt b/sample/nucleusApp/src/main/kotlin/io/github/vinceglb/filekit/sample/nucleus/Main.kt index 73be04d3..a935a381 100644 --- a/sample/nucleusApp/src/main/kotlin/io/github/vinceglb/filekit/sample/nucleus/Main.kt +++ b/sample/nucleusApp/src/main/kotlin/io/github/vinceglb/filekit/sample/nucleus/Main.kt @@ -5,8 +5,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.application.DecoratedWindow import dev.nucleusframework.application.NucleusBackend +import dev.nucleusframework.application.NucleusWindow import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.core.runtime.Platform import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent import io.github.vinceglb.filekit.sample.shared.App fun main() = nucleusApplication(backend = NucleusBackend.Tao) { @@ -19,6 +22,26 @@ fun main() = nucleusApplication(backend = NucleusBackend.Tao) { title = "FileKit Nucleus Sample", onCloseRequest = ::exitApplication, ) { - App() + val dialogParent = nucleusWindow.fileKitDialogParent() + App( + dialogSettingsTransform = { settings -> + settings.copy(parent = dialogParent) + }, + ) + } +} + +private fun NucleusWindow.fileKitDialogParent(): FileKitDialogParent? = when (Platform.Current) { + Platform.Windows -> { + // Nucleus exposes an HWND here only on Windows. Its Tao API intentionally + // returns 0 on Linux, where FileKit requires an X11 XID or portal token. + unsafe.taoWindow + ?.nativeHandle + ?.takeIf { it != 0L } + ?.let(FileKitDialogParent::windows) + } + + else -> { + null } } diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/App.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/App.kt index 0e8a041a..c6a44c7e 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/App.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/App.kt @@ -5,11 +5,15 @@ import androidx.compose.ui.Modifier import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import io.github.vinceglb.filekit.coil.addPlatformFileSupport +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.sample.shared.navigation.AppNavigation import io.github.vinceglb.filekit.sample.shared.ui.theme.AppTheme @Composable -public fun App(modifier: Modifier = Modifier) { +public fun App( + modifier: Modifier = Modifier, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, +) { setSingletonImageLoaderFactory { context -> ImageLoader .Builder(context) @@ -21,6 +25,7 @@ public fun App(modifier: Modifier = Modifier) { AppTheme { AppNavigation( modifier = modifier, + dialogSettingsTransform = dialogSettingsTransform, ) } } diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt index 881458ed..7bb5fc47 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt @@ -13,6 +13,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import androidx.savedstate.serialization.SavedStateConfiguration import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.sample.shared.ui.screens.bookmarks.BookmarksRoute import io.github.vinceglb.filekit.sample.shared.ui.screens.camerapicker.CameraPickerRoute import io.github.vinceglb.filekit.sample.shared.ui.screens.debug.DebugRoute @@ -63,6 +64,7 @@ private data class FileDetails( @Composable internal fun AppNavigation( modifier: Modifier = Modifier, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, ) { val backStack = rememberNavBackStack( configuration = SavedStateConfiguration { @@ -113,6 +115,7 @@ internal fun AppNavigation( onDisplayFileDetails = { file -> backStack.add(FileDetails(file)) }, + dialogSettingsTransform = dialogSettingsTransform, ) } entry { diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt index c510389a..4cc31d2b 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.tooling.preview.AndroidUiModes import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitPickerState import io.github.vinceglb.filekit.dialogs.FileKitType @@ -56,10 +57,12 @@ import io.github.vinceglb.filekit.sample.shared.util.plus internal fun FilePickerRoute( onNavigateBack: () -> Unit, onDisplayFileDetails: (file: PlatformFile) -> Unit, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, ) { FilePickerScreen( onNavigateBack = onNavigateBack, onDisplayFileDetails = onDisplayFileDetails, + dialogSettingsTransform = dialogSettingsTransform, ) } @@ -68,6 +71,7 @@ internal fun FilePickerRoute( private fun FilePickerScreen( onNavigateBack: () -> Unit, onDisplayFileDetails: (file: PlatformFile) -> Unit, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, ) { var buttonState by remember { mutableStateOf(AppScreenHeaderButtonState.Enabled) } var pickerMode by remember { mutableStateOf(Modes.Single) } @@ -77,9 +81,11 @@ private fun FilePickerScreen( var files by remember { mutableStateOf(emptyList()) } val dialogSettingsState = rememberFilePickerDialogSettingsState() + val dialogSettings = dialogSettingsTransform(dialogSettingsState.build()) val startDirectoryLauncher = rememberDirectoryPickerLauncher( directory = startDirectory, + dialogSettings = dialogSettings, ) { directory -> if (directory != null) { startDirectory = directory @@ -92,7 +98,7 @@ private fun FilePickerScreen( type = resolvedType, mode = FileKitMode.Single, directory = startDirectory, - dialogSettings = dialogSettingsState.build(), + dialogSettings = dialogSettings, ) { selectedFile -> buttonState = AppScreenHeaderButtonState.Enabled files = selectedFile?.let(::listOf) ?: emptyList() @@ -102,7 +108,7 @@ private fun FilePickerScreen( type = resolvedType, mode = FileKitMode.Multiple(maxItems = pickerMaxItems), directory = startDirectory, - dialogSettings = dialogSettingsState.build(), + dialogSettings = dialogSettings, ) { selectedFiles -> buttonState = AppScreenHeaderButtonState.Enabled files = selectedFiles ?: emptyList() @@ -112,7 +118,7 @@ private fun FilePickerScreen( type = resolvedType, mode = FileKitMode.SingleWithState, directory = startDirectory, - dialogSettings = dialogSettingsState.build(), + dialogSettings = dialogSettings, ) { state -> buttonState = AppScreenHeaderButtonState.Enabled files = when (state) { @@ -128,7 +134,7 @@ private fun FilePickerScreen( type = resolvedType, mode = FileKitMode.MultipleWithState(maxItems = pickerMaxItems), directory = startDirectory, - dialogSettings = dialogSettingsState.build(), + dialogSettings = dialogSettings, ) { state -> buttonState = AppScreenHeaderButtonState.Enabled files = when (state) {