Merge pull request #2549 from vitorpamplona/claude/improve-desktop-design-iOokB
Add native OS theming and improve desktop UI layout
This commit is contained in:
@@ -592,7 +592,7 @@ fun AmethystTheme(
|
||||
colorScheme = colors,
|
||||
typography = Typography,
|
||||
shapes = Shapes,
|
||||
content = { ProvideMaterialSymbols(content) },
|
||||
content = { ProvideMaterialSymbols(content = content) },
|
||||
)
|
||||
|
||||
val view = LocalView.current
|
||||
|
||||
+13
-6
@@ -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,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Manual Testing: Desktop Native Theming
|
||||
|
||||
The desktop app adapts its colors, fonts, shapes, and accent to the host OS
|
||||
(macOS, Windows, GNOME, KDE, other Linux). This guide shows how to preview
|
||||
each platform's theme without leaving your dev machine, and what to look at
|
||||
when reviewing a theming change.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Three environment variables drive the preview overrides. Each one also has
|
||||
a `-Damethyst.<key>=<value>` system-property form, forwarded from gradle to
|
||||
the launched app's JVM.
|
||||
|
||||
| Variable | Values | Effect |
|
||||
|---|---|---|
|
||||
| `AMETHYST_PLATFORM` | `MACOS`, `WINDOWS`, `GNOME`, `KDE`, `LINUX_OTHER`, `UNKNOWN` | Forces in-app theming for that OS |
|
||||
| `AMETHYST_APPEARANCE` | `light`, `dark` | Forces dark/light mode |
|
||||
| `AMETHYST_ACCENT` | `#RRGGBB`, `RRGGBB`, or libadwaita name (`blue`, `teal`, `green`, `yellow`, `orange`, `red`, `pink`, `purple`, `slate`) | Forces accent color |
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Native (no override) — uses your real OS
|
||||
./gradlew :desktopApp:run
|
||||
|
||||
# GNOME light theme with the libadwaita default blue accent
|
||||
AMETHYST_PLATFORM=GNOME AMETHYST_APPEARANCE=light AMETHYST_ACCENT=blue ./gradlew :desktopApp:run
|
||||
|
||||
# KDE Breeze dark with a custom accent
|
||||
AMETHYST_PLATFORM=KDE AMETHYST_APPEARANCE=dark AMETHYST_ACCENT=#3DAEE9 ./gradlew :desktopApp:run
|
||||
|
||||
# Windows 11 (WinUI 3 mica tones)
|
||||
AMETHYST_PLATFORM=WINDOWS ./gradlew :desktopApp:run
|
||||
|
||||
# Equivalent system-property form
|
||||
./gradlew :desktopApp:run -Damethyst.platform=GNOME -Damethyst.appearance=light
|
||||
```
|
||||
|
||||
## What Changes vs. What Doesn't
|
||||
|
||||
The override swaps **in-app theming only**. The window chrome (title bar,
|
||||
traffic lights / minimize-maximize buttons, screen menu bar on macOS) is
|
||||
drawn by AWT from the actual host OS, not by our theme code. So:
|
||||
|
||||
| Element | Follows override? | Notes |
|
||||
|---|---|---|
|
||||
| `colorScheme` (background, surface, primary…) | ✅ | Per-OS reference palettes |
|
||||
| Body / heading fonts | ✅ | SF Pro on macOS, Cantarell on GNOME, Noto Sans on KDE, Segoe UI Variable on Windows |
|
||||
| Button / card / dialog rounding | ✅ | macOS 8/10/14, libadwaita 9/12/16, Breeze 6/8/12, WinUI 4/8/8 |
|
||||
| Accent color | ✅ | Threaded through `MaterialTheme.colorScheme.primary` |
|
||||
| Sidebar density (56 dp) | ✅ | Same on all OSes (desktop convention) |
|
||||
| Native title bar / traffic lights | ❌ | Drawn by host OS — to see the real GNOME header bar or KDE Breeze title, you need a real Linux machine or VM |
|
||||
| macOS screen menu bar | ❌ | Only active when host OS is macOS |
|
||||
| `apple.awt.transparentTitleBar` content extension | ❌ | macOS-host-only |
|
||||
|
||||
## Review Checklist
|
||||
|
||||
When reviewing a theming change, launch each preview and verify:
|
||||
|
||||
### macOS (`AMETHYST_PLATFORM=MACOS`, or no override on a Mac)
|
||||
|
||||
- [ ] Sidebar background reads as `surfaceContainer` — slightly lighter than the deck background, not jarringly different
|
||||
- [ ] Body text renders in SF Pro Text (check by zooming a screenshot — SF has distinctive 'a', 'g', 'k' shapes)
|
||||
- [ ] Card / dialog corners ~10 dp (a hair tighter than libadwaita)
|
||||
- [ ] Letter spacing is slightly tight at large headings (SF tightens at display sizes)
|
||||
- [ ] On a real Mac: traffic lights sit at top-left over the sidebar color, NOT over a white default-OS strip
|
||||
- [ ] On a real Mac: menu bar appears at the top of the screen, not inside the window
|
||||
|
||||
### GNOME (`AMETHYST_PLATFORM=GNOME`)
|
||||
|
||||
- [ ] Surfaces match libadwaita references: `#242424` window bg dark, `#FAFAFA` window bg light
|
||||
- [ ] Cards have 12 dp medium rounding (visibly more rounded than macOS)
|
||||
- [ ] If Cantarell or Adwaita Sans is installed locally, body text uses it; otherwise falls through to Inter / Noto Sans
|
||||
- [ ] Try `AMETHYST_ACCENT=blue` and confirm primary color is `#3584E4` (libadwaita default)
|
||||
|
||||
### KDE (`AMETHYST_PLATFORM=KDE`)
|
||||
|
||||
- [ ] Surfaces match Breeze references: `#1B1E20` background dark, `#EFF0F1` background light
|
||||
- [ ] Rounding is tighter than macOS / GNOME (8 dp medium, 6 dp small)
|
||||
- [ ] Body text renders in Noto Sans if installed
|
||||
- [ ] Default accent (when nothing forced) is the Amethyst purple fallback — KDE accent detection won't run on macOS
|
||||
|
||||
### Windows (`AMETHYST_PLATFORM=WINDOWS`)
|
||||
|
||||
- [ ] Surfaces match WinUI 3 mica tones: `#202020` background dark, `#F3F3F3` background light
|
||||
- [ ] Rounding is the tightest of any platform: 4 dp small, 8 dp medium
|
||||
- [ ] Body text uses Segoe UI Variable Text only if installed locally (not present on macOS by default — falls back to FontFamily.Default)
|
||||
|
||||
## Side-by-side Comparison
|
||||
|
||||
The launched app is a single window. To compare two themes you currently
|
||||
need to launch the app twice:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
AMETHYST_PLATFORM=GNOME ./gradlew :desktopApp:run
|
||||
|
||||
# Terminal 2 (after the first finishes building)
|
||||
AMETHYST_PLATFORM=MACOS ./gradlew :desktopApp:run
|
||||
```
|
||||
|
||||
Each launch opens its own window — drag them next to each other.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **No native chrome on host OS.** The window frame, title bar buttons,
|
||||
and (on macOS) screen menu bar always come from the real host OS.
|
||||
To see a real GNOME header bar or KDE title bar, use a Linux machine
|
||||
or VM.
|
||||
|
||||
2. **OS detection shell-outs return defaults when their CLI is missing.**
|
||||
On macOS, `gsettings` and `kreadconfig5` aren't installed, so
|
||||
`AMETHYST_PLATFORM=GNOME` without `AMETHYST_APPEARANCE` defaults to
|
||||
dark and without `AMETHYST_ACCENT` defaults to Amethyst purple. Pass
|
||||
the explicit overrides to control them.
|
||||
|
||||
3. **Font fallback chain is deterministic but not always satisfying.**
|
||||
The chain (e.g. for GNOME: Adwaita Sans → Cantarell → Inter → Noto
|
||||
Sans → DejaVu Sans) walks Skia's font manager and picks the first
|
||||
installed family. If none of the candidates are installed,
|
||||
`FontFamily.Default` is used (looks like Roboto-ish). To install the
|
||||
GNOME family on macOS for testing:
|
||||
```bash
|
||||
brew install --cask font-cantarell
|
||||
```
|
||||
|
||||
4. **Accent name list is libadwaita-only.** Apple's named accents (red,
|
||||
orange, etc. as integers) and Windows registry accents resolve only
|
||||
when the corresponding host OS is the real OS. Use hex
|
||||
(`AMETHYST_ACCENT=#FF6B35`) for arbitrary colors.
|
||||
|
||||
## Where the Code Lives
|
||||
|
||||
All preview behavior is in `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/`:
|
||||
|
||||
- `PlatformInfo.kt` — OS detection + `amethyst.platform` override
|
||||
- `PlatformAppearance.kt` — dark/light detection + `amethyst.appearance` override
|
||||
- `PlatformAccent.kt` — accent detection + `amethyst.accent` override
|
||||
- `PlatformFonts.kt` — system font resolution via Skia FontMgr
|
||||
- `PlatformShapes.kt` — per-OS Material3 Shapes
|
||||
- `PlatformTypography.kt` — per-OS Material3 Typography
|
||||
- `PlatformColorScheme.kt` — per-OS dark/light ColorSchemes
|
||||
- `PlatformTheme.kt` — `PlatformMaterialTheme` composable + `applyNativeWindowChrome()`
|
||||
@@ -90,6 +90,13 @@ compose.desktop {
|
||||
|
||||
jvmArgs += "-Xmx2g"
|
||||
|
||||
// Forward platform-preview overrides from the gradle invocation to the
|
||||
// launched app's JVM so `./gradlew :desktopApp:run -Damethyst.platform=GNOME`
|
||||
// works in addition to the env-var form (`AMETHYST_PLATFORM=GNOME`).
|
||||
listOf("amethyst.platform", "amethyst.appearance", "amethyst.accent").forEach { key ->
|
||||
System.getProperty(key)?.let { jvmArgs += "-D$key=$it" }
|
||||
}
|
||||
|
||||
nativeDistributions {
|
||||
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm)
|
||||
|
||||
@@ -37,6 +37,7 @@ object DesktopPreferences {
|
||||
private const val KEY_LAST_SCREEN = "last_screen"
|
||||
private const val KEY_DECK_COLUMNS = "deck_columns"
|
||||
private const val KEY_LAYOUT_MODE = "layout_mode"
|
||||
private const val KEY_MESSAGES_LIST_WIDTH = "messages_list_width_dp"
|
||||
|
||||
var feedMode: FeedMode
|
||||
get() {
|
||||
@@ -69,6 +70,13 @@ object DesktopPreferences {
|
||||
prefs.put(KEY_LAYOUT_MODE, value)
|
||||
}
|
||||
|
||||
/** Width (dp) of the conversation list pane in the Messages screen. */
|
||||
var messagesListWidthDp: Float
|
||||
get() = prefs.getFloat(KEY_MESSAGES_LIST_WIDTH, 280f)
|
||||
set(value) {
|
||||
prefs.putFloat(KEY_MESSAGES_LIST_WIDTH, value)
|
||||
}
|
||||
|
||||
private const val KEY_WORKSPACES = "workspaces"
|
||||
|
||||
var workspaces: String
|
||||
|
||||
@@ -20,14 +20,17 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -45,7 +48,6 @@ import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -60,6 +62,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyShortcut
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -81,6 +85,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
||||
import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome
|
||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||
@@ -130,7 +135,7 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
private val isMacOS = com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS
|
||||
|
||||
enum class LayoutMode {
|
||||
SINGLE_PANE,
|
||||
@@ -183,6 +188,42 @@ sealed class DesktopScreen {
|
||||
private var activeTorManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager? = null
|
||||
|
||||
fun main() {
|
||||
// macOS: route the app's MenuBar to the system menu bar at the top of the
|
||||
// screen and set the application name shown in the apple-menu. Both must be
|
||||
// set before AWT initializes (i.e. before any Swing/AWT class loads).
|
||||
if (com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS) {
|
||||
System.setProperty("apple.laf.useScreenMenuBar", "true")
|
||||
System.setProperty("apple.awt.application.name", "Amethyst")
|
||||
System.setProperty("apple.awt.application.appearance", "system")
|
||||
}
|
||||
|
||||
// Set the dock / taskbar icon image before any window is shown.
|
||||
//
|
||||
// On macOS the Cmd+Tab app switcher and the dock use the Taskbar API's
|
||||
// iconImage, NOT the Window(icon=) composable parameter (which only sets
|
||||
// the in-title-bar proxy icon). Without this, a JVM launched via gradle
|
||||
// shows the generic Java coffee-cup square in Cmd+Tab. ImageIO preserves
|
||||
// the PNG alpha channel so the dock renders the logo with transparency;
|
||||
// on macOS the logo is then wrapped in a squircle so it matches
|
||||
// first-party dock icons.
|
||||
try {
|
||||
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
|
||||
val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
|
||||
val adapted =
|
||||
raw?.let {
|
||||
com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon
|
||||
.adaptForHost(it)
|
||||
}
|
||||
if (adapted != null && java.awt.Taskbar.isTaskbarSupported()) {
|
||||
val taskbar = java.awt.Taskbar.getTaskbar()
|
||||
if (taskbar.isSupported(java.awt.Taskbar.Feature.ICON_IMAGE)) {
|
||||
taskbar.iconImage = adapted
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Main") { "Failed to set dock icon: ${e.message}" }
|
||||
}
|
||||
|
||||
Log.minLevel = LogLevel.DEBUG
|
||||
DesktopImageLoaderSetup.setup()
|
||||
Runtime.getRuntime().addShutdownHook(
|
||||
@@ -241,11 +282,35 @@ fun main() {
|
||||
// Callback set by App() for single pane navigation from MenuBar
|
||||
var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) }
|
||||
|
||||
// Window title-bar / taskbar thumbnail icon. On macOS the source logo
|
||||
// is wrapped in a squircle so it matches every other dock icon; on
|
||||
// other platforms the raw transparent logo is used as-is.
|
||||
val appIcon =
|
||||
remember {
|
||||
val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes()
|
||||
val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
|
||||
val adapted =
|
||||
com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon
|
||||
.adaptForHost(raw)
|
||||
val buf = java.io.ByteArrayOutputStream()
|
||||
javax.imageio.ImageIO.write(adapted, "png", buf)
|
||||
val bitmap =
|
||||
org.jetbrains.skia.Image
|
||||
.makeFromEncoded(buf.toByteArray())
|
||||
.toComposeImageBitmap()
|
||||
BitmapPainter(bitmap)
|
||||
}
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = windowState,
|
||||
title = "Amethyst",
|
||||
icon = appIcon,
|
||||
) {
|
||||
// macOS: transparent + full-window-content title bar so the deck/sidebar
|
||||
// shows through, with traffic lights still drawn on top. No-op elsewhere.
|
||||
applyNativeWindowChrome()
|
||||
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item(
|
||||
@@ -737,10 +802,13 @@ fun App(
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = darkColorScheme(),
|
||||
val isDark by com.vitorpamplona.amethyst.desktop.platform
|
||||
.rememberSystemDark(LocalAwtWindow.current)
|
||||
|
||||
com.vitorpamplona.amethyst.desktop.platform.PlatformMaterialTheme(isDark = isDark) {
|
||||
ProvideMaterialSymbols(
|
||||
weight = com.vitorpamplona.amethyst.desktop.platform.PlatformIconWeight.current,
|
||||
) {
|
||||
ProvideMaterialSymbols {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
@@ -1254,21 +1322,39 @@ fun RelaySettingsScreen(
|
||||
accountManager.loadNwcConnection()
|
||||
}
|
||||
|
||||
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
|
||||
val sidePadding =
|
||||
com.vitorpamplona.amethyst.desktop.ui
|
||||
.readingHorizontalPadding()
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = sidePadding),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Wallet Connect Section
|
||||
Text(
|
||||
"Wallet Connect (NWC)",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -1377,7 +1463,7 @@ fun RelaySettingsScreen(
|
||||
|
||||
Text(
|
||||
"Relay Settings",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -1464,3 +1550,4 @@ fun RelaySettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-19
@@ -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)
|
||||
|
||||
+192
@@ -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
|
||||
}
|
||||
}
|
||||
+166
@@ -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)
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import java.awt.Window
|
||||
import java.awt.event.WindowFocusListener
|
||||
|
||||
/**
|
||||
* Reads the OS's preferred light/dark appearance.
|
||||
*
|
||||
* - macOS: `defaults read -g AppleInterfaceStyle` returns "Dark" only when dark is on.
|
||||
* - GNOME: `gsettings get org.gnome.desktop.interface color-scheme` → 'prefer-dark' / 'prefer-light' / 'default'.
|
||||
* - KDE: `kreadconfig5 --group General --key ColorScheme` (or kreadconfig6) — name contains "Dark" when dark.
|
||||
* - Windows: HKCU AppsUseLightTheme registry value (0 = dark).
|
||||
*
|
||||
* Detection is stale-tolerant: re-runs on window focus so users who flip their
|
||||
* system theme see Amethyst follow within a second of bringing the window forward.
|
||||
*/
|
||||
object PlatformAppearance {
|
||||
/**
|
||||
* Resolves the OS dark/light preference, with an override hook for testing
|
||||
* on a single machine: `-Damethyst.appearance=light|dark` (system property)
|
||||
* or `AMETHYST_APPEARANCE=light|dark` (env var).
|
||||
*
|
||||
* When the platform is overridden but appearance isn't, this calls the
|
||||
* override platform's detection routine. On a Mac forced to GNOME the
|
||||
* `gsettings` shell-out won't exist and it falls through to the GNOME
|
||||
* default (dark) — pass `AMETHYST_APPEARANCE=light` to flip it.
|
||||
*/
|
||||
fun isSystemDark(): Boolean {
|
||||
forcedAppearance()?.let { return it }
|
||||
return when (PlatformInfo.current) {
|
||||
Platform.MACOS -> isMacOSDark()
|
||||
Platform.GNOME -> isGnomeDark()
|
||||
Platform.KDE -> isKdeDark()
|
||||
Platform.WINDOWS -> isWindowsDark()
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun forcedAppearance(): Boolean? {
|
||||
val raw =
|
||||
System.getProperty("amethyst.appearance")
|
||||
?: System.getenv("AMETHYST_APPEARANCE")
|
||||
?: return null
|
||||
return when (raw.lowercase()) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMacOSDark(): Boolean {
|
||||
val out = exec("defaults", "read", "-g", "AppleInterfaceStyle") ?: return false
|
||||
return out.contains("Dark", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun isGnomeDark(): Boolean {
|
||||
val out = exec("gsettings", "get", "org.gnome.desktop.interface", "color-scheme") ?: return true
|
||||
if (out.contains("prefer-dark", ignoreCase = true)) return true
|
||||
if (out.contains("prefer-light", ignoreCase = true)) return false
|
||||
// 'default' → fall back to legacy gtk-theme heuristic
|
||||
val theme = exec("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme").orEmpty()
|
||||
return theme.contains("dark", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun isKdeDark(): Boolean {
|
||||
// Try Plasma 6 first, fall back to 5.
|
||||
val tools = listOf("kreadconfig6", "kreadconfig5")
|
||||
for (tool in tools) {
|
||||
val out = exec(tool, "--group", "General", "--key", "ColorScheme")
|
||||
if (!out.isNullOrBlank()) return out.contains("dark", ignoreCase = true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isWindowsDark(): Boolean {
|
||||
val out =
|
||||
exec(
|
||||
"reg",
|
||||
"query",
|
||||
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
|
||||
"/v",
|
||||
"AppsUseLightTheme",
|
||||
) ?: return false
|
||||
// Output line looks like: " AppsUseLightTheme REG_DWORD 0x0"
|
||||
val match = Regex("AppsUseLightTheme\\s+REG_DWORD\\s+0x([0-9a-fA-F]+)").find(out) ?: return false
|
||||
return match.groupValues[1].toIntOrNull(16) == 0
|
||||
}
|
||||
|
||||
private fun exec(vararg cmd: String): String? =
|
||||
try {
|
||||
val proc =
|
||||
ProcessBuilder(*cmd)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val out =
|
||||
proc.inputStream
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
.trim()
|
||||
val finished = proc.waitFor(2, java.util.concurrent.TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
proc.destroyForcibly()
|
||||
null
|
||||
} else if (proc.exitValue() != 0) {
|
||||
null
|
||||
} else {
|
||||
out
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d("PlatformAppearance") { "Failed to exec ${cmd.joinToString(" ")}: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Compose [State]<Boolean> that tracks the OS dark/light preference and
|
||||
* refreshes whenever the given AWT [Window] gains focus. Lightweight (~30ms shell-out
|
||||
* on focus) and avoids polling.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSystemDark(awtWindow: Window?): State<Boolean> {
|
||||
val state = remember { mutableStateOf(PlatformAppearance.isSystemDark()) }
|
||||
DisposableEffect(awtWindow) {
|
||||
if (awtWindow == null) return@DisposableEffect onDispose {}
|
||||
val listener =
|
||||
object : WindowFocusListener {
|
||||
override fun windowGainedFocus(e: java.awt.event.WindowEvent?) {
|
||||
state.value = PlatformAppearance.isSystemDark()
|
||||
}
|
||||
|
||||
override fun windowLostFocus(e: java.awt.event.WindowEvent?) = Unit
|
||||
}
|
||||
awtWindow.addWindowFocusListener(listener)
|
||||
onDispose { awtWindow.removeWindowFocusListener(listener) }
|
||||
}
|
||||
return state
|
||||
}
|
||||
+295
@@ -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
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.platform.Typeface
|
||||
import org.jetbrains.skia.FontMgr
|
||||
import org.jetbrains.skia.FontStyle
|
||||
import java.awt.GraphicsEnvironment
|
||||
|
||||
/**
|
||||
* Resolves a [FontFamily] backed by the host OS's preferred UI font, with a
|
||||
* per-OS fallback chain. Uses Skia's font manager to look up the family by name
|
||||
* — names not installed on the system are skipped, so the order acts as a chain.
|
||||
*
|
||||
* Targets per OS:
|
||||
* - macOS: SF Pro Text (the system UI font; "Helvetica Neue" / "Helvetica" as fallbacks).
|
||||
* - GNOME: Adwaita Sans (47+), Cantarell (older), Inter, Noto Sans.
|
||||
* - KDE Plasma: Noto Sans (Plasma's default), then Inter / DejaVu Sans.
|
||||
* - Windows: Segoe UI Variable Text (Win 11), Segoe UI (Win 10).
|
||||
*
|
||||
* The final fallback is `FontFamily.Default` so Compose still has something to draw
|
||||
* with on stripped-down systems.
|
||||
*/
|
||||
object PlatformFonts {
|
||||
val ui: FontFamily by lazy { resolve(uiCandidates()) }
|
||||
|
||||
val mono: FontFamily by lazy { resolve(monoCandidates(), fallback = FontFamily.Monospace) }
|
||||
|
||||
private fun uiCandidates(): List<String> =
|
||||
when (PlatformInfo.current) {
|
||||
Platform.MACOS -> {
|
||||
listOf("SF Pro Text", ".AppleSystemUIFont", "Helvetica Neue", "Helvetica")
|
||||
}
|
||||
|
||||
Platform.WINDOWS -> {
|
||||
listOf("Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI")
|
||||
}
|
||||
|
||||
Platform.GNOME -> {
|
||||
listOf("Adwaita Sans", "Cantarell", "Inter", "Noto Sans", "DejaVu Sans")
|
||||
}
|
||||
|
||||
Platform.KDE -> {
|
||||
listOf("Noto Sans", "Inter", "DejaVu Sans", "Cantarell")
|
||||
}
|
||||
|
||||
Platform.LINUX_OTHER -> {
|
||||
listOf("Inter", "Noto Sans", "DejaVu Sans", "Cantarell")
|
||||
}
|
||||
|
||||
Platform.UNKNOWN -> {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun monoCandidates(): List<String> =
|
||||
when (PlatformInfo.current) {
|
||||
Platform.MACOS -> {
|
||||
listOf("SF Mono", "Menlo", "Monaco")
|
||||
}
|
||||
|
||||
Platform.WINDOWS -> {
|
||||
listOf("Cascadia Mono", "Cascadia Code", "Consolas")
|
||||
}
|
||||
|
||||
Platform.GNOME -> {
|
||||
listOf("Adwaita Mono", "Source Code Pro", "DejaVu Sans Mono")
|
||||
}
|
||||
|
||||
Platform.KDE -> {
|
||||
listOf("Hack", "Noto Sans Mono", "Source Code Pro")
|
||||
}
|
||||
|
||||
Platform.LINUX_OTHER -> {
|
||||
listOf("JetBrains Mono", "Source Code Pro", "DejaVu Sans Mono")
|
||||
}
|
||||
|
||||
Platform.UNKNOWN -> {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolve(
|
||||
candidates: List<String>,
|
||||
fallback: FontFamily = FontFamily.Default,
|
||||
): FontFamily {
|
||||
if (candidates.isEmpty()) return fallback
|
||||
val installed = installedFamilies
|
||||
val name = candidates.firstOrNull { it in installed } ?: return fallback
|
||||
return systemFamily(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [FontFamily] from a system family name. Skia's `FontMgr.matchFamilyStyle`
|
||||
* gives us a regular-weight typeface; Skia synthesizes bold/italic from it on demand,
|
||||
* which is how every native UI toolkit on desktop renders system fonts.
|
||||
*/
|
||||
private fun systemFamily(name: String): FontFamily {
|
||||
val skTypeface =
|
||||
runCatching { FontMgr.default.matchFamilyStyle(name, FontStyle.NORMAL) }
|
||||
.getOrNull() ?: return FontFamily.Default
|
||||
return FontFamily(Typeface(skTypeface))
|
||||
}
|
||||
|
||||
private val installedFamilies: Set<String> by lazy {
|
||||
try {
|
||||
GraphicsEnvironment
|
||||
.getLocalGraphicsEnvironment()
|
||||
.availableFontFamilyNames
|
||||
.toSet()
|
||||
} catch (e: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Per-OS Material3 [Shapes] tuned to match each platform's native rounding language.
|
||||
* Material's defaults (4 / 4 / 0 dp) read as Android — these values match what users
|
||||
* see in their OS's first-party apps.
|
||||
*
|
||||
* - macOS (Sonoma+): ~10 / 12 / 16 dp continuous-style corners.
|
||||
* - GNOME (libadwaita): 9 / 12 / 16 dp — adw_dialog / adw_card baseline.
|
||||
* - KDE (Breeze): 6 / 8 / 12 dp — Breeze prefers tighter rounding than libadwaita.
|
||||
* - Windows (WinUI 3): 4 / 8 / 8 dp — WinUI's mica surfaces use modest rounding.
|
||||
*/
|
||||
object PlatformShapes {
|
||||
val current: Shapes by lazy {
|
||||
when (PlatformInfo.current) {
|
||||
Platform.MACOS -> {
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(6.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(10.dp),
|
||||
large = RoundedCornerShape(14.dp),
|
||||
extraLarge = RoundedCornerShape(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Platform.GNOME -> {
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(6.dp),
|
||||
small = RoundedCornerShape(9.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Platform.KDE -> {
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(4.dp),
|
||||
small = RoundedCornerShape(6.dp),
|
||||
medium = RoundedCornerShape(8.dp),
|
||||
large = RoundedCornerShape(12.dp),
|
||||
extraLarge = RoundedCornerShape(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Platform.WINDOWS -> {
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(4.dp),
|
||||
small = RoundedCornerShape(4.dp),
|
||||
medium = RoundedCornerShape(8.dp),
|
||||
large = RoundedCornerShape(8.dp),
|
||||
extraLarge = RoundedCornerShape(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Platform.LINUX_OTHER, Platform.UNKNOWN -> {
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(6.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(10.dp),
|
||||
large = RoundedCornerShape(14.dp),
|
||||
extraLarge = RoundedCornerShape(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.FrameWindowScope
|
||||
|
||||
/**
|
||||
* Wraps content in a [MaterialTheme] tuned for the host OS: native fonts, accent
|
||||
* color, surface tones, and shape rounding all match what the user sees in their
|
||||
* other apps.
|
||||
*
|
||||
* Pair this with [applyNativeWindowChrome] inside the [androidx.compose.ui.window.Window]
|
||||
* scope to also get native title-bar treatment (transparent title bar with
|
||||
* traffic-light inset on macOS).
|
||||
*/
|
||||
@Composable
|
||||
fun PlatformMaterialTheme(
|
||||
isDark: Boolean,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val accent = remember { PlatformAccent.systemAccent() }
|
||||
val colorScheme = remember(isDark, accent) { PlatformColorScheme.resolve(isDark, accent) }
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = PlatformTypography.current,
|
||||
shapes = PlatformShapes.current,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inset to apply at the top of window content so it doesn't underlap the macOS
|
||||
* traffic lights once `applyNativeWindowChrome` has made the title bar transparent
|
||||
* + full-content. ~28 dp clears the buttons with comfortable padding. On non-macOS
|
||||
* platforms returns 0 dp so layouts stay flush.
|
||||
*/
|
||||
val titleBarInsetTop: Dp
|
||||
get() = if (PlatformInfo.isMacOS) 28.dp else 0.dp
|
||||
|
||||
/**
|
||||
* Applies platform-specific native window chrome.
|
||||
*
|
||||
* On macOS:
|
||||
* - `apple.awt.transparentTitleBar = true` removes the title bar tint so app
|
||||
* content shows through.
|
||||
* - `apple.awt.fullWindowContent = true` lets content extend under the title bar
|
||||
* while the traffic lights stay drawn on top.
|
||||
* - `apple.awt.windowTitleVisible = false` hides the "Amethyst" string so the
|
||||
* bar is clean.
|
||||
*
|
||||
* On other platforms this is a no-op — the OS chrome stays as-is, which is
|
||||
* already what users expect (custom Compose-drawn title bars on Linux/Windows
|
||||
* mostly look worse than the system's, especially for window snapping & a11y).
|
||||
*/
|
||||
@Composable
|
||||
fun FrameWindowScope.applyNativeWindowChrome() {
|
||||
if (!PlatformInfo.isMacOS) return
|
||||
val rootPane = window.rootPane
|
||||
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
|
||||
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
|
||||
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.platform
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Material3 [Typography] using the host OS's preferred UI font with per-OS letter
|
||||
* spacing tuned to match native apps. Material's defaults are positively tracked
|
||||
* (Roboto-style); Apple's SF Pro and GNOME's Adwaita are tracked tighter at the
|
||||
* larger sizes, so we mirror that.
|
||||
*
|
||||
* The body sizes stay close to Material defaults so the existing layout code that
|
||||
* assumes 14–16sp body text doesn't reflow.
|
||||
*/
|
||||
object PlatformTypography {
|
||||
val current: Typography by lazy { build(PlatformFonts.ui) }
|
||||
|
||||
private fun build(family: FontFamily): Typography {
|
||||
// Per-OS letter spacing offset applied to display/headline styles.
|
||||
val tightening: TextUnit =
|
||||
when (PlatformInfo.current) {
|
||||
Platform.MACOS -> (-0.4).sp
|
||||
|
||||
// SF Pro is set tight at large sizes
|
||||
Platform.GNOME -> (-0.2).sp
|
||||
|
||||
// Adwaita Sans is mildly tight
|
||||
Platform.KDE, Platform.WINDOWS -> 0.sp
|
||||
|
||||
else -> 0.sp
|
||||
}
|
||||
|
||||
fun ts(
|
||||
size: Int,
|
||||
line: Int,
|
||||
weight: FontWeight = FontWeight.Normal,
|
||||
tracking: TextUnit = 0.sp,
|
||||
) = TextStyle(
|
||||
fontFamily = family,
|
||||
fontWeight = weight,
|
||||
fontSize = size.sp,
|
||||
lineHeight = line.sp,
|
||||
letterSpacing = tracking,
|
||||
)
|
||||
|
||||
return Typography(
|
||||
displayLarge = ts(57, 64, FontWeight.Normal, tightening),
|
||||
displayMedium = ts(45, 52, FontWeight.Normal, tightening),
|
||||
displaySmall = ts(36, 44, FontWeight.Normal, tightening),
|
||||
headlineLarge = ts(32, 40, FontWeight.SemiBold, tightening),
|
||||
headlineMedium = ts(28, 36, FontWeight.SemiBold, tightening),
|
||||
headlineSmall = ts(24, 32, FontWeight.SemiBold, tightening),
|
||||
titleLarge = ts(22, 28, FontWeight.SemiBold),
|
||||
titleMedium = ts(16, 24, FontWeight.Medium, 0.15.sp),
|
||||
titleSmall = ts(14, 20, FontWeight.Medium, 0.1.sp),
|
||||
bodyLarge = ts(16, 24, FontWeight.Normal, 0.15.sp),
|
||||
bodyMedium = ts(14, 20, FontWeight.Normal, 0.25.sp),
|
||||
bodySmall = ts(12, 16, FontWeight.Normal, 0.4.sp),
|
||||
labelLarge = ts(14, 20, FontWeight.Medium, 0.1.sp),
|
||||
labelMedium = ts(12, 16, FontWeight.Medium, 0.5.sp),
|
||||
labelSmall = ts(11, 16, FontWeight.Medium, 0.5.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+28
-6
@@ -29,12 +29,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
@@ -63,6 +66,8 @@ import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownEditorState
|
||||
import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar
|
||||
import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel
|
||||
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip23LongContent.LongFormPublishAction
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
@@ -227,24 +232,41 @@ fun ArticleEditorScreen(
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Top bar
|
||||
// Header — Messages-style: back + titleMedium on left, actions on right
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = onBack) {
|
||||
Text("Back")
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Article",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
saveMessage?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
)
|
||||
}
|
||||
// Save/Publish stay as text buttons — they're primary destructive
|
||||
// actions, not affordances you'd reduce to an icon.
|
||||
OutlinedButton(onClick = { saveDraft() }) {
|
||||
Text("Save")
|
||||
}
|
||||
|
||||
+6
-5
@@ -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) {
|
||||
|
||||
+8
-3
@@ -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(
|
||||
|
||||
+27
-16
@@ -20,19 +20,19 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -52,6 +52,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore
|
||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -65,33 +66,42 @@ fun DraftsScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ReadingColumn {
|
||||
val sidePadding = readingHorizontalPadding()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = sidePadding, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Drafts",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Button(onClick = { onOpenEditor(null) }) {
|
||||
Icon(MaterialSymbols.Add, contentDescription = null)
|
||||
Text("New Draft", modifier = Modifier.padding(start = 4.dp))
|
||||
// Convert "New Draft" button to an icon for consistency with other
|
||||
// screens' tabs-first + icon-actions header pattern.
|
||||
IconButton(onClick = { onOpenEditor(null) }, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
MaterialSymbols.Add,
|
||||
contentDescription = "New Draft",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (drafts.isEmpty()) {
|
||||
Text(
|
||||
"No drafts yet. Click \"New Draft\" to start writing.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
EmptyState(
|
||||
title = "No drafts yet",
|
||||
description = "Click \"New Draft\" to start writing.",
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = sidePadding),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(drafts, key = { it.slug }) { entry ->
|
||||
@@ -139,7 +149,8 @@ private fun DraftCard(
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
|
||||
+16
-47
@@ -18,11 +18,12 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.feed
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -33,10 +34,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.RelayStatusColors
|
||||
|
||||
/**
|
||||
* Header component for feed screens with title and relay connection status.
|
||||
* Compact Messages-style header for feed screens: titleMedium on the left,
|
||||
* a single refresh IconButton on the right. Relay count is rolled into the
|
||||
* refresh button's content description so it still surfaces in hover tooltips
|
||||
* / accessibility readers without stealing a whole row.
|
||||
*
|
||||
* @param title The feed title
|
||||
* @param connectedRelayCount Number of connected relays
|
||||
@@ -51,62 +54,28 @@ fun FeedHeader(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = readingHorizontalPadding(), vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
RelayStatusIndicator(
|
||||
connectedCount = connectedRelayCount,
|
||||
onRefresh = onRefresh,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact relay connection status indicator with refresh button.
|
||||
*/
|
||||
@Composable
|
||||
fun RelayStatusIndicator(
|
||||
connectedCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
IconButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val statusColor =
|
||||
when {
|
||||
connectedCount == 0 -> RelayStatusColors.Disconnected
|
||||
connectedCount < 3 -> RelayStatusColors.Connecting
|
||||
else -> RelayStatusColors.Connected
|
||||
}
|
||||
|
||||
Icon(
|
||||
symbol = if (connectedCount > 0) MaterialSymbols.Check else MaterialSymbols.Close,
|
||||
contentDescription = null,
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
|
||||
Text(
|
||||
"$connectedCount relay${if (connectedCount != 1) "s" else ""}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(
|
||||
MaterialSymbols.Refresh,
|
||||
contentDescription = "Reconnect",
|
||||
contentDescription = "Refresh ($connectedRelayCount relays connected)",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,12 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -33,14 +33,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -487,9 +486,8 @@ fun FeedScreen(
|
||||
onDispose { subId?.let { coordinator.releaseInteractions(it) } }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ReadingColumn {
|
||||
// Header with compose button
|
||||
FeedHeader(
|
||||
feedMode = feedMode,
|
||||
@@ -546,7 +544,9 @@ fun FeedScreen(
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
val loadedState by state.feed.collectAsState()
|
||||
val sidePadding = LocalReadingSidePadding.current
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = sidePadding + 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(loadedState.list, key = { it.idHex }) { note ->
|
||||
@@ -627,9 +627,15 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed header with title, mode selector, relay count, and compose button.
|
||||
* Feed header \u2014 Messages-style single row: Following/Global tabs on the left,
|
||||
* compact icon buttons on the right (relays, refresh, compose). The selected
|
||||
* tab IS the title; no redundant "Global Feed" / "Following Feed" label.
|
||||
*
|
||||
* Relay count and followed-users count are surfaced via a hover tooltip on
|
||||
* the relays icon (desktop convention \u2014 the at-a-glance info is preserved
|
||||
* without stealing header real estate).
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FeedHeader(
|
||||
feedMode: FeedMode,
|
||||
@@ -642,90 +648,100 @@ private fun FeedHeader(
|
||||
onNavigateToRelays: () -> Unit = {},
|
||||
onOpenRelayPicker: () -> Unit = {},
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
val sidePadding = LocalReadingSidePadding.current
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = sidePadding + 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
// Tabs \u2014 the selected one is the screen title.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (account != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.GLOBAL,
|
||||
onClick = { onFeedModeChange(FeedMode.GLOBAL) },
|
||||
label = { Text("Global") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.FOLLOWING,
|
||||
onClick = { onFeedModeChange(FeedMode.FOLLOWING) },
|
||||
label = { Text("Following") },
|
||||
)
|
||||
}
|
||||
}
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.GLOBAL,
|
||||
onClick = { onFeedModeChange(FeedMode.GLOBAL) },
|
||||
label = { Text("Global") },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
// Actions \u2014 compact icon buttons at the same scale as the Messages header.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${feedRelays.size} relays",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier =
|
||||
Modifier.clickable { onNavigateToRelays() },
|
||||
)
|
||||
val relaysTooltip =
|
||||
buildString {
|
||||
append("${feedRelays.size} relays")
|
||||
if (feedMode == FeedMode.FOLLOWING) {
|
||||
append(" \u2022 $followedUsersCount followed")
|
||||
}
|
||||
}
|
||||
TooltipArea(
|
||||
tooltip = {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Text(
|
||||
" \u2022 $followedUsersCount followed",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
relaysTooltip,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (account != null && !account.isReadOnly) {
|
||||
},
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onOpenRelayPicker,
|
||||
modifier = Modifier.size(24.dp),
|
||||
onClick = {
|
||||
if (account != null && !account.isReadOnly) {
|
||||
onOpenRelayPicker()
|
||||
} else {
|
||||
onNavigateToRelays()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Dns,
|
||||
contentDescription = "Edit Feed Relays",
|
||||
contentDescription = relaysTooltip,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.size(24.dp),
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (account != null && !account.isReadOnly) {
|
||||
IconButton(
|
||||
onClick = onCompose,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Add,
|
||||
contentDescription = "New Post",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onCompose,
|
||||
enabled = account != null && !account.isReadOnly,
|
||||
) {
|
||||
Icon(MaterialSymbols.Add, "New Post", Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("New Post")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -56,7 +56,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPa
|
||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
@@ -238,15 +237,13 @@ fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ReadingColumn {
|
||||
FeedHeader(
|
||||
title = "Notifications",
|
||||
connectedRelayCount = connectedRelays.size,
|
||||
onRefresh = { relayManager.connect() },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else if (notifications.isEmpty() && !initialLoadComplete) {
|
||||
@@ -259,6 +256,7 @@ fun NotificationsScreen(
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification ->
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Maximum reading width for single-pane content screens. Matches the
|
||||
* comfortable column width used by Twitter / Mastodon / Threads on desktop —
|
||||
* wider than a book column (which tops out around 600 dp), narrower than a
|
||||
* full-window feed, so cards don't stretch disproportionately on 4K displays.
|
||||
*/
|
||||
val DefaultReadingWidth: Dp = 720.dp
|
||||
|
||||
/**
|
||||
* Side padding the current screen should apply to its scrollable list /
|
||||
* headers to keep content centered at [DefaultReadingWidth] within the
|
||||
* window. `0.dp` outside of a [ReadingColumn].
|
||||
*
|
||||
* Screens use this to widen the gutter on wide displays (`horizontal =
|
||||
* readingSidePadding + 12.dp`) while keeping the scrollable area itself at
|
||||
* full window width — so the mouse wheel scrolls the feed wherever it
|
||||
* hovers, not only inside the 720 dp column.
|
||||
*/
|
||||
val LocalReadingSidePadding = compositionLocalOf { 0.dp }
|
||||
|
||||
/**
|
||||
* Convenience: reads the current reading-column side padding and adds the
|
||||
* standard 12.dp screen-edge gutter. Use this inside composable bodies when
|
||||
* applying horizontal padding to header rows or `contentPadding` on
|
||||
* LazyColumns so items stay centered at [DefaultReadingWidth] while the
|
||||
* scrollable surface still spans the full window width.
|
||||
*/
|
||||
@Composable
|
||||
fun readingHorizontalPadding(): Dp = LocalReadingSidePadding.current + 12.dp
|
||||
|
||||
/**
|
||||
* Top-level scaffold for single-pane content screens. Measures the window
|
||||
* width and computes the side padding that centers a [maxWidth]-wide column
|
||||
* within it. The actual content (header + LazyColumn) fills the window — the
|
||||
* centering is done via [LocalReadingSidePadding] applied to inner modifiers
|
||||
* (`horizontal` padding on a header Row, `contentPadding` on a LazyColumn).
|
||||
* This keeps scroll events live across the full window, not just the center
|
||||
* column.
|
||||
*
|
||||
* Not used by:
|
||||
* - Messages (two-pane layout with its own sizing)
|
||||
* - Article Reader (has its own narrower reading-width logic)
|
||||
* - Editor / Chess / Relay Dashboard (tools that want full width)
|
||||
*/
|
||||
@Composable
|
||||
fun ReadingColumn(
|
||||
maxWidth: Dp = DefaultReadingWidth,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val sidePadding = ((this.maxWidth - maxWidth) / 2).coerceAtLeast(0.dp)
|
||||
CompositionLocalProvider(LocalReadingSidePadding provides sidePadding) {
|
||||
Column(modifier = Modifier.fillMaxSize(), content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-48
@@ -23,16 +23,13 @@ package com.vitorpamplona.amethyst.desktop.ui
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
@@ -100,10 +97,8 @@ fun LongFormCard(
|
||||
val publishedAt = event.publishedAt() ?: event.createdAt
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
@@ -289,66 +284,45 @@ fun ReadsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header — wraps on narrow columns
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
ReadingColumn {
|
||||
val sidePadding = readingHorizontalPadding()
|
||||
// Header — Messages-style: tabs left, refresh right. The selected tab
|
||||
// (Following / Global) acts as the screen title, so no separate label.
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = sidePadding, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Reads",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
// Feed mode selector
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (account != null) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.GLOBAL,
|
||||
onClick = { feedMode = FeedMode.GLOBAL },
|
||||
label = { Text("Global") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.FOLLOWING,
|
||||
onClick = { feedMode = FeedMode.FOLLOWING },
|
||||
label = { Text("Following") },
|
||||
)
|
||||
}
|
||||
}
|
||||
FilterChip(
|
||||
selected = feedMode == FeedMode.GLOBAL,
|
||||
onClick = { feedMode = FeedMode.GLOBAL },
|
||||
label = { Text("Global") },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${connectedRelays.size} relays connected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = { relayManager.connect() },
|
||||
modifier = Modifier.size(24.dp),
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
contentDescription = "Refresh (${connectedRelays.size} relays connected)",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
when {
|
||||
connectedRelays.isEmpty() -> {
|
||||
@@ -383,6 +357,7 @@ fun ReadsScreen(
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = sidePadding),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(events, key = { it.id }) { event ->
|
||||
|
||||
+70
-13
@@ -28,11 +28,13 @@ import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -46,7 +48,6 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -287,10 +288,12 @@ fun SearchScreen(
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
ReadingColumn {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
when (event.key) {
|
||||
@@ -329,14 +332,19 @@ fun SearchScreen(
|
||||
)
|
||||
|
||||
// Title row
|
||||
val sidePadding = readingHorizontalPadding()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = sidePadding, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Search",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(
|
||||
@@ -348,40 +356,87 @@ fun SearchScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Search bar with advanced toggle
|
||||
// Search bar with advanced toggle — honors the reading width cap so it
|
||||
// stays centered with the rest of the screen's content on wide windows.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
// Compact desktop search field. M3's OutlinedTextField has a hard
|
||||
// ~32dp vertical contentPadding baked in, so forcing .height(40dp)
|
||||
// clipped the placeholder. BasicTextField + DecorationBox lets us
|
||||
// override contentPadding to 8dp vertical so the field can sit at
|
||||
// a true 40dp tall without clipping the bodyMedium line-height.
|
||||
val searchInteraction =
|
||||
remember {
|
||||
androidx.compose.foundation.interaction
|
||||
.MutableInteractionSource()
|
||||
}
|
||||
androidx.compose.foundation.text.BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
textFieldValue = it
|
||||
state.updateFromText(it.text)
|
||||
},
|
||||
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
||||
placeholder = { Text("Search notes, people, tags... or use operators") },
|
||||
modifier = Modifier.weight(1f).height(40.dp).focusRequester(focusRequester),
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyMedium
|
||||
.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
cursorBrush =
|
||||
androidx.compose.ui.graphics
|
||||
.SolidColor(MaterialTheme.colorScheme.primary),
|
||||
singleLine = true,
|
||||
interactionSource = searchInteraction,
|
||||
decorationBox = { innerTextField ->
|
||||
androidx.compose.material3.OutlinedTextFieldDefaults.DecorationBox(
|
||||
value = textFieldValue.text,
|
||||
innerTextField = innerTextField,
|
||||
enabled = true,
|
||||
singleLine = true,
|
||||
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
|
||||
interactionSource = searchInteraction,
|
||||
placeholder = {
|
||||
Text(
|
||||
"Search people, tags, notes…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
MaterialSymbols.Search,
|
||||
contentDescription = "Search",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (displayText.isNotEmpty()) {
|
||||
IconButton(onClick = { state.clearSearch() }) {
|
||||
IconButton(onClick = { state.clearSearch() }, modifier = Modifier.size(28.dp)) {
|
||||
Icon(
|
||||
MaterialSymbols.Clear,
|
||||
contentDescription = "Clear",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 12.dp,
|
||||
vertical = 8.dp,
|
||||
),
|
||||
container = {
|
||||
androidx.compose.material3.OutlinedTextFieldDefaults.Container(
|
||||
enabled = true,
|
||||
isError = false,
|
||||
interactionSource = searchInteraction,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
if (account != null && !account.isReadOnly) {
|
||||
IconButton(onClick = { showRelayPicker = true }) {
|
||||
@@ -508,6 +563,7 @@ fun SearchScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchEmptyState(
|
||||
@@ -519,6 +575,7 @@ private fun SearchEmptyState(
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Saved searches
|
||||
|
||||
+10
-6
@@ -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
|
||||
|
||||
+49
-18
@@ -27,6 +27,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -113,6 +114,7 @@ fun UserProfileScreen(
|
||||
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
onBack: () -> Unit,
|
||||
canGoBack: Boolean = false,
|
||||
onCompose: () -> Unit = {},
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
@@ -434,12 +436,14 @@ fun UserProfileScreen(
|
||||
previousFirstVisibleItemScrollOffset = currentOffset
|
||||
}
|
||||
|
||||
ReadingColumn {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (connectedRelays.isEmpty()) {
|
||||
LoadingState("Connecting to relays...")
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
@@ -457,43 +461,57 @@ fun UserProfileScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Header with back button
|
||||
// Header — Messages-style: compact row, titleMedium title.
|
||||
// Horizontal gutter already supplied by LazyColumn.contentPadding.
|
||||
item(key = "header") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back")
|
||||
// Back is shown only when stacked onto a nav stack (e.g.
|
||||
// clicked a user in the feed). Top-level "My Profile"
|
||||
// from the nav rail sets canGoBack = false so no arrow.
|
||||
if (canGoBack) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
"Profile",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
// Edit button for own profile
|
||||
// Edit button for own profile — compact IconButton to match
|
||||
// the action-icon pattern every other screen's header uses.
|
||||
if (isOwnProfile && account.isReadOnly == false) {
|
||||
OutlinedButton(
|
||||
IconButton(
|
||||
onClick = {
|
||||
editingDisplayName = displayName ?: ""
|
||||
showEditDialog = true
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Edit,
|
||||
contentDescription = "Edit profile",
|
||||
modifier = Modifier.size(18.dp),
|
||||
contentDescription = "Edit Profile",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Edit Profile")
|
||||
}
|
||||
}
|
||||
|
||||
// Follow/Unfollow button for other profiles
|
||||
// Follow/Unfollow button for other profiles — compact to
|
||||
// match the header row height (32dp); primary-coloured
|
||||
// text button so the affordance is still legible.
|
||||
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Button(
|
||||
@@ -520,6 +538,8 @@ fun UserProfileScreen(
|
||||
}
|
||||
},
|
||||
enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
|
||||
) {
|
||||
val state = followState.state.collectAsState().value
|
||||
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
|
||||
@@ -879,8 +899,11 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Floating header — appears on scroll up when profile header is out of view
|
||||
AnimatedVisibility(
|
||||
// Floating header — appears on scroll up when profile header is out of view.
|
||||
// Fully-qualified call to force the non-scoped overload; ReadingColumn
|
||||
// provides a ColumnScope in the outer lambda which would otherwise win
|
||||
// overload resolution and break the BoxScope Modifier.align call below.
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = showFloatingHeader,
|
||||
enter = slideInVertically { -it },
|
||||
exit = slideOutVertically { -it },
|
||||
@@ -891,13 +914,20 @@ fun UserProfileScreen(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back")
|
||||
if (canGoBack) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
UserAvatar(
|
||||
userHex = pubKeyHex,
|
||||
pictureUrl = picture,
|
||||
@@ -914,6 +944,7 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lightbox overlay
|
||||
lightboxState?.let { state ->
|
||||
|
||||
+10
-4
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.chat
|
||||
package com.vitorpamplona.amethyst.desktop.ui.chats
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleMaxSizeModifier
|
||||
@@ -128,9 +129,14 @@ fun ChatBubbleLayout(
|
||||
)
|
||||
}
|
||||
|
||||
val bubbleShape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem
|
||||
// Clip the hover/ripple to the bubble shape — combinedClickable otherwise
|
||||
// paints a rectangular ripple that ignores the Surface's rounded corners.
|
||||
val clickableModifier =
|
||||
remember {
|
||||
Modifier.combinedClickable(
|
||||
remember(bubbleShape) {
|
||||
Modifier
|
||||
.clip(bubbleShape)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (!onClick()) {
|
||||
if (!isComplete) {
|
||||
@@ -148,7 +154,7 @@ fun ChatBubbleLayout(
|
||||
) {
|
||||
Surface(
|
||||
color = bgColor.value,
|
||||
shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem,
|
||||
shape = bubbleShape,
|
||||
modifier = clickableModifier,
|
||||
) {
|
||||
Column(modifier = MessageBubbleLimits, verticalArrangement = ChatRowColSpacing5dp) {
|
||||
+1
-1
@@ -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
|
||||
+54
-14
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -41,7 +42,6 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -61,6 +61,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isCtrlPressed
|
||||
@@ -81,11 +82,6 @@ import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.commons.ui.chat.ChatMessageCompose
|
||||
import com.vitorpamplona.amethyst.commons.ui.chat.ChatroomHeader
|
||||
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastBanner
|
||||
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus
|
||||
import com.vitorpamplona.amethyst.commons.ui.chat.GroupChatroomHeader
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
@@ -558,11 +554,13 @@ private fun MessageWithReactions(
|
||||
}
|
||||
}
|
||||
|
||||
// AddReaction icon on hover
|
||||
if (showIcon) {
|
||||
Box {
|
||||
// AddReaction icon: always laid out to reserve space (so hover
|
||||
// doesn't reflow the detail row and shift the whole list);
|
||||
// alpha fades in/out based on hover.
|
||||
Box(modifier = Modifier.alpha(if (showIcon) 1f else 0f)) {
|
||||
IconButton(
|
||||
onClick = { showPicker = !showPicker },
|
||||
enabled = showIcon,
|
||||
modifier = Modifier.size(20.dp),
|
||||
) {
|
||||
Icon(
|
||||
@@ -590,7 +588,6 @@ private fun MessageWithReactions(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { _ ->
|
||||
when (note.event) {
|
||||
@@ -714,12 +711,22 @@ private fun MessageInput(
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
// BasicTextField + DecorationBox with custom contentPadding lets the
|
||||
// field sit at a compact ~40dp minimum on desktop (M3's default
|
||||
// OutlinedTextField has ~32dp vertical contentPadding baked in, which
|
||||
// would clip the bodyMedium placeholder at any height below ~52dp).
|
||||
val messageInteraction =
|
||||
remember {
|
||||
androidx.compose.foundation.interaction
|
||||
.MutableInteractionSource()
|
||||
}
|
||||
androidx.compose.foundation.text.BasicTextField(
|
||||
value = messageText,
|
||||
onValueChange = onMessageChange,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 40.dp)
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
// Cmd+Enter (Mac) or Ctrl+Enter to send
|
||||
@@ -731,10 +738,43 @@ private fun MessageInput(
|
||||
false
|
||||
}
|
||||
},
|
||||
placeholder = { Text("Message... (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)") },
|
||||
singleLine = false,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyMedium
|
||||
.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
cursorBrush =
|
||||
androidx.compose.ui.graphics
|
||||
.SolidColor(MaterialTheme.colorScheme.primary),
|
||||
maxLines = 4,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
interactionSource = messageInteraction,
|
||||
decorationBox = { innerTextField ->
|
||||
androidx.compose.material3.OutlinedTextFieldDefaults.DecorationBox(
|
||||
value = messageText,
|
||||
innerTextField = innerTextField,
|
||||
enabled = true,
|
||||
singleLine = false,
|
||||
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
|
||||
interactionSource = messageInteraction,
|
||||
placeholder = {
|
||||
Text(
|
||||
"Message\u2026 (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 12.dp,
|
||||
vertical = 8.dp,
|
||||
),
|
||||
container = {
|
||||
androidx.compose.material3.OutlinedTextFieldDefaults.Container(
|
||||
enabled = true,
|
||||
isError = false,
|
||||
interactionSource = messageInteraction,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
IconButton(
|
||||
|
||||
+1
-1
@@ -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
|
||||
+43
-24
@@ -55,6 +55,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
@@ -119,6 +120,9 @@ fun ConversationListPane(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
// Chat-list pane reads as a secondary surface (like Messages.app,
|
||||
// Slack, Telegram); the chat content pane to the right stays white.
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { event ->
|
||||
@@ -283,9 +287,13 @@ private fun ConversationCard(
|
||||
) {
|
||||
val backgroundColor =
|
||||
when {
|
||||
isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
isFocused -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
|
||||
|
||||
isFocused -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)
|
||||
|
||||
// Transparent lets the pane's surfaceContainer show through — the
|
||||
// list pane is the "secondary surface" in this screen.
|
||||
else -> Color.Transparent
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -297,22 +305,12 @@ private fun ConversationCard(
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Unread indicator
|
||||
if (item.hasUnread) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
} else {
|
||||
Spacer(Modifier.width(14.dp))
|
||||
}
|
||||
|
||||
// Avatar
|
||||
// Avatar — starts flush with the header's 12dp padding. Unread state is
|
||||
// signalled by a small primary-coloured dot overlaid on the avatar's
|
||||
// bottom-right corner (Slack / iMessage-style), not an inline indent
|
||||
// that would push the whole card to the right of the header label.
|
||||
val firstUser = item.users.firstOrNull()
|
||||
Box {
|
||||
if (firstUser != null) {
|
||||
UserAvatar(
|
||||
userHex = firstUser.pubkeyHex,
|
||||
@@ -327,10 +325,27 @@ private fun ConversationCard(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (item.hasUnread) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(1.5.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
// Name, preview, timestamp
|
||||
// Name, preview, timestamp — vertical hierarchy: name = medium-weight
|
||||
// primary text (titleSmall), preview = muted secondary. Prior styling had
|
||||
// both lines at the same weight / near-same color and stacked flush,
|
||||
// making them blur into each other.
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -339,7 +354,7 @@ private fun ConversationCard(
|
||||
) {
|
||||
Text(
|
||||
text = item.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -347,30 +362,34 @@ private fun ConversationCard(
|
||||
)
|
||||
|
||||
if (item.lastMessageTimestamp > 0) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = item.lastMessageTimestamp.toTimeAgo(withDot = false),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.lastMessagePreview.isNotEmpty()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = item.lastMessagePreview,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
// Group indicator
|
||||
// Group indicator — labelSmall with muted tertiary so the name line
|
||||
// still wins the eye even when a group badge is present.
|
||||
if (item.isGroup) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "Group (${item.users.size + 1})",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+71
-5
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
+5
-4
@@ -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,
|
||||
|
||||
+4
-3
@@ -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,
|
||||
) {
|
||||
|
||||
+9
-4
@@ -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,
|
||||
|
||||
+17
-5
@@ -23,11 +23,12 @@ package com.vitorpamplona.amethyst.desktop.ui.highlights
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -70,14 +71,24 @@ fun MyHighlightsScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
var deleteTarget by remember { mutableStateOf<HighlightData?>(null) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
|
||||
val sidePadding =
|
||||
com.vitorpamplona.amethyst.desktop.ui
|
||||
.readingHorizontalPadding()
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = sidePadding, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Highlights",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (allHighlights.isEmpty()) {
|
||||
EmptyState(
|
||||
@@ -86,6 +97,7 @@ fun MyHighlightsScreen(
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = sidePadding),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
allHighlights.forEach { (addressTag, highlights) ->
|
||||
|
||||
+32
-32
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+15
-7
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -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(),
|
||||
|
||||
+2
@@ -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 ->
|
||||
|
||||
+5
-2
@@ -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))
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ fun TorSettingsSection(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Tor",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 16 KiB |
Reference in New Issue
Block a user