Merge pull request #2549 from vitorpamplona/claude/improve-desktop-design-iOokB

Add native OS theming and improve desktop UI layout
This commit is contained in:
Vitor Pamplona
2026-04-25 19:28:51 -04:00
committed by GitHub
51 changed files with 3179 additions and 1224 deletions
@@ -592,7 +592,7 @@ fun AmethystTheme(
colorScheme = colors,
typography = Typography,
shapes = Shapes,
content = { ProvideMaterialSymbols(content) },
content = { ProvideMaterialSymbols(content = content) },
)
val view = LocalView.current
@@ -47,24 +47,31 @@ private const val TEXT_MEASURER_CACHE_SIZE = 64
/**
* Builds the Material Symbols FontFamily and a shared TextMeasurer once for the subtree and
* exposes them via CompositionLocal. Wrap app roots (AmethystTheme, desktop MaterialTheme) in this.
*
* The optional [weight] override lets callers pick a stroke thickness different from the
* library default — desktop uses per-OS weights (macOS likes the thinner SF-Symbols-ish
* look at 200, Windows Fluent icons sit closer to 400) while Android keeps the default.
*/
@Composable
fun ProvideMaterialSymbols(content: @Composable () -> Unit) {
fun ProvideMaterialSymbols(
weight: Int = MaterialSymbolsDefaults.WEIGHT,
content: @Composable () -> Unit,
) {
val font =
Font(
resource = Res.font.material_symbols_outlined,
weight = FontWeight(MaterialSymbolsDefaults.WEIGHT),
weight = FontWeight(weight),
variationSettings =
FontVariation.Settings(
FontVariation.weight(MaterialSymbolsDefaults.WEIGHT),
FontVariation.weight(weight),
FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL),
FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE),
FontVariation.Setting("GRAD", MaterialSymbolsDefaults.GRADE),
),
)
// Keyless remember: the Font wrapper identity changes every composition but the underlying
// resource is a compile-time constant, so one FontFamily for the lifetime of the subtree.
val fontFamily = remember { FontFamily(font) }
// Keyless remember is safe only when weight is stable; key on weight so a platform-
// preview override swap actually rebuilds the FontFamily.
val fontFamily = remember(weight) { FontFamily(font) }
val textMeasurer = rememberTextMeasurer(cacheSize = TEXT_MEASURER_CACHE_SIZE)
CompositionLocalProvider(
LocalMaterialSymbolsFontFamily provides fontFamily,
+143
View File
@@ -0,0 +1,143 @@
# Manual Testing: Desktop Native Theming
The desktop app adapts its colors, fonts, shapes, and accent to the host OS
(macOS, Windows, GNOME, KDE, other Linux). This guide shows how to preview
each platform's theme without leaving your dev machine, and what to look at
when reviewing a theming change.
## Quick Start
Three environment variables drive the preview overrides. Each one also has
a `-Damethyst.<key>=<value>` system-property form, forwarded from gradle to
the launched app's JVM.
| Variable | Values | Effect |
|---|---|---|
| `AMETHYST_PLATFORM` | `MACOS`, `WINDOWS`, `GNOME`, `KDE`, `LINUX_OTHER`, `UNKNOWN` | Forces in-app theming for that OS |
| `AMETHYST_APPEARANCE` | `light`, `dark` | Forces dark/light mode |
| `AMETHYST_ACCENT` | `#RRGGBB`, `RRGGBB`, or libadwaita name (`blue`, `teal`, `green`, `yellow`, `orange`, `red`, `pink`, `purple`, `slate`) | Forces accent color |
Examples:
```bash
# Native (no override) — uses your real OS
./gradlew :desktopApp:run
# GNOME light theme with the libadwaita default blue accent
AMETHYST_PLATFORM=GNOME AMETHYST_APPEARANCE=light AMETHYST_ACCENT=blue ./gradlew :desktopApp:run
# KDE Breeze dark with a custom accent
AMETHYST_PLATFORM=KDE AMETHYST_APPEARANCE=dark AMETHYST_ACCENT=#3DAEE9 ./gradlew :desktopApp:run
# Windows 11 (WinUI 3 mica tones)
AMETHYST_PLATFORM=WINDOWS ./gradlew :desktopApp:run
# Equivalent system-property form
./gradlew :desktopApp:run -Damethyst.platform=GNOME -Damethyst.appearance=light
```
## What Changes vs. What Doesn't
The override swaps **in-app theming only**. The window chrome (title bar,
traffic lights / minimize-maximize buttons, screen menu bar on macOS) is
drawn by AWT from the actual host OS, not by our theme code. So:
| Element | Follows override? | Notes |
|---|---|---|
| `colorScheme` (background, surface, primary…) | ✅ | Per-OS reference palettes |
| Body / heading fonts | ✅ | SF Pro on macOS, Cantarell on GNOME, Noto Sans on KDE, Segoe UI Variable on Windows |
| Button / card / dialog rounding | ✅ | macOS 8/10/14, libadwaita 9/12/16, Breeze 6/8/12, WinUI 4/8/8 |
| Accent color | ✅ | Threaded through `MaterialTheme.colorScheme.primary` |
| Sidebar density (56 dp) | ✅ | Same on all OSes (desktop convention) |
| Native title bar / traffic lights | ❌ | Drawn by host OS — to see the real GNOME header bar or KDE Breeze title, you need a real Linux machine or VM |
| macOS screen menu bar | ❌ | Only active when host OS is macOS |
| `apple.awt.transparentTitleBar` content extension | ❌ | macOS-host-only |
## Review Checklist
When reviewing a theming change, launch each preview and verify:
### macOS (`AMETHYST_PLATFORM=MACOS`, or no override on a Mac)
- [ ] Sidebar background reads as `surfaceContainer` — slightly lighter than the deck background, not jarringly different
- [ ] Body text renders in SF Pro Text (check by zooming a screenshot — SF has distinctive 'a', 'g', 'k' shapes)
- [ ] Card / dialog corners ~10 dp (a hair tighter than libadwaita)
- [ ] Letter spacing is slightly tight at large headings (SF tightens at display sizes)
- [ ] On a real Mac: traffic lights sit at top-left over the sidebar color, NOT over a white default-OS strip
- [ ] On a real Mac: menu bar appears at the top of the screen, not inside the window
### GNOME (`AMETHYST_PLATFORM=GNOME`)
- [ ] Surfaces match libadwaita references: `#242424` window bg dark, `#FAFAFA` window bg light
- [ ] Cards have 12 dp medium rounding (visibly more rounded than macOS)
- [ ] If Cantarell or Adwaita Sans is installed locally, body text uses it; otherwise falls through to Inter / Noto Sans
- [ ] Try `AMETHYST_ACCENT=blue` and confirm primary color is `#3584E4` (libadwaita default)
### KDE (`AMETHYST_PLATFORM=KDE`)
- [ ] Surfaces match Breeze references: `#1B1E20` background dark, `#EFF0F1` background light
- [ ] Rounding is tighter than macOS / GNOME (8 dp medium, 6 dp small)
- [ ] Body text renders in Noto Sans if installed
- [ ] Default accent (when nothing forced) is the Amethyst purple fallback — KDE accent detection won't run on macOS
### Windows (`AMETHYST_PLATFORM=WINDOWS`)
- [ ] Surfaces match WinUI 3 mica tones: `#202020` background dark, `#F3F3F3` background light
- [ ] Rounding is the tightest of any platform: 4 dp small, 8 dp medium
- [ ] Body text uses Segoe UI Variable Text only if installed locally (not present on macOS by default — falls back to FontFamily.Default)
## Side-by-side Comparison
The launched app is a single window. To compare two themes you currently
need to launch the app twice:
```bash
# Terminal 1
AMETHYST_PLATFORM=GNOME ./gradlew :desktopApp:run
# Terminal 2 (after the first finishes building)
AMETHYST_PLATFORM=MACOS ./gradlew :desktopApp:run
```
Each launch opens its own window — drag them next to each other.
## Known Limitations
1. **No native chrome on host OS.** The window frame, title bar buttons,
and (on macOS) screen menu bar always come from the real host OS.
To see a real GNOME header bar or KDE title bar, use a Linux machine
or VM.
2. **OS detection shell-outs return defaults when their CLI is missing.**
On macOS, `gsettings` and `kreadconfig5` aren't installed, so
`AMETHYST_PLATFORM=GNOME` without `AMETHYST_APPEARANCE` defaults to
dark and without `AMETHYST_ACCENT` defaults to Amethyst purple. Pass
the explicit overrides to control them.
3. **Font fallback chain is deterministic but not always satisfying.**
The chain (e.g. for GNOME: Adwaita Sans → Cantarell → Inter → Noto
Sans → DejaVu Sans) walks Skia's font manager and picks the first
installed family. If none of the candidates are installed,
`FontFamily.Default` is used (looks like Roboto-ish). To install the
GNOME family on macOS for testing:
```bash
brew install --cask font-cantarell
```
4. **Accent name list is libadwaita-only.** Apple's named accents (red,
orange, etc. as integers) and Windows registry accents resolve only
when the corresponding host OS is the real OS. Use hex
(`AMETHYST_ACCENT=#FF6B35`) for arbitrary colors.
## Where the Code Lives
All preview behavior is in `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/`:
- `PlatformInfo.kt` — OS detection + `amethyst.platform` override
- `PlatformAppearance.kt` — dark/light detection + `amethyst.appearance` override
- `PlatformAccent.kt` — accent detection + `amethyst.accent` override
- `PlatformFonts.kt` — system font resolution via Skia FontMgr
- `PlatformShapes.kt` — per-OS Material3 Shapes
- `PlatformTypography.kt` — per-OS Material3 Typography
- `PlatformColorScheme.kt` — per-OS dark/light ColorSchemes
- `PlatformTheme.kt` — `PlatformMaterialTheme` composable + `applyNativeWindowChrome()`
+7
View File
@@ -90,6 +90,13 @@ compose.desktop {
jvmArgs += "-Xmx2g"
// Forward platform-preview overrides from the gradle invocation to the
// launched app's JVM so `./gradlew :desktopApp:run -Damethyst.platform=GNOME`
// works in addition to the env-var form (`AMETHYST_PLATFORM=GNOME`).
listOf("amethyst.platform", "amethyst.appearance", "amethyst.accent").forEach { key ->
System.getProperty(key)?.let { jvmArgs += "-D$key=$it" }
}
nativeDistributions {
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm)
@@ -37,6 +37,7 @@ object DesktopPreferences {
private const val KEY_LAST_SCREEN = "last_screen"
private const val KEY_DECK_COLUMNS = "deck_columns"
private const val KEY_LAYOUT_MODE = "layout_mode"
private const val KEY_MESSAGES_LIST_WIDTH = "messages_list_width_dp"
var feedMode: FeedMode
get() {
@@ -69,6 +70,13 @@ object DesktopPreferences {
prefs.put(KEY_LAYOUT_MODE, value)
}
/** Width (dp) of the conversation list pane in the Messages screen. */
var messagesListWidthDp: Float
get() = prefs.getFloat(KEY_MESSAGES_LIST_WIDTH, 280f)
set(value) {
prefs.putFloat(KEY_MESSAGES_LIST_WIDTH, value)
}
private const val KEY_WORKSPACES = "workspaces"
var workspaces: String
@@ -20,14 +20,17 @@
*/
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
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -45,7 +48,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
@@ -60,6 +62,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut
import androidx.compose.ui.unit.dp
@@ -81,6 +85,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
@@ -130,7 +135,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,
@@ -183,6 +188,42 @@ 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")
}
// Set the dock / taskbar icon image before any window is shown.
//
// On macOS the Cmd+Tab app switcher and the dock use the Taskbar API's
// iconImage, NOT the Window(icon=) composable parameter (which only sets
// the in-title-bar proxy icon). Without this, a JVM launched via gradle
// shows the generic Java coffee-cup square in Cmd+Tab. ImageIO preserves
// the PNG alpha channel so the dock renders the logo with transparency;
// on macOS the logo is then wrapped in a squircle so it matches
// first-party dock icons.
try {
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
val adapted =
raw?.let {
com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon
.adaptForHost(it)
}
if (adapted != null && java.awt.Taskbar.isTaskbarSupported()) {
val taskbar = java.awt.Taskbar.getTaskbar()
if (taskbar.isSupported(java.awt.Taskbar.Feature.ICON_IMAGE)) {
taskbar.iconImage = adapted
}
}
} catch (e: Exception) {
Log.w("Main") { "Failed to set dock icon: ${e.message}" }
}
Log.minLevel = LogLevel.DEBUG
DesktopImageLoaderSetup.setup()
Runtime.getRuntime().addShutdownHook(
@@ -241,11 +282,35 @@ fun main() {
// Callback set by App() for single pane navigation from MenuBar
var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) }
// Window title-bar / taskbar thumbnail icon. On macOS the source logo
// is wrapped in a squircle so it matches every other dock icon; on
// other platforms the raw transparent logo is used as-is.
val appIcon =
remember {
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
val adapted =
com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon
.adaptForHost(raw)
val buf = java.io.ByteArrayOutputStream()
javax.imageio.ImageIO.write(adapted, "png", buf)
val bitmap =
org.jetbrains.skia.Image
.makeFromEncoded(buf.toByteArray())
.toComposeImageBitmap()
BitmapPainter(bitmap)
}
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst",
icon = appIcon,
) {
// 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(
@@ -737,10 +802,13 @@ 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(
weight = com.vitorpamplona.amethyst.desktop.platform.PlatformIconWeight.current,
) {
ProvideMaterialSymbols {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
@@ -1254,21 +1322,39 @@ fun RelaySettingsScreen(
accountManager.loadNwcConnection()
}
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
val sidePadding =
com.vitorpamplona.amethyst.desktop.ui
.readingHorizontalPadding()
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(horizontal = sidePadding),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Settings",
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
Spacer(Modifier.height(24.dp))
Spacer(Modifier.height(16.dp))
// Wallet Connect Section
Text(
"Wallet Connect (NWC)",
style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
@@ -1377,7 +1463,7 @@ fun RelaySettingsScreen(
Text(
"Relay Settings",
style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
@@ -1464,3 +1550,4 @@ fun RelaySettingsScreen(
}
}
}
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -31,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@@ -38,7 +40,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
@@ -178,42 +179,49 @@ fun ChessScreen(
var showNewGameDialog by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) {
// Header
// Header — Messages-style: compact row, titleMedium title, icon-only actions
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (selectedGameId != null) {
IconButton(onClick = { viewModel.selectGame(null) }) {
Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back to list")
IconButton(onClick = { viewModel.selectGame(null) }, modifier = Modifier.size(32.dp)) {
Icon(
MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = "Back to list",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(8.dp))
}
Text(
if (selectedGameId != null) "Live Game" else "Chess",
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
if (selectedGameId == null) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Refresh button
IconButton(onClick = { viewModel.forceRefresh() }) {
Icon(MaterialSymbols.Refresh, "Refresh")
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { viewModel.forceRefresh() }, modifier = Modifier.size(32.dp)) {
Icon(
MaterialSymbols.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
// New Game button
if (!account.isReadOnly) {
Button(onClick = { showNewGameDialog = true }) {
Icon(MaterialSymbols.Add, "New Game")
Spacer(Modifier.width(8.dp))
Text("New Game")
IconButton(onClick = { showNewGameDialog = true }, modifier = Modifier.size(32.dp)) {
Icon(
MaterialSymbols.Add,
contentDescription = "New Game",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
}
@@ -408,6 +416,7 @@ private fun ChessLobby(
LazyColumn(
state = listState,
contentPadding = PaddingValues(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Active games section (user is participant)
@@ -0,0 +1,192 @@
/*
* 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 {
/**
* Resolves the OS accent color, with an override hook for testing:
* `-Damethyst.accent=#3584E4` (system property) or `AMETHYST_ACCENT=blue`
* (env var). Accepted: `#RRGGBB`, `RRGGBB`, or any libadwaita accent name
* (blue, teal, green, yellow, orange, red, pink, purple, slate).
*/
fun systemAccent(): Color {
forcedAccent()?.let { return it }
return when (PlatformInfo.current) {
Platform.MACOS -> macOSAccent()
Platform.GNOME -> gnomeAccent()
Platform.KDE -> kdeAccent()
Platform.WINDOWS -> windowsAccent()
else -> DefaultPrimary
} ?: DefaultPrimary
}
private fun forcedAccent(): Color? {
val raw =
System.getProperty("amethyst.accent")
?: System.getenv("AMETHYST_ACCENT")
?: return null
val trimmed = raw.trim()
// Hex (#RRGGBB or RRGGBB) — long form for clarity
val hex = trimmed.removePrefix("#")
if (hex.length == 6 && hex.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
val v = hex.toLongOrNull(16) ?: return null
return Color(
red = ((v shr 16) and 0xFFL).toInt(),
green = ((v shr 8) and 0xFFL).toInt(),
blue = (v and 0xFFL).toInt(),
)
}
return gnomeAccents[trimmed.lowercase()]
}
// 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,166 @@
/*
* 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 java.awt.AlphaComposite
import java.awt.Color
import java.awt.RenderingHints
import java.awt.geom.RoundRectangle2D
import java.awt.image.BufferedImage
import java.awt.image.ConvolveOp
import java.awt.image.Kernel
/**
* Adapts a transparent source logo to the host OS's app-icon conventions.
*
* - macOS (Big Sur+) requires a "squircle" app icon template: the mark sits on
* a rounded-square background at ~22.5% corner radius with padding around the
* edges. A raw transparent logo in the dock looks out-of-place next to
* first-party apps, so we wrap it in a white squircle when running on macOS.
* - Other platforms return the source image unchanged Windows and most Linux
* DEs render transparent icons fine, and GNOME apps specifically tend to
* avoid background shapes.
*/
object PlatformAppIcon {
/**
* Wraps [source] in a macOS-style squircle if the host OS is macOS; returns
* the source unchanged otherwise. Not scaled by [PlatformInfo.current] (the
* preview override) the dock is drawn by the real host OS, so only the
* real host platform matters here.
*/
fun adaptForHost(source: BufferedImage): BufferedImage =
when (PlatformInfo.host) {
Platform.MACOS -> macOsSquircle(source)
else -> source
}
/**
* Renders [source] inside a white squircle following Apple's macOS app icon
* template (Big Sur+). The squircle fills ~80.5% of the canvas width
* (824/1024 per Apple's HIG) the rest is transparent margin, matching
* the size at which first-party dock icons render so ours doesn't appear
* oversized next to them.
*
* Implementation notes:
* - Apple's "squircle" is a superellipse, not a standard rounded rectangle;
* Java2D doesn't ship a superellipse primitive, so [RoundRectangle2D]
* with a ~22.37% corner radius (185/824) is the practical approximation
* used by most third-party tooling. At dock-icon sizes the difference
* is invisible.
* - The mark is padded ~10% inside the squircle so it doesn't touch the
* rounded corners.
*/
private fun macOsSquircle(source: BufferedImage): BufferedImage {
val canvas = 1024
// Apple's reference template: 824x824 squircle centered in a 1024x1024
// canvas (100px transparent margin each side).
val squircleSize = 824
val squircleMargin = (canvas - squircleSize) / 2
// Apple's reference corner radius on the 824-box is ~185px (≈22.45%).
val cornerDiameter = (squircleSize * 0.4490f)
// Mark padding inside the squircle: 5% each side (~90% fill) so the
// mark reads at roughly the same visual weight as first-party dock icons.
val markPadding = (squircleSize * 0.05f).toInt()
val markOrigin = squircleMargin + markPadding
val markSize = squircleSize - 2 * markPadding
// Drop shadow under the squircle — Apple's dock icons include a subtle
// shadow baked into the PNG; the dock doesn't add one at render time.
val shadowOffset = 8
val shadowBlur = 18
val squircle =
RoundRectangle2D.Float(
squircleMargin.toFloat(),
squircleMargin.toFloat(),
squircleSize.toFloat(),
squircleSize.toFloat(),
cornerDiameter,
cornerDiameter,
)
// Rasterize the shadow onto its own layer so the blur doesn't leak
// into the white squircle or the mark.
val shadowLayer = BufferedImage(canvas, canvas, BufferedImage.TYPE_INT_ARGB)
shadowLayer.createGraphics().apply {
setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
color = Color(0, 0, 0, 72) // ~28% black
val shadowShape =
RoundRectangle2D.Float(
squircleMargin.toFloat(),
(squircleMargin + shadowOffset).toFloat(),
squircleSize.toFloat(),
squircleSize.toFloat(),
cornerDiameter,
cornerDiameter,
)
fill(shadowShape)
dispose()
}
val blurredShadow = gaussianBlur(shadowLayer, shadowBlur)
val out = BufferedImage(canvas, canvas, BufferedImage.TYPE_INT_ARGB)
val g = out.createGraphics()
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC)
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
// Shadow first, then the white squircle on top, then the mark.
g.composite = AlphaComposite.SrcOver
g.drawImage(blurredShadow, 0, 0, null)
g.color = Color(0xFFFFFF)
g.fill(squircle)
g.clip = squircle
g.drawImage(source, markOrigin, markOrigin, markSize, markSize, null)
} finally {
g.dispose()
}
return out
}
/**
* Separable Gaussian blur via [ConvolveOp]. Splits the 2D kernel into two
* 1D passes (horizontal then vertical) O(N) per pixel instead of O()
* for a radius-N blur, which keeps startup snappy even at 1024x1024.
*/
private fun gaussianBlur(
src: BufferedImage,
radius: Int,
): BufferedImage {
if (radius < 1) return src
val size = radius * 2 + 1
val sigma = radius / 2f
val kernel = FloatArray(size)
var sum = 0f
for (i in 0 until size) {
val x = (i - radius).toFloat()
kernel[i] = kotlin.math.exp(-(x * x) / (2f * sigma * sigma))
sum += kernel[i]
}
for (i in 0 until size) kernel[i] /= sum
val horiz = ConvolveOp(Kernel(size, 1, kernel), ConvolveOp.EDGE_NO_OP, null)
val vert = ConvolveOp(Kernel(1, size, kernel), ConvolveOp.EDGE_NO_OP, null)
return vert.filter(horiz.filter(src, null), null)
}
}
@@ -0,0 +1,163 @@
/*
* 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 {
/**
* Resolves the OS dark/light preference, with an override hook for testing
* on a single machine: `-Damethyst.appearance=light|dark` (system property)
* or `AMETHYST_APPEARANCE=light|dark` (env var).
*
* When the platform is overridden but appearance isn't, this calls the
* override platform's detection routine. On a Mac forced to GNOME the
* `gsettings` shell-out won't exist and it falls through to the GNOME
* default (dark) pass `AMETHYST_APPEARANCE=light` to flip it.
*/
fun isSystemDark(): Boolean {
forcedAppearance()?.let { return it }
return when (PlatformInfo.current) {
Platform.MACOS -> isMacOSDark()
Platform.GNOME -> isGnomeDark()
Platform.KDE -> isKdeDark()
Platform.WINDOWS -> isWindowsDark()
else -> true
}
}
private fun forcedAppearance(): Boolean? {
val raw =
System.getProperty("amethyst.appearance")
?: System.getenv("AMETHYST_APPEARANCE")
?: return null
return when (raw.lowercase()) {
"dark" -> true
"light" -> false
else -> null
}
}
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,295 @@
/*
* 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): ColorScheme {
// Apple's accent palette includes Yellow (#F8BA00) and Graphite (#8C8C8C)
// which have terrible contrast on any light surface. Darken high-luminance
// accents so the primary color stays readable without disabling the user's
// preference — the hue is preserved, just pulled toward a usable lightness.
val readable = darkenForLight(accent)
return lightColorScheme(
primary = readable,
onPrimary = onAccent(readable),
secondary = readable,
tertiary = readable,
// Apple's light-mode main content background is near-white (#F5F5F7 /
// Apple's secondarySystemBackgroundColor); the #ECECEC gray is only
// used behind the title bar chrome — which we map to surfaceContainer.
background = Color(0xFFF5F5F7),
onBackground = Color(0xFF1A1A1A),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1A1A),
surfaceVariant = Color(0xFFECECEC),
onSurfaceVariant = Color(0xFF555555),
surfaceContainer = Color(0xFFECECEC),
surfaceContainerHigh = Color(0xFFE5E5E5),
surfaceContainerHighest = Color(0xFFDDDDDD),
surfaceContainerLow = Color(0xFFF0F0F0),
surfaceContainerLowest = Color(0xFFFFFFFF),
outline = Color(0xFFB0B0B0),
outlineVariant = Color(0xFFD8D8D8),
)
}
/**
* Scales an accent color toward black until its relative luminance is at
* most [maxLuminance]. Preserves hue (all RGB channels scale by the same
* factor). Only applied on light surfaces on dark surfaces the raw accent
* already has good contrast.
*
* 0.38 was picked empirically: it keeps a saturated blue visible without
* darkening it, while pulling yellow/graphite down to a readable amber/gray.
*/
private fun darkenForLight(
c: Color,
maxLuminance: Float = 0.38f,
): Color {
val lum = 0.2126f * c.red + 0.7152f * c.green + 0.0722f * c.blue
if (lum <= maxLuminance) return c
val scale = maxLuminance / lum
return Color(
red = (c.red * scale).coerceIn(0f, 1f),
green = (c.green * scale).coerceIn(0f, 1f),
blue = (c.blue * scale).coerceIn(0f, 1f),
alpha = c.alpha,
)
}
// ── 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,46 @@
/*
* 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
/**
* Preferred Material Symbols stroke weight per host OS. Each desktop UI language
* draws icons at a different visual weight; matching them makes the in-app icons
* feel at home next to the OS's own. Values use the Material Symbols variation
* axis (100 = Thin, 200 = ExtraLight, 300 = Light, 400 = Regular, , 700 = Bold).
*
* Reference calibration:
* - macOS: SF Symbols "Regular" is visually lighter than 400 Material Symbols;
* 200 (Thin/ExtraLight) matches the delicate stroke macOS users expect.
* - GNOME: libadwaita symbolic icons are a hair thinner than Regular 300 matches.
* - KDE Breeze: Breeze icons are thin-to-medium, also 300.
* - Windows (Fluent): Fluent Icons have moderate weight, 400 is the sweet spot.
*/
object PlatformIconWeight {
val current: Int by lazy {
when (PlatformInfo.current) {
Platform.MACOS -> 200
Platform.GNOME -> 300
Platform.KDE -> 300
Platform.WINDOWS -> 400
Platform.LINUX_OTHER, Platform.UNKNOWN -> 300
}
}
}
@@ -0,0 +1,95 @@
/*
* 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 {
/**
* The detected (or overridden) UI platform drives all per-OS theming.
*
* Override for testing on a single machine via `-Damethyst.platform=GNOME`
* (system property) or `AMETHYST_PLATFORM=GNOME` (env var). Accepted values
* are the [Platform] enum names, case-insensitive: MACOS, WINDOWS, GNOME,
* KDE, LINUX_OTHER, UNKNOWN.
*/
val current: Platform by lazy { override() ?: detect() }
/** The actual host OS, ignoring any override. Useful for shell-out helpers
* that should always hit the real underlying system (e.g. accent detection
* falling back to the Mac's value when the override platform's CLI is absent). */
val host: 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 override(): Platform? {
val raw =
System.getProperty("amethyst.platform")
?: System.getenv("AMETHYST_PLATFORM")
?: return null
return runCatching { Platform.valueOf(raw.uppercase()) }.getOrNull()
}
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),
)
}
}
@@ -29,12 +29,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@@ -63,6 +66,8 @@ import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownEditorState
import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar
import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip23LongContent.LongFormPublishAction
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
@@ -227,24 +232,41 @@ fun ArticleEditorScreen(
}
},
) {
// Top bar
// Header — Messages-style: back + titleMedium on left, actions on right
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBack) {
Text("Back")
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
Icon(
MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Spacer(Modifier.width(8.dp))
Text(
"Article",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
saveMessage?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterVertically),
)
}
// Save/Publish stay as text buttons — they're primary destructive
// actions, not affordances you'd reduce to an icon.
OutlinedButton(onClick = { saveDraft() }) {
Text("Save")
}
@@ -403,24 +403,25 @@ fun ArticleReaderScreen(
}
},
) {
// Top bar: back + bookmark placeholder
// Header — Messages-style: compact row, titleMedium title
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
Icon(
MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(8.dp))
Text(
"Article",
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
if (zoomLevel != 1.0f) {
@@ -22,10 +22,12 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -246,18 +248,20 @@ fun BookmarksScreen(
val currentEvents = if (selectedTab == BookmarkTab.PUBLIC) publicEvents else privateEvents
val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds
Column(modifier = Modifier.fillMaxSize()) {
ReadingColumn {
val sidePadding = readingHorizontalPadding()
// Header with tabs
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
.heightIn(min = 48.dp)
.padding(horizontal = sidePadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Bookmarks",
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
@@ -303,6 +307,7 @@ fun BookmarksScreen(
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = sidePadding),
) {
items(currentEvents, key = { it.id }) { event ->
Column(
@@ -20,19 +20,19 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
@@ -52,6 +52,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry
import kotlinx.coroutines.launch
@@ -65,33 +66,42 @@ fun DraftsScreen(
val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
ReadingColumn {
val sidePadding = readingHorizontalPadding()
Row(
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = sidePadding, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Drafts",
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Button(onClick = { onOpenEditor(null) }) {
Icon(MaterialSymbols.Add, contentDescription = null)
Text("New Draft", modifier = Modifier.padding(start = 4.dp))
// Convert "New Draft" button to an icon for consistency with other
// screens' tabs-first + icon-actions header pattern.
IconButton(onClick = { onOpenEditor(null) }, modifier = Modifier.size(32.dp)) {
Icon(
MaterialSymbols.Add,
contentDescription = "New Draft",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
Spacer(Modifier.height(16.dp))
if (drafts.isEmpty()) {
Text(
"No drafts yet. Click \"New Draft\" to start writing.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
EmptyState(
title = "No drafts yet",
description = "Click \"New Draft\" to start writing.",
)
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(drafts, key = { it.slug }) { entry ->
@@ -139,7 +149,8 @@ private fun DraftCard(
onDelete: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
@@ -18,11 +18,12 @@
* 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.commons.ui.feed
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -33,10 +34,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.theme.RelayStatusColors
/**
* Header component for feed screens with title and relay connection status.
* Compact Messages-style header for feed screens: titleMedium on the left,
* a single refresh IconButton on the right. Relay count is rolled into the
* refresh button's content description so it still surfaces in hover tooltips
* / accessibility readers without stealing a whole row.
*
* @param title The feed title
* @param connectedRelayCount Number of connected relays
@@ -51,62 +54,28 @@ fun FeedHeader(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = readingHorizontalPadding(), vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
title,
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
RelayStatusIndicator(
connectedCount = connectedRelayCount,
onRefresh = onRefresh,
)
}
}
/**
* Compact relay connection status indicator with refresh button.
*/
@Composable
fun RelayStatusIndicator(
connectedCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
IconButton(
onClick = onRefresh,
modifier = Modifier.size(32.dp),
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val statusColor =
when {
connectedCount == 0 -> RelayStatusColors.Disconnected
connectedCount < 3 -> RelayStatusColors.Connecting
else -> RelayStatusColors.Connected
}
Icon(
symbol = if (connectedCount > 0) MaterialSymbols.Check else MaterialSymbols.Close,
contentDescription = null,
tint = statusColor,
modifier = Modifier.size(16.dp),
)
Text(
"$connectedCount relay${if (connectedCount != 1) "s" else ""}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
IconButton(onClick = onRefresh) {
Icon(
MaterialSymbols.Refresh,
contentDescription = "Reconnect",
contentDescription = "Refresh ($connectedRelayCount relays connected)",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
@@ -20,12 +20,12 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -33,14 +33,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -487,9 +486,8 @@ fun FeedScreen(
onDispose { subId?.let { coordinator.releaseInteractions(it) } }
}
@OptIn(ExperimentalLayoutApi::class)
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
ReadingColumn {
// Header with compose button
FeedHeader(
feedMode = feedMode,
@@ -546,7 +544,9 @@ fun FeedScreen(
is FeedState.Loaded -> {
val loadedState by state.feed.collectAsState()
val sidePadding = LocalReadingSidePadding.current
LazyColumn(
contentPadding = PaddingValues(horizontal = sidePadding + 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(loadedState.list, key = { it.idHex }) { note ->
@@ -627,9 +627,15 @@ fun FeedScreen(
}
/**
* Feed header with title, mode selector, relay count, and compose button.
* Feed header \u2014 Messages-style single row: Following/Global tabs on the left,
* compact icon buttons on the right (relays, refresh, compose). The selected
* tab IS the title; no redundant "Global Feed" / "Following Feed" label.
*
* Relay count and followed-users count are surfaced via a hover tooltip on
* the relays icon (desktop convention \u2014 the at-a-glance info is preserved
* without stealing header real estate).
*/
@OptIn(ExperimentalLayoutApi::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FeedHeader(
feedMode: FeedMode,
@@ -642,90 +648,100 @@ private fun FeedHeader(
onNavigateToRelays: () -> Unit = {},
onOpenRelayPicker: () -> Unit = {},
) {
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
val sidePadding = LocalReadingSidePadding.current
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = sidePadding + 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column {
FlowRow(
verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
// Tabs \u2014 the selected one is the screen title.
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (account != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { onFeedModeChange(FeedMode.GLOBAL) },
label = { Text("Global") },
)
FilterChip(
selected = feedMode == FeedMode.FOLLOWING,
onClick = { onFeedModeChange(FeedMode.FOLLOWING) },
label = { Text("Following") },
)
}
}
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { onFeedModeChange(FeedMode.GLOBAL) },
label = { Text("Global") },
)
}
Spacer(Modifier.height(4.dp))
// Actions \u2014 compact icon buttons at the same scale as the Messages header.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${feedRelays.size} relays",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier.clickable { onNavigateToRelays() },
)
val relaysTooltip =
buildString {
append("${feedRelays.size} relays")
if (feedMode == FeedMode.FOLLOWING) {
append(" \u2022 $followedUsersCount followed")
}
}
TooltipArea(
tooltip = {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 2.dp,
shadowElevation = 4.dp,
) {
Text(
" \u2022 $followedUsersCount followed",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
relaysTooltip,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.width(8.dp))
if (account != null && !account.isReadOnly) {
},
) {
IconButton(
onClick = onOpenRelayPicker,
modifier = Modifier.size(24.dp),
onClick = {
if (account != null && !account.isReadOnly) {
onOpenRelayPicker()
} else {
onNavigateToRelays()
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.Dns,
contentDescription = "Edit Feed Relays",
contentDescription = relaysTooltip,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(4.dp))
}
IconButton(
onClick = onRefresh,
modifier = Modifier.size(24.dp),
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(20.dp),
)
}
if (account != null && !account.isReadOnly) {
IconButton(
onClick = onCompose,
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.Add,
contentDescription = "New Post",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
}
Button(
onClick = onCompose,
enabled = account != null && !account.isReadOnly,
) {
Icon(MaterialSymbols.Add, "New Post", Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("New Post")
}
}
}
@@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -56,7 +56,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPa
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
@@ -238,15 +237,13 @@ fun NotificationsScreen(
}
}
Column(modifier = Modifier.fillMaxSize()) {
ReadingColumn {
FeedHeader(
title = "Notifications",
connectedRelayCount = connectedRelays.size,
onRefresh = { relayManager.connect() },
)
Spacer(Modifier.height(16.dp))
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else if (notifications.isEmpty() && !initialLoadComplete) {
@@ -259,6 +256,7 @@ fun NotificationsScreen(
)
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification ->
@@ -0,0 +1,89 @@
/*
* 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.ui
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Maximum reading width for single-pane content screens. Matches the
* comfortable column width used by Twitter / Mastodon / Threads on desktop
* wider than a book column (which tops out around 600 dp), narrower than a
* full-window feed, so cards don't stretch disproportionately on 4K displays.
*/
val DefaultReadingWidth: Dp = 720.dp
/**
* Side padding the current screen should apply to its scrollable list /
* headers to keep content centered at [DefaultReadingWidth] within the
* window. `0.dp` outside of a [ReadingColumn].
*
* Screens use this to widen the gutter on wide displays (`horizontal =
* readingSidePadding + 12.dp`) while keeping the scrollable area itself at
* full window width so the mouse wheel scrolls the feed wherever it
* hovers, not only inside the 720 dp column.
*/
val LocalReadingSidePadding = compositionLocalOf { 0.dp }
/**
* Convenience: reads the current reading-column side padding and adds the
* standard 12.dp screen-edge gutter. Use this inside composable bodies when
* applying horizontal padding to header rows or `contentPadding` on
* LazyColumns so items stay centered at [DefaultReadingWidth] while the
* scrollable surface still spans the full window width.
*/
@Composable
fun readingHorizontalPadding(): Dp = LocalReadingSidePadding.current + 12.dp
/**
* Top-level scaffold for single-pane content screens. Measures the window
* width and computes the side padding that centers a [maxWidth]-wide column
* within it. The actual content (header + LazyColumn) fills the window the
* centering is done via [LocalReadingSidePadding] applied to inner modifiers
* (`horizontal` padding on a header Row, `contentPadding` on a LazyColumn).
* This keeps scroll events live across the full window, not just the center
* column.
*
* Not used by:
* - Messages (two-pane layout with its own sizing)
* - Article Reader (has its own narrower reading-width logic)
* - Editor / Chess / Relay Dashboard (tools that want full width)
*/
@Composable
fun ReadingColumn(
maxWidth: Dp = DefaultReadingWidth,
content: @Composable ColumnScope.() -> Unit,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val sidePadding = ((this.maxWidth - maxWidth) / 2).coerceAtLeast(0.dp)
CompositionLocalProvider(LocalReadingSidePadding provides sidePadding) {
Column(modifier = Modifier.fillMaxSize(), content = content)
}
}
}
@@ -23,16 +23,13 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
@@ -100,10 +97,8 @@ fun LongFormCard(
val publishedAt = event.publishedAt() ?: event.createdAt
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
@@ -289,66 +284,45 @@ fun ReadsScreen(
}
}
@OptIn(ExperimentalLayoutApi::class)
Column(modifier = Modifier.fillMaxSize()) {
// Header — wraps on narrow columns
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
ReadingColumn {
val sidePadding = readingHorizontalPadding()
// Header — Messages-style: tabs left, refresh right. The selected tab
// (Following / Global) acts as the screen title, so no separate label.
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = sidePadding, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column {
FlowRow(
verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Reads",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
// Feed mode selector
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (account != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { feedMode = FeedMode.GLOBAL },
label = { Text("Global") },
)
FilterChip(
selected = feedMode == FeedMode.FOLLOWING,
onClick = { feedMode = FeedMode.FOLLOWING },
label = { Text("Following") },
)
}
}
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { feedMode = FeedMode.GLOBAL },
label = { Text("Global") },
)
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${connectedRelays.size} relays connected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = { relayManager.connect() },
modifier = Modifier.size(24.dp),
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.Refresh,
contentDescription = "Refresh",
contentDescription = "Refresh (${connectedRelays.size} relays connected)",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(20.dp),
)
}
}
}
}
Spacer(Modifier.height(8.dp))
when {
connectedRelays.isEmpty() -> {
@@ -383,6 +357,7 @@ fun ReadsScreen(
else -> {
LazyColumn(
contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(events, key = { it.id }) { event ->
@@ -28,11 +28,13 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -46,7 +48,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -287,10 +288,12 @@ fun SearchScreen(
focusRequester.requestFocus()
}
ReadingColumn {
Column(
modifier =
modifier
.fillMaxSize()
.fillMaxWidth()
.fillMaxHeight()
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
@@ -329,14 +332,19 @@ fun SearchScreen(
)
// Title row
val sidePadding = readingHorizontalPadding()
Row(
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = sidePadding, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Search",
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
@@ -348,40 +356,87 @@ fun SearchScreen(
Spacer(Modifier.height(16.dp))
// Search bar with advanced toggle
// Search bar with advanced toggle — honors the reading width cap so it
// stays centered with the rest of the screen's content on wide windows.
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
// Compact desktop search field. M3's OutlinedTextField has a hard
// ~32dp vertical contentPadding baked in, so forcing .height(40dp)
// clipped the placeholder. BasicTextField + DecorationBox lets us
// override contentPadding to 8dp vertical so the field can sit at
// a true 40dp tall without clipping the bodyMedium line-height.
val searchInteraction =
remember {
androidx.compose.foundation.interaction
.MutableInteractionSource()
}
androidx.compose.foundation.text.BasicTextField(
value = textFieldValue,
onValueChange = {
textFieldValue = it
state.updateFromText(it.text)
},
modifier = Modifier.weight(1f).focusRequester(focusRequester),
placeholder = { Text("Search notes, people, tags... or use operators") },
modifier = Modifier.weight(1f).height(40.dp).focusRequester(focusRequester),
textStyle =
MaterialTheme.typography.bodyMedium
.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush =
androidx.compose.ui.graphics
.SolidColor(MaterialTheme.colorScheme.primary),
singleLine = true,
interactionSource = searchInteraction,
decorationBox = { innerTextField ->
androidx.compose.material3.OutlinedTextFieldDefaults.DecorationBox(
value = textFieldValue.text,
innerTextField = innerTextField,
enabled = true,
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
interactionSource = searchInteraction,
placeholder = {
Text(
"Search people, tags, notes…",
style = MaterialTheme.typography.bodyMedium,
)
},
leadingIcon = {
Icon(
MaterialSymbols.Search,
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
},
trailingIcon = {
if (displayText.isNotEmpty()) {
IconButton(onClick = { state.clearSearch() }) {
IconButton(onClick = { state.clearSearch() }, modifier = Modifier.size(28.dp)) {
Icon(
MaterialSymbols.Clear,
contentDescription = "Clear",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
contentPadding =
androidx.compose.foundation.layout.PaddingValues(
horizontal = 12.dp,
vertical = 8.dp,
),
container = {
androidx.compose.material3.OutlinedTextFieldDefaults.Container(
enabled = true,
isError = false,
interactionSource = searchInteraction,
shape = RoundedCornerShape(10.dp),
)
},
)
},
)
if (account != null && !account.isReadOnly) {
IconButton(onClick = { showRelayPicker = true }) {
@@ -508,6 +563,7 @@ fun SearchScreen(
}
}
}
}
@Composable
private fun SearchEmptyState(
@@ -519,6 +575,7 @@ private fun SearchEmptyState(
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Saved searches
@@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -196,23 +197,25 @@ fun ThreadScreen(
val replyNotes = threadNotes.filter { it.idHex != noteId }
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
// Header with back button
ReadingColumn {
val sidePadding = readingHorizontalPadding()
// Header — Messages-style: compact row with back + titleMedium
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
Icon(
MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = "Back",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(8.dp))
Text(
"Thread",
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
@@ -237,6 +240,7 @@ fun ThreadScreen(
else -> {
LazyColumn(
contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Root note
@@ -27,6 +27,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -113,6 +114,7 @@ fun UserProfileScreen(
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
onBack: () -> Unit,
canGoBack: Boolean = false,
onCompose: () -> Unit = {},
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
@@ -434,12 +436,14 @@ fun UserProfileScreen(
previousFirstVisibleItemScrollOffset = currentOffset
}
ReadingColumn {
Box(modifier = Modifier.fillMaxSize()) {
if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...")
} else {
LazyColumn(
state = listState,
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
@@ -457,43 +461,57 @@ fun UserProfileScreen(
)
}
// Header with back button
// Header — Messages-style: compact row, titleMedium title.
// Horizontal gutter already supplied by LazyColumn.contentPadding.
item(key = "header") {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back")
// Back is shown only when stacked onto a nav stack (e.g.
// clicked a user in the feed). Top-level "My Profile"
// from the nav rail sets canGoBack = false so no arrow.
if (canGoBack) {
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
Icon(
MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(8.dp))
}
Text(
"Profile",
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleMedium,
)
}
// Edit button for own profile
// Edit button for own profile — compact IconButton to match
// the action-icon pattern every other screen's header uses.
if (isOwnProfile && account.isReadOnly == false) {
OutlinedButton(
IconButton(
onClick = {
editingDisplayName = displayName ?: ""
showEditDialog = true
},
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.Edit,
contentDescription = "Edit profile",
modifier = Modifier.size(18.dp),
contentDescription = "Edit Profile",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
Text("Edit Profile")
}
}
// Follow/Unfollow button for other profiles
// Follow/Unfollow button for other profiles — compact to
// match the header row height (32dp); primary-coloured
// text button so the affordance is still legible.
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
Column(horizontalAlignment = Alignment.End) {
Button(
@@ -520,6 +538,8 @@ fun UserProfileScreen(
}
},
enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
) {
val state = followState.state.collectAsState().value
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
@@ -879,8 +899,11 @@ fun UserProfileScreen(
}
}
// Floating header — appears on scroll up when profile header is out of view
AnimatedVisibility(
// Floating header — appears on scroll up when profile header is out of view.
// Fully-qualified call to force the non-scoped overload; ReadingColumn
// provides a ColumnScope in the outer lambda which would otherwise win
// overload resolution and break the BoxScope Modifier.align call below.
androidx.compose.animation.AnimatedVisibility(
visible = showFloatingHeader,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
@@ -891,13 +914,20 @@ fun UserProfileScreen(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f))
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back")
if (canGoBack) {
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
Icon(
MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = "Back",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(4.dp))
}
Spacer(Modifier.width(8.dp))
UserAvatar(
userHex = pubKeyHex,
pictureUrl = picture,
@@ -914,6 +944,7 @@ fun UserProfileScreen(
}
}
}
}
// Lightbox overlay
lightboxState?.let { state ->
@@ -18,7 +18,7 @@
* 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.commons.ui.chat
package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
@@ -34,6 +34,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleMaxSizeModifier
@@ -128,9 +129,14 @@ fun ChatBubbleLayout(
)
}
val bubbleShape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem
// Clip the hover/ripple to the bubble shape — combinedClickable otherwise
// paints a rectangular ripple that ignores the Surface's rounded corners.
val clickableModifier =
remember {
Modifier.combinedClickable(
remember(bubbleShape) {
Modifier
.clip(bubbleShape)
.combinedClickable(
onClick = {
if (!onClick()) {
if (!isComplete) {
@@ -148,7 +154,7 @@ fun ChatBubbleLayout(
) {
Surface(
color = bgColor.value,
shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem,
shape = bubbleShape,
modifier = clickableModifier,
) {
Column(modifier = MessageBubbleLimits, verticalArrangement = ChatRowColSpacing5dp) {
@@ -18,7 +18,7 @@
* 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.commons.ui.chat
package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -41,7 +42,6 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
@@ -61,6 +61,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
@@ -81,11 +82,6 @@ import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.chat.ChatMessageCompose
import com.vitorpamplona.amethyst.commons.ui.chat.ChatroomHeader
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastBanner
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus
import com.vitorpamplona.amethyst.commons.ui.chat.GroupChatroomHeader
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
@@ -558,11 +554,13 @@ private fun MessageWithReactions(
}
}
// AddReaction icon on hover
if (showIcon) {
Box {
// AddReaction icon: always laid out to reserve space (so hover
// doesn't reflow the detail row and shift the whole list);
// alpha fades in/out based on hover.
Box(modifier = Modifier.alpha(if (showIcon) 1f else 0f)) {
IconButton(
onClick = { showPicker = !showPicker },
enabled = showIcon,
modifier = Modifier.size(20.dp),
) {
Icon(
@@ -590,7 +588,6 @@ private fun MessageWithReactions(
}
}
}
}
},
) { _ ->
when (note.event) {
@@ -714,12 +711,22 @@ private fun MessageInput(
)
}
OutlinedTextField(
// BasicTextField + DecorationBox with custom contentPadding lets the
// field sit at a compact ~40dp minimum on desktop (M3's default
// OutlinedTextField has ~32dp vertical contentPadding baked in, which
// would clip the bodyMedium placeholder at any height below ~52dp).
val messageInteraction =
remember {
androidx.compose.foundation.interaction
.MutableInteractionSource()
}
androidx.compose.foundation.text.BasicTextField(
value = messageText,
onValueChange = onMessageChange,
modifier =
Modifier
.weight(1f)
.heightIn(min = 40.dp)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
// Cmd+Enter (Mac) or Ctrl+Enter to send
@@ -731,10 +738,43 @@ private fun MessageInput(
false
}
},
placeholder = { Text("Message... (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)") },
singleLine = false,
textStyle =
MaterialTheme.typography.bodyMedium
.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush =
androidx.compose.ui.graphics
.SolidColor(MaterialTheme.colorScheme.primary),
maxLines = 4,
shape = RoundedCornerShape(12.dp),
interactionSource = messageInteraction,
decorationBox = { innerTextField ->
androidx.compose.material3.OutlinedTextFieldDefaults.DecorationBox(
value = messageText,
innerTextField = innerTextField,
enabled = true,
singleLine = false,
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
interactionSource = messageInteraction,
placeholder = {
Text(
"Message\u2026 (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)",
style = MaterialTheme.typography.bodyMedium,
)
},
contentPadding =
androidx.compose.foundation.layout.PaddingValues(
horizontal = 12.dp,
vertical = 8.dp,
),
container = {
androidx.compose.material3.OutlinedTextFieldDefaults.Container(
enabled = true,
isError = false,
interactionSource = messageInteraction,
shape = RoundedCornerShape(10.dp),
)
},
)
},
)
IconButton(
@@ -18,7 +18,7 @@
* 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.commons.ui.chat
package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -55,6 +55,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
@@ -119,6 +120,9 @@ fun ConversationListPane(
modifier =
modifier
.fillMaxHeight()
// Chat-list pane reads as a secondary surface (like Messages.app,
// Slack, Telegram); the chat content pane to the right stays white.
.background(MaterialTheme.colorScheme.surfaceContainer)
.focusRequester(focusRequester)
.focusable()
.onPreviewKeyEvent { event ->
@@ -283,9 +287,13 @@ private fun ConversationCard(
) {
val backgroundColor =
when {
isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
isFocused -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else -> MaterialTheme.colorScheme.surface
isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
isFocused -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)
// Transparent lets the pane's surfaceContainer show through — the
// list pane is the "secondary surface" in this screen.
else -> Color.Transparent
}
Row(
@@ -297,22 +305,12 @@ private fun ConversationCard(
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Unread indicator
if (item.hasUnread) {
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
Spacer(Modifier.width(6.dp))
} else {
Spacer(Modifier.width(14.dp))
}
// Avatar
// Avatar — starts flush with the header's 12dp padding. Unread state is
// signalled by a small primary-coloured dot overlaid on the avatar's
// bottom-right corner (Slack / iMessage-style), not an inline indent
// that would push the whole card to the right of the header label.
val firstUser = item.users.firstOrNull()
Box {
if (firstUser != null) {
UserAvatar(
userHex = firstUser.pubkeyHex,
@@ -327,10 +325,27 @@ private fun ConversationCard(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (item.hasUnread) {
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.size(10.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(1.5.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
Spacer(Modifier.width(10.dp))
// Name, preview, timestamp
// Name, preview, timestamp — vertical hierarchy: name = medium-weight
// primary text (titleSmall), preview = muted secondary. Prior styling had
// both lines at the same weight / near-same color and stacked flush,
// making them blur into each other.
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -339,7 +354,7 @@ private fun ConversationCard(
) {
Text(
text = item.displayName,
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -347,30 +362,34 @@ private fun ConversationCard(
)
if (item.lastMessageTimestamp > 0) {
Spacer(Modifier.width(8.dp))
Text(
text = item.lastMessageTimestamp.toTimeAgo(withDot = false),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
if (item.lastMessagePreview.isNotEmpty()) {
Spacer(Modifier.height(2.dp))
Text(
text = item.lastMessagePreview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Group indicator
// Group indicator — labelSmall with muted tertiary so the name line
// still wins the eye even when a group badge is present.
if (item.isGroup) {
Spacer(Modifier.height(2.dp))
Text(
text = "Group (${item.users.size + 1})",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
@@ -30,7 +32,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -49,12 +50,14 @@ import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
@@ -62,6 +65,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.CoroutineScope
import java.awt.Cursor
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
@@ -272,6 +276,15 @@ private fun SplitMessagesContent(
onShowRelayPicker: () -> Unit = {},
keyHandler: Modifier,
) {
// Draggable split: the conversation list width is user-adjustable and
// persisted so it survives app restarts.
var listWidth by remember {
androidx.compose.runtime.mutableStateOf(
com.vitorpamplona.amethyst.desktop.DesktopPreferences.messagesListWidthDp.dp,
)
}
val density = androidx.compose.ui.platform.LocalDensity.current
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
ConversationListPane(
state = listState,
@@ -280,12 +293,25 @@ private fun SplitMessagesContent(
onNewConversation = onShowNewDm,
onShowRelayPicker = onShowRelayPicker,
focusRequester = listFocusRequester,
modifier = Modifier.width(280.dp),
modifier = Modifier.width(listWidth),
)
VerticalDivider(modifier = Modifier.fillMaxHeight())
MessagesDraggableDivider(
onDrag = { deltaPx ->
val deltaDp = with(density) { deltaPx.toDp() }
val next = (listWidth + deltaDp).coerceIn(220.dp, 480.dp)
listWidth = next
com.vitorpamplona.amethyst.desktop.DesktopPreferences.messagesListWidthDp = next.value
},
)
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
Box(
modifier =
Modifier
.weight(1f)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surface),
) {
val currentRoom = selectedRoom
if (currentRoom != null) {
val feedViewModel =
@@ -350,3 +376,43 @@ private fun EmptyConversationState() {
}
}
}
/**
* Draggable vertical divider between the conversation list pane and the chat
* pane. The hit area is split in half horizontally: the left half takes the
* conversation list's surfaceContainer fill, the right half takes the chat
* pane's surface fill, so the divider reads as a continuation of the two panes
* instead of a single contrasting stripe. Cursor flips to the horizontal
* resize arrow on hover.
*/
@Composable
private fun MessagesDraggableDivider(onDrag: (Float) -> Unit) {
Row(
modifier =
Modifier
.width(12.dp)
.fillMaxHeight()
.pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
onDrag(dragAmount.x)
}
},
) {
Box(
modifier =
Modifier
.width(6.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceContainer),
)
Box(
modifier =
Modifier
.width(6.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surface),
)
}
}
@@ -18,7 +18,7 @@
* 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.commons.ui.chat
package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
@@ -18,7 +18,7 @@
* 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.commons.ui.chat
package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.runtime.Immutable
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.chats
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
@@ -18,7 +18,7 @@
* 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.commons.ui.chat
package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -130,9 +129,10 @@ fun DeckColumnContainer(
HorizontalDivider()
Box(
modifier = Modifier.fillMaxSize().padding(12.dp),
) {
// Content runs edge-to-edge; each screen adds its own header padding
// to match the Messages pattern (padding(horizontal = 12, vertical = 8)
// on the title row, no outer wrapper).
Box(modifier = Modifier.fillMaxSize()) {
// Always keep RootContent composed so state (e.g. search results) survives navigation
RootContent(
columnType = column.type,
@@ -460,6 +460,7 @@ internal fun OverlayContent(
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
onBack = onBack,
canGoBack = true,
onCompose = onShowComposeDialog,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
@Composable
@@ -56,10 +57,10 @@ fun DeckSidebar(
Column(
modifier =
modifier
.width(48.dp)
.width(56.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(vertical = 8.dp),
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(top = 8.dp + titleBarInsetTop, bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -52,6 +53,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
@@ -97,7 +99,9 @@ fun SinglePaneLayout(
if (!isImmersive) {
NavigationRail(
modifier = Modifier.width(80.dp).fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
// macOS: push rail items below the traffic lights.
header = { Spacer(Modifier.height(titleBarInsetTop)) },
) {
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
pinnedScreens.forEach { screenType ->
@@ -176,9 +180,10 @@ fun SinglePaneLayout(
}
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
Box(
modifier = Modifier.fillMaxSize().padding(if (isImmersive) 0.dp else 12.dp),
) {
// Content extends to the window edges; individual screens add their
// own internal padding where appropriate (Messages uses full-bleed
// panes to match native two-column chat apps).
Box(modifier = Modifier.fillMaxSize()) {
// Always keep RootContent composed so state (e.g. search results) survives navigation
RootContent(
columnType = currentColumnType,
@@ -23,11 +23,12 @@ package com.vitorpamplona.amethyst.desktop.ui.highlights
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -70,14 +71,24 @@ fun MyHighlightsScreen(
val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<HighlightData?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
val sidePadding =
com.vitorpamplona.amethyst.desktop.ui
.readingHorizontalPadding()
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = sidePadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Highlights",
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(16.dp))
}
if (allHighlights.isEmpty()) {
EmptyState(
@@ -86,6 +97,7 @@ fun MyHighlightsScreen(
)
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
allHighlights.forEach { (addressTag, highlights) ->
@@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -34,7 +35,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -156,34 +156,30 @@ fun NoteCard(
400.dp
}
Card(
modifier = modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
// Whole-card hover/ripple: pass onClick to M3 Card so the click-surface is
// the entire card (M3 handles shape clipping for us). Action buttons inside
// the NoteActionsRow have their own clickables that consume the click before
// it reaches the Card's handler, so tapping an action still fires only that
// action.
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
val cardElevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
val cardBody: @Composable ColumnScope.() -> Unit = {
Column(modifier = Modifier.padding(12.dp)) {
// Header + text area — clickable to navigate to thread
Column(
modifier =
if (onClick != null) {
Modifier.clickable { onClick() }
} else {
Modifier
},
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
// Author with avatar
// Author with avatar — stadium-shaped hover to match the
// avatar+name chip's visual shape.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
if (onAuthorClick != null) {
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
Modifier
.clip(RoundedCornerShape(100.dp))
.clickable { onAuthorClick(note.pubKeyHex) }
} else {
Modifier
},
@@ -305,21 +301,25 @@ fun NoteCard(
}
}
}
Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
Spacer(Modifier.height(4.dp))
// Event ID (truncated)
Text(
text = "ID: ${note.id.take(12)}...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
if (onClick != null) {
Card(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
colors = cardColors,
elevation = cardElevation,
content = cardBody,
)
} else {
Card(
modifier = modifier.fillMaxWidth(),
colors = cardColors,
elevation = cardElevation,
content = cardBody,
)
}
}
/**
@@ -72,7 +72,7 @@ fun RelayConfigTab(
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 8.dp),
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
// 1. Connected Relays (collapsed by default to show other sections)
CollapsibleSection(
@@ -20,11 +20,13 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.relay
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -34,7 +36,9 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
@@ -50,7 +54,6 @@ enum class DashboardTab(
CONFIGURE("Configure"),
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RelayDashboardScreen(
relayManager: DesktopRelayConnectionManager,
@@ -79,12 +82,17 @@ fun RelayDashboardScreen(
}
Column(modifier = modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = DashboardTab.entries.indexOf(selectedTab)) {
// Header — Messages-style tabs-first: selected chip acts as the screen title.
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DashboardTab.entries.forEach { tab ->
Tab(
FilterChip(
selected = selectedTab == tab,
onClick = { selectedTab = tab },
text = { Text(tab.label) },
label = { Text(tab.label) },
)
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.desktop.ui.relay
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -62,9 +61,9 @@ fun RelayMetricCard(
value = nip11Fetcher.fetch(status.url)
}
Card(modifier = modifier.fillMaxWidth()) {
Card(onClick = onToggleExpand, modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.clickable { onToggleExpand() }.padding(12.dp),
modifier = Modifier.padding(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui.relay
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -91,6 +92,7 @@ fun RelayMetricsTab(
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(statuses, key = { it.url.url }) { status ->
@@ -91,10 +91,13 @@ fun MediaServerSettings(
}
}
Column(modifier = modifier.fillMaxWidth().padding(16.dp)) {
// Parent (RelaySettingsScreen) provides the 12dp horizontal gutter, so this
// column no longer adds its own all-sides 16dp which was showing up as an
// extra frame inside the settings screen.
Column(modifier = modifier.fillMaxWidth()) {
Text(
"Media Servers (Blossom)",
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(8.dp))
@@ -70,7 +70,7 @@ fun TorSettingsSection(
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Tor",
style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.width(12.dp))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 16 KiB