feat: implement right-click as mirror for long-click across shared UI

This commit introduces a new `Modifier.onRightClick` extension to improve desktop usability by mapping secondary mouse clicks to existing long-click actions. It also removes the legacy desktop `MenuBar` implementation in favor of this more consistent cross-platform UX approach.

Specific changes include:
- **Core UI**: Added `Modifier.onRightClick` in `ModifierExtensions.kt` using `PointerButton.Secondary` to detect right-clicks on desktop platforms.
- **Desktop**: Removed the `MenuBar` and associated keyboard shortcuts from the main entry point.
- **Messaging Feature**: Added right-click support to `MessageItem`, `Reaction`, and `ContactItem`.
- **Node Feature**:
    - Added right-click support to `NodeItem`, `NodeDetailsSection`, `NodeDetailComponents`, and `InfoCard` to trigger copy actions or context menus.
    - Updated `TracerouteLog`, `NeighborInfoLog`, and `NodeItem` to handle right-click events.
- **Connections Feature**: Updated `DeviceListItem` to support right-click for deletion/context actions.
- **Documentation**: Updated `roadmap.md` to reflect the completion of right-click UX integration.

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-22 11:48:46 -05:00
parent 5a287f7133
commit 0bc907ec32
13 changed files with 117 additions and 112 deletions

View file

@ -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()
}
}
}

View file

@ -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<String>) = 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

View file

@ -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) |

View file

@ -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)
}

View file

@ -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

View file

@ -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 {

View file

@ -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 },

View file

@ -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),

View file

@ -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

View file

@ -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" },
) {

View file

@ -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(

View file

@ -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 {

View file

@ -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 {