From 8ae8a1ccee4e1419c087da93d15fce98b4701f92 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Sun, 5 Jul 2026 20:27:48 +0200 Subject: [PATCH] Polish native shell navigation --- desktopApp/build.gradle.kts | 1 + .../com/vnidrop/app/MacOsAppKitAppearance.kt | 51 ++++++++++++++ .../src/main/kotlin/com/vnidrop/app/main.kt | 2 + gradle/libs.versions.toml | 2 + .../app/ui/navigation/AppNavigation.kt | 66 +++++++------------ .../vnidrop/app/ui/navigation/VniDropIcons.kt | 49 ++++++-------- .../platform/PlatformSystemAppearance.jvm.kt | 14 +++- .../platform/DesktopSystemAppearanceTest.kt | 11 ++++ 8 files changed, 123 insertions(+), 73 deletions(-) create mode 100644 desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 622b007..01b21e3 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutinesSwing) + implementation(libs.jna) implementation(libs.compose.uiToolingPreview) } diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt new file mode 100644 index 0000000..44eb707 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt @@ -0,0 +1,51 @@ +package com.vnidrop.app + +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.NativeLibrary +import com.sun.jna.Pointer + +internal object MacOsAppKitAppearance { + private val objc: ObjCRuntime? by lazy { + runCatching { + NativeLibrary.getInstance("AppKit") + Native.load("objc", ObjCRuntime::class.java) + }.getOrNull() + } + + fun apply(isDarkTheme: Boolean) { + if (!isMacOs()) return + runCatching { + val runtime = objc ?: return + val applicationClass = runtime.objc_getClass("NSApplication") ?: return + val appearanceClass = runtime.objc_getClass("NSAppearance") ?: return + val application = runtime.objc_msgSend(applicationClass, runtime.sel_registerName("sharedApplication")) ?: return + val appearanceName = nsString(runtime, macOsAppearanceName(isDarkTheme)) ?: return + val appearance = runtime.objc_msgSend( + appearanceClass, + runtime.sel_registerName("appearanceNamed:"), + appearanceName, + ) ?: return + runtime.objc_msgSend(application, runtime.sel_registerName("setAppearance:"), appearance) + } + } + + private fun macOsAppearanceName(isDarkTheme: Boolean): String = + if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua" + + private fun nsString(runtime: ObjCRuntime, value: String): Pointer? { + val stringClass = runtime.objc_getClass("NSString") ?: return null + return runtime.objc_msgSend(stringClass, runtime.sel_registerName("stringWithUTF8String:"), value) + } + + private fun isMacOs(): Boolean = + System.getProperty("os.name").startsWith("Mac", ignoreCase = true) +} + +private interface ObjCRuntime : Library { + fun objc_getClass(name: String): Pointer? + fun sel_registerName(name: String): Pointer + fun objc_msgSend(receiver: Pointer?, selector: Pointer?): Pointer? + fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: Pointer?): Pointer? + fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: String): Pointer? +} diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index 7db1794..0ce6bb7 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -2,9 +2,11 @@ package com.vnidrop.app import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import com.vnidrop.app.platform.DesktopAppearanceBridge fun main() { configureMacOsNativeAppearance() + DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply application { Window( onCloseRequest = ::exitApplication, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 48ff50d..8ac0a6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ junit = "4.13.2" kotlin = "2.4.0" kotlinx-coroutines = "1.11.0" material3 = "1.11.0-alpha07" +jna = "5.17.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -36,6 +37,7 @@ compose-components-resources = { module = "org.jetbrains.compose.components:comp compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt index 861301f..e18df13 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/AppNavigation.kt @@ -1,7 +1,6 @@ package com.vnidrop.app.ui.navigation import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -40,23 +39,34 @@ fun AppSidebarNavigation( modifier: Modifier = Modifier, ) { val colors = LocalVniDropColors.current - Column( + Box( modifier = modifier .width(88.dp) .fillMaxHeight() - .background(colors.backgroundSurface200) - .border(width = 1.dp, color = colors.borderDefault) - .padding(vertical = 10.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp), + .background(colors.backgroundSurface200), ) { - primaryNavigationItems.forEach { item -> - SidebarNavigationItem( - item = item, - selected = item.destination == selected, - onClick = { onDestinationSelected(item.destination) }, - ) + Column( + modifier = Modifier + .fillMaxHeight() + .padding(vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + primaryNavigationItems.forEach { item -> + SidebarNavigationItem( + item = item, + selected = item.destination == selected, + onClick = { onDestinationSelected(item.destination) }, + ) + } } + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .width(1.dp) + .fillMaxHeight() + .background(colors.borderDefault), + ) } } @@ -70,10 +80,8 @@ fun AppBottomNavigation( Column( modifier = modifier .fillMaxWidth() - .background(colors.backgroundSurface200) - .border(width = 1.dp, color = colors.borderDefault), + .background(colors.backgroundSurface200), ) { - ActiveBottomIndicator(selected = selected) Row( modifier = Modifier .fillMaxWidth() @@ -107,18 +115,8 @@ private fun SidebarNavigationItem( modifier = Modifier .fillMaxWidth() .selectable(selected = selected, onClick = onClick) - .background(if (selected) colors.backgroundSurface300 else Color.Transparent) .padding(vertical = 13.dp), ) { - if (selected) { - Box( - modifier = Modifier - .align(Alignment.CenterStart) - .size(width = 4.dp, height = 46.dp) - .clip(RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp)) - .background(colors.brandLink), - ) - } Column( modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, @@ -168,19 +166,3 @@ private fun BottomNavigationItem( ) } } - -@Composable -private fun ActiveBottomIndicator(selected: AppDestination) { - val colors = LocalVniDropColors.current - val index = primaryNavigationItems.indexOfFirst { it.destination == selected }.coerceAtLeast(0) - Row(modifier = Modifier.fillMaxWidth()) { - repeat(primaryNavigationItems.size) { itemIndex -> - Box( - modifier = Modifier - .weight(1f) - .size(height = 3.dp, width = 1.dp) - .background(if (itemIndex == index) colors.brandLink else Color.Transparent), - ) - } - } -} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/VniDropIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/VniDropIcons.kt index 2f8acf0..eddae76 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/VniDropIcons.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/navigation/VniDropIcons.kt @@ -41,17 +41,13 @@ object VniDropIcons { strokeLineJoin = StrokeJoin.Round, pathFillType = PathFillType.NonZero, ) { - moveTo(12f, 3f) - lineTo(12f, 15f) - moveTo(7f, 10f) - lineTo(12f, 15f) - lineTo(17f, 10f) - moveTo(5f, 21f) - lineTo(19f, 21f) - moveTo(5f, 17f) + moveTo(12f, 17f) + lineTo(12f, 3f) + moveTo(6f, 11f) + lineTo(12f, 17f) + lineTo(18f, 11f) + moveTo(19f, 21f) lineTo(5f, 21f) - moveTo(19f, 17f) - lineTo(19f, 21f) } }.build() } @@ -66,25 +62,22 @@ object VniDropIcons { strokeLineJoin = StrokeJoin.Round, pathFillType = PathFillType.NonZero, ) { + moveTo(9.671f, 4.136f) + arcToRelative(2.34f, 2.34f, 0f, false, true, 4.659f, 0f) + arcToRelative(2.34f, 2.34f, 0f, false, false, 3.319f, 1.915f) + arcToRelative(2.34f, 2.34f, 0f, false, true, 2.33f, 4.033f) + arcToRelative(2.34f, 2.34f, 0f, false, false, 0f, 3.831f) + arcToRelative(2.34f, 2.34f, 0f, false, true, -2.33f, 4.033f) + arcToRelative(2.34f, 2.34f, 0f, false, false, -3.319f, 1.915f) + arcToRelative(2.34f, 2.34f, 0f, false, true, -4.659f, 0f) + arcToRelative(2.34f, 2.34f, 0f, false, false, -3.32f, -1.915f) + arcToRelative(2.34f, 2.34f, 0f, false, true, -2.33f, -4.033f) + arcToRelative(2.34f, 2.34f, 0f, false, false, 0f, -3.831f) + arcTo(2.34f, 2.34f, 0f, false, true, 6.35f, 6.051f) + arcToRelative(2.34f, 2.34f, 0f, false, false, 3.319f, -1.915f) moveTo(12f, 15f) - arcTo(3f, 3f, 0f, false, false, 12f, 9f) - arcTo(3f, 3f, 0f, false, false, 12f, 15f) - moveTo(19.4f, 15f) - lineTo(20.8f, 17.4f) - lineTo(18.4f, 21f) - lineTo(15.8f, 20f) - moveTo(8.2f, 4f) - lineTo(5.6f, 3f) - lineTo(3.2f, 6.6f) - lineTo(4.6f, 9f) - moveTo(15.8f, 4f) - lineTo(18.4f, 3f) - lineTo(20.8f, 6.6f) - lineTo(19.4f, 9f) - moveTo(4.6f, 15f) - lineTo(3.2f, 17.4f) - lineTo(5.6f, 21f) - lineTo(8.2f, 20f) + arcTo(3f, 3f, 0f, false, true, 12f, 9f) + arcTo(3f, 3f, 0f, false, true, 12f, 15f) } }.build() } diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt index 511f37a..3426d6c 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt @@ -22,6 +22,7 @@ internal object DesktopSystemAppearance { if (!isMacOs()) return System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme)) EventQueue.invokeLater { + DesktopAppearanceBridge.applyNativeAppearance?.invoke(isDarkTheme) Window.getWindows().forEach { window -> applyWindowChrome(window, isDarkTheme) } @@ -31,6 +32,8 @@ internal object DesktopSystemAppearance { internal fun macOsAppearanceName(isDarkTheme: Boolean): String = if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua" + internal fun usesTransparentTitlebar(): Boolean = true + internal fun titlebarBackground(isDarkTheme: Boolean): Color = if (isDarkTheme) Color(0x12, 0x12, 0x12) else Color(0xF8, 0xF8, 0xF8) @@ -38,9 +41,9 @@ internal object DesktopSystemAppearance { val background = titlebarBackground(isDarkTheme) window.background = background (window as? JFrame)?.rootPane?.let { rootPane -> - // Keep the native macOS controls and drag behavior, but let the - // decorated titlebar blend with the app's resolved light/dark surface. - rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, true) + // The titlebar stays native, but AppKit receives the resolved app + // appearance so title text and controls switch contrast at runtime. + rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar()) rootPane.background = background rootPane.contentPane.background = background } @@ -49,3 +52,8 @@ internal object DesktopSystemAppearance { private fun isMacOs(): Boolean = System.getProperty("os.name").startsWith("Mac", ignoreCase = true) } + +object DesktopAppearanceBridge { + @Volatile + var applyNativeAppearance: ((Boolean) -> Unit)? = null +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt index d82df4d..c0d7e2b 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt @@ -15,4 +15,15 @@ class DesktopSystemAppearanceTest { assertEquals(0x121212, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF) assertEquals(0xF8F8F8, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF) } + + @Test + fun transparentTitlebarIsAlwaysUsedWithAppKitAppearance() { + assertEquals(true, DesktopSystemAppearance.usesTransparentTitlebar()) + } + + @Test + fun runtimeAppearanceCallIsFailSoft() { + DesktopSystemAppearance.apply(isDarkTheme = true) + DesktopSystemAppearance.apply(isDarkTheme = false) + } }