Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/src/main/assets/layouts/shortcut_top/shortcut_top.txt
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions app/src/main/java/helium314/keyboard/keyboard/Key.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 47 additions & 22 deletions app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. <code>width ==
// 0</code>
// 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);
}
Expand All @@ -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;
Expand Down
80 changes: 80 additions & 0 deletions app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading