feat(desktop): unify Linux window chrome

This commit is contained in:
2026-07-13 16:43:13 +02:00
parent 76dbff5a7c
commit 98375f479a
5 changed files with 229 additions and 9 deletions

View File

@@ -1,27 +1,52 @@
package com.vnidrop.app package com.vnidrop.app
import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.text.BasicText
import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.foundation.window.WindowDraggableArea
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.awtEventOrNull 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.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight 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.unit.sp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowScope import androidx.compose.ui.window.WindowScope
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.vnidrop.app.platform.DesktopAppearanceBridge import com.vnidrop.app.platform.DesktopAppearanceBridge
import com.vnidrop.app.feature.send.DesktopShareBridge import com.vnidrop.app.feature.send.DesktopShareBridge
import com.vnidrop.app.feature.receive.ExternalInvitationController import com.vnidrop.app.feature.receive.ExternalInvitationController
@@ -35,6 +60,8 @@ import java.io.File
fun main(args: Array<String>) { fun main(args: Array<String>) {
val externalInvitations = ExternalInvitationController() val externalInvitations = ExternalInvitationController()
val macOs = DesktopAppearanceBridge.isMacOs() val macOs = DesktopAppearanceBridge.isMacOs()
val linux = DesktopAppearanceBridge.isLinux()
val customWindowChrome = macOs || linux
configureMacOsNativeAppearance() configureMacOsNativeAppearance()
configureInvitationOpenHandler(externalInvitations) configureInvitationOpenHandler(externalInvitations)
args.asSequence() args.asSequence()
@@ -46,18 +73,39 @@ fun main(args: Array<String>) {
DesktopShareBridge.shareFile = MacOsShareSheet::share DesktopShareBridge.shareFile = MacOsShareSheet::share
} }
application { application {
val windowState = rememberWindowState()
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
state = windowState,
title = "vnidrop", title = "vnidrop",
// Compose keeps edge resizers active for this client-decorated Linux window.
undecorated = linux,
) { ) {
App( App(
dependencies = rememberJvmAppDependencies(externalInvitations), dependencies = rememberJvmAppDependencies(externalInvitations),
windowChromeTopInset = if (macOs) MacOsTitleBarHeight else 0.dp, windowChromeTopInset = when {
windowContentTopStartRadius = if (macOs) MacOsContentCornerRadius else 0.dp, macOs -> MacOsTitleBarHeight
windowChrome = if (macOs) { linux -> LinuxTitleBarHeight
else -> 0.dp
},
windowContentTopStartRadius = if (customWindowChrome) DesktopContentCornerRadius else 0.dp,
windowChrome = when {
macOs -> {
{ MacOsTitleBar() } { MacOsTitleBar() }
} else { }
null 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 MacOsTitleBarHeight = 28.dp
private val MacOsTrafficLightsWidth = 76.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 @Composable
@OptIn(ExperimentalComposeUiApi::class) @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()

View File

@@ -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))
}
}

View File

@@ -1,8 +1,10 @@
package com.vnidrop.app package com.vnidrop.app
import com.vnidrop.app.platform.DesktopAppearanceBridge
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import org.junit.Assume.assumeTrue
class MacOsShareSheetTest { class MacOsShareSheetTest {
@Test @Test
@@ -12,6 +14,7 @@ class MacOsShareSheetTest {
@Test @Test
fun nativeMainDispatchQueueCanBeResolved() { fun nativeMainDispatchQueueCanBeResolved() {
assumeTrue(DesktopAppearanceBridge.isMacOs())
assertTrue(MacOsShareSheet.hasNativeMainQueue()) assertTrue(MacOsShareSheet.hasNativeMainQueue())
} }
} }

View File

@@ -65,6 +65,7 @@ object DesktopAppearanceBridge {
var applyNativeAppearance: ((Boolean) -> Unit)? = null var applyNativeAppearance: ((Boolean) -> Unit)? = null
fun isMacOs(): Boolean = isMacOs(System.getProperty("os.name")) fun isMacOs(): Boolean = isMacOs(System.getProperty("os.name"))
fun isLinux(): Boolean = isLinux(System.getProperty("os.name"))
fun toggleMaximized(window: Window) { fun toggleMaximized(window: Window) {
if (!isMacOs()) return if (!isMacOs()) return
@@ -77,6 +78,9 @@ object DesktopAppearanceBridge {
internal fun isMacOs(osName: String): Boolean = internal fun isMacOs(osName: String): Boolean =
osName.startsWith("Mac", ignoreCase = true) osName.startsWith("Mac", ignoreCase = true)
internal fun isLinux(osName: String): Boolean =
osName.startsWith("Linux", ignoreCase = true)
internal fun toggledWindowState(currentState: Int): Int = internal fun toggledWindowState(currentState: Int): Int =
if (currentState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH) { if (currentState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH) {
Frame.NORMAL Frame.NORMAL

View File

@@ -10,10 +10,13 @@ import kotlin.test.assertTrue
class DesktopSystemAppearanceTest { class DesktopSystemAppearanceTest {
@Test @Test
fun customWindowChromeIsMacOsOnly() { fun customWindowChromeSupportsMacOsAndLinux() {
assertTrue(DesktopAppearanceBridge.isMacOs("Mac OS X")) 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("Linux"))
assertFalse(DesktopAppearanceBridge.isMacOs("Windows 11"))
assertFalse(DesktopAppearanceBridge.isLinux("Windows 11"))
} }
@Test @Test