diff --git a/examples/jvm/src/main/kotlin/Main.kt b/examples/jvm/src/main/kotlin/Main.kt index 69479f4..ca149ab 100644 --- a/examples/jvm/src/main/kotlin/Main.kt +++ b/examples/jvm/src/main/kotlin/Main.kt @@ -51,17 +51,28 @@ fun main() = application { } } - FilePicker(showSingleFile, fileExtensions = listOf("jpg", "png")) { file -> + FilePicker( + showSingleFile, + fileExtensions = listOf("jpg", "png"), + title = "Choose a file", + ) { file -> pathSingleChosen = file?.file?.path ?: "none selected" showSingleFile = false } - MultipleFilePicker(showMultiFile, fileExtensions = listOf("jpg", "png")) { files -> + MultipleFilePicker( + showMultiFile, + fileExtensions = listOf("jpg", "png"), + title = "Choose files" + ) { files -> pathMultiChosen = files?.map { it.file.path + "\n" } ?: emptyList() showMultiFile = false } - DirectoryPicker(showDirPicker) { path -> + DirectoryPicker( + showDirPicker, + title = "Choose a directory" + ) { path -> dirChosen = path ?: "none selected" showDirPicker = false } diff --git a/gradle.properties b/gradle.properties index 145c414..fdab6c6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ kotlin.code.style=official +kotlin.mpp.androidGradlePluginCompatibility.nowarn=true android.useAndroidX=true org.jetbrains.compose.experimental.jscanvas.enabled=true org.jetbrains.compose.experimental.macos.enabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c462752..fdbf692 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,8 @@ agp = "8.3.0" androidx-appcompat = "1.6.1" androidx-core = "1.12.0" compose-android = "1.8.2" -compose-plugin = "1.6.0" +compose-plugin = "1.6.1" +jna = "5.14.0" junit = "4.13.2" kotlin = "1.9.22" kotlinx-coroutines = "1.8.0" @@ -17,11 +18,12 @@ android-min-sdk = "21" androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } compose-activity = { module = "androidx.activity:activity-compose", version.ref = "compose-android" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +junit = { module = "junit:junit", version.ref = "junit" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-html = { module = "org.jetbrains.kotlinx:kotlinx-html", version.ref = "kotlinx-html" } -junit = { module = "junit:junit", version.ref = "junit" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/mpfilepicker/build.gradle.kts b/mpfilepicker/build.gradle.kts index d444e96..3dbc04b 100644 --- a/mpfilepicker/build.gradle.kts +++ b/mpfilepicker/build.gradle.kts @@ -79,22 +79,7 @@ kotlin { api(compose.preview) api(compose.material) - val lwjglVersion = "3.3.1" - listOf("lwjgl", "lwjgl-tinyfd").forEach { lwjglDep -> - implementation("org.lwjgl:${lwjglDep}:${lwjglVersion}") - listOf( - "natives-windows", - "natives-windows-x86", - "natives-windows-arm64", - "natives-macos", - "natives-macos-arm64", - "natives-linux", - "natives-linux-arm64", - "natives-linux-arm32" - ).forEach { native -> - runtimeOnly("org.lwjgl:${lwjglDep}:${lwjglVersion}:${native}") - } - } + implementation(libs.jna) } val jvmTest by getting val jsMain by getting diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/FileChooser.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/FileChooser.kt deleted file mode 100644 index 3fe4ca9..0000000 --- a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/FileChooser.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.darkrockstudios.libraries.mpfilepicker - -import org.lwjgl.system.MemoryStack -import org.lwjgl.util.tinyfd.TinyFileDialogs -import org.lwjgl.util.tinyfd.TinyFileDialogs.tinyfd_selectFolderDialog - -internal fun chooseFile( - initialDirectory: String, - fileExtension: String, - title: String? -): String? = MemoryStack.stackPush().use { stack -> - val filters = if (fileExtension.isNotEmpty()) fileExtension.split(",") else emptyList() - val aFilterPatterns = stack.mallocPointer(filters.size) - filters.forEach { - aFilterPatterns.put(stack.UTF8("*.$it")) - } - aFilterPatterns.flip() - TinyFileDialogs.tinyfd_openFileDialog( - title, - initialDirectory, - aFilterPatterns, - null, - false - ) -} - -internal fun chooseFiles( - initialDirectory: String, - fileExtension: String, - title: String?, -): List? = MemoryStack.stackPush().use { stack -> - val filters = if (fileExtension.isNotEmpty()) fileExtension.split(",") else emptyList() - val aFilterPatterns = stack.mallocPointer(filters.size) - filters.forEach { - aFilterPatterns.put(stack.UTF8("*.$it")) - } - aFilterPatterns.flip() - val t = TinyFileDialogs.tinyfd_openFileDialog( - /* aTitle = */ title, - /* aDefaultPathAndFile = */ initialDirectory, - /* aFilterPatterns = */ aFilterPatterns, - /* aSingleFilterDescription = */ null, - /* aAllowMultipleSelects = */ true, - ) - t?.split("|") -} - -internal fun chooseDirectory( - initialDirectory: String, - title: String? -): String? = tinyfd_selectFolderDialog( - title, - initialDirectory -) diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/FilePicker.desktop.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/FilePicker.desktop.kt index 5ca5400..5b82ca6 100644 --- a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/FilePicker.desktop.kt +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/FilePicker.desktop.kt @@ -4,12 +4,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import java.io.File -public actual data class PlatformFile( +actual data class PlatformFile( val file: File, ) @Composable -public actual fun FilePicker( +actual fun FilePicker( show: Boolean, initialDirectory: String?, fileExtensions: List, @@ -18,32 +18,25 @@ public actual fun FilePicker( ) { LaunchedEffect(show) { if (show) { - val fileFilter = if (fileExtensions.isNotEmpty()) { - fileExtensions.joinToString(",") - } else { - "" - } - - val initialDir = initialDirectory ?: System.getProperty("user.dir") - val filePath = chooseFile( - initialDirectory = initialDir, - fileExtension = fileFilter, - title = title + // Get path from native file picker + val filePicker = PlatformFilePickerUtil.current + val filePath = filePicker.pickFile( + initialDirectory = initialDirectory, + fileExtensions = fileExtensions, + title = title, ) - if (filePath != null) { - val file = File(filePath) - val platformFile = PlatformFile(file) - onFileSelected(platformFile) - } else { - onFileSelected(null) - } + // Convert path to PlatformFile + val result = filePath?.let { PlatformFile(File(it)) } + + // Return result + onFileSelected(result) } } } @Composable -public actual fun MultipleFilePicker( +actual fun MultipleFilePicker( show: Boolean, initialDirectory: String?, fileExtensions: List, @@ -52,30 +45,25 @@ public actual fun MultipleFilePicker( ) { LaunchedEffect(show) { if (show) { - val fileFilter = if (fileExtensions.isNotEmpty()) { - fileExtensions.joinToString(",") - } else { - "" - } - - val initialDir = initialDirectory ?: System.getProperty("user.dir") - val filePaths = chooseFiles( - initialDirectory = initialDir, - fileExtension = fileFilter, - title = title + // Get paths from native file picker + val filePicker = PlatformFilePickerUtil.current + val filePaths = filePicker.pickFiles( + initialDirectory = initialDirectory, + fileExtensions = fileExtensions, + title = title, ) - if (filePaths != null) { - onFileSelected(filePaths.map { PlatformFile(File(it)) }) - } else { - onFileSelected(null) - } + // Convert paths to PlatformFile + val result = filePaths?.map { PlatformFile(File(it)) } + + // Return result + onFileSelected(result) } } } @Composable -public actual fun DirectoryPicker( +actual fun DirectoryPicker( show: Boolean, initialDirectory: String?, title: String?, @@ -83,9 +71,15 @@ public actual fun DirectoryPicker( ) { LaunchedEffect(show) { if (show) { - val initialDir = initialDirectory ?: System.getProperty("user.dir") - val fileChosen = chooseDirectory(initialDir, title) - onFileSelected(fileChosen) + // Get path from native file picker + val filePicker = PlatformFilePickerUtil.current + val filePath = filePicker.pickDirectory( + initialDirectory = initialDirectory, + title = title, + ) + + // Return result + onFileSelected(filePath) } } } diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/PlatformFilePicker.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/PlatformFilePicker.kt new file mode 100644 index 0000000..1c7c347 --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/PlatformFilePicker.kt @@ -0,0 +1,37 @@ +package com.darkrockstudios.libraries.mpfilepicker + +import com.darkrockstudios.libraries.mpfilepicker.mac.MacOSFilePicker +import com.darkrockstudios.libraries.mpfilepicker.util.Platform +import com.darkrockstudios.libraries.mpfilepicker.util.PlatformUtil +import com.darkrockstudios.libraries.mpfilepicker.windows.WindowsFilePicker + +internal interface PlatformFilePicker { + fun pickFile( + initialDirectory: String? = null, + fileExtensions: List? = null, + title: String? = null, + ): String? + + fun pickFiles( + initialDirectory: String? = null, + fileExtensions: List? = null, + title: String? = null, + ): List? + + fun pickDirectory( + initialDirectory: String? = null, + title: String? = null, + ): String? +} + +internal object PlatformFilePickerUtil { + val current: PlatformFilePicker by lazy { createPlatformFilePicker() } + + private fun createPlatformFilePicker(): PlatformFilePicker { + return when (PlatformUtil.current) { + Platform.MacOS -> MacOSFilePicker() + Platform.Windows -> WindowsFilePicker() + Platform.Linux -> WindowsFilePicker() // TODO: WindowsFilePicker is compatible with other platforms but we need to implement native Linux file picker + } + } +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/MacOSFilePicker.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/MacOSFilePicker.kt new file mode 100644 index 0000000..601aebd --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/MacOSFilePicker.kt @@ -0,0 +1,146 @@ +package com.darkrockstudios.libraries.mpfilepicker.mac + +import com.darkrockstudios.libraries.mpfilepicker.PlatformFilePicker +import com.darkrockstudios.libraries.mpfilepicker.mac.foundation.Foundation +import com.darkrockstudios.libraries.mpfilepicker.mac.foundation.ID + +class MacOSFilePicker : PlatformFilePicker { + override fun pickFile( + initialDirectory: String?, + fileExtensions: List?, + title: String? + ): String? { + return callNativeMacOSPicker( + mode = MacOSFilePickerMode.File, + initialDirectory = initialDirectory, + fileExtensions = fileExtensions, + title = title + ) + } + + override fun pickFiles( + initialDirectory: String?, + fileExtensions: List?, + title: String? + ): List? { + return callNativeMacOSPicker( + mode = MacOSFilePickerMode.Files, + initialDirectory = initialDirectory, + fileExtensions = fileExtensions, + title = title + ) + } + + override fun pickDirectory(initialDirectory: String?, title: String?): String? { + return callNativeMacOSPicker( + mode = MacOSFilePickerMode.Directories, + initialDirectory = initialDirectory, + fileExtensions = null, + title = title + ) + } + + private fun callNativeMacOSPicker( + mode: MacOSFilePickerMode, + initialDirectory: String?, + fileExtensions: List?, + title: String?, + ): T? { + val pool = Foundation.NSAutoreleasePool() + return try { + var response: T? = null + + Foundation.executeOnMainThread( + withAutoreleasePool = false, + waitUntilDone = true, + ) { + // Create the file picker + val openPanel = Foundation.invoke("NSOpenPanel", "new") + + // Setup single, multiple selection or directory mode + mode.setupPickerMode(openPanel) + + // Set the title + title?.let { + Foundation.invoke(openPanel, "setMessage:", Foundation.nsString(it)) + } + + // Set initial directory + initialDirectory?.let { + Foundation.invoke(openPanel, "setDirectoryURL:", Foundation.nsURL(it)) + } + + // Set file extensions + fileExtensions?.let { extensions -> + val items = extensions.map { Foundation.nsString(it) } + val nsData = Foundation.invokeVarArg("NSArray", "arrayWithObjects:", *items.toTypedArray()) + Foundation.invoke(openPanel, "setAllowedFileTypes:", nsData) + } + + // Open the file picker + val result = Foundation.invoke(openPanel, "runModal") + + // Get the path(s) from the file picker if the user validated the selection + if (result.toInt() == 1) { + response = mode.getResult(openPanel) + } + } + + response + } finally { + pool.drain() + } + } + + private companion object { + fun singlePath(openPanel: ID): String? { + val url = Foundation.invoke(openPanel, "URL") + val path = Foundation.invoke(url, "path") + return Foundation.toStringViaUTF8(path) + } + + fun multiplePaths(openPanel: ID): List? { + val urls = Foundation.invoke(openPanel, "URLs") + val urlCount = Foundation.invoke(urls, "count").toInt() + + return (0 until urlCount).mapNotNull { index -> + val url = Foundation.invoke(urls, "objectAtIndex:", index) + val path = Foundation.invoke(url, "path") + Foundation.toStringViaUTF8(path) + }.ifEmpty { null } + } + } + + private sealed class MacOSFilePickerMode { + abstract fun setupPickerMode(openPanel: ID) + abstract fun getResult(openPanel: ID): T? + + data object File : MacOSFilePickerMode() { + override fun setupPickerMode(openPanel: ID) { + Foundation.invoke(openPanel, "setCanChooseFiles:", true) + Foundation.invoke(openPanel, "setCanChooseDirectories:", false) + } + + override fun getResult(openPanel: ID): String? = singlePath(openPanel) + } + + data object Files : MacOSFilePickerMode>() { + override fun setupPickerMode(openPanel: ID) { + Foundation.invoke(openPanel, "setCanChooseFiles:", true) + Foundation.invoke(openPanel, "setCanChooseDirectories:", false) + Foundation.invoke(openPanel, "setAllowsMultipleSelection:", true) + } + + override fun getResult(openPanel: ID): List? = multiplePaths(openPanel) + } + + data object Directories : MacOSFilePickerMode() { + override fun setupPickerMode(openPanel: ID) { + Foundation.invoke(openPanel, "setCanChooseFiles:", false) + Foundation.invoke(openPanel, "setCanChooseDirectories:", true) + } + + override fun getResult(openPanel: ID): String? = singlePath(openPanel) + } + } +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/foundation/Foundation.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/foundation/Foundation.kt new file mode 100644 index 0000000..b8e4182 --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/foundation/Foundation.kt @@ -0,0 +1,590 @@ +package com.darkrockstudios.libraries.mpfilepicker.mac.foundation + +import com.sun.jna.Callback +import com.sun.jna.Function +import com.sun.jna.Library +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.PointerType +import com.sun.jna.ptr.PointerByReference +import org.jetbrains.annotations.NonNls +import java.io.File +import java.lang.reflect.Proxy +import java.nio.CharBuffer +import java.nio.charset.StandardCharsets +import java.util.Arrays +import java.util.Collections +import java.util.UUID + +/** + * see [Documentation](http://developer.apple.com/documentation/Cocoa/Reference/ObjCRuntimeRef/Reference/reference.html) + */ +@NonNls +object Foundation { + private val myFoundationLibrary: FoundationLibrary = Native.load( + "Foundation", + FoundationLibrary::class.java, Collections.singletonMap("jna.encoding", "UTF8") + ) + + private val myObjcMsgSend: Function by lazy { + val nativeLibrary = (Proxy.getInvocationHandler(myFoundationLibrary) as Library.Handler).nativeLibrary + nativeLibrary.getFunction("objc_msgSend") + } + + /** + * Get the ID of the NSClass with className + */ + fun getObjcClass(className: String?): ID? { + return myFoundationLibrary.objc_getClass(className) + } + + fun getProtocol(name: String?): ID? { + return myFoundationLibrary.objc_getProtocol(name) + } + + fun createSelector(s: String?): Pointer? { + return myFoundationLibrary.sel_registerName(s) + } + + private fun prepInvoke(id: ID?, selector: Pointer?, args: Array): Array { + val invokArgs = arrayOfNulls(args.size + 2) + invokArgs[0] = id + invokArgs[1] = selector + System.arraycopy(args, 0, invokArgs, 2, args.size) + return invokArgs + } + + fun invoke(id: ID?, selector: Pointer?, vararg args: Any?): ID { + // objc_msgSend is called with the calling convention of the target method + // on x86_64 this does not make a difference, but arm64 uses a different calling convention for varargs + // it is therefore important to not call objc_msgSend as a vararg function + return ID(myObjcMsgSend.invokeLong(prepInvoke(id, selector, args))) + } + + /** + * Invokes the given vararg selector. + * Expects `NSArray arrayWithObjects:(id), ...` like signature, i.e. exactly one fixed argument, followed by varargs. + */ + fun invokeVarArg(id: ID?, selector: Pointer?, vararg args: Any?): ID? { + // c functions and objc methods have at least 1 fixed argument, we therefore need to separate out the first argument + return myFoundationLibrary.objc_msgSend( + id, selector, + args[0], *Arrays.copyOfRange(args, 1, args.size) + ) + } + + fun invoke(cls: String?, selector: String?, vararg args: Any?): ID { + return invoke(getObjcClass(cls), createSelector(selector), *args) + } + + fun invokeVarArg(cls: String?, selector: String?, vararg args: Any?): ID? { + return invokeVarArg(getObjcClass(cls), createSelector(selector), *args) + } + + fun safeInvoke(stringCls: String?, stringSelector: String?, vararg args: Any): ID { + val cls = getObjcClass(stringCls) + val selector = createSelector(stringSelector) + if (!invoke(cls, "respondsToSelector:", selector).booleanValue()) { + throw RuntimeException( + String.format( + "Missing selector %s for %s", + stringSelector, + stringCls + ) + ) + } + return invoke(cls, selector, *args) + } + + fun invoke(id: ID?, selector: String?, vararg args: Any?): ID { + return invoke(id, createSelector(selector), *args) + } + + fun invoke_fpret(receiver: ID?, selector: Pointer?, vararg args: Any?): Double { + return myObjcMsgSend.invokeDouble(prepInvoke(receiver, selector, args)) + } + + fun invoke_fpret(receiver: ID?, selector: String?, vararg args: Any?): Double { + return invoke_fpret(receiver, createSelector(selector), *args) + } + + fun isNil(id: ID?): Boolean { + return id == null || ID.NIL == id + } + + fun safeInvoke(id: ID, stringSelector: String?, vararg args: Any): ID { + val selector = createSelector(stringSelector) + if (id != ID.NIL && !invoke(id, "respondsToSelector:", selector).booleanValue()) { + throw RuntimeException( + String.format( + "Missing selector %s for %s", stringSelector, toStringViaUTF8( + invoke(id, "description") + ) + ) + ) + } + return invoke(id, selector, *args) + } + + fun allocateObjcClassPair(superCls: ID?, name: String?): ID? { + return myFoundationLibrary.objc_allocateClassPair(superCls, name, 0) + } + + fun registerObjcClassPair(cls: ID?) { + myFoundationLibrary.objc_registerClassPair(cls) + } + + fun isClassRespondsToSelector(cls: ID?, selectorName: Pointer?): Boolean { + return myFoundationLibrary.class_respondsToSelector(cls, selectorName) + } + + /** + * @param cls The class to which to add a method. + * @param selectorName A selector that specifies the name of the method being added. + * @param impl A function which is the implementation of the new method. The function must take at least two arguments-self and _cmd. + * @param types An array of characters that describe the types of the arguments to the method. + * See [](https://developer.apple.com/library/IOs/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html#//apple_ref/doc/uid/TP40008048-CH100) + * @return true if the method was added successfully, otherwise false (for example, the class already contains a method implementation with that name). + */ + fun addMethod(cls: ID?, selectorName: Pointer?, impl: Callback?, types: String?): Boolean { + return myFoundationLibrary.class_addMethod(cls, selectorName, impl, types) + } + + fun addProtocol(aClass: ID?, protocol: ID?): Boolean { + return myFoundationLibrary.class_addProtocol(aClass, protocol) + } + + fun addMethodByID(cls: ID?, selectorName: Pointer?, impl: ID?, types: String?): Boolean { + return myFoundationLibrary.class_addMethod(cls, selectorName, impl, types) + } + + fun isMetaClass(cls: ID?): Boolean { + return myFoundationLibrary.class_isMetaClass(cls) + } + + fun stringFromSelector(selector: Pointer?): String? { + val id = myFoundationLibrary.NSStringFromSelector(selector) + return if (ID.NIL == id) null else toStringViaUTF8(id) + } + + fun stringFromClass(aClass: ID?): String? { + val id = myFoundationLibrary.NSStringFromClass(aClass) + return if (ID.NIL == id) null else toStringViaUTF8(id) + } + + fun getClass(clazz: Pointer?): Pointer? { + return myFoundationLibrary.objc_getClass(clazz) + } + + fun fullUserName(): String? { + return toStringViaUTF8(myFoundationLibrary.NSFullUserName()) + } + + fun class_replaceMethod(cls: ID?, selector: Pointer?, impl: Callback?, types: String?): ID? { + return myFoundationLibrary.class_replaceMethod(cls, selector, impl, types) + } + + fun getMetaClass(className: String?): ID? { + return myFoundationLibrary.objc_getMetaClass(className) + } + + fun isPackageAtPath(path: String): Boolean { + val workspace = invoke("NSWorkspace", "sharedWorkspace") + val result = invoke(workspace, createSelector("isFilePackageAtPath:"), nsString(path)) + + return result.booleanValue() + } + + fun isPackageAtPath(file: File): Boolean { + if (!file.isDirectory) return false + return isPackageAtPath(file.path) + } + + fun nsString(s: String?): ID { + return if (s == null) ID.NIL else NSString.create(s) + } + + fun nsString(s: CharSequence?): ID { + return if (s == null) ID.NIL else NSString.create(s) + } + + fun nsUUID(uuid: UUID): ID { + return nsUUID(uuid.toString()) + } + + fun nsUUID(uuid: String): ID { + return invoke( + invoke(invoke("NSUUID", "alloc"), "initWithUUIDString:", nsString(uuid)), + "autorelease" + ) + } + + fun nsURL(path: String): ID { + return invoke("NSURL", "fileURLWithPath:", nsString(path)) + } + + fun toStringViaUTF8(cfString: ID?): String? { + if (ID.NIL == cfString) return null + + val lengthInChars = myFoundationLibrary.CFStringGetLength(cfString) + val potentialLengthInBytes = + 3 * lengthInChars + 1 // UTF8 fully escaped 16 bit chars, plus nul + + val buffer = ByteArray(potentialLengthInBytes) + val ok = myFoundationLibrary.CFStringGetCString( + cfString, + buffer, + buffer.size, + FoundationLibrary.kCFStringEncodingUTF8 + ) + if (ok.toInt() == 0) throw RuntimeException("Could not convert string") + return Native.toString(buffer) + } + + // @NlsSafe +// fun getNSErrorText(error: ID?): String? { +// if (error == null || error.toInt() == 0) return null +// +// var description = toStringViaUTF8(invoke(error, "localizedDescription")) +// val recovery = toStringViaUTF8(invoke(error, "localizedRecoverySuggestion")) +// if (recovery != null) description += """ +// +// $recovery +// """.trimIndent() +// return StringUtil.notNullize(description) +// } + + fun getEncodingName(nsStringEncoding: Long): String? { + val cfEncoding = + myFoundationLibrary.CFStringConvertNSStringEncodingToEncoding(nsStringEncoding) + val pointer = myFoundationLibrary.CFStringConvertEncodingToIANACharSetName(cfEncoding) + var name = toStringViaUTF8(pointer) + if ("macintosh" == name) name = + "MacRoman" // JDK8 does not recognize IANA's "macintosh" alias + + return name + } + +// fun getEncodingCode(encodingName: String?): Long { +// if (StringUtil.isEmptyOrSpaces(encodingName)) return -1 +// +// val converted = nsString(encodingName) +// val cfEncoding = myFoundationLibrary.CFStringConvertIANACharSetNameToEncoding(converted) +// +// val restored = myFoundationLibrary.CFStringConvertEncodingToIANACharSetName(cfEncoding) +// if (ID.NIL == restored) return -1 +// +// return convertCFEncodingToNS(cfEncoding) +// } + + private fun convertCFEncodingToNS(cfEncoding: Long): Long { + return myFoundationLibrary.CFStringConvertEncodingToNSStringEncoding(cfEncoding) and 0xffffffffffL // trim to C-type limits + } + + fun cfRetain(id: ID?) { + myFoundationLibrary.CFRetain(id) + } + + fun cfRelease(vararg ids: ID?) { + for (id in ids) { + if (id != null) { + myFoundationLibrary.CFRelease(id) + } + } + } + + fun autorelease(id: ID?): ID { + return invoke(id, "autorelease") + } + + val isMainThread: Boolean + get() = invoke("NSThread", "isMainThread").booleanValue() + + private var ourRunnableCallback: Callback? = null + private val ourMainThreadRunnables: MutableMap = HashMap() + private var ourCurrentRunnableCount: Long = 0 + private val RUNNABLE_LOCK = Any() + + fun executeOnMainThread( + withAutoreleasePool: Boolean, + waitUntilDone: Boolean, + runnable: Runnable + ) { + var runnableCountString: String? + synchronized(RUNNABLE_LOCK) { + initRunnableSupport() + runnableCountString = (++ourCurrentRunnableCount).toString() + ourMainThreadRunnables.put( + runnableCountString, + RunnableInfo(runnable, withAutoreleasePool) + ) + } + + // fixme: Use Grand Central Dispatch instead? + val ideaRunnable = getObjcClass("IdeaRunnable") + val runnableObject = invoke(invoke(ideaRunnable, "alloc"), "init") + val keyObject = invoke(nsString(runnableCountString), "retain") + invoke( + runnableObject, + "performSelectorOnMainThread:withObject:waitUntilDone:", + createSelector("run:"), + keyObject, + waitUntilDone + ) + invoke(runnableObject, "release") + } + + /** + * Registers idea runnable adapter class in ObjC runtime, if not registered yet. + * + * + * Warning: NOT THREAD-SAFE! Must be called under lock. Danger of segmentation fault. + */ + private fun initRunnableSupport() { + if (ourRunnableCallback == null) { + val runnableClass = allocateObjcClassPair(getObjcClass("NSObject"), "IdeaRunnable") + registerObjcClassPair(runnableClass) + + val callback: Callback = object : Callback { + fun callback(self: ID?, selector: String?, keyObject: ID?) { + val key = toStringViaUTF8(keyObject) + invoke(keyObject, "release") + + var info: RunnableInfo? + synchronized(RUNNABLE_LOCK) { + info = ourMainThreadRunnables.remove(key) + } + + if (info == null) { + return + } + + var pool: ID? = null + try { + if (info!!.myUseAutoreleasePool) { + pool = invoke("NSAutoreleasePool", "new") + } + + info!!.myRunnable.run() + } finally { + if (pool != null) { + invoke(pool, "release") + } + } + } + } + if (!addMethod(runnableClass, createSelector("run:"), callback, "v@:*")) { + throw RuntimeException("Unable to add method to objective-c runnableClass class!") + } + ourRunnableCallback = callback + } + } + + fun fillArray(a: Array): ID { + val result = invoke("NSMutableArray", "array") + for (s in a) { + invoke(result, "addObject:", convertType(s)) + } + + return result + } + + fun createDict(keys: Array, values: Array): ID { + val nsKeys = invokeVarArg("NSArray", "arrayWithObjects:", *convertTypes(keys.map { it }.toTypedArray())) + val nsData = invokeVarArg("NSArray", "arrayWithObjects:", *convertTypes(values)) + return invoke("NSDictionary", "dictionaryWithObjects:forKeys:", nsData, nsKeys) + } + + fun createPointerReference(): PointerType { + val reference: PointerType = PointerByReference(Memory(Native.POINTER_SIZE.toLong())) + reference.pointer.clear(Native.POINTER_SIZE.toLong()) + return reference + } + + fun castPointerToNSError(pointerType: PointerType): ID { + return ID(pointerType.pointer.getLong(0)) + } + + fun convertTypes(v: Array): Array { + val result = arrayOfNulls(v.size + 1) + for (i in v.indices) { + result[i] = convertType(v[i]) + } + result[v.size] = ID.NIL + return result + } + + private fun convertType(o: Any): Any { + return if (o is Pointer || o is ID) { + o + } else if (o is String) { + nsString(o) + } else { + throw IllegalArgumentException("Unsupported type! " + o.javaClass) + } + } + + private object NSString { + private val nsStringCls = getObjcClass("NSString") + private val stringSel = createSelector("string") + private val allocSel = createSelector("alloc") + private val autoreleaseSel = createSelector("autorelease") + private val initWithBytesLengthEncodingSel = + createSelector("initWithBytes:length:encoding:") + private val nsEncodingUTF16LE = + convertCFEncodingToNS(FoundationLibrary.kCFStringEncodingUTF16LE.toLong()) + + fun create(s: String): ID { + // Use a byte[] rather than letting jna do the String -> char* marshalling itself. + // Turns out about 10% quicker for long strings. + if (s.isEmpty()) { + return invoke(nsStringCls, stringSel) + } + + val utf16Bytes = s.toByteArray(StandardCharsets.UTF_16LE) + return create(utf16Bytes) + } + + fun create(cs: CharSequence): ID { + if (cs is String) { + return create(cs) + } + if (cs.isEmpty()) { + return invoke(nsStringCls, stringSel) + } + + val utf16Bytes = StandardCharsets.UTF_16LE.encode(CharBuffer.wrap(cs)).array() + return create(utf16Bytes) + } + + private fun create(utf16Bytes: ByteArray): ID { + val emptyNsString = invoke(nsStringCls, allocSel) + val initializedNsString = invoke( + emptyNsString, + initWithBytesLengthEncodingSel, + utf16Bytes, + utf16Bytes.size, + nsEncodingUTF16LE + ) + return invoke(initializedNsString, autoreleaseSel) + } + } + + internal class RunnableInfo(var myRunnable: Runnable, var myUseAutoreleasePool: Boolean) + + class NSDictionary(private val myDelegate: ID?) { + fun get(key: ID?): ID { + return invoke(myDelegate, "objectForKey:", key) + } + + fun get(key: String?): ID { + return get(nsString(key)) + } + + fun count(): Int { + return invoke(myDelegate, "count").toInt() + } + + fun keys(): NSArray { + return NSArray(invoke(myDelegate, "allKeys")) + } + + companion object { + fun toStringMap(delegate: ID?): Map { + val result: MutableMap = HashMap() + if (isNil(delegate)) { + return result + } + + val dict = NSDictionary(delegate) + val keys = dict.keys() + + for (i in 0 until keys.count()) { + val key = toStringViaUTF8(keys.at(i)) + val `val` = toStringViaUTF8(dict.get(key)) + result[key] = `val` + } + + return result + } + + fun toStringDictionary(map: Map): ID { + val dict = invoke("NSMutableDictionary", "dictionaryWithCapacity:", map.size) + for ((key, value) in map) { + invoke( + dict, "setObject:forKey:", nsString(value), nsString( + key + ) + ) + } + return dict + } + } + } + + class NSArray(private val myDelegate: ID) { + fun count(): Int { + return invoke(myDelegate, "count").toInt() + } + + fun at(index: Int): ID { + return invoke(myDelegate, "objectAtIndex:", index) + } + + val list: List + get() { + val result: MutableList = ArrayList() + for (i in 0 until count()) { + result.add(at(i)) + } + return result + } + } + +// class NSData // delegate should not be nil +// (private val myDelegate: ID) { +// fun length(): Int { +// return invoke(myDelegate, "length").toInt() +// } +// +// fun bytes(): ByteArray { +// val data = Pointer(invoke(myDelegate, "bytes").toLong()) +// return data.getByteArray(0, length()) +// } +// +// fun createImageFromBytes(): Image { +// return ImageLoader.loadFromBytes(bytes()) +// } +// } + + class NSAutoreleasePool { + private val myDelegate = + invoke(invoke("NSAutoreleasePool", "alloc"), "init") + + fun drain() { + invoke(myDelegate, "drain") + } + } + +// @Structure.FieldOrder("origin", "size") +// class NSRect(x: Double, y: Double, w: Double, h: Double) : Structure(), +// Structure.ByValue { +// var origin: NSPoint = NSPoint(x, y) +// var size: NSSize = NSSize(w, h) +// } +// +// @Structure.FieldOrder("x", "y") +// class NSPoint @JvmOverloads constructor(x: Double = 0.0, y: Double = 0.0) : +// Structure(), Structure.ByValue { +// var x: CoreGraphics.CGFloat = CGFloat(x) +// var y: CoreGraphics.CGFloat = CGFloat(y) +// } +// +// @Structure.FieldOrder("width", "height") +// class NSSize @JvmOverloads constructor(width: Double = 0.0, height: Double = 0.0) : +// Structure(), Structure.ByValue { +// var width: CoreGraphics.CGFloat = CGFloat(width) +// var height: CoreGraphics.CGFloat = CGFloat(height) +// } +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/foundation/FoundationLibrary.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/foundation/FoundationLibrary.kt new file mode 100644 index 0000000..440a853 --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/foundation/FoundationLibrary.kt @@ -0,0 +1,106 @@ +package com.darkrockstudios.libraries.mpfilepicker.mac.foundation + +import com.sun.jna.Callback +import com.sun.jna.Library +import com.sun.jna.Pointer + +interface FoundationLibrary : Library { + fun NSLog(pString: Pointer?, thing: Any?) + + fun NSFullUserName(): ID? + + fun objc_allocateClassPair(supercls: ID?, name: String?, extraBytes: Int): ID? + fun objc_registerClassPair(cls: ID?) + + fun CFStringCreateWithBytes( + allocator: Pointer?, + bytes: ByteArray?, + byteCount: Int, + encoding: Int, + isExternalRepresentation: Byte + ): ID? + + fun CFStringGetCString(theString: ID?, buffer: ByteArray?, bufferSize: Int, encoding: Int): Byte + fun CFStringGetLength(theString: ID?): Int + + fun CFStringConvertNSStringEncodingToEncoding(nsEncoding: Long): Long + fun CFStringConvertEncodingToIANACharSetName(cfEncoding: Long): ID? + + fun CFStringConvertIANACharSetNameToEncoding(encodingName: ID?): Long + fun CFStringConvertEncodingToNSStringEncoding(cfEncoding: Long): Long + + fun CFRetain(cfTypeRef: ID?) + fun CFRelease(cfTypeRef: ID?) + fun CFGetRetainCount(cfTypeRef: Pointer?): Int + + fun objc_getClass(className: String?): ID? + fun objc_getProtocol(name: String?): ID? + + fun class_createInstance(pClass: ID?, extraBytes: Int): ID? + fun sel_registerName(selectorName: String?): Pointer? + + fun class_replaceMethod(cls: ID?, selName: Pointer?, impl: Callback?, types: String?): ID? + + fun objc_getMetaClass(name: String?): ID? + + /** + * Note: Vararg version. Should only be used only for selectors with a single fixed argument followed by varargs. + */ + fun objc_msgSend(receiver: ID?, selector: Pointer?, firstArg: Any?, vararg args: Any?): ID? + + fun class_respondsToSelector(cls: ID?, selName: Pointer?): Boolean + fun class_addMethod(cls: ID?, selName: Pointer?, imp: Callback?, types: String?): Boolean + + fun class_addMethod(cls: ID?, selName: Pointer?, imp: ID?, types: String?): Boolean + fun class_addProtocol(aClass: ID?, protocol: ID?): Boolean + + fun class_isMetaClass(cls: ID?): Boolean + + fun NSStringFromSelector(selector: Pointer?): ID? + fun NSStringFromClass(aClass: ID?): ID? + + fun objc_getClass(clazz: Pointer?): Pointer? + + companion object { + const val kCFStringEncodingMacRoman: Int = 0 + const val kCFStringEncodingWindowsLatin1: Int = 0x0500 + const val kCFStringEncodingISOLatin1: Int = 0x0201 + const val kCFStringEncodingNextStepLatin: Int = 0x0B01 + const val kCFStringEncodingASCII: Int = 0x0600 + const val kCFStringEncodingUnicode: Int = 0x0100 + const val kCFStringEncodingUTF8: Int = 0x08000100 + const val kCFStringEncodingNonLossyASCII: Int = 0x0BFF + + const val kCFStringEncodingUTF16: Int = 0x0100 + const val kCFStringEncodingUTF16BE: Int = 0x10000100 + const val kCFStringEncodingUTF16LE: Int = 0x14000100 + const val kCFStringEncodingUTF32: Int = 0x0c000100 + const val kCFStringEncodingUTF32BE: Int = 0x18000100 + const val kCFStringEncodingUTF32LE: Int = 0x1c000100 + + // https://developer.apple.com/library/mac/documentation/Carbon/Reference/CGWindow_Reference/Constants/Constants.html#//apple_ref/doc/constant_group/Window_List_Option_Constants + const val kCGWindowListOptionAll: Int = 0 + const val kCGWindowListOptionOnScreenOnly: Int = 1 + const val kCGWindowListOptionOnScreenAboveWindow: Int = 2 + const val kCGWindowListOptionOnScreenBelowWindow: Int = 4 + const val kCGWindowListOptionIncludingWindow: Int = 8 + const val kCGWindowListExcludeDesktopElements: Int = 16 + + //https://developer.apple.com/library/mac/documentation/Carbon/Reference/CGWindow_Reference/Constants/Constants.html#//apple_ref/doc/constant_group/Window_Image_Types + const val kCGWindowImageDefault: Int = 0 + const val kCGWindowImageBoundsIgnoreFraming: Int = 1 + const val kCGWindowImageShouldBeOpaque: Int = 2 + const val kCGWindowImageOnlyShadows: Int = 4 + const val kCGWindowImageBestResolution: Int = 8 + const val kCGWindowImageNominalResolution: Int = 16 + + + // see enum NSBitmapImageFileType + const val NSBitmapImageFileTypeTIFF: Int = 0 + const val NSBitmapImageFileTypeBMP: Int = 1 + const val NSBitmapImageFileTypeGIF: Int = 2 + const val NSBitmapImageFileTypeJPEG: Int = 3 + const val NSBitmapImageFileTypePNG: Int = 4 + const val NSBitmapImageFileTypeJPEG2000: Int = 5 + } +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/foundation/ID.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/foundation/ID.kt new file mode 100644 index 0000000..a318ccb --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/mac/foundation/ID.kt @@ -0,0 +1,28 @@ +package com.darkrockstudios.libraries.mpfilepicker.mac.foundation + +import com.sun.jna.NativeLong + +/** + * Could be an address in memory (if pointer to a class or method) or a value (like 0 or 1) + */ +class ID : NativeLong { + constructor() + + constructor(peer: Long) : super(peer) + + fun booleanValue(): Boolean { + return toInt() != 0 + } + + override fun toByte(): Byte { + return toLong().toByte() + } + + override fun toShort(): Short { + return toLong().toShort() + } + + companion object { + val NIL: ID = ID(0L) + } +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/util/Platform.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/util/Platform.kt new file mode 100644 index 0000000..a47e4f4 --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/util/Platform.kt @@ -0,0 +1,27 @@ +package com.darkrockstudios.libraries.mpfilepicker.util + +object PlatformUtil { + val current: Platform + get() { + val system = System.getProperty("os.name").lowercase() + return if (system.contains("win")) { + Platform.Windows + } else if ( + system.contains("nix") || + system.contains("nux") || + system.contains("aix") + ) { + Platform.Linux + } else if (system.contains("mac")) { + Platform.MacOS + } else { + Platform.Linux + } + } +} + +enum class Platform { + Linux, + MacOS, + Windows +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/WindowsFilePicker.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/WindowsFilePicker.kt new file mode 100644 index 0000000..c44307b --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/WindowsFilePicker.kt @@ -0,0 +1,96 @@ +package com.darkrockstudios.libraries.mpfilepicker.windows + +import com.darkrockstudios.libraries.mpfilepicker.PlatformFilePicker +import com.darkrockstudios.libraries.mpfilepicker.windows.api.JnaFileChooser + +class WindowsFilePicker : PlatformFilePicker { + private val fileChooser = JnaFileChooser() + + override fun pickFile( + initialDirectory: String?, + fileExtensions: List?, + title: String? + ): String? { + // Setup file chooser + fileChooser.apply { + // Set mode + mode = JnaFileChooser.Mode.Files + + // Only allow single selection + isMultiSelectionEnabled = false + + // Set initial directory, title and file extensions + setup(initialDirectory, fileExtensions, title) + } + + // Show file chooser + fileChooser.showOpenDialog(null) + + // Return selected file + return fileChooser.selectedFile?.absolutePath + } + + override fun pickFiles( + initialDirectory: String?, + fileExtensions: List?, + title: String? + ): List? { + // Setup file chooser + fileChooser.apply { + // Set mode + mode = JnaFileChooser.Mode.Files + + // Allow multiple selection + isMultiSelectionEnabled = true + + // Set initial directory, title and file extensions + setup(initialDirectory, fileExtensions, title) + } + + // Show file chooser + fileChooser.showOpenDialog(null) + + // Return selected files + return fileChooser.selectedFiles + .mapNotNull { it?.absolutePath } + .ifEmpty { null } + } + + override fun pickDirectory(initialDirectory: String?, title: String?): String? { + // Setup file chooser + fileChooser.apply { + // Set mode + mode = JnaFileChooser.Mode.Directories + + // Only allow single selection + isMultiSelectionEnabled = false + + // Set initial directory and title + setup(initialDirectory, null, title) + } + + // Show file chooser + fileChooser.showOpenDialog(null) + + // Return selected directory + return fileChooser.selectedFile?.absolutePath + } + + private fun JnaFileChooser.setup( + initialDirectory: String?, + fileExtensions: List?, + title: String? + ) { + // Set title + title?.let(::setTitle) + + // Set initial directory + initialDirectory?.let(::setCurrentDirectory) + + // Set file extension + if (!fileExtensions.isNullOrEmpty()) { + val filterName = fileExtensions.joinToString(", ", "Supported Files (", ")") + addFilter(filterName, *fileExtensions.toTypedArray()) + } + } +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/api/JnaFileChooser.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/api/JnaFileChooser.kt new file mode 100644 index 0000000..e4d46b3 --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/api/JnaFileChooser.kt @@ -0,0 +1,297 @@ +package com.darkrockstudios.libraries.mpfilepicker.windows.api + +import com.sun.jna.Platform +import java.awt.Window +import java.io.File +import java.util.Arrays +import java.util.Collections +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter + +/** + * JnaFileChooser is a wrapper around the native Windows file chooser + * and folder browser that falls back to the Swing JFileChooser on platforms + * other than Windows or if the user chooses a combination of features + * that are not supported by the native dialogs (for example multiple + * selection of directories). + * + * Example: + * JnaFileChooser fc = new JnaFileChooser(); + * fc.setFilter("All Files", "*"); + * fc.setFilter("Pictures", "jpg", "jpeg", "gif", "png", "bmp"); + * fc.setMultiSelectionEnabled(true); + * fc.setMode(JnaFileChooser.Mode.FilesAndDirectories); + * if (fc.showOpenDialog(parent)) { + * Files[] selected = fc.getSelectedFiles(); + * // do something with selected + * } + * + * @see JFileChooser, WindowsFileChooser, WindowsFileBrowser + */ +class JnaFileChooser + () { + private enum class Action { + Open, Save + } + + /** + * the availabe selection modes of the dialog + */ + enum class Mode(val jFileChooserValue: Int) { + Files(JFileChooser.FILES_ONLY), + Directories(JFileChooser.DIRECTORIES_ONLY), + FilesAndDirectories(JFileChooser.FILES_AND_DIRECTORIES) + } + + var selectedFiles: Array + protected set + var currentDirectory: File? = null + protected set + protected var filters: ArrayList> = ArrayList() + + /** + * sets whether to enable multiselection + * + * @param enabled true to enable multiselection, false to disable it + */ + var isMultiSelectionEnabled: Boolean = false + + /** + * sets the selection mode + * + * @param mode the selection mode + */ + var mode: Mode = Mode.Files + + private var defaultFile: String = "" + private var dialogTitle: String = "" + private var openButtonText: String = "" + private var saveButtonText: String = "" + + /** + * creates a new file chooser with multiselection disabled and mode set + * to allow file selection only. + */ + init { + selectedFiles = arrayOf(null) + } + + /** + * creates a new file chooser with the specified initial directory + * + * @param currentDirectory the initial directory + */ + constructor(currentDirectory: File?) : this() { + if (currentDirectory != null) { + this.currentDirectory = + if (currentDirectory.isDirectory) currentDirectory else currentDirectory.parentFile + } + } + + /** + * creates a new file chooser with the specified initial directory + * + * @param currentDirectory the initial directory + */ + constructor(currentDirectoryPath: String?) : this( + if (currentDirectoryPath != null) File( + currentDirectoryPath + ) else null + ) + + /** + * shows a dialog for opening files + * + * @param parent the parent window + * + * @return true if the user clicked OK + */ + fun showOpenDialog(parent: Window?): Boolean { + return showDialog(parent, Action.Open) + } + + /** + * shows a dialog for saving files + * + * @param parent the parent window + * + * @return true if the user clicked OK + */ + fun showSaveDialog(parent: Window): Boolean { + return showDialog(parent, Action.Save) + } + + private fun showDialog(parent: Window?, action: Action): Boolean { + // native windows filechooser doesn't support mixed selection mode + if (Platform.isWindows() && mode != Mode.FilesAndDirectories) { + // windows filechooser can only multiselect files + if (isMultiSelectionEnabled && mode == Mode.Files) { + // TODO Here we would use the native windows dialog + // to choose multiple files. However I haven't been able + // to get it to work properly yet because it requires + // tricky callback magic and somehow this didn't work for me + // quite as documented (probably because I messed something up). + // Because I don't need this feature right now I've put it on + // hold to get on with stuff. + // Example code: http://support.microsoft.com/kb/131462/en-us + // GetOpenFileName: http://msdn.microsoft.com/en-us/library/ms646927.aspx + // OFNHookProc: http://msdn.microsoft.com/en-us/library/ms646931.aspx + // CDN_SELCHANGE: http://msdn.microsoft.com/en-us/library/ms646865.aspx + // SendMessage: http://msdn.microsoft.com/en-us/library/ms644950.aspx + } else if (!isMultiSelectionEnabled) { + if (mode == Mode.Files) { + return showWindowsFileChooser(parent, action) + } else if (mode == Mode.Directories) { + return showWindowsFolderBrowser(parent) + } + } + } + + // fallback to Swing + return showSwingFileChooser(parent, action) + } + + private fun showSwingFileChooser(parent: Window?, action: Action): Boolean { + val fc = JFileChooser(currentDirectory) + fc.isMultiSelectionEnabled = isMultiSelectionEnabled + fc.fileSelectionMode = mode.jFileChooserValue + + // set select file + if (!defaultFile.isEmpty() and (action == Action.Save)) { + val fsel = File(defaultFile) + fc.selectedFile = fsel + } + if (!dialogTitle.isEmpty()) { + fc.dialogTitle = dialogTitle + } + if ((action == Action.Open) and !openButtonText.isEmpty()) { + fc.approveButtonText = openButtonText + } else if ((action == Action.Save) and !saveButtonText.isEmpty()) { + fc.approveButtonText = saveButtonText + } + + // build filters + if (filters.size > 0) { + var useAcceptAllFilter = false + for (spec in filters) { + // the "All Files" filter is handled specially by JFileChooser + if (spec[1] == "*") { + useAcceptAllFilter = true + continue + } + fc.addChoosableFileFilter( + FileNameExtensionFilter( + spec[0], *Arrays.copyOfRange(spec, 1, spec.size) + ) + ) + } + fc.isAcceptAllFileFilterUsed = useAcceptAllFilter + } + + var result = -1 + result = if (action == Action.Open) { + fc.showOpenDialog(parent) + } else { + if (saveButtonText.isEmpty()) { + fc.showSaveDialog(parent) + } else { + fc.showDialog(parent, null) + } + } + if (result == JFileChooser.APPROVE_OPTION) { + selectedFiles = + if (isMultiSelectionEnabled) fc.selectedFiles else arrayOf(fc.selectedFile) + currentDirectory = fc.currentDirectory + return true + } + + return false + } + + private fun showWindowsFileChooser(parent: Window?, action: Action): Boolean { + val fc = WindowsFileChooser(currentDirectory) + fc.setFilters(filters) + + if (!defaultFile.isEmpty()) fc.setDefaultFilename(defaultFile) + + if (!dialogTitle.isEmpty()) { + fc.setTitle(dialogTitle) + } + + val result = fc.showDialog(parent, action == Action.Open) + if (result) { + selectedFiles = arrayOf(fc.selectedFile) + currentDirectory = fc.currentDirectory + } + return result + } + + private fun showWindowsFolderBrowser(parent: Window?): Boolean { + val fb = WindowsFolderBrowser() + if (!dialogTitle.isEmpty()) { + fb.setTitle(dialogTitle) + } + val file = fb.showDialog(parent) + if (file != null) { + selectedFiles = arrayOf(file) + currentDirectory = if (file.parentFile != null) file.parentFile else file + return true + } + + return false + } + + /** + * add a filter to the user-selectable list of file filters + * + * @param name name of the filter + * @param filter you must pass at least 1 argument, the arguments are the file + * extensions. + */ + fun addFilter(name: String, vararg filter: String) { + require(filter.isNotEmpty()) + val parts = ArrayList() + parts.add(name) + Collections.addAll(parts, *filter) + filters.add(parts.toTypedArray()) + } + + fun setCurrentDirectory(currentDirectoryPath: String?) { + this.currentDirectory = + (if (currentDirectoryPath != null) File(currentDirectoryPath) else null) + } + + fun setDefaultFileName(dfile: String) { + this.defaultFile = dfile + } + + /** + * set a title name + * + * @param Title of dialog + */ + fun setTitle(title: String) { + this.dialogTitle = title + } + + /** + * set a open button name + * + * @param open button text + */ + fun setOpenButtonText(buttonText: String) { + this.openButtonText = buttonText + } + + /** + * set a save button name + * + * @param save button text + */ + fun setSaveButtonText(buttonText: String) { + this.saveButtonText = buttonText + } + + val selectedFile: File? + get() = selectedFiles[0] +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/api/WindowsFileChooser.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/api/WindowsFileChooser.kt new file mode 100644 index 0000000..f0ce67d --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/api/WindowsFileChooser.kt @@ -0,0 +1,300 @@ +package com.darkrockstudios.libraries.mpfilepicker.windows.api + +import com.darkrockstudios.libraries.mpfilepicker.windows.win32.Comdlg32 +import com.darkrockstudios.libraries.mpfilepicker.windows.win32.Comdlg32.CommDlgExtendedError +import com.darkrockstudios.libraries.mpfilepicker.windows.win32.Comdlg32.GetOpenFileNameW +import com.darkrockstudios.libraries.mpfilepicker.windows.win32.Comdlg32.GetSaveFileNameW +import com.darkrockstudios.libraries.mpfilepicker.windows.win32.Comdlg32.OpenFileName +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.WString +import java.awt.Window +import java.io.File +import java.util.Collections + +/** + * The native Windows file chooser dialog. + * + * Example: + * WindowsFileChooser fc = new WindowsFileChooser("C:\\"); + * fc.addFilter("All Files", "*"); + * fc.addFilter("Text files", "txt", "log", "xml", "css", "html"); + * fc.addFilter("Source code", "java", "c", "cpp", "cc", "h", "hpp"); + * fc.addFilter("Binary files", "exe", "class", "jar", "dll", "so"); + * if (fc.showOpenDialog(parent)) { + * File f = fc.getSelectedFile(); + * // do something with f + * } + * + * Note that although you can set the initial directory Windows will + * determine the initial directory according to the following rules + * (the initial directory is referred to as "lpstrInitialDir"): + * + * Windows 7: + * 1. If lpstrInitialDir has the same value as was passed the first time the + * application used an Open or Save As dialog box, the path most recently + * selected by the user is used as the initial directory. + * 2. Otherwise, if lpstrFile contains a path, that path is the initial + * directory. + * 3. Otherwise, if lpstrInitialDir is not NULL, it specifies the initial + * directory. + * 4. If lpstrInitialDir is NULL and the current directory contains any files of + * the specified filter types, the initial directory is the current + * directory. + * 5. Otherwise, the initial directory is the personal files directory of the + * current user. + * 6. Otherwise, the initial directory is the Desktop folder. + * + * Windows 2000/XP/Vista: + * 1. If lpstrFile contains a path, that path is the initial directory. + * 2. Otherwise, lpstrInitialDir specifies the initial directory. + * 3. Otherwise, if the application has used an Open or Save As dialog box in + * the past, the path most recently used is selected as the initial + * directory. However, if an application is not run for a long time, its + * saved selected path is discarded. + * 4. If lpstrInitialDir is NULL and the current directory contains any files + * of the specified filter types, the initial directory is the current + * directory. + * 5. Otherwise, the initial directory is the personal files directory of the + * current user. + * 6. Otherwise, the initial directory is the Desktop folder. + * + * Therefore you probably want to use an exe wrapper like WinRun4J in order + * for this to work properly on Windows 7. Otherwise multiple programs may + * interfere with each other. Unfortunately there doesn't seem to be a way + * to override this behaviour. + * + * [://msdn.microsoft.com/en-us/library/ms646839.aspx][http] + * [://winrun4j.sourceforge.net/][http] + */ +class WindowsFileChooser { + /** + * returns the file selected by the user + * + * @return the selected file; null if the dialog was canceled or never shown + */ + var selectedFile: File? = null + protected set + + /** + * returns the current directory + * + * This is always the parent directory of the chosen file, even if you + * enter an absolute path to a file that doesn't exist in the current + * directory. + * + * @return the current directory + */ + var currentDirectory: File? = null + protected set + private var filters: ArrayList> + + private var defaultFilename: String = "" + private var dialogTitle: String = "" + + /** + * creates a new file chooser + */ + constructor() { + filters = ArrayList() + } + + /** + * creates a new file chooser with the specified initial directory + * + * If the given file is not a directory the parent file will be used instead. + * + * @param currentDirectory the initial directory + */ + constructor(currentDirectory: File?) { + filters = ArrayList() + if (currentDirectory != null) { + this.currentDirectory = + if (currentDirectory.isDirectory) currentDirectory else currentDirectory.parentFile + } + } + + /** + * creates a new file chooser with the specified initial directory path + * + * @param currentDirectoryPath the initial directory path; may be null + */ + constructor(currentDirectoryPath: String?) : this( + if (currentDirectoryPath != null) File( + currentDirectoryPath + ) else null + ) + + // this is a package private method used by the JnaFileChooser + // facade to directly set the filter list + fun setFilters(filters: ArrayList>) { + this.filters = filters + } + + /** + * add a filter to the user-selectable list of file filters + * + * @param name name of the filter + * @param filter you must pass at least 1 argument, the arguments + * are the file extensions. + */ + fun addFilter(name: String, vararg filter: String) { + require(filter.size >= 1) + val parts = mutableListOf() + parts.add(name) + Collections.addAll(parts, *filter) + filters.add(parts.toTypedArray()) + } + + /** + * set a title name + * + * @param Title of dialog + */ + fun setTitle(tname: String) { + this.dialogTitle = tname + } + + /** + * show the dialog for opening a file + * + * @param parent the parent window of the dialog + * + * @return true if the user clicked ok, false otherwise + */ + fun showOpenDialog(parent: Window?): Boolean { + return showDialog(parent, true) + } + + /** + * show the dialog for saving a file + * + * @param parent the parent window of the dialog + * + * @return true if the user clicked ok, false otherwise + */ + fun showSaveDialog(parent: Window?): Boolean { + return showDialog(parent, false) + } + + /* + * shows the dialog + * + * @param parent the parent window + * @param open whether to show the open dialog, if false save dialog is shown + * + * @return true if the user clicked ok, false otherwise + */ + fun showDialog(parent: Window?, open: Boolean): Boolean { + val params = OpenFileName() + params.Flags = // use explorer-style interface + (Comdlg32.OFN_EXPLORER // the dialog changes the current directory when browsing, + // this flag causes the original value to be restored after the + // dialog returns + or Comdlg32.OFN_NOCHANGEDIR // disable "open as read-only" feature + or Comdlg32.OFN_HIDEREADONLY // enable resizing of the dialog + or Comdlg32.OFN_ENABLESIZING) + + params.hwndOwner = if (parent == null) null else Native.getWindowPointer(parent) + + // lpstrFile contains the selection path after the dialog + // returns. It must be big enough for the path to fit or + // GetOpenFileName returns an error (FNERR_BUFFERTOOSMALL). + // MAX_PATH is 260 so 4*260+1 bytes should be big enough (I hope...) + // http://msdn.microsoft.com/en-us/library/aa365247.aspx#maxpath + val bufferLength = 260 + // 4 bytes per char + 1 null byte + val bufferSize = 4 * bufferLength + 1 + params.lpstrFile = Memory(bufferSize.toLong()) + if (defaultFilename.isNotEmpty()) { + params.lpstrFile?.setWideString(0, defaultFilename) + } else { + params.lpstrFile?.clear(bufferSize.toLong()) + } + if (!dialogTitle.isEmpty()) { + params.lpstrTitle = WString(dialogTitle) + } + + // nMaxFile + // http://msdn.microsoft.com/en-us/library/ms646839.aspx: + // "The size, in characters, of the buffer pointed to by + // lpstrFile. The buffer must be large enough to store the + // path and file name string or strings, including the + // terminating NULL character." + + // Therefore because we're using the unicode version of the + // API the nMaxFile value must be 1/4 of the lpstrFile + // buffer size plus one for the terminating null byte. + params.nMaxFile = bufferLength + + if (currentDirectory != null) { + params.lpstrInitialDir = WString(currentDirectory!!.absolutePath) + } + + // build filter string if filters were specified + if (filters.size > 0) { + params.lpstrFilter = WString(buildFilterString()) + params.nFilterIndex = 1 // TODO don't hardcode here + } + + val approved = if (open) GetOpenFileNameW(params) else GetSaveFileNameW(params) + + if (approved) { + val filePath = params.lpstrFile?.getWideString(0) + selectedFile = File(filePath) + val dir = selectedFile!!.parentFile + currentDirectory = dir + } else { + val errCode = CommDlgExtendedError() + // if the code is 0 the user clicked cancel + if (errCode != 0) { + throw RuntimeException( + "GetOpenFileName failed with error $errCode" + ) + } + } + return approved + } + + /* + * builds a filter string + * + * from MSDN: + * A buffer containing pairs of null-terminated filter strings. The last + * string in the buffer must be terminated by two NULL characters. + * + * The first string in each pair is a display string that describes the + * filter (for example, "Text Files"), and the second string specifies the + * filter pattern (for example, "*.TXT"). To specify multiple filter + * patterns for a single display string, use a semicolon to separate the + * patterns (for example, "*.TXT;*.DOC;*.BAK"). + * + * http://msdn.microsoft.com/en-us/library/ms646839.aspx + */ + private fun buildFilterString(): String { + val filterStr = StringBuilder() + for (spec in filters) { + val label = spec[0] + // add label and terminate with null byte + filterStr.append(label) + filterStr.append('\u0000') + // build file extension patterns seperated by a + // semicolon and terminated by a null byte + for (i in 1 until spec.size) { + filterStr.append("*.") + filterStr.append(spec[i]) + filterStr.append(';') + } + // remove last superfluous ";" and add terminator + filterStr.deleteCharAt(filterStr.length - 1) + filterStr.append('\u0000') + } + // final terminator + filterStr.append('\u0000') + return filterStr.toString() + } + + fun setDefaultFilename(defaultFilename: String) { + this.defaultFilename = defaultFilename + } +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/api/WindowsFolderBrowser.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/api/WindowsFolderBrowser.kt new file mode 100644 index 0000000..5396392 --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/api/WindowsFolderBrowser.kt @@ -0,0 +1,82 @@ +package com.darkrockstudios.libraries.mpfilepicker.windows.api + +import com.darkrockstudios.libraries.mpfilepicker.windows.win32.Ole32.CoTaskMemFree +import com.darkrockstudios.libraries.mpfilepicker.windows.win32.Ole32.OleInitialize +import com.darkrockstudios.libraries.mpfilepicker.windows.win32.Shell32 +import com.darkrockstudios.libraries.mpfilepicker.windows.win32.Shell32.BrowseInfo +import com.darkrockstudios.libraries.mpfilepicker.windows.win32.Shell32.SHBrowseForFolder +import com.darkrockstudios.libraries.mpfilepicker.windows.win32.Shell32.SHGetPathFromIDListW +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.Pointer +import java.awt.Window +import java.io.File + +/** + * The native Windows folder browser. + * + * Example: + * WindowsFolderBrowser fb = new WindowsFolderBrowser(); + * File dir = fb.showDialog(parentWindow); + * if (dir != null) { + * // do something with dir + * } + */ +class WindowsFolderBrowser { + private var title: String? + + /** + * creates a new folder browser + */ + constructor() { + title = null + } + + /** + * creates a new folder browser with text that can be used as title + * or to give instructions to the user + * + * @param title text that will be displayed at the top of the dialog + */ + constructor(title: String?) { + this.title = title + } + + fun setTitle(title: String?) { + this.title = title + } + + /** + * displays the dialog to the user + * + * @param parent the parent window + * + * @return the selected directory or null if the user canceled the dialog + */ + fun showDialog(parent: Window?): File? { + OleInitialize(null) + val params = BrowseInfo() + params.hwndOwner = if (parent == null) null else Native.getWindowPointer(parent) + params.ulFlags = // disable the OK button if the user selects a virtual PIDL + Shell32.BIF_RETURNONLYFSDIRS or // BIF_USENEWUI is only available as of Windows 2000/Me (Shell32.dll 5.0) + // but I guess no one is using older versions anymore anyway right?! + // I don't know what happens if this is executed where it's + // not supported. + Shell32.BIF_USENEWUI + if (title != null) { + params.lpszTitle = title + } + val pidl = SHBrowseForFolder(params) + if (pidl != null) { + // MAX_PATH is 260 on Windows XP x32 so 4kB should + // be more than big enough + val path: Pointer = Memory((1024 * 4).toLong()) + SHGetPathFromIDListW(pidl, path) + val filePath = path.getWideString(0) + val file = File(filePath) + CoTaskMemFree(pidl) + return file + } + return null + } +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/win32/Comdlg32.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/win32/Comdlg32.kt new file mode 100644 index 0000000..6af9ead --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/win32/Comdlg32.kt @@ -0,0 +1,111 @@ +package com.darkrockstudios.libraries.mpfilepicker.windows.win32 + +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.WString + +object Comdlg32 { + init { + Native.register("comdlg32") + } + + external fun GetOpenFileNameW(params: OpenFileName?): Boolean + external fun GetSaveFileNameW(params: OpenFileName?): Boolean + external fun CommDlgExtendedError(): Int + + // flags for the OpenFileName structure + const val OFN_READONLY: Int = 0x00000001 + const val OFN_OVERWRITEPROMPT: Int = 0x00000002 + const val OFN_HIDEREADONLY: Int = 0x00000004 + const val OFN_NOCHANGEDIR: Int = 0x00000008 + const val OFN_SHOWHELP: Int = 0x00000010 + const val OFN_ENABLEHOOK: Int = 0x00000020 + const val OFN_ENABLETEMPLATE: Int = 0x00000040 + const val OFN_ENABLETEMPLATEHANDLE: Int = 0x00000080 + const val OFN_NOVALIDATE: Int = 0x00000100 + const val OFN_ALLOWMULTISELECT: Int = 0x00000200 + const val OFN_EXTENSIONDIFFERENT: Int = 0x00000400 + const val OFN_PATHMUSTEXIST: Int = 0x00000800 + const val OFN_FILEMUSTEXIST: Int = 0x00001000 + const val OFN_CREATEPROMPT: Int = 0x00002000 + const val OFN_SHAREAWARE: Int = 0x00004000 + const val OFN_NOREADONLYRETURN: Int = 0x00008000 + const val OFN_NOTESTFILECREATE: Int = 0x00010000 + const val OFN_NONETWORKBUTTON: Int = 0x00020000 + const val OFN_NOLONGNAMES: Int = 0x00040000 + const val OFN_EXPLORER: Int = 0x00080000 + const val OFN_NODEREFERENCELINKS: Int = 0x00100000 + const val OFN_LONGNAMES: Int = 0x00200000 + const val OFN_ENABLEINCLUDENOTIFY: Int = 0x00400000 + const val OFN_ENABLESIZING: Int = 0x00800000 + const val OFN_DONTADDTORECENT: Int = 0x02000000 + const val OFN_FORCESHOWHIDDEN: Int = 0x10000000 + + // error codes from cderr.h which may be returned by + // CommDlgExtendedError for the GetOpenFileName and + // GetSaveFileName functions. + const val CDERR_DIALOGFAILURE: Int = 0xFFFF + const val CDERR_FINDRESFAILURE: Int = 0x0006 + const val CDERR_INITIALIZATION: Int = 0x0002 + const val CDERR_LOADRESFAILURE: Int = 0x0007 + const val CDERR_LOADSTRFAILURE: Int = 0x0005 + const val CDERR_LOCKRESFAILURE: Int = 0x0008 + const val CDERR_MEMALLOCFAILURE: Int = 0x0009 + const val CDERR_MEMLOCKFAILURE: Int = 0x000A + const val CDERR_NOHINSTANCE: Int = 0x0004 + const val CDERR_NOHOOK: Int = 0x000B + const val CDERR_NOTEMPLATE: Int = 0x0003 + const val CDERR_STRUCTSIZE: Int = 0x0001 + const val FNERR_SUBCLASSFAILURE: Int = 0x3001 + const val FNERR_INVALIDFILENAME: Int = 0x3002 + const val FNERR_BUFFERTOOSMALL: Int = 0x3003 + + class OpenFileName : Structure() { + @JvmField var lStructSize: Int = size() + @JvmField var hwndOwner: Pointer? = null + @JvmField var hInstance: Pointer? = null + @JvmField var lpstrFilter: WString? = null + @JvmField var lpstrCustomFilter: WString? = null + @JvmField var nMaxCustFilter: Int = 0 + @JvmField var nFilterIndex: Int = 0 + @JvmField var lpstrFile: Pointer? = null + @JvmField var nMaxFile: Int = 0 + @JvmField var lpstrDialogTitle: String? = null + @JvmField var nMaxDialogTitle: Int = 0 + @JvmField var lpstrInitialDir: WString? = null + @JvmField var lpstrTitle: WString? = null + @JvmField var Flags: Int = 0 + @JvmField var nFileOffset: Short = 0 + @JvmField var nFileExtension: Short = 0 + @JvmField var lpstrDefExt: String? = null + @JvmField var lCustData: Pointer? = null + @JvmField var lpfnHook: Pointer? = null + @JvmField var lpTemplateName: Pointer? = null + + override fun getFieldOrder(): List { + return listOf( + "lStructSize", + "hwndOwner", + "hInstance", + "lpstrFilter", + "lpstrCustomFilter", + "nMaxCustFilter", + "nFilterIndex", + "lpstrFile", + "nMaxFile", + "lpstrDialogTitle", + "nMaxDialogTitle", + "lpstrInitialDir", + "lpstrTitle", + "Flags", + "nFileOffset", + "nFileExtension", + "lpstrDefExt", + "lCustData", + "lpfnHook", + "lpTemplateName" + ) + } + } +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/win32/Ole32.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/win32/Ole32.kt new file mode 100644 index 0000000..640cc9c --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/win32/Ole32.kt @@ -0,0 +1,13 @@ +package com.darkrockstudios.libraries.mpfilepicker.windows.win32 + +import com.sun.jna.Native +import com.sun.jna.Pointer + +object Ole32 { + init { + Native.register("ole32") + } + + external fun OleInitialize(pvReserved: Pointer?): Pointer? + external fun CoTaskMemFree(pv: Pointer?) +} diff --git a/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/win32/Shell32.kt b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/win32/Shell32.kt new file mode 100644 index 0000000..18d6a29 --- /dev/null +++ b/mpfilepicker/src/jvmMain/kotlin/com/darkrockstudios/libraries/mpfilepicker/windows/win32/Shell32.kt @@ -0,0 +1,50 @@ +package com.darkrockstudios.libraries.mpfilepicker.windows.win32 + +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure + +object Shell32 { + init { + Native.register("shell32") + } + + external fun SHBrowseForFolder(params: BrowseInfo?): Pointer? + external fun SHGetPathFromIDListW(pidl: Pointer?, path: Pointer?): Boolean + + // flags for the BrowseInfo structure + const val BIF_RETURNONLYFSDIRS: Int = 0x00000001 + const val BIF_DONTGOBELOWDOMAIN: Int = 0x00000002 + const val BIF_NEWDIALOGSTYLE: Int = 0x00000040 + const val BIF_EDITBOX: Int = 0x00000010 + const val BIF_USENEWUI: Int = BIF_EDITBOX or BIF_NEWDIALOGSTYLE + const val BIF_NONEWFOLDERBUTTON: Int = 0x00000200 + const val BIF_BROWSEINCLUDEFILES: Int = 0x00004000 + const val BIF_SHAREABLE: Int = 0x00008000 + const val BIF_BROWSEFILEJUNCTIONS: Int = 0x00010000 + + // http://msdn.microsoft.com/en-us/library/bb773205.aspx + class BrowseInfo : Structure() { + @JvmField var hwndOwner: Pointer? = null + @JvmField var pidlRoot: Pointer? = null + @JvmField var pszDisplayName: String? = null + @JvmField var lpszTitle: String? = null + @JvmField var ulFlags: Int = 0 + @JvmField var lpfn: Pointer? = null + @JvmField var lParam: Pointer? = null + @JvmField var iImage: Int = 0 + + override fun getFieldOrder(): List { + return listOf( + "hwndOwner", + "pidlRoot", + "pszDisplayName", + "lpszTitle", + "ulFlags", + "lpfn", + "lParam", + "iImage" + ) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2b36aa5..c468fc6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,5 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + pluginManagement { repositories { maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") @@ -19,6 +21,7 @@ dependencyResolutionManagement { rootProject.name = "MultiplatformFilePicker" include(":mpfilepicker") +include(":picker-core") include(":examples:android") include(":examples:jvm") include(":examples:web-wasm")