mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(desktop): unify Linux window chrome
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user