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,
|
colorScheme = colors,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
shapes = Shapes,
|
shapes = Shapes,
|
||||||
content = { ProvideMaterialSymbols(content) },
|
content = { ProvideMaterialSymbols(content = content) },
|
||||||
)
|
)
|
||||||
|
|
||||||
val view = LocalView.current
|
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
|
* 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.
|
* 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
|
@Composable
|
||||||
fun ProvideMaterialSymbols(content: @Composable () -> Unit) {
|
fun ProvideMaterialSymbols(
|
||||||
|
weight: Int = MaterialSymbolsDefaults.WEIGHT,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
val font =
|
val font =
|
||||||
Font(
|
Font(
|
||||||
resource = Res.font.material_symbols_outlined,
|
resource = Res.font.material_symbols_outlined,
|
||||||
weight = FontWeight(MaterialSymbolsDefaults.WEIGHT),
|
weight = FontWeight(weight),
|
||||||
variationSettings =
|
variationSettings =
|
||||||
FontVariation.Settings(
|
FontVariation.Settings(
|
||||||
FontVariation.weight(MaterialSymbolsDefaults.WEIGHT),
|
FontVariation.weight(weight),
|
||||||
FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL),
|
FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL),
|
||||||
FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE),
|
FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE),
|
||||||
FontVariation.Setting("GRAD", MaterialSymbolsDefaults.GRADE),
|
FontVariation.Setting("GRAD", MaterialSymbolsDefaults.GRADE),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Keyless remember: the Font wrapper identity changes every composition but the underlying
|
// Keyless remember is safe only when weight is stable; key on weight so a platform-
|
||||||
// resource is a compile-time constant, so one FontFamily for the lifetime of the subtree.
|
// preview override swap actually rebuilds the FontFamily.
|
||||||
val fontFamily = remember { FontFamily(font) }
|
val fontFamily = remember(weight) { FontFamily(font) }
|
||||||
val textMeasurer = rememberTextMeasurer(cacheSize = TEXT_MEASURER_CACHE_SIZE)
|
val textMeasurer = rememberTextMeasurer(cacheSize = TEXT_MEASURER_CACHE_SIZE)
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalMaterialSymbolsFontFamily provides fontFamily,
|
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"
|
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 {
|
nativeDistributions {
|
||||||
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
|
appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources"))
|
||||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm)
|
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_LAST_SCREEN = "last_screen"
|
||||||
private const val KEY_DECK_COLUMNS = "deck_columns"
|
private const val KEY_DECK_COLUMNS = "deck_columns"
|
||||||
private const val KEY_LAYOUT_MODE = "layout_mode"
|
private const val KEY_LAYOUT_MODE = "layout_mode"
|
||||||
|
private const val KEY_MESSAGES_LIST_WIDTH = "messages_list_width_dp"
|
||||||
|
|
||||||
var feedMode: FeedMode
|
var feedMode: FeedMode
|
||||||
get() {
|
get() {
|
||||||
@@ -69,6 +70,13 @@ object DesktopPreferences {
|
|||||||
prefs.put(KEY_LAYOUT_MODE, value)
|
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"
|
private const val KEY_WORKSPACES = "workspaces"
|
||||||
|
|
||||||
var workspaces: String
|
var workspaces: String
|
||||||
|
|||||||
@@ -20,14 +20,17 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop
|
package com.vitorpamplona.amethyst.desktop
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -45,7 +48,6 @@ import androidx.compose.material3.SnackbarHostState
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
@@ -60,6 +62,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.Key
|
||||||
import androidx.compose.ui.input.key.KeyShortcut
|
import androidx.compose.ui.input.key.KeyShortcut
|
||||||
import androidx.compose.ui.unit.dp
|
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.DefaultRelays
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
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.highlights.DesktopHighlightStore
|
||||||
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
||||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||||
@@ -130,7 +135,7 @@ import kotlinx.coroutines.runBlocking
|
|||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlin.time.Duration.Companion.seconds
|
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 {
|
enum class LayoutMode {
|
||||||
SINGLE_PANE,
|
SINGLE_PANE,
|
||||||
@@ -183,6 +188,42 @@ sealed class DesktopScreen {
|
|||||||
private var activeTorManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager? = null
|
private var activeTorManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager? = null
|
||||||
|
|
||||||
fun main() {
|
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
|
Log.minLevel = LogLevel.DEBUG
|
||||||
DesktopImageLoaderSetup.setup()
|
DesktopImageLoaderSetup.setup()
|
||||||
Runtime.getRuntime().addShutdownHook(
|
Runtime.getRuntime().addShutdownHook(
|
||||||
@@ -241,11 +282,35 @@ fun main() {
|
|||||||
// Callback set by App() for single pane navigation from MenuBar
|
// Callback set by App() for single pane navigation from MenuBar
|
||||||
var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) }
|
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(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
state = windowState,
|
state = windowState,
|
||||||
title = "Amethyst",
|
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 {
|
MenuBar {
|
||||||
Menu("File") {
|
Menu("File") {
|
||||||
Item(
|
Item(
|
||||||
@@ -737,10 +802,13 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
val isDark by com.vitorpamplona.amethyst.desktop.platform
|
||||||
colorScheme = darkColorScheme(),
|
.rememberSystemDark(LocalAwtWindow.current)
|
||||||
|
|
||||||
|
com.vitorpamplona.amethyst.desktop.platform.PlatformMaterialTheme(isDark = isDark) {
|
||||||
|
ProvideMaterialSymbols(
|
||||||
|
weight = com.vitorpamplona.amethyst.desktop.platform.PlatformIconWeight.current,
|
||||||
) {
|
) {
|
||||||
ProvideMaterialSymbols {
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
@@ -1254,21 +1322,39 @@ fun RelaySettingsScreen(
|
|||||||
accountManager.loadNwcConnection()
|
accountManager.loadNwcConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
|
||||||
|
val sidePadding =
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui
|
||||||
|
.readingHorizontalPadding()
|
||||||
Column(
|
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(
|
Text(
|
||||||
"Settings",
|
"Settings",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Wallet Connect Section
|
// Wallet Connect Section
|
||||||
Text(
|
Text(
|
||||||
"Wallet Connect (NWC)",
|
"Wallet Connect (NWC)",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -1377,7 +1463,7 @@ fun RelaySettingsScreen(
|
|||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Relay Settings",
|
"Relay Settings",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
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.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
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.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -178,42 +179,49 @@ fun ChessScreen(
|
|||||||
var showNewGameDialog by remember { mutableStateOf(false) }
|
var showNewGameDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header
|
// Header — Messages-style: compact row, titleMedium title, icon-only actions
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (selectedGameId != null) {
|
if (selectedGameId != null) {
|
||||||
IconButton(onClick = { viewModel.selectGame(null) }) {
|
IconButton(onClick = { viewModel.selectGame(null) }, modifier = Modifier.size(32.dp)) {
|
||||||
Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back to list")
|
Icon(
|
||||||
|
MaterialSymbols.AutoMirrored.ArrowBack,
|
||||||
|
contentDescription = "Back to list",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
if (selectedGameId != null) "Live Game" else "Chess",
|
if (selectedGameId != null) "Live Game" else "Chess",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedGameId == null) {
|
if (selectedGameId == null) {
|
||||||
Row(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
IconButton(onClick = { viewModel.forceRefresh() }, modifier = Modifier.size(32.dp)) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Icon(
|
||||||
) {
|
MaterialSymbols.Refresh,
|
||||||
// Refresh button
|
contentDescription = "Refresh",
|
||||||
IconButton(onClick = { viewModel.forceRefresh() }) {
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
Icon(MaterialSymbols.Refresh, "Refresh")
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New Game button
|
|
||||||
if (!account.isReadOnly) {
|
if (!account.isReadOnly) {
|
||||||
Button(onClick = { showNewGameDialog = true }) {
|
IconButton(onClick = { showNewGameDialog = true }, modifier = Modifier.size(32.dp)) {
|
||||||
Icon(MaterialSymbols.Add, "New Game")
|
Icon(
|
||||||
Spacer(Modifier.width(8.dp))
|
MaterialSymbols.Add,
|
||||||
Text("New Game")
|
contentDescription = "New Game",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,6 +416,7 @@ private fun ChessLobby(
|
|||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
// Active games section (user is participant)
|
// 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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.layout.widthIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
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.MarkdownToolbar
|
||||||
import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel
|
import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel
|
||||||
import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown
|
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.commons.model.nip23LongContent.LongFormPublishAction
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
OutlinedButton(onClick = onBack) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text("Back")
|
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 {
|
saveMessage?.let {
|
||||||
Text(
|
Text(
|
||||||
it,
|
it,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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() }) {
|
OutlinedButton(onClick = { saveDraft() }) {
|
||||||
Text("Save")
|
Text("Save")
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -403,24 +403,25 @@ fun ArticleReaderScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// Top bar: back + bookmark placeholder
|
// Header — Messages-style: compact row, titleMedium title
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.AutoMirrored.ArrowBack,
|
MaterialSymbols.AutoMirrored.ArrowBack,
|
||||||
contentDescription = "Back",
|
contentDescription = "Back",
|
||||||
modifier = Modifier.size(24.dp),
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
"Article",
|
"Article",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
if (zoomLevel != 1.0f) {
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -246,18 +248,20 @@ fun BookmarksScreen(
|
|||||||
val currentEvents = if (selectedTab == BookmarkTab.PUBLIC) publicEvents else privateEvents
|
val currentEvents = if (selectedTab == BookmarkTab.PUBLIC) publicEvents else privateEvents
|
||||||
val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds
|
val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
ReadingColumn {
|
||||||
|
val sidePadding = readingHorizontalPadding()
|
||||||
// Header with tabs
|
// Header with tabs
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.heightIn(min = 48.dp)
|
||||||
|
.padding(horizontal = sidePadding, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Bookmarks",
|
text = "Bookmarks",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -303,6 +307,7 @@ fun BookmarksScreen(
|
|||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePadding),
|
||||||
) {
|
) {
|
||||||
items(currentEvents, key = { it.id }) { event ->
|
items(currentEvents, key = { it.id }) { event ->
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
+27
-16
@@ -20,19 +20,19 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui
|
package com.vitorpamplona.amethyst.desktop.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -52,6 +52,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
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.DesktopDraftStore
|
||||||
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry
|
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -65,33 +66,42 @@ fun DraftsScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) }
|
var deleteTarget by remember { mutableStateOf<DraftEntry?>(null) }
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
ReadingColumn {
|
||||||
|
val sidePadding = readingHorizontalPadding()
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.padding(horizontal = sidePadding, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Drafts",
|
"Drafts",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Button(onClick = { onOpenEditor(null) }) {
|
// Convert "New Draft" button to an icon for consistency with other
|
||||||
Icon(MaterialSymbols.Add, contentDescription = null)
|
// screens' tabs-first + icon-actions header pattern.
|
||||||
Text("New Draft", modifier = Modifier.padding(start = 4.dp))
|
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()) {
|
if (drafts.isEmpty()) {
|
||||||
Text(
|
EmptyState(
|
||||||
"No drafts yet. Click \"New Draft\" to start writing.",
|
title = "No drafts yet",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
description = "Click \"New Draft\" to start writing.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePadding),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(drafts, key = { it.slug }) { entry ->
|
items(drafts, key = { it.slug }) { entry ->
|
||||||
@@ -139,7 +149,8 @@ private fun DraftCard(
|
|||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
onClick = onClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
|||||||
+16
-47
@@ -18,11 +18,12 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -33,10 +34,12 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
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 title The feed title
|
||||||
* @param connectedRelayCount Number of connected relays
|
* @param connectedRelayCount Number of connected relays
|
||||||
@@ -51,62 +54,28 @@ fun FeedHeader(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = readingHorizontalPadding(), vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
|
|
||||||
RelayStatusIndicator(
|
IconButton(
|
||||||
connectedCount = connectedRelayCount,
|
onClick = onRefresh,
|
||||||
onRefresh = onRefresh,
|
modifier = Modifier.size(32.dp),
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact relay connection status indicator with refresh button.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun RelayStatusIndicator(
|
|
||||||
connectedCount: Int,
|
|
||||||
onRefresh: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
Row(
|
|
||||||
modifier = modifier,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
val statusColor =
|
|
||||||
when {
|
|
||||||
connectedCount == 0 -> RelayStatusColors.Disconnected
|
|
||||||
connectedCount < 3 -> RelayStatusColors.Connecting
|
|
||||||
else -> RelayStatusColors.Connected
|
|
||||||
}
|
|
||||||
|
|
||||||
Icon(
|
|
||||||
symbol = if (connectedCount > 0) MaterialSymbols.Check else MaterialSymbols.Close,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = statusColor,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"$connectedCount relay${if (connectedCount != 1) "s" else ""}",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(onClick = onRefresh) {
|
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.Refresh,
|
MaterialSymbols.Refresh,
|
||||||
contentDescription = "Reconnect",
|
contentDescription = "Refresh ($connectedRelayCount relays connected)",
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,12 +20,12 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -487,9 +486,8 @@ fun FeedScreen(
|
|||||||
onDispose { subId?.let { coordinator.releaseInteractions(it) } }
|
onDispose { subId?.let { coordinator.releaseInteractions(it) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
ReadingColumn {
|
||||||
// Header with compose button
|
// Header with compose button
|
||||||
FeedHeader(
|
FeedHeader(
|
||||||
feedMode = feedMode,
|
feedMode = feedMode,
|
||||||
@@ -546,7 +544,9 @@ fun FeedScreen(
|
|||||||
|
|
||||||
is FeedState.Loaded -> {
|
is FeedState.Loaded -> {
|
||||||
val loadedState by state.feed.collectAsState()
|
val loadedState by state.feed.collectAsState()
|
||||||
|
val sidePadding = LocalReadingSidePadding.current
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePadding + 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(loadedState.list, key = { it.idHex }) { note ->
|
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
|
@Composable
|
||||||
private fun FeedHeader(
|
private fun FeedHeader(
|
||||||
feedMode: FeedMode,
|
feedMode: FeedMode,
|
||||||
@@ -642,90 +648,100 @@ private fun FeedHeader(
|
|||||||
onNavigateToRelays: () -> Unit = {},
|
onNavigateToRelays: () -> Unit = {},
|
||||||
onOpenRelayPicker: () -> Unit = {},
|
onOpenRelayPicker: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
val sidePadding = LocalReadingSidePadding.current
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = sidePadding + 12.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column {
|
// Tabs \u2014 the selected one is the screen title.
|
||||||
FlowRow(
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (account != null) {
|
if (account != null) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
FilterChip(
|
|
||||||
selected = feedMode == FeedMode.GLOBAL,
|
|
||||||
onClick = { onFeedModeChange(FeedMode.GLOBAL) },
|
|
||||||
label = { Text("Global") },
|
|
||||||
)
|
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = feedMode == FeedMode.FOLLOWING,
|
selected = feedMode == FeedMode.FOLLOWING,
|
||||||
onClick = { onFeedModeChange(FeedMode.FOLLOWING) },
|
onClick = { onFeedModeChange(FeedMode.FOLLOWING) },
|
||||||
label = { Text("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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
val relaysTooltip =
|
||||||
"${feedRelays.size} relays",
|
buildString {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
append("${feedRelays.size} relays")
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier =
|
|
||||||
Modifier.clickable { onNavigateToRelays() },
|
|
||||||
)
|
|
||||||
if (feedMode == FeedMode.FOLLOWING) {
|
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(
|
Text(
|
||||||
" \u2022 $followedUsersCount followed",
|
relaysTooltip,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
},
|
||||||
if (account != null && !account.isReadOnly) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onOpenRelayPicker,
|
onClick = {
|
||||||
modifier = Modifier.size(24.dp),
|
if (account != null && !account.isReadOnly) {
|
||||||
|
onOpenRelayPicker()
|
||||||
|
} else {
|
||||||
|
onNavigateToRelays()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.Dns,
|
MaterialSymbols.Dns,
|
||||||
contentDescription = "Edit Feed Relays",
|
contentDescription = relaysTooltip,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onRefresh,
|
onClick = onRefresh,
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.Refresh,
|
MaterialSymbols.Refresh,
|
||||||
contentDescription = "Refresh",
|
contentDescription = "Refresh",
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.state.EventCollectionState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
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.commons.util.toTimeAgo
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
@@ -238,15 +237,13 @@ fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
ReadingColumn {
|
||||||
FeedHeader(
|
FeedHeader(
|
||||||
title = "Notifications",
|
title = "Notifications",
|
||||||
connectedRelayCount = connectedRelays.size,
|
connectedRelayCount = connectedRelays.size,
|
||||||
onRefresh = { relayManager.connect() },
|
onRefresh = { relayManager.connect() },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
if (connectedRelays.isEmpty()) {
|
if (connectedRelays.isEmpty()) {
|
||||||
LoadingState("Connecting to relays...")
|
LoadingState("Connecting to relays...")
|
||||||
} else if (notifications.isEmpty() && !initialLoadComplete) {
|
} else if (notifications.isEmpty() && !initialLoadComplete) {
|
||||||
@@ -259,6 +256,7 @@ fun NotificationsScreen(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification ->
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -100,10 +97,8 @@ fun LongFormCard(
|
|||||||
val publishedAt = event.publishedAt() ?: event.createdAt
|
val publishedAt = event.publishedAt() ?: event.createdAt
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier =
|
onClick = onClick,
|
||||||
Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onClick),
|
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
@@ -289,66 +284,45 @@ fun ReadsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
ReadingColumn {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
val sidePadding = readingHorizontalPadding()
|
||||||
// Header — wraps on narrow columns
|
// Header — Messages-style: tabs left, refresh right. The selected tab
|
||||||
FlowRow(
|
// (Following / Global) acts as the screen title, so no separate label.
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = sidePadding, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
FlowRow(
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Reads",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Feed mode selector
|
|
||||||
if (account != null) {
|
if (account != null) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
FilterChip(
|
|
||||||
selected = feedMode == FeedMode.GLOBAL,
|
|
||||||
onClick = { feedMode = FeedMode.GLOBAL },
|
|
||||||
label = { Text("Global") },
|
|
||||||
)
|
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = feedMode == FeedMode.FOLLOWING,
|
selected = feedMode == FeedMode.FOLLOWING,
|
||||||
onClick = { feedMode = FeedMode.FOLLOWING },
|
onClick = { feedMode = FeedMode.FOLLOWING },
|
||||||
label = { Text("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(
|
IconButton(
|
||||||
onClick = { relayManager.connect() },
|
onClick = { relayManager.connect() },
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.Refresh,
|
MaterialSymbols.Refresh,
|
||||||
contentDescription = "Refresh",
|
contentDescription = "Refresh (${connectedRelays.size} relays connected)",
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
|
|
||||||
when {
|
when {
|
||||||
connectedRelays.isEmpty() -> {
|
connectedRelays.isEmpty() -> {
|
||||||
@@ -383,6 +357,7 @@ fun ReadsScreen(
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePadding),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
items(events, key = { it.id }) { event ->
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -46,7 +48,6 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -287,10 +288,12 @@ fun SearchScreen(
|
|||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ReadingColumn {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight()
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
when (event.key) {
|
when (event.key) {
|
||||||
@@ -329,14 +332,19 @@ fun SearchScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Title row
|
// Title row
|
||||||
|
val sidePadding = readingHorizontalPadding()
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.padding(horizontal = sidePadding, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Search",
|
"Search",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
@@ -348,40 +356,87 @@ fun SearchScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
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,
|
value = textFieldValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
textFieldValue = it
|
textFieldValue = it
|
||||||
state.updateFromText(it.text)
|
state.updateFromText(it.text)
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
modifier = Modifier.weight(1f).height(40.dp).focusRequester(focusRequester),
|
||||||
placeholder = { Text("Search notes, people, tags... or use operators") },
|
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 = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.Search,
|
MaterialSymbols.Search,
|
||||||
contentDescription = "Search",
|
contentDescription = "Search",
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
if (displayText.isNotEmpty()) {
|
if (displayText.isNotEmpty()) {
|
||||||
IconButton(onClick = { state.clearSearch() }) {
|
IconButton(onClick = { state.clearSearch() }, modifier = Modifier.size(28.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.Clear,
|
MaterialSymbols.Clear,
|
||||||
contentDescription = "Clear",
|
contentDescription = "Clear",
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
singleLine = true,
|
contentPadding =
|
||||||
shape = RoundedCornerShape(12.dp),
|
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) {
|
if (account != null && !account.isReadOnly) {
|
||||||
IconButton(onClick = { showRelayPicker = true }) {
|
IconButton(onClick = { showRelayPicker = true }) {
|
||||||
@@ -508,6 +563,7 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchEmptyState(
|
private fun SearchEmptyState(
|
||||||
@@ -519,6 +575,7 @@ private fun SearchEmptyState(
|
|||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
// Saved searches
|
// Saved searches
|
||||||
|
|||||||
+10
-6
@@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -196,23 +197,25 @@ fun ThreadScreen(
|
|||||||
val replyNotes = threadNotes.filter { it.idHex != noteId }
|
val replyNotes = threadNotes.filter { it.idHex != noteId }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
ReadingColumn {
|
||||||
// Header with back button
|
val sidePadding = readingHorizontalPadding()
|
||||||
|
// Header — Messages-style: compact row with back + titleMedium
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.AutoMirrored.ArrowBack,
|
MaterialSymbols.AutoMirrored.ArrowBack,
|
||||||
contentDescription = "Back",
|
contentDescription = "Back",
|
||||||
modifier = Modifier.size(24.dp),
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
"Thread",
|
"Thread",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -237,6 +240,7 @@ fun ThreadScreen(
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePadding),
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
) {
|
) {
|
||||||
// Root note
|
// Root note
|
||||||
|
|||||||
+49
-18
@@ -27,6 +27,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -113,6 +114,7 @@ fun UserProfileScreen(
|
|||||||
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
canGoBack: Boolean = false,
|
||||||
onCompose: () -> Unit = {},
|
onCompose: () -> Unit = {},
|
||||||
onNavigateToProfile: (String) -> Unit = {},
|
onNavigateToProfile: (String) -> Unit = {},
|
||||||
onNavigateToThread: (String) -> Unit = {},
|
onNavigateToThread: (String) -> Unit = {},
|
||||||
@@ -434,12 +436,14 @@ fun UserProfileScreen(
|
|||||||
previousFirstVisibleItemScrollOffset = currentOffset
|
previousFirstVisibleItemScrollOffset = currentOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ReadingColumn {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
if (connectedRelays.isEmpty()) {
|
if (connectedRelays.isEmpty()) {
|
||||||
LoadingState("Connecting to relays...")
|
LoadingState("Connecting to relays...")
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
|
contentPadding = PaddingValues(horizontal = readingHorizontalPadding()),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
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") {
|
item(key = "header") {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
IconButton(onClick = onBack) {
|
// Back is shown only when stacked onto a nav stack (e.g.
|
||||||
Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back")
|
// 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))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
"Profile",
|
"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) {
|
if (isOwnProfile && account.isReadOnly == false) {
|
||||||
OutlinedButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
editingDisplayName = displayName ?: ""
|
editingDisplayName = displayName ?: ""
|
||||||
showEditDialog = true
|
showEditDialog = true
|
||||||
},
|
},
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
MaterialSymbols.Edit,
|
MaterialSymbols.Edit,
|
||||||
contentDescription = "Edit profile",
|
contentDescription = "Edit Profile",
|
||||||
modifier = Modifier.size(18.dp),
|
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) {
|
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
Button(
|
Button(
|
||||||
@@ -520,6 +538,8 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
|
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 state = followState.state.collectAsState().value
|
||||||
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
|
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
|
// Floating header — appears on scroll up when profile header is out of view.
|
||||||
AnimatedVisibility(
|
// 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,
|
visible = showFloatingHeader,
|
||||||
enter = slideInVertically { -it },
|
enter = slideInVertically { -it },
|
||||||
exit = slideOutVertically { -it },
|
exit = slideOutVertically { -it },
|
||||||
@@ -891,13 +914,20 @@ fun UserProfileScreen(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f))
|
.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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
if (canGoBack) {
|
||||||
Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back")
|
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(
|
UserAvatar(
|
||||||
userHex = pubKeyHex,
|
userHex = pubKeyHex,
|
||||||
pictureUrl = picture,
|
pictureUrl = picture,
|
||||||
@@ -914,6 +944,7 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lightbox overlay
|
// Lightbox overlay
|
||||||
lightboxState?.let { state ->
|
lightboxState?.let { state ->
|
||||||
|
|||||||
+10
-4
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -34,6 +34,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.compositeOver
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleMaxSizeModifier
|
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 =
|
val clickableModifier =
|
||||||
remember {
|
remember(bubbleShape) {
|
||||||
Modifier.combinedClickable(
|
Modifier
|
||||||
|
.clip(bubbleShape)
|
||||||
|
.combinedClickable(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (!onClick()) {
|
if (!onClick()) {
|
||||||
if (!isComplete) {
|
if (!isComplete) {
|
||||||
@@ -148,7 +154,7 @@ fun ChatBubbleLayout(
|
|||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = bgColor.value,
|
color = bgColor.value,
|
||||||
shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem,
|
shape = bubbleShape,
|
||||||
modifier = clickableModifier,
|
modifier = clickableModifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier = MessageBubbleLimits, verticalArrangement = ChatRowColSpacing5dp) {
|
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
|
* 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.
|
* 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.foundation.layout.Row
|
||||||
import androidx.compose.runtime.Composable
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.HorizontalDivider
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -61,6 +61,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||||
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
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.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.isCtrlPressed
|
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.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||||
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
|
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.components.LoadingState
|
||||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||||
@@ -558,11 +554,13 @@ private fun MessageWithReactions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddReaction icon on hover
|
// AddReaction icon: always laid out to reserve space (so hover
|
||||||
if (showIcon) {
|
// doesn't reflow the detail row and shift the whole list);
|
||||||
Box {
|
// alpha fades in/out based on hover.
|
||||||
|
Box(modifier = Modifier.alpha(if (showIcon) 1f else 0f)) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { showPicker = !showPicker },
|
onClick = { showPicker = !showPicker },
|
||||||
|
enabled = showIcon,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -590,7 +588,6 @@ private fun MessageWithReactions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { _ ->
|
) { _ ->
|
||||||
when (note.event) {
|
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,
|
value = messageText,
|
||||||
onValueChange = onMessageChange,
|
onValueChange = onMessageChange,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
.heightIn(min = 40.dp)
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
// Cmd+Enter (Mac) or Ctrl+Enter to send
|
// Cmd+Enter (Mac) or Ctrl+Enter to send
|
||||||
@@ -731,10 +738,43 @@ private fun MessageInput(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
placeholder = { Text("Message... (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)") },
|
textStyle =
|
||||||
singleLine = false,
|
MaterialTheme.typography.bodyMedium
|
||||||
|
.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
|
cursorBrush =
|
||||||
|
androidx.compose.ui.graphics
|
||||||
|
.SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
maxLines = 4,
|
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(
|
IconButton(
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
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.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
@@ -119,6 +120,9 @@ fun ConversationListPane(
|
|||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.fillMaxHeight()
|
.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)
|
.focusRequester(focusRequester)
|
||||||
.focusable()
|
.focusable()
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
@@ -283,9 +287,13 @@ private fun ConversationCard(
|
|||||||
) {
|
) {
|
||||||
val backgroundColor =
|
val backgroundColor =
|
||||||
when {
|
when {
|
||||||
isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
|
||||||
isFocused -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
|
||||||
else -> MaterialTheme.colorScheme.surface
|
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(
|
Row(
|
||||||
@@ -297,22 +305,12 @@ private fun ConversationCard(
|
|||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Unread indicator
|
// Avatar — starts flush with the header's 12dp padding. Unread state is
|
||||||
if (item.hasUnread) {
|
// signalled by a small primary-coloured dot overlaid on the avatar's
|
||||||
Box(
|
// bottom-right corner (Slack / iMessage-style), not an inline indent
|
||||||
modifier =
|
// that would push the whole card to the right of the header label.
|
||||||
Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.primary),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
} else {
|
|
||||||
Spacer(Modifier.width(14.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avatar
|
|
||||||
val firstUser = item.users.firstOrNull()
|
val firstUser = item.users.firstOrNull()
|
||||||
|
Box {
|
||||||
if (firstUser != null) {
|
if (firstUser != null) {
|
||||||
UserAvatar(
|
UserAvatar(
|
||||||
userHex = firstUser.pubkeyHex,
|
userHex = firstUser.pubkeyHex,
|
||||||
@@ -327,10 +325,27 @@ private fun ConversationCard(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
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))
|
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)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -339,7 +354,7 @@ private fun ConversationCard(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = item.displayName,
|
text = item.displayName,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -347,30 +362,34 @@ private fun ConversationCard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (item.lastMessageTimestamp > 0) {
|
if (item.lastMessageTimestamp > 0) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = item.lastMessageTimestamp.toTimeAgo(withDot = false),
|
text = item.lastMessageTimestamp.toTimeAgo(withDot = false),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.lastMessagePreview.isNotEmpty()) {
|
if (item.lastMessagePreview.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = item.lastMessagePreview,
|
text = item.lastMessagePreview,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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) {
|
if (item.isGroup) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Group (${item.users.size + 1})",
|
text = "Group (${item.users.size + 1})",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
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
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
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.foundation.layout.width
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.key
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
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 androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
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.ChatNewMessageState
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
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.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import java.awt.Cursor
|
||||||
|
|
||||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||||
|
|
||||||
@@ -272,6 +276,15 @@ private fun SplitMessagesContent(
|
|||||||
onShowRelayPicker: () -> Unit = {},
|
onShowRelayPicker: () -> Unit = {},
|
||||||
keyHandler: Modifier,
|
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)) {
|
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||||
ConversationListPane(
|
ConversationListPane(
|
||||||
state = listState,
|
state = listState,
|
||||||
@@ -280,12 +293,25 @@ private fun SplitMessagesContent(
|
|||||||
onNewConversation = onShowNewDm,
|
onNewConversation = onShowNewDm,
|
||||||
onShowRelayPicker = onShowRelayPicker,
|
onShowRelayPicker = onShowRelayPicker,
|
||||||
focusRequester = listFocusRequester,
|
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
|
val currentRoom = selectedRoom
|
||||||
if (currentRoom != null) {
|
if (currentRoom != null) {
|
||||||
val feedViewModel =
|
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
|
* 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.
|
* 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.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
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
|
* 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.
|
* 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
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
-1
@@ -20,7 +20,6 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui.chats
|
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.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
|
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
|
* 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.
|
* 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.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
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.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -130,9 +129,10 @@ fun DeckColumnContainer(
|
|||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
Box(
|
// Content runs edge-to-edge; each screen adds its own header padding
|
||||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
// 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
|
// Always keep RootContent composed so state (e.g. search results) survives navigation
|
||||||
RootContent(
|
RootContent(
|
||||||
columnType = column.type,
|
columnType = column.type,
|
||||||
@@ -460,6 +460,7 @@ internal fun OverlayContent(
|
|||||||
nwcConnection = nwcConnection,
|
nwcConnection = nwcConnection,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
canGoBack = true,
|
||||||
onCompose = onShowComposeDialog,
|
onCompose = onShowComposeDialog,
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
onNavigateToThread = onNavigateToThread,
|
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.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
|
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
|
||||||
|
import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
|
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -56,10 +57,10 @@ fun DeckSidebar(
|
|||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.width(48.dp)
|
.width(56.dp)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.padding(vertical = 8.dp),
|
.padding(top = 8.dp + titleBarInsetTop, bottom = 8.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Top,
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
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.service.highlights.DesktopHighlightStore
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||||
@@ -97,7 +99,9 @@ fun SinglePaneLayout(
|
|||||||
if (!isImmersive) {
|
if (!isImmersive) {
|
||||||
NavigationRail(
|
NavigationRail(
|
||||||
modifier = Modifier.width(80.dp).fillMaxHeight(),
|
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()
|
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
|
||||||
pinnedScreens.forEach { screenType ->
|
pinnedScreens.forEach { screenType ->
|
||||||
@@ -176,9 +180,10 @@ fun SinglePaneLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||||
Box(
|
// Content extends to the window edges; individual screens add their
|
||||||
modifier = Modifier.fillMaxSize().padding(if (isImmersive) 0.dp else 12.dp),
|
// 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
|
// Always keep RootContent composed so state (e.g. search results) survives navigation
|
||||||
RootContent(
|
RootContent(
|
||||||
columnType = currentColumnType,
|
columnType = currentColumnType,
|
||||||
|
|||||||
+17
-5
@@ -23,11 +23,12 @@ package com.vitorpamplona.amethyst.desktop.ui.highlights
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -70,14 +71,24 @@ fun MyHighlightsScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var deleteTarget by remember { mutableStateOf<HighlightData?>(null) }
|
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(
|
Text(
|
||||||
"Highlights",
|
"Highlights",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
if (allHighlights.isEmpty()) {
|
if (allHighlights.isEmpty()) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
@@ -86,6 +97,7 @@ fun MyHighlightsScreen(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(horizontal = sidePadding),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
allHighlights.forEach { (addressTag, highlights) ->
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -156,34 +156,30 @@ fun NoteCard(
|
|||||||
400.dp
|
400.dp
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
// Whole-card hover/ripple: pass onClick to M3 Card so the click-surface is
|
||||||
modifier = modifier.fillMaxWidth(),
|
// the entire card (M3 handles shape clipping for us). Action buttons inside
|
||||||
colors =
|
// the NoteActionsRow have their own clickables that consume the click before
|
||||||
CardDefaults.cardColors(
|
// it reaches the Card's handler, so tapping an action still fires only that
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
// 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)) {
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
// Header + text area — clickable to navigate to thread
|
Column {
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
if (onClick != null) {
|
|
||||||
Modifier.clickable { onClick() }
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Author with avatar
|
// Author with avatar — stadium-shaped hover to match the
|
||||||
|
// avatar+name chip's visual shape.
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier =
|
modifier =
|
||||||
if (onAuthorClick != null) {
|
if (onAuthorClick != null) {
|
||||||
Modifier.clickable { onAuthorClick(note.pubKeyHex) }
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(100.dp))
|
||||||
|
.clickable { onAuthorClick(note.pubKeyHex) }
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
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
|
modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(top = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
// 1. Connected Relays (collapsed by default to show other sections)
|
// 1. Connected Relays (collapsed by default to show other sections)
|
||||||
CollapsibleSection(
|
CollapsibleSection(
|
||||||
|
|||||||
+15
-7
@@ -20,11 +20,13 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui.relay
|
package com.vitorpamplona.amethyst.desktop.ui.relay
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.material3.PrimaryTabRow
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -34,7 +36,9 @@ import androidx.compose.runtime.mutableStateListOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
|
import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState
|
||||||
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
|
import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
@@ -50,7 +54,6 @@ enum class DashboardTab(
|
|||||||
CONFIGURE("Configure"),
|
CONFIGURE("Configure"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RelayDashboardScreen(
|
fun RelayDashboardScreen(
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
@@ -79,12 +82,17 @@ fun RelayDashboardScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
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 ->
|
DashboardTab.entries.forEach { tab ->
|
||||||
Tab(
|
FilterChip(
|
||||||
selected = selectedTab == tab,
|
selected = selectedTab == tab,
|
||||||
onClick = { 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
|
package com.vitorpamplona.amethyst.desktop.ui.relay
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -62,9 +61,9 @@ fun RelayMetricCard(
|
|||||||
value = nip11Fetcher.fetch(status.url)
|
value = nip11Fetcher.fetch(status.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(modifier = modifier.fillMaxWidth()) {
|
Card(onClick = onToggleExpand, modifier = modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.clickable { onToggleExpand() }.padding(12.dp),
|
modifier = Modifier.padding(12.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -91,6 +92,7 @@ fun RelayMetricsTab(
|
|||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(statuses, key = { it.url.url }) { status ->
|
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(
|
Text(
|
||||||
"Media Servers (Blossom)",
|
"Media Servers (Blossom)",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ fun TorSettingsSection(
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
"Tor",
|
"Tor",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
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