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:
Claude
2026-04-24 13:15:05 +00:00
parent 9874a44956
commit a15c5d2692
10 changed files with 1073 additions and 7 deletions
@@ -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 -> {
@@ -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
}
}
@@ -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
}
@@ -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
}
}
@@ -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()
}
}
}
@@ -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
}
}
}
@@ -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),
)
}
}
}
}
@@ -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)
}
@@ -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 1416sp 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),
)
}
}
@@ -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,