From 98375f479a06c6be04b3c76c81a78e609b7d3227 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Mon, 13 Jul 2026 16:43:13 +0200 Subject: [PATCH] feat(desktop): unify Linux window chrome --- .../src/main/kotlin/com/vnidrop/app/main.kt | 211 +++++++++++++++++- .../vnidrop/app/DesktopWindowChromeTest.kt | 13 ++ .../com/vnidrop/app/MacOsShareSheetTest.kt | 3 + .../platform/PlatformSystemAppearance.jvm.kt | 4 + .../platform/DesktopSystemAppearanceTest.kt | 7 +- 5 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index fa8c564..585b8d0 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -1,27 +1,52 @@ package com.vnidrop.app +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight 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.layout.width import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember 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.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.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent +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.sp import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowScope import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState import com.vnidrop.app.platform.DesktopAppearanceBridge import com.vnidrop.app.feature.send.DesktopShareBridge import com.vnidrop.app.feature.receive.ExternalInvitationController @@ -35,6 +60,8 @@ import java.io.File fun main(args: Array) { val externalInvitations = ExternalInvitationController() val macOs = DesktopAppearanceBridge.isMacOs() + val linux = DesktopAppearanceBridge.isLinux() + val customWindowChrome = macOs || linux configureMacOsNativeAppearance() configureInvitationOpenHandler(externalInvitations) args.asSequence() @@ -46,18 +73,39 @@ fun main(args: Array) { DesktopShareBridge.shareFile = MacOsShareSheet::share } application { + val windowState = rememberWindowState() Window( onCloseRequest = ::exitApplication, + state = windowState, title = "vnidrop", + // Compose keeps edge resizers active for this client-decorated Linux window. + undecorated = linux, ) { App( dependencies = rememberJvmAppDependencies(externalInvitations), - windowChromeTopInset = if (macOs) MacOsTitleBarHeight else 0.dp, - windowContentTopStartRadius = if (macOs) MacOsContentCornerRadius else 0.dp, - windowChrome = if (macOs) { - { MacOsTitleBar() } - } else { - null + windowChromeTopInset = when { + macOs -> MacOsTitleBarHeight + linux -> LinuxTitleBarHeight + else -> 0.dp + }, + windowContentTopStartRadius = if (customWindowChrome) DesktopContentCornerRadius else 0.dp, + windowChrome = when { + macOs -> { + { MacOsTitleBar() } + } + linux -> { + { + LinuxTitleBar( + isMaximized = windowState.placement == WindowPlacement.Maximized, + onMinimize = { windowState.isMinimized = true }, + onToggleMaximize = { + windowState.placement = toggledWindowPlacement(windowState.placement) + }, + onClose = ::exitApplication, + ) + } + } + else -> null }, ) } @@ -91,7 +139,10 @@ private fun configureMacOsNativeAppearance() { private val MacOsTitleBarHeight = 28.dp private val MacOsTrafficLightsWidth = 76.dp -private val MacOsContentCornerRadius = 20.dp +private val LinuxTitleBarHeight = 40.dp +private val LinuxWindowControlWidth = 46.dp +private val LinuxWindowControlsWidth = 138.dp +private val DesktopContentCornerRadius = 20.dp @Composable @OptIn(ExperimentalComposeUiApi::class) @@ -127,3 +178,149 @@ private fun WindowScope.MacOsTitleBar() { } } } + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +private fun WindowScope.LinuxTitleBar( + isMaximized: Boolean, + onMinimize: () -> Unit, + onToggleMaximize: () -> Unit, + onClose: () -> Unit, +) { + val colors = LocalVniDropColors.current + Box( + modifier = Modifier + .fillMaxWidth() + .height(LinuxTitleBarHeight) + .background(colors.backgroundSurface200), + ) { + BasicText( + text = "vnidrop", + modifier = Modifier.align(Alignment.Center), + style = TextStyle( + color = colors.foregroundDefault, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ), + ) + WindowDraggableArea( + modifier = Modifier + .fillMaxSize() + .padding(end = LinuxWindowControlsWidth) + .onPointerEvent(PointerEventType.Press) { event -> + if (event.awtEventOrNull?.clickCount == 2) onToggleMaximize() + }, + ) { + Box(Modifier.fillMaxSize()) + } + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(), + ) { + LinuxWindowControlButton( + icon = LinuxMinimizeIcon, + contentDescription = "Minimize window", + onClick = onMinimize, + ) + LinuxWindowControlButton( + icon = if (isMaximized) LinuxRestoreIcon else LinuxMaximizeIcon, + contentDescription = if (isMaximized) "Restore window" else "Maximize window", + onClick = onToggleMaximize, + ) + LinuxWindowControlButton( + icon = LinuxCloseIcon, + contentDescription = "Close window", + isClose = true, + onClick = onClose, + ) + } + } +} + +@Composable +private fun LinuxWindowControlButton( + icon: ImageVector, + contentDescription: String, + isClose: Boolean = false, + onClick: () -> Unit, +) { + val colors = LocalVniDropColors.current + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val pressed by interactionSource.collectIsPressedAsState() + val active = hovered || pressed + val background = when { + isClose && active -> colors.destructiveDefault + active -> colors.backgroundOverlayHover + else -> Color.Transparent + } + Box( + modifier = Modifier + .width(LinuxWindowControlWidth) + .fillMaxHeight() + .background(background) + .hoverable(interactionSource) + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Image( + painter = rememberVectorPainter(icon), + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(if (isClose && active) Color.White else colors.foregroundLight), + modifier = Modifier.size(15.dp), + ) + } +} + +internal fun toggledWindowPlacement(current: WindowPlacement): WindowPlacement = + if (current == WindowPlacement.Maximized) WindowPlacement.Floating else WindowPlacement.Maximized + +private val LinuxMinimizeIcon = windowControlIcon("Minimize") { + moveTo(6f, 12f) + lineTo(18f, 12f) +} + +private val LinuxMaximizeIcon = windowControlIcon("Maximize") { + moveTo(6.5f, 6.5f) + lineTo(17.5f, 6.5f) + lineTo(17.5f, 17.5f) + lineTo(6.5f, 17.5f) + close() +} + +private val LinuxRestoreIcon = windowControlIcon("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 LinuxCloseIcon = windowControlIcon("Close") { + moveTo(7f, 7f) + lineTo(17f, 17f) + moveTo(17f, 7f) + lineTo(7f, 17f) +} + +private fun windowControlIcon(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.6f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + pathFillType = PathFillType.NonZero, + pathBuilder = block, + ) + }.build() diff --git a/desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt b/desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt new file mode 100644 index 0000000..09a5a1f --- /dev/null +++ b/desktopApp/src/test/kotlin/com/vnidrop/app/DesktopWindowChromeTest.kt @@ -0,0 +1,13 @@ +package com.vnidrop.app + +import androidx.compose.ui.window.WindowPlacement +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopWindowChromeTest { + @Test + fun maximizeControlTogglesBetweenFloatingAndMaximized() { + assertEquals(WindowPlacement.Maximized, toggledWindowPlacement(WindowPlacement.Floating)) + assertEquals(WindowPlacement.Floating, toggledWindowPlacement(WindowPlacement.Maximized)) + } +} diff --git a/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt b/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt index 5ff6e6c..65b0aaf 100644 --- a/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt +++ b/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt @@ -1,8 +1,10 @@ package com.vnidrop.app +import com.vnidrop.app.platform.DesktopAppearanceBridge import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import org.junit.Assume.assumeTrue class MacOsShareSheetTest { @Test @@ -12,6 +14,7 @@ class MacOsShareSheetTest { @Test fun nativeMainDispatchQueueCanBeResolved() { + assumeTrue(DesktopAppearanceBridge.isMacOs()) assertTrue(MacOsShareSheet.hasNativeMainQueue()) } } 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 23bceb5..d40dfec 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 @@ -65,6 +65,7 @@ object DesktopAppearanceBridge { var applyNativeAppearance: ((Boolean) -> Unit)? = null fun isMacOs(): Boolean = isMacOs(System.getProperty("os.name")) + fun isLinux(): Boolean = isLinux(System.getProperty("os.name")) fun toggleMaximized(window: Window) { if (!isMacOs()) return @@ -77,6 +78,9 @@ object DesktopAppearanceBridge { internal fun isMacOs(osName: String): Boolean = osName.startsWith("Mac", ignoreCase = true) + internal fun isLinux(osName: String): Boolean = + osName.startsWith("Linux", 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/DesktopSystemAppearanceTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt index df32775..4c7bd2f 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt @@ -10,10 +10,13 @@ import kotlin.test.assertTrue class DesktopSystemAppearanceTest { @Test - fun customWindowChromeIsMacOsOnly() { + fun customWindowChromeSupportsMacOsAndLinux() { assertTrue(DesktopAppearanceBridge.isMacOs("Mac OS X")) - assertFalse(DesktopAppearanceBridge.isMacOs("Windows 11")) + assertFalse(DesktopAppearanceBridge.isLinux("Mac OS X")) + assertTrue(DesktopAppearanceBridge.isLinux("Linux")) assertFalse(DesktopAppearanceBridge.isMacOs("Linux")) + assertFalse(DesktopAppearanceBridge.isMacOs("Windows 11")) + assertFalse(DesktopAppearanceBridge.isLinux("Windows 11")) } @Test