mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
5a287f7133
commit
0bc907ec32
13 changed files with 117 additions and 112 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue