Skip to content

Commit 0394960

Browse files
authored
feat: add configurable keyboard shortcuts for paste and session management (#49)
Add fully configurable keyboard shortcut system with: - ShortcutBinding data model with serialize/deserialize and conflict detection - KeyShortcutHandler for centralized dispatch (paste, new/close/switch session) - ShortcutCaptureDialog with real-time modifier display and conflict checking Uses LocalWindowInfo.isWindowFocused to fix first-keypress capture issue - Settings UI section in Customization with master toggle and per-action config - PASTE button added to virtual keys row for touchscreen users - Localized strings for English, Chinese, and Arabic Default shortcuts: Ctrl+Shift+V (paste), Ctrl+Shift+N/W (new/close session), Ctrl+Shift+Left/Right (switch session) Co-authored-by: jeffusion <admin@jeffusion.cc>
1 parent 67b4fb5 commit 0394960

12 files changed

Lines changed: 574 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Download the latest APK from the [Releases Section](https://github.com/RohitKush
88
- [x] Virtual Keys
99
- [x] Multiple Sessions
1010
- [x] Alpine Linux support
11+
- [x] Configurable Keyboard Shortcuts (Paste, Session Management)
12+
- [x] Touchscreen Paste Button
1113

1214
# Screenshots
1315
<div>

core/main/src/main/java/com/rk/settings/Settings.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ object Settings {
8989
get() = Preference.getBoolean(key = "force_soft_keyboard", default = true)
9090
set(value) = Preference.setBoolean(key = "force_soft_keyboard",value)
9191

92+
var shortcuts_enabled
93+
get() = Preference.getBoolean(key = "shortcuts_enabled", default = true)
94+
set(value) = Preference.setBoolean(key = "shortcuts_enabled", value)
95+
96+
fun getShortcutBinding(action: com.rk.terminal.ui.screens.terminal.ShortcutAction): com.rk.terminal.ui.screens.terminal.ShortcutBinding {
97+
val raw = Preference.getString(key = action.prefKey, default = action.default.serialize())
98+
return com.rk.terminal.ui.screens.terminal.ShortcutBinding.deserialize(raw)
99+
}
100+
101+
fun setShortcutBinding(action: com.rk.terminal.ui.screens.terminal.ShortcutAction, binding: com.rk.terminal.ui.screens.terminal.ShortcutBinding) {
102+
Preference.setString(key = action.prefKey, value = binding.serialize())
103+
}
104+
92105

93106

94107
}

core/main/src/main/java/com/rk/terminal/ui/screens/customization/Customization.kt

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ import com.rk.terminal.ui.screens.terminal.showToolbar
5959
import com.rk.terminal.ui.screens.terminal.showVirtualKeys
6060
import com.rk.terminal.ui.screens.terminal.terminalView
6161
import com.rk.terminal.ui.screens.terminal.wallAlpha
62+
import com.rk.terminal.ui.screens.terminal.ShortcutAction
63+
import com.rk.terminal.ui.screens.terminal.ShortcutCaptureDialog
6264
import kotlinx.coroutines.Dispatchers
6365
import kotlinx.coroutines.launch
6466
import kotlinx.coroutines.withContext
@@ -405,6 +407,59 @@ fun Customization(modifier: Modifier = Modifier) {
405407

406408
}
407409

410+
// Keyboard Shortcuts
411+
PreferenceGroup(heading = stringResource(strings.keyboard_shortcuts)) {
412+
var shortcutsEnabled by remember { mutableStateOf(Settings.shortcuts_enabled) }
413+
var showCaptureFor by remember { mutableStateOf<ShortcutAction?>(null) }
414+
415+
SettingsToggle(
416+
label = stringResource(strings.keyboard_shortcuts),
417+
description = stringResource(strings.keyboard_shortcuts_desc),
418+
showSwitch = true,
419+
default = Settings.shortcuts_enabled,
420+
sideEffect = {
421+
Settings.shortcuts_enabled = it
422+
shortcutsEnabled = it
423+
})
424+
425+
for (action in ShortcutAction.entries) {
426+
val binding = Settings.getShortcutBinding(action)
427+
val labelRes = when (action) {
428+
ShortcutAction.PASTE -> strings.shortcut_paste
429+
ShortcutAction.NEW_SESSION -> strings.shortcut_new_session
430+
ShortcutAction.CLOSE_SESSION -> strings.shortcut_close_session
431+
ShortcutAction.SWITCH_SESSION_PREV -> strings.shortcut_switch_prev
432+
ShortcutAction.SWITCH_SESSION_NEXT -> strings.shortcut_switch_next
433+
}
434+
val descRes = when (action) {
435+
ShortcutAction.PASTE -> strings.shortcut_paste_desc
436+
ShortcutAction.NEW_SESSION -> strings.shortcut_new_session_desc
437+
ShortcutAction.CLOSE_SESSION -> strings.shortcut_close_session_desc
438+
ShortcutAction.SWITCH_SESSION_PREV -> strings.shortcut_switch_prev_desc
439+
ShortcutAction.SWITCH_SESSION_NEXT -> strings.shortcut_switch_next_desc
440+
}
441+
SettingsToggle(
442+
isEnabled = shortcutsEnabled,
443+
label = stringResource(labelRes),
444+
description = "${stringResource(descRes)} (${binding.toDisplayString()})",
445+
showSwitch = false,
446+
default = false,
447+
sideEffect = { showCaptureFor = action },
448+
)
449+
}
450+
451+
if (showCaptureFor != null) {
452+
ShortcutCaptureDialog(
453+
action = showCaptureFor!!,
454+
onDismiss = { showCaptureFor = null },
455+
onConfirm = { binding ->
456+
Settings.setShortcutBinding(showCaptureFor!!, binding)
457+
showCaptureFor = null
458+
},
459+
)
460+
}
461+
}
462+
408463

409464
}
410465

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.rk.terminal.ui.screens.terminal
2+
3+
import android.view.KeyEvent
4+
import com.blankj.utilcode.util.ClipboardUtils
5+
import com.rk.settings.Settings
6+
import com.rk.terminal.ui.activities.terminal.MainActivity
7+
8+
/**
9+
* Centralized keyboard shortcut handler for the terminal.
10+
* Reads configurable bindings from Settings and dispatches actions.
11+
*/
12+
object KeyShortcutHandler {
13+
14+
/**
15+
* Handle a key event. Returns true if the key was consumed by a shortcut.
16+
*/
17+
fun handle(keyCode: Int, event: KeyEvent, activity: MainActivity): Boolean {
18+
if (!Settings.shortcuts_enabled) return false
19+
20+
// Try each action's binding
21+
for (action in ShortcutAction.entries) {
22+
val binding = Settings.getShortcutBinding(action)
23+
if (binding.matches(event)) {
24+
return dispatch(action, activity)
25+
}
26+
}
27+
return false
28+
}
29+
30+
private fun dispatch(action: ShortcutAction, activity: MainActivity): Boolean {
31+
return when (action) {
32+
ShortcutAction.PASTE -> handlePaste()
33+
ShortcutAction.NEW_SESSION -> handleNewSession(activity)
34+
ShortcutAction.CLOSE_SESSION -> handleCloseSession(activity)
35+
ShortcutAction.SWITCH_SESSION_PREV -> handleSwitchSession(activity, forward = false)
36+
ShortcutAction.SWITCH_SESSION_NEXT -> handleSwitchSession(activity, forward = true)
37+
}
38+
}
39+
40+
private fun handlePaste(): Boolean {
41+
val clip = ClipboardUtils.getText()?.toString() ?: return true
42+
if (clip.trim().isNotEmpty()) {
43+
terminalView.get()?.mEmulator?.paste(clip)
44+
}
45+
return true
46+
}
47+
48+
private fun handleNewSession(activity: MainActivity): Boolean {
49+
val binder = activity.sessionBinder ?: return true
50+
val service = binder.getService()
51+
52+
val sessionId = generateUniqueSessionId(service.sessionList.keys.toList())
53+
terminalView.get()?.let {
54+
val client = TerminalBackEnd(it, activity)
55+
binder.createSession(sessionId, client, activity, workingMode = Settings.working_Mode)
56+
}
57+
changeSession(activity, session_id = sessionId)
58+
return true
59+
}
60+
61+
private fun handleCloseSession(activity: MainActivity): Boolean {
62+
val binder = activity.sessionBinder ?: return true
63+
val service = binder.getService()
64+
val currentId = service.currentSession.value.first
65+
val sessionKeys = service.sessionList.keys.toList()
66+
67+
if (sessionKeys.size <= 1) {
68+
binder.terminateSession(currentId)
69+
if (service.sessionList.isEmpty()) {
70+
activity.finish()
71+
}
72+
} else {
73+
val currentIndex = sessionKeys.indexOf(currentId)
74+
val nextId = if (currentIndex < sessionKeys.size - 1) {
75+
sessionKeys[currentIndex + 1]
76+
} else {
77+
sessionKeys[currentIndex - 1]
78+
}
79+
changeSession(activity, session_id = nextId)
80+
binder.terminateSession(currentId)
81+
}
82+
return true
83+
}
84+
85+
private fun handleSwitchSession(activity: MainActivity, forward: Boolean): Boolean {
86+
val binder = activity.sessionBinder ?: return true
87+
val service = binder.getService()
88+
val sessionKeys = service.sessionList.keys.toList()
89+
90+
if (sessionKeys.size <= 1) return true
91+
92+
val currentId = service.currentSession.value.first
93+
val currentIndex = sessionKeys.indexOf(currentId)
94+
95+
val nextIndex = if (forward) {
96+
(currentIndex + 1) % sessionKeys.size
97+
} else {
98+
(currentIndex - 1 + sessionKeys.size) % sessionKeys.size
99+
}
100+
101+
changeSession(activity, session_id = sessionKeys[nextIndex])
102+
return true
103+
}
104+
105+
private fun generateUniqueSessionId(existingIds: List<String>): String {
106+
var index = 1
107+
var newId: String
108+
do {
109+
newId = "main$index"
110+
index++
111+
} while (newId in existingIds)
112+
return newId
113+
}
114+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package com.rk.terminal.ui.screens.terminal
2+
3+
import android.view.KeyEvent
4+
5+
/**
6+
* Represents a configurable keyboard shortcut binding.
7+
* Stored as a string in SharedPreferences: "modifiers|keyCode"
8+
* e.g. "CTRL|SHIFT|54" for Ctrl+Shift+V
9+
*/
10+
data class ShortcutBinding(
11+
val ctrl: Boolean = false,
12+
val shift: Boolean = false,
13+
val alt: Boolean = false,
14+
val keyCode: Int = 0,
15+
) {
16+
/** Check if this binding is empty (no key assigned) */
17+
val isEmpty: Boolean get() = keyCode == 0
18+
19+
/** Check if a KeyEvent matches this binding */
20+
fun matches(event: KeyEvent): Boolean {
21+
if (isEmpty) return false
22+
return event.keyCode == keyCode
23+
&& event.isCtrlPressed == ctrl
24+
&& event.isShiftPressed == shift
25+
&& event.isAltPressed == alt
26+
}
27+
28+
/** Serialize to string for SharedPreferences storage */
29+
fun serialize(): String {
30+
if (isEmpty) return ""
31+
val parts = mutableListOf<String>()
32+
if (ctrl) parts.add("CTRL")
33+
if (shift) parts.add("SHIFT")
34+
if (alt) parts.add("ALT")
35+
parts.add(keyCode.toString())
36+
return parts.joinToString("|")
37+
}
38+
39+
/** Human-readable display string */
40+
fun toDisplayString(): String {
41+
if (isEmpty) return "Not set"
42+
val parts = mutableListOf<String>()
43+
if (ctrl) parts.add("Ctrl")
44+
if (shift) parts.add("Shift")
45+
if (alt) parts.add("Alt")
46+
parts.add(KeyEvent.keyCodeToString(keyCode)
47+
.removePrefix("KEYCODE_")
48+
.replace("_", " ")
49+
.lowercase()
50+
.replaceFirstChar { it.uppercase() })
51+
return parts.joinToString(" + ")
52+
}
53+
54+
companion object {
55+
/** Deserialize from SharedPreferences string */
56+
fun deserialize(value: String): ShortcutBinding {
57+
if (value.isBlank()) return ShortcutBinding()
58+
val parts = value.split("|")
59+
var ctrl = false
60+
var shift = false
61+
var alt = false
62+
var keyCode = 0
63+
for (part in parts) {
64+
when (part) {
65+
"CTRL" -> ctrl = true
66+
"SHIFT" -> shift = true
67+
"ALT" -> alt = true
68+
else -> keyCode = part.toIntOrNull() ?: 0
69+
}
70+
}
71+
return ShortcutBinding(ctrl, shift, alt, keyCode)
72+
}
73+
74+
/** Create from a KeyEvent (for capture dialog) */
75+
fun fromKeyEvent(event: KeyEvent): ShortcutBinding {
76+
return ShortcutBinding(
77+
ctrl = event.isCtrlPressed,
78+
shift = event.isShiftPressed,
79+
alt = event.isAltPressed,
80+
keyCode = event.keyCode,
81+
)
82+
}
83+
84+
/** Keys that should not be used as shortcut targets */
85+
private val RESERVED_KEY_CODES = setOf(
86+
KeyEvent.KEYCODE_HOME,
87+
KeyEvent.KEYCODE_BACK,
88+
KeyEvent.KEYCODE_APP_SWITCH,
89+
KeyEvent.KEYCODE_POWER,
90+
KeyEvent.KEYCODE_VOLUME_UP,
91+
KeyEvent.KEYCODE_VOLUME_DOWN,
92+
KeyEvent.KEYCODE_VOLUME_MUTE,
93+
KeyEvent.KEYCODE_MENU,
94+
)
95+
96+
/** Modifier-only key codes (should not finalize a binding) */
97+
val MODIFIER_KEY_CODES = setOf(
98+
KeyEvent.KEYCODE_CTRL_LEFT,
99+
KeyEvent.KEYCODE_CTRL_RIGHT,
100+
KeyEvent.KEYCODE_SHIFT_LEFT,
101+
KeyEvent.KEYCODE_SHIFT_RIGHT,
102+
KeyEvent.KEYCODE_ALT_LEFT,
103+
KeyEvent.KEYCODE_ALT_RIGHT,
104+
KeyEvent.KEYCODE_META_LEFT,
105+
KeyEvent.KEYCODE_META_RIGHT,
106+
)
107+
108+
fun isReservedKey(keyCode: Int): Boolean = keyCode in RESERVED_KEY_CODES
109+
fun isModifierKey(keyCode: Int): Boolean = keyCode in MODIFIER_KEY_CODES
110+
}
111+
}
112+
113+
/**
114+
* Defines all available shortcut actions with their default bindings and preference keys.
115+
*/
116+
enum class ShortcutAction(
117+
val prefKey: String,
118+
val default: ShortcutBinding,
119+
) {
120+
PASTE(
121+
prefKey = "shortcut_paste",
122+
default = ShortcutBinding(ctrl = true, shift = true, keyCode = KeyEvent.KEYCODE_V),
123+
),
124+
NEW_SESSION(
125+
prefKey = "shortcut_new_session",
126+
default = ShortcutBinding(ctrl = true, shift = true, keyCode = KeyEvent.KEYCODE_N),
127+
),
128+
CLOSE_SESSION(
129+
prefKey = "shortcut_close_session",
130+
default = ShortcutBinding(ctrl = true, shift = true, keyCode = KeyEvent.KEYCODE_W),
131+
),
132+
SWITCH_SESSION_PREV(
133+
prefKey = "shortcut_switch_prev",
134+
default = ShortcutBinding(ctrl = true, shift = true, keyCode = KeyEvent.KEYCODE_DPAD_LEFT),
135+
),
136+
SWITCH_SESSION_NEXT(
137+
prefKey = "shortcut_switch_next",
138+
default = ShortcutBinding(ctrl = true, shift = true, keyCode = KeyEvent.KEYCODE_DPAD_RIGHT),
139+
),
140+
}

0 commit comments

Comments
 (0)