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:
parent
21d96b71f0
commit
8e59db01f4
4 changed files with 481 additions and 276 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue