Skip to content
Open
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
58 changes: 52 additions & 6 deletions docs/dialogs/dialog-settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,11 +36,57 @@ 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:<lowercase-hex-XID>`), 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. 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

On iOS and macOS, you can configure:
Expand Down Expand Up @@ -91,7 +137,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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +13 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve value equality for AWT parent wrappers

Because Awt uses identity equality, two settings independently created with the same window—such as FileKitDialogSettings(parentWindow = window)—now compare unequal, whereas the previous data class compared the stored Window references and considered them equal. This breaks the value semantics of FileKitDialogSettings for equality checks, sets, and caches; the wrapper should compare its contained window rather than its wrapper allocation.

Useful? React with 👍 / 👎.


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("[+-]?(?:0[xX][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
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,90 @@ 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(),
Comment on lines +30 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep null legacy construction unambiguous

Existing source such as FileKitDialogSettings("Choose", null) no longer compiles: null matches both the new primary constructor's FileKitDialogParent? parameter and this compatibility constructor's Window? parameter, producing an overload-resolution ambiguity. This was valid with the former nullable parentWindow constructor, so the one-release source-compatibility adapter needs an overload shape that preserves positional-null calls.

Useful? React with 👍 / 👎.

) : this(
title = title,
parent = parentWindow?.let(FileKitDialogParent::awt),
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))"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the positional replacement nullable-safe

When window is declared as Window?, which this compatibility constructor explicitly accepts, applying the deprecation quick-fix rewrites the valid call to FileKitDialogParent.awt(window), but awt requires a non-null Window. The original constructor treats null as an unparented dialog, so the replacement should retain window?.let(FileKitDialogParent::awt) rather than leaving migrated code uncompilable.

Useful? React with 👍 / 👎.

)
public constructor(
window: Window?,
macOS: FileKitMacOSSettings = FileKitMacOSSettings(),
) : this(
parent = window?.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].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading