diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index 0921de6..fa8c564 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -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) { val externalInvitations = ExternalInvitationController() + val macOs = DesktopAppearanceBridge.isMacOs() configureMacOsNativeAppearance() configureInvitationOpenHandler(externalInvitations) args.asSequence() @@ -20,7 +42,7 @@ fun main(args: Array) { .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) { 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, + ), + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 2db6bf5..f1c9198 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -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,55 +131,67 @@ fun App(dependencies: AppDependencies) { val darkTheme = rememberResolvedDarkTheme(appState.themeMode) PlatformSystemAppearance(darkTheme) VniDropTheme(isDarkTheme = darkTheme) { - BoxWithConstraints { - val windowClass = windowClassFor(maxWidth.value) - val showSendAction = appState.destination == AppDestination.Send && - windowClass == WindowClass.Phone && - sendState.selectedTransferId?.let { selectedId -> - sendCoreState.transfers.any { it.transferId == selectedId } - } != true && - sendCoreState.transfers.any { it.direction == TransferDirection.Send } - val showReceiveAction = appState.destination == AppDestination.Receive && - windowClass == WindowClass.Phone && - !receiveState.isAcquisitionOpen && - receiveCoreState.transfers.any { it.direction == TransferDirection.Receive } - AppShell( - modifier = Modifier.fillMaxSize(), - selectedDestination = appState.destination, - windowClass = windowClass, - onDestinationSelected = appViewModel::selectDestination, - overlay = { - VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter)) - }, - floatingAction = if (showSendAction) { - { - SendFloatingAction( - onClick = sendViewModel::openComposer, - modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), - ) - } - } else if (showReceiveAction) { - { - ReceiveFloatingAction( - onClick = receiveViewModel::openAcquisition, - modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), - ) - } - } else { - null - }, + Box( + modifier = Modifier + .fillMaxSize() + .background(LocalVniDropColors.current.backgroundSurface200), + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .padding(top = windowChromeTopInset), ) { - when (appState.destination) { - AppDestination.Send -> SendRoute(sendViewModel, windowClass) - AppDestination.Receive -> ReceiveRoute(receiveViewModel, windowClass) - AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) } + val windowClass = windowClassFor(maxWidth.value) + val showSendAction = appState.destination == AppDestination.Send && + windowClass == WindowClass.Phone && + sendState.selectedTransferId?.let { selectedId -> + sendCoreState.transfers.any { it.transferId == selectedId } + } != true && + sendCoreState.transfers.any { it.direction == TransferDirection.Send } + val showReceiveAction = appState.destination == AppDestination.Receive && + windowClass == WindowClass.Phone && + !receiveState.isAcquisitionOpen && + receiveCoreState.transfers.any { it.direction == TransferDirection.Receive } + AppShell( + modifier = Modifier.fillMaxSize(), + selectedDestination = appState.destination, + windowClass = windowClass, + mainContentTopStartRadius = windowContentTopStartRadius, + onDestinationSelected = appViewModel::selectDestination, + overlay = { + VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter)) + }, + floatingAction = if (showSendAction) { + { + SendFloatingAction( + onClick = sendViewModel::openComposer, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) + } + } else if (showReceiveAction) { + { + ReceiveFloatingAction( + onClick = receiveViewModel::openAcquisition, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) + } + } else { + null + }, + ) { + when (appState.destination) { + AppDestination.Send -> SendRoute(sendViewModel, windowClass) + AppDestination.Receive -> ReceiveRoute(receiveViewModel, windowClass) + AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) } + } } + ApprovalModalHost( + state = approvalState, + onAccept = graph.approvalCoordinator::accept, + onRefuse = graph.approvalCoordinator::refuse, + ) } - ApprovalModalHost( - state = approvalState, - onAccept = graph.approvalCoordinator::accept, - onRefuse = graph.approvalCoordinator::refuse, - ) + windowChrome?.invoke() } } } 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 e18df13..8815212 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 @@ -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), diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt index 8cd455e..d149b62 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/shell/AppShell.kt @@ -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() } 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 3426d6c..23bceb5 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 @@ -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. - rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar()) - rootPane.background = background - rootPane.contentPane.background = background - } + (window as? JFrame)?.rootPane?.let { rootPane -> applyRootPaneChrome(rootPane, background) } } - private fun isMacOs(): Boolean = - System.getProperty("os.name").startsWith("Mac", ignoreCase = true) + 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 + } } 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 + } } 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 c0d7e2b..df32775 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt @@ -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)