feat(desktop): polish Linux native experience

This commit is contained in:
2026-07-22 11:23:10 +02:00
parent 627c205853
commit cf7734fb42
13 changed files with 346 additions and 115 deletions

View File

@@ -9,13 +9,13 @@ 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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.window.WindowDraggableArea
import androidx.compose.runtime.Composable
@@ -53,10 +53,12 @@ import com.vnidrop.app.feature.receive.VniDropInvitationExtension
import com.vnidrop.app.feature.receive.decodeInvitationBytes
import com.vnidrop.app.platform.DesktopAppearanceBridge
import com.vnidrop.app.ui.theme.LocalVniDropColors
import io.github.vinceglb.filekit.FileKit
import java.awt.Desktop
import java.io.File
fun main(args: Array<String>) {
FileKit.init(appId = "vnidrop")
val externalInvitations = ExternalInvitationController()
val linux = DesktopAppearanceBridge.isLinux()
configureInvitationOpenHandler(externalInvitations)
@@ -114,9 +116,10 @@ private fun ExternalInvitationController.openFile(file: File) {
}
}
private val LinuxTitleBarHeight = 40.dp
private val LinuxWindowControlWidth = 46.dp
private val LinuxWindowControlsWidth = 138.dp
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
@Composable
@@ -156,7 +159,9 @@ private fun WindowScope.LinuxTitleBar(
Row(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight(),
.padding(end = 10.dp)
.background(colors.backgroundSurface300, RoundedCornerShape(20.dp))
.padding(3.dp),
) {
LinuxWindowControlButton(
icon = LinuxMinimizeIcon,
@@ -189,17 +194,15 @@ private fun LinuxWindowControlButton(
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
val visualState = linuxWindowControlVisualState(isClose, hovered, pressed)
val background = when (visualState) {
LinuxWindowControlVisualState.Default -> Color.Transparent
LinuxWindowControlVisualState.NeutralActive -> colors.backgroundOverlayHover
LinuxWindowControlVisualState.DestructiveActive -> colors.destructiveDefault
}
Box(
modifier = Modifier
.width(LinuxWindowControlWidth)
.fillMaxHeight()
.background(background)
.size(LinuxWindowControlHitTargetSize)
.hoverable(interactionSource)
.clickable(
interactionSource = interactionSource,
@@ -209,15 +212,41 @@ private fun LinuxWindowControlButton(
),
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),
)
Box(
modifier = Modifier
.size(LinuxWindowControlVisualSize)
.background(background, CircleShape),
contentAlignment = Alignment.Center,
) {
Image(
painter = rememberVectorPainter(icon),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(
if (visualState == LinuxWindowControlVisualState.DestructiveActive) Color.White
else colors.foregroundLight,
),
modifier = Modifier.size(14.dp),
)
}
}
}
internal enum class LinuxWindowControlVisualState {
Default,
NeutralActive,
DestructiveActive,
}
internal fun linuxWindowControlVisualState(
isClose: Boolean,
isHovered: Boolean,
isPressed: Boolean,
): LinuxWindowControlVisualState = when {
isClose && (isHovered || isPressed) -> LinuxWindowControlVisualState.DestructiveActive
isHovered || isPressed -> LinuxWindowControlVisualState.NeutralActive
else -> LinuxWindowControlVisualState.Default
}
internal fun toggledWindowPlacement(current: WindowPlacement): WindowPlacement =
if (current == WindowPlacement.Maximized) WindowPlacement.Floating else WindowPlacement.Maximized

View File

@@ -10,4 +10,28 @@ class DesktopWindowChromeTest {
assertEquals(WindowPlacement.Maximized, toggledWindowPlacement(WindowPlacement.Floating))
assertEquals(WindowPlacement.Floating, toggledWindowPlacement(WindowPlacement.Maximized))
}
@Test
fun closeControlUsesDestructiveStateWhenHoveredOrPressed() {
assertEquals(
LinuxWindowControlVisualState.DestructiveActive,
linuxWindowControlVisualState(isClose = true, isHovered = true, isPressed = false),
)
assertEquals(
LinuxWindowControlVisualState.DestructiveActive,
linuxWindowControlVisualState(isClose = true, isHovered = false, isPressed = true),
)
}
@Test
fun standardControlsKeepNeutralInteractionState() {
assertEquals(
LinuxWindowControlVisualState.NeutralActive,
linuxWindowControlVisualState(isClose = false, isHovered = true, isPressed = false),
)
assertEquals(
LinuxWindowControlVisualState.Default,
linuxWindowControlVisualState(isClose = false, isHovered = false, isPressed = false),
)
}
}