diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c551bbc4..8268bd9f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java index 427b88e6b..4b03b1405 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java +++ b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java @@ -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; @@ -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; @@ -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; @@ -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 @@ -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; 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 c0e875464..82794d573 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -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 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 cf89a5379..294d24146 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -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"; 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 95efd7756..9f382a5b7 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -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; @@ -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, diff --git a/app/src/main/java/helium314/keyboard/latin/utils/SourceKeySwipeActions.kt b/app/src/main/java/helium314/keyboard/latin/utils/SourceKeySwipeActions.kt new file mode 100644 index 000000000..0b20a0c6e --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/utils/SourceKeySwipeActions.kt @@ -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 = 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, ToolbarKey> = + bindings(spec).mapKeys { it.key.sourceKey to it.key.direction } + + private fun bindings(spec: String): Map { + if (spec == cachedSpec) return cachedBindings + val parsed = EnumMap>(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() ?: return@forEach + val action = actionName.enumValueOrNull() ?: 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 > String.enumValueOrNull(): T? = + runCatching { enumValueOf(trim().uppercase()) }.getOrNull() + + const val SWIPE_UP = 1 + const val SWIPE_DOWN = 2 + const val SWIPE_LEFT = 3 + const val SWIPE_RIGHT = 4 +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index 133c5069f..106dc8ef5 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -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, @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82e563c1c..0382f093c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -468,6 +468,12 @@ Add all available variants URL detection + + Source-key swipe actions + + Map swipes from Enter, period, or comma to existing toolbar actions + + Example: ACTION:LEFT=JOIN_NEXT;PERIOD:UP=UNDO_WORD Try to detect URLs and similar as a single word diff --git a/app/src/test/java/helium314/keyboard/latin/utils/SourceKeySwipeActionsTest.kt b/app/src/test/java/helium314/keyboard/latin/utils/SourceKeySwipeActionsTest.kt new file mode 100644 index 000000000..b0369da70 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/utils/SourceKeySwipeActionsTest.kt @@ -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)) + } +}