Skip to content

Commit 751f858

Browse files
authored
Merge pull request #2602 from switchifyapp/feature/pc-alphabet-shortcuts-2601
Add collapsible PC alphabet shortcut grid
2 parents 77b24c7 + a7915a4 commit 751f858

11 files changed

Lines changed: 494 additions & 64 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package com.enaboapps.switchify.components
2+
3+
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.animation.expandVertically
5+
import androidx.compose.animation.fadeIn
6+
import androidx.compose.animation.fadeOut
7+
import androidx.compose.animation.shrinkVertically
8+
import androidx.compose.animation.core.tween
9+
import androidx.compose.foundation.BorderStroke
10+
import androidx.compose.foundation.clickable
11+
import androidx.compose.foundation.layout.Arrangement
12+
import androidx.compose.foundation.layout.Column
13+
import androidx.compose.foundation.layout.ColumnScope
14+
import androidx.compose.foundation.layout.Row
15+
import androidx.compose.foundation.layout.fillMaxWidth
16+
import androidx.compose.foundation.layout.padding
17+
import androidx.compose.foundation.shape.RoundedCornerShape
18+
import androidx.compose.material.icons.Icons
19+
import androidx.compose.material.icons.filled.KeyboardArrowDown
20+
import androidx.compose.material.icons.filled.KeyboardArrowUp
21+
import androidx.compose.material3.Icon
22+
import androidx.compose.material3.MaterialTheme
23+
import androidx.compose.material3.Surface
24+
import androidx.compose.material3.Text
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.ui.Alignment
27+
import androidx.compose.ui.Modifier
28+
import androidx.compose.ui.semantics.Role
29+
import androidx.compose.ui.semantics.stateDescription
30+
import androidx.compose.ui.semantics.semantics
31+
import androidx.compose.ui.text.font.FontWeight
32+
import androidx.compose.ui.unit.dp
33+
34+
@Composable
35+
fun CollapsibleSection(
36+
title: String,
37+
expanded: Boolean,
38+
onExpandedChange: (Boolean) -> Unit,
39+
modifier: Modifier = Modifier,
40+
enabled: Boolean = true,
41+
subtitle: String? = null,
42+
content: @Composable ColumnScope.() -> Unit
43+
) {
44+
Surface(
45+
modifier = modifier.fillMaxWidth(),
46+
shape = RoundedCornerShape(12.dp),
47+
color = MaterialTheme.colorScheme.surface,
48+
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
49+
) {
50+
Column(modifier = Modifier.fillMaxWidth()) {
51+
Row(
52+
modifier = Modifier
53+
.fillMaxWidth()
54+
.clickable(
55+
enabled = enabled,
56+
role = Role.Button,
57+
onClick = { onExpandedChange(!expanded) }
58+
)
59+
.semantics {
60+
stateDescription = if (expanded) "Expanded" else "Collapsed"
61+
}
62+
.padding(horizontal = 12.dp, vertical = 10.dp),
63+
horizontalArrangement = Arrangement.spacedBy(12.dp),
64+
verticalAlignment = Alignment.CenterVertically
65+
) {
66+
Column(
67+
modifier = Modifier.weight(1f),
68+
verticalArrangement = Arrangement.spacedBy(2.dp)
69+
) {
70+
Text(
71+
text = title,
72+
style = MaterialTheme.typography.titleMedium,
73+
fontWeight = FontWeight.SemiBold,
74+
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
75+
)
76+
subtitle?.let {
77+
Text(
78+
text = it,
79+
style = MaterialTheme.typography.bodySmall,
80+
color = MaterialTheme.colorScheme.onSurfaceVariant
81+
)
82+
}
83+
}
84+
Icon(
85+
imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
86+
contentDescription = null,
87+
tint = MaterialTheme.colorScheme.onSurfaceVariant
88+
)
89+
}
90+
AnimatedVisibility(
91+
visible = expanded,
92+
enter = fadeIn(animationSpec = tween(180)) + expandVertically(animationSpec = tween(180)),
93+
exit = fadeOut(animationSpec = tween(120)) + shrinkVertically(animationSpec = tween(120))
94+
) {
95+
Column(
96+
modifier = Modifier
97+
.fillMaxWidth()
98+
.padding(start = 12.dp, end = 12.dp, bottom = 12.dp),
99+
verticalArrangement = Arrangement.spacedBy(8.dp),
100+
content = content
101+
)
102+
}
103+
}
104+
}
105+
}

app/src/main/java/com/enaboapps/switchify/pc/PcKeyboardKey.kt

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,36 @@ enum class PcKeyboardKey(
3737
}
3838

3939
enum class PcKeyboardShortcutKey(val protocolValue: String) {
40-
Meta("Meta"),
4140
Ctrl("Ctrl"),
41+
Alt("Alt"),
42+
Shift("Shift"),
43+
Meta("Meta"),
4244
A("A"),
45+
B("B"),
4346
C("C"),
44-
X("X")
47+
D("D"),
48+
E("E"),
49+
F("F"),
50+
G("G"),
51+
H("H"),
52+
I("I"),
53+
J("J"),
54+
K("K"),
55+
L("L"),
56+
M("M"),
57+
N("N"),
58+
O("O"),
59+
P("P"),
60+
Q("Q"),
61+
R("R"),
62+
S("S"),
63+
T("T"),
64+
U("U"),
65+
V("V"),
66+
W("W"),
67+
X("X"),
68+
Y("Y"),
69+
Z("Z")
4570
}
4671

4772
enum class PcKeyboardModifierKey(
@@ -53,3 +78,41 @@ enum class PcKeyboardModifierKey(
5378
Shift("Shift", R.string.pc_modifier_shift),
5479
Meta("Meta", R.string.pc_modifier_start)
5580
}
81+
82+
fun PcKeyboardModifierKey.toShortcutKey(): PcKeyboardShortcutKey {
83+
return when (this) {
84+
PcKeyboardModifierKey.Ctrl -> PcKeyboardShortcutKey.Ctrl
85+
PcKeyboardModifierKey.Alt -> PcKeyboardShortcutKey.Alt
86+
PcKeyboardModifierKey.Shift -> PcKeyboardShortcutKey.Shift
87+
PcKeyboardModifierKey.Meta -> PcKeyboardShortcutKey.Meta
88+
}
89+
}
90+
91+
val PC_SHORTCUT_LETTER_KEYS: List<PcKeyboardShortcutKey> = listOf(
92+
PcKeyboardShortcutKey.A,
93+
PcKeyboardShortcutKey.B,
94+
PcKeyboardShortcutKey.C,
95+
PcKeyboardShortcutKey.D,
96+
PcKeyboardShortcutKey.E,
97+
PcKeyboardShortcutKey.F,
98+
PcKeyboardShortcutKey.G,
99+
PcKeyboardShortcutKey.H,
100+
PcKeyboardShortcutKey.I,
101+
PcKeyboardShortcutKey.J,
102+
PcKeyboardShortcutKey.K,
103+
PcKeyboardShortcutKey.L,
104+
PcKeyboardShortcutKey.M,
105+
PcKeyboardShortcutKey.N,
106+
PcKeyboardShortcutKey.O,
107+
PcKeyboardShortcutKey.P,
108+
PcKeyboardShortcutKey.Q,
109+
PcKeyboardShortcutKey.R,
110+
PcKeyboardShortcutKey.S,
111+
PcKeyboardShortcutKey.T,
112+
PcKeyboardShortcutKey.U,
113+
PcKeyboardShortcutKey.V,
114+
PcKeyboardShortcutKey.W,
115+
PcKeyboardShortcutKey.X,
116+
PcKeyboardShortcutKey.Y,
117+
PcKeyboardShortcutKey.Z
118+
)

app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseCommandGrid.kt

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ data class PcCompactCommandCell(
7878
val selected: Boolean = false
7979
)
8080

81+
data class PcCompactTextCommandCell(
82+
val label: String,
83+
val enabled: Boolean,
84+
val onClick: () -> Unit,
85+
val selected: Boolean = false
86+
)
87+
8188
/**
8289
* Small connection indicator shown in the navbar next to the surface switcher.
8390
* Non-interactive; the connection state is exposed through semantics so speech
@@ -224,6 +231,44 @@ fun PcCompactCommandGrid(
224231
}
225232
}
226233

234+
@Composable
235+
fun PcCompactTextCommandGrid(
236+
columns: Int,
237+
minTileHeightDp: Int,
238+
horizontalGapDp: Int = 8,
239+
verticalGapDp: Int = 8,
240+
cells: List<PcCompactTextCommandCell>,
241+
modifier: Modifier = Modifier
242+
) {
243+
require(columns > 0)
244+
val horizontalGap = horizontalGapDp.dp
245+
val verticalGap = verticalGapDp.dp
246+
247+
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
248+
val tileWidth = (maxWidth - horizontalGap * (columns - 1)) / columns
249+
Column(verticalArrangement = Arrangement.spacedBy(verticalGap)) {
250+
cells.chunked(columns).forEach { rowCells ->
251+
EqualHeightGridRow(
252+
items = rowCells,
253+
columns = columns,
254+
itemWidth = tileWidth,
255+
minItemHeight = minTileHeightDp.dp,
256+
horizontalGap = horizontalGap
257+
) { cell, itemModifier ->
258+
PcScannedCommandTile(
259+
label = cell.label,
260+
enabled = cell.enabled,
261+
onClick = cell.onClick,
262+
selected = cell.selected,
263+
minHeightDp = minTileHeightDp,
264+
modifier = itemModifier
265+
)
266+
}
267+
}
268+
}
269+
}
270+
}
271+
227272
@Composable
228273
private fun PcCommandSectionTitle(@StringRes titleResId: Int) {
229274
Text(
@@ -245,6 +290,33 @@ fun PcScannedCommandTile(
245290
selected: Boolean = false,
246291
minHeightDp: Int = 52,
247292
square: Boolean = false
293+
) {
294+
PcScannedCommandTile(
295+
label = stringResource(labelResId),
296+
enabled = enabled,
297+
onClick = onClick,
298+
modifier = modifier,
299+
icon = icon,
300+
iconRotationDegrees = iconRotationDegrees,
301+
tone = tone,
302+
selected = selected,
303+
minHeightDp = minHeightDp,
304+
square = square
305+
)
306+
}
307+
308+
@Composable
309+
fun PcScannedCommandTile(
310+
label: String,
311+
enabled: Boolean,
312+
onClick: () -> Unit,
313+
modifier: Modifier = Modifier,
314+
icon: ImageVector? = null,
315+
iconRotationDegrees: Float = 0f,
316+
tone: PcCommandTone = PcCommandTone.Neutral,
317+
selected: Boolean = false,
318+
minHeightDp: Int = 52,
319+
square: Boolean = false
248320
) {
249321
val interactionSource = remember { MutableInteractionSource() }
250322
val pressed by interactionSource.collectIsPressedAsState()
@@ -321,7 +393,7 @@ fun PcScannedCommandTile(
321393
)
322394
}
323395
Text(
324-
text = stringResource(labelResId),
396+
text = label,
325397
style = MaterialTheme.typography.labelLarge,
326398
fontWeight = if (tone == PcCommandTone.Primary) FontWeight.SemiBold else FontWeight.Medium,
327399
color = contentColor,

app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ private fun PcMouseControlScreen(
184184
enabled = surfaceEnabled,
185185
activeModifiers = uiState.activeModifiers,
186186
onModifierSelected = viewModel::toggleModifier,
187+
onShortcutLetterSelected = viewModel::sendShortcutLetter,
187188
onCommandSelected = viewModel::send
188189
)
189190
}

app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import com.enaboapps.switchify.pc.PcControlCommand
1414
import com.enaboapps.switchify.pc.PcErrorReason
1515
import com.enaboapps.switchify.pc.PcKeyboardKey
1616
import com.enaboapps.switchify.pc.PcKeyboardModifierKey
17+
import com.enaboapps.switchify.pc.PcKeyboardShortcutKey
1718
import com.enaboapps.switchify.pc.PcMouseRepeatManager
1819
import com.enaboapps.switchify.pc.PcServiceConnectResult
1920
import com.enaboapps.switchify.pc.PcServiceConnectionController
@@ -23,6 +24,7 @@ import com.enaboapps.switchify.pc.isSafePcTypedText
2324
import com.enaboapps.switchify.pc.pcTextStreamItemsFor
2425
import com.enaboapps.switchify.pc.supportsModifierToggle
2526
import com.enaboapps.switchify.pc.supportsTextStreams
27+
import com.enaboapps.switchify.pc.toShortcutKey
2628
import com.enaboapps.switchify.service.core.ServiceCore
2729
import java.util.UUID
2830
import kotlinx.coroutines.CoroutineStart
@@ -174,6 +176,28 @@ class PcMouseControlViewModel(
174176
}
175177
}
176178

179+
fun sendShortcutLetter(letter: PcKeyboardShortcutKey) {
180+
val modifiers = orderedShortcutModifiers(_uiState.value.activeModifiers)
181+
if (modifiers.isEmpty()) {
182+
_uiState.update { it.copy(message = SELECT_SHORTCUT_MODIFIER_MESSAGE) }
183+
return
184+
}
185+
val keys = modifiers.map { it.toShortcutKey() } + letter
186+
viewModelScope.launch {
187+
when (sendNoAckCommandNow(PcControlCommand.KeyboardShortcut(keys)) {
188+
it.copy(
189+
isBusy = false,
190+
busyCommand = null,
191+
message = null
192+
)
193+
}) {
194+
PcCommandResult.Ack -> releaseActiveModifiersIfPossible()
195+
is PcCommandResult.AuthFailed,
196+
is PcCommandResult.Failed -> Unit
197+
}
198+
}
199+
}
200+
177201
fun sendMouseCommand(command: PcControlCommand, repeatable: Boolean) {
178202
val mouseRepeat = currentMouseRepeatCapabilities()
179203
if (repeatable && mouseRepeat?.supported == true) {
@@ -474,7 +498,7 @@ class PcMouseControlViewModel(
474498
}
475499

476500
private fun releaseActiveModifiersIfPossible() {
477-
val modifiers = _uiState.value.activeModifiers.toList()
501+
val modifiers = orderedShortcutModifiers(_uiState.value.activeModifiers).asReversed()
478502
if (modifiers.isEmpty()) return
479503
_uiState.update { it.copy(activeModifiers = emptySet()) }
480504
val controller = serviceControllerProvider()
@@ -1086,6 +1110,7 @@ class PcMouseControlViewModel(
10861110
const val KEY_FAILED_MESSAGE = "Could not send key to PC."
10871111
const val TEXT_TOO_LONG_MESSAGE = "Text is too long."
10881112
const val TEXT_UNSUPPORTED_MESSAGE = "Text includes unsupported characters."
1113+
const val SELECT_SHORTCUT_MODIFIER_MESSAGE = "Choose Ctrl, Alt, Shift, or Start first."
10891114
const val TEXT_STREAM_SEND_DELAY_MS = 250L
10901115
const val TEXT_STREAM_RECONNECT_TIMEOUT_MS = 15_000L
10911116
const val TEXT_STREAM_RECONNECT_RETRY_LIMIT = 3

0 commit comments

Comments
 (0)