Skip to content

Commit f60294a

Browse files
committed
app: add a manual keyboard toggle to the terminal
Tapping the terminal always raised the software keyboard, which is wrong inside an editor: there a tap places the cursor, so the keyboard covers half the screen every time you move it. Termux forwards the click to the running app and our client raises the IME on the same tap, because GestureAndScaleRecognizer discards what onUp returns and the queued onSingleTapConfirmed fires regardless. Guard the IME call in onSingleTapUp behind a new Terminal setting, and when it is off, float a keyboard button over the terminal that toggles the IME both ways. Which bottom corner it sits in is a second setting, so it can be reached one-handed either way. The setting defaults to on, so nothing changes on upgrade. Hardware keyboards are unaffected: KEYBOARD_QWERTY never reached showSoftInput, and 12-key keypads keep activate12KeyInputMethodIfNeeded on tab open. The button reads dark against the terminal's own black rather than following the app theme, the deviation DESIGN.md already records for the virtual keys background directly above it. Signed-off-by: ravindu644 <droidcasts@protonmail.com>
1 parent d78c89b commit f60294a

6 files changed

Lines changed: 204 additions & 67 deletions

File tree

‎Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt‎

Lines changed: 124 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.activity.compose.BackHandler
1010
import androidx.compose.animation.AnimatedVisibility
1111
import androidx.compose.animation.fadeIn
1212
import androidx.compose.animation.fadeOut
13+
import androidx.compose.foundation.BorderStroke
1314
import androidx.compose.foundation.background
1415
import androidx.compose.foundation.layout.*
1516
import androidx.compose.foundation.rememberScrollState
@@ -20,6 +21,8 @@ import androidx.compose.material.icons.Icons
2021
import androidx.compose.material.icons.automirrored.filled.ArrowBack
2122
import androidx.compose.material.icons.filled.Add
2223
import androidx.compose.material.icons.filled.Close
24+
import androidx.compose.material.icons.filled.Keyboard
25+
import androidx.compose.material.icons.filled.KeyboardHide
2326
import androidx.compose.material3.*
2427
import androidx.compose.runtime.*
2528
import androidx.compose.ui.Alignment
@@ -371,6 +374,13 @@ private fun TerminalTabView(
371374
// Terminal-only dark mode: renders the terminal page dark even when the rest
372375
// of the app follows the light theme. Read once at entry; re-enter to apply.
373376
val terminalDarkTheme = remember { prefsManager.terminalDarkTheme }
377+
// When taps no longer raise the keyboard, the button on the terminal is the
378+
// only way to get it back, so both are read together at entry.
379+
val tapOpensKeyboard = remember { prefsManager.terminalTapKeyboard }
380+
val keyboardButtonLeft = remember { prefsManager.terminalKeyboardLeft }
381+
// Holds this tab's client so the button acts on the terminal it sits on,
382+
// rather than on whichever view TerminalScreenState last saw.
383+
var backEnd by remember { mutableStateOf<TerminalBackEnd?>(null) }
374384
// Termux TerminalColors indices: 256 = default foreground, 257 = background,
375385
// 258 = cursor. Dark mode uses the classic termux white-on-black scheme:
376386
// pure white foreground on pure black background.
@@ -392,68 +402,85 @@ private fun TerminalTabView(
392402
) {
393403
Column(modifier = Modifier.fillMaxSize()) {
394404

395-
AndroidView(
396-
factory = { ctx ->
397-
TerminalView(ctx, null).apply {
398-
TerminalScreenState.terminalView = WeakReference(this)
399-
setTextSize(fontSizePx) // must run first, initializes mRenderer
400-
setTypeface(terminalTypeface) // JetBrains Mono; null = system default
401-
keepScreenOn = true
402-
isFocusableInTouchMode = true
403-
// The renderer only paints cell backgrounds; the full-screen
404-
// default background comes from the View itself.
405-
if (terminalDarkTheme) {
406-
setBackgroundColor(terminalBackground)
407-
}
405+
// The button rides inside the terminal's own Box, which is what keeps
406+
// it clear of the virtual keys row without hardcoding its height.
407+
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
408+
AndroidView(
409+
factory = { ctx ->
410+
TerminalView(ctx, null).apply {
411+
TerminalScreenState.terminalView = WeakReference(this)
412+
setTextSize(fontSizePx) // must run first, initializes mRenderer
413+
setTypeface(terminalTypeface) // JetBrains Mono; null = system default
414+
keepScreenOn = true
415+
isFocusableInTouchMode = true
416+
// The renderer only paints cell backgrounds; the full-screen
417+
// default background comes from the View itself.
418+
if (terminalDarkTheme) {
419+
setBackgroundColor(terminalBackground)
420+
}
408421

409-
if (activity != null) {
410-
val client = TerminalBackEnd(
411-
terminal = this,
412-
activity = activity,
413-
initialFontSizePx = fontSizePx,
414-
onSessionFinished = onSessionFinished,
415-
onFontSizeChanged = { newSize ->
416-
TerminalSessionService.globalSessionList[tab.id]?.let { info ->
417-
TerminalSessionService.globalSessionList[tab.id] = info.copy(fontSizePx = newSize)
418-
}
419-
},
420-
)
421-
val session: TerminalSession =
422-
binder.getSession(tab.id) ?: binder.createSession(
423-
containerName = containerName,
424-
client = client,
425-
containerUser = tab.user,
426-
sessionId = tab.id,
422+
if (activity != null) {
423+
val client = TerminalBackEnd(
424+
terminal = this,
425+
activity = activity,
426+
initialFontSizePx = fontSizePx,
427+
onSessionFinished = onSessionFinished,
428+
onFontSizeChanged = { newSize ->
429+
TerminalSessionService.globalSessionList[tab.id]?.let { info ->
430+
TerminalSessionService.globalSessionList[tab.id] = info.copy(fontSizePx = newSize)
431+
}
432+
},
427433
)
428-
session.updateTerminalSessionClient(client)
429-
attachSession(session)
430-
setTerminalViewClient(client)
431-
}
434+
val session: TerminalSession =
435+
binder.getSession(tab.id) ?: binder.createSession(
436+
containerName = containerName,
437+
client = client,
438+
containerUser = tab.user,
439+
sessionId = tab.id,
440+
)
441+
session.updateTerminalSessionClient(client)
442+
attachSession(session)
443+
setTerminalViewClient(client)
444+
backEnd = client
445+
}
432446

433-
post {
434-
requestFocus()
435-
(mClient as? TerminalBackEnd)?.activate12KeyInputMethodIfNeeded()
436-
mEmulator?.mColors?.mCurrentColors?.apply {
437-
set(256, terminalForeground)
438-
set(258, terminalForeground)
439-
if (terminalDarkTheme) {
440-
set(257, terminalBackground)
447+
post {
448+
requestFocus()
449+
(mClient as? TerminalBackEnd)?.activate12KeyInputMethodIfNeeded()
450+
mEmulator?.mColors?.mCurrentColors?.apply {
451+
set(256, terminalForeground)
452+
set(258, terminalForeground)
453+
if (terminalDarkTheme) {
454+
set(257, terminalBackground)
455+
}
441456
}
442457
}
443458
}
444-
}
445-
},
446-
update = { tv ->
447-
if (isVisible) {
448-
// Re-apply before setTextSize: termux renderer resets typeface on size changes
449-
tv.setTypeface(terminalTypeface)
450-
tv.setTextSize(fontSizePx)
451-
tv.onScreenUpdated()
452-
TerminalScreenState.terminalView = WeakReference(tv)
453-
}
454-
},
455-
modifier = Modifier.fillMaxWidth().weight(1f)
456-
)
459+
},
460+
update = { tv ->
461+
if (isVisible) {
462+
// Re-apply before setTextSize: termux renderer resets typeface on size changes
463+
tv.setTypeface(terminalTypeface)
464+
tv.setTextSize(fontSizePx)
465+
tv.onScreenUpdated()
466+
TerminalScreenState.terminalView = WeakReference(tv)
467+
}
468+
},
469+
modifier = Modifier.fillMaxSize()
470+
)
471+
472+
if (!tapOpensKeyboard) {
473+
KeyboardToggleButton(
474+
darkTheme = terminalDarkTheme,
475+
onToggle = { imeVisible ->
476+
if (imeVisible) backEnd?.hideSoftInput() else backEnd?.showSoftInput()
477+
},
478+
modifier = Modifier
479+
.align(if (keyboardButtonLeft) Alignment.BottomStart else Alignment.BottomEnd)
480+
.padding(16.dp)
481+
)
482+
}
483+
}
457484

458485
AndroidView(
459486
factory = { ctx ->
@@ -485,6 +512,47 @@ private fun TerminalTabView(
485512
}
486513
}
487514

515+
/**
516+
* Manual keyboard control for when taps are handed to the terminal instead. It
517+
* toggles, so the same button also puts the keyboard away.
518+
*/
519+
@OptIn(ExperimentalLayoutApi::class)
520+
@Composable
521+
private fun KeyboardToggleButton(
522+
darkTheme: Boolean,
523+
onToggle: (Boolean) -> Unit,
524+
modifier: Modifier = Modifier,
525+
) {
526+
val context = LocalContext.current
527+
val imeVisible = WindowInsets.isImeVisible
528+
// Literals for the same reason virtualKeysBackground above uses them, and the
529+
// same decided exception in DESIGN.md: this sits on the terminal's own black,
530+
// so it has to read dark even when the app itself is in the light theme.
531+
val fill = if (darkTheme) Color(0xFF1A1A1E) else MaterialTheme.colorScheme.surfaceContainerHighest
532+
val accent = if (darkTheme) Color.White else MaterialTheme.colorScheme.onSurface
533+
val edge = if (darkTheme) Color.White.copy(alpha = 0.15f)
534+
else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)
535+
536+
Surface(
537+
onClick = { onToggle(imeVisible) },
538+
modifier = modifier.size(56.dp),
539+
shape = RoundedCornerShape(16.dp),
540+
// Slightly transparent so it never fully hides a line of output under it
541+
color = fill.copy(alpha = 0.9f),
542+
border = BorderStroke(1.dp, edge),
543+
tonalElevation = 0.dp
544+
) {
545+
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
546+
Icon(
547+
imageVector = if (imeVisible) Icons.Default.KeyboardHide else Icons.Default.Keyboard,
548+
contentDescription = context.getString(R.string.terminal_toggle_keyboard),
549+
tint = accent,
550+
modifier = Modifier.size(28.dp)
551+
)
552+
}
553+
}
554+
}
555+
488556
@Composable
489557
private fun UserPickerDialog(
490558
users: List<String>,

‎Android/app/src/main/java/com/droidspaces/app/ui/screen/TerminalAppearanceScreen.kt‎

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.droidspaces.app.ui.screen
22

3+
import androidx.compose.animation.AnimatedVisibility
34
import androidx.compose.foundation.BorderStroke
45
import androidx.compose.foundation.background
56
import androidx.compose.foundation.clickable
@@ -26,7 +27,9 @@ import androidx.compose.material.icons.Icons
2627
import androidx.compose.material.icons.automirrored.filled.ArrowBack
2728
import androidx.compose.material.icons.filled.ChevronRight
2829
import androidx.compose.material.icons.filled.Close
30+
import androidx.compose.material.icons.automirrored.filled.KeyboardBackspace
2931
import androidx.compose.material.icons.filled.FontDownload
32+
import androidx.compose.material.icons.filled.Keyboard
3033
import androidx.compose.material.icons.filled.FormatSize
3134
import androidx.compose.material.icons.filled.Terminal
3235
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -89,6 +92,8 @@ fun TerminalAppearanceScreen(onBack: () -> Unit) {
8992
var terminalDarkTheme by remember { mutableStateOf(prefsManager.terminalDarkTheme) }
9093
var fontSizePx by remember { mutableFloatStateOf(prefsManager.terminalFontSizePx.toFloat()) }
9194
var confirmClose by remember { mutableStateOf(prefsManager.terminalConfirmClose) }
95+
var tapKeyboard by remember { mutableStateOf(prefsManager.terminalTapKeyboard) }
96+
var keyboardLeft by remember { mutableStateOf(prefsManager.terminalKeyboardLeft) }
9297

9398
var selectedFont by remember { mutableStateOf(prefsManager.terminalFontFile) }
9499
var showFontDialog by remember { mutableStateOf(false) }
@@ -296,17 +301,46 @@ fun TerminalAppearanceScreen(onBack: () -> Unit) {
296301
color = cardColor,
297302
border = cardBorder
298303
) {
299-
// Opt-in: guards the X on a terminal tab against fat fingers
300-
SwitchItem(
301-
icon = Icons.Default.Close,
302-
title = context.getString(R.string.terminal_confirm_close),
303-
summary = context.getString(R.string.terminal_confirm_close_description),
304-
checked = confirmClose,
305-
onCheckedChange = { checked ->
306-
prefsManager.terminalConfirmClose = checked
307-
confirmClose = checked
304+
Column {
305+
// Opt-in: guards the X on a terminal tab against fat fingers
306+
SwitchItem(
307+
icon = Icons.Default.Close,
308+
title = context.getString(R.string.terminal_confirm_close),
309+
summary = context.getString(R.string.terminal_confirm_close_description),
310+
checked = confirmClose,
311+
onCheckedChange = { checked ->
312+
prefsManager.terminalConfirmClose = checked
313+
confirmClose = checked
314+
}
315+
)
316+
317+
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
318+
319+
SwitchItem(
320+
icon = Icons.Default.Keyboard,
321+
title = context.getString(R.string.terminal_tap_keyboard),
322+
summary = context.getString(R.string.terminal_tap_keyboard_description),
323+
checked = tapKeyboard,
324+
onCheckedChange = { checked ->
325+
prefsManager.terminalTapKeyboard = checked
326+
tapKeyboard = checked
327+
}
328+
)
329+
330+
// Nothing to place while taps still open the keyboard
331+
AnimatedVisibility(visible = !tapKeyboard) {
332+
SwitchItem(
333+
icon = Icons.AutoMirrored.Filled.KeyboardBackspace,
334+
title = context.getString(R.string.terminal_keyboard_left),
335+
summary = context.getString(R.string.terminal_keyboard_left_description),
336+
checked = keyboardLeft,
337+
onCheckedChange = { checked ->
338+
prefsManager.terminalKeyboardLeft = checked
339+
keyboardLeft = checked
340+
}
341+
)
308342
}
309-
)
343+
}
310344
}
311345

312346
Spacer(modifier = Modifier.height(12.dp))

‎Android/app/src/main/java/com/droidspaces/app/ui/terminal/TerminalBackEnd.kt‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import android.view.KeyEvent
99
import android.view.MotionEvent
1010
import android.view.inputmethod.InputMethodManager
1111
import com.droidspaces.app.ui.terminal.virtualkeys.SpecialButton
12+
import com.droidspaces.app.util.PreferencesManager
1213
import com.termux.terminal.TerminalEmulator
1314
import com.termux.terminal.TerminalSession
1415
import com.termux.terminal.TerminalSessionClient
@@ -82,6 +83,9 @@ class TerminalBackEnd(
8283
}
8384

8485
override fun onSingleTapUp(e: MotionEvent) {
86+
// Read live: the setting is changed on another screen while this object
87+
// is alive, so a constructor copy would go stale.
88+
if (!PreferencesManager.getInstance(activity).terminalTapKeyboard) return
8589
val keyboard = terminal.resources.configuration.keyboard
8690
if (keyboard == Configuration.KEYBOARD_NOKEYS ||
8791
keyboard == Configuration.KEYBOARD_12KEY) {
@@ -131,9 +135,14 @@ class TerminalBackEnd(
131135
if (terminal.mEmulator != null) terminal.setTerminalCursorBlinkerState(true, true)
132136
}
133137

134-
private fun showSoftInput() {
138+
internal fun showSoftInput() {
135139
terminal.requestFocus()
136140
val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
137141
imm.showSoftInput(terminal, InputMethodManager.SHOW_IMPLICIT)
138142
}
143+
144+
internal fun hideSoftInput() {
145+
val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
146+
imm.hideSoftInputFromWindow(terminal.windowToken, 0)
147+
}
139148
}

‎Android/app/src/main/java/com/droidspaces/app/util/Constants.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ object Constants {
4646
const val KEY_TERMINAL_FONT_SIZE = "terminal_font_size_px"
4747
const val KEY_TERMINAL_FONT_FILE = "terminal_font_file"
4848
const val KEY_TERMINAL_CONFIRM_CLOSE = "terminal_confirm_close"
49+
const val KEY_TERMINAL_TAP_KEYBOARD = "terminal_tap_keyboard"
50+
const val KEY_TERMINAL_KEYBOARD_LEFT = "terminal_keyboard_button_left"
4951
const val KEY_APP_LOCALE = "app_locale"
5052
const val KEY_BACKEND_MODE = "backend_mode"
5153
const val KEY_DAEMON_MODE_ENABLED = "daemon_mode_enabled"

‎Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,23 @@ class PreferencesManager private constructor(context: Context) {
168168
prefs.edit().putBoolean(KEY_TERMINAL_CONFIRM_CLOSE, value).apply()
169169
}
170170

171+
// On by default so nothing changes on upgrade. Off hands a tap to whatever
172+
// runs in the terminal, which is what an editor wants, and the keyboard
173+
// comes from the button on the terminal instead.
174+
var terminalTapKeyboard: Boolean
175+
get() = prefs.getBoolean(KEY_TERMINAL_TAP_KEYBOARD, true)
176+
set(value) {
177+
prefs.edit().putBoolean(KEY_TERMINAL_TAP_KEYBOARD, value).apply()
178+
}
179+
180+
// Which bottom corner that button sits in. False is the right, for the
181+
// right-handed majority.
182+
var terminalKeyboardLeft: Boolean
183+
get() = prefs.getBoolean(KEY_TERMINAL_KEYBOARD_LEFT, false)
184+
set(value) {
185+
prefs.edit().putBoolean(KEY_TERMINAL_KEYBOARD_LEFT, value).apply()
186+
}
187+
171188
var isDaemonModeEnabled: Boolean
172189
get() = prefs.getBoolean(KEY_DAEMON_MODE_ENABLED, false)
173190
set(value) {
@@ -413,6 +430,8 @@ class PreferencesManager private constructor(context: Context) {
413430
private const val KEY_TERMINAL_FONT_SIZE = Constants.KEY_TERMINAL_FONT_SIZE
414431
private const val KEY_TERMINAL_FONT_FILE = Constants.KEY_TERMINAL_FONT_FILE
415432
private const val KEY_TERMINAL_CONFIRM_CLOSE = Constants.KEY_TERMINAL_CONFIRM_CLOSE
433+
private const val KEY_TERMINAL_TAP_KEYBOARD = Constants.KEY_TERMINAL_TAP_KEYBOARD
434+
private const val KEY_TERMINAL_KEYBOARD_LEFT = Constants.KEY_TERMINAL_KEYBOARD_LEFT
416435
const val KEY_THEME_PALETTE = Constants.KEY_THEME_PALETTE
417436
const val KEY_DAEMON_MODE_ENABLED = Constants.KEY_DAEMON_MODE_ENABLED
418437
const val KEY_SYMLINK_ENABLED = Constants.KEY_SYMLINK_ENABLED

0 commit comments

Comments
 (0)