From be7a61e948a58c9521128845b35003cd450273c6 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 22 Jul 2026 15:49:41 +0200 Subject: [PATCH] feat(desktop): integrate native Windows titlebar --- .../com/vnidrop/app/WindowsWindowChrome.kt | 241 ++++++++++++++++++ .../src/main/kotlin/com/vnidrop/app/main.kt | 55 ++-- .../platform/PlatformSystemAppearance.jvm.kt | 4 + .../platform/DesktopAppearanceBridgeTest.kt | 8 + 4 files changed, 288 insertions(+), 20 deletions(-) create mode 100644 desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowChrome.kt diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowChrome.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowChrome.kt new file mode 100644 index 0000000..07a5af4 --- /dev/null +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/WindowsWindowChrome.kt @@ -0,0 +1,241 @@ +package com.vnidrop.app + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +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.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathBuilder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowScope +import androidx.compose.ui.window.WindowState +import com.vnidrop.app.ui.theme.LocalVniDropColors + +internal class WindowsChromeConfiguration( + val topInset: Dp = 0.dp, + val contentTopStartRadius: Dp = 0.dp, + val useNativeBackdrop: Boolean = false, + val onDarkThemeChanged: (Boolean) -> Unit = {}, + val chrome: (@Composable () -> Unit)? = null, +) + +@Composable +internal fun WindowScope.WindowsWindowFrame( + windowState: WindowState, + content: @Composable (WindowsChromeConfiguration) -> Unit, +) { + val controller = remember(window) { WindowsNativeWindowController.install(window) } + DisposableEffect(controller) { + onDispose { controller?.close() } + } + if (controller == null) { + content(WindowsChromeConfiguration()) + return + } + if (!controller.usesCustomChrome) { + content(WindowsChromeConfiguration()) + return + } + + val density = LocalDensity.current + val insets = controller.frameInsets + Box( + modifier = Modifier + .fillMaxSize() + .padding( + start = with(density) { insets.left.toDp() }, + top = with(density) { insets.top.toDp() }, + end = with(density) { insets.right.toDp() }, + bottom = with(density) { insets.bottom.toDp() }, + ), + ) { + content( + WindowsChromeConfiguration( + topInset = WindowsTitleBarHeight, + contentTopStartRadius = DesktopContentCornerRadius, + useNativeBackdrop = controller.usesNativeBackdrop, + onDarkThemeChanged = controller::setDarkTheme, + chrome = { + WindowsTitleBar( + controller = controller, + isMaximized = windowState.placement == WindowPlacement.Maximized, + ) + }, + ), + ) + } +} + +@Composable +private fun WindowsTitleBar( + controller: WindowsNativeWindowController, + isMaximized: Boolean, +) { + val colors = LocalVniDropColors.current + val foreground = colors.foregroundDefault.copy(alpha = if (controller.isWindowActive) 1f else 0.55f) + Box( + modifier = Modifier + .fillMaxWidth() + .height(WindowsTitleBarHeight) + .background(if (controller.usesNativeBackdrop) Color.Transparent else colors.backgroundSurface200) + .onGloballyPositioned { controller.updateCaptionBounds(it.boundsInWindow()) }, + ) { + BasicText( + text = "VniDrop", + modifier = Modifier.align(Alignment.Center), + style = TextStyle( + color = foreground, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ), + ) + if (!controller.usesSystemCaptionButtons) { + Row(modifier = Modifier.align(Alignment.TopEnd)) { + WindowsCaptionButton( + button = WindowsCaptionButton.Minimize, + icon = WindowsMinimizeIcon, + contentDescription = "Minimize window", + controller = controller, + onClick = controller::minimize, + ) + WindowsCaptionButton( + button = WindowsCaptionButton.Maximize, + icon = if (isMaximized) WindowsRestoreIcon else WindowsMaximizeIcon, + contentDescription = if (isMaximized) "Restore window" else "Maximize window", + controller = controller, + onClick = controller::toggleMaximize, + ) + WindowsCaptionButton( + button = WindowsCaptionButton.Close, + icon = WindowsCloseIcon, + contentDescription = "Close window", + controller = controller, + onClick = { controller.postCloseRequest() }, + ) + } + } + } +} + +@Composable +private fun WindowsCaptionButton( + button: WindowsCaptionButton, + icon: ImageVector, + contentDescription: String, + controller: WindowsNativeWindowController, + onClick: () -> Unit, +) { + val colors = LocalVniDropColors.current + val hovered = controller.hoveredCaptionButton == button + val pressed = controller.pressedCaptionButton == button + val destructive = button == WindowsCaptionButton.Close && (hovered || pressed) + val background = when { + destructive -> colors.destructiveDefault + pressed -> colors.backgroundOverlayHover + hovered -> colors.backgroundOverlayHover + else -> Color.Transparent + } + val foreground = when { + destructive -> Color.White + controller.isWindowActive -> colors.foregroundDefault + else -> colors.foregroundDefault.copy(alpha = 0.55f) + } + Box( + modifier = Modifier + .size(width = WindowsCaptionButtonWidth, height = WindowsCaptionButtonHeight) + .background(background) + .clickable(role = Role.Button, onClick = onClick) + .onGloballyPositioned { coordinates -> + when (button) { + WindowsCaptionButton.Minimize -> controller.updateMinimizeButtonBounds(coordinates.boundsInWindow()) + WindowsCaptionButton.Maximize -> controller.updateMaximizeButtonBounds(coordinates.boundsInWindow()) + WindowsCaptionButton.Close -> controller.updateCloseButtonBounds(coordinates.boundsInWindow()) + } + }, + contentAlignment = Alignment.Center, + ) { + Image( + painter = rememberVectorPainter(icon), + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(foreground), + modifier = Modifier.size(24.dp), + ) + } +} + +internal val WindowsTitleBarHeight = WindowsTitleBarHeightDip.dp +private val WindowsCaptionButtonWidth = 48.dp +private val WindowsCaptionButtonHeight = 48.dp + +private val WindowsMinimizeIcon = windowsCaptionIcon("Minimize") { + moveTo(5f, 12f) + lineTo(19f, 12f) +} + +private val WindowsMaximizeIcon = windowsCaptionIcon("Maximize") { + moveTo(6.5f, 6.5f) + lineTo(17.5f, 6.5f) + lineTo(17.5f, 17.5f) + lineTo(6.5f, 17.5f) + close() +} + +private val WindowsRestoreIcon = windowsCaptionIcon("Restore") { + moveTo(8.5f, 8.5f) + lineTo(18f, 8.5f) + lineTo(18f, 18f) + lineTo(8.5f, 18f) + close() + moveTo(6f, 15.5f) + lineTo(6f, 6f) + lineTo(15.5f, 6f) +} + +private val WindowsCloseIcon = windowsCaptionIcon("Close") { + moveTo(6.5f, 6.5f) + lineTo(17.5f, 17.5f) + moveTo(17.5f, 6.5f) + lineTo(6.5f, 17.5f) +} + +private fun windowsCaptionIcon(name: String, block: PathBuilder.() -> Unit): ImageVector = + ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply { + path( + fill = SolidColor(Color.Transparent), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.4f, + strokeLineCap = StrokeCap.Square, + strokeLineJoin = StrokeJoin.Miter, + pathFillType = PathFillType.NonZero, + pathBuilder = block, + ) + }.build() diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index eb9d3b5..965a932 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -61,6 +61,7 @@ fun main(args: Array) { FileKit.init(appId = "vnidrop") val externalInvitations = ExternalInvitationController() val linux = DesktopAppearanceBridge.isLinux() + val windows = DesktopAppearanceBridge.isWindows() configureInvitationOpenHandler(externalInvitations) args.asSequence() .map(::File) @@ -75,25 +76,39 @@ fun main(args: Array) { // Compose keeps edge resizers active for this client-decorated Linux window. undecorated = linux, ) { - App( - dependencies = rememberJvmAppDependencies(externalInvitations), - windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp, - windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp, - windowChrome = if (linux) { - { - LinuxTitleBar( - isMaximized = windowState.placement == WindowPlacement.Maximized, - onMinimize = { windowState.isMinimized = true }, - onToggleMaximize = { - windowState.placement = toggledWindowPlacement(windowState.placement) - }, - onClose = ::exitApplication, - ) - } - } else { - null - }, - ) + val dependencies = rememberJvmAppDependencies(externalInvitations) + if (windows) { + WindowsWindowFrame(windowState) { chrome -> + App( + dependencies = dependencies, + windowChromeTopInset = chrome.topInset, + windowContentTopStartRadius = chrome.contentTopStartRadius, + useNativeWindowBackdrop = chrome.useNativeBackdrop, + onResolvedDarkThemeChanged = chrome.onDarkThemeChanged, + windowChrome = chrome.chrome, + ) + } + } else { + App( + dependencies = dependencies, + windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp, + windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp, + windowChrome = if (linux) { + { + LinuxTitleBar( + isMaximized = windowState.placement == WindowPlacement.Maximized, + onMinimize = { windowState.isMinimized = true }, + onToggleMaximize = { + windowState.placement = toggledWindowPlacement(windowState.placement) + }, + onClose = ::exitApplication, + ) + } + } else { + null + }, + ) + } } } } @@ -120,7 +135,7 @@ private val LinuxTitleBarHeight = 48.dp private val LinuxWindowControlHitTargetSize = 34.dp private val LinuxWindowControlVisualSize = 28.dp private val LinuxWindowControlsWidth = 120.dp -private val DesktopContentCornerRadius = 20.dp +internal val DesktopContentCornerRadius = 20.dp @Composable @OptIn(ExperimentalComposeUiApi::class) 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 30abff5..6b9e849 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 @@ -8,10 +8,14 @@ actual fun PlatformSystemAppearance(isDarkTheme: Boolean) = Unit object DesktopAppearanceBridge { fun isLinux(): Boolean = isLinux(System.getProperty("os.name")) + fun isWindows(): Boolean = isWindows(System.getProperty("os.name")) internal fun isLinux(osName: String): Boolean = osName.startsWith("Linux", ignoreCase = true) + internal fun isWindows(osName: String): Boolean = + osName.startsWith("Windows", ignoreCase = true) + internal fun toggledWindowState(currentState: Int): Int = if (currentState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH) { Frame.NORMAL diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt index 8b2932c..608e67a 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt @@ -14,6 +14,14 @@ class DesktopAppearanceBridgeTest { assertFalse(DesktopAppearanceBridge.isLinux("Windows 11")) } + @Test + fun nativeWindowsChromeIsLimitedToWindows() { + assertFalse(DesktopAppearanceBridge.isWindows("Mac OS X")) + assertFalse(DesktopAppearanceBridge.isWindows("Linux")) + assertTrue(DesktopAppearanceBridge.isWindows("Windows 10")) + assertTrue(DesktopAppearanceBridge.isWindows("Windows 11")) + } + @Test fun titlebarDoubleClickTogglesMaximizedWindowState() { assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))