From f5effdfed7480792a91808bc6feed5ffd44cd6fb Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 4 Jul 2026 09:47:56 +0100 Subject: [PATCH 1/2] Add PC alphabet shortcut grid - Add reusable collapsible section UI for expandable command groups - Add an A-Z shortcut grid driven by active PC modifier toggles - Extend PC shortcut key modeling and tests for alphabet shortcuts Auto-generated --- .../components/CollapsibleSection.kt | 105 ++++++++++++++++++ .../enaboapps/switchify/pc/PcKeyboardKey.kt | 67 ++++++++++- .../screens/pc/PcMouseCommandGrid.kt | 74 +++++++++++- .../screens/pc/PcMouseControlActivity.kt | 1 + .../screens/pc/PcMouseControlViewModel.kt | 19 ++++ .../screens/pc/PcWindowControlScreen.kt | 95 ++++++++++------ app/src/main/res/values/strings.xml | 5 +- .../enaboapps/switchify/pc/PcProtocolTest.kt | 58 ++++++++++ .../pc/bluetooth/SwitchifyPcBleClientTest.kt | 12 +- .../screens/pc/PcMouseControlViewModelTest.kt | 63 +++++++++++ .../screens/pc/PcWindowControlScreenTest.kt | 49 ++++---- 11 files changed, 485 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/com/enaboapps/switchify/components/CollapsibleSection.kt diff --git a/app/src/main/java/com/enaboapps/switchify/components/CollapsibleSection.kt b/app/src/main/java/com/enaboapps/switchify/components/CollapsibleSection.kt new file mode 100644 index 00000000..4cd24daf --- /dev/null +++ b/app/src/main/java/com/enaboapps/switchify/components/CollapsibleSection.kt @@ -0,0 +1,105 @@ +package com.enaboapps.switchify.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun CollapsibleSection( + title: String, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + subtitle: String? = null, + content: @Composable ColumnScope.() -> Unit +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = enabled, + role = Role.Button, + onClick = { onExpandedChange(!expanded) } + ) + .semantics { + stateDescription = if (expanded) "Expanded" else "Collapsed" + } + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant + ) + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Icon( + imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + AnimatedVisibility( + visible = expanded, + enter = fadeIn(animationSpec = tween(180)) + expandVertically(animationSpec = tween(180)), + exit = fadeOut(animationSpec = tween(120)) + shrinkVertically(animationSpec = tween(120)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = content + ) + } + } + } +} diff --git a/app/src/main/java/com/enaboapps/switchify/pc/PcKeyboardKey.kt b/app/src/main/java/com/enaboapps/switchify/pc/PcKeyboardKey.kt index db611aee..8112a7ac 100644 --- a/app/src/main/java/com/enaboapps/switchify/pc/PcKeyboardKey.kt +++ b/app/src/main/java/com/enaboapps/switchify/pc/PcKeyboardKey.kt @@ -37,11 +37,36 @@ enum class PcKeyboardKey( } enum class PcKeyboardShortcutKey(val protocolValue: String) { - Meta("Meta"), Ctrl("Ctrl"), + Alt("Alt"), + Shift("Shift"), + Meta("Meta"), A("A"), + B("B"), C("C"), - X("X") + D("D"), + E("E"), + F("F"), + G("G"), + H("H"), + I("I"), + J("J"), + K("K"), + L("L"), + M("M"), + N("N"), + O("O"), + P("P"), + Q("Q"), + R("R"), + S("S"), + T("T"), + U("U"), + V("V"), + W("W"), + X("X"), + Y("Y"), + Z("Z") } enum class PcKeyboardModifierKey( @@ -53,3 +78,41 @@ enum class PcKeyboardModifierKey( Shift("Shift", R.string.pc_modifier_shift), Meta("Meta", R.string.pc_modifier_start) } + +fun PcKeyboardModifierKey.toShortcutKey(): PcKeyboardShortcutKey { + return when (this) { + PcKeyboardModifierKey.Ctrl -> PcKeyboardShortcutKey.Ctrl + PcKeyboardModifierKey.Alt -> PcKeyboardShortcutKey.Alt + PcKeyboardModifierKey.Shift -> PcKeyboardShortcutKey.Shift + PcKeyboardModifierKey.Meta -> PcKeyboardShortcutKey.Meta + } +} + +val PC_SHORTCUT_LETTER_KEYS: List = listOf( + PcKeyboardShortcutKey.A, + PcKeyboardShortcutKey.B, + PcKeyboardShortcutKey.C, + PcKeyboardShortcutKey.D, + PcKeyboardShortcutKey.E, + PcKeyboardShortcutKey.F, + PcKeyboardShortcutKey.G, + PcKeyboardShortcutKey.H, + PcKeyboardShortcutKey.I, + PcKeyboardShortcutKey.J, + PcKeyboardShortcutKey.K, + PcKeyboardShortcutKey.L, + PcKeyboardShortcutKey.M, + PcKeyboardShortcutKey.N, + PcKeyboardShortcutKey.O, + PcKeyboardShortcutKey.P, + PcKeyboardShortcutKey.Q, + PcKeyboardShortcutKey.R, + PcKeyboardShortcutKey.S, + PcKeyboardShortcutKey.T, + PcKeyboardShortcutKey.U, + PcKeyboardShortcutKey.V, + PcKeyboardShortcutKey.W, + PcKeyboardShortcutKey.X, + PcKeyboardShortcutKey.Y, + PcKeyboardShortcutKey.Z +) diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt index 57075fe5..f68b1b7d 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt @@ -78,6 +78,13 @@ data class PcCompactCommandCell( val selected: Boolean = false ) +data class PcCompactTextCommandCell( + val label: String, + val enabled: Boolean, + val onClick: () -> Unit, + val selected: Boolean = false +) + /** * Small connection indicator shown in the navbar next to the surface switcher. * Non-interactive; the connection state is exposed through semantics so speech @@ -224,6 +231,44 @@ fun PcCompactCommandGrid( } } +@Composable +fun PcCompactTextCommandGrid( + columns: Int, + minTileHeightDp: Int, + horizontalGapDp: Int = 8, + verticalGapDp: Int = 8, + cells: List, + modifier: Modifier = Modifier +) { + require(columns > 0) + val horizontalGap = horizontalGapDp.dp + val verticalGap = verticalGapDp.dp + + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val tileWidth = (maxWidth - horizontalGap * (columns - 1)) / columns + Column(verticalArrangement = Arrangement.spacedBy(verticalGap)) { + cells.chunked(columns).forEach { rowCells -> + EqualHeightGridRow( + items = rowCells, + columns = columns, + itemWidth = tileWidth, + minItemHeight = minTileHeightDp.dp, + horizontalGap = horizontalGap + ) { cell, itemModifier -> + PcScannedCommandTile( + label = cell.label, + enabled = cell.enabled, + onClick = cell.onClick, + selected = cell.selected, + minHeightDp = minTileHeightDp, + modifier = itemModifier + ) + } + } + } + } +} + @Composable private fun PcCommandSectionTitle(@StringRes titleResId: Int) { Text( @@ -245,6 +290,33 @@ fun PcScannedCommandTile( selected: Boolean = false, minHeightDp: Int = 52, square: Boolean = false +) { + PcScannedCommandTile( + label = stringResource(labelResId), + enabled = enabled, + onClick = onClick, + modifier = modifier, + icon = icon, + iconRotationDegrees = iconRotationDegrees, + tone = tone, + selected = selected, + minHeightDp = minHeightDp, + square = square + ) +} + +@Composable +fun PcScannedCommandTile( + label: String, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + iconRotationDegrees: Float = 0f, + tone: PcCommandTone = PcCommandTone.Neutral, + selected: Boolean = false, + minHeightDp: Int = 52, + square: Boolean = false ) { val interactionSource = remember { MutableInteractionSource() } val pressed by interactionSource.collectIsPressedAsState() @@ -321,7 +393,7 @@ fun PcScannedCommandTile( ) } Text( - text = stringResource(labelResId), + text = label, style = MaterialTheme.typography.labelLarge, fontWeight = if (tone == PcCommandTone.Primary) FontWeight.SemiBold else FontWeight.Medium, color = contentColor, diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt index 19dda890..63080854 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt @@ -184,6 +184,7 @@ private fun PcMouseControlScreen( enabled = surfaceEnabled, activeModifiers = uiState.activeModifiers, onModifierSelected = viewModel::toggleModifier, + onShortcutLetterSelected = viewModel::sendShortcutLetter, onCommandSelected = viewModel::send ) } diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt index cd7813f2..4f626d5a 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt @@ -14,6 +14,7 @@ import com.enaboapps.switchify.pc.PcControlCommand import com.enaboapps.switchify.pc.PcErrorReason import com.enaboapps.switchify.pc.PcKeyboardKey import com.enaboapps.switchify.pc.PcKeyboardModifierKey +import com.enaboapps.switchify.pc.PcKeyboardShortcutKey import com.enaboapps.switchify.pc.PcMouseRepeatManager import com.enaboapps.switchify.pc.PcServiceConnectResult import com.enaboapps.switchify.pc.PcServiceConnectionController @@ -23,6 +24,7 @@ import com.enaboapps.switchify.pc.isSafePcTypedText import com.enaboapps.switchify.pc.pcTextStreamItemsFor import com.enaboapps.switchify.pc.supportsModifierToggle import com.enaboapps.switchify.pc.supportsTextStreams +import com.enaboapps.switchify.pc.toShortcutKey import com.enaboapps.switchify.service.core.ServiceCore import java.util.UUID import kotlinx.coroutines.CoroutineStart @@ -174,6 +176,22 @@ class PcMouseControlViewModel( } } + fun sendShortcutLetter(letter: PcKeyboardShortcutKey) { + val modifiers = orderedShortcutModifiers(_uiState.value.activeModifiers) + if (modifiers.isEmpty()) { + _uiState.update { it.copy(message = SELECT_SHORTCUT_MODIFIER_MESSAGE) } + return + } + val keys = modifiers.map { it.toShortcutKey() } + letter + sendNoAckCommand(PcControlCommand.KeyboardShortcut(keys)) { + it.copy( + isBusy = false, + busyCommand = null, + message = null + ) + } + } + fun sendMouseCommand(command: PcControlCommand, repeatable: Boolean) { val mouseRepeat = currentMouseRepeatCapabilities() if (repeatable && mouseRepeat?.supported == true) { @@ -1086,6 +1104,7 @@ class PcMouseControlViewModel( const val KEY_FAILED_MESSAGE = "Could not send key to PC." const val TEXT_TOO_LONG_MESSAGE = "Text is too long." const val TEXT_UNSUPPORTED_MESSAGE = "Text includes unsupported characters." + const val SELECT_SHORTCUT_MODIFIER_MESSAGE = "Choose Ctrl, Alt, Shift, or Start first." const val TEXT_STREAM_SEND_DELAY_MS = 250L const val TEXT_STREAM_RECONNECT_TIMEOUT_MS = 15_000L const val TEXT_STREAM_RECONNECT_RETRY_LIMIT = 3 diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcWindowControlScreen.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcWindowControlScreen.kt index c799378d..1fdd5197 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcWindowControlScreen.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcWindowControlScreen.kt @@ -14,11 +14,17 @@ import androidx.compose.material.icons.rounded.Computer import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.enaboapps.switchify.R +import com.enaboapps.switchify.components.CollapsibleSection +import com.enaboapps.switchify.pc.PC_SHORTCUT_LETTER_KEYS import com.enaboapps.switchify.pc.PcControlCommand import com.enaboapps.switchify.pc.PcKeyboardKey import com.enaboapps.switchify.pc.PcKeyboardModifierKey @@ -37,6 +43,7 @@ fun PcWindowControlScreen( enabled: Boolean, activeModifiers: Set, onModifierSelected: (PcKeyboardModifierKey) -> Unit, + onShortcutLetterSelected: (PcKeyboardShortcutKey) -> Unit, onCommandSelected: (PcControlCommand) -> Unit, modifier: Modifier = Modifier ) { @@ -81,23 +88,10 @@ fun PcWindowControlScreen( ) } ) - Text( - text = stringResource(R.string.pc_window_section_shortcuts), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - PcCompactCommandGrid( - columns = 3, - minTileHeightDp = 52, - cells = pcWindowShortcutSpecs().map { spec -> - PcCompactCommandCell( - labelResId = spec.labelResId, - enabled = enabled, - onClick = { onCommandSelected(spec.command) }, - icon = spec.icon, - tone = spec.tone - ) - } + PcShortcutAlphabetAccordion( + enabled = enabled, + activeModifiers = activeModifiers, + onShortcutLetterSelected = onShortcutLetterSelected ) PcKeyboardNavigationCluster( enabled = enabled, @@ -108,6 +102,43 @@ fun PcWindowControlScreen( } } +@Composable +fun PcShortcutAlphabetAccordion( + enabled: Boolean, + activeModifiers: Set, + onShortcutLetterSelected: (PcKeyboardShortcutKey) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by rememberSaveable { mutableStateOf(false) } + val orderedModifiers = orderedShortcutModifiers(activeModifiers) + val modifierLabels = orderedModifiers.map { stringResource(it.labelResId) }.joinToString(" + ") + val subtitle = if (orderedModifiers.isEmpty()) { + stringResource(R.string.pc_window_shortcuts_no_modifier) + } else { + stringResource(R.string.pc_window_shortcuts_with_modifiers, modifierLabels) + } + CollapsibleSection( + title = stringResource(R.string.pc_window_section_shortcuts), + subtitle = subtitle, + expanded = expanded, + onExpandedChange = { expanded = it }, + enabled = enabled, + modifier = modifier + ) { + PcCompactTextCommandGrid( + columns = 6, + minTileHeightDp = 44, + cells = pcWindowShortcutLetterSpecs().map { key -> + PcCompactTextCommandCell( + label = key.protocolValue, + enabled = enabled && orderedModifiers.isNotEmpty(), + onClick = { onShortcutLetterSelected(key) } + ) + } + ) + } +} + data class PcWindowModifierSpec( @param:StringRes val labelResId: Int, val key: PcKeyboardModifierKey @@ -122,25 +153,21 @@ fun pcWindowModifierSpecs(): List { ) } -fun pcWindowCompactControlSpecs(): List { - return pcWindowControlSpecs() + listOf(null) +fun orderedShortcutModifiers(activeModifiers: Set): List { + return listOf( + PcKeyboardModifierKey.Ctrl, + PcKeyboardModifierKey.Alt, + PcKeyboardModifierKey.Shift, + PcKeyboardModifierKey.Meta + ).filter { activeModifiers.contains(it) } } -fun pcWindowShortcutSpecs(): List { - return listOf( - PcWindowControlSpec( - R.string.pc_shortcut_select_all, - PcControlCommand.KeyboardShortcut(listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.A)) - ), - PcWindowControlSpec( - R.string.pc_shortcut_copy, - PcControlCommand.KeyboardShortcut(listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.C)) - ), - PcWindowControlSpec( - R.string.pc_shortcut_cut, - PcControlCommand.KeyboardShortcut(listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.X)) - ) - ) +fun pcWindowShortcutLetterSpecs(): List { + return PC_SHORTCUT_LETTER_KEYS +} + +fun pcWindowCompactControlSpecs(): List { + return pcWindowControlSpecs() + listOf(null) } fun pcWindowControlSpecs(): List { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d50abb9b..a2c39671 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -425,9 +425,8 @@ Close app Modifiers Shortcuts - Select all - Copy - Cut + Choose Ctrl, Alt, Shift, or Start first. + Send with %1$s. Ctrl Alt Shift diff --git a/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt b/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt index 60f40c09..a72233da 100644 --- a/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/pc/PcProtocolTest.kt @@ -549,6 +549,60 @@ class PcProtocolTest { assertFalse(json.toString().contains("shared-token")) } + @Test + fun shortcutKeysIncludeFullAlphabetInOrder() { + assertEquals( + ('A'..'Z').map { it.toString() }, + PC_SHORTCUT_LETTER_KEYS.map { it.protocolValue } + ) + } + + @Test + fun buildsMultiModifierAlphabetShortcutCommands() { + val ctrlShiftA = JSONObject( + PcProtocol.keyboardShortcut( + id = "shortcut-ctrl-shift-a", + deviceId = "device-1", + token = "shared-token", + timestamp = 1000L, + keys = listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.Shift, PcKeyboardShortcutKey.A) + ) + ) + val altF = JSONObject( + PcProtocol.keyboardShortcut( + id = "shortcut-alt-f", + deviceId = "device-1", + token = "shared-token", + timestamp = 1000L, + keys = listOf(PcKeyboardShortcutKey.Alt, PcKeyboardShortcutKey.F) + ) + ) + val ctrlZ = JSONObject( + PcProtocol.keyboardShortcut( + id = "shortcut-ctrl-z", + deviceId = "device-1", + token = "shared-token", + timestamp = 1000L, + keys = listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.Z) + ) + ) + + assertEquals(listOf("Ctrl", "Shift", "A"), ctrlShiftA.getJSONObject("payload").getJSONArray("keys").asStringList()) + assertEquals(listOf("Alt", "F"), altF.getJSONObject("payload").getJSONArray("keys").asStringList()) + assertEquals(listOf("Ctrl", "Z"), ctrlZ.getJSONObject("payload").getJSONArray("keys").asStringList()) + assertEquals( + PcProtocol.authProof( + id = "shortcut-ctrl-shift-a", + deviceId = "device-1", + timestamp = 1000L, + type = "keyboard.shortcut", + payload = JSONObject().put("keys", JSONArray(listOf("Ctrl", "Shift", "A"))), + token = "shared-token" + ), + ctrlShiftA.getString("auth") + ) + } + @Test fun buildsKeyboardModifierCommandsWithAuthProof() { val down = JSONObject( @@ -801,3 +855,7 @@ class PcProtocolTest { """.trimIndent() } } + +private fun JSONArray.asStringList(): List { + return (0 until length()).map { index -> getString(index) } +} diff --git a/app/src/test/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClientTest.kt b/app/src/test/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClientTest.kt index b717250a..cb467f1a 100644 --- a/app/src/test/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClientTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/pc/bluetooth/SwitchifyPcBleClientTest.kt @@ -311,14 +311,24 @@ class SwitchifyPcBleClientTest { PcControlCommand.KeyboardShortcut(listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.X)) ) ) + assertEquals( + PcCommandResult.Ack, + client.sendCommand( + session, + PcControlCommand.KeyboardShortcut(listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.Shift, PcKeyboardShortcutKey.Z)) + ) + ) - assertEquals(listOf("keyboard.shortcut", "keyboard.shortcut", "keyboard.shortcut"), messages.map { it.getString("type") }) + assertEquals(listOf("keyboard.shortcut", "keyboard.shortcut", "keyboard.shortcut", "keyboard.shortcut"), messages.map { it.getString("type") }) assertEquals("Ctrl", messages[0].getJSONObject("payload").getJSONArray("keys").getString(0)) assertEquals("A", messages[0].getJSONObject("payload").getJSONArray("keys").getString(1)) assertEquals("Ctrl", messages[1].getJSONObject("payload").getJSONArray("keys").getString(0)) assertEquals("C", messages[1].getJSONObject("payload").getJSONArray("keys").getString(1)) assertEquals("Ctrl", messages[2].getJSONObject("payload").getJSONArray("keys").getString(0)) assertEquals("X", messages[2].getJSONObject("payload").getJSONArray("keys").getString(1)) + assertEquals("Ctrl", messages[3].getJSONObject("payload").getJSONArray("keys").getString(0)) + assertEquals("Shift", messages[3].getJSONObject("payload").getJSONArray("keys").getString(1)) + assertEquals("Z", messages[3].getJSONObject("payload").getJSONArray("keys").getString(2)) } @Test diff --git a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt index 5aea425e..9f490ab2 100644 --- a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt @@ -1167,6 +1167,69 @@ class PcMouseControlViewModelTest { assertNull(viewModel.uiState.value.message) } + @Test + fun shortcutLetterWithoutActiveModifiersDoesNotSendCommand() = runTest(dispatcher) { + val connector = FakeConnector() + val controller = connectedController(connector = connector) + val viewModel = viewModel(controller) + + viewModel.sendShortcutLetter(PcKeyboardShortcutKey.C) + advanceUntilIdle() + + assertTrue(connector.realtimeCommands.isEmpty()) + assertEquals(PcMouseControlViewModel.SELECT_SHORTCUT_MODIFIER_MESSAGE, viewModel.uiState.value.message) + } + + @Test + fun shortcutLetterSendsWithActiveModifiersInStableOrder() = runTest(dispatcher) { + val connector = FakeConnector() + val controller = connectedController(connector = connector) + val viewModel = viewModel(controller) + advanceUntilIdle() + + viewModel.toggleModifier(PcKeyboardModifierKey.Shift) + advanceUntilIdle() + viewModel.toggleModifier(PcKeyboardModifierKey.Ctrl) + advanceUntilIdle() + viewModel.sendShortcutLetter(PcKeyboardShortcutKey.Z) + advanceUntilIdle() + + assertEquals( + listOf( + PcControlCommand.ModifierDown(PcKeyboardModifierKey.Shift), + PcControlCommand.ModifierDown(PcKeyboardModifierKey.Ctrl), + PcControlCommand.KeyboardShortcut( + listOf( + PcKeyboardShortcutKey.Ctrl, + PcKeyboardShortcutKey.Shift, + PcKeyboardShortcutKey.Z + ) + ) + ), + connector.realtimeCommands + ) + assertEquals(setOf(PcKeyboardModifierKey.Shift, PcKeyboardModifierKey.Ctrl), viewModel.uiState.value.activeModifiers) + assertNull(viewModel.uiState.value.message) + } + + @Test + fun failedShortcutLetterDoesNotClearActiveModifiers() = runTest(dispatcher) { + val connector = FakeConnector( + realtimeResults = mutableListOf(PcCommandResult.Ack, PcCommandResult.Failed()) + ) + val controller = connectedController(connector = connector) + val viewModel = viewModel(controller) + advanceUntilIdle() + + viewModel.toggleModifier(PcKeyboardModifierKey.Ctrl) + advanceUntilIdle() + viewModel.sendShortcutLetter(PcKeyboardShortcutKey.C) + advanceUntilIdle() + + assertEquals(setOf(PcKeyboardModifierKey.Ctrl), viewModel.uiState.value.activeModifiers) + assertEquals(PcMouseControlViewModel.COMMAND_FAILED_MESSAGE, viewModel.uiState.value.message) + } + @Test fun controlCommandDoesNotSetBusy() = runTest(dispatcher) { val connector = FakeConnector() diff --git a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcWindowControlScreenTest.kt b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcWindowControlScreenTest.kt index 6f010db8..ec1a5d31 100644 --- a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcWindowControlScreenTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcWindowControlScreenTest.kt @@ -6,6 +6,7 @@ import com.enaboapps.switchify.pc.PcKeyboardKey import com.enaboapps.switchify.pc.PcKeyboardModifierKey import com.enaboapps.switchify.pc.PcKeyboardShortcutKey import com.enaboapps.switchify.pc.PcWindowControlAction +import com.enaboapps.switchify.pc.toShortcutKey import org.junit.Assert.assertEquals import org.junit.Test @@ -63,16 +64,10 @@ class PcWindowControlScreenTest { } @Test - fun windowShortcutCommandsUseStableOrder() { - val commands = pcWindowShortcutSpecs().map { it.command } - + fun shortcutLetterSpecsUseAlphabeticalOrder() { assertEquals( - listOf( - PcControlCommand.KeyboardShortcut(listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.A)), - PcControlCommand.KeyboardShortcut(listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.C)), - PcControlCommand.KeyboardShortcut(listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.X)) - ), - commands + ('A'..'Z').map { it.toString() }, + pcWindowShortcutLetterSpecs().map { it.protocolValue } ) } @@ -101,23 +96,33 @@ class PcWindowControlScreenTest { } @Test - fun windowShortcutLabelsMatchActions() { - val specs = pcWindowShortcutSpecs() - - assertEquals(R.string.pc_shortcut_select_all, specs[0].labelResId) - assertEquals( - PcControlCommand.KeyboardShortcut(listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.A)), - specs[0].command + fun shortcutModifiersUseStableOrderAndMapping() { + val modifiers = orderedShortcutModifiers( + setOf( + PcKeyboardModifierKey.Meta, + PcKeyboardModifierKey.Shift, + PcKeyboardModifierKey.Ctrl, + PcKeyboardModifierKey.Alt + ) ) - assertEquals(R.string.pc_shortcut_copy, specs[1].labelResId) + assertEquals( - PcControlCommand.KeyboardShortcut(listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.C)), - specs[1].command + listOf( + PcKeyboardModifierKey.Ctrl, + PcKeyboardModifierKey.Alt, + PcKeyboardModifierKey.Shift, + PcKeyboardModifierKey.Meta + ), + modifiers ) - assertEquals(R.string.pc_shortcut_cut, specs[2].labelResId) assertEquals( - PcControlCommand.KeyboardShortcut(listOf(PcKeyboardShortcutKey.Ctrl, PcKeyboardShortcutKey.X)), - specs[2].command + listOf( + PcKeyboardShortcutKey.Ctrl, + PcKeyboardShortcutKey.Alt, + PcKeyboardShortcutKey.Shift, + PcKeyboardShortcutKey.Meta + ), + modifiers.map { it.toShortcutKey() } ) } From a7915a4a517dad5263e17b0ce749b2b738885239 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 4 Jul 2026 10:20:02 +0100 Subject: [PATCH 2/2] Release modifiers after shortcut letters - Release active PC modifier toggles after a successful alphabet shortcut - Send modifier-up commands in stable reverse modifier order - Update ViewModel coverage for the temporary shortcut chord behavior Auto-generated --- .../screens/pc/PcMouseControlViewModel.kt | 20 ++++++++++++------- .../screens/pc/PcMouseControlViewModelTest.kt | 6 ++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt index 4f626d5a..d421a4ea 100644 --- a/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt +++ b/app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt @@ -183,12 +183,18 @@ class PcMouseControlViewModel( return } val keys = modifiers.map { it.toShortcutKey() } + letter - sendNoAckCommand(PcControlCommand.KeyboardShortcut(keys)) { - it.copy( - isBusy = false, - busyCommand = null, - message = null - ) + viewModelScope.launch { + when (sendNoAckCommandNow(PcControlCommand.KeyboardShortcut(keys)) { + it.copy( + isBusy = false, + busyCommand = null, + message = null + ) + }) { + PcCommandResult.Ack -> releaseActiveModifiersIfPossible() + is PcCommandResult.AuthFailed, + is PcCommandResult.Failed -> Unit + } } } @@ -492,7 +498,7 @@ class PcMouseControlViewModel( } private fun releaseActiveModifiersIfPossible() { - val modifiers = _uiState.value.activeModifiers.toList() + val modifiers = orderedShortcutModifiers(_uiState.value.activeModifiers).asReversed() if (modifiers.isEmpty()) return _uiState.update { it.copy(activeModifiers = emptySet()) } val controller = serviceControllerProvider() diff --git a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt index 9f490ab2..45fa0969 100644 --- a/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt +++ b/app/src/test/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModelTest.kt @@ -1204,11 +1204,13 @@ class PcMouseControlViewModelTest { PcKeyboardShortcutKey.Shift, PcKeyboardShortcutKey.Z ) - ) + ), + PcControlCommand.ModifierUp(PcKeyboardModifierKey.Shift), + PcControlCommand.ModifierUp(PcKeyboardModifierKey.Ctrl) ), connector.realtimeCommands ) - assertEquals(setOf(PcKeyboardModifierKey.Shift, PcKeyboardModifierKey.Ctrl), viewModel.uiState.value.activeModifiers) + assertEquals(emptySet(), viewModel.uiState.value.activeModifiers) assertNull(viewModel.uiState.value.message) }