diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index b794b28a3..d6c4ebf26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -592,7 +592,7 @@ fun AmethystTheme( colorScheme = colors, typography = Typography, shapes = Shapes, - content = { ProvideMaterialSymbols(content) }, + content = { ProvideMaterialSymbols(content = content) }, ) val view = LocalView.current diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt index 27e58f724..882dd1e90 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt @@ -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, diff --git a/desktopApp/THEME_PREVIEW.md b/desktopApp/THEME_PREVIEW.md new file mode 100644 index 000000000..9cfc41a53 --- /dev/null +++ b/desktopApp/THEME_PREVIEW.md @@ -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.=` 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()` diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 22bb08d3b..179af93cf 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 11e9521bb..00d8e26fa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 52c2690e2..21ee6c8fc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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(), - ) { - ProvideMaterialSymbols { + 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, + ) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, @@ -1254,213 +1322,232 @@ fun RelaySettingsScreen( accountManager.loadNwcConnection() } - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - ) { - Text( - "Settings", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - - Spacer(Modifier.height(24.dp)) - - // Wallet Connect Section - Text( - "Wallet Connect (NWC)", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(8.dp)) - - Text( - "Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(Modifier.height(12.dp)) - - if (nwcConnection != null) { + com.vitorpamplona.amethyst.desktop.ui.ReadingColumn { + val sidePadding = + com.vitorpamplona.amethyst.desktop.ui + .readingHorizontalPadding() + Column( + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(horizontal = sidePadding), + ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column { - Text( - "Wallet Connected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - Text( - "Relay: ${nwcConnection!!.relayUri.url}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - OutlinedButton( - onClick = { accountManager.clearNwcConnection() }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + Text( + "Settings", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Wallet Connect Section + Text( + "Wallet Connect (NWC)", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + Text( + "Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + if (nwcConnection != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text("Disconnect") + Column { + Text( + "Wallet Connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + "Relay: ${nwcConnection!!.relayUri.url}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton( + onClick = { accountManager.clearNwcConnection() }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Disconnect") + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = nwcInput, + onValueChange = { + nwcInput = it + nwcError = null + }, + label = { Text("NWC Connection String") }, + placeholder = { Text("nostr+walletconnect://...") }, + modifier = Modifier.weight(1f), + singleLine = true, + isError = nwcError != null, + supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, + ) + Button( + onClick = { + val result = accountManager.setNwcConnection(nwcInput) + result.fold( + onSuccess = { nwcInput = "" }, + onFailure = { nwcError = it.message ?: "Invalid connection string" }, + ) + }, + enabled = nwcInput.isNotBlank(), + ) { + Text("Connect") + } } } - } else { + + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + + // Media Server Settings + MediaServerSettings( + initialServers = DesktopPreferences.blossomServers, + onServersChanged = { DesktopPreferences.blossomServers = it }, + ) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + + // Tor Settings + com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection( + torStatus = torStatus, + currentSettings = torSettings, + onSettingsChanged = onTorSettingsChanged, + ) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + + // Developer Settings Section (only in debug mode) + if (DebugConfig.isDebugMode) { + com.vitorpamplona.amethyst.desktop.ui + .DevSettingsSection(account = account) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + } + + Text( + "Relay Settings", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "${connectedRelays.size} of ${relayStatuses.size} relays connected", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + IconButton(onClick = { relayManager.connect() }) { + Icon( + MaterialSymbols.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(Modifier.height(16.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( - value = nwcInput, - onValueChange = { - nwcInput = it - nwcError = null - }, - label = { Text("NWC Connection String") }, - placeholder = { Text("nostr+walletconnect://...") }, + value = newRelayUrl, + onValueChange = { newRelayUrl = it }, + label = { Text("Add relay") }, + placeholder = { Text("wss://relay.example.com") }, modifier = Modifier.weight(1f), singleLine = true, - isError = nwcError != null, - supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, ) Button( onClick = { - val result = accountManager.setNwcConnection(nwcInput) - result.fold( - onSuccess = { nwcInput = "" }, - onFailure = { nwcError = it.message ?: "Invalid connection string" }, - ) + if (newRelayUrl.isNotBlank()) { + relayManager.addRelay(newRelayUrl) + newRelayUrl = "" + } }, - enabled = nwcInput.isNotBlank(), + enabled = newRelayUrl.isNotBlank(), ) { - Text("Connect") + Text("Add") } } - } - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(16.dp)) - // Media Server Settings - MediaServerSettings( - initialServers = DesktopPreferences.blossomServers, - onServersChanged = { DesktopPreferences.blossomServers = it }, - ) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - - // Tor Settings - com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection( - torStatus = torStatus, - currentSettings = torSettings, - onSettingsChanged = onTorSettingsChanged, - ) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - - // Developer Settings Section (only in debug mode) - if (DebugConfig.isDebugMode) { - com.vitorpamplona.amethyst.desktop.ui - .DevSettingsSection(account = account) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - } - - Text( - "Relay Settings", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(8.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "${connectedRelays.size} of ${relayStatuses.size} relays connected", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - IconButton(onClick = { relayManager.connect() }) { - Icon( - MaterialSymbols.Refresh, - contentDescription = "Reconnect", - tint = MaterialTheme.colorScheme.primary, - ) - } - } - - Spacer(Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = newRelayUrl, - onValueChange = { newRelayUrl = it }, - label = { Text("Add relay") }, - placeholder = { Text("wss://relay.example.com") }, + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f), - singleLine = true, - ) - Button( - onClick = { - if (newRelayUrl.isNotBlank()) { - relayManager.addRelay(newRelayUrl) - newRelayUrl = "" - } - }, - enabled = newRelayUrl.isNotBlank(), ) { - Text("Add") + items(relayStatuses.values.toList(), key = { it.url.url }) { status -> + RelayStatusCard( + status = status, + onRemove = { relayManager.removeRelay(status.url) }, + ) + } } - } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(16.dp)) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.weight(1f), - ) { - items(relayStatuses.values.toList(), key = { it.url.url }) { status -> - RelayStatusCard( - status = status, - onRemove = { relayManager.removeRelay(status.url) }, - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { + Text("Reset to Defaults") + } } - } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { - Text("Reset to Defaults") + val logoutScope = rememberCoroutineScope() + OutlinedButton( + onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Logout") } } - - Spacer(Modifier.height(16.dp)) - - val logoutScope = rememberCoroutineScope() - OutlinedButton( - onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text("Logout") - } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 461af612e..cf11e8966 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt new file mode 100644 index 000000000..fb7616782 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt @@ -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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt new file mode 100644 index 000000000..aa6adf65e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt @@ -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(N²) + * 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) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt new file mode 100644 index 000000000..6754782d6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt @@ -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] that tracks the OS dark/light preference and + * refreshes whenever the given AWT [Window] gains focus. Lightweight (~30ms shell-out + * on focus) and avoids polling. + */ +@Composable +fun rememberSystemDark(awtWindow: Window?): State { + val state = remember { mutableStateOf(PlatformAppearance.isSystemDark()) } + DisposableEffect(awtWindow) { + if (awtWindow == null) return@DisposableEffect onDispose {} + val listener = + object : WindowFocusListener { + override fun windowGainedFocus(e: java.awt.event.WindowEvent?) { + state.value = PlatformAppearance.isSystemDark() + } + + override fun windowLostFocus(e: java.awt.event.WindowEvent?) = Unit + } + awtWindow.addWindowFocusListener(listener) + onDispose { awtWindow.removeWindowFocusListener(listener) } + } + return state +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt new file mode 100644 index 000000000..6a8c3557b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt @@ -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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformFonts.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformFonts.kt new file mode 100644 index 000000000..8cddb3431 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformFonts.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Typeface +import org.jetbrains.skia.FontMgr +import org.jetbrains.skia.FontStyle +import java.awt.GraphicsEnvironment + +/** + * Resolves a [FontFamily] backed by the host OS's preferred UI font, with a + * per-OS fallback chain. Uses Skia's font manager to look up the family by name + * — names not installed on the system are skipped, so the order acts as a chain. + * + * Targets per OS: + * - macOS: SF Pro Text (the system UI font; "Helvetica Neue" / "Helvetica" as fallbacks). + * - GNOME: Adwaita Sans (47+), Cantarell (older), Inter, Noto Sans. + * - KDE Plasma: Noto Sans (Plasma's default), then Inter / DejaVu Sans. + * - Windows: Segoe UI Variable Text (Win 11), Segoe UI (Win 10). + * + * The final fallback is `FontFamily.Default` so Compose still has something to draw + * with on stripped-down systems. + */ +object PlatformFonts { + val ui: FontFamily by lazy { resolve(uiCandidates()) } + + val mono: FontFamily by lazy { resolve(monoCandidates(), fallback = FontFamily.Monospace) } + + private fun uiCandidates(): List = + when (PlatformInfo.current) { + Platform.MACOS -> { + listOf("SF Pro Text", ".AppleSystemUIFont", "Helvetica Neue", "Helvetica") + } + + Platform.WINDOWS -> { + listOf("Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI") + } + + Platform.GNOME -> { + listOf("Adwaita Sans", "Cantarell", "Inter", "Noto Sans", "DejaVu Sans") + } + + Platform.KDE -> { + listOf("Noto Sans", "Inter", "DejaVu Sans", "Cantarell") + } + + Platform.LINUX_OTHER -> { + listOf("Inter", "Noto Sans", "DejaVu Sans", "Cantarell") + } + + Platform.UNKNOWN -> { + emptyList() + } + } + + private fun monoCandidates(): List = + when (PlatformInfo.current) { + Platform.MACOS -> { + listOf("SF Mono", "Menlo", "Monaco") + } + + Platform.WINDOWS -> { + listOf("Cascadia Mono", "Cascadia Code", "Consolas") + } + + Platform.GNOME -> { + listOf("Adwaita Mono", "Source Code Pro", "DejaVu Sans Mono") + } + + Platform.KDE -> { + listOf("Hack", "Noto Sans Mono", "Source Code Pro") + } + + Platform.LINUX_OTHER -> { + listOf("JetBrains Mono", "Source Code Pro", "DejaVu Sans Mono") + } + + Platform.UNKNOWN -> { + emptyList() + } + } + + private fun resolve( + candidates: List, + fallback: FontFamily = FontFamily.Default, + ): FontFamily { + if (candidates.isEmpty()) return fallback + val installed = installedFamilies + val name = candidates.firstOrNull { it in installed } ?: return fallback + return systemFamily(name) + } + + /** + * Builds a [FontFamily] from a system family name. Skia's `FontMgr.matchFamilyStyle` + * gives us a regular-weight typeface; Skia synthesizes bold/italic from it on demand, + * which is how every native UI toolkit on desktop renders system fonts. + */ + private fun systemFamily(name: String): FontFamily { + val skTypeface = + runCatching { FontMgr.default.matchFamilyStyle(name, FontStyle.NORMAL) } + .getOrNull() ?: return FontFamily.Default + return FontFamily(Typeface(skTypeface)) + } + + private val installedFamilies: Set by lazy { + try { + GraphicsEnvironment + .getLocalGraphicsEnvironment() + .availableFontFamilyNames + .toSet() + } catch (e: Exception) { + emptySet() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformIconWeight.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformIconWeight.kt new file mode 100644 index 000000000..e72ce429c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformIconWeight.kt @@ -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 + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt new file mode 100644 index 000000000..6c9d7c59b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt @@ -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 + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt new file mode 100644 index 000000000..fbd183140 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +/** + * Per-OS Material3 [Shapes] tuned to match each platform's native rounding language. + * Material's defaults (4 / 4 / 0 dp) read as Android — these values match what users + * see in their OS's first-party apps. + * + * - macOS (Sonoma+): ~10 / 12 / 16 dp continuous-style corners. + * - GNOME (libadwaita): 9 / 12 / 16 dp — adw_dialog / adw_card baseline. + * - KDE (Breeze): 6 / 8 / 12 dp — Breeze prefers tighter rounding than libadwaita. + * - Windows (WinUI 3): 4 / 8 / 8 dp — WinUI's mica surfaces use modest rounding. + */ +object PlatformShapes { + val current: Shapes by lazy { + when (PlatformInfo.current) { + Platform.MACOS -> { + Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(10.dp), + large = RoundedCornerShape(14.dp), + extraLarge = RoundedCornerShape(20.dp), + ) + } + + Platform.GNOME -> { + Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(9.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(24.dp), + ) + } + + Platform.KDE -> { + Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(6.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(12.dp), + extraLarge = RoundedCornerShape(16.dp), + ) + } + + Platform.WINDOWS -> { + Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(8.dp), + extraLarge = RoundedCornerShape(12.dp), + ) + } + + Platform.LINUX_OTHER, Platform.UNKNOWN -> { + Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(10.dp), + large = RoundedCornerShape(14.dp), + extraLarge = RoundedCornerShape(20.dp), + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt new file mode 100644 index 000000000..739d0ee0a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.FrameWindowScope + +/** + * Wraps content in a [MaterialTheme] tuned for the host OS: native fonts, accent + * color, surface tones, and shape rounding all match what the user sees in their + * other apps. + * + * Pair this with [applyNativeWindowChrome] inside the [androidx.compose.ui.window.Window] + * scope to also get native title-bar treatment (transparent title bar with + * traffic-light inset on macOS). + */ +@Composable +fun PlatformMaterialTheme( + isDark: Boolean, + content: @Composable () -> Unit, +) { + val accent = remember { PlatformAccent.systemAccent() } + val colorScheme = remember(isDark, accent) { PlatformColorScheme.resolve(isDark, accent) } + MaterialTheme( + colorScheme = colorScheme, + typography = PlatformTypography.current, + shapes = PlatformShapes.current, + content = content, + ) +} + +/** + * Inset to apply at the top of window content so it doesn't underlap the macOS + * traffic lights once `applyNativeWindowChrome` has made the title bar transparent + * + full-content. ~28 dp clears the buttons with comfortable padding. On non-macOS + * platforms returns 0 dp so layouts stay flush. + */ +val titleBarInsetTop: Dp + get() = if (PlatformInfo.isMacOS) 28.dp else 0.dp + +/** + * Applies platform-specific native window chrome. + * + * On macOS: + * - `apple.awt.transparentTitleBar = true` removes the title bar tint so app + * content shows through. + * - `apple.awt.fullWindowContent = true` lets content extend under the title bar + * while the traffic lights stay drawn on top. + * - `apple.awt.windowTitleVisible = false` hides the "Amethyst" string so the + * bar is clean. + * + * On other platforms this is a no-op — the OS chrome stays as-is, which is + * already what users expect (custom Compose-drawn title bars on Linux/Windows + * mostly look worse than the system's, especially for window snapping & a11y). + */ +@Composable +fun FrameWindowScope.applyNativeWindowChrome() { + if (!PlatformInfo.isMacOS) return + val rootPane = window.rootPane + rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + rootPane.putClientProperty("apple.awt.fullWindowContent", true) + rootPane.putClientProperty("apple.awt.windowTitleVisible", false) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt new file mode 100644 index 000000000..f11e02839 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +/** + * Material3 [Typography] using the host OS's preferred UI font with per-OS letter + * spacing tuned to match native apps. Material's defaults are positively tracked + * (Roboto-style); Apple's SF Pro and GNOME's Adwaita are tracked tighter at the + * larger sizes, so we mirror that. + * + * The body sizes stay close to Material defaults so the existing layout code that + * assumes 14–16sp body text doesn't reflow. + */ +object PlatformTypography { + val current: Typography by lazy { build(PlatformFonts.ui) } + + private fun build(family: FontFamily): Typography { + // Per-OS letter spacing offset applied to display/headline styles. + val tightening: TextUnit = + when (PlatformInfo.current) { + Platform.MACOS -> (-0.4).sp + + // SF Pro is set tight at large sizes + Platform.GNOME -> (-0.2).sp + + // Adwaita Sans is mildly tight + Platform.KDE, Platform.WINDOWS -> 0.sp + + else -> 0.sp + } + + fun ts( + size: Int, + line: Int, + weight: FontWeight = FontWeight.Normal, + tracking: TextUnit = 0.sp, + ) = TextStyle( + fontFamily = family, + fontWeight = weight, + fontSize = size.sp, + lineHeight = line.sp, + letterSpacing = tracking, + ) + + return Typography( + displayLarge = ts(57, 64, FontWeight.Normal, tightening), + displayMedium = ts(45, 52, FontWeight.Normal, tightening), + displaySmall = ts(36, 44, FontWeight.Normal, tightening), + headlineLarge = ts(32, 40, FontWeight.SemiBold, tightening), + headlineMedium = ts(28, 36, FontWeight.SemiBold, tightening), + headlineSmall = ts(24, 32, FontWeight.SemiBold, tightening), + titleLarge = ts(22, 28, FontWeight.SemiBold), + titleMedium = ts(16, 24, FontWeight.Medium, 0.15.sp), + titleSmall = ts(14, 20, FontWeight.Medium, 0.1.sp), + bodyLarge = ts(16, 24, FontWeight.Normal, 0.15.sp), + bodyMedium = ts(14, 20, FontWeight.Normal, 0.25.sp), + bodySmall = ts(12, 16, FontWeight.Normal, 0.4.sp), + labelLarge = ts(14, 20, FontWeight.Medium, 0.1.sp), + labelMedium = ts(12, 16, FontWeight.Medium, 0.5.sp), + labelSmall = ts(11, 16, FontWeight.Medium, 0.5.sp), + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt index 965b25821..6812d5226 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt @@ -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), + ) + } + Spacer(Modifier.width(8.dp)) + Text( + "Article", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + 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") } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt index a3d00fd5f..2b2249b92 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -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) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index e692b6470..54a2b1626 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index 9c64249b3..5ca2760bf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -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(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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedHeader.kt similarity index 60% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedHeader.kt index 7e9bce5f3..57c7fccc3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedHeader.kt @@ -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, -) { - 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) { + IconButton( + onClick = onRefresh, + modifier = Modifier.size(32.dp), + ) { Icon( MaterialSymbols.Refresh, - contentDescription = "Reconnect", + contentDescription = "Refresh ($connectedRelayCount relays connected)", tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 7bdded93d..c24e30d2a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -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) { + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { onFeedModeChange(FeedMode.FOLLOWING) }, + label = { Text("Following") }, ) - - 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") }, - ) - } - } - } - - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "${feedRelays.size} relays", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = - Modifier.clickable { onNavigateToRelays() }, - ) - if (feedMode == FeedMode.FOLLOWING) { - Text( - " \u2022 $followedUsersCount followed", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(8.dp)) - if (account != null && !account.isReadOnly) { - IconButton( - onClick = onOpenRelayPicker, - modifier = Modifier.size(24.dp), - ) { - Icon( - MaterialSymbols.Dns, - contentDescription = "Edit Feed Relays", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - Spacer(Modifier.width(4.dp)) - } - IconButton( - onClick = onRefresh, - modifier = Modifier.size(24.dp), - ) { - Icon( - MaterialSymbols.Refresh, - contentDescription = "Refresh", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } } + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { onFeedModeChange(FeedMode.GLOBAL) }, + label = { Text("Global") }, + ) } - 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") + // Actions \u2014 compact icon buttons at the same scale as the Messages header. + Row(verticalAlignment = Alignment.CenterVertically) { + 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( + relaysTooltip, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + }, + ) { + IconButton( + onClick = { + if (account != null && !account.isReadOnly) { + onOpenRelayPicker() + } else { + onNavigateToRelays() + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.Dns, + contentDescription = relaysTooltip, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } + + IconButton( + onClick = onRefresh, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + 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), + ) + } + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index ef1e1d677..1429565d7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -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 -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt new file mode 100644 index 000000000..040e385a1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt @@ -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) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 8215314e2..dc783fcb1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -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,67 +284,46 @@ 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, + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (account != null) { + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { feedMode = FeedMode.FOLLOWING }, + label = { Text("Following") }, ) - - // Feed mode selector - 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), - ) { - Icon( - MaterialSymbols.Refresh, - contentDescription = "Refresh", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - } + IconButton( + onClick = { relayManager.connect() }, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.Refresh, + contentDescription = "Refresh (${connectedRelays.size} relays connected)", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) } } - Spacer(Modifier.height(8.dp)) - when { connectedRelays.isEmpty() -> { LoadingState("Connecting to relays...") @@ -383,6 +357,7 @@ fun ReadsScreen( else -> { LazyColumn( + contentPadding = PaddingValues(horizontal = sidePadding), verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(events, key = { it.id }) { event -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 2d027ade5..5574aceb5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -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,224 +288,279 @@ fun SearchScreen( focusRequester.requestFocus() } - Column( - modifier = - modifier - .fillMaxSize() - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (event.key) { - Key.Escape -> { - if (panelExpanded) { - state.togglePanel() - } else if (displayText.isNotEmpty()) { - state.clearSearch() + ReadingColumn { + Column( + modifier = + modifier + .fillMaxWidth() + .fillMaxHeight() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.Escape -> { + if (panelExpanded) { + state.togglePanel() + } else if (displayText.isNotEmpty()) { + state.clearSearch() + } + true + } + + else -> { + false } - true } - - else -> { - false - } - } - }, - ) { - // Progress bar at very top - AnimatedVisibility( - visible = isSearching, - enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + }, ) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) - } - - // Relay status banner - SearchSyncBanner( - relayStates = relayStates, - isSearching = isSearching, - ) - - // Title row - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "Search", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - "${localCache.userCount()} users cached", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Spacer(Modifier.height(16.dp)) - - // Search bar with advanced toggle - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedTextField( - value = textFieldValue, - onValueChange = { - textFieldValue = it - state.updateFromText(it.text) - }, - modifier = Modifier.weight(1f).focusRequester(focusRequester), - placeholder = { Text("Search notes, people, tags... or use operators") }, - leadingIcon = { - Icon( - MaterialSymbols.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailingIcon = { - if (displayText.isNotEmpty()) { - IconButton(onClick = { state.clearSearch() }) { - Icon( - MaterialSymbols.Clear, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - ) - if (account != null && !account.isReadOnly) { - IconButton(onClick = { showRelayPicker = true }) { - Icon( - MaterialSymbols.Dns, - contentDescription = "Search Relays", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - IconButton(onClick = { state.togglePanel() }) { - Icon( - MaterialSymbols.Tune, - contentDescription = "Advanced Search", - tint = - if (panelExpanded) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + // Progress bar at very top + AnimatedVisibility( + visible = isSearching, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, ) } - } - // Search relay picker dialog - if (showRelayPicker && account != null) { - val pickerRelays = - remember { - mutableStateListOf().also { - it.addAll(searchRelays) - } - } - AlertDialog( - onDismissRequest = { showRelayPicker = false }, - title = { Text("Search Relays") }, - text = { - SearchRelayEditor( - localRelays = pickerRelays, - signer = account.signer, - onPublish = { event -> - relayManager.broadcastToAll(event) - accountRelays?.consumePublishedEvent(event) - accountRelays?.setSearchRelays(pickerRelays.toSet()) - }, - ) - }, - confirmButton = { - TextButton(onClick = { showRelayPicker = false }) { - Text("Close") - } - }, + // Relay status banner + SearchSyncBanner( + relayStates = relayStates, + isSearching = isSearching, ) - } - // Expandable advanced panel - AnimatedVisibility( - visible = panelExpanded, - enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), - ) { - AdvancedSearchPanel( - query = query, - onKindsChanged = { state.updateKinds(it) }, - onPseudoKindsChanged = { state.updatePseudoKinds(it) }, - onAuthorAdded = { state.addAuthor(it) }, - onAuthorRemoved = { state.removeAuthor(it) }, - onDateRangeChanged = { since, until -> state.updateDateRange(since, until) }, - onHashtagAdded = { state.addHashtag(it) }, - onHashtagRemoved = { state.removeHashtag(it) }, - onExcludeAdded = { state.addExcludeTerm(it) }, - onExcludeRemoved = { state.removeExcludeTerm(it) }, - onLanguageChanged = { state.updateLanguage(it) }, - onClear = { state.clearSearch() }, - modifier = Modifier.padding(top = 8.dp), - ) - } - - Spacer(Modifier.height(16.dp)) - - // Results - val hasAnyResults = - bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty() - - if (bech32Results.isNotEmpty()) { - // Show bech32 results (exact lookup) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Title row + val sidePadding = readingHorizontalPadding() + Row( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = sidePadding, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { Text( - "Direct lookup", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 4.dp), + "Search", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, ) - bech32Results.forEach { result -> - SearchResultCard( - result = result, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onNavigateToHashtag = onNavigateToHashtag, + Text( + "${localCache.userCount()} users cached", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + // 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().padding(horizontal = sidePadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // 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).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() }, modifier = Modifier.size(28.dp)) { + Icon( + MaterialSymbols.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.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 }) { + Icon( + MaterialSymbols.Dns, + contentDescription = "Search Relays", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton(onClick = { state.togglePanel() }) { + Icon( + MaterialSymbols.Tune, + contentDescription = "Advanced Search", + tint = + if (panelExpanded) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) } } - } else if (hasAnyResults) { - SearchResultsList( - state = state, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - localCache = localCache, - ) - } else if (!debouncedQuery.isEmpty && !isSearching) { - Text( - "No results found. Try broader terms or fewer filters.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - } else if (!isSearching) { - // Empty state: show history + saved searches + operator hints - SearchEmptyState( - historyItems = historyItems, - savedSearches = savedSearches, - onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) }, - onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) }, - onClearHistory = { SearchHistoryStore.clearHistory() }, - ) + + // Search relay picker dialog + if (showRelayPicker && account != null) { + val pickerRelays = + remember { + mutableStateListOf().also { + it.addAll(searchRelays) + } + } + AlertDialog( + onDismissRequest = { showRelayPicker = false }, + title = { Text("Search Relays") }, + text = { + SearchRelayEditor( + localRelays = pickerRelays, + signer = account.signer, + onPublish = { event -> + relayManager.broadcastToAll(event) + accountRelays?.consumePublishedEvent(event) + accountRelays?.setSearchRelays(pickerRelays.toSet()) + }, + ) + }, + confirmButton = { + TextButton(onClick = { showRelayPicker = false }) { + Text("Close") + } + }, + ) + } + + // Expandable advanced panel + AnimatedVisibility( + visible = panelExpanded, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + AdvancedSearchPanel( + query = query, + onKindsChanged = { state.updateKinds(it) }, + onPseudoKindsChanged = { state.updatePseudoKinds(it) }, + onAuthorAdded = { state.addAuthor(it) }, + onAuthorRemoved = { state.removeAuthor(it) }, + onDateRangeChanged = { since, until -> state.updateDateRange(since, until) }, + onHashtagAdded = { state.addHashtag(it) }, + onHashtagRemoved = { state.removeHashtag(it) }, + onExcludeAdded = { state.addExcludeTerm(it) }, + onExcludeRemoved = { state.removeExcludeTerm(it) }, + onLanguageChanged = { state.updateLanguage(it) }, + onClear = { state.clearSearch() }, + modifier = Modifier.padding(top = 8.dp), + ) + } + + Spacer(Modifier.height(16.dp)) + + // Results + val hasAnyResults = + bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty() + + if (bech32Results.isNotEmpty()) { + // Show bech32 results (exact lookup) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Direct lookup", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + bech32Results.forEach { result -> + SearchResultCard( + result = result, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onNavigateToHashtag = onNavigateToHashtag, + ) + } + } + } else if (hasAnyResults) { + SearchResultsList( + state = state, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + localCache = localCache, + ) + } else if (!debouncedQuery.isEmpty && !isSearching) { + Text( + "No results found. Try broader terms or fewer filters.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } else if (!isSearching) { + // Empty state: show history + saved searches + operator hints + SearchEmptyState( + historyItems = historyItems, + savedSearches = savedSearches, + onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) }, + onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) }, + onClearHistory = { SearchHistoryStore.clearHistory() }, + ) + } } } } @@ -519,6 +575,7 @@ private fun SearchEmptyState( ) { LazyColumn( modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = readingHorizontalPadding()), verticalArrangement = Arrangement.spacedBy(4.dp), ) { // Saved searches diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index d53d044eb..894481dc0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 39c67e6b0..81deb966a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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,483 +436,512 @@ fun UserProfileScreen( previousFirstVisibleItemScrollOffset = currentOffset } - Box(modifier = Modifier.fillMaxSize()) { - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else { - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxSize(), - ) { - // Broadcast banner - item(key = "broadcast") { - ProfileBroadcastBanner( - status = broadcastStatus, - onTap = { - if (broadcastStatus is ProfileBroadcastStatus.Success || - broadcastStatus is ProfileBroadcastStatus.Failed - ) { - broadcastStatus = ProfileBroadcastStatus.Idle - } - }, - ) - } + 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(), + ) { + // Broadcast banner + item(key = "broadcast") { + ProfileBroadcastBanner( + status = broadcastStatus, + onTap = { + if (broadcastStatus is ProfileBroadcastStatus.Success || + broadcastStatus is ProfileBroadcastStatus.Failed + ) { + broadcastStatus = ProfileBroadcastStatus.Idle + } + }, + ) + } - // Header with back button - item(key = "header") { - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back") - } - Spacer(Modifier.width(8.dp)) - Text( - "Profile", - style = MaterialTheme.typography.headlineMedium, - ) - } - - // Edit button for own profile - if (isOwnProfile && account.isReadOnly == false) { - OutlinedButton( - onClick = { - editingDisplayName = displayName ?: "" - showEditDialog = true - }, - ) { - Icon( - MaterialSymbols.Edit, - contentDescription = "Edit profile", - modifier = Modifier.size(18.dp), + // Header — Messages-style: compact row, titleMedium title. + // Horizontal gutter already supplied by LazyColumn.contentPadding. + item(key = "header") { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // 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.titleMedium, ) - Spacer(Modifier.width(8.dp)) - Text("Edit Profile") } - } - // Follow/Unfollow button for other profiles - if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { - Column(horizontalAlignment = Alignment.End) { - Button( + // Edit button for own profile — compact IconButton to match + // the action-icon pattern every other screen's header uses. + if (isOwnProfile && account.isReadOnly == false) { + IconButton( onClick = { - scope.launch { - val currentStatus = followState.currentStatusOrNull() + editingDisplayName = displayName ?: "" + showEditDialog = true + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.Edit, + contentDescription = "Edit Profile", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } - followState.setFollowLoading() - try { - val updatedEvent = - if (currentStatus?.isFollowing == true) { - unfollowUser(pubKeyHex, account, relayManager, myContactList) - } else { - followUser(pubKeyHex, account, relayManager, myContactList) - } + // 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( + onClick = { + scope.launch { + val currentStatus = followState.currentStatusOrNull() - // Update both stored contact list and followState - myContactList = updatedEvent - followState.setFollowSuccess(updatedEvent, pubKeyHex) - } catch (e: Exception) { - e.printStackTrace() - followState.setFollowError(e.message ?: "Failed to update follow status", e) + followState.setFollowLoading() + try { + val updatedEvent = + if (currentStatus?.isFollowing == true) { + unfollowUser(pubKeyHex, account, relayManager, myContactList) + } else { + followUser(pubKeyHex, account, relayManager, myContactList) + } + + // Update both stored contact list and followState + myContactList = updatedEvent + followState.setFollowSuccess(updatedEvent, pubKeyHex) + } catch (e: Exception) { + e.printStackTrace() + followState.setFollowError(e.message ?: "Failed to update follow status", e) + } + } + }, + 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 + val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading + + when { + !contactListLoaded -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text("Loading...") + } + + isLoading -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollowing..." else "Following...") + } + + else -> { + Icon( + if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd, + contentDescription = if (isFollowing) "Unfollow" else "Follow", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollow" else "Follow") } } - }, - enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, - ) { - val state = followState.state.collectAsState().value - val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false - val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading - - when { - !contactListLoaded -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text("Loading...") - } - - isLoading -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollowing..." else "Following...") - } - - else -> { - Icon( - if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd, - contentDescription = if (isFollowing) "Unfollow" else "Follow", - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollow" else "Follow") - } } - } - val errorMessage = - followState.state - .collectAsState() - .value - .errorOrNull() - errorMessage?.let { error -> - Spacer(Modifier.height(4.dp)) - Text( - error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) + val errorMessage = + followState.state + .collectAsState() + .value + .errorOrNull() + errorMessage?.let { error -> + Spacer(Modifier.height(4.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } } } } - } - // Profile card - item(key = "profile-card") { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - UserAvatar( - userHex = pubKeyHex, - pictureUrl = picture, - size = 56.dp, - contentDescription = "Profile picture", - ) - - Column(modifier = Modifier.weight(1f)) { - Text( - displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + // Profile card + item(key = "profile-card") { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 56.dp, + contentDescription = "Profile picture", ) - Spacer(Modifier.height(4.dp)) - val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() - var copied by remember { mutableStateOf(false) } - LaunchedEffect(copied) { - if (copied) { - delay(2000) - copied = false + Column(modifier = Modifier.weight(1f)) { + Text( + displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + (npub?.take(32) ?: pubKeyHex.take(32)) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (npub != null) { + IconButton( + onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(npub), null) + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + if (copied) MaterialSymbols.Check else MaterialSymbols.ContentCopy, + contentDescription = if (copied) "Copied" else "Copy npub", + modifier = Modifier.size(14.dp), + tint = + if (copied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } } } + } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { + if (about != null) { + Spacer(Modifier.height(12.dp)) + Text( + about!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column { Text( - (npub?.take(32) ?: pubKeyHex.take(32)) + "...", + "$followersCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Followers", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (npub != null) { - IconButton( - onClick = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(npub), null) - copied = true - }, - modifier = Modifier.size(20.dp), - ) { - Icon( - if (copied) MaterialSymbols.Check else MaterialSymbols.ContentCopy, - contentDescription = if (copied) "Copied" else "Copy npub", - modifier = Modifier.size(14.dp), - tint = - if (copied) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + } + Column { + Text( + "$followingCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Following", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + // Tabs + item(key = "tabs") { + PrimaryTabRow(selectedTabIndex = selectedTab) { + Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { + Text("Notes", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { + Text( + "Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) { + Text("Gallery", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) { + Text( + "Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + } + } + + // Tab content + when (selectedTab) { + 0 -> { + when (profileFeedState) { + is FeedState.Loading -> { + item(key = "loading") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + androidx.compose.material3.CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Loading posts...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } - } - if (about != null) { - Spacer(Modifier.height(12.dp)) - Text( - about!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - Spacer(Modifier.height(12.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Column { - Text( - "$followersCount", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - "Followers", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Column { - Text( - "$followingCount", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - "Following", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - - // Tabs - item(key = "tabs") { - PrimaryTabRow(selectedTabIndex = selectedTab) { - Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { - Text("Notes", modifier = Modifier.padding(12.dp)) - } - Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { - Text( - "Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}", - modifier = Modifier.padding(12.dp), - ) - } - Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) { - Text("Gallery", modifier = Modifier.padding(12.dp)) - } - Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) { - Text( - "Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}", - modifier = Modifier.padding(12.dp), - ) - } - } - } - - // Tab content - when (selectedTab) { - 0 -> { - when (profileFeedState) { - is FeedState.Loading -> { - item(key = "loading") { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - androidx.compose.material3.CircularProgressIndicator() - Spacer(Modifier.height(16.dp)) + is FeedState.Empty -> { + item(key = "empty") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { Text( - "Loading posts...", + "No posts yet", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } - } - is FeedState.Empty -> { - item(key = "empty") { + is FeedState.FeedError -> { + item(key = "error") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Failed to load posts", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + Text( + (profileFeedState as FeedState.FeedError).errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = { retryTrigger++ }) { + Text("Retry") + } + } + } + } + } + + is FeedState.Loaded -> { + // loadedNotes collected outside LazyColumn in profileLoadedNotes + items(profileLoadedNotes, key = { it.idHex }) { note -> + FeedNoteCard( + note = note, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onCompose, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> + lightboxState = LightboxState(urls, index) + }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + ) + } + } + } + } + + 1 -> { + if (articleEvents.isEmpty()) { + item(key = "no-articles") { Box( modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center, ) { Text( - "No posts yet", + "No long-form articles", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + } else { + items( + articleEvents.sortedWith(compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }), + key = { "art-${it.id}" }, + ) { article -> + LongFormCard( + event = article, + localCache = localCache, + onAuthorClick = { onNavigateToProfile(article.pubKey) }, + onClick = { + val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}" + onNavigateToArticle(addressTag) + }, + ) + } } + } - is FeedState.FeedError -> { - item(key = "error") { + 2 -> { + item(key = "gallery") { + GalleryTab( + pictureEvents = pictureEvents, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + modifier = Modifier.fillParentMaxHeight(), + ) + } + } + + 3 -> { + if (highlightEvents.isEmpty()) { + item(key = "no-highlights") { Box( modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - "Failed to load posts", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - Spacer(Modifier.height(8.dp)) - Text( - (profileFeedState as FeedState.FeedError).errorMessage, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - OutlinedButton(onClick = { retryTrigger++ }) { - Text("Retry") - } - } + Text( + "No published highlights", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - } - - is FeedState.Loaded -> { - // loadedNotes collected outside LazyColumn in profileLoadedNotes - items(profileLoadedNotes, key = { it.idHex }) { note -> - FeedNoteCard( - note = note, - relayManager = relayManager, + } else { + items( + highlightEvents.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }), + key = { "hl-${it.id}" }, + ) { highlight -> + PublishedHighlightCard( + highlight = highlight, localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = onCompose, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onImageClick = { urls, index -> - lightboxState = LightboxState(urls, index) - }, - onMediaClick = { urls, index, seekPos -> - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .playVideo(urls[index], seekPos) - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .toggleFullscreen() - }, ) } } } } - - 1 -> { - if (articleEvents.isEmpty()) { - item(key = "no-articles") { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - "No long-form articles", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - items( - articleEvents.sortedWith(compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }), - key = { "art-${it.id}" }, - ) { article -> - LongFormCard( - event = article, - localCache = localCache, - onAuthorClick = { onNavigateToProfile(article.pubKey) }, - onClick = { - val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}" - onNavigateToArticle(addressTag) - }, - ) - } - } - } - - 2 -> { - item(key = "gallery") { - GalleryTab( - pictureEvents = pictureEvents, - onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, - modifier = Modifier.fillParentMaxHeight(), - ) - } - } - - 3 -> { - if (highlightEvents.isEmpty()) { - item(key = "no-highlights") { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - "No published highlights", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - items( - highlightEvents.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }), - key = { "hl-${it.id}" }, - ) { highlight -> - PublishedHighlightCard( - highlight = highlight, - localCache = localCache, - ) - } - } - } } } - } - // Floating header — appears on scroll up when profile header is out of view - AnimatedVisibility( - visible = showFloatingHeader, - enter = slideInVertically { -it }, - exit = slideOutVertically { -it }, - modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, + // 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 }, + modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(), ) { - IconButton(onClick = onBack) { - Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back") + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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)) + } + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 28.dp, + contentDescription = "Profile picture", + ) + Spacer(Modifier.width(8.dp)) + Text( + displayName ?: pubKeyHex.take(12) + "...", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) } - Spacer(Modifier.width(8.dp)) - UserAvatar( - userHex = pubKeyHex, - pictureUrl = picture, - size = 28.dp, - contentDescription = "Profile picture", - ) - Spacer(Modifier.width(8.dp)) - Text( - displayName ?: pubKeyHex.take(12) + "...", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatBubbleLayout.kt similarity index 89% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatBubbleLayout.kt index bd92d401a..c0f1dd5d4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatBubbleLayout.kt @@ -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,18 +129,23 @@ 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( - onClick = { - if (!onClick()) { - if (!isComplete) { - showDetails.value = !showDetails.value + remember(bubbleShape) { + Modifier + .clip(bubbleShape) + .combinedClickable( + onClick = { + if (!onClick()) { + if (!isComplete) { + showDetails.value = !showDetails.value + } } - } - }, - onLongClick = { popupExpanded.value = true }, - ) + }, + onLongClick = { popupExpanded.value = true }, + ) } Row( @@ -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) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatMessageCompose.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatMessageCompose.kt index a848bfd8a..22784f879 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatMessageCompose.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 6e4dbb203..6b1a429a2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -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,35 +554,36 @@ private fun MessageWithReactions( } } - // AddReaction icon on hover - if (showIcon) { - Box { - IconButton( - onClick = { showPicker = !showPicker }, - modifier = Modifier.size(20.dp), - ) { - Icon( - symbol = MaterialSymbols.AddReaction, - contentDescription = "React", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + // 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( + symbol = MaterialSymbols.AddReaction, + contentDescription = "React", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - if (showPicker) { - Popup( - alignment = Alignment.TopCenter, - offset = IntOffset(0, -44), - onDismissRequest = { showPicker = false }, - properties = PopupProperties(focusable = true), - ) { - ReactionBar( - onReaction = { emoji -> - onReaction(emoji) - showPicker = false - }, - ) - } + if (showPicker) { + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(0, -44), + onDismissRequest = { showPicker = false }, + properties = PopupProperties(focusable = true), + ) { + ReactionBar( + onReaction = { emoji -> + onReaction(emoji) + showPicker = false + }, + ) } } } @@ -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( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt index 021b7530f..760974048 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index d5754f273..ed7ee475a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -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,40 +305,47 @@ 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() - if (firstUser != null) { - UserAvatar( - userHex = firstUser.pubkeyHex, - pictureUrl = firstUser.profilePicture(), - size = 40.dp, - ) - } else if (item.isGroup) { - Icon( - MaterialSymbols.Group, - contentDescription = "Group", - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Box { + if (firstUser != null) { + UserAvatar( + userHex = firstUser.pubkeyHex, + pictureUrl = firstUser.profilePicture(), + size = 40.dp, + ) + } else if (item.isGroup) { + Icon( + MaterialSymbols.Group, + contentDescription = "Group", + modifier = Modifier.size(40.dp), + 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), ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 1e6369d91..768e7f1bf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -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), + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastBanner.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastBanner.kt index db0c2ea79..1a7b70d96 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastBanner.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastStatus.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastStatus.kt index 0d6973560..240090c4c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastStatus.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt index 3399e8e4d..398a72e77 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/UserDisplayNameLayout.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/UserDisplayNameLayout.kt index 703369752..4876011c2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/UserDisplayNameLayout.kt @@ -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 diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index c870c2a8c..d41b1de86 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index a150df955..ce865835f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -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, ) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index f9fafb310..c375a15ba 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt index fd3b47b9d..8fbc168f5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -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(null) } - Column(modifier = Modifier.fillMaxSize()) { - Text( - "Highlights", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - - Spacer(Modifier.height(16.dp)) + 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.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } 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) -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 7cc2500c0..879ae5ca2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -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, + ) + } } /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt index a2fa5e836..82448c2c0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt @@ -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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt index fdff5c1a7..f2d6163f4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt @@ -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) }, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt index 047e9f1a1..692aa674e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt @@ -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(), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt index 0db5e637a..49ee0f336 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt @@ -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 -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt index 9670d0646..82f4c782d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt @@ -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)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt index 33161d667..1020fc3c3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt @@ -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)) diff --git a/desktopApp/src/jvmMain/resources/icon.png b/desktopApp/src/jvmMain/resources/icon.png index dbb5d7153..ffe89a16f 100644 Binary files a/desktopApp/src/jvmMain/resources/icon.png and b/desktopApp/src/jvmMain/resources/icon.png differ