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.