diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c551bbc4..2dea8c413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Added +- **Source-key action targets** — slide from the Enter/action key into a temporary action target + panel for existing actions like `JOIN_NEXT`, `FORCE_NEXT_SPACE`, and `UNDO_WORD`. (#37) + ## [3.9.1] - 2026-06-11 ### Fixed diff --git a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java index 96399c446..fee9cf96b 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java @@ -684,6 +684,19 @@ private PopupKeysPanel showPopupKeysKeyboard(@NonNull final Key key, return popupKeysKeyboardView; } + @Override + @Nullable + public PopupKeysPanel showSourceKeyActionKeyboard(@NonNull final Key key, + @NonNull final PointerTracker tracker, @NonNull final PopupKeySpec[] popupKeys) { + if (popupKeys.length == 0) { + return null; + } + final Key popupParentKey = Key.copyWithShortcutPopupKeys(key, popupKeys); + final int fixedKeyWidth = popupKeys.length > 0 ? key.getWidth() : 0; + return showPopupKeysKeyboard(popupParentKey, tracker, false, + PopupKeysKeyboardView.NO_ROW_ALIGN, fixedKeyWidth); + } + @Override @Nullable public PopupKeysPanel showShortcutRowKeyboard(@NonNull final Key key, diff --git a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java index 427b88e6b..1b8ebbaa2 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java +++ b/app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java @@ -28,6 +28,7 @@ import helium314.keyboard.keyboard.internal.GestureStrokeRecognitionParams; import helium314.keyboard.keyboard.internal.PointerTrackerQueue; import helium314.keyboard.keyboard.internal.TimerProxy; +import helium314.keyboard.keyboard.internal.PopupKeySpec; import helium314.keyboard.keyboard.internal.TypingTimeRecorder; import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode; import helium314.keyboard.latin.R; @@ -40,6 +41,7 @@ import helium314.keyboard.latin.utils.KtxKt; import helium314.keyboard.latin.utils.LayoutType; import helium314.keyboard.latin.utils.Log; +import helium314.keyboard.latin.utils.SourceKeyActionTargets; import java.util.ArrayList; import java.util.Locale; @@ -215,6 +217,8 @@ public static int consumeGestureSeedCodepoint() { private boolean mShortcutBottomRowSwipeAllowed = false; private boolean mInShortcutRowSwipe = false; private static boolean sInShortcutRowSwipe = false; + private boolean mSourceKeyActionAllowed = false; + private boolean mInSourceKeyAction = false; // Touchpad mode for cursor control public static boolean sPersistentTouchpadModeActive = false; @@ -969,6 +973,7 @@ private void dismissPopupKeysPanel() { mInShortcutRowSwipe = false; sInShortcutRowSwipe = false; } + mInSourceKeyAction = false; } private void onDownEventInternal(final int x, final int y, final long eventTime) { @@ -987,6 +992,10 @@ private void onDownEventInternal(final int x, final int y, final long eventTime) mKeySwipeAllowed = true; sInKeySwipe = true; } + mSourceKeyActionAllowed = key != null && !sInGesture && !mKeySwipeAllowed + && SourceKeyActionTargets.hasTargetsForSource( + key.getCode(), Settings.getValues().mSourceKeyActionTargets); + mInSourceKeyAction = false; mShortcutTopRowSwipeAllowed = isShortcutRowSource(key, true); mShortcutBottomRowSwipeAllowed = isShortcutRowSource(key, false); mInShortcutRowSwipe = false; @@ -1340,6 +1349,37 @@ private void onKeySwipe(final int code, final int x, final int y, final long eve } } + private boolean tryStartSourceKeyAction(final int x, final int y, final long eventTime) { + if (!mSourceKeyActionAllowed || mInSourceKeyAction || isShowingPopupKeysPanel() + || sInGesture || mCurrentKey == null) { + return mInSourceKeyAction; + } + final int dX = x - mStartX; + final int dY = y - mStartY; + if (abs(dX) < sPointerStep && abs(dY) < sPointerStep) { + return false; + } + final PopupKeySpec[] popupKeys = SourceKeyActionTargets.popupKeysForSource( + mCurrentKey.getCode(), Settings.getValues().mSourceKeyActionTargets, Locale.getDefault()); + if (popupKeys == null || popupKeys.length == 0) { + return false; + } + final PopupKeysPanel popupKeysPanel = sDrawingProxy.showSourceKeyActionKeyboard( + mCurrentKey, this, popupKeys); + 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; + mInSourceKeyAction = true; + return true; + } + private boolean isShortcutRowSource(final Key key, final boolean topRow) { final SettingsValues sv = Settings.getValues(); if (!sv.mShortcutRowsEnabled @@ -1409,6 +1449,9 @@ private void onMoveEventInternal(final int x, final int y, final long eventTime) onKeySwipe(oldKey.getCode(), x, y, eventTime); return; } + if (tryStartSourceKeyAction(x, y, eventTime)) { + return; + } final Key newKey = onMoveKey(x, y); final int lastX = mLastX; 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 731d4df51..51f2719c0 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/DrawingProxy.java @@ -42,6 +42,10 @@ public interface DrawingProxy { PopupKeysPanel showShortcutRowKeyboard(@NonNull Key key, @NonNull PointerTracker tracker, @NonNull LayoutType layoutType, boolean belowSourceKey); + @Nullable + PopupKeysPanel showSourceKeyActionKeyboard(@NonNull Key key, @NonNull PointerTracker tracker, + @NonNull PopupKeySpec[] popupKeys); + /** * 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/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index c0e875464..f0ab3f4e8 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_ACTION_TARGETS = "ACTION=JOIN_NEXT,FORCE_NEXT_SPACE,UNDO_WORD" 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..79a9377a7 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_ACTION_TARGETS = "source_key_action_targets"; 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..b32cbe8e0 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 mSourceKeyActionTargets; 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); + mSourceKeyActionTargets = prefs.getString(Settings.PREF_SOURCE_KEY_ACTION_TARGETS, + Defaults.PREF_SOURCE_KEY_ACTION_TARGETS); 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/SourceKeyActionTargets.kt b/app/src/main/java/helium314/keyboard/latin/utils/SourceKeyActionTargets.kt new file mode 100644 index 000000000..31fdf9bcd --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/utils/SourceKeyActionTargets.kt @@ -0,0 +1,79 @@ +package helium314.keyboard.latin.utils + +import helium314.keyboard.keyboard.internal.KeyboardIconsSet +import helium314.keyboard.keyboard.internal.KeyboardCodesSet +import helium314.keyboard.keyboard.internal.PopupKeySpec +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode +import helium314.keyboard.latin.common.Constants +import java.util.EnumMap +import java.util.Locale + +object SourceKeyActionTargets { + enum class SourceKey { ACTION_KEY, PERIOD, COMMA } + + private var cachedSpec: String? = null + private var cachedTargets: Map> = emptyMap() + + @JvmStatic + fun hasTargetsForSource(code: Int, spec: String): Boolean { + val source = sourceKeyForCode(code) ?: return false + return targets(spec)[source]?.isNotEmpty() == true + } + + @JvmStatic + fun popupKeysForSource(code: Int, spec: String, locale: Locale): Array? { + val source = sourceKeyForCode(code) ?: return null + val keys = targets(spec)[source].orEmpty() + if (keys.isEmpty()) return null + return keys.map { toolbarKey -> + val keyName = toolbarKeyStrings[toolbarKey] ?: toolbarKey.name.lowercase(Locale.US) + PopupKeySpec( + "${KeyboardIconsSet.PREFIX_ICON}$keyName|${KeyboardCodesSet.PREFIX_CODE}${getCodeForToolbarKey(toolbarKey)}", + false, + locale, + ) + }.toTypedArray() + } + + fun parse(spec: String): Map> = targets(spec) + + @JvmStatic + fun clearCache() { + cachedSpec = null + cachedTargets = emptyMap() + } + + private fun targets(spec: String): Map> { + if (spec == cachedSpec) return cachedTargets + val parsed = EnumMap>(SourceKey::class.java) + spec.split(';').forEach { entry -> + val trimmed = entry.trim() + if (trimmed.isEmpty()) return@forEach + val source = sourceKeyForName(trimmed.substringBefore('=', missingDelimiterValue = "")) ?: return@forEach + val actions = trimmed.substringAfter('=', missingDelimiterValue = "") + .split(',') + .mapNotNull { it.enumValueOrNull() } + if (actions.isNotEmpty()) parsed[source] = actions.toMutableList() + } + cachedSpec = spec + cachedTargets = parsed + return cachedTargets + } + + 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 inline fun > String.enumValueOrNull(): T? = + runCatching { enumValueOf(trim().uppercase()) }.getOrNull() +} 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..cca86825c 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_ACTION_TARGETS, 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_ACTION_TARGETS, R.string.source_key_action_targets, R.string.source_key_action_targets_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_action_targets_hint)) }, + initialText = prefs.getString(setting.key, Defaults.PREF_SOURCE_KEY_ACTION_TARGETS)!!, + onConfirmed = { prefs.edit { putString(setting.key, it) } }, + title = { Text(stringResource(R.string.source_key_action_targets)) }, + 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..23bfd4ff0 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 action targets + + Slide from Enter, period, or comma to choose existing toolbar actions + + Example: ACTION=JOIN_NEXT,FORCE_NEXT_SPACE,UNDO_WORD;PERIOD=UNDO_WORD Try to detect URLs and similar as a single word diff --git a/app/src/test/java/helium314/keyboard/latin/utils/SourceKeyActionTargetsTest.kt b/app/src/test/java/helium314/keyboard/latin/utils/SourceKeyActionTargetsTest.kt new file mode 100644 index 000000000..50419c341 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/utils/SourceKeyActionTargetsTest.kt @@ -0,0 +1,81 @@ +package helium314.keyboard.latin.utils + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.common.Constants +import helium314.keyboard.latin.utils.SourceKeyActionTargets.SourceKey +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.junit.Test +import java.util.Locale +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class SourceKeyActionTargetsTest { + @Before + fun setup() { + Settings.init(ApplicationProvider.getApplicationContext()) + SourceKeyActionTargets.clearCache() + } + @Test + fun parseAcceptsTargetListsForSourceKeys() { + val targets = SourceKeyActionTargets.parse( + "ACTION=JOIN_NEXT,FORCE_NEXT_SPACE,UNDO_WORD;PERIOD=UNDO_WORD;COMMA=EMOJI,SETTINGS" + ) + + assertEquals( + listOf(ToolbarKey.JOIN_NEXT, ToolbarKey.FORCE_NEXT_SPACE, ToolbarKey.UNDO_WORD), + targets[SourceKey.ACTION_KEY], + ) + assertEquals(listOf(ToolbarKey.UNDO_WORD), targets[SourceKey.PERIOD]) + assertEquals(listOf(ToolbarKey.EMOJI, ToolbarKey.SETTINGS), targets[SourceKey.COMMA]) + } + + @Test + fun parseAcceptsUserFriendlySourceAliases() { + val targets = SourceKeyActionTargets.parse("ENTER=JOIN_NEXT;.=UNDO_WORD;,=FORCE_NEXT_SPACE") + + assertEquals(listOf(ToolbarKey.JOIN_NEXT), targets[SourceKey.ACTION_KEY]) + assertEquals(listOf(ToolbarKey.UNDO_WORD), targets[SourceKey.PERIOD]) + assertEquals(listOf(ToolbarKey.FORCE_NEXT_SPACE), targets[SourceKey.COMMA]) + } + + @Test + fun parseSkipsMalformedEntriesAndUnknownActions() { + val targets = SourceKeyActionTargets.parse( + "ACTION=JOIN_NEXT,NO_SUCH_ACTION;BAD=JOIN_NEXT;PERIOD=NO_SUCH_ACTION;garbage" + ) + + assertEquals(mapOf(SourceKey.ACTION_KEY to listOf(ToolbarKey.JOIN_NEXT)), targets) + } + + @Test + fun hasTargetsCanonicalizesSupportedSourceCodes() { + val spec = "ACTION=JOIN_NEXT;PERIOD=UNDO_WORD;COMMA=FORCE_NEXT_SPACE" + + assertTrue(SourceKeyActionTargets.hasTargetsForSource(Constants.CODE_ENTER, spec)) + assertTrue(SourceKeyActionTargets.hasTargetsForSource(Constants.CODE_PERIOD, spec)) + assertTrue(SourceKeyActionTargets.hasTargetsForSource(','.code, spec)) + assertFalse(SourceKeyActionTargets.hasTargetsForSource('a'.code, spec)) + } + + @Test + fun popupKeysResolveTargetsToExistingToolbarKeyCodes() { + val popupKeys = SourceKeyActionTargets.popupKeysForSource( + Constants.CODE_ENTER, + "ACTION=JOIN_NEXT,FORCE_NEXT_SPACE", + Locale.US, + ) + + assertNotNull(popupKeys) + assertEquals(2, popupKeys!!.size) + assertEquals(getCodeForToolbarKey(ToolbarKey.JOIN_NEXT), popupKeys[0].mCode) + assertEquals(getCodeForToolbarKey(ToolbarKey.FORCE_NEXT_SPACE), popupKeys[1].mCode) + } +}