QB Customizer overhaul: view-swap pattern, 55-key pool, app editor, sequential action parser

QuickBar Customizer:
- Keys tab: view-swap pattern (Your Keys ↔ Available Keys full-screen)
- 55 keys in 4 sections (Modifiers/Navigation/Symbols/F-Keys) with OutlinedButton style
- App Shortcuts: list with undo-on-delete snackbar, tap to open editor
- App Shortcut Editor: editable name, maxCols stepper (2-6), key map drag-reorder
- Action format: sequential parser with [Ctrl]x, [Alt]x, [Esc], [F1]-[F12], 0xHH, \n, \t
- Validate/OK two-step flow, modifier insert buttons, auto-close tokens, case normalization
- Reset dialog with "also reset key order" checkbox

Connection list & tabs:
- Duplicate connection appears below original with 10s blue flash highlight
- Tab labels show end of name (start-ellipsis) for long names
- Duplicate nickname validation in EditConnectionScreen
- Save button in EditConnection title bar, Save & Connect full width

Naming & quality:
- "Quick Bar" → "QuickBar" (one word) across all UI, docs, locales
- All hardcoded strings moved to strings.xml (EN/ES/SV)
- StateFlow atomic .update{} for thread safety (20 sites in MainViewModel)
- Removed dead code: unused imports, variables, parameters
- Fixed localized string truncation (.take(60) → TextOverflow.Ellipsis)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-04 15:10:10 +02:00
parent 8e59db01f4
commit d2f925cc4d
17 changed files with 1271 additions and 638 deletions

View file

@ -3,6 +3,7 @@ package com.roundingmobile.sshworkbench.terminal
import android.graphics.Color as AndroidColor
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
import androidx.compose.material3.Checkbox
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -94,7 +95,8 @@ fun KeyboardSettingsScreen(
proFeatures: ProFeatures?,
onSave: (KeyboardDisplaySettings) -> Unit,
onDismiss: () -> Unit,
onCustomizeQb: (() -> Unit)? = null
onCustomizeQb: (() -> Unit)? = null,
onResetKeys: (() -> Unit)? = null
) {
val isPro = proFeatures == null || proFeatures.keyboardCustomization
@ -267,6 +269,7 @@ fun KeyboardSettingsScreen(
qbColorPreset = d.qbColorPreset
qbColorCustom = d.qbColorCustom
},
onResetKeys = onResetKeys,
onCustomize = onCustomizeQb,
modifier = Modifier.weight(1f)
)
@ -318,7 +321,8 @@ fun AqbSettingsScreen(
proFeatures: ProFeatures?,
onSave: (QuickBarDisplaySettings) -> Unit,
onDismiss: () -> Unit,
onCustomizeQb: (() -> Unit)? = null
onCustomizeQb: (() -> Unit)? = null,
onResetKeys: (() -> Unit)? = null
) {
val isPro = proFeatures == null || proFeatures.keyboardCustomization
@ -489,10 +493,41 @@ fun AqbSettingsScreen(
) { Text(stringResource(R.string.qb_customize)) }
}
// Reset
var showResetQbDialog by remember { mutableStateOf(false) }
var resetKeysAlso by remember { mutableStateOf(false) }
OutlinedButton(
onClick = { position = "top"; size = 42; colorPreset = "default"; colorCustom = "" },
onClick = { showResetQbDialog = true },
enabled = isPro, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
) { Text(stringResource(R.string.reset_quickbar_defaults)) }
if (showResetQbDialog) {
AlertDialog(
onDismissRequest = { showResetQbDialog = false; resetKeysAlso = false },
title = { Text(stringResource(R.string.reset_qb_title)) },
text = {
Column {
Text(stringResource(R.string.reset_qb_message))
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = resetKeysAlso, onCheckedChange = { resetKeysAlso = it })
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.reset_qb_keys_also), style = MaterialTheme.typography.bodyMedium)
}
}
},
confirmButton = {
TextButton(onClick = {
position = "top"; size = 42; colorPreset = "default"; colorCustom = ""
if (resetKeysAlso) onResetKeys?.invoke()
showResetQbDialog = false; resetKeysAlso = false
}) { Text(stringResource(R.string.reset)) }
},
dismissButton = {
TextButton(onClick = { showResetQbDialog = false; resetKeysAlso = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
}
}
@ -787,9 +822,49 @@ private fun QuickBarTab(
onColorPresetChange: (String) -> Unit,
onColorCustomChange: (String) -> Unit,
onReset: () -> Unit,
onResetKeys: (() -> Unit)? = null,
onCustomize: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
var showResetDialog by remember { mutableStateOf(false) }
var resetKeysAlso by remember { mutableStateOf(false) }
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false; resetKeysAlso = false },
title = { Text(stringResource(R.string.reset_qb_title)) },
text = {
Column {
Text(stringResource(R.string.reset_qb_message))
if (onResetKeys != null) {
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = resetKeysAlso,
onCheckedChange = { resetKeysAlso = it }
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.reset_qb_keys_also), style = MaterialTheme.typography.bodyMedium)
}
}
}
},
confirmButton = {
TextButton(onClick = {
onReset()
if (resetKeysAlso) onResetKeys?.invoke()
showResetDialog = false
resetKeysAlso = false
}) { Text(stringResource(R.string.reset)) }
},
dismissButton = {
TextButton(onClick = { showResetDialog = false; resetKeysAlso = false }) {
Text(stringResource(R.string.cancel))
}
}
)
}
Column(
modifier = modifier.verticalScroll(rememberScrollState())
) {
@ -839,7 +914,7 @@ private fun QuickBarTab(
// Reset
OutlinedButton(
onClick = onReset,
onClick = { showResetDialog = true },
enabled = isPro,
modifier = Modifier
.fillMaxWidth()

View file

@ -1,6 +1,7 @@
package com.roundingmobile.sshworkbench.terminal
import com.roundingmobile.keyboard.model.*
import com.roundingmobile.sshworkbench.R
import org.json.JSONArray
import org.json.JSONObject
@ -9,37 +10,78 @@ import org.json.JSONObject
*/
object QuickBarKeyPool {
/** Section definitions for available keys grid */
data class KeySection(val nameResId: Int, val keyIds: List<String>)
/** All possible QB keys — superset of what appears in the JSON layout */
val allKeys: List<KeyDefinition> = listOf(
// Modifiers
KeyDefinition("qb_ctrl", "CTRL", action = KeyAction.ToggleMod("CTRL"), w = 1f),
KeyDefinition("qb_alt", "ALT", action = KeyAction.ToggleMod("ALT"), w = 1f),
KeyDefinition("qb_shift", "SHIFT",action = KeyAction.ToggleMod("SHIFT"), w = 1f),
KeyDefinition("qb_esc", "ESC", action = KeyAction.Bytes(listOf(27)), w = 1f),
// Navigation
KeyDefinition("qb_tab", "TAB", labelIcon = "tab", action = KeyAction.Bytes(listOf(9)), w = 0.8f),
KeyDefinition("qb_colon", ":", action = KeyAction.Char(":"), w = 0.7f),
KeyDefinition("qb_tilde", "~", action = KeyAction.Char("~"), w = 0.7f),
KeyDefinition("qb_pipe", "|", action = KeyAction.Char("|"), w = 0.7f),
KeyDefinition("qb_slash", "/", action = KeyAction.Char("/"), w = 0.7f),
KeyDefinition("qb_stab", "", labelIcon = "backtab", action = KeyAction.EscSeq("[Z"), w = 0.8f),
KeyDefinition("qb_left", "", labelIcon = "left", action = KeyAction.EscSeq("[D"), w = 0.8f, repeatable = true),
KeyDefinition("qb_right", "", labelIcon = "right", action = KeyAction.EscSeq("[C"), w = 0.8f, repeatable = true),
KeyDefinition("qb_up", "", labelIcon = "up", action = KeyAction.EscSeq("[A"), w = 0.8f, repeatable = true),
KeyDefinition("qb_down", "", labelIcon = "down", action = KeyAction.EscSeq("[B"), w = 0.8f, repeatable = true),
KeyDefinition("qb_stab", "", labelIcon = "backtab", action = KeyAction.EscSeq("[Z"), w = 0.8f),
KeyDefinition("qb_home", "HOME", action = KeyAction.EscSeq("[H"), w = 1f),
KeyDefinition("qb_end", "END", action = KeyAction.EscSeq("[F"), w = 1f),
KeyDefinition("qb_pgup", "PGUP", labelIcon = "pgup", action = KeyAction.EscSeq("[5~"), w = 1f, repeatable = true),
KeyDefinition("qb_pgdn", "PGDN", labelIcon = "pgdn", action = KeyAction.EscSeq("[6~"), w = 1f, repeatable = true),
// Additional keys not in default layout
KeyDefinition("qb_bksp", "", action = KeyAction.Bytes(listOf(127)), w = 0.8f, repeatable = true),
KeyDefinition("qb_del", "DEL", action = KeyAction.EscSeq("[3~"), w = 0.8f, repeatable = true),
KeyDefinition("qb_ins", "INS", action = KeyAction.EscSeq("[2~"), w = 0.8f),
// Symbols
KeyDefinition("qb_colon", ":", action = KeyAction.Char(":"), w = 0.7f),
KeyDefinition("qb_tilde", "~", action = KeyAction.Char("~"), w = 0.7f),
KeyDefinition("qb_pipe", "|", action = KeyAction.Char("|"), w = 0.7f),
KeyDefinition("qb_slash", "/", action = KeyAction.Char("/"), w = 0.7f),
KeyDefinition("qb_bslash","\\", action = KeyAction.Char("\\"), w = 0.7f),
KeyDefinition("qb_dash", "-", action = KeyAction.Char("-"), w = 0.7f),
KeyDefinition("qb_dot", ".", action = KeyAction.Char("."), w = 0.7f),
KeyDefinition("qb_eq", "=", action = KeyAction.Char("="), w = 0.7f),
KeyDefinition("qb_at", "@", action = KeyAction.Char("@"), w = 0.7f),
KeyDefinition("qb_hash", "#", action = KeyAction.Char("#"), w = 0.7f),
KeyDefinition("qb_bslash","\\", action = KeyAction.Char("\\"), w = 0.7f),
KeyDefinition("qb_amp", "&", action = KeyAction.Char("&"), w = 0.7f),
KeyDefinition("qb_semi", ";", action = KeyAction.Char(";"), w = 0.7f),
KeyDefinition("qb_bang", "!", action = KeyAction.Char("!"), w = 0.7f),
KeyDefinition("qb_btick", "`", action = KeyAction.Char("`"), w = 0.7f),
KeyDefinition("qb_under", "_", action = KeyAction.Char("_"), w = 0.7f),
KeyDefinition("qb_dollar","$", action = KeyAction.Char("$"), w = 0.7f),
KeyDefinition("qb_pct", "%", action = KeyAction.Char("%"), w = 0.7f),
KeyDefinition("qb_caret", "^", action = KeyAction.Char("^"), w = 0.7f),
KeyDefinition("qb_squot", "'", action = KeyAction.Char("'"), w = 0.7f),
KeyDefinition("qb_dquot", "\"", action = KeyAction.Char("\""), w = 0.7f),
KeyDefinition("qb_lparen","(", action = KeyAction.Char("("), w = 0.7f),
KeyDefinition("qb_rparen",")", action = KeyAction.Char(")"), w = 0.7f),
KeyDefinition("qb_lbrack","[", action = KeyAction.Char("["), w = 0.7f),
KeyDefinition("qb_rbrack","]", action = KeyAction.Char("]"), w = 0.7f),
KeyDefinition("qb_lbrace","{", action = KeyAction.Char("{"), w = 0.7f),
KeyDefinition("qb_rbrace","}", action = KeyAction.Char("}"), w = 0.7f),
// F-Keys
KeyDefinition("qb_f1", "F1", action = KeyAction.EscSeq("OP"), w = 0.8f),
KeyDefinition("qb_f2", "F2", action = KeyAction.EscSeq("OQ"), w = 0.8f),
KeyDefinition("qb_f3", "F3", action = KeyAction.EscSeq("OR"), w = 0.8f),
KeyDefinition("qb_f4", "F4", action = KeyAction.EscSeq("OS"), w = 0.8f),
KeyDefinition("qb_f5", "F5", action = KeyAction.EscSeq("[15~"), w = 0.8f),
KeyDefinition("qb_f6", "F6", action = KeyAction.EscSeq("[17~"), w = 0.8f),
KeyDefinition("qb_f7", "F7", action = KeyAction.EscSeq("[18~"), w = 0.8f),
KeyDefinition("qb_f8", "F8", action = KeyAction.EscSeq("[19~"), w = 0.8f),
KeyDefinition("qb_f9", "F9", action = KeyAction.EscSeq("[20~"), w = 0.8f),
KeyDefinition("qb_f10", "F10", action = KeyAction.EscSeq("[21~"), w = 0.8f),
KeyDefinition("qb_f11", "F11", action = KeyAction.EscSeq("[23~"), w = 0.8f),
KeyDefinition("qb_f12", "F12", action = KeyAction.EscSeq("[24~"), w = 0.8f),
)
/** Sections for the available keys grid in the customizer */
val sections: List<KeySection> = listOf(
KeySection(R.string.qb_section_modifiers, listOf("qb_ctrl", "qb_alt", "qb_shift", "qb_esc")),
KeySection(R.string.qb_section_navigation, listOf("qb_tab", "qb_stab", "qb_left", "qb_right", "qb_up", "qb_down", "qb_home", "qb_end", "qb_pgup", "qb_pgdn", "qb_bksp", "qb_del", "qb_ins")),
KeySection(R.string.qb_section_symbols, listOf("qb_colon", "qb_tilde", "qb_pipe", "qb_slash", "qb_bslash", "qb_dash", "qb_dot", "qb_eq", "qb_at", "qb_hash", "qb_amp", "qb_semi", "qb_bang", "qb_btick", "qb_under", "qb_dollar", "qb_pct", "qb_caret", "qb_squot", "qb_dquot", "qb_lparen", "qb_rparen", "qb_lbrack", "qb_rbrack", "qb_lbrace", "qb_rbrace")),
KeySection(R.string.qb_section_fkeys, listOf("qb_f1", "qb_f2", "qb_f3", "qb_f4", "qb_f5", "qb_f6", "qb_f7", "qb_f8", "qb_f9", "qb_f10", "qb_f11", "qb_f12")),
)
/** Default app shortcuts — matches the JSON layout */
@ -115,6 +157,7 @@ fun serializeAppShortcuts(apps: List<AppShortcut>): String {
val obj = JSONObject()
obj.put("id", app.id)
obj.put("label", app.label)
if (app.maxCols != 3) obj.put("maxCols", app.maxCols)
val items = JSONArray()
for (item in app.menuItems) {
val mi = JSONObject()
@ -143,7 +186,8 @@ fun deserializeAppShortcuts(json: String): List<AppShortcut>? {
val mObj = mi.getJSONObject(j)
MenuItem(mObj.getString("label"), deserializeAction(mObj.getJSONObject("action")))
}
} ?: emptyList()
} ?: emptyList(),
maxCols = obj.optInt("maxCols", 3)
)
}
} catch (_: Exception) { null }

View file

@ -65,7 +65,6 @@ import com.roundingmobile.sshworkbench.pro.ProFeatures
import com.roundingmobile.sshworkbench.data.local.Snippet
import com.roundingmobile.sshworkbench.terminal.AqbSettingsScreen
import com.roundingmobile.sshworkbench.terminal.KeyboardSettingsScreen
import kotlinx.coroutines.flow.first
import com.roundingmobile.sshworkbench.terminal.SnippetPickerSheet
import com.roundingmobile.sshworkbench.terminal.AuthPromptDialog
import com.roundingmobile.sshworkbench.terminal.HostKeyDialog
@ -419,7 +418,13 @@ class MainActivity : AppCompatActivity() {
proFeatures = proFeatures,
onSave = { newSettings -> mainViewModel.saveKeyboardSettings(newSettings) },
onDismiss = { showKbSettings = false },
onCustomizeQb = if (proFeatures.keyboardCustomization) {{ showKbSettings = false; showQbCustomizer = true }} else null
onCustomizeQb = if (proFeatures.keyboardCustomization) {{ showKbSettings = false; showQbCustomizer = true }} else null,
onResetKeys = {
lifecycleScope.launch {
mainViewModel.terminalPrefs.setCqbCustomKeys("")
mainViewModel.terminalPrefs.setCqbCustomApps("")
}
}
)
}
@ -431,7 +436,13 @@ class MainActivity : AppCompatActivity() {
proFeatures = proFeatures,
onSave = { s -> mainViewModel.saveAqbSettings(s) },
onDismiss = { showAqbSettings = false },
onCustomizeQb = if (proFeatures.keyboardCustomization) {{ showAqbSettings = false; showQbCustomizer = true }} else null
onCustomizeQb = if (proFeatures.keyboardCustomization) {{ showAqbSettings = false; showQbCustomizer = true }} else null,
onResetKeys = {
lifecycleScope.launch {
mainViewModel.terminalPrefs.setAqbCustomKeys("")
mainViewModel.terminalPrefs.setAqbCustomApps("")
}
}
)
}
@ -463,7 +474,11 @@ class MainActivity : AppCompatActivity() {
}
}
},
onDismiss = { showQbCustomizer = false }
onDismiss = {
// Open settings first so it's in the tree before customizer disappears
if (isCustomKeyboard) showKbSettings = true else showAqbSettings = true
showQbCustomizer = false
}
)
}
@ -729,9 +744,10 @@ class MainActivity : AppCompatActivity() {
biometricAuth = biometricAuth,
activity = this@MainActivity,
proFeatures = proFeatures,
terminalService = mainViewModel.terminalService,
pendingQrKey = pendingQrKey,
onPendingQrKeyConsumed = { mainViewModel.consumePendingQrKey() }
onPendingQrKeyConsumed = { mainViewModel.consumePendingQrKey() },
pendingHighlightId = mainViewModel.highlightConnectionId,
onHighlightConsumed = { mainViewModel.highlightConnectionId = null }
)
}

View file

@ -1,5 +1,8 @@
package com.roundingmobile.sshworkbench.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.roundingmobile.ssh.SessionState
@ -425,10 +428,10 @@ class MainViewModel @Inject constructor(
// Create tab immediately (shows loading state), connect SFTP async
val info = SftpTabInfo(tabId = tabId, connectionId = connectionId)
_sftpTabs.value = _sftpTabs.value + (tabId to info)
_tabTypes.value = _tabTypes.value + (tabId to TabType.SFTP)
_sessionLabels.value = _sessionLabels.value + (tabId to "SFTP")
_tabOrder.value = _tabOrder.value + tabId
_sftpTabs.update { it + (tabId to info) }
_tabTypes.update { it + (tabId to TabType.SFTP) }
_sessionLabels.update { it + (tabId to "SFTP") }
_tabOrder.update { it + tabId }
switchToTerminal(tabId)
// Resolve label from Room and open SFTP session
@ -443,15 +446,15 @@ class MainViewModel @Inject constructor(
val hasSftpForConn = _sftpTabs.value.values.any { it.connectionId == connectionId && it.tabId != tabId }
val finalLabel = if (!hasSftpForConn) cleanLabel
else "$cleanLabel (${(existingNums.maxOrNull() ?: 1) + 1})"
_sessionLabels.value = _sessionLabels.value + (tabId to finalLabel)
_sessionLabels.update { it + (tabId to finalLabel) }
val sftpSessionId = svc.openSftpSession(connectionId)
if (sftpSessionId == null) {
FileLogger.log(TAG, "openSftpTab: failed to open SFTP for conn $connectionId")
_sftpTabs.value = _sftpTabs.value + (tabId to info.copy(error = strings.getString(R.string.sftp_connect_failed)))
_sftpTabs.update { it + (tabId to info.copy(error = strings.getString(R.string.sftp_connect_failed))) }
return@launch
}
_sftpTabs.value = _sftpTabs.value + (tabId to info.copy(sftpSessionId = sftpSessionId))
_sftpTabs.update { it + (tabId to info.copy(sftpSessionId = sftpSessionId)) }
refreshSessionCounts()
// Monitor the SSH session behind this SFTP tab — update tab state on disconnect
@ -466,11 +469,13 @@ class MainViewModel @Inject constructor(
is com.roundingmobile.ssh.SessionState.Error -> state.message
else -> null
}
val current = _sftpTabs.value[tabId] ?: return@collect
_sftpTabs.value = _sftpTabs.value + (tabId to current.copy(
error = reason ?: strings.getString(R.string.sftp_disconnected),
sftpSessionId = null
))
_sftpTabs.update { current ->
val entry = current[tabId] ?: return@update current
current + (tabId to entry.copy(
error = reason ?: strings.getString(R.string.sftp_disconnected),
sftpSessionId = null
))
}
refreshSessionCounts()
}
}
@ -484,15 +489,15 @@ class MainViewModel @Inject constructor(
val info = _sftpTabs.value[tabId] ?: return
FileLogger.log(TAG, "reconnectSftp: tab=$tabId conn=${info.connectionId}")
// Clear error, retry connection
_sftpTabs.value = _sftpTabs.value + (tabId to info.copy(error = null, sftpSessionId = null))
_sftpTabs.update { it + (tabId to info.copy(error = null, sftpSessionId = null)) }
viewModelScope.launch(Dispatchers.IO) {
val sftpSessionId = svc.openSftpSession(info.connectionId)
if (sftpSessionId == null) {
FileLogger.log(TAG, "reconnectSftp: failed for conn ${info.connectionId}")
_sftpTabs.value = _sftpTabs.value + (tabId to info.copy(error = strings.getString(R.string.sftp_connect_failed)))
_sftpTabs.update { it + (tabId to info.copy(error = strings.getString(R.string.sftp_connect_failed))) }
return@launch
}
_sftpTabs.value = _sftpTabs.value + (tabId to info.copy(sftpSessionId = sftpSessionId, error = null))
_sftpTabs.update { it + (tabId to info.copy(sftpSessionId = sftpSessionId, error = null)) }
refreshSessionCounts()
// Monitor SSH state for disconnect detection
@ -507,11 +512,13 @@ class MainViewModel @Inject constructor(
is com.roundingmobile.ssh.SessionState.Error -> state.message
else -> null
}
val current = _sftpTabs.value[tabId] ?: return@collect
_sftpTabs.value = _sftpTabs.value + (tabId to current.copy(
error = reason ?: strings.getString(R.string.sftp_disconnected),
sftpSessionId = null
))
_sftpTabs.update { current ->
val entry = current[tabId] ?: return@update current
current + (tabId to entry.copy(
error = reason ?: strings.getString(R.string.sftp_disconnected),
sftpSessionId = null
))
}
refreshSessionCounts()
}
}
@ -552,10 +559,10 @@ class MainViewModel @Inject constructor(
if (info?.sftpSessionId != null) {
terminalService?.closeSftpSession(info.sftpSessionId)
}
_sftpTabs.value = _sftpTabs.value - tabId
_tabTypes.value = _tabTypes.value - tabId
_sessionLabels.value = _sessionLabels.value - tabId
_tabOrder.value = _tabOrder.value - tabId
_sftpTabs.update { it - tabId }
_tabTypes.update { it - tabId }
_sessionLabels.update { it - tabId }
_tabOrder.update { it - tabId }
refreshSessionCounts()
if (_appState.value.currentPane == Pane.TERMINAL && _appState.value.activeSessionId == tabId) {
@ -607,9 +614,7 @@ class MainViewModel @Inject constructor(
val scrollback = terminalPrefs.scrollbackLines.first()
launch(Dispatchers.Main) {
// Pre-set label BEFORE connectSSH so the observer doesn't overwrite it
_sessionLabels.value = _sessionLabels.value.toMutableMap().also {
it[newId] = dupLabel
}
_sessionLabels.update { it + (newId to dupLabel) }
if (entry.telnetSession != null) {
svc.connectTelnet(newId, entry.savedConnectionId, host, entry.port,
24, 80, scrollbackLines = scrollback)
@ -624,12 +629,14 @@ class MainViewModel @Inject constructor(
// Also store on entry for persistence across Activity death
svc.getSession(newId)?.customLabel = dupLabel
// Place new tab right after the source tab
val order = _tabOrder.value.toMutableList()
order.remove(newId)
val idx = order.indexOf(sessionId)
if (idx >= 0) order.add(idx + 1, newId)
else order.add(newId)
_tabOrder.value = order
_tabOrder.update { current ->
val order = current.toMutableList()
order.remove(newId)
val idx = order.indexOf(sessionId)
if (idx >= 0) order.add(idx + 1, newId)
else order.add(newId)
order
}
switchToTerminal(newId)
}
}
@ -649,8 +656,8 @@ class MainViewModel @Inject constructor(
FileLogger.log(TAG, "renameSession: $sessionId → '$newName'") // TRACE
val name = newName.ifBlank { null }
terminalService?.getSession(sessionId)?.customLabel = name
_sessionLabels.value = _sessionLabels.value.toMutableMap().also {
it[sessionId] = name ?: it[sessionId] ?: strings.getString(R.string.session_fallback_label, sessionId)
_sessionLabels.update {
it + (sessionId to (name ?: it[sessionId] ?: strings.getString(R.string.session_fallback_label, sessionId)))
}
}
@ -659,9 +666,9 @@ class MainViewModel @Inject constructor(
FileLogger.log(TAG, "setSessionTheme: $sessionId → '$themeName'") // TRACE
val theme = if (themeName == "Default Dark") null else themeName
terminalService?.getSession(sessionId)?.customThemeName = theme
_sessionThemes.value = _sessionThemes.value.toMutableMap().also {
if (theme == null) it.remove(sessionId)
else it[sessionId] = themeName
_sessionThemes.update {
if (theme == null) it - sessionId
else it + (sessionId to themeName)
}
}
@ -698,21 +705,28 @@ class MainViewModel @Inject constructor(
// --- Duplicate / duplicate session ---
/** ID of a recently duplicated connection — consumed by ConnectionListScreen for flash highlight */
var highlightConnectionId by mutableStateOf<Long?>(null)
fun duplicateConnection(connection: SavedConnection, onCreated: (Long) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
// Place duplicate just below original in the list (sorted by lastConnected DESC)
val newId = connectionDao.insert(
connection.copy(
id = 0,
nickname = if (connection.nickname.isNotBlank())
"${connection.nickname} (copy)" else "",
name = "${connection.name} (copy)",
lastConnected = 0
lastConnected = if (connection.lastConnected > 0) connection.lastConnected - 1 else 0
)
)
credentialStore.getPassword(connection.id)?.let { pass ->
credentialStore.savePassword(newId, pass)
}
launch(Dispatchers.Main) { onCreated(newId) }
launch(Dispatchers.Main) {
onCreated(newId)
highlightConnectionId = newId
}
}
}

View file

@ -50,10 +50,11 @@ fun SshWorkbenchNavGraph(
biometricAuth: BiometricAuthManager,
activity: FragmentActivity,
proFeatures: ProFeatures,
terminalService: TerminalService? = null,
startDestination: String = Routes.CONNECTION_LIST,
pendingQrKey: String? = null,
onPendingQrKeyConsumed: () -> Unit = {}
onPendingQrKeyConsumed: () -> Unit = {},
pendingHighlightId: Long? = null,
onHighlightConsumed: () -> Unit = {}
) {
NavHost(
navController = navController,
@ -93,7 +94,9 @@ fun SshWorkbenchNavGraph(
},
onCopyLog = onCopyLog,
onClearLog = onClearLog,
proFeatures = proFeatures
proFeatures = proFeatures,
pendingHighlightId = pendingHighlightId,
onHighlightConsumed = onHighlightConsumed
)
}
@ -168,6 +171,8 @@ fun SshWorkbenchNavGraph(
onNavigateToSettings = {},
onNavigateToKeys = {},
proFeatures = proFeatures,
pendingHighlightId = pendingHighlightId,
onHighlightConsumed = onHighlightConsumed,
mode = ScreenMode.PICKER,
onClose = {
navController.popBackStack()

View file

@ -22,6 +22,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@ -51,6 +53,8 @@ internal fun ConnectionItem(
connection: SavedConnection,
sessionInfo: ConnectionSessionInfo?,
sessionTrackingEnabled: Boolean = true,
highlight: Boolean = false,
onHighlightDone: () -> Unit = {},
onTap: () -> Unit,
onLongPress: () -> Unit
) {
@ -58,6 +62,15 @@ internal fun ConnectionItem(
val isActive = activeSessionCount > 0
val cardShape = RoundedCornerShape(12.dp)
val flashAlpha = remember { Animatable(0f) }
LaunchedEffect(highlight) {
if (highlight) {
flashAlpha.snapTo(0.4f)
flashAlpha.animateTo(0f, tween(10000))
onHighlightDone()
}
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
@ -78,6 +91,8 @@ internal fun ConnectionItem(
),
shape = cardShape
) {
// Flash overlay drawn ON TOP of card content
Box {
Row(
modifier = Modifier
.fillMaxWidth()
@ -211,6 +226,17 @@ internal fun ConnectionItem(
)
}
}
if (flashAlpha.value > 0f) {
Box(
modifier = Modifier
.matchParentSize()
.background(
Color(0xFF4FC3F7).copy(alpha = flashAlpha.value),
cardShape
)
)
}
} // Box
}
}

View file

@ -109,6 +109,8 @@ fun ConnectionListScreen(
onCopyLog: () -> Unit = {},
onClearLog: () -> Unit = {},
proFeatures: ProFeatures? = null,
pendingHighlightId: Long? = null,
onHighlightConsumed: () -> Unit = {},
mode: ScreenMode = ScreenMode.HOME,
onClose: () -> Unit = {},
pendingQrKey: String? = null,
@ -124,6 +126,7 @@ fun ConnectionListScreen(
var sessionPickerConnection by remember { mutableStateOf<SavedConnection?>(null) }
var showKebabMenu by remember { mutableStateOf(false) }
var showImportSheet by remember { mutableStateOf(false) }
// Highlight a recently duplicated connection (flash + scroll)
// Auto-open import sheet when QR key is shared from another app
LaunchedEffect(pendingQrKey) {
@ -333,7 +336,21 @@ fun ConnectionListScreen(
}
}
} else {
val connectionListState = rememberLazyListState()
// Scroll to show both original and duplicate when returning from edit
LaunchedEffect(pendingHighlightId, connections) {
val hId = pendingHighlightId ?: return@LaunchedEffect
val idx = connections.indexOfFirst { it.id == hId }
if (idx < 0) return@LaunchedEffect
val visible = connectionListState.layoutInfo.visibleItemsInfo
val isVisible = visible.any { it.key == hId }
if (!isVisible) {
val scrollTo = (idx - 1).coerceAtLeast(0)
connectionListState.animateScrollToItem(scrollTo)
}
}
LazyColumn(
state = connectionListState,
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
@ -352,6 +369,8 @@ fun ConnectionListScreen(
connection = connection,
sessionInfo = connInfo,
sessionTrackingEnabled = proFeatures?.sessionTracking != false,
highlight = pendingHighlightId == connection.id,
onHighlightDone = onHighlightConsumed,
onTap = {
if (isPicker) {
// PICKER mode: always connect, then close picker

View file

@ -48,6 +48,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.Alignment
@ -139,6 +140,12 @@ fun EditConnectionScreen(
)
}
val isDuplicateNickname = remember(viewModel.nickname, savedConnections) {
viewModel.nickname.isNotBlank() && savedConnections.any {
it.id != connectionId && it.nickname.equals(viewModel.nickname, ignoreCase = true)
}
}
Scaffold(
topBar = {
TopAppBar(
@ -148,6 +155,13 @@ fun EditConnectionScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
val canSave = !isDuplicateNickname && (viewModel.protocol == "local" || (viewModel.host.isNotBlank() && (viewModel.protocol == "telnet" || viewModel.username.isNotBlank())))
TextButton(
onClick = { viewModel.save(); onBack() },
enabled = canSave
) { Text(stringResource(R.string.save)) }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
@ -210,6 +224,8 @@ fun EditConnectionScreen(
label = { Text(stringResource(R.string.nickname)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = isDuplicateNickname,
supportingText = if (isDuplicateNickname) {{ Text(stringResource(R.string.duplicate_nickname_error)) }} else null,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
@ -485,9 +501,11 @@ fun EditConnectionScreen(
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.agent_forwarding_info).take(60) + "",
text = stringResource(R.string.agent_forwarding_info),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Switch(
@ -537,9 +555,11 @@ fun EditConnectionScreen(
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.wifi_lock_info_edit).take(55) + "",
text = stringResource(R.string.wifi_lock_info_edit),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Switch(
@ -703,36 +723,21 @@ fun EditConnectionScreen(
Spacer(modifier = Modifier.height(16.dp))
// --- Action buttons ---
Row(
// --- Action button ---
val validConnection = !isDuplicateNickname && when (viewModel.protocol) {
"local" -> true
"telnet" -> viewModel.host.isNotBlank() &&
viewModel.port.isNotBlank() && (viewModel.port.toIntOrNull() ?: 0) in 1..65535
else -> viewModel.host.isNotBlank() &&
viewModel.port.isNotBlank() && (viewModel.port.toIntOrNull() ?: 0) in 1..65535 &&
viewModel.username.isNotBlank() && (viewModel.authType == "password" || viewModel.keyId > 0L)
}
Button(
onClick = { viewModel.saveAndConnect() },
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
enabled = validConnection
) {
val validConnection = when (viewModel.protocol) {
"local" -> true
"telnet" -> viewModel.host.isNotBlank() &&
viewModel.port.isNotBlank() && (viewModel.port.toIntOrNull() ?: 0) in 1..65535
else -> viewModel.host.isNotBlank() &&
viewModel.port.isNotBlank() && (viewModel.port.toIntOrNull() ?: 0) in 1..65535 &&
viewModel.username.isNotBlank() && (viewModel.authType == "password" || viewModel.keyId > 0L)
}
Button(
onClick = { viewModel.saveAndConnect() },
modifier = Modifier.weight(1f),
enabled = validConnection
) {
Text(stringResource(R.string.connect_and_save))
}
OutlinedButton(
onClick = {
viewModel.save()
onBack()
},
modifier = Modifier.weight(1f),
enabled = viewModel.protocol == "local" || (viewModel.host.isNotBlank() && (viewModel.protocol == "telnet" || viewModel.username.isNotBlank()))
) {
Text(stringResource(R.string.save))
}
Text(stringResource(R.string.connect_and_save))
}
Spacer(modifier = Modifier.height(24.dp))

View file

@ -250,7 +250,7 @@ private fun SessionChip(
else Modifier
)
.clickable(onClick = onTap)
.padding(start = 8.dp, end = 2.dp),
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Red dot for disconnected tabs
@ -268,14 +268,9 @@ private fun SessionChip(
!isConnected -> typeAccent.copy(alpha = 0.35f)
else -> TextPrimary
}
// Show label with suffix visible: "...kap (2)" instead of "Nordka..."
// Show end of label so user can differentiate similar names: "…rod Server" not "My Prod Se…"
val displayLabel = remember(label) {
val suffixMatch = Regex(" \\(\\d+\\)$").find(label)
if (suffixMatch != null && label.length > 12) {
val suffix = suffixMatch.value
val maxBase = 12 - suffix.length - 1
"${label.dropLast(suffix.length).takeLast(maxBase)}$suffix"
} else label
if (label.length > 18) "${label.takeLast(17)}" else label
}
Text(
text = displayLabel,

View file

@ -13,6 +13,8 @@
<string name="download">Descargar</string>
<string name="connect">Conectar</string>
<string name="ok">Aceptar</string>
<string name="reset">Restablecer</string>
<string name="undo">Deshacer</string>
<string name="close">Cerrar</string>
<string name="discard">Descartar</string>
@ -57,6 +59,7 @@
<string name="protocol_local">Local</string>
<string name="connection">Conexión</string>
<string name="nickname">Alias</string>
<string name="duplicate_nickname_error">Ya existe una conexión con este nombre</string>
<string name="host">Host</string>
<string name="port">Puerto</string>
<string name="username">Usuario</string>
@ -98,7 +101,7 @@
<string name="lang_spanish">Español</string>
<string name="lang_swedish">Svenska</string>
<string name="haptic_feedback">Vibración al pulsar</string>
<string name="show_quick_bar">Mostrar barra rápida</string>
<string name="show_quick_bar">Mostrar QuickBar</string>
<string name="security">Seguridad</string>
<string name="biometric_lock">Bloqueo biométrico</string>
<string name="about">Acerca de</string>
@ -222,18 +225,21 @@
<string name="key_color">Color de teclas</string>
<string name="show_key_hints">Mostrar sugerencias de teclas</string>
<string name="reset_keyboard_defaults">Restablecer teclado a valores predeterminados</string>
<string name="reset_quickbar_defaults">Restablecer barra rápida a valores predeterminados</string>
<string name="reset_quickbar_defaults">Restablecer QuickBar a valores predeterminados</string>
<string name="reset_qb_title">Restablecer QuickBar</string>
<string name="reset_qb_message">Esto restablecerá la posición, el tamaño y el color a los valores predeterminados.</string>
<string name="reset_qb_keys_also">También restablecer el orden de teclas</string>
<string name="qb_position">Posición</string>
<string name="qb_color">Color de barra rápida</string>
<string name="qb_color">Color de QuickBar</string>
<string name="qb_size">Tamaño</string>
<string name="custom_hex">Personalizado: #</string>
<string name="apply">Aplicar</string>
<string name="pro_feature_banner">FUNCIÓN PRO</string>
<string name="keyboard_customization_pro">La personalización del teclado requiere SSH Workbench Pro</string>
<string name="keyboard_tab">Teclado</string>
<string name="quickbar_tab">Barra rápida</string>
<string name="quickbar_tab">QuickBar</string>
<string name="keyboard_layout_colors">Ajustes de teclado</string>
<string name="quick_bar_settings">Ajustes de barra rápida</string>
<string name="quick_bar_settings">Ajustes de QuickBar</string>
<string name="no_device_lock_warning">Para usar el bloqueo biométrico, primero debes configurar un bloqueo de pantalla (PIN, patrón o contraseña) en los ajustes del dispositivo.</string>
<string name="auto_reconnect_label">Reconexión automática</string>
<string name="auto_reconnect_desc">Reconectar automáticamente cuando se pierde la conexión</string>
@ -426,8 +432,8 @@
<string name="quick_connect_hint">user@host:port</string>
<!-- QB Customizer -->
<string name="qb_customize_title">Personalizar barra rápida</string>
<string name="qb_customize">Personalizar</string>
<string name="qb_customize_title">Personalizar QuickBar</string>
<string name="qb_customize">Personalizar teclas</string>
<string name="qb_reset_default">Restablecer</string>
<string name="qb_tab_keys">Teclas</string>
<string name="qb_tab_shortcuts">Atajos de apps</string>
@ -442,5 +448,18 @@
<string name="qb_key_label">Etiqueta</string>
<string name="qb_key_action">Acción</string>
<string name="qb_action_hint">Bytes hex (0f), \\eSeq (\\eOP), o macro de texto (\\r para Enter)</string>
<string name="qb_validate">Validar</string>
<string name="qb_section_modifiers">Modificadores</string>
<string name="qb_section_navigation">Navegación</string>
<string name="qb_section_symbols">Símbolos</string>
<string name="qb_section_fkeys">F-Keys</string>
<string name="qb_error_label_required">La etiqueta es obligatoria</string>
<string name="qb_error_action_required">La acción es obligatoria</string>
<string name="qb_error_dangling_modifier">Añade un carácter después del modificador</string>
<string name="qb_examples">Ejemplos:</string>
<string name="qb_columns">Columnas</string>
<string name="qb_key_count">%d teclas</string>
<string name="qb_app_removed">%s eliminado</string>
<string name="qb_action_literal_bracket">\\[ para escribir [</string>
<string name="qb_reset_confirm">¿Restablecer todas las teclas y atajos a los valores predeterminados?</string>
</resources>

View file

@ -13,6 +13,8 @@
<string name="download">Ladda ner</string>
<string name="connect">Anslut</string>
<string name="ok">OK</string>
<string name="reset">Återställ</string>
<string name="undo">Ångra</string>
<string name="close">Stäng</string>
<string name="discard">Förkasta</string>
@ -57,6 +59,7 @@
<string name="protocol_local">Lokal</string>
<string name="connection">Anslutning</string>
<string name="nickname">Smeknamn</string>
<string name="duplicate_nickname_error">Det finns redan en anslutning med detta namn</string>
<string name="host">Värd</string>
<string name="port">Port</string>
<string name="username">Användarnamn</string>
@ -98,7 +101,7 @@
<string name="lang_spanish">Español</string>
<string name="lang_swedish">Svenska</string>
<string name="haptic_feedback">Vibration vid knapptryck</string>
<string name="show_quick_bar">Visa snabbfält</string>
<string name="show_quick_bar">Visa QuickBar</string>
<string name="security">Säkerhet</string>
<string name="biometric_lock">Biometriskt lås</string>
<string name="about">Om</string>
@ -221,7 +224,10 @@
<string name="key_color">Tangentfärg</string>
<string name="show_key_hints">Visa tangenttips</string>
<string name="reset_keyboard_defaults">Återställ tangentbord till standardvärden</string>
<string name="reset_quickbar_defaults">Återställ snabbfält till standardvärden</string>
<string name="reset_quickbar_defaults">Återställ QuickBar till standardvärden</string>
<string name="reset_qb_title">Återställ QuickBar</string>
<string name="reset_qb_message">Detta återställer position, storlek och färg till standardvärden.</string>
<string name="reset_qb_keys_also">Återställ även tangentordning</string>
<string name="qb_position">Position</string>
<string name="qb_color">Snabbfältsfärg</string>
<string name="qb_size">Storlek</string>
@ -230,7 +236,7 @@
<string name="pro_feature_banner">PRO-FUNKTION</string>
<string name="keyboard_customization_pro">Tangentbordsanpassning kräver SSH Workbench Pro</string>
<string name="keyboard_tab">Tangentbord</string>
<string name="quickbar_tab">Snabbfält</string>
<string name="quickbar_tab">QuickBar</string>
<string name="keyboard_layout_colors">Tangentbordsinställningar</string>
<string name="quick_bar_settings">Snabbfältsinställningar</string>
<string name="no_device_lock_warning">För att använda biometriskt lås måste du först ställa in ett skärmlås (PIN, mönster eller lösenord) i enhetens inställningar.</string>
@ -425,8 +431,8 @@
<string name="quick_connect_hint">user@host:port</string>
<!-- QB Customizer -->
<string name="qb_customize_title">Anpassa snabbfältet</string>
<string name="qb_customize">Anpassa</string>
<string name="qb_customize_title">Anpassa QuickBar</string>
<string name="qb_customize">Anpassa tangenter</string>
<string name="qb_reset_default">Återställ</string>
<string name="qb_tab_keys">Tangenter</string>
<string name="qb_tab_shortcuts">Appgenvägar</string>
@ -441,5 +447,18 @@
<string name="qb_key_label">Etikett</string>
<string name="qb_key_action">Åtgärd</string>
<string name="qb_action_hint">Hex-bytes (0f), \\eSeq (\\eOP), eller textmakro (\\r för Enter)</string>
<string name="qb_validate">Validera</string>
<string name="qb_section_modifiers">Modifierare</string>
<string name="qb_section_navigation">Navigering</string>
<string name="qb_section_symbols">Symboler</string>
<string name="qb_section_fkeys">F-tangenter</string>
<string name="qb_error_label_required">Etikett krävs</string>
<string name="qb_error_action_required">Åtgärd krävs</string>
<string name="qb_error_dangling_modifier">Lägg till ett tecken efter modifieraren</string>
<string name="qb_examples">Exempel:</string>
<string name="qb_columns">Kolumner</string>
<string name="qb_key_count">%d tangenter</string>
<string name="qb_app_removed">%s borttagen</string>
<string name="qb_action_literal_bracket">\\[ för bokstavlig [</string>
<string name="qb_reset_confirm">Återställ alla tangenter och appgenvägar till standardvärden?</string>
</resources>

View file

@ -12,6 +12,8 @@
<string name="download">Download</string>
<string name="connect">Connect</string>
<string name="ok">OK</string>
<string name="reset">Reset</string>
<string name="undo">Undo</string>
<string name="close">Close</string>
<string name="discard">Discard</string>
@ -56,6 +58,7 @@
<string name="protocol_local">Local</string>
<string name="connection">Connection</string>
<string name="nickname">Nickname</string>
<string name="duplicate_nickname_error">A connection with this name already exists</string>
<string name="host">Host</string>
<string name="port">Port</string>
<string name="username">Username</string>
@ -99,7 +102,7 @@
<string name="lang_spanish">Español</string>
<string name="lang_swedish">Svenska</string>
<string name="haptic_feedback">Haptic Feedback</string>
<string name="show_quick_bar">Show Quick Bar</string>
<string name="show_quick_bar">Show QuickBar</string>
<string name="security">Security</string>
<string name="biometric_lock">Biometric Lock</string>
<string name="about">About</string>
@ -223,7 +226,10 @@
<string name="key_color">Key color</string>
<string name="show_key_hints">Show key hints</string>
<string name="reset_keyboard_defaults">Reset keyboard to defaults</string>
<string name="reset_quickbar_defaults">Reset quick bar to defaults</string>
<string name="reset_quickbar_defaults">Reset QuickBar to defaults</string>
<string name="reset_qb_title">Reset QuickBar</string>
<string name="reset_qb_message">This will reset position, size, and color to defaults.</string>
<string name="reset_qb_keys_also">Also reset key order to defaults</string>
<string name="qb_position">Position</string>
<string name="qb_color">Quick bar color</string>
<string name="qb_size">Size</string>
@ -232,11 +238,11 @@
<string name="pro_feature_banner">PRO FEATURE</string>
<string name="keyboard_customization_pro">Keyboard customization requires SSH Workbench Pro</string>
<string name="keyboard_tab">Keyboard</string>
<string name="quickbar_tab">Quick Bar</string>
<string name="quickbar_tab">QuickBar</string>
<!-- Edit Connection Screen -->
<string name="keyboard_layout_colors">Keyboard Settings</string>
<string name="quick_bar_settings">Quick Bar Settings</string>
<string name="quick_bar_settings">QuickBar Settings</string>
<string name="no_device_lock_warning">To use biometric lock, you need to set up a screen lock (PIN, pattern, or password) in your device settings first.</string>
<!-- Edit Connection -->
@ -247,7 +253,7 @@
<string name="default_for_new_hosts">\nThis setting will be the default for any new host created.</string>
<string name="default_theme_label">Default (%s)</string>
<!-- Quick Bar Positions -->
<!-- QuickBar Positions -->
<string name="qb_pos_top">Top of screen</string>
<string name="qb_pos_above_keyboard">Above keyboard</string>
<string name="qb_pos_below_keyboard">Below keyboard</string>
@ -446,8 +452,8 @@
<string name="quick_connect_hint">user@host:port</string>
<!-- QB Customizer -->
<string name="qb_customize_title">Customize Quick Bar</string>
<string name="qb_customize">Customize</string>
<string name="qb_customize_title">Customize QuickBar</string>
<string name="qb_customize">Customize Keys</string>
<string name="qb_reset_default">Reset</string>
<string name="qb_tab_keys">Keys</string>
<string name="qb_tab_shortcuts">App Shortcuts</string>
@ -462,5 +468,18 @@
<string name="qb_key_label">Label</string>
<string name="qb_key_action">Action</string>
<string name="qb_action_hint">Hex bytes (0f), \\eSeq (\\eOP), or text macro (\\r for Enter)</string>
<string name="qb_validate">Validate</string>
<string name="qb_section_modifiers">Modifiers</string>
<string name="qb_section_navigation">Navigation</string>
<string name="qb_section_symbols">Symbols</string>
<string name="qb_section_fkeys">F-Keys</string>
<string name="qb_error_label_required">Label is required</string>
<string name="qb_error_action_required">Action is required</string>
<string name="qb_error_dangling_modifier">Add a character after the modifier</string>
<string name="qb_examples">Examples:</string>
<string name="qb_columns">Columns</string>
<string name="qb_key_count">%d keys</string>
<string name="qb_app_removed">%s removed</string>
<string name="qb_action_literal_bracket">\\[ for literal [</string>
<string name="qb_reset_confirm">Reset all keys and app shortcuts to defaults?</string>
</resources>

View file

@ -5,6 +5,8 @@
| **TV** | Terminal View | The terminal surface where sessions are displayed, typed into, and interacted with |
| **AKB** | Android Keyboard | System IME (Google Keyboard, Samsung Keyboard, etc.) |
| **CKB** | Custom Keyboard | Our Canvas-based keyboard (`lib-terminal-keyboard`) |
| **AQB** | AKB Quick Bar | Quick Bar shown when using the Android Keyboard |
| **CQB** | CKB Quick Bar | Quick Bar shown when using the Custom Keyboard |
| **AQB** | AKB QuickBar | QuickBar shown when using the Android Keyboard |
| **CQB** | CKB QuickBar | QuickBar shown when using the Custom Keyboard |
| **W** | Shortcuts Button | App icon button on QB far-left — opens popup with app shortcuts (vim, nano, tmux, screen, F1-12) and key map grids |
| **Con** | Connection | A saved connection profile (`SavedConnection` entity) |
| **Con List** | Connection List | The home screen where you select/manage profiles (`ConnectionListScreen`) |

View file

@ -12,8 +12,8 @@
| **TV** | Terminal View | Terminal surface — displays sessions, receives input |
| **AKB** | Android Keyboard | System IME (Gboard, Samsung, etc.) |
| **CKB** | Custom Keyboard | Our Canvas-based keyboard (`lib-terminal-keyboard`) |
| **AQB** | AKB Quick Bar | Quick Bar when using the Android Keyboard |
| **CQB** | CKB Quick Bar | Quick Bar when using the Custom Keyboard |
| **AQB** | AKB QuickBar | QuickBar when using the Android Keyboard |
| **CQB** | CKB QuickBar | QuickBar when using the Custom Keyboard |
---
@ -25,7 +25,7 @@ Two keyboard modes, selected in Settings → Keyboard → Keyboard Type:
┌─────────────────────────────────────────┐
│ TV (TerminalSurfaceView) │
├─────────────────────────────────────────┤
│ Quick Bar (AQB or CQB) │ ← always visible (if enabled)
│ QuickBar (AQB or CQB) │ ← always visible (if enabled)
├─────────────────────────────────────────┤
│ CKB (custom keyboard pages) │ ← only in CKB mode
│ — or — │
@ -55,7 +55,7 @@ Two keyboard modes, selected in Settings → Keyboard → Keyboard Type:
|------|-----|---------|
| Keyboard type | `keyboard_type` | `"custom"` |
| Haptic feedback | `haptic_feedback` | `true` |
| Show Quick Bar | `quick_bar_visible` | `true` |
| Show QuickBar | `quick_bar_visible` | `true` |
### CKB-only
| Pref | Key | Default |
@ -71,7 +71,7 @@ Two keyboard modes, selected in Settings → Keyboard → Keyboard Type:
| Key repeat delay | `key_repeat_delay` | `400` |
| Long press delay | `long_press_delay` | `350` |
### CQB (Custom Keyboard Quick Bar)
### CQB (Custom Keyboard QuickBar)
| Pref | Key | Default |
|------|-----|---------|
| Position | `quick_bar_position` | `"above_keyboard"` |
@ -79,7 +79,7 @@ Two keyboard modes, selected in Settings → Keyboard → Keyboard Type:
| Color preset | `qb_color_preset` | `"default"` |
| Color custom | `qb_color_custom` | `""` |
### AQB (Android Keyboard Quick Bar)
### AQB (Android Keyboard QuickBar)
Positions: `top`, `vertical_left`, `vertical_right`, `none` (no `above_keyboard`/`below_keyboard` — no CKB to be relative to)
| Pref | Key | Default |
@ -95,9 +95,9 @@ Positions: `top`, `vertical_left`, `vertical_right`, `none` (no `above_keyboard`
Settings → Keyboard section adapts based on `keyboard_type`:
**CKB selected**: Haptic, Show QB, Type selector, "Keyboard Settings" button → opens `KeyboardSettingsDialog` (two tabs: Keyboard + Quick Bar)
**CKB selected**: Haptic, Show QB, Type selector, "Keyboard Settings" button → opens `KeyboardSettingsDialog` (two tabs: Keyboard + QuickBar)
**AKB selected**: Haptic, Show QB, Type selector, "Quick Bar Settings" button → opens `KeyboardSettingsDialog.showAqb()` (position, size, color only)
**AKB selected**: Haptic, Show QB, Type selector, "QuickBar Settings" button → opens `KeyboardSettingsDialog.showAqb()` (position, size, color only)
---
@ -121,27 +121,26 @@ Settings → Keyboard section adapts based on `keyboard_type`:
---
## Quick Bar Keys
## QuickBar Keys
### CQB (Custom Keyboard mode)
Defined in `layout_qwerty.json``quickBar.keys`:
ESC, TAB, `:`, `~`, `|`, ←, →, Shift+Tab, vim, nano, tmux, screen
### AQB (System Keyboard mode)
Currently uses the same key set as CQB.
**Planned** (from TECHNICAL.md): separate key set with CTRL, ESC, TAB, `:`, `/`, arrows, HOME, END, PGUP, PGDN, F1-F12. Not yet implemented.
Separate key set via `QuickBarView.systemKeyboardQuickBarKeys()`:
CTRL, ESC, TAB, `:`, `/`, ←, →, ↑, ↓, HOME, END, PGUP, PGDN, F1-F12. Uses minimum key width (36dp) based on smallest key weight.
---
## Quick Bar Customizer (Pro)
## QuickBar Customizer (Pro)
Full-screen dialog for customizing QB keys and app shortcuts. Two tabs:
- **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.
- **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 (+). 55 keys in the master pool (4 modifiers + 13 navigation + 26 symbols + 12 F-keys).
- **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.
Access: KB Settings → QuickBar tab → **Customize** button, or AQB Settings → **Customize** button.
CQB and AQB have independent custom configurations stored in DataStore (`cqb_custom_keys`, `cqb_custom_apps`, `aqb_custom_keys`, `aqb_custom_apps`). Empty = use defaults from JSON layout.

View file

@ -4,7 +4,8 @@ package com.roundingmobile.keyboard.model
data class AppShortcut(
val id: String,
val label: String,
val menuItems: List<MenuItem>
val menuItems: List<MenuItem>,
val maxCols: Int = 3
)
data class QuickBarConfig(

View file

@ -40,8 +40,9 @@ class AppShortcutsPopupView(context: Context) : View(context) {
private var cellRects = listOf<RectF>()
private var backBtnRect = RectF()
// Grid config for key map
private val gridCols = 3
// Grid config for key map — uses per-app maxCols, default 3
private val gridCols: Int
get() = apps.getOrNull(selectedAppIndex)?.maxCols ?: 3
// Dimensions
private val cellWidth = dpToPx(64f)