Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions app/src/main/java/helium314/keyboard/keyboard/PointerTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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_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
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_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";
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 mSourceKeyActionTargets;
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);
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SourceKey, List<ToolbarKey>> = 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<PopupKeySpec>? {
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<SourceKey, List<ToolbarKey>> = targets(spec)

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

private fun targets(spec: String): Map<SourceKey, List<ToolbarKey>> {
if (spec == cachedSpec) return cachedTargets
val parsed = EnumMap<SourceKey, MutableList<ToolbarKey>>(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<ToolbarKey>() }
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 <reified T : Enum<T>> String.enumValueOrNull(): T? =
runCatching { enumValueOf<T>(trim().uppercase()) }.getOrNull()
}
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_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,
Expand Down Expand Up @@ -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
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 action target layers -->
<string name="source_key_action_targets">Source-key action targets</string>
<!-- Description for "source_key_action_targets" setting -->
<string name="source_key_action_targets_summary">Slide from Enter, period, or comma to choose existing toolbar actions</string>
<!-- Input hint for "source_key_action_targets" setting -->
<string name="source_key_action_targets_hint">Example: ACTION=JOIN_NEXT,FORCE_NEXT_SPACE,UNDO_WORD;PERIOD=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,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<Context>())
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)
}
}
Loading