Number row modes, mini numpad, font scaling, tablet defaults

- Number row setting: top (legacy), left/right (mini numpad), hidden (long-press)
- Mini numpad: JSON-defined side section (3x3+1 grid), separate KeyboardPageView
  with touch handling, LinearLayout split in AndroidView, clipChildren fix for
  SurfaceView rendering
- Font scaling: proportional 45%/25% of tallest key height, 10sp/7sp floor
  (was 13sp/8sp clamping to theme minimum, too big on small keyboards)
- Tablet defaults: sameSizeBoth=false, landscape height min 20% (phones 30%)
- Disconnect notification tap navigates to session, red badge on connection cards
- CKB hide/show via tab bar kebab menu
- KeyboardDisplaySettings Eagerly shared to avoid stale initial values

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-04 20:32:19 +02:00
parent d2f925cc4d
commit 293fbcba1e
23 changed files with 533 additions and 93 deletions

View file

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

View file

@ -31,6 +31,7 @@ object TerminalPrefsKeys {
val KEYBOARD_HEIGHT_PERCENT = floatPreferencesKey("keyboard_height_percent")
val KEYBOARD_HEIGHT_LANDSCAPE = floatPreferencesKey("keyboard_height_landscape")
val KEYBOARD_SAME_SIZE_BOTH = booleanPreferencesKey("keyboard_same_size_both")
val NUMBER_ROW_MODE = stringPreferencesKey("number_row_mode")
val QUICK_BAR_POSITION = stringPreferencesKey("quick_bar_position")
val QUICK_BAR_SIZE = intPreferencesKey("quick_bar_size") // dp
val SHOW_PAGE_INDICATORS = booleanPreferencesKey("show_page_indicators")
@ -70,7 +71,7 @@ object TerminalPrefsKeys {
val RECOVERY_PROTOCOL = stringPreferencesKey("recovery_protocol")
}
class TerminalPreferences(private val dataStore: DataStore<Preferences>) {
class TerminalPreferences(private val dataStore: DataStore<Preferences>, private val isTablet: Boolean = false) {
val fontSizeSp: Flow<Float> = dataStore.data.map { prefs ->
prefs[TerminalPrefsKeys.FONT_SIZE_SP] ?: 10f
}
@ -176,13 +177,21 @@ class TerminalPreferences(private val dataStore: DataStore<Preferences>) {
}
val keyboardSameSizeBoth: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[TerminalPrefsKeys.KEYBOARD_SAME_SIZE_BOTH] ?: true
prefs[TerminalPrefsKeys.KEYBOARD_SAME_SIZE_BOTH] ?: !isTablet
}
suspend fun setKeyboardSameSizeBoth(same: Boolean) {
dataStore.edit { prefs -> prefs[TerminalPrefsKeys.KEYBOARD_SAME_SIZE_BOTH] = same }
}
val numberRowMode: Flow<String> = dataStore.data.map { prefs ->
prefs[TerminalPrefsKeys.NUMBER_ROW_MODE] ?: "left"
}
suspend fun setNumberRowMode(mode: String) {
dataStore.edit { prefs -> prefs[TerminalPrefsKeys.NUMBER_ROW_MODE] = mode }
}
val quickBarPosition: Flow<String> = dataStore.data.map { prefs ->
prefs[TerminalPrefsKeys.QUICK_BAR_POSITION] ?: "above_keyboard"
}

View file

@ -55,6 +55,7 @@ object DatabaseModule {
@Provides
@Singleton
fun provideTerminalPreferences(@ApplicationContext context: Context): TerminalPreferences {
return TerminalPreferences(context.terminalDataStore)
val isTablet = context.resources.configuration.smallestScreenWidthDp >= 600
return TerminalPreferences(context.terminalDataStore, isTablet)
}
}

View file

@ -14,6 +14,7 @@ data class KeyboardDisplaySettings(
val showPageIndicators: Boolean = true,
val keyColorPreset: String = "default",
val keyColorCustom: String = "", // hex #RRGGBB or empty
val numberRowMode: String = "left",
val showHints: Boolean = true,
val keyRepeatDelay: Int = 400,
val longPressDelay: Int = 350,
@ -31,6 +32,7 @@ data class KeyboardDisplaySettings(
put("showPageIndicators", showPageIndicators)
put("keyColorPreset", keyColorPreset)
put("keyColorCustom", keyColorCustom)
put("numberRowMode", numberRowMode)
put("showHints", showHints)
put("keyRepeatDelay", keyRepeatDelay)
put("longPressDelay", longPressDelay)
@ -54,6 +56,7 @@ data class KeyboardDisplaySettings(
showPageIndicators = j.optBoolean("showPageIndicators", defaults.showPageIndicators),
keyColorPreset = j.optString("keyColorPreset", defaults.keyColorPreset),
keyColorCustom = j.optString("keyColorCustom", defaults.keyColorCustom),
numberRowMode = j.optString("numberRowMode", defaults.numberRowMode),
showHints = j.optBoolean("showHints", defaults.showHints),
keyRepeatDelay = j.optInt("keyRepeatDelay", defaults.keyRepeatDelay),
longPressDelay = j.optInt("longPressDelay", defaults.longPressDelay),

View file

@ -98,6 +98,7 @@ fun KeyboardSettingsScreen(
onCustomizeQb: (() -> Unit)? = null,
onResetKeys: (() -> Unit)? = null
) {
val context = LocalContext.current
val isPro = proFeatures == null || proFeatures.keyboardCustomization
// Mutable state for all settings
@ -105,6 +106,7 @@ fun KeyboardSettingsScreen(
var heightPercent by remember { mutableFloatStateOf(currentSettings.heightPercent) }
var heightLandscape by remember { mutableFloatStateOf(currentSettings.heightLandscape) }
var sameSizeBoth by remember { mutableStateOf(currentSettings.sameSizeBoth) }
var numberRowMode by remember { mutableStateOf(currentSettings.numberRowMode) }
var showPageIndicators by remember { mutableStateOf(currentSettings.showPageIndicators) }
var keyColorPreset by remember { mutableStateOf(currentSettings.keyColorPreset) }
var keyColorCustom by remember { mutableStateOf(currentSettings.keyColorCustom) }
@ -121,9 +123,10 @@ fun KeyboardSettingsScreen(
fun currentState() = KeyboardDisplaySettings(
language, heightPercent, heightLandscape, sameSizeBoth, showPageIndicators,
keyColorPreset, keyColorCustom, showHints,
keyRepeatDelay, longPressDelay, quickBarPosition, quickBarSize,
qbColorPreset, qbColorCustom
keyColorPreset, keyColorCustom, numberRowMode = numberRowMode, showHints = showHints,
keyRepeatDelay = keyRepeatDelay, longPressDelay = longPressDelay,
quickBarPosition = quickBarPosition, quickBarSize = quickBarSize,
qbColorPreset = qbColorPreset, qbColorCustom = qbColorCustom
)
fun hasChanges() = currentState() != currentSettings
@ -225,6 +228,8 @@ fun KeyboardSettingsScreen(
onSameSizeBothChange = { sameSizeBoth = it },
heightLandscape = heightLandscape,
onHeightLandscapeChange = { heightLandscape = it },
numberRowMode = numberRowMode,
onNumberRowModeChange = { numberRowMode = it },
showPageIndicators = showPageIndicators,
onShowPageIndicatorsChange = { showPageIndicators = it },
keyColorPreset = keyColorPreset,
@ -238,10 +243,12 @@ fun KeyboardSettingsScreen(
longPressDelay = longPressDelay,
onLongPressDelayChange = { longPressDelay = it },
onReset = {
val isTab = context.resources.configuration.smallestScreenWidthDp >= 600
val d = KeyboardDisplaySettings()
heightPercent = d.heightPercent
heightLandscape = d.heightLandscape
sameSizeBoth = d.sameSizeBoth
sameSizeBoth = if (isTab) false else d.sameSizeBoth
numberRowMode = d.numberRowMode
showPageIndicators = d.showPageIndicators
keyColorPreset = d.keyColorPreset
keyColorCustom = d.keyColorCustom
@ -683,6 +690,8 @@ private fun KeyboardTab(
onSameSizeBothChange: (Boolean) -> Unit,
heightLandscape: Float,
onHeightLandscapeChange: (Float) -> Unit,
numberRowMode: String,
onNumberRowModeChange: (String) -> Unit,
showPageIndicators: Boolean,
onShowPageIndicatorsChange: (Boolean) -> Unit,
keyColorPreset: String,
@ -734,16 +743,35 @@ private fun KeyboardTab(
// Size (landscape) — conditionally visible
if (!sameSizeBoth) {
val isTab = LocalContext.current.resources.configuration.smallestScreenWidthDp >= 600
val landscapeMin = if (isTab) 20 else 30
SectionLabel(stringResource(R.string.size_landscape))
SliderRow(
value = (heightLandscape * 100).roundToInt(),
min = 15, max = 50,
min = landscapeMin, max = 50,
enabled = isPro,
label = "${(heightLandscape * 100).roundToInt()}%",
onValueChange = { onHeightLandscapeChange(it / 100f) }
)
}
// Number row mode
SectionLabel(stringResource(R.string.number_row))
val numberRowOptions = remember {
listOf(
"top" to R.string.number_row_top,
"left" to R.string.number_row_left,
"right" to R.string.number_row_right,
"hidden" to R.string.number_row_hidden
)
}
LanguageDropdown(
options = numberRowOptions.map { it.first to stringResource(it.second) },
selected = numberRowMode,
enabled = isPro,
onSelected = onNumberRowModeChange
)
// Page indicators
SwitchRow(
label = stringResource(R.string.show_page_indicators),

View file

@ -0,0 +1,74 @@
package com.roundingmobile.sshworkbench.terminal
import com.roundingmobile.keyboard.model.*
/**
* Transforms a [KeyboardLayout] based on the number row mode setting.
*
* Modes:
* - "top": no change (default dedicated number row)
* - "hidden": remove number row, add number hints + long-press to QWERTY keys
* - "left": remove number row, prepend number keys to each row as a side column
* - "right": remove number row, append number keys to each row as a side column
*/
fun transformNumberRow(layout: KeyboardLayout, mode: String): KeyboardLayout {
if (mode == "top") return layout
val alphaPageIndex = layout.pages.indexOfFirst { it.id == "alpha" }
if (alphaPageIndex < 0) return layout
val alphaPage = layout.pages[alphaPageIndex]
val rows = alphaPage.rows
if (rows.isEmpty()) return layout
// Find the number row (first row, height 0.75, keys are digits)
val numberRow = rows.firstOrNull() ?: return layout
val numberKeys = numberRow.keys
if (numberKeys.size != 10) return layout
// Remaining rows (QWERTY, ASDF, ZXCV, bottom)
val letterRows = rows.drop(1)
val newRows = when (mode) {
"hidden" -> transformHidden(letterRows, numberKeys)
"left", "right" -> letterRows // just remove number row; mini pad handles numbers
else -> return layout
}
val newPage = alphaPage.copy(rows = newRows)
val newPages = layout.pages.toMutableList()
newPages[alphaPageIndex] = newPage
return layout.copy(pages = newPages)
}
/**
* Hidden mode: remove number row entirely, add number hints and long-press to QWERTY keys.
* q1, w2, e3, r4, t5, y6, u7, i8, o9, p0
*/
private fun transformHidden(
letterRows: List<KeyRow>,
numberKeys: List<KeyDefinition>
): List<KeyRow> {
if (letterRows.isEmpty()) return letterRows
// Map: position index → number character
val numberChars = numberKeys.map { key ->
val action = key.action
if (action is KeyAction.Char) action.primary else key.label
}
// First letter row is QWERTY — add number hints
val qwertyRow = letterRows[0]
val newQwertyKeys = qwertyRow.keys.mapIndexed { i, key ->
if (i < numberChars.size) {
val num = numberChars[i]
val existingLong = key.longPress?.toMutableList() ?: mutableListOf()
if (num !in existingLong) existingLong.add(0, num)
key.copy(hint = num, longPress = existingLong)
} else key
}
val newQwertyRow = qwertyRow.copy(keys = newQwertyKeys)
return listOf(newQwertyRow) + letterRows.drop(1)
}

View file

@ -48,9 +48,15 @@ class SessionNotifier(
/** Labels awaiting batch flush (within 5s window) */
private val pending = mutableListOf<String>()
/** Session IDs awaiting batch flush — parallel to [pending] */
private val pendingSessionIds = mutableListOf<Long>()
/** Labels currently shown as disconnected in the live notification */
private val active = mutableSetOf<String>()
/** First session ID to navigate to when notification is tapped */
private var targetSessionId: Long = -1L
private var batchJob: Job? = null
/** Create the notification channel. Call once from Service.onCreate(). */
@ -66,9 +72,10 @@ class SessionNotifier(
}
/** A session disconnected. Batched for 5s. */
fun onDisconnect(label: String) {
fun onDisconnect(label: String, sessionId: Long = -1L) {
synchronized(lock) {
pending.add(label)
pendingSessionIds.add(sessionId)
}
scheduleBatchFlush()
}
@ -77,7 +84,12 @@ class SessionNotifier(
fun onReconnect(label: String) {
val wasPending: Boolean
synchronized(lock) {
wasPending = pending.remove(label)
val idx = pending.indexOf(label)
wasPending = idx >= 0
if (wasPending) {
pending.removeAt(idx)
pendingSessionIds.removeAt(idx)
}
}
if (wasPending) return // still in batch window — just removed, done
@ -112,7 +124,12 @@ class SessionNotifier(
val flushed: List<String>
synchronized(lock) {
flushed = pending.toList()
// Use the first pending session ID as navigation target
if (targetSessionId < 0 && pendingSessionIds.isNotEmpty()) {
targetSessionId = pendingSessionIds.first { it >= 0 }
}
pending.clear()
pendingSessionIds.clear()
active.addAll(flushed)
}
if (flushed.isEmpty()) return@launch
@ -127,6 +144,7 @@ class SessionNotifier(
val labels = synchronized(lock) { active.toList() }
if (labels.isEmpty()) {
synchronized(lock) { targetSessionId = -1L }
nm.cancel(NOTIFICATION_ID)
FileLogger.log(TAG, "notification cancelled (all reconnected)") // TRACE
return
@ -136,8 +154,10 @@ class SessionNotifier(
val text = if (labels.size == 1) labels[0]
else context.getString(R.string.notification_sessions_disconnected, labels.size)
val navSessionId = synchronized(lock) { targetSessionId }
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (navSessionId >= 0) putExtra("sessionId", navSessionId)
}
val pi = PendingIntent.getActivity(
context, 1, intent,

View file

@ -519,7 +519,7 @@ class TerminalService : Service() {
releaseWifiLock(entry)
if (state.reason != "User disconnected" && !state.cleanExit) {
dumpDisconnectInfo(entry, state.reason, isError = false)
sessionNotifier.onDisconnect(entry.customLabel ?: entry.host ?: getString(R.string.session_fallback_label, sessionId))
sessionNotifier.onDisconnect(entry.customLabel ?: entry.host ?: getString(R.string.session_fallback_label, sessionId), sessionId)
}
if (state.reason != "User disconnected"
&& !state.cleanExit
@ -532,7 +532,7 @@ class TerminalService : Service() {
is SessionState.Error -> {
releaseWifiLock(entry)
dumpDisconnectInfo(entry, state.message, isError = true)
sessionNotifier.onDisconnect(entry.customLabel ?: entry.host ?: getString(R.string.session_fallback_label, sessionId))
sessionNotifier.onDisconnect(entry.customLabel ?: entry.host ?: getString(R.string.session_fallback_label, sessionId), sessionId)
// Dump SSH debug log to terminal on failure
dumpDebugLog(entry, session)
if (entry.autoReconnectEnabled) {
@ -954,14 +954,14 @@ class TerminalService : Service() {
releaseWifiLock(entry)
if (state.reason != "User disconnected" && !state.cleanExit) {
dumpDisconnectInfo(entry, state.reason, isError = false)
sessionNotifier.onDisconnect(entry.customLabel ?: entry.host ?: getString(R.string.session_fallback_label, sessionId))
sessionNotifier.onDisconnect(entry.customLabel ?: entry.host ?: getString(R.string.session_fallback_label, sessionId), sessionId)
}
cleanupDisconnectedSession(sessionId)
}
is SessionState.Error -> {
releaseWifiLock(entry)
dumpDisconnectInfo(entry, state.message, isError = true)
sessionNotifier.onDisconnect(entry.customLabel ?: entry.host ?: getString(R.string.session_fallback_label, sessionId))
sessionNotifier.onDisconnect(entry.customLabel ?: entry.host ?: getString(R.string.session_fallback_label, sessionId), sessionId)
cleanupDisconnectedSession(sessionId)
}
else -> {}

View file

@ -25,6 +25,7 @@ import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.foundation.layout.Column
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
@ -162,6 +163,12 @@ class MainActivity : AppCompatActivity() {
mainViewModel.connectByProfile(it)
pendingProfile = null
}
// Navigate to session from disconnect notification
if (pendingSessionId >= 0) {
FileLogger.log(TAG, "onServiceConnected — navigating to session $pendingSessionId") // TRACE
mainViewModel.switchToTerminal(pendingSessionId)
pendingSessionId = -1L
}
}
override fun onServiceDisconnected(name: ComponentName?) {
@ -234,9 +241,15 @@ class MainActivity : AppCompatActivity() {
val keyboardType by mainViewModel.terminalPrefs.keyboardType.collectAsStateWithLifecycle("")
val hapticEnabled by mainViewModel.terminalPrefs.hapticFeedback.collectAsStateWithLifecycle(true)
val quickBarVisible by mainViewModel.terminalPrefs.quickBarVisible.collectAsStateWithLifecycle(true)
val keyboardHeightPercent by mainViewModel.terminalPrefs.keyboardHeightPercent.collectAsStateWithLifecycle(0.27f)
val keyboardHeightPortrait by mainViewModel.terminalPrefs.keyboardHeightPercent.collectAsStateWithLifecycle(0.27f)
val keyboardHeightLandscape by mainViewModel.terminalPrefs.keyboardHeightLandscape.collectAsStateWithLifecycle(0.27f)
val isTablet = LocalContext.current.resources.configuration.smallestScreenWidthDp >= 600
val sameSizeBoth by mainViewModel.terminalPrefs.keyboardSameSizeBoth.collectAsStateWithLifecycle(!isTablet)
val isLandscape = LocalConfiguration.current.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
val keyboardHeightPercent = if (isLandscape && !sameSizeBoth) keyboardHeightLandscape else keyboardHeightPortrait
val keyboardLanguage by mainViewModel.terminalPrefs.keyboardLanguage.collectAsStateWithLifecycle("en")
val showKeyHints by mainViewModel.terminalPrefs.showKeyHints.collectAsStateWithLifecycle(true)
val numberRowMode by mainViewModel.terminalPrefs.numberRowMode.collectAsStateWithLifecycle("left")
val showPageIndicators by mainViewModel.terminalPrefs.showPageIndicators.collectAsStateWithLifecycle(true)
val cqbSize by mainViewModel.terminalPrefs.quickBarSize.collectAsStateWithLifecycle(42)
val aqbSize by mainViewModel.terminalPrefs.aqbSize.collectAsStateWithLifecycle(42)
@ -260,7 +273,6 @@ class MainActivity : AppCompatActivity() {
// Keyboard settings dialog state (opened from CKB gear menu)
var showKbSettings by remember { mutableStateOf(false) }
var showAqbSettings by remember { mutableStateOf(false) }
var showGearMenu by remember { mutableStateOf(false) }
var showQbCustomizer by remember { mutableStateOf(false) }
// Resolve keyboard layout and language resource IDs
@ -275,12 +287,16 @@ class MainActivity : AppCompatActivity() {
else -> com.roundingmobile.terminalkeyboard.R.raw.lang_en
}
// Single shared keyboard instance — recreated only when language changes
// Single shared keyboard instance — recreated when language or number row mode changes
val context = LocalContext.current
val keyboard = remember(keyboardLanguage) {
val keyboard = remember(keyboardLanguage, numberRowMode) {
val baseLayout = com.roundingmobile.keyboard.parser.LayoutParser.fromResource(
context, layoutResId,
com.roundingmobile.keyboard.parser.LanguageParser.fromResource(context, langResId)
)
val transformedLayout = com.roundingmobile.sshworkbench.terminal.transformNumberRow(baseLayout, numberRowMode)
TerminalKeyboard.Builder(context)
.fromJson(layoutResId)
.language(langResId)
.layout(transformedLayout)
.hapticFeedback(hapticEnabled)
.showHints(showKeyHints)
.build()
@ -323,10 +339,9 @@ class MainActivity : AppCompatActivity() {
}
}
// Wire snippet and settings callbacks (resolve active session dynamically)
// Wire snippet callback (resolve active session dynamically)
LaunchedEffect(keyboard) {
keyboard.onSnippetsTap = { showSnippetPicker() }
keyboard.onSettingsTap = { showGearMenu = true }
}
// Update dynamic keyboard settings
@ -390,27 +405,7 @@ class MainActivity : AppCompatActivity() {
}
}
// CKB gear long-press menu (Hide Keyboard / Keyboard Settings)
if (showGearMenu) {
val hideLabel = getString(R.string.hide_keyboard)
val settingsLabel = getString(R.string.keyboard_settings_title)
androidx.compose.material3.AlertDialog(
onDismissRequest = { showGearMenu = false },
confirmButton = {},
text = {
Column {
androidx.compose.material3.TextButton(onClick = { showGearMenu = false; ckbHidden = true }) {
androidx.compose.material3.Text(hideLabel)
}
androidx.compose.material3.TextButton(onClick = { showGearMenu = false; showKbSettings = true }) {
androidx.compose.material3.Text(settingsLabel)
}
}
}
)
}
// Keyboard settings dialog (opened from CKB gear → Keyboard Settings)
// Keyboard settings dialog (opened from tab bar kebab → KB Settings)
if (showKbSettings) {
val cachedKbSettings by mainViewModel.currentKeyboardSettings.collectAsStateWithLifecycle()
KeyboardSettingsScreen(
@ -549,7 +544,9 @@ class MainActivity : AppCompatActivity() {
},
kbSettingsLabel = if (isCustomKeyboard)
getString(R.string.keyboard_settings_title)
else getString(R.string.quick_bar_settings)
else getString(R.string.quick_bar_settings),
ckbVisible = isCustomKeyboard && !ckbHidden,
onToggleCkb = { ckbHidden = !ckbHidden }
)
}
@ -683,17 +680,64 @@ class MainActivity : AppCompatActivity() {
}
// --- Shared keyboard (one instance for all sessions) ---
val showMini = (numberRowMode == "left" || numberRowMode == "right") && keyboard.miniSection != null
val miniWeight = if (showMini) (keyboard.miniSection?.widthPercent ?: 10) / 100f else 0f
val kbWeight = if (showMini) 1f - miniWeight else 1f
key(keyboardLanguage, numberRowMode) {
AndroidView(
factory = { ctx ->
FrameLayout(ctx).apply {
clipChildren = false
clipToPadding = false
keyboard.attachTo(this)
if (showMini) {
android.widget.LinearLayout(ctx).apply {
orientation = android.widget.LinearLayout.HORIZONTAL
clipChildren = false
clipToPadding = false
val miniFrame = FrameLayout(ctx).apply {
clipChildren = false; clipToPadding = false
}
keyboard.attachMiniTo(miniFrame)
val kbFrame = FrameLayout(ctx).apply {
clipChildren = false; clipToPadding = false
}
keyboard.attachTo(kbFrame)
if (numberRowMode == "left") {
addView(miniFrame, android.widget.LinearLayout.LayoutParams(0, android.view.ViewGroup.LayoutParams.MATCH_PARENT, miniWeight))
addView(kbFrame, android.widget.LinearLayout.LayoutParams(0, android.view.ViewGroup.LayoutParams.MATCH_PARENT, kbWeight))
} else {
addView(kbFrame, android.widget.LinearLayout.LayoutParams(0, android.view.ViewGroup.LayoutParams.MATCH_PARENT, kbWeight))
addView(miniFrame, android.widget.LinearLayout.LayoutParams(0, android.view.ViewGroup.LayoutParams.MATCH_PARENT, miniWeight))
}
// attachTo/attachMiniTo propagate clipChildren=false up the
// entire parent chain which breaks SurfaceView rendering.
// Re-enable clipping from the LinearLayout upward.
post {
var v: android.view.ViewGroup? = this@apply
while (v != null) {
v.clipChildren = true
v.clipToPadding = true
v = v.parent as? android.view.ViewGroup
}
// Keep clipChildren=false only on the direct keyboard containers
kbFrame.clipChildren = false
kbFrame.clipToPadding = false
miniFrame.clipChildren = false
miniFrame.clipToPadding = false
}
}
} else {
FrameLayout(ctx).apply {
clipChildren = false
clipToPadding = false
keyboard.attachTo(this)
}
}
},
update = { container ->
val isTermTab = tabTypes[appState.activeSessionId] != TabType.SFTP
container.visibility = if (isCustomKeyboard && !ckbHidden && showTerminal && isTermTab) View.VISIBLE else View.GONE
container.visibility = if (isCustomKeyboard && !ckbHidden && showTerminal && isTermTab) android.view.View.VISIBLE else android.view.View.GONE
val screenHeight = container.resources.displayMetrics.heightPixels
val kbHeightPx = (screenHeight * keyboardHeightPercent).toInt()
val params = container.layoutParams
@ -703,6 +747,7 @@ class MainActivity : AppCompatActivity() {
}
}
)
} // key(keyboardLanguage, numberRowMode)
}
}
@ -896,8 +941,20 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
FileLogger.log(TAG, "onNewIntent action=${intent.action}") // TRACE
FileLogger.log(TAG, "onNewIntent action=${intent.action} extras=${intent.extras?.keySet()}") // TRACE
handleSharedImage(intent)
handleSessionNavigation(intent)
}
/** Navigate to a specific session when launched from disconnect notification. */
private fun handleSessionNavigation(intent: Intent?) {
val sessionId = intent?.getLongExtra("sessionId", -1L) ?: -1L
if (sessionId >= 0) {
FileLogger.log(TAG, "handleSessionNavigation: sessionId=$sessionId") // TRACE
// Clear the extra so we don't re-navigate on config change
intent?.removeExtra("sessionId")
mainViewModel.switchToTerminal(sessionId)
}
}
override fun onResume() {
@ -977,6 +1034,8 @@ class MainActivity : AppCompatActivity() {
/** Profile name from launch intent — connected once the service is bound. */
private var pendingProfile: String? = null
/** Session ID from disconnect notification — navigated once the service is bound. */
private var pendingSessionId: Long = -1L
private fun handleLaunchExtras(intent: Intent?) {
intent ?: return
@ -997,6 +1056,19 @@ class MainActivity : AppCompatActivity() {
pendingProfile = null
}
}
// Navigate to session from disconnect notification (fresh Activity launch)
val sessionId = intent.getLongExtra("sessionId", -1L)
if (sessionId >= 0) {
FileLogger.log(TAG, "handleLaunchExtras: sessionId=$sessionId") // TRACE
pendingSessionId = sessionId
intent.removeExtra("sessionId")
// If service is already bound, navigate now; otherwise onServiceBound handles it
if (serviceBound) {
mainViewModel.switchToTerminal(sessionId)
pendingSessionId = -1L
}
}
}
// ========================================================================

View file

@ -42,10 +42,11 @@ enum class TabType { TERMINAL, TELNET, LOCAL, SFTP }
data class ConnectionSessionInfo(
val terminalCount: Int = 0,
val sftpCount: Int = 0,
val disconnectedCount: Int = 0,
val terminalEarliestStart: Long = 0L,
val sftpEarliestStart: Long = 0L
) {
val totalCount: Int get() = terminalCount + sftpCount
val totalCount: Int get() = terminalCount + sftpCount + disconnectedCount
val earliestStart: Long get() = when {
terminalEarliestStart > 0 && sftpEarliestStart > 0 -> minOf(terminalEarliestStart, sftpEarliestStart)
terminalEarliestStart > 0 -> terminalEarliestStart
@ -533,12 +534,14 @@ class MainViewModel @Inject constructor(
// Terminal/Telnet sessions
for (entry in svc.getAllSessions()) {
val state = _activeSessions.value[entry.sessionId]
val connId = entry.savedConnectionId
val prev = infos[connId] ?: ConnectionSessionInfo()
if (state is com.roundingmobile.ssh.SessionState.Connected || state is com.roundingmobile.ssh.SessionState.Connecting) {
val connId = entry.savedConnectionId
val prev = infos[connId] ?: ConnectionSessionInfo()
val earliest = if (prev.terminalEarliestStart == 0L) entry.createdAt
else minOf(prev.terminalEarliestStart, entry.createdAt)
infos[connId] = prev.copy(terminalCount = prev.terminalCount + 1, terminalEarliestStart = earliest)
} else if (state is com.roundingmobile.ssh.SessionState.Disconnected || state is com.roundingmobile.ssh.SessionState.Error) {
infos[connId] = prev.copy(disconnectedCount = prev.disconnectedCount + 1)
}
}
// SFTP tabs
@ -847,13 +850,14 @@ class MainViewModel @Inject constructor(
prefs.keyRepeatDelay,
prefs.longPressDelay
) { preset, custom, hints, repeat, longPress -> listOf(preset, custom, hints, repeat, longPress) },
prefs.numberRowMode,
combine(
prefs.quickBarPosition,
prefs.quickBarSize,
prefs.qbColorPreset,
prefs.qbColorCustom
) { pos, size, preset, custom -> listOf(pos, size, preset, custom) }
) { kb, colors, qb ->
) { kb, colors, numMode, qb ->
com.roundingmobile.sshworkbench.terminal.KeyboardDisplaySettings(
language = kb[0] as String,
heightPercent = kb[1] as Float,
@ -862,6 +866,7 @@ class MainViewModel @Inject constructor(
showPageIndicators = kb[4] as Boolean,
keyColorPreset = colors[0] as String,
keyColorCustom = colors[1] as String,
numberRowMode = numMode,
showHints = colors[2] as Boolean,
keyRepeatDelay = colors[3] as Int,
longPressDelay = colors[4] as Int,
@ -870,7 +875,7 @@ class MainViewModel @Inject constructor(
qbColorPreset = qb[2] as String,
qbColorCustom = qb[3] as String
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000),
}.stateIn(viewModelScope, SharingStarted.Eagerly,
com.roundingmobile.sshworkbench.terminal.KeyboardDisplaySettings())
}
@ -917,6 +922,7 @@ class MainViewModel @Inject constructor(
terminalPrefs.setShowPageIndicators(settings.showPageIndicators)
terminalPrefs.setKeyColorPreset(settings.keyColorPreset)
terminalPrefs.setKeyColorCustom(settings.keyColorCustom)
terminalPrefs.setNumberRowMode(settings.numberRowMode)
terminalPrefs.setShowKeyHints(settings.showHints)
terminalPrefs.setKeyRepeatDelay(settings.keyRepeatDelay)
terminalPrefs.setLongPressDelay(settings.longPressDelay)

View file

@ -46,6 +46,7 @@ import kotlinx.coroutines.delay
internal val ActiveGreen = Color(0xFF4CAF50)
private val AccentAmber = Color(0xFFFFB020)
private val DisconnectedRed = Color(0xFFE53935)
@OptIn(ExperimentalFoundationApi::class)
@Composable
@ -76,9 +77,10 @@ internal fun ConnectionItem(
.fillMaxWidth()
.then(
if (isActive) {
val barColor = if ((sessionInfo?.terminalCount ?: 0) > 0 || (sessionInfo?.sftpCount ?: 0) > 0) ActiveGreen else DisconnectedRed
Modifier.drawBehind {
drawRect(
color = ActiveGreen,
color = barColor,
topLeft = Offset.Zero,
size = androidx.compose.ui.geometry.Size(4.dp.toPx(), size.height)
)
@ -100,8 +102,11 @@ internal fun ConnectionItem(
verticalAlignment = Alignment.CenterVertically
) {
// Status dot + colored circle
val circleColor = if (isActive) {
val hasConnected = (sessionInfo?.terminalCount ?: 0) > 0 || (sessionInfo?.sftpCount ?: 0) > 0
val circleColor = if (isActive && hasConnected) {
ActiveGreen
} else if (isActive) {
DisconnectedRed
} else if (connection.color != 0) {
Color(connection.color)
} else {
@ -165,6 +170,15 @@ internal fun ConnectionItem(
is24h = is24h
)
}
// Disconnected sessions line
if (sessionInfo.disconnectedCount > 0) {
if (sessionInfo.terminalCount > 0 || sessionInfo.sftpCount > 0) Spacer(modifier = Modifier.height(2.dp))
SessionTypeLabel(
count = sessionInfo.disconnectedCount,
color = DisconnectedRed,
label = stringResource(R.string.notification_count_disconnected, sessionInfo.disconnectedCount)
)
}
} else if (isActive && sessionInfo != null) {
// Session tracking disabled — just show count per type
if (sessionInfo.terminalCount > 0) {
@ -174,6 +188,10 @@ internal fun ConnectionItem(
if (sessionInfo.terminalCount > 0) Spacer(modifier = Modifier.height(2.dp))
SessionTypeDot(sessionInfo.sftpCount, AccentAmber)
}
if (sessionInfo.disconnectedCount > 0) {
if (sessionInfo.terminalCount > 0 || sessionInfo.sftpCount > 0) Spacer(modifier = Modifier.height(2.dp))
SessionTypeDot(sessionInfo.disconnectedCount, DisconnectedRed)
}
} else {
Text(
text = "${connection.username}@${connection.host}:${connection.port}",
@ -206,12 +224,13 @@ internal fun ConnectionItem(
Spacer(modifier = Modifier.width(8.dp))
if (isActive) {
// Simple green dot — per-type details are in the text lines
// Status dot — green if any connected, red if only disconnected
val dotColor = if (hasConnected) ActiveGreen else DisconnectedRed
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(ActiveGreen)
.background(dotColor)
)
} else {
Icon(
@ -282,6 +301,33 @@ private fun SessionTypeLine(
}
}
@Composable
private fun SessionTypeLabel(count: Int, color: Color, label: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(18.dp)
.clip(CircleShape)
.background(color),
contentAlignment = Alignment.Center
) {
Text(
text = "$count",
color = Color.White,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = color.copy(alpha = 0.85f)
)
}
}
@Composable
private fun SessionTypeDot(count: Int, color: Color) {
Row(verticalAlignment = Alignment.CenterVertically) {

View file

@ -22,6 +22,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.KeyboardHide
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
@ -96,6 +98,8 @@ fun SessionTabBar(
onPlusTap: () -> Unit,
onKbSettings: () -> Unit = {},
kbSettingsLabel: String = "",
ckbVisible: Boolean = false,
onToggleCkb: () -> Unit = {},
onDuplicate: (Long) -> Unit = {},
onSftp: (Long) -> Unit = {},
onConnectTerminal: (Long) -> Unit = {},
@ -178,6 +182,19 @@ fun SessionTabBar(
onClick = { showBarMenu = false; onPlusTap() },
leadingIcon = { Icon(Icons.Filled.Add, contentDescription = null, tint = AccentTeal) }
)
if (ckbVisible) {
DropdownMenuItem(
text = { Text(stringResource(R.string.hide_keyboard)) },
onClick = { showBarMenu = false; onToggleCkb() },
leadingIcon = { Icon(Icons.Filled.KeyboardHide, contentDescription = null) }
)
} else {
DropdownMenuItem(
text = { Text(stringResource(R.string.show_keyboard)) },
onClick = { showBarMenu = false; onToggleCkb() },
leadingIcon = { Icon(Icons.Filled.Keyboard, contentDescription = null) }
)
}
DropdownMenuItem(
text = { Text(kbSettingsLabel.ifEmpty { stringResource(R.string.quick_bar_settings) }) },
onClick = { showBarMenu = false; onKbSettings() },

View file

@ -214,6 +214,7 @@
<!-- Keyboard Settings Dialog -->
<string name="hide_keyboard">Ocultar teclado</string>
<string name="show_keyboard">Mostrar teclado</string>
<string name="keyboard_settings_title">Ajustes de teclado</string>
<string name="unsaved_changes_title">Cambios sin guardar</string>
<string name="unsaved_changes_message">¿Guardar los cambios?</string>
@ -221,6 +222,11 @@
<string name="size_portrait">Tamaño (vertical)</string>
<string name="size_landscape">Tamaño (horizontal)</string>
<string name="same_size_both">Mismo tamaño en ambas orientaciones</string>
<string name="number_row">Fila de números</string>
<string name="number_row_top">Fila superior</string>
<string name="number_row_left">Lado izquierdo</string>
<string name="number_row_right">Lado derecho</string>
<string name="number_row_hidden">Oculta (pulsación larga)</string>
<string name="show_page_indicators">Mostrar indicadores de página</string>
<string name="key_color">Color de teclas</string>
<string name="show_key_hints">Mostrar sugerencias de teclas</string>

View file

@ -213,6 +213,7 @@
<string name="agent_forwarding_feature">Agentvidarebefordran</string>
<!-- Keyboard Settings Dialog -->
<string name="hide_keyboard">Dölj tangentbord</string>
<string name="show_keyboard">Visa tangentbord</string>
<string name="keyboard_settings_title">Tangentbordsinställningar</string>
<string name="unsaved_changes_title">Osparade ändringar</string>
<string name="unsaved_changes_message">Spara dina ändringar?</string>
@ -220,6 +221,11 @@
<string name="size_portrait">Storlek (stående)</string>
<string name="size_landscape">Storlek (liggande)</string>
<string name="same_size_both">Samma storlek i båda orienteringarna</string>
<string name="number_row">Sifferrad</string>
<string name="number_row_top">Översta raden</string>
<string name="number_row_left">Vänster sida</string>
<string name="number_row_right">Höger sida</string>
<string name="number_row_hidden">Dold (långtryck)</string>
<string name="show_page_indicators">Visa sidindikatorer</string>
<string name="key_color">Tangentfärg</string>
<string name="show_key_hints">Visa tangenttips</string>

View file

@ -215,6 +215,7 @@
<!-- Keyboard Settings Dialog -->
<string name="hide_keyboard">Hide keyboard</string>
<string name="show_keyboard">Show keyboard</string>
<string name="keyboard_settings_title">Keyboard Settings</string>
<string name="unsaved_changes_title">Unsaved changes</string>
<string name="unsaved_changes_message">Save your changes?</string>
@ -222,6 +223,11 @@
<string name="size_portrait">Size (portrait)</string>
<string name="size_landscape">Size (landscape)</string>
<string name="same_size_both">Same size for both orientations</string>
<string name="number_row">Number row</string>
<string name="number_row_top">Top row</string>
<string name="number_row_left">Left side</string>
<string name="number_row_right">Right side</string>
<string name="number_row_hidden">Hidden (long-press)</string>
<string name="show_page_indicators">Show page indicators</string>
<string name="key_color">Key color</string>
<string name="show_key_hints">Show key hints</string>

View file

@ -154,7 +154,7 @@ Key files:
## Temporary Hide (CKB only)
- Gear long-press → "Hide Keyboard" sets `ckbHidden` Compose state (not a pref change)
- Tab bar kebab menu → "Hide keyboard" / "Show keyboard" toggles `ckbHidden` Compose state (not a pref change)
- CKB view hidden, QB stays visible
- Tapping TV fires `onTapShowKeyboard` → resets `ckbHidden`
- Does not affect AKB mode (system IME has its own show/hide)

View file

@ -51,6 +51,9 @@ class TerminalKeyboard private constructor(
// --- Views ---
private var keyboardView: KeyboardView? = null
/** Returns the keyboard view for external layout adjustments (padding, margins). */
fun getKeyboardView(): KeyboardView? = keyboardView
var quickBarView: QuickBarView? = null
private set
private var hintPopup: HintPopupView? = null
@ -257,6 +260,31 @@ class TerminalKeyboard private constructor(
}
}
// --- Mini number pad ---
private var miniPageView: KeyboardPageView? = null
private var miniContainer: ViewGroup? = null
/** The mini section definition from the layout, if any. */
val miniSection get() = layout.mini
@SuppressLint("ClickableViewAccessibility")
fun attachMiniTo(container: ViewGroup) {
miniPageView?.let { (it.parent as? ViewGroup)?.removeView(it) }
val mini = layout.mini ?: return
if (mini.rows.isEmpty()) return
this.miniContainer = container
val page = KeyboardPage(id = "mini", name = "Mini", rows = mini.rows)
val pv = KeyboardPageView(context, page, theme)
pv.setOnTouchListener { _, event -> handlePageTouch(pv, event) }
container.addView(pv, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
))
miniPageView = pv
}
fun detach() {
modStateJob?.cancel()
keyRepeatHandler.destroy()
@ -266,11 +294,14 @@ class TerminalKeyboard private constructor(
quickBarView?.let { (it.parent as? ViewGroup)?.removeView(it) }
hintPopup?.let { (it.parent as? ViewGroup)?.removeView(it) }
longPressPopup?.let { (it.parent as? ViewGroup)?.removeView(it) }
miniPageView?.let { (it.parent as? ViewGroup)?.removeView(it) }
keyboardView = null
quickBarView = null
hintPopup = null
longPressPopup = null
miniPageView = null
container = null
miniContainer = null
}
// --- Touch handling ---

View file

@ -1,10 +1,16 @@
package com.roundingmobile.keyboard.model
data class MiniSection(
val widthPercent: Int = 10,
val rows: List<KeyRow> = emptyList()
)
data class KeyboardLayout(
val id: String,
val name: String,
val pages: List<KeyboardPage>,
val quickBar: QuickBarConfig? = null,
val mini: MiniSection? = null,
val settings: KeyboardSettings = KeyboardSettings()
)

View file

@ -14,6 +14,7 @@ object LayoutParser {
name = obj.getString("name"),
pages = parsePages(obj.getJSONArray("pages"), languagePack),
quickBar = obj.optJSONObject("quickBar")?.let { parseQuickBar(it, languagePack) },
mini = obj.optJSONObject("mini")?.let { parseMiniSection(it, languagePack) },
settings = obj.optJSONObject("settings")?.let { parseSettings(it) } ?: KeyboardSettings()
)
}
@ -147,6 +148,12 @@ object LayoutParser {
infiniteScroll = obj.optBoolean("infiniteScroll", true)
)
private fun parseMiniSection(obj: JSONObject, lang: LanguagePack?): MiniSection =
MiniSection(
widthPercent = obj.optInt("widthPercent", 10),
rows = obj.optJSONArray("rows")?.let { parseRows(it, lang) } ?: emptyList()
)
private fun parseSettings(obj: JSONObject): KeyboardSettings =
KeyboardSettings(
showHints = obj.optBoolean("showHints", true),

View file

@ -41,6 +41,8 @@ class KeyboardPageView(
private var gapPx = 0f
private var radiusPx = 0f
private var paddingPx = 0f
/** Cached typical key height from last layout — drives font scaling */
private var typicalKeyHeight = 0f
init {
updateMetrics()
@ -50,8 +52,22 @@ class KeyboardPageView(
gapPx = dpToPx(theme.keyGapDp.toFloat())
radiusPx = dpToPx(theme.keyRadiusDp.toFloat())
paddingPx = dpToPx(4f)
labelPaint.textSize = spToPx(theme.mainLabelSizeSp.toFloat())
hintPaint.textSize = spToPx(theme.hintLabelSizeSp.toFloat())
updateFontSizes()
}
/** Scale label and hint fonts to a fraction of the typical key height.
* Low absolute floor (6sp/4sp) prevents unreadable text; theme minimums
* are only used as fallback before the first layout pass. */
private fun updateFontSizes() {
if (typicalKeyHeight > 0f) {
val floorLabel = spToPx(10f)
val floorHint = spToPx(7f)
labelPaint.textSize = maxOf(typicalKeyHeight * 0.45f, floorLabel)
hintPaint.textSize = maxOf(typicalKeyHeight * 0.25f, floorHint)
} else {
labelPaint.textSize = spToPx(theme.mainLabelSizeSp.toFloat())
hintPaint.textSize = spToPx(theme.hintLabelSizeSp.toFloat())
}
}
fun setTheme(newTheme: KeyboardTheme) {
@ -113,6 +129,13 @@ class KeyboardPageView(
}
y += rowH + gapPx
}
// Update font sizes based on the tallest key (skip short number-row keys)
val maxKeyH = allKeys.maxOfOrNull { it.bounds.height() } ?: 0f
if (maxKeyH > 0f && maxKeyH != typicalKeyHeight) {
typicalKeyHeight = maxKeyH
updateFontSizes()
}
}
override fun onDraw(canvas: Canvas) {
@ -148,17 +171,24 @@ class KeyboardPageView(
labelPaint.color = theme.resolveKeyText(key.style)
labelPaint.isFakeBoldText = pressed
val displayLabel = resolveDisplayLabel(key)
val fm = labelPaint.fontMetrics
val labelY = if (key.hint != null) {
rect.centerY() - dpToPx(2f)
// Label + hint: label in upper portion, hint below
val totalH = -fm.ascent + fm.descent + hintPaint.textSize
val startY = rect.centerY() - totalH / 2f
startY - fm.ascent
} else {
rect.centerY() + labelPaint.textSize / 3f
// Label only: vertically centered using font metrics
rect.centerY() - (fm.ascent + fm.descent) / 2f
}
canvas.drawText(displayLabel, rect.centerX(), labelY, labelPaint)
// Hint label
if (key.hint != null) {
hintPaint.color = theme.keyHintText
canvas.drawText(key.hint, rect.centerX(), labelY + labelPaint.textSize * 0.9f, hintPaint)
val hintFm = hintPaint.fontMetrics
val hintY = labelY + fm.descent + (-hintFm.ascent)
canvas.drawText(key.hint, rect.centerX(), hintY, hintPaint)
}
}

View file

@ -84,7 +84,7 @@
{ "id": "slash", "label": "/", "w": 0.9, "action": { "type": "char", "primary": "/" } },
{ "id": "dash", "label": "-", "w": 0.9, "action": { "type": "char", "primary": "-" }, "longPress": ["-", "_", "=", "+"] },
{ "id": "comma", "label": ",", "w": 1, "action": { "type": "char", "primary": "," }, "longPress": [",", "<", ">", "(", ")"] },
{ "id": "space", "label": " ", "w": 2, "action": { "type": "char", "primary": " " }, "longPress": ["\u2699"] },
{ "id": "space", "label": " ", "w": 2, "action": { "type": "char", "primary": " " } },
{ "id": "dot", "label": ".", "w": 1, "action": { "type": "char", "primary": "." }, "longPress": [".", ":", ";", "!", "?"] },
{ "id": "enter", "label": "⏎", "w": 1.8, "action": { "type": "bytes", "value": [13] }, "style": "confirm" }
]
@ -165,18 +165,18 @@
{ "id": "f2", "label": "F2", "w": 1, "action": { "type": "esc_seq", "seq": "OQ" }, "style": "fn" },
{ "id": "f3", "label": "F3", "w": 1, "action": { "type": "esc_seq", "seq": "OR" }, "style": "fn" },
{ "id": "f4", "label": "F4", "w": 1, "action": { "type": "esc_seq", "seq": "OS" }, "style": "fn" },
{ "id": "f5", "label": "F5", "w": 1, "action": { "type": "esc_seq", "seq": "[15~" }, "style": "fn" },
{ "id": "f6", "label": "F6", "w": 1, "action": { "type": "esc_seq", "seq": "[17~" }, "style": "fn" }
{ "id": "f5", "label": "F5", "w": 1, "action": { "type": "esc_seq", "seq": "[15~" } },
{ "id": "f6", "label": "F6", "w": 1, "action": { "type": "esc_seq", "seq": "[17~" } }
]
},
{
"keys": [
{ "id": "f7", "label": "F7", "w": 1, "action": { "type": "esc_seq", "seq": "[18~" }, "style": "fn" },
{ "id": "f8", "label": "F8", "w": 1, "action": { "type": "esc_seq", "seq": "[19~" }, "style": "fn" },
{ "id": "f9", "label": "F9", "w": 1, "action": { "type": "esc_seq", "seq": "[20~" }, "style": "fn" },
{ "id": "f10", "label": "F10", "w": 1, "action": { "type": "esc_seq", "seq": "[21~" }, "style": "fn" },
{ "id": "f11", "label": "F11", "w": 1, "action": { "type": "esc_seq", "seq": "[23~" }, "style": "fn" },
{ "id": "f12", "label": "F12", "w": 1, "action": { "type": "esc_seq", "seq": "[24~" }, "style": "fn" }
{ "id": "f7", "label": "F7", "w": 1, "action": { "type": "esc_seq", "seq": "[18~" } },
{ "id": "f8", "label": "F8", "w": 1, "action": { "type": "esc_seq", "seq": "[19~" } },
{ "id": "f9", "label": "F9", "w": 1, "action": { "type": "esc_seq", "seq": "[20~" } },
{ "id": "f10", "label": "F10", "w": 1, "action": { "type": "esc_seq", "seq": "[21~" } },
{ "id": "f11", "label": "F11", "w": 1, "action": { "type": "esc_seq", "seq": "[23~" } },
{ "id": "f12", "label": "F12", "w": 1, "action": { "type": "esc_seq", "seq": "[24~" } }
]
},
{
@ -201,6 +201,30 @@
}
],
"mini": {
"widthPercent": 10,
"rows": [
{ "keys": [
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },
{ "id": "m2", "label": "2", "action": { "type": "char", "primary": "2", "shift": "@" } },
{ "id": "m3", "label": "3", "action": { "type": "char", "primary": "3", "shift": "#" } }
]},
{ "keys": [
{ "id": "m4", "label": "4", "action": { "type": "char", "primary": "4", "shift": "$" } },
{ "id": "m5", "label": "5", "action": { "type": "char", "primary": "5", "shift": "%" } },
{ "id": "m6", "label": "6", "action": { "type": "char", "primary": "6", "shift": "^" } }
]},
{ "keys": [
{ "id": "m7", "label": "7", "action": { "type": "char", "primary": "7", "shift": "&" } },
{ "id": "m8", "label": "8", "action": { "type": "char", "primary": "8", "shift": "*" } },
{ "id": "m9", "label": "9", "action": { "type": "char", "primary": "9", "shift": "(" } }
]},
{ "keys": [
{ "id": "m0", "label": "0", "action": { "type": "char", "primary": "0", "shift": ")" } }
]}
]
},
"quickBar": {
"position": "top",
"orientation": "horizontal",

View file

@ -85,7 +85,7 @@
{ "id": "slash", "label": "/", "w": 0.9, "action": { "type": "char", "primary": "/" } },
{ "id": "dash", "label": "-", "w": 0.9, "action": { "type": "char", "primary": "-" }, "longPress": ["-", "_", "=", "+"] },
{ "id": "comma", "label": ",", "w": 1, "action": { "type": "char", "primary": "," }, "longPress": [",", "<", ">", "(", ")"] },
{ "id": "space", "label": " ", "w": 2, "action": { "type": "char", "primary": " " }, "longPress": ["\u2699"] },
{ "id": "space", "label": " ", "w": 2, "action": { "type": "char", "primary": " " } },
{ "id": "dot", "label": ".", "w": 1, "action": { "type": "char", "primary": "." }, "longPress": [".", ":", ";", "!", "?"] },
{ "id": "enter", "label": "⏎", "w": 1.8, "action": { "type": "bytes", "value": [13] }, "style": "confirm" }
]
@ -166,18 +166,18 @@
{ "id": "f2", "label": "F2", "w": 1, "action": { "type": "esc_seq", "seq": "OQ" }, "style": "fn" },
{ "id": "f3", "label": "F3", "w": 1, "action": { "type": "esc_seq", "seq": "OR" }, "style": "fn" },
{ "id": "f4", "label": "F4", "w": 1, "action": { "type": "esc_seq", "seq": "OS" }, "style": "fn" },
{ "id": "f5", "label": "F5", "w": 1, "action": { "type": "esc_seq", "seq": "[15~" }, "style": "fn" },
{ "id": "f6", "label": "F6", "w": 1, "action": { "type": "esc_seq", "seq": "[17~" }, "style": "fn" }
{ "id": "f5", "label": "F5", "w": 1, "action": { "type": "esc_seq", "seq": "[15~" } },
{ "id": "f6", "label": "F6", "w": 1, "action": { "type": "esc_seq", "seq": "[17~" } }
]
},
{
"keys": [
{ "id": "f7", "label": "F7", "w": 1, "action": { "type": "esc_seq", "seq": "[18~" }, "style": "fn" },
{ "id": "f8", "label": "F8", "w": 1, "action": { "type": "esc_seq", "seq": "[19~" }, "style": "fn" },
{ "id": "f9", "label": "F9", "w": 1, "action": { "type": "esc_seq", "seq": "[20~" }, "style": "fn" },
{ "id": "f10", "label": "F10", "w": 1, "action": { "type": "esc_seq", "seq": "[21~" }, "style": "fn" },
{ "id": "f11", "label": "F11", "w": 1, "action": { "type": "esc_seq", "seq": "[23~" }, "style": "fn" },
{ "id": "f12", "label": "F12", "w": 1, "action": { "type": "esc_seq", "seq": "[24~" }, "style": "fn" }
{ "id": "f7", "label": "F7", "w": 1, "action": { "type": "esc_seq", "seq": "[18~" } },
{ "id": "f8", "label": "F8", "w": 1, "action": { "type": "esc_seq", "seq": "[19~" } },
{ "id": "f9", "label": "F9", "w": 1, "action": { "type": "esc_seq", "seq": "[20~" } },
{ "id": "f10", "label": "F10", "w": 1, "action": { "type": "esc_seq", "seq": "[21~" } },
{ "id": "f11", "label": "F11", "w": 1, "action": { "type": "esc_seq", "seq": "[23~" } },
{ "id": "f12", "label": "F12", "w": 1, "action": { "type": "esc_seq", "seq": "[24~" } }
]
},
{
@ -202,6 +202,30 @@
}
],
"mini": {
"widthPercent": 10,
"rows": [
{ "keys": [
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },
{ "id": "m2", "label": "2", "action": { "type": "char", "primary": "2", "shift": "@" } },
{ "id": "m3", "label": "3", "action": { "type": "char", "primary": "3", "shift": "#" } }
]},
{ "keys": [
{ "id": "m4", "label": "4", "action": { "type": "char", "primary": "4", "shift": "$" } },
{ "id": "m5", "label": "5", "action": { "type": "char", "primary": "5", "shift": "%" } },
{ "id": "m6", "label": "6", "action": { "type": "char", "primary": "6", "shift": "^" } }
]},
{ "keys": [
{ "id": "m7", "label": "7", "action": { "type": "char", "primary": "7", "shift": "&" } },
{ "id": "m8", "label": "8", "action": { "type": "char", "primary": "8", "shift": "*" } },
{ "id": "m9", "label": "9", "action": { "type": "char", "primary": "9", "shift": "(" } }
]},
{ "keys": [
{ "id": "m0", "label": "0", "action": { "type": "char", "primary": "0", "shift": ")" } }
]}
]
},
"quickBar": {
"position": "top",
"orientation": "horizontal",

View file

@ -87,7 +87,7 @@
{ "id": "slash", "label": "/", "w": 0.9, "action": { "type": "char", "primary": "/" } },
{ "id": "dash", "label": "-", "w": 0.9, "action": { "type": "char", "primary": "-" }, "longPress": ["-", "_", "=", "+"] },
{ "id": "comma", "label": ",", "w": 1, "action": { "type": "char", "primary": "," }, "longPress": [",", "<", ">", "(", ")"] },
{ "id": "space", "label": " ", "w": 2, "action": { "type": "char", "primary": " " }, "longPress": ["\u2699"] },
{ "id": "space", "label": " ", "w": 2, "action": { "type": "char", "primary": " " } },
{ "id": "dot", "label": ".", "w": 1, "action": { "type": "char", "primary": "." }, "longPress": [".", ":", ";", "!", "?"] },
{ "id": "enter", "label": "⏎", "w": 1.8, "action": { "type": "bytes", "value": [13] }, "style": "confirm" }
]
@ -168,18 +168,18 @@
{ "id": "f2", "label": "F2", "w": 1, "action": { "type": "esc_seq", "seq": "OQ" }, "style": "fn" },
{ "id": "f3", "label": "F3", "w": 1, "action": { "type": "esc_seq", "seq": "OR" }, "style": "fn" },
{ "id": "f4", "label": "F4", "w": 1, "action": { "type": "esc_seq", "seq": "OS" }, "style": "fn" },
{ "id": "f5", "label": "F5", "w": 1, "action": { "type": "esc_seq", "seq": "[15~" }, "style": "fn" },
{ "id": "f6", "label": "F6", "w": 1, "action": { "type": "esc_seq", "seq": "[17~" }, "style": "fn" }
{ "id": "f5", "label": "F5", "w": 1, "action": { "type": "esc_seq", "seq": "[15~" } },
{ "id": "f6", "label": "F6", "w": 1, "action": { "type": "esc_seq", "seq": "[17~" } }
]
},
{
"keys": [
{ "id": "f7", "label": "F7", "w": 1, "action": { "type": "esc_seq", "seq": "[18~" }, "style": "fn" },
{ "id": "f8", "label": "F8", "w": 1, "action": { "type": "esc_seq", "seq": "[19~" }, "style": "fn" },
{ "id": "f9", "label": "F9", "w": 1, "action": { "type": "esc_seq", "seq": "[20~" }, "style": "fn" },
{ "id": "f10", "label": "F10", "w": 1, "action": { "type": "esc_seq", "seq": "[21~" }, "style": "fn" },
{ "id": "f11", "label": "F11", "w": 1, "action": { "type": "esc_seq", "seq": "[23~" }, "style": "fn" },
{ "id": "f12", "label": "F12", "w": 1, "action": { "type": "esc_seq", "seq": "[24~" }, "style": "fn" }
{ "id": "f7", "label": "F7", "w": 1, "action": { "type": "esc_seq", "seq": "[18~" } },
{ "id": "f8", "label": "F8", "w": 1, "action": { "type": "esc_seq", "seq": "[19~" } },
{ "id": "f9", "label": "F9", "w": 1, "action": { "type": "esc_seq", "seq": "[20~" } },
{ "id": "f10", "label": "F10", "w": 1, "action": { "type": "esc_seq", "seq": "[21~" } },
{ "id": "f11", "label": "F11", "w": 1, "action": { "type": "esc_seq", "seq": "[23~" } },
{ "id": "f12", "label": "F12", "w": 1, "action": { "type": "esc_seq", "seq": "[24~" } }
]
},
{
@ -204,6 +204,30 @@
}
],
"mini": {
"widthPercent": 10,
"rows": [
{ "keys": [
{ "id": "m1", "label": "1", "action": { "type": "char", "primary": "1", "shift": "!" } },
{ "id": "m2", "label": "2", "action": { "type": "char", "primary": "2", "shift": "@" } },
{ "id": "m3", "label": "3", "action": { "type": "char", "primary": "3", "shift": "#" } }
]},
{ "keys": [
{ "id": "m4", "label": "4", "action": { "type": "char", "primary": "4", "shift": "$" } },
{ "id": "m5", "label": "5", "action": { "type": "char", "primary": "5", "shift": "%" } },
{ "id": "m6", "label": "6", "action": { "type": "char", "primary": "6", "shift": "^" } }
]},
{ "keys": [
{ "id": "m7", "label": "7", "action": { "type": "char", "primary": "7", "shift": "&" } },
{ "id": "m8", "label": "8", "action": { "type": "char", "primary": "8", "shift": "*" } },
{ "id": "m9", "label": "9", "action": { "type": "char", "primary": "9", "shift": "(" } }
]},
{ "keys": [
{ "id": "m0", "label": "0", "action": { "type": "char", "primary": "0", "shift": ")" } }
]}
]
},
"quickBar": {
"position": "top",
"orientation": "horizontal",