mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(desktop): unify macOS window chrome
This commit is contained in:
@@ -1,6 +1,26 @@
|
|||||||
package com.vnidrop.app
|
package com.vnidrop.app
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.text.BasicText
|
||||||
|
import androidx.compose.foundation.window.WindowDraggableArea
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||||
|
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.Window
|
||||||
|
import androidx.compose.ui.window.WindowScope
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
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
|
||||||
@@ -8,11 +28,13 @@ import com.vnidrop.app.feature.receive.ExternalInvitationController
|
|||||||
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
|
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
|
||||||
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
|
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
|
||||||
import com.vnidrop.app.feature.receive.decodeInvitationBytes
|
import com.vnidrop.app.feature.receive.decodeInvitationBytes
|
||||||
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
import java.awt.Desktop
|
import java.awt.Desktop
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
val externalInvitations = ExternalInvitationController()
|
val externalInvitations = ExternalInvitationController()
|
||||||
|
val macOs = DesktopAppearanceBridge.isMacOs()
|
||||||
configureMacOsNativeAppearance()
|
configureMacOsNativeAppearance()
|
||||||
configureInvitationOpenHandler(externalInvitations)
|
configureInvitationOpenHandler(externalInvitations)
|
||||||
args.asSequence()
|
args.asSequence()
|
||||||
@@ -20,7 +42,7 @@ fun main(args: Array<String>) {
|
|||||||
.filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) }
|
.filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) }
|
||||||
.forEach { externalInvitations.openFile(it) }
|
.forEach { externalInvitations.openFile(it) }
|
||||||
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
|
DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply
|
||||||
if (System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) {
|
if (macOs) {
|
||||||
DesktopShareBridge.shareFile = MacOsShareSheet::share
|
DesktopShareBridge.shareFile = MacOsShareSheet::share
|
||||||
}
|
}
|
||||||
application {
|
application {
|
||||||
@@ -28,7 +50,16 @@ fun main(args: Array<String>) {
|
|||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
title = "vnidrop",
|
title = "vnidrop",
|
||||||
) {
|
) {
|
||||||
App(rememberJvmAppDependencies(externalInvitations))
|
App(
|
||||||
|
dependencies = rememberJvmAppDependencies(externalInvitations),
|
||||||
|
windowChromeTopInset = if (macOs) MacOsTitleBarHeight else 0.dp,
|
||||||
|
windowContentTopStartRadius = if (macOs) MacOsContentCornerRadius else 0.dp,
|
||||||
|
windowChrome = if (macOs) {
|
||||||
|
{ MacOsTitleBar() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,8 +83,47 @@ private fun ExternalInvitationController.openFile(file: File) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun configureMacOsNativeAppearance() {
|
private fun configureMacOsNativeAppearance() {
|
||||||
if (!System.getProperty("os.name").startsWith("Mac", ignoreCase = true)) return
|
if (!DesktopAppearanceBridge.isMacOs()) return
|
||||||
// AWT reads this before creating the first native window. Runtime theme
|
// AWT reads this before creating the first native window. Runtime theme
|
||||||
// changes are handled in the JVM platform appearance hook.
|
// changes are handled in the JVM platform appearance hook.
|
||||||
System.setProperty("apple.awt.application.appearance", "system")
|
System.setProperty("apple.awt.application.appearance", "system")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val MacOsTitleBarHeight = 28.dp
|
||||||
|
private val MacOsTrafficLightsWidth = 76.dp
|
||||||
|
private val MacOsContentCornerRadius = 20.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
private fun WindowScope.MacOsTitleBar() {
|
||||||
|
val colors = LocalVniDropColors.current
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(MacOsTitleBarHeight)
|
||||||
|
.background(colors.backgroundSurface200),
|
||||||
|
) {
|
||||||
|
WindowDraggableArea(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(start = MacOsTrafficLightsWidth)
|
||||||
|
.onPointerEvent(PointerEventType.Press) { event ->
|
||||||
|
if (event.awtEventOrNull?.clickCount == 2) {
|
||||||
|
DesktopAppearanceBridge.toggleMaximized(window)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) {
|
||||||
|
BasicText(
|
||||||
|
text = "vnidrop",
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
style = TextStyle(
|
||||||
|
color = colors.foregroundDefault,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.vnidrop.app
|
package com.vnidrop.app
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -10,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
@@ -36,6 +39,7 @@ import com.vnidrop.app.ui.shell.ScreenScrollContainer
|
|||||||
import com.vnidrop.app.core.TransferDirection
|
import com.vnidrop.app.core.TransferDirection
|
||||||
import com.vnidrop.app.ui.state.WindowClass
|
import com.vnidrop.app.ui.state.WindowClass
|
||||||
import com.vnidrop.app.ui.state.windowClassFor
|
import com.vnidrop.app.ui.state.windowClassFor
|
||||||
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
import com.vnidrop.app.ui.theme.VniDropTheme
|
import com.vnidrop.app.ui.theme.VniDropTheme
|
||||||
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
|
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
@@ -43,7 +47,12 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App(dependencies: AppDependencies) {
|
fun App(
|
||||||
|
dependencies: AppDependencies,
|
||||||
|
windowChromeTopInset: Dp = 0.dp,
|
||||||
|
windowContentTopStartRadius: Dp = 0.dp,
|
||||||
|
windowChrome: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
val graphHolder = viewModel { AppGraphViewModel(dependencies) }
|
val graphHolder = viewModel { AppGraphViewModel(dependencies) }
|
||||||
val graph = graphHolder.graph
|
val graph = graphHolder.graph
|
||||||
|
|
||||||
@@ -122,7 +131,16 @@ fun App(dependencies: AppDependencies) {
|
|||||||
val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
|
val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
|
||||||
PlatformSystemAppearance(darkTheme)
|
PlatformSystemAppearance(darkTheme)
|
||||||
VniDropTheme(isDarkTheme = darkTheme) {
|
VniDropTheme(isDarkTheme = darkTheme) {
|
||||||
BoxWithConstraints {
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(LocalVniDropColors.current.backgroundSurface200),
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = windowChromeTopInset),
|
||||||
|
) {
|
||||||
val windowClass = windowClassFor(maxWidth.value)
|
val windowClass = windowClassFor(maxWidth.value)
|
||||||
val showSendAction = appState.destination == AppDestination.Send &&
|
val showSendAction = appState.destination == AppDestination.Send &&
|
||||||
windowClass == WindowClass.Phone &&
|
windowClass == WindowClass.Phone &&
|
||||||
@@ -138,6 +156,7 @@ fun App(dependencies: AppDependencies) {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
selectedDestination = appState.destination,
|
selectedDestination = appState.destination,
|
||||||
windowClass = windowClass,
|
windowClass = windowClass,
|
||||||
|
mainContentTopStartRadius = windowContentTopStartRadius,
|
||||||
onDestinationSelected = appViewModel::selectDestination,
|
onDestinationSelected = appViewModel::selectDestination,
|
||||||
overlay = {
|
overlay = {
|
||||||
VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter))
|
VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter))
|
||||||
@@ -172,5 +191,7 @@ fun App(dependencies: AppDependencies) {
|
|||||||
onRefuse = graph.approvalCoordinator::refuse,
|
onRefuse = graph.approvalCoordinator::refuse,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
windowChrome?.invoke()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
@@ -36,6 +37,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
fun AppSidebarNavigation(
|
fun AppSidebarNavigation(
|
||||||
selected: AppDestination,
|
selected: AppDestination,
|
||||||
onDestinationSelected: (AppDestination) -> Unit,
|
onDestinationSelected: (AppDestination) -> Unit,
|
||||||
|
dividerTopInset: Dp = 0.dp,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val colors = LocalVniDropColors.current
|
val colors = LocalVniDropColors.current
|
||||||
@@ -62,7 +64,8 @@ fun AppSidebarNavigation(
|
|||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(top = dividerTopInset)
|
||||||
.width(1.dp)
|
.width(1.dp)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.background(colors.borderDefault),
|
.background(colors.borderDefault),
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vnidrop.app.ui.navigation.AppBottomNavigation
|
import com.vnidrop.app.ui.navigation.AppBottomNavigation
|
||||||
import com.vnidrop.app.ui.navigation.AppDestination
|
import com.vnidrop.app.ui.navigation.AppDestination
|
||||||
@@ -26,6 +29,7 @@ fun AppShell(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
selectedDestination: AppDestination,
|
selectedDestination: AppDestination,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
|
mainContentTopStartRadius: Dp = 0.dp,
|
||||||
onDestinationSelected: (AppDestination) -> Unit,
|
onDestinationSelected: (AppDestination) -> Unit,
|
||||||
overlay: @Composable BoxScope.() -> Unit = {},
|
overlay: @Composable BoxScope.() -> Unit = {},
|
||||||
floatingAction: (@Composable BoxScope.() -> Unit)? = null,
|
floatingAction: (@Composable BoxScope.() -> Unit)? = null,
|
||||||
@@ -49,6 +53,7 @@ fun AppShell(
|
|||||||
} else {
|
} else {
|
||||||
WideShell(
|
WideShell(
|
||||||
selectedDestination = selectedDestination,
|
selectedDestination = selectedDestination,
|
||||||
|
mainContentTopStartRadius = mainContentTopStartRadius,
|
||||||
onDestinationSelected = onDestinationSelected,
|
onDestinationSelected = onDestinationSelected,
|
||||||
overlay = overlay,
|
overlay = overlay,
|
||||||
floatingAction = floatingAction,
|
floatingAction = floatingAction,
|
||||||
@@ -61,17 +66,38 @@ fun AppShell(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WideShell(
|
private fun WideShell(
|
||||||
selectedDestination: AppDestination,
|
selectedDestination: AppDestination,
|
||||||
|
mainContentTopStartRadius: Dp,
|
||||||
onDestinationSelected: (AppDestination) -> Unit,
|
onDestinationSelected: (AppDestination) -> Unit,
|
||||||
overlay: @Composable BoxScope.() -> Unit,
|
overlay: @Composable BoxScope.() -> Unit,
|
||||||
floatingAction: (@Composable BoxScope.() -> Unit)?,
|
floatingAction: (@Composable BoxScope.() -> Unit)?,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
val colors = LocalVniDropColors.current
|
||||||
|
val roundedContent = mainContentTopStartRadius > 0.dp
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(if (roundedContent) Modifier.background(colors.backgroundSurface200) else Modifier),
|
||||||
|
) {
|
||||||
AppSidebarNavigation(
|
AppSidebarNavigation(
|
||||||
selected = selectedDestination,
|
selected = selectedDestination,
|
||||||
|
dividerTopInset = mainContentTopStartRadius,
|
||||||
onDestinationSelected = onDestinationSelected,
|
onDestinationSelected = onDestinationSelected,
|
||||||
)
|
)
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxSize()
|
||||||
|
.then(
|
||||||
|
if (roundedContent) {
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(topStart = mainContentTopStartRadius))
|
||||||
|
.background(colors.backgroundDashCanvas)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
content()
|
content()
|
||||||
floatingAction?.invoke(this)
|
floatingAction?.invoke(this)
|
||||||
Box(Modifier.fillMaxSize().padding(bottom = if (floatingAction == null) 0.dp else 72.dp)) { overlay() }
|
Box(Modifier.fillMaxSize().padding(bottom = if (floatingAction == null) 0.dp else 72.dp)) { overlay() }
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import java.awt.Color
|
import java.awt.Color
|
||||||
import java.awt.EventQueue
|
import java.awt.EventQueue
|
||||||
|
import java.awt.Frame
|
||||||
import java.awt.Window
|
import java.awt.Window
|
||||||
import javax.swing.JFrame
|
import javax.swing.JFrame
|
||||||
|
import javax.swing.JRootPane
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
|
actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
|
||||||
@@ -16,10 +18,12 @@ actual fun PlatformSystemAppearance(isDarkTheme: Boolean) {
|
|||||||
|
|
||||||
internal object DesktopSystemAppearance {
|
internal object DesktopSystemAppearance {
|
||||||
private const val MAC_APPEARANCE_PROPERTY = "apple.awt.application.appearance"
|
private const val MAC_APPEARANCE_PROPERTY = "apple.awt.application.appearance"
|
||||||
|
private const val FULL_WINDOW_CONTENT_PROPERTY = "apple.awt.fullWindowContent"
|
||||||
private const val TRANSPARENT_TITLE_BAR_PROPERTY = "apple.awt.transparentTitleBar"
|
private const val TRANSPARENT_TITLE_BAR_PROPERTY = "apple.awt.transparentTitleBar"
|
||||||
|
private const val WINDOW_TITLE_VISIBLE_PROPERTY = "apple.awt.windowTitleVisible"
|
||||||
|
|
||||||
fun apply(isDarkTheme: Boolean) {
|
fun apply(isDarkTheme: Boolean) {
|
||||||
if (!isMacOs()) return
|
if (!DesktopAppearanceBridge.isMacOs()) return
|
||||||
System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme))
|
System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme))
|
||||||
EventQueue.invokeLater {
|
EventQueue.invokeLater {
|
||||||
DesktopAppearanceBridge.applyNativeAppearance?.invoke(isDarkTheme)
|
DesktopAppearanceBridge.applyNativeAppearance?.invoke(isDarkTheme)
|
||||||
@@ -33,27 +37,50 @@ internal object DesktopSystemAppearance {
|
|||||||
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
|
if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
|
||||||
|
|
||||||
internal fun usesTransparentTitlebar(): Boolean = true
|
internal fun usesTransparentTitlebar(): Boolean = true
|
||||||
|
internal fun usesFullWindowContent(): Boolean = true
|
||||||
|
internal fun showsNativeWindowTitle(): Boolean = false
|
||||||
|
|
||||||
internal fun titlebarBackground(isDarkTheme: Boolean): Color =
|
internal fun titlebarBackground(isDarkTheme: Boolean): Color =
|
||||||
if (isDarkTheme) Color(0x12, 0x12, 0x12) else Color(0xF8, 0xF8, 0xF8)
|
if (isDarkTheme) Color(0x21, 0x21, 0x21) else Color(0xF3, 0xF3, 0xF3)
|
||||||
|
|
||||||
private fun applyWindowChrome(window: Window, isDarkTheme: Boolean) {
|
private fun applyWindowChrome(window: Window, isDarkTheme: Boolean) {
|
||||||
val background = titlebarBackground(isDarkTheme)
|
val background = titlebarBackground(isDarkTheme)
|
||||||
window.background = background
|
window.background = background
|
||||||
(window as? JFrame)?.rootPane?.let { rootPane ->
|
(window as? JFrame)?.rootPane?.let { rootPane -> applyRootPaneChrome(rootPane, background) }
|
||||||
// The titlebar stays native, but AppKit receives the resolved app
|
}
|
||||||
// appearance so title text and controls switch contrast at runtime.
|
|
||||||
|
internal fun applyRootPaneChrome(rootPane: JRootPane, background: Color) {
|
||||||
|
// Extending the Compose surface beneath the native titlebar lets the
|
||||||
|
// window chrome and sidebar share one uninterrupted background.
|
||||||
|
rootPane.putClientProperty(FULL_WINDOW_CONTENT_PROPERTY, usesFullWindowContent())
|
||||||
rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar())
|
rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar())
|
||||||
|
rootPane.putClientProperty(WINDOW_TITLE_VISIBLE_PROPERTY, showsNativeWindowTitle())
|
||||||
rootPane.background = background
|
rootPane.background = background
|
||||||
rootPane.contentPane.background = background
|
rootPane.contentPane.background = background
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun isMacOs(): Boolean =
|
|
||||||
System.getProperty("os.name").startsWith("Mac", ignoreCase = true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
object DesktopAppearanceBridge {
|
object DesktopAppearanceBridge {
|
||||||
@Volatile
|
@Volatile
|
||||||
var applyNativeAppearance: ((Boolean) -> Unit)? = null
|
var applyNativeAppearance: ((Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
fun isMacOs(): Boolean = isMacOs(System.getProperty("os.name"))
|
||||||
|
|
||||||
|
fun toggleMaximized(window: Window) {
|
||||||
|
if (!isMacOs()) return
|
||||||
|
val frame = window as? Frame ?: return
|
||||||
|
EventQueue.invokeLater {
|
||||||
|
frame.extendedState = toggledWindowState(frame.extendedState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isMacOs(osName: String): Boolean =
|
||||||
|
osName.startsWith("Mac", ignoreCase = true)
|
||||||
|
|
||||||
|
internal fun toggledWindowState(currentState: Int): Int =
|
||||||
|
if (currentState and Frame.MAXIMIZED_BOTH == Frame.MAXIMIZED_BOTH) {
|
||||||
|
Frame.NORMAL
|
||||||
|
} else {
|
||||||
|
Frame.MAXIMIZED_BOTH
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
package com.vnidrop.app.platform
|
package com.vnidrop.app.platform
|
||||||
|
|
||||||
|
import java.awt.Color
|
||||||
|
import java.awt.Frame
|
||||||
|
import javax.swing.JRootPane
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class DesktopSystemAppearanceTest {
|
class DesktopSystemAppearanceTest {
|
||||||
|
@Test
|
||||||
|
fun customWindowChromeIsMacOsOnly() {
|
||||||
|
assertTrue(DesktopAppearanceBridge.isMacOs("Mac OS X"))
|
||||||
|
assertFalse(DesktopAppearanceBridge.isMacOs("Windows 11"))
|
||||||
|
assertFalse(DesktopAppearanceBridge.isMacOs("Linux"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun titlebarDoubleClickTogglesMaximizedWindowState() {
|
||||||
|
assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL))
|
||||||
|
assertEquals(Frame.NORMAL, DesktopAppearanceBridge.toggledWindowState(Frame.MAXIMIZED_BOTH))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun macOsAppearanceNamesMatchResolvedTheme() {
|
fun macOsAppearanceNamesMatchResolvedTheme() {
|
||||||
assertEquals("NSAppearanceNameDarkAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = true))
|
assertEquals("NSAppearanceNameDarkAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = true))
|
||||||
@@ -11,9 +29,9 @@ class DesktopSystemAppearanceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun titlebarBackgroundUsesLightAndDarkSurfaces() {
|
fun titlebarBackgroundMatchesSidebarSurface() {
|
||||||
assertEquals(0x121212, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF)
|
assertEquals(0x212121, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF)
|
||||||
assertEquals(0xF8F8F8, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF)
|
assertEquals(0xF3F3F3, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -21,6 +39,20 @@ class DesktopSystemAppearanceTest {
|
|||||||
assertEquals(true, DesktopSystemAppearance.usesTransparentTitlebar())
|
assertEquals(true, DesktopSystemAppearance.usesTransparentTitlebar())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun composeContentExtendsUnderMacOsTitlebar() {
|
||||||
|
val rootPane = JRootPane()
|
||||||
|
val background = Color(0x21, 0x21, 0x21)
|
||||||
|
|
||||||
|
DesktopSystemAppearance.applyRootPaneChrome(rootPane, background)
|
||||||
|
|
||||||
|
assertEquals(true, rootPane.getClientProperty("apple.awt.fullWindowContent"))
|
||||||
|
assertEquals(true, rootPane.getClientProperty("apple.awt.transparentTitleBar"))
|
||||||
|
assertEquals(false, rootPane.getClientProperty("apple.awt.windowTitleVisible"))
|
||||||
|
assertEquals(background, rootPane.background)
|
||||||
|
assertEquals(background, rootPane.contentPane.background)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun runtimeAppearanceCallIsFailSoft() {
|
fun runtimeAppearanceCallIsFailSoft() {
|
||||||
DesktopSystemAppearance.apply(isDarkTheme = true)
|
DesktopSystemAppearance.apply(isDarkTheme = true)
|
||||||
|
|||||||
Reference in New Issue
Block a user