Skip to content
desktop-expert logo

Desktop Expert

desktop-expert

Compose Multiplatform Desktop patterns for the `desktopApp/` module. Use when working with (1) Desktop-only APIs (Window, WindowState, Tray, MenuBar, Dialog), (2) keyboard shortcuts and menu systems with OS-aware conventions (Cmd vs Ctrl, isMacOS branching), (3) desktop navigation (NavigationRail...

vitorpamplona/amethyst0installs1.6Kstars

SKILL.md

Full skill instructions

Desktop Expert

Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles.

When to Use This Skill

Auto-invoke when:

  • Working with desktopApp/ module files
  • Using Desktop-only APIs: Window, Tray, MenuBar, Dialog
  • Implementing keyboard shortcuts, menu systems
  • Desktop navigation (NavigationRail, multi-window)
  • File system operations (file pickers, drag-drop)
  • OS-specific behavior (macOS, Windows, Linux)
  • Desktop UX patterns (keyboard-first, tooltips)

Delegate to:

  • kotlin-multiplatform: Shared code questions, jvmMain source set structure
  • gradle-expert: All build.gradle.kts issues, dependency conflicts
  • compose-expert: General Compose patterns, @Composable best practices, Material3

Scope

In scope:

  • Desktop-only Compose APIs
  • Window management, positioning, state
  • MenuBar + keyboard shortcuts (OS-specific)
  • System Tray integration
  • Desktop navigation patterns (NavigationRail)
  • File dialogs, Desktop.getDesktop()
  • OS conventions (macOS vs Windows vs Linux)
  • Desktop UX principles

Out of scope:

  • Build configuration → gradle-expert
  • Shared composables → compose-expert
  • KMP structure → kotlin-multiplatform

1. Desktop Entry Point

application {} DSL

Desktop apps start with the application {} block:

// desktopApp/​src/​jvmMain/​kotlin/​Main.kt
fun main() = application {
    val windowState = rememberWindowState(
        width = 1200.dp,
        height = 800.dp,
        position = WindowPosition.Aligned(Alignment.Center)
    )

    Window(
        onCloseRequest = ::exitApplication,
        state = windowState,
        title = "Amethyst"
    ) {
        MenuBar { /​* ... */ }
        App()
    }
}

Key points:

  • application {} is the root composable (JVM-only)
  • Window() creates the main window
  • rememberWindowState() manages size/​position
  • onCloseRequest handles window close

See: desktopApp/​src/​jvmMain/​kotlin/​com/​vitorpamplona/​amethyst/​desktop/​Main.kt — grep for fun main(), application {, the top-level Window, and MenuBar { (the file is large and line numbers drift; navigate by symbol).


2. Window Management

WindowState

val windowState = rememberWindowState(
    width = 1200.dp,
    height = 800.dp,
    position = WindowPosition.Aligned(Alignment.Center)
)

Window(
    state = windowState,
    title = "My App",
    resizable = true,
    onCloseRequest = ::exitApplication
) {
    // Content
}

Multiple Windows

fun main() = application {
    var showSettings by remember { mutableStateOf(false) }

    Window(onCloseRequest = ::exitApplication, title = "Main") {
        Button(onClick = { showSettings = true }) {
            Text("Open Settings")
        }
    }

    if (showSettings) {
        Window(
            onCloseRequest = { showSettings = false },
            title = "Settings"
        ) {
            // Settings UI
        }
    }
}

Pattern: Use state to control window visibility conditionally.


3. MenuBar System

Basic MenuBar

Window(onCloseRequest = ::exitApplication, title = "App") {
    MenuBar {
        Menu("File") {
            Item("New Note", onClick = { /​* ... */ })
            Separator()
            Item("Quit", onClick = ::exitApplication)
        }
        Menu("Edit") {
            Item("Copy", onClick = { /​* ... */ })
            Item("Paste", onClick = { /​* ... */ })
        }
    }
    App()
}

Keyboard Shortcuts (OS-Aware)

Current state: Main.kt already branches on isMacOS (declared at L120) for every menu shortcut — if (isMacOS) { KeyShortcut(..., meta = true) } else { KeyShortcut(..., ctrl = true) } (see L239, L249, L286, L313, L325, L335, L347, L358, L374, L384, L400, L416, L449). When adding a new shortcut, follow the same branching pattern rather than hardcoding ctrl = true.

OS-specific shortcuts:

import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyShortcut

// Detect OS
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")

MenuBar {
    Menu("File") {
        Item(
            "New Note",
            shortcut = if (isMacOS) {
                KeyShortcut(Key.N, meta = true)  // Cmd+N on macOS
            } else {
                KeyShortcut(Key.N, ctrl = true)   // Ctrl+N on Win/​Linux
            },
            onClick = { /​* ... */ }
        )
        Item(
            "Settings",
            shortcut = if (isMacOS) {
                KeyShortcut(Key.Comma, meta = true)  // Cmd+, on macOS
            } else {
                KeyShortcut(Key.Comma, ctrl = true)   // Ctrl+, on Win/​Linux
            },
            onClick = { /​* ... */ }
        )
        Separator()
        Item(
            "Quit",
            shortcut = if (isMacOS) {
                KeyShortcut(Key.Q, meta = true)  // Cmd+Q on macOS
            } else {
                KeyShortcut(Key.Q, ctrl = true)   // Ctrl+Q on Win/​Linux
            },
            onClick = ::exitApplication
        )
    }
}

Standard shortcuts:

ActionmacOSWindows/​Linux
NewCmd+NCtrl+N
OpenCmd+OCtrl+O
SaveCmd+SCtrl+S
QuitCmd+QCtrl+Q (Alt+F4)
SettingsCmd+,Ctrl+,
CopyCmd+CCtrl+C
PasteCmd+VCtrl+V
UndoCmd+ZCtrl+Z

See: references/​keyboard-shortcuts.md for full list.


4. System Tray

Basic Tray

application {
    var isVisible by remember { mutableStateOf(true) }

    Tray(
        icon = painterResource("icon.png"),
        onAction = { isVisible = true },
        menu = {
            Item("Show", onClick = { isVisible = true })
            Separator()
            Item("Quit", onClick = ::exitApplication)
        }
    )

    if (isVisible) {
        Window(
            onCloseRequest = { isVisible = false }, // Minimize to tray
            title = "App"
        ) {
            // Content
        }
    }
}

Pattern: Hide window to tray instead of closing.

Current status: Not implemented in Main.kt. Planned feature.


5. Desktop Navigation Patterns

NavigationRail (Current Pattern)

Desktop uses NavigationRail (vertical sidebar) instead of Android's bottom navigation.

Row(Modifier.fillMaxSize()) {
    // Sidebar
    NavigationRail(
        modifier = Modifier.width(80.dp).fillMaxHeight(),
        containerColor = MaterialTheme.colorScheme.surfaceVariant
    ) {
        NavigationRailItem(
            icon = { Icon(Icons.Default.Home, "Feed") },
            label = { Text("Feed") },
            selected = currentScreen == AppScreen.Feed,
            onClick = { currentScreen = AppScreen.Feed }
        )
        // More items...
    }

    VerticalDivider()

    // Main content area
    Box(Modifier.weight(1f).fillMaxHeight()) {
        when (currentScreen) {
            AppScreen.Feed -> FeedScreen()
            // Other screens...
        }
    }
}

In Amethyst Desktop: the sidebar is the custom MainSidebar composable in desktopApp/​.../​ui/​deck/​DeckSidebar.kt, instantiated from Main.kt and shared by both layout modes (SinglePaneLayout and the multi-pane DeckLayout alongside it). It is hand-rolled, not Material's NavigationRail — use NavigationRail only for new, simpler cases.

Why a left sidebar?

  • Desktop has horizontal space (1200+ dp width)
  • Vertical sidebar is standard desktop pattern
  • Always visible (no tabs hidden)
  • Icon + label both visible

Android comparison:

  • Android: BottomNavigationBar (horizontal, bottom)
  • Desktop: left vertical sidebar (MainSidebar)

Multi-Pane Layouts

Desktop can leverage wide screens:

Row {
    // Left: Navigation
    NavigationRail { /​* ... */ }

    // Center: Main content
    Box(Modifier.weight(0.6f)) {
        FeedScreen()
    }

    // Right: Details pane (desktop only)
    if (selectedNote != null) {
        VerticalDivider()
        Box(Modifier.weight(0.4f)) {
            NoteDetailPane(selectedNote)
        }
    }
}

See: references/​desktop-navigation.md


6. File System Integration

File Dialogs

// File picker (load)
val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD)
fileDialog.isVisible = true
val filePath = fileDialog.file?.let { "${fileDialog.directory}$it" }

// File picker (save)
val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE)
saveDialog.isVisible = true
val savePath = saveDialog.file?.let { "${saveDialog.directory}$it" }

Note: Compose Desktop doesn't have native file picker composable yet. Use AWT FileDialog.

Open External URLs

// jvmMain actual implementation
actual fun openExternalUrl(url: String) {
    if (Desktop.isDesktopSupported()) {
        Desktop.getDesktop().browse(URI(url))
    }
}

Pattern: Define expect in commonMain, implement actual in jvmMain.

Drag & Drop (Future)

// Compose Desktop drag-drop (experimental)
Box(
    modifier = Modifier
        .onExternalDrag(
            onDragStart = { /​* ... */ },
            onDrag = { /​* ... */ },
            onDragExit = { /​* ... */ },
            onDrop = { state ->
                val dragData = state.dragData
                // Handle dropped files
            }
        )
) {
    Text("Drop files here")
}

7. OS-Specific Conventions

Platform Detection

val osName = System.getProperty("os.name").lowercase()

val isMacOS = osName.contains("mac")
val isWindows = osName.contains("win")
val isLinux = osName.contains("nux") || osName.contains("nix")

Menu Bar Placement

OSBehavior
macOSSystem-wide menu bar at top of screen
WindowsIn-window menu bar
LinuxVaries by desktop environment

Compose Desktop MenuBar adapts automatically.

Keyboard Modifier Keys

ModifiermacOSWindows/​Linux
Primarymeta = true (Cmd)ctrl = true
Secondaryctrl = truealt = true
Shiftshift = trueshift = true

Best practice: Detect OS and use appropriate modifier.

System Tray Behavior

OSTray Location
macOSTop-right menu bar
WindowsBottom-right taskbar
LinuxTop panel (varies)

8. Desktop UX Principles

Keyboard-First Design

Every action should have:

  1. Mouse/​touch interaction
  2. Keyboard shortcut (if frequent)
  3. Tooltip showing shortcut
IconButton(
    onClick = { /​* refresh */ },
    modifier = Modifier.tooltipArea(
        tooltip = {
            Text("Refresh (${if (isMacOS) "Cmd" else "Ctrl"}+R)")
        }
    )
) {
    Icon(Icons.Default.Refresh, "Refresh")
}

Tooltip Best Practices

  • Show keyboard shortcut in tooltip
  • Use native modifier name (Cmd vs Ctrl)
  • Brief description + shortcut

Context Menus

Right-click should show context menu:

// Future: Compose Desktop context menu API
Box(
    modifier = Modifier.contextMenuArea(
        items = {
            listOf(
                ContextMenuItem("Copy") { /​* ... */ },
                ContextMenuItem("Paste") { /​* ... */ }
            )
        }
    )
) {
    // Content
}

Current: Use popup or custom implementation.

Window State Persistence

Save/​restore window size/​position:

// Save on close
windowState.size // DpSize
windowState.position // WindowPosition

// Restore on launch
val savedWidth = preferences.getInt("window.width", 1200)
val savedHeight = preferences.getInt("window.height", 800)

val windowState = rememberWindowState(
    width = savedWidth.dp,
    height = savedHeight.dp
)

9. Desktop Module Structure

desktopApp/
├── build.gradle.kts                  # Desktop-only build config
└── src/
    └── jvmMain/
        ├── kotlin/
        │   └── com/​vitorpamplona/​amethyst/​desktop/
        │       ├── Main.kt               # Entry point, Window, MenuBar
        │       ├── network/
        │       │   ├── DesktopHttpClient.kt
        │       │   └── DesktopRelayConnectionManager.kt
        │       └── ui/
        │           ├── FeedScreen.kt     # Desktop screen layouts
        │           └── LoginScreen.kt
        └── resources/
            ├── icon.icns                 # macOS icon
            ├── icon.ico                  # Windows icon
            └── icon.png                  # Linux icon

Key files:

  • desktopApp/​src/​jvmMain/​kotlin/​com/​vitorpamplona/​amethyst/​desktop/​Main.kt — fun main() L172, application { L186, Window L229, MenuBar L234 (OS-aware shortcuts begin at L239)
  • desktopApp/​src/​jvmMain/​kotlin/​com/​vitorpamplona/​amethyst/​desktop/​ui/​deck/​SinglePaneLayout.kt — NavigationRail at L97
  • desktopApp/​src/​jvmMain/​kotlin/​com/​vitorpamplona/​amethyst/​desktop/​ui/​deck/ — DeckLayout, WorkspaceManager, DeckState (multi-pane)
  • desktopApp/​build.gradle.kts — desktop packaging config (DMG/​MSI/​DEB)

10. Packaging & Distribution

Build Configuration

// desktopApp/​build.gradle.kts
compose.desktop {
    application {
        mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"

        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)

            packageName = "Amethyst"
            packageVersion = "1.0.0"

            macOS {
                bundleID = "com.vitorpamplona.amethyst.desktop"
                iconFile.set(project.file("src/​jvmMain/​resources/​icon.icns"))
            }

            windows {
                iconFile.set(project.file("src/​jvmMain/​resources/​icon.ico"))
                menuGroup = "Amethyst"
            }

            linux {
                iconFile.set(project.file("src/​jvmMain/​resources/​icon.png"))
            }
        }
    }
}

See: desktopApp/​build.gradle.kts:45-73

Gradle Tasks

# Run desktop app
./​gradlew :desktopApp:run

# Package for distribution
./​gradlew :desktopApp:packageDmg       # macOS
./​gradlew :desktopApp:packageMsi       # Windows
./​gradlew :desktopApp:packageDeb       # Linux

Delegate packaging issues to gradle-expert.


Common Patterns

Pattern: OS-Aware Shortcuts Helper

// commons/​src/​jvmMain/​kotlin/​shortcuts/​ShortcutUtils.kt
object DesktopShortcuts {
    private val isMacOS = System.getProperty("os.name")
        .lowercase().contains("mac")

    fun primary(key: Key) = if (isMacOS) {
        KeyShortcut(key, meta = true)
    } else {
        KeyShortcut(key, ctrl = true)
    }

    fun primaryShift(key: Key) = if (isMacOS) {
        KeyShortcut(key, meta = true, shift = true)
    } else {
        KeyShortcut(key, ctrl = true, shift = true)
    }

    val modifierName = if (isMacOS) "Cmd" else "Ctrl"
}

// Usage in MenuBar
Item(
    "New Note",
    shortcut = DesktopShortcuts.primary(Key.N),
    onClick = { /​* ... */ }
)

Pattern: Shared Composables, Platform Layouts

// commons/​commonMain - Shared NoteCard
@Composable
fun NoteCard(note: NoteDisplayData) {
    // Business logic, UI component (shared)
}

// desktopApp/​jvmMain - Desktop layout
@Composable
fun FeedScreen() {
    Column {
        FeedHeader(/​* ... */)  // Shared from commons
        LazyColumn {
            items(notes) { note ->
                NoteCard(note)  // Shared composable
            }
        }
    }
}

// amethyst/​androidMain - Android layout
@Composable
fun FeedScreen() {
    Scaffold(
        bottomBar = { BottomNavigationBar() }  // Android-specific
    ) {
        LazyColumn {
            items(notes) { note ->
                NoteCard(note)  // Same shared composable
            }
        }
    }
}

Philosophy: Share UI components (cards, buttons), keep navigation/​layout platform-specific.


Resources

Official Documentation

Bundled References

  • references/​desktop-compose-apis.md - Complete Desktop API catalog
  • references/​desktop-navigation.md - NavigationRail vs BottomNav patterns
  • references/​keyboard-shortcuts.md - Standard shortcuts by OS
  • references/​os-detection.md - Platform detection patterns

Codebase Examples

  • Main.kt — Window + MenuBar entry point (application L186, Window L229, MenuBar L234)
  • ui/​deck/​SinglePaneLayout.kt — NavigationRail at L97
  • ui/​deck/​DeckLayout.kt / WorkspaceManager.kt — multi-pane workspace
  • ui/​feed/ — Desktop feed screens
  • ui/​login/ — Centered desktop login

Questions to Ask

When working on desktop features:

  1. Should this be shared or desktop-only?

    • Business logic → Share in commonMain
    • Navigation/​layout → Keep in desktopApp/​jvmMain
  2. Does this need OS-specific behavior?

    • Keyboard shortcuts → Yes (Cmd vs Ctrl)
    • File paths → Yes (separators)
    • Icons → Yes (per-OS formats)
  3. Is there a desktop UX convention?

    • Check MenuBar standards
    • Consider keyboard-first design
    • Tooltips for all actions
  4. Does this need gradle-expert?

    • Any build.gradle.kts changes → Delegate
    • Packaging/​distribution issues → Delegate

Anti-Patterns

❌ Hardcoding Ctrl everywhere

// Do NOT do this in a new shortcut:
shortcut = KeyShortcut(Key.N, ctrl = true)  // Wrong on macOS

✅ OS-aware shortcuts (the pattern Main.kt already uses)

shortcut = if (isMacOS) {
    KeyShortcut(Key.N, meta = true)    // Cmd+N on macOS
} else {
    KeyShortcut(Key.N, ctrl = true)    // Ctrl+N on Win/​Linux
}

❌ Using Android navigation on Desktop

Scaffold(bottomBar = { BottomNavigationBar() })  // Wrong for desktop

✅ NavigationRail for desktop

Row {
    NavigationRail { /​* ... */ }
    MainContent()
}

❌ No keyboard shortcuts

IconButton(onClick = { refresh() }) {
    Icon(Icons.Default.Refresh, "Refresh")
}

✅ Shortcuts + tooltips

IconButton(
    onClick = { refresh() },
    modifier = Modifier.tooltipArea("Refresh (Cmd+R)")
) {
    Icon(Icons.Default.Refresh, "Refresh")
}

Next Steps

When implementing desktop features:

  1. Read references/​desktop-compose-apis.md for API catalog
  2. Check references/​keyboard-shortcuts.md for standard shortcuts
  3. Reference Main.kt (entry point L172-L450+) and ui/​deck/​SinglePaneLayout.kt (NavigationRail) for current patterns
  4. Test on all 3 platforms (macOS, Windows, Linux) if possible
  5. Delegate build issues to gradle-expert
  6. Share UI components via compose-expert, not desktop-expert

Version: 1.0.0 Last Updated: 2025-12-30 Codebase Reference: AmethystMultiplatform commit 258c4e011

More skills from vitorpamplona

quartz-integration logo
vitorpamplona/amethyst

quartz-integration

Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSign...

1.6K 0
View
amy-expert logo
vitorpamplona/amethyst

amy-expert

Patterns for extending `amy`, the Amethyst CLI in `cli/`. Use when adding an `amy <verb>` command, touching files under `cli/src/main/kotlin/…/cli/`, wiring a new subcommand into `Main.kt`, writing an interop test script that drives Amy, or extracting logic out of `amethyst/` into `commons/` so a...

1.6K 0
View
auth-signers logo
vitorpamplona/amethyst

auth-signers

Signer abstraction patterns in Amethyst. Use when working with event signing, choosing between a local keypair (`NostrSignerInternal`), a remote NIP-46 bunker signer (`NostrSignerRemote`), or a NIP-55 Android external-app signer (`NostrSignerExternal`). Covers the abstract `NostrSigner` base clas...

1.6K 0
View
gradle-expert logo
vitorpamplona/amethyst

gradle-expert

Build optimization, dependency resolution, and multi-module KMP troubleshooting for AmethystMultiplatform. Use when working with: (1) Gradle build files (build.gradle.kts, settings.gradle), (2) Version catalog (libs.versions.toml), (3) Build errors and dependency conflicts, (4) Module dependencie...

1.6K 0
View
account-state logo
vitorpamplona/amethyst

account-state

Account state and in-memory event store patterns in Amethyst. Use when working with `Account.kt` (per-user StateFlow properties — follow list, relays, settings, mutes, bookmarks), `LocalCache` (the object-level event store backed by `LargeCache`), `User`/`Note` model classes, or any ViewModel tha...

1.6K 0
View
android-expert logo
vitorpamplona/amethyst

android-expert

Android platform patterns for the `amethyst/` module. Use when working with (1) Android navigation (Navigation Compose, type-safe routes, bottom nav), (2) runtime permissions (camera, notifications, biometrics), (3) platform APIs (Intent, Context, Activity, ContentResolver), (4) Material3 theming...

1.6K 0
View
relay-client logo
vitorpamplona/amethyst

relay-client

Subscription and filter-assembly patterns for the Amethyst relay client layer in `commons/.../relayClient/`. Use when working with compose-scoped subscriptions (`ComposeSubscriptionManager`, `Subscribable`), filter assemblers (`MetadataFilterAssembler`, `ReactionsFilterAssembler`, `FeedMetadataCo...

1.6K 0
View
Amethyst logo
vitorpamplona/amethyst

Amethyst

Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, and distribute your own version.

1.6K 0
View
kotlin-coroutines logo
vitorpamplona/amethyst

kotlin-coroutines

Advanced Kotlin coroutines patterns for AmethystMultiplatform. Use when working with: (1) Structured concurrency (supervisorScope, coroutineScope), (2) Advanced Flow operators (flatMapLatest, combine, merge, shareIn, stateIn), (3) Channels and callbackFlow, (4) Dispatcher management and context s...

1.6K 0
View
kotlin-expert logo
vitorpamplona/amethyst

kotlin-expert

Advanced Kotlin patterns for AmethystMultiplatform. Flow state management (StateFlow/SharedFlow), sealed hierarchies (classes vs interfaces), immutability (@Immutable, data classes), DSL builders (type-safe fluent APIs), inline functions (reified generics, performance). Use when working with: (1)...

1.6K 0
View
find-missing-translations logo
vitorpamplona/amethyst

find-missing-translations

Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language

1.6K 0
View
find-non-lambda-logs logo
vitorpamplona/amethyst

find-non-lambda-logs

Use when auditing or migrating Log calls — flags both interpolated Log.d/i/w/e that should use the lambda overload (allocation hygiene) and catch-block Log.w/e that interpolate ${e.message} but drop the throwable (lost stack traces)

1.6K 0
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View