QB app shortcuts, Canvas key icons, bounded scroll, alt buffer reflow fix
Quick Bar: - App shortcuts button (W icon, far-left) opens two-level popup: vim/nano/tmux/scr/F1-12 → 3-column key map grid - AppShortcutsPopupView: Canvas-based, direction-aware, tap-to-select - PopupWindow replaced with DecorView overlay + onBackPressedDispatcher + layout listener for AKB close detection (one BACK closes both) - W button toggles popup; toggle_mod actions keep popup open - Canvas-drawn key icons (tab, backtab, arrows, pgup, pgdn) via labelIcon - Bounded scroll mode (infiniteScroll=false default) - CTRL, HOME, END, PGUP, PGDN added to QB scrollable keys - Menu keys (vim/nano/tmux/screen) removed from scrollable area Tab bar: - + button replaced with kebab menu (New Session + KB/QB Settings) - KB Settings label adapts to keyboard mode (CKB vs AKB) Bug fixes: - Alt buffer reflow: main screen content preserved during resize when vim/htop is active (was lost on navigate away + return) - Quick-connect: no saved connection lookup for connectionId=0, tab label shows user@host instead of saved connection nickname - ADB broadcast: enter fires independently from text Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2417b442f1
commit
256d059d51
15 changed files with 1312 additions and 133 deletions
|
|
@ -2,5 +2,5 @@ package com.roundingmobile.sshworkbench
|
|||
|
||||
// Auto-generated — do not edit
|
||||
object BuildTimestamp {
|
||||
const val TIME = "2026-04-03 12:55:21"
|
||||
const val TIME = "2026-04-03 18:47:16"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ import com.roundingmobile.sshworkbench.R
|
|||
import com.roundingmobile.sshworkbench.auth.BiometricAuthManager
|
||||
import com.roundingmobile.sshworkbench.pro.ProFeatures
|
||||
import com.roundingmobile.sshworkbench.data.local.Snippet
|
||||
import com.roundingmobile.sshworkbench.terminal.AqbSettingsScreen
|
||||
import com.roundingmobile.sshworkbench.terminal.KeyboardSettingsScreen
|
||||
import kotlinx.coroutines.flow.first
|
||||
import com.roundingmobile.sshworkbench.terminal.SnippetPickerSheet
|
||||
import com.roundingmobile.sshworkbench.terminal.AuthPromptDialog
|
||||
import com.roundingmobile.sshworkbench.terminal.HostKeyDialog
|
||||
|
|
@ -241,6 +243,7 @@ class MainActivity : AppCompatActivity() {
|
|||
var ckbHidden by remember { mutableStateOf(false) }
|
||||
// Keyboard settings dialog state (opened from CKB gear menu)
|
||||
var showKbSettings by remember { mutableStateOf(false) }
|
||||
var showAqbSettings by remember { mutableStateOf(false) }
|
||||
var showGearMenu by remember { mutableStateOf(false) }
|
||||
|
||||
// Resolve keyboard layout and language resource IDs
|
||||
|
|
@ -292,6 +295,10 @@ class MainActivity : AppCompatActivity() {
|
|||
LaunchedEffect(hapticEnabled, showKeyHints) {
|
||||
keyboard.updateSettings { it.copy(hapticFeedback = hapticEnabled, showHints = showKeyHints) }
|
||||
}
|
||||
// Tell the keyboard library where the QB actually is (for popup direction)
|
||||
LaunchedEffect(Unit) {
|
||||
keyboard.quickBarPosition = com.roundingmobile.keyboard.model.QuickBarPosition.BOTTOM
|
||||
}
|
||||
LaunchedEffect(showPageIndicators) {
|
||||
keyboard.setPageIndicatorVisible(showPageIndicators)
|
||||
}
|
||||
|
|
@ -354,6 +361,29 @@ class MainActivity : AppCompatActivity() {
|
|||
)
|
||||
}
|
||||
|
||||
// AQB settings dialog (opened from tab bar kebab menu in AKB mode)
|
||||
if (showAqbSettings) {
|
||||
val prefs = mainViewModel.terminalPrefs
|
||||
AqbSettingsScreen(
|
||||
currentSettings = com.roundingmobile.sshworkbench.terminal.QuickBarDisplaySettings(
|
||||
position = kotlinx.coroutines.runBlocking { prefs.aqbPosition.first() },
|
||||
size = kotlinx.coroutines.runBlocking { prefs.aqbSize.first() },
|
||||
colorPreset = kotlinx.coroutines.runBlocking { prefs.aqbColorPreset.first() },
|
||||
colorCustom = kotlinx.coroutines.runBlocking { prefs.aqbColorCustom.first() }
|
||||
),
|
||||
proFeatures = proFeatures,
|
||||
onSave = { s ->
|
||||
lifecycleScope.launch {
|
||||
prefs.setAqbPosition(s.position)
|
||||
prefs.setAqbSize(s.size)
|
||||
prefs.setAqbColorPreset(s.colorPreset)
|
||||
prefs.setAqbColorCustom(s.colorCustom)
|
||||
}
|
||||
},
|
||||
onDismiss = { showAqbSettings = false }
|
||||
)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// --- Layer 1: Terminal surfaces + tab bar + shared keyboard ---
|
||||
// Always in the tree — visibility toggled. Views survive navigation.
|
||||
|
|
@ -388,7 +418,14 @@ class MainActivity : AppCompatActivity() {
|
|||
onRename = { sid, name -> mainViewModel.renameSession(sid, name) },
|
||||
onTheme = { sid, theme -> mainViewModel.setSessionTheme(sid, theme) },
|
||||
sftpCapable = mainViewModel.getSftpCapableSessions(),
|
||||
sessionThemes = sessionThemes
|
||||
sessionThemes = sessionThemes,
|
||||
onKbSettings = {
|
||||
if (isCustomKeyboard) showKbSettings = true
|
||||
else showAqbSettings = true
|
||||
},
|
||||
kbSettingsLabel = if (isCustomKeyboard)
|
||||
getString(R.string.keyboard_settings_title)
|
||||
else getString(R.string.quick_bar_settings)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -321,9 +321,8 @@ class MainViewModel @Inject constructor(
|
|||
FileLogger.log(TAG, "connect: $username@$host:$port keyId=$keyId connId=$connectionId") // TRACE
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Use the passed connectionId directly when available (from saved connection tap).
|
||||
// Fall back to findByHostPortUser only for quick-connect (connectionId=0).
|
||||
val saved = if (connectionId > 0) connectionDao.getById(connectionId)
|
||||
else connectionDao.findByHostPortUser(host, port, username)
|
||||
// Quick-connect (connectionId=0) uses plain defaults — no saved connection lookup.
|
||||
val saved = if (connectionId > 0) connectionDao.getById(connectionId) else null
|
||||
val savedConnId = saved?.id ?: 0L
|
||||
val sessionId = svc.generateSessionId()
|
||||
val scrollback = terminalPrefs.scrollbackLines.first()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
|
|
@ -93,6 +94,8 @@ fun SessionTabBar(
|
|||
sessionLabels: Map<Long, String>,
|
||||
onSessionTap: (Long) -> Unit,
|
||||
onPlusTap: () -> Unit,
|
||||
onKbSettings: () -> Unit = {},
|
||||
kbSettingsLabel: String = "",
|
||||
onDuplicate: (Long) -> Unit = {},
|
||||
onSftp: (Long) -> Unit = {},
|
||||
onConnectTerminal: (Long) -> Unit = {},
|
||||
|
|
@ -156,13 +159,31 @@ fun SessionTabBar(
|
|||
}
|
||||
}
|
||||
|
||||
// + button pinned right
|
||||
IconButton(onClick = onPlusTap) {
|
||||
Icon(
|
||||
Icons.Filled.Add,
|
||||
contentDescription = "+",
|
||||
tint = AccentTeal
|
||||
)
|
||||
// Kebab menu pinned right
|
||||
var showBarMenu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { showBarMenu = true }) {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(R.string.more_options),
|
||||
tint = AccentTeal
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showBarMenu,
|
||||
onDismissRequest = { showBarMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.new_session)) },
|
||||
onClick = { showBarMenu = false; onPlusTap() },
|
||||
leadingIcon = { Icon(Icons.Filled.Add, contentDescription = null, tint = AccentTeal) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(kbSettingsLabel.ifEmpty { stringResource(R.string.quick_bar_settings) }) },
|
||||
onClick = { showBarMenu = false; onKbSettings() },
|
||||
leadingIcon = { Icon(Icons.Filled.Settings, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
## Features
|
||||
|
||||
- **SSH VPN mode** — `EditConnectionScreen.kt:608` — `// TODO PRO v2.0: SSH VPN mode using VpnService + tun2socks`. Full device VPN tunneling over SSH.
|
||||
- **Quick-connect search** — typing in the quick-connect bar filters saved connections (match on host, user, nickname, name). Suggestions shown below input. Tap suggestion = connect with saved settings. Press enter = fresh connection with defaults only, no saved lookup.
|
||||
- **SCP file transfer** — simple single-file copy without SFTP overhead.
|
||||
- **Mosh support** — UDP-based mobile shell for high-latency connections.
|
||||
- **Macro editor UI** — visual editor for keyboard macros.
|
||||
|
|
|
|||
|
|
@ -72,6 +72,15 @@ class TerminalKeyboard private constructor(
|
|||
private var menuPopupItems: List<MenuItem>? = null
|
||||
private var menuOverlay: View? = null
|
||||
|
||||
// App shortcuts popup state
|
||||
private var appShortcutsPopup: AppShortcutsPopupView? = null
|
||||
private var appShortcutsOverlay: View? = null
|
||||
private var appShortcutsBackCallback: androidx.activity.OnBackPressedCallback? = null
|
||||
private var appShortcutsLayoutListener: android.view.ViewTreeObserver.OnGlobalLayoutListener? = null
|
||||
|
||||
/** The current runtime QB position — set by the app module. */
|
||||
var quickBarPosition: QuickBarPosition = QuickBarPosition.TOP
|
||||
|
||||
// Double-tap state (shared between keyboard pages and quick bar)
|
||||
private var lastTapKeyId: String? = null
|
||||
private var lastTapTime: Long = 0
|
||||
|
|
@ -192,6 +201,15 @@ class TerminalKeyboard private constructor(
|
|||
}
|
||||
qb.onSnippetsTap = { onSnippetsTap?.invoke() }
|
||||
qb.onMenuKeyTap = { key -> showMenuPopup(key, qb) }
|
||||
qb.onAppShortcutsTap = {
|
||||
// Toggle: if popup is showing, dismiss it; otherwise show it
|
||||
if (appShortcutsOverlay != null) {
|
||||
dismissAppShortcutsPopup()
|
||||
} else {
|
||||
val shortcuts = layout.quickBar?.appShortcuts ?: emptyList()
|
||||
if (shortcuts.isNotEmpty()) showAppShortcutsPopup(shortcuts, qb)
|
||||
}
|
||||
}
|
||||
qb.onKeyDown = { key ->
|
||||
hapticFeedback(qb)
|
||||
if (layout.settings.showHints) {
|
||||
|
|
@ -242,6 +260,7 @@ class TerminalKeyboard private constructor(
|
|||
modStateJob?.cancel()
|
||||
keyRepeatHandler.destroy()
|
||||
dismissMenuPopup()
|
||||
dismissAppShortcutsPopup()
|
||||
keyboardView?.let { (it.parent as? ViewGroup)?.removeView(it) }
|
||||
quickBarView?.let { (it.parent as? ViewGroup)?.removeView(it) }
|
||||
hintPopup?.let { (it.parent as? ViewGroup)?.removeView(it) }
|
||||
|
|
@ -544,6 +563,163 @@ class TerminalKeyboard private constructor(
|
|||
menuOverlay = null
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun showAppShortcutsPopup(shortcuts: List<AppShortcut>, qbView: QuickBarView) {
|
||||
val rootContainer = container
|
||||
if (rootContainer == null) { android.util.Log.e("TermKB", "showAppShortcutsPopup: container is null"); return }
|
||||
hapticFeedback(qbView)
|
||||
|
||||
// Determine anchor from the shortcuts button's screen rect
|
||||
val btnRect = qbView.getShortcutsButtonScreenRect()
|
||||
if (btnRect == null) { android.util.Log.e("TermKB", "showAppShortcutsPopup: btnRect is null, hasAppShortcuts=${qbView.hasAppShortcuts}"); return }
|
||||
android.util.Log.d("TermKB", "showAppShortcutsPopup: btnRect=$btnRect shortcuts=${shortcuts.size}")
|
||||
val popupDir = resolvePopupDirection()
|
||||
|
||||
val anchorX: Float
|
||||
val anchorY: Float
|
||||
when (popupDir) {
|
||||
AppShortcutsPopupView.PopupDirection.DOWN -> {
|
||||
anchorX = btnRect.left
|
||||
anchorY = btnRect.bottom
|
||||
}
|
||||
AppShortcutsPopupView.PopupDirection.UP -> {
|
||||
anchorX = btnRect.left
|
||||
anchorY = btnRect.top
|
||||
}
|
||||
AppShortcutsPopupView.PopupDirection.RIGHT -> {
|
||||
anchorX = btnRect.right
|
||||
anchorY = btnRect.top
|
||||
}
|
||||
AppShortcutsPopupView.PopupDirection.LEFT -> {
|
||||
anchorX = btnRect.left
|
||||
anchorY = btnRect.top
|
||||
}
|
||||
}
|
||||
|
||||
// Lazily create the popup view
|
||||
val popup = appShortcutsPopup ?: AppShortcutsPopupView(context).also { appShortcutsPopup = it }
|
||||
// Full-screen overlay on DecorView for touch capture
|
||||
if (appShortcutsOverlay != null) dismissAppShortcutsPopup()
|
||||
(popup.parent as? ViewGroup)?.removeView(popup)
|
||||
val activity = context as? android.app.Activity
|
||||
val rootView = activity?.window?.decorView as? ViewGroup ?: rootContainer
|
||||
val self = this
|
||||
val overlay = object : View(context) {
|
||||
override fun dispatchKeyEvent(event: android.view.KeyEvent): Boolean {
|
||||
if (event.keyCode == android.view.KeyEvent.KEYCODE_BACK && event.action == android.view.KeyEvent.ACTION_UP) {
|
||||
self.dismissAppShortcutsPopup()
|
||||
return true
|
||||
}
|
||||
if (event.keyCode == android.view.KeyEvent.KEYCODE_BACK) return true // consume DOWN too
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}.apply {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
overlay.setOnTouchListener { _, event ->
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP) {
|
||||
val result = popup.handleTouch(event.rawX, event.rawY)
|
||||
when (result) {
|
||||
is AppShortcutsPopupView.TouchResult.AppTapped -> {
|
||||
if (result.index >= 0) {
|
||||
hapticFeedback(popup)
|
||||
popup.showKeyMap(result.index)
|
||||
}
|
||||
}
|
||||
is AppShortcutsPopupView.TouchResult.ItemTapped -> {
|
||||
hapticFeedback(popup)
|
||||
val fakeKey = KeyDefinition(
|
||||
id = "shortcut_item",
|
||||
label = result.item.label,
|
||||
action = result.item.action
|
||||
)
|
||||
handleKeyAction(fakeKey)
|
||||
// Modifier toggles stay open; everything else dismisses
|
||||
if (result.item.action !is KeyAction.ToggleMod) {
|
||||
dismissAppShortcutsPopup()
|
||||
}
|
||||
}
|
||||
is AppShortcutsPopupView.TouchResult.BackToList -> {
|
||||
hapticFeedback(popup)
|
||||
popup.show(popup.apps, anchorX, anchorY, popupDir, theme)
|
||||
}
|
||||
null -> {
|
||||
dismissAppShortcutsPopup()
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
rootView.addView(overlay, ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
))
|
||||
rootView.addView(popup, ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
))
|
||||
appShortcutsOverlay = overlay
|
||||
|
||||
// Register BACK handler to dismiss popup instead of navigating away
|
||||
val componentActivity = activity as? androidx.activity.ComponentActivity
|
||||
if (componentActivity != null) {
|
||||
val callback = object : androidx.activity.OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
dismissAppShortcutsPopup()
|
||||
}
|
||||
}
|
||||
componentActivity.onBackPressedDispatcher.addCallback(callback)
|
||||
appShortcutsBackCallback = callback
|
||||
}
|
||||
|
||||
// Detect AKB close (BACK pressed while IME visible) → dismiss popup too
|
||||
val decorView = rootView
|
||||
val visibleRect = android.graphics.Rect()
|
||||
decorView.getWindowVisibleDisplayFrame(visibleRect)
|
||||
val initialVisibleBottom = visibleRect.bottom
|
||||
val layoutListener = android.view.ViewTreeObserver.OnGlobalLayoutListener {
|
||||
val rect = android.graphics.Rect()
|
||||
decorView.getWindowVisibleDisplayFrame(rect)
|
||||
// If visible area bottom grew (AKB closed), dismiss popup
|
||||
if (rect.bottom > initialVisibleBottom + 50) {
|
||||
dismissAppShortcutsPopup()
|
||||
}
|
||||
}
|
||||
decorView.viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
|
||||
appShortcutsLayoutListener = layoutListener
|
||||
|
||||
// Show after the view is attached so getLocationOnScreen() works correctly
|
||||
popup.post {
|
||||
popup.show(shortcuts, anchorX, anchorY, popupDir, theme)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissAppShortcutsPopup() {
|
||||
appShortcutsBackCallback?.remove()
|
||||
appShortcutsBackCallback = null
|
||||
appShortcutsLayoutListener?.let { listener ->
|
||||
val activity = context as? android.app.Activity
|
||||
val rootView = activity?.window?.decorView
|
||||
rootView?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
|
||||
}
|
||||
appShortcutsLayoutListener = null
|
||||
appShortcutsPopup?.let { popup ->
|
||||
popup.dismiss()
|
||||
(popup.parent as? ViewGroup)?.removeView(popup)
|
||||
}
|
||||
appShortcutsOverlay?.let { (it.parent as? ViewGroup)?.removeView(it) }
|
||||
appShortcutsOverlay = null
|
||||
}
|
||||
|
||||
private fun resolvePopupDirection(): AppShortcutsPopupView.PopupDirection = when (quickBarPosition) {
|
||||
QuickBarPosition.TOP -> AppShortcutsPopupView.PopupDirection.DOWN
|
||||
QuickBarPosition.BOTTOM -> AppShortcutsPopupView.PopupDirection.UP
|
||||
QuickBarPosition.LEFT -> AppShortcutsPopupView.PopupDirection.RIGHT
|
||||
QuickBarPosition.RIGHT -> AppShortcutsPopupView.PopupDirection.LEFT
|
||||
QuickBarPosition.FLOATING -> AppShortcutsPopupView.PopupDirection.DOWN
|
||||
}
|
||||
|
||||
private fun showHintPopup(key: KeyDefinition, pageView: KeyboardPageView) {
|
||||
val hint = hintPopup ?: return
|
||||
hint.theme = theme
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
package com.roundingmobile.keyboard.model
|
||||
|
||||
/** An app with its shortcut key map (vim, nano, tmux, screen, etc.). */
|
||||
data class AppShortcut(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val menuItems: List<MenuItem>
|
||||
)
|
||||
|
||||
data class QuickBarConfig(
|
||||
val position: QuickBarPosition = QuickBarPosition.TOP,
|
||||
val orientation: QuickBarOrientation = QuickBarOrientation.HORIZONTAL,
|
||||
|
|
@ -7,7 +14,9 @@ data class QuickBarConfig(
|
|||
val height: Int = 42,
|
||||
val background: String? = null,
|
||||
val opacity: Float = 0.95f,
|
||||
val keys: List<KeyDefinition> = emptyList()
|
||||
val keys: List<KeyDefinition> = emptyList(),
|
||||
val appShortcuts: List<AppShortcut> = emptyList(),
|
||||
val infiniteScroll: Boolean = true
|
||||
)
|
||||
|
||||
enum class QuickBarPosition { TOP, BOTTOM, LEFT, RIGHT, FLOATING }
|
||||
|
|
|
|||
|
|
@ -142,7 +142,9 @@ object LayoutParser {
|
|||
height = obj.optInt("height", 42),
|
||||
background = obj.optString("background", null),
|
||||
opacity = obj.optDouble("opacity", 0.95).toFloat(),
|
||||
keys = obj.optJSONArray("keys")?.let { parseKeys(it, lang) } ?: emptyList()
|
||||
keys = obj.optJSONArray("keys")?.let { parseKeys(it, lang) } ?: emptyList(),
|
||||
appShortcuts = obj.optJSONArray("appShortcuts")?.let { parseAppShortcuts(it) } ?: emptyList(),
|
||||
infiniteScroll = obj.optBoolean("infiniteScroll", true)
|
||||
)
|
||||
|
||||
private fun parseSettings(obj: JSONObject): KeyboardSettings =
|
||||
|
|
@ -160,6 +162,16 @@ object LayoutParser {
|
|||
fullKeyboardScrollStyle = obj.optString("fullKeyboardScrollStyle", "viewpager")
|
||||
)
|
||||
|
||||
private fun parseAppShortcuts(arr: JSONArray): List<AppShortcut> =
|
||||
(0 until arr.length()).map { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
AppShortcut(
|
||||
id = obj.getString("id"),
|
||||
label = obj.getString("label"),
|
||||
menuItems = parseMenuItems(obj.optJSONArray("menuItems")) ?: emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseMenuItems(arr: JSONArray?): List<MenuItem>? {
|
||||
arr ?: return null
|
||||
if (arr.length() == 0) return null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
package com.roundingmobile.keyboard.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import com.roundingmobile.keyboard.model.AppShortcut
|
||||
import com.roundingmobile.keyboard.model.MenuItem
|
||||
import com.roundingmobile.keyboard.theme.KeyboardTheme
|
||||
|
||||
/**
|
||||
* Two-level popup for app shortcuts (vim, nano, tmux, screen).
|
||||
*
|
||||
* State 1 (APP_LIST): vertical list of app names — tap to select.
|
||||
* State 2 (KEY_MAP): grid of selected app's shortcuts — tap to fire.
|
||||
*
|
||||
* Tap outside dismisses at any state.
|
||||
*/
|
||||
class AppShortcutsPopupView(context: Context) : View(context) {
|
||||
|
||||
enum class PopupDirection { DOWN, UP, RIGHT, LEFT }
|
||||
|
||||
sealed class TouchResult {
|
||||
data class AppTapped(val index: Int) : TouchResult()
|
||||
data class ItemTapped(val item: MenuItem) : TouchResult()
|
||||
object BackToList : TouchResult()
|
||||
}
|
||||
|
||||
private enum class State { APP_LIST, KEY_MAP }
|
||||
|
||||
var theme: KeyboardTheme? = null
|
||||
var apps: List<AppShortcut> = emptyList()
|
||||
private set
|
||||
private var direction: PopupDirection = PopupDirection.DOWN
|
||||
private var state: State = State.APP_LIST
|
||||
private var selectedAppIndex: Int = -1
|
||||
|
||||
// Layout rects (screen coordinates)
|
||||
private var panelRect = RectF()
|
||||
private var cellRects = listOf<RectF>()
|
||||
private var backBtnRect = RectF()
|
||||
|
||||
// Grid config for key map
|
||||
private val gridCols = 3
|
||||
|
||||
// Dimensions
|
||||
private val cellWidth = dpToPx(64f)
|
||||
private val cellHeight = dpToPx(40f)
|
||||
private val cellGap = dpToPx(3f)
|
||||
private val cellInset = dpToPx(1.5f)
|
||||
private val panelPadding = dpToPx(6f)
|
||||
private val radius = dpToPx(8f)
|
||||
private val cellRadius = dpToPx(5f)
|
||||
private val backBtnHeight = dpToPx(32f)
|
||||
|
||||
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val cellPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE; strokeWidth = dpToPx(1f)
|
||||
}
|
||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = Typeface.MONOSPACE; textAlign = Paint.Align.CENTER
|
||||
}
|
||||
private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.parseColor("#80000000")
|
||||
maskFilter = BlurMaskFilter(dpToPx(4f), BlurMaskFilter.Blur.NORMAL)
|
||||
}
|
||||
|
||||
private var anchorX = 0f
|
||||
private var anchorY = 0f
|
||||
|
||||
fun show(
|
||||
apps: List<AppShortcut>,
|
||||
anchorX: Float,
|
||||
anchorY: Float,
|
||||
direction: PopupDirection,
|
||||
theme: KeyboardTheme
|
||||
) {
|
||||
this.apps = apps
|
||||
this.direction = direction
|
||||
this.theme = theme
|
||||
this.anchorX = anchorX
|
||||
this.anchorY = anchorY
|
||||
this.state = State.APP_LIST
|
||||
this.selectedAppIndex = -1
|
||||
visibility = VISIBLE
|
||||
computeAppListLayout()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun showKeyMap(index: Int) {
|
||||
selectedAppIndex = index
|
||||
state = State.KEY_MAP
|
||||
computeKeyMapLayout()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
visibility = GONE
|
||||
state = State.APP_LIST
|
||||
selectedAppIndex = -1
|
||||
apps = emptyList()
|
||||
}
|
||||
|
||||
val isShowing: Boolean get() = visibility == VISIBLE
|
||||
|
||||
/**
|
||||
* Hit-test a raw screen coordinate. Returns result or null (outside).
|
||||
*/
|
||||
fun handleTouch(rawX: Float, rawY: Float): TouchResult? {
|
||||
when (state) {
|
||||
State.APP_LIST -> {
|
||||
for (i in cellRects.indices) {
|
||||
if (cellRects[i].contains(rawX, rawY)) {
|
||||
return TouchResult.AppTapped(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
State.KEY_MAP -> {
|
||||
// Back button
|
||||
if (backBtnRect.contains(rawX, rawY)) {
|
||||
return TouchResult.BackToList
|
||||
}
|
||||
// Item cells
|
||||
val app = apps.getOrNull(selectedAppIndex)
|
||||
if (app != null) {
|
||||
for (i in cellRects.indices) {
|
||||
if (i < app.menuItems.size && cellRects[i].contains(rawX, rawY)) {
|
||||
return TouchResult.ItemTapped(app.menuItems[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inside panel but not on a cell — absorb
|
||||
if (panelRect.contains(rawX, rawY)) return when (state) {
|
||||
State.APP_LIST -> TouchResult.AppTapped(-1) // absorb, no action
|
||||
State.KEY_MAP -> TouchResult.BackToList // absorb
|
||||
}
|
||||
return null // outside — caller should dismiss
|
||||
}
|
||||
|
||||
// === Layout computation ===
|
||||
|
||||
private fun computeAppListLayout() {
|
||||
val count = apps.size
|
||||
val rows = count
|
||||
val panelW = cellWidth + panelPadding * 2
|
||||
val panelH = rows * cellHeight + (rows - 1).coerceAtLeast(0) * cellGap + panelPadding * 2
|
||||
|
||||
panelRect = positionPanel(panelW, panelH)
|
||||
cellRects = buildVerticalCells(panelRect, count)
|
||||
backBtnRect = RectF()
|
||||
}
|
||||
|
||||
private fun computeKeyMapLayout() {
|
||||
val app = apps.getOrNull(selectedAppIndex) ?: return
|
||||
val items = app.menuItems
|
||||
val rows = (items.size + gridCols - 1) / gridCols
|
||||
val panelW = gridCols * cellWidth + (gridCols - 1) * cellGap + panelPadding * 2
|
||||
val panelH = backBtnHeight + cellGap + rows * cellHeight + (rows - 1).coerceAtLeast(0) * cellGap + panelPadding * 2
|
||||
|
||||
panelRect = positionPanel(panelW, panelH)
|
||||
|
||||
// Back button at top of panel
|
||||
backBtnRect = RectF(
|
||||
panelRect.left + panelPadding,
|
||||
panelRect.top + panelPadding,
|
||||
panelRect.right - panelPadding,
|
||||
panelRect.top + panelPadding + backBtnHeight
|
||||
)
|
||||
|
||||
// Grid cells below back button
|
||||
val rects = mutableListOf<RectF>()
|
||||
val gridTop = backBtnRect.bottom + cellGap
|
||||
for (i in items.indices) {
|
||||
val col = i % gridCols
|
||||
val row = i / gridCols
|
||||
val x = panelRect.left + panelPadding + col * (cellWidth + cellGap)
|
||||
val y = gridTop + row * (cellHeight + cellGap)
|
||||
rects.add(RectF(x, y, x + cellWidth, y + cellHeight))
|
||||
}
|
||||
cellRects = rects
|
||||
}
|
||||
|
||||
private fun positionPanel(panelW: Float, panelH: Float): RectF {
|
||||
val screenW = resources.displayMetrics.widthPixels.toFloat()
|
||||
val screenH = resources.displayMetrics.heightPixels.toFloat()
|
||||
|
||||
val panel = when (direction) {
|
||||
PopupDirection.UP -> RectF(anchorX, anchorY - panelH, anchorX + panelW, anchorY)
|
||||
PopupDirection.DOWN -> RectF(anchorX, anchorY, anchorX + panelW, anchorY + panelH)
|
||||
PopupDirection.RIGHT -> RectF(anchorX, anchorY, anchorX + panelW, anchorY + panelH)
|
||||
PopupDirection.LEFT -> RectF(anchorX - panelW, anchorY, anchorX, anchorY + panelH)
|
||||
}
|
||||
|
||||
// Clamp to screen
|
||||
if (panel.right > screenW) panel.offset(screenW - panel.right, 0f)
|
||||
if (panel.left < 0f) panel.offset(-panel.left, 0f)
|
||||
if (panel.bottom > screenH) panel.offset(0f, screenH - panel.bottom)
|
||||
if (panel.top < 0f) panel.offset(0f, -panel.top)
|
||||
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun buildVerticalCells(panel: RectF, count: Int): List<RectF> {
|
||||
val rects = mutableListOf<RectF>()
|
||||
var y = panel.top + panelPadding
|
||||
for (i in 0 until count) {
|
||||
rects.add(RectF(panel.left + panelPadding, y, panel.right - panelPadding, y + cellHeight))
|
||||
y += cellHeight + cellGap
|
||||
}
|
||||
return rects
|
||||
}
|
||||
|
||||
// === Drawing ===
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val screenW = resources.displayMetrics.widthPixels
|
||||
val screenH = resources.displayMetrics.heightPixels
|
||||
setMeasuredDimension(screenW, screenH)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val th = theme ?: return
|
||||
if (apps.isEmpty()) return
|
||||
|
||||
val loc = IntArray(2)
|
||||
getLocationOnScreen(loc)
|
||||
canvas.save()
|
||||
canvas.translate(-loc[0].toFloat(), -loc[1].toFloat())
|
||||
|
||||
// Panel background
|
||||
drawPanel(canvas, th, panelRect)
|
||||
|
||||
when (state) {
|
||||
State.APP_LIST -> {
|
||||
textPaint.textSize = spToPx(14f)
|
||||
for (i in apps.indices) {
|
||||
val rect = cellRects.getOrNull(i) ?: continue
|
||||
drawCell(canvas, th, rect, apps[i].label, false)
|
||||
}
|
||||
}
|
||||
State.KEY_MAP -> {
|
||||
val app = apps.getOrNull(selectedAppIndex)
|
||||
if (app != null) {
|
||||
// Back button with app name
|
||||
drawBackButton(canvas, th, backBtnRect, "← ${app.label}")
|
||||
// Grid of shortcuts
|
||||
textPaint.textSize = spToPx(12f)
|
||||
for (i in cellRects.indices) {
|
||||
val rect = cellRects[i]
|
||||
val label = app.menuItems.getOrNull(i)?.label ?: continue
|
||||
drawCell(canvas, th, rect, label, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
private fun drawPanel(canvas: Canvas, th: KeyboardTheme, rect: RectF) {
|
||||
if (rect.isEmpty) return
|
||||
canvas.drawRoundRect(
|
||||
rect.left - 2f, rect.top - 2f, rect.right + 2f, rect.bottom + 2f,
|
||||
radius, radius, shadowPaint
|
||||
)
|
||||
bgPaint.color = th.keyboardBg
|
||||
canvas.drawRoundRect(rect, radius, radius, bgPaint)
|
||||
}
|
||||
|
||||
private fun drawCell(canvas: Canvas, th: KeyboardTheme, rect: RectF, label: String, selected: Boolean) {
|
||||
val inner = RectF(rect.left + cellInset, rect.top + cellInset, rect.right - cellInset, rect.bottom - cellInset)
|
||||
cellPaint.color = if (selected) th.keyBgPressed else th.keyBg
|
||||
canvas.drawRoundRect(inner, cellRadius, cellRadius, cellPaint)
|
||||
borderPaint.color = th.keyBorder
|
||||
canvas.drawRoundRect(inner, cellRadius, cellRadius, borderPaint)
|
||||
textPaint.color = th.keyText
|
||||
canvas.drawText(label, inner.centerX(), inner.centerY() + textPaint.textSize / 3f, textPaint)
|
||||
}
|
||||
|
||||
private fun drawBackButton(canvas: Canvas, th: KeyboardTheme, rect: RectF, label: String) {
|
||||
val inner = RectF(rect.left + cellInset, rect.top + cellInset, rect.right - cellInset, rect.bottom - cellInset)
|
||||
// Accent color for the back/title bar
|
||||
cellPaint.color = th.resolveKeyBg("modifier")
|
||||
canvas.drawRoundRect(inner, cellRadius, cellRadius, cellPaint)
|
||||
borderPaint.color = th.resolveKeyBorder("modifier")
|
||||
canvas.drawRoundRect(inner, cellRadius, cellRadius, borderPaint)
|
||||
textPaint.textSize = spToPx(13f)
|
||||
textPaint.color = th.resolveKeyText("modifier")
|
||||
textPaint.isFakeBoldText = true
|
||||
canvas.drawText(label, inner.centerX(), inner.centerY() + textPaint.textSize / 3f, textPaint)
|
||||
textPaint.isFakeBoldText = false
|
||||
}
|
||||
|
||||
private fun dpToPx(dp: Float): Float =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
|
||||
|
||||
private fun spToPx(sp: Float): Float =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics)
|
||||
}
|
||||
|
|
@ -32,10 +32,18 @@ class QuickBarView(context: Context) : View(context) {
|
|||
var onMenuKeyTap: ((KeyDefinition) -> Unit)? = null
|
||||
/** Callback for the snippets button */
|
||||
var onSnippetsTap: (() -> Unit)? = null
|
||||
/** Callback for the app shortcuts button */
|
||||
var onAppShortcutsTap: (() -> Unit)? = null
|
||||
|
||||
/** Active (ARMED/LOCKED) modifier key IDs — drawn with highlight color */
|
||||
val activeModifiers = mutableSetOf<String>()
|
||||
|
||||
/** Whether the app shortcuts button is visible (set from config.appShortcuts) */
|
||||
var hasAppShortcuts = false
|
||||
|
||||
/** Bounded vs infinite scroll (set from config.infiniteScroll) */
|
||||
var infiniteScroll = true
|
||||
|
||||
// Scroll style
|
||||
var scrollStyle: String = "infinite"
|
||||
|
||||
|
|
@ -65,6 +73,9 @@ class QuickBarView(context: Context) : View(context) {
|
|||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE; textAlign = Paint.Align.CENTER }
|
||||
private val gripPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#555555") }
|
||||
private val snippetPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE; textAlign = Paint.Align.CENTER }
|
||||
private val shortcutPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE; textAlign = Paint.Align.CENTER }
|
||||
private val iconPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private var appIconBitmap: Bitmap? = null
|
||||
|
||||
private val gapPx = dpToPx(3f)
|
||||
private val radiusPx = dpToPx(4f)
|
||||
|
|
@ -89,6 +100,8 @@ class QuickBarView(context: Context) : View(context) {
|
|||
allKeys.addAll(quickBarConfig.keys.filter { it.visible })
|
||||
keyWidths.clear()
|
||||
scrollOffset = 0f
|
||||
hasAppShortcuts = quickBarConfig.appShortcuts.isNotEmpty()
|
||||
infiniteScroll = quickBarConfig.infiniteScroll
|
||||
|
||||
quickBarConfig.background?.let {
|
||||
try { bgPaint.color = Color.parseColor(it) } catch (_: Exception) {}
|
||||
|
|
@ -164,20 +177,21 @@ class QuickBarView(context: Context) : View(context) {
|
|||
if (totalWeight <= 0f) return
|
||||
val gripSpace = if (cfg.draggable) gripWidth else 0f
|
||||
val snippetSpace = snippetBtnWidth + gapPx
|
||||
val shortcutSpace = if (hasAppShortcuts) snippetBtnWidth + gapPx else 0f
|
||||
|
||||
if (isHorizontal) {
|
||||
val availW = viewW - gripSpace - snippetSpace - gapPx * (keys.size + 1)
|
||||
val availW = viewW - gripSpace - shortcutSpace - snippetSpace - gapPx * (keys.size + 1)
|
||||
val unitW = availW / totalWeight
|
||||
var x = gripSpace + gapPx
|
||||
var x = gripSpace + shortcutSpace + gapPx
|
||||
for (key in keys) {
|
||||
val keyW = unitW * key.w
|
||||
key.bounds = RectF(x, gapPx, x + keyW, viewH - gapPx)
|
||||
x += keyW + gapPx
|
||||
}
|
||||
} else {
|
||||
val availH = viewH - gripSpace - gapPx * (keys.size + 1)
|
||||
val availH = viewH - gripSpace - shortcutSpace - snippetSpace - gapPx * (keys.size + 1)
|
||||
val unitH = availH / totalWeight
|
||||
var y = gripSpace + gapPx
|
||||
var y = gripSpace + shortcutSpace + gapPx
|
||||
for (key in keys) {
|
||||
val keyH = unitH * key.w
|
||||
key.bounds = RectF(gapPx, y, viewW - gapPx, y + keyH)
|
||||
|
|
@ -209,6 +223,33 @@ class QuickBarView(context: Context) : View(context) {
|
|||
}
|
||||
}
|
||||
|
||||
// App shortcuts button — left edge after grip (horizontal) or top after grip (vertical)
|
||||
if (hasAppShortcuts) {
|
||||
val gripSpace = if (cfg.draggable) gripWidth else 0f
|
||||
val shortcutRect = if (isVertical) {
|
||||
RectF(gapPx, gripSpace + gapPx, width - gapPx, gripSpace + gapPx + snippetBtnWidth)
|
||||
} else {
|
||||
RectF(gripSpace + gapPx, gapPx, gripSpace + gapPx + snippetBtnWidth, height - gapPx)
|
||||
}
|
||||
// Draw app icon filling the entire button (or fallback key with ">_" text)
|
||||
val icon = getAppIcon()
|
||||
if (icon != null) {
|
||||
val dst = Rect(
|
||||
shortcutRect.left.toInt(), shortcutRect.top.toInt(),
|
||||
shortcutRect.right.toInt(), shortcutRect.bottom.toInt()
|
||||
)
|
||||
canvas.drawBitmap(icon, null, dst, null)
|
||||
} else {
|
||||
keyBgPaint.color = th.resolveKeyBg("modifier")
|
||||
canvas.drawRoundRect(shortcutRect, radiusPx, radiusPx, keyBgPaint)
|
||||
borderPaint.color = th.resolveKeyBorder("modifier")
|
||||
canvas.drawRoundRect(shortcutRect, radiusPx, radiusPx, borderPaint)
|
||||
shortcutPaint.color = th.resolveKeyText("modifier")
|
||||
shortcutPaint.textSize = spToPx(14f)
|
||||
canvas.drawText(">_", shortcutRect.centerX(), shortcutRect.centerY() + shortcutPaint.textSize / 3f, shortcutPaint)
|
||||
}
|
||||
}
|
||||
|
||||
// Snippets button — right edge (horizontal) or bottom edge (vertical)
|
||||
val snippetRect = if (isVertical) {
|
||||
RectF(gapPx, height - snippetBtnWidth - gapPx, width - gapPx, height - gapPx)
|
||||
|
|
@ -246,8 +287,9 @@ class QuickBarView(context: Context) : View(context) {
|
|||
private fun drawInfiniteKeys(canvas: Canvas, th: KeyboardTheme) {
|
||||
if (totalContentWidth <= 0f) return
|
||||
val gripSpace = if (config?.draggable == true) gripWidth else 0f
|
||||
val shortcutSpace = if (hasAppShortcuts) snippetBtnWidth + gapPx else 0f
|
||||
val snippetSpace = snippetBtnWidth + gapPx
|
||||
val drawStart = gripSpace
|
||||
val drawStart = gripSpace + shortcutSpace
|
||||
val drawEnd = width - snippetSpace
|
||||
|
||||
// Apply fling deceleration
|
||||
|
|
@ -260,37 +302,63 @@ class QuickBarView(context: Context) : View(context) {
|
|||
if (kotlin.math.abs(flingVelocity) < 10f) flingVelocity = 0f
|
||||
}
|
||||
|
||||
// Normalize scroll offset
|
||||
while (scrollOffset < 0) scrollOffset += totalContentWidth
|
||||
while (scrollOffset >= totalContentWidth) scrollOffset -= totalContentWidth
|
||||
// Normalize scroll offset — infinite wrap or bounded clamp
|
||||
if (infiniteScroll) {
|
||||
while (scrollOffset < 0) scrollOffset += totalContentWidth
|
||||
while (scrollOffset >= totalContentWidth) scrollOffset -= totalContentWidth
|
||||
} else {
|
||||
val viewportWidth = drawEnd - drawStart
|
||||
val maxScroll = (totalContentWidth - viewportWidth).coerceAtLeast(0f)
|
||||
scrollOffset = scrollOffset.coerceIn(0f, maxScroll)
|
||||
if (scrollOffset <= 0f || scrollOffset >= maxScroll) flingVelocity = 0f
|
||||
}
|
||||
|
||||
// Draw keys wrapping around
|
||||
// Draw keys
|
||||
canvas.save()
|
||||
canvas.clipRect(drawStart, 0f, drawEnd, height.toFloat())
|
||||
|
||||
var x = drawStart + gapPx - scrollOffset
|
||||
// Start one full cycle before to ensure wrap coverage
|
||||
x -= totalContentWidth
|
||||
val viewH = height.toFloat()
|
||||
|
||||
var drawn = 0
|
||||
while (x < drawEnd && drawn < allKeys.size * 3) {
|
||||
val keyIdx = ((drawn % allKeys.size) + allKeys.size) % allKeys.size
|
||||
val key = allKeys[keyIdx]
|
||||
val keyW = keyWidths[keyIdx]
|
||||
val right = x + keyW
|
||||
if (right > drawStart && x < drawEnd) {
|
||||
val rect = RectF(x, gapPx, right, viewH - gapPx)
|
||||
drawSingleKey(canvas, th, key, rect)
|
||||
key.bounds = RectF(rect)
|
||||
// Separator line after key
|
||||
val sepX = right + gapPx / 2f
|
||||
if (sepX > drawStart && sepX < drawEnd) {
|
||||
canvas.drawLine(sepX, gapPx * 2, sepX, viewH - gapPx * 2, separatorPaint)
|
||||
if (infiniteScroll) {
|
||||
// Wrapping: start one full cycle before to ensure wrap coverage
|
||||
var x = drawStart + gapPx - scrollOffset - totalContentWidth
|
||||
var drawn = 0
|
||||
while (x < drawEnd && drawn < allKeys.size * 3) {
|
||||
val keyIdx = ((drawn % allKeys.size) + allKeys.size) % allKeys.size
|
||||
val key = allKeys[keyIdx]
|
||||
val keyW = keyWidths[keyIdx]
|
||||
val right = x + keyW
|
||||
if (right > drawStart && x < drawEnd) {
|
||||
val rect = RectF(x, gapPx, right, viewH - gapPx)
|
||||
drawSingleKey(canvas, th, key, rect)
|
||||
key.bounds = RectF(rect)
|
||||
val sepX = right + gapPx / 2f
|
||||
if (sepX > drawStart && sepX < drawEnd) {
|
||||
canvas.drawLine(sepX, gapPx * 2, sepX, viewH - gapPx * 2, separatorPaint)
|
||||
}
|
||||
}
|
||||
x += keyW + gapPx
|
||||
drawn++
|
||||
}
|
||||
} else {
|
||||
// Bounded: single pass, no wrapping
|
||||
var x = drawStart + gapPx - scrollOffset
|
||||
for (i in allKeys.indices) {
|
||||
val key = allKeys[i]
|
||||
val keyW = keyWidths[i]
|
||||
val right = x + keyW
|
||||
if (right > drawStart && x < drawEnd) {
|
||||
val rect = RectF(x, gapPx, right, viewH - gapPx)
|
||||
drawSingleKey(canvas, th, key, rect)
|
||||
key.bounds = RectF(rect)
|
||||
val sepX = right + gapPx / 2f
|
||||
if (sepX > drawStart && sepX < drawEnd) {
|
||||
canvas.drawLine(sepX, gapPx * 2, sepX, viewH - gapPx * 2, separatorPaint)
|
||||
}
|
||||
}
|
||||
x += keyW + gapPx
|
||||
if (x > drawEnd) break
|
||||
}
|
||||
x += keyW + gapPx
|
||||
drawn++
|
||||
}
|
||||
|
||||
canvas.restore()
|
||||
|
|
@ -299,8 +367,9 @@ class QuickBarView(context: Context) : View(context) {
|
|||
private fun drawInfiniteKeysVertical(canvas: Canvas, th: KeyboardTheme) {
|
||||
if (totalContentWidth <= 0f) return
|
||||
val gripSpace = if (config?.draggable == true) gripWidth else 0f
|
||||
val shortcutSpace = if (hasAppShortcuts) snippetBtnWidth + gapPx else 0f
|
||||
val snippetSpace = snippetBtnWidth + gapPx
|
||||
val drawStart = gripSpace
|
||||
val drawStart = gripSpace + shortcutSpace
|
||||
val drawEnd = height - snippetSpace
|
||||
|
||||
// Apply fling deceleration
|
||||
|
|
@ -313,36 +382,63 @@ class QuickBarView(context: Context) : View(context) {
|
|||
if (kotlin.math.abs(flingVelocity) < 10f) flingVelocity = 0f
|
||||
}
|
||||
|
||||
// Normalize scroll offset
|
||||
while (scrollOffset < 0) scrollOffset += totalContentWidth
|
||||
while (scrollOffset >= totalContentWidth) scrollOffset -= totalContentWidth
|
||||
// Normalize scroll offset — infinite wrap or bounded clamp
|
||||
if (infiniteScroll) {
|
||||
while (scrollOffset < 0) scrollOffset += totalContentWidth
|
||||
while (scrollOffset >= totalContentWidth) scrollOffset -= totalContentWidth
|
||||
} else {
|
||||
val viewportHeight = drawEnd - drawStart
|
||||
val maxScroll = (totalContentWidth - viewportHeight).coerceAtLeast(0f)
|
||||
scrollOffset = scrollOffset.coerceIn(0f, maxScroll)
|
||||
if (scrollOffset <= 0f || scrollOffset >= maxScroll) flingVelocity = 0f
|
||||
}
|
||||
|
||||
// Draw keys wrapping around vertically
|
||||
// Draw keys
|
||||
canvas.save()
|
||||
canvas.clipRect(0f, drawStart, width.toFloat(), drawEnd)
|
||||
|
||||
var y = drawStart + gapPx - scrollOffset
|
||||
y -= totalContentWidth
|
||||
val viewW = width.toFloat()
|
||||
|
||||
var drawn = 0
|
||||
while (y < drawEnd && drawn < allKeys.size * 3) {
|
||||
val keyIdx = ((drawn % allKeys.size) + allKeys.size) % allKeys.size
|
||||
val key = allKeys[keyIdx]
|
||||
val keyH = keyWidths[keyIdx]
|
||||
val bottom = y + keyH
|
||||
if (bottom > drawStart && y < drawEnd) {
|
||||
val rect = RectF(gapPx, y, viewW - gapPx, bottom)
|
||||
drawSingleKey(canvas, th, key, rect)
|
||||
key.bounds = RectF(rect)
|
||||
// Separator line after key
|
||||
val sepY = bottom + gapPx / 2f
|
||||
if (sepY > drawStart && sepY < drawEnd) {
|
||||
canvas.drawLine(gapPx * 2, sepY, viewW - gapPx * 2, sepY, separatorPaint)
|
||||
if (infiniteScroll) {
|
||||
// Wrapping: start one full cycle before to ensure wrap coverage
|
||||
var y = drawStart + gapPx - scrollOffset - totalContentWidth
|
||||
var drawn = 0
|
||||
while (y < drawEnd && drawn < allKeys.size * 3) {
|
||||
val keyIdx = ((drawn % allKeys.size) + allKeys.size) % allKeys.size
|
||||
val key = allKeys[keyIdx]
|
||||
val keyH = keyWidths[keyIdx]
|
||||
val bottom = y + keyH
|
||||
if (bottom > drawStart && y < drawEnd) {
|
||||
val rect = RectF(gapPx, y, viewW - gapPx, bottom)
|
||||
drawSingleKey(canvas, th, key, rect)
|
||||
key.bounds = RectF(rect)
|
||||
val sepY = bottom + gapPx / 2f
|
||||
if (sepY > drawStart && sepY < drawEnd) {
|
||||
canvas.drawLine(gapPx * 2, sepY, viewW - gapPx * 2, sepY, separatorPaint)
|
||||
}
|
||||
}
|
||||
y += keyH + gapPx
|
||||
drawn++
|
||||
}
|
||||
} else {
|
||||
// Bounded: single pass, no wrapping
|
||||
var y = drawStart + gapPx - scrollOffset
|
||||
for (i in allKeys.indices) {
|
||||
val key = allKeys[i]
|
||||
val keyH = keyWidths[i]
|
||||
val bottom = y + keyH
|
||||
if (bottom > drawStart && y < drawEnd) {
|
||||
val rect = RectF(gapPx, y, viewW - gapPx, bottom)
|
||||
drawSingleKey(canvas, th, key, rect)
|
||||
key.bounds = RectF(rect)
|
||||
val sepY = bottom + gapPx / 2f
|
||||
if (sepY > drawStart && sepY < drawEnd) {
|
||||
canvas.drawLine(gapPx * 2, sepY, viewW - gapPx * 2, sepY, separatorPaint)
|
||||
}
|
||||
}
|
||||
y += keyH + gapPx
|
||||
if (y > drawEnd) break
|
||||
}
|
||||
y += keyH + gapPx
|
||||
drawn++
|
||||
}
|
||||
|
||||
canvas.restore()
|
||||
|
|
@ -361,16 +457,155 @@ class QuickBarView(context: Context) : View(context) {
|
|||
borderPaint.color = th.resolveKeyBorder(key.style)
|
||||
canvas.drawRoundRect(rect, radiusPx, radiusPx, borderPaint)
|
||||
|
||||
textPaint.color = if (modActive) 0xFFFFFFFF.toInt() else th.resolveKeyText(key.style)
|
||||
textPaint.textSize = if (key.label.length == 1) spToPx(16f) else spToPx(11f)
|
||||
canvas.drawText(key.label, rect.centerX(), rect.centerY() + textPaint.textSize / 3f, textPaint)
|
||||
val iconColor = if (modActive) 0xFFFFFFFF.toInt() else th.resolveKeyText(key.style)
|
||||
if (key.labelIcon != null && drawKeyIcon(canvas, key.labelIcon, rect, iconColor)) {
|
||||
// Icon drawn successfully
|
||||
} else {
|
||||
textPaint.color = iconColor
|
||||
textPaint.textSize = if (key.label.length == 1) spToPx(16f) else spToPx(11f)
|
||||
canvas.drawText(key.label, rect.centerX(), rect.centerY() + textPaint.textSize / 3f, textPaint)
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw a Canvas-based icon for a key. Returns true if icon was drawn. */
|
||||
private fun drawKeyIcon(canvas: Canvas, icon: String, rect: RectF, color: Int): Boolean {
|
||||
iconPaint.color = color
|
||||
iconPaint.strokeWidth = dpToPx(1.8f)
|
||||
iconPaint.style = Paint.Style.STROKE
|
||||
iconPaint.strokeCap = Paint.Cap.ROUND
|
||||
iconPaint.strokeJoin = Paint.Join.ROUND
|
||||
|
||||
val cx = rect.centerX()
|
||||
val cy = rect.centerY()
|
||||
val s = rect.height() * 0.2f // icon size relative to key
|
||||
|
||||
when (icon) {
|
||||
"tab" -> {
|
||||
// Right arrow with end bar: →|
|
||||
val path = Path()
|
||||
path.moveTo(cx - s, cy)
|
||||
path.lineTo(cx + s * 0.5f, cy)
|
||||
path.moveTo(cx + s * 0.1f, cy - s * 0.5f)
|
||||
path.lineTo(cx + s * 0.5f, cy)
|
||||
path.lineTo(cx + s * 0.1f, cy + s * 0.5f)
|
||||
canvas.drawPath(path, iconPaint)
|
||||
canvas.drawLine(cx + s * 0.7f, cy - s * 0.6f, cx + s * 0.7f, cy + s * 0.6f, iconPaint)
|
||||
}
|
||||
"backtab" -> {
|
||||
// Left arrow with start bar: |←
|
||||
val path = Path()
|
||||
path.moveTo(cx + s, cy)
|
||||
path.lineTo(cx - s * 0.5f, cy)
|
||||
path.moveTo(cx - s * 0.1f, cy - s * 0.5f)
|
||||
path.lineTo(cx - s * 0.5f, cy)
|
||||
path.lineTo(cx - s * 0.1f, cy + s * 0.5f)
|
||||
canvas.drawPath(path, iconPaint)
|
||||
canvas.drawLine(cx - s * 0.7f, cy - s * 0.6f, cx - s * 0.7f, cy + s * 0.6f, iconPaint)
|
||||
}
|
||||
"home" -> {
|
||||
// Double left chevron with bar: |<<
|
||||
val path = Path()
|
||||
path.moveTo(cx + s * 0.2f, cy - s * 0.5f)
|
||||
path.lineTo(cx - s * 0.2f, cy)
|
||||
path.lineTo(cx + s * 0.2f, cy + s * 0.5f)
|
||||
path.moveTo(cx + s * 0.6f, cy - s * 0.5f)
|
||||
path.lineTo(cx + s * 0.2f, cy)
|
||||
path.lineTo(cx + s * 0.6f, cy + s * 0.5f)
|
||||
canvas.drawPath(path, iconPaint)
|
||||
canvas.drawLine(cx - s * 0.5f, cy - s * 0.6f, cx - s * 0.5f, cy + s * 0.6f, iconPaint)
|
||||
}
|
||||
"end" -> {
|
||||
// Double right chevron with bar: >>|
|
||||
val path = Path()
|
||||
path.moveTo(cx - s * 0.2f, cy - s * 0.5f)
|
||||
path.lineTo(cx + s * 0.2f, cy)
|
||||
path.lineTo(cx - s * 0.2f, cy + s * 0.5f)
|
||||
path.moveTo(cx - s * 0.6f, cy - s * 0.5f)
|
||||
path.lineTo(cx - s * 0.2f, cy)
|
||||
path.lineTo(cx - s * 0.6f, cy + s * 0.5f)
|
||||
canvas.drawPath(path, iconPaint)
|
||||
canvas.drawLine(cx + s * 0.5f, cy - s * 0.6f, cx + s * 0.5f, cy + s * 0.6f, iconPaint)
|
||||
}
|
||||
"pgup" -> {
|
||||
// Double up chevron ^^
|
||||
val path = Path()
|
||||
path.moveTo(cx - s * 0.5f, cy)
|
||||
path.lineTo(cx, cy - s * 0.5f)
|
||||
path.lineTo(cx + s * 0.5f, cy)
|
||||
path.moveTo(cx - s * 0.5f, cy + s * 0.5f)
|
||||
path.lineTo(cx, cy)
|
||||
path.lineTo(cx + s * 0.5f, cy + s * 0.5f)
|
||||
canvas.drawPath(path, iconPaint)
|
||||
}
|
||||
"pgdn" -> {
|
||||
// Double down chevron vv
|
||||
val path = Path()
|
||||
path.moveTo(cx - s * 0.5f, cy - s * 0.5f)
|
||||
path.lineTo(cx, cy)
|
||||
path.lineTo(cx + s * 0.5f, cy - s * 0.5f)
|
||||
path.moveTo(cx - s * 0.5f, cy)
|
||||
path.lineTo(cx, cy + s * 0.5f)
|
||||
path.lineTo(cx + s * 0.5f, cy)
|
||||
canvas.drawPath(path, iconPaint)
|
||||
}
|
||||
"up" -> {
|
||||
val path = Path()
|
||||
path.moveTo(cx - s * 0.5f, cy + s * 0.3f)
|
||||
path.lineTo(cx, cy - s * 0.3f)
|
||||
path.lineTo(cx + s * 0.5f, cy + s * 0.3f)
|
||||
canvas.drawPath(path, iconPaint)
|
||||
}
|
||||
"down" -> {
|
||||
val path = Path()
|
||||
path.moveTo(cx - s * 0.5f, cy - s * 0.3f)
|
||||
path.lineTo(cx, cy + s * 0.3f)
|
||||
path.lineTo(cx + s * 0.5f, cy - s * 0.3f)
|
||||
canvas.drawPath(path, iconPaint)
|
||||
}
|
||||
"left" -> {
|
||||
val path = Path()
|
||||
path.moveTo(cx + s * 0.3f, cy - s * 0.5f)
|
||||
path.lineTo(cx - s * 0.3f, cy)
|
||||
path.lineTo(cx + s * 0.3f, cy + s * 0.5f)
|
||||
canvas.drawPath(path, iconPaint)
|
||||
}
|
||||
"right" -> {
|
||||
val path = Path()
|
||||
path.moveTo(cx - s * 0.3f, cy - s * 0.5f)
|
||||
path.lineTo(cx + s * 0.3f, cy)
|
||||
path.lineTo(cx - s * 0.3f, cy + s * 0.5f)
|
||||
canvas.drawPath(path, iconPaint)
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
val cfg = config ?: return false
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
android.util.Log.d("QuickBarView", "onTouchEvent DOWN xy=${event.x},${event.y} size=${width}x${height} hasShortcuts=$hasAppShortcuts")
|
||||
}
|
||||
val isVertical = cfg.orientation == QuickBarOrientation.VERTICAL
|
||||
|
||||
// App shortcuts button
|
||||
if (hasAppShortcuts) {
|
||||
val gripSpace = if (cfg.draggable) gripWidth else 0f
|
||||
val shortcutRect = if (isVertical) {
|
||||
RectF(gapPx, gripSpace + gapPx, width - gapPx, gripSpace + gapPx + snippetBtnWidth)
|
||||
} else {
|
||||
RectF(gripSpace + gapPx, gapPx, gripSpace + gapPx + snippetBtnWidth, height - gapPx)
|
||||
}
|
||||
if (shortcutRect.contains(event.x, event.y)) {
|
||||
android.util.Log.d("QuickBarView", "shortcut btn touch action=${event.actionMasked} rect=$shortcutRect xy=${event.x},${event.y} hasCallback=${onAppShortcutsTap != null}")
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP) {
|
||||
onAppShortcutsTap?.invoke()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Snippets button
|
||||
val snippetRect = if (isVertical) {
|
||||
RectF(gapPx, height - snippetBtnWidth - gapPx, width - gapPx, height - gapPx)
|
||||
|
|
@ -507,9 +742,40 @@ class QuickBarView(context: Context) : View(context) {
|
|||
else y < gripWidth
|
||||
}
|
||||
|
||||
/** Returns the screen-coordinate rect of the app shortcuts button, or null if not visible. */
|
||||
fun getShortcutsButtonScreenRect(): RectF? {
|
||||
if (!hasAppShortcuts) return null
|
||||
val cfg = config ?: return null
|
||||
val isVertical = cfg.orientation == QuickBarOrientation.VERTICAL
|
||||
val gripSpace = if (cfg.draggable) gripWidth else 0f
|
||||
val loc = IntArray(2)
|
||||
getLocationOnScreen(loc)
|
||||
val localRect = if (isVertical) {
|
||||
RectF(gapPx, gripSpace + gapPx, width - gapPx, gripSpace + gapPx + snippetBtnWidth)
|
||||
} else {
|
||||
RectF(gripSpace + gapPx, gapPx, gripSpace + gapPx + snippetBtnWidth, height - gapPx)
|
||||
}
|
||||
localRect.offset(loc[0].toFloat(), loc[1].toFloat())
|
||||
return localRect
|
||||
}
|
||||
|
||||
private fun findKeyAt(x: Float, y: Float): KeyDefinition? =
|
||||
allKeys.firstOrNull { it.bounds.contains(x, y) }
|
||||
|
||||
private fun getAppIcon(): Bitmap? {
|
||||
appIconBitmap?.let { return it }
|
||||
return try {
|
||||
val icon = context.packageManager.getApplicationIcon(context.applicationInfo)
|
||||
val size = dpToPx(32f).toInt()
|
||||
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bmp)
|
||||
icon.setBounds(0, 0, size, size)
|
||||
icon.draw(canvas)
|
||||
appIconBitmap = bmp
|
||||
bmp
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun dpToPx(dp: Float): Float =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics)
|
||||
|
||||
|
|
|
|||
|
|
@ -207,38 +207,51 @@
|
|||
"draggable": false,
|
||||
"height": 42,
|
||||
"keys": [
|
||||
{ "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_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_stab", "label": "⇤", "w": 0.8, "action": { "type": "esc_seq", "seq": "[Z" } },
|
||||
{ "id": "qb_vim", "label": "vim", "w": 1, "style": "menu", "action": { "type": "none" }, "menuItems": [
|
||||
{ "label": ":w", "action": { "type": "macro", "text": ":w\r" } },
|
||||
{ "label": ":q", "action": { "type": "macro", "text": ":q\r" } },
|
||||
{ "label": ":wq", "action": { "type": "macro", "text": ":wq\r" } },
|
||||
{ "label": ":q!", "action": { "type": "macro", "text": ":q!\r" } },
|
||||
{ "label": ":%s/", "action": { "type": "macro", "text": ":%s/" } },
|
||||
{ "label": "ciw", "action": { "type": "macro", "text": "ciw" } },
|
||||
{ "label": ">>", "action": { "type": "macro", "text": ">>" } },
|
||||
{ "label": "<<", "action": { "type": "macro", "text": "<<" } },
|
||||
{ "label": "redo", "action": { "type": "bytes", "value": [18] } }
|
||||
{ "id": "qb_ctrl", "label": "CTRL", "w": 1, "action": { "type": "toggle_mod", "mod": "CTRL" } },
|
||||
{ "id": "qb_esc", "label": "ESC", "w": 1, "action": { "type": "bytes", "value": [27] } },
|
||||
{ "id": "qb_tab", "label": "TAB", "labelIcon": "tab", "w": 0.8, "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_slash", "label": "/", "w": 0.7, "action": { "type": "char", "primary": "/" } },
|
||||
{ "id": "qb_left", "label": "◀", "labelIcon": "left", "w": 0.8, "action": { "type": "esc_seq", "seq": "[D" }, "repeatable": true },
|
||||
{ "id": "qb_right", "label": "▶", "labelIcon": "right", "w": 0.8, "action": { "type": "esc_seq", "seq": "[C" }, "repeatable": true },
|
||||
{ "id": "qb_up", "label": "▲", "labelIcon": "up", "w": 0.8, "action": { "type": "esc_seq", "seq": "[A" }, "repeatable": true },
|
||||
{ "id": "qb_down", "label": "▼", "labelIcon": "down", "w": 0.8, "action": { "type": "esc_seq", "seq": "[B" }, "repeatable": true },
|
||||
{ "id": "qb_stab", "label": "⇤", "labelIcon": "backtab", "w": 0.8, "action": { "type": "esc_seq", "seq": "[Z" } },
|
||||
{ "id": "qb_home", "label": "HOME", "w": 1, "action": { "type": "esc_seq", "seq": "[H" } },
|
||||
{ "id": "qb_end", "label": "END", "w": 1, "action": { "type": "esc_seq", "seq": "[F" } },
|
||||
{ "id": "qb_pgup", "label": "PGUP", "labelIcon": "pgup", "w": 1, "action": { "type": "esc_seq", "seq": "[5~" }, "repeatable": true },
|
||||
{ "id": "qb_pgdn", "label": "PGDN", "labelIcon": "pgdn", "w": 1, "action": { "type": "esc_seq", "seq": "[6~" }, "repeatable": true }
|
||||
],
|
||||
"infiniteScroll": false,
|
||||
"appShortcuts": [
|
||||
{ "id": "fkeys", "label": "F1-12", "menuItems": [
|
||||
{ "label": "F1", "action": { "type": "esc_seq", "seq": "OP" } },
|
||||
{ "label": "F2", "action": { "type": "esc_seq", "seq": "OQ" } },
|
||||
{ "label": "F3", "action": { "type": "esc_seq", "seq": "OR" } },
|
||||
{ "label": "F4", "action": { "type": "esc_seq", "seq": "OS" } },
|
||||
{ "label": "F5", "action": { "type": "esc_seq", "seq": "[15~" } },
|
||||
{ "label": "F6", "action": { "type": "esc_seq", "seq": "[17~" } },
|
||||
{ "label": "F7", "action": { "type": "esc_seq", "seq": "[18~" } },
|
||||
{ "label": "F8", "action": { "type": "esc_seq", "seq": "[19~" } },
|
||||
{ "label": "F9", "action": { "type": "esc_seq", "seq": "[20~" } },
|
||||
{ "label": "F10", "action": { "type": "esc_seq", "seq": "[21~" } },
|
||||
{ "label": "F11", "action": { "type": "esc_seq", "seq": "[23~" } },
|
||||
{ "label": "F12", "action": { "type": "esc_seq", "seq": "[24~" } }
|
||||
]},
|
||||
{ "id": "qb_nano", "label": "nano", "w": 1, "style": "menu", "action": { "type": "none" }, "menuItems": [
|
||||
{ "label": "save", "action": { "type": "bytes", "value": [15] } },
|
||||
{ "label": "exit", "action": { "type": "bytes", "value": [24] } },
|
||||
{ "label": "find", "action": { "type": "bytes", "value": [23] } },
|
||||
{ "label": "repl", "action": { "type": "bytes", "value": [28] } },
|
||||
{ "label": "cut", "action": { "type": "bytes", "value": [11] } },
|
||||
{ "label": "paste","action": { "type": "bytes", "value": [21] } },
|
||||
{ "label": "help", "action": { "type": "bytes", "value": [7] } },
|
||||
{ "label": "goto", "action": { "type": "bytes", "value": [20] } }
|
||||
{ "id": "screen", "label": "scr", "menuItems": [
|
||||
{ "label": "new", "action": { "type": "bytes", "value": [1, 99] } },
|
||||
{ "label": "nxt", "action": { "type": "bytes", "value": [1, 110] } },
|
||||
{ "label": "prv", "action": { "type": "bytes", "value": [1, 112] } },
|
||||
{ "label": "det", "action": { "type": "bytes", "value": [1, 100] } },
|
||||
{ "label": "sp─", "action": { "type": "bytes", "value": [1, 83] } },
|
||||
{ "label": "sp│", "action": { "type": "bytes", "value": [1, 124] } },
|
||||
{ "label": "cpy", "action": { "type": "bytes", "value": [1, 91] } },
|
||||
{ "label": "kill", "action": { "type": "bytes", "value": [1, 107] } },
|
||||
{ "label": "list", "action": { "type": "bytes", "value": [1, 34] } }
|
||||
]},
|
||||
{ "id": "qb_tmux", "label": "tmux", "w": 1, "style": "menu", "action": { "type": "none" }, "menuItems": [
|
||||
{ "id": "tmux", "label": "tmux", "menuItems": [
|
||||
{ "label": "new", "action": { "type": "bytes", "value": [2, 99] } },
|
||||
{ "label": "nxt", "action": { "type": "bytes", "value": [2, 110] } },
|
||||
{ "label": "prv", "action": { "type": "bytes", "value": [2, 112] } },
|
||||
|
|
@ -249,16 +262,26 @@
|
|||
{ "label": "zoom", "action": { "type": "bytes", "value": [2, 122] } },
|
||||
{ "label": "kill", "action": { "type": "bytes", "value": [2, 120] } }
|
||||
]},
|
||||
{ "id": "qb_screen","label": "scr", "w": 1, "style": "menu", "action": { "type": "none" }, "menuItems": [
|
||||
{ "label": "new", "action": { "type": "bytes", "value": [1, 99] } },
|
||||
{ "label": "nxt", "action": { "type": "bytes", "value": [1, 110] } },
|
||||
{ "label": "prv", "action": { "type": "bytes", "value": [1, 112] } },
|
||||
{ "label": "det", "action": { "type": "bytes", "value": [1, 100] } },
|
||||
{ "label": "sp─", "action": { "type": "bytes", "value": [1, 83] } },
|
||||
{ "label": "sp│", "action": { "type": "bytes", "value": [1, 124] } },
|
||||
{ "label": "cpy", "action": { "type": "bytes", "value": [1, 91] } },
|
||||
{ "label": "kill", "action": { "type": "bytes", "value": [1, 107] } },
|
||||
{ "label": "list", "action": { "type": "bytes", "value": [1, 34] } }
|
||||
{ "id": "nano", "label": "nano", "menuItems": [
|
||||
{ "label": "save", "action": { "type": "bytes", "value": [15] } },
|
||||
{ "label": "exit", "action": { "type": "bytes", "value": [24] } },
|
||||
{ "label": "find", "action": { "type": "bytes", "value": [23] } },
|
||||
{ "label": "repl", "action": { "type": "bytes", "value": [28] } },
|
||||
{ "label": "cut", "action": { "type": "bytes", "value": [11] } },
|
||||
{ "label": "paste","action": { "type": "bytes", "value": [21] } },
|
||||
{ "label": "help", "action": { "type": "bytes", "value": [7] } },
|
||||
{ "label": "goto", "action": { "type": "bytes", "value": [20] } }
|
||||
]},
|
||||
{ "id": "vim", "label": "vim", "menuItems": [
|
||||
{ "label": ":w", "action": { "type": "macro", "text": ":w\r" } },
|
||||
{ "label": ":q", "action": { "type": "macro", "text": ":q\r" } },
|
||||
{ "label": ":wq", "action": { "type": "macro", "text": ":wq\r" } },
|
||||
{ "label": ":q!", "action": { "type": "macro", "text": ":q!\r" } },
|
||||
{ "label": ":%s/", "action": { "type": "macro", "text": ":%s/" } },
|
||||
{ "label": "ciw", "action": { "type": "macro", "text": "ciw" } },
|
||||
{ "label": ">>", "action": { "type": "macro", "text": ">>" } },
|
||||
{ "label": "<<", "action": { "type": "macro", "text": "<<" } },
|
||||
{ "label": "redo", "action": { "type": "bytes", "value": [18] } }
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,16 +208,82 @@
|
|||
"draggable": false,
|
||||
"height": 42,
|
||||
"keys": [
|
||||
{ "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_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_stab", "label": "⇤", "w": 0.8, "action": { "type": "esc_seq", "seq": "[Z" } }
|
||||
{ "id": "qb_ctrl", "label": "CTRL", "w": 1, "action": { "type": "toggle_mod", "mod": "CTRL" } },
|
||||
{ "id": "qb_esc", "label": "ESC", "w": 1, "action": { "type": "bytes", "value": [27] } },
|
||||
{ "id": "qb_tab", "label": "TAB", "labelIcon": "tab", "w": 0.8, "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_slash", "label": "/", "w": 0.7, "action": { "type": "char", "primary": "/" } },
|
||||
{ "id": "qb_left", "label": "◀", "labelIcon": "left", "w": 0.8, "action": { "type": "esc_seq", "seq": "[D" }, "repeatable": true },
|
||||
{ "id": "qb_right", "label": "▶", "labelIcon": "right", "w": 0.8, "action": { "type": "esc_seq", "seq": "[C" }, "repeatable": true },
|
||||
{ "id": "qb_up", "label": "▲", "labelIcon": "up", "w": 0.8, "action": { "type": "esc_seq", "seq": "[A" }, "repeatable": true },
|
||||
{ "id": "qb_down", "label": "▼", "labelIcon": "down", "w": 0.8, "action": { "type": "esc_seq", "seq": "[B" }, "repeatable": true },
|
||||
{ "id": "qb_stab", "label": "⇤", "labelIcon": "backtab", "w": 0.8, "action": { "type": "esc_seq", "seq": "[Z" } },
|
||||
{ "id": "qb_home", "label": "HOME", "w": 1, "action": { "type": "esc_seq", "seq": "[H" } },
|
||||
{ "id": "qb_end", "label": "END", "w": 1, "action": { "type": "esc_seq", "seq": "[F" } },
|
||||
{ "id": "qb_pgup", "label": "PGUP", "labelIcon": "pgup", "w": 1, "action": { "type": "esc_seq", "seq": "[5~" }, "repeatable": true },
|
||||
{ "id": "qb_pgdn", "label": "PGDN", "labelIcon": "pgdn", "w": 1, "action": { "type": "esc_seq", "seq": "[6~" }, "repeatable": true }
|
||||
],
|
||||
"infiniteScroll": false,
|
||||
"appShortcuts": [
|
||||
{ "id": "fkeys", "label": "F1-12", "menuItems": [
|
||||
{ "label": "F1", "action": { "type": "esc_seq", "seq": "OP" } },
|
||||
{ "label": "F2", "action": { "type": "esc_seq", "seq": "OQ" } },
|
||||
{ "label": "F3", "action": { "type": "esc_seq", "seq": "OR" } },
|
||||
{ "label": "F4", "action": { "type": "esc_seq", "seq": "OS" } },
|
||||
{ "label": "F5", "action": { "type": "esc_seq", "seq": "[15~" } },
|
||||
{ "label": "F6", "action": { "type": "esc_seq", "seq": "[17~" } },
|
||||
{ "label": "F7", "action": { "type": "esc_seq", "seq": "[18~" } },
|
||||
{ "label": "F8", "action": { "type": "esc_seq", "seq": "[19~" } },
|
||||
{ "label": "F9", "action": { "type": "esc_seq", "seq": "[20~" } },
|
||||
{ "label": "F10", "action": { "type": "esc_seq", "seq": "[21~" } },
|
||||
{ "label": "F11", "action": { "type": "esc_seq", "seq": "[23~" } },
|
||||
{ "label": "F12", "action": { "type": "esc_seq", "seq": "[24~" } }
|
||||
]},
|
||||
{ "id": "screen", "label": "scr", "menuItems": [
|
||||
{ "label": "new", "action": { "type": "bytes", "value": [1, 99] } },
|
||||
{ "label": "nxt", "action": { "type": "bytes", "value": [1, 110] } },
|
||||
{ "label": "prv", "action": { "type": "bytes", "value": [1, 112] } },
|
||||
{ "label": "det", "action": { "type": "bytes", "value": [1, 100] } },
|
||||
{ "label": "sp─", "action": { "type": "bytes", "value": [1, 83] } },
|
||||
{ "label": "sp│", "action": { "type": "bytes", "value": [1, 124] } },
|
||||
{ "label": "cpy", "action": { "type": "bytes", "value": [1, 91] } },
|
||||
{ "label": "kill", "action": { "type": "bytes", "value": [1, 107] } },
|
||||
{ "label": "list", "action": { "type": "bytes", "value": [1, 34] } }
|
||||
]},
|
||||
{ "id": "tmux", "label": "tmux", "menuItems": [
|
||||
{ "label": "new", "action": { "type": "bytes", "value": [2, 99] } },
|
||||
{ "label": "nxt", "action": { "type": "bytes", "value": [2, 110] } },
|
||||
{ "label": "prv", "action": { "type": "bytes", "value": [2, 112] } },
|
||||
{ "label": "det", "action": { "type": "bytes", "value": [2, 100] } },
|
||||
{ "label": "sp│", "action": { "type": "bytes", "value": [2, 37] } },
|
||||
{ "label": "sp─", "action": { "type": "bytes", "value": [2, 34] } },
|
||||
{ "label": "scr", "action": { "type": "bytes", "value": [2, 91] } },
|
||||
{ "label": "zoom", "action": { "type": "bytes", "value": [2, 122] } },
|
||||
{ "label": "kill", "action": { "type": "bytes", "value": [2, 120] } }
|
||||
]},
|
||||
{ "id": "nano", "label": "nano", "menuItems": [
|
||||
{ "label": "save", "action": { "type": "bytes", "value": [15] } },
|
||||
{ "label": "exit", "action": { "type": "bytes", "value": [24] } },
|
||||
{ "label": "find", "action": { "type": "bytes", "value": [23] } },
|
||||
{ "label": "repl", "action": { "type": "bytes", "value": [28] } },
|
||||
{ "label": "cut", "action": { "type": "bytes", "value": [11] } },
|
||||
{ "label": "paste","action": { "type": "bytes", "value": [21] } },
|
||||
{ "label": "help", "action": { "type": "bytes", "value": [7] } },
|
||||
{ "label": "goto", "action": { "type": "bytes", "value": [20] } }
|
||||
]},
|
||||
{ "id": "vim", "label": "vim", "menuItems": [
|
||||
{ "label": ":w", "action": { "type": "macro", "text": ":w\r" } },
|
||||
{ "label": ":q", "action": { "type": "macro", "text": ":q\r" } },
|
||||
{ "label": ":wq", "action": { "type": "macro", "text": ":wq\r" } },
|
||||
{ "label": ":q!", "action": { "type": "macro", "text": ":q!\r" } },
|
||||
{ "label": ":%s/", "action": { "type": "macro", "text": ":%s/" } },
|
||||
{ "label": "ciw", "action": { "type": "macro", "text": "ciw" } },
|
||||
{ "label": ">>", "action": { "type": "macro", "text": ">>" } },
|
||||
{ "label": "<<", "action": { "type": "macro", "text": "<<" } },
|
||||
{ "label": "redo", "action": { "type": "bytes", "value": [18] } }
|
||||
]}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,16 +210,82 @@
|
|||
"draggable": false,
|
||||
"height": 42,
|
||||
"keys": [
|
||||
{ "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_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_stab", "label": "⇤", "w": 0.8, "action": { "type": "esc_seq", "seq": "[Z" } }
|
||||
{ "id": "qb_ctrl", "label": "CTRL", "w": 1, "action": { "type": "toggle_mod", "mod": "CTRL" } },
|
||||
{ "id": "qb_esc", "label": "ESC", "w": 1, "action": { "type": "bytes", "value": [27] } },
|
||||
{ "id": "qb_tab", "label": "TAB", "labelIcon": "tab", "w": 0.8, "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_slash", "label": "/", "w": 0.7, "action": { "type": "char", "primary": "/" } },
|
||||
{ "id": "qb_left", "label": "◀", "labelIcon": "left", "w": 0.8, "action": { "type": "esc_seq", "seq": "[D" }, "repeatable": true },
|
||||
{ "id": "qb_right", "label": "▶", "labelIcon": "right", "w": 0.8, "action": { "type": "esc_seq", "seq": "[C" }, "repeatable": true },
|
||||
{ "id": "qb_up", "label": "▲", "labelIcon": "up", "w": 0.8, "action": { "type": "esc_seq", "seq": "[A" }, "repeatable": true },
|
||||
{ "id": "qb_down", "label": "▼", "labelIcon": "down", "w": 0.8, "action": { "type": "esc_seq", "seq": "[B" }, "repeatable": true },
|
||||
{ "id": "qb_stab", "label": "⇤", "labelIcon": "backtab", "w": 0.8, "action": { "type": "esc_seq", "seq": "[Z" } },
|
||||
{ "id": "qb_home", "label": "HOME", "w": 1, "action": { "type": "esc_seq", "seq": "[H" } },
|
||||
{ "id": "qb_end", "label": "END", "w": 1, "action": { "type": "esc_seq", "seq": "[F" } },
|
||||
{ "id": "qb_pgup", "label": "PGUP", "labelIcon": "pgup", "w": 1, "action": { "type": "esc_seq", "seq": "[5~" }, "repeatable": true },
|
||||
{ "id": "qb_pgdn", "label": "PGDN", "labelIcon": "pgdn", "w": 1, "action": { "type": "esc_seq", "seq": "[6~" }, "repeatable": true }
|
||||
],
|
||||
"infiniteScroll": false,
|
||||
"appShortcuts": [
|
||||
{ "id": "fkeys", "label": "F1-12", "menuItems": [
|
||||
{ "label": "F1", "action": { "type": "esc_seq", "seq": "OP" } },
|
||||
{ "label": "F2", "action": { "type": "esc_seq", "seq": "OQ" } },
|
||||
{ "label": "F3", "action": { "type": "esc_seq", "seq": "OR" } },
|
||||
{ "label": "F4", "action": { "type": "esc_seq", "seq": "OS" } },
|
||||
{ "label": "F5", "action": { "type": "esc_seq", "seq": "[15~" } },
|
||||
{ "label": "F6", "action": { "type": "esc_seq", "seq": "[17~" } },
|
||||
{ "label": "F7", "action": { "type": "esc_seq", "seq": "[18~" } },
|
||||
{ "label": "F8", "action": { "type": "esc_seq", "seq": "[19~" } },
|
||||
{ "label": "F9", "action": { "type": "esc_seq", "seq": "[20~" } },
|
||||
{ "label": "F10", "action": { "type": "esc_seq", "seq": "[21~" } },
|
||||
{ "label": "F11", "action": { "type": "esc_seq", "seq": "[23~" } },
|
||||
{ "label": "F12", "action": { "type": "esc_seq", "seq": "[24~" } }
|
||||
]},
|
||||
{ "id": "screen", "label": "scr", "menuItems": [
|
||||
{ "label": "new", "action": { "type": "bytes", "value": [1, 99] } },
|
||||
{ "label": "nxt", "action": { "type": "bytes", "value": [1, 110] } },
|
||||
{ "label": "prv", "action": { "type": "bytes", "value": [1, 112] } },
|
||||
{ "label": "det", "action": { "type": "bytes", "value": [1, 100] } },
|
||||
{ "label": "sp─", "action": { "type": "bytes", "value": [1, 83] } },
|
||||
{ "label": "sp│", "action": { "type": "bytes", "value": [1, 124] } },
|
||||
{ "label": "cpy", "action": { "type": "bytes", "value": [1, 91] } },
|
||||
{ "label": "kill", "action": { "type": "bytes", "value": [1, 107] } },
|
||||
{ "label": "list", "action": { "type": "bytes", "value": [1, 34] } }
|
||||
]},
|
||||
{ "id": "tmux", "label": "tmux", "menuItems": [
|
||||
{ "label": "new", "action": { "type": "bytes", "value": [2, 99] } },
|
||||
{ "label": "nxt", "action": { "type": "bytes", "value": [2, 110] } },
|
||||
{ "label": "prv", "action": { "type": "bytes", "value": [2, 112] } },
|
||||
{ "label": "det", "action": { "type": "bytes", "value": [2, 100] } },
|
||||
{ "label": "sp│", "action": { "type": "bytes", "value": [2, 37] } },
|
||||
{ "label": "sp─", "action": { "type": "bytes", "value": [2, 34] } },
|
||||
{ "label": "scr", "action": { "type": "bytes", "value": [2, 91] } },
|
||||
{ "label": "zoom", "action": { "type": "bytes", "value": [2, 122] } },
|
||||
{ "label": "kill", "action": { "type": "bytes", "value": [2, 120] } }
|
||||
]},
|
||||
{ "id": "nano", "label": "nano", "menuItems": [
|
||||
{ "label": "save", "action": { "type": "bytes", "value": [15] } },
|
||||
{ "label": "exit", "action": { "type": "bytes", "value": [24] } },
|
||||
{ "label": "find", "action": { "type": "bytes", "value": [23] } },
|
||||
{ "label": "repl", "action": { "type": "bytes", "value": [28] } },
|
||||
{ "label": "cut", "action": { "type": "bytes", "value": [11] } },
|
||||
{ "label": "paste","action": { "type": "bytes", "value": [21] } },
|
||||
{ "label": "help", "action": { "type": "bytes", "value": [7] } },
|
||||
{ "label": "goto", "action": { "type": "bytes", "value": [20] } }
|
||||
]},
|
||||
{ "id": "vim", "label": "vim", "menuItems": [
|
||||
{ "label": ":w", "action": { "type": "macro", "text": ":w\r" } },
|
||||
{ "label": ":q", "action": { "type": "macro", "text": ":q\r" } },
|
||||
{ "label": ":wq", "action": { "type": "macro", "text": ":wq\r" } },
|
||||
{ "label": ":q!", "action": { "type": "macro", "text": ":q!\r" } },
|
||||
{ "label": ":%s/", "action": { "type": "macro", "text": ":%s/" } },
|
||||
{ "label": "ciw", "action": { "type": "macro", "text": "ciw" } },
|
||||
{ "label": ">>", "action": { "type": "macro", "text": ">>" } },
|
||||
{ "label": "<<", "action": { "type": "macro", "text": "<<" } },
|
||||
{ "label": "redo", "action": { "type": "bytes", "value": [18] } }
|
||||
]}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -876,11 +876,36 @@ class ScreenBuffer(
|
|||
newBuf.cursorVisible = cursorVisible
|
||||
|
||||
if (usingAltBuffer) {
|
||||
// Don't reflow alt buffer — just clamp cursor and let server redraw
|
||||
newBuf.switchToAltBuffer(false)
|
||||
newBuf.cursorRow = cursorRow.coerceIn(0, newRows - 1)
|
||||
newBuf.cursorCol = cursorCol.coerceIn(0, newCols - 1)
|
||||
return newBuf
|
||||
// Reflow the MAIN screen into the new buffer first, so it's preserved
|
||||
// when vim/htop exits and restores the main buffer.
|
||||
// The main screen is in 'screen' (not altScreen) + 'history'.
|
||||
// We temporarily pretend we're not in alt mode to reflow main content.
|
||||
val savedAlt = usingAltBuffer
|
||||
usingAltBuffer = false
|
||||
val mainCurRow = mainSavedCursorRow
|
||||
val mainCurCol = mainSavedCursorCol
|
||||
cursorRow = mainCurRow
|
||||
cursorCol = mainCurCol
|
||||
val reflowed = reflowResize(newRows, newCols)
|
||||
// Restore our alt state
|
||||
usingAltBuffer = savedAlt
|
||||
cursorRow = cursorRow // will be overwritten below
|
||||
|
||||
// Now switch the reflowed buffer to alt mode
|
||||
// Copy saved main cursor from the reflowed result
|
||||
reflowed.mainSavedCursorRow = reflowed.cursorRow
|
||||
reflowed.mainSavedCursorCol = reflowed.cursorCol
|
||||
reflowed.mainSavedAttr = mainSavedAttr
|
||||
reflowed.mainDecscRow = mainDecscRow
|
||||
reflowed.mainDecscCol = mainDecscCol
|
||||
reflowed.mainDecscAttr = mainDecscAttr
|
||||
|
||||
// Switch to alt buffer (creates fresh alt screen, server will redraw)
|
||||
reflowed.switchToAltBuffer(false)
|
||||
// Restore the alt buffer cursor position
|
||||
reflowed.cursorRow = cursorRow.coerceIn(0, newRows - 1)
|
||||
reflowed.cursorCol = cursorCol.coerceIn(0, newCols - 1)
|
||||
return reflowed
|
||||
}
|
||||
|
||||
// 1. Collect all rows: history + screen
|
||||
|
|
|
|||
176
scripts/tests/06_shortcuts_popup.py
Normal file
176
scripts/tests/06_shortcuts_popup.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test: App shortcuts popup (W button) — open, navigate, fire shortcut, dismiss."""
|
||||
|
||||
import sys, time
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "test_lib"))
|
||||
from common import TestRunner
|
||||
|
||||
t = TestRunner()
|
||||
t.begin("shortcuts_popup")
|
||||
|
||||
# Zebra TC21 positions (720x1280 screen, 2x density)
|
||||
# QB with AKB: [0,610]-[720,694] → W icon center: (40, 650)
|
||||
# QB without AKB: [0,1100]-[720,1184] → W icon center: (40, 1140)
|
||||
# Terminal center: (360, 400)
|
||||
W_BTN_WITH_AKB = (40, 650)
|
||||
W_BTN_NO_AKB = (40, 1140)
|
||||
TERMINAL_CENTER = (360, 400)
|
||||
# App list items when popup opens UP from QB at y=1100 (no AKB):
|
||||
APP_VIM = (80, 795)
|
||||
APP_NANO = (80, 880)
|
||||
APP_TMUX = (80, 965)
|
||||
APP_SCR = (80, 1050)
|
||||
# Vim key map grid (3 columns × 3 rows + back button):
|
||||
# Back button "← vim": full width, ~y=770
|
||||
# Row 1: :w(60,850) :q(200,850) :wq(340,850)
|
||||
# Row 2: :q!(60,935) :%s/(200,935) ciw(340,935)
|
||||
# Row 3: >>(60,1020) <<(200,1020) redo(340,1020)
|
||||
KEYMAP_BACK = (200, 770)
|
||||
KEYMAP_W = (60, 850)
|
||||
KEYMAP_Q = (200, 850)
|
||||
KEYMAP_WQ = (340, 850)
|
||||
|
||||
def tap(xy, delay=0.5):
|
||||
t.adb_shell(f"input tap {xy[0]} {xy[1]}")
|
||||
time.sleep(delay)
|
||||
|
||||
# Clean start
|
||||
t.force_stop()
|
||||
t.adb_shell(f"rm -f {t.LOG_PATH}")
|
||||
time.sleep(0.5)
|
||||
|
||||
t.launch_profile("SSHTest", clear_log=False)
|
||||
t.wait_for_log("Connected visible=true", timeout=20)
|
||||
t.set_font_size(7)
|
||||
time.sleep(1)
|
||||
|
||||
# === Test 1: Open popup without AKB ===
|
||||
print(" Test 1: W button without AKB")
|
||||
tap(W_BTN_NO_AKB, 1)
|
||||
|
||||
img = t.screenshot("popup_no_akb")
|
||||
t.verify("Popup shows without AKB", img,
|
||||
"Is there a vertical popup showing app names (vim, nano, tmux, scr) "
|
||||
"above the Quick Bar? The system keyboard should NOT be visible.")
|
||||
|
||||
# Tap outside to dismiss
|
||||
tap(TERMINAL_CENTER, 1)
|
||||
|
||||
img = t.screenshot("dismissed_tap_outside")
|
||||
t.verify("Popup dismissed by tap outside", img,
|
||||
"Is the popup gone? Should show just the terminal with shell prompt, no popup overlay.")
|
||||
|
||||
# === Test 2: Open popup with AKB ===
|
||||
print(" Test 2: W button with AKB")
|
||||
tap(TERMINAL_CENTER, 1) # open AKB
|
||||
tap(W_BTN_WITH_AKB, 1)
|
||||
|
||||
img = t.screenshot("popup_with_akb")
|
||||
t.verify("Popup shows with AKB open", img,
|
||||
"Is the vertical popup showing (vim, nano, tmux, scr)? "
|
||||
"The system keyboard should still be visible below.")
|
||||
|
||||
# === Test 2b: AKB stays open when popup opens ===
|
||||
print(" Test 2b: AKB stays open with popup")
|
||||
tap(TERMINAL_CENTER, 1) # tap terminal to show AKB
|
||||
time.sleep(0.5)
|
||||
tap(W_BTN_WITH_AKB, 1)
|
||||
img = t.screenshot("popup_akb_stays")
|
||||
t.verify("Popup opens AND AKB stays open", img,
|
||||
"Are BOTH the shortcuts popup (vim/nano/tmux/scr) AND the system keyboard (QWERTY) "
|
||||
"visible at the same time?")
|
||||
|
||||
# === Test 2c: Single BACK dismisses both AKB and popup ===
|
||||
print(" Test 2c: Single BACK dismisses both")
|
||||
t.adb_shell("input keyevent KEYCODE_BACK")
|
||||
time.sleep(1)
|
||||
img = t.screenshot("back_both_closed")
|
||||
t.verify("One BACK closed both AKB and popup", img,
|
||||
"Are BOTH the popup AND the system keyboard gone? Should show the terminal with "
|
||||
"shell prompt, Quick Bar at bottom, no popup overlay, no keyboard.")
|
||||
|
||||
# === Test 3: BACK dismisses popup (no AKB), stays on terminal ===
|
||||
print(" Test 3: BACK dismisses popup (no AKB)")
|
||||
tap(W_BTN_NO_AKB, 1) # open popup without AKB
|
||||
t.adb_shell("input keyevent KEYCODE_BACK")
|
||||
time.sleep(1)
|
||||
|
||||
img = t.screenshot("after_back")
|
||||
t.verify("BACK dismissed popup, stayed on terminal", img,
|
||||
"Is the popup gone AND we're still on the terminal screen (not the connection list)? "
|
||||
"The tab bar should show 'SSHTest'.")
|
||||
|
||||
# === Test 4: Open popup, tap vim, see key map grid ===
|
||||
print(" Test 4: Vim key map")
|
||||
tap(W_BTN_NO_AKB, 1)
|
||||
# Tap "vim" — first item in the popup, approximately (80, popup_y)
|
||||
# Popup opens UP from QB. With QB at y=1100, popup top is ~y=920
|
||||
# vim is the first (top) item: ~(80, 940)
|
||||
# Actually need to calculate: 4 items × 40dp height + gaps ≈ 180px
|
||||
# Popup bottom at ~1100, top at ~920. vim at top: ~(80, 940)
|
||||
tap(APP_VIM, 1)
|
||||
|
||||
img = t.screenshot("vim_keymap")
|
||||
t.verify("Vim key map grid", img,
|
||||
"Is there a grid showing vim shortcuts like :w, :q, :wq, :q!, :%s/, ciw, >>, <<, redo? "
|
||||
"There should be a '← vim' back button at the top of the grid.")
|
||||
|
||||
# === Test 5: Tap a shortcut — :w ===
|
||||
print(" Test 5: Fire :w shortcut")
|
||||
# First open vim so the shortcut has effect
|
||||
tap(TERMINAL_CENTER, 0.5) # dismiss popup first
|
||||
time.sleep(0.5)
|
||||
t.send_text("vim /tmp/shortcut_test.txt")
|
||||
t.send_enter()
|
||||
time.sleep(2)
|
||||
t.send_text("itest content")
|
||||
t.send_esc("")
|
||||
time.sleep(0.5)
|
||||
|
||||
# Now open popup → vim → :w
|
||||
tap(W_BTN_NO_AKB, 1)
|
||||
tap(APP_VIM, 1) # vim key map
|
||||
|
||||
img = t.screenshot("vim_keymap_in_vim")
|
||||
# Tap :w shortcut
|
||||
tap(KEYMAP_W, 1)
|
||||
|
||||
img = t.screenshot("after_w_shortcut")
|
||||
t.verify("Shortcut fired, popup dismissed", img,
|
||||
"Is the popup dismissed and vim showing? The :w command should have been sent. "
|
||||
"Look for a save confirmation or the file being written at the bottom of vim.")
|
||||
|
||||
# Clean up — :q! to exit vim
|
||||
t.send_esc("")
|
||||
time.sleep(0.3)
|
||||
t.send_text(":q!")
|
||||
t.send_enter()
|
||||
time.sleep(1)
|
||||
t.send_text("rm -f /tmp/shortcut_test.txt")
|
||||
t.send_enter()
|
||||
time.sleep(0.5)
|
||||
|
||||
# === Test 6: Back button in key map returns to app list ===
|
||||
print(" Test 6: Back button in key map")
|
||||
tap(W_BTN_NO_AKB, 1)
|
||||
tap(APP_VIM, 1) # vim key map
|
||||
|
||||
img = t.screenshot("keymap_before_back")
|
||||
t.verify("Key map showing", img,
|
||||
"Is the vim key map grid visible with shortcuts?")
|
||||
|
||||
# Tap "← vim" back button
|
||||
tap(KEYMAP_BACK, 1)
|
||||
|
||||
img = t.screenshot("back_to_app_list")
|
||||
t.verify("Back to app list", img,
|
||||
"Does it show the app list again (vim, nano, tmux, scr) instead of the key map grid?")
|
||||
|
||||
# Dismiss
|
||||
tap(TERMINAL_CENTER, 1)
|
||||
|
||||
t.assert_log("Session still connected", "state: Connected")
|
||||
t.pull_log()
|
||||
ok = t.summary()
|
||||
sys.exit(0 if ok else 1)
|
||||
Loading…
Add table
Add a link
Reference in a new issue