feat(desktop): visual personality overhaul — unified theme, sidebar, cards

Phase 1: Replace per-OS color schemes with unified Amethyst brand
- Cyan/blue accent (#0096FF light, #4DB8FF dark) replacing OS-adaptive colors
- Amethyst purple as tertiary heritage color
- Unified shapes (8/12/16/24dp) replacing per-OS variants
- Standardized typography weights (Light for display, SemiBold for headlines)
- Letter spacing unified to -0.3sp

Phase 2: Spacing system
- AmethystSpacing CompositionLocal with design tokens
- LocalIsDarkTheme for M3-compatible dark mode detection

Phase 3: Sidebar redesign
- 240dp wide sidebar with icon + text labels (was 56dp icon-only)
- Animated collapse/expand with smooth width transition
- Avatar + username at top with account switcher
- Custom feeds section from FeedDefinitionRepository
- Active item cyan pill indicator with hover effects
- Collapse state persisted in Preferences
- Debounced fitColumnsToWidth to prevent animation thrash

Phase 4: Card refinement
- OutlinedCard with 1dp border replacing 1dp shadow elevation
- 16dp internal padding (was 12dp)
- Converted NoteCard, ReadsScreen, DraftsScreen, MyHighlightsScreen, UserProfileScreen

Phase 5: Column header restyling
- 48dp height (was 40dp) with surfaceContainer background
- 12dp horizontal padding (was 8dp)

Phase 6: Polish
- HoverModifiers.kt — shared hover highlight using onPointerEvent + drawBehind
- ShimmerPlaceholder.kt — skeleton loading animation in commons/commonMain

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-29 07:02:49 +03:00
co-authored by Claude Opus 4.6
parent 16afed0f3f
commit b431c1efab
18 changed files with 820 additions and 402 deletions
@@ -0,0 +1,96 @@
/*
* 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.commons.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.dp
@Composable
fun ShimmerPlaceholder(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "shimmer")
val translateAnim by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "shimmerTranslate",
)
val shimmerColors =
listOf(
MaterialTheme.colorScheme.surfaceContainerHigh,
MaterialTheme.colorScheme.surfaceContainer,
MaterialTheme.colorScheme.surfaceContainerHigh,
)
val brush =
Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnim - 200f, translateAnim - 200f),
end = Offset(translateAnim, translateAnim),
)
Box(modifier.background(brush, MaterialTheme.shapes.small))
}
@Composable
fun NoteCardSkeleton(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShimmerPlaceholder(Modifier.size(32.dp).clip(CircleShape))
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
ShimmerPlaceholder(Modifier.width(120.dp).height(12.dp))
ShimmerPlaceholder(Modifier.width(60.dp).height(10.dp))
}
}
ShimmerPlaceholder(Modifier.fillMaxWidth().height(14.dp))
ShimmerPlaceholder(Modifier.fillMaxWidth(0.7f).height(14.dp))
ShimmerPlaceholder(Modifier.fillMaxWidth().height(180.dp))
}
}
@@ -23,6 +23,10 @@ package com.vitorpamplona.amethyst.commons.ui.theme
import androidx.compose.ui.graphics.Color
// Primary brand colors
val AmethystBlue = Color(0xFF0096FF)
val AmethystBlueDark = Color(0xFF4DB8FF)
val AmethystPurple = Color(0xFF9A82DB)
val Primary50 = Color(red = 127, green = 103, blue = 190)
val Primary60 = Color(red = 154, green = 130, blue = 219)
val Primary70 = Color(red = 182, green = 157, blue = 248)
@@ -105,4 +105,12 @@ object DesktopPreferences {
val preferredBlossomServer: String
get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER
private const val KEY_SIDEBAR_COLLAPSED = "sidebar_collapsed"
var sidebarCollapsed: Boolean
get() = prefs.getBoolean(KEY_SIDEBAR_COLLAPSED, false)
set(value) {
prefs.putBoolean(KEY_SIDEBAR_COLLAPSED, value)
}
}
@@ -1381,6 +1381,10 @@ fun MainContent(
val allAccountsState by accountManager.allAccounts.collectAsState()
var showAddAccountDialog by remember { mutableStateOf(false) }
val deckColumns by deckState.columns.collectAsState()
val focusedIdx by deckState.focusedColumnIndex.collectAsState()
val activeColumnType = deckColumns.getOrNull(focusedIdx)?.type
DeckSidebar(
activeNpub = accountManager.currentAccount()?.npub,
allAccounts = allAccountsState,
@@ -1404,6 +1408,14 @@ fun MainContent(
deckState.addColumn(DeckColumnType.Settings)
}
},
onNavigate = { type ->
if (deckState.hasColumnOfType(type)) {
deckState.focusExistingColumn(type)
} else {
deckState.addColumn(type)
}
},
activeColumnType = activeColumnType,
onShowImportFollowListDialog = onShowImportFollowListDialog,
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
@@ -24,272 +24,92 @@ import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import com.vitorpamplona.amethyst.commons.ui.theme.AmethystBlue
import com.vitorpamplona.amethyst.commons.ui.theme.AmethystBlueDark
import com.vitorpamplona.amethyst.commons.ui.theme.AmethystPurple
/**
* 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.
* Unified Amethyst brand [ColorScheme] — consistent visual identity across all
* platforms. Uses cyan/blue (#0096FF) as primary accent, with Amethyst purple
* as tertiary heritage color.
*/
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)
}
fun resolve(dark: Boolean): ColorScheme = if (dark) amethystDark() else amethystLight()
// ── macOS ─────────────────────────────────────────────────────────────────
private fun amethystLight() =
lightColorScheme(
primary = AmethystBlue,
onPrimary = Color.White,
primaryContainer = AmethystBlue.copy(alpha = 0.12f).compositeOver(Color.White),
onPrimaryContainer = AmethystBlue,
secondary = Color(0xFF5E8FAD),
onSecondary = Color.White,
secondaryContainer = Color(0xFF5E8FAD).copy(alpha = 0.12f).compositeOver(Color.White),
onSecondaryContainer = Color(0xFF5E8FAD),
tertiary = AmethystPurple,
onTertiary = Color.White,
tertiaryContainer = AmethystPurple.copy(alpha = 0.12f).compositeOver(Color.White),
onTertiaryContainer = AmethystPurple,
background = Color(0xFFF2F2F7),
onBackground = Color(0xFF1C1C1E),
surface = Color.White,
onSurface = Color(0xFF1C1C1E),
surfaceVariant = Color(0xFFF0F0F5),
onSurfaceVariant = Color(0xFF6E6E73),
surfaceContainer = Color(0xFFF7F7FA),
surfaceContainerHigh = Color(0xFFEEEEF2),
surfaceContainerHighest = Color(0xFFE5E5EA),
surfaceContainerLow = Color(0xFFFAFAFC),
surfaceContainerLowest = Color.White,
surfaceDim = Color(0xFFE8E8ED),
surfaceBright = Color.White,
outline = Color(0xFFE0E0E0),
outlineVariant = Color(0xFFEBEBEB),
inverseSurface = Color(0xFF2C2C2E),
inverseOnSurface = Color(0xFFF2F2F7),
inversePrimary = AmethystBlueDark,
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
)
private fun macOSDark(accent: Color) =
private fun amethystDark() =
darkColorScheme(
primary = accent,
onPrimary = onAccent(accent),
secondary = accent,
tertiary = accent,
background = Color(0xFF1E1E1E),
onBackground = Color(0xFFE5E5E5),
primary = AmethystBlueDark,
onPrimary = Color.White,
primaryContainer = AmethystBlueDark.copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)),
onPrimaryContainer = AmethystBlueDark,
secondary = Color(0xFF7EAEC8),
onSecondary = Color.White,
secondaryContainer = Color(0xFF7EAEC8).copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)),
onSecondaryContainer = Color(0xFF7EAEC8),
tertiary = Color(0xFFB6A0E0),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFB6A0E0).copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)),
onTertiaryContainer = Color(0xFFB6A0E0),
background = Color(0xFF121212),
onBackground = Color(0xFFE5E5EA),
surface = Color(0xFF1E1E1E),
onSurface = Color(0xFFE5E5E5),
onSurface = Color(0xFFE5E5EA),
surfaceVariant = Color(0xFF2A2A2A),
onSurfaceVariant = Color(0xFFB8B8B8),
onSurfaceVariant = Color(0xFF9E9EA3),
surfaceContainer = Color(0xFF252525),
surfaceContainerHigh = Color(0xFF2D2D2D),
surfaceContainerHighest = Color(0xFF353535),
surfaceContainerHigh = Color(0xFF2E2E2E),
surfaceContainerHighest = Color(0xFF383838),
surfaceContainerLow = Color(0xFF1A1A1A),
surfaceContainerLowest = Color(0xFF141414),
surfaceDim = Color(0xFF1A1A1A),
surfaceBright = Color(0xFF353535),
outline = Color(0xFF6A6A6A),
outlineVariant = Color(0xFF3A3A3A),
surfaceBright = Color(0xFF383838),
outline = Color(0xFF3A3A3A),
outlineVariant = Color(0xFF2E2E2E),
inverseSurface = Color(0xFFE5E5EA),
inverseOnSurface = Color(0xFF1C1C1E),
inversePrimary = AmethystBlue,
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
)
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
}
}
@@ -25,67 +25,15 @@ 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.
* Unified Amethyst brand [Shapes] — consistent rounding across all platforms.
*/
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),
)
}
}
}
val current: Shapes =
Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(24.dp),
)
}
@@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.desktop.platform
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import com.vitorpamplona.amethyst.desktop.ui.theme.AmethystSpacing
import com.vitorpamplona.amethyst.desktop.ui.theme.LocalIsDarkTheme
import com.vitorpamplona.amethyst.desktop.ui.theme.LocalSpacing
/**
* Wraps content in a [MaterialTheme] tuned for the host OS: native fonts, accent
@@ -41,14 +45,18 @@ 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,
)
val colorScheme = remember(isDark) { PlatformColorScheme.resolve(isDark) }
CompositionLocalProvider(
LocalSpacing provides AmethystSpacing(),
LocalIsDarkTheme provides isDark,
) {
MaterialTheme(
colorScheme = colorScheme,
typography = PlatformTypography.current,
shapes = PlatformShapes.current,
content = content,
)
}
}
/**
@@ -40,19 +40,7 @@ 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
}
val tightening: TextUnit = (-0.3).sp
fun ts(
size: Int,
@@ -68,9 +56,9 @@ object PlatformTypography {
)
return Typography(
displayLarge = ts(57, 64, FontWeight.Normal, tightening),
displayMedium = ts(45, 52, FontWeight.Normal, tightening),
displaySmall = ts(36, 44, FontWeight.Normal, tightening),
displayLarge = ts(57, 64, FontWeight.Light, tightening),
displayMedium = ts(45, 52, FontWeight.Light, tightening),
displaySmall = ts(36, 44, FontWeight.Light, tightening),
headlineLarge = ts(32, 40, FontWeight.SemiBold, tightening),
headlineMedium = ts(28, 36, FontWeight.SemiBold, tightening),
headlineSmall = ts(24, 32, FontWeight.SemiBold, tightening),
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -33,10 +34,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -148,14 +149,15 @@ private fun DraftCard(
onClick: () -> Unit,
onDelete: () -> Unit,
) {
Card(
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -32,12 +33,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -96,14 +97,15 @@ fun LongFormCard(
val authorName = author.toBestDisplayName()
val publishedAt = event.publishedAt() ?: event.createdAt
Card(
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(16.dp)) {
// Title
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -48,6 +49,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
@@ -1139,13 +1141,14 @@ private fun PublishedHighlightCard(
val articleAddress = highlight.inPostAddress()
val articleTitle = articleAddress?.let { "Article" } ?: "Unknown source"
Card(
OutlinedCard(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
colors =
CardDefaults.cardColors(
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(16.dp)) {
// Quoted highlight text
@@ -56,13 +56,13 @@ fun ColumnHeader(
modifier =
modifier
.fillMaxWidth()
.height(40.dp)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainer)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { onDoubleClick() },
)
}.padding(horizontal = 8.dp),
}.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (hasBackStack) {
@@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
@@ -51,6 +52,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import java.awt.Cursor
@Composable
@@ -80,14 +83,23 @@ fun DeckLayout(
val availableWidthDp = with(density) { constraints.maxWidth.toDp().value }
deckState.setAvailableWidth(availableWidthDp)
// Auto-fit columns on first composition or when available width changes significantly
LaunchedEffect(availableWidthDp, columns.size) {
val dividers = (columns.size - 1) * DeckState.DIVIDER_WIDTH
val totalColumnWidth = columns.sumOf { it.width.toDouble() }.toFloat()
val diff = kotlin.math.abs(totalColumnWidth + dividers - availableWidthDp)
if (diff > 20f && columns.isNotEmpty()) {
deckState.fitColumnsToWidth(availableWidthDp)
}
// Auto-fit columns when available width changes (debounced to avoid per-frame
// recomposition during sidebar expand/collapse animation)
@OptIn(FlowPreview::class)
LaunchedEffect(Unit) {
snapshotFlow { availableWidthDp to columns.size }
.debounce(300)
.collect { (width, size) ->
val dividers = (size - 1) * DeckState.DIVIDER_WIDTH
val totalColumnWidth =
deckState.columns.value
.sumOf { it.width.toDouble() }
.toFloat()
val diff = kotlin.math.abs(totalColumnWidth + dividers - width)
if (diff > 20f && size > 0) {
deckState.fitColumnsToWidth(width)
}
}
}
Row(
@@ -20,32 +20,88 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.IconButton
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.account.AccountInfo
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop
import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.collections.immutable.ImmutableList
private val EXPANDED_WIDTH = 240.dp
private val COLLAPSED_WIDTH = 56.dp
/**
* Data class for a sidebar navigation item.
*/
private data class NavItem(
val type: DeckColumnType,
val label: String,
val icon: MaterialSymbol,
)
private val NAV_ITEMS =
listOf(
NavItem(DeckColumnType.HomeFeed, "Home", MaterialSymbols.Home),
NavItem(DeckColumnType.Search, "Search", MaterialSymbols.Search),
NavItem(DeckColumnType.Messages, "Messages", MaterialSymbols.Mail),
NavItem(DeckColumnType.Wallet, "Wallet", MaterialSymbols.AccountBalanceWallet),
NavItem(DeckColumnType.Bookmarks, "Bookmarks", MaterialSymbols.Bookmark),
NavItem(DeckColumnType.Settings, "Settings", MaterialSymbols.Settings),
)
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DeckSidebar(
activeNpub: String?,
@@ -56,67 +112,415 @@ fun DeckSidebar(
onRemoveAccount: (String) -> Unit,
onAddColumn: () -> Unit,
onOpenSettings: () -> Unit,
onNavigate: (DeckColumnType) -> Unit,
activeColumnType: DeckColumnType?,
onShowImportFollowListDialog: () -> Unit = {},
signerConnectionState: SignerConnectionState,
lastPingTimeSec: Long?,
torStatus: TorServiceStatus,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(!DesktopPreferences.sidebarCollapsed) }
val animatedWidth by animateDpAsState(
targetValue = if (expanded) EXPANDED_WIDTH else COLLAPSED_WIDTH,
animationSpec = tween(300, easing = FastOutSlowInEasing),
)
// Observe metadata to recompose when display names/pictures load
@Suppress("UNUSED_VARIABLE")
val metadataVersion by localCache?.metadataVersion?.collectAsState()
?: remember { mutableStateOf(0L) }
// Resolve user info for avatar header
val pubkeyHex = remember(activeNpub) { activeNpub?.let { decodePublicKeyAsHexOrNull(it) } }
val user =
remember(pubkeyHex, metadataVersion) {
pubkeyHex?.let { localCache?.getUserIfExists(it) }
}
val displayName =
remember(user, metadataVersion) {
user?.let {
val name = it.toBestDisplayName()
name.takeIf { n -> n != it.pubkeyDisplayHex() }
}
}
val avatarUrl = remember(user, metadataVersion) { user?.profilePicture() }
// Custom feeds from repository
val feedRepo = LocalFeedRepository.current
val allFeeds by feedRepo.feeds.collectAsState()
Column(
modifier =
modifier
.width(56.dp)
.width(animatedWidth)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceContainer)
.clipToBounds()
.background(MaterialTheme.colorScheme.surface)
.padding(top = 8.dp + titleBarInsetTop, bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
AccountSwitcherDropdown(
// -- Avatar + Account header --
SidebarAccountHeader(
activeNpub = activeNpub,
allAccounts = allAccounts,
localCache = localCache,
displayName = displayName,
avatarUrl = avatarUrl,
pubkeyHex = pubkeyHex,
expanded = expanded,
onSwitchAccount = onSwitchAccount,
onAddAccount = onAddAccount,
onRemoveAccount = onRemoveAccount,
)
Spacer(Modifier.size(16.dp))
Spacer(Modifier.height(8.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(Modifier.height(8.dp))
IconButton(onClick = onAddColumn) {
Icon(
MaterialSymbols.Add,
contentDescription = "Add Column",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
// -- Nav items (scrollable) --
Column(
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
) {
NAV_ITEMS.forEach { item ->
val isActive = activeColumnType?.typeKey() == item.type.typeKey()
SidebarNavItem(
icon = item.icon,
label = item.label,
isActive = isActive,
expanded = expanded,
onClick = { onNavigate(item.type) },
)
}
// -- Custom feeds section --
if (allFeeds.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(Modifier.height(8.dp))
AnimatedVisibility(
visible = expanded,
enter = fadeIn(tween(200, delayMillis = 100)),
exit = fadeOut(tween(100)),
) {
Text(
text = "FEEDS",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
allFeeds.forEach { feed ->
val isActive =
activeColumnType is DeckColumnType.CustomFeed &&
activeColumnType.feedId == feed.id
SidebarFeedItem(
feed = feed,
isActive = isActive,
expanded = expanded,
onClick = {
onNavigate(
DeckColumnType.CustomFeed(
feedId = feed.id,
feedName = feed.name,
feedEmoji = feed.emoji,
),
)
},
)
}
}
}
IconButton(onClick = onShowImportFollowListDialog) {
Icon(
MaterialSymbols.PersonAdd,
contentDescription = "Import Follow List",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
// -- Bottom section: indicators + collapse toggle --
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
)
Spacer(Modifier.width(4.dp))
TorStatusIndicator(status = torStatus, onClick = onOpenSettings)
}
Spacer(Modifier.weight(1f))
Spacer(Modifier.height(4.dp))
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
// Collapse toggle
SidebarNavItem(
icon = if (expanded) MaterialSymbols.AutoMirrored.KeyboardArrowLeft else MaterialSymbols.ChevronRight,
label = if (expanded) "Collapse" else "Expand",
isActive = false,
expanded = expanded,
onClick = {
expanded = !expanded
DesktopPreferences.sidebarCollapsed = !expanded
},
)
}
}
Spacer(Modifier.size(4.dp))
TorStatusIndicator(status = torStatus, onClick = onOpenSettings)
@Composable
private fun SidebarAccountHeader(
activeNpub: String?,
allAccounts: ImmutableList<AccountInfo>,
localCache: DesktopLocalCache?,
displayName: String?,
avatarUrl: String?,
pubkeyHex: String?,
expanded: Boolean,
onSwitchAccount: (String) -> Unit,
onAddAccount: () -> Unit,
onRemoveAccount: (String) -> Unit,
) {
if (!expanded) {
// Collapsed: just the account switcher dropdown (icon-only)
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
AccountSwitcherDropdown(
activeNpub = activeNpub,
allAccounts = allAccounts,
localCache = localCache,
onSwitchAccount = onSwitchAccount,
onAddAccount = onAddAccount,
onRemoveAccount = onRemoveAccount,
)
}
} else {
// Expanded: avatar + name + npub, tappable to open account switcher
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Avatar
if (pubkeyHex != null) {
UserAvatar(
userHex = pubkeyHex,
pictureUrl = avatarUrl,
size = 40.dp,
contentDescription = "Account avatar",
)
} else {
Box(
modifier =
Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
MaterialSymbols.Person,
contentDescription = "Account",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
}
}
Spacer(Modifier.size(4.dp))
Spacer(Modifier.width(10.dp))
IconButton(onClick = onOpenSettings) {
Icon(
MaterialSymbols.Settings,
contentDescription = "Settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName ?: "Account",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (activeNpub != null) {
Text(
text = npubShortForSidebar(activeNpub),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
// Overlay the dropdown so clicking opens it
AccountSwitcherDropdown(
activeNpub = activeNpub,
allAccounts = allAccounts,
localCache = localCache,
onSwitchAccount = onSwitchAccount,
onAddAccount = onAddAccount,
onRemoveAccount = onRemoveAccount,
modifier = Modifier.matchParentSize(),
)
}
}
}
/**
* A single navigation item row with icon + optional label.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SidebarNavItem(
icon: MaterialSymbol,
label: String,
isActive: Boolean,
expanded: Boolean,
onClick: () -> Unit,
) {
var isHovered by remember { mutableStateOf(false) }
val backgroundColor =
when {
isActive -> MaterialTheme.colorScheme.primaryContainer
isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
else -> MaterialTheme.colorScheme.surface
}
val iconTint =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val textColor =
if (isActive) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.clickable(onClick = onClick)
.onPointerEvent(PointerEventType.Enter) { isHovered = true }
.onPointerEvent(PointerEventType.Exit) { isHovered = false }
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
icon,
contentDescription = if (!expanded) label else null,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
AnimatedVisibility(
visible = expanded,
enter = fadeIn(tween(200, delayMillis = 100)),
exit = fadeOut(tween(100)),
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
/**
* A custom feed item in the sidebar.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SidebarFeedItem(
feed: FeedDefinition,
isActive: Boolean,
expanded: Boolean,
onClick: () -> Unit,
) {
var isHovered by remember { mutableStateOf(false) }
val backgroundColor =
when {
isActive -> MaterialTheme.colorScheme.primaryContainer
isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
else -> MaterialTheme.colorScheme.surface
}
val iconTint =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val textColor =
if (isActive) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.clickable(onClick = onClick)
.onPointerEvent(PointerEventType.Enter) { isHovered = true }
.onPointerEvent(PointerEventType.Exit) { isHovered = false }
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (feed.emoji.isNotEmpty() && expanded) {
Text(
text = feed.emoji,
modifier = Modifier.size(24.dp),
style = MaterialTheme.typography.titleMedium,
)
} else {
Icon(
MaterialSymbols.AutoMirrored.Feed,
contentDescription = if (!expanded) feed.name else null,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
}
AnimatedVisibility(
visible = expanded,
enter = fadeIn(tween(200, delayMillis = 100)),
exit = fadeOut(tween(100)),
) {
Text(
text = feed.name.ifEmpty { "Feed" },
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
private fun npubShortForSidebar(npub: String): String {
if (npub.length <= 20) return npub
return "${npub.take(10)}...${npub.takeLast(6)}"
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -34,10 +35,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -175,13 +176,14 @@ private fun HighlightCard(
highlight: HighlightData,
onDelete: () -> Unit,
) {
Card(
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -36,6 +37,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -147,10 +149,15 @@ fun NoteCard(
// the NoteActionsRow have their own clickables that consume the click before
// it reaches the Card's handler, so tapping an action still fires only that
// action.
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
val cardElevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
val outlineVariant = MaterialTheme.colorScheme.outlineVariant
val cardBorder =
remember(outlineVariant) {
BorderStroke(1.dp, outlineVariant)
}
val cardColors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface)
val cardShape = MaterialTheme.shapes.medium
val cardBody: @Composable ColumnScope.() -> Unit = {
Column(modifier = Modifier.padding(12.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -302,18 +309,20 @@ fun NoteCard(
}
if (onClick != null) {
Card(
OutlinedCard(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
colors = cardColors,
elevation = cardElevation,
border = cardBorder,
shape = cardShape,
content = cardBody,
)
} else {
Card(
OutlinedCard(
modifier = modifier.fillMaxWidth(),
colors = cardColors,
elevation = cardElevation,
border = cardBorder,
shape = cardShape,
content = cardBody,
)
}
@@ -0,0 +1,53 @@
/*
* 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.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Immutable
data class AmethystSpacing(
val xxs: Dp = 2.dp,
val xs: Dp = 4.dp,
val sm: Dp = 8.dp,
val md: Dp = 12.dp,
val lg: Dp = 16.dp,
val xl: Dp = 24.dp,
val xxl: Dp = 32.dp,
val cardPadding: Dp = 16.dp,
val cardGap: Dp = 8.dp,
val sidebarExpandedWidth: Dp = 240.dp,
val sidebarCollapsedWidth: Dp = 56.dp,
val columnHeaderHeight: Dp = 48.dp,
)
val LocalSpacing = staticCompositionLocalOf { AmethystSpacing() }
val LocalIsDarkTheme = staticCompositionLocalOf { false }
val MaterialTheme.spacing: AmethystSpacing
@Composable @ReadOnlyComposable
get() = LocalSpacing.current
@@ -0,0 +1,47 @@
/*
* 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.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
/**
* Adds a subtle background highlight on hover. The state read is deferred to
* the draw phase via [drawBehind], so hover enter/exit only invalidates draw —
* no recomposition of the host composable.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun Modifier.hoverHighlight(hoverColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)): Modifier {
val color = remember { mutableStateOf(Color.Transparent) }
return this
.onPointerEvent(PointerEventType.Enter) { color.value = hoverColor }
.onPointerEvent(PointerEventType.Exit) { color.value = Color.Transparent }
.drawBehind { drawRect(color.value) }
}