QB Customizer: drag-and-drop reorder, trashcan delete, grid for available keys

Replace up/down arrows with drag handles (Modifier.draggable) for reordering
in both Keys and App Shortcuts tabs. Replace ✕ with trashcan icon positioned
left of drag handle. Available keys now shown in a compact 4-column grid
instead of a vertical list. App shortcuts auto-collapse when drag starts.
Menu items within expanded apps also use drag-and-drop reorder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-04 01:27:55 +02:00
parent 21d96b71f0
commit 8e59db01f4
4 changed files with 481 additions and 276 deletions

View file

@ -2,5 +2,5 @@ package com.roundingmobile.sshworkbench
// Auto-generated — do not edit
object BuildTimestamp {
const val TIME = "2026-04-03 23:04:46"
const val TIME = "2026-04-04 00:39:45"
}

View file

@ -3,11 +3,16 @@ package com.roundingmobile.sshworkbench.terminal
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
@ -16,17 +21,21 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
import com.roundingmobile.keyboard.model.AppShortcut
import com.roundingmobile.keyboard.model.KeyAction
import com.roundingmobile.keyboard.model.MenuItem
import com.roundingmobile.sshworkbench.R
import kotlin.math.roundToInt
/**
* Full-screen dialog for customizing QB keys and app shortcuts.
@ -155,44 +164,128 @@ private fun KeysTab(
QuickBarKeyPool.allKeys.filter { it.id !in activeKeyIds }
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
// Drag-and-drop state
var draggedIndex by remember { mutableIntStateOf(-1) }
var dragOffsetY by remember { mutableFloatStateOf(0f) }
var measuredItemHeight by remember { mutableFloatStateOf(0f) }
val currentKeyIds by rememberUpdatedState(activeKeyIds)
val currentOnKeysChanged by rememberUpdatedState(onKeysChanged)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
// Section: Active keys
item {
Text(
stringResource(R.string.qb_your_keys),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 8.dp)
)
}
Text(
stringResource(R.string.qb_your_keys),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 8.dp)
)
itemsIndexed(activeKeyIds, key = { _, id -> "active_$id" }) { index, keyId ->
val keyDef = QuickBarKeyPool.findKey(keyId)
ActiveKeyRow(
label = keyDef?.label ?: keyId,
canMoveUp = index > 0,
canMoveDown = index < activeKeyIds.size - 1,
onMoveUp = {
val list = activeKeyIds.toMutableList()
list.add(index - 1, list.removeAt(index))
onKeysChanged(list)
},
onMoveDown = {
val list = activeKeyIds.toMutableList()
list.add(index + 1, list.removeAt(index))
onKeysChanged(list)
},
onRemove = {
onKeysChanged(activeKeyIds.toMutableList().also { it.removeAt(index) })
activeKeyIds.forEachIndexed { index, keyId ->
key(keyId) {
val keyDef = QuickBarKeyPool.findKey(keyId)
val isDragged = draggedIndex == index
val elevation by animateDpAsState(
targetValue = if (isDragged) 4.dp else 0.dp,
label = "dragElevation"
)
val currentIndex by rememberUpdatedState(index)
val draggableState = rememberDraggableState { delta ->
dragOffsetY += delta
val h = measuredItemHeight
if (h > 0f && draggedIndex >= 0) {
val keys = currentKeyIds
when {
dragOffsetY > h * 0.5f && draggedIndex < keys.size - 1 -> {
val list = keys.toMutableList()
list.add(draggedIndex + 1, list.removeAt(draggedIndex))
currentOnKeysChanged(list)
dragOffsetY -= h
draggedIndex++
}
dragOffsetY < -h * 0.5f && draggedIndex > 0 -> {
val list = keys.toMutableList()
list.add(draggedIndex - 1, list.removeAt(draggedIndex))
currentOnKeysChanged(list)
dragOffsetY += h
draggedIndex--
}
}
}
}
)
Row(
modifier = Modifier
.fillMaxWidth()
.zIndex(if (isDragged) 1f else 0f)
.offset { IntOffset(0, if (isDragged) dragOffsetY.roundToInt() else 0) }
.onGloballyPositioned { coords ->
if (measuredItemHeight == 0f) measuredItemHeight =
coords.size.height.toFloat()
}
.padding(vertical = 2.dp)
.shadow(elevation, RoundedCornerShape(8.dp))
.background(
if (isDragged) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(8.dp)
)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
keyDef?.label ?: keyId,
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.weight(1f).padding(start = 4.dp)
)
// Delete button (left of drag handle)
IconButton(
onClick = {
onKeysChanged(
activeKeyIds.toMutableList().also { it.removeAt(index) })
},
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.qb_remove_key),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
// Drag handle
Icon(
Icons.Filled.DragHandle,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(32.dp)
.draggable(
state = draggableState,
orientation = Orientation.Vertical,
onDragStarted = {
draggedIndex = currentIndex
dragOffsetY = 0f
},
onDragStopped = {
draggedIndex = -1
dragOffsetY = 0f
}
)
)
}
}
}
// Section: Available keys
item {
// Section: Available keys (grid)
if (availableKeys.isNotEmpty()) {
HorizontalDivider(Modifier.padding(vertical = 12.dp))
Text(
stringResource(R.string.qb_available_keys),
@ -200,73 +293,51 @@ private fun KeysTab(
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(vertical = 8.dp)
)
}
items(availableKeys, key = { "avail_${it.id}" }) { keyDef ->
AvailableKeyRow(
label = keyDef.label,
onAdd = { onKeysChanged(activeKeyIds + keyDef.id) }
)
val columns = 4
availableKeys.chunked(columns).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
row.forEach { keyDef ->
Surface(
onClick = { onKeysChanged(activeKeyIds + keyDef.id) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow
) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(2.dp))
Text(
keyDef.label,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
maxLines = 1
)
}
}
}
repeat(columns - row.size) {
Spacer(Modifier.weight(1f))
}
}
}
}
}
}
@Composable
private fun ActiveKeyRow(
label: String,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onRemove: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Up/Down buttons
IconButton(onClick = onMoveUp, enabled = canMoveUp, modifier = Modifier.size(32.dp)) {
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = null, modifier = Modifier.size(20.dp))
}
IconButton(onClick = onMoveDown, enabled = canMoveDown, modifier = Modifier.size(32.dp)) {
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(20.dp))
}
Text(
label,
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
)
IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.qb_remove_key),
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp))
}
}
}
@Composable
private fun AvailableKeyRow(label: String, onAdd: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.clickable(onClick = onAdd)
.background(MaterialTheme.colorScheme.surfaceContainerLow, RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.Add, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(12.dp))
Text(label, style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace))
}
}
// =============================================================================
// Tab 2: App Shortcuts
// =============================================================================
@ -280,85 +351,323 @@ private fun AppShortcutsTab(
var showAddAppDialog by remember { mutableStateOf(false) }
var editingMenuItem by remember { mutableStateOf<EditMenuItemState?>(null) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
item {
Text(
stringResource(R.string.qb_app_shortcuts),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 8.dp)
)
}
// App-level drag state
var draggedAppIndex by remember { mutableIntStateOf(-1) }
var appDragOffsetY by remember { mutableFloatStateOf(0f) }
var measuredAppHeight by remember { mutableFloatStateOf(0f) }
val currentApps by rememberUpdatedState(apps)
val currentOnAppsChanged by rememberUpdatedState(onAppsChanged)
itemsIndexed(apps, key = { _, app -> "app_${app.id}" }) { index, app ->
val isExpanded = expandedAppId == app.id
AppShortcutRow(
app = app,
isExpanded = isExpanded,
canMoveUp = index > 0,
canMoveDown = index < apps.size - 1,
onToggleExpand = { expandedAppId = if (isExpanded) null else app.id },
onMoveUp = {
val list = apps.toMutableList()
list.add(index - 1, list.removeAt(index))
onAppsChanged(list)
},
onMoveDown = {
val list = apps.toMutableList()
list.add(index + 1, list.removeAt(index))
onAppsChanged(list)
},
onRemove = {
onAppsChanged(apps.toMutableList().also { it.removeAt(index) })
if (isExpanded) expandedAppId = null
},
onAddMenuItem = {
editingMenuItem = EditMenuItemState(appIndex = index, itemIndex = -1)
},
onEditMenuItem = { itemIndex ->
editingMenuItem = EditMenuItemState(appIndex = index, itemIndex = itemIndex,
label = app.menuItems[itemIndex].label,
actionText = menuItemActionToText(app.menuItems[itemIndex].action))
},
onRemoveMenuItem = { itemIndex ->
val newItems = app.menuItems.toMutableList().also { it.removeAt(itemIndex) }
val newApps = apps.toMutableList()
newApps[index] = app.copy(menuItems = newItems)
onAppsChanged(newApps)
},
onMoveMenuItemUp = { itemIndex ->
if (itemIndex > 0) {
val newItems = app.menuItems.toMutableList()
newItems.add(itemIndex - 1, newItems.removeAt(itemIndex))
val newApps = apps.toMutableList()
newApps[index] = app.copy(menuItems = newItems)
onAppsChanged(newApps)
}
},
onMoveMenuItemDown = { itemIndex ->
if (itemIndex < app.menuItems.size - 1) {
val newItems = app.menuItems.toMutableList()
newItems.add(itemIndex + 1, newItems.removeAt(itemIndex))
val newApps = apps.toMutableList()
newApps[index] = app.copy(menuItems = newItems)
onAppsChanged(newApps)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
stringResource(R.string.qb_app_shortcuts),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 8.dp)
)
apps.forEachIndexed { index, app ->
key(app.id) {
val isExpanded = expandedAppId == app.id
val isDragged = draggedAppIndex == index
val elevation by animateDpAsState(
targetValue = if (isDragged) 4.dp else 0.dp,
label = "appDragElevation"
)
val currentIndex by rememberUpdatedState(index)
val appDraggableState = rememberDraggableState { delta ->
appDragOffsetY += delta
val h = measuredAppHeight
if (h > 0f && draggedAppIndex >= 0) {
val a = currentApps
when {
appDragOffsetY > h * 0.5f && draggedAppIndex < a.size - 1 -> {
val list = a.toMutableList()
list.add(draggedAppIndex + 1, list.removeAt(draggedAppIndex))
currentOnAppsChanged(list)
appDragOffsetY -= h
draggedAppIndex++
}
appDragOffsetY < -h * 0.5f && draggedAppIndex > 0 -> {
val list = a.toMutableList()
list.add(draggedAppIndex - 1, list.removeAt(draggedAppIndex))
currentOnAppsChanged(list)
appDragOffsetY += h
draggedAppIndex--
}
}
}
}
)
// Menu-item drag state (per app, survives expand/collapse)
var draggedMenuIndex by remember { mutableIntStateOf(-1) }
var menuDragOffsetY by remember { mutableFloatStateOf(0f) }
var measuredMenuHeight by remember { mutableFloatStateOf(0f) }
Column(
modifier = Modifier
.fillMaxWidth()
.zIndex(if (isDragged) 1f else 0f)
.offset { IntOffset(0, if (isDragged) appDragOffsetY.roundToInt() else 0) }
.onGloballyPositioned { coords ->
if (!isExpanded && !isDragged) measuredAppHeight =
coords.size.height.toFloat()
}
.padding(vertical = 2.dp)
.shadow(elevation, RoundedCornerShape(8.dp))
.background(
if (isDragged) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(8.dp)
)
) {
// App header
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = {
if (draggedAppIndex < 0) expandedAppId =
if (isExpanded) null else app.id
})
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Text(
app.label,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
)
Text(
"${app.menuItems.size}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(4.dp))
// Delete button
IconButton(
onClick = {
onAppsChanged(
apps.toMutableList().also { it.removeAt(index) })
if (isExpanded) expandedAppId = null
},
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Filled.Delete, contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
}
// Drag handle
@Suppress("UNUSED_EXPRESSION")
Box(
modifier = Modifier
.size(28.dp)
.clickable { } // consume taps
.draggable(
state = appDraggableState,
orientation = Orientation.Vertical,
onDragStarted = {
draggedAppIndex = currentIndex
appDragOffsetY = 0f
expandedAppId = null
},
onDragStopped = {
draggedAppIndex = -1
appDragOffsetY = 0f
}
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.DragHandle,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp)
)
}
}
// Expanded: menu items with drag
if (isExpanded) {
Column(Modifier.padding(start = 16.dp, end = 12.dp, bottom = 8.dp)) {
app.menuItems.forEachIndexed { idx, item ->
val isMenuDragged = draggedMenuIndex == idx
val menuElevation by animateDpAsState(
targetValue = if (isMenuDragged) 4.dp else 0.dp,
label = "menuDragElevation"
)
val currentMenuIndex by rememberUpdatedState(idx)
val menuDraggableState =
rememberDraggableState { delta ->
menuDragOffsetY += delta
val h = measuredMenuHeight
if (h > 0f && draggedMenuIndex >= 0) {
val curApp = currentApps[currentIndex]
val items = curApp.menuItems
when {
menuDragOffsetY > h * 0.5f && draggedMenuIndex < items.size - 1 -> {
val newItems = items.toMutableList()
newItems.add(
draggedMenuIndex + 1,
newItems.removeAt(draggedMenuIndex)
)
val newApps = currentApps.toMutableList()
newApps[currentIndex] =
curApp.copy(menuItems = newItems)
currentOnAppsChanged(newApps)
menuDragOffsetY -= h
draggedMenuIndex++
}
menuDragOffsetY < -h * 0.5f && draggedMenuIndex > 0 -> {
val newItems = items.toMutableList()
newItems.add(
draggedMenuIndex - 1,
newItems.removeAt(draggedMenuIndex)
)
val newApps = currentApps.toMutableList()
newApps[currentIndex] =
curApp.copy(menuItems = newItems)
currentOnAppsChanged(newApps)
menuDragOffsetY += h
draggedMenuIndex--
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.zIndex(if (isMenuDragged) 1f else 0f)
.offset {
IntOffset(
0,
if (isMenuDragged) menuDragOffsetY.roundToInt() else 0
)
}
.onGloballyPositioned { coords ->
if (measuredMenuHeight == 0f) measuredMenuHeight =
coords.size.height.toFloat()
}
.padding(vertical = 1.dp)
.shadow(menuElevation, RoundedCornerShape(4.dp))
.background(
if (isMenuDragged) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerLow,
RoundedCornerShape(4.dp)
)
.clickable {
editingMenuItem = EditMenuItemState(
appIndex = index,
itemIndex = idx,
label = item.label,
actionText = menuItemActionToText(item.action)
)
}
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
item.label,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
)
Text(
menuItemActionToText(item.action),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.widthIn(max = 100.dp)
)
// Delete button
IconButton(
onClick = {
val newItems = app.menuItems.toMutableList()
.also { it.removeAt(idx) }
val newApps = apps.toMutableList()
newApps[index] = app.copy(menuItems = newItems)
onAppsChanged(newApps)
},
modifier = Modifier.size(24.dp)
) {
Icon(
Icons.Filled.Delete, contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
}
// Drag handle
Box(
modifier = Modifier
.size(24.dp)
.clickable { }
.draggable(
state = menuDraggableState,
orientation = Orientation.Vertical,
onDragStarted = {
draggedMenuIndex = currentMenuIndex
menuDragOffsetY = 0f
},
onDragStopped = {
draggedMenuIndex = -1
menuDragOffsetY = 0f
}
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.DragHandle,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
}
}
TextButton(onClick = {
editingMenuItem =
EditMenuItemState(appIndex = index, itemIndex = -1)
}) {
Icon(
Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.qb_add_key))
}
}
}
}
}
}
item {
OutlinedButton(
onClick = { showAddAppDialog = true },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.qb_add_app))
}
OutlinedButton(
onClick = { showAddAppDialog = true },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.qb_add_app))
}
}
@ -406,110 +715,6 @@ private data class EditMenuItemState(
val actionText: String = ""
)
@Composable
private fun AppShortcutRow(
app: AppShortcut,
isExpanded: Boolean,
canMoveUp: Boolean,
canMoveDown: Boolean,
onToggleExpand: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onRemove: () -> Unit,
onAddMenuItem: () -> Unit,
onEditMenuItem: (Int) -> Unit,
onRemoveMenuItem: (Int) -> Unit,
onMoveMenuItemUp: (Int) -> Unit,
onMoveMenuItemDown: (Int) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(8.dp))
) {
// App header
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggleExpand)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onMoveUp, enabled = canMoveUp, modifier = Modifier.size(28.dp)) {
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = null, modifier = Modifier.size(18.dp))
}
IconButton(onClick = onMoveDown, enabled = canMoveDown, modifier = Modifier.size(28.dp)) {
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
}
Text(
app.label,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
)
Text(
"${app.menuItems.size}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) {
Icon(Icons.Filled.Close, contentDescription = null,
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
}
Icon(
if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
// Expanded: menu items
if (isExpanded) {
Column(Modifier.padding(start = 24.dp, end = 12.dp, bottom = 8.dp)) {
app.menuItems.forEachIndexed { idx, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 1.dp)
.background(MaterialTheme.colorScheme.surfaceContainerLow, RoundedCornerShape(4.dp))
.clickable { onEditMenuItem(idx) }
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { onMoveMenuItemUp(idx) }, enabled = idx > 0, modifier = Modifier.size(24.dp)) {
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = null, modifier = Modifier.size(16.dp))
}
IconButton(onClick = { onMoveMenuItemDown(idx) }, enabled = idx < app.menuItems.size - 1, modifier = Modifier.size(24.dp)) {
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(16.dp))
}
Text(
item.label,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
)
Text(
menuItemActionToText(item.action),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.widthIn(max = 120.dp)
)
IconButton(onClick = { onRemoveMenuItem(idx) }, modifier = Modifier.size(24.dp)) {
Icon(Icons.Filled.Close, contentDescription = null,
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(16.dp))
}
}
}
TextButton(onClick = onAddMenuItem) {
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.qb_add_key))
}
}
}
}
}
// =============================================================================
// Dialogs
// =============================================================================

View file

@ -138,8 +138,8 @@ Currently uses the same key set as CQB.
Full-screen dialog for customizing QB keys and app shortcuts. Two tabs:
- **Keys tab**: Active keys with up/down reorder, remove (✕). Available keys pool with tap-to-add (+). 29 keys in the master pool.
- **App Shortcuts tab**: Reorder, add/remove apps. Expand to see/edit individual key maps (label + action). Action format: hex bytes (`01 63`), escape sequences (`\eOP`), or text macros (`\r` for Enter).
- **Keys tab**: Active keys with drag-and-drop reorder (drag handle) and delete (trashcan). Available keys in a 4-column grid with tap-to-add (+). 27 keys in the master pool.
- **App Shortcuts tab**: Drag-and-drop reorder, add/remove apps (trashcan + drag handle). Expand to see/edit individual key maps with drag-and-drop reorder (label + action). Action format: hex bytes (`01 63`), escape sequences (`\eOP`), or text macros (`\r` for Enter).
Access: KB Settings → Quick Bar tab → **Customize** button, or AQB Settings → **Customize** button.

View file

@ -746,7 +746,7 @@ Built-in: Dark Terminal, Light, Monokai, Solarized Dark.
- **System keyboard quick bar**: separate key set (`systemKeyboardQuickBarKeys()`) with CTRL, ESC, TAB, `:`, `/`, arrows, HOME, END, PGUP, PGDN, F1-F12. Uses minimum key width (36dp) based on smallest key weight for proper sizing
- **Quick bar keys override**: `setQuickBarKeys()` stores override so `setTheme()` doesn't reset custom keys
- **CTRL modifier for system keyboard**: `consumeArmedModifiers()` allows external input paths to consume all ARMED modifier states
- **QB Customizer** (pro): `QuickBarCustomizerScreen.kt` — full-screen Compose dialog with two tabs (Keys, App Shortcuts). Keys tab: reorder (up/down), add from pool, remove. App Shortcuts tab: reorder apps, expand to edit individual key maps, add/remove apps and keys. `QuickBarCustomizer.kt` provides `QuickBarKeyPool` (29 keys), serialization (`serializeKeyIds`/`deserializeKeyIds`, `serializeAppShortcuts`/`deserializeAppShortcuts`), and `resolveKeys()`. CQB and AQB have independent custom configs via DataStore prefs (`cqb_custom_keys`, `cqb_custom_apps`, `aqb_custom_keys`, `aqb_custom_apps`). Accessed from KB Settings → Quick Bar tab → Customize, or AQB Settings → Customize. `TerminalKeyboard.setAppShortcuts()` / `getQuickBarKeys()` / `getAppShortcuts()` for runtime overrides.
- **QB Customizer** (pro): `QuickBarCustomizerScreen.kt` — full-screen Compose dialog with two tabs (Keys, App Shortcuts). Keys tab: drag-and-drop reorder (drag handle), delete (trashcan), available keys in 4-column grid with tap-to-add. App Shortcuts tab: drag-and-drop reorder apps (auto-collapses on drag), expand to edit individual key maps with drag-and-drop reorder, add/remove apps and keys. Both tabs use `Modifier.draggable` with swap-on-half-height-threshold, `rememberUpdatedState` for stable drag callbacks, animated elevation on dragged items. `QuickBarCustomizer.kt` provides `QuickBarKeyPool` (27 keys), serialization (`serializeKeyIds`/`deserializeKeyIds`, `serializeAppShortcuts`/`deserializeAppShortcuts`), and `resolveKeys()`. CQB and AQB have independent custom configs via DataStore prefs (`cqb_custom_keys`, `cqb_custom_apps`, `aqb_custom_keys`, `aqb_custom_apps`). Accessed from KB Settings → Quick Bar tab → Customize, or AQB Settings → Customize. `TerminalKeyboard.setAppShortcuts()` / `getQuickBarKeys()` / `getAppShortcuts()` for runtime overrides.
### Key Repeat
`KeyRepeatHandler`: supports two overloads: