mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
Polish native shell navigation
This commit is contained in:
@@ -11,6 +11,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation(compose.desktop.currentOs)
|
implementation(compose.desktop.currentOs)
|
||||||
implementation(libs.kotlinx.coroutinesSwing)
|
implementation(libs.kotlinx.coroutinesSwing)
|
||||||
|
implementation(libs.jna)
|
||||||
|
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ package com.vnidrop.app
|
|||||||
|
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
|
import com.vnidrop.app.platform.DesktopAppearanceBridge
|
||||||
|
|
||||||
fun main() {
|
fun main() {
|
||||||
configureMacOsNativeAppearance()
|
configureMacOsNativeAppearance()
|
||||||
|
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
|
||||||
application {
|
application {
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ junit = "4.13.2"
|
|||||||
kotlin = "2.4.0"
|
kotlin = "2.4.0"
|
||||||
kotlinx-coroutines = "1.11.0"
|
kotlinx-coroutines = "1.11.0"
|
||||||
material3 = "1.11.0-alpha07"
|
material3 = "1.11.0-alpha07"
|
||||||
|
jna = "5.17.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
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" }
|
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-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" }
|
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||||
|
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.vnidrop.app.ui.navigation
|
package com.vnidrop.app.ui.navigation
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -40,23 +39,34 @@ fun AppSidebarNavigation(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val colors = LocalVniDropColors.current
|
val colors = LocalVniDropColors.current
|
||||||
Column(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(88.dp)
|
.width(88.dp)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.background(colors.backgroundSurface200)
|
.background(colors.backgroundSurface200),
|
||||||
.border(width = 1.dp, color = colors.borderDefault)
|
|
||||||
.padding(vertical = 10.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
) {
|
) {
|
||||||
primaryNavigationItems.forEach { item ->
|
Column(
|
||||||
SidebarNavigationItem(
|
modifier = Modifier
|
||||||
item = item,
|
.fillMaxHeight()
|
||||||
selected = item.destination == selected,
|
.padding(vertical = 10.dp),
|
||||||
onClick = { onDestinationSelected(item.destination) },
|
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(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(colors.backgroundSurface200)
|
.background(colors.backgroundSurface200),
|
||||||
.border(width = 1.dp, color = colors.borderDefault),
|
|
||||||
) {
|
) {
|
||||||
ActiveBottomIndicator(selected = selected)
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -107,18 +115,8 @@ private fun SidebarNavigationItem(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.selectable(selected = selected, onClick = onClick)
|
.selectable(selected = selected, onClick = onClick)
|
||||||
.background(if (selected) colors.backgroundSurface300 else Color.Transparent)
|
|
||||||
.padding(vertical = 13.dp),
|
.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(
|
Column(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,17 +41,13 @@ object VniDropIcons {
|
|||||||
strokeLineJoin = StrokeJoin.Round,
|
strokeLineJoin = StrokeJoin.Round,
|
||||||
pathFillType = PathFillType.NonZero,
|
pathFillType = PathFillType.NonZero,
|
||||||
) {
|
) {
|
||||||
moveTo(12f, 3f)
|
moveTo(12f, 17f)
|
||||||
lineTo(12f, 15f)
|
lineTo(12f, 3f)
|
||||||
moveTo(7f, 10f)
|
moveTo(6f, 11f)
|
||||||
lineTo(12f, 15f)
|
lineTo(12f, 17f)
|
||||||
lineTo(17f, 10f)
|
lineTo(18f, 11f)
|
||||||
moveTo(5f, 21f)
|
moveTo(19f, 21f)
|
||||||
lineTo(19f, 21f)
|
|
||||||
moveTo(5f, 17f)
|
|
||||||
lineTo(5f, 21f)
|
lineTo(5f, 21f)
|
||||||
moveTo(19f, 17f)
|
|
||||||
lineTo(19f, 21f)
|
|
||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
@@ -66,25 +62,22 @@ object VniDropIcons {
|
|||||||
strokeLineJoin = StrokeJoin.Round,
|
strokeLineJoin = StrokeJoin.Round,
|
||||||
pathFillType = PathFillType.NonZero,
|
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)
|
moveTo(12f, 15f)
|
||||||
arcTo(3f, 3f, 0f, false, false, 12f, 9f)
|
arcTo(3f, 3f, 0f, false, true, 12f, 9f)
|
||||||
arcTo(3f, 3f, 0f, false, false, 12f, 15f)
|
arcTo(3f, 3f, 0f, false, true, 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)
|
|
||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ internal object DesktopSystemAppearance {
|
|||||||
if (!isMacOs()) return
|
if (!isMacOs()) return
|
||||||
System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme))
|
System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme))
|
||||||
EventQueue.invokeLater {
|
EventQueue.invokeLater {
|
||||||
|
DesktopAppearanceBridge.applyNativeAppearance?.invoke(isDarkTheme)
|
||||||
Window.getWindows().forEach { window ->
|
Window.getWindows().forEach { window ->
|
||||||
applyWindowChrome(window, isDarkTheme)
|
applyWindowChrome(window, isDarkTheme)
|
||||||
}
|
}
|
||||||
@@ -31,6 +32,8 @@ internal object DesktopSystemAppearance {
|
|||||||
internal fun macOsAppearanceName(isDarkTheme: Boolean): String =
|
internal fun macOsAppearanceName(isDarkTheme: Boolean): String =
|
||||||
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
|
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
|
||||||
|
|
||||||
|
internal fun usesTransparentTitlebar(): Boolean = true
|
||||||
|
|
||||||
internal fun titlebarBackground(isDarkTheme: Boolean): Color =
|
internal fun titlebarBackground(isDarkTheme: Boolean): Color =
|
||||||
if (isDarkTheme) Color(0x12, 0x12, 0x12) else Color(0xF8, 0xF8, 0xF8)
|
if (isDarkTheme) Color(0x12, 0x12, 0x12) else Color(0xF8, 0xF8, 0xF8)
|
||||||
|
|
||||||
@@ -38,9 +41,9 @@ internal object DesktopSystemAppearance {
|
|||||||
val background = titlebarBackground(isDarkTheme)
|
val background = titlebarBackground(isDarkTheme)
|
||||||
window.background = background
|
window.background = background
|
||||||
(window as? JFrame)?.rootPane?.let { rootPane ->
|
(window as? JFrame)?.rootPane?.let { rootPane ->
|
||||||
// Keep the native macOS controls and drag behavior, but let the
|
// The titlebar stays native, but AppKit receives the resolved app
|
||||||
// decorated titlebar blend with the app's resolved light/dark surface.
|
// appearance so title text and controls switch contrast at runtime.
|
||||||
rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, true)
|
rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar())
|
||||||
rootPane.background = background
|
rootPane.background = background
|
||||||
rootPane.contentPane.background = background
|
rootPane.contentPane.background = background
|
||||||
}
|
}
|
||||||
@@ -49,3 +52,8 @@ internal object DesktopSystemAppearance {
|
|||||||
private fun isMacOs(): Boolean =
|
private fun isMacOs(): Boolean =
|
||||||
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
|
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object DesktopAppearanceBridge {
|
||||||
|
@Volatile
|
||||||
|
var applyNativeAppearance: ((Boolean) -> Unit)? = null
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,4 +15,15 @@ class DesktopSystemAppearanceTest {
|
|||||||
assertEquals(0x121212, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF)
|
assertEquals(0x121212, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF)
|
||||||
assertEquals(0xF8F8F8, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user