QB menu keys: vim, nano, tmux, screen combo popups

Add menu key system to Quick Bar — tap a menu key to show a popup strip
with common combos. Each item fires the right byte sequence on tap.

- MenuItem model + menuItems on KeyDefinition, parsed from JSON
- LongPressPopupView: menu mode with auto-sized cells, gaps, borders
- QuickBarView: onMenuKeyTap callback routes menu keys
- TerminalKeyboard: full-screen overlay captures tap on popup item
- New "menu" style in all 4 keyboard themes (purple accent)
- vim: :w, :q, :wq, :q!, :%s/, ciw, >>, <<, redo
- nano: save, exit, find, repl, cut, paste, help, goto
- tmux: new, nxt, prv, det, sp│, sp─, scr, zoom, kill
- screen: new, nxt, prv, det, sp─, sp│, cpy, kill, list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-02 02:01:00 +02:00
parent 678295b03b
commit 8f957ae0e7
7 changed files with 305 additions and 12 deletions

View file

@ -68,6 +68,11 @@ class TerminalKeyboard private constructor(
private var modLongPressRunnable: Runnable? = null
private var modStateJob: Job? = null
// Menu popup state
private var menuPopupActive = false
private var menuPopupItems: List<MenuItem>? = null
private var menuOverlay: View? = null
// Double-tap state (shared between keyboard pages and quick bar)
private var lastTapKeyId: String? = null
private var lastTapTime: Long = 0
@ -187,6 +192,7 @@ class TerminalKeyboard private constructor(
recordTap(key)
}
qb.onSnippetsTap = { onSnippetsTap?.invoke() }
qb.onMenuKeyTap = { key -> showMenuPopup(key, qb) }
qb.onKeyDown = { key ->
hapticFeedback(qb)
if (layout.settings.showHints) {
@ -236,6 +242,7 @@ class TerminalKeyboard private constructor(
fun detach() {
modStateJob?.cancel()
keyRepeatHandler.destroy()
dismissMenuPopup()
keyboardView?.let { (it.parent as? ViewGroup)?.removeView(it) }
quickBarView?.let { (it.parent as? ViewGroup)?.removeView(it) }
hintPopup?.let { (it.parent as? ViewGroup)?.removeView(it) }
@ -454,6 +461,92 @@ class TerminalKeyboard private constructor(
keyboardView?.setSwipeEnabled(true) // re-enable page swiping
}
@SuppressLint("ClickableViewAccessibility")
private fun showMenuPopup(key: KeyDefinition, qbView: QuickBarView) {
val items = key.menuItems ?: return
val popup = longPressPopup ?: return
val rootContainer = container ?: return
hapticFeedback(qbView)
menuPopupActive = true
menuPopupItems = items
popup.theme = theme
popup.showMenu(items.map { it.label })
popup.visibility = View.VISIBLE
// Position above the key
popup.post {
val keyBounds = key.bounds
val qbLocation = IntArray(2)
qbView.getLocationInWindow(qbLocation)
val containerLocation = IntArray(2)
(popup.parent as? View)?.getLocationInWindow(containerLocation)
val keyScreenX = qbLocation[0] + keyBounds.centerX()
val keyScreenY = qbLocation[1] + keyBounds.top
val containerWidth = (popup.parent as? View)?.width ?: popup.width
popup.x = (keyScreenX - containerLocation[0] - popup.width / 2f)
.coerceIn(0f, (containerWidth - popup.width).toFloat().coerceAtLeast(0f))
popup.y = keyScreenY - containerLocation[1] - popup.height - 8f
}
// Transparent overlay on the root DecorView to capture taps anywhere on screen
val activity = context as? android.app.Activity
val rootView = activity?.window?.decorView as? ViewGroup ?: rootContainer
val overlay = View(context)
overlay.setOnTouchListener { _, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
val popupLoc = IntArray(2)
popup.getLocationOnScreen(popupLoc)
val localX = event.rawX - popupLoc[0]
val localY = event.rawY - popupLoc[1]
if (localX >= 0 && localX <= popup.width && localY >= 0 && localY <= popup.height) {
val idx = popup.hitTestVariant(localX)
if (idx >= 0) popup.selectedIndex = idx
}
}
MotionEvent.ACTION_UP -> {
val popupLoc = IntArray(2)
popup.getLocationOnScreen(popupLoc)
val localX = event.rawX - popupLoc[0]
val localY = event.rawY - popupLoc[1]
if (localX >= 0 && localX <= popup.width && localY >= 0 && localY <= popup.height) {
val idx = popup.hitTestVariant(localX)
if (idx >= 0 && idx < items.size) {
hapticFeedback(popup)
val menuItem = items[idx]
val fakeKey = KeyDefinition(
id = "menu_${idx}",
label = menuItem.label,
action = menuItem.action
)
handleKeyAction(fakeKey)
}
}
dismissMenuPopup()
return@setOnTouchListener true
}
}
true
}
rootView.addView(overlay, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
))
menuOverlay = overlay
}
private fun dismissMenuPopup() {
longPressPopup?.visibility = View.GONE
menuPopupActive = false
menuPopupItems = null
menuOverlay?.let { (it.parent as? ViewGroup)?.removeView(it) }
menuOverlay = null
}
private fun showHintPopup(key: KeyDefinition, pageView: KeyboardPageView) {
val hint = hintPopup ?: return
hint.theme = theme

View file

@ -2,6 +2,12 @@ package com.roundingmobile.keyboard.model
import android.graphics.RectF
/** A labeled action shown in a menu popup (vim/tmux/screen combo keys). */
data class MenuItem(
val label: String,
val action: KeyAction
)
data class KeyDefinition(
val id: String,
val label: String,
@ -13,6 +19,7 @@ data class KeyDefinition(
val style: String? = null,
val repeatable: Boolean = false,
val longPress: List<String>? = null,
val menuItems: List<MenuItem>? = null,
val visible: Boolean = true
) {
/** Computed pixel bounds after layout pass — used for hit testing and drawing */

View file

@ -93,6 +93,7 @@ object LayoutParser {
style = obj.optString("style", null),
repeatable = obj.optBoolean("repeatable", false),
longPress = parseStringList(obj.optJSONArray("longPress")),
menuItems = parseMenuItems(obj.optJSONArray("menuItems")),
visible = obj.optBoolean("visible", true)
)
}
@ -159,6 +160,18 @@ object LayoutParser {
fullKeyboardScrollStyle = obj.optString("fullKeyboardScrollStyle", "viewpager")
)
private fun parseMenuItems(arr: JSONArray?): List<MenuItem>? {
arr ?: return null
if (arr.length() == 0) return null
return (0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
MenuItem(
label = obj.getString("label"),
action = parseAction(obj.getJSONObject("action"))
)
}
}
private fun parseStringList(arr: JSONArray?): List<String>? {
arr ?: return null
return (0 until arr.length()).map { arr.getString(it) }

View file

@ -81,6 +81,12 @@ object BuiltInThemes {
keyBg = Color.parseColor("#3A3A3D"),
keyBgPressed = Color.parseColor("#4A4A4F"),
keyText = Color.parseColor("#B0B0B3")
),
"menu" to KeyStyleOverride(
keyBg = Color.parseColor("#2A1A3A"),
keyBgPressed = Color.parseColor("#3A2A4A"),
keyBorder = Color.parseColor("#6A4A8A"),
keyText = Color.parseColor("#C8A0E8")
)
)
)
@ -124,6 +130,12 @@ object BuiltInThemes {
keyBg = Color.parseColor("#E8E8EB"),
keyBgPressed = Color.parseColor("#D8D8DB"),
keyText = Color.parseColor("#666666")
),
"menu" to KeyStyleOverride(
keyBg = Color.parseColor("#E8DDFF"),
keyBgPressed = Color.parseColor("#D8CCEE"),
keyBorder = Color.parseColor("#9977CC"),
keyText = Color.parseColor("#6633AA")
)
)
)
@ -162,6 +174,12 @@ object BuiltInThemes {
keyBg = Color.parseColor("#3A3A30"),
keyBgPressed = Color.parseColor("#4A4A40"),
keyText = Color.parseColor("#A6A68A")
),
"menu" to KeyStyleOverride(
keyBg = Color.parseColor("#3A2A22"),
keyBgPressed = Color.parseColor("#4A3A32"),
keyBorder = Color.parseColor("#8A5A3A"),
keyText = Color.parseColor("#E6A060")
)
)
)
@ -200,6 +218,12 @@ object BuiltInThemes {
keyBg = Color.parseColor("#0A3A40"),
keyBgPressed = Color.parseColor("#1A4A50"),
keyText = Color.parseColor("#657B83")
),
"menu" to KeyStyleOverride(
keyBg = Color.parseColor("#1A2A36"),
keyBgPressed = Color.parseColor("#2A3A46"),
keyBorder = Color.parseColor("#6C71C4"),
keyText = Color.parseColor("#B58EDB")
)
)
)

View file

@ -7,18 +7,31 @@ import android.view.View
import com.roundingmobile.keyboard.theme.KeyboardTheme
/**
* Popup that shows accent/variant characters when a key is long-pressed.
* Popup that shows accent/variant characters when a key is long-pressed,
* or menu items when a menu key is tapped.
* Drawn above the key, user slides finger to select a variant.
*/
class LongPressPopupView(context: Context) : View(context) {
var variants: List<String> = emptyList()
set(value) { field = value; selectedIndex = 0; requestLayout(); invalidate() }
set(value) { field = value; selectedIndex = 0; _menuMode = false; requestLayout(); invalidate() }
var theme: KeyboardTheme? = null
var selectedIndex: Int = 0
set(value) { field = value; invalidate() }
/** Menu mode — wider cells, text-sized widths */
private var _menuMode = false
val isMenuMode: Boolean get() = _menuMode
/** Show menu-style labels (wider cells auto-sized to text) */
fun showMenu(labels: List<String>) {
variants = labels // triggers setter (resets _menuMode to false)
_menuMode = true // override back — layout/draw are async, this takes effect first
requestLayout()
invalidate()
}
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val selectedPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
@ -31,33 +44,116 @@ class LongPressPopupView(context: Context) : View(context) {
}
private val defaultCellWidth = dpToPx(46f)
private val menuMinCellWidth = dpToPx(38f)
private val menuPadding = dpToPx(14f)
private val menuGap = dpToPx(2f)
private val cellHeight = dpToPx(48f)
private val radius = dpToPx(6f)
private val menuCellRadius = dpToPx(5f)
private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 1f
}
/** Effective cell width — shrinks when too many variants to fit the screen */
private var cellWidth = defaultCellWidth
/** Per-cell widths for menu mode (text-sized) */
private var menuCellWidths: FloatArray = FloatArray(0)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val screenWidth = resources.displayMetrics.widthPixels.toFloat()
val maxPopupWidth = screenWidth - dpToPx(8f) // 4dp margin each side
val idealWidth = defaultCellWidth * variants.size
cellWidth = if (idealWidth > maxPopupWidth && variants.isNotEmpty()) {
maxPopupWidth / variants.size
if (_menuMode && variants.isNotEmpty()) {
// Measure each label and compute per-cell widths
textPaint.textSize = spToPx(14f)
menuCellWidths = FloatArray(variants.size) { i ->
(textPaint.measureText(variants[i]) + menuPadding).coerceAtLeast(menuMinCellWidth)
}
val gapsTotal = menuGap * (variants.size - 1)
val totalW = menuCellWidths.sum() + gapsTotal
// Shrink proportionally if too wide
if (totalW > maxPopupWidth) {
val availForCells = maxPopupWidth - gapsTotal
val cellsTotal = menuCellWidths.sum()
val scale = availForCells / cellsTotal
for (i in menuCellWidths.indices) menuCellWidths[i] *= scale
}
val w = (menuCellWidths.sum() + gapsTotal + dpToPx(4f)).toInt()
val h = (cellHeight + dpToPx(4f)).toInt()
setMeasuredDimension(w, h)
} else {
defaultCellWidth
val idealWidth = defaultCellWidth * variants.size
cellWidth = if (idealWidth > maxPopupWidth && variants.isNotEmpty()) {
maxPopupWidth / variants.size
} else {
defaultCellWidth
}
val w = (cellWidth * variants.size + dpToPx(4f)).toInt()
val h = (cellHeight + dpToPx(4f)).toInt()
setMeasuredDimension(w, h)
}
val w = (cellWidth * variants.size + dpToPx(4f)).toInt()
val h = (cellHeight + dpToPx(4f)).toInt()
setMeasuredDimension(w, h)
}
override fun onDraw(canvas: Canvas) {
if (variants.isEmpty()) return
val th = theme ?: return
val startY = dpToPx(2f)
if (_menuMode) {
drawMenu(canvas, th, startY)
} else {
drawVariants(canvas, th, startY)
}
}
private fun drawMenu(canvas: Canvas, th: KeyboardTheme, startY: Float) {
val gapsTotal = menuGap * (variants.size - 1)
val totalW = menuCellWidths.sum() + gapsTotal
val startX = (width - totalW) / 2f
// Shadow behind entire strip
canvas.drawRoundRect(startX - 2f, startY - 2f, startX + totalW + 2f, startY + cellHeight + 2f, radius, radius, shadowPaint)
// Background strip
bgPaint.color = th.keyboardBg
canvas.drawRoundRect(startX, startY, startX + totalW, startY + cellHeight, radius, radius, bgPaint)
textPaint.textSize = spToPx(14f)
val cellInset = dpToPx(1.5f)
var x = startX
for (i in variants.indices) {
val cw = menuCellWidths[i]
val cellRect = RectF(x + cellInset, startY + cellInset, x + cw - cellInset, startY + cellHeight - cellInset)
// Each cell — regular key colors, highlighted when selected
if (i == selectedIndex) {
selectedPaint.color = th.keyBgPressed
canvas.drawRoundRect(cellRect, menuCellRadius, menuCellRadius, selectedPaint)
} else {
bgPaint.color = th.keyBg
canvas.drawRoundRect(cellRect, menuCellRadius, menuCellRadius, bgPaint)
}
// Subtle border
dividerPaint.color = th.keyBorder
dividerPaint.style = Paint.Style.STROKE
canvas.drawRoundRect(cellRect, menuCellRadius, menuCellRadius, dividerPaint)
// Text
textPaint.color = th.keyText
textPaint.isFakeBoldText = i == selectedIndex
canvas.drawText(variants[i], cellRect.centerX(), cellRect.centerY() + textPaint.textSize / 3f, textPaint)
x += cw + menuGap
}
}
private fun drawVariants(canvas: Canvas, th: KeyboardTheme, startY: Float) {
val totalW = cellWidth * variants.size
val startX = (width - totalW) / 2f
val startY = dpToPx(2f)
// Shadow
canvas.drawRoundRect(startX - 2f, startY - 2f, startX + totalW + 2f, startY + cellHeight + 2f, radius, radius, shadowPaint)
@ -91,6 +187,17 @@ class LongPressPopupView(context: Context) : View(context) {
*/
fun hitTestVariant(localX: Float): Int {
if (variants.isEmpty()) return -1
if (_menuMode) {
val gapsTotal = menuGap * (variants.size - 1)
val totalW = menuCellWidths.sum() + gapsTotal
val startX = (width - totalW) / 2f
var x = startX
for (i in menuCellWidths.indices) {
if (localX < x + menuCellWidths[i]) return i
x += menuCellWidths[i] + menuGap
}
return variants.size - 1
}
val totalW = cellWidth * variants.size
val startX = (width - totalW) / 2f
val relX = localX - startX

View file

@ -28,6 +28,8 @@ class QuickBarView(context: Context) : View(context) {
var onKeyTap: ((KeyDefinition) -> Unit)? = null
var onKeyDown: ((KeyDefinition) -> Unit)? = null
var onKeyUp: (() -> Unit)? = null
/** Callback for menu keys (vim/tmux/screen) — passes key and its screen bounds */
var onMenuKeyTap: ((KeyDefinition) -> Unit)? = null
/** Callback for the snippets button */
var onSnippetsTap: (() -> Unit)? = null
@ -478,7 +480,11 @@ class QuickBarView(context: Context) : View(context) {
pressedKeys.clear()
invalidate()
if (key != null) {
onKeyTap?.invoke(key)
if (!key.menuItems.isNullOrEmpty()) {
onMenuKeyTap?.invoke(key)
} else {
onKeyTap?.invoke(key)
}
}
onKeyUp?.invoke()
velocityTracker?.recycle()

View file

@ -216,7 +216,50 @@
{ "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_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_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": "qb_tmux", "label": "tmux", "w": 1, "style": "menu", "action": { "type": "none" }, "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": "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] } }
]}
]
}
}