initial skills
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
# Desktop Compose APIs Catalog
|
||||
|
||||
Complete reference for Compose Multiplatform Desktop-only APIs.
|
||||
|
||||
## Window Management
|
||||
|
||||
### application
|
||||
|
||||
Root entry point for desktop apps.
|
||||
|
||||
```kotlin
|
||||
fun main() = application {
|
||||
Window(onCloseRequest = ::exitApplication) {
|
||||
Text("Hello Desktop")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Window
|
||||
|
||||
Creates a window.
|
||||
|
||||
```kotlin
|
||||
Window(
|
||||
onCloseRequest: () -> Unit,
|
||||
state: WindowState = rememberWindowState(),
|
||||
visible: Boolean = true,
|
||||
title: String = "Untitled",
|
||||
icon: Painter? = null,
|
||||
undecorated: Boolean = false,
|
||||
transparent: Boolean = false,
|
||||
resizable: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
focusable: Boolean = true,
|
||||
alwaysOnTop: Boolean = false,
|
||||
onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
|
||||
onKeyEvent: ((KeyEvent) -> Boolean) = { false },
|
||||
content: @Composable FrameWindowScope.() -> Unit
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```kotlin
|
||||
val windowState = rememberWindowState(
|
||||
width = 1200.dp,
|
||||
height = 800.dp,
|
||||
position = WindowPosition.Aligned(Alignment.Center)
|
||||
)
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = windowState,
|
||||
title = "My App",
|
||||
resizable = true
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
```
|
||||
|
||||
### rememberWindowState
|
||||
|
||||
Manages window size and position.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun rememberWindowState(
|
||||
placement: WindowPlacement = WindowPlacement.Floating,
|
||||
isMinimized: Boolean = false,
|
||||
position: WindowPosition = WindowPosition.PlatformDefault,
|
||||
width: Dp = Dp.Unspecified,
|
||||
height: Dp = Dp.Unspecified
|
||||
): WindowState
|
||||
```
|
||||
|
||||
**WindowPlacement:**
|
||||
- `Floating` - Normal window
|
||||
- `Maximized` - Fullscreen
|
||||
- `Fullscreen` - Fullscreen without decorations
|
||||
|
||||
**WindowPosition:**
|
||||
- `PlatformDefault` - OS decides
|
||||
- `Aligned(alignment)` - Center, TopStart, etc.
|
||||
- `Absolute(x, y)` - Fixed position in pixels
|
||||
|
||||
### DialogWindow
|
||||
|
||||
Modal dialog.
|
||||
|
||||
```kotlin
|
||||
DialogWindow(
|
||||
onCloseRequest: () -> Unit,
|
||||
state: DialogState = rememberDialogState(),
|
||||
visible: Boolean = true,
|
||||
title: String = "Dialog",
|
||||
icon: Painter? = null,
|
||||
undecorated: Boolean = false,
|
||||
transparent: Boolean = false,
|
||||
resizable: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
focusable: Boolean = true,
|
||||
content: @Composable DialogWindowScope.() -> Unit
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```kotlin
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDialog) {
|
||||
DialogWindow(
|
||||
onCloseRequest = { showDialog = false },
|
||||
title = "Confirm"
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("Are you sure?")
|
||||
Row {
|
||||
Button(onClick = { showDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(onClick = { /* confirm */ }) {
|
||||
Text("OK")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MenuBar
|
||||
|
||||
### MenuBar
|
||||
|
||||
Native menu bar for windows.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FrameWindowScope.MenuBar(
|
||||
content: @Composable MenuBarScope.() -> Unit
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```kotlin
|
||||
Window(onCloseRequest = ::exitApplication) {
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item("New", onClick = { /* ... */ })
|
||||
Item("Open", onClick = { /* ... */ })
|
||||
Separator()
|
||||
Item("Quit", onClick = ::exitApplication)
|
||||
}
|
||||
Menu("Edit") {
|
||||
Item("Copy", onClick = { /* ... */ })
|
||||
Item("Paste", onClick = { /* ... */ })
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Menu
|
||||
|
||||
Top-level menu.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MenuBarScope.Menu(
|
||||
text: String,
|
||||
mnemonic: Char? = null,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable MenuScope.() -> Unit
|
||||
)
|
||||
```
|
||||
|
||||
### Item
|
||||
|
||||
Menu item.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MenuScope.Item(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
shortcut: KeyShortcut? = null,
|
||||
mnemonic: Char? = null,
|
||||
enabled: Boolean = true,
|
||||
icon: Painter? = null
|
||||
)
|
||||
```
|
||||
|
||||
**With keyboard shortcut:**
|
||||
```kotlin
|
||||
Item(
|
||||
text = "Save",
|
||||
onClick = { save() },
|
||||
shortcut = KeyShortcut(Key.S, ctrl = true),
|
||||
icon = painterResource("save.png")
|
||||
)
|
||||
```
|
||||
|
||||
### Separator
|
||||
|
||||
Menu separator line.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MenuScope.Separator()
|
||||
```
|
||||
|
||||
### CheckboxItem
|
||||
|
||||
Toggleable menu item.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MenuScope.CheckboxItem(
|
||||
text: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
shortcut: KeyShortcut? = null,
|
||||
mnemonic: Char? = null,
|
||||
enabled: Boolean = true
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```kotlin
|
||||
var darkMode by remember { mutableStateOf(false) }
|
||||
|
||||
Menu("View") {
|
||||
CheckboxItem(
|
||||
text = "Dark Mode",
|
||||
checked = darkMode,
|
||||
onCheckedChange = { darkMode = it },
|
||||
shortcut = KeyShortcut(Key.D, ctrl = true)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### RadioButtonItem
|
||||
|
||||
Radio button menu item.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MenuScope.RadioButtonItem(
|
||||
text: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
shortcut: KeyShortcut? = null,
|
||||
mnemonic: Char? = null,
|
||||
enabled: Boolean = true
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## System Tray
|
||||
|
||||
### Tray
|
||||
|
||||
System tray icon with menu.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ApplicationScope.Tray(
|
||||
icon: Painter,
|
||||
state: TrayState = rememberTrayState(),
|
||||
tooltip: String? = null,
|
||||
onAction: () -> Unit = {},
|
||||
menu: @Composable MenuScope.() -> Unit = {}
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```kotlin
|
||||
application {
|
||||
var isVisible by remember { mutableStateOf(true) }
|
||||
|
||||
Tray(
|
||||
icon = painterResource("tray-icon.png"),
|
||||
tooltip = "My App",
|
||||
onAction = { isVisible = true },
|
||||
menu = {
|
||||
Item("Show Window", onClick = { isVisible = true })
|
||||
Separator()
|
||||
Item("Quit", onClick = ::exitApplication)
|
||||
}
|
||||
)
|
||||
|
||||
if (isVisible) {
|
||||
Window(
|
||||
onCloseRequest = { isVisible = false },
|
||||
title = "App"
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### rememberTrayState
|
||||
|
||||
Manages tray state.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun rememberTrayState(): TrayState
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
### Notification (via Tray)
|
||||
|
||||
Show desktop notifications through tray.
|
||||
|
||||
```kotlin
|
||||
val trayState = rememberTrayState()
|
||||
|
||||
Tray(
|
||||
icon = painterResource("icon.png"),
|
||||
state = trayState
|
||||
)
|
||||
|
||||
// Send notification
|
||||
LaunchedEffect(Unit) {
|
||||
trayState.sendNotification(
|
||||
Notification(
|
||||
title = "Message",
|
||||
message = "You have a new message",
|
||||
type = Notification.Type.Info
|
||||
)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Notification types:**
|
||||
- `Info` - Information
|
||||
- `Warning` - Warning
|
||||
- `Error` - Error
|
||||
|
||||
---
|
||||
|
||||
## Keyboard
|
||||
|
||||
### KeyShortcut
|
||||
|
||||
Keyboard shortcut definition.
|
||||
|
||||
```kotlin
|
||||
data class KeyShortcut(
|
||||
val key: Key,
|
||||
val ctrl: Boolean = false,
|
||||
val meta: Boolean = false,
|
||||
val alt: Boolean = false,
|
||||
val shift: Boolean = false
|
||||
)
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```kotlin
|
||||
// Ctrl+S (Windows/Linux)
|
||||
KeyShortcut(Key.S, ctrl = true)
|
||||
|
||||
// Cmd+S (macOS)
|
||||
KeyShortcut(Key.S, meta = true)
|
||||
|
||||
// Ctrl+Shift+N
|
||||
KeyShortcut(Key.N, ctrl = true, shift = true)
|
||||
|
||||
// Alt+F4
|
||||
KeyShortcut(Key.F4, alt = true)
|
||||
```
|
||||
|
||||
### onPreviewKeyEvent / onKeyEvent
|
||||
|
||||
Window-level keyboard handlers.
|
||||
|
||||
```kotlin
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
onPreviewKeyEvent = { event ->
|
||||
if (event.key == Key.Escape && event.type == KeyEventType.KeyDown) {
|
||||
// Handle Escape
|
||||
true // Consume event
|
||||
} else {
|
||||
false // Propagate
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mouse
|
||||
|
||||
### PointerMoveFilter (Deprecated, use Modifier.pointerInput)
|
||||
|
||||
```kotlin
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
// Handle mouse events
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Mouse cursor
|
||||
|
||||
```kotlin
|
||||
Box(
|
||||
modifier = Modifier.pointerHoverIcon(
|
||||
icon = PointerIcon(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR))
|
||||
)
|
||||
) {
|
||||
Text("Hover me")
|
||||
}
|
||||
```
|
||||
|
||||
**Cursor types:**
|
||||
- `DEFAULT_CURSOR`
|
||||
- `HAND_CURSOR`
|
||||
- `TEXT_CURSOR`
|
||||
- `CROSSHAIR_CURSOR`
|
||||
- `WAIT_CURSOR`
|
||||
- `MOVE_CURSOR`
|
||||
- `E_RESIZE_CURSOR`, `W_RESIZE_CURSOR`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Drag & Drop (Experimental)
|
||||
|
||||
### onExternalDrag
|
||||
|
||||
Handle drag-and-drop from external sources.
|
||||
|
||||
```kotlin
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(200.dp)
|
||||
.background(Color.LightGray)
|
||||
.onExternalDrag(
|
||||
onDragStart = { externalDragValue ->
|
||||
println("Drag started")
|
||||
},
|
||||
onDrag = { externalDragValue ->
|
||||
println("Dragging: ${externalDragValue.dragData}")
|
||||
},
|
||||
onDragExit = {
|
||||
println("Drag exited")
|
||||
},
|
||||
onDrop = { externalDragValue ->
|
||||
val dragData = externalDragValue.dragData
|
||||
when (dragData) {
|
||||
is DragData.FilesList -> {
|
||||
println("Files dropped: ${dragData.readFiles()}")
|
||||
}
|
||||
is DragData.Text -> {
|
||||
println("Text dropped: ${dragData.readText()}")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text("Drop files here", Modifier.align(Alignment.Center))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
### painterResource
|
||||
|
||||
Load images from resources.
|
||||
|
||||
```kotlin
|
||||
val icon = painterResource("icon.png")
|
||||
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = "App icon"
|
||||
)
|
||||
```
|
||||
|
||||
**Resource location:** `src/jvmMain/resources/`
|
||||
|
||||
---
|
||||
|
||||
## Platform Integration
|
||||
|
||||
### Desktop.getDesktop() (AWT)
|
||||
|
||||
Access system desktop features (not Compose API, but commonly used).
|
||||
|
||||
```kotlin
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
|
||||
// Open URL in browser
|
||||
if (Desktop.isDesktopSupported()) {
|
||||
Desktop.getDesktop().browse(URI("https://example.com"))
|
||||
}
|
||||
|
||||
// Open file with default app
|
||||
Desktop.getDesktop().open(File("/path/to/file.pdf"))
|
||||
|
||||
// Open email client
|
||||
Desktop.getDesktop().mail(URI("mailto:user@example.com"))
|
||||
```
|
||||
|
||||
### FileDialog (AWT)
|
||||
|
||||
File picker dialogs.
|
||||
|
||||
```kotlin
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
|
||||
// Open file
|
||||
val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD)
|
||||
fileDialog.isVisible = true
|
||||
val selectedFile = fileDialog.file
|
||||
val directory = fileDialog.directory
|
||||
|
||||
// Save file
|
||||
val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE)
|
||||
saveDialog.file = "document.txt"
|
||||
saveDialog.isVisible = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SwingPanel (Interop)
|
||||
|
||||
Embed Swing components in Compose.
|
||||
|
||||
```kotlin
|
||||
import androidx.compose.ui.awt.SwingPanel
|
||||
import javax.swing.JButton
|
||||
|
||||
SwingPanel(
|
||||
factory = {
|
||||
JButton("Swing Button").apply {
|
||||
addActionListener {
|
||||
println("Swing button clicked")
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(200.dp, 50.dp)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ComposePanel (Reverse Interop)
|
||||
|
||||
Embed Compose in Swing.
|
||||
|
||||
```kotlin
|
||||
import androidx.compose.ui.awt.ComposePanel
|
||||
import javax.swing.JFrame
|
||||
|
||||
val frame = JFrame("Swing Frame")
|
||||
val composePanel = ComposePanel()
|
||||
|
||||
composePanel.setContent {
|
||||
Text("Compose in Swing")
|
||||
}
|
||||
|
||||
frame.contentPane.add(composePanel)
|
||||
frame.setSize(400, 300)
|
||||
frame.isVisible = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Version Requirements
|
||||
|
||||
- **Kotlin:** 2.0+
|
||||
- **Compose Multiplatform:** 1.7.0+
|
||||
- **JVM Target:** 11+ (recommend 21)
|
||||
|
||||
**See also:**
|
||||
- [Official Desktop API docs](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html)
|
||||
- [Compose Multiplatform repo](https://github.com/JetBrains/compose-multiplatform)
|
||||
@@ -0,0 +1,464 @@
|
||||
# Desktop Navigation Patterns
|
||||
|
||||
Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform.
|
||||
|
||||
## Core Difference
|
||||
|
||||
| Platform | Pattern | Location | Rationale |
|
||||
|----------|---------|----------|-----------|
|
||||
| **Android** | Bottom Navigation Bar | Horizontal, bottom | Thumb reach on mobile |
|
||||
| **Desktop** | Navigation Rail | Vertical, left sidebar | Horizontal screen space |
|
||||
|
||||
---
|
||||
|
||||
## Desktop: NavigationRail
|
||||
|
||||
### Current Implementation
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:191-264`
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MainContent(
|
||||
currentScreen: AppScreen,
|
||||
onScreenChange: (AppScreen) -> Unit,
|
||||
// ...
|
||||
) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// LEFT: Vertical Sidebar (NavigationRail)
|
||||
NavigationRail(
|
||||
modifier = Modifier.width(80.dp).fillMaxHeight(),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Top navigation items
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Home, "Feed") },
|
||||
label = { Text("Feed") },
|
||||
selected = currentScreen == AppScreen.Feed,
|
||||
onClick = { onScreenChange(AppScreen.Feed) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Search, "Search") },
|
||||
label = { Text("Search") },
|
||||
selected = currentScreen == AppScreen.Search,
|
||||
onClick = { onScreenChange(AppScreen.Search) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Email, "Messages") },
|
||||
label = { Text("DMs") },
|
||||
selected = currentScreen == AppScreen.Messages,
|
||||
onClick = { onScreenChange(AppScreen.Messages) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Notifications, "Notifications") },
|
||||
label = { Text("Alerts") },
|
||||
selected = currentScreen == AppScreen.Notifications,
|
||||
onClick = { onScreenChange(AppScreen.Notifications) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Person, "Profile") },
|
||||
label = { Text("Profile") },
|
||||
selected = currentScreen == AppScreen.Profile,
|
||||
onClick = { onScreenChange(AppScreen.Profile) }
|
||||
)
|
||||
|
||||
// Push Settings to bottom
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Settings, "Settings") },
|
||||
label = { Text("Settings") },
|
||||
selected = currentScreen == AppScreen.Settings,
|
||||
onClick = { onScreenChange(AppScreen.Settings) }
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// RIGHT: Main Content Area
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp)) {
|
||||
when (currentScreen) {
|
||||
AppScreen.Feed -> FeedScreen(relayManager)
|
||||
AppScreen.Search -> SearchPlaceholder()
|
||||
AppScreen.Messages -> MessagesPlaceholder()
|
||||
AppScreen.Notifications -> NotificationsPlaceholder()
|
||||
AppScreen.Profile -> ProfileScreen(account, accountManager)
|
||||
AppScreen.Settings -> RelaySettingsScreen(relayManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Structure
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ [Menu Bar: File, Edit, View, Help] │ ← MenuBar (OS-native)
|
||||
├──────┬─────────────────────────────────┤
|
||||
│ │ │
|
||||
│ [🏠] │ │
|
||||
│ Feed │ │
|
||||
│ │ │
|
||||
│ [🔍] │ Main Content Area │
|
||||
│Search│ (Feed, Messages, etc.) │
|
||||
│ │ │
|
||||
│ [✉️] │ │
|
||||
│ DMs │ │
|
||||
│ │ │
|
||||
│ [🔔] │ │
|
||||
│Alerts│ │
|
||||
│ │ │
|
||||
│ [👤] │ │
|
||||
│Profile │
|
||||
│ │ │
|
||||
│ ─ │ │
|
||||
│ [⚙️] │ │
|
||||
│Settings │
|
||||
│ │ │
|
||||
└──────┴─────────────────────────────────┘
|
||||
80dp Remaining width (weight=1f)
|
||||
```
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Always visible:** All nav items visible at once
|
||||
2. **Icon + Label:** Both shown (not just icons)
|
||||
3. **Vertical list:** Natural reading order
|
||||
4. **Settings at bottom:** Separated by divider + Spacer.weight(1f)
|
||||
5. **80dp width:** Standard NavigationRail width
|
||||
|
||||
---
|
||||
|
||||
## Android: BottomNavigationBar (Future)
|
||||
|
||||
### Expected Implementation
|
||||
|
||||
**Location:** `amethyst/src/androidMain/kotlin/...` (not yet implemented)
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
currentScreen: AppScreen,
|
||||
onScreenChange: (AppScreen) -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Home, "Feed") },
|
||||
label = { Text("Feed") },
|
||||
selected = currentScreen == AppScreen.Feed,
|
||||
onClick = { onScreenChange(AppScreen.Feed) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Search, "Search") },
|
||||
label = { Text("Search") },
|
||||
selected = currentScreen == AppScreen.Search,
|
||||
onClick = { onScreenChange(AppScreen.Search) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Email, "Messages") },
|
||||
label = { Text("Messages") },
|
||||
selected = currentScreen == AppScreen.Messages,
|
||||
onClick = { onScreenChange(AppScreen.Messages) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Person, "Profile") },
|
||||
label = { Text("Profile") },
|
||||
selected = currentScreen == AppScreen.Profile,
|
||||
onClick = { onScreenChange(AppScreen.Profile) }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(Modifier.padding(paddingValues)) {
|
||||
when (currentScreen) {
|
||||
AppScreen.Feed -> FeedScreen()
|
||||
AppScreen.Search -> SearchScreen()
|
||||
AppScreen.Messages -> MessagesScreen()
|
||||
AppScreen.Profile -> ProfileScreen()
|
||||
// Settings accessed via Profile or overflow menu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
│ │
|
||||
│ Main Content Area │
|
||||
│ (Feed, Messages, etc.) │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
├─────────────────────────────────────┤
|
||||
│ [🏠] [🔍] [✉️] [👤] │ ← NavigationBar
|
||||
│ Feed Search DMs Profile │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Differences from Desktop
|
||||
|
||||
1. **Bottom placement:** Thumb reach
|
||||
2. **Horizontal layout:** Limited vertical space
|
||||
3. **Fewer items:** 3-5 primary destinations
|
||||
4. **Label optional:** Can hide on small screens
|
||||
5. **Settings hidden:** In profile or overflow
|
||||
|
||||
---
|
||||
|
||||
## Shared Navigation State
|
||||
|
||||
Both platforms use the same `AppScreen` enum from `commons`.
|
||||
|
||||
**File:** `commons/src/commonMain/kotlin/.../navigation/AppScreen.kt` (expected)
|
||||
|
||||
```kotlin
|
||||
// Shared navigation destinations
|
||||
enum class AppScreen {
|
||||
Feed,
|
||||
Search,
|
||||
Messages,
|
||||
Notifications,
|
||||
Profile,
|
||||
Settings
|
||||
}
|
||||
```
|
||||
|
||||
**State management (shared):**
|
||||
|
||||
```kotlin
|
||||
// commons/src/jvmAndroid/kotlin/.../navigation/NavigationViewModel.kt
|
||||
class NavigationViewModel : ViewModel() {
|
||||
private val _currentScreen = MutableStateFlow(AppScreen.Feed)
|
||||
val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
|
||||
|
||||
fun navigateTo(screen: AppScreen) {
|
||||
_currentScreen.value = screen
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Pane Desktop Layout (Advanced)
|
||||
|
||||
Desktop can utilize horizontal space for multi-pane layouts.
|
||||
|
||||
### Two-Pane Layout
|
||||
|
||||
```kotlin
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// Left: NavigationRail (fixed 80dp)
|
||||
NavigationRail { /* ... */ }
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// Center: Main content (60% width)
|
||||
Box(Modifier.weight(0.6f)) {
|
||||
FeedScreen()
|
||||
}
|
||||
|
||||
// Right: Detail pane (40% width, conditional)
|
||||
if (selectedNote != null) {
|
||||
VerticalDivider()
|
||||
Box(Modifier.weight(0.4f)) {
|
||||
NoteDetailPane(selectedNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Layout:
|
||||
|
||||
```
|
||||
┌──────┬───────────────────┬─────────────┐
|
||||
│ │ │ │
|
||||
│ Nav │ Feed List │ Detail │
|
||||
│ Rail │ (60%) │ Pane │
|
||||
│ │ │ (40%) │
|
||||
│ │ │ │
|
||||
└──────┴───────────────────┴─────────────┘
|
||||
80dp weight(0.6f) weight(0.4f)
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Email: List + message detail
|
||||
- Notes: List + editor
|
||||
- Settings: Categories + options
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Navigation
|
||||
|
||||
Desktop should support keyboard navigation.
|
||||
|
||||
### Tab Navigation
|
||||
|
||||
```kotlin
|
||||
NavigationRail(
|
||||
modifier = Modifier.focusable()
|
||||
) {
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Home, "Feed") },
|
||||
label = { Text("Feed") },
|
||||
selected = currentScreen == AppScreen.Feed,
|
||||
onClick = { onScreenChange(AppScreen.Feed) },
|
||||
modifier = Modifier.focusable()
|
||||
)
|
||||
// More items...
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
```kotlin
|
||||
Window(
|
||||
onPreviewKeyEvent = { event ->
|
||||
when {
|
||||
event.key == Key.One && event.isCtrlPressed ->
|
||||
onScreenChange(AppScreen.Feed).also { true }
|
||||
event.key == Key.Two && event.isCtrlPressed ->
|
||||
onScreenChange(AppScreen.Search).also { true }
|
||||
event.key == Key.Three && event.isCtrlPressed ->
|
||||
onScreenChange(AppScreen.Messages).also { true }
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
```
|
||||
|
||||
**Standard:**
|
||||
- Ctrl+1: First nav item (Feed)
|
||||
- Ctrl+2: Second nav item (Search)
|
||||
- Ctrl+3: Third nav item (Messages)
|
||||
- Ctrl+Comma: Settings
|
||||
|
||||
---
|
||||
|
||||
## Navigation Transitions
|
||||
|
||||
### Desktop (Instant)
|
||||
|
||||
No fancy animations. Instant switch.
|
||||
|
||||
```kotlin
|
||||
Box {
|
||||
when (currentScreen) {
|
||||
AppScreen.Feed -> FeedScreen()
|
||||
AppScreen.Search -> SearchScreen()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Android (Animated, Future)
|
||||
|
||||
Can use Navigation Compose for transitions.
|
||||
|
||||
```kotlin
|
||||
NavHost(navController, startDestination = "feed") {
|
||||
composable("feed") { FeedScreen() }
|
||||
composable("search") { SearchScreen() }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Desktop NavigationRail
|
||||
|
||||
✅ **DO:**
|
||||
- Keep width 72-80dp
|
||||
- Show both icon and label
|
||||
- Use Spacer.weight(1f) for bottom items
|
||||
- Separate sections with HorizontalDivider
|
||||
- Limit to 5-7 primary items
|
||||
|
||||
❌ **DON'T:**
|
||||
- Use bottom navigation on desktop
|
||||
- Hide labels (plenty of space)
|
||||
- Make it collapsible (not standard)
|
||||
- Use hamburger menu (not desktop pattern)
|
||||
|
||||
### Android NavigationBar
|
||||
|
||||
✅ **DO:**
|
||||
- Limit to 3-5 items
|
||||
- Use bottom placement
|
||||
- Consider label visibility on small screens
|
||||
- Use standard icons
|
||||
|
||||
❌ **DON'T:**
|
||||
- Put more than 5 items
|
||||
- Use top placement (deprecated)
|
||||
- Put critical actions only in nav bar
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
When adding Android support:
|
||||
|
||||
1. **Extract shared state:** Move `AppScreen` to `commons/commonMain`
|
||||
2. **Platform layouts:** Keep `NavigationRail` in `desktopApp/jvmMain`, `NavigationBar` in `amethyst/androidMain`
|
||||
3. **Shared screens:** Composables in `commons/commonMain` (FeedScreen content)
|
||||
4. **Platform chrome:** Navigation containers in platform modules
|
||||
|
||||
**Example:**
|
||||
|
||||
```kotlin
|
||||
// commons/commonMain - Shared screen content
|
||||
@Composable
|
||||
fun FeedContent(notes: List<Note>) {
|
||||
LazyColumn {
|
||||
items(notes) { note ->
|
||||
NoteCard(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// desktopApp/jvmMain - Desktop wrapper
|
||||
@Composable
|
||||
fun FeedScreen() {
|
||||
Column {
|
||||
FeedHeader() // Desktop-specific header
|
||||
FeedContent(notes) // Shared content
|
||||
}
|
||||
}
|
||||
|
||||
// amethyst/androidMain - Android wrapper
|
||||
@Composable
|
||||
fun FeedScreen() {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar { Text("Feed") } }
|
||||
) {
|
||||
FeedContent(notes) // Same shared content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Current Desktop:** Main.kt:191-264
|
||||
- **Material3 NavigationRail:** [Material Design Docs](https://m3.material.io/components/navigation-rail)
|
||||
- **Material3 NavigationBar:** [Material Design Docs](https://m3.material.io/components/navigation-bar)
|
||||
@@ -0,0 +1,400 @@
|
||||
# Keyboard Shortcuts Reference
|
||||
|
||||
Standard keyboard shortcuts for desktop applications across macOS, Windows, and Linux.
|
||||
|
||||
## Primary Modifier Keys
|
||||
|
||||
| Platform | Primary | Secondary | Tertiary |
|
||||
|----------|---------|-----------|----------|
|
||||
| **macOS** | Cmd (⌘) / `meta` | Option (⌥) / `alt` | Ctrl (⌃) / `ctrl` |
|
||||
| **Windows** | Ctrl / `ctrl` | Alt / `alt` | Win / `meta` |
|
||||
| **Linux** | Ctrl / `ctrl` | Alt / `alt` | Super / `meta` |
|
||||
|
||||
**In Compose Desktop:**
|
||||
|
||||
```kotlin
|
||||
// macOS
|
||||
KeyShortcut(Key.N, meta = true) // Cmd+N
|
||||
|
||||
// Windows/Linux
|
||||
KeyShortcut(Key.N, ctrl = true) // Ctrl+N
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Operations
|
||||
|
||||
| Action | macOS | Windows | Linux | Notes |
|
||||
|--------|-------|---------|-------|-------|
|
||||
| **New** | Cmd+N | Ctrl+N | Ctrl+N | Create new |
|
||||
| **Open** | Cmd+O | Ctrl+O | Ctrl+O | Open file |
|
||||
| **Save** | Cmd+S | Ctrl+S | Ctrl+S | Save current |
|
||||
| **Save As** | Cmd+Shift+S | Ctrl+Shift+S | Ctrl+Shift+S | Save with new name |
|
||||
| **Close** | Cmd+W | Ctrl+W | Ctrl+W | Close window/tab |
|
||||
| **Quit** | Cmd+Q | Ctrl+Q | Ctrl+Q | Exit app |
|
||||
| **Print** | Cmd+P | Ctrl+P | Ctrl+P | Print |
|
||||
|
||||
**Compose Implementation:**
|
||||
|
||||
```kotlin
|
||||
val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item(
|
||||
"New Note",
|
||||
shortcut = if (isMacOS) {
|
||||
KeyShortcut(Key.N, meta = true)
|
||||
} else {
|
||||
KeyShortcut(Key.N, ctrl = true)
|
||||
},
|
||||
onClick = { createNewNote() }
|
||||
)
|
||||
Item(
|
||||
"Save",
|
||||
shortcut = if (isMacOS) {
|
||||
KeyShortcut(Key.S, meta = true)
|
||||
} else {
|
||||
KeyShortcut(Key.S, ctrl = true)
|
||||
},
|
||||
onClick = { save() }
|
||||
)
|
||||
Separator()
|
||||
Item(
|
||||
"Quit",
|
||||
shortcut = if (isMacOS) {
|
||||
KeyShortcut(Key.Q, meta = true)
|
||||
} else {
|
||||
KeyShortcut(Key.Q, ctrl = true)
|
||||
},
|
||||
onClick = ::exitApplication
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edit Operations
|
||||
|
||||
| Action | macOS | Windows | Linux | Notes |
|
||||
|--------|-------|---------|-------|-------|
|
||||
| **Undo** | Cmd+Z | Ctrl+Z | Ctrl+Z | Universal |
|
||||
| **Redo** | Cmd+Shift+Z | Ctrl+Y | Ctrl+Y | Windows/Linux use Y |
|
||||
| **Cut** | Cmd+X | Ctrl+X | Ctrl+X | Universal |
|
||||
| **Copy** | Cmd+C | Ctrl+C | Ctrl+C | Universal |
|
||||
| **Paste** | Cmd+V | Ctrl+V | Ctrl+V | Universal |
|
||||
| **Select All** | Cmd+A | Ctrl+A | Ctrl+A | Universal |
|
||||
| **Find** | Cmd+F | Ctrl+F | Ctrl+F | Search |
|
||||
| **Find Next** | Cmd+G | F3 | F3 | Next result |
|
||||
| **Replace** | Cmd+Option+F | Ctrl+H | Ctrl+H | Find & replace |
|
||||
|
||||
**Note:** Undo/Redo typically handled by text fields automatically.
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
| Action | macOS | Windows | Linux | Notes |
|
||||
|--------|-------|---------|-------|-------|
|
||||
| **Tab 1** | Cmd+1 | Ctrl+1 | Ctrl+1 | First tab/view |
|
||||
| **Tab 2** | Cmd+2 | Ctrl+2 | Ctrl+2 | Second tab/view |
|
||||
| **Tab 3** | Cmd+3 | Ctrl+3 | Ctrl+3 | Third tab/view |
|
||||
| **Next Tab** | Cmd+Option+→ | Ctrl+Tab | Ctrl+Tab | Cycle forward |
|
||||
| **Prev Tab** | Cmd+Option+← | Ctrl+Shift+Tab | Ctrl+Shift+Tab | Cycle back |
|
||||
| **Go Back** | Cmd+[ | Alt+← | Alt+← | Browser-style |
|
||||
| **Go Forward** | Cmd+] | Alt+→ | Alt+→ | Browser-style |
|
||||
|
||||
**Compose Implementation:**
|
||||
|
||||
```kotlin
|
||||
Window(
|
||||
onPreviewKeyEvent = { event ->
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
when {
|
||||
event.key == Key.One && event.isPrimaryPressed() -> {
|
||||
navigateTo(AppScreen.Feed)
|
||||
true
|
||||
}
|
||||
event.key == Key.Two && event.isPrimaryPressed() -> {
|
||||
navigateTo(AppScreen.Search)
|
||||
true
|
||||
}
|
||||
event.key == Key.Three && event.isPrimaryPressed() -> {
|
||||
navigateTo(AppScreen.Messages)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
// Content
|
||||
}
|
||||
|
||||
// Helper extension
|
||||
fun KeyEvent.isPrimaryPressed() = if (isMacOS) isMetaPressed else isCtrlPressed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Window Management
|
||||
|
||||
| Action | macOS | Windows | Linux | Notes |
|
||||
|--------|-------|---------|-------|-------|
|
||||
| **New Window** | Cmd+N | Ctrl+N | Ctrl+N | New instance |
|
||||
| **Close Window** | Cmd+W | Alt+F4 | Alt+F4 | Close current |
|
||||
| **Minimize** | Cmd+M | Win+Down | Super+Down | Minimize to dock/taskbar |
|
||||
| **Maximize** | Cmd+Ctrl+F | Win+Up | Super+Up | Fullscreen/maximize |
|
||||
| **Hide App** | Cmd+H | - | - | macOS only |
|
||||
| **Switch Window** | Cmd+` | Alt+Tab | Alt+Tab | Between app windows |
|
||||
|
||||
**Note:** Window management often handled by OS, not app shortcuts.
|
||||
|
||||
---
|
||||
|
||||
## App-Specific (Amethyst)
|
||||
|
||||
### Nostr Actions
|
||||
|
||||
| Action | macOS | Windows | Linux | Description |
|
||||
|--------|-------|---------|-------|-------------|
|
||||
| **New Note** | Cmd+N | Ctrl+N | Ctrl+N | Compose new post |
|
||||
| **Refresh Feed** | Cmd+R | Ctrl+R | Ctrl+R | Reload timeline |
|
||||
| **Search** | Cmd+K | Ctrl+K | Ctrl+K | Quick search |
|
||||
| **DMs** | Cmd+Shift+M | Ctrl+Shift+M | Ctrl+Shift+M | Open messages |
|
||||
| **Settings** | Cmd+, | Ctrl+, | Ctrl+, | Open preferences |
|
||||
| **Notifications** | Cmd+Shift+N | Ctrl+Shift+N | Ctrl+Shift+N | View alerts |
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```kotlin
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item(
|
||||
"New Note",
|
||||
shortcut = DesktopShortcuts.primary(Key.N),
|
||||
onClick = { showComposeDialog() }
|
||||
)
|
||||
Item(
|
||||
"Settings",
|
||||
shortcut = DesktopShortcuts.primary(Key.Comma),
|
||||
onClick = { navigateTo(AppScreen.Settings) }
|
||||
)
|
||||
}
|
||||
Menu("View") {
|
||||
Item(
|
||||
"Refresh Feed",
|
||||
shortcut = DesktopShortcuts.primary(Key.R),
|
||||
onClick = { refreshFeed() }
|
||||
)
|
||||
Item(
|
||||
"Search",
|
||||
shortcut = DesktopShortcuts.primary(Key.K),
|
||||
onClick = { focusSearch() }
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
| Action | macOS | Windows | Linux | Description |
|
||||
|--------|-------|---------|-------|-------------|
|
||||
| **Zoom In** | Cmd++ | Ctrl++ | Ctrl++ | Increase size |
|
||||
| **Zoom Out** | Cmd+- | Ctrl+- | Ctrl+- | Decrease size |
|
||||
| **Reset Zoom** | Cmd+0 | Ctrl+0 | Ctrl+0 | Default size |
|
||||
| **Help** | Cmd+? | F1 | F1 | Show help |
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. OS-Aware Helper
|
||||
|
||||
Create a utility for OS detection:
|
||||
|
||||
```kotlin
|
||||
// commons/src/jvmMain/kotlin/utils/PlatformShortcuts.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)
|
||||
}
|
||||
|
||||
fun primaryAlt(key: Key) = if (isMacOS) {
|
||||
KeyShortcut(key, meta = true, alt = true)
|
||||
} else {
|
||||
KeyShortcut(key, ctrl = true, alt = true)
|
||||
}
|
||||
|
||||
val modifierName = if (isMacOS) "Cmd" else "Ctrl"
|
||||
val secondaryName = if (isMacOS) "Option" else "Alt"
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```kotlin
|
||||
Item(
|
||||
"Save",
|
||||
shortcut = DesktopShortcuts.primary(Key.S),
|
||||
onClick = { save() }
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Show Shortcuts in Tooltips
|
||||
|
||||
```kotlin
|
||||
IconButton(
|
||||
onClick = { refresh() },
|
||||
modifier = Modifier.tooltipArea {
|
||||
Text("Refresh (${DesktopShortcuts.modifierName}+R)")
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, "Refresh")
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Shortcuts Menu
|
||||
|
||||
Provide a "Keyboard Shortcuts" help menu:
|
||||
|
||||
```kotlin
|
||||
Menu("Help") {
|
||||
Item("Keyboard Shortcuts", onClick = { showShortcutsDialog() })
|
||||
}
|
||||
|
||||
// Dialog content
|
||||
@Composable
|
||||
fun ShortcutsDialog() {
|
||||
Dialog(onDismissRequest = { /* close */ }) {
|
||||
Surface {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("Keyboard Shortcuts", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
ShortcutRow("New Note", "${DesktopShortcuts.modifierName}+N")
|
||||
ShortcutRow("Save", "${DesktopShortcuts.modifierName}+S")
|
||||
ShortcutRow("Search", "${DesktopShortcuts.modifierName}+K")
|
||||
ShortcutRow("Settings", "${DesktopShortcuts.modifierName}+,")
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShortcutRow(action: String, shortcut: String) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(action, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
shortcut,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Avoid Conflicts
|
||||
|
||||
**Check for OS-level shortcuts:**
|
||||
|
||||
| macOS Reserved | Description |
|
||||
|----------------|-------------|
|
||||
| Cmd+Tab | Switch apps |
|
||||
| Cmd+Space | Spotlight |
|
||||
| Cmd+H | Hide window |
|
||||
| Cmd+M | Minimize |
|
||||
| Cmd+Q | Quit |
|
||||
| Cmd+W | Close window |
|
||||
|
||||
**Windows Reserved:**
|
||||
|
||||
| Windows Reserved | Description |
|
||||
|-----------------|-------------|
|
||||
| Win+D | Show desktop |
|
||||
| Win+E | File Explorer |
|
||||
| Win+L | Lock screen |
|
||||
| Alt+Tab | Switch apps |
|
||||
| Alt+F4 | Close window |
|
||||
|
||||
**Don't override these unless critical.**
|
||||
|
||||
---
|
||||
|
||||
## Testing Shortcuts
|
||||
|
||||
```kotlin
|
||||
// Test OS detection
|
||||
@Test
|
||||
fun testOsDetection() {
|
||||
val osName = System.getProperty("os.name")
|
||||
println("OS: $osName")
|
||||
|
||||
val isMacOS = osName.lowercase().contains("mac")
|
||||
println("Is macOS: $isMacOS")
|
||||
|
||||
val shortcut = if (isMacOS) {
|
||||
KeyShortcut(Key.N, meta = true)
|
||||
} else {
|
||||
KeyShortcut(Key.N, ctrl = true)
|
||||
}
|
||||
|
||||
println("Primary modifier for New: $shortcut")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current Issues in Amethyst
|
||||
|
||||
**Main.kt:105-123** hardcodes `ctrl = true`:
|
||||
|
||||
```kotlin
|
||||
// ❌ WRONG: Hardcoded Ctrl (doesn't work on macOS)
|
||||
Item(
|
||||
"New Note",
|
||||
shortcut = KeyShortcut(Key.N, ctrl = true), // Should be Cmd on macOS
|
||||
onClick = { /* ... */ }
|
||||
)
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
|
||||
```kotlin
|
||||
// ✅ CORRECT: OS-aware
|
||||
Item(
|
||||
"New Note",
|
||||
shortcut = DesktopShortcuts.primary(Key.N),
|
||||
onClick = { /* ... */ }
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [macOS Keyboard Shortcuts](https://support.apple.com/en-us/102650)
|
||||
- [Windows Keyboard Shortcuts](https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec)
|
||||
- [GNOME Keyboard Shortcuts](https://help.gnome.org/users/gnome-help/stable/shell-keyboard-shortcuts.html)
|
||||
- [Material Design: Keyboard Shortcuts](https://m3.material.io/foundations/interaction/keyboard)
|
||||
- [Compose Desktop: Keyboard Events](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-keyboard.html)
|
||||
@@ -0,0 +1,579 @@
|
||||
# OS Detection & Platform-Specific Code
|
||||
|
||||
Patterns for detecting operating system and implementing platform-specific behavior in Compose Desktop.
|
||||
|
||||
## OS Detection
|
||||
|
||||
### Basic Detection
|
||||
|
||||
```kotlin
|
||||
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")
|
||||
```
|
||||
|
||||
### System Properties
|
||||
|
||||
```kotlin
|
||||
// OS name
|
||||
System.getProperty("os.name")
|
||||
// Examples: "Mac OS X", "Windows 10", "Linux"
|
||||
|
||||
// OS version
|
||||
System.getProperty("os.version")
|
||||
// Examples: "14.2.1", "10.0", "6.5.0-14-generic"
|
||||
|
||||
// OS architecture
|
||||
System.getProperty("os.arch")
|
||||
// Examples: "aarch64", "x86_64", "amd64"
|
||||
|
||||
// User home directory
|
||||
System.getProperty("user.home")
|
||||
// Examples: "/Users/username", "C:\Users\username", "/home/username"
|
||||
|
||||
// File separator
|
||||
System.getProperty("file.separator")
|
||||
// Examples: "/" (Unix), "\" (Windows)
|
||||
|
||||
// Path separator
|
||||
System.getProperty("path.separator")
|
||||
// Examples: ":" (Unix), ";" (Windows)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PlatformDetector Utility
|
||||
|
||||
Create a centralized utility for platform detection.
|
||||
|
||||
**File:** `commons/src/jvmMain/kotlin/utils/PlatformDetector.kt`
|
||||
|
||||
```kotlin
|
||||
package com.vitorpamplona.amethyst.commons.utils
|
||||
|
||||
object PlatformDetector {
|
||||
private val osName = System.getProperty("os.name").lowercase()
|
||||
|
||||
val isMacOS: Boolean = osName.contains("mac")
|
||||
val isWindows: Boolean = osName.contains("win")
|
||||
val isLinux: Boolean = osName.contains("nux") || osName.contains("nix")
|
||||
|
||||
val platform: Platform = when {
|
||||
isMacOS -> Platform.MacOS
|
||||
isWindows -> Platform.Windows
|
||||
isLinux -> Platform.Linux
|
||||
else -> Platform.Unknown
|
||||
}
|
||||
|
||||
enum class Platform {
|
||||
MacOS,
|
||||
Windows,
|
||||
Linux,
|
||||
Unknown
|
||||
}
|
||||
|
||||
// File paths
|
||||
val fileSeparator: String = System.getProperty("file.separator")
|
||||
val pathSeparator: String = System.getProperty("path.separator")
|
||||
|
||||
// User directories
|
||||
val userHome: String = System.getProperty("user.home")
|
||||
|
||||
val appDataDir: String = when (platform) {
|
||||
Platform.MacOS -> "$userHome/Library/Application Support"
|
||||
Platform.Windows -> System.getenv("APPDATA") ?: "$userHome\\AppData\\Roaming"
|
||||
Platform.Linux -> System.getenv("XDG_CONFIG_HOME") ?: "$userHome/.config"
|
||||
Platform.Unknown -> userHome
|
||||
}
|
||||
|
||||
// Modifier key names
|
||||
val primaryModifierName: String = if (isMacOS) "Cmd" else "Ctrl"
|
||||
val secondaryModifierName: String = if (isMacOS) "Option" else "Alt"
|
||||
|
||||
fun platformSpecific(
|
||||
macOS: () -> Unit = {},
|
||||
windows: () -> Unit = {},
|
||||
linux: () -> Unit = {},
|
||||
fallback: () -> Unit = {}
|
||||
) {
|
||||
when (platform) {
|
||||
Platform.MacOS -> macOS()
|
||||
Platform.Windows -> windows()
|
||||
Platform.Linux -> linux()
|
||||
Platform.Unknown -> fallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```kotlin
|
||||
// Simple check
|
||||
if (PlatformDetector.isMacOS) {
|
||||
// macOS-specific code
|
||||
}
|
||||
|
||||
// Pattern matching
|
||||
when (PlatformDetector.platform) {
|
||||
Platform.MacOS -> setupMacDock()
|
||||
Platform.Windows -> setupWindowsTray()
|
||||
Platform.Linux -> setupLinuxTray()
|
||||
Platform.Unknown -> showWarning()
|
||||
}
|
||||
|
||||
// Platform-specific execution
|
||||
PlatformDetector.platformSpecific(
|
||||
macOS = { setupMacMenuBar() },
|
||||
windows = { setupWindowsMenu() },
|
||||
linux = { setupLinuxMenu() }
|
||||
)
|
||||
|
||||
// File paths
|
||||
val configPath = "${PlatformDetector.appDataDir}${PlatformDetector.fileSeparator}amethyst"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific UI
|
||||
|
||||
### Keyboard Shortcuts Helper
|
||||
|
||||
```kotlin
|
||||
package com.vitorpamplona.amethyst.commons.utils
|
||||
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyShortcut
|
||||
|
||||
object DesktopShortcuts {
|
||||
private val isMacOS = PlatformDetector.isMacOS
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun primaryAlt(key: Key) = if (isMacOS) {
|
||||
KeyShortcut(key, meta = true, alt = true)
|
||||
} else {
|
||||
KeyShortcut(key, ctrl = true, alt = true)
|
||||
}
|
||||
|
||||
val modifierName = PlatformDetector.primaryModifierName
|
||||
val secondaryName = PlatformDetector.secondaryModifierName
|
||||
|
||||
fun formatShortcut(key: String, withPrimary: Boolean = true): String {
|
||||
return if (withPrimary) "$modifierName+$key" else key
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File Paths Helper
|
||||
|
||||
```kotlin
|
||||
package com.vitorpamplona.amethyst.commons.utils
|
||||
|
||||
import java.io.File
|
||||
|
||||
object FilePaths {
|
||||
private val separator = PlatformDetector.fileSeparator
|
||||
|
||||
fun join(vararg parts: String): String {
|
||||
return parts.joinToString(separator)
|
||||
}
|
||||
|
||||
fun appConfig(appName: String): String {
|
||||
return join(PlatformDetector.appDataDir, appName)
|
||||
}
|
||||
|
||||
fun appCache(appName: String): String {
|
||||
return when (PlatformDetector.platform) {
|
||||
PlatformDetector.Platform.MacOS ->
|
||||
join(PlatformDetector.userHome, "Library", "Caches", appName)
|
||||
PlatformDetector.Platform.Windows ->
|
||||
join(System.getenv("LOCALAPPDATA") ?: "${PlatformDetector.userHome}\\AppData\\Local", appName)
|
||||
PlatformDetector.Platform.Linux ->
|
||||
join(System.getenv("XDG_CACHE_HOME") ?: "${PlatformDetector.userHome}/.cache", appName)
|
||||
else -> join(PlatformDetector.userHome, ".cache", appName)
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureDirectory(path: String): File {
|
||||
return File(path).apply {
|
||||
if (!exists()) {
|
||||
mkdirs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
val configDir = FilePaths.ensureDirectory(FilePaths.appConfig("amethyst"))
|
||||
val cacheDir = FilePaths.ensureDirectory(FilePaths.appCache("amethyst"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform-Specific Features
|
||||
|
||||
### Open External URL
|
||||
|
||||
```kotlin
|
||||
// commons/src/commonMain/kotlin/utils/ExternalUrl.kt
|
||||
expect fun openExternalUrl(url: String)
|
||||
|
||||
// commons/src/jvmMain/kotlin/utils/ExternalUrl.jvm.kt
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
|
||||
actual fun openExternalUrl(url: String) {
|
||||
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||
Desktop.getDesktop().browse(URI(url))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File Picker
|
||||
|
||||
```kotlin
|
||||
// Platform-specific file picker
|
||||
fun showFilePicker(
|
||||
title: String = "Select file",
|
||||
mode: FilePickerMode = FilePickerMode.Load
|
||||
): String? {
|
||||
val fileDialog = java.awt.FileDialog(
|
||||
java.awt.Frame(),
|
||||
title,
|
||||
when (mode) {
|
||||
FilePickerMode.Load -> java.awt.FileDialog.LOAD
|
||||
FilePickerMode.Save -> java.awt.FileDialog.SAVE
|
||||
}
|
||||
)
|
||||
|
||||
// macOS-specific: Enable file selection features
|
||||
if (PlatformDetector.isMacOS) {
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", "false")
|
||||
}
|
||||
|
||||
fileDialog.isVisible = true
|
||||
|
||||
return fileDialog.file?.let { "${fileDialog.directory}$it" }
|
||||
}
|
||||
|
||||
enum class FilePickerMode {
|
||||
Load,
|
||||
Save
|
||||
}
|
||||
```
|
||||
|
||||
### Directory Picker (macOS)
|
||||
|
||||
```kotlin
|
||||
fun showDirectoryPicker(title: String = "Select directory"): String? {
|
||||
if (PlatformDetector.isMacOS) {
|
||||
// macOS-specific directory picker
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", "true")
|
||||
}
|
||||
|
||||
val fileDialog = java.awt.FileDialog(java.awt.Frame(), title, java.awt.FileDialog.LOAD)
|
||||
fileDialog.isVisible = true
|
||||
|
||||
if (PlatformDetector.isMacOS) {
|
||||
System.setProperty("apple.awt.fileDialogForDirectories", "false")
|
||||
}
|
||||
|
||||
return fileDialog.directory
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Window Decorations
|
||||
|
||||
### macOS-Specific
|
||||
|
||||
```kotlin
|
||||
// Unified title bar (macOS Big Sur+)
|
||||
if (PlatformDetector.isMacOS) {
|
||||
Window(
|
||||
undecorated = false,
|
||||
transparent = true,
|
||||
// ...
|
||||
) {
|
||||
// Custom title bar
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Windows-Specific
|
||||
|
||||
```kotlin
|
||||
// Custom window chrome (Windows)
|
||||
if (PlatformDetector.isWindows) {
|
||||
Window(
|
||||
undecorated = true,
|
||||
// Custom decorations
|
||||
) {
|
||||
Column {
|
||||
// Custom title bar with min/max/close buttons
|
||||
WindowTitleBar()
|
||||
// Content
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## System Tray Icons
|
||||
|
||||
Different icon formats per OS:
|
||||
|
||||
```kotlin
|
||||
fun getTrayIcon(): Painter {
|
||||
return when (PlatformDetector.platform) {
|
||||
Platform.MacOS -> painterResource("tray-icon-mac.png") // Template icon
|
||||
Platform.Windows -> painterResource("tray-icon-win.ico")
|
||||
Platform.Linux -> painterResource("tray-icon-linux.png")
|
||||
else -> painterResource("tray-icon.png")
|
||||
}
|
||||
}
|
||||
|
||||
// macOS: Template icons (black/transparent)
|
||||
// Windows: ICO format, 16x16
|
||||
// Linux: PNG, typically 24x24
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Native Notifications
|
||||
|
||||
```kotlin
|
||||
// Platform-specific notification implementation
|
||||
fun sendNotification(title: String, message: String) {
|
||||
PlatformDetector.platformSpecific(
|
||||
macOS = {
|
||||
// macOS: Use NSUserNotification (via tray)
|
||||
trayState.sendNotification(
|
||||
Notification(title, message, Notification.Type.Info)
|
||||
)
|
||||
},
|
||||
windows = {
|
||||
// Windows: Use Windows toast notifications
|
||||
trayState.sendNotification(
|
||||
Notification(title, message, Notification.Type.Info)
|
||||
)
|
||||
},
|
||||
linux = {
|
||||
// Linux: Use libnotify (via tray)
|
||||
trayState.sendNotification(
|
||||
Notification(title, message, Notification.Type.Info)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Detection
|
||||
|
||||
```kotlin
|
||||
object ArchDetector {
|
||||
private val arch = System.getProperty("os.arch").lowercase()
|
||||
|
||||
val isArm: Boolean = arch.contains("aarch") || arch.contains("arm")
|
||||
val isX64: Boolean = arch.contains("x86_64") || arch.contains("amd64")
|
||||
val isX86: Boolean = arch.contains("x86") && !isX64
|
||||
|
||||
val architecture: Architecture = when {
|
||||
isArm -> Architecture.ARM
|
||||
isX64 -> Architecture.X64
|
||||
isX86 -> Architecture.X86
|
||||
else -> Architecture.Unknown
|
||||
}
|
||||
|
||||
enum class Architecture {
|
||||
ARM,
|
||||
X64,
|
||||
X86,
|
||||
Unknown
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: Load correct native library
|
||||
fun loadNativeLib() {
|
||||
val libName = when {
|
||||
PlatformDetector.isMacOS && ArchDetector.isArm -> "libsecp256k1-macos-arm64"
|
||||
PlatformDetector.isMacOS && ArchDetector.isX64 -> "libsecp256k1-macos-x64"
|
||||
PlatformDetector.isWindows && ArchDetector.isX64 -> "libsecp256k1-win-x64"
|
||||
PlatformDetector.isLinux && ArchDetector.isX64 -> "libsecp256k1-linux-x64"
|
||||
else -> throw UnsupportedOperationException("Unsupported platform")
|
||||
}
|
||||
|
||||
System.loadLibrary(libName)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Platform Detection
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun testPlatformDetection() {
|
||||
println("OS: ${System.getProperty("os.name")}")
|
||||
println("Version: ${System.getProperty("os.version")}")
|
||||
println("Arch: ${System.getProperty("os.arch")}")
|
||||
println()
|
||||
println("Is macOS: ${PlatformDetector.isMacOS}")
|
||||
println("Is Windows: ${PlatformDetector.isWindows}")
|
||||
println("Is Linux: ${PlatformDetector.isLinux}")
|
||||
println("Platform: ${PlatformDetector.platform}")
|
||||
println()
|
||||
println("User home: ${PlatformDetector.userHome}")
|
||||
println("App data: ${PlatformDetector.appDataDir}")
|
||||
println("File separator: ${PlatformDetector.fileSeparator}")
|
||||
}
|
||||
|
||||
// Example output (macOS):
|
||||
// OS: Mac OS X
|
||||
// Version: 14.2.1
|
||||
// Arch: aarch64
|
||||
//
|
||||
// Is macOS: true
|
||||
// Is Windows: false
|
||||
// Is Linux: false
|
||||
// Platform: MacOS
|
||||
//
|
||||
// User home: /Users/username
|
||||
// App data: /Users/username/Library/Application Support
|
||||
// File separator: /
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Centralize Detection
|
||||
|
||||
✅ **DO:** Use PlatformDetector singleton
|
||||
```kotlin
|
||||
if (PlatformDetector.isMacOS) { /* ... */ }
|
||||
```
|
||||
|
||||
❌ **DON'T:** Repeat detection everywhere
|
||||
```kotlin
|
||||
if (System.getProperty("os.name").lowercase().contains("mac")) { /* ... */ }
|
||||
```
|
||||
|
||||
### 2. Use expect/actual for Platform APIs
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect fun openFile(path: String)
|
||||
|
||||
// jvmMain (Desktop)
|
||||
actual fun openFile(path: String) {
|
||||
Desktop.getDesktop().open(File(path))
|
||||
}
|
||||
|
||||
// androidMain
|
||||
actual fun openFile(path: String) {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(path)))
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Graceful Degradation
|
||||
|
||||
```kotlin
|
||||
fun openBrowser(url: String) {
|
||||
try {
|
||||
if (Desktop.isDesktopSupported()) {
|
||||
Desktop.getDesktop().browse(URI(url))
|
||||
} else {
|
||||
// Fallback: Copy to clipboard
|
||||
Toolkit.getDefaultToolkit().systemClipboard.setContents(
|
||||
StringSelection(url),
|
||||
null
|
||||
)
|
||||
showMessage("URL copied to clipboard: $url")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showError("Failed to open browser: ${e.message}")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Test on All Platforms
|
||||
|
||||
Always test platform-specific code on:
|
||||
- macOS (Intel + Apple Silicon if possible)
|
||||
- Windows (10/11)
|
||||
- Linux (Ubuntu/Fedora)
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern: Config File Location
|
||||
|
||||
```kotlin
|
||||
fun getConfigFile(filename: String): File {
|
||||
val configDir = when (PlatformDetector.platform) {
|
||||
Platform.MacOS ->
|
||||
File("${PlatformDetector.userHome}/Library/Application Support/Amethyst")
|
||||
Platform.Windows ->
|
||||
File("${System.getenv("APPDATA")}\\Amethyst")
|
||||
Platform.Linux ->
|
||||
File("${PlatformDetector.userHome}/.config/amethyst")
|
||||
else ->
|
||||
File("${PlatformDetector.userHome}/.amethyst")
|
||||
}
|
||||
|
||||
if (!configDir.exists()) {
|
||||
configDir.mkdirs()
|
||||
}
|
||||
|
||||
return File(configDir, filename)
|
||||
}
|
||||
|
||||
// Usage
|
||||
val settingsFile = getConfigFile("settings.json")
|
||||
```
|
||||
|
||||
### Pattern: Platform-Specific Resources
|
||||
|
||||
```kotlin
|
||||
fun getPlatformIcon(name: String): Painter {
|
||||
val extension = when (PlatformDetector.platform) {
|
||||
Platform.MacOS -> "icns"
|
||||
Platform.Windows -> "ico"
|
||||
else -> "png"
|
||||
}
|
||||
|
||||
return painterResource("$name.$extension")
|
||||
}
|
||||
|
||||
// Resources:
|
||||
// src/jvmMain/resources/app-icon.icns (macOS)
|
||||
// src/jvmMain/resources/app-icon.ico (Windows)
|
||||
// src/jvmMain/resources/app-icon.png (Linux)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [System Properties (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/System.html#getProperties())
|
||||
- [Desktop API (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.desktop/java/awt/Desktop.html)
|
||||
- [File System Standards (XDG)](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)
|
||||
Reference in New Issue
Block a user