feat(desktop): native theming for macOS, GNOME, KDE, Windows
Replace the hard-coded Material3 dark theme with a per-OS theme that follows the host system's appearance, accent color, fonts, and rounding language. Adds a `desktop/platform/` module with: - PlatformInfo: detects macOS, Windows, GNOME, KDE, other Linux via XDG_CURRENT_DESKTOP / DESKTOP_SESSION. - PlatformAppearance: reads OS dark/light preference (defaults on macOS, gsettings on GNOME, kreadconfig on KDE, registry on Windows) and refreshes on window focus. - PlatformAccent: pulls the user's accent color from each OS (named AppleAccentColor, gsettings accent-color, kdeglobals AccentColor, DWM AccentColor) and falls back to Amethyst purple. - PlatformFonts: resolves the OS's preferred UI font via Skia's FontMgr (SF Pro Text on macOS, Segoe UI Variable on Win 11, Cantarell/Adwaita Sans on GNOME, Noto Sans on KDE). - PlatformShapes / PlatformTypography / PlatformColorScheme: per-OS rounding (macOS 8/10/14, libadwaita 9/12/16, Breeze 6/8/12, WinUI 4/8/8), letter-spacing tightening for SF Pro / Adwaita, and surface tones from each OS's reference palette. macOS gets native chrome treatment: `apple.laf.useScreenMenuBar` routes the MenuBar to the system menu bar; `apple.awt.transparentTitleBar` + `apple.awt.fullWindowContent` lets the deck/sidebar extend under the traffic lights; a 28dp top strip in MainContent reserves space so content doesn't underlap them. DeckSidebar bumps from 48dp to 56dp (desktop density) and switches its background to the Material3 `surfaceContainer` token, which our new per-OS schemes drive to the right tone for each platform. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -46,7 +47,6 @@ import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
||||
import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome
|
||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||
@@ -131,7 +132,7 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
private val isMacOS = com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS
|
||||
|
||||
enum class LayoutMode {
|
||||
SINGLE_PANE,
|
||||
@@ -184,6 +185,15 @@ sealed class DesktopScreen {
|
||||
private var activeTorManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager? = null
|
||||
|
||||
fun main() {
|
||||
// macOS: route the app's MenuBar to the system menu bar at the top of the
|
||||
// screen and set the application name shown in the apple-menu. Both must be
|
||||
// set before AWT initializes (i.e. before any Swing/AWT class loads).
|
||||
if (com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS) {
|
||||
System.setProperty("apple.laf.useScreenMenuBar", "true")
|
||||
System.setProperty("apple.awt.application.name", "Amethyst")
|
||||
System.setProperty("apple.awt.application.appearance", "system")
|
||||
}
|
||||
|
||||
Log.minLevel = LogLevel.DEBUG
|
||||
DesktopImageLoaderSetup.setup()
|
||||
Runtime.getRuntime().addShutdownHook(
|
||||
@@ -247,6 +257,10 @@ fun main() {
|
||||
state = windowState,
|
||||
title = "Amethyst",
|
||||
) {
|
||||
// macOS: transparent + full-window-content title bar so the deck/sidebar
|
||||
// shows through, with traffic lights still drawn on top. No-op elsewhere.
|
||||
applyNativeWindowChrome()
|
||||
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item(
|
||||
@@ -738,9 +752,10 @@ fun App(
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = darkColorScheme(),
|
||||
) {
|
||||
val isDark by com.vitorpamplona.amethyst.desktop.platform
|
||||
.rememberSystemDark(LocalAwtWindow.current)
|
||||
|
||||
com.vitorpamplona.amethyst.desktop.platform.PlatformMaterialTheme(isDark = isDark) {
|
||||
ProvideMaterialSymbols {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -1102,6 +1117,19 @@ fun MainContent(
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// macOS: reserve a title bar strip so deck/sidebar content doesn't
|
||||
// underlap the traffic lights. The strip is colored to match the
|
||||
// sidebar so the whole top edge reads as one continuous toolbar.
|
||||
if (!isImmersive &&
|
||||
com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxSize().weight(1f)) {
|
||||
when (layoutMode) {
|
||||
LayoutMode.SINGLE_PANE -> {
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.DefaultPrimary
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Resolves the user's preferred OS accent color. Falls back to Amethyst's purple
|
||||
* brand color if the OS doesn't expose an accent or detection fails.
|
||||
*
|
||||
* - macOS: `defaults read -g AppleAccentColor` returns -1..7 (named accents) or
|
||||
* `AppleHighlightColor` carries an RGB triplet for the multicolor case.
|
||||
* - GNOME 47+: `gsettings get org.gnome.desktop.interface accent-color`.
|
||||
* - KDE: parses the `AccentColor` key from kdeglobals.
|
||||
* - Windows: registry `ColorizationColor` (ABGR DWORD).
|
||||
*
|
||||
* Returning the brand color is a safe default — Amethyst purple matches the app's
|
||||
* identity and looks intentional rather than broken.
|
||||
*/
|
||||
object PlatformAccent {
|
||||
fun systemAccent(): Color =
|
||||
when (PlatformInfo.current) {
|
||||
Platform.MACOS -> macOSAccent()
|
||||
Platform.GNOME -> gnomeAccent()
|
||||
Platform.KDE -> kdeAccent()
|
||||
Platform.WINDOWS -> windowsAccent()
|
||||
else -> DefaultPrimary
|
||||
} ?: DefaultPrimary
|
||||
|
||||
// macOS named accent colors (NSColor controlAccentColor variants).
|
||||
// Index matches AppleAccentColor defaults value; -1 = Multicolor (use highlight).
|
||||
private val macAccents =
|
||||
mapOf(
|
||||
-1 to Color(0xFF0064E1), // Multicolor → blue (system default)
|
||||
0 to Color(0xFFEF5743), // Red
|
||||
1 to Color(0xFFEC8E2C), // Orange
|
||||
2 to Color(0xFFF8BA00), // Yellow
|
||||
3 to Color(0xFF62BA46), // Green
|
||||
4 to Color(0xFF0064E1), // Blue
|
||||
5 to Color(0xFFC44495), // Purple
|
||||
6 to Color(0xFFF74F9E), // Pink
|
||||
7 to Color(0xFF8C8C8C), // Graphite
|
||||
)
|
||||
|
||||
private fun macOSAccent(): Color? {
|
||||
val out = exec("defaults", "read", "-g", "AppleAccentColor")
|
||||
val idx = out?.trim()?.toIntOrNull()
|
||||
if (idx != null) return macAccents[idx]
|
||||
|
||||
// Highlight color triple ("0.7 0.45 0.85 Purple") — first 3 floats are RGB 0..1.
|
||||
val highlight = exec("defaults", "read", "-g", "AppleHighlightColor") ?: return null
|
||||
val rgb = highlight.trim().split(" ").mapNotNull { it.toFloatOrNull() }
|
||||
if (rgb.size >= 3) return Color(rgb[0].coerceIn(0f, 1f), rgb[1].coerceIn(0f, 1f), rgb[2].coerceIn(0f, 1f))
|
||||
return null
|
||||
}
|
||||
|
||||
// GNOME 47+ accent names mapped to the libadwaita reference accent colors.
|
||||
private val gnomeAccents =
|
||||
mapOf(
|
||||
"blue" to Color(0xFF3584E4),
|
||||
"teal" to Color(0xFF2190A4),
|
||||
"green" to Color(0xFF3A944A),
|
||||
"yellow" to Color(0xFFC88800),
|
||||
"orange" to Color(0xFFED5B00),
|
||||
"red" to Color(0xFFE62D42),
|
||||
"pink" to Color(0xFFD56199),
|
||||
"purple" to Color(0xFF9141AC),
|
||||
"slate" to Color(0xFF6F8396),
|
||||
)
|
||||
|
||||
private fun gnomeAccent(): Color? {
|
||||
val out = exec("gsettings", "get", "org.gnome.desktop.interface", "accent-color") ?: return null
|
||||
val name = out.trim().trim('\'').lowercase()
|
||||
return gnomeAccents[name]
|
||||
}
|
||||
|
||||
private fun kdeAccent(): Color? {
|
||||
// kdeglobals stores AccentColor as comma-separated RGB ("123,45,200").
|
||||
val home = System.getProperty("user.home") ?: return null
|
||||
val candidates =
|
||||
listOf(
|
||||
"$home/.config/kdeglobals",
|
||||
"$home/.kde/share/config/kdeglobals",
|
||||
)
|
||||
for (path in candidates) {
|
||||
val file = java.io.File(path)
|
||||
if (!file.exists()) continue
|
||||
try {
|
||||
val text = file.readText()
|
||||
val match = Regex("(?m)^AccentColor\\s*=\\s*(\\d+),\\s*(\\d+),\\s*(\\d+)").find(text)
|
||||
if (match != null) {
|
||||
val (r, g, b) = match.destructured
|
||||
return Color(r.toInt(), g.toInt(), b.toInt())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d("PlatformAccent") { "Failed to parse KDE accent at $path: ${e.message}" }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun windowsAccent(): Color? {
|
||||
// HKCU\Software\Microsoft\Windows\DWM has AccentColor as a DWORD in 0xAABBGGRR (ABGR).
|
||||
val out =
|
||||
exec(
|
||||
"reg",
|
||||
"query",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\DWM",
|
||||
"/v",
|
||||
"AccentColor",
|
||||
) ?: return null
|
||||
val match = Regex("AccentColor\\s+REG_DWORD\\s+0x([0-9a-fA-F]+)").find(out) ?: return null
|
||||
val value = match.groupValues[1].toLongOrNull(16) ?: return null
|
||||
// ABGR: high byte = A, then B, G, R
|
||||
val r = (value and 0xFFL).toInt()
|
||||
val g = ((value shr 8) and 0xFFL).toInt()
|
||||
val b = ((value shr 16) and 0xFFL).toInt()
|
||||
return Color(r, g, b)
|
||||
}
|
||||
|
||||
private fun exec(vararg cmd: String): String? =
|
||||
try {
|
||||
val proc =
|
||||
ProcessBuilder(*cmd)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val out =
|
||||
proc.inputStream
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
.trim()
|
||||
val finished = proc.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
proc.destroyForcibly()
|
||||
null
|
||||
} else if (proc.exitValue() != 0) {
|
||||
null
|
||||
} else {
|
||||
out
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d("PlatformAccent") { "Failed to exec ${cmd.joinToString(" ")}: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import java.awt.Window
|
||||
import java.awt.event.WindowFocusListener
|
||||
|
||||
/**
|
||||
* Reads the OS's preferred light/dark appearance.
|
||||
*
|
||||
* - macOS: `defaults read -g AppleInterfaceStyle` returns "Dark" only when dark is on.
|
||||
* - GNOME: `gsettings get org.gnome.desktop.interface color-scheme` → 'prefer-dark' / 'prefer-light' / 'default'.
|
||||
* - KDE: `kreadconfig5 --group General --key ColorScheme` (or kreadconfig6) — name contains "Dark" when dark.
|
||||
* - Windows: HKCU AppsUseLightTheme registry value (0 = dark).
|
||||
*
|
||||
* Detection is stale-tolerant: re-runs on window focus so users who flip their
|
||||
* system theme see Amethyst follow within a second of bringing the window forward.
|
||||
*/
|
||||
object PlatformAppearance {
|
||||
fun isSystemDark(): Boolean =
|
||||
when (PlatformInfo.current) {
|
||||
Platform.MACOS -> isMacOSDark()
|
||||
Platform.GNOME -> isGnomeDark()
|
||||
Platform.KDE -> isKdeDark()
|
||||
Platform.WINDOWS -> isWindowsDark()
|
||||
else -> true
|
||||
}
|
||||
|
||||
private fun isMacOSDark(): Boolean {
|
||||
val out = exec("defaults", "read", "-g", "AppleInterfaceStyle") ?: return false
|
||||
return out.contains("Dark", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun isGnomeDark(): Boolean {
|
||||
val out = exec("gsettings", "get", "org.gnome.desktop.interface", "color-scheme") ?: return true
|
||||
if (out.contains("prefer-dark", ignoreCase = true)) return true
|
||||
if (out.contains("prefer-light", ignoreCase = true)) return false
|
||||
// 'default' → fall back to legacy gtk-theme heuristic
|
||||
val theme = exec("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme").orEmpty()
|
||||
return theme.contains("dark", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun isKdeDark(): Boolean {
|
||||
// Try Plasma 6 first, fall back to 5.
|
||||
val tools = listOf("kreadconfig6", "kreadconfig5")
|
||||
for (tool in tools) {
|
||||
val out = exec(tool, "--group", "General", "--key", "ColorScheme")
|
||||
if (!out.isNullOrBlank()) return out.contains("dark", ignoreCase = true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isWindowsDark(): Boolean {
|
||||
val out =
|
||||
exec(
|
||||
"reg",
|
||||
"query",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
|
||||
"/v",
|
||||
"AppsUseLightTheme",
|
||||
) ?: return false
|
||||
// Output line looks like: " AppsUseLightTheme REG_DWORD 0x0"
|
||||
val match = Regex("AppsUseLightTheme\\s+REG_DWORD\\s+0x([0-9a-fA-F]+)").find(out) ?: return false
|
||||
return match.groupValues[1].toIntOrNull(16) == 0
|
||||
}
|
||||
|
||||
private fun exec(vararg cmd: String): String? =
|
||||
try {
|
||||
val proc =
|
||||
ProcessBuilder(*cmd)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val out =
|
||||
proc.inputStream
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
.trim()
|
||||
val finished = proc.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
proc.destroyForcibly()
|
||||
null
|
||||
} else if (proc.exitValue() != 0) {
|
||||
null
|
||||
} else {
|
||||
out
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d("PlatformAppearance") { "Failed to exec ${cmd.joinToString(" ")}: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Compose [State]<Boolean> that tracks the OS dark/light preference and
|
||||
* refreshes whenever the given AWT [Window] gains focus. Lightweight (~30ms shell-out
|
||||
* on focus) and avoids polling.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSystemDark(awtWindow: Window?): State<Boolean> {
|
||||
val state = remember { mutableStateOf(PlatformAppearance.isSystemDark()) }
|
||||
DisposableEffect(awtWindow) {
|
||||
if (awtWindow == null) return@DisposableEffect onDispose {}
|
||||
val listener =
|
||||
object : WindowFocusListener {
|
||||
override fun windowGainedFocus(e: java.awt.event.WindowEvent?) {
|
||||
state.value = PlatformAppearance.isSystemDark()
|
||||
}
|
||||
|
||||
override fun windowLostFocus(e: java.awt.event.WindowEvent?) = Unit
|
||||
}
|
||||
awtWindow.addWindowFocusListener(listener)
|
||||
onDispose { awtWindow.removeWindowFocusListener(listener) }
|
||||
}
|
||||
return state
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Per-OS Material3 [ColorScheme]s tuned to match each platform's native surface
|
||||
* tones. The OS accent (resolved via [PlatformAccent]) is plumbed in as `primary`
|
||||
* so links, focus rings, and selection highlights match the rest of the user's
|
||||
* system.
|
||||
*
|
||||
* Surface tones come from each OS's reference palettes:
|
||||
* - macOS dark: NSWindowBackgroundColor / NSAlternateSelectedControlColor.
|
||||
* - macOS light: NSColor systemBackgroundColor (#FFFFFF) / window background (#ECECEC).
|
||||
* - GNOME dark: libadwaita `@window_bg_color` (#242424) / `@view_bg_color` (#1E1E1E).
|
||||
* - GNOME light: libadwaita `@window_bg_color` (#FAFAFA) / `@view_bg_color` (#FFFFFF).
|
||||
* - KDE Breeze dark/light defaults.
|
||||
* - Windows 11 dark/light: WinUI mica reference values.
|
||||
*/
|
||||
object PlatformColorScheme {
|
||||
fun resolve(
|
||||
dark: Boolean,
|
||||
accent: Color,
|
||||
): ColorScheme =
|
||||
when (PlatformInfo.current) {
|
||||
Platform.MACOS -> if (dark) macOSDark(accent) else macOSLight(accent)
|
||||
Platform.GNOME -> if (dark) gnomeDark(accent) else gnomeLight(accent)
|
||||
Platform.KDE -> if (dark) kdeDark(accent) else kdeLight(accent)
|
||||
Platform.WINDOWS -> if (dark) windowsDark(accent) else windowsLight(accent)
|
||||
else -> if (dark) genericDark(accent) else genericLight(accent)
|
||||
}
|
||||
|
||||
// ── macOS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun macOSDark(accent: Color) =
|
||||
darkColorScheme(
|
||||
primary = accent,
|
||||
onPrimary = onAccent(accent),
|
||||
secondary = accent,
|
||||
tertiary = accent,
|
||||
background = Color(0xFF1E1E1E),
|
||||
onBackground = Color(0xFFE5E5E5),
|
||||
surface = Color(0xFF1E1E1E),
|
||||
onSurface = Color(0xFFE5E5E5),
|
||||
surfaceVariant = Color(0xFF2A2A2A),
|
||||
onSurfaceVariant = Color(0xFFB8B8B8),
|
||||
surfaceContainer = Color(0xFF252525),
|
||||
surfaceContainerHigh = Color(0xFF2D2D2D),
|
||||
surfaceContainerHighest = Color(0xFF353535),
|
||||
surfaceContainerLow = Color(0xFF1A1A1A),
|
||||
surfaceContainerLowest = Color(0xFF141414),
|
||||
surfaceDim = Color(0xFF1A1A1A),
|
||||
surfaceBright = Color(0xFF353535),
|
||||
outline = Color(0xFF6A6A6A),
|
||||
outlineVariant = Color(0xFF3A3A3A),
|
||||
)
|
||||
|
||||
private fun macOSLight(accent: Color) =
|
||||
lightColorScheme(
|
||||
primary = accent,
|
||||
onPrimary = onAccent(accent),
|
||||
secondary = accent,
|
||||
tertiary = accent,
|
||||
background = Color(0xFFECECEC),
|
||||
onBackground = Color(0xFF1A1A1A),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onSurface = Color(0xFF1A1A1A),
|
||||
surfaceVariant = Color(0xFFF2F2F2),
|
||||
onSurfaceVariant = Color(0xFF555555),
|
||||
surfaceContainer = Color(0xFFF6F6F6),
|
||||
surfaceContainerHigh = Color(0xFFEDEDED),
|
||||
surfaceContainerHighest = Color(0xFFE5E5E5),
|
||||
surfaceContainerLow = Color(0xFFFAFAFA),
|
||||
surfaceContainerLowest = Color(0xFFFFFFFF),
|
||||
outline = Color(0xFFB0B0B0),
|
||||
outlineVariant = Color(0xFFD8D8D8),
|
||||
)
|
||||
|
||||
// ── GNOME (libadwaita) ────────────────────────────────────────────────────
|
||||
|
||||
private fun gnomeDark(accent: Color) =
|
||||
darkColorScheme(
|
||||
primary = accent,
|
||||
onPrimary = onAccent(accent),
|
||||
secondary = accent,
|
||||
tertiary = accent,
|
||||
background = Color(0xFF242424),
|
||||
onBackground = Color(0xFFFFFFFF),
|
||||
surface = Color(0xFF1E1E1E),
|
||||
onSurface = Color(0xFFFFFFFF),
|
||||
surfaceVariant = Color(0xFF323232),
|
||||
onSurfaceVariant = Color(0xFFCCCCCC),
|
||||
surfaceContainer = Color(0xFF2C2C2C),
|
||||
surfaceContainerHigh = Color(0xFF383838),
|
||||
surfaceContainerHighest = Color(0xFF424242),
|
||||
surfaceContainerLow = Color(0xFF222222),
|
||||
surfaceContainerLowest = Color(0xFF1A1A1A),
|
||||
outline = Color(0xFF5E5E5E),
|
||||
outlineVariant = Color(0xFF3A3A3A),
|
||||
)
|
||||
|
||||
private fun gnomeLight(accent: Color) =
|
||||
lightColorScheme(
|
||||
primary = accent,
|
||||
onPrimary = onAccent(accent),
|
||||
secondary = accent,
|
||||
tertiary = accent,
|
||||
background = Color(0xFFFAFAFA),
|
||||
onBackground = Color(0xFF1A1A1A),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onSurface = Color(0xFF1A1A1A),
|
||||
surfaceVariant = Color(0xFFF0F0F0),
|
||||
onSurfaceVariant = Color(0xFF5E5E5E),
|
||||
surfaceContainer = Color(0xFFF4F4F4),
|
||||
surfaceContainerHigh = Color(0xFFEDEDED),
|
||||
surfaceContainerHighest = Color(0xFFE5E5E5),
|
||||
surfaceContainerLow = Color(0xFFF9F9F9),
|
||||
surfaceContainerLowest = Color(0xFFFFFFFF),
|
||||
outline = Color(0xFFB0B0B0),
|
||||
outlineVariant = Color(0xFFD4D4D4),
|
||||
)
|
||||
|
||||
// ── KDE Plasma (Breeze) ───────────────────────────────────────────────────
|
||||
|
||||
private fun kdeDark(accent: Color) =
|
||||
darkColorScheme(
|
||||
primary = accent,
|
||||
onPrimary = onAccent(accent),
|
||||
secondary = accent,
|
||||
tertiary = accent,
|
||||
background = Color(0xFF1B1E20),
|
||||
onBackground = Color(0xFFFCFCFC),
|
||||
surface = Color(0xFF232629),
|
||||
onSurface = Color(0xFFFCFCFC),
|
||||
surfaceVariant = Color(0xFF2A2E32),
|
||||
onSurfaceVariant = Color(0xFFBDC3C7),
|
||||
surfaceContainer = Color(0xFF272A2E),
|
||||
surfaceContainerHigh = Color(0xFF31353A),
|
||||
surfaceContainerHighest = Color(0xFF3B4045),
|
||||
surfaceContainerLow = Color(0xFF1F2225),
|
||||
surfaceContainerLowest = Color(0xFF18191B),
|
||||
outline = Color(0xFF4D5258),
|
||||
outlineVariant = Color(0xFF34383C),
|
||||
)
|
||||
|
||||
private fun kdeLight(accent: Color) =
|
||||
lightColorScheme(
|
||||
primary = accent,
|
||||
onPrimary = onAccent(accent),
|
||||
secondary = accent,
|
||||
tertiary = accent,
|
||||
background = Color(0xFFEFF0F1),
|
||||
onBackground = Color(0xFF232629),
|
||||
surface = Color(0xFFFCFCFC),
|
||||
onSurface = Color(0xFF232629),
|
||||
surfaceVariant = Color(0xFFE5E9EC),
|
||||
onSurfaceVariant = Color(0xFF4D4D4D),
|
||||
surfaceContainer = Color(0xFFF2F3F4),
|
||||
surfaceContainerHigh = Color(0xFFEAECEE),
|
||||
surfaceContainerHighest = Color(0xFFE0E3E5),
|
||||
surfaceContainerLow = Color(0xFFF7F8F9),
|
||||
surfaceContainerLowest = Color(0xFFFFFFFF),
|
||||
outline = Color(0xFFBABEC2),
|
||||
outlineVariant = Color(0xFFD9DCDF),
|
||||
)
|
||||
|
||||
// ── Windows 11 (WinUI 3 / Mica) ───────────────────────────────────────────
|
||||
|
||||
private fun windowsDark(accent: Color) =
|
||||
darkColorScheme(
|
||||
primary = accent,
|
||||
onPrimary = onAccent(accent),
|
||||
secondary = accent,
|
||||
tertiary = accent,
|
||||
background = Color(0xFF202020),
|
||||
onBackground = Color(0xFFFFFFFF),
|
||||
surface = Color(0xFF2B2B2B),
|
||||
onSurface = Color(0xFFFFFFFF),
|
||||
surfaceVariant = Color(0xFF323232),
|
||||
onSurfaceVariant = Color(0xFFCCCCCC),
|
||||
surfaceContainer = Color(0xFF272727),
|
||||
surfaceContainerHigh = Color(0xFF323232),
|
||||
surfaceContainerHighest = Color(0xFF3D3D3D),
|
||||
surfaceContainerLow = Color(0xFF1F1F1F),
|
||||
surfaceContainerLowest = Color(0xFF1A1A1A),
|
||||
outline = Color(0xFF5E5E5E),
|
||||
outlineVariant = Color(0xFF3A3A3A),
|
||||
)
|
||||
|
||||
private fun windowsLight(accent: Color) =
|
||||
lightColorScheme(
|
||||
primary = accent,
|
||||
onPrimary = onAccent(accent),
|
||||
secondary = accent,
|
||||
tertiary = accent,
|
||||
background = Color(0xFFF3F3F3),
|
||||
onBackground = Color(0xFF1A1A1A),
|
||||
surface = Color(0xFFFBFBFB),
|
||||
onSurface = Color(0xFF1A1A1A),
|
||||
surfaceVariant = Color(0xFFEDEDED),
|
||||
onSurfaceVariant = Color(0xFF555555),
|
||||
surfaceContainer = Color(0xFFF6F6F6),
|
||||
surfaceContainerHigh = Color(0xFFEDEDED),
|
||||
surfaceContainerHighest = Color(0xFFE5E5E5),
|
||||
surfaceContainerLow = Color(0xFFFAFAFA),
|
||||
surfaceContainerLowest = Color(0xFFFFFFFF),
|
||||
outline = Color(0xFFB0B0B0),
|
||||
outlineVariant = Color(0xFFD8D8D8),
|
||||
)
|
||||
|
||||
// ── Generic (other Linux DEs / Unknown) ──────────────────────────────────
|
||||
|
||||
private fun genericDark(accent: Color) =
|
||||
darkColorScheme(
|
||||
primary = accent,
|
||||
onPrimary = onAccent(accent),
|
||||
secondary = accent,
|
||||
tertiary = accent,
|
||||
background = Color(0xFF1E1E1E),
|
||||
surface = Color(0xFF1E1E1E),
|
||||
surfaceVariant = Color(0xFF2A2A2A),
|
||||
)
|
||||
|
||||
private fun genericLight(accent: Color) =
|
||||
lightColorScheme(
|
||||
primary = accent,
|
||||
onPrimary = onAccent(accent),
|
||||
secondary = accent,
|
||||
tertiary = accent,
|
||||
)
|
||||
|
||||
/**
|
||||
* Picks readable text color (white or black) on top of the given accent based on
|
||||
* its perceived luminance (Rec. 709 weights).
|
||||
*/
|
||||
private fun onAccent(accent: Color): Color {
|
||||
val luminance = 0.2126f * accent.red + 0.7152f * accent.green + 0.0722f * accent.blue
|
||||
return if (luminance > 0.55f) Color.Black else Color.White
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.platform.Typeface
|
||||
import org.jetbrains.skia.FontMgr
|
||||
import org.jetbrains.skia.FontStyle
|
||||
import java.awt.GraphicsEnvironment
|
||||
|
||||
/**
|
||||
* Resolves a [FontFamily] backed by the host OS's preferred UI font, with a
|
||||
* per-OS fallback chain. Uses Skia's font manager to look up the family by name
|
||||
* — names not installed on the system are skipped, so the order acts as a chain.
|
||||
*
|
||||
* Targets per OS:
|
||||
* - macOS: SF Pro Text (the system UI font; "Helvetica Neue" / "Helvetica" as fallbacks).
|
||||
* - GNOME: Adwaita Sans (47+), Cantarell (older), Inter, Noto Sans.
|
||||
* - KDE Plasma: Noto Sans (Plasma's default), then Inter / DejaVu Sans.
|
||||
* - Windows: Segoe UI Variable Text (Win 11), Segoe UI (Win 10).
|
||||
*
|
||||
* The final fallback is `FontFamily.Default` so Compose still has something to draw
|
||||
* with on stripped-down systems.
|
||||
*/
|
||||
object PlatformFonts {
|
||||
val ui: FontFamily by lazy { resolve(uiCandidates()) }
|
||||
|
||||
val mono: FontFamily by lazy { resolve(monoCandidates(), fallback = FontFamily.Monospace) }
|
||||
|
||||
private fun uiCandidates(): List<String> =
|
||||
when (PlatformInfo.current) {
|
||||
Platform.MACOS -> {
|
||||
listOf("SF Pro Text", ".AppleSystemUIFont", "Helvetica Neue", "Helvetica")
|
||||
}
|
||||
|
||||
Platform.WINDOWS -> {
|
||||
listOf("Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI")
|
||||
}
|
||||
|
||||
Platform.GNOME -> {
|
||||
listOf("Adwaita Sans", "Cantarell", "Inter", "Noto Sans", "DejaVu Sans")
|
||||
}
|
||||
|
||||
Platform.KDE -> {
|
||||
listOf("Noto Sans", "Inter", "DejaVu Sans", "Cantarell")
|
||||
}
|
||||
|
||||
Platform.LINUX_OTHER -> {
|
||||
listOf("Inter", "Noto Sans", "DejaVu Sans", "Cantarell")
|
||||
}
|
||||
|
||||
Platform.UNKNOWN -> {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun monoCandidates(): List<String> =
|
||||
when (PlatformInfo.current) {
|
||||
Platform.MACOS -> {
|
||||
listOf("SF Mono", "Menlo", "Monaco")
|
||||
}
|
||||
|
||||
Platform.WINDOWS -> {
|
||||
listOf("Cascadia Mono", "Cascadia Code", "Consolas")
|
||||
}
|
||||
|
||||
Platform.GNOME -> {
|
||||
listOf("Adwaita Mono", "Source Code Pro", "DejaVu Sans Mono")
|
||||
}
|
||||
|
||||
Platform.KDE -> {
|
||||
listOf("Hack", "Noto Sans Mono", "Source Code Pro")
|
||||
}
|
||||
|
||||
Platform.LINUX_OTHER -> {
|
||||
listOf("JetBrains Mono", "Source Code Pro", "DejaVu Sans Mono")
|
||||
}
|
||||
|
||||
Platform.UNKNOWN -> {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolve(
|
||||
candidates: List<String>,
|
||||
fallback: FontFamily = FontFamily.Default,
|
||||
): FontFamily {
|
||||
if (candidates.isEmpty()) return fallback
|
||||
val installed = installedFamilies
|
||||
val name = candidates.firstOrNull { it in installed } ?: return fallback
|
||||
return systemFamily(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [FontFamily] from a system family name. Skia's `FontMgr.matchFamilyStyle`
|
||||
* gives us a regular-weight typeface; Skia synthesizes bold/italic from it on demand,
|
||||
* which is how every native UI toolkit on desktop renders system fonts.
|
||||
*/
|
||||
private fun systemFamily(name: String): FontFamily {
|
||||
val skTypeface =
|
||||
runCatching { FontMgr.default.matchFamilyStyle(name, FontStyle.NORMAL) }
|
||||
.getOrNull() ?: return FontFamily.Default
|
||||
return FontFamily(Typeface(skTypeface))
|
||||
}
|
||||
|
||||
private val installedFamilies: Set<String> by lazy {
|
||||
try {
|
||||
GraphicsEnvironment
|
||||
.getLocalGraphicsEnvironment()
|
||||
.availableFontFamilyNames
|
||||
.toSet()
|
||||
} catch (e: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
/**
|
||||
* Identifies the host operating system and (on Linux) the desktop environment so the
|
||||
* UI can adopt native fonts, shapes, accent colors, and window chrome.
|
||||
*
|
||||
* Detection happens once at JVM start and is cached for the process lifetime.
|
||||
*/
|
||||
enum class Platform {
|
||||
MACOS,
|
||||
WINDOWS,
|
||||
GNOME,
|
||||
KDE,
|
||||
LINUX_OTHER,
|
||||
UNKNOWN,
|
||||
;
|
||||
|
||||
val isLinux: Boolean get() = this == GNOME || this == KDE || this == LINUX_OTHER
|
||||
}
|
||||
|
||||
object PlatformInfo {
|
||||
val current: Platform by lazy { detect() }
|
||||
|
||||
val isMacOS: Boolean get() = current == Platform.MACOS
|
||||
val isWindows: Boolean get() = current == Platform.WINDOWS
|
||||
val isGnome: Boolean get() = current == Platform.GNOME
|
||||
val isKde: Boolean get() = current == Platform.KDE
|
||||
val isLinux: Boolean get() = current.isLinux
|
||||
|
||||
private fun detect(): Platform {
|
||||
val osName = System.getProperty("os.name", "").lowercase()
|
||||
return when {
|
||||
osName.contains("mac") || osName.contains("darwin") -> Platform.MACOS
|
||||
osName.contains("win") -> Platform.WINDOWS
|
||||
osName.contains("nux") || osName.contains("nix") -> detectLinuxEnv()
|
||||
else -> Platform.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectLinuxEnv(): Platform {
|
||||
// Per the freedesktop spec, XDG_CURRENT_DESKTOP is the canonical hint.
|
||||
// It's a colon-separated list — match any token.
|
||||
val xdg = System.getenv("XDG_CURRENT_DESKTOP")?.lowercase().orEmpty()
|
||||
val session = System.getenv("XDG_SESSION_DESKTOP")?.lowercase().orEmpty()
|
||||
val desktop = System.getenv("DESKTOP_SESSION")?.lowercase().orEmpty()
|
||||
val combined = "$xdg:$session:$desktop"
|
||||
|
||||
return when {
|
||||
"gnome" in combined || "unity" in combined || "pantheon" in combined -> Platform.GNOME
|
||||
"kde" in combined || "plasma" in combined -> Platform.KDE
|
||||
else -> Platform.LINUX_OTHER
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Per-OS Material3 [Shapes] tuned to match each platform's native rounding language.
|
||||
* Material's defaults (4 / 4 / 0 dp) read as Android — these values match what users
|
||||
* see in their OS's first-party apps.
|
||||
*
|
||||
* - macOS (Sonoma+): ~10 / 12 / 16 dp continuous-style corners.
|
||||
* - GNOME (libadwaita): 9 / 12 / 16 dp — adw_dialog / adw_card baseline.
|
||||
* - KDE (Breeze): 6 / 8 / 12 dp — Breeze prefers tighter rounding than libadwaita.
|
||||
* - Windows (WinUI 3): 4 / 8 / 8 dp — WinUI's mica surfaces use modest rounding.
|
||||
*/
|
||||
object PlatformShapes {
|
||||
val current: Shapes by lazy {
|
||||
when (PlatformInfo.current) {
|
||||
Platform.MACOS -> {
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(6.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(10.dp),
|
||||
large = RoundedCornerShape(14.dp),
|
||||
extraLarge = RoundedCornerShape(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Platform.GNOME -> {
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(6.dp),
|
||||
small = RoundedCornerShape(9.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Platform.KDE -> {
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(4.dp),
|
||||
small = RoundedCornerShape(6.dp),
|
||||
medium = RoundedCornerShape(8.dp),
|
||||
large = RoundedCornerShape(12.dp),
|
||||
extraLarge = RoundedCornerShape(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Platform.WINDOWS -> {
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(4.dp),
|
||||
small = RoundedCornerShape(4.dp),
|
||||
medium = RoundedCornerShape(8.dp),
|
||||
large = RoundedCornerShape(8.dp),
|
||||
extraLarge = RoundedCornerShape(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Platform.LINUX_OTHER, Platform.UNKNOWN -> {
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(6.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(10.dp),
|
||||
large = RoundedCornerShape(14.dp),
|
||||
extraLarge = RoundedCornerShape(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.FrameWindowScope
|
||||
|
||||
/**
|
||||
* Wraps content in a [MaterialTheme] tuned for the host OS: native fonts, accent
|
||||
* color, surface tones, and shape rounding all match what the user sees in their
|
||||
* other apps.
|
||||
*
|
||||
* Pair this with [applyNativeWindowChrome] inside the [androidx.compose.ui.window.Window]
|
||||
* scope to also get native title-bar treatment (transparent title bar with
|
||||
* traffic-light inset on macOS).
|
||||
*/
|
||||
@Composable
|
||||
fun PlatformMaterialTheme(
|
||||
isDark: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val accent = remember { PlatformAccent.systemAccent() }
|
||||
val colorScheme = remember(isDark, accent) { PlatformColorScheme.resolve(isDark, accent) }
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = PlatformTypography.current,
|
||||
shapes = PlatformShapes.current,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inset to apply at the top of window content so it doesn't underlap the macOS
|
||||
* traffic lights once `applyNativeWindowChrome` has made the title bar transparent
|
||||
* + full-content. ~28 dp clears the buttons with comfortable padding. On non-macOS
|
||||
* platforms returns 0 dp so layouts stay flush.
|
||||
*/
|
||||
val titleBarInsetTop: Dp
|
||||
get() = if (PlatformInfo.isMacOS) 28.dp else 0.dp
|
||||
|
||||
/**
|
||||
* Applies platform-specific native window chrome.
|
||||
*
|
||||
* On macOS:
|
||||
* - `apple.awt.transparentTitleBar = true` removes the title bar tint so app
|
||||
* content shows through.
|
||||
* - `apple.awt.fullWindowContent = true` lets content extend under the title bar
|
||||
* while the traffic lights stay drawn on top.
|
||||
* - `apple.awt.windowTitleVisible = false` hides the "Amethyst" string so the
|
||||
* bar is clean.
|
||||
*
|
||||
* On other platforms this is a no-op — the OS chrome stays as-is, which is
|
||||
* already what users expect (custom Compose-drawn title bars on Linux/Windows
|
||||
* mostly look worse than the system's, especially for window snapping & a11y).
|
||||
*/
|
||||
@Composable
|
||||
fun FrameWindowScope.applyNativeWindowChrome() {
|
||||
if (!PlatformInfo.isMacOS) return
|
||||
val rootPane = window.rootPane
|
||||
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
|
||||
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
|
||||
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Material3 [Typography] using the host OS's preferred UI font with per-OS letter
|
||||
* spacing tuned to match native apps. Material's defaults are positively tracked
|
||||
* (Roboto-style); Apple's SF Pro and GNOME's Adwaita are tracked tighter at the
|
||||
* larger sizes, so we mirror that.
|
||||
*
|
||||
* The body sizes stay close to Material defaults so the existing layout code that
|
||||
* assumes 14–16sp body text doesn't reflow.
|
||||
*/
|
||||
object PlatformTypography {
|
||||
val current: Typography by lazy { build(PlatformFonts.ui) }
|
||||
|
||||
private fun build(family: FontFamily): Typography {
|
||||
// Per-OS letter spacing offset applied to display/headline styles.
|
||||
val tightening: TextUnit =
|
||||
when (PlatformInfo.current) {
|
||||
Platform.MACOS -> (-0.4).sp
|
||||
|
||||
// SF Pro is set tight at large sizes
|
||||
Platform.GNOME -> (-0.2).sp
|
||||
|
||||
// Adwaita Sans is mildly tight
|
||||
Platform.KDE, Platform.WINDOWS -> 0.sp
|
||||
|
||||
else -> 0.sp
|
||||
}
|
||||
|
||||
fun ts(
|
||||
size: Int,
|
||||
line: Int,
|
||||
weight: FontWeight = FontWeight.Normal,
|
||||
tracking: TextUnit = 0.sp,
|
||||
) = TextStyle(
|
||||
fontFamily = family,
|
||||
fontWeight = weight,
|
||||
fontSize = size.sp,
|
||||
lineHeight = line.sp,
|
||||
letterSpacing = tracking,
|
||||
)
|
||||
|
||||
return Typography(
|
||||
displayLarge = ts(57, 64, FontWeight.Normal, tightening),
|
||||
displayMedium = ts(45, 52, FontWeight.Normal, tightening),
|
||||
displaySmall = ts(36, 44, FontWeight.Normal, tightening),
|
||||
headlineLarge = ts(32, 40, FontWeight.SemiBold, tightening),
|
||||
headlineMedium = ts(28, 36, FontWeight.SemiBold, tightening),
|
||||
headlineSmall = ts(24, 32, FontWeight.SemiBold, tightening),
|
||||
titleLarge = ts(22, 28, FontWeight.SemiBold),
|
||||
titleMedium = ts(16, 24, FontWeight.Medium, 0.15.sp),
|
||||
titleSmall = ts(14, 20, FontWeight.Medium, 0.1.sp),
|
||||
bodyLarge = ts(16, 24, FontWeight.Normal, 0.15.sp),
|
||||
bodyMedium = ts(14, 20, FontWeight.Normal, 0.25.sp),
|
||||
bodySmall = ts(12, 16, FontWeight.Normal, 0.4.sp),
|
||||
labelLarge = ts(14, 20, FontWeight.Medium, 0.1.sp),
|
||||
labelMedium = ts(12, 16, FontWeight.Medium, 0.5.sp),
|
||||
labelSmall = ts(11, 16, FontWeight.Medium, 0.5.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -57,9 +57,9 @@ fun DeckSidebar(
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.width(48.dp)
|
||||
.width(56.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
|
||||
Reference in New Issue
Block a user