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

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