From e0206b74b829b3fdf915218b67b2b15b48e7095c Mon Sep 17 00:00:00 2001 From: saicharankandukuri Date: Mon, 9 Nov 2026 13:19:35 +0530 Subject: [PATCH 1/3] feat(terminal): add configurable extra keys Add editable multi-row terminal key layouts with labels, macros, modifier lifecycles, drag reordering, and Termux JSON import and export. Persist layouts per app installation and cover parsing, reordering, and input-state behavior with focused tests. --- .../TerminalExtraKeysInstrumentedTest.kt | 47 ++ .../udroid/input/ExtraKeyBinding.kt | 16 + .../udroid/runtime/TerminalExtraKeys.kt | 245 ++++++ .../org/randomcoder/udroid/ui/TerminalPage.kt | 325 ++++++-- .../udroid/ui/TerminalSettingsPage.kt | 782 +++++++++++++++++- .../udroid/runtime/TerminalExtraKeysTest.kt | 41 + .../udroid/ui/TerminalModifierStateTest.kt | 53 ++ .../udroid/ui/TerminalSettingsPageTest.kt | 66 ++ 8 files changed, 1485 insertions(+), 90 deletions(-) create mode 100644 app/src/androidTest/java/org/randomcoder/udroid/runtime/TerminalExtraKeysInstrumentedTest.kt create mode 100644 app/src/main/java/org/randomcoder/udroid/input/ExtraKeyBinding.kt create mode 100644 app/src/main/java/org/randomcoder/udroid/runtime/TerminalExtraKeys.kt create mode 100644 app/src/test/java/org/randomcoder/udroid/runtime/TerminalExtraKeysTest.kt create mode 100644 app/src/test/java/org/randomcoder/udroid/ui/TerminalModifierStateTest.kt diff --git a/app/src/androidTest/java/org/randomcoder/udroid/runtime/TerminalExtraKeysInstrumentedTest.kt b/app/src/androidTest/java/org/randomcoder/udroid/runtime/TerminalExtraKeysInstrumentedTest.kt new file mode 100644 index 0000000..efc221c --- /dev/null +++ b/app/src/androidTest/java/org/randomcoder/udroid/runtime/TerminalExtraKeysInstrumentedTest.kt @@ -0,0 +1,47 @@ +package org.randomcoder.udroid.runtime + +import android.view.KeyEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.randomcoder.udroid.input.ExtraKeyBinding + +@RunWith(AndroidJUnit4::class) +class TerminalExtraKeysInstrumentedTest { + @Test + fun importsTermuxJsonSyntaxAndReportsDroppedPopups() { + val imported = + TerminalExtraKeys.parseTermuxJson( + "[['ESC',{key:'-',popup:'|'}],['CTRL','LEFT']]", + ) + + assertEquals( + listOf( + listOf(TerminalExtraKeys.binding(KeyEvent.KEYCODE_ESCAPE), TerminalExtraKeys.binding(KeyEvent.KEYCODE_MINUS)), + listOf(TerminalExtraKeys.binding(KeyEvent.KEYCODE_CTRL_LEFT), TerminalExtraKeys.binding(KeyEvent.KEYCODE_DPAD_LEFT)), + ), + imported.rows, + ) + assertEquals(1, imported.ignoredPopups) + } + + @Test + fun importsAndExportsMacrosAndDisplayLabels() { + val imported = + TerminalExtraKeys.parseTermuxJson( + "[[{macro:'CTRL f d',display:'tmux ๐Ÿšช'},{key:'ESC',display:'๐Ÿšช'}]]", + ) + + assertEquals( + listOf( + listOf( + ExtraKeyBinding("CTRL f d", macro = true, display = "tmux ๐Ÿšช"), + ExtraKeyBinding("ESC", display = "๐Ÿšช"), + ), + ), + imported.rows, + ) + assertEquals(imported.rows, TerminalExtraKeys.parseTermuxJson(TerminalExtraKeys.toTermuxJson(imported.rows)).rows) + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/input/ExtraKeyBinding.kt b/app/src/main/java/org/randomcoder/udroid/input/ExtraKeyBinding.kt new file mode 100644 index 0000000..e123d23 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/input/ExtraKeyBinding.kt @@ -0,0 +1,16 @@ +package org.randomcoder.udroid.input + +data class ExtraKeyBinding( + val action: String, + val macro: Boolean = false, + val display: String? = null, +) { + fun normalized(): ExtraKeyBinding = + copy( + action = action.trim(), + display = display?.trim()?.takeIf(String::isNotEmpty), + ) + + fun tokens(): List = + if (macro) action.trim().split(Regex("\\s+")).filter(String::isNotEmpty) else listOf(action.trim()) +} diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/TerminalExtraKeys.kt b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalExtraKeys.kt new file mode 100644 index 0000000..4fbd77b --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/runtime/TerminalExtraKeys.kt @@ -0,0 +1,245 @@ +package org.randomcoder.udroid.runtime + +import android.content.Context +import android.view.KeyEvent +import org.json.JSONArray +import org.json.JSONObject +import org.randomcoder.udroid.input.ExtraKeyBinding +import org.randomcoder.udroid.x11.OverlayKeyboard + +data class TerminalExtraKeysState( + val visible: Boolean = true, + val rows: List> = TerminalExtraKeys.defaults, +) { + fun normalized(): TerminalExtraKeysState = copy(rows = TerminalExtraKeys.normalized(rows)) +} + +data class TerminalExtraKeysImport( + val rows: List>, + val ignoredPopups: Int, +) + +object TerminalExtraKeys { + val defaults: List> = + listOf( + listOf( + ExtraKeyBinding("CTRL"), + ExtraKeyBinding("ALT"), + ExtraKeyBinding("ESC"), + ExtraKeyBinding("TAB"), + ExtraKeyBinding("HOME"), + ExtraKeyBinding("END"), + ), + listOf( + ExtraKeyBinding("SHIFT"), + ExtraKeyBinding("ENTER"), + ExtraKeyBinding("LEFT"), + ExtraKeyBinding("UP"), + ExtraKeyBinding("DOWN"), + ExtraKeyBinding("RIGHT"), + ), + ) + + val keys: List = + OverlayKeyboard.keys.filterNot { + it < 0 || it == KeyEvent.KEYCODE_META_LEFT || it == KeyEvent.KEYCODE_META_RIGHT + } + KeyEvent.KEYCODE_FUNCTION + + fun normalized(rows: List>): List> { + val valid = + rows.mapNotNull { row -> + row.mapNotNull { candidate -> + candidate.normalized().takeIf { it.action.isNotEmpty() } + }.takeIf(List::isNotEmpty) + } + return valid.takeIf(List>::isNotEmpty) ?: defaults + } + + fun binding(keyCode: Int): ExtraKeyBinding = ExtraKeyBinding(termuxName(keyCode)) + + fun label(binding: ExtraKeyBinding): String { + if (binding.display != null) return binding.display + if (binding.macro) return binding.action + return keyCode(binding.action)?.let(::label) ?: binding.action + } + + fun label(keyCode: Int): String = + if (keyCode == KeyEvent.KEYCODE_FUNCTION) "Fn" else OverlayKeyboard.label(keyCode) + + fun isModifier(keyCode: Int): Boolean = + OverlayKeyboard.isModifier(keyCode) || keyCode == KeyEvent.KEYCODE_FUNCTION + + fun parseTermuxJson(text: String): TerminalExtraKeysImport { + val root = JSONArray(text.trim()) + require(root.length() > 0) { "Add at least one key row" } + var ignoredPopups = 0 + val rows = + (0 until root.length()).map { rowIndex -> + val row = root.optJSONArray(rowIndex) + ?: error("Row ${rowIndex + 1} must be a JSON array") + require(row.length() > 0) { + "Row ${rowIndex + 1} must contain at least one key" + } + (0 until row.length()).map { columnIndex -> + val value = row.get(columnIndex) + val binding = + when (value) { + is String -> ExtraKeyBinding(value) + is JSONObject -> { + if (value.has("popup")) ignoredPopups += 1 + val key = value.optString("key").takeIf(String::isNotBlank) + val macro = value.optString("macro").takeIf(String::isNotBlank) + require((key == null) != (macro == null)) { + "Set either key or macro at row ${rowIndex + 1}, key ${columnIndex + 1}" + } + ExtraKeyBinding( + action = key ?: macro.orEmpty(), + macro = macro != null, + display = value.optString("display").takeIf(String::isNotBlank), + ) + } + else -> error("Invalid key at row ${rowIndex + 1}, key ${columnIndex + 1}") + } + require(binding.tokens().all { keyCode(it) != null || it.isNotEmpty() }) { + "Invalid action at row ${rowIndex + 1}, key ${columnIndex + 1}" + } + binding + } + } + return TerminalExtraKeysImport(normalized(rows), ignoredPopups) + } + + fun toTermuxJson(rows: List>): String = + JSONArray().apply { + normalized(rows).forEach { row -> + put( + JSONArray().apply { + row.forEach { binding -> + if (!binding.macro && binding.display == null) { + put(binding.action) + } else { + put( + JSONObject().apply { + put(if (binding.macro) "macro" else "key", binding.action) + binding.display?.let { put("display", it) } + }, + ) + } + } + }, + ) + } + }.toString(2) + + fun keyCode(rawName: String): Int? { + val name = rawName.trim() + val upper = name.uppercase() + return when { + upper.length == 1 && upper[0] in 'A'..'Z' -> + KeyEvent.KEYCODE_A + (upper[0] - 'A') + upper.length == 1 && upper[0] in '0'..'9' -> + KeyEvent.KEYCODE_0 + (upper[0] - '0') + upper.startsWith("F") && upper.drop(1).toIntOrNull() in 1..12 -> + KeyEvent.KEYCODE_F1 + (upper.drop(1).toInt() - 1) + else -> TERMUX_TO_KEY_CODE[upper] + } + } + + fun termuxName(keyCode: Int): String = + when { + keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> + ('A' + (keyCode - KeyEvent.KEYCODE_A)).toString() + keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> + ('0' + (keyCode - KeyEvent.KEYCODE_0)).toString() + keyCode in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> + "F${keyCode - KeyEvent.KEYCODE_F1 + 1}" + else -> KEY_CODE_TO_TERMUX[keyCode] ?: error("Unsupported key code $keyCode") + } + + private val TERMUX_TO_KEY_CODE = + mapOf( + "CTRL" to KeyEvent.KEYCODE_CTRL_LEFT, + "CONTROL" to KeyEvent.KEYCODE_CTRL_LEFT, + "ALT" to KeyEvent.KEYCODE_ALT_LEFT, + "SHIFT" to KeyEvent.KEYCODE_SHIFT_LEFT, + "SHFT" to KeyEvent.KEYCODE_SHIFT_LEFT, + "FN" to KeyEvent.KEYCODE_FUNCTION, + "FUNCTION" to KeyEvent.KEYCODE_FUNCTION, + "ESC" to KeyEvent.KEYCODE_ESCAPE, + "ESCAPE" to KeyEvent.KEYCODE_ESCAPE, + "TAB" to KeyEvent.KEYCODE_TAB, + "SPACE" to KeyEvent.KEYCODE_SPACE, + "ENTER" to KeyEvent.KEYCODE_ENTER, + "RETURN" to KeyEvent.KEYCODE_ENTER, + "BKSP" to KeyEvent.KEYCODE_DEL, + "BACKSPACE" to KeyEvent.KEYCODE_DEL, + "DEL" to KeyEvent.KEYCODE_FORWARD_DEL, + "DELETE" to KeyEvent.KEYCODE_FORWARD_DEL, + "UP" to KeyEvent.KEYCODE_DPAD_UP, + "LEFT" to KeyEvent.KEYCODE_DPAD_LEFT, + "DOWN" to KeyEvent.KEYCODE_DPAD_DOWN, + "RIGHT" to KeyEvent.KEYCODE_DPAD_RIGHT, + "HOME" to KeyEvent.KEYCODE_MOVE_HOME, + "END" to KeyEvent.KEYCODE_MOVE_END, + "PGUP" to KeyEvent.KEYCODE_PAGE_UP, + "PAGEUP" to KeyEvent.KEYCODE_PAGE_UP, + "PAGE_UP" to KeyEvent.KEYCODE_PAGE_UP, + "PGDN" to KeyEvent.KEYCODE_PAGE_DOWN, + "PAGEDOWN" to KeyEvent.KEYCODE_PAGE_DOWN, + "PAGE_DOWN" to KeyEvent.KEYCODE_PAGE_DOWN, + "INS" to KeyEvent.KEYCODE_INSERT, + "INSERT" to KeyEvent.KEYCODE_INSERT, + "-" to KeyEvent.KEYCODE_MINUS, + "=" to KeyEvent.KEYCODE_EQUALS, + "[" to KeyEvent.KEYCODE_LEFT_BRACKET, + "]" to KeyEvent.KEYCODE_RIGHT_BRACKET, + "\\" to KeyEvent.KEYCODE_BACKSLASH, + ";" to KeyEvent.KEYCODE_SEMICOLON, + "'" to KeyEvent.KEYCODE_APOSTROPHE, + "," to KeyEvent.KEYCODE_COMMA, + "." to KeyEvent.KEYCODE_PERIOD, + "/" to KeyEvent.KEYCODE_SLASH, + ) + + private val KEY_CODE_TO_TERMUX = + TERMUX_TO_KEY_CODE.entries + .filterNot { + it.key in + setOf( + "CONTROL", "SHFT", "FUNCTION", "ESCAPE", "RETURN", + "BACKSPACE", "DELETE", "PAGEUP", "PAGE_UP", "PAGEDOWN", + "PAGE_DOWN", "INSERT", + ) + } + .associate { (name, keyCode) -> keyCode to name } +} + +class TerminalExtraKeysStore(context: Context) { + private val preferences = + context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + fun load(): TerminalExtraKeysState { + val rows = + preferences.getString(KEY_ROWS, null)?.let { saved -> + runCatching { TerminalExtraKeys.parseTermuxJson(saved).rows }.getOrNull() + } ?: TerminalExtraKeys.defaults + return TerminalExtraKeysState( + visible = preferences.getBoolean(KEY_VISIBLE, true), + rows = rows, + ).normalized() + } + + fun save(state: TerminalExtraKeysState): TerminalExtraKeysState = + state.normalized().also { normalized -> + preferences.edit() + .putBoolean(KEY_VISIBLE, normalized.visible) + .putString(KEY_ROWS, TerminalExtraKeys.toTermuxJson(normalized.rows)) + .apply() + } + + private companion object { + const val PREFERENCES_NAME = "terminal-extra-keys" + const val KEY_VISIBLE = "visible" + const val KEY_ROWS = "rows" + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt index 6329b8d..da6a103 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/TerminalPage.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.os.SystemClock import android.util.Log import android.view.KeyEvent import android.view.MotionEvent @@ -15,6 +16,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -41,7 +43,7 @@ import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material.icons.outlined.MoreVert -import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -70,6 +72,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -84,6 +92,10 @@ import org.randomcoder.udroid.runtime.RuntimePhase import org.randomcoder.udroid.runtime.RuntimeSnapshot import org.randomcoder.udroid.runtime.RuntimeSupervisorService import org.randomcoder.udroid.runtime.TerminalColorProfile +import org.randomcoder.udroid.runtime.TerminalExtraKeys +import org.randomcoder.udroid.runtime.TerminalExtraKeysState +import org.randomcoder.udroid.runtime.TerminalExtraKeysStore +import org.randomcoder.udroid.input.ExtraKeyBinding import org.randomcoder.udroid.runtime.TerminalProfileState import org.randomcoder.udroid.runtime.TerminalTypographyState import org.randomcoder.udroid.runtime.TerminalTypographyStore @@ -111,6 +123,7 @@ fun InteractiveTerminalPage( onStop: () -> Unit, onExit: () -> Unit, ) { + val context = LocalContext.current val tabs = service?.terminalTabSnapshots().orEmpty() val activeTab = tabs.firstOrNull(TerminalTabSnapshot::active) val session = service?.currentTerminalSession() @@ -120,7 +133,9 @@ fun InteractiveTerminalPage( var pendingCloseTabId by remember { mutableStateOf(null) } var creatingTab by remember { mutableStateOf(false) } var showDistroPicker by remember { mutableStateOf(false) } - var showAppearance by remember { mutableStateOf(false) } + var showSettings by remember { mutableStateOf(false) } + val extraKeysStore = remember(context) { TerminalExtraKeysStore(context) } + var extraKeysState by remember(extraKeysStore) { mutableStateOf(extraKeysStore.load()) } val stopping = stopRequested || snapshot.phase == RuntimePhase.STOPPING fun createTab(rootfsName: String?) { @@ -145,16 +160,20 @@ fun InteractiveTerminalPage( } } - if (showAppearance) { + if (showSettings) { TerminalSettingsPage( modifier = modifier, state = profileState, typographyState = typographyState, - onBack = { showAppearance = false }, + extraKeysState = extraKeysState, + onBack = { showSettings = false }, onSelectProfile = onSelectProfile, onSaveProfile = onSaveProfile, onDeleteProfile = onDeleteProfile, onTypographyChanged = onTypographyChanged, + onExtraKeysChanged = { + extraKeysState = extraKeysStore.save(it) + }, ) return } @@ -172,7 +191,7 @@ fun InteractiveTerminalPage( stopping = stopping, onExit = onExit, onChooseDistro = { showDistroPicker = true }, - onShowAppearance = { showAppearance = true }, + onShowSettings = { showSettings = true }, onStop = { stopRequested = true onStop() @@ -229,6 +248,7 @@ fun InteractiveTerminalPage( service = service, session = session, typography = typographyState, + extraKeys = extraKeysState, onTypographyChanged = onTypographyChanged, ) } @@ -301,7 +321,7 @@ private fun TerminalSessionBar( stopping: Boolean, onExit: () -> Unit, onChooseDistro: () -> Unit, - onShowAppearance: () -> Unit, + onShowSettings: () -> Unit, onStop: () -> Unit, ) { val tabClosing = @@ -403,10 +423,10 @@ private fun TerminalSessionBar( } else { Spacer(Modifier.width(48.dp)) } - IconButton(onClick = onShowAppearance) { + IconButton(onClick = onShowSettings) { Icon( - imageVector = Icons.Outlined.Palette, - contentDescription = "Terminal appearance", + imageVector = Icons.Outlined.Settings, + contentDescription = "Terminal settings", tint = UdroidTerminalMuted, ) } @@ -885,6 +905,7 @@ private fun LiveTerminal( service: RuntimeSupervisorService, session: TerminalSession, typography: TerminalTypographyState, + extraKeys: TerminalExtraKeysState, onTypographyChanged: (TerminalTypographyState) -> Unit, ) { val context = LocalContext.current @@ -938,6 +959,7 @@ private fun LiveTerminal( DisposableEffect(service, terminalView) { service.attachTerminalView(terminalView) onDispose { + modifiers.reset() client.stopCursorBlinking() service.detachTerminalView(terminalView) } @@ -958,10 +980,13 @@ private fun LiveTerminal( .weight(1f) .fillMaxWidth(), ) - ExtraKeysRow( - modifiers = modifiers, - write = service::writeToTerminal, - ) + if (extraKeys.visible) { + TerminalExtraKeysRail( + rows = extraKeys.rows, + modifiers = modifiers, + terminalView = terminalView, + ) + } } pendingLink?.let { uri -> AlertDialog( @@ -1007,78 +1032,237 @@ internal fun terminalLinkCanOpen(scheme: String?): Boolean = scheme.equals("http", true) || scheme.equals("https", true) || scheme.equals("mailto", true) @Composable -private fun ExtraKeysRow( +private fun TerminalExtraKeysRail( + rows: List>, modifiers: TerminalModifierState, - write: (String) -> Unit, + terminalView: TerminalView, ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .height(58.dp) .background(UdroidTerminalSurface) - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 7.dp, vertical = 5.dp), - horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - TerminalKey( - label = "Ctrl", - active = modifiers.control, - onClick = { modifiers.control = !modifiers.control }, - ) - TerminalKey( - label = "Alt", - active = modifiers.alt, - onClick = { modifiers.alt = !modifiers.alt }, - ) - TerminalKey("Esc") { write("\u001B") } - TerminalKey("Tab") { write("\t") } - TerminalKey("โ†") { write("\u001B[D") } - TerminalKey("โ†‘") { write("\u001B[A") } - TerminalKey("โ†“") { write("\u001B[B") } - TerminalKey("โ†’") { write("\u001B[C") } - TerminalKey("Home") { write("\u001B[H") } - TerminalKey("PgUp") { write("\u001B[5~") } - TerminalKey("PgDn") { write("\u001B[6~") } - TerminalKey("End") { write("\u001B[F") } + var slot = 0 + rows.forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + row.forEach { binding -> + val keySlot = slot++ + val keyCode = binding.takeUnless(ExtraKeyBinding::macro)?.let { TerminalExtraKeys.keyCode(it.action) } + TerminalExtraKey( + modifier = Modifier.weight(1f), + slot = keySlot, + binding = binding, + active = keyCode?.let(modifiers::isActive) == true, + onPressed = { pressed -> + if (keyCode == null) return@TerminalExtraKey + val now = SystemClock.uptimeMillis() + modifiers.onKeyEvent(keySlot, keyCode, pressed, now) { code, down -> + val action = if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP + val event = KeyEvent(now, now, action, code, 0) + if (down) terminalView.onKeyDown(code, event) else terminalView.onKeyUp(code, event) + } + }, + onMacro = { sendTerminalMacro(binding, terminalView) }, + ) + } + } + } } } @Composable -private fun TerminalKey( - label: String, - active: Boolean = false, - onClick: () -> Unit, +private fun TerminalExtraKey( + modifier: Modifier, + slot: Int, + binding: ExtraKeyBinding, + active: Boolean, + onPressed: (Boolean) -> Unit, + onMacro: () -> Unit, ) { + val label = TerminalExtraKeys.label(binding) Box( modifier = - Modifier - .width(if (label.length > 2) 56.dp else 48.dp) - .height(48.dp) + modifier + .height(34.dp) .background( - color = - if (active) { - MaterialTheme.colorScheme.primaryContainer - } else { - UdroidTerminalRaised - }, + color = if (active) MaterialTheme.colorScheme.primaryContainer else UdroidTerminalRaised, shape = RoundedCornerShape(8.dp), - ) - .clickable(onClick = onClick), + ).semantics { + role = Role.Button + contentDescription = + if (binding.display == null) "$label extra key" else "$label, ${binding.action} extra key" + onClick { + if (binding.macro || TerminalExtraKeys.keyCode(binding.action) == null) { + onMacro() + } else { + onPressed(true) + onPressed(false) + } + true + } + }.pointerInput(slot, binding) { + if (binding.macro || TerminalExtraKeys.keyCode(binding.action) == null) { + detectTapGestures(onTap = { onMacro() }) + } else { + detectTapGestures( + onPress = { + onPressed(true) + tryAwaitRelease() + onPressed(false) + }, + ) + } + }, contentAlignment = Alignment.Center, ) { Text( label, color = if (active) UdroidTerminalGreen else UdroidTerminalText, - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } -private class TerminalModifierState { - var control by mutableStateOf(false) - var alt by mutableStateOf(false) +private fun sendTerminalMacro( + binding: ExtraKeyBinding, + terminalView: TerminalView, +) { + val tokens = binding.tokens() + val modifiers = tokens.mapNotNull(TerminalExtraKeys::keyCode).filter(TerminalExtraKeys::isModifier).toSet() + val metaState = + (if (KeyEvent.KEYCODE_CTRL_LEFT in modifiers) KeyEvent.META_CTRL_LEFT_ON or KeyEvent.META_CTRL_ON else 0) or + (if (KeyEvent.KEYCODE_ALT_LEFT in modifiers) KeyEvent.META_ALT_LEFT_ON or KeyEvent.META_ALT_ON else 0) or + (if (KeyEvent.KEYCODE_SHIFT_LEFT in modifiers) KeyEvent.META_SHIFT_LEFT_ON or KeyEvent.META_SHIFT_ON else 0) or + (if (KeyEvent.KEYCODE_FUNCTION in modifiers) KeyEvent.META_FUNCTION_ON else 0) + tokens.forEach { token -> + val keyCode = TerminalExtraKeys.keyCode(token) + if (keyCode?.let(TerminalExtraKeys::isModifier) == true) return@forEach + if (keyCode != null) { + val now = SystemClock.uptimeMillis() + terminalView.onKeyDown(keyCode, KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, 0, metaState)) + terminalView.onKeyUp(keyCode, KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0, metaState)) + } else { + token.codePoints().forEach { terminalView.inputCodePoint(it, metaState and KeyEvent.META_CTRL_ON != 0, metaState and KeyEvent.META_ALT_ON != 0) } + } + } +} + +internal class TerminalModifierState( + private val doubleTapMillis: Long = 300, +) { + private enum class Modifier { CONTROL, ALT, SHIFT, FUNCTION } + + private data class Held( + val modifier: Modifier, + val pressedAt: Long, + var usedInChord: Boolean = false, + ) + + private val held = mutableMapOf() + private val armed = mutableSetOf() + private val locked = mutableSetOf() + private val ignoredReleases = mutableSetOf() + private var lastTapSlot = -1 + private var lastTapAt = 0L + private var revision by mutableStateOf(0) + + fun onKeyEvent( + slot: Int, + keyCode: Int, + pressed: Boolean, + atMillis: Long, + send: (Int, Boolean) -> Unit, + ) { + val modifier = modifierFor(keyCode) + if (pressed) { + if (modifier == null) { + held.values.forEach { it.usedInChord = true } + send(keyCode, true) + } else { + when { + modifier in locked -> { + locked -= modifier + ignoredReleases += slot + } + modifier in armed && slot == lastTapSlot && atMillis - lastTapAt in 0..doubleTapMillis -> { + armed -= modifier + locked += modifier + ignoredReleases += slot + lastTapSlot = -1 + } + else -> held[slot] = Held(modifier, atMillis) + } + changed() + } + } else if (modifier == null) { + send(keyCode, false) + consumeArmed() + } else if (!ignoredReleases.remove(slot)) { + held.remove(slot)?.let { key -> + if (!key.usedInChord && atMillis - key.pressedAt <= 500) { + armed += key.modifier + lastTapSlot = slot + lastTapAt = atMillis + } + changed() + } + } + } + + fun isActive(keyCode: Int): Boolean { + revision + val modifier = modifierFor(keyCode) ?: return false + return modifier in armed || modifier in locked || held.values.any { it.modifier == modifier } + } + + fun isControlActive(): Boolean = isActive(KeyEvent.KEYCODE_CTRL_LEFT) + + fun isAltActive(): Boolean = isActive(KeyEvent.KEYCODE_ALT_LEFT) + + fun isShiftActive(): Boolean = isActive(KeyEvent.KEYCODE_SHIFT_LEFT) + + fun isFunctionActive(): Boolean = isActive(KeyEvent.KEYCODE_FUNCTION) + + fun markChordUsed() { + held.values.forEach { it.usedInChord = true } + } + + fun consumeArmed() { + if (armed.isEmpty()) return + armed.clear() + lastTapSlot = -1 + changed() + } + + fun reset() { + held.clear() + armed.clear() + locked.clear() + ignoredReleases.clear() + lastTapSlot = -1 + changed() + } + + private fun changed() { + revision += 1 + } + + private fun modifierFor(keyCode: Int): Modifier? = + when (keyCode) { + KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT -> Modifier.CONTROL + KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_ALT_RIGHT -> Modifier.ALT + KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT -> Modifier.SHIFT + KeyEvent.KEYCODE_FUNCTION -> Modifier.FUNCTION + else -> null + } } private class UdroidTerminalViewClient( @@ -1153,7 +1337,13 @@ private class UdroidTerminalViewClient( keyCode: Int, event: KeyEvent, session: TerminalSession, - ): Boolean = false + ): Boolean { + if (!TerminalExtraKeys.isModifier(keyCode)) { + modifiers.markChordUsed() + terminalView?.post(modifiers::consumeArmed) + } + return false + } override fun onKeyUp( keyCode: Int, @@ -1162,23 +1352,20 @@ private class UdroidTerminalViewClient( override fun onLongPress(event: MotionEvent): Boolean = false - override fun readControlKey(): Boolean = modifiers.control + override fun readControlKey(): Boolean = modifiers.isControlActive() - override fun readAltKey(): Boolean = modifiers.alt + override fun readAltKey(): Boolean = modifiers.isAltActive() - override fun readShiftKey(): Boolean = false + override fun readShiftKey(): Boolean = modifiers.isShiftActive() - override fun readFnKey(): Boolean = false + override fun readFnKey(): Boolean = modifiers.isFunctionActive() override fun onCodePoint( codePoint: Int, ctrlDown: Boolean, session: TerminalSession, ): Boolean { - terminalView?.post { - modifiers.control = false - modifiers.alt = false - } + terminalView?.post(modifiers::consumeArmed) return false } diff --git a/app/src/main/java/org/randomcoder/udroid/ui/TerminalSettingsPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/TerminalSettingsPage.kt index 746b126..8d5eedd 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/TerminalSettingsPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/TerminalSettingsPage.kt @@ -14,6 +14,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -22,13 +24,20 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack @@ -36,9 +45,11 @@ import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.material.icons.outlined.TextFields import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Button @@ -60,24 +71,42 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.viewinterop.AndroidView import org.randomcoder.udroid.runtime.TerminalColorProfile import org.randomcoder.udroid.runtime.TerminalCursorStyle import org.randomcoder.udroid.runtime.TerminalFonts import org.randomcoder.udroid.runtime.TerminalFontProfile +import org.randomcoder.udroid.runtime.TerminalExtraKeys +import org.randomcoder.udroid.runtime.TerminalExtraKeysState import org.randomcoder.udroid.runtime.TerminalProfileState import org.randomcoder.udroid.runtime.TerminalTypographyState import org.randomcoder.udroid.runtime.TerminalTypographyStore @@ -92,38 +121,58 @@ import org.randomcoder.udroid.runtime.exportTermuxProfile import org.randomcoder.udroid.runtime.formatTerminalColor import org.randomcoder.udroid.runtime.importTerminalProfile import org.randomcoder.udroid.runtime.parseTerminalColor +import org.randomcoder.udroid.input.ExtraKeyBinding import java.util.Locale import java.util.UUID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.roundToInt +import kotlin.math.abs -private enum class TerminalAppearancePage { +private enum class TerminalSettingsDestination { HOME, COLORS, TYPOGRAPHY, FONTS, + EXTRA_KEYS, + KEY_PICKER, } +private sealed interface TerminalExtraKeyTarget { + data class Replace(val row: Int, val column: Int) : TerminalExtraKeyTarget + + data class Append(val row: Int) : TerminalExtraKeyTarget + + data object NewRow : TerminalExtraKeyTarget +} + +private data class TerminalExtraKeyPosition(val row: Int, val column: Int) + +private data class TerminalExtraKeyDrop(val target: TerminalExtraKeyPosition, val after: Boolean) + @Composable internal fun TerminalSettingsPage( modifier: Modifier = Modifier, state: TerminalProfileState, typographyState: TerminalTypographyState, + extraKeysState: TerminalExtraKeysState, onBack: () -> Unit, onSelectProfile: (String) -> Unit, onSaveProfile: (TerminalColorProfile) -> Unit, onDeleteProfile: (String) -> Unit, onTypographyChanged: (TerminalTypographyState) -> Unit, + onExtraKeysChanged: (TerminalExtraKeysState) -> Unit, ) { val context = LocalContext.current val scope = rememberCoroutineScope() val typographyStore = remember(context) { TerminalTypographyStore(context) } var editingProfile by remember { mutableStateOf(null) } - var page by remember { mutableStateOf(TerminalAppearancePage.HOME) } + var page by remember { mutableStateOf(TerminalSettingsDestination.HOME) } + var extraKeyTarget by remember { mutableStateOf(null) } var pendingDeleteId by remember { mutableStateOf(null) } var fileMessage by remember { mutableStateOf(null) } + var extraKeysMessage by remember { mutableStateOf(null) } var searchQuery by remember { mutableStateOf("") } val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> @@ -133,7 +182,10 @@ internal fun TerminalSettingsPage( withContext(Dispatchers.IO) { runCatching { val fileName = context.displayName(uri) ?: "Imported profile" - importTerminalProfile(fileName, context.readPaletteText(uri)).getOrThrow() + importTerminalProfile( + fileName, + context.readLimitedText(uri, "Palette file"), + ).getOrThrow() } } result.onSuccess { profile -> @@ -166,12 +218,61 @@ internal fun TerminalSettingsPage( } } } + val importExtraKeysLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + scope.launch { + val result = + withContext(Dispatchers.IO) { + runCatching { + TerminalExtraKeys.parseTermuxJson( + context.readLimitedText(uri, "Extra-keys file"), + ) + } + } + result.onSuccess { imported -> + onExtraKeysChanged(extraKeysState.copy(rows = imported.rows)) + extraKeysMessage = + if (imported.ignoredPopups == 0) { + "Imported ${imported.rows.sumOf { it.size }} keys" + } else { + "Imported primary keys; ${imported.ignoredPopups} popup actions were not imported" + } + }.onFailure { error -> + extraKeysMessage = "Could not import keys: ${error.message ?: "invalid JSON"}" + } + } + } + } + val exportExtraKeysLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + if (uri != null) { + scope.launch { + val result = + withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openOutputStream(uri)?.bufferedWriter()?.use { + it.write(TerminalExtraKeys.toTermuxJson(extraKeysState.rows)) + } ?: error("Could not open file") + } + } + result.onSuccess { extraKeysMessage = "Exported Termux extra keys" } + .onFailure { error -> + extraKeysMessage = "Could not export keys: ${error.message ?: "write failed"}" + } + } + } + } val editing = editingProfile BackHandler { when { editing != null -> editingProfile = null - page == TerminalAppearancePage.FONTS -> page = TerminalAppearancePage.TYPOGRAPHY - page != TerminalAppearancePage.HOME -> page = TerminalAppearancePage.HOME + page == TerminalSettingsDestination.FONTS -> page = TerminalSettingsDestination.TYPOGRAPHY + page == TerminalSettingsDestination.KEY_PICKER -> { + extraKeyTarget = null + page = TerminalSettingsDestination.EXTRA_KEYS + } + page != TerminalSettingsDestination.HOME -> page = TerminalSettingsDestination.HOME else -> onBack() } } @@ -187,36 +288,100 @@ internal fun TerminalSettingsPage( ) return } - if (page == TerminalAppearancePage.TYPOGRAPHY) { + if (page == TerminalSettingsDestination.TYPOGRAPHY) { TerminalTypographySettingsPage( modifier = modifier, state = typographyState, colorProfile = state.selectedProfile, store = typographyStore, - onBack = { page = TerminalAppearancePage.HOME }, - onChooseFont = { page = TerminalAppearancePage.FONTS }, + onBack = { page = TerminalSettingsDestination.HOME }, + onChooseFont = { page = TerminalSettingsDestination.FONTS }, onChanged = onTypographyChanged, ) return } - if (page == TerminalAppearancePage.FONTS) { + if (page == TerminalSettingsDestination.FONTS) { TerminalFontPickerPage( modifier = modifier, state = typographyState, store = typographyStore, - onBack = { page = TerminalAppearancePage.TYPOGRAPHY }, + onBack = { page = TerminalSettingsDestination.TYPOGRAPHY }, onChanged = onTypographyChanged, ) return } - if (page == TerminalAppearancePage.HOME) { - TerminalAppearanceHome( + if (page == TerminalSettingsDestination.EXTRA_KEYS) { + TerminalExtraKeysSettingsPage( + modifier = modifier, + state = extraKeysState, + message = extraKeysMessage, + onBack = { page = TerminalSettingsDestination.HOME }, + onChanged = onExtraKeysChanged, + onEdit = { row, column -> + extraKeyTarget = TerminalExtraKeyTarget.Replace(row, column) + searchQuery = "" + page = TerminalSettingsDestination.KEY_PICKER + }, + onAddKey = { row -> + extraKeyTarget = TerminalExtraKeyTarget.Append(row) + searchQuery = "" + page = TerminalSettingsDestination.KEY_PICKER + }, + onAddRow = { + extraKeyTarget = TerminalExtraKeyTarget.NewRow + searchQuery = "" + page = TerminalSettingsDestination.KEY_PICKER + }, + onRemoveKey = { row, column -> + onExtraKeysChanged(extraKeysState.copy(rows = removeTerminalExtraKey(extraKeysState.rows, row, column))) + }, + onRemoveRow = { row -> + onExtraKeysChanged(extraKeysState.copy(rows = removeTerminalExtraKeyRow(extraKeysState.rows, row))) + }, + onImport = { importExtraKeysLauncher.launch(arrayOf("application/json", "text/*")) }, + onExport = { exportExtraKeysLauncher.launch("udroid-extra-keys.json") }, + ) + return + } + if (page == TerminalSettingsDestination.KEY_PICKER) { + val initialBinding = + (extraKeyTarget as? TerminalExtraKeyTarget.Replace)?.let { target -> + extraKeysState.rows.getOrNull(target.row)?.getOrNull(target.column) + } + TerminalExtraKeyPickerPage( + modifier = modifier, + query = searchQuery, + initial = initialBinding, + onQueryChanged = { searchQuery = it }, + onBack = { + extraKeyTarget = null + page = TerminalSettingsDestination.EXTRA_KEYS + }, + onSelected = { binding -> + val rows = extraKeysState.rows.map { it.toMutableList() }.toMutableList() + when (val target = extraKeyTarget) { + is TerminalExtraKeyTarget.Replace -> rows[target.row][target.column] = binding + is TerminalExtraKeyTarget.Append -> rows[target.row].add(binding) + TerminalExtraKeyTarget.NewRow -> rows.add(mutableListOf(binding)) + null -> Unit + } + onExtraKeysChanged(extraKeysState.copy(rows = rows)) + extraKeyTarget = null + page = TerminalSettingsDestination.EXTRA_KEYS + }, + ) + return + } + if (page == TerminalSettingsDestination.HOME) { + TerminalSettingsHome( modifier = modifier, colorProfile = state.selectedProfile, typography = typographyState, onBack = onBack, - onOpenColors = { page = TerminalAppearancePage.COLORS }, - onOpenTypography = { page = TerminalAppearancePage.TYPOGRAPHY }, + extraKeys = extraKeysState, + onOpenColors = { page = TerminalSettingsDestination.COLORS }, + onOpenTypography = { page = TerminalSettingsDestination.TYPOGRAPHY }, + onOpenExtraKeys = { page = TerminalSettingsDestination.EXTRA_KEYS }, ) return } @@ -228,7 +393,7 @@ internal fun TerminalSettingsPage( .fillMaxSize() .background(UdroidTerminal), ) { - TerminalSettingsHeader(title = "Colors", onBack = { page = TerminalAppearancePage.HOME }) + TerminalSettingsHeader(title = "Colors", onBack = { page = TerminalSettingsDestination.HOME }) LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), @@ -345,13 +510,15 @@ internal fun TerminalSettingsPage( } @Composable -private fun TerminalAppearanceHome( +private fun TerminalSettingsHome( modifier: Modifier, colorProfile: TerminalColorProfile, typography: TerminalTypographyState, + extraKeys: TerminalExtraKeysState, onBack: () -> Unit, onOpenColors: () -> Unit, onOpenTypography: () -> Unit, + onOpenExtraKeys: () -> Unit, ) { Column( modifier = @@ -359,7 +526,7 @@ private fun TerminalAppearanceHome( .fillMaxSize() .background(UdroidTerminal), ) { - TerminalSettingsHeader(title = "Terminal appearance", onBack = onBack) + TerminalSettingsHeader(title = "Terminal settings", onBack = onBack) Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -381,6 +548,534 @@ private fun TerminalAppearanceHome( detail = "${typography.selectedFont.name} ยท ${typography.textSizeSp.formatValue()} sp", onClick = onOpenTypography, ) + TerminalAppearanceEntry( + icon = { Icon(Icons.Outlined.Keyboard, contentDescription = null, tint = UdroidTerminalGreen) }, + title = "Extra keys", + detail = + if (extraKeys.visible) { + "${extraKeys.rows.size} rows ยท ${extraKeys.rows.sumOf { it.size }} keys" + } else { + "Hidden" + }, + onClick = onOpenExtraKeys, + ) + } + } +} + +@Composable +internal fun TerminalExtraKeysSettingsPage( + modifier: Modifier, + state: TerminalExtraKeysState, + message: String?, + title: String = "Extra keys", + visibilityTitle: String = "Show extra keys", + visibilityDetail: String = "Keep the key rail below the terminal", + resetLabel: String = "Reset extra keys", + bindingLabel: (ExtraKeyBinding) -> String = TerminalExtraKeys::label, + onBack: () -> Unit, + onChanged: (TerminalExtraKeysState) -> Unit, + onEdit: (Int, Int) -> Unit, + onAddKey: (Int) -> Unit, + onAddRow: () -> Unit, + onRemoveKey: (Int, Int) -> Unit, + onRemoveRow: (Int) -> Unit, + onReset: () -> Unit = { onChanged(TerminalExtraKeysState(visible = state.visible)) }, + onImport: () -> Unit, + onExport: () -> Unit, +) { + val density = LocalDensity.current + val haptics = LocalHapticFeedback.current + val listState = rememberLazyListState() + val keyBounds = remember { mutableStateMapOf() } + val rowBounds = remember { mutableStateMapOf() } + val rowScrollStates = remember { mutableStateMapOf() } + var pageBounds by remember { mutableStateOf(Rect.Zero) } + var listBounds by remember { mutableStateOf(Rect.Zero) } + var dragSource by remember { mutableStateOf(null) } + var dragLabel by remember { mutableStateOf("") } + var dragCenter by remember { mutableStateOf(Offset.Zero) } + var dragSize by remember { mutableStateOf(IntSize.Zero) } + var drop by remember { mutableStateOf(null) } + val edgeSize = with(density) { 48.dp.toPx() } + val scrollStep = with(density) { 20.dp.toPx() } + + fun relocate( + source: TerminalExtraKeyPosition, + target: TerminalExtraKeyPosition, + after: Boolean, + ): Boolean { + val rows = moveTerminalExtraKey(state.rows, source.row, source.column, target.row, target.column, after) + if (rows == state.rows) return false + onChanged(state.copy(rows = rows)) + return true + } + + fun clearDrag() { + dragSource = null + dragLabel = "" + dragCenter = Offset.Zero + dragSize = IntSize.Zero + drop = null + } + + fun targetAt(point: Offset, source: TerminalExtraKeyPosition): TerminalExtraKeyDrop? { + val row = + rowBounds.entries.minByOrNull { (_, bounds) -> + when { + point.y < bounds.top -> bounds.top - point.y + point.y > bounds.bottom -> point.y - bounds.bottom + else -> 0f + } + } ?: return null + if (point.y < row.value.top - edgeSize || point.y > row.value.bottom + edgeSize) return null + val target = + keyBounds.entries + .filter { it.key.row == row.key && it.key != source } + .minByOrNull { abs(point.x - it.value.center.x) } + ?: return null + return TerminalExtraKeyDrop(target.key, point.x >= target.value.center.x) + } + + fun autoScroll(point: Offset) { + rowBounds.entries.firstOrNull { point.y in it.value.top..it.value.bottom }?.let { (row, bounds) -> + val delta = + when { + point.x < bounds.left + edgeSize -> -scrollStep + point.x > bounds.right - edgeSize -> scrollStep + else -> 0f + } + if (delta != 0f) rowScrollStates[row]?.dispatchRawDelta(delta) + } + val delta = + when { + point.y < listBounds.top + edgeSize -> -scrollStep + point.y > listBounds.bottom - edgeSize -> scrollStep + else -> 0f + } + if (delta != 0f) listState.dispatchRawDelta(delta) + } + + Box( + modifier = + modifier + .fillMaxSize() + .background(UdroidTerminal) + .onGloballyPositioned { pageBounds = it.boundsInRoot() }, + ) { + Column(Modifier.fillMaxSize()) { + TerminalSettingsHeader(title = title, onBack = onBack) + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .onGloballyPositioned { listBounds = it.boundsInRoot() }, + state = listState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(visibilityTitle, color = UdroidTerminalText, fontWeight = FontWeight.SemiBold) + Text( + visibilityDetail, + color = UdroidTerminalMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + Switch( + checked = state.visible, + onCheckedChange = { onChanged(state.copy(visible = it)) }, + ) + } + } + item { + Text( + "Tap a slot to change it, or hold its handle and drag it to move it. Tap a modifier once for the next key, hold it for a chord, or double-tap to lock it.", + color = UdroidTerminalMuted, + style = MaterialTheme.typography.bodyMedium, + ) + } + itemsIndexed(state.rows) { rowIndex, row -> + val rowScrollState = rememberScrollState() + rowScrollStates[rowIndex] = rowScrollState + Surface( + modifier = Modifier.onGloballyPositioned { rowBounds[rowIndex] = it.boundsInRoot() }, + color = UdroidTerminalSurface, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, UdroidTerminalLine), + ) { + Column(modifier = Modifier.padding(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Row ${rowIndex + 1}", + modifier = Modifier.weight(1f).padding(start = 4.dp), + color = UdroidTerminalText, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.labelLarge, + ) + TextButton(onClick = { onAddKey(rowIndex) }) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text("Add key") + } + IconButton( + onClick = { onRemoveRow(rowIndex) }, + enabled = state.rows.size > 1, + ) { + Icon( + Icons.Outlined.Delete, + contentDescription = "Remove row ${rowIndex + 1}", + ) + } + } + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rowScrollState), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + row.forEachIndexed { columnIndex, binding -> + val position = TerminalExtraKeyPosition(rowIndex, columnIndex) + val targeted = drop?.target == position + Surface( + modifier = + Modifier + .onGloballyPositioned { keyBounds[position] = it.boundsInRoot() } + .graphicsLayer { alpha = if (dragSource == position) 0.3f else 1f } + .semantics { + customActions = + buildList { + if (columnIndex > 0) { + add( + CustomAccessibilityAction("Move left") { + relocate(position, position.copy(column = columnIndex - 1), false) + }, + ) + } + if (columnIndex < row.lastIndex) { + add( + CustomAccessibilityAction("Move right") { + relocate(position, position.copy(column = columnIndex + 1), true) + }, + ) + } + if (rowIndex > 0) { + add( + CustomAccessibilityAction("Move to previous row") { + relocate( + position, + TerminalExtraKeyPosition( + rowIndex - 1, + state.rows[rowIndex - 1].lastIndex, + ), + true, + ) + }, + ) + } + if (rowIndex < state.rows.lastIndex) { + add( + CustomAccessibilityAction("Move to next row") { + relocate(position, TerminalExtraKeyPosition(rowIndex + 1, 0), false) + }, + ) + } + } + }, + color = UdroidTerminalRaised, + shape = RoundedCornerShape(8.dp), + border = + BorderStroke( + if (targeted) 2.dp else 1.dp, + if (targeted) MaterialTheme.colorScheme.primary else UdroidTerminalLine, + ), + ) { + Box(Modifier.height(38.dp)) { + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Rounded.DragHandle, + contentDescription = "Drag ${bindingLabel(binding)} to move", + modifier = + Modifier + .size(32.dp) + .pointerInput(position, binding) { + detectDragGesturesAfterLongPress( + onDragStart = { + keyBounds[position]?.let { bounds -> + dragSource = position + dragLabel = bindingLabel(binding) + dragCenter = bounds.center + dragSize = + IntSize( + bounds.width.roundToInt(), + bounds.height.roundToInt(), + ) + drop = null + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + } + }, + onDrag = { change, amount -> + if (dragSource == position) { + change.consume() + dragCenter += amount + autoScroll(dragCenter) + drop = targetAt(dragCenter, position) + } + }, + onDragEnd = { + val source = dragSource + val destination = drop + clearDrag() + if (source != null && destination != null) { + if (relocate(source, destination.target, destination.after)) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + } + } + }, + onDragCancel = ::clearDrag, + ) + }.padding(6.dp), + tint = UdroidTerminalMuted, + ) + Text( + bindingLabel(binding), + modifier = + Modifier + .clickable { onEdit(rowIndex, columnIndex) } + .widthIn(max = 180.dp) + .padding(start = 10.dp, end = 6.dp), + color = UdroidTerminalText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + ) + IconButton( + onClick = { onRemoveKey(rowIndex, columnIndex) }, + modifier = Modifier.size(32.dp), + enabled = state.rows.size > 1 || row.size > 1, + ) { + Icon( + Icons.Rounded.Close, + contentDescription = + "Remove ${bindingLabel(binding)} from row ${rowIndex + 1}", + modifier = Modifier.size(16.dp), + ) + } + } + if (targeted) { + Box( + Modifier + .align( + if (drop?.after == true) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + ).width(3.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.primary), + ) + } + } + } + } + } + } + } + } + item { + OutlinedButton( + onClick = onAddRow, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(9.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Add row") + } + } + item { + TextButton(onClick = onReset) { + Text(resetLabel) + } + } + item { + HorizontalDivider(color = UdroidTerminalLine) + Spacer(Modifier.height(14.dp)) + Text("Termux layout", color = UdroidTerminalText, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text( + "Import or export the Termux extra-keys JSON matrix. Keys, macros, and custom display labels are supported; popup actions are not.", + color = UdroidTerminalMuted, + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onImport, modifier = Modifier.weight(1f)) { + Text("Import JSON") + } + OutlinedButton(onClick = onExport, modifier = Modifier.weight(1f)) { + Text("Export JSON") + } + } + message?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = UdroidTerminalMuted, style = MaterialTheme.typography.bodySmall) + } + } + } + } + + if (dragSource != null && dragSize != IntSize.Zero) { + Surface( + modifier = + Modifier + .offset { + IntOffset( + (dragCenter.x - pageBounds.left - dragSize.width / 2f).roundToInt(), + (dragCenter.y - pageBounds.top - dragSize.height / 2f).roundToInt(), + ) + }.width(with(density) { dragSize.width.toDp() }) + .height(with(density) { dragSize.height.toDp() }) + .graphicsLayer { + scaleX = 1.05f + scaleY = 1.05f + alpha = 0.95f + }, + color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), + shadowElevation = 8.dp, + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Rounded.DragHandle, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + dragLabel, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } +} + +@Composable +internal fun TerminalExtraKeyPickerPage( + modifier: Modifier, + query: String, + initial: ExtraKeyBinding?, + title: String = if (initial == null) "Add key" else "Edit key", + keys: List = TerminalExtraKeys.keys, + keyLabel: (Int) -> String = TerminalExtraKeys::label, + keyBinding: (Int) -> ExtraKeyBinding = TerminalExtraKeys::binding, + onQueryChanged: (String) -> Unit, + onBack: () -> Unit, + onSelected: (ExtraKeyBinding) -> Unit, +) { + var display by remember(initial) { mutableStateOf(initial?.display.orEmpty()) } + var macro by remember(initial) { mutableStateOf(initial?.takeIf(ExtraKeyBinding::macro)?.action.orEmpty()) } + val matches = + remember(query) { + keys.filter { + keyLabel(it).contains(query.trim(), ignoreCase = true) + } + } + Column( + modifier = + modifier + .fillMaxSize() + .background(UdroidTerminal), + ) { + TerminalSettingsHeader(title = title, onBack = onBack) + OutlinedTextField( + value = display, + onValueChange = { display = it.replace("\n", "") }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + label = { Text("Display label (optional)") }, + placeholder = { Text("Text, symbol, or emoji") }, + singleLine = true, + ) + OutlinedTextField( + value = macro, + onValueChange = { macro = it.replace("\n", " ") }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + label = { Text("Macro (optional)") }, + supportingText = { Text("Termux syntax, for example: CTRL f d") }, + singleLine = true, + ) + if (macro.isNotBlank()) { + Button( + onClick = { onSelected(ExtraKeyBinding(macro, macro = true, display = display.ifBlank { null })) }, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + Text("Use macro") + } + } else if (initial != null) { + Button( + onClick = { onSelected(initial.copy(display = display.ifBlank { null })) }, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + Text("Save label") + } + } + OutlinedTextField( + value = query, + onValueChange = onQueryChanged, + modifier = Modifier.fillMaxWidth().padding(16.dp), + placeholder = { Text("Search keys") }, + leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, + trailingIcon = + if (query.isBlank()) { + null + } else { + { + IconButton(onClick = { onQueryChanged("") }) { + Icon(Icons.Rounded.Close, contentDescription = "Clear search") + } + } + }, + singleLine = true, + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), + ) { + items(matches, key = { it }) { keyCode -> + Text( + text = keyLabel(keyCode), + modifier = + Modifier + .fillMaxWidth() + .clickable { + onSelected( + keyBinding(keyCode).copy(display = display.ifBlank { null }), + ) + } + .padding(horizontal = 12.dp, vertical = 14.dp), + color = UdroidTerminalText, + style = MaterialTheme.typography.bodyLarge, + ) + HorizontalDivider(color = UdroidTerminalLine) + } } } } @@ -429,7 +1124,7 @@ private fun TerminalAppearanceEntry( } @Composable -private fun TerminalSettingsHeader( +internal fun TerminalSettingsHeader( title: String, onBack: () -> Unit, ) { @@ -1076,6 +1771,48 @@ internal fun filterTerminalProfiles( ): List = if (query.isBlank()) profiles else profiles.filter { it.name.contains(query.trim(), ignoreCase = true) } +internal fun removeTerminalExtraKey( + rows: List>, + rowIndex: Int, + columnIndex: Int, +): List> { + if (rows.sumOf { it.size } <= 1) return rows + val updated = rows.map { it.toMutableList() }.toMutableList() + val row = updated.getOrNull(rowIndex) ?: return rows + if (columnIndex !in row.indices) return rows + row.removeAt(columnIndex) + if (row.isEmpty()) updated.removeAt(rowIndex) + return updated +} + +internal fun removeTerminalExtraKeyRow( + rows: List>, + rowIndex: Int, +): List> = + if (rows.size <= 1 || rowIndex !in rows.indices) rows else rows.filterIndexed { index, _ -> index != rowIndex } + +internal fun moveTerminalExtraKey( + rows: List>, + sourceRow: Int, + sourceColumn: Int, + targetRow: Int, + targetColumn: Int, + after: Boolean, +): List> { + val source = rows.getOrNull(sourceRow) ?: return rows + val target = rows.getOrNull(targetRow) ?: return rows + if (sourceColumn !in source.indices || targetColumn !in target.indices) return rows + if (sourceRow == targetRow && sourceColumn == targetColumn) return rows + + val updated = rows.map { it.toMutableList() }.toMutableList() + val key = updated[sourceRow].removeAt(sourceColumn) + var destination = targetColumn + if (after) 1 else 0 + if (sourceRow == targetRow && sourceColumn < destination) destination -= 1 + updated[targetRow].add(destination.coerceIn(0, updated[targetRow].size), key) + if (updated[sourceRow].isEmpty()) updated.removeAt(sourceRow) + return updated +} + @Composable private fun TerminalProfileEditor( modifier: Modifier, @@ -1261,7 +1998,7 @@ private fun contrastingColor(color: Int): Color { private const val BACKGROUND_COLOR = -3 private const val FOREGROUND_COLOR = -2 private const val CURSOR_COLOR = -1 -private const val MAX_PALETTE_FILE_CHARS = 1_048_576 +private const val MAX_TERMINAL_SETTINGS_FILE_CHARS = 1_048_576 private val INVALID_FILE_NAME = Regex("[^A-Za-z0-9._ -]") private fun Context.displayName(uri: Uri): String? = @@ -1269,7 +2006,10 @@ private fun Context.displayName(uri: Uri): String? = if (cursor.moveToFirst()) cursor.getString(0) else null } -private fun Context.readPaletteText(uri: Uri): String = +internal fun Context.readLimitedText( + uri: Uri, + label: String, +): String = contentResolver.openInputStream(uri)?.bufferedReader()?.use { reader -> buildString { val buffer = CharArray(8192) @@ -1277,7 +2017,7 @@ private fun Context.readPaletteText(uri: Uri): String = val count = reader.read(buffer) if (count < 0) break append(buffer, 0, count) - require(length <= MAX_PALETTE_FILE_CHARS) { "Palette file is larger than 1 MiB" } + require(length <= MAX_TERMINAL_SETTINGS_FILE_CHARS) { "$label is larger than 1 MiB" } } } } ?: error("Could not open file") diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/TerminalExtraKeysTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalExtraKeysTest.kt new file mode 100644 index 0000000..62177d4 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/runtime/TerminalExtraKeysTest.kt @@ -0,0 +1,41 @@ +package org.randomcoder.udroid.runtime + +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Test +import org.randomcoder.udroid.input.ExtraKeyBinding + +class TerminalExtraKeysTest { + @Test + fun termuxNamesRoundTripSupportedKeys() { + val keys = + listOf( + KeyEvent.KEYCODE_CTRL_LEFT, + KeyEvent.KEYCODE_ESCAPE, + KeyEvent.KEYCODE_A, + KeyEvent.KEYCODE_7, + KeyEvent.KEYCODE_F12, + KeyEvent.KEYCODE_SLASH, + ) + + keys.forEach { keyCode -> + assertEquals(keyCode, TerminalExtraKeys.keyCode(TerminalExtraKeys.termuxName(keyCode))) + } + } + + @Test + fun invalidRowsFallBackWithoutKeepingUnsupportedButtons() { + assertEquals(TerminalExtraKeys.defaults, TerminalExtraKeys.normalized(emptyList())) + assertEquals( + listOf(listOf(TerminalExtraKeys.binding(KeyEvent.KEYCODE_TAB))), + TerminalExtraKeys.normalized(listOf(listOf(ExtraKeyBinding(""), TerminalExtraKeys.binding(KeyEvent.KEYCODE_TAB)))), + ) + } + + @Test + fun layoutShapeIsNotCapped() { + val rows = List(6) { List(14) { TerminalExtraKeys.binding(KeyEvent.KEYCODE_TAB) } } + + assertEquals(rows, TerminalExtraKeys.normalized(rows)) + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/ui/TerminalModifierStateTest.kt b/app/src/test/java/org/randomcoder/udroid/ui/TerminalModifierStateTest.kt new file mode 100644 index 0000000..d7c6312 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/ui/TerminalModifierStateTest.kt @@ -0,0 +1,53 @@ +package org.randomcoder.udroid.ui + +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalModifierStateTest { + @Test + fun modifierTapAppliesToOnlyTheNextKey() { + val state = TerminalModifierState() + val sent = mutableListOf>() + + state.onKeyEvent(0, KeyEvent.KEYCODE_CTRL_LEFT, true, 0) { key, down -> sent += key to down } + state.onKeyEvent(0, KeyEvent.KEYCODE_CTRL_LEFT, false, 50) { key, down -> sent += key to down } + assertTrue(state.isControlActive()) + + state.onKeyEvent(1, KeyEvent.KEYCODE_C, true, 60) { key, down -> sent += key to down } + state.onKeyEvent(1, KeyEvent.KEYCODE_C, false, 70) { key, down -> sent += key to down } + + assertFalse(state.isControlActive()) + assertEquals(listOf(KeyEvent.KEYCODE_C to true, KeyEvent.KEYCODE_C to false), sent) + } + + @Test + fun heldModifierSurvivesChordUntilReleased() { + val state = TerminalModifierState() + + state.onKeyEvent(0, KeyEvent.KEYCODE_ALT_LEFT, true, 0) { _, _ -> } + state.onKeyEvent(1, KeyEvent.KEYCODE_B, true, 600) { _, _ -> } + state.onKeyEvent(1, KeyEvent.KEYCODE_B, false, 610) { _, _ -> } + assertTrue(state.isAltActive()) + + state.onKeyEvent(0, KeyEvent.KEYCODE_ALT_LEFT, false, 620) { _, _ -> } + assertFalse(state.isAltActive()) + } + + @Test + fun doubleTapLocksAndNextTapUnlocksModifier() { + val state = TerminalModifierState(doubleTapMillis = 300) + + state.onKeyEvent(0, KeyEvent.KEYCODE_SHIFT_LEFT, true, 0) { _, _ -> } + state.onKeyEvent(0, KeyEvent.KEYCODE_SHIFT_LEFT, false, 40) { _, _ -> } + state.onKeyEvent(0, KeyEvent.KEYCODE_SHIFT_LEFT, true, 200) { _, _ -> } + state.onKeyEvent(0, KeyEvent.KEYCODE_SHIFT_LEFT, false, 240) { _, _ -> } + assertTrue(state.isShiftActive()) + + state.onKeyEvent(0, KeyEvent.KEYCODE_SHIFT_LEFT, true, 400) { _, _ -> } + state.onKeyEvent(0, KeyEvent.KEYCODE_SHIFT_LEFT, false, 440) { _, _ -> } + assertFalse(state.isShiftActive()) + } +} diff --git a/app/src/test/java/org/randomcoder/udroid/ui/TerminalSettingsPageTest.kt b/app/src/test/java/org/randomcoder/udroid/ui/TerminalSettingsPageTest.kt index d8a6057..c4e8f99 100644 --- a/app/src/test/java/org/randomcoder/udroid/ui/TerminalSettingsPageTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/ui/TerminalSettingsPageTest.kt @@ -1,5 +1,6 @@ package org.randomcoder.udroid.ui +import android.view.KeyEvent import org.junit.Assert.assertEquals import org.junit.Test import org.randomcoder.udroid.runtime.TerminalProfiles @@ -20,4 +21,69 @@ class TerminalSettingsPageTest { filterTerminalProfiles(TerminalProfiles.builtIns, " "), ) } + + @Test + fun removingOnlyKeyInRowRemovesThatRow() { + assertEquals( + listOf(listOf(KeyEvent.KEYCODE_TAB)), + removeTerminalExtraKey( + listOf(listOf(KeyEvent.KEYCODE_ESCAPE), listOf(KeyEvent.KEYCODE_TAB)), + rowIndex = 0, + columnIndex = 0, + ), + ) + } + + @Test + fun finalKeyAndFinalRowCannotBeRemoved() { + val rows = listOf(listOf(KeyEvent.KEYCODE_ESCAPE)) + + assertEquals(rows, removeTerminalExtraKey(rows, rowIndex = 0, columnIndex = 0)) + assertEquals(rows, removeTerminalExtraKeyRow(rows, rowIndex = 0)) + } + + @Test + fun movingKeyReordersWithinRow() { + assertEquals( + listOf(listOf("B", "C", "A")), + moveTerminalExtraKey( + rows = listOf(listOf("A", "B", "C")), + sourceRow = 0, + sourceColumn = 0, + targetRow = 0, + targetColumn = 2, + after = true, + ), + ) + } + + @Test + fun movingKeyBeforeTargetReordersWithinRow() { + assertEquals( + listOf(listOf("C", "A", "B")), + moveTerminalExtraKey( + rows = listOf(listOf("A", "B", "C")), + sourceRow = 0, + sourceColumn = 2, + targetRow = 0, + targetColumn = 0, + after = false, + ), + ) + } + + @Test + fun movingOnlyKeyToAnotherRowRemovesEmptyRow() { + assertEquals( + listOf(listOf("B", "A", "C")), + moveTerminalExtraKey( + rows = listOf(listOf("A"), listOf("B", "C")), + sourceRow = 0, + sourceColumn = 0, + targetRow = 1, + targetColumn = 0, + after = true, + ), + ) + } } From fdb54313c14fb7ebb7fd41e0cf3ab9b0c4de4151 Mon Sep 17 00:00:00 2001 From: saicharankandukuri Date: Mon, 9 Nov 2026 13:19:50 +0530 Subject: [PATCH 2/3] feat(desktop): add configurable on-screen controls Move desktop settings into a full-screen hierarchy with dedicated keyboard-strip and display-button pages. Support arbitrary key rows, labels, macros, drag reordering, Termux JSON layouts, safe legacy migration, and explicit input release across settings and renderer lifecycles. --- .../randomcoder/udroid/x11/X11SettingsTest.kt | 30 + .../org/randomcoder/udroid/ui/DesktopPage.kt | 5 +- .../udroid/ui/X11DesktopHostView.kt | 126 ++- .../udroid/ui/X11SettingsDialog.kt | 884 +++++++++--------- .../randomcoder/udroid/x11/OverlayKeyboard.kt | 127 ++- .../udroid/x11/X11DisplayView.java | 4 + .../org/randomcoder/udroid/x11/X11Settings.kt | 75 +- .../udroid/x11/OverlayKeyboardTest.kt | 39 +- docs/USER_GUIDE.md | 58 +- 9 files changed, 851 insertions(+), 497 deletions(-) create mode 100644 app/src/androidTest/java/org/randomcoder/udroid/x11/X11SettingsTest.kt diff --git a/app/src/androidTest/java/org/randomcoder/udroid/x11/X11SettingsTest.kt b/app/src/androidTest/java/org/randomcoder/udroid/x11/X11SettingsTest.kt new file mode 100644 index 0000000..6a57df2 --- /dev/null +++ b/app/src/androidTest/java/org/randomcoder/udroid/x11/X11SettingsTest.kt @@ -0,0 +1,30 @@ +package org.randomcoder.udroid.x11 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.randomcoder.udroid.input.ExtraKeyBinding + +@RunWith(AndroidJUnit4::class) +class X11SettingsTest { + @Test + fun savedFlatKeyboardLayoutMigratesToSixKeyRows() { + val raw = """["CTRL","ALT","A","B","C","D","LEFT"]""" + + assertEquals(listOf(6, 1), parseOverlayKeyRows(raw).map(List<*>::size)) + } + + @Test + fun savedMatrixKeepsMacrosAndCustomLabels() { + val raw = """[[{"macro":"CTRL f d","display":"tmux"}],["ESC"]]""" + + assertEquals( + listOf( + listOf(ExtraKeyBinding("CTRL f d", macro = true, display = "tmux")), + listOf(ExtraKeyBinding("ESC")), + ), + parseOverlayKeyRows(raw), + ) + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt index 2ec6be4..fdca94f 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt @@ -89,6 +89,7 @@ fun DesktopPage( } LaunchedEffect(arrangingDisplayKeys, showSettings) { + if (showSettings) displayView?.resetInput() if (arrangingDisplayKeys && !showSettings) { withFrameNanos { } displayView?.hideKeyboard() @@ -182,7 +183,7 @@ fun DesktopPage( updateSettings(settings.copy(displayKeys = moved)) } it.setPaletteVisible(inputPaletteVisible) - it.setKeyboardBindings(settings.overlayKeys) + it.setKeyboardBindings(settings.overlayKeyRows) it.setKeyboardStripVisible(settings.showKeyboardStrip) it.setDisplayKeys(settings.displayKeys) it.setDisplayKeysVisible(displayKeysVisible) @@ -201,7 +202,7 @@ fun DesktopPage( } if (showSettings) { - X11SettingsDialog( + X11SettingsPage( settings = settings, onSettingsChanged = updateSettings, onArrangeDisplayKeys = { diff --git a/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt b/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt index abf901b..fb1b010 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt @@ -5,6 +5,7 @@ import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.graphics.drawable.StateListDrawable +import android.text.TextUtils import android.view.Gravity import android.view.MotionEvent import android.view.View @@ -13,23 +14,27 @@ import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView import com.termux.x11.input.InputStub +import org.randomcoder.udroid.input.ExtraKeyBinding import org.randomcoder.udroid.x11.X11DisplayView import org.randomcoder.udroid.x11.DisplayKey import org.randomcoder.udroid.x11.OverlayKeyboard import org.randomcoder.udroid.x11.OverlayKeyState import kotlin.math.roundToInt +import java.nio.charset.StandardCharsets.UTF_8 internal class X11DesktopHostView(context: Context) : FrameLayout(context) { val displayView = X11DisplayView(context) private val density = resources.displayMetrics.density - private val keyboardStripHeight = dp(76) + private var keyboardStripHeight = dp(76) private val palette = LinearLayout(context) private val keyboardStrip = LinearLayout(context) private val displayKeyLayer = FrameLayout(context) private val keyboardButtons = mutableListOf() private val displayKeyButtons = mutableListOf() - private val keyboardState = OverlayKeyState { binding, pressed -> + private val keyboardState = OverlayKeyState { binding, pressed -> sendOverlayKey(binding, pressed) } + + private fun sendOverlayKey(binding: Int, pressed: Boolean) { when (binding) { OverlayKeyboard.MOUSE_LEFT -> displayView.setOverlayMouseButton(InputStub.BUTTON_LEFT, pressed) @@ -45,7 +50,8 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { private val heldKeyboardSlots = mutableSetOf() private val chordUsedSlots = mutableSetOf() private val modifierTaps = ModifierDoubleTap(ViewConfiguration.getDoubleTapTimeout().toLong()) - private var keyboardBindings = OverlayKeyboard.defaults + private var keyboardRows = OverlayKeyboard.defaultRows + private var keyboardBindings = keyboardRows.flatten() private var displayKeys = emptyList() private var displayKeysVisible = false private var arrangingDisplayKeys = false @@ -82,7 +88,7 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT), ) displayKeyLayer.visibility = View.GONE - buildKeyboardStrip() + buildKeyboardStrip(keyboardRows) addView( keyboardStrip, LayoutParams(LayoutParams.MATCH_PARENT, keyboardStripHeight).apply { gravity = Gravity.BOTTOM }, @@ -142,7 +148,7 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { displayKeyLayer.removeAllViews() displayKeyButtons.clear() normalized.forEachIndexed { index, key -> - val label = OverlayKeyboard.label(key.keyCode) + val label = OverlayKeyboard.label(key.binding) val button = textButton(label, "Hold $label key; arrange to move").apply { textSize = 12f background = displayKeyBackground(false) @@ -156,16 +162,13 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { updateKeyboardButtons() } - fun setKeyboardBindings(bindings: List) { - val normalized = OverlayKeyboard.normalized(bindings) - if (normalized == keyboardBindings) return + fun setKeyboardBindings(rows: List>) { + val normalized = OverlayKeyboard.normalizedRows(rows) + if (normalized == keyboardRows) return releaseKeyboard() - keyboardBindings = normalized - keyboardButtons.forEachIndexed { index, view -> - val label = OverlayKeyboard.label(normalized[index]) - view.text = label - view.contentDescription = "Hold $label key" - } + keyboardRows = normalized + keyboardBindings = normalized.flatten() + buildKeyboardStrip(normalized) updateKeyboardButtons() } @@ -243,19 +246,21 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { palette.addView(mouseBody) } - private fun buildKeyboardStrip() { + private fun buildKeyboardStrip(rows: List>) { + keyboardStrip.removeAllViews() + keyboardButtons.clear() keyboardStrip.orientation = LinearLayout.VERTICAL keyboardStrip.isMotionEventSplittingEnabled = true keyboardStrip.setBackgroundColor(surfaceContainer) keyboardStrip.setPadding(dp(4), dp(2), dp(4), dp(2)) - repeat(2) { rowIndex -> + var slot = 0 + rows.forEach { bindings -> val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL isMotionEventSplittingEnabled = true } - repeat(6) { columnIndex -> - val slot = rowIndex * 6 + columnIndex - val label = OverlayKeyboard.label(keyboardBindings[slot]) + bindings.forEach { binding -> + val label = OverlayKeyboard.label(binding) val button = textButton(label, "Hold $label key").apply { textSize = 12f background = stripButtonBackground(false) @@ -263,9 +268,18 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { installKeyboardButton(button, slot) keyboardButtons += button row.addView(button, LinearLayout.LayoutParams(0, dp(36), 1f)) + slot += 1 } keyboardStrip.addView(row, LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, dp(36))) } + keyboardStripHeight = dp(rows.size * 36 + 4) + keyboardStrip.layoutParams = + (keyboardStrip.layoutParams as? LayoutParams)?.apply { height = keyboardStripHeight } + ?: LayoutParams(LayoutParams.MATCH_PARENT, keyboardStripHeight).apply { gravity = Gravity.BOTTOM } + if (keyboardStrip.visibility == View.VISIBLE) { + (displayView.layoutParams as LayoutParams).bottomMargin = keyboardStripHeight + (displayKeyLayer.layoutParams as LayoutParams).bottomMargin = keyboardStripHeight + } } private fun installPaletteDrag( @@ -372,15 +386,21 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { if (handledTouchClick) { handledTouchClick = false } else if (displayView.isRendererAttached) { - val keyCode = bindingForSlot(slot) + val binding = bindingForSlot(slot) modifierTaps.clear() - keyboardState.press(touchOwner(slot), keyCode) - keyboardState.release(touchOwner(slot)) + val keyCode = bindingKeyCode(binding) + if (keyCode == null) { + executeBinding(binding) + } else { + keyboardState.press(touchOwner(slot), keyCode) + keyboardState.release(touchOwner(slot)) + } updateKeyboardButtons() } } view.setOnLongClickListener { - if (!displayView.isRendererAttached || !OverlayKeyboard.isModifier(bindingForSlot(slot))) { + val keyCode = bindingKeyCode(bindingForSlot(slot)) + if (!displayView.isRendererAttached || keyCode == null || !OverlayKeyboard.isModifier(keyCode)) { false } else { modifierTaps.clear() @@ -427,6 +447,13 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { if (!displayView.isRendererAttached) return@setOnTouchListener true + val binding = bindingForSlot(slot) + val keyCode = bindingKeyCode(binding) + if (keyCode == null) { + target.isPressed = true + downTime = event.eventTime + return@setOnTouchListener true + } modifierTaps.cancelUnless(slot) if (heldKeyboardSlots.isNotEmpty()) { chordUsedSlots.addAll(heldKeyboardSlots) @@ -436,14 +463,23 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { heldKeyboardSlots.add(slot) downTime = event.eventTime target.isPressed = true - keyboardState.press(touchOwner(slot), bindingForSlot(slot)) + keyboardState.press(touchOwner(slot), keyCode) updateKeyboardButtons() true } MotionEvent.ACTION_UP -> { target.isPressed = false - val keyCode = bindingForSlot(slot) + val binding = bindingForSlot(slot) + val keyCode = bindingKeyCode(binding) + if (keyCode == null) { + target.isPressed = false + executeBinding(binding) + handledTouchClick = true + target.performClick() + updateKeyboardButtons() + return@setOnTouchListener true + } if (isQuickModifierTap( isHeld = slot in heldKeyboardSlots, isModifier = OverlayKeyboard.isModifier(keyCode), @@ -472,7 +508,7 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { MotionEvent.ACTION_CANCEL -> { target.isPressed = false modifierTaps.clear() - keyboardState.release(touchOwner(slot)) + if (bindingKeyCode(bindingForSlot(slot)) != null) keyboardState.release(touchOwner(slot)) heldKeyboardSlots.remove(slot) chordUsedSlots.remove(slot) updateKeyboardButtons() @@ -485,8 +521,31 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { } } - private fun bindingForSlot(slot: Int): Int = - if (slot >= 1000) displayKeys[slot - 1000].keyCode else keyboardBindings[slot] + private fun bindingForSlot(slot: Int): ExtraKeyBinding = + if (slot >= 1000) displayKeys[slot - 1000].binding else keyboardBindings[slot] + + private fun bindingKeyCode(binding: ExtraKeyBinding): Int? = + binding.takeUnless(ExtraKeyBinding::macro)?.let { OverlayKeyboard.keyCode(it.action) } + + private fun executeBinding(binding: ExtraKeyBinding) { + val tokens = binding.tokens() + val modifiers = tokens.mapNotNull(OverlayKeyboard::keyCode).filter(OverlayKeyboard::isModifier).distinct() + modifiers.forEach { sendOverlayKey(it, true) } + try { + tokens.forEach { token -> + val keyCode = OverlayKeyboard.keyCode(token) + if (keyCode?.let(OverlayKeyboard::isModifier) == true) return@forEach + if (keyCode == null) { + displayView.sendTextEvent(token.toByteArray(UTF_8)) + } else { + sendOverlayKey(keyCode, true) + sendOverlayKey(keyCode, false) + } + } + } finally { + modifiers.asReversed().forEach { sendOverlayKey(it, false) } + } + } private fun positionDisplayKeys() { displayKeyButtons.forEachIndexed { index, button -> @@ -496,11 +555,12 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { } private fun toggleLatchedKey(slot: Int) { + val keyCode = bindingKeyCode(bindingForSlot(slot)) ?: return val owner = latchOwner(slot) if (keyboardState.isPressed(owner)) { keyboardState.release(owner) } else { - keyboardState.press(owner, bindingForSlot(slot)) + keyboardState.press(owner, keyCode) } } @@ -521,9 +581,11 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { (keyboardButtons.mapIndexed { slot, view -> slot to view } + displayKeyButtons.mapIndexed { index, view -> (1000 + index) to view }).forEach { (slot, view) -> val active = keyboardState.isPressed(touchOwner(slot)) || keyboardState.isPressed(latchOwner(slot)) - view.isLongClickable = !arrangingDisplayKeys && OverlayKeyboard.isModifier(bindingForSlot(slot)) + val binding = bindingForSlot(slot) + val keyCode = bindingKeyCode(binding) + view.isLongClickable = !arrangingDisplayKeys && keyCode != null && OverlayKeyboard.isModifier(keyCode) if (slot >= 1000) { - val label = OverlayKeyboard.label(bindingForSlot(slot)) + val label = OverlayKeyboard.label(binding) view.contentDescription = if (arrangingDisplayKeys) "Drag $label to move" else "Hold $label key" } view.background = @@ -599,6 +661,8 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { text = label contentDescription = description gravity = Gravity.CENTER + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END setTextColor(onSurface) textSize = 14f isClickable = true diff --git a/app/src/main/java/org/randomcoder/udroid/ui/X11SettingsDialog.kt b/app/src/main/java/org/randomcoder/udroid/ui/X11SettingsDialog.kt index 5da5521..6c9008b 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/X11SettingsDialog.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/X11SettingsDialog.kt @@ -1,28 +1,33 @@ package org.randomcoder.udroid.ui +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.AlertDialog +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow @@ -36,439 +41,495 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.randomcoder.udroid.runtime.TerminalExtraKeys +import org.randomcoder.udroid.runtime.TerminalExtraKeysState +import org.randomcoder.udroid.x11.DisplayKey +import org.randomcoder.udroid.x11.OverlayKeyboard import org.randomcoder.udroid.x11.X11DisplayFilter import org.randomcoder.udroid.x11.X11ResolutionMode import org.randomcoder.udroid.x11.X11Settings import org.randomcoder.udroid.x11.X11TouchMode -import org.randomcoder.udroid.x11.DisplayKey -import org.randomcoder.udroid.x11.OverlayKeyboard import kotlin.math.roundToInt +private enum class X11SettingsDestination { + HOME, + KEYBOARD_STRIP, + KEY_PICKER, + DISPLAY_BUTTONS, + DISPLAY_BUTTON_PICKER, +} + +private sealed interface X11KeyTarget { + data class Replace(val row: Int, val column: Int) : X11KeyTarget + + data class Append(val row: Int) : X11KeyTarget + + data object NewRow : X11KeyTarget +} + @Composable -fun X11SettingsDialog( +fun X11SettingsPage( settings: X11Settings, onSettingsChanged: (X11Settings) -> Unit, onArrangeDisplayKeys: () -> Unit, onDismiss: () -> Unit, ) { - var editingSlot by remember { mutableIntStateOf(-1) } - var editingDisplayKey by remember { mutableIntStateOf(-1) } - var addingDisplayKey by remember { mutableStateOf(false) } - var keySearch by remember { mutableStateOf("") } - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - UdroidTerminalTheme { - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 32.dp) - .widthIn(max = 560.dp) - .heightIn(max = 760.dp), - shape = MaterialTheme.shapes.extraLarge, - color = UdroidTerminalSurface, - shadowElevation = 6.dp, - ) { - Column { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 20.dp, top = 12.dp, end = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Desktop settings", - style = MaterialTheme.typography.titleLarge, - ) - Text( - text = "Changes apply live to display :0", - color = UdroidTerminalMuted, - style = MaterialTheme.typography.bodySmall, - ) - } - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = "Close desktop settings", - ) + val context = LocalContext.current + val scope = rememberCoroutineScope() + var destination by remember { mutableStateOf(X11SettingsDestination.HOME) } + var keyTarget by remember { mutableStateOf(null) } + var displayButtonIndex by remember { mutableIntStateOf(-1) } + var query by remember { mutableStateOf("") } + var importMessage by remember { mutableStateOf(null) } + val importKeys = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + scope.launch { + val result = + withContext(Dispatchers.IO) { + runCatching { + TerminalExtraKeys.parseTermuxJson(context.readLimitedText(uri, "Extra-keys file")) + } } - } - Column( - modifier = - Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 12.dp), - ) { - SettingsSectionTitle("Output") - SettingsLabel( - title = "Resolution", - subtitle = "Native matches your screen. Scaled changes the size of Linux controls.", + result.onSuccess { imported -> + onSettingsChanged( + settings.copy(overlayKeyRows = OverlayKeyboard.normalizedRows(imported.rows)), ) - ChoiceGroup( - selected = settings.resolutionMode, - options = - listOf( - X11ResolutionMode.NATIVE to "Native", - X11ResolutionMode.SCALED to "Scaled", - X11ResolutionMode.EXACT to "Fixed", + importMessage = + if (imported.ignoredPopups == 0) { + "Imported ${imported.rows.sumOf { it.size }} keys" + } else { + "Imported primary keys; ${imported.ignoredPopups} popup actions were ignored" + } + }.onFailure { error -> + importMessage = "Could not import keys: ${error.message ?: "invalid JSON"}" + } + } + } + } + val exportKeys = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + if (uri != null) { + scope.launch { + val result = + withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openOutputStream(uri)?.bufferedWriter()?.use { + it.write(TerminalExtraKeys.toTermuxJson(settings.overlayKeyRows)) + } ?: error("Could not open file") + } + } + result.onSuccess { importMessage = "Exported display keyboard layout" } + .onFailure { error -> + importMessage = "Could not export keys: ${error.message ?: "write failed"}" + } + } + } + } + + fun goBack() { + destination = + when (destination) { + X11SettingsDestination.KEY_PICKER -> X11SettingsDestination.KEYBOARD_STRIP + X11SettingsDestination.DISPLAY_BUTTON_PICKER -> X11SettingsDestination.DISPLAY_BUTTONS + X11SettingsDestination.HOME -> { + onDismiss() + X11SettingsDestination.HOME + } + else -> X11SettingsDestination.HOME + } + } + + BackHandler(onBack = ::goBack) + + UdroidTerminalTheme { + Surface( + modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding(), + color = UdroidTerminal, + ) { + when (destination) { + X11SettingsDestination.HOME -> + X11SettingsHomePage( + settings = settings, + onSettingsChanged = onSettingsChanged, + onKeyboardStrip = { destination = X11SettingsDestination.KEYBOARD_STRIP }, + onDisplayButtons = { destination = X11SettingsDestination.DISPLAY_BUTTONS }, + onBack = onDismiss, + ) + + X11SettingsDestination.KEYBOARD_STRIP -> + TerminalExtraKeysSettingsPage( + modifier = Modifier.fillMaxSize(), + state = TerminalExtraKeysState(settings.showKeyboardStrip, settings.overlayKeyRows), + message = importMessage, + title = "Keyboard strip", + visibilityTitle = "Show keyboard strip", + visibilityDetail = "Keep these rows below the Linux desktop", + resetLabel = "Reset keyboard strip", + bindingLabel = OverlayKeyboard::label, + onBack = ::goBack, + onChanged = { + onSettingsChanged( + settings.copy( + showKeyboardStrip = it.visible, + overlayKeyRows = OverlayKeyboard.normalizedRows(it.rows), ), - onSelected = { - onSettingsChanged(settings.copy(resolutionMode = it)) - }, - ) - if (settings.resolutionMode == X11ResolutionMode.SCALED) { - SettingsSlider( - title = "Display scale", - value = settings.displayScalePercent, - range = 50..200, - suffix = "%", - onChanged = { - onSettingsChanged( - settings.copy(displayScalePercent = it), - ) - }, ) - } - if (settings.resolutionMode == X11ResolutionMode.EXACT) { - SettingsLabel( - title = "Fixed size", - subtitle = "${settings.exactWidth} ร— ${settings.exactHeight}", + }, + onEdit = { row, column -> + keyTarget = X11KeyTarget.Replace(row, column) + query = "" + destination = X11SettingsDestination.KEY_PICKER + }, + onAddKey = { row -> + keyTarget = X11KeyTarget.Append(row) + query = "" + destination = X11SettingsDestination.KEY_PICKER + }, + onAddRow = { + keyTarget = X11KeyTarget.NewRow + query = "" + destination = X11SettingsDestination.KEY_PICKER + }, + onRemoveKey = { row, column -> + onSettingsChanged( + settings.copy( + overlayKeyRows = removeTerminalExtraKey(settings.overlayKeyRows, row, column), + ), ) - ChoiceGroup( - selected = settings.exactWidth to settings.exactHeight, - options = - listOf( - (1280 to 720) to "720p", - (1600 to 900) to "900p", - (1920 to 1080) to "1080p", - ), - onSelected = { - onSettingsChanged( - settings.copy( - exactWidth = it.first, - exactHeight = it.second, - ), - ) - }, + }, + onRemoveRow = { row -> + onSettingsChanged( + settings.copy(overlayKeyRows = removeTerminalExtraKeyRow(settings.overlayKeyRows, row)), ) + }, + onReset = { onSettingsChanged(settings.copy(overlayKeyRows = OverlayKeyboard.defaultRows)) }, + onImport = { importKeys.launch(arrayOf("application/json", "text/*")) }, + onExport = { exportKeys.launch("udroid-display-keys.json") }, + ) + + X11SettingsDestination.KEY_PICKER -> { + val initial = + (keyTarget as? X11KeyTarget.Replace)?.let { + settings.overlayKeyRows.getOrNull(it.row)?.getOrNull(it.column) } - ChoiceGroup( - selected = settings.displayFilter, - options = - listOf( - X11DisplayFilter.NEAREST to "Sharp", - X11DisplayFilter.BILINEAR to "Smooth", - ), - onSelected = { - onSettingsChanged(settings.copy(displayFilter = it)) - }, - ) - SettingsSwitch( - title = "Stretch to fill", - subtitle = "Fill the screen even if the picture changes shape", - checked = settings.stretchDisplay, - onCheckedChange = { - onSettingsChanged(settings.copy(stretchDisplay = it)) - }, - ) + TerminalExtraKeyPickerPage( + modifier = Modifier.fillMaxSize(), + query = query, + initial = initial, + keys = OverlayKeyboard.keys, + keyLabel = OverlayKeyboard::label, + keyBinding = OverlayKeyboard::binding, + onQueryChanged = { query = it }, + onBack = ::goBack, + onSelected = { binding -> + val rows = settings.overlayKeyRows.map { it.toMutableList() }.toMutableList() + when (val target = keyTarget) { + is X11KeyTarget.Replace -> rows[target.row][target.column] = binding + is X11KeyTarget.Append -> rows[target.row].add(binding) + X11KeyTarget.NewRow -> rows.add(mutableListOf(binding)) + null -> Unit + } + onSettingsChanged(settings.copy(overlayKeyRows = OverlayKeyboard.normalizedRows(rows))) + keyTarget = null + destination = X11SettingsDestination.KEYBOARD_STRIP + }, + ) + } - SettingsDivider() - SettingsSectionTitle("Pointer") - SettingsLabel( - title = "Touch input", - subtitle = - "Direct follows your finger. Trackpad moves the pointer relative to your touch. " + - "Native sends all touch points to Linux.", - ) - ChoiceGroup( - selected = settings.touchMode, - options = - listOf( - X11TouchMode.DIRECT to "Direct", - X11TouchMode.TRACKPAD to "Trackpad", - X11TouchMode.NATIVE to "Native", + X11SettingsDestination.DISPLAY_BUTTONS -> + DisplayButtonsSettingsPage( + settings = settings, + onBack = ::goBack, + onAdd = { + displayButtonIndex = -1 + query = "" + destination = X11SettingsDestination.DISPLAY_BUTTON_PICKER + }, + onEdit = { + displayButtonIndex = it + query = "" + destination = X11SettingsDestination.DISPLAY_BUTTON_PICKER + }, + onRemove = { index -> + onSettingsChanged( + settings.copy( + displayKeys = settings.displayKeys.filterIndexed { i, _ -> i != index }, ), - onSelected = { - onSettingsChanged(settings.copy(touchMode = it)) - }, - ) - if (settings.touchMode == X11TouchMode.TRACKPAD) { - SettingsSlider( - title = "Trackpad speed", - value = settings.trackpadSpeedPercent, - range = 25..300, - suffix = "%", - onChanged = { - onSettingsChanged( - settings.copy(trackpadSpeedPercent = it), - ) - }, ) - } + }, + onArrange = onArrangeDisplayKeys, + ) - SettingsDivider() - SettingsSectionTitle("Keyboard") - SettingsSwitch( - title = "Use hardware keyboard layout", - subtitle = "Let Linux manage the physical keyboard layout", - checked = settings.preferScancodes, - onCheckedChange = { - onSettingsChanged(settings.copy(preferScancodes = it)) - }, - ) - SettingsSwitch( - title = "Keep desktop above keyboard", - subtitle = - "Resize the Linux display when the Android keyboard opens. " + - "Turn off to let the keyboard cover the desktop.", - checked = settings.avoidSoftKeyboard, - onCheckedChange = { - onSettingsChanged(settings.copy(avoidSoftKeyboard = it)) - }, - ) - SettingsLabel( - title = "Keyboard strip", - subtitle = - "These keys stay below Linux and move above the Android keyboard when it opens. " + - "Hold for chords; double-tap a modifier to lock it.", - ) - SettingsSwitch( - title = "Show keyboard strip", - subtitle = "Hide the two rows to give Linux more room", - checked = settings.showKeyboardStrip, - onCheckedChange = { - onSettingsChanged(settings.copy(showKeyboardStrip = it)) - }, - ) - OverlayKeyboard.normalized(settings.overlayKeys).chunked(4) - .forEachIndexed { row, bindings -> - Row(modifier = Modifier.fillMaxWidth()) { - bindings.forEachIndexed { column, keyCode -> - val slot = row * 4 + column - OutlinedButton( - onClick = { - keySearch = "" - editingSlot = slot - }, - modifier = Modifier.weight(1f).padding(horizontal = 2.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(2.dp), - ) { - Text( - OverlayKeyboard.label(keyCode), - maxLines = 1, - style = MaterialTheme.typography.labelSmall, - ) - } - } - } + X11SettingsDestination.DISPLAY_BUTTON_PICKER -> { + val initial = settings.displayKeys.getOrNull(displayButtonIndex)?.binding + TerminalExtraKeyPickerPage( + modifier = Modifier.fillMaxSize(), + query = query, + initial = initial, + title = if (displayButtonIndex < 0) "Add display button" else "Edit display button", + keys = OverlayKeyboard.keys, + keyLabel = OverlayKeyboard::label, + keyBinding = OverlayKeyboard::binding, + onQueryChanged = { query = it }, + onBack = ::goBack, + onSelected = { binding -> + val buttons = settings.displayKeys.toMutableList() + if (displayButtonIndex in buttons.indices) { + buttons[displayButtonIndex] = buttons[displayButtonIndex].copy(binding = binding) + } else { + val index = buttons.size + buttons += DisplayKey(binding, 0.12f + (index % 4) * 0.2f, 0.4f + (index / 4) * 0.08f) } - TextButton( - onClick = { - onSettingsChanged(settings.copy(overlayKeys = OverlayKeyboard.defaults)) - }, - ) { - Text("Reset keyboard strip") - } + onSettingsChanged(settings.copy(displayKeys = buttons)) + destination = X11SettingsDestination.DISPLAY_BUTTONS + }, + ) + } + } + } + } +} - SettingsLabel( - title = "Display buttons", - subtitle = - "Place individual keys over Linux for shortcuts or game controls. " + - "They work without opening the Android keyboard. " + - "Use Arrange on display to move them without pressing a key.", - ) - settings.displayKeys.forEachIndexed { index, key -> - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = OverlayKeyboard.label(key.keyCode), - modifier = Modifier.weight(1f).padding(start = 12.dp), - ) - TextButton(onClick = { editingDisplayKey = index }) { - Text("Change") - } - TextButton( - onClick = { - onSettingsChanged( - settings.copy( - displayKeys = settings.displayKeys.filterIndexed { i, _ -> i != index }, - ), - ) - }, - ) { - Text("Remove") - } - } - } - Row { - TextButton( - onClick = { addingDisplayKey = true }, - enabled = settings.displayKeys.size < 32, - ) { - Text("Add button") - } - if (settings.displayKeys.isNotEmpty()) { - TextButton(onClick = onArrangeDisplayKeys) { - Text("Arrange on display") - } - } - } +@Composable +private fun X11SettingsHomePage( + settings: X11Settings, + onSettingsChanged: (X11Settings) -> Unit, + onKeyboardStrip: () -> Unit, + onDisplayButtons: () -> Unit, + onBack: () -> Unit, +) { + Column(Modifier.fillMaxSize()) { + TerminalSettingsHeader("Desktop settings", onBack) + LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp)) { + item { + Text( + "Changes apply live to display :0", + color = UdroidTerminalMuted, + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(18.dp)) + SettingsSectionTitle("Output") + SettingsLabel("Resolution", "Native matches your screen. Scaled changes the size of Linux controls.") + ChoiceGroup( + selected = settings.resolutionMode, + options = + listOf( + X11ResolutionMode.NATIVE to "Native", + X11ResolutionMode.SCALED to "Scaled", + X11ResolutionMode.EXACT to "Fixed", + ), + onSelected = { onSettingsChanged(settings.copy(resolutionMode = it)) }, + ) + if (settings.resolutionMode == X11ResolutionMode.SCALED) { + SettingsSlider("Display scale", settings.displayScalePercent, 50..200, "%") { + onSettingsChanged(settings.copy(displayScalePercent = it)) + } + } + if (settings.resolutionMode == X11ResolutionMode.EXACT) { + SettingsLabel("Fixed size", "${settings.exactWidth} ร— ${settings.exactHeight}") + ChoiceGroup( + selected = settings.exactWidth to settings.exactHeight, + options = + listOf( + (1280 to 720) to "720p", + (1600 to 900) to "900p", + (1920 to 1080) to "1080p", + ), + onSelected = { + onSettingsChanged(settings.copy(exactWidth = it.first, exactHeight = it.second)) + }, + ) + } + ChoiceGroup( + selected = settings.displayFilter, + options = + listOf( + X11DisplayFilter.NEAREST to "Sharp", + X11DisplayFilter.BILINEAR to "Smooth", + ), + onSelected = { onSettingsChanged(settings.copy(displayFilter = it)) }, + ) + SettingsSwitch("Stretch to fill", "Fill the screen even if the picture changes shape", settings.stretchDisplay) { + onSettingsChanged(settings.copy(stretchDisplay = it)) + } - SettingsDivider() - SettingsSectionTitle("Session") - SettingsSwitch( - title = "Keep screen on", - subtitle = "Prevent the screen from sleeping while the desktop is open", - checked = settings.keepScreenOn, - onCheckedChange = { - onSettingsChanged(settings.copy(keepScreenOn = it)) - }, - ) - SettingsSwitch( - title = "Start with controls collapsed", - subtitle = "Show only the compact handle when the desktop opens", - checked = settings.startControlsCollapsed, - onCheckedChange = { - onSettingsChanged( - settings.copy(startControlsCollapsed = it), - ) - }, - ) - Spacer(modifier = Modifier.height(8.dp)) + SettingsDivider() + SettingsSectionTitle("Pointer") + SettingsLabel( + "Touch input", + "Direct follows your finger. Trackpad moves the pointer relative to your touch. Native sends all touch points to Linux.", + ) + ChoiceGroup( + selected = settings.touchMode, + options = + listOf( + X11TouchMode.DIRECT to "Direct", + X11TouchMode.TRACKPAD to "Trackpad", + X11TouchMode.NATIVE to "Native", + ), + onSelected = { onSettingsChanged(settings.copy(touchMode = it)) }, + ) + if (settings.touchMode == X11TouchMode.TRACKPAD) { + SettingsSlider("Trackpad speed", settings.trackpadSpeedPercent, 25..300, "%") { + onSettingsChanged(settings.copy(trackpadSpeedPercent = it)) } } + + SettingsDivider() + SettingsSectionTitle("Keyboard & controls") + SettingsSwitch("Use hardware keyboard layout", "Let Linux manage the physical keyboard layout", settings.preferScancodes) { + onSettingsChanged(settings.copy(preferScancodes = it)) + } + SettingsSwitch("Keep desktop above keyboard", "Resize Linux when the Android keyboard opens", settings.avoidSoftKeyboard) { + onSettingsChanged(settings.copy(avoidSoftKeyboard = it)) + } + SettingsNavigationRow( + "Keyboard strip", + if (settings.showKeyboardStrip) { + "${settings.overlayKeyRows.size} rows ยท ${settings.overlayKeyRows.sumOf { it.size }} keys" + } else { + "Hidden" + }, + onKeyboardStrip, + ) + Spacer(Modifier.height(8.dp)) + SettingsNavigationRow( + "Display buttons", + if (settings.displayKeys.isEmpty()) "No floating buttons" else "${settings.displayKeys.size} buttons ยท position over Linux", + onDisplayButtons, + ) + + SettingsDivider() + SettingsSectionTitle("Session") + SettingsSwitch("Keep screen on", "Prevent sleep while the desktop is open", settings.keepScreenOn) { + onSettingsChanged(settings.copy(keepScreenOn = it)) + } + SettingsSwitch("Start with controls collapsed", "Show only the compact handle when the desktop opens", settings.startControlsCollapsed) { + onSettingsChanged(settings.copy(startControlsCollapsed = it)) + } } } } +} - if (editingSlot >= 0 || editingDisplayKey >= 0 || addingDisplayKey) { - UdroidTerminalTheme { - AlertDialog( - onDismissRequest = { - editingSlot = -1 - editingDisplayKey = -1 - addingDisplayKey = false - }, - title = { Text("Choose key") }, - text = { - Column { - OutlinedTextField( - value = keySearch, - onValueChange = { keySearch = it }, - label = { Text("Search keys") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) { - items( - OverlayKeyboard.keys.filter { - OverlayKeyboard.label(it).contains(keySearch, ignoreCase = true) - }, - ) { keyCode -> - Text( - text = OverlayKeyboard.label(keyCode), - modifier = - Modifier.fillMaxWidth().clickable { - when { - editingSlot >= 0 -> { - val bindings = OverlayKeyboard.normalized(settings.overlayKeys).toMutableList() - bindings[editingSlot] = keyCode - onSettingsChanged(settings.copy(overlayKeys = bindings)) - } - editingDisplayKey >= 0 -> { - val buttons = settings.displayKeys.toMutableList() - buttons[editingDisplayKey] = buttons[editingDisplayKey].copy(keyCode = keyCode) - onSettingsChanged(settings.copy(displayKeys = buttons)) - } - addingDisplayKey -> { - val index = settings.displayKeys.size - onSettingsChanged( - settings.copy( - displayKeys = settings.displayKeys + - DisplayKey(keyCode, 0.12f + (index % 4) * 0.2f, 0.4f + (index / 4) * 0.08f), - ), - ) - } - } - editingSlot = -1 - editingDisplayKey = -1 - addingDisplayKey = false - }.padding(12.dp), - ) - } +@Composable +private fun DisplayButtonsSettingsPage( + settings: X11Settings, + onBack: () -> Unit, + onAdd: () -> Unit, + onEdit: (Int) -> Unit, + onRemove: (Int) -> Unit, + onArrange: () -> Unit, +) { + Column(Modifier.fillMaxSize()) { + TerminalSettingsHeader("Display buttons", onBack) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + Text( + "Place shortcuts or game controls directly over Linux. Labels and macros use the same format as the keyboard strip.", + color = UdroidTerminalMuted, + style = MaterialTheme.typography.bodyMedium, + ) + } + itemsIndexed(settings.displayKeys) { index, key -> + Surface( + color = UdroidTerminalSurface, + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, UdroidTerminalLine), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(OverlayKeyboard.label(key.binding), fontWeight = FontWeight.SemiBold) + Text(if (key.binding.macro) "Macro" else "Key", color = UdroidTerminalMuted, style = MaterialTheme.typography.bodySmall) + } + TextButton(onClick = { onEdit(index) }) { Text("Edit") } + IconButton(onClick = { onRemove(index) }) { + Icon(Icons.Outlined.Delete, contentDescription = "Remove ${OverlayKeyboard.label(key.binding)}") } } - }, - confirmButton = { - TextButton(onClick = { - editingSlot = -1 - editingDisplayKey = -1 - addingDisplayKey = false - }) { Text("Cancel") } - }, - ) + } + } + item { + OutlinedButton(onClick = onAdd, modifier = Modifier.fillMaxWidth(), enabled = settings.displayKeys.size < 32) { + Icon(Icons.Outlined.Add, contentDescription = null) + Spacer(Modifier.padding(4.dp)) + Text("Add display button") + } + } + if (settings.displayKeys.isNotEmpty()) { + item { + TextButton(onClick = onArrange, modifier = Modifier.fillMaxWidth()) { Text("Arrange on display") } + } + } + } + } +} + +@Composable +private fun SettingsNavigationRow( + title: String, + detail: String, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + color = UdroidTerminalSurface, + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, UdroidTerminalLine), + ) { + Row(modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text(detail, color = UdroidTerminalMuted, style = MaterialTheme.typography.bodySmall) + } + Icon(Icons.AutoMirrored.Outlined.KeyboardArrowRight, contentDescription = "Open $title", tint = UdroidTerminalMuted) } } } @Composable private fun SettingsSectionTitle(text: String) { - Text( - text = text, - color = UdroidTerminalGreen, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(bottom = 10.dp), - ) + Text(text, color = UdroidTerminalGreen, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(bottom = 10.dp)) } @Composable -private fun SettingsLabel( - title: String, - subtitle: String, -) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = subtitle, - color = UdroidTerminalMuted, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 2.dp, bottom = 10.dp), - ) +private fun SettingsLabel(title: String, subtitle: String) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(subtitle, color = UdroidTerminalMuted, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 2.dp, bottom = 10.dp)) } @Composable @OptIn(ExperimentalMaterial3Api::class) -private fun ChoiceGroup( - selected: T, - options: List>, - onSelected: (T) -> Unit, -) { +private fun ChoiceGroup(selected: T, options: List>, onSelected: (T) -> Unit) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { options.forEachIndexed { index, (value, label) -> - val isSelected = value == selected SegmentedButton( - selected = isSelected, + selected = value == selected, onClick = { onSelected(value) }, shape = SegmentedButtonDefaults.itemShape(index, options.size), - ) { - Text(text = label) - } + ) { Text(label) } } } - Spacer(modifier = Modifier.height(14.dp)) + Spacer(Modifier.height(14.dp)) } @Composable @@ -480,70 +541,33 @@ private fun SettingsSlider( step: Int = 5, onChanged: (Int) -> Unit, ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), - ) - Text( - text = "$value$suffix", - fontFamily = FontFamily.Monospace, - color = UdroidTerminalGreen, - style = MaterialTheme.typography.labelMedium, - ) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + Text("$value$suffix", fontFamily = FontFamily.Monospace, color = UdroidTerminalGreen, style = MaterialTheme.typography.labelMedium) } Slider( value = value.toFloat(), - onValueChange = { - val quantized = (it / step).roundToInt() * step - onChanged(quantized.coerceIn(range)) - }, + onValueChange = { onChanged(((it / step).roundToInt() * step).coerceIn(range)) }, valueRange = range.first.toFloat()..range.last.toFloat(), steps = ((range.last - range.first) / step - 1).coerceAtLeast(0), ) } @Composable -private fun SettingsSwitch( - title: String, - subtitle: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { +private fun SettingsSwitch(title: String, subtitle: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { onCheckedChange(!checked) } - .padding(vertical = 8.dp), + modifier = Modifier.fillMaxWidth().clickable { onCheckedChange(!checked) }.padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = subtitle, - color = UdroidTerminalMuted, - style = MaterialTheme.typography.bodySmall, - ) + Text(title, style = MaterialTheme.typography.titleMedium) + Text(subtitle, color = UdroidTerminalMuted, style = MaterialTheme.typography.bodySmall) } - Switch( - checked = checked, - onCheckedChange = null, - ) + Switch(checked = checked, onCheckedChange = null) } } @Composable private fun SettingsDivider() { - HorizontalDivider( - modifier = Modifier.padding(vertical = 18.dp), - color = UdroidTerminalLine, - ) + HorizontalDivider(modifier = Modifier.padding(vertical = 18.dp), color = UdroidTerminalLine) } diff --git a/app/src/main/java/org/randomcoder/udroid/x11/OverlayKeyboard.kt b/app/src/main/java/org/randomcoder/udroid/x11/OverlayKeyboard.kt index bef2d14..f4a95a3 100644 --- a/app/src/main/java/org/randomcoder/udroid/x11/OverlayKeyboard.kt +++ b/app/src/main/java/org/randomcoder/udroid/x11/OverlayKeyboard.kt @@ -1,8 +1,9 @@ package org.randomcoder.udroid.x11 import android.view.KeyEvent +import org.randomcoder.udroid.input.ExtraKeyBinding -data class DisplayKey(val keyCode: Int, val x: Float, val y: Float) +data class DisplayKey(val binding: ExtraKeyBinding, val x: Float, val y: Float) object OverlayKeyboard { const val MOUSE_LEFT = -1 @@ -34,7 +35,7 @@ object OverlayKeyboard { (KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9).toList() + (KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12).toList() - val defaults: List = + val defaultKeyCodes: List = listOf( KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_META_LEFT, @@ -44,17 +45,120 @@ object OverlayKeyboard { KeyEvent.KEYCODE_MOVE_HOME, KeyEvent.KEYCODE_MOVE_END, ) - fun normalized(saved: List): List = + val defaultRows: List> = defaultKeyCodes.map(::binding).chunked(6) + val defaults: List = defaultRows.flatten() + + fun normalized(saved: List): List = defaults.indices.map { index -> - saved.getOrNull(index)?.takeIf { it in keys } ?: defaults[index] + saved.getOrNull(index)?.normalized()?.takeIf { it.action.isNotEmpty() } ?: defaults[index] } + fun normalizedRows(saved: List>): List> = + saved.mapNotNull { row -> + row.mapNotNull { it.normalized().takeIf { binding -> binding.action.isNotEmpty() } } + .takeIf { it.isNotEmpty() } + }.takeIf { it.isNotEmpty() } ?: defaultRows + fun normalizedDisplayKeys(saved: List): List = saved.take(32).mapNotNull { key -> - key.takeIf { it.keyCode in keys && it.x.isFinite() && it.y.isFinite() } + key.takeIf { it.binding.action.isNotBlank() && it.x.isFinite() && it.y.isFinite() } ?.copy(x = key.x.coerceIn(0f, 1f), y = key.y.coerceIn(0f, 1f)) } + fun binding(keyCode: Int): ExtraKeyBinding = ExtraKeyBinding(name(keyCode)) + + fun keyCode(name: String): Int? = + when (name.trim().uppercase()) { + "MOUSE_LEFT" -> MOUSE_LEFT + "MOUSE_MIDDLE" -> MOUSE_MIDDLE + "MOUSE_RIGHT" -> MOUSE_RIGHT + "SCROLL_UP" -> SCROLL_UP + "SCROLL_DOWN" -> SCROLL_DOWN + "CTRL", "CONTROL" -> KeyEvent.KEYCODE_CTRL_LEFT + "RCTRL", "RIGHT_CTRL" -> KeyEvent.KEYCODE_CTRL_RIGHT + "ALT" -> KeyEvent.KEYCODE_ALT_LEFT + "RALT", "RIGHT_ALT" -> KeyEvent.KEYCODE_ALT_RIGHT + "SHIFT" -> KeyEvent.KEYCODE_SHIFT_LEFT + "RSHIFT", "RIGHT_SHIFT" -> KeyEvent.KEYCODE_SHIFT_RIGHT + "META", "SUPER" -> KeyEvent.KEYCODE_META_LEFT + "RMETA", "RSUPER" -> KeyEvent.KEYCODE_META_RIGHT + "ESC", "ESCAPE" -> KeyEvent.KEYCODE_ESCAPE + "TAB" -> KeyEvent.KEYCODE_TAB + "SPACE" -> KeyEvent.KEYCODE_SPACE + "ENTER", "RETURN" -> KeyEvent.KEYCODE_ENTER + "BKSP", "BACKSPACE" -> KeyEvent.KEYCODE_DEL + "DEL", "DELETE" -> KeyEvent.KEYCODE_FORWARD_DEL + "UP" -> KeyEvent.KEYCODE_DPAD_UP + "LEFT" -> KeyEvent.KEYCODE_DPAD_LEFT + "DOWN" -> KeyEvent.KEYCODE_DPAD_DOWN + "RIGHT" -> KeyEvent.KEYCODE_DPAD_RIGHT + "HOME" -> KeyEvent.KEYCODE_MOVE_HOME + "END" -> KeyEvent.KEYCODE_MOVE_END + "PGUP" -> KeyEvent.KEYCODE_PAGE_UP + "PGDN" -> KeyEvent.KEYCODE_PAGE_DOWN + "INS" -> KeyEvent.KEYCODE_INSERT + "-" -> KeyEvent.KEYCODE_MINUS + "=" -> KeyEvent.KEYCODE_EQUALS + "[" -> KeyEvent.KEYCODE_LEFT_BRACKET + "]" -> KeyEvent.KEYCODE_RIGHT_BRACKET + "\\" -> KeyEvent.KEYCODE_BACKSLASH + ";" -> KeyEvent.KEYCODE_SEMICOLON + "'" -> KeyEvent.KEYCODE_APOSTROPHE + "," -> KeyEvent.KEYCODE_COMMA + "." -> KeyEvent.KEYCODE_PERIOD + "/" -> KeyEvent.KEYCODE_SLASH + else -> parseLetterNumberOrFunction(name.trim()) + } + + fun name(keyCode: Int): String = + when (keyCode) { + MOUSE_LEFT -> "MOUSE_LEFT" + MOUSE_MIDDLE -> "MOUSE_MIDDLE" + MOUSE_RIGHT -> "MOUSE_RIGHT" + SCROLL_UP -> "SCROLL_UP" + SCROLL_DOWN -> "SCROLL_DOWN" + KeyEvent.KEYCODE_CTRL_LEFT -> "CTRL" + KeyEvent.KEYCODE_CTRL_RIGHT -> "RCTRL" + KeyEvent.KEYCODE_ALT_LEFT -> "ALT" + KeyEvent.KEYCODE_ALT_RIGHT -> "RALT" + KeyEvent.KEYCODE_SHIFT_LEFT -> "SHIFT" + KeyEvent.KEYCODE_SHIFT_RIGHT -> "RSHIFT" + KeyEvent.KEYCODE_META_LEFT -> "META" + KeyEvent.KEYCODE_META_RIGHT -> "RMETA" + KeyEvent.KEYCODE_ESCAPE -> "ESC" + KeyEvent.KEYCODE_TAB -> "TAB" + KeyEvent.KEYCODE_SPACE -> "SPACE" + KeyEvent.KEYCODE_ENTER -> "ENTER" + KeyEvent.KEYCODE_DEL -> "BKSP" + KeyEvent.KEYCODE_FORWARD_DEL -> "DEL" + KeyEvent.KEYCODE_DPAD_UP -> "UP" + KeyEvent.KEYCODE_DPAD_LEFT -> "LEFT" + KeyEvent.KEYCODE_DPAD_DOWN -> "DOWN" + KeyEvent.KEYCODE_DPAD_RIGHT -> "RIGHT" + KeyEvent.KEYCODE_MOVE_HOME -> "HOME" + KeyEvent.KEYCODE_MOVE_END -> "END" + KeyEvent.KEYCODE_PAGE_UP -> "PGUP" + KeyEvent.KEYCODE_PAGE_DOWN -> "PGDN" + KeyEvent.KEYCODE_INSERT -> "INS" + KeyEvent.KEYCODE_MINUS -> "-" + KeyEvent.KEYCODE_EQUALS -> "=" + KeyEvent.KEYCODE_LEFT_BRACKET -> "[" + KeyEvent.KEYCODE_RIGHT_BRACKET -> "]" + KeyEvent.KEYCODE_BACKSLASH -> "\\" + KeyEvent.KEYCODE_SEMICOLON -> ";" + KeyEvent.KEYCODE_APOSTROPHE -> "'" + KeyEvent.KEYCODE_COMMA -> "," + KeyEvent.KEYCODE_PERIOD -> "." + KeyEvent.KEYCODE_SLASH -> "/" + in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> ('A'.code + keyCode - KeyEvent.KEYCODE_A).toChar().toString() + in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> ('0'.code + keyCode - KeyEvent.KEYCODE_0).toChar().toString() + in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> "F${keyCode - KeyEvent.KEYCODE_F1 + 1}" + else -> "Key $keyCode" + } + + fun label(binding: ExtraKeyBinding): String = + binding.display ?: if (binding.macro) binding.action else keyCode(binding.action)?.let(::label) ?: binding.action + fun isModifier(keyCode: Int): Boolean = keyCode in KeyEvent.KEYCODE_CTRL_LEFT..KeyEvent.KEYCODE_CTRL_RIGHT || keyCode in KeyEvent.KEYCODE_ALT_LEFT..KeyEvent.KEYCODE_ALT_RIGHT || @@ -87,8 +191,19 @@ object OverlayKeyboard { KeyEvent.KEYCODE_MOVE_END -> "End" KeyEvent.KEYCODE_PAGE_UP -> "PgUp" KeyEvent.KEYCODE_PAGE_DOWN -> "PgDn" - else -> KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_") + else -> name(keyCode) } + + private fun parseLetterNumberOrFunction(name: String): Int? { + val upper = name.uppercase() + return when { + upper.length == 1 && upper[0] in 'A'..'Z' -> KeyEvent.KEYCODE_A + (upper[0] - 'A') + upper.length == 1 && upper[0] in '0'..'9' -> KeyEvent.KEYCODE_0 + (upper[0] - '0') + upper.startsWith("F") && upper.drop(1).toIntOrNull() in 1..12 -> + KeyEvent.KEYCODE_F1 + (upper.drop(1).toInt() - 1) + else -> null + } + } } /** Tracks each touch/latch separately so duplicate bindings release only after their last owner. */ diff --git a/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java b/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java index a0c5794..b50bad8 100644 --- a/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java +++ b/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayView.java @@ -217,6 +217,10 @@ public void detachRenderer() { rendererAttached = false; } + public void resetInput() { + releaseAllInput(); + } + public boolean isRendererAttached() { return rendererAttached && connected(); } diff --git a/app/src/main/java/org/randomcoder/udroid/x11/X11Settings.kt b/app/src/main/java/org/randomcoder/udroid/x11/X11Settings.kt index f1f98fb..c175de5 100644 --- a/app/src/main/java/org/randomcoder/udroid/x11/X11Settings.kt +++ b/app/src/main/java/org/randomcoder/udroid/x11/X11Settings.kt @@ -3,6 +3,9 @@ package org.randomcoder.udroid.x11 import android.content.Context import org.json.JSONArray import org.json.JSONException +import org.json.JSONObject +import org.randomcoder.udroid.input.ExtraKeyBinding +import org.randomcoder.udroid.runtime.TerminalExtraKeys import kotlin.math.roundToInt enum class X11ResolutionMode { @@ -34,7 +37,7 @@ data class X11Settings( val preferScancodes: Boolean = false, val keepScreenOn: Boolean = true, val startControlsCollapsed: Boolean = false, - val overlayKeys: List = OverlayKeyboard.defaults, + val overlayKeyRows: List> = OverlayKeyboard.defaultRows, val displayKeys: List = emptyList(), val showKeyboardStrip: Boolean = true, val avoidSoftKeyboard: Boolean = true, @@ -143,11 +146,7 @@ class X11SettingsStore(context: Context) { keepScreenOn = preferences.getBoolean(KEY_KEEP_SCREEN_ON, true), startControlsCollapsed = preferences.getBoolean(KEY_START_CONTROLS_COLLAPSED, false), - overlayKeys = - OverlayKeyboard.normalized( - preferences.getString(KEY_OVERLAY_KEYS, null) - ?.split(',')?.mapNotNull(String::toIntOrNull).orEmpty(), - ), + overlayKeyRows = parseOverlayKeyRows(preferences.getString(KEY_OVERLAY_KEYS, null)), displayKeys = loadDisplayKeys(preferences.getString(KEY_DISPLAY_KEYS, null)), showKeyboardStrip = preferences.getBoolean(KEY_SHOW_KEYBOARD_STRIP, true), avoidSoftKeyboard = preferences.getBoolean(KEY_AVOID_SOFT_KEYBOARD, true), @@ -171,7 +170,11 @@ class X11SettingsStore(context: Context) { settings.startControlsCollapsed, ).putString( KEY_OVERLAY_KEYS, - OverlayKeyboard.normalized(settings.overlayKeys).joinToString(","), + JSONArray().apply { + OverlayKeyboard.normalizedRows(settings.overlayKeyRows).forEach { row -> + put(JSONArray().apply { row.forEach { put(bindingJson(it)) } }) + } + }.toString(), ).putBoolean( KEY_SHOW_KEYBOARD_STRIP, settings.showKeyboardStrip, @@ -182,7 +185,7 @@ class X11SettingsStore(context: Context) { KEY_DISPLAY_KEYS, JSONArray().apply { OverlayKeyboard.normalizedDisplayKeys(settings.displayKeys).forEach { key -> - put(JSONArray().put(key.keyCode).put(key.x.toDouble()).put(key.y.toDouble())) + put(JSONArray().put(bindingJson(key.binding)).put(key.x.toDouble()).put(key.y.toDouble())) } }.toString(), ).apply() @@ -198,9 +201,15 @@ class X11SettingsStore(context: Context) { try { val saved = JSONArray(raw ?: "[]") OverlayKeyboard.normalizedDisplayKeys( - (0 until saved.length()).map { index -> + (0 until saved.length()).mapNotNull { index -> saved.getJSONArray(index).let { key -> - DisplayKey(key.getInt(0), key.getDouble(1).toFloat(), key.getDouble(2).toFloat()) + val binding = + if (key.get(0) is Number) { + key.getInt(0).takeIf { it in OverlayKeyboard.keys }?.let(OverlayKeyboard::binding) + } else { + parseBinding(key.get(0)) + } + binding?.let { DisplayKey(it, key.getDouble(1).toFloat(), key.getDouble(2).toFloat()) } } }, ) @@ -208,6 +217,19 @@ class X11SettingsStore(context: Context) { emptyList() } + private fun parseBinding(value: Any): ExtraKeyBinding? = + parseOverlayBinding(value) + + private fun bindingJson(binding: ExtraKeyBinding): Any = + if (!binding.macro && binding.display == null) { + binding.action + } else { + JSONObject().apply { + put(if (binding.macro) "macro" else "key", binding.action) + binding.display?.let { put("display", it) } + } + } + private companion object { const val PREFERENCES_NAME = "embedded-x11-settings" const val KEY_RESOLUTION_MODE = "resolution-mode" @@ -227,3 +249,36 @@ class X11SettingsStore(context: Context) { const val KEY_AVOID_SOFT_KEYBOARD = "avoid-soft-keyboard" } } + +internal fun parseOverlayKeyRows(raw: String?): List> { + if (raw.isNullOrBlank()) return OverlayKeyboard.defaultRows + return runCatching { + if (raw.startsWith("[")) { + val array = JSONArray(raw) + if (array.optJSONArray(0) != null) { + TerminalExtraKeys.parseTermuxJson(raw).rows + } else { + (0 until array.length()).mapNotNull { parseOverlayBinding(array.get(it)) }.chunked(6) + } + } else { + raw.split(',').mapNotNull(String::toIntOrNull).map(OverlayKeyboard::binding).chunked(6) + } + }.map(OverlayKeyboard::normalizedRows).getOrDefault(OverlayKeyboard.defaultRows) +} + +private fun parseOverlayBinding(value: Any): ExtraKeyBinding? = + when (value) { + is String -> ExtraKeyBinding(value) + is JSONObject -> { + val key = value.optString("key").takeIf(String::isNotBlank) + val macro = value.optString("macro").takeIf(String::isNotBlank) + if ((key == null) == (macro == null)) null else { + ExtraKeyBinding( + action = key ?: macro.orEmpty(), + macro = macro != null, + display = value.optString("display").takeIf(String::isNotBlank), + ) + } + } + else -> null + } diff --git a/app/src/test/java/org/randomcoder/udroid/x11/OverlayKeyboardTest.kt b/app/src/test/java/org/randomcoder/udroid/x11/OverlayKeyboardTest.kt index bccba5f..26f9aef 100644 --- a/app/src/test/java/org/randomcoder/udroid/x11/OverlayKeyboardTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/x11/OverlayKeyboardTest.kt @@ -5,15 +5,16 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.randomcoder.udroid.input.ExtraKeyBinding class OverlayKeyboardTest { @Test fun bindingsRejectUnsupportedOrMissingKeys() { - val saved = listOf(KeyEvent.KEYCODE_F5, OverlayKeyboard.MOUSE_LEFT, -100) + val saved = listOf(OverlayKeyboard.binding(KeyEvent.KEYCODE_F5), OverlayKeyboard.binding(OverlayKeyboard.MOUSE_LEFT), ExtraKeyBinding("")) val normalized = OverlayKeyboard.normalized(saved) assertEquals(OverlayKeyboard.defaults.size, normalized.size) - assertEquals(KeyEvent.KEYCODE_F5, normalized[0]) - assertEquals(OverlayKeyboard.MOUSE_LEFT, normalized[1]) + assertEquals(OverlayKeyboard.binding(KeyEvent.KEYCODE_F5), normalized[0]) + assertEquals(OverlayKeyboard.binding(OverlayKeyboard.MOUSE_LEFT), normalized[1]) assertEquals(OverlayKeyboard.defaults[2], normalized[2]) } @@ -21,12 +22,36 @@ class OverlayKeyboardTest { fun displayButtonsKeepValidKeysAndClampTheirPositions() { val buttons = OverlayKeyboard.normalizedDisplayKeys( listOf( - DisplayKey(KeyEvent.KEYCODE_W, -1f, 2f), - DisplayKey(-100, 0.5f, 0.5f), - DisplayKey(KeyEvent.KEYCODE_A, Float.NaN, 0.5f), + DisplayKey(OverlayKeyboard.binding(KeyEvent.KEYCODE_W), -1f, 2f), + DisplayKey(ExtraKeyBinding(""), 0.5f, 0.5f), + DisplayKey(OverlayKeyboard.binding(KeyEvent.KEYCODE_A), Float.NaN, 0.5f), + ), + ) + assertEquals(listOf(DisplayKey(OverlayKeyboard.binding(KeyEvent.KEYCODE_W), 0f, 1f)), buttons) + } + + @Test + fun customDisplayDoesNotChangeTheBoundAction() { + val binding = ExtraKeyBinding("CTRL f d", macro = true, display = "tmux ๐Ÿšช") + + assertEquals("tmux ๐Ÿšช", OverlayKeyboard.label(binding)) + assertEquals(listOf("CTRL", "f", "d"), binding.tokens()) + } + + @Test + fun keyboardRowsKeepCustomLayoutAndDropBlankBindings() { + assertEquals( + listOf( + listOf(ExtraKeyBinding("CTRL"), ExtraKeyBinding("A")), + listOf(ExtraKeyBinding("ALT")), + ), + OverlayKeyboard.normalizedRows( + listOf( + listOf(ExtraKeyBinding("CTRL"), ExtraKeyBinding(""), ExtraKeyBinding("A")), + listOf(ExtraKeyBinding("ALT")), + ), ), ) - assertEquals(listOf(DisplayKey(KeyEvent.KEYCODE_W, 0f, 1f)), buttons) } @Test diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index de499dc..813dfb5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -47,7 +47,7 @@ stopped. ### Terminal colors -Select the palette button in **Terminal** to open **Terminal appearance**, then +Select the settings button in **Terminal** to open **Terminal settings**, then choose **Colors**. You can choose a built-in profile, create one, or customize a built-in profile as an editable copy. Changes apply to the running terminal and are kept for new terminal sessions. Built-in choices include uDroid, @@ -89,7 +89,7 @@ base appearance for the next session. ### Terminal font and text -Open **Terminal appearance**, then **Font & text**, to change the terminal font, +Open **Terminal settings**, then **Font & text**, to change the terminal font, text size, line height, cursor shape, and cursor blinking. The preview shows the selected settings before you return to the terminal. Pinch inside the terminal to change the same saved text-size setting. @@ -109,6 +109,33 @@ without deleting imported fonts. provides downloadable monospaced fonts. Check a font's license before sharing or redistributing its file. +### Terminal extra keys + +Open **Terminal settings โ†’ Extra keys** to show, hide, or customize the key +rail below the terminal. Tap a slot and search for a modifier, navigation key, +letter, number, punctuation key, or function key. Use **Add key** or **Add row** +to extend the layout; remove controls beside each key and row shrink it again. +Hold a key's drag handle and move it onto another key to relocate it within or +between rows. +An optional display label can use text, symbols, or emoji without changing the +key that is sent. A macro uses Termux's space-separated syntax, such as +`CTRL f d`, and runs the complete chord when tapped. + +| Gesture | Result | +| --- | --- | +| Tap Ctrl, Alt, Shift, or Fn | Apply it to the next key. | +| Hold a modifier and tap another key | Send a temporary chord. | +| Double-tap a modifier | Lock it until you tap it again. | + +**Import JSON** accepts the same two-dimensional `extra-keys` JSON matrix used +by Termux. Rows may contain key names or objects with `key`, `macro`, and +`display`. Popup actions are reported but not imported. Row and column counts +are preserved. **Export JSON** keeps macros and custom labels and can be used as +the value of `extra-keys` in `termux.properties`. + +See the [Termux extra-keys documentation](https://github.com/termux/termux-tools/blob/master/doc/termux.1.md.in) +for the upstream format. + ### Terminal OSC compatibility Terminal programs use Operating System Command (OSC) sequences to communicate @@ -198,18 +225,27 @@ button. ### Keyboard strip and display buttons -The two-row keyboard strip stays at the bottom even when the Android keyboard -is closed. If **Keep desktop above keyboard** is on, the strip moves above -the Android keyboard when it opens. Turn **Show keyboard strip** off in -**Desktop settings โ†’ Keyboard** to reclaim the space. +The keyboard strip stays at the bottom even when the Android keyboard is +closed. If **Keep desktop above keyboard** is on, the strip moves above the +Android keyboard when it opens. Open **Desktop settings โ†’ Keyboard strip** to +show or hide it. + +Add or remove rows and keys from the keyboard-strip page. Tap a key to edit it, +or hold its handle and drag it within or between rows. The editor supports +letters, numbers, modifiers, arrows, function and navigation keys, punctuation, +mouse buttons, and scroll directions. **Reset keyboard strip** restores the +default two-row layout. + +The page can also import or export a Termux extra-keys JSON matrix. Primary +keys, macros, and custom labels are supported; Termux popup actions are ignored. -The strip has 12 editable slots. In **Desktop settings โ†’ Keyboard**, tap a -slot to assign a letter, number, modifier, arrow, function or navigation key, -supported punctuation key, mouse button, or scroll direction. **Reset keyboard -strip** restores the default bindings. +Each strip or display button can also have a custom text, symbol, or emoji +label. Enter a Termux-style macro such as `CTRL c` to make a button send a +complete shortcut when tapped. Macro buttons are tap actions; ordinary key +buttons continue to support press, hold, chords, and modifier locking. **Keys** on the toolbar shows separate translucent buttons over the Linux -display. Add, change, or remove them in **Desktop settings โ†’ Keyboard**. To +display. Add, edit, or remove them in **Desktop settings โ†’ Display buttons**. To move one, choose **Arrange on display**, drag it to the desired place, then tap **Done** on the toolbar. Positions are saved. Normal use does not move buttons, so holding a key cannot accidentally drag it. From 6df23a18af05e1e8cef41ceb184c3ffc456c6a94 Mon Sep 17 00:00:00 2001 From: saicharankandukuri Date: Mon, 9 Nov 2026 13:32:06 +0530 Subject: [PATCH 3/3] fix(desktop): align keyboard strip styling Match the desktop keyboard strip to the terminal strip's spacing, sizing, typography, surface colors, and active modifier state. --- .../org/randomcoder/udroid/ui/DesktopPage.kt | 10 +++-- .../udroid/ui/X11DesktopHostView.kt | 37 ++++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt index fdca94f..7f8f77b 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/DesktopPage.kt @@ -127,9 +127,11 @@ fun DesktopPage( } val palettePrimary = MaterialTheme.colorScheme.primaryContainer.toArgb() - val paletteSurface = MaterialTheme.colorScheme.surfaceContainerHigh.toArgb() - val paletteText = MaterialTheme.colorScheme.onSurface.toArgb() - val paletteOutline = MaterialTheme.colorScheme.outline.toArgb() + val paletteSurface = UdroidTerminalSurface.toArgb() + val paletteRaised = UdroidTerminalRaised.toArgb() + val paletteText = UdroidTerminalText.toArgb() + val paletteActiveText = UdroidTerminalGreen.toArgb() + val paletteOutline = UdroidTerminalLine.toArgb() Surface( modifier = Modifier.fillMaxSize(), @@ -191,7 +193,9 @@ fun DesktopPage( it.setPaletteColors( primaryContainer = palettePrimary, surfaceContainer = paletteSurface, + raisedContainer = paletteRaised, onSurface = paletteText, + activeText = paletteActiveText, outline = paletteOutline, ) }, diff --git a/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt b/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt index fb1b010..959d033 100644 --- a/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt +++ b/app/src/main/java/org/randomcoder/udroid/ui/X11DesktopHostView.kt @@ -64,7 +64,9 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { private var momentaryButton = InputStub.BUTTON_UNDEFINED private var primaryContainer = Color.DKGRAY private var surfaceContainer = Color.rgb(35, 35, 35) + private var raisedContainer = Color.rgb(45, 45, 45) private var onSurface = Color.WHITE + private var activeText = Color.GREEN private var outline = Color.GRAY init { @@ -175,12 +177,16 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { fun setPaletteColors( primaryContainer: Int, surfaceContainer: Int, + raisedContainer: Int, onSurface: Int, + activeText: Int, outline: Int, ) { this.primaryContainer = primaryContainer this.surfaceContainer = surfaceContainer + this.raisedContainer = raisedContainer this.onSurface = onSurface + this.activeText = activeText this.outline = outline palette.background = roundedBackground(surfaceContainer, outline, 30) keyboardStrip.setBackgroundColor(surfaceContainer) @@ -252,27 +258,39 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { keyboardStrip.orientation = LinearLayout.VERTICAL keyboardStrip.isMotionEventSplittingEnabled = true keyboardStrip.setBackgroundColor(surfaceContainer) - keyboardStrip.setPadding(dp(4), dp(2), dp(4), dp(2)) + keyboardStrip.setPadding(dp(6), dp(4), dp(6), dp(4)) var slot = 0 - rows.forEach { bindings -> + rows.forEachIndexed { rowIndex, bindings -> val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL isMotionEventSplittingEnabled = true } - bindings.forEach { binding -> + bindings.forEachIndexed { columnIndex, binding -> val label = OverlayKeyboard.label(binding) val button = textButton(label, "Hold $label key").apply { - textSize = 12f + textSize = 11f + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) background = stripButtonBackground(false) } installKeyboardButton(button, slot) keyboardButtons += button - row.addView(button, LinearLayout.LayoutParams(0, dp(36), 1f)) + row.addView( + button, + LinearLayout.LayoutParams(0, dp(34), 1f).apply { + if (columnIndex > 0) marginStart = dp(2) + if (columnIndex < bindings.lastIndex) marginEnd = dp(2) + }, + ) slot += 1 } - keyboardStrip.addView(row, LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, dp(36))) + keyboardStrip.addView( + row, + LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, dp(34)).apply { + if (rowIndex < rows.lastIndex) bottomMargin = dp(4) + }, + ) } - keyboardStripHeight = dp(rows.size * 36 + 4) + keyboardStripHeight = dp(rows.size * 34 + (rows.size - 1) * 4 + 8) keyboardStrip.layoutParams = (keyboardStrip.layoutParams as? LayoutParams)?.apply { height = keyboardStripHeight } ?: LayoutParams(LayoutParams.MATCH_PARENT, keyboardStripHeight).apply { gravity = Gravity.BOTTOM } @@ -583,6 +601,7 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { val active = keyboardState.isPressed(touchOwner(slot)) || keyboardState.isPressed(latchOwner(slot)) val binding = bindingForSlot(slot) val keyCode = bindingKeyCode(binding) + view.setTextColor(if (active) activeText else onSurface) view.isLongClickable = !arrangingDisplayKeys && keyCode != null && OverlayKeyboard.isModifier(keyCode) if (slot >= 1000) { val label = OverlayKeyboard.label(binding) @@ -595,8 +614,8 @@ internal class X11DesktopHostView(context: Context) : FrameLayout(context) { private fun stripButtonBackground(active: Boolean): StateListDrawable = StateListDrawable().apply { - addState(intArrayOf(android.R.attr.state_pressed), roundedBackground(primaryContainer, Color.TRANSPARENT, 6)) - addState(intArrayOf(), roundedBackground(if (active) primaryContainer else Color.TRANSPARENT, Color.TRANSPARENT, 6)) + addState(intArrayOf(android.R.attr.state_pressed), roundedBackground(primaryContainer, Color.TRANSPARENT, 8)) + addState(intArrayOf(), roundedBackground(if (active) primaryContainer else raisedContainer, Color.TRANSPARENT, 8)) } private fun displayKeyBackground(active: Boolean): StateListDrawable =