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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

### Added
- **Source-key swipe actions** — configurable MVP for mapping swipes from Enter/action, period,
and comma keys to existing toolbar/key-code actions such as `JOIN_NEXT`, `FORCE_NEXT_SPACE`,
or `UNDO_WORD`. (#37)

## [3.9.1] - 2026-06-11

### Fixed
Expand Down
41 changes: 41 additions & 0 deletions app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import helium314.keyboard.latin.utils.KtxKt;
import helium314.keyboard.latin.utils.LayoutType;
import helium314.keyboard.latin.utils.Log;
import helium314.keyboard.latin.utils.SourceKeySwipeActions;

import java.util.ArrayList;
import java.util.Locale;
Expand Down Expand Up @@ -215,6 +216,9 @@ public static int consumeGestureSeedCodepoint() {
private boolean mShortcutBottomRowSwipeAllowed = false;
private boolean mInShortcutRowSwipe = false;
private static boolean sInShortcutRowSwipe = false;
private boolean mSourceKeySwipeAllowed = false;
private boolean mInSourceKeySwipe = false;
private int mSourceKeySwipeCode = Constants.NOT_A_CODE;

// Touchpad mode for cursor control
public static boolean sPersistentTouchpadModeActive = false;
Expand Down Expand Up @@ -987,6 +991,10 @@ private void onDownEventInternal(final int x, final int y, final long eventTime)
mKeySwipeAllowed = true;
sInKeySwipe = true;
}
mSourceKeySwipeAllowed = key != null && !sInGesture && !mKeySwipeAllowed
&& SourceKeySwipeActions.hasBindingForSource(key.getCode(), Settings.getValues().mSourceKeySwipeActions);
mInSourceKeySwipe = false;
mSourceKeySwipeCode = mSourceKeySwipeAllowed ? key.getCode() : Constants.NOT_A_CODE;
mShortcutTopRowSwipeAllowed = isShortcutRowSource(key, true);
mShortcutBottomRowSwipeAllowed = isShortcutRowSource(key, false);
mInShortcutRowSwipe = false;
Expand Down Expand Up @@ -1340,6 +1348,35 @@ private void onKeySwipe(final int code, final int x, final int y, final long eve
}
}

private boolean onSourceKeySwipe(final int x, final int y) {
if (!mSourceKeySwipeAllowed || mInSourceKeySwipe || mSourceKeySwipeCode == Constants.NOT_A_CODE) {
return false;
}
final int dX = x - mStartX;
final int dY = y - mStartY;
final int absX = abs(dX);
final int absY = abs(dY);
if (absX < sPointerStep && absY < sPointerStep) {
return false;
}
final int direction;
if (absX >= absY) {
direction = dX < 0 ? SourceKeySwipeActions.SWIPE_LEFT : SourceKeySwipeActions.SWIPE_RIGHT;
} else {
direction = dY < 0 ? SourceKeySwipeActions.SWIPE_UP : SourceKeySwipeActions.SWIPE_DOWN;
}
final int code = SourceKeySwipeActions.codeForSwipe(
mSourceKeySwipeCode, direction, Settings.getValues().mSourceKeySwipeActions);
if (code == KeyCode.UNSPECIFIED) {
return false;
}
sTimerProxy.cancelKeyTimersOf(this);
mInSourceKeySwipe = true;
mIsTrackingForActionDisabled = true;
sListener.onCodeInput(code, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false);
return true;
}

private boolean isShortcutRowSource(final Key key, final boolean topRow) {
final SettingsValues sv = Settings.getValues();
if (!sv.mShortcutRowsEnabled
Expand Down Expand Up @@ -1409,6 +1446,10 @@ private void onMoveEventInternal(final int x, final int y, final long eventTime)
onKeySwipe(oldKey.getCode(), x, y, eventTime);
return;
}
if (onSourceKeySwipe(x, y)) {
setReleasedKeyGraphics(oldKey, true);
return;
}

final Key newKey = onMoveKey(x, y);
final int lastX = mLastX;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ object Defaults {
const val PREF_MORE_POPUP_KEYS = "main"
const val PREF_SPACE_TO_CHANGE_LANG = true
const val PREF_LANGUAGE_SWIPE_DISTANCE = 5
const val PREF_SOURCE_KEY_SWIPE_ACTIONS = ""
const val PREF_TOUCHPAD_SENSITIVITY = 50
const val PREF_TOUCHPAD_EDGE_SCROLL = false
const val PREF_FORCE_AUTO_CAPS = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang

public static final String PREF_SPACE_TO_CHANGE_LANG = "prefs_long_press_keyboard_to_change_lang";
public static final String PREF_LANGUAGE_SWIPE_DISTANCE = "language_swipe_distance";
public static final String PREF_SOURCE_KEY_SWIPE_ACTIONS = "source_key_swipe_actions";
public static final String PREF_TOUCHPAD_SENSITIVITY = "touchpad_sensitivity";
public static final String PREF_TOUCHPAD_EDGE_SCROLL = "touchpad_edge_scroll";
public static final String PREF_PERSIST_FLOATING_KEYBOARD = "persist_floating_keyboard";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public class SettingsValues {
public final int mSpaceSwipeHorizontal;
public final int mSpaceSwipeVertical;
public final int mLanguageSwipeDistance;
public final String mSourceKeySwipeActions;
public final int mTouchpadSensitivity;
public final boolean mTouchpadEdgeScroll;
public final boolean mForceAutoCaps;
Expand Down Expand Up @@ -435,6 +436,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
mSpaceSwipeVertical = Settings.readVerticalSpaceSwipe(prefs);
mLanguageSwipeDistance = prefs.getInt(Settings.PREF_LANGUAGE_SWIPE_DISTANCE,
Defaults.PREF_LANGUAGE_SWIPE_DISTANCE);
mSourceKeySwipeActions = prefs.getString(Settings.PREF_SOURCE_KEY_SWIPE_ACTIONS,
Defaults.PREF_SOURCE_KEY_SWIPE_ACTIONS);
mTouchpadSensitivity = prefs.getInt(Settings.PREF_TOUCHPAD_SENSITIVITY,
Defaults.PREF_TOUCHPAD_SENSITIVITY);
mTouchpadEdgeScroll = prefs.getBoolean(Settings.PREF_TOUCHPAD_EDGE_SCROLL,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package helium314.keyboard.latin.utils

import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode
import helium314.keyboard.latin.common.Constants
import java.util.EnumMap

object SourceKeySwipeActions {
enum class SourceKey { ACTION_KEY, PERIOD, COMMA }
enum class Direction { UP, DOWN, LEFT, RIGHT }

private data class BindingKey(val sourceKey: SourceKey, val direction: Direction)

private var cachedSpec: String? = null
private var cachedBindings: Map<BindingKey, ToolbarKey> = emptyMap()

@JvmStatic
fun hasBindingForSource(code: Int, spec: String): Boolean {
val sourceKey = sourceKeyForCode(code) ?: return false
return bindings(spec).keys.any { it.sourceKey == sourceKey }
}

@JvmStatic
fun codeForSwipe(sourceCode: Int, direction: Int, spec: String): Int {
val sourceKey = sourceKeyForCode(sourceCode) ?: return KeyCode.UNSPECIFIED
val swipeDirection = directionForCode(direction) ?: return KeyCode.UNSPECIFIED
val toolbarKey = bindings(spec)[BindingKey(sourceKey, swipeDirection)] ?: return KeyCode.UNSPECIFIED
return getCodeForToolbarKey(toolbarKey)
}

@JvmStatic
fun clearCache() {
cachedSpec = null
cachedBindings = emptyMap()
}

fun parse(spec: String): Map<Pair<SourceKey, Direction>, ToolbarKey> =
bindings(spec).mapKeys { it.key.sourceKey to it.key.direction }

private fun bindings(spec: String): Map<BindingKey, ToolbarKey> {
if (spec == cachedSpec) return cachedBindings
val parsed = EnumMap<SourceKey, EnumMap<Direction, ToolbarKey>>(SourceKey::class.java)
spec.split(';').forEach { entry ->
val trimmed = entry.trim()
if (trimmed.isEmpty()) return@forEach
val sourceAndDirection = trimmed.substringBefore('=', missingDelimiterValue = "")
val actionName = trimmed.substringAfter('=', missingDelimiterValue = "")
val sourceName = sourceAndDirection.substringBefore(':', missingDelimiterValue = "")
val directionName = sourceAndDirection.substringAfter(':', missingDelimiterValue = "")
val sourceKey = sourceKeyForName(sourceName) ?: return@forEach
val direction = directionName.enumValueOrNull<Direction>() ?: return@forEach
val action = actionName.enumValueOrNull<ToolbarKey>() ?: return@forEach
parsed.getOrPut(sourceKey) { EnumMap(Direction::class.java) }[direction] = action
}
cachedSpec = spec
cachedBindings = parsed.flatMap { sourceEntry ->
sourceEntry.value.map { directionEntry ->
BindingKey(sourceEntry.key, directionEntry.key) to directionEntry.value
}
}.toMap()
return cachedBindings
}

private fun sourceKeyForName(name: String): SourceKey? = when (name.trim().uppercase()) {
"ACTION", "ACTION_KEY", "ENTER" -> SourceKey.ACTION_KEY
"PERIOD", "." -> SourceKey.PERIOD
"COMMA", "," -> SourceKey.COMMA
else -> null
}

private fun sourceKeyForCode(code: Int): SourceKey? = when (code) {
Constants.CODE_ENTER, KeyCode.SHIFT_ENTER, KeyCode.ACTION_NEXT, KeyCode.ACTION_PREVIOUS -> SourceKey.ACTION_KEY
Constants.CODE_PERIOD -> SourceKey.PERIOD
','.code -> SourceKey.COMMA
else -> null
}

private fun directionForCode(direction: Int): Direction? = when (direction) {
SWIPE_UP -> Direction.UP
SWIPE_DOWN -> Direction.DOWN
SWIPE_LEFT -> Direction.LEFT
SWIPE_RIGHT -> Direction.RIGHT
else -> null
}

private inline fun <reified T : Enum<T>> String.enumValueOrNull(): T? =
runCatching { enumValueOf<T>(trim().uppercase()) }.getOrNull()

const val SWIPE_UP = 1
const val SWIPE_DOWN = 2
const val SWIPE_LEFT = 3
const val SWIPE_RIGHT = 4
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ fun AdvancedSettingsScreen(
|| Settings.readVerticalSpaceSwipe(prefs) == KeyboardActionListener.SWIPE_SWITCH_LANGUAGE)
Settings.PREF_LANGUAGE_SWIPE_DISTANCE else null,
Settings.PREF_SPACE_TO_CHANGE_LANG,
Settings.PREF_SOURCE_KEY_SWIPE_ACTIONS,
Settings.PREFS_LONG_PRESS_SYMBOLS_FOR_NUMPAD,
Settings.PREF_ENABLE_EMOJI_ALT_PHYSICAL_KEY,
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) Settings.PREF_SHOW_SETUP_WIZARD_ICON else null,
Expand Down Expand Up @@ -195,6 +196,26 @@ fun createAdvancedSettings(context: Context) = listOfNotNull(
)
}
},
Setting(context, Settings.PREF_SOURCE_KEY_SWIPE_ACTIONS, R.string.source_key_swipe_actions, R.string.source_key_swipe_actions_summary) { setting ->
var showDialog by rememberSaveable { mutableStateOf(false) }
Preference(
name = setting.title,
description = setting.description,
onClick = { showDialog = true }
)
if (showDialog) {
val prefs = LocalContext.current.prefs()
TextInputDialog(
onDismissRequest = { showDialog = false },
textInputLabel = { Text(stringResource(R.string.source_key_swipe_actions_hint)) },
initialText = prefs.getString(setting.key, Defaults.PREF_SOURCE_KEY_SWIPE_ACTIONS)!!,
onConfirmed = { prefs.edit { putString(setting.key, it) } },
title = { Text(stringResource(R.string.source_key_swipe_actions)) },
neutralButtonText = if (prefs.contains(setting.key)) stringResource(R.string.button_default) else null,
onNeutral = { prefs.edit { remove(setting.key) } },
)
}
},
Setting(context, Settings.PREF_MORE_POPUP_KEYS, R.string.show_popup_keys_title) {
val items = listOf(POPUP_KEYS_NORMAL, POPUP_KEYS_MAIN, POPUP_KEYS_MORE, POPUP_KEYS_ALL).map { setting ->
stringResource(morePopupKeysResId(setting)) to setting
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,12 @@
<string name="show_popup_keys_all">Add all available variants</string>
<!-- Preferences item for enabling URL detection -->
<string name="url_detection_title">URL detection</string>
<!-- Preferences item for source-key swipe actions -->
<string name="source_key_swipe_actions">Source-key swipe actions</string>
<!-- Description for "source_key_swipe_actions" setting -->
<string name="source_key_swipe_actions_summary">Map swipes from Enter, period, or comma to existing toolbar actions</string>
<!-- Input hint for "source_key_swipe_actions" setting -->
<string name="source_key_swipe_actions_hint">Example: ACTION:LEFT=JOIN_NEXT;PERIOD:UP=UNDO_WORD</string>
<!-- Description for "url_detection_title" option. -->
<string name="url_detection_summary">Try to detect URLs and similar as a single word</string>
<!-- Preferences item for disabling word learning -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package helium314.keyboard.latin.utils

import helium314.keyboard.latin.common.Constants
import helium314.keyboard.latin.utils.SourceKeySwipeActions.Direction
import helium314.keyboard.latin.utils.SourceKeySwipeActions.SourceKey
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class SourceKeySwipeActionsTest {
@Test
fun parseAcceptsCanonicalSourceDirectionAndToolbarActionNames() {
val bindings = SourceKeySwipeActions.parse(
"ACTION_KEY:LEFT=JOIN_NEXT;PERIOD:UP=UNDO_WORD;COMMA:DOWN=FORCE_NEXT_SPACE"
)

assertEquals(ToolbarKey.JOIN_NEXT, bindings[SourceKey.ACTION_KEY to Direction.LEFT])
assertEquals(ToolbarKey.UNDO_WORD, bindings[SourceKey.PERIOD to Direction.UP])
assertEquals(ToolbarKey.FORCE_NEXT_SPACE, bindings[SourceKey.COMMA to Direction.DOWN])
}

@Test
fun parseAcceptsUserFriendlySourceAliases() {
val bindings = SourceKeySwipeActions.parse("ENTER:RIGHT=JOIN_NEXT;.:UP=UNDO_WORD;,:DOWN=FORCE_NEXT_SPACE")

assertEquals(ToolbarKey.JOIN_NEXT, bindings[SourceKey.ACTION_KEY to Direction.RIGHT])
assertEquals(ToolbarKey.UNDO_WORD, bindings[SourceKey.PERIOD to Direction.UP])
assertEquals(ToolbarKey.FORCE_NEXT_SPACE, bindings[SourceKey.COMMA to Direction.DOWN])
}

@Test
fun parseSkipsMalformedEntries() {
val bindings = SourceKeySwipeActions.parse(
"ACTION:LEFT=JOIN_NEXT;BAD:LEFT=JOIN_NEXT;PERIOD:BAD=UNDO_WORD;COMMA:UP=NO_SUCH_ACTION;garbage"
)

assertEquals(mapOf(SourceKey.ACTION_KEY to Direction.LEFT to ToolbarKey.JOIN_NEXT), bindings)
}

@Test
fun hasBindingForSourceCanonicalizesEnterPeriodAndComma() {
val spec = "ACTION:LEFT=JOIN_NEXT;PERIOD:UP=UNDO_WORD;COMMA:DOWN=FORCE_NEXT_SPACE"

assertTrue(SourceKeySwipeActions.hasBindingForSource(Constants.CODE_ENTER, spec))
assertTrue(SourceKeySwipeActions.hasBindingForSource(Constants.CODE_PERIOD, spec))
assertTrue(SourceKeySwipeActions.hasBindingForSource(','.code, spec))
assertFalse(SourceKeySwipeActions.hasBindingForSource('a'.code, spec))
}
}
Loading