From a0d63617ac956a17b1836e926b669a8394f67e90 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Mon, 14 Sep 2026 07:06:35 +0300 Subject: [PATCH 1/5] Add editable row-aligned swipe shortcut menus Build on upstream popup and secondary-layout infrastructure. Preserve row alignment, number-row targeting, key lifecycle and cancellation without importing fork input logic. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d01a009-639e-403d-8608-f127da2a4ddc --- .../assets/layouts/swipe_down/swipe_down.json | 11 + .../assets/layouts/swipe_up/swipe_up.json | 11 + .../helium314/keyboard/keyboard/Keyboard.kt | 2 + .../keyboard/keyboard/MainKeyboardView.kt | 36 ++ .../keyboard/keyboard/PointerTracker.kt | 67 ++- .../keyboard/PopupKeysKeyboardView.kt | 57 +- .../keyboard/keyboard/SwipeShortcutMenu.kt | 105 ++++ .../keyboard/internal/DrawingProxy.kt | 2 + .../keyboard/latin/settings/Defaults.kt | 4 + .../keyboard/latin/settings/Settings.kt | 2 + .../keyboard/latin/settings/SettingsValues.kt | 4 + .../keyboard/latin/utils/LayoutType.kt | 4 +- .../settings/screens/GestureTypingScreen.kt | 8 + app/src/main/res/values/strings.xml | 6 + .../keyboard/SwipeShortcutMenuTest.kt | 509 ++++++++++++++++++ .../settings/SettingsContainerTest.kt | 16 + layouts.md | 37 ++ 17 files changed, 875 insertions(+), 6 deletions(-) create mode 100644 app/src/main/assets/layouts/swipe_down/swipe_down.json create mode 100644 app/src/main/assets/layouts/swipe_up/swipe_up.json create mode 100644 app/src/main/java/helium314/keyboard/keyboard/SwipeShortcutMenu.kt create mode 100644 app/src/test/java/helium314/keyboard/SwipeShortcutMenuTest.kt diff --git a/app/src/main/assets/layouts/swipe_down/swipe_down.json b/app/src/main/assets/layouts/swipe_down/swipe_down.json new file mode 100644 index 000000000..4b16fa53c --- /dev/null +++ b/app/src/main/assets/layouts/swipe_down/swipe_down.json @@ -0,0 +1,11 @@ +[ + [ + { "label": "left" }, + { "label": "right" }, + { "label": "word_left" }, + { "label": "word_right" }, + { "label": "clipboard" }, + { "label": "numpad" }, + { "label": "emoji" } + ] +] diff --git a/app/src/main/assets/layouts/swipe_up/swipe_up.json b/app/src/main/assets/layouts/swipe_up/swipe_up.json new file mode 100644 index 000000000..b7c71eab9 --- /dev/null +++ b/app/src/main/assets/layouts/swipe_up/swipe_up.json @@ -0,0 +1,11 @@ +[ + [ + { "label": "undo" }, + { "label": "redo" }, + { "label": "cut" }, + { "label": "copy" }, + { "label": "paste" }, + { "label": "select_all" }, + { "label": "clipboard" } + ] +] diff --git a/app/src/main/java/helium314/keyboard/keyboard/Keyboard.kt b/app/src/main/java/helium314/keyboard/keyboard/Keyboard.kt index e6769a050..92e7d7f9b 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/Keyboard.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/Keyboard.kt @@ -57,6 +57,8 @@ open class Keyboard { val mAltCodeKeysWhileTyping: List val mIconsSet: KeyboardIconsSet + internal val swipeShortcutRows by lazy { SwipeShortcutMenu.findRows(this) } + private val mKeyCache = SparseArray() private val mProximityInfo: ProximityInfo diff --git a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.kt b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.kt index b960a21c1..ef330bf6b 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.kt @@ -292,6 +292,40 @@ class MainKeyboardView @JvmOverloads constructor( return popupKeysKeyboardView } + override fun showSwipeShortcutMenu( + tracker: PointerTracker, direction: SwipeShortcutMenu.Direction + ): PopupKeysPanel? { + val parent = keyboard ?: return null + val row = parent.swipeShortcutRows[direction] ?: return null + val menu = try { + SwipeShortcutMenu.Builder(context, parent, row, direction).build() + } catch (e: IllegalArgumentException) { + Log.e("SwipeShortcutMenu", "Invalid ${direction.layoutType} layout", e) + helium314.keyboard.settings.FeedbackManager.message(context, context.getString(R.string.layout_error, e.message)) + return null + } catch (e: IllegalStateException) { + Log.e("SwipeShortcutMenu", "Invalid ${direction.layoutType} layout", e) + helium314.keyboard.settings.FeedbackManager.message(context, context.getString(R.string.layout_error, e.message)) + return null + } + val container = mPopupKeysKeyboardContainer + container.findViewById(R.id.description_view)?.visibility = View.GONE + val popup = container.findViewById(R.id.popup_keys_keyboard_view) + popup.setKeyboard(menu) + container.measure(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + container.layout(0, 0, container.measuredWidth, container.measuredHeight) + val down = CoordinateUtils.newInstance() + tracker.getDownCoordinates(down) + val top = if (direction == SwipeShortcutMenu.Direction.UP) + row.top - menu.mOccupiedHeight - parent.mVerticalGap + else row.bottom + parent.mVerticalGap + popup.showSwipeShortcutPanel( + this, this, paddingLeft + row.left, paddingTop + top, + direction, CoordinateUtils.y(down), mKeyboardActionListener + ) + return popup + } + fun isInDraggingFinger(): Boolean = isShowingPopupKeysPanel() || PointerTracker.isAnyInDraggingFinger() override fun onShowPopupKeysPanel(panel: PopupKeysPanel) { @@ -315,6 +349,7 @@ class MainKeyboardView @JvmOverloads constructor( override fun onTouchEvent(event: MotionEvent): Boolean { if (keyboard == null) return false if (mNonDistinctMultitouchHelper != null) { + if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) PointerTracker.cancelSwipeShortcutMenus() if (event.pointerCount > 1 && mTimerHandler.isInKeyRepeat()) mTimerHandler.cancelKeyRepeatTimers() mNonDistinctMultitouchHelper.processMotionEvent(event, mKeyDetector) return true @@ -323,6 +358,7 @@ class MainKeyboardView @JvmOverloads constructor( } fun processMotionEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) PointerTracker.cancelSwipeShortcutMenus() val index = event.actionIndex val id = event.getPointerId(index) val tracker = PointerTracker.getPointerTracker(id) diff --git a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt index 32b19c2e8..ae50bb932 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt @@ -114,6 +114,8 @@ class PointerTracker private constructor( private var mIsAllowedDraggingFinger = false // true if a keyswipe gesture is enabled and warranted. private var mKeySwipeAllowed = false + private var mSwipeShortcutSource: Key? = null + private var mSwipeShortcutActive = false private var mInTouchpadMode = false private var mTouchpadLastX = 0 @@ -230,6 +232,8 @@ class PointerTracker private constructor( if (keyDetector === mKeyDetector && keyboard === mKeyboard) { return } + mSwipeShortcutSource = null + if (mSwipeShortcutActive) dismissPopupKeysPanel() val oldKeyboard = mKeyboard if (oldKeyboard != null) { // changing keyboards may change height @@ -471,6 +475,7 @@ class PointerTracker private constructor( } val key = getKeyOn(x, y) + cancelSwipeShortcutMenus() mBogusMoveEventDetector.onActualDownEvent(x, y) if (key != null && key.isModifier()) { if (sInGesture) { @@ -503,6 +508,10 @@ class PointerTracker private constructor( fun isShowingPopupKeysPanel(): Boolean = mPopupKeysPanel != null private fun dismissPopupKeysPanel() { + if (mSwipeShortcutActive) { + mSwipeShortcutActive = false + mIsTrackingForActionDisabled = true + } if (isShowingPopupKeysPanel()) { mPopupKeysPanel?.dismissPopupKeysPanel() mPopupKeysPanel = null @@ -510,6 +519,7 @@ class PointerTracker private constructor( } private fun onDownEventInternal(x: Int, y: Int, eventTime: Long) { + mSwipeShortcutSource = null var key = onDownKey(x, y, eventTime) val isEmojiClipBottomRow = mKeyboard?.mId?.isEmojiClipBottomRow == true mIsAllowedDraggingFinger = (sParams?.mKeySelectionByDraggingFinger == true) || @@ -538,6 +548,11 @@ class PointerTracker private constructor( mStartX = x mStartY = y mStartTime = System.currentTimeMillis() + val settings = Settings.getValues() + if (getActivePointerTrackerCount() == 1 && !mKeySwipeAllowed && + (settings.mSwipeUpMenuEnabled || settings.mSwipeDownMenuEnabled)) { + mSwipeShortcutSource = key + } } } @@ -608,7 +623,8 @@ class PointerTracker private constructor( return } - if (sGestureEnabler.shouldHandleGesture() && me != null) { + if (!isShowingPopupKeysPanel() && me != null && + (mSwipeShortcutSource != null || sGestureEnabler.shouldHandleGesture())) { // Add historical points to gesture path. val pointerIndex = me.findPointerIndex(mPointerId) val historicalSize = me.historySize @@ -616,7 +632,9 @@ class PointerTracker private constructor( val historicalX = me.getHistoricalX(pointerIndex, h).toInt() val historicalY = me.getHistoricalY(pointerIndex, h).toInt() val historicalTime = me.getHistoricalEventTime(h) - onGestureMoveEvent(historicalX, historicalY, historicalTime, false, null) + if (tryStartSwipeShortcut(historicalX, historicalY, historicalTime)) break + if (sGestureEnabler.shouldHandleGesture()) + onGestureMoveEvent(historicalX, historicalY, historicalTime, false, null) } } @@ -631,9 +649,40 @@ class PointerTracker private constructor( } return } + if (tryStartSwipeShortcut(x, y, eventTime)) return onMoveEventInternal(x, y, eventTime) } + private fun tryStartSwipeShortcut(x: Int, y: Int, eventTime: Long): Boolean { + val source = mSwipeShortcutSource ?: return false + if (sInGesture || sInKeySwipe || isShowingPopupKeysPanel() || + getActivePointerTrackerCount() != 1 || mIsTrackingForActionDisabled) { + mSwipeShortcutSource = null + return false + } + val dx = x - CoordinateUtils.x(mDownCoordinates) + val dy = y - CoordinateUtils.y(mDownCoordinates) + val threshold = maxOf(2 * sPointerStep, source.height / 4) + if (abs(dx) < threshold && abs(dy) < threshold) return false + mSwipeShortcutSource = null + if (abs(dy) <= abs(dx)) return false + val direction = if (dy < 0) SwipeShortcutMenu.Direction.UP else SwipeShortcutMenu.Direction.DOWN + val settings = Settings.getValues() + if (direction == SwipeShortcutMenu.Direction.UP && !settings.mSwipeUpMenuEnabled || + direction == SwipeShortcutMenu.Direction.DOWN && !settings.mSwipeDownMenuEnabled) return false + val row = mKeyboard?.swipeShortcutRows?.get(direction) ?: return false + if (source !in row.keys) return false + val panel = sDrawingProxy?.showSwipeShortcutMenu(this, direction) ?: return false + getTimerProxy().cancelKeyTimersOf(this) + mIsDetectingGesture = false + setReleasedKeyGraphics(mCurrentKey, true) + mCurrentKey = null + mSwipeShortcutActive = true + mPopupKeysPanel = panel + panel.onDownEvent(panel.translateX(x), panel.translateY(y), mPointerId, eventTime) + return true + } + private fun processDraggingFingerInToNewKey(newKey: Key, x: Int, y: Int, eventTime: Long) { var key: Key? = newKey if (callListenerOnPressAndCheckKeyboardLayoutChange(newKey, 0)) { @@ -858,6 +907,7 @@ class PointerTracker private constructor( printTouchEvent("onUpEvent :", x, y, eventTime) } + tryStartSwipeShortcut(x, y, eventTime) getTimerProxy().cancelUpdateBatchInputTimer(this) if (!sInGesture) { if (mCurrentKey?.isModifier() == true) { @@ -879,6 +929,7 @@ class PointerTracker private constructor( } private fun onUpEventInternal(x: Int, y: Int, eventTime: Long) { + mSwipeShortcutSource = null getTimerProxy().cancelKeyTimersOf(this) val isInDraggingFinger = mIsInDraggingFinger val isInSlidingKeyInput = mIsInSlidingKeyInput @@ -955,7 +1006,8 @@ class PointerTracker private constructor( } override fun cancelTrackingForAction() { - if (isShowingPopupKeysPanel()) { + mSwipeShortcutSource = null + if (isShowingPopupKeysPanel() && !mSwipeShortcutActive) { return } mIsTrackingForActionDisabled = true @@ -964,6 +1016,7 @@ class PointerTracker private constructor( val isInOperation: Boolean get() = !mIsTrackingForActionDisabled fun onLongPressed() { + mSwipeShortcutSource = null getTimerProxy().cancelLongPressTimersOf(this) if (isShowingPopupKeysPanel()) { return @@ -1118,6 +1171,7 @@ class PointerTracker private constructor( } fun onKeyRepeat(code: Int, repeatCount: Int) { + mSwipeShortcutSource = null val key = key if (key == null || key.code != code) { mCurrentRepeatingKeyCode = Constants.NOT_A_CODE @@ -1270,6 +1324,13 @@ class PointerTracker private constructor( fun isAnyInDraggingFinger(): Boolean = sPointerTrackerQueue.isAnyInDraggingFinger() + fun cancelSwipeShortcutMenus() { + for (tracker in sTrackers) { + tracker.mSwipeShortcutSource = null + if (tracker.mSwipeShortcutActive) tracker.dismissPopupKeysPanel() + } + } + fun cancelAllPointerTrackers() { sPointerTrackerQueue.cancelAllPointerTrackers() } diff --git a/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.kt b/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.kt index dd80ebcff..ec5000592 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.kt @@ -17,6 +17,7 @@ import android.view.MotionEvent import android.view.View import helium314.keyboard.accessibility.AccessibilityUtils import helium314.keyboard.accessibility.PopupKeysKeyboardAccessibilityDelegate +import helium314.keyboard.event.HapticEvent import helium314.keyboard.keyboard.emoji.EmojiViewCallback import helium314.keyboard.keyboard.internal.KeyDrawParams import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode @@ -26,6 +27,7 @@ import helium314.keyboard.latin.common.Constants import helium314.keyboard.latin.common.CoordinateUtils import kotlin.math.max import kotlin.math.min +import kotlin.math.abs /** * A view that renders a virtual [PopupKeysKeyboard]. It handles rendering of keys and @@ -47,6 +49,8 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( private var mOriginX = 0 private var mOriginY = 0 private var mCurrentKey: Key? = null + private var mSwipeShortcutDirection: SwipeShortcutMenu.Direction? = null + private var mSwipeShortcutCancelY = 0 private var mActivePointerId = 0 @@ -97,6 +101,9 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( } override fun setKeyboard(keyboard: Keyboard) { + mCurrentKey?.onReleased() + mCurrentKey = null + mSwipeShortcutDirection = null super.setKeyboard(keyboard) mKeyDetector.setKeyboard( keyboard, @@ -132,6 +139,32 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( showPopupKeysPanelInternal(parentView, controller, pointX, pointY) } + fun showSwipeShortcutPanel( + parentView: View, + controller: PopupKeysPanel.Controller, + left: Int, + top: Int, + direction: SwipeShortcutMenu.Direction, + cancelY: Int, + listener: KeyboardActionListener + ) { + mListener = listener + mEmojiViewCallback = null + mController = controller + mSwipeShortcutDirection = direction + mSwipeShortcutCancelY = cancelY + mOriginX = left - paddingLeft + mOriginY = top - paddingTop + val container = getContainerView() + parentView.getLocationInWindow(mCoordinates) + // Only the background padding may overhang; the key centers stay on the source row. + container.x = (CoordinateUtils.x(mCoordinates) + mOriginX - this.left).toFloat() + container.y = (CoordinateUtils.y(mCoordinates) + mOriginY - this.top).toFloat() + translationX = 0f + controller.setLayoutGravity(Gravity.CENTER_HORIZONTAL) + onPanelShown() + } + override fun showPopupKeysPanel( parentView: View, controller: PopupKeysPanel.Controller, @@ -192,7 +225,11 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( mOriginX = panelFinalX mOriginY = y + container.paddingTop + this.y.toInt() - controller.onShowPopupKeysPanel(this) + onPanelShown() + } + + private fun onPanelShown() { + mController.onShowPopupKeysPanel(this) val accessibilityDelegate = mAccessibilityDelegate if (accessibilityDelegate != null && AccessibilityUtils.instance.isAccessibilityEnabled) { accessibilityDelegate.onShowPopupKeysKeyboard() @@ -245,6 +282,8 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( val listener = mListener if (listener != null) { val code = key.code + val isSwipeShortcut = mSwipeShortcutDirection != null + if (isSwipeShortcut) listener.onPressKey(code, 0, true, HapticEvent.NO_HAPTICS) if (code == KeyCode.MULTIPLE_CODE_POINTS) { listener.onTextInput(key.outputText) } else if (code != KeyCode.NOT_SPECIFIED) { @@ -259,6 +298,7 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( ) } } + if (isSwipeShortcut) listener.onReleaseKey(code, false) } else { mEmojiViewCallback?.onReleaseKey(key) } @@ -266,7 +306,8 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( private fun detectKey(x: Int, y: Int): Key? { val oldKey = mCurrentKey - val newKey = mKeyDetector.detectHitKey(x, y) + val newKey = if (mSwipeShortcutDirection == null) mKeyDetector.detectHitKey(x, y) + else detectSwipeShortcut(x, y) if (newKey === oldKey) { return newKey } @@ -282,6 +323,18 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( return newKey } + private fun detectSwipeShortcut(x: Int, y: Int): Key? { + val keyboard = keyboard ?: return null + val parentY = y + mOriginY + val outward = if (mSwipeShortcutDirection == SwipeShortcutMenu.Direction.UP) + parentY < mSwipeShortcutCancelY else parentY > mSwipeShortcutCancelY + val touchX = mKeyDetector.getTouchX(x) + if (!outward || touchX < 0 || touchX >= keyboard.mOccupiedWidth) return null + // Project onto the menu row so a short outward swipe already selects its aligned item. + return keyboard.sortedKeys.minByOrNull { abs(touchX - (it.x + it.width / 2)) } + ?.takeIf { it.isEnabled && !it.isSpacer } + } + private fun updateReleaseKeyGraphics(key: Key) { key.onReleased() invalidateKey(key) diff --git a/app/src/main/java/helium314/keyboard/keyboard/SwipeShortcutMenu.kt b/app/src/main/java/helium314/keyboard/keyboard/SwipeShortcutMenu.kt new file mode 100644 index 000000000..05ba1be71 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/keyboard/SwipeShortcutMenu.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.keyboard + +import android.content.Context +import helium314.keyboard.keyboard.internal.KeyboardBuilder +import helium314.keyboard.keyboard.internal.keyboard_parser.LayoutParser +import helium314.keyboard.keyboard.internal.keyboard_parser.addLocaleKeyTextsToParams +import helium314.keyboard.latin.common.Constants +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.utils.LayoutType +import kotlin.math.floor +import kotlin.math.roundToInt + +/** Row geometry belongs to the keyboard, not to the key which opens the menu. */ +object SwipeShortcutMenu { + enum class Direction(val layoutType: LayoutType) { + UP(LayoutType.SWIPE_UP), DOWN(LayoutType.SWIPE_DOWN) + } + + class Row(val keys: List) { + val left = keys.first().x + val right = keys.last().x + keys.last().width + val top = keys.minOf { it.y } + val bottom = keys.maxOf { it.y + it.height } + + fun centers(count: Int): List { + require(count > 0 && count <= right - left) { "Too many swipe shortcuts for this row" } + return List(count) { index -> + val position = if (count == 1) keys.lastIndex / 2f + else index.toFloat() * keys.lastIndex / (count - 1) + val before = floor(position).toInt() + val after = (before + 1).coerceAtMost(keys.lastIndex) + val start = keys[before].x + keys[before].width / 2 + val end = keys[after].x + keys[after].width / 2 + (start + (end - start) * (position - before)).roundToInt() + } + } + } + + fun findRows(keyboard: Keyboard): Map { + // Custom menus may contain clipboard history or private text; mirror locked toolbar behavior. + if (!keyboard.mId.isAlphabetKeyboard || keyboard.mId.mDeviceLocked) return emptyMap() + // The space/punctuation row is functional; "." must keep its own long-press menu. + val spaceY = keyboard.getKey(Constants.CODE_SPACE)?.y ?: Int.MAX_VALUE + val characters = keyboard.sortedKeys.filter { + it.y < spaceY && it.isEnabled && !it.isSpacer && !it.isModifier() + && it.backgroundType == Key.BACKGROUND_TYPE_NORMAL + && (it.code > Constants.CODE_SPACE || !it.outputText.isNullOrEmpty()) + } + if (characters.isEmpty()) return emptyMap() + val top = characters.minOf { it.y } + val bottom = characters.maxOf { it.y } + return mapOf( + Direction.UP to Row(characters.filter { it.y == top }.sortedBy { it.x }), + Direction.DOWN to Row(characters.filter { it.y == bottom }.sortedBy { it.x }) + ) + } + + class Builder( + context: Context, + parent: Keyboard, + private val row: Row, + private val direction: Direction + ) : KeyboardBuilder( + context, PopupKeysKeyboard.PopupKeysKeyboardParams() + ) { + init { + mParams.mId = parent.mId + readAttributes(parent.mPopupKeysTemplate) + mParams.mLeftPadding = 0 + mParams.mRightPadding = 0 + mParams.mTopPadding = 0 + mParams.mBottomPadding = 0 + mParams.mHorizontalGap = 0 + mParams.mVerticalGap = 0 + mParams.mRelativeHorizontalGap = 0f + mParams.mRelativeVerticalGap = 0f + mParams.mOccupiedWidth = row.right - row.left + mParams.mBaseWidth = mParams.mOccupiedWidth + mParams.mOccupiedHeight = row.bottom - row.top + mParams.mBaseHeight = mParams.mOccupiedHeight + mParams.mDefaultRowHeight = 1f + addLocaleKeyTextsToParams(context, mParams, Settings.getValues().mShowMorePopupKeys) + } + + override fun build(): PopupKeysKeyboard { + val data = LayoutParser.parseLayout(direction.layoutType, mParams, mContext).flatten() + require(data.isNotEmpty()) { "Swipe shortcut layout is empty" } + val centers = row.centers(data.size) + val spacing = centers.zipWithNext { a, b -> b - a }.minOrNull() + ?: mParams.mBaseWidth + require(spacing > 0) { "Too many swipe shortcuts for this row" } + val width = minOf(row.keys.minOf { it.width }, spacing) + data.forEachIndexed { index, keyData -> + val keyParams = keyData.toKeyParams(mParams, Key.LABEL_FLAGS_AUTO_X_SCALE) + keyParams.xPos = (centers[index] - row.left - width / 2).toFloat() + keyParams.yPos = 0f + keyParams.mAbsoluteWidth = width.toFloat() + keyParams.mAbsoluteHeight = mParams.mBaseHeight.toFloat() + mParams.onAddKey(keyParams.createKey()) + } + return PopupKeysKeyboard(mParams) + } + } +} diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.kt index ccd5c00fb..abe91497f 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.kt @@ -9,11 +9,13 @@ package helium314.keyboard.keyboard.internal import helium314.keyboard.keyboard.Key import helium314.keyboard.keyboard.PopupKeysPanel import helium314.keyboard.keyboard.PointerTracker +import helium314.keyboard.keyboard.SwipeShortcutMenu interface DrawingProxy { fun onKeyPressed(key: Key, withPreview: Boolean) fun onKeyReleased(key: Key, withAnimation: Boolean) fun showPopupKeysKeyboard(key: Key, tracker: PointerTracker): PopupKeysPanel? + fun showSwipeShortcutMenu(tracker: PointerTracker, direction: SwipeShortcutMenu.Direction): PopupKeysPanel? fun startWhileTypingAnimation(fadeInOrOut: Int) fun showSlidingKeyInputPreview(tracker: PointerTracker?) fun showGestureTrail(tracker: PointerTracker, showsFloatingPreviewText: Boolean) diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index cb65ad424..d70cfe696 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -45,6 +45,8 @@ object Defaults { LayoutType.CUSTOM3 -> "symbols" LayoutType.CUSTOM4 -> "symbols" LayoutType.CUSTOM5 -> "symbols" + LayoutType.SWIPE_UP -> "swipe_up" + LayoutType.SWIPE_DOWN -> "swipe_down" } const val PREF_SPLIT_TOOLBAR = false @@ -124,6 +126,8 @@ object Defaults { const val PREF_SPACE_HORIZONTAL_SWIPE = "move_cursor" const val PREF_SPACE_VERTICAL_SWIPE = "touchpad_mode" const val PREF_DELETE_SWIPE = true + const val PREF_SWIPE_UP_MENU = false + const val PREF_SWIPE_DOWN_MENU = false const val PREF_AUTOSPACE_AFTER_PUNCTUATION = false const val PREF_AUTOSPACE_AFTER_EMOJI = false const val PREF_AUTOSPACE_AFTER_SUGGESTION = true diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt b/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt index 0f1c01fa5..4f91993f2 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.kt @@ -348,6 +348,8 @@ class Settings private constructor() : SharedPreferences.OnSharedPreferenceChang const val PREF_SPACE_HORIZONTAL_SWIPE = "horizontal_space_swipe" const val PREF_SPACE_VERTICAL_SWIPE = "vertical_space_swipe" const val PREF_DELETE_SWIPE = "delete_swipe" + const val PREF_SWIPE_UP_MENU = "swipe_up_menu" + const val PREF_SWIPE_DOWN_MENU = "swipe_down_menu" const val PREF_AUTOSPACE_AFTER_PUNCTUATION = "autospace_after_punctuation" const val PREF_AUTOSPACE_AFTER_EMOJI = "autospace_after_emoji" const val PREF_AUTOSPACE_AFTER_SUGGESTION = "autospace_after_suggestion" diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt index 995ac3798..44f35a646 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.kt @@ -91,6 +91,8 @@ open class SettingsValues( val mTouchpadFullscreen: Boolean val mForceAutoCaps: Boolean val mDeleteSwipeEnabled: Boolean + val mSwipeUpMenuEnabled: Boolean + val mSwipeDownMenuEnabled: Boolean val mAutospaceAfterPunctuation: Boolean val mAutospaceAfterEmoji: Boolean val mAutospaceAfterSuggestion: Boolean @@ -356,6 +358,8 @@ open class SettingsValues( mTouchpadFullscreen = prefs.getBoolean(Settings.PREF_TOUCHPAD_FULLSCREEN, Defaults.PREF_TOUCHPAD_FULLSCREEN) mForceAutoCaps = prefs.getBoolean(Settings.PREF_FORCE_AUTO_CAPS, Defaults.PREF_FORCE_AUTO_CAPS) mDeleteSwipeEnabled = prefs.getBoolean(Settings.PREF_DELETE_SWIPE, Defaults.PREF_DELETE_SWIPE) + mSwipeUpMenuEnabled = prefs.getBoolean(Settings.PREF_SWIPE_UP_MENU, Defaults.PREF_SWIPE_UP_MENU) + mSwipeDownMenuEnabled = prefs.getBoolean(Settings.PREF_SWIPE_DOWN_MENU, Defaults.PREF_SWIPE_DOWN_MENU) mAutospaceAfterPunctuation = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, Defaults.PREF_AUTOSPACE_AFTER_PUNCTUATION) mAutospaceAfterEmoji = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_EMOJI, Defaults.PREF_AUTOSPACE_AFTER_EMOJI) mAutospaceAfterSuggestion = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_SUGGESTION, Defaults.PREF_AUTOSPACE_AFTER_SUGGESTION) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt b/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt index b14a91556..c0bd223d0 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt @@ -9,7 +9,7 @@ import java.util.EnumMap enum class LayoutType { MAIN, SYMBOLS, MORE_SYMBOLS, FUNCTIONAL, NUMBER, NUMBER_ROW, NUMPAD, NUMPAD_LANDSCAPE, PHONE, PHONE_SYMBOLS, EMOJI_BOTTOM, CLIPBOARD_BOTTOM, HANDWRITING_BOTTOM, EDITING, - CUSTOM1, CUSTOM2, CUSTOM3, CUSTOM4, CUSTOM5; + CUSTOM1, CUSTOM2, CUSTOM3, CUSTOM4, CUSTOM5, SWIPE_UP, SWIPE_DOWN; companion object { fun EnumMap.toExtraValue() = map { it.key.name + Separators.KV + it.value }.joinToString(Separators.ENTRY) @@ -45,6 +45,8 @@ enum class LayoutType { CUSTOM3 -> R.string.layout_custom3 CUSTOM4 -> R.string.layout_custom4 CUSTOM5 -> R.string.layout_custom5 + SWIPE_UP -> R.string.layout_swipe_up + SWIPE_DOWN -> R.string.layout_swipe_down } fun getMainLayoutFromExtraValue(extraValue: String): String? { diff --git a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt index c237e77ba..73b86009e 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt @@ -68,6 +68,8 @@ fun GestureTypingScreen( add(Settings.PREF_SPACE_HORIZONTAL_SWIPE) add(Settings.PREF_SPACE_VERTICAL_SWIPE) add(Settings.PREF_DELETE_SWIPE) + add(Settings.PREF_SWIPE_UP_MENU) + add(Settings.PREF_SWIPE_DOWN_MENU) add(R.string.settings_category_touchpad) add(Settings.PREF_TOUCHPAD_SENSITIVITY) @@ -175,6 +177,12 @@ fun createGestureTypingSettings(context: Context) = listOf( Setting(context, Settings.PREF_DELETE_SWIPE, R.string.delete_swipe, R.string.delete_swipe_summary) { SwitchPreference(it, Defaults.PREF_DELETE_SWIPE) }, + Setting(context, Settings.PREF_SWIPE_UP_MENU, R.string.swipe_up_menu, R.string.swipe_up_menu_summary) { + SwitchPreference(it, Defaults.PREF_SWIPE_UP_MENU) + }, + Setting(context, Settings.PREF_SWIPE_DOWN_MENU, R.string.swipe_down_menu, R.string.swipe_down_menu_summary) { + SwitchPreference(it, Defaults.PREF_SWIPE_DOWN_MENU) + }, ) @Preview diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9eff8568..22f468d9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1644,4 +1644,10 @@ New dictionary: Custom Prompt for Key %1$d Tag / Label (e.g. \'French\', \'Grammar\', \'Rewrite\') Selected Keyword Functions: + Swipe up for shortcuts + Swipe up from the top row, including the number row when shown. Edit the menu in Secondary layouts. + Swipe down for shortcuts + Swipe down from the bottom letter row. Edit the menu in Secondary layouts. + Swipe-up shortcuts + Swipe-down shortcuts diff --git a/app/src/test/java/helium314/keyboard/SwipeShortcutMenuTest.kt b/app/src/test/java/helium314/keyboard/SwipeShortcutMenuTest.kt new file mode 100644 index 000000000..f3b9c6d10 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/SwipeShortcutMenuTest.kt @@ -0,0 +1,509 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard + +import android.content.Context +import android.content.res.AssetManager +import android.app.KeyguardManager +import android.view.ContextThemeWrapper +import android.view.MotionEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import helium314.keyboard.event.HapticEvent +import helium314.keyboard.event.Event +import helium314.keyboard.keyboard.Key +import helium314.keyboard.keyboard.Keyboard +import helium314.keyboard.keyboard.KeyboardActionListener +import helium314.keyboard.keyboard.KeyboardId +import helium314.keyboard.keyboard.KeyboardLayoutSet +import helium314.keyboard.keyboard.KeyboardTheme +import helium314.keyboard.keyboard.MainKeyboardView +import helium314.keyboard.keyboard.PointerTracker +import helium314.keyboard.keyboard.PopupKeysKeyboardView +import helium314.keyboard.keyboard.SwipeShortcutMenu +import helium314.keyboard.keyboard.SwipeShortcutMenu.Direction.DOWN +import helium314.keyboard.keyboard.SwipeShortcutMenu.Direction.UP +import helium314.keyboard.keyboard.internal.keyboard_parser.LayoutParser +import helium314.keyboard.keyboard.internal.KeyboardState +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode +import helium314.keyboard.latin.LatinIME +import helium314.keyboard.latin.R +import helium314.keyboard.latin.RichInputMethodSubtype +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.settings.DebugSettings +import helium314.keyboard.latin.utils.LayoutType +import helium314.keyboard.latin.utils.LayoutUtilsCustom +import helium314.keyboard.latin.utils.SubtypeUtilsAdditional +import helium314.keyboard.latin.utils.prefs +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.* +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.Locale +import kotlin.math.abs +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], shadows = [ShadowInputMethodManager2::class, ShadowProximityInfo::class]) +class SwipeShortcutMenuTest { + private lateinit var context: Context + private lateinit var keyboard: Keyboard + private lateinit var view: MainKeyboardView + private lateinit var listener: KeyboardActionListener + private var eventTime = 10_000L + private var downTime = 0L + private var useTouchEvent = false + + @Before + fun setUp() { + val ime = Robolectric.setupService(LatinIME::class.java) + val assets = mock(AssetManager::class.java) + // Android uses slash-separated asset paths, even when Robolectric runs on Windows. + `when`(assets.list(anyString())).thenAnswer { + ime.assets.list(it.getArgument(0).replace('\\', '/')) + } + `when`(assets.open(anyString())).thenAnswer { + ime.assets.open(it.getArgument(0).replace('\\', '/')) + } + context = object : ContextThemeWrapper(ime, KeyboardTheme.getKeyboardTheme(ime).mStyleId) { + override fun getAssets() = assets + } + context.prefs().edit() + .putBoolean(Settings.PREF_SWIPE_UP_MENU, true) + .putBoolean(Settings.PREF_SWIPE_DOWN_MENU, true) + .putBoolean(Settings.PREF_POPUP_ON, false) + .remove(Settings.PREF_LAYOUT_PREFIX + LayoutType.SWIPE_UP.name) + .remove(Settings.PREF_LAYOUT_PREFIX + LayoutType.SWIPE_DOWN.name) + .apply() + Settings.getInstance().loadSettings(context) + LayoutUtilsCustom.onLayoutFileChanged() + KeyboardLayoutSet.onKeyboardThemeChanged() + keyboard = buildKeyboard() + view = MainKeyboardView(context) + view.setKeyboard(keyboard) + view.measure(exact(700), exact(400)) + view.layout(0, 0, 700, 400) + listener = mock(KeyboardActionListener::class.java) + view.setKeyboardActionListener(listener) + view.setGestureHandlingEnabledByUser(false, false, false) + } + + @After + fun tearDown() { + if (::view.isInitialized) { + useTouchEvent = false + event(MotionEvent.ACTION_CANCEL, 0, 0) + view.cancelAllOngoingEvents() + } + LayoutParser.clearCache() + } + + @Test + fun `seven bottom shortcuts align with z through m`() { + val row = keyboard.swipeShortcutRows.getValue(DOWN) + assertEquals("zxcvbnm", row.keys.map { it.code.toChar() }.joinToString("")) + val menu = SwipeShortcutMenu.Builder(context, keyboard, row, DOWN).build() + assertEquals( + listOf(KeyCode.ARROW_LEFT, KeyCode.ARROW_RIGHT, KeyCode.WORD_LEFT, KeyCode.WORD_RIGHT, + KeyCode.CLIPBOARD, KeyCode.NUMPAD, KeyCode.EMOJI), + menu.sortedKeys.map { it.code } + ) + row.keys.zip(menu.sortedKeys).forEach { (source, shortcut) -> + assertTrue(abs(center(source) - row.left - center(shortcut)) <= 1) + } + } + + @Test + fun `top row follows visible number row`() { + assertEquals("qwertyuiop", keyboard.swipeShortcutRows.getValue(UP).keys.map { it.code.toChar() }.joinToString("")) + val numbered = buildKeyboard(numberRow = true) + assertEquals("1234567890", numbered.swipeShortcutRows.getValue(UP).keys.map { it.code.toChar() }.joinToString("")) + assertEquals("zxcvbnm", numbered.swipeShortcutRows.getValue(DOWN).keys.map { it.code.toChar() }.joinToString("")) + } + + @Test + fun `nonalphabet layouts do not intercept swipes`() { + assertTrue(buildKeyboard(element = KeyboardId.ELEMENT_SYMBOLS).swipeShortcutRows.isEmpty()) + assertTrue(buildKeyboard(element = KeyboardId.ELEMENT_NUMBER).swipeShortcutRows.isEmpty()) + } + + @Test + fun `menu endpoints remain aligned at different item counts`() { + val row = keyboard.swipeShortcutRows.getValue(DOWN) + for (count in listOf(2, 3, 7, 10, 20)) { + val centers = row.centers(count) + assertTrue(abs(center(row.keys.first()) - centers.first()) <= 1) + assertTrue(abs(center(row.keys.last()) - centers.last()) <= 1) + assertEquals(count, centers.distinct().size) + } + assertEquals(center(row.keys[3]), row.centers(1).single()) + } + + @Test + fun `split layout preserves physical source centers`() { + val split = buildKeyboard(split = true) + assertTrue(split.mId.mIsSplitLayout) + val row = split.swipeShortcutRows.getValue(DOWN) + assertEquals(row.keys.map(::center), row.centers(row.keys.size)) + } + + @Test + fun `custom secondary menu preserves order text and all rows`() { + val layout = """[[{"label":"copy"},{"label":"Hello","code":${KeyCode.MULTIPLE_CODE_POINTS}}], [{"label":"!"}]]""" + // Use the standard layout editor's file naming and storage path. + val name = LayoutUtilsCustom.getLayoutName("My menu", LayoutType.SWIPE_DOWN) + "json" + val file = LayoutUtilsCustom.getLayoutFile(name, LayoutType.SWIPE_DOWN, context) + file.writeText(layout) + assertEquals(layout, file.readText()) + LayoutUtilsCustom.onLayoutFileChanged() + Settings.writeDefaultLayoutName(name, LayoutType.SWIPE_DOWN, context.prefs()) + LayoutParser.clearCache() + val row = keyboard.swipeShortcutRows.getValue(DOWN) + val menu = SwipeShortcutMenu.Builder(context, keyboard, row, DOWN).build() + assertEquals(3, menu.sortedKeys.size) + assertEquals(KeyCode.CLIPBOARD_COPY, menu.sortedKeys.first().code) + assertEquals("Hello", menu.sortedKeys[1].outputText) + assertEquals('!'.code, menu.sortedKeys.last().code) + file.writeText("""[[{"label":"paste"}]]""") + LayoutUtilsCustom.onLayoutFileChanged() + KeyboardLayoutSet.onKeyboardThemeChanged() + val edited = SwipeShortcutMenu.Builder(context, keyboard, row, DOWN).build() + assertEquals(KeyCode.CLIPBOARD_PASTE, edited.sortedKeys.single().code) + file.delete() + LayoutUtilsCustom.onLayoutFileChanged() + Settings.writeDefaultLayoutName(null, LayoutType.SWIPE_DOWN, context.prefs()) + } + + @Test + fun `downward swipe sends shortcut but not source letter`() { + val source = keyboard.getKey('z'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_MOVE, x, y + source.height) + assertTrue(view.isShowingPopupKeysPanel()) + event(MotionEvent.ACTION_UP, x, y + source.height) + assertFalse(view.isShowingPopupKeysPanel()) + verify(listener).onCodeInput(eq(KeyCode.ARROW_LEFT), anyInt(), anyInt(), eq(false)) + verify(listener, never()).onCodeInput(eq('z'.code), anyInt(), anyInt(), anyBoolean()) + } + + @Test + fun `number row swipe opens upward menu and q does not`() { + keyboard = buildKeyboard(numberRow = true) + view.setKeyboard(keyboard) + swipe(keyboard.getKey('1'.code)!!, UP) + verify(listener).onCodeInput(eq(KeyCode.UNDO), anyInt(), anyInt(), eq(false)) + clearInvocations(listener) + val q = keyboard.getKey('q'.code)!! + event(MotionEvent.ACTION_DOWN, center(q), q.y + q.height / 2) + event(MotionEvent.ACTION_MOVE, center(q), q.y - q.height / 2) + assertFalse(view.isShowingPopupKeysPanel()) + } + + @Test + fun `swiping from each bottom letter chooses corresponding item`() { + val row = keyboard.swipeShortcutRows.getValue(DOWN) + val codes = SwipeShortcutMenu.Builder(context, keyboard, row, DOWN).build().sortedKeys.map { it.code } + row.keys.zip(codes).forEach { (source, code) -> + clearInvocations(listener) + swipe(source, DOWN) + verify(listener).onCodeInput(eq(code), anyInt(), anyInt(), eq(false)) + verify(listener, times(1)).onCodeInput(anyInt(), anyInt(), anyInt(), anyBoolean()) + } + } + + @Test + fun `dragging across menu selects last item and keeps menu fixed`() { + val row = keyboard.swipeShortcutRows.getValue(DOWN) + val source = row.keys.first() + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, center(source), y) + event(MotionEvent.ACTION_MOVE, center(source), y + source.height) + event(MotionEvent.ACTION_MOVE, center(row.keys.last()), y + source.height) + assertTrue(view.isShowingPopupKeysPanel()) + event(MotionEvent.ACTION_UP, center(row.keys.last()), y + source.height) + verify(listener).onCodeInput(eq(KeyCode.EMOJI), anyInt(), anyInt(), eq(false)) + } + + @Test + fun `returning to starting height cancels without typing`() { + val source = keyboard.getKey('z'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_MOVE, x, y + source.height) + assertTrue(view.isShowingPopupKeysPanel()) + event(MotionEvent.ACTION_MOVE, x, y) + event(MotionEvent.ACTION_UP, x, y) + assertFalse(view.isShowingPopupKeysPanel()) + verify(listener, never()).onCodeInput(anyInt(), anyInt(), anyInt(), anyBoolean()) + } + + @Test + fun `action cancel cannot commit a popup through phantom up`() { + val source = keyboard.getKey('m'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_MOVE, x, y + source.height) + assertTrue(view.isShowingPopupKeysPanel()) + event(MotionEvent.ACTION_CANCEL, x, y + source.height) + assertFalse(view.isShowingPopupKeysPanel()) + verify(listener, never()).onCodeInput(anyInt(), anyInt(), anyInt(), anyBoolean()) + } + + @Test + fun `locked keyboard cannot expose editable shortcuts`() { + val keyguard = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + shadowOf(keyguard).setIsDeviceLocked(true) + val locked = buildKeyboard() + assertTrue(locked.mId.mDeviceLocked) + assertTrue(locked.swipeShortcutRows.isEmpty()) + shadowOf(keyguard).setIsDeviceLocked(false) + } + + @Test + fun `second finger cancels before the popup event filter`() { + val source = keyboard.getKey('z'.code)!! + val other = keyboard.getKey('q'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_MOVE, x, y + source.height) + assertTrue(view.isShowingPopupKeysPanel()) + multiEvent(MotionEvent.ACTION_POINTER_DOWN or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), + listOf(0 to (x to y + source.height), 1 to (center(other) to other.y + other.height / 2))) + assertFalse(view.isShowingPopupKeysPanel()) + multiEvent(MotionEvent.ACTION_POINTER_UP, + listOf(0 to (x to y + source.height), 1 to (center(other) to other.y + other.height / 2))) + multiEvent(MotionEvent.ACTION_UP, listOf(1 to (center(other) to other.y + other.height / 2))) + verify(listener, never()).onCodeInput(eq(KeyCode.ARROW_LEFT), anyInt(), anyInt(), anyBoolean()) + verify(listener, never()).onCodeInput(eq('z'.code), anyInt(), anyInt(), anyBoolean()) + } + + @Test + fun `non distinct multitouch cannot select through its synthetic up`() { + context.prefs().edit().putBoolean(DebugSettings.PREF_FORCE_NON_DISTINCT_MULTITOUCH, true).apply() + view = MainKeyboardView(context) + view.setKeyboard(keyboard) + view.measure(exact(700), exact(400)) + view.layout(0, 0, 700, 400) + view.setKeyboardActionListener(listener) + view.setGestureHandlingEnabledByUser(false, false, false) + useTouchEvent = true + val source = keyboard.getKey('z'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_MOVE, x, y + source.height) + assertTrue(view.isShowingPopupKeysPanel()) + multiEvent(MotionEvent.ACTION_POINTER_DOWN or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), + listOf(0 to (x to y + source.height), 1 to (300 to 100))) + assertFalse(view.isShowingPopupKeysPanel()) + verify(listener, never()).onCodeInput(anyInt(), anyInt(), anyInt(), anyBoolean()) + } + + @Test + fun `custom symbol toggle drives real keyboard state`() { + val name = LayoutUtilsCustom.getLayoutName("Mode menu", LayoutType.SWIPE_DOWN) + "json" + val file = LayoutUtilsCustom.getLayoutFile(name, LayoutType.SWIPE_DOWN, context) + file.writeText("""[[{"label":"symbol_alpha"}]]""") + LayoutUtilsCustom.onLayoutFileChanged() + Settings.writeDefaultLayoutName(name, LayoutType.SWIPE_DOWN, context.prefs()) + LayoutParser.clearCache() + val switches = mock(KeyboardState.SwitchActions::class.java) + val state = KeyboardState(switches) + state.onLoadKeyboard(0, null, false) + clearInvocations(switches) + val stateListener = object : KeyboardActionListener by KeyboardActionListener.EMPTY_LISTENER { + override fun onPressKey(primaryCode: Int, repeatCount: Int, isSinglePointer: Boolean, hapticEvent: HapticEvent) { + state.onPressKey(primaryCode, isSinglePointer, 0, null) + } + override fun onCodeInput(primaryCode: Int, x: Int, y: Int, isKeyRepeat: Boolean) { + state.onEvent(Event.createSoftwareKeypressEvent(primaryCode, 0, x, y, isKeyRepeat), 0, null) + } + override fun onReleaseKey(primaryCode: Int, withSliding: Boolean) { + state.onReleaseKey(primaryCode, withSliding, 0, null) + } + } + view.setKeyboardActionListener(stateListener) + try { + swipe(keyboard.getKey('z'.code)!!, DOWN) + verify(switches, times(1)).setSymbolsKeyboard() + verify(switches, never()).setAlphabetKeyboard() + } finally { + Settings.writeDefaultLayoutName(null, LayoutType.SWIPE_DOWN, context.prefs()) + file.delete() + LayoutUtilsCustom.onLayoutFileChanged() + } + } + + @Test + fun `fast swipe with no move event still commits once`() { + val source = keyboard.getKey('z'.code)!! + event(MotionEvent.ACTION_DOWN, center(source), source.y + source.height / 2) + event(MotionEvent.ACTION_UP, center(source), source.y + source.height * 2) + verify(listener).onCodeInput(eq(KeyCode.ARROW_LEFT), anyInt(), anyInt(), eq(false)) + verify(listener, times(1)).onCodeInput(anyInt(), anyInt(), anyInt(), anyBoolean()) + } + + @Test + fun `coalesced sideways history prevents a late outward menu`() { + val source = keyboard.getKey('z'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + eventTime += 100 + val motion = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, + (x + source.width).toFloat(), y.toFloat(), 0) + motion.addBatch(eventTime + 100, x.toFloat(), (y + source.height).toFloat(), 1f, 1f, 0) + assertEquals(1, motion.historySize) + view.processMotionEvent(motion) + motion.recycle() + assertFalse(view.isShowingPopupKeysPanel()) + } + + @Test + fun `changing keyboards cancels the menu without committing`() { + val source = keyboard.getKey('z'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_MOVE, x, y + source.height) + assertTrue(view.isShowingPopupKeysPanel()) + view.setKeyboard(buildKeyboard(numberRow = true)) + assertFalse(view.isShowingPopupKeysPanel()) + event(MotionEvent.ACTION_UP, x, y + source.height) + verify(listener, never()).onCodeInput(anyInt(), anyInt(), anyInt(), anyBoolean()) + } + + @Test + fun `shortcut press and release are delivered only on commitment`() { + val source = keyboard.getKey('z'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_MOVE, x, y + source.height) + verify(listener, never()).onPressKey(KeyCode.ARROW_LEFT, 0, true, HapticEvent.NO_HAPTICS) + event(MotionEvent.ACTION_UP, x, y + source.height) + val order = inOrder(listener) + order.verify(listener).onPressKey(KeyCode.ARROW_LEFT, 0, true, HapticEvent.NO_HAPTICS) + order.verify(listener).onCodeInput(eq(KeyCode.ARROW_LEFT), anyInt(), anyInt(), eq(false)) + order.verify(listener).onReleaseKey(KeyCode.ARROW_LEFT, false) + } + + @Test + fun `disabled menus and ordinary taps keep original behavior`() { + context.prefs().edit().putBoolean(Settings.PREF_SWIPE_DOWN_MENU, false).apply() + Settings.getInstance().loadSettings(context) + val source = keyboard.getKey('z'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_UP, x, y) + verify(listener).onCodeInput(eq('z'.code), anyInt(), anyInt(), eq(false)) + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_MOVE, x, y + source.height) + assertFalse(view.isShowingPopupKeysPanel()) + } + + @Test + fun `sideways start cannot later become an outward shortcut`() { + val source = keyboard.getKey('z'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_MOVE, x + source.width, y) + event(MotionEvent.ACTION_MOVE, x, y + source.height) + assertFalse(view.isShowingPopupKeysPanel()) + } + + @Test + fun `period long press still opens normal punctuation popup`() { + val period = keyboard.getKey('.'.code)!! + event(MotionEvent.ACTION_DOWN, center(period), period.y + period.height / 2) + PointerTracker.getPointerTracker(0).onLongPressed() + assertTrue(view.isShowingPopupKeysPanel()) + } + + @Test + fun `popup placement does not depend on initiating key or view padding`() { + view.setPadding(9, 5, 7, 3) + for (direction in listOf(UP, DOWN)) { + val row = keyboard.swipeShortcutRows.getValue(direction) + for (source in listOf(row.keys.first(), row.keys.last())) { + val x = center(source) + view.paddingLeft + val y = source.y + source.height / 2 + view.paddingTop + event(MotionEvent.ACTION_DOWN, x, y) + val panel = assertNotNull(view.showSwipeShortcutMenu(PointerTracker.getPointerTracker(0), direction)) + val popup = panel as PopupKeysKeyboardView + val menu = assertNotNull(popup.keyboard) + menu.sortedKeys.zip(row.centers(menu.sortedKeys.size)).forEach { (key, expected) -> + val actual = popup.getContainerView().x + popup.x + popup.paddingLeft + center(key) + assertTrue(abs(actual - view.paddingLeft - expected) <= 1, "$direction $actual != $expected") + } + popup.dismissPopupKeysPanel() + event(MotionEvent.ACTION_CANCEL, x, y) + } + } + } + + private fun buildKeyboard( + numberRow: Boolean = false, split: Boolean = false, element: Int = KeyboardId.ELEMENT_ALPHABET + ): Keyboard = KeyboardLayoutSet.Builder(context, EditorInfo()) + .setKeyboardGeometry(700, 400) + .setSubtype(RichInputMethodSubtype.get( + SubtypeUtilsAdditional.createEmojiCapableAdditionalSubtype(Locale.ENGLISH, "qwerty", true) + )) + .setNumberRowEnabled(numberRow) + .setSplitLayoutEnabled(split) + .disableTouchPositionCorrectionData() + .build().getKeyboard(element) + + private fun swipe(key: Key, direction: SwipeShortcutMenu.Direction) { + val x = center(key) + val y = key.y + key.height / 2 + val endY = y + if (direction == UP) -key.height else key.height + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_MOVE, x, endY) + assertTrue(view.isShowingPopupKeysPanel()) + event(MotionEvent.ACTION_UP, x, endY) + } + + private fun event(action: Int, x: Int, y: Int) { + eventTime += 100 + if (action == MotionEvent.ACTION_DOWN) downTime = eventTime + val motion = MotionEvent.obtain(downTime, eventTime, action, x.toFloat(), y.toFloat(), 0) + if (useTouchEvent) view.onTouchEvent(motion) else view.processMotionEvent(motion) + motion.recycle() + } + + private fun multiEvent(action: Int, points: List>>) { + eventTime += 100 + val properties = points.map { (id, _) -> MotionEvent.PointerProperties().apply { + this.id = id + toolType = MotionEvent.TOOL_TYPE_FINGER + } }.toTypedArray() + val coordinates = points.map { (_, point) -> MotionEvent.PointerCoords().apply { + x = point.first.toFloat() + y = point.second.toFloat() + pressure = 1f + size = 1f + } }.toTypedArray() + val motion = MotionEvent.obtain(downTime, eventTime, action, points.size, properties, coordinates, + 0, 0, 1f, 1f, 0, 0, 0, 0) + if (useTouchEvent) view.onTouchEvent(motion) else view.processMotionEvent(motion) + motion.recycle() + } + + private fun center(key: Key) = key.x + key.width / 2 + private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY) +} diff --git a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt index 99f275c1e..2fca88332 100644 --- a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt +++ b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt @@ -1,6 +1,8 @@ package helium314.keyboard.settings import android.content.Context +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.utils.LayoutType import androidx.test.core.app.ApplicationProvider import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -30,6 +32,20 @@ class SettingsContainerTest { assertTrue(res.isNotEmpty()) } + @Test + fun swipeMenusHaveOneSettingAndOneSecondaryLayoutEach() { + val keys = listOf( + Settings.PREF_SWIPE_UP_MENU, Settings.PREF_SWIPE_DOWN_MENU, + Settings.PREF_LAYOUT_PREFIX + LayoutType.SWIPE_UP.name, + Settings.PREF_LAYOUT_PREFIX + LayoutType.SWIPE_DOWN.name + ) + val settings = container.filter("") + keys.forEach { key -> + assertEquals(1, settings.count { it.key == key }) + assertTrue(container[key] != null) + } + } + @Test fun testFilterPerformance() { val searches = listOf("a", "b", "c", "theme", "color", "sound", "vib", "dictionary", "key", "layout") diff --git a/layouts.md b/layouts.md index c7c6f8c51..5f8822c63 100644 --- a/layouts.md +++ b/layouts.md @@ -15,6 +15,43 @@ If you use an external glide typing library, you likely will have issues if your If the layout has exactly 2 keys in the bottom row, these keys will replace comma and period keys. More exactly: the first key will replace the first functional key with `"groupId": 1` in the bottom row, and the second key with replace the first key with `"groupId": 2`. +## Swipe shortcut menus + +Enable **Swipe up for shortcuts** and/or **Swipe down for shortcuts** in the gesture +settings. These work on the alphabet keyboard: up starts on its top character row +(the number row when visible); down starts on its bottom character row, above the +space/punctuation row. Space/delete gestures and ordinary long-press popups are +unchanged. Both menus are off by default. +Menus are unavailable while the device is locked, because editable entries can +contain private text or open clipboard history. + +Swipe outward, slide sideways to highlight an item, and release to activate it. +Returning to the starting height, sliding outside the menu horizontally, or adding +a second finger cancels the menu without typing the source key. + +Edit **Swipe-up shortcuts** or **Swipe-down shortcuts** in **Secondary layouts**, +using the same edit/import/select controls as other layouts. Both simple text and +JSON are supported. The menu reads every entry in file order into one horizontal +row; key widths are determined by the source row rather than the layout's `width` +properties. Labels, action codes, text output and selectors use the normal layout +parser. Nested long-press popups are not opened inside the menu. + +With seven items on QWERTY, the lower menu aligns with `z x c v b n m`. It stays in +the same place whichever key starts the swipe. With a different number of items, +the first and last stay at the row's first and last key centers and intermediate +items are distributed between them; a single item is centered. + +For example, a three-action menu: + +```json +[[{ "label": "undo" }, { "label": "copy" }, { "label": "paste" }]] +``` + +The built-in upper menu contains undo, redo, cut, copy, paste, select all and +clipboard. The lower menu contains left, right, word left, word right, clipboard, +numpad and emoji. Defaults live in `assets/layouts/swipe_up` and +`assets/layouts/swipe_down`. + ## Simple format * One key per line * Key format: [label] [popup keys], all separated by space, e.g. `a 0 + *` will create a key with text `a`, and the keys `0`, `+`, and `*` on long press From 6969dc20e45269427f3dc0a7e08887d9c1e31d65 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Mon, 14 Sep 2026 07:11:37 +0300 Subject: [PATCH 2/5] Document swipe shortcuts in fork changelog (#156) Keep fork-only release metadata separate from the upstream-portable feature commit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d01a009-639e-403d-8608-f127da2a4ddc --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa8fc8ab..5e4a873b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +- Optional swipe-up and swipe-down shortcut menus, aligned with the top and bottom character rows and editable through Secondary layouts. The upper gesture follows the number row when shown. (#156) + ### Upstream - Based on LeanBitLab/LeanType commit `2a9d5fecb67c12b834ba2a1e80ffcdc8ab7d6720`; upstream release history remains in `docs/releasenote/`. From c03e95b1f071e632a5c288dea113815d27eaf0c9 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Mon, 14 Sep 2026 09:41:15 +0300 Subject: [PATCH 3/5] Stop queued glide updates when swipe menus take over Run swipe regressions with the real gesture arbiter enabled and disabled. Cover normal glide completion and recovery after menu cancellation; reproduce a pending glide timer firing under an open menu and cancel it on activation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d01a009-639e-403d-8608-f127da2a4ddc --- .../keyboard/keyboard/PointerTracker.kt | 1 + .../keyboard/SwipeShortcutMenuTest.kt | 92 ++++++++++++++++++- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt index ae50bb932..af61ef334 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt @@ -674,6 +674,7 @@ class PointerTracker private constructor( if (source !in row.keys) return false val panel = sDrawingProxy?.showSwipeShortcutMenu(this, direction) ?: return false getTimerProxy().cancelKeyTimersOf(this) + getTimerProxy().cancelUpdateBatchInputTimer(this) mIsDetectingGesture = false setReleasedKeyGraphics(mCurrentKey, true) mCurrentKey = null diff --git a/app/src/test/java/helium314/keyboard/SwipeShortcutMenuTest.kt b/app/src/test/java/helium314/keyboard/SwipeShortcutMenuTest.kt index f3b9c6d10..9ba9b562f 100644 --- a/app/src/test/java/helium314/keyboard/SwipeShortcutMenuTest.kt +++ b/app/src/test/java/helium314/keyboard/SwipeShortcutMenuTest.kt @@ -4,6 +4,7 @@ package helium314.keyboard import android.content.Context import android.content.res.AssetManager import android.app.KeyguardManager +import android.os.Looper import android.view.ContextThemeWrapper import android.view.MotionEvent import android.view.View @@ -28,6 +29,7 @@ import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode import helium314.keyboard.latin.LatinIME import helium314.keyboard.latin.R import helium314.keyboard.latin.RichInputMethodSubtype +import helium314.keyboard.latin.common.InputPointers import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.settings.DebugSettings import helium314.keyboard.latin.utils.LayoutType @@ -40,19 +42,26 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.* import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner +import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import java.util.Locale +import java.util.concurrent.TimeUnit import kotlin.math.abs import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue -@RunWith(RobolectricTestRunner::class) +@RunWith(ParameterizedRobolectricTestRunner::class) @Config(sdk = [33], shadows = [ShadowInputMethodManager2::class, ShadowProximityInfo::class]) -class SwipeShortcutMenuTest { +class SwipeShortcutMenuTest(private val glideEnabled: Boolean) { + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "glide={0}") + fun gestureModes() = listOf(arrayOf(false), arrayOf(true)) + } + private lateinit var context: Context private lateinit var keyboard: Keyboard private lateinit var view: MainKeyboardView @@ -92,7 +101,7 @@ class SwipeShortcutMenuTest { view.layout(0, 0, 700, 400) listener = mock(KeyboardActionListener::class.java) view.setKeyboardActionListener(listener) - view.setGestureHandlingEnabledByUser(false, false, false) + configureGlide() } @After @@ -297,7 +306,7 @@ class SwipeShortcutMenuTest { view.measure(exact(700), exact(400)) view.layout(0, 0, 700, 400) view.setKeyboardActionListener(listener) - view.setGestureHandlingEnabledByUser(false, false, false) + configureGlide() useTouchEvent = true val source = keyboard.getKey('z'.code)!! val x = center(source) @@ -355,6 +364,45 @@ class SwipeShortcutMenuTest { verify(listener, times(1)).onCodeInput(anyInt(), anyInt(), anyInt(), anyBoolean()) } + @Test + fun `horizontal glide keeps its path when it later moves outward`() { + for (direction in listOf(UP, DOWN)) assertGlideSurvivesOutwardMove(direction) + } + + @Test + fun `holding shortcut menu cannot deliver a pending glide update`() { + val source = keyboard.getKey('z'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + // Sampled by the gesture arbiter but still below the shortcut activation threshold. + event(MotionEvent.ACTION_MOVE, x, y + source.width / 4) + event(MotionEvent.ACTION_MOVE, x, y + source.height) + assertTrue(view.isShowingPopupKeysPanel()) + shadowOf(Looper.getMainLooper()).idleFor(1, TimeUnit.SECONDS) + assertTrue(view.isShowingPopupKeysPanel()) + assertFalse(mockingDetails(listener).invocations.any { it.method.name == "onUpdateBatchInput" }, + "A pending glide update must not run after a shortcut menu takes ownership") + verify(listener, never()).onStartBatchInput() + event(MotionEvent.ACTION_UP, x, y + source.height) + verify(listener).onCodeInput(eq(KeyCode.ARROW_LEFT), anyInt(), anyInt(), eq(false)) + } + + @Test + fun `cancelled shortcut does not disable the next glide`() { + val source = keyboard.getKey('z'.code)!! + val x = center(source) + val y = source.y + source.height / 2 + event(MotionEvent.ACTION_DOWN, x, y) + event(MotionEvent.ACTION_MOVE, x, y + source.height) + assertTrue(view.isShowingPopupKeysPanel()) + event(MotionEvent.ACTION_MOVE, x, y) + event(MotionEvent.ACTION_UP, x, y) + assertFalse(view.isShowingPopupKeysPanel()) + verify(listener, never()).onCodeInput(anyInt(), anyInt(), anyInt(), anyBoolean()) + assertGlideSurvivesOutwardMove(UP) + } + @Test fun `coalesced sideways history prevents a late outward menu`() { val source = keyboard.getKey('z'.code)!! @@ -478,6 +526,40 @@ class SwipeShortcutMenuTest { event(MotionEvent.ACTION_UP, x, endY) } + private fun configureGlide() { + view.setMainDictionaryAvailability(true) + PointerTracker.setClipboardInlineInputActive(false) + view.setGestureHandlingEnabledByUser(glideEnabled, false, false) + } + + private fun assertGlideSurvivesOutwardMove(direction: SwipeShortcutMenu.Direction) { + val row = keyboard.swipeShortcutRows.getValue(direction) + val source = row.keys.first() + val y = source.y + source.height / 2 + clearInvocations(listener) + event(MotionEvent.ACTION_DOWN, center(source), y) + for (key in row.keys.drop(1).take(3)) { + event(MotionEvent.ACTION_MOVE, center(key), y) + } + if (glideEnabled) verify(listener).onStartBatchInput() + else verify(listener, never()).onStartBatchInput() + val x = center(row.keys[3]) + val outwardY = y + if (direction == UP) -source.height else source.height + event(MotionEvent.ACTION_MOVE, x, outwardY) + assertFalse(view.isShowingPopupKeysPanel()) + event(MotionEvent.ACTION_UP, x, outwardY) + val completions = mockingDetails(listener).invocations.filter { it.method.name == "onEndBatchInput" } + assertEquals(if (glideEnabled) 1 else 0, completions.size) + if (glideEnabled) { + val points = completions.single().arguments.single() as InputPointers + assertEquals(5, points.pointerSize) + assertEquals(center(source), points.xCoordinates[0]) + assertEquals(x, points.xCoordinates[4]) + assertEquals(outwardY, points.yCoordinates[4]) + verify(listener, never()).onCodeInput(anyInt(), anyInt(), anyInt(), anyBoolean()) + } + } + private fun event(action: Int, x: Int, y: Int) { eventTime += 100 if (action == MotionEvent.ACTION_DOWN) downTime = eventTime From 0e3f0aefede4234ee6f05c6242bb1953b511e6fe Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Mon, 14 Sep 2026 09:47:27 +0300 Subject: [PATCH 4/5] Clarify swipe input lifecycle and popup coordinate spaces Name popup completion and candidate cancellation explicitly, including accessibility callers. Use named coordinate-space arguments and a shared error reporter without changing catch behavior or menu UX. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d01a009-639e-403d-8608-f127da2a4ddc --- .../PopupKeysKeyboardAccessibilityDelegate.kt | 4 +- .../keyboard/keyboard/MainKeyboardView.kt | 41 ++++++++++------ .../keyboard/keyboard/PointerTracker.kt | 49 ++++++++++--------- .../keyboard/PopupKeysKeyboardView.kt | 44 +++++++++-------- 4 files changed, 77 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/accessibility/PopupKeysKeyboardAccessibilityDelegate.kt b/app/src/main/java/helium314/keyboard/accessibility/PopupKeysKeyboardAccessibilityDelegate.kt index a35133395..b33a76ae3 100644 --- a/app/src/main/java/helium314/keyboard/accessibility/PopupKeysKeyboardAccessibilityDelegate.kt +++ b/app/src/main/java/helium314/keyboard/accessibility/PopupKeysKeyboardAccessibilityDelegate.kt @@ -87,13 +87,13 @@ class PopupKeysKeyboardAccessibilityDelegate( mKeyboardView.onUpEvent(x, y, pointerId, eventTime) // TODO: Should fix this reference. This is a hack to clear the state of // {@link PointerTracker}. - PointerTracker.dismissAllPopupKeysPanels() + PointerTracker.finishAllPopupKeysInput() return } // Close the popup keys keyboard. // TODO: Should fix this reference. This is a hack to clear the state of // {@link PointerTracker}. - PointerTracker.dismissAllPopupKeysPanels() + PointerTracker.finishAllPopupKeysInput() } companion object { diff --git a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.kt b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.kt index ef330bf6b..852c65a0a 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.kt @@ -34,6 +34,7 @@ import helium314.keyboard.latin.settings.DebugSettings import helium314.keyboard.latin.settings.Defaults import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.* +import helium314.keyboard.settings.FeedbackManager import java.util.Locale import java.util.WeakHashMap @@ -295,17 +296,15 @@ class MainKeyboardView @JvmOverloads constructor( override fun showSwipeShortcutMenu( tracker: PointerTracker, direction: SwipeShortcutMenu.Direction ): PopupKeysPanel? { - val parent = keyboard ?: return null - val row = parent.swipeShortcutRows[direction] ?: return null + val sourceKeyboard = keyboard ?: return null + val sourceRow = sourceKeyboard.swipeShortcutRows[direction] ?: return null val menu = try { - SwipeShortcutMenu.Builder(context, parent, row, direction).build() + SwipeShortcutMenu.Builder(context, sourceKeyboard, sourceRow, direction).build() } catch (e: IllegalArgumentException) { - Log.e("SwipeShortcutMenu", "Invalid ${direction.layoutType} layout", e) - helium314.keyboard.settings.FeedbackManager.message(context, context.getString(R.string.layout_error, e.message)) + reportInvalidSwipeLayout(direction, e) return null } catch (e: IllegalStateException) { - Log.e("SwipeShortcutMenu", "Invalid ${direction.layoutType} layout", e) - helium314.keyboard.settings.FeedbackManager.message(context, context.getString(R.string.layout_error, e.message)) + reportInvalidSwipeLayout(direction, e) return null } val container = mPopupKeysKeyboardContainer @@ -314,18 +313,28 @@ class MainKeyboardView @JvmOverloads constructor( popup.setKeyboard(menu) container.measure(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) container.layout(0, 0, container.measuredWidth, container.measuredHeight) - val down = CoordinateUtils.newInstance() - tracker.getDownCoordinates(down) - val top = if (direction == SwipeShortcutMenu.Direction.UP) - row.top - menu.mOccupiedHeight - parent.mVerticalGap - else row.bottom + parent.mVerticalGap + val downInView = CoordinateUtils.newInstance() + tracker.getDownCoordinates(downInView) + val menuTopInKeyboard = if (direction == SwipeShortcutMenu.Direction.UP) + sourceRow.top - menu.mOccupiedHeight - sourceKeyboard.mVerticalGap + else sourceRow.bottom + sourceKeyboard.mVerticalGap popup.showSwipeShortcutPanel( - this, this, paddingLeft + row.left, paddingTop + top, - direction, CoordinateUtils.y(down), mKeyboardActionListener + parentView = this, + controller = this, + keysLeftInParent = paddingLeft + sourceRow.left, + keysTopInParent = paddingTop + menuTopInKeyboard, + direction = direction, + startYInParent = CoordinateUtils.y(downInView), + listener = mKeyboardActionListener ) return popup } + private fun reportInvalidSwipeLayout(direction: SwipeShortcutMenu.Direction, error: RuntimeException) { + Log.e("SwipeShortcutMenu", "Invalid ${direction.layoutType} layout", error) + FeedbackManager.message(context, context.getString(R.string.layout_error, error.message)) + } + fun isInDraggingFinger(): Boolean = isShowingPopupKeysPanel() || PointerTracker.isAnyInDraggingFinger() override fun onShowPopupKeysPanel(panel: PopupKeysPanel) { @@ -338,7 +347,7 @@ class MainKeyboardView @JvmOverloads constructor( } fun isShowingPopupKeysPanel(): Boolean = mPopupKeysPanel?.isShowingInParent == true - override fun onCancelPopupKeysPanel() { PointerTracker.dismissAllPopupKeysPanels() } + override fun onCancelPopupKeysPanel() { PointerTracker.finishAllPopupKeysInput() } override fun onDismissPopupKeysPanel() { if (isShowingPopupKeysPanel()) { mPopupKeysPanel?.removeFromParent(); mPopupKeysPanel = null } } fun startDoubleTapShiftKeyTimer() { mTimerHandler.startDoubleTapShiftKeyTimer() } @@ -373,7 +382,7 @@ class MainKeyboardView @JvmOverloads constructor( PointerTracker.setReleasedKeyGraphicsToAllKeys() mGestureFloatingTextDrawingPreview.dismissGestureFloatingPreviewText() mSlidingKeyInputDrawingPreview.dismissSlidingKeyInputPreview() - PointerTracker.dismissAllPopupKeysPanels() + PointerTracker.finishAllPopupKeysInput() dismissAllKeyPreviews() PointerTracker.cancelAllPointerTrackers() } diff --git a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt index af61ef334..19b0e262f 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.kt @@ -114,7 +114,8 @@ class PointerTracker private constructor( private var mIsAllowedDraggingFinger = false // true if a keyswipe gesture is enabled and warranted. private var mKeySwipeAllowed = false - private var mSwipeShortcutSource: Key? = null + // The candidate is cleared when another gesture wins; an active menu consumes the pointer. + private var mSwipeShortcutCandidateKey: Key? = null private var mSwipeShortcutActive = false private var mInTouchpadMode = false @@ -232,8 +233,7 @@ class PointerTracker private constructor( if (keyDetector === mKeyDetector && keyboard === mKeyboard) { return } - mSwipeShortcutSource = null - if (mSwipeShortcutActive) dismissPopupKeysPanel() + cancelSwipeShortcut() val oldKeyboard = mKeyboard if (oldKeyboard != null) { // changing keyboards may change height @@ -365,7 +365,7 @@ class PointerTracker private constructor( Log.d(TAG, String.format(Locale.US, "[%d] onStartBatchInput", mPointerId)) } sListener.onStartBatchInput() - dismissAllPopupKeysPanels() + finishAllPopupKeysInput() getTimerProxy().cancelLongPressTimersOf(this) } @@ -507,9 +507,10 @@ class PointerTracker private constructor( fun isShowingPopupKeysPanel(): Boolean = mPopupKeysPanel != null - private fun dismissPopupKeysPanel() { + private fun finishPopupKeysInput() { if (mSwipeShortcutActive) { mSwipeShortcutActive = false + // Dismissing or committing a menu must never fall through to typing its source key. mIsTrackingForActionDisabled = true } if (isShowingPopupKeysPanel()) { @@ -518,8 +519,13 @@ class PointerTracker private constructor( } } + private fun cancelSwipeShortcut() { + mSwipeShortcutCandidateKey = null + if (mSwipeShortcutActive) finishPopupKeysInput() + } + private fun onDownEventInternal(x: Int, y: Int, eventTime: Long) { - mSwipeShortcutSource = null + mSwipeShortcutCandidateKey = null var key = onDownKey(x, y, eventTime) val isEmojiClipBottomRow = mKeyboard?.mId?.isEmojiClipBottomRow == true mIsAllowedDraggingFinger = (sParams?.mKeySelectionByDraggingFinger == true) || @@ -551,7 +557,7 @@ class PointerTracker private constructor( val settings = Settings.getValues() if (getActivePointerTrackerCount() == 1 && !mKeySwipeAllowed && (settings.mSwipeUpMenuEnabled || settings.mSwipeDownMenuEnabled)) { - mSwipeShortcutSource = key + mSwipeShortcutCandidateKey = key } } } @@ -624,8 +630,8 @@ class PointerTracker private constructor( } if (!isShowingPopupKeysPanel() && me != null && - (mSwipeShortcutSource != null || sGestureEnabler.shouldHandleGesture())) { - // Add historical points to gesture path. + (mSwipeShortcutCandidateKey != null || sGestureEnabler.shouldHandleGesture())) { + // Let the earliest decisive sample choose between shortcuts and glide. val pointerIndex = me.findPointerIndex(mPointerId) val historicalSize = me.historySize for (h in 0 until historicalSize) { @@ -654,17 +660,17 @@ class PointerTracker private constructor( } private fun tryStartSwipeShortcut(x: Int, y: Int, eventTime: Long): Boolean { - val source = mSwipeShortcutSource ?: return false + val source = mSwipeShortcutCandidateKey ?: return false if (sInGesture || sInKeySwipe || isShowingPopupKeysPanel() || getActivePointerTrackerCount() != 1 || mIsTrackingForActionDisabled) { - mSwipeShortcutSource = null + mSwipeShortcutCandidateKey = null return false } val dx = x - CoordinateUtils.x(mDownCoordinates) val dy = y - CoordinateUtils.y(mDownCoordinates) val threshold = maxOf(2 * sPointerStep, source.height / 4) if (abs(dx) < threshold && abs(dy) < threshold) return false - mSwipeShortcutSource = null + mSwipeShortcutCandidateKey = null if (abs(dy) <= abs(dx)) return false val direction = if (dy < 0) SwipeShortcutMenu.Direction.UP else SwipeShortcutMenu.Direction.DOWN val settings = Settings.getValues() @@ -930,7 +936,7 @@ class PointerTracker private constructor( } private fun onUpEventInternal(x: Int, y: Int, eventTime: Long) { - mSwipeShortcutSource = null + mSwipeShortcutCandidateKey = null getTimerProxy().cancelKeyTimersOf(this) val isInDraggingFinger = mIsInDraggingFinger val isInSlidingKeyInput = mIsInSlidingKeyInput @@ -955,7 +961,7 @@ class PointerTracker private constructor( panel.onUpEvent(translatedX, translatedY, mPointerId, eventTime) } } - dismissPopupKeysPanel() + finishPopupKeysInput() if (isInSlidingKeyInput) { callListenerOnFinishSlidingInput() } @@ -1007,7 +1013,7 @@ class PointerTracker private constructor( } override fun cancelTrackingForAction() { - mSwipeShortcutSource = null + mSwipeShortcutCandidateKey = null if (isShowingPopupKeysPanel() && !mSwipeShortcutActive) { return } @@ -1017,7 +1023,7 @@ class PointerTracker private constructor( val isInOperation: Boolean get() = !mIsTrackingForActionDisabled fun onLongPressed() { - mSwipeShortcutSource = null + mSwipeShortcutCandidateKey = null getTimerProxy().cancelLongPressTimersOf(this) if (isShowingPopupKeysPanel()) { return @@ -1082,7 +1088,7 @@ class PointerTracker private constructor( getTimerProxy().cancelKeyTimersOf(this) setReleasedKeyGraphics(mCurrentKey, true) resetKeySelectionByDraggingFinger() - dismissPopupKeysPanel() + finishPopupKeysInput() } private fun isMajorEnoughMoveToBeOnNewKey( @@ -1172,7 +1178,7 @@ class PointerTracker private constructor( } fun onKeyRepeat(code: Int, repeatCount: Int) { - mSwipeShortcutSource = null + mSwipeShortcutCandidateKey = null val key = key if (key == null || key.code != code) { mCurrentRepeatingKeyCode = Constants.NOT_A_CODE @@ -1327,8 +1333,7 @@ class PointerTracker private constructor( fun cancelSwipeShortcutMenus() { for (tracker in sTrackers) { - tracker.mSwipeShortcutSource = null - if (tracker.mSwipeShortcutActive) tracker.dismissPopupKeysPanel() + tracker.cancelSwipeShortcut() } } @@ -1358,11 +1363,11 @@ class PointerTracker private constructor( } } - fun dismissAllPopupKeysPanels() { + fun finishAllPopupKeysInput() { val trackersSize = sTrackers.size for (i in 0 until trackersSize) { val tracker = sTrackers[i] - tracker.dismissPopupKeysPanel() + tracker.finishPopupKeysInput() } } diff --git a/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.kt b/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.kt index ec5000592..21038d3a5 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.kt @@ -46,11 +46,11 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( private var mController: PopupKeysPanel.Controller = PopupKeysPanel.EMPTY_CONTROLLER protected var mListener: KeyboardActionListener? = null protected var mEmojiViewCallback: EmojiViewCallback? = null - private var mOriginX = 0 - private var mOriginY = 0 + private var mOriginXInParent = 0 + private var mOriginYInParent = 0 private var mCurrentKey: Key? = null private var mSwipeShortcutDirection: SwipeShortcutMenu.Direction? = null - private var mSwipeShortcutCancelY = 0 + private var mSwipeStartYInParent = 0 private var mActivePointerId = 0 @@ -142,24 +142,26 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( fun showSwipeShortcutPanel( parentView: View, controller: PopupKeysPanel.Controller, - left: Int, - top: Int, + keysLeftInParent: Int, + keysTopInParent: Int, direction: SwipeShortcutMenu.Direction, - cancelY: Int, + startYInParent: Int, listener: KeyboardActionListener ) { mListener = listener mEmojiViewCallback = null mController = controller mSwipeShortcutDirection = direction - mSwipeShortcutCancelY = cancelY - mOriginX = left - paddingLeft - mOriginY = top - paddingTop + mSwipeStartYInParent = startYInParent + mOriginXInParent = keysLeftInParent - paddingLeft + mOriginYInParent = keysTopInParent - paddingTop val container = getContainerView() parentView.getLocationInWindow(mCoordinates) - // Only the background padding may overhang; the key centers stay on the source row. - container.x = (CoordinateUtils.x(mCoordinates) + mOriginX - this.left).toFloat() - container.y = (CoordinateUtils.y(mCoordinates) + mOriginY - this.top).toFloat() + val parentXInWindow = CoordinateUtils.x(mCoordinates) + val parentYInWindow = CoordinateUtils.y(mCoordinates) + // The preview container is window-relative; its child view includes background padding. + container.x = (parentXInWindow + mOriginXInParent - left).toFloat() + container.y = (parentYInWindow + mOriginYInParent - top).toFloat() translationX = 0f controller.setLayoutGravity(Gravity.CENTER_HORIZONTAL) onPanelShown() @@ -223,8 +225,8 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( translationX = (panelFinalX - containerFinalX).toFloat() controller.setLayoutGravity(layoutGravity) - mOriginX = panelFinalX - mOriginY = y + container.paddingTop + this.y.toInt() + mOriginXInParent = panelFinalX + mOriginYInParent = y + container.paddingTop + this.y.toInt() onPanelShown() } @@ -325,13 +327,13 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( private fun detectSwipeShortcut(x: Int, y: Int): Key? { val keyboard = keyboard ?: return null - val parentY = y + mOriginY + val touchYInParent = y + mOriginYInParent val outward = if (mSwipeShortcutDirection == SwipeShortcutMenu.Direction.UP) - parentY < mSwipeShortcutCancelY else parentY > mSwipeShortcutCancelY - val touchX = mKeyDetector.getTouchX(x) - if (!outward || touchX < 0 || touchX >= keyboard.mOccupiedWidth) return null + touchYInParent < mSwipeStartYInParent else touchYInParent > mSwipeStartYInParent + val touchXInKeyboard = mKeyDetector.getTouchX(x) + if (!outward || touchXInKeyboard < 0 || touchXInKeyboard >= keyboard.mOccupiedWidth) return null // Project onto the menu row so a short outward swipe already selects its aligned item. - return keyboard.sortedKeys.minByOrNull { abs(touchX - (it.x + it.width / 2)) } + return keyboard.sortedKeys.minByOrNull { abs(touchXInKeyboard - (it.x + it.width / 2)) } ?.takeIf { it.isEnabled && !it.isSpacer } } @@ -357,11 +359,11 @@ open class PopupKeysKeyboardView @JvmOverloads constructor( } override fun translateX(x: Int): Int { - return x - mOriginX + return x - mOriginXInParent } override fun translateY(y: Int): Int { - return y - mOriginY + return y - mOriginYInParent } @SuppressLint("ClickableViewAccessibility") From da4985e936f0d80ceb1ba8b24ce9e1f7ede8656b Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Mon, 14 Sep 2026 09:47:59 +0300 Subject: [PATCH 5/5] Record swipe glide fix and cleanup in fork changelog Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8d01a009-639e-403d-8608-f127da2a4ddc --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4a873b4..1824994ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ ### Added - Optional swipe-up and swipe-down shortcut menus, aligned with the top and bottom character rows and editable through Secondary layouts. The upper gesture follows the number row when shown. (#156) +### Fixed +- Cancel queued glide updates when a swipe shortcut menu takes over the touch gesture. (#156) + +### Reliability & testing +- Exercise swipe menus with glide detection both enabled and disabled, including completed glide paths, pending timers and recovery after cancellation; clarify popup input completion and coordinate spaces. (#156) + ### Upstream - Based on LeanBitLab/LeanType commit `2a9d5fecb67c12b834ba2a1e80ffcdc8ab7d6720`; upstream release history remains in `docs/releasenote/`.