diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 6b5472079..e0ac62577 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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 -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt new file mode 100644 index 000000000..48dc1950c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt @@ -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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt new file mode 100644 index 000000000..e2e6d1008 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt @@ -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] 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 { + 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 +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt new file mode 100644 index 000000000..5018a7f2d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt @@ -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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformFonts.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformFonts.kt new file mode 100644 index 000000000..8cddb3431 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformFonts.kt @@ -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 = + 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 = + 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, + 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 by lazy { + try { + GraphicsEnvironment + .getLocalGraphicsEnvironment() + .availableFontFamilyNames + .toSet() + } catch (e: Exception) { + emptySet() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt new file mode 100644 index 000000000..58f41b32c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt @@ -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 + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt new file mode 100644 index 000000000..fbd183140 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt @@ -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), + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt new file mode 100644 index 000000000..739d0ee0a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt @@ -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) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt new file mode 100644 index 000000000..f11e02839 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt @@ -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), + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index a5a282e00..807cfd167 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -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,