Edge-to-edge insets, quick bar redesign, multi-session focus, keyboard settings

- Add status bar insets to SessionTabBar and navigation bar insets to terminal
  Column for proper edge-to-edge rendering on Samsung S23 (Android 14+)
- Redesign quick bar: ESC, TAB, :, ~, |, arrows, shift-tab (remove Ctrl+C)
- Defer haptic in infinite scroll mode until tap is confirmed (no haptic on scroll)
- Manage focus explicitly when switching sessions — clear focus on invisible
  views, request focus + restartInput on visible to fix Samsung IME routing
- Wire space long-press gear to KeyboardSettingsDialog from terminal pane
- Add diagnostic logging for writeInput drops and null session entries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-03-30 23:02:00 +02:00
parent 70ab307294
commit c4f60b05cb
9 changed files with 116 additions and 16 deletions

View file

@ -988,7 +988,11 @@ class TerminalService : Service() {
// ========================================================================
fun writeInput(sessionId: Long, bytes: ByteArray) {
val entry = sessions[sessionId] ?: return
val entry = sessions[sessionId]
if (entry == null) {
FileLogger.log(TAG, "writeInput[$sessionId]: entry NOT FOUND, dropping ${bytes.size} bytes (sessions=${sessions.keys})")
return
}
entry.sshSession?.write(bytes)
entry.localShellSession?.write(bytes)
entry.telnetSession?.write(bytes)

View file

@ -20,7 +20,10 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -38,6 +41,7 @@ import com.roundingmobile.sshworkbench.BuildConfig
import com.roundingmobile.sshworkbench.R
import com.roundingmobile.sshworkbench.auth.BiometricAuthManager
import com.roundingmobile.sshworkbench.pro.ProFeatures
import com.roundingmobile.sshworkbench.terminal.KeyboardSettingsDialog
import com.roundingmobile.sshworkbench.terminal.TerminalService
import com.roundingmobile.sshworkbench.ui.navigation.Routes
import com.roundingmobile.sshworkbench.ui.navigation.SshWorkbenchNavGraph
@ -170,7 +174,9 @@ class MainActivity : AppCompatActivity() {
val showTerminal = appState.currentPane == Pane.TERMINAL
Column(
modifier = if (showTerminal) Modifier.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars)
else Modifier.fillMaxSize().alpha(0f)
.windowInsetsPadding(WindowInsets.navigationBars)
) {
// Session tab bar
if (showTerminal) {
@ -236,7 +242,12 @@ class MainActivity : AppCompatActivity() {
// TODO: show snippet picker dialog
},
onSettingsTap = {
// TODO: show keyboard settings dialog
KeyboardSettingsDialog.show(
context = this@MainActivity,
currentSettings = mainViewModel.getCurrentKeyboardSettings(),
proFeatures = proFeatures,
onSave = { newSettings -> mainViewModel.saveKeyboardSettings(newSettings) }
)
},
onReconnect = {
mainViewModel.reconnectSession(sid)

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -569,4 +570,45 @@ class MainViewModel @Inject constructor(
fun writeToSession(sessionId: Long, bytes: ByteArray) {
terminalService?.writeInput(sessionId, bytes)
}
// --- Keyboard settings (for space long-press gear) ---
fun getCurrentKeyboardSettings(): com.roundingmobile.sshworkbench.terminal.KeyboardDisplaySettings {
val prefs = terminalPrefs
return com.roundingmobile.sshworkbench.terminal.KeyboardDisplaySettings(
language = runBlocking { prefs.keyboardLanguage.first() },
heightPercent = runBlocking { prefs.keyboardHeightPercent.first() },
heightLandscape = runBlocking { prefs.keyboardHeightLandscape.first() },
sameSizeBoth = runBlocking { prefs.keyboardSameSizeBoth.first() },
showPageIndicators = runBlocking { prefs.showPageIndicators.first() },
keyColorPreset = runBlocking { prefs.keyColorPreset.first() },
keyColorCustom = runBlocking { prefs.keyColorCustom.first() },
showHints = runBlocking { prefs.showKeyHints.first() },
keyRepeatDelay = runBlocking { prefs.keyRepeatDelay.first() },
longPressDelay = runBlocking { prefs.longPressDelay.first() },
quickBarPosition = runBlocking { prefs.quickBarPosition.first() },
quickBarSize = runBlocking { prefs.quickBarSize.first() },
qbColorPreset = runBlocking { prefs.qbColorPreset.first() },
qbColorCustom = runBlocking { prefs.qbColorCustom.first() }
)
}
fun saveKeyboardSettings(settings: com.roundingmobile.sshworkbench.terminal.KeyboardDisplaySettings) {
viewModelScope.launch {
terminalPrefs.setKeyboardLanguage(settings.language)
terminalPrefs.setKeyboardHeightPercent(settings.heightPercent)
terminalPrefs.setKeyboardHeightLandscape(settings.heightLandscape)
terminalPrefs.setKeyboardSameSizeBoth(settings.sameSizeBoth)
terminalPrefs.setShowPageIndicators(settings.showPageIndicators)
terminalPrefs.setKeyColorPreset(settings.keyColorPreset)
terminalPrefs.setKeyColorCustom(settings.keyColorCustom)
terminalPrefs.setShowKeyHints(settings.showHints)
terminalPrefs.setKeyRepeatDelay(settings.keyRepeatDelay)
terminalPrefs.setLongPressDelay(settings.longPressDelay)
terminalPrefs.setQuickBarPosition(settings.quickBarPosition)
terminalPrefs.setQuickBarSize(settings.quickBarSize)
terminalPrefs.setQbColorPreset(settings.qbColorPreset)
terminalPrefs.setQbColorCustom(settings.qbColorCustom)
}
}
}

View file

@ -8,11 +8,14 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@ -90,8 +93,9 @@ fun SessionTabBar(
Row(
modifier = modifier
.fillMaxWidth()
.height(41.dp)
.background(TabBarBg)
.windowInsetsPadding(WindowInsets.statusBars)
.height(41.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {

View file

@ -272,13 +272,33 @@ fun TerminalPane(
root.visibility = if (visible) View.VISIBLE else View.INVISIBLE
val tags = root.tag as? ViewTags ?: return@AndroidView
// Update screen buffer binding
// Ensure the active session's terminal gets focus for IME input.
// INVISIBLE views retain focus, so we must explicitly manage it.
if (visible) {
tags.terminalView.isFocusable = true
tags.terminalView.isFocusableInTouchMode = true
if (!tags.terminalView.hasFocus()) {
tags.terminalView.requestFocus()
// Force IME to re-create InputConnection for this view —
// Samsung One UI can hold stale connections after view switch
val imm = root.context.getSystemService(InputMethodManager::class.java)
imm?.restartInput(tags.terminalView)
}
} else {
tags.terminalView.clearFocus()
tags.terminalView.isFocusable = false
tags.terminalView.isFocusableInTouchMode = false
}
// Update screen buffer binding + render callback
val currentEntry = terminalService?.getSession(sessionId)
if (currentEntry != null) {
if (tags.terminalView.screenBuffer !== currentEntry.screenBuffer) {
tags.terminalView.screenBuffer = currentEntry.screenBuffer
}
currentEntry.onScreenUpdated = { tags.terminalView.invalidateTerminal() }
} else {
FileLogger.log(TAG, "update[$sessionId]: entry NULL, visible=$visible")
}
// Update keyboard visibility

View file

@ -403,6 +403,12 @@ class QuickBarView(context: Context) : View(context) {
isScrolling = false
velocityTracker = VelocityTracker.obtain()
velocityTracker?.addMovement(event)
// In infinite scroll mode, defer haptic/onKeyDown until ACTION_UP
// confirms this is a tap, not a scroll gesture.
val key = findKeyAt(event.x, event.y) ?: return true
pressedKeys.add(key.id)
invalidate()
return true
}
val key = findKeyAt(event.x, event.y) ?: return true
@ -424,7 +430,7 @@ class QuickBarView(context: Context) : View(context) {
if (!isScrolling && Math.abs(event.y - scrollTouchStartY) > dpToPx(8f)) {
isScrolling = true
pressedKeys.clear()
onKeyUp?.invoke()
invalidate()
}
if (isScrolling) {
scrollOffset -= dy
@ -437,7 +443,7 @@ class QuickBarView(context: Context) : View(context) {
if (!isScrolling && Math.abs(event.x - scrollTouchStartX) > dpToPx(8f)) {
isScrolling = true
pressedKeys.clear()
onKeyUp?.invoke()
invalidate()
}
if (isScrolling) {
scrollOffset -= dx
@ -469,8 +475,12 @@ class QuickBarView(context: Context) : View(context) {
val key = findKeyAt(event.x, event.y)
pressedKeys.clear()
invalidate()
if (key != null) {
// Confirmed tap — fire haptic + action together
onKeyDown?.invoke(key)
onKeyTap?.invoke(key)
}
onKeyUp?.invoke()
if (key != null) onKeyTap?.invoke(key)
velocityTracker?.recycle()
velocityTracker = null
return true

View file

@ -264,13 +264,16 @@
"draggable": false,
"height": 42,
"keys": [
{ "id": "qb_ctrlc", "label": "^C", "w": 1, "action": { "type": "combo", "mod": ["ctrl"], "key": "c" }, "style": "danger" },
{ "id": "qb_esc", "label": "ESC", "w": 1, "action": { "type": "bytes", "value": [27] } },
{ "id": "qb_tab", "label": "TAB", "w": 1, "action": { "type": "bytes", "value": [9] } },
{ "id": "qb_colon", "label": ":", "w": 0.7, "action": { "type": "char", "primary": ":" } },
{ "id": "qb_tilde", "label": "~", "w": 0.7, "action": { "type": "char", "primary": "~" } },
{ "id": "qb_pipe", "label": "|", "w": 0.7, "action": { "type": "char", "primary": "|" } },
{ "id": "qb_left", "label": "◀", "w": 0.8, "action": { "type": "esc_seq", "seq": "[D" }, "repeatable": true },
{ "id": "qb_up", "label": "▲", "w": 0.8, "action": { "type": "esc_seq", "seq": "[A" }, "repeatable": true },
{ "id": "qb_down", "label": "▼", "w": 0.8, "action": { "type": "esc_seq", "seq": "[B" }, "repeatable": true },
{ "id": "qb_left", "label": "◀", "w": 0.8, "action": { "type": "esc_seq", "seq": "[D" }, "repeatable": true },
{ "id": "qb_right", "label": "▶", "w": 0.8, "action": { "type": "esc_seq", "seq": "[C" }, "repeatable": true },
{ "id": "qb_esc", "label": "ESC", "w": 1, "action": { "type": "bytes", "value": [27] } }
{ "id": "qb_stab", "label": "⇤", "w": 0.8, "action": { "type": "esc_seq", "seq": "[Z" } }
]
}
}

View file

@ -265,13 +265,16 @@
"draggable": false,
"height": 42,
"keys": [
{ "id": "qb_ctrlc", "label": "^C", "w": 1, "action": { "type": "combo", "mod": ["ctrl"], "key": "c" }, "style": "danger" },
{ "id": "qb_esc", "label": "ESC", "w": 1, "action": { "type": "bytes", "value": [27] } },
{ "id": "qb_tab", "label": "TAB", "w": 1, "action": { "type": "bytes", "value": [9] } },
{ "id": "qb_colon", "label": ":", "w": 0.7, "action": { "type": "char", "primary": ":" } },
{ "id": "qb_tilde", "label": "~", "w": 0.7, "action": { "type": "char", "primary": "~" } },
{ "id": "qb_pipe", "label": "|", "w": 0.7, "action": { "type": "char", "primary": "|" } },
{ "id": "qb_left", "label": "◀", "w": 0.8, "action": { "type": "esc_seq", "seq": "[D" }, "repeatable": true },
{ "id": "qb_up", "label": "▲", "w": 0.8, "action": { "type": "esc_seq", "seq": "[A" }, "repeatable": true },
{ "id": "qb_down", "label": "▼", "w": 0.8, "action": { "type": "esc_seq", "seq": "[B" }, "repeatable": true },
{ "id": "qb_left", "label": "◀", "w": 0.8, "action": { "type": "esc_seq", "seq": "[D" }, "repeatable": true },
{ "id": "qb_right", "label": "▶", "w": 0.8, "action": { "type": "esc_seq", "seq": "[C" }, "repeatable": true },
{ "id": "qb_esc", "label": "ESC", "w": 1, "action": { "type": "bytes", "value": [27] } }
{ "id": "qb_stab", "label": "⇤", "w": 0.8, "action": { "type": "esc_seq", "seq": "[Z" } }
]
}
}

View file

@ -267,13 +267,16 @@
"draggable": false,
"height": 42,
"keys": [
{ "id": "qb_ctrlc", "label": "^C", "w": 1, "action": { "type": "combo", "mod": ["ctrl"], "key": "c" }, "style": "danger" },
{ "id": "qb_esc", "label": "ESC", "w": 1, "action": { "type": "bytes", "value": [27] } },
{ "id": "qb_tab", "label": "TAB", "w": 1, "action": { "type": "bytes", "value": [9] } },
{ "id": "qb_colon", "label": ":", "w": 0.7, "action": { "type": "char", "primary": ":" } },
{ "id": "qb_tilde", "label": "~", "w": 0.7, "action": { "type": "char", "primary": "~" } },
{ "id": "qb_pipe", "label": "|", "w": 0.7, "action": { "type": "char", "primary": "|" } },
{ "id": "qb_left", "label": "◀", "w": 0.8, "action": { "type": "esc_seq", "seq": "[D" }, "repeatable": true },
{ "id": "qb_up", "label": "▲", "w": 0.8, "action": { "type": "esc_seq", "seq": "[A" }, "repeatable": true },
{ "id": "qb_down", "label": "▼", "w": 0.8, "action": { "type": "esc_seq", "seq": "[B" }, "repeatable": true },
{ "id": "qb_left", "label": "◀", "w": 0.8, "action": { "type": "esc_seq", "seq": "[D" }, "repeatable": true },
{ "id": "qb_right", "label": "▶", "w": 0.8, "action": { "type": "esc_seq", "seq": "[C" }, "repeatable": true },
{ "id": "qb_esc", "label": "ESC", "w": 1, "action": { "type": "bytes", "value": [27] } }
{ "id": "qb_stab", "label": "⇤", "w": 0.8, "action": { "type": "esc_seq", "seq": "[Z" } }
]
}
}