diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 000000000..1d2bbb276 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,85 @@ +# Vertical Shortcut Rows + +## Goal + +Add an upstreamable, optional vertical shortcut-row gesture to LeanType: moving upward from an eligible top-row source key opens a top shortcut row; moving downward from an eligible bottom-row source key opens a bottom shortcut row. + +The feature gives users access to compact shortcut/action rows without changing normal tap input, long-press popups, or swipe/gesture typing semantics. + +## Non-goals + +- No arbitrary all-direction per-key swipe action system. +- No two-thumb typing changes. +- No toolbar, clipboard, dictionary, spacing, or autocorrect changes. +- No local test identity changes (`applicationId`, package name, app label, version). +- No Java/Kotlin package or namespace refactor. + +## User-visible behavior + +- A setting enables/disables shortcut rows globally. +- Separate settings allow the top and bottom shortcut rows to be independently enabled. +- When enabled, a pointer that starts on an eligible top-row normal key can move vertically upward to show the top shortcut row. +- A pointer that starts on an eligible bottom-row normal key can move vertically downward to show the bottom shortcut row. +- The gesture must be vertical-dominant (`abs(dY) > abs(dX)`) and exceed the normal pointer step threshold so regular taps and horizontal gesture typing are not hijacked. +- Once the shortcut row is shown, subsequent pointer movement is interpreted against that temporary row until release/cancel. + +## Design + +### Layouts + +Add two layout types: + +- `shortcut_top` +- `shortcut_bottom` + +Each type has a default JSON layout asset under `app/src/main/assets/layouts/`. + +### Pointer tracking + +`PointerTracker` records whether the original down key is eligible for the top and/or bottom shortcut row. Eligibility requires: + +- shortcut rows are globally enabled; +- the specific row setting is enabled; +- the current keyboard is alphabetic; +- the source key is a normal enabled non-modifier, non-space key; +- the source key belongs to the top-most or bottom-most eligible letter row for the requested direction. + +During move handling, before regular gesture movement consumes the stroke, the tracker checks whether the movement is vertical-dominant and crosses the pointer step threshold. If so, it asks the drawing proxy to show the matching shortcut-row keyboard, marks the shortcut-row swipe active, and prevents gesture typing from continuing for that pointer. + +### Keyboard view/switcher + +`MainKeyboardView` exposes a narrow method to temporarily show a keyboard for `LayoutType.SHORTCUT_TOP` or `LayoutType.SHORTCUT_BOTTOM` and translate it above/below the source key. + +The temporary keyboard is hidden on cancel/release via existing popup-key dismissal paths. + +### Settings + +Use the repository settings wiring pattern: + +1. `Settings.java` +2. `Defaults.kt` +3. `SettingsValues.java` +4. `res/values/strings.xml` +5. existing settings screen entry + +The settings should default off for upstream safety. + +### Tests + +Add focused JVM tests for: + +- shortcut-row layout assets parse to popup key specs; +- settings container wiring includes the new preferences if covered by existing settings tests. + +## Verification + +Run targeted tests only: + +- `:app:testOfflineDebugUnitTest --tests "*KeyboardParserTest*"` +- settings wiring test if modified/available + +## Commit plan + +1. Commit this `SPEC.md` design. +2. Commit the upstreamable implementation. +3. Add a separate local-only test identity commit after the upstreamable implementation is complete and verified. diff --git a/app/src/main/assets/layouts/shortcut_bottom/shortcut_bottom.txt b/app/src/main/assets/layouts/shortcut_bottom/shortcut_bottom.txt new file mode 100644 index 000000000..8baa78546 --- /dev/null +++ b/app/src/main/assets/layouts/shortcut_bottom/shortcut_bottom.txt @@ -0,0 +1,7 @@ +←|!code/key_arrow_left +↓|!code/key_arrow_down +↑|!code/key_arrow_up +→|!code/key_arrow_right +W←|!code/key_word_left +W→|!code/key_word_right +Ins|!code/key_insert diff --git a/app/src/main/assets/layouts/shortcut_top/shortcut_top.txt b/app/src/main/assets/layouts/shortcut_top/shortcut_top.txt new file mode 100644 index 000000000..fa7659e8a --- /dev/null +++ b/app/src/main/assets/layouts/shortcut_top/shortcut_top.txt @@ -0,0 +1,6 @@ +Home|!code/key_home +End|!code/key_end +PgUp|!code/key_page_up +PgDn|!code/key_page_down +Tab|!code/key_event_tab +Esc|!code/key_escape diff --git a/app/src/main/java/helium314/keyboard/keyboard/Key.java b/app/src/main/java/helium314/keyboard/keyboard/Key.java index e8cd1f274..97fc7fd83 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/Key.java +++ b/app/src/main/java/helium314/keyboard/keyboard/Key.java @@ -284,6 +284,42 @@ protected Key(@NonNull final Key key, @Nullable final PopupKeySpec[] popupKeys, mPressed = key.mPressed; mEnabled = key.mEnabled; } + public static Key copyWithShortcutPopupKeys(@NonNull final Key key, + @NonNull final PopupKeySpec[] popupKeys) { + return new Key(key, popupKeys, null, key.mBackgroundType, + POPUP_KEYS_MODE_FIXED_COLUMN_WITH_FIXED_ORDER + | (popupKeys.length & POPUP_KEYS_COLUMN_NUMBER_MASK)); + } + + private Key(@NonNull final Key key, @Nullable final PopupKeySpec[] popupKeys, + @Nullable final String labelHint, final int backgroundType, + final int popupKeysColumnAndFlags) { + // Final attributes. + mCode = key.mCode; + mLabel = key.mLabel; + mHintLabel = labelHint; + mLabelFlags = key.mLabelFlags | LABEL_FLAGS_HAS_POPUP_HINT; + mIconName = key.mIconName; + mWidth = key.mWidth; + mHeight = key.mHeight; + mHorizontalGap = key.mHorizontalGap; + mVerticalGap = key.mVerticalGap; + mX = key.mX; + mY = key.mY; + mHitBox.set(key.mHitBox); + mPopupKeys = popupKeys; + mPopupKeysColumnAndFlags = popupKeysColumnAndFlags; + mBackgroundType = backgroundType; + mActionFlags = key.mActionFlags; + mKeyVisualAttributes = key.mKeyVisualAttributes; + mOptionalAttributes = key.mOptionalAttributes; + mHashCode = key.mHashCode; + // Key state. + mPressed = key.mPressed; + mEnabled = key.mEnabled; + } + + /** * constructor for creating emoji recent keys when there is no keyboard to take diff --git a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java index 9d8cab874..6b65a8a5d 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java @@ -61,6 +61,7 @@ import helium314.keyboard.latin.settings.Settings; import helium314.keyboard.latin.utils.KtxKt; import helium314.keyboard.latin.utils.LanguageOnSpacebarUtils; +import helium314.keyboard.latin.utils.LayoutType; import helium314.keyboard.latin.utils.Log; import helium314.keyboard.latin.utils.ToolbarKey; import helium314.keyboard.latin.utils.TypefaceUtils; @@ -509,27 +510,27 @@ protected void onDetachedFromWindow() { @Nullable public PopupKeysPanel showPopupKeysKeyboard(@NonNull final Key key, @NonNull final PointerTracker tracker) { + return showPopupKeysKeyboard(key, tracker, false, + PopupKeysKeyboardView.NO_ROW_ALIGN, 0); + } + + @Nullable + private PopupKeysPanel showPopupKeysKeyboard(@NonNull final Key key, + @NonNull final PointerTracker tracker, final boolean belowSourceKey, + final int rowAlignedLeftX, final int fixedKeyWidth) { final PopupKeySpec[] popupKeys = key.getPopupKeys(); if (popupKeys == null) { return null; } Keyboard popupKeysKeyboard = mPopupKeysKeyboardCache.get(key); if (popupKeysKeyboard == null) { - // {@link KeyPreviewDrawParams#mPreviewVisibleWidth} should have been set at - // {@link - // KeyPreviewChoreographer#placeKeyPreview(Key,TextView,KeyboardIconsSet,KeyDrawParams,int,int[]}, - // though there may be some chances that the value is zero. width == - // 0 - // will cause zero-division error at - // {@link - // PopupKeysKeyboardParams#setParameters(int,int,int,int,int,int,boolean,int)}. final boolean isSinglePopupKeyWithPreview = mKeyPreviewDrawParams.isPopupEnabled() && key.hasPreview() && popupKeys.length == 1 && mKeyPreviewDrawParams.getVisibleWidth() > 0; final PopupKeysKeyboard.Builder builder = new PopupKeysKeyboard.Builder( getContext(), key, getKeyboard(), isSinglePopupKeyWithPreview, mKeyPreviewDrawParams.getVisibleWidth(), - mKeyPreviewDrawParams.getVisibleHeight(), newLabelPaint(key)); + mKeyPreviewDrawParams.getVisibleHeight(), newLabelPaint(key), fixedKeyWidth); popupKeysKeyboard = builder.build(); mPopupKeysKeyboardCache.put(key, popupKeysKeyboard); } @@ -543,26 +544,50 @@ public PopupKeysPanel showPopupKeysKeyboard(@NonNull final Key key, final int[] lastCoords = CoordinateUtils.newInstance(); tracker.getLastCoordinates(lastCoords); final boolean keyPreviewEnabled = mKeyPreviewDrawParams.isPopupEnabled() && key.hasPreview(); - // The popup keys keyboard is usually horizontally aligned with the center of - // the parent key. - // If showPopupKeysKeyboardAtTouchedPoint is true and the key preview is - // disabled, the more - // keys keyboard is placed at the touch point of the parent key. final int pointX = (mConfigShowPopupKeysKeyboardAtTouchedPoint && !keyPreviewEnabled) ? CoordinateUtils.x(lastCoords) : key.getX() + key.getWidth() / 2; - // The popup keys keyboard is usually vertically aligned with the top edge of - // the parent key - // (plus vertical gap). If the key preview is enabled, the popup keys keyboard - // is vertically - // aligned with the bottom edge of the visible part of the key preview. - // {@code mPreviewVisibleOffset} has been set appropriately in - // {@link KeyboardView#showKeyPreview(PointerTracker)}. - final int pointY = key.getY() + mKeyPreviewDrawParams.getVisibleOffset(); + final int pointY = belowSourceKey + ? key.getY() + key.getHeight() + : key.getY() + mKeyPreviewDrawParams.getVisibleOffset(); + popupKeysKeyboardView.setShowBelowAnchor(belowSourceKey); + popupKeysKeyboardView.setRowAlignedLeftX(rowAlignedLeftX); popupKeysKeyboardView.showPopupKeysPanel(this, this, pointX, pointY, mKeyboardActionListener); return popupKeysKeyboardView; } + @Override + @Nullable + public PopupKeysPanel showShortcutRowKeyboard(@NonNull final Key key, + @NonNull final PointerTracker tracker, @NonNull final LayoutType layoutType, + final boolean belowSourceKey) { + final Keyboard keyboard = getKeyboard(); + if (keyboard == null) { + return null; + } + final Key popupParentKey = ShortcutRowKeys.createPopupParentKey( + getContext(), key, keyboard, layoutType); + if (popupParentKey == null) { + return null; + } + int rowLeftX = Integer.MAX_VALUE; + int rowRightX = Integer.MIN_VALUE; + for (final Key rowKey : keyboard.getSortedKeys()) { + if (rowKey.getY() == key.getY() && rowKey.getBackgroundType() == Key.BACKGROUND_TYPE_NORMAL + && !rowKey.isModifier() && !rowKey.isSpacer()) { + rowLeftX = Math.min(rowLeftX, rowKey.getX()); + rowRightX = Math.max(rowRightX, rowKey.getX() + rowKey.getWidth()); + } + } + if (rowLeftX == Integer.MAX_VALUE) { + rowLeftX = key.getX(); + rowRightX = key.getX() + key.getWidth(); + } + final int iconCount = popupParentKey.getPopupKeys().length; + final int proportionalKeyWidth = iconCount > 0 ? (rowRightX - rowLeftX) / iconCount : 0; + return showPopupKeysKeyboard(popupParentKey, tracker, belowSourceKey, rowLeftX, proportionalKeyWidth); + } + public boolean isInDraggingFinger() { if (isShowingPopupKeysPanel()) { return true; diff --git a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java index b4fe8ce76..1ef13e1d8 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java +++ b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java @@ -37,6 +37,7 @@ import helium314.keyboard.latin.settings.Settings; import helium314.keyboard.latin.settings.SettingsValues; import helium314.keyboard.latin.utils.KtxKt; +import helium314.keyboard.latin.utils.LayoutType; import helium314.keyboard.latin.utils.Log; import java.util.ArrayList; @@ -177,6 +178,10 @@ public static void switchTo(DrawingProxy drawingProxy) { // true if a keyswipe gesture is enabled and warranted. private boolean mKeySwipeAllowed = false; private static boolean sInKeySwipe = false; + private boolean mShortcutTopRowSwipeAllowed = false; + private boolean mShortcutBottomRowSwipeAllowed = false; + private boolean mInShortcutRowSwipe = false; + private static boolean sInShortcutRowSwipe = false; // Touchpad mode for cursor control public static boolean sPersistentTouchpadModeActive = false; @@ -741,6 +746,10 @@ private void dismissPopupKeysPanel() { mPopupKeysPanel.dismissPopupKeysPanel(); mPopupKeysPanel = null; } + if (mInShortcutRowSwipe) { + mInShortcutRowSwipe = false; + sInShortcutRowSwipe = false; + } } private void onDownEventInternal(final int x, final int y, final long eventTime) { @@ -759,6 +768,9 @@ private void onDownEventInternal(final int x, final int y, final long eventTime) mKeySwipeAllowed = true; sInKeySwipe = true; } + mShortcutTopRowSwipeAllowed = isShortcutRowSource(key, true); + mShortcutBottomRowSwipeAllowed = isShortcutRowSource(key, false); + mInShortcutRowSwipe = false; mKeyboardLayoutHasBeenChanged = false; mIsTrackingForActionDisabled = false; resetKeySelectionByDraggingFinger(); @@ -1106,9 +1118,74 @@ private void onKeySwipe(final int code, final int x, final int y, final long eve } } + + private boolean isShortcutRowSource(final Key key, final boolean topRow) { + final SettingsValues sv = Settings.getValues(); + if ((topRow && !sv.mShortcutTopRowEnabled) + || (!topRow && !sv.mShortcutBottomRowEnabled) + || key == null || mKeyboard == null || key.isSpacer() || !key.isEnabled() + || key.isModifier() || isSwiper(key.getCode()) + || !mKeyboard.mId.isAlphabetKeyboard()) { + return false; + } + final int sourceY = key.getY(); + boolean foundEligibleRow = false; + int targetY = topRow ? Integer.MAX_VALUE : Integer.MIN_VALUE; + for (final Key candidate : mKeyboard.getSortedKeys()) { + if (candidate.isSpacer() || !candidate.isEnabled() || candidate.isModifier() + || isSwiper(candidate.getCode()) + || candidate.getBackgroundType() != Key.BACKGROUND_TYPE_NORMAL) { + continue; + } + foundEligibleRow = true; + targetY = topRow ? Math.min(targetY, candidate.getY()) : Math.max(targetY, candidate.getY()); + } + return foundEligibleRow && sourceY == targetY; + } + + private boolean tryStartShortcutRowSwipe(final int x, final int y, final long eventTime) { + if (mInShortcutRowSwipe || isShowingPopupKeysPanel() || sInGesture || sInKeySwipe + || sInShortcutRowSwipe || mCurrentKey == null) { + return mInShortcutRowSwipe; + } + final int dX = x - mStartX; + final int dY = y - mStartY; + final LayoutType layoutType; + final boolean belowSourceKey; + if (mShortcutTopRowSwipeAllowed && dY <= -sPointerStep && abs(dY) > abs(dX)) { + layoutType = LayoutType.SHORTCUT_TOP; + belowSourceKey = false; + } else if (mShortcutBottomRowSwipeAllowed && dY >= sPointerStep && abs(dY) > abs(dX)) { + layoutType = LayoutType.SHORTCUT_BOTTOM; + belowSourceKey = true; + } else { + return false; + } + + final PopupKeysPanel popupKeysPanel = sDrawingProxy.showShortcutRowKeyboard( + mCurrentKey, this, layoutType, belowSourceKey); + if (popupKeysPanel == null) { + return false; + } + sTimerProxy.cancelKeyTimersOf(this); + mIsDetectingGesture = false; + setReleasedKeyGraphics(mCurrentKey, true); + final int translatedX = popupKeysPanel.translateX(x); + final int translatedY = popupKeysPanel.translateY(y); + popupKeysPanel.onDownEvent(translatedX, translatedY, mPointerId, eventTime); + mPopupKeysPanel = popupKeysPanel; + mInShortcutRowSwipe = true; + sInShortcutRowSwipe = true; + return true; + } + private void onMoveEventInternal(final int x, final int y, final long eventTime) { final Key oldKey = mCurrentKey; + if (tryStartShortcutRowSwipe(x, y, eventTime)) { + return; + } + // todo (later): move key swipe stuff to KeyboardActionListener (and finally // extend it) if (mKeySwipeAllowed) { @@ -1179,6 +1256,9 @@ public void onPhantomUpEvent(final long eventTime) { if (DEBUG_EVENT) { printTouchEvent("onPhntEvent:", mLastX, mLastY, eventTime); } + if (mInShortcutRowSwipe) { + mIsTrackingForActionDisabled = true; + } onUpEventInternal(mLastX, mLastY, eventTime); cancelTrackingForAction(); } diff --git a/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboard.java b/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboard.java index 645e420bb..04c470738 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboard.java +++ b/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboard.java @@ -262,6 +262,13 @@ public static class Builder extends KeyboardBuilder { public Builder(final Context context, final Key key, final Keyboard keyboard, final boolean isSinglePopupKeyWithPreview, final int keyPreviewVisibleWidth, final int keyPreviewVisibleHeight, final Paint paintToMeasure) { + this(context, key, keyboard, isSinglePopupKeyWithPreview, keyPreviewVisibleWidth, + keyPreviewVisibleHeight, paintToMeasure, 0); + } + + public Builder(final Context context, final Key key, final Keyboard keyboard, + final boolean isSinglePopupKeyWithPreview, final int keyPreviewVisibleWidth, + final int keyPreviewVisibleHeight, final Paint paintToMeasure, final int fixedKeyWidth) { super(context, new PopupKeysKeyboardParams()); mParams.mId = keyboard.mId; readAttributes(keyboard.mPopupKeysTemplate); @@ -283,6 +290,9 @@ public Builder(final Context context, final Key key, final Keyboard keyboard, // adjusted with their bottom paddings deducted. keyWidth = keyPreviewVisibleWidth; rowHeight = keyPreviewVisibleHeight + mParams.mVerticalGap; + } else if (fixedKeyWidth > 0) { + keyWidth = fixedKeyWidth; + rowHeight = keyboard.mMostCommonKeyHeight; } else { final float padding = context.getResources().getDimension( R.dimen.config_popup_keys_keyboard_key_horizontal_padding) diff --git a/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.java b/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.java index ef5b80352..6f58adc08 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/PopupKeysKeyboardView.java @@ -44,6 +44,9 @@ public class PopupKeysKeyboardView extends KeyboardView implements PopupKeysPane private int mOriginX; private int mOriginY; private Key mCurrentKey; + private boolean mShowBelowAnchor; + public static final int NO_ROW_ALIGN = Integer.MIN_VALUE; + private int mRowAlignedLeftX = NO_ROW_ALIGN; private int mActivePointerId; @@ -138,6 +141,14 @@ public void showPopupKeysPanel(final View parentView, final Controller controlle showPopupKeysPanelInternal(parentView, controller, pointX, pointY); } + public void setShowBelowAnchor(final boolean below) { + mShowBelowAnchor = below; + } + + public void setRowAlignedLeftX(final int rowLeftX) { + mRowAlignedLeftX = rowLeftX; + } + @SuppressLint("RtlHardcoded") // a key on the left is on the left, independent of layout direction private void showPopupKeysPanelInternal(final View parentView, final Controller controller, final int pointX, final int pointY) { @@ -145,9 +156,13 @@ private void showPopupKeysPanelInternal(final View parentView, final Controller final View container = getContainerView(); // The coordinates of panel's left-top corner in parentView's coordinate system. // We need to consider background drawable paddings. - final int x = pointX - getDefaultCoordX() - container.getPaddingLeft() - getPaddingLeft(); - final int y = pointY - container.getMeasuredHeight() + container.getPaddingBottom() - + getPaddingBottom(); + final int x = (mRowAlignedLeftX != NO_ROW_ALIGN) + ? mRowAlignedLeftX - container.getPaddingLeft() - getPaddingLeft() + : pointX - getDefaultCoordX() - container.getPaddingLeft() - getPaddingLeft(); + final int y = mShowBelowAnchor + ? pointY - container.getPaddingTop() - getPaddingTop() + : pointY - container.getMeasuredHeight() + container.getPaddingBottom() + + getPaddingBottom(); parentView.getLocationInWindow(mCoordinates); final int containerY = y + CoordinateUtils.y(mCoordinates); diff --git a/app/src/main/java/helium314/keyboard/keyboard/ShortcutRowKeys.kt b/app/src/main/java/helium314/keyboard/keyboard/ShortcutRowKeys.kt new file mode 100644 index 000000000..9743eacd8 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/keyboard/ShortcutRowKeys.kt @@ -0,0 +1,47 @@ +package helium314.keyboard.keyboard + +import android.content.Context +import helium314.keyboard.keyboard.internal.KeyboardParams +import helium314.keyboard.keyboard.internal.PopupKeySpec +import helium314.keyboard.keyboard.internal.keyboard_parser.LayoutParser +import helium314.keyboard.keyboard.internal.keyboard_parser.addLocaleKeyTextsToParams +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.utils.LayoutType + +object ShortcutRowKeys { + @JvmStatic + fun createPopupParentKey( + context: Context, + sourceKey: Key, + parentKeyboard: Keyboard, + layoutType: LayoutType, + ): Key? { + val params = KeyboardParams() + params.mId = parentKeyboard.mId + params.mThemeId = parentKeyboard.mThemeId + params.mOccupiedWidth = parentKeyboard.mOccupiedWidth + params.mOccupiedHeight = parentKeyboard.mOccupiedHeight + params.mBaseWidth = parentKeyboard.mBaseWidth + params.mBaseHeight = parentKeyboard.mBaseHeight + params.mTopPadding = parentKeyboard.mTopPadding + params.mVerticalGap = parentKeyboard.mVerticalGap / 2 + params.mDefaultKeyWidth = parentKeyboard.mMostCommonKeyWidth.toFloat() / parentKeyboard.mBaseWidth + params.mDefaultRowHeight = parentKeyboard.mMostCommonKeyHeight.toFloat() / parentKeyboard.mBaseHeight + params.mDefaultAbsoluteKeyWidth = parentKeyboard.mMostCommonKeyWidth + params.mAbsolutePopupKeyWidth = parentKeyboard.mMostCommonKeyWidth + params.mDefaultAbsoluteRowHeight = parentKeyboard.mMostCommonKeyHeight + params.mMaxPopupKeysKeyboardColumn = 12 + addLocaleKeyTextsToParams(context, params, Settings.getValues().mShowMorePopupKeys) + + val row = LayoutParser.parseLayout(layoutType, params, context) + .firstOrNull { it.isNotEmpty() } + ?: return null + val specs = row.mapNotNull { keyData -> + keyData.compute(params)?.getPopupLabel(params) + }.map { popupLabel -> + PopupKeySpec(popupLabel, false, params.mId.locale) + }.toTypedArray() + if (specs.isEmpty()) return null + return Key.copyWithShortcutPopupKeys(sourceKey, specs) + } +} diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java b/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java index 986bf9197..fbe44d7af 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java @@ -12,6 +12,7 @@ import helium314.keyboard.keyboard.Key; import helium314.keyboard.keyboard.PopupKeysPanel; import helium314.keyboard.keyboard.PointerTracker; +import helium314.keyboard.latin.utils.LayoutType; public interface DrawingProxy { /** @@ -37,6 +38,10 @@ public interface DrawingProxy { @Nullable PopupKeysPanel showPopupKeysKeyboard(@NonNull Key key, @NonNull PointerTracker tracker); + @Nullable + PopupKeysPanel showShortcutRowKeyboard(@NonNull Key key, @NonNull PointerTracker tracker, + @NonNull LayoutType layoutType, boolean belowSourceKey); + /** * Start a while-typing-animation. * @param fadeInOrOut {@link #FADE_IN} starts while-typing-fade-in animation. diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardCodesSet.java b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardCodesSet.java index 7671784e0..ac89ebe89 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardCodesSet.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardCodesSet.java @@ -55,7 +55,20 @@ public static int getCode(final String name) { "key_toggle_onehanded", "key_start_onehanded", // keep name to avoid breaking custom layouts "key_stop_onehanded", // keep name to avoid breaking custom layouts - "key_switch_onehanded" + "key_switch_onehanded", + "key_arrow_left", + "key_arrow_down", + "key_arrow_up", + "key_arrow_right", + "key_home", + "key_end", + "key_page_up", + "key_page_down", + "key_word_left", + "key_word_right", + "key_escape", + "key_insert", + "key_event_tab" }; private static final int[] DEFAULT = { @@ -81,7 +94,20 @@ public static int getCode(final String name) { KeyCode.TOGGLE_ONE_HANDED_MODE, KeyCode.TOGGLE_ONE_HANDED_MODE, KeyCode.TOGGLE_ONE_HANDED_MODE, - KeyCode.SWITCH_ONE_HANDED_MODE + KeyCode.SWITCH_ONE_HANDED_MODE, + KeyCode.ARROW_LEFT, + KeyCode.ARROW_DOWN, + KeyCode.ARROW_UP, + KeyCode.ARROW_RIGHT, + KeyCode.MOVE_START_OF_LINE, + KeyCode.MOVE_END_OF_LINE, + KeyCode.PAGE_UP, + KeyCode.PAGE_DOWN, + KeyCode.WORD_LEFT, + KeyCode.WORD_RIGHT, + KeyCode.ESCAPE, + KeyCode.INSERT, + KeyCode.TAB }; static { 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 f0e6034c7..01f155241 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -38,6 +38,8 @@ object Defaults { LayoutType.PHONE_SYMBOLS -> "phone_symbols" LayoutType.EMOJI_BOTTOM -> "emoji_bottom_row" LayoutType.CLIPBOARD_BOTTOM -> "clip_bottom_row" + LayoutType.SHORTCUT_TOP -> "shortcut_top" + LayoutType.SHORTCUT_BOTTOM -> "shortcut_bottom" LayoutType.HANDWRITING_BOTTOM -> "handwriting_bottom_row" LayoutType.EDITING -> "editing" } @@ -106,6 +108,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_SHORTCUT_TOP_ROW = false + const val PREF_SHORTCUT_BOTTOM_ROW = 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.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index b676fb54f..ec484a5e7 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -107,6 +107,8 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_SPACE_HORIZONTAL_SWIPE = "horizontal_space_swipe"; public static final String PREF_SPACE_VERTICAL_SWIPE = "vertical_space_swipe"; public static final String PREF_DELETE_SWIPE = "delete_swipe"; + public static final String PREF_SHORTCUT_TOP_ROW = "shortcut_top_row"; + public static final String PREF_SHORTCUT_BOTTOM_ROW = "shortcut_bottom_row"; public static final String PREF_AUTOSPACE_AFTER_PUNCTUATION = "autospace_after_punctuation"; public static final String PREF_AUTOSPACE_AFTER_EMOJI = "autospace_after_emoji"; public static final String PREF_AUTOSPACE_AFTER_SUGGESTION = "autospace_after_suggestion"; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index f54150a9f..51a7b94be 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -85,6 +85,8 @@ public class SettingsValues { public final boolean mTouchpadFullscreen; public final boolean mForceAutoCaps; public final boolean mDeleteSwipeEnabled; + public final boolean mShortcutTopRowEnabled; + public final boolean mShortcutBottomRowEnabled; public final boolean mAutospaceAfterPunctuation; public final boolean mAutospaceAfterEmoji; public final boolean mAutospaceAfterSuggestion; @@ -357,7 +359,10 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina 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); + mDeleteSwipeEnabled = prefs.getBoolean(Settings.PREF_DELETE_SWIPE, Defaults.PREF_DELETE_SWIPE); mShortcutTopRowEnabled = prefs.getBoolean(Settings.PREF_SHORTCUT_TOP_ROW, + Defaults.PREF_SHORTCUT_TOP_ROW); + mShortcutBottomRowEnabled = prefs.getBoolean(Settings.PREF_SHORTCUT_BOTTOM_ROW, + Defaults.PREF_SHORTCUT_BOTTOM_ROW); mAutospaceAfterPunctuation = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, Defaults.PREF_AUTOSPACE_AFTER_PUNCTUATION); mAutospaceAfterEmoji = prefs.getBoolean(Settings.PREF_AUTOSPACE_AFTER_EMOJI, 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 54def4bf1..fb0e267cf 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt @@ -8,7 +8,8 @@ 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; + NUMPAD_LANDSCAPE, PHONE, PHONE_SYMBOLS, EMOJI_BOTTOM, CLIPBOARD_BOTTOM, + SHORTCUT_TOP, SHORTCUT_BOTTOM, HANDWRITING_BOTTOM, EDITING; companion object { fun EnumMap.toExtraValue() = map { it.key.name + Separators.KV + it.value }.joinToString(Separators.ENTRY) @@ -37,6 +38,8 @@ enum class LayoutType { PHONE_SYMBOLS -> R.string.layout_phone_symbols EMOJI_BOTTOM -> R.string.layout_emoji_bottom_row CLIPBOARD_BOTTOM -> R.string.layout_clip_bottom_row + SHORTCUT_TOP -> R.string.layout_shortcut_top + SHORTCUT_BOTTOM -> R.string.layout_shortcut_bottom HANDWRITING_BOTTOM -> R.string.layout_emoji_bottom_row EDITING -> R.string.text_edit } 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 a206f09ee..fa35604cc 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/GestureTypingScreen.kt @@ -71,6 +71,8 @@ fun GestureTypingScreen( add(Settings.PREF_SPACE_HORIZONTAL_SWIPE) add(Settings.PREF_SPACE_VERTICAL_SWIPE) add(Settings.PREF_DELETE_SWIPE) + add(Settings.PREF_SHORTCUT_TOP_ROW) + add(Settings.PREF_SHORTCUT_BOTTOM_ROW) add(R.string.settings_category_touchpad) add(Settings.PREF_TOUCHPAD_SENSITIVITY) @@ -179,6 +181,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_SHORTCUT_TOP_ROW, R.string.shortcut_top_row, R.string.shortcut_top_row_summary) { + SwitchPreference(it, Defaults.PREF_SHORTCUT_TOP_ROW) + }, + Setting(context, Settings.PREF_SHORTCUT_BOTTOM_ROW, R.string.shortcut_bottom_row, R.string.shortcut_bottom_row_summary) { + SwitchPreference(it, Defaults.PREF_SHORTCUT_BOTTOM_ROW) + }, ) @Preview diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c15b1ede9..04addb690 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -252,6 +252,11 @@ Delete swipe Perform a swipe from the delete key to select and remove bigger portions of text at once + Top shortcut row + Enable upward swipes from the top letter row + Bottom shortcut row + Enable downward swipes from the bottom letter row + Backup and restore @@ -812,6 +817,9 @@ language, hence "No language". --> Emoji bottom row Emoji bottom row with action key + Top shortcut row + Bottom shortcut row + Clipboard bottom row diff --git a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt index 026cf1325..57b30a39c 100644 --- a/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt +++ b/app/src/test/java/helium314/keyboard/KeyboardParserTest.kt @@ -522,6 +522,37 @@ f""", // no newline at the end } } + + @Test fun shortcutRowsParseToPopupKeySpecs() { + fun parseShortcutRow(path: String) = LayoutParser.parseSimpleString(File(path).readText()).first() + .mapNotNull { it.compute(params)?.getPopupLabel(params) } + .map { helium314.keyboard.keyboard.internal.PopupKeySpec(it, false, Locale.US).mCode } + + assertEquals( + listOf( + KeyCode.MOVE_START_OF_LINE, + KeyCode.MOVE_END_OF_LINE, + KeyCode.PAGE_UP, + KeyCode.PAGE_DOWN, + KeyCode.TAB, + KeyCode.ESCAPE, + ), + parseShortcutRow("src/main/assets/layouts/shortcut_top/shortcut_top.txt"), + ) + assertEquals( + listOf( + KeyCode.ARROW_LEFT, + KeyCode.ARROW_DOWN, + KeyCode.ARROW_UP, + KeyCode.ARROW_RIGHT, + KeyCode.WORD_LEFT, + KeyCode.WORD_RIGHT, + KeyCode.INSERT, + ), + parseShortcutRow("src/main/assets/layouts/shortcut_bottom/shortcut_bottom.txt"), + ) + } + @Test fun simpleWithLabelPopupHasCode() { val keys = LayoutParser.parseSimpleString(""" a symbol diff --git a/docs/shortcut-rows.md b/docs/shortcut-rows.md new file mode 100644 index 000000000..a59e56da0 --- /dev/null +++ b/docs/shortcut-rows.md @@ -0,0 +1,108 @@ +# Vertical Shortcut Rows + +Vertical shortcut rows add optional, swipe-reachable action rows above and below the alphabet keyboard. + +They are designed for commands that are useful while typing but do not need to occupy permanent keyboard space. + +## Enable it + +Open **Settings → Gesture Typing → Advanced Gestures** and enable either or both: + +- **Top shortcut row** +- **Bottom shortcut row** + +The settings default to off. + +## How to use + +- Swipe upward from an eligible key in the top letter row to open the top shortcut row. +- Swipe downward from an eligible key in the bottom letter row to open the bottom shortcut row. +- Keep sliding to the desired shortcut and release. + +The swipe must be mostly vertical and cross the normal pointer step threshold. Horizontal movement continues to behave like normal gesture typing or key movement. + +## Default shortcuts + +The default rows focus on navigation and editing actions that are not already easy to reach from the alphabet keyboard. + +Top row: + +```text +Home +End +PgUp +PgDn +Tab +Esc +``` + +Bottom row: + +```text +← +↓ +↑ +→ +W← +W→ +Ins +``` + +`W←` and `W→` move by word. + +## Customizing layouts + +The default layouts are simple row-based layout files: + +```text +app/src/main/assets/layouts/shortcut_top/shortcut_top.txt +app/src/main/assets/layouts/shortcut_bottom/shortcut_bottom.txt +``` + +Each line is one shortcut key. For example: + +```text +Home|!code/key_home +End|!code/key_end +PgUp|!code/key_page_up +``` + +The feature adds stable key-code aliases for navigation keys so custom layouts can avoid raw numeric key codes: + +```text +key_arrow_left +key_arrow_down +key_arrow_up +key_arrow_right +key_home +key_end +key_page_up +key_page_down +key_word_left +key_word_right +key_escape +key_insert +key_event_tab +``` + +`key_tab` remains the existing literal tab character. Use `key_event_tab` when you want a hardware-style Tab key event. + +## Interaction rules + +Shortcut-row swipes are intentionally narrow: + +- only alphabet keyboards can start them +- only normal, enabled, non-modifier source keys are eligible +- the top row starts only from the top eligible letter row +- the bottom row starts only from the bottom eligible letter row +- normal long-press popups, gesture typing, and horizontal swipes stay separate + +If the IME receives a cancel event while a shortcut row is open, the popup is dismissed without committing the highlighted shortcut. + +## Implementation notes + +`PointerTracker` detects the vertical movement and asks `MainKeyboardView` to show a temporary popup keyboard for `LayoutType.SHORTCUT_TOP` or `LayoutType.SHORTCUT_BOTTOM`. + +`ShortcutRowKeys` parses the selected shortcut layout and converts its first row into popup key specs on a synthetic parent key. + +Popup placement aligns the shortcut icons across the source letter row so the swipe position maps proportionally to the visible shortcuts.