diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModifierExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModifierExtensions.kt index 6140a6044..c00bb6c9e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModifierExtensions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModifierExtensions.kt @@ -16,7 +16,12 @@ */ package org.meshtastic.core.ui.util +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput /** * Conditionally applies the [action] to the receiver [Modifier] if [precondition] is true. Otherwise, returns the @@ -24,3 +29,18 @@ import androidx.compose.ui.Modifier */ inline fun Modifier.thenIf(precondition: Boolean, action: Modifier.() -> Modifier): Modifier = if (precondition) action() else this + +/** + * Adds a secondary (right) mouse-button click handler. On touch-only platforms the secondary button event never fires, + * so this is a safe no-op. Intended to mirror `onLongClick` behavior for desktop users who expect right-click context + * actions. + */ +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.onRightClick(action: () -> Unit): Modifier = pointerInput(action) { + awaitEachGesture { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Press && event.button == PointerButton.Secondary) { + action() + } + } +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 3b2585fe3..8dd8d1fd3 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -28,12 +28,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyShortcut import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.MenuBar import androidx.compose.ui.window.Notification import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Window @@ -49,7 +46,6 @@ import org.koin.core.context.startKoin import org.meshtastic.core.common.util.MeshtasticUri import org.meshtastic.core.datastore.UiPreferencesDataSource import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig -import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.service.MeshServiceOrchestrator import org.meshtastic.core.ui.theme.AppTheme @@ -201,56 +197,6 @@ fun main(args: Array) = application(exitProcessOnExit = false) { val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey) - MenuBar { - Menu("File") { - Item("Settings", shortcut = KeyShortcut(Key.Comma, meta = true)) { - if ( - TopLevelDestination.Settings != TopLevelDestination.fromNavKey(backStack.lastOrNull()) - ) { - backStack.add(TopLevelDestination.Settings.route) - while (backStack.size > 1) { - backStack.removeAt(0) - } - } - } - Separator() - Item("Quit", shortcut = KeyShortcut(Key.Q, meta = true)) { exitApplication() } - } - Menu("View") { - Item("Toggle Theme", shortcut = KeyShortcut(Key.T, meta = true, shift = true)) { - val newTheme = if (isDarkTheme) 1 else 2 // 1 = Light, 2 = Dark - uiPrefs.setTheme(newTheme) - } - } - Menu("Navigate") { - Item("Conversations", shortcut = KeyShortcut(Key.One, meta = true)) { - backStack.add(TopLevelDestination.Conversations.route) - while (backStack.size > 1) { - backStack.removeAt(0) - } - } - Item("Nodes", shortcut = KeyShortcut(Key.Two, meta = true)) { - backStack.add(TopLevelDestination.Nodes.route) - while (backStack.size > 1) { - backStack.removeAt(0) - } - } - Item("Map", shortcut = KeyShortcut(Key.Three, meta = true)) { - backStack.add(TopLevelDestination.Map.route) - while (backStack.size > 1) { - backStack.removeAt(0) - } - } - Item("Connections", shortcut = KeyShortcut(Key.Four, meta = true)) { - backStack.add(TopLevelDestination.Connections.route) - while (backStack.size > 1) { - backStack.removeAt(0) - } - } - } - Menu("Help") { Item("About") { backStack.add(SettingsRoutes.About) } } - } - // Providing localePref via a staticCompositionLocalOf forces the entire subtree to // recompose when the locale changes — CMP Resources' rememberResourceEnvironment then // re-reads Locale.current and all stringResource() calls update. Unlike key(), this diff --git a/docs/roadmap.md b/docs/roadmap.md index b97be24c1..6f026ae6f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -> Last updated: 2026-03-17 +> Last updated: 2026-03-22 Forward-looking priorities for the Meshtastic KMP multi-target effort. For current state, see [`kmp-status.md`](./kmp-status.md). For the full gap analysis, see [`decisions/architecture-review-2026-03.md`](./decisions/architecture-review-2026-03.md). @@ -42,7 +42,7 @@ These items address structural gaps identified in the March 2026 architecture re - Test navigation flows end-to-end 2. **Tier 2: Polish (High Priority)** - Additional desktop-specific settings polish - - ✅ **MenuBar integration** and Keyboard shortcuts + - ✅ **Right-click mirrors long-click** across all shared UI components (`Modifier.onRightClick` in `core:ui`) - Window management - State persistence 3. **Tier 3: Advanced (Nice-to-have)** @@ -74,7 +74,7 @@ These items address structural gaps identified in the March 2026 architecture re | Charts | ✅ Vico KMP charts wired in commonMain (Device, Environment, Signal, Power, Pax) | | Debug Panel | ✅ Real screen (mesh log viewer via shared `DebugViewModel`) | | Notifications | ✅ Desktop native notifications with system tray icon support | -| MenuBar | ✅ Done — Native application menu bar with File/View menus | +| Right-click UX | ✅ Done — `Modifier.onRightClick` mirrors long-click across all shared components | | About | ✅ Shared `commonMain` screen (AboutLibraries KMP `produceLibraries` + per-platform JSON) | | Packaging | ✅ Done — Native distribution pipeline in CI (DMG, MSI, DEB) | diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt index 9331cc909..48741446f 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt @@ -58,6 +58,7 @@ import org.meshtastic.core.resources.network import org.meshtastic.core.resources.serial import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.core.ui.component.Rssi +import org.meshtastic.core.ui.util.onRightClick import org.meshtastic.feature.connections.model.DeviceListEntry private const val RSSI_UPDATE_RATE_MS = 2000L @@ -113,7 +114,7 @@ fun DeviceListItem( val clickableModifier = if (onDelete != null) { - Modifier.combinedClickable(onClick = onSelect, onLongClick = onDelete) + Modifier.combinedClickable(onClick = onSelect, onLongClick = onDelete).onRightClick { onDelete() } } else { Modifier.clickable(onClick = onSelect) } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index 9a24b8a01..7a112c0cc 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -74,6 +74,7 @@ import org.meshtastic.core.ui.icon.Hops import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.theme.MessageItemColors import org.meshtastic.core.ui.util.createClipEntry +import org.meshtastic.core.ui.util.onRightClick @OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -241,6 +242,12 @@ fun MessageItem( }, onDoubleClick = onDoubleClick, ) + .onRightClick { + onLongClick() + if (!inSelectionMode) { + activeSheet = ActiveSheet.Actions + } + } .then(messageModifier) .semantics(mergeDescendants = true) { val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index d387222ff..76128875f 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -75,6 +75,7 @@ import org.meshtastic.core.ui.component.Snr import org.meshtastic.core.ui.emoji.EmojiPickerDialog import org.meshtastic.core.ui.icon.Hops import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.util.onRightClick import org.meshtastic.feature.messaging.DeliveryInfo @Composable @@ -93,6 +94,7 @@ internal fun ReactionItem( modifier = modifier .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .onRightClick(onLongClick) .then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier), color = when { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt index 00f518f0d..6ac64f966 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.model.Contact import org.meshtastic.core.ui.component.SecurityIcon +import org.meshtastic.core.ui.util.onRightClick import org.meshtastic.proto.ChannelSet @Suppress("LongMethod") @@ -88,6 +89,7 @@ fun ContactItem( modifier = modifier .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .onRightClick(onLongClick) .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 4.dp) .semantics { contentDescription = shortName }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt index b905b1887..cc8bfcc02 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt @@ -49,6 +49,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.copy import org.meshtastic.core.ui.util.createClipEntry +import org.meshtastic.core.ui.util.onRightClick import org.meshtastic.core.ui.util.thenIf @OptIn(ExperimentalFoundationApi::class) @@ -66,17 +67,20 @@ fun InfoCard( val shape = MaterialTheme.shapes.medium val copyLabel = stringResource(Res.string.copy) + val copyAction = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(value, text)) } } + Card( modifier = modifier .defaultMinSize(minHeight = 48.dp) .clip(shape) .combinedClickable( - onLongClick = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(value, text)) } }, + onLongClick = { copyAction() }, onLongClickLabel = copyLabel, onClick = {}, role = Role.Button, ) + .onRightClick { copyAction() } .semantics(mergeDescendants = true) { contentDescription = "$text: $value" }, shape = shape, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponents.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponents.kt index 3f79154a7..b86b174cd 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponents.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponents.kt @@ -54,6 +54,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.copy import org.meshtastic.core.ui.util.createClipEntry +import org.meshtastic.core.ui.util.onRightClick @Composable internal fun SectionCard( @@ -95,17 +96,20 @@ internal fun InfoItem( val coroutineScope = rememberCoroutineScope() val copyLabel = stringResource(Res.string.copy) + val copyAction = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(value, label)) } } + Column( modifier = modifier .fillMaxWidth() .defaultMinSize(minHeight = 48.dp) // Minimum touch target height .combinedClickable( - onLongClick = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(value, label)) } }, + onLongClick = { copyAction() }, onLongClickLabel = copyLabel, // Clear intent for accessibility onClick = {}, role = Role.Button, ) + .onRightClick { copyAction() } .padding(horizontal = 20.dp, vertical = 8.dp) .semantics(mergeDescendants = true) { // Screen readers read as a unified data unit diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index 95291e07c..5f3557826 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -89,6 +89,7 @@ import org.meshtastic.core.ui.icon.Verified import org.meshtastic.core.ui.icon.role import org.meshtastic.core.ui.util.createClipEntry import org.meshtastic.core.ui.util.formatAgo +import org.meshtastic.core.ui.util.onRightClick @Composable fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) { @@ -324,20 +325,23 @@ private fun PublicKeyItem(publicKeyBytes: ByteArray) { val label = stringResource(Res.string.public_key) val copyLabel = stringResource(Res.string.copy) + val copyAction = { + if (!isMismatch) { + coroutineScope.launch { clipboard.setClipEntry(createClipEntry(publicKeyBase64, label)) } + } + } + Column( modifier = Modifier.fillMaxWidth() .defaultMinSize(minHeight = 48.dp) .combinedClickable( - onLongClick = { - if (!isMismatch) { - coroutineScope.launch { clipboard.setClipEntry(createClipEntry(publicKeyBase64, label)) } - } - }, + onLongClick = { copyAction() }, onLongClickLabel = copyLabel, onClick = {}, role = Role.Button, ) + .onRightClick { copyAction() } .padding(horizontal = 20.dp, vertical = 8.dp) .semantics(mergeDescendants = true) { contentDescription = "$label: $publicKeyBase64" }, ) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index 15d317cae..6bc4d02a1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -89,6 +89,8 @@ import org.meshtastic.core.ui.component.determineSignalQuality import org.meshtastic.core.ui.icon.AirUtilization import org.meshtastic.core.ui.icon.ChannelUtilization import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.util.onRightClick +import org.meshtastic.core.ui.util.thenIf import org.meshtastic.proto.Config private const val ACTIVE_ALPHA = 0.5f @@ -153,7 +155,10 @@ fun NodeItem( Card(modifier = modifier.fillMaxWidth(), colors = cardColors) { Column( modifier = - Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick).fillMaxWidth().padding(12.dp), + Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick) + .thenIf(onLongClick != null) { onRightClick { onLongClick?.invoke() } } + .fillMaxWidth() + .padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { NodeItemHeader( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt index 218b271bc..e1cfe6d37 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt @@ -54,6 +54,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusYellow import org.meshtastic.core.ui.util.annotateNeighborInfo +import org.meshtastic.core.ui.util.onRightClick import org.meshtastic.feature.node.component.CooldownIconButton import org.meshtastic.feature.node.detail.NodeRequestEffect @@ -130,22 +131,26 @@ fun NeighborInfoLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewM text = "$time - $text", contentDescription = stringResource(Res.string.neighbor_info), modifier = - Modifier.combinedClickable(onLongClick = { expanded = true }) { - result - ?.fromRadio - ?.packet - ?.getNeighborInfoResponse(::getUsername, header = header) - ?.let { - val message = - annotateNeighborInfo( - it, - statusGreen = statusGreen, - statusYellow = statusYellow, - statusOrange = statusOrange, - ) - viewModel.showLogDetail(Res.string.neighbor_info, message) - } - }, + Modifier.combinedClickable( + onLongClick = { expanded = true }, + onClick = { + result + ?.fromRadio + ?.packet + ?.getNeighborInfoResponse(::getUsername, header = header) + ?.let { + val message = + annotateNeighborInfo( + it, + statusGreen = statusGreen, + statusYellow = statusYellow, + statusOrange = statusOrange, + ) + viewModel.showLogDetail(Res.string.neighbor_info, message) + } + }, + ) + .onRightClick { expanded = true }, ) DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DeleteItem { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index e5321773b..45fbdb61a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -70,6 +70,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusYellow import org.meshtastic.core.ui.util.annotateTraceroute +import org.meshtastic.core.ui.util.onRightClick import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.node.component.CooldownIconButton import org.meshtastic.feature.node.detail.NodeRequestEffect @@ -196,37 +197,41 @@ fun TracerouteLogScreen( text = stringResource(Res.string.traceroute_time_and_text, time, text), contentDescription = stringResource(Res.string.traceroute), modifier = - Modifier.combinedClickable(onLongClick = { expanded = true }) { - val dialogMessage = - tracerouteDetailsAnnotated - ?: result - ?.fromRadio - ?.packet - ?.getTracerouteResponse( - ::getUsername, - headerTowards = headerTowardsStr, - headerBack = headerBackStr, - ) - ?.let { - annotateTraceroute( - it, - statusGreen = statusGreen, - statusYellow = statusYellow, - statusOrange = statusOrange, + Modifier.combinedClickable( + onLongClick = { expanded = true }, + onClick = { + val dialogMessage = + tracerouteDetailsAnnotated + ?: result + ?.fromRadio + ?.packet + ?.getTracerouteResponse( + ::getUsername, + headerTowards = headerTowardsStr, + headerBack = headerBackStr, ) - } - dialogMessage?.let { - val responseLogUuid = result?.uuid ?: return@combinedClickable - viewModel.showTracerouteDetail( - annotatedMessage = it, - requestId = log.fromRadio.packet?.id ?: 0, - responseLogUuid = responseLogUuid, - overlay = overlay, - onViewOnMap = onViewOnMap, - onShowError = { /* Handle error */ }, - ) - } - }, + ?.let { + annotateTraceroute( + it, + statusGreen = statusGreen, + statusYellow = statusYellow, + statusOrange = statusOrange, + ) + } + dialogMessage?.let { + val responseLogUuid = result?.uuid ?: return@combinedClickable + viewModel.showTracerouteDetail( + annotatedMessage = it, + requestId = log.fromRadio.packet?.id ?: 0, + responseLogUuid = responseLogUuid, + overlay = overlay, + onViewOnMap = onViewOnMap, + onShowError = { /* Handle error */ }, + ) + } + }, + ) + .onRightClick { expanded = true }, ) DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DeleteItem {