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:
jima 2026-04-03 18:53:54 +02:00
parent 2417b442f1
commit 256d059d51
15 changed files with 1312 additions and 133 deletions

View file

@ -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"
}

View file

@ -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)
)
}

View file

@ -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()

View file

@ -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) }
)
}
}
}
}

View file

@ -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.

View file

@ -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

View file

@ -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 }

View file

@ -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

View file

@ -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)
}

View file

@ -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)

View file

@ -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] } }
]}
]
}

View file

@ -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] } }
]}
]
}
}

View file

@ -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] } }
]}
]
}
}

View file

@ -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

View 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)