feat(desktop): integrate native Windows titlebar

This commit is contained in:
2026-07-22 15:49:41 +02:00
parent 0e97c014a1
commit be7a61e948
4 changed files with 288 additions and 20 deletions

View File

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

View File

@@ -61,6 +61,7 @@ fun main(args: Array<String>) {
FileKit.init(appId = "vnidrop") FileKit.init(appId = "vnidrop")
val externalInvitations = ExternalInvitationController() val externalInvitations = ExternalInvitationController()
val linux = DesktopAppearanceBridge.isLinux() val linux = DesktopAppearanceBridge.isLinux()
val windows = DesktopAppearanceBridge.isWindows()
configureInvitationOpenHandler(externalInvitations) configureInvitationOpenHandler(externalInvitations)
args.asSequence() args.asSequence()
.map(::File) .map(::File)
@@ -75,25 +76,39 @@ fun main(args: Array<String>) {
// Compose keeps edge resizers active for this client-decorated Linux window. // Compose keeps edge resizers active for this client-decorated Linux window.
undecorated = linux, undecorated = linux,
) { ) {
App( val dependencies = rememberJvmAppDependencies(externalInvitations)
dependencies = rememberJvmAppDependencies(externalInvitations), if (windows) {
windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp, WindowsWindowFrame(windowState) { chrome ->
windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp, App(
windowChrome = if (linux) { dependencies = dependencies,
{ windowChromeTopInset = chrome.topInset,
LinuxTitleBar( windowContentTopStartRadius = chrome.contentTopStartRadius,
isMaximized = windowState.placement == WindowPlacement.Maximized, useNativeWindowBackdrop = chrome.useNativeBackdrop,
onMinimize = { windowState.isMinimized = true }, onResolvedDarkThemeChanged = chrome.onDarkThemeChanged,
onToggleMaximize = { windowChrome = chrome.chrome,
windowState.placement = toggledWindowPlacement(windowState.placement) )
}, }
onClose = ::exitApplication, } else {
) App(
} dependencies = dependencies,
} else { windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp,
null 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 LinuxWindowControlHitTargetSize = 34.dp
private val LinuxWindowControlVisualSize = 28.dp private val LinuxWindowControlVisualSize = 28.dp
private val LinuxWindowControlsWidth = 120.dp private val LinuxWindowControlsWidth = 120.dp
private val DesktopContentCornerRadius = 20.dp internal val DesktopContentCornerRadius = 20.dp
@Composable @Composable
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)

View File

@@ -8,10 +8,14 @@ actual fun PlatformSystemAppearance(isDarkTheme: Boolean) = Unit
object DesktopAppearanceBridge { object DesktopAppearanceBridge {
fun isLinux(): Boolean = isLinux(System.getProperty("os.name")) fun isLinux(): Boolean = isLinux(System.getProperty("os.name"))
fun isWindows(): Boolean = isWindows(System.getProperty("os.name"))
internal fun isLinux(osName: String): Boolean = internal fun isLinux(osName: String): Boolean =
osName.startsWith("Linux", ignoreCase = true) osName.startsWith("Linux", ignoreCase = true)
internal fun isWindows(osName: String): Boolean =
osName.startsWith("Windows", 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

@@ -14,6 +14,14 @@ class DesktopAppearanceBridgeTest {
assertFalse(DesktopAppearanceBridge.isLinux("Windows 11")) 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 @Test
fun titlebarDoubleClickTogglesMaximizedWindowState() { fun titlebarDoubleClickTogglesMaximizedWindowState() {
assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL)) assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))