Merge pull request #12 from vnidrop/feat/macos-unified-window-chrome

feat(desktop): unify macOS window chrome
This commit is contained in:
Hammed Abass
2026-07-13 08:44:00 +02:00
committed by GitHub
6 changed files with 246 additions and 67 deletions

View File

@@ -1,6 +1,26 @@
package com.vnidrop.app
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.window.WindowDraggableArea
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.awtEventOrNull
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowScope
import androidx.compose.ui.window.application
import com.vnidrop.app.platform.DesktopAppearanceBridge
import com.vnidrop.app.feature.send.DesktopShareBridge
@@ -8,11 +28,13 @@ import com.vnidrop.app.feature.receive.ExternalInvitationController
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
import com.vnidrop.app.feature.receive.decodeInvitationBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import java.awt.Desktop
import java.io.File
fun main(args: Array<String>) {
val externalInvitations = ExternalInvitationController()
val macOs = DesktopAppearanceBridge.isMacOs()
configureMacOsNativeAppearance()
configureInvitationOpenHandler(externalInvitations)
args.asSequence()
@@ -20,7 +42,7 @@ fun main(args: Array<String>) {
.filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) }
.forEach { externalInvitations.openFile(it) }
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) {
if (macOs) {
DesktopShareBridge.shareFile = MacOsShareSheet::share
}
application {
@@ -28,7 +50,16 @@ fun main(args: Array<String>) {
onCloseRequest = ::exitApplication,
title = "vnidrop",
) {
App(rememberJvmAppDependencies(externalInvitations))
App(
dependencies = rememberJvmAppDependencies(externalInvitations),
windowChromeTopInset = if (macOs) MacOsTitleBarHeight else 0.dp,
windowContentTopStartRadius = if (macOs) MacOsContentCornerRadius else 0.dp,
windowChrome = if (macOs) {
{ MacOsTitleBar() }
} else {
null
},
)
}
}
}
@@ -52,8 +83,47 @@ private fun ExternalInvitationController.openFile(file: File) {
}
private fun configureMacOsNativeAppearance() {
if (!System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) return
if (!DesktopAppearanceBridge.isMacOs()) return
// AWT reads this before creating the first native window. Runtime theme
// changes are handled in the JVM platform appearance hook.
System.setProperty("apple.awt.application.appearance", "system")
}
private val MacOsTitleBarHeight = 28.dp
private val MacOsTrafficLightsWidth = 76.dp
private val MacOsContentCornerRadius = 20.dp
@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun WindowScope.MacOsTitleBar() {
val colors = LocalVniDropColors.current
Box(
modifier = Modifier
.fillMaxWidth()
.height(MacOsTitleBarHeight)
.background(colors.backgroundSurface200),
) {
WindowDraggableArea(
modifier = Modifier
.fillMaxSize()
.padding(start = MacOsTrafficLightsWidth)
.onPointerEvent(PointerEventType.Press) { event ->
if (event.awtEventOrNull?.clickCount == 2) {
DesktopAppearanceBridge.toggleMaximized(window)
}
},
) {
Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) {
BasicText(
text = "vnidrop",
modifier = Modifier.align(Alignment.Center),
style = TextStyle(
color = colors.foregroundDefault,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
),
)
}
}
}
}

View File

@@ -1,5 +1,7 @@
package com.vnidrop.app
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -10,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -36,6 +39,7 @@ import com.vnidrop.app.ui.shell.ScreenScrollContainer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.windowClassFor
import com.vnidrop.app.ui.theme.LocalVniDropColors
import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter
@@ -43,7 +47,12 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
@Composable
fun App(dependencies: AppDependencies) {
fun App(
dependencies: AppDependencies,
windowChromeTopInset: Dp = 0.dp,
windowContentTopStartRadius: Dp = 0.dp,
windowChrome: (@Composable () -> Unit)? = null,
) {
val graphHolder = viewModel { AppGraphViewModel(dependencies) }
val graph = graphHolder.graph
@@ -122,7 +131,16 @@ fun App(dependencies: AppDependencies) {
val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
PlatformSystemAppearance(darkTheme)
VniDropTheme(isDarkTheme = darkTheme) {
BoxWithConstraints {
Box(
modifier = Modifier
.fillMaxSize()
.background(LocalVniDropColors.current.backgroundSurface200),
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(top = windowChromeTopInset),
) {
val windowClass = windowClassFor(maxWidth.value)
val showSendAction = appState.destination == AppDestination.Send &&
windowClass == WindowClass.Phone &&
@@ -138,6 +156,7 @@ fun App(dependencies: AppDependencies) {
modifier = Modifier.fillMaxSize(),
selectedDestination = appState.destination,
windowClass = windowClass,
mainContentTopStartRadius = windowContentTopStartRadius,
onDestinationSelected = appViewModel::selectDestination,
overlay = {
VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter))
@@ -172,5 +191,7 @@ fun App(dependencies: AppDependencies) {
onRefuse = graph.approvalCoordinator::refuse,
)
}
windowChrome?.invoke()
}
}
}

View File

@@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
@@ -36,6 +37,7 @@ import org.jetbrains.compose.resources.stringResource
fun AppSidebarNavigation(
selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
dividerTopInset: Dp = 0.dp,
modifier: Modifier = Modifier,
) {
val colors = LocalVniDropColors.current
@@ -62,7 +64,8 @@ fun AppSidebarNavigation(
}
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.align(Alignment.TopEnd)
.padding(top = dividerTopInset)
.width(1.dp)
.fillMaxHeight()
.background(colors.borderDefault),

View File

@@ -10,9 +10,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.navigation.AppBottomNavigation
import com.vnidrop.app.ui.navigation.AppDestination
@@ -26,6 +29,7 @@ fun AppShell(
modifier: Modifier = Modifier,
selectedDestination: AppDestination,
windowClass: WindowClass,
mainContentTopStartRadius: Dp = 0.dp,
onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit = {},
floatingAction: (@Composable BoxScope.() -> Unit)? = null,
@@ -49,6 +53,7 @@ fun AppShell(
} else {
WideShell(
selectedDestination = selectedDestination,
mainContentTopStartRadius = mainContentTopStartRadius,
onDestinationSelected = onDestinationSelected,
overlay = overlay,
floatingAction = floatingAction,
@@ -61,17 +66,38 @@ fun AppShell(
@Composable
private fun WideShell(
selectedDestination: AppDestination,
mainContentTopStartRadius: Dp,
onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit,
floatingAction: (@Composable BoxScope.() -> Unit)?,
content: @Composable () -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
val colors = LocalVniDropColors.current
val roundedContent = mainContentTopStartRadius > 0.dp
Row(
modifier = Modifier
.fillMaxSize()
.then(if (roundedContent) Modifier.background(colors.backgroundSurface200) else Modifier),
) {
AppSidebarNavigation(
selected = selectedDestination,
dividerTopInset = mainContentTopStartRadius,
onDestinationSelected = onDestinationSelected,
)
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.then(
if (roundedContent) {
Modifier
.clip(RoundedCornerShape(topStart = mainContentTopStartRadius))
.background(colors.backgroundDashCanvas)
} else {
Modifier
},
),
) {
content()
floatingAction?.invoke(this)
Box(Modifier.fillMaxSize().padding(bottom = if (floatingAction == null) 0.dp else 72.dp)) { overlay() }

View File

@@ -4,8 +4,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import java.awt.Color
import java.awt.EventQueue
import java.awt.Frame
import java.awt.Window
import javax.swing.JFrame
import javax.swing.JRootPane
@Composable
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
@@ -16,10 +18,12 @@ actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
internal object DesktopSystemAppearance {
private const val MAC_APPEARANCE_PROPERTY = "apple.awt.application.appearance"
private const val FULL_WINDOW_CONTENT_PROPERTY = "apple.awt.fullWindowContent"
private const val TRANSPARENT_TITLE_BAR_PROPERTY = "apple.awt.transparentTitleBar"
private const val WINDOW_TITLE_VISIBLE_PROPERTY = "apple.awt.windowTitleVisible"
fun apply(isDarkTheme: Boolean) {
if (!isMacOs()) return
if (!DesktopAppearanceBridge.isMacOs()) return
System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme))
EventQueue.invokeLater {
DesktopAppearanceBridge.applyNativeAppearance?.invoke(isDarkTheme)
@@ -33,27 +37,50 @@ internal object DesktopSystemAppearance {
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
internal fun usesTransparentTitlebar(): Boolean = true
internal fun usesFullWindowContent(): Boolean = true
internal fun showsNativeWindowTitle(): Boolean = false
internal fun titlebarBackground(isDarkTheme: Boolean): Color =
if (isDarkTheme) Color(0x12, 0x12, 0x12) else Color(0xF8, 0xF8, 0xF8)
if (isDarkTheme) Color(0x21, 0x21, 0x21) else Color(0xF3, 0xF3, 0xF3)
private fun applyWindowChrome(window: Window, isDarkTheme: Boolean) {
val background = titlebarBackground(isDarkTheme)
window.background = background
(window as? JFrame)?.rootPane?.let { rootPane ->
// The titlebar stays native, but AppKit receives the resolved app
// appearance so title text and controls switch contrast at runtime.
(window as? JFrame)?.rootPane?.let { rootPane -> applyRootPaneChrome(rootPane, background) }
}
internal fun applyRootPaneChrome(rootPane: JRootPane, background: Color) {
// Extending the Compose surface beneath the native titlebar lets the
// window chrome and sidebar share one uninterrupted background.
rootPane.putClientProperty(FULL_WINDOW_CONTENT_PROPERTY, usesFullWindowContent())
rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar())
rootPane.putClientProperty(WINDOW_TITLE_VISIBLE_PROPERTY, showsNativeWindowTitle())
rootPane.background = background
rootPane.contentPane.background = background
}
}
private fun isMacOs(): Boolean =
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
}
object DesktopAppearanceBridge {
@Volatile
var applyNativeAppearance: ((Boolean) -> Unit)? = null
fun isMacOs(): Boolean = isMacOs(System.getProperty("os.name"))
fun toggleMaximized(window: Window) {
if (!isMacOs()) return
val frame = window as? Frame ?: return
EventQueue.invokeLater {
frame.extendedState = toggledWindowState(frame.extendedState)
}
}
internal fun isMacOs(osName: String): Boolean =
osName.startsWith("Mac", ignoreCase = true)
internal fun toggledWindowState(currentState: Int): Int =
if (currentState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH) {
Frame.NORMAL
} else {
Frame.MAXIMIZED_BOTH
}
}

View File

@@ -1,9 +1,27 @@
package com.vnidrop.app.platform
import java.awt.Color
import java.awt.Frame
import javax.swing.JRootPane
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DesktopSystemAppearanceTest {
@Test
fun customWindowChromeIsMacOsOnly() {
assertTrue(DesktopAppearanceBridge.isMacOs("Mac OS X"))
assertFalse(DesktopAppearanceBridge.isMacOs("Windows 11"))
assertFalse(DesktopAppearanceBridge.isMacOs("Linux"))
}
@Test
fun titlebarDoubleClickTogglesMaximizedWindowState() {
assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))
assertEquals(Frame.NORMAL, DesktopAppearanceBridge.toggledWindowState(Frame.MAXIMIZED_BOTH))
}
@Test
fun macOsAppearanceNamesMatchResolvedTheme() {
assertEquals("NSAppearanceNameDarkAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = true))
@@ -11,9 +29,9 @@ class DesktopSystemAppearanceTest {
}
@Test
fun titlebarBackgroundUsesLightAndDarkSurfaces() {
assertEquals(0x121212, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF)
assertEquals(0xF8F8F8, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF)
fun titlebarBackgroundMatchesSidebarSurface() {
assertEquals(0x212121, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF)
assertEquals(0xF3F3F3, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF)
}
@Test
@@ -21,6 +39,20 @@ class DesktopSystemAppearanceTest {
assertEquals(true, DesktopSystemAppearance.usesTransparentTitlebar())
}
@Test
fun composeContentExtendsUnderMacOsTitlebar() {
val rootPane = JRootPane()
val background = Color(0x21, 0x21, 0x21)
DesktopSystemAppearance.applyRootPaneChrome(rootPane, background)
assertEquals(true, rootPane.getClientProperty("apple.awt.fullWindowContent"))
assertEquals(true, rootPane.getClientProperty("apple.awt.transparentTitleBar"))
assertEquals(false, rootPane.getClientProperty("apple.awt.windowTitleVisible"))
assertEquals(background, rootPane.background)
assertEquals(background, rootPane.contentPane.background)
}
@Test
fun runtimeAppearanceCallIsFailSoft() {
DesktopSystemAppearance.apply(isDarkTheme = true)