Skip to content

Commit f8f8b8a

Browse files
committed
✨ Add Zellij support and custom gesture prefix keys ✨
app/src/main/java/io/github/tabssh/terminal/gestures/GestureCommandMapper.kt app/src/main/java/io/github/tabssh/terminal/gestures/PrefixParser.kt app/src/main/java/io/github/tabssh/ui/activities/TabTerminalActivity.kt app/src/main/java/io/github/tabssh/ui/adapters/TerminalPagerAdapter.kt app/src/main/java/io/github/tabssh/ui/views/TerminalView.kt app/src/main/res/values/arrays.xml app/src/main/res/xml/preferences_terminal.xml app/src/main/java/io/github/tabssh/terminal/gestures/GestureCommandMapper.kt app/src/main/java/io/github/tabssh/terminal/gestures/PrefixParser.kt app/src/main/java/io/github/tabssh/ui/activities/TabTerminalActivity.kt app/src/main/java/io/github/tabssh/ui/adapters/TerminalPagerAdapter.kt app/src/main/java/io/github/tabssh/ui/views/TerminalView.kt app/src/main/res/values/arrays.xml app/src/main/res/xml/preferences_terminal.xml
1 parent 602e5ce commit f8f8b8a

7 files changed

Lines changed: 329 additions & 62 deletions

File tree

app/src/main/java/io/github/tabssh/terminal/gestures/GestureCommandMapper.kt

Lines changed: 139 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ object GestureCommandMapper {
1212
enum class MultiplexerType {
1313
TMUX,
1414
SCREEN,
15+
ZELLIJ,
1516
NONE
1617
}
1718

@@ -33,107 +34,193 @@ object GestureCommandMapper {
3334

3435
/**
3536
* Get the command sequence for a specific gesture and multiplexer
37+
* @param gestureType The gesture that was detected
38+
* @param multiplexerType The multiplexer type (tmux, screen, zellij, or none)
39+
* @param customPrefix Optional custom prefix notation (e.g., "C-a", "C-Space")
40+
* @return Byte sequence to send, or null if not mapped
3641
*/
37-
fun getCommand(gestureType: GestureType, multiplexerType: MultiplexerType): ByteArray? {
42+
fun getCommand(
43+
gestureType: GestureType,
44+
multiplexerType: MultiplexerType,
45+
customPrefix: String? = null
46+
): ByteArray? {
47+
// If custom prefix specified, use it with multiplexer commands
48+
val prefix = if (!customPrefix.isNullOrEmpty()) {
49+
PrefixParser.parse(customPrefix) ?: getDefaultPrefix(multiplexerType)
50+
} else {
51+
getDefaultPrefix(multiplexerType)
52+
}
53+
54+
if (prefix.isEmpty()) {
55+
return null // NONE multiplexer
56+
}
57+
3858
return when (multiplexerType) {
39-
MultiplexerType.TMUX -> getTmuxCommand(gestureType)
40-
MultiplexerType.SCREEN -> getScreenCommand(gestureType)
59+
MultiplexerType.TMUX -> getTmuxCommand(gestureType, prefix)
60+
MultiplexerType.SCREEN -> getScreenCommand(gestureType, prefix)
61+
MultiplexerType.ZELLIJ -> getZellijCommand(gestureType, prefix)
4162
MultiplexerType.NONE -> null
4263
}
4364
}
4465

66+
/**
67+
* Get default prefix for multiplexer type
68+
*/
69+
private fun getDefaultPrefix(multiplexerType: MultiplexerType): ByteArray {
70+
return when (multiplexerType) {
71+
MultiplexerType.TMUX -> byteArrayOf(0x02.toByte()) // Ctrl+B
72+
MultiplexerType.SCREEN -> byteArrayOf(0x01.toByte()) // Ctrl+A
73+
MultiplexerType.ZELLIJ -> byteArrayOf(0x07.toByte()) // Ctrl+G
74+
MultiplexerType.NONE -> byteArrayOf()
75+
}
76+
}
77+
4578
/**
4679
* Get tmux command sequence for gesture
47-
* tmux uses Ctrl+B as prefix
80+
* @param gestureType The gesture detected
81+
* @param prefix The prefix key(s) to use (default or custom)
4882
*/
49-
private fun getTmuxCommand(gestureType: GestureType): ByteArray? {
50-
val CTRL_B = 0x02.toByte()
51-
83+
private fun getTmuxCommand(gestureType: GestureType, prefix: ByteArray): ByteArray? {
5284
return when (gestureType) {
5385
// Window navigation
54-
GestureType.TWO_FINGER_SWIPE_RIGHT -> byteArrayOf(CTRL_B, 'n'.code.toByte()) // next window
55-
GestureType.TWO_FINGER_SWIPE_LEFT -> byteArrayOf(CTRL_B, 'p'.code.toByte()) // previous window
56-
GestureType.TWO_FINGER_SWIPE_DOWN -> byteArrayOf(CTRL_B, 'c'.code.toByte()) // create window
57-
GestureType.TWO_FINGER_SWIPE_UP -> byteArrayOf(CTRL_B, 'w'.code.toByte()) // list windows
86+
GestureType.TWO_FINGER_SWIPE_RIGHT -> prefix + byteArrayOf('n'.code.toByte()) // next window
87+
GestureType.TWO_FINGER_SWIPE_LEFT -> prefix + byteArrayOf('p'.code.toByte()) // previous window
88+
GestureType.TWO_FINGER_SWIPE_DOWN -> prefix + byteArrayOf('c'.code.toByte()) // create window
89+
GestureType.TWO_FINGER_SWIPE_UP -> prefix + byteArrayOf('w'.code.toByte()) // list windows
5890

5991
// Pane operations
60-
GestureType.THREE_FINGER_SWIPE_RIGHT -> byteArrayOf(CTRL_B, 'o'.code.toByte()) // next pane
61-
GestureType.THREE_FINGER_SWIPE_LEFT -> byteArrayOf(CTRL_B, ';'.code.toByte()) // last pane
62-
GestureType.THREE_FINGER_SWIPE_DOWN -> byteArrayOf(CTRL_B, '"'.code.toByte()) // split horizontal
63-
GestureType.THREE_FINGER_SWIPE_UP -> byteArrayOf(CTRL_B, '%'.code.toByte()) // split vertical
92+
GestureType.THREE_FINGER_SWIPE_RIGHT -> prefix + byteArrayOf('o'.code.toByte()) // next pane
93+
GestureType.THREE_FINGER_SWIPE_LEFT -> prefix + byteArrayOf(';'.code.toByte()) // last pane
94+
GestureType.THREE_FINGER_SWIPE_DOWN -> prefix + byteArrayOf('"'.code.toByte()) // split horizontal
95+
GestureType.THREE_FINGER_SWIPE_UP -> prefix + byteArrayOf('%'.code.toByte()) // split vertical
6496

6597
// Zoom/detach
66-
GestureType.PINCH_IN -> byteArrayOf(CTRL_B, 'z'.code.toByte()) // zoom pane
67-
GestureType.PINCH_OUT -> byteArrayOf(CTRL_B, 'd'.code.toByte()) // detach
98+
GestureType.PINCH_IN -> prefix + byteArrayOf('z'.code.toByte()) // zoom pane
99+
GestureType.PINCH_OUT -> prefix + byteArrayOf('d'.code.toByte()) // detach
68100
}
69101
}
70102

71103
/**
72104
* Get screen command sequence for gesture
73-
* screen uses Ctrl+A as prefix
105+
* @param gestureType The gesture detected
106+
* @param prefix The prefix key(s) to use (default or custom)
74107
*/
75-
private fun getScreenCommand(gestureType: GestureType): ByteArray? {
76-
val CTRL_A = 0x01.toByte()
77-
108+
private fun getScreenCommand(gestureType: GestureType, prefix: ByteArray): ByteArray? {
78109
return when (gestureType) {
79110
// Window navigation
80-
GestureType.THREE_FINGER_SWIPE_RIGHT -> byteArrayOf(CTRL_A, 'n'.code.toByte()) // next window
81-
GestureType.THREE_FINGER_SWIPE_LEFT -> byteArrayOf(CTRL_A, 'p'.code.toByte()) // previous window
82-
GestureType.THREE_FINGER_SWIPE_DOWN -> byteArrayOf(CTRL_A, 'c'.code.toByte()) // create window
83-
GestureType.THREE_FINGER_SWIPE_UP -> byteArrayOf(CTRL_A, 'w'.code.toByte()) // list windows
111+
GestureType.THREE_FINGER_SWIPE_RIGHT -> prefix + byteArrayOf('n'.code.toByte()) // next window
112+
GestureType.THREE_FINGER_SWIPE_LEFT -> prefix + byteArrayOf('p'.code.toByte()) // previous window
113+
GestureType.THREE_FINGER_SWIPE_DOWN -> prefix + byteArrayOf('c'.code.toByte()) // create window
114+
GestureType.THREE_FINGER_SWIPE_UP -> prefix + byteArrayOf('w'.code.toByte()) // list windows
84115

85116
// Split operations
86-
GestureType.TWO_FINGER_SWIPE_DOWN -> byteArrayOf(CTRL_A, 'S'.code.toByte()) // split horizontal
87-
GestureType.TWO_FINGER_SWIPE_RIGHT -> byteArrayOf(CTRL_A, '\t'.code.toByte()) // next region
88-
GestureType.TWO_FINGER_SWIPE_LEFT -> byteArrayOf(CTRL_A, '\t'.code.toByte()) // next region (same)
117+
GestureType.TWO_FINGER_SWIPE_DOWN -> prefix + byteArrayOf('S'.code.toByte()) // split horizontal
118+
GestureType.TWO_FINGER_SWIPE_RIGHT -> prefix + byteArrayOf('\t'.code.toByte()) // next region
119+
GestureType.TWO_FINGER_SWIPE_LEFT -> prefix + byteArrayOf('\t'.code.toByte()) // next region (same)
89120

90121
// Detach
91-
GestureType.PINCH_OUT -> byteArrayOf(CTRL_A, 'd'.code.toByte()) // detach
122+
GestureType.PINCH_OUT -> prefix + byteArrayOf('d'.code.toByte()) // detach
92123

93124
// Not mapped
94125
GestureType.TWO_FINGER_SWIPE_UP,
95126
GestureType.PINCH_IN -> null
96127
}
97128
}
98129

130+
/**
131+
* Get zellij command sequence for gesture
132+
* @param gestureType The gesture detected
133+
* @param prefix The prefix key(s) to use (default or custom)
134+
*/
135+
private fun getZellijCommand(gestureType: GestureType, prefix: ByteArray): ByteArray? {
136+
return when (gestureType) {
137+
// Tab navigation
138+
GestureType.TWO_FINGER_SWIPE_RIGHT -> prefix + byteArrayOf('n'.code.toByte()) // next tab
139+
GestureType.TWO_FINGER_SWIPE_LEFT -> prefix + byteArrayOf('p'.code.toByte()) // previous tab
140+
GestureType.TWO_FINGER_SWIPE_DOWN -> prefix + byteArrayOf('t'.code.toByte()) // new tab
141+
GestureType.TWO_FINGER_SWIPE_UP -> prefix + byteArrayOf('w'.code.toByte()) // close tab
142+
143+
// Pane operations
144+
GestureType.THREE_FINGER_SWIPE_RIGHT -> prefix + byteArrayOf('h'.code.toByte()) // next pane
145+
GestureType.THREE_FINGER_SWIPE_LEFT -> prefix + byteArrayOf('l'.code.toByte()) // previous pane
146+
GestureType.THREE_FINGER_SWIPE_DOWN -> prefix + byteArrayOf('s'.code.toByte()) // split horizontal
147+
GestureType.THREE_FINGER_SWIPE_UP -> prefix + byteArrayOf('v'.code.toByte()) // split vertical
148+
149+
// Zoom/quit
150+
GestureType.PINCH_IN -> prefix + byteArrayOf('z'.code.toByte()) // toggle fullscreen
151+
GestureType.PINCH_OUT -> prefix + byteArrayOf('q'.code.toByte()) // quit
152+
}
153+
}
154+
99155
/**
100156
* Get human-readable description of gesture command
101157
*/
102-
fun getDescription(gestureType: GestureType, multiplexerType: MultiplexerType): String {
158+
fun getDescription(gestureType: GestureType, multiplexerType: MultiplexerType, customPrefix: String? = null): String {
159+
val prefixDesc = if (!customPrefix.isNullOrEmpty()) {
160+
PrefixParser.getDescription(customPrefix)
161+
} else {
162+
getDefaultPrefixDescription(multiplexerType)
163+
}
164+
103165
return when (multiplexerType) {
104-
MultiplexerType.TMUX -> getTmuxDescription(gestureType)
105-
MultiplexerType.SCREEN -> getScreenDescription(gestureType)
166+
MultiplexerType.TMUX -> getTmuxDescription(gestureType, prefixDesc)
167+
MultiplexerType.SCREEN -> getScreenDescription(gestureType, prefixDesc)
168+
MultiplexerType.ZELLIJ -> getZellijDescription(gestureType, prefixDesc)
106169
MultiplexerType.NONE -> "Gesture disabled"
107170
}
108171
}
109172

110-
private fun getTmuxDescription(gestureType: GestureType): String {
173+
private fun getDefaultPrefixDescription(multiplexerType: MultiplexerType): String {
174+
return when (multiplexerType) {
175+
MultiplexerType.TMUX -> "Ctrl+B"
176+
MultiplexerType.SCREEN -> "Ctrl+A"
177+
MultiplexerType.ZELLIJ -> "Ctrl+G"
178+
MultiplexerType.NONE -> ""
179+
}
180+
}
181+
182+
private fun getTmuxDescription(gestureType: GestureType, prefix: String): String {
111183
return when (gestureType) {
112-
GestureType.TWO_FINGER_SWIPE_RIGHT -> "tmux: Next window (Ctrl+B n)"
113-
GestureType.TWO_FINGER_SWIPE_LEFT -> "tmux: Previous window (Ctrl+B p)"
114-
GestureType.TWO_FINGER_SWIPE_DOWN -> "tmux: Create window (Ctrl+B c)"
115-
GestureType.TWO_FINGER_SWIPE_UP -> "tmux: List windows (Ctrl+B w)"
116-
GestureType.THREE_FINGER_SWIPE_RIGHT -> "tmux: Next pane (Ctrl+B o)"
117-
GestureType.THREE_FINGER_SWIPE_LEFT -> "tmux: Last pane (Ctrl+B ;)"
118-
GestureType.THREE_FINGER_SWIPE_DOWN -> "tmux: Split horizontal (Ctrl+B \")"
119-
GestureType.THREE_FINGER_SWIPE_UP -> "tmux: Split vertical (Ctrl+B %)"
120-
GestureType.PINCH_IN -> "tmux: Zoom pane (Ctrl+B z)"
121-
GestureType.PINCH_OUT -> "tmux: Detach (Ctrl+B d)"
184+
GestureType.TWO_FINGER_SWIPE_RIGHT -> "tmux: Next window ($prefix n)"
185+
GestureType.TWO_FINGER_SWIPE_LEFT -> "tmux: Previous window ($prefix p)"
186+
GestureType.TWO_FINGER_SWIPE_DOWN -> "tmux: Create window ($prefix c)"
187+
GestureType.TWO_FINGER_SWIPE_UP -> "tmux: List windows ($prefix w)"
188+
GestureType.THREE_FINGER_SWIPE_RIGHT -> "tmux: Next pane ($prefix o)"
189+
GestureType.THREE_FINGER_SWIPE_LEFT -> "tmux: Last pane ($prefix ;)"
190+
GestureType.THREE_FINGER_SWIPE_DOWN -> "tmux: Split horizontal ($prefix \")"
191+
GestureType.THREE_FINGER_SWIPE_UP -> "tmux: Split vertical ($prefix %)"
192+
GestureType.PINCH_IN -> "tmux: Zoom pane ($prefix z)"
193+
GestureType.PINCH_OUT -> "tmux: Detach ($prefix d)"
122194
}
123195
}
124196

125-
private fun getScreenDescription(gestureType: GestureType): String {
197+
private fun getScreenDescription(gestureType: GestureType, prefix: String): String {
126198
return when (gestureType) {
127-
GestureType.THREE_FINGER_SWIPE_RIGHT -> "screen: Next window (Ctrl+A n)"
128-
GestureType.THREE_FINGER_SWIPE_LEFT -> "screen: Previous window (Ctrl+A p)"
129-
GestureType.THREE_FINGER_SWIPE_DOWN -> "screen: Create window (Ctrl+A c)"
130-
GestureType.THREE_FINGER_SWIPE_UP -> "screen: List windows (Ctrl+A w)"
131-
GestureType.TWO_FINGER_SWIPE_DOWN -> "screen: Split horizontal (Ctrl+A S)"
132-
GestureType.TWO_FINGER_SWIPE_RIGHT -> "screen: Next region (Ctrl+A Tab)"
133-
GestureType.TWO_FINGER_SWIPE_LEFT -> "screen: Next region (Ctrl+A Tab)"
134-
GestureType.PINCH_OUT -> "screen: Detach (Ctrl+A d)"
199+
GestureType.THREE_FINGER_SWIPE_RIGHT -> "screen: Next window ($prefix n)"
200+
GestureType.THREE_FINGER_SWIPE_LEFT -> "screen: Previous window ($prefix p)"
201+
GestureType.THREE_FINGER_SWIPE_DOWN -> "screen: Create window ($prefix c)"
202+
GestureType.THREE_FINGER_SWIPE_UP -> "screen: List windows ($prefix w)"
203+
GestureType.TWO_FINGER_SWIPE_DOWN -> "screen: Split horizontal ($prefix S)"
204+
GestureType.TWO_FINGER_SWIPE_RIGHT -> "screen: Next region ($prefix Tab)"
205+
GestureType.TWO_FINGER_SWIPE_LEFT -> "screen: Next region ($prefix Tab)"
206+
GestureType.PINCH_OUT -> "screen: Detach ($prefix d)"
135207
GestureType.TWO_FINGER_SWIPE_UP,
136208
GestureType.PINCH_IN -> "Not mapped for screen"
137209
}
138210
}
211+
212+
private fun getZellijDescription(gestureType: GestureType, prefix: String): String {
213+
return when (gestureType) {
214+
GestureType.TWO_FINGER_SWIPE_RIGHT -> "zellij: Next tab ($prefix n)"
215+
GestureType.TWO_FINGER_SWIPE_LEFT -> "zellij: Previous tab ($prefix p)"
216+
GestureType.TWO_FINGER_SWIPE_DOWN -> "zellij: New tab ($prefix t)"
217+
GestureType.TWO_FINGER_SWIPE_UP -> "zellij: Close tab ($prefix w)"
218+
GestureType.THREE_FINGER_SWIPE_RIGHT -> "zellij: Next pane ($prefix h)"
219+
GestureType.THREE_FINGER_SWIPE_LEFT -> "zellij: Previous pane ($prefix l)"
220+
GestureType.THREE_FINGER_SWIPE_DOWN -> "zellij: Split horizontal ($prefix s)"
221+
GestureType.THREE_FINGER_SWIPE_UP -> "zellij: Split vertical ($prefix v)"
222+
GestureType.PINCH_IN -> "zellij: Toggle fullscreen ($prefix z)"
223+
GestureType.PINCH_OUT -> "zellij: Quit ($prefix q)"
224+
}
225+
}
139226
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package io.github.tabssh.terminal.gestures
2+
3+
/**
4+
* Parses terminal multiplexer prefix key notation into byte sequences
5+
* Supports common notations: C-a, ^A, Ctrl-A, M-b, Alt-b, etc.
6+
*/
7+
object PrefixParser {
8+
9+
/**
10+
* Parse prefix notation into byte array
11+
*
12+
* @param notation The prefix notation (e.g., "C-a", "^B", "C-Space", "`", "M-b")
13+
* @return ByteArray of the prefix sequence, or null if invalid
14+
*
15+
* Examples:
16+
* - "C-a", "^A", "Ctrl-A" → Ctrl+A (0x01)
17+
* - "C-b", "^B", "Ctrl-B" → Ctrl+B (0x02)
18+
* - "C-Space" → Ctrl+Space (0x00)
19+
* - "`" → Backtick literal (0x60)
20+
* - "M-b", "Alt-b" → Alt+B (ESC + 'b')
21+
*/
22+
fun parse(notation: String): ByteArray? {
23+
if (notation.isEmpty()) {
24+
return null
25+
}
26+
27+
val trimmed = notation.trim()
28+
29+
return when {
30+
// Ctrl+key variants: C-a, ^a, Ctrl-a
31+
trimmed.matches(Regex("^(C-|\\^|Ctrl-)([a-zA-Z])$", RegexOption.IGNORE_CASE)) -> {
32+
parseCtrlKey(trimmed)
33+
}
34+
35+
// Ctrl+Space: C-Space, ^Space, Ctrl-Space
36+
trimmed.matches(Regex("^(C-|\\^|Ctrl-)Space$", RegexOption.IGNORE_CASE)) -> {
37+
byteArrayOf(0x00.toByte()) // Ctrl+Space is NUL
38+
}
39+
40+
// Alt+key variants: M-a, Alt-a
41+
trimmed.matches(Regex("^(M-|Alt-)([a-zA-Z])$", RegexOption.IGNORE_CASE)) -> {
42+
parseAltKey(trimmed)
43+
}
44+
45+
// Literal single character: `, ~, etc.
46+
trimmed.length == 1 -> {
47+
byteArrayOf(trimmed[0].code.toByte())
48+
}
49+
50+
// Hex notation: 0x02, \x02
51+
trimmed.matches(Regex("^(0x|\\\\x)([0-9a-fA-F]{2})$")) -> {
52+
parseHexKey(trimmed)
53+
}
54+
55+
else -> null // Invalid notation
56+
}
57+
}
58+
59+
/**
60+
* Parse Ctrl+key notation
61+
* Ctrl+A = 0x01, Ctrl+B = 0x02, etc.
62+
*/
63+
private fun parseCtrlKey(notation: String): ByteArray? {
64+
val key = notation.last().lowercaseChar()
65+
66+
// Ctrl+key is calculated as: (key - 'a' + 1) for lowercase
67+
// Valid range: Ctrl+A (0x01) to Ctrl+Z (0x1A)
68+
if (key in 'a'..'z') {
69+
val ctrlCode = (key.code - 'a'.code + 1).toByte()
70+
return byteArrayOf(ctrlCode)
71+
}
72+
73+
return null
74+
}
75+
76+
/**
77+
* Parse Alt+key notation
78+
* Alt+key is sent as ESC (0x1B) followed by the key
79+
*/
80+
private fun parseAltKey(notation: String): ByteArray {
81+
val key = notation.last()
82+
return byteArrayOf(0x1B.toByte(), key.code.toByte())
83+
}
84+
85+
/**
86+
* Parse hex notation: 0x02 or \x02
87+
*/
88+
private fun parseHexKey(notation: String): ByteArray? {
89+
return try {
90+
val hex = notation.substringAfter('x')
91+
val byte = hex.toInt(16).toByte()
92+
byteArrayOf(byte)
93+
} catch (e: NumberFormatException) {
94+
null
95+
}
96+
}
97+
98+
/**
99+
* Get human-readable description of prefix notation
100+
*/
101+
fun getDescription(notation: String): String {
102+
if (notation.isEmpty()) {
103+
return "Default prefix"
104+
}
105+
106+
return when {
107+
notation.matches(Regex("^(C-|\\^|Ctrl-)([a-zA-Z])$", RegexOption.IGNORE_CASE)) -> {
108+
val key = notation.last().uppercaseChar()
109+
"Ctrl+$key"
110+
}
111+
notation.matches(Regex("^(C-|\\^|Ctrl-)Space$", RegexOption.IGNORE_CASE)) -> {
112+
"Ctrl+Space"
113+
}
114+
notation.matches(Regex("^(M-|Alt-)([a-zA-Z])$", RegexOption.IGNORE_CASE)) -> {
115+
val key = notation.last().uppercaseChar()
116+
"Alt+$key"
117+
}
118+
notation.length == 1 -> {
119+
"Literal '${notation}'"
120+
}
121+
notation.matches(Regex("^(0x|\\\\x)([0-9a-fA-F]{2})$")) -> {
122+
"Hex: $notation"
123+
}
124+
else -> "Unknown: $notation"
125+
}
126+
}
127+
128+
/**
129+
* Validate prefix notation
130+
* @return true if notation is valid, false otherwise
131+
*/
132+
fun isValid(notation: String): Boolean {
133+
return parse(notation) != null
134+
}
135+
}

0 commit comments

Comments
 (0)