name: desktop-expert
description: 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/sidebar vs Android bottom nav, multi-window), (4) file system integration (file pickers, drag-and-drop, Desktop.getDesktop()), (5) OS-specific behavior on macOS/Windows/Linux, (6) desktop UX principles (keyboard-first, tooltips). Delegates shared composables to compose-expert, build/packaging to gradle-expert, and source-set structure to kotlin-multiplatform.
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,
jvmMainsource set structure - gradle-expert: All
build.gradle.ktsissues, dependency conflicts - compose-expert: General Compose patterns,
@Composablebest 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 windowrememberWindowState()manages size/positiononCloseRequesthandles 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:
| Action | macOS | Windows/Linux |
|---|---|---|
| New | Cmd+N | Ctrl+N |
| Open | Cmd+O | Ctrl+O |
| Save | Cmd+S | Ctrl+S |
| Quit | Cmd+Q | Ctrl+Q (Alt+F4) |
| Settings | Cmd+, | Ctrl+, |
| Copy | Cmd+C | Ctrl+C |
| Paste | Cmd+V | Ctrl+V |
| Undo | Cmd+Z | Ctrl+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
| OS | Behavior |
|---|---|
| macOS | System-wide menu bar at top of screen |
| Windows | In-window menu bar |
| Linux | Varies by desktop environment |
Compose Desktop MenuBar adapts automatically.
Keyboard Modifier Keys
| Modifier | macOS | Windows/Linux |
|---|---|---|
| Primary | meta = true (Cmd) |
ctrl = true |
| Secondary | ctrl = true |
alt = true |
| Shift | shift = true |
shift = true |
Best practice: Detect OS and use appropriate modifier.
System Tray Behavior
| OS | Tray Location |
|---|---|
| macOS | Top-right menu bar |
| Windows | Bottom-right taskbar |
| Linux | Top panel (varies) |
8. Desktop UX Principles
Keyboard-First Design
Every action should have:
- Mouse/touch interaction
- Keyboard shortcut (if frequent)
- 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,WindowL229,MenuBarL234 (OS-aware shortcuts begin at L239)desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt— NavigationRail at L97desktopApp/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 catalogreferences/desktop-navigation.md- NavigationRail vs BottomNav patternsreferences/keyboard-shortcuts.md- Standard shortcuts by OSreferences/os-detection.md- Platform detection patterns
Codebase Examples
Main.kt— Window + MenuBar entry point (applicationL186,WindowL229,MenuBarL234)ui/deck/SinglePaneLayout.kt— NavigationRail at L97ui/deck/DeckLayout.kt/WorkspaceManager.kt— multi-pane workspaceui/feed/— Desktop feed screensui/login/— Centered desktop login
Questions to Ask
When working on desktop features:
Should this be shared or desktop-only?
- Business logic → Share in
commonMain - Navigation/layout → Keep in
desktopApp/jvmMain
- Business logic → Share in
Does this need OS-specific behavior?
- Keyboard shortcuts → Yes (Cmd vs Ctrl)
- File paths → Yes (separators)
- Icons → Yes (per-OS formats)
Is there a desktop UX convention?
- Check MenuBar standards
- Consider keyboard-first design
- Tooltips for all actions
Does this need gradle-expert?
- Any
build.gradle.ktschanges → Delegate - Packaging/distribution issues → Delegate
- Any
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:
- Read
references/desktop-compose-apis.mdfor API catalog - Check
references/keyboard-shortcuts.mdfor standard shortcuts - Reference
Main.kt(entry point L172-L450+) andui/deck/SinglePaneLayout.kt(NavigationRail) for current patterns - Test on all 3 platforms (macOS, Windows, Linux) if possible
- Delegate build issues to gradle-expert
- Share UI components via compose-expert, not desktop-expert
Version: 1.0.0 Last Updated: 2025-12-30 Codebase Reference: AmethystMultiplatform commit 258c4e011