diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3fd169135..b7f708f0a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,12 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
## [Unreleased]
+### Fixed
+- **Fallback gesture suggestions no longer leak dictionary capitalization** when Shift is off; the Java gesture engine now emits canonical lowercase candidates before the existing suggestion presentation-casing layer. (#118)
+
+### Upstream
+- Merged **LeanBitLab/LeanType v3.9.5** (pinned at `8cfe7f1fc`, including v3.9.3/v3.9.4) — adds direct switching to a configured IME, persistent custom-layout state, multi-word suggestion controls, and the v3.9.5 release notes. Fork identity (`LeanTypeDual`, distinct `applicationId`, privacy tiers, and fork-specific features) is preserved.
+
## [3.10.0] - 2026-06-20
### Added
diff --git a/README.md b/README.md
index 828457305..b8ba433bc 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult
- **[🛡️ Offline AI (GGUF)](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** - Private, on-device proofreading and translation using local **GGUF models** powered by `llama.cpp` (Offline build only).
- **🌐 AI Translation** - Translate selected text using your chosen provider, with a separate model selector.
- **[✍️ Handwriting Input](docs/FEATURES.md#8-handwriting-input)** - Draw characters directly on a handwriting recognition canvas (Standard version, requires [Leantype-Handwriting-Plugin](https://github.com/LeanBitLab/Leantype-Handwriting-Plugin)).
+- **[👆 Built-in Gesture Typing](docs/FEATURES.md#9-built-in-gesture-typing)** - Gesture typing works out of the box using our new built-in pure-Java fallback engine, removing the strict dependency on native Google libraries.
- **[🧠 Custom AI Keys](docs/FEATURES.md#4-custom-ai-keys--keywords)** - Assign custom prompts, personas (#editor, #proofread), and labels/tags (themed capsules) to 10 customizable toolbar keys.
- **📝 Text Expander** - Shortcut → expansion with dynamic placeholders (`%clipboard%`, `%day%`, `%time12%`, `%cursor%`, lists), regex shortcuts, backspace-to-revert, and a guide.
- **🧠 Smarter learned words** - *graduated trust* keeps a just-learned word below real-dictionary suggestions until you've used it a few times (no premature autocorrect to half-typed words); flag unknown words to **Add** or **Block** them via a Blocklist screen.
@@ -34,6 +35,8 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult
- **🪟 Floating Keyboard** - Detach the keyboard into a draggable, resizable window (true OS-level overlay), with an optional persistent mode.
- **⌨️ Dual Toolbar / Split Suggestions** - Split the suggestion strip and toolbar for easier reach.
- **🖱️ Touchpad Mode** - Swipe the spacebar up for a cursor touchpad with sensitivity controls and edge-scroll acceleration, including a full-screen laptop-style mode.
+- **[⌨️ Direct Switch IME](docs/FEATURES.md#10-direct-switch-target-ime)** - Map custom keycode (`-10076`) to any toolbar key to switch directly to another input method.
+- **[🎨 Custom Layouts](docs/FEATURES.md#11-custom-layouts-customization)** - Save up to five custom layout profiles with persistent slot index tracking.
- **✍️ Text editing mode** - A toolbar key opens a text-editing overlay for selection, cursor movement, and clipboard actions.
- **🎨 Modern UI** - "Squircle" key backgrounds, refined icons, and polished aesthetics.
- **🔄 Google Dictionary Import** - Import your personal dictionary words.
@@ -109,7 +112,7 @@ Type with **both thumbs gliding at the same time**: LeanTypeDual aggregates mult
Customize keyboard themes (style, colors and background image)
Customize keyboard layouts
Multilingual typing
-
Glide typing (requires library)
+
Glide typing (works out of the box with built-in pure-Java fallback engine, or use native library)
Clipboard history
One-handed mode
Split keyboard
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 39f3bf9eb..b7f6199d8 100755
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -130,11 +130,11 @@ android {
variant.proguardFiles.add(project.layout.buildDirectory.file(project.buildFile.parent + "/proguard-rules.pro"))
}
if (variant.flavorName == "standard" || variant.flavorName == "standardfull") {
- // ponytail: dynamically find all dict files to ignore in standard flavor except main_en-US.dict
+ // Ignore all dictionary assets in standard/standardfull flavors
val dictsDir = project.file("src/main/assets/dicts")
if (dictsDir.exists() && dictsDir.isDirectory) {
dictsDir.listFiles()?.forEach { file ->
- if (file.name.endsWith(".dict") && file.name != "main_en-US.dict") {
+ if (file.name.endsWith(".dict")) {
patterns.add(file.name)
}
}
diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt
index 4d6e7bd38..c6d1e0d5f 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt
+++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt
@@ -104,7 +104,7 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp
override fun onCodeInput(primaryCode: Int, x: Int, y: Int, isKeyRepeat: Boolean) {
val isArrow = primaryCode == KeyCode.ARROW_LEFT || primaryCode == KeyCode.ARROW_RIGHT || primaryCode == KeyCode.ARROW_UP || primaryCode == KeyCode.ARROW_DOWN
if (isArrow) {
- val isSelecting = keyboardSwitcher.keyboard?.mId?.isAlphabetShifted == true || sPersistentSelectionModeActive
+ val isSelecting = keyboardSwitcher.keyboard?.mId?.isAlphabetShiftedManually == true || sPersistentSelectionModeActive
if (isSelecting) {
val androidKeyCode = when (primaryCode) {
KeyCode.ARROW_LEFT -> KeyEvent.KEYCODE_DPAD_LEFT
@@ -219,7 +219,7 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp
|| primaryCode == KeyCode.MOVE_START_OF_PAGE || primaryCode == KeyCode.MOVE_END_OF_PAGE
|| primaryCode == KeyCode.MOVE_START_OF_LINE || primaryCode == KeyCode.MOVE_END_OF_LINE
|| primaryCode == KeyCode.PAGE_UP || primaryCode == KeyCode.PAGE_DOWN
- val eventMetaState = if (isEditingNav && (keyboardSwitcher.keyboard?.mId?.isAlphabetShifted == true || sPersistentSelectionModeActive)) {
+ val eventMetaState = if (isEditingNav && (keyboardSwitcher.keyboard?.mId?.isAlphabetShiftedManually == true || sPersistentSelectionModeActive)) {
metaState or KeyEvent.META_SHIFT_ON
} else {
metaState
@@ -391,7 +391,7 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp
val rtl = RichInputMethodManager.getInstance().currentSubtype.isRtlSubtype
val steps = if (rtl) -rawSteps else rawSteps
- val isSelecting = keyboardSwitcher.keyboard?.mId?.isAlphabetShifted == true || sPersistentSelectionModeActive
+ val isSelecting = keyboardSwitcher.keyboard?.mId?.isAlphabetShiftedManually == true || sPersistentSelectionModeActive
if (isSelecting) {
val code = if (steps < 0) {
gestureMoveBackHaptics()
diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java
index 3276af6f5..b603ec77c 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java
+++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java
@@ -69,6 +69,11 @@ public final class KeyboardId {
public static final int ELEMENT_CLIPBOARD_BOTTOM_ROW = 30;
public static final int ELEMENT_HANDWRITING_BOTTOM_ROW = 31;
public static final int ELEMENT_TEXT_EDIT = 32;
+ public static final int ELEMENT_CUSTOM1 = 33;
+ public static final int ELEMENT_CUSTOM2 = 34;
+ public static final int ELEMENT_CUSTOM3 = 35;
+ public static final int ELEMENT_CUSTOM4 = 36;
+ public static final int ELEMENT_CUSTOM5 = 37;
public final RichInputMethodSubtype mSubtype;
public final int mWidth;
@@ -296,6 +301,11 @@ public static String elementIdToName(final int elementId) {
case ELEMENT_CLIPBOARD_BOTTOM_ROW -> "clipboardBottomRow";
case ELEMENT_HANDWRITING_BOTTOM_ROW -> "handwritingBottomRow";
case ELEMENT_TEXT_EDIT -> "editing";
+ case ELEMENT_CUSTOM1 -> "custom1";
+ case ELEMENT_CUSTOM2 -> "custom2";
+ case ELEMENT_CUSTOM3 -> "custom3";
+ case ELEMENT_CUSTOM4 -> "custom4";
+ case ELEMENT_CUSTOM5 -> "custom5";
default -> null;
};
}
diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java
index 9f9334369..41abb8a22 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java
+++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java
@@ -338,6 +338,22 @@ public void setSymbolsShiftedKeyboard() {
setKeyboard(KeyboardId.ELEMENT_SYMBOLS_SHIFTED, KeyboardSwitchState.SYMBOLS_SHIFTED);
}
+ @Override
+ public void setCustomKeyboard(int customIndex) {
+ if (DEBUG_ACTION) {
+ Log.d(TAG, "setCustomKeyboard: " + customIndex);
+ }
+ final int elementId = switch (customIndex) {
+ case 1 -> KeyboardId.ELEMENT_CUSTOM1;
+ case 2 -> KeyboardId.ELEMENT_CUSTOM2;
+ case 3 -> KeyboardId.ELEMENT_CUSTOM3;
+ case 4 -> KeyboardId.ELEMENT_CUSTOM4;
+ case 5 -> KeyboardId.ELEMENT_CUSTOM5;
+ default -> KeyboardId.ELEMENT_ALPHABET;
+ };
+ setKeyboard(elementId, KeyboardSwitchState.OTHER);
+ }
+
public boolean isImeSuppressedByHardwareKeyboard(
@NonNull final SettingsValues settingsValues,
@NonNull final KeyboardSwitchState toggleState) {
diff --git a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java
index 142bbaf9f..39d818f1f 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java
+++ b/app/src/main/java/helium314/keyboard/keyboard/MainKeyboardView.java
@@ -492,6 +492,9 @@ public void onKeyReleased(@NonNull final Key key, final boolean withAnimation) {
private void dismissKeyPreview(@NonNull final Key key) {
if (isHardwareAccelerated()) {
mKeyPreviewChoreographer.dismissKeyPreview(key);
+ } else {
+ // ponytail: fallback if hardware acceleration is disabled
+ dismissKeyPreviewWithoutDelay(key);
}
}
diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt
index 384bf644e..70709a416 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt
+++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardState.kt
@@ -39,6 +39,7 @@ class KeyboardState(private val switchActions: SwitchActions) {
fun toggleNumpad(withSliding: Boolean, autoCapsFlags: Int, recapitalizeMode: RecapitalizeMode?, forceReturnToAlpha: Boolean)
fun setSymbolsKeyboard()
fun setSymbolsShiftedKeyboard()
+ fun setCustomKeyboard(customIndex: Int)
/** Request to call back [KeyboardState.onUpdateShiftState]. */
fun requestUpdatingShiftState(autoCapsFlags: Int, recapitalizeMode: RecapitalizeMode?)
@@ -65,6 +66,8 @@ class KeyboardState(private val switchActions: SwitchActions) {
private var mode = Mode.ALPHABET
private var modeBeforeNumpad = Mode.ALPHABET
+ // ponytail: track active custom layout index, 0 means default
+ private var lastCustomIndex = 0
private var isSymbolShifted = false
private var prevMainKeyboardWasShiftLocked = false
private var prevSymbolsKeyboardWasShifted = false
@@ -109,6 +112,7 @@ class KeyboardState(private val switchActions: SwitchActions) {
savedKeyboardState.isValid = false
} else {
// Reset keyboard to alphabet mode.
+ lastCustomIndex = 0
setAlphabetKeyboard(autoCapsFlags, recapitalizeMode)
}
switchActions.setOneHandedModeEnabled(onHandedModeEnabled)
@@ -151,6 +155,11 @@ class KeyboardState(private val switchActions: SwitchActions) {
Mode.CLIPBOARD -> setClipboardKeyboard()
// don't overwrite toggle state if reloading from orientation change, etc.
Mode.NUMPAD -> setNumpadKeyboard(false, false, false)
+ Mode.CUSTOM1 -> setCustomKeyboard(1)
+ Mode.CUSTOM2 -> setCustomKeyboard(2)
+ Mode.CUSTOM3 -> setCustomKeyboard(3)
+ Mode.CUSTOM4 -> setCustomKeyboard(4)
+ Mode.CUSTOM5 -> setCustomKeyboard(5)
}
}
@@ -205,7 +214,7 @@ class KeyboardState(private val switchActions: SwitchActions) {
if (DebugFlags.DEBUG_ENABLED) {
Log.d(TAG, "toggleAlphabetAndSymbols: ${stateToString(autoCapsFlags, recapitalizeMode)}")
}
- if (mode == Mode.ALPHABET) {
+ if (mode == Mode.ALPHABET || mode.isCustom) {
prevMainKeyboardWasShiftLocked = alphabetShiftState.isShiftLocked
if (prevSymbolsKeyboardWasShifted) setSymbolsShiftedKeyboard() else setSymbolsKeyboard()
prevSymbolsKeyboardWasShifted = false
@@ -223,7 +232,7 @@ class KeyboardState(private val switchActions: SwitchActions) {
if (DebugFlags.DEBUG_ENABLED) {
Log.d(TAG, "resetKeyboardStateToAlphabet: ${stateToString(autoCapsFlags, recapitalizeMode)}")
}
- if (mode == Mode.ALPHABET) return
+ if (mode == Mode.ALPHABET || mode.isCustom) return
prevSymbolsKeyboardWasShifted = isSymbolShifted
setAlphabetKeyboard(autoCapsFlags, recapitalizeMode)
@@ -246,6 +255,12 @@ class KeyboardState(private val switchActions: SwitchActions) {
Log.d(TAG, "setAlphabetKeyboard: ${stateToString(autoCapsFlags, recapitalizeMode)}")
}
+ // ponytail: restore custom layout if active
+ if (lastCustomIndex != 0) {
+ setCustomKeyboard(lastCustomIndex)
+ return
+ }
+
switchActions.setAlphabetKeyboard()
mode = Mode.ALPHABET
isSymbolShifted = false
@@ -254,6 +269,23 @@ class KeyboardState(private val switchActions: SwitchActions) {
switchActions.requestUpdatingShiftState(autoCapsFlags, recapitalizeMode)
}
+ private fun setCustomKeyboard(customIndex: Int) {
+ if (DebugFlags.DEBUG_ENABLED) {
+ Log.d(TAG, "setCustomKeyboard: $customIndex")
+ }
+ mode = when (customIndex) {
+ 1 -> Mode.CUSTOM1
+ 2 -> Mode.CUSTOM2
+ 3 -> Mode.CUSTOM3
+ 4 -> Mode.CUSTOM4
+ 5 -> Mode.CUSTOM5
+ else -> Mode.ALPHABET
+ }
+ lastCustomIndex = customIndex
+ recapitalizeMode = null
+ switchActions.setCustomKeyboard(customIndex)
+ }
+
private fun setSymbolsKeyboard() {
if (DebugFlags.DEBUG_ENABLED) {
Log.d(TAG, "setSymbolsKeyboard")
@@ -340,7 +372,7 @@ class KeyboardState(private val switchActions: SwitchActions) {
setNumpadKeyboard(withSliding, forceReturnToAlpha, rememberState)
return
}
- if (modeBeforeNumpad == Mode.ALPHABET || forceReturnToAlpha) {
+ if (modeBeforeNumpad == Mode.ALPHABET || modeBeforeNumpad.isCustom || forceReturnToAlpha) {
setAlphabetKeyboard(autoCapsFlags, recapitalizeMode)
if (prevMainKeyboardWasShiftLocked) {
setShiftLocked(true)
@@ -355,6 +387,7 @@ class KeyboardState(private val switchActions: SwitchActions) {
Mode.EMOJI -> setEmojiKeyboard()
Mode.CLIPBOARD -> setClipboardKeyboard()
Mode.NUMPAD -> {}
+ else -> {}
}
if (withSliding) switchState = SwitchState.MOMENTARY_FROM_NUMPAD
}
@@ -510,6 +543,10 @@ class KeyboardState(private val switchActions: SwitchActions) {
if (recapitalizeMode != null) {
return
}
+ if (mode.isCustom) {
+ shiftKeyState.onPress()
+ return
+ }
if (mode != Mode.ALPHABET) {
// In symbol mode, just toggle symbol and symbol popup keyboard.
toggleShiftInSymbols()
@@ -555,6 +592,8 @@ class KeyboardState(private val switchActions: SwitchActions) {
if (this.recapitalizeMode != null) {
// We are recapitalizing. We should match the keyboard state to the recapitalize state in priority.
updateShiftStateForRecapitalize(this.recapitalizeMode)
+ } else if (mode.isCustom) {
+ shiftKeyState.onRelease()
} else if (mode != Mode.ALPHABET) {
// In symbol mode, switch back to the previous keyboard mode if the user chords the
// shift key and another key, then releases the shift key.
@@ -665,11 +704,19 @@ class KeyboardState(private val switchActions: SwitchActions) {
updateAlphabetShiftState(autoCapsFlags, recapitalizeMode)
} else when (code) {
KeyCode.EMOJI -> setEmojiKeyboard()
- KeyCode.ALPHA -> setAlphabetKeyboard(autoCapsFlags, recapitalizeMode)
+ KeyCode.ALPHA -> {
+ lastCustomIndex = 0
+ setAlphabetKeyboard(autoCapsFlags, recapitalizeMode)
+ }
// Note: Printing clipboard content is handled in InputLogic.handleFunctionalEvent
KeyCode.CLIPBOARD -> if (Settings.getValues().mClipboardHistoryEnabled) setClipboardKeyboard()
KeyCode.NUMPAD -> toggleNumpad(false, autoCapsFlags, recapitalizeMode, false, true)
KeyCode.SYMBOL -> setSymbolsKeyboard()
+ KeyCode.CUSTOM1 -> setCustomKeyboard(1)
+ KeyCode.CUSTOM2 -> setCustomKeyboard(2)
+ KeyCode.CUSTOM3 -> setCustomKeyboard(3)
+ KeyCode.CUSTOM4 -> setCustomKeyboard(4)
+ KeyCode.CUSTOM5 -> setCustomKeyboard(5)
KeyCode.TOGGLE_ONE_HANDED_MODE -> setOneHandedModeEnabled(!Settings.getValues().mOneHandedModeEnabled)
KeyCode.SWITCH_ONE_HANDED_MODE -> switchOneHandedMode()
KeyCode.TOGGLE_FLOATING_KEYBOARD -> switchActions.toggleFloatingKeyboard()
@@ -679,6 +726,7 @@ class KeyboardState(private val switchActions: SwitchActions) {
override fun toString(): String {
val keyboard = when {
mode == Mode.ALPHABET -> alphabetShiftState.toString()
+ mode.isCustom -> mode.toString()
isSymbolShifted -> "SYMBOLS_SHIFTED"
else -> "SYMBOLS"
}
@@ -707,6 +755,13 @@ class KeyboardState(private val switchActions: SwitchActions) {
EMOJI,
CLIPBOARD,
NUMPAD,
+ CUSTOM1,
+ CUSTOM2,
+ CUSTOM3,
+ CUSTOM4,
+ CUSTOM5;
+
+ val isCustom: Boolean get() = this == CUSTOM1 || this == CUSTOM2 || this == CUSTOM3 || this == CUSTOM4 || this == CUSTOM5
}
private enum class ShiftMode {
diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt
index ac69e9bc5..69d7c3e63 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt
+++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt
@@ -59,6 +59,11 @@ class KeyboardParser(private val params: KeyboardParams, private val context: Co
KeyboardId.ELEMENT_CLIPBOARD_BOTTOM_ROW -> LayoutType.CLIPBOARD_BOTTOM
KeyboardId.ELEMENT_HANDWRITING_BOTTOM_ROW -> LayoutType.HANDWRITING_BOTTOM
KeyboardId.ELEMENT_TEXT_EDIT -> LayoutType.EDITING
+ KeyboardId.ELEMENT_CUSTOM1 -> LayoutType.CUSTOM1
+ KeyboardId.ELEMENT_CUSTOM2 -> LayoutType.CUSTOM2
+ KeyboardId.ELEMENT_CUSTOM3 -> LayoutType.CUSTOM3
+ KeyboardId.ELEMENT_CUSTOM4 -> LayoutType.CUSTOM4
+ KeyboardId.ELEMENT_CUSTOM5 -> LayoutType.CUSTOM5
else -> LayoutType.MAIN
}
val baseKeys = LayoutParser.parseLayout(layoutType, params, context)
@@ -315,31 +320,51 @@ class KeyboardParser(private val params: KeyboardParams, private val context: Co
private fun getNumberRow(): MutableList {
val row = LayoutParser.parseLayout(LayoutType.NUMBER_ROW, params, context).first()
val localizedNumbers = params.mLocaleKeyboardInfos.localizedNumberKeys
- if (localizedNumbers?.size != 10) return row
- if (Settings.getValues().mLocalizedNumberRow) {
- // replace 0-9 with localized numbers, and move latin number into popup
- for (i in row.indices) {
- val key = row[i]
- val number = key.label.toIntOrNull() ?: continue
- when (number) {
- 0 -> row[i] = key.copy(newLabel = localizedNumbers[9], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup))
- in 1..9 -> row[i] = key.copy(newLabel = localizedNumbers[number - 1], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup))
+ if (localizedNumbers?.size == 10) {
+ if (Settings.getValues().mLocalizedNumberRow) {
+ // replace 0-9 with localized numbers, and move latin number into popup
+ for (i in row.indices) {
+ val key = row[i]
+ val number = key.label.toIntOrNull() ?: continue
+ when (number) {
+ 0 -> row[i] = key.copy(newLabel = localizedNumbers[9], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup))
+ in 1..9 -> row[i] = key.copy(newLabel = localizedNumbers[number - 1], newCode = KeyCode.UNSPECIFIED, newPopup = SimplePopups(listOf(key.label)).merge(key.popup))
+ }
+ }
+ } else {
+ // add localized numbers to popups on 0-9
+ for (i in row.indices) {
+ val key = row[i]
+ val number = key.label.toIntOrNull() ?: continue
+ when (number) {
+ 0 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[9])).merge(key.popup))
+ in 1..9 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[number - 1])).merge(key.popup))
+ }
}
}
- } else {
- // add localized numbers to popups on 0-9
+ }
+ if (params.mId.mElementId == KeyboardId.ELEMENT_SYMBOLS_SHIFTED) {
for (i in row.indices) {
- val key = row[i]
- val number = key.label.toIntOrNull() ?: continue
- when (number) {
- 0 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[9])).merge(key.popup))
- in 1..9 -> row[i] = key.copy(newPopup = SimplePopups(listOf(localizedNumbers[number - 1])).merge(key.popup))
- }
+ row[i] = shiftKeyData(row[i])
}
}
return row
}
+ private fun shiftKeyData(key: KeyData): KeyData {
+ val popupLabels = key.popup.getPopupKeyLabels(params) ?: return key
+ val shiftedLabel = popupLabels.firstOrNull() ?: return key
+ val remainingPopups = popupLabels.drop(1)
+ val newPopupList = mutableListOf(key.label)
+ newPopupList.addAll(remainingPopups)
+ val newCode = if (shiftedLabel.length == 1) Character.codePointAt(shiftedLabel, 0) else KeyCode.UNSPECIFIED
+ return key.copy(
+ newLabel = shiftedLabel,
+ newCode = newCode,
+ newPopup = SimplePopups(newPopupList)
+ )
+ }
+
// some layouts have numbers hardcoded in the main layout (pcqwerty as keys, and others as popups)
private fun hasBuiltInNumbers() = params.mId.mSubtype.mainLayoutName == "pcqwerty"
|| (Settings.getValues().mPopupKeyTypes.contains(POPUP_KEYS_LAYOUT)
diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt
index 2f25f25d0..a6081d486 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt
+++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt
@@ -112,6 +112,12 @@ object KeyCode {
const val CURRENCY_SLOT_6 = -806
const val MULTIPLE_CODE_POINTS = -902
+
+ const val CUSTOM1 = -10081
+ const val CUSTOM2 = -10082
+ const val CUSTOM3 = -10083
+ const val CUSTOM4 = -10084
+ const val CUSTOM5 = -10085
//const val DRAG_MARKER = -991
//const val NOOP = -999
@@ -203,6 +209,7 @@ object KeyCode {
const val CLIPBOARD_SEARCH = -10071
const val HANDWRITING = -10074
const val CLEAR_HANDWRITING = -10075
+ const val SWITCH_TO_USER_IME = -10076
// Intents
@@ -229,7 +236,8 @@ object KeyCode {
TIMESTAMP, CTRL_LEFT, CTRL_RIGHT, ALT_LEFT, ALT_RIGHT, META_LEFT, META_RIGHT, SEND_INTENT_ONE, SEND_INTENT_TWO,
SEND_INTENT_THREE, INLINE_EMOJI_SEARCH_DONE, META_LOCK, PROOFREAD, TRANSLATE, SHOW_TRANSLATE_LANGUAGES,
CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5,
- CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, TOGGLE_TEXT_EDIT_MODE, TOGGLE_SELECTION_MODE, HANDWRITING, CLEAR_HANDWRITING
+ CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, TOGGLE_TEXT_EDIT_MODE, TOGGLE_SELECTION_MODE, HANDWRITING, CLEAR_HANDWRITING,
+ CUSTOM1, CUSTOM2, CUSTOM3, CUSTOM4, CUSTOM5, SWITCH_TO_USER_IME
-> this
// conversion
diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt
index abde5f96f..ebf93fd88 100644
--- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt
+++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt
@@ -113,9 +113,15 @@ object KeyLabel {
DEL -> "Del"
TAB -> "!icon/tab_key|!code/${KeyCode.TAB}"
TIMESTAMP -> "⌚"
- else -> if (label in toolbarKeyStrings.values)
- "!icon/$label|!code/${getCodeForToolbarKey(ToolbarKey.valueOf(label.uppercase(Locale.US)))}"
- else label
+ else -> {
+ if (label.startsWith("layout_")) {
+ label.substringAfter("layout_")
+ } else if (label in toolbarKeyStrings.values) {
+ "!icon/$label|!code/${getCodeForToolbarKey(ToolbarKey.valueOf(label.uppercase(Locale.US)))}"
+ } else {
+ label
+ }
+ }
}
val code = when (label) { // maybe a bit lazy to not assemble the entire string above
"clear_handwriting" -> KeyCode.CLEAR_HANDWRITING
@@ -129,7 +135,20 @@ object KeyLabel {
ESCAPE -> KeyCode.ESCAPE
DEL -> KeyCode.FORWARD_DELETE
TIMESTAMP -> KeyCode.TIMESTAMP
- else -> null
+ else -> {
+ if (label.startsWith("layout_")) {
+ when (label.substringAfter("layout_")) {
+ "custom1" -> KeyCode.CUSTOM1
+ "custom2" -> KeyCode.CUSTOM2
+ "custom3" -> KeyCode.CUSTOM3
+ "custom4" -> KeyCode.CUSTOM4
+ "custom5" -> KeyCode.CUSTOM5
+ "symbol" -> KeyCode.SYMBOL
+ "alpha" -> KeyCode.ALPHA
+ else -> null
+ }
+ } else null
+ }
}
return if (code == null) newLabel
else "$newLabel|!code/$code"
diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java
index 5f91d6aaf..854270124 100644
--- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java
+++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java
@@ -117,6 +117,8 @@ void resetDictionaries(
void reloadBlacklist();
+ boolean isBlacklisted(String word);
+
void closeDictionaries();
/** main dictionaries are loaded asynchronously after resetDictionaries */
@@ -171,4 +173,7 @@ void unlearnFromUserHistory(final String word,
default Map getAllMainDictionaryWordsWithFrequency() {
return Collections.emptyMap();
}
+
+ default void forEachMainDictionaryWord(java.util.function.BiConsumer consumer) {
+ }
}
diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt
index 30a08d30a..999722534 100644
--- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt
+++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt
@@ -39,6 +39,7 @@ import helium314.keyboard.latin.utils.SuggestionResults
import helium314.keyboard.latin.utils.getSecondaryLocales
import helium314.keyboard.latin.utils.locale
import helium314.keyboard.latin.utils.prefs
+import helium314.keyboard.latin.utils.DeviceProtectedUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -64,6 +65,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator {
private var mPrefs: SharedPreferences? = null
private var mContext: Context? = null
private var mEnabledDictionariesState: Map = emptyMap()
+ private var mLoadedDownloadPrefs: Map = emptyMap()
private var dictionaryGroups = listOf(DictionaryGroup())
@Volatile
@@ -143,7 +145,8 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator {
if (prefs != null) {
val currentPrefs = prefs.all.filterKeys { it.startsWith("pref_dict_enabled_") }
.mapValues { it.value as? Boolean ?: true }
- if (currentPrefs != mEnabledDictionariesState) {
+ val currentDownloadPrefs = prefs.all.filterKeys { it.startsWith("pref_dict_download_link_") }
+ if (currentPrefs != mEnabledDictionariesState || currentDownloadPrefs != mLoadedDownloadPrefs) {
return false
}
}
@@ -179,6 +182,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator {
mPrefs = prefs
mEnabledDictionariesState = prefs.all.filterKeys { it.startsWith("pref_dict_enabled_") }
.mapValues { it.value as? Boolean ?: true }
+ mLoadedDownloadPrefs = prefs.all.filterKeys { it.startsWith("pref_dict_download_link_") }
// Initialize session word boost with context if not yet done
if (sessionWordBoost == null) {
@@ -392,6 +396,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator {
dictionaryGroup: DictionaryGroup, ngramContext: NgramContext, word: String, wasAutoCapitalized: Boolean,
timeStampInSeconds: Int, blockPotentiallyOffensive: Boolean
) {
+ if (dictionaryGroup.isBlacklisted(word)) return
val userHistoryDictionary = dictionaryGroup.getSubDict(Dictionary.TYPE_USER_HISTORY) ?: return
// Never re-learn a word the user has blacklisted (e.g. a deleted gesture-misfire junk word).
@@ -551,7 +556,17 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator {
if (userDict != null) {
result.putAll(userDict.getAllWordsWithFrequency())
}
- return result
+ return result;
+ }
+
+ override fun forEachMainDictionaryWord(consumer: java.util.function.BiConsumer) {
+ val dictGroup = dictionaryGroups.firstOrNull() ?: return
+ val mainDict = dictGroup.getDict(Dictionary.TYPE_MAIN)
+ mainDict?.forEachWord(consumer)
+ val userHistoryDict = dictGroup.getSubDict(Dictionary.TYPE_USER_HISTORY)
+ userHistoryDict?.forEachWord(consumer)
+ val userDict = dictGroup.getSubDict(Dictionary.TYPE_USER)
+ userDict?.forEachWord(consumer)
}
// TODO: Revise the way to fusion suggestion results.
@@ -609,9 +624,39 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator {
val weightForLocale = dictGroup.getWeightForLocale(dictionaryGroups, composedData.mIsBatchMode)
for (dictType in DictionaryFacilitator.ALL_DICTIONARY_TYPES) {
val dictionary = dictGroup.getDict(dictType) ?: continue
- val dictionarySuggestions = dictionary.getSuggestions(composedData, ngramContext, proximityInfoHandle,
+ var dictionarySuggestions = dictionary.getSuggestions(composedData, ngramContext, proximityInfoHandle,
settingsValuesForSuggestion, sessionId, weightForLocale, weightOfLangModelVsSpatialModel
- ) ?: continue
+ )
+ if (composedData.mTypedWord.isEmpty() && (dictionarySuggestions == null || dictionarySuggestions.isEmpty())
+ && (dictType == Dictionary.TYPE_USER || dictType == Dictionary.TYPE_USER_HISTORY)
+ ) {
+ val allWords = try {
+ dictionary.allWordsWithFrequency
+ } catch (e: Exception) {
+ null
+ }
+ if (allWords != null && allWords.isNotEmpty()) {
+ val topWords = allWords.entries
+ .sortedByDescending { it.value }
+ .take(15)
+ val unigramSuggestions = ArrayList()
+ for (entry in topWords) {
+ unigramSuggestions.add(
+ SuggestedWordInfo(
+ entry.key,
+ "",
+ entry.value,
+ SuggestedWordInfo.KIND_PREDICTION,
+ dictionary,
+ SuggestedWordInfo.NOT_AN_INDEX,
+ SuggestedWordInfo.NOT_A_CONFIDENCE
+ )
+ )
+ }
+ dictionarySuggestions = unigramSuggestions
+ }
+ }
+ if (dictionarySuggestions == null) continue
// For some reason "garbage" words are produced when glide typing. For user history
// and main dictionaries we can filter them out by checking whether the dictionary
@@ -637,7 +682,16 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator {
if (word.length == 1 && info.mSourceDict.mDictType == Dictionary.TYPE_EMOJI && !StringUtils.mightBeEmoji(word[0].code))
continue
- suggestions.add(info)
+ if (composedData.mTypedWord.isEmpty() && (dictType == Dictionary.TYPE_USER_HISTORY || dictType == Dictionary.TYPE_USER)) {
+ val boostedScore = info.mScore + 1000
+ val boostedInfo = SuggestedWordInfo(
+ info.mWord, info.mPrevWordsContext, boostedScore, info.mKindAndFlags,
+ info.mSourceDict, info.mIndexOfTouchPointOfSecondWord, info.mAutoCommitFirstWordConfidence
+ )
+ suggestions.add(boostedInfo)
+ } else {
+ suggestions.add(info)
+ }
}
}
return suggestions
@@ -731,7 +785,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator {
return dictionariesToCheck.any { dictionaryGroup.getDict(it)?.isValidWord(word) == true }
}
- private fun isBlacklisted(word: String): Boolean = dictionaryGroups.any { it.isBlacklisted(word) }
+ override fun isBlacklisted(word: String): Boolean = dictionaryGroups.any { it.isBlacklisted(word) }
override fun removeWord(word: String) {
for (dictionaryGroup in dictionaryGroups) {
@@ -995,9 +1049,9 @@ private class DictionaryGroup(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(2))
// words cannot be (permanently) removed from some dictionaries, so we use a blacklist for "removing" words
- private val blacklistFile = if (context?.filesDir == null) null
+ private val blacklistFile = if (context == null) null
else {
- val file = File(context.filesDir.absolutePath + File.separator + "blacklists" + File.separator + locale.toLanguageTag() + ".txt")
+ val file = File(DeviceProtectedUtils.getFilesDir(context).absolutePath + File.separator + "blacklists" + File.separator + locale.toLanguageTag() + ".txt")
if (file.isDirectory) file.delete() // this apparently was an issue in some versions
if (file.parentFile?.exists() == true || file.parentFile?.mkdirs() == true) file
else null
diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java
index a1874689c..d445b25ad 100644
--- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java
+++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java
@@ -547,6 +547,7 @@ public LatinIME() {
@Override
public void onCreate() {
+ helium314.keyboard.latin.gesture.SwipeGestureEngine.initialize(this);
mSettings.startListener();
KeyboardIconsSet.Companion.getInstance().loadIcons(this);
mRichImm = RichInputMethodManager.getInstance();
@@ -853,6 +854,15 @@ public void onFinishInputView(final boolean finishingInput) {
mHandler.onFinishInputView(finishingInput);
mStatsUtilsManager.onFinishInputView();
mGestureConsumer = GestureConsumer.NULL_GESTURE_CONSUMER;
+ // ponytail: reset text edit mode when input view finishes if persist is false
+ if (KeyboardActionListenerImpl.sPersistentTextEditModeActive) {
+ if (!Settings.getInstance().getCurrent().mPersistTextEditMode) {
+ KeyboardActionListenerImpl.sPersistentTextEditModeActive = false;
+ if (mKeyboardSwitcher != null) {
+ mKeyboardSwitcher.hideTextEditView();
+ }
+ }
+ }
}
@Override
@@ -1533,6 +1543,52 @@ public void switchToNextSubtype() {
mSubtypeState.switchSubtype(mRichImm);
}
+ public void switchToUserIme() {
+ final android.content.SharedPreferences prefs = helium314.keyboard.latin.utils.DeviceProtectedUtils
+ .getSharedPreferences(this);
+ final String target = prefs.getString(Settings.PREF_DIRECT_IME_SWITCH_TARGET, helium314.keyboard.latin.settings.Defaults.PREF_DIRECT_IME_SWITCH_TARGET);
+ if (target == null || target.isEmpty()) {
+ return;
+ }
+ final String[] parts = target.split(";");
+ if (parts.length == 0) return;
+ final String imiId = parts[0];
+ if (imiId.isEmpty()) return;
+
+ android.view.inputmethod.InputMethodInfo targetImi = null;
+ for (final android.view.inputmethod.InputMethodInfo imi : mRichImm.getInputMethodManager().getEnabledInputMethodList()) {
+ if (imi.getId().equals(imiId)) {
+ targetImi = imi;
+ break;
+ }
+ }
+ if (targetImi == null) return;
+
+ android.view.inputmethod.InputMethodSubtype targetSubtype = null;
+ if (parts.length > 1 && !parts[1].isEmpty()) {
+ try {
+ final int subtypeHash = java.lang.Integer.parseInt(parts[1]);
+ for (final android.view.inputmethod.InputMethodSubtype subtype : mRichImm.getEnabledInputMethodSubtypes(targetImi, true)) {
+ if (subtype.hashCode() == subtypeHash) {
+ targetSubtype = subtype;
+ break;
+ }
+ }
+ } catch (NumberFormatException ignored) {
+ }
+ }
+
+ if (targetImi.getId().equals(mRichImm.getInputMethodInfoOfThisIme().getId())) {
+ if (targetSubtype != null) {
+ switchToSubtype(targetSubtype);
+ }
+ } else if (targetSubtype != null) {
+ ImeCompat.INSTANCE.switchInputMethodAndSubtype(this, targetImi, targetSubtype);
+ } else {
+ switchInputMethod(targetImi.getId());
+ }
+ }
+
// Implementation of {@link SuggestionStripView.Listener}.
@Override
public void onCodeInput(final int codePoint, final int x, final int y, final boolean isKeyRepeat) {
@@ -1543,6 +1599,10 @@ public void onCodeInput(final int codePoint, final int x, final int y, final boo
// should
// completely replace #onCodeInput.
public void onEvent(@NonNull final Event event) {
+ if (KeyCode.SWITCH_TO_USER_IME == event.getKeyCode()) {
+ switchToUserIme();
+ return;
+ }
if (KeyCode.VOICE_INPUT == event.getKeyCode()) {
mRichImm.switchToShortcutIme(this);
}
@@ -1742,11 +1802,15 @@ public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) {
}
}
- // ponytail: self-learning — bump gesture rank for words the user picks from fallback engine
if (suggestionInfo.isKindOf(helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo.KIND_CORRECTION)
&& helium314.keyboard.latin.dictionary.Dictionary.DICTIONARY_USER_TYPED.equals(
suggestionInfo.mSourceDict != null ? suggestionInfo.mSourceDict.mDictType : "")) {
- helium314.keyboard.latin.gesture.SwipeGestureEngine.recordAccepted(suggestionInfo.mWord);
+ helium314.keyboard.latin.gesture.SwipeGestureEngine.recordAccepted(
+ suggestionInfo.mWord,
+ mInputLogic.getWordComposer().getComposedDataSnapshot().mInputPointers,
+ mKeyboardSwitcher.getKeyboard(),
+ mInputLogic.getSuggest().getGestureIndex()
+ );
}
}
@@ -1863,6 +1927,11 @@ public void removeSuggestion(final String word) {
mInputLogic.getSuggest().clearNextWordSuggestionsCache();
}
+ public void reloadBlacklist() {
+ mDictionaryFacilitator.reloadBlacklist();
+ mInputLogic.getSuggest().clearNextWordSuggestionsCache();
+ }
+
public DictionaryFacilitator getDictionaryFacilitator() {
return mDictionaryFacilitator;
}
@@ -2033,6 +2102,7 @@ void launchSettings() {
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
| Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ intent.putExtra("from_ime", true);
startActivity(intent);
}
diff --git a/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt b/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt
index 4c9fe480c..803eda1b0 100644
--- a/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt
+++ b/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt
@@ -136,6 +136,8 @@ class SingleDictionaryFacilitator(private val dict: Dictionary) : DictionaryFaci
override fun reloadBlacklist() {}
+ override fun isBlacklisted(word: String): Boolean = false
+
override fun clearUserHistoryDictionary(context: Context) {}
override fun localesAndConfidences(): String? = null
diff --git a/app/src/main/java/helium314/keyboard/latin/Suggest.kt b/app/src/main/java/helium314/keyboard/latin/Suggest.kt
index 952d22fc6..0c0244894 100644
--- a/app/src/main/java/helium314/keyboard/latin/Suggest.kt
+++ b/app/src/main/java/helium314/keyboard/latin/Suggest.kt
@@ -49,6 +49,8 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) {
@Volatile private var gestureIndex: SwipeGestureEngine.GestureIndex? = null
@Volatile private var gestureIndexFingerprint: Int = 0
+ fun getGestureIndex(): SwipeGestureEngine.GestureIndex? = gestureIndex
+
// Cached scoreLimit to avoid repeated Settings lookups in hot path
// The read-then-write of (mLastScoreLimitUpdateTime, mCachedScoreLimitForAutocorrect)
// is guarded by `synchronized(this)` in shouldBeAutoCorrected() to make the update atomic
@@ -59,6 +61,7 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) {
// cache cleared whenever LatinIME.loadSettings is called, notably on changing layout and switching input fields
fun clearNextWordSuggestionsCache() {
nextWordSuggestionsCache.evictAll()
+ gestureIndex = null
// Also reset scoreLimit cache to force refresh on next use
synchronized(this) {
mLastScoreLimitUpdateTime = 0
@@ -101,6 +104,7 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) {
getNextWordSuggestions(ngramContext, keyboard, inputStyleIfNotPrediction, settingsValuesForSuggestion)
else mDictionaryFacilitator.getSuggestionResults(wordComposer.composedDataSnapshot, ngramContext, keyboard,
settingsValuesForSuggestion, SESSION_ID_TYPING, inputStyleIfNotPrediction)
+ filterMultiWordSuggestions(suggestionResults, Settings.getValues().mDisableMultiWordSuggestions)
val trailingSingleQuotesCount = StringUtils.getTrailingSingleQuotesCount(typedWordString)
val suggestionsContainer = getTransformedSuggestedWordInfoList(wordComposer, suggestionResults,
trailingSingleQuotesCount, mDictionaryFacilitator.mainLocale, keyboard)
@@ -344,8 +348,7 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) {
val fingerprint = SwipeGestureEngine.layoutFingerprint(keyboard)
var index = gestureIndex
if (index == null || index.byFirst.isEmpty() || gestureIndexFingerprint != fingerprint) {
- val words = mDictionaryFacilitator.getAllMainDictionaryWordsWithFrequency()
- index = SwipeGestureEngine.buildIndex(words, keyboard)
+ index = SwipeGestureEngine.buildIndex(mDictionaryFacilitator, keyboard)
if (index.byFirst.isNotEmpty()) {
gestureIndex = index
gestureIndexFingerprint = fingerprint
@@ -366,7 +369,9 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) {
settingsValuesForSuggestion, SESSION_ID_GESTURE, inputStyle
)
}
+ filterMultiWordSuggestions(suggestionResults, Settings.getValues().mDisableMultiWordSuggestions)
replaceSingleLetterFirstSuggestion(suggestionResults)
+ adjustToTooSuggestions(suggestionResults, pointers, keyboard)
// For transforming words that don't come from a dictionary, because it's our best bet
val locale = mDictionaryFacilitator.mainLocale
@@ -444,10 +449,46 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) {
if (cachedResults != null) return cachedResults
val newResults = mDictionaryFacilitator.getSuggestionResults(ComposedData(InputPointers(1),
false, ""), ngramContext, keyboard, settingsValuesForSuggestion, SESSION_ID_TYPING, inputStyle)
+ filterMultiWordSuggestions(newResults, Settings.getValues().mDisableMultiWordSuggestions)
nextWordSuggestionsCache.put(ngramContext, newResults)
return newResults
}
+ private fun adjustToTooSuggestions(suggestionResults: SuggestionResults, pointers: InputPointers, keyboard: Keyboard) {
+ if (suggestionResults.size < 2) return
+ val hasLoop = SwipeGestureEngine.hasLoopAtEnd(pointers, keyboard)
+ if (!hasLoop) {
+ var toInfo: SuggestedWordInfo? = null
+ var tooInfo: SuggestedWordInfo? = null
+ for (info in suggestionResults) {
+ val lower = info.mWord.lowercase(Locale.ROOT)
+ if (lower == "to") {
+ toInfo = info
+ } else if (lower == "too") {
+ tooInfo = info
+ }
+ }
+ if (toInfo != null && tooInfo != null && tooInfo.mScore >= toInfo.mScore) {
+ suggestionResults.remove(toInfo)
+ suggestionResults.remove(tooInfo)
+ val toScore = tooInfo.mScore
+ val tooScore = if (tooInfo.mScore > toInfo.mScore) toInfo.mScore else tooInfo.mScore - 1
+ suggestionResults.add(
+ SuggestedWordInfo(
+ toInfo.mWord, toInfo.mPrevWordsContext, toScore,
+ toInfo.mKindAndFlags, toInfo.mSourceDict, toInfo.mIndexOfTouchPointOfSecondWord, toInfo.mAutoCommitFirstWordConfidence
+ )
+ )
+ suggestionResults.add(
+ SuggestedWordInfo(
+ tooInfo.mWord, tooInfo.mPrevWordsContext, tooScore,
+ tooInfo.mKindAndFlags, tooInfo.mSourceDict, tooInfo.mIndexOfTouchPointOfSecondWord, tooInfo.mAutoCommitFirstWordConfidence
+ )
+ )
+ }
+ }
+ }
+
companion object {
private val TAG: String = Suggest::class.java.simpleName
private const val SCORE_LIMIT_CACHE_UPDATE_INTERVAL_MS = 100L
@@ -639,3 +680,8 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) {
}
}
}
+
+
+internal fun filterMultiWordSuggestions(results: SuggestionResults, enabled: Boolean) {
+ if (enabled) results.removeAll { it.mWord.contains(' ') }
+}
diff --git a/app/src/main/java/helium314/keyboard/latin/common/Constants.java b/app/src/main/java/helium314/keyboard/latin/common/Constants.java
index 338a075fa..460388834 100644
--- a/app/src/main/java/helium314/keyboard/latin/common/Constants.java
+++ b/app/src/main/java/helium314/keyboard/latin/common/Constants.java
@@ -234,6 +234,7 @@ public static String printableCode(final int code) {
case KeyCode.TOGGLE_FLOATING_KEYBOARD: return "toggleFloatingKeyboard";
case KeyCode.SPLIT_LAYOUT: return "splitLayout";
case KeyCode.NUMPAD: return "numpad";
+ case KeyCode.SWITCH_TO_USER_IME: return "switchToUserIme";
default:
if (code < CODE_SPACE) return String.format("\\u%02X", code);
if (code < 0x100) return String.format("%c", code);
diff --git a/app/src/main/java/helium314/keyboard/latin/common/Constants.kt b/app/src/main/java/helium314/keyboard/latin/common/Constants.kt
index c54ad17c9..238e65347 100644
--- a/app/src/main/java/helium314/keyboard/latin/common/Constants.kt
+++ b/app/src/main/java/helium314/keyboard/latin/common/Constants.kt
@@ -11,6 +11,7 @@ object Links {
const val GITHUB = "https://github.com/AsafMah/LeanType"
const val LICENSE = "$GITHUB/blob/main/LICENSE"
const val SPONSOR = "https://github.com/sponsors/LeanBitLab"
+ const val FEATURES_URL = "$GITHUB/blob/main/docs/FEATURES.md"
// Original HeliBoard wiki and community links
const val ORIGINAL_GITHUB = "https://github.com/Helium314/HeliBoard"
const val LAYOUT_WIKI_URL = "$ORIGINAL_GITHUB/wiki/2.-Layouts"
diff --git a/app/src/main/java/helium314/keyboard/latin/common/StringUtils.kt b/app/src/main/java/helium314/keyboard/latin/common/StringUtils.kt
index 0596e5fd4..691ff0b5b 100644
--- a/app/src/main/java/helium314/keyboard/latin/common/StringUtils.kt
+++ b/app/src/main/java/helium314/keyboard/latin/common/StringUtils.kt
@@ -65,11 +65,18 @@ fun hasLetterBeforeLastSpaceBeforeCursor(text: CharSequence): Boolean {
fun getFullEmojiAtEnd(text: CharSequence): String {
val s = text.toString()
var offset = s.length
+ if (offset == 0) return ""
+ val lastCodepoint = s.codePointBefore(offset)
+ if (!mightBeEmoji(lastCodepoint)) return ""
+
while (offset > 0) {
val codepoint = s.codePointBefore(offset)
// continue if codepoint could be emoji, or if it's followed by a variation selector
- if (!(mightBeEmoji(codepoint) || (offset <= s.lastIndex && (s[offset].code == 0xFE0F || s[offset].code == 0xFE0E))))
- return text.substring(offset)
+ if (!(mightBeEmoji(codepoint) || (offset <= s.lastIndex && (s[offset].code == 0xFE0F || s[offset].code == 0xFE0E)))) {
+ val result = s.substring(offset)
+ if (isEmoji(result)) return result
+ return s.substring(s.length - Character.charCount(lastCodepoint))
+ }
offset -= Character.charCount(codepoint)
if (offset > 0 && s[offset - 1].code == KeyCode.ZWJ) {
// todo: this appends ZWJ in weird cases like text, ZWJ, emoji
@@ -91,7 +98,9 @@ fun getFullEmojiAtEnd(text: CharSequence): String {
val textToCheck = s.substring(offset)
if (isEmoji(textToCheck)) return textToCheck
}
- return s.substring(offset)
+ val result = s.substring(offset)
+ if (isEmoji(result)) return result
+ return s.substring(s.length - Character.charCount(lastCodepoint))
}
/**
diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java b/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java
index 734d3d706..3af1b590d 100644
--- a/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java
+++ b/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java
@@ -110,6 +110,12 @@ public Map getAllWordsWithFrequency() {
return Collections.emptyMap();
}
+ public void forEachWord(java.util.function.BiConsumer consumer) {
+ for (Map.Entry entry : getAllWordsWithFrequency().entrySet()) {
+ consumer.accept(entry.getKey(), entry.getValue());
+ }
+ }
+
/**
* Get the frequency of the word.
* @param word the word to get the frequency of.
diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryCollection.java b/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryCollection.java
index 5e5b4de22..750700e79 100644
--- a/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryCollection.java
+++ b/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryCollection.java
@@ -99,6 +99,13 @@ public Map getAllWordsWithFrequency() {
return result;
}
+ @Override
+ public void forEachWord(java.util.function.BiConsumer consumer) {
+ for (Dictionary dict : mDictionaries) {
+ dict.forEachWord(consumer);
+ }
+ }
+
@Override
public boolean isInitialized() {
return !mDictionaries.isEmpty();
diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt b/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt
index 01bc3ddd2..b1fff8cad 100644
--- a/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt
+++ b/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt
@@ -7,6 +7,7 @@ package helium314.keyboard.latin.dictionary
import android.content.Context
import helium314.keyboard.latin.common.LocaleUtils
+import helium314.keyboard.latin.common.LocaleUtils.constructLocale
import helium314.keyboard.latin.utils.DictionaryInfoUtils
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.latin.utils.Log
@@ -69,10 +70,19 @@ object DictionaryFactory {
val header = DictionaryInfoUtils.getDictionaryFileHeaderOrNull(file)
if (header != null) {
val prefs = context.prefs()
- val mainPrefKey = "pref_dict_enabled_main:${header.mIdString.substringAfter(":")}"
- if (!prefs.getBoolean(mainPrefKey, true) || !prefs.getBoolean("pref_dict_enabled_${header.mIdString}", true)) {
- Log.i("DictionaryFactory", "skipping disabled dictionary ${header.mIdString}")
- return
+ val dictType = header.mIdString.split(":").first()
+ if (dictType == Dictionary.TYPE_MAIN) {
+ val localeTag = locale.toLanguageTag().lowercase().replace("-", "_")
+ val mainPrefKey = "pref_dict_enabled_main:$localeTag"
+ if (!prefs.getBoolean(mainPrefKey, true)) {
+ Log.i("DictionaryFactory", "skipping disabled main dictionary for locale $locale")
+ return
+ }
+ } else {
+ if (!prefs.getBoolean("pref_dict_enabled_${header.mIdString}", true)) {
+ Log.i("DictionaryFactory", "skipping disabled addon dictionary ${header.mIdString}")
+ return
+ }
}
}
val dictionary = getDictionary(file, locale) ?: return
@@ -95,8 +105,9 @@ object DictionaryFactory {
return null
}
val dictType = header.mIdString.split(":").first()
+ val dictLocale = header.mLocaleString.constructLocale()
val readOnlyBinaryDictionary = ReadOnlyBinaryDictionary(
- file.absolutePath, 0, file.length(), false, locale, dictType
+ file.absolutePath, 0, file.length(), false, dictLocale, dictType
)
if (readOnlyBinaryDictionary.isValidDictionary) {
diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java b/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java
index 2363a9b8a..c908c0fc9 100644
--- a/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java
+++ b/app/src/main/java/helium314/keyboard/latin/dictionary/ExpandableBinaryDictionary.java
@@ -459,6 +459,38 @@ public Map getAllWordsWithFrequency() {
return words;
}
+ @Override
+ public void forEachWord(java.util.function.BiConsumer consumer) {
+ boolean lockAcquired = false;
+ try {
+ lockAcquired = mLock.readLock().tryLock(
+ TIMEOUT_FOR_READ_OPS_IN_MILLISECONDS, TimeUnit.MILLISECONDS);
+ if (lockAcquired) {
+ if (mBinaryDictionary == null || !mBinaryDictionary.isValidDictionary()) {
+ return;
+ }
+ int token = 0;
+ do {
+ BinaryDictionary.GetNextWordAndFrequencyResult result =
+ mBinaryDictionary.getNextWordAndFrequency(token);
+ if (result.mWordAndFrequency == null) break;
+ String word = result.mWordAndFrequency.mWord;
+ int freq = result.mWordAndFrequency.mFrequency;
+ if (word != null && !word.isEmpty() && freq >= 0) {
+ consumer.accept(word, freq);
+ }
+ token = result.mNextToken;
+ } while (token != 0);
+ }
+ } catch (final InterruptedException e) {
+ Log.e(TAG, "Interrupted tryLock() in forEachWord().", e);
+ } finally {
+ if (lockAcquired) {
+ mLock.readLock().unlock();
+ }
+ }
+ }
+
@Override
public int getMaxFrequencyOfExactMatches(final String word) {
reloadDictionaryIfRequired();
diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java b/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java
index 63764d7be..ae17e1063 100644
--- a/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java
+++ b/app/src/main/java/helium314/keyboard/latin/dictionary/ReadOnlyBinaryDictionary.java
@@ -160,6 +160,52 @@ public Map getAllWordsWithFrequency() {
return words;
}
+ @Override
+ public void forEachWord(java.util.function.BiConsumer consumer) {
+ int token = 0;
+ int count = 0;
+ do {
+ if (!mLock.readLock().tryLock()) {
+ try {
+ Thread.sleep(5);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ continue;
+ }
+ try {
+ if (!mBinaryDictionary.isValidDictionary()) {
+ break;
+ }
+ BinaryDictionary.GetNextWordAndFrequencyResult result =
+ mBinaryDictionary.getNextWordAndFrequency(token);
+ if (result.mWordAndFrequency == null) break;
+ String word = result.mWordAndFrequency.mWord;
+ int freq = result.mWordAndFrequency.mFrequency;
+ if (word != null && !word.isEmpty() && freq >= 0) {
+ consumer.accept(word, freq);
+ }
+ token = result.mNextToken;
+ } finally {
+ mLock.readLock().unlock();
+ }
+
+ count++;
+ if (count % 200 == 0) {
+ Thread.yield();
+ }
+ if (count % 2000 == 0) {
+ try {
+ Thread.sleep(1);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ } while (token != 0);
+ }
+
@Override
public WordProperty getWordProperty(String word, boolean isBeginningOfSentence) {
if (mLock.readLock().tryLock()) {
diff --git a/app/src/main/java/helium314/keyboard/latin/gesture/SwipeGestureEngine.java b/app/src/main/java/helium314/keyboard/latin/gesture/SwipeGestureEngine.java
index e0f6baed6..abb8f8133 100644
--- a/app/src/main/java/helium314/keyboard/latin/gesture/SwipeGestureEngine.java
+++ b/app/src/main/java/helium314/keyboard/latin/gesture/SwipeGestureEngine.java
@@ -9,6 +9,8 @@
*/
package helium314.keyboard.latin.gesture;
+import android.content.Context;
+import android.graphics.Rect;
import helium314.keyboard.keyboard.Key;
import helium314.keyboard.keyboard.Keyboard;
import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo;
@@ -16,6 +18,7 @@
import helium314.keyboard.latin.dictionary.Dictionary;
import helium314.keyboard.latin.utils.SuggestionResults;
+import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
@@ -32,28 +35,205 @@ public class SwipeGestureEngine {
// ── Self-learning: boost words user actually confirmed via gesture ─────────
// ponytail: ConcurrentHashMap so corrections from any thread don't corrupt state
private static final ConcurrentHashMap sUserBoost = new ConcurrentHashMap<>();
+ private static final ConcurrentHashMap sUserPaths = new ConcurrentHashMap<>();
private static final int USER_BOOST_MAX = 50; // cap to avoid runaway inflation
+ private static final float[] sUserBoostCache = new float[USER_BOOST_MAX + 1];
+ static {
+ for (int i = 0; i <= USER_BOOST_MAX; i++) {
+ sUserBoostCache[i] = (float) Math.log(i + 1) * 0.08f;
+ }
+ }
+
+ private static File sUserDataFile = null;
+
+ public static void initialize(Context context) {
+ if (sUserDataFile != null) return;
+ sUserDataFile = new File(context.getFilesDir(), "gesture_user_data.bin");
+ loadUserData();
+ }
- /** Call when user selects a gesture suggestion — bumps its score in future matches. */
- public static void recordAccepted(String word) {
+ /** Call when user selects a gesture suggestion — bumps its score and saves their swipe path. */
+ public static void recordAccepted(String word, InputPointers pointers, Keyboard keyboard, GestureIndex activeIndex) {
if (word == null || word.isEmpty()) return;
String key = word.toLowerCase(Locale.ROOT);
sUserBoost.merge(key, 1, (a, b) -> Math.min(a + b, USER_BOOST_MAX));
+
+ if (pointers != null && pointers.getPointerSize() >= 2 && keyboard != null) {
+ int n = pointers.getPointerSize();
+ int[] xs = pointers.getXCoordinates();
+ int[] ys = pointers.getYCoordinates();
+ float kw = keyboard.mOccupiedWidth, kh = keyboard.mOccupiedHeight;
+ float[] rawFlat = new float[n * 2];
+ for (int i = 0; i < n; i++) {
+ rawFlat[2 * i] = xs[i] / kw;
+ rawFlat[2 * i + 1] = ys[i] / kh;
+ }
+ float[] inputVec = resampleFlat(rawFlat, n, N_PTS);
+ sUserPaths.put(key, inputVec);
+
+ // Update active index in-place if provided
+ if (activeIndex != null && !key.isEmpty()) {
+ char first = key.charAt(0);
+ List list = activeIndex.byFirst.get(first);
+ if (list != null) {
+ for (IndexEntry entry : list) {
+ if (getLowerCase(entry.word).equals(key)) {
+ float[] path = new float[N_PTS * 2];
+ entry.unpackPath(path);
+ for (int i = 0; i < N_PTS * 2; i++) {
+ path[i] = path[i] * 0.3f + inputVec[i] * 0.7f;
+ }
+ entry.updatePath(path);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ saveUserDataAsync();
+ }
+
+ private static void saveUserData() {
+ if (sUserDataFile == null) return;
+ try (java.io.DataOutputStream out = new java.io.DataOutputStream(
+ new java.io.BufferedOutputStream(new java.io.FileOutputStream(sUserDataFile)))) {
+ out.writeInt(1); // format version
+
+ // Save boosts
+ out.writeInt(sUserBoost.size());
+ for (Map.Entry entry : sUserBoost.entrySet()) {
+ out.writeUTF(entry.getKey());
+ out.writeInt(entry.getValue());
+ }
+
+ // Save paths
+ out.writeInt(sUserPaths.size());
+ for (Map.Entry entry : sUserPaths.entrySet()) {
+ out.writeUTF(entry.getKey());
+ float[] path = entry.getValue();
+ for (int i = 0; i < N_PTS * 2; i++) {
+ out.writeFloat(path[i]);
+ }
+ }
+ } catch (Exception e) {
+ android.util.Log.e("SwipeGestureEngine", "Error saving user data", e);
+ }
+ }
+
+ private static void saveUserDataAsync() {
+ new Thread(() -> {
+ synchronized (SwipeGestureEngine.class) {
+ saveUserData();
+ }
+ }).start();
+ }
+
+ private static void loadUserData() {
+ if (sUserDataFile == null || !sUserDataFile.exists()) return;
+ synchronized (SwipeGestureEngine.class) {
+ try (java.io.DataInputStream in = new java.io.DataInputStream(
+ new java.io.BufferedInputStream(new java.io.FileInputStream(sUserDataFile)))) {
+ int version = in.readInt();
+ if (version != 1) return;
+
+ sUserBoost.clear();
+ int numBoosts = in.readInt();
+ for (int i = 0; i < numBoosts; i++) {
+ String key = in.readUTF();
+ int count = in.readInt();
+ sUserBoost.put(key, count);
+ }
+
+ sUserPaths.clear();
+ int numPaths = in.readInt();
+ for (int i = 0; i < numPaths; i++) {
+ String key = in.readUTF();
+ float[] path = new float[N_PTS * 2];
+ for (int j = 0; j < N_PTS * 2; j++) {
+ path[j] = in.readFloat();
+ }
+ sUserPaths.put(key, path);
+ }
+ } catch (Exception e) {
+ android.util.Log.e("SwipeGestureEngine", "Error loading user data", e);
+ }
+ }
}
// ── Precomputed index ─────────────────────────────────────────────────────
+ private static String getLowerCase(String s) {
+ int len = s.length();
+ for (int i = 0; i < len; i++) {
+ char c = s.charAt(i);
+ if (c >= 'A' && c <= 'Z') {
+ return s.toLowerCase(Locale.ROOT);
+ }
+ }
+ return s;
+ }
+
+ private static long pack8Bytes(float[] pts, int startIndex) {
+ long value = 0;
+ for (int i = 0; i < 8; i++) {
+ float f = pts[startIndex + i];
+ if (f < 0f) f = 0f;
+ else if (f > 1f) f = 1f;
+ int b = Math.round(f * 255f) & 0xFF;
+ value |= ((long) b) << (i * 8);
+ }
+ return value;
+ }
+
+ private static void unpack8Bytes(long value, float[] out, int startIndex) {
+ for (int i = 0; i < 8; i++) {
+ int b = (int) ((value >>> (i * 8)) & 0xFF);
+ out[startIndex + i] = b / 255f;
+ }
+ }
+
public static class IndexEntry {
public final String word;
- public final float[] path; // length N_PTS*2
public final int frequency;
- // ponytail: cache path length to avoid recomputing every ranking call
- public final float pathLen;
+ // ponytail: cache path length and freq bonus to avoid recomputing every ranking call
+ public float pathLen;
+ public final float freqBonus;
+ private long path0;
+ private long path1;
+ private long path2;
+ private long path3;
+
IndexEntry(String word, float[] path, int frequency) {
this.word = word;
- this.path = path;
this.frequency = frequency;
- this.pathLen = pathLength(path);
+ this.freqBonus = (frequency > 0) ? (float)(Math.log(frequency + 1) * FREQ_WEIGHT) : 0f;
+
+ String lk = getLowerCase(word);
+ float[] blended = path;
+ float[] userPath = sUserPaths.get(lk);
+ if (userPath != null && userPath.length == N_PTS * 2) {
+ blended = new float[N_PTS * 2];
+ for (int i = 0; i < N_PTS * 2; i++) {
+ blended[i] = path[i] * 0.3f + userPath[i] * 0.7f;
+ }
+ }
+ updatePath(blended);
+ }
+
+ public void updatePath(float[] newPath) {
+ this.path0 = pack8Bytes(newPath, 0);
+ this.path1 = pack8Bytes(newPath, 8);
+ this.path2 = pack8Bytes(newPath, 16);
+ this.path3 = pack8Bytes(newPath, 24);
+ this.pathLen = pathLength(newPath);
+ }
+
+ public void unpackPath(float[] out) {
+ unpack8Bytes(path0, out, 0);
+ unpack8Bytes(path1, out, 8);
+ unpack8Bytes(path2, out, 16);
+ unpack8Bytes(path3, out, 24);
}
}
@@ -67,24 +247,28 @@ public static class GestureIndex {
}
}
- public static GestureIndex buildIndex(Map wordsWithFreq, Keyboard keyboard) {
+ public static GestureIndex buildIndex(helium314.keyboard.latin.DictionaryFacilitator facilitator, Keyboard keyboard) {
float[][] charToPos = buildCharToPos(keyboard);
Map> byFirst = new HashMap<>();
- for (Map.Entry entry : wordsWithFreq.entrySet()) {
- String raw = entry.getKey();
- int freq = entry.getValue() != null ? entry.getValue() : 0;
+ facilitator.forEachMainDictionaryWord((raw, freqVal) -> {
+ if (raw == null) return;
+ if (facilitator.isBlacklisted(raw)) return;
+ int freq = freqVal != null ? freqVal : 0;
// ponytail: apply user boost to freq so self-learned words rank higher immediately
- String lk = raw.toLowerCase(Locale.ROOT);
+ String lk = getLowerCase(raw);
Integer boost = sUserBoost.get(lk);
if (boost != null) freq = Math.min(freq + boost * 5, 255);
- if (freq < 3) continue;
+ if (freq < 3) return;
String word = lk;
- if (word.isEmpty()) continue;
+ if (word.isEmpty()) return;
char first = word.charAt(0);
- if (first < 'a' || first > 'z') continue;
+ if (first < 'a' || first > 'z') return;
float[] path = wordPath(word, charToPos);
byFirst.computeIfAbsent(first, k -> new ArrayList<>())
- .add(new IndexEntry(raw, path, freq));
+ .add(new IndexEntry(word, path, freq));
+ });
+ for (List list : byFirst.values()) {
+ list.sort((a, b) -> Integer.compare(b.frequency, a.frequency));
}
return new GestureIndex(byFirst, charToPos);
}
@@ -111,7 +295,7 @@ private static List nearestLettersFromMap(float nx, float ny, float[]
if (d < minDist) minDist = d;
}
List results = new ArrayList<>(4);
- float threshold = minDist + 0.02f;
+ float threshold = minDist + 0.035f;
for (int i = 0; i < 26; i++) {
if (dists[i] <= threshold) results.add((char) ('a' + i));
}
@@ -124,23 +308,50 @@ public static List nearestLetters(int x, int y, Keyboard keyboard) {
return nearestLettersFromMap(x / kw, y / kh, buildCharToPos(keyboard));
}
+ private static float sqDistanceToSegment(float px, float py, float ax, float ay, float bx, float by, float[] outT) {
+ float dx = bx - ax;
+ float dy = by - ay;
+ float segmentLenSq = dx * dx + dy * dy;
+ if (segmentLenSq < 1e-9f) {
+ outT[0] = 0f;
+ return (px - ax) * (px - ax) + (py - ay) * (py - ay);
+ }
+ float t = ((px - ax) * dx + (py - ay) * dy) / segmentLenSq;
+ if (t < 0f) t = 0f;
+ else if (t > 1f) t = 1f;
+ outT[0] = t;
+ float closestX = ax + t * dx;
+ float closestY = ay + t * dy;
+ return (px - closestX) * (px - closestX) + (py - closestY) * (py - closestY);
+ }
+
public static boolean isSequenceMatch(String word, float[] path, float[][] charToPos) {
int n = path.length / 2;
- int pathIdx = 0;
+ int segmentIdx = 0;
+ float prevT = -0.01f;
char lastChar = 0;
- String w = word.toLowerCase(Locale.ROOT);
- for (int i = 0; i < w.length(); i++) {
- char c = w.charAt(i);
+ float[] outT = new float[1];
+ for (int i = 0; i < word.length(); i++) {
+ char c = word.charAt(i);
if (c < 'a' || c > 'z') continue;
if (c == lastChar) continue;
float[] target = charToPos[c - 'a'];
if (target[0] == 0f && target[1] == 0f) continue;
boolean found = false;
- while (pathIdx < n) {
- float dx = path[2 * pathIdx] - target[0];
- float dy = path[2 * pathIdx + 1] - target[1];
- if (dx * dx + dy * dy <= 0.05f) { found = true; break; }
- pathIdx++;
+ while (segmentIdx < n - 1) {
+ float distSq = sqDistanceToSegment(target[0], target[1],
+ path[2 * segmentIdx], path[2 * segmentIdx + 1],
+ path[2 * (segmentIdx + 1)], path[2 * (segmentIdx + 1) + 1], outT);
+ if (distSq <= 0.05f) {
+ float t = outT[0];
+ if (t > prevT) {
+ prevT = t;
+ found = true;
+ break;
+ }
+ }
+ segmentIdx++;
+ prevT = -0.01f;
}
if (!found) return false;
lastChar = c;
@@ -188,8 +399,8 @@ public static SuggestionResults rankByIndex(
// Filter by last letter first; relax if empty
List filtered = new ArrayList<>(candidates.size());
for (IndexEntry e : candidates) {
- String w = e.word.toLowerCase(Locale.ROOT);
- if (!w.isEmpty() && endLetters.contains(w.charAt(w.length() - 1)))
+ String lower = getLowerCase(e.word);
+ if (!lower.isEmpty() && endLetters.contains(lower.charAt(lower.length() - 1)))
filtered.add(e);
}
if (filtered.isEmpty()) filtered = candidates;
@@ -199,21 +410,39 @@ public static SuggestionResults rankByIndex(
// ponytail: parallel float[] + int[] sort avoids Integer boxing
float[] scores = new float[m];
int[] order = new int[m];
+ float[] topScores = new float[maxResults];
+ Arrays.fill(topScores, -Float.MAX_VALUE);
+ float threshold = -Float.MAX_VALUE;
+
+ float[] candidatePath = new float[N_PTS * 2];
for (int i = 0; i < m; i++) {
IndexEntry e = filtered.get(i);
- float freqBonus = (e.frequency > 0) ? (float)(Math.log(e.frequency + 1) * FREQ_WEIGHT) : 0f;
- boolean seqMatch = isSequenceMatch(e.word, inputVec, charToPos);
+ String lower = getLowerCase(e.word);
+ e.unpackPath(candidatePath);
+ boolean seqMatch = isSequenceMatch(lower, inputVec, charToPos);
float seqPenalty = seqMatch ? 0f : -0.4f;
- boolean isPredicted = predictionSet != null && predictionSet.contains(e.word.toLowerCase(Locale.ROOT));
+ boolean isPredicted = predictionSet != null && predictionSet.contains(lower);
float predBonus = isPredicted ? 0.15f : 0f;
- // ponytail: use precomputed pathLen from IndexEntry instead of recalculating
float lenPenalty = -Math.abs(inputLength - e.pathLen) * 0.4f;
- // ponytail: apply self-learning user boost at ranking time too
- String lk = e.word.toLowerCase(Locale.ROOT);
- Integer ub = sUserBoost.get(lk);
- float userBonus = ub != null ? (float) Math.log(ub + 1) * 0.08f : 0f;
- scores[i] = -l2(inputVec, e.path) + freqBonus + seqPenalty + predBonus + lenPenalty + userBonus;
+ Integer ub = sUserBoost.get(lower);
+ float userBonus = ub != null ? sUserBoostCache[ub] : 0f;
+
+ float bonuses = e.freqBonus + seqPenalty + predBonus + lenPenalty + userBonus;
+ float maxL2 = (threshold == -Float.MAX_VALUE) ? Float.MAX_VALUE : (bonuses - threshold);
+ float distance;
+ if (maxL2 <= 0f) {
+ distance = Float.MAX_VALUE;
+ } else {
+ distance = l2(inputVec, candidatePath, maxL2);
+ }
+ float score = -distance + bonuses;
+ scores[i] = score;
order[i] = i;
+
+
+ if (score > threshold) {
+ threshold = updateThreshold(topScores, score);
+ }
}
// ponytail: primitive int sort with insertion sort for small N (fast for <500 items)
@@ -265,8 +494,9 @@ static float[][] buildCharToPos(Keyboard keyboard) {
int code = key.getCode();
if (!isAsciiLetter(code)) continue;
int idx = Character.toLowerCase((char) code) - 'a';
- map[idx][0] = (key.getX() + key.getWidth() / 2f) / kw;
- map[idx][1] = (key.getY() + key.getHeight() / 2f) / kh;
+ Rect hitBox = key.getHitBox();
+ map[idx][0] = hitBox.exactCenterX() / kw;
+ map[idx][1] = hitBox.exactCenterY() / kh;
}
return map;
}
@@ -334,9 +564,10 @@ static float[] resample(List pts, int n) {
return resampleFlat(flat, pts.size(), n);
}
- private static float l2(float[] a, float[] b) {
+ private static float l2(float[] a, float[] b, float maxL2) {
float s = 0;
int n = a.length / 2;
+ float limitSq = maxL2 * maxL2;
for (int i = 0; i < n; i++) {
float dx = a[2 * i] - b[2 * i];
float dy = a[2 * i + 1] - b[2 * i + 1];
@@ -344,7 +575,52 @@ private static float l2(float[] a, float[] b) {
// ponytail: weight endpoints twice — more precisely typed
if (i == 0 || i == n - 1) s += distSq * 2.0f;
else s += distSq;
+ if (s > limitSq) return Float.MAX_VALUE;
}
return (float) Math.sqrt(s);
}
+
+ private static float updateThreshold(float[] topScores, float newScore) {
+ int minIdx = 0;
+ for (int i = 1; i < topScores.length; i++) {
+ if (topScores[i] < topScores[minIdx]) minIdx = i;
+ }
+ if (newScore > topScores[minIdx]) {
+ topScores[minIdx] = newScore;
+ }
+ float min = topScores[0];
+ for (int i = 1; i < topScores.length; i++) {
+ if (topScores[i] < min) min = topScores[i];
+ }
+ return min;
+ }
+
+ public static boolean hasLoopAtEnd(InputPointers pointers, Keyboard keyboard) {
+ int n = pointers.getPointerSize();
+ if (n < 6) return false;
+ int[] xs = pointers.getXCoordinates();
+ int[] ys = pointers.getYCoordinates();
+
+ // Look at the last min(n/2, 10) points
+ int pointsToCheck = Math.min(n / 2, 10);
+ if (pointsToCheck < 4) pointsToCheck = 4;
+ int startIdx = n - pointsToCheck;
+
+ float pathLen = 0f;
+ for (int i = startIdx; i < n - 1; i++) {
+ float dx = xs[i+1] - xs[i];
+ float dy = ys[i+1] - ys[i];
+ pathLen += (float) Math.sqrt(dx * dx + dy * dy);
+ }
+
+ float startEndX = xs[n - 1] - xs[startIdx];
+ float startEndY = ys[n - 1] - ys[startIdx];
+ float displacement = (float) Math.sqrt(startEndX * startEndX + startEndY * startEndY);
+
+ float kw = keyboard.mOccupiedWidth;
+ // Make sure the loop is physically large enough to be a deliberate loop, not finger jitter
+ if (pathLen < kw * 0.02f) return false;
+
+ return pathLen > 2.0f * displacement;
+ }
}
diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt
index 78a5ed0ca..c89a73875 100644
--- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt
+++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt
@@ -154,6 +154,7 @@ class HandwritingView @JvmOverloads constructor(
val intent = android.content.Intent()
intent.setClass(context, helium314.keyboard.settings.SettingsActivity2::class.java)
intent.putExtra("screen", helium314.keyboard.settings.SettingsDestination.Libraries)
+ intent.putExtra("from_ime", true)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP
try {
context.startActivity(intent)
@@ -466,4 +467,4 @@ class HandwritingView @JvmOverloads constructor(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
index 84957ce88..78d08db5f 100644
--- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
+++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
@@ -190,7 +190,7 @@ public final class InputLogic {
/**
* Create a new instance of the input logic.
- *
+ *
* @param latinIME the instance of the parent LatinIME. We
* should remove this when we can.
* @param suggestionStripViewAccessor an object to access the suggestion strip
@@ -307,7 +307,7 @@ public void startInput(final String combiningSpec, final SettingsValues settings
/**
* Call this when the subtype changes.
- *
+ *
* @param combiningSpec the spec string for the combining rules
* @param settingsValues the current settings values
*/
@@ -318,7 +318,7 @@ public void onSubtypeChanged(final String combiningSpec, final SettingsValues se
/**
* Call this when the orientation changes.
- *
+ *
* @param settingsValues the current values of the settings.
*/
public void onOrientationChange(final SettingsValues settingsValues) {
@@ -402,7 +402,7 @@ public InputTransaction onTextInput(final SettingsValues settingsValues, final E
/**
* A suggestion was picked from the suggestion strip.
- *
+ *
* @param settingsValues the current values of the settings.
* @param suggestionInfo the suggestion info.
* @param keyboardShiftState the shift state of the keyboard, as returned by
@@ -532,7 +532,7 @@ public InputTransaction onPickSuggestionManually(final SettingsValues settingsVa
* part of normal typing or whether it was an explicit cursor move by the user.
* In any case,
* do the necessary adjustments.
- *
+ *
* @param oldSelStart old selection start
* @param oldSelEnd old selection end
* @param newSelStart new selection start
@@ -1972,6 +1972,9 @@ private void handleNonFunctionalEvent(final Event event, final InputTransaction
final LatinIME.UIHandler handler) {
inputTransaction.setDidAffectContents();
if (event.getCodePoint() == Constants.CODE_ENTER) {
+ if (tryJumpToNextPlaceholder()) {
+ return;
+ }
final EditorInfo editorInfo = getCurrentInputEditorInfo();
final int imeOptionsActionId = InputTypeUtils.getImeOptionsActionIdFromEditorInfo(editorInfo);
if (InputTypeUtils.IME_ACTION_CUSTOM_LABEL == imeOptionsActionId) {
@@ -2086,7 +2089,7 @@ private void addToHistoryIfEmoji(final String text, final SettingsValues setting
/**
* Handle a non-separator.
- *
+ *
* @param event The event to handle.
* @param settingsValues The current settings values.
* @param inputTransaction The transaction in progress.
@@ -2231,6 +2234,7 @@ private void handleNonSeparatorEvent(final Event event, final SettingsValues set
helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWordForTyped(typedWord, textStr, mLatinIME);
if (result != null) {
if (result.getPrefixLength() > 0) {
+ mConnection.commitText("", 1);
mConnection.deleteTextBeforeCursor(result.getPrefixLength());
}
commitExpandedText(result.getMatchedString(), result.getExpandedText());
@@ -2267,7 +2271,7 @@ private boolean isCursorAtStartOrAfterSeparator(SettingsValues settingsValues) {
/**
* Handle input of a separator code point.
- *
+ *
* @param event The event to handle.
* @param inputTransaction The transaction in progress.
*/
@@ -2341,7 +2345,15 @@ private void handleSeparatorEvent(final Event event, final InputTransaction inpu
}
// isComposingWord() may have changed since we stored wasComposing
if (mWordComposer.isComposingWord()) {
- if (settingsValues.mAutoCorrectEnabled && !isInlineEmojiSearchAction()) {
+ boolean shouldTriggerAutoCorrect = settingsValues.mAutoCorrectEnabled;
+ if (shouldTriggerAutoCorrect) {
+ if ("space".equals(settingsValues.mAutoCorrectTrigger)) {
+ shouldTriggerAutoCorrect = Character.isWhitespace(codePoint);
+ } else if ("punctuation".equals(settingsValues.mAutoCorrectTrigger)) {
+ shouldTriggerAutoCorrect = !Character.isWhitespace(codePoint);
+ }
+ }
+ if (shouldTriggerAutoCorrect && !isInlineEmojiSearchAction()) {
final String separator = shouldAvoidSendingCode ? LastComposedWord.NOT_A_SEPARATOR
: StringUtils.newSingleCodePointString(codePoint);
commitCurrentAutoCorrection(settingsValues, separator, handler);
@@ -2448,7 +2460,7 @@ private void handleSeparatorEvent(final Event event, final InputTransaction inpu
/**
* Handle a press on the backspace key.
- *
+ *
* @param event The event to handle.
* @param inputTransaction The transaction in progress.
*/
@@ -2863,7 +2875,7 @@ private void handleLanguageSwitchKey() {
* This method will check that there are two characters before the cursor and
* that the first
* one is a space before it does the actual swapping.
- *
+ *
* @param event The event to handle.
* @param inputTransaction The transaction in progress.
* @return true if the swap has been performed, false if it was prevented by
@@ -2912,11 +2924,11 @@ private static boolean isSpaceStrippingPunctuation(final int codePoint) {
/*
* Strip a trailing space if necessary and returns whether it's a swap weak
* space situation.
- *
+ *
* @param event The event to handle.
- *
+ *
* @param inputTransaction The transaction in progress.
- *
+ *
* @return whether we should swap the space instead of removing it.
*/
private boolean tryStripSpaceAndReturnWhetherShouldSwapInstead(final Event event,
@@ -2930,7 +2942,8 @@ private boolean tryStripSpaceAndReturnWhetherShouldSwapInstead(final Event event
}
// ponytail: only strip auto-inserted spaces (WEAK/PHANTOM/SWAP), never manual (NONE)
- if (isSpaceStrippingPunctuation(codePoint)
+ if (!inputTransaction.getSettingsValues().mPreserveSpaceBeforePunctuation
+ && isSpaceStrippingPunctuation(codePoint)
&& !inputTransaction.getSettingsValues().isUsuallyPrecededBySpace(codePoint)
&& inputTransaction.getSpaceState() != SpaceState.NONE) {
if (mConnection.getCodePointBeforeCursor() == Constants.CODE_SPACE) {
@@ -2938,7 +2951,8 @@ private boolean tryStripSpaceAndReturnWhetherShouldSwapInstead(final Event event
}
}
- if ((SpaceState.WEAK == inputTransaction.getSpaceState()
+ if (!inputTransaction.getSettingsValues().mPreserveSpaceBeforePunctuation
+ && (SpaceState.WEAK == inputTransaction.getSpaceState()
|| SpaceState.SWAP_PUNCTUATION == inputTransaction.getSpaceState())
&& isFromSuggestionStrip) {
if (inputTransaction.getSettingsValues().isUsuallyPrecededBySpace(codePoint)) {
@@ -3063,7 +3077,7 @@ private static boolean canBeFollowedByDoubleSpacePeriod(final int codePoint) {
/**
* Performs a recapitalization event.
- *
+ *
* @param settingsValues The current settings values.
*/
private void performRecapitalization(final SettingsValues settingsValues) {
@@ -3509,7 +3523,7 @@ private void revertCommit(final InputTransaction inputTransaction) {
/**
* Factor in auto-caps and manual caps and compute the current caps mode.
- *
+ *
* @param settingsValues the current settings values.
* @param keyboardShiftMode the current shift mode of the keyboard. See
* KeyboardSwitcher#getKeyboardShiftMode() for possible
@@ -3590,7 +3604,7 @@ private EditorInfo getCurrentInputEditorInfo() {
/**
* Get n-gram context from the nth previous word before the cursor as context
* for the suggestion process.
- *
+ *
* @param spacingAndPunctuations the current spacing and punctuations settings.
* @param nthPreviousWord reverse index of the word to get (1-indexed)
* @return the information of previous words
@@ -3740,7 +3754,7 @@ private void resetComposingState(final boolean alsoResetLastComposedWord) {
* See
* {@link helium314.keyboard.latin.SuggestedWords#getTypedWordAndPreviousSuggestions(
* SuggestedWordInfo, helium314.keyboard.latin.SuggestedWords)}.
- *
+ *
* @param typedWordInfo The typed word as a SuggestedWordInfo.
* @param previousSuggestedWords The previously suggested words.
* @return Obsolete suggestions with the newly typed word.
@@ -3932,7 +3946,7 @@ private boolean textBeforeCursorMayBeUrlOrSimilar(final SettingsValues settingsV
/**
* Do the final processing after a batch input has ended. This commits the word
* to the editor.
- *
+ *
* @param settingsValues the current values of the settings.
* @param suggestedWords suggestedWords to use.
*/
@@ -4299,6 +4313,7 @@ private void commitChosenWord(final SettingsValues settingsValues, final String
mConnection.commitText(getTextWithSuggestionSpan(mLatinIME, chosenWord, mSuggestedWords, getDictionaryFacilitatorLocale()), 1);
mConnection.deleteTextBeforeCursor(result.getPrefixLength() + chosenWord.length());
commitExpandedText(result.getMatchedString(), result.getExpandedText());
+ resetComposingState(true);
return;
}
@@ -4525,7 +4540,7 @@ private void setComposingTextInternalWithBackgroundColor(final CharSequence newC
/**
* Gets an object allowing private IME commands to be sent to the
* underlying editor.
- *
+ *
* @return An object for sending private commands to the underlying editor.
*/
public PrivateCommandPerformer getPrivateCommandPerformer() {
@@ -4558,7 +4573,7 @@ public int getComposingStart() {
/**
* Gets the expected length in Java chars of the composing span.
* May be 0 if there is no valid composing span.
- *
+ *
* @see #getComposingStart()
* @return The expected length of the composing span.
*/
@@ -4879,24 +4894,112 @@ public void onError(String errorMessage) {
});
}
+ private boolean tryJumpToNextPlaceholder() {
+ final CharSequence before = mConnection.getTextBeforeCursor(1000, 0);
+ final CharSequence after = mConnection.getTextAfterCursor(1000, 0);
+ final String beforeStr = before != null ? before.toString() : "";
+ final String afterStr = after != null ? after.toString() : "";
+
+ final String fullText = beforeStr + afterStr;
+
+ Log.d(TAG, "tryJumpToNextPlaceholder: beforeStr=[" + beforeStr + "] afterStr=[" + afterStr + "]");
+
+ final java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("%cursor(\\d+)%");
+ final java.util.regex.Matcher matcher = pattern.matcher(fullText);
+
+ int bestStart = -1;
+ int bestEnd = -1;
+ int lowestNum = Integer.MAX_VALUE;
+
+ while (matcher.find()) {
+ try {
+ final int num = Integer.parseInt(matcher.group(1));
+ Log.d(TAG, "tryJumpToNextPlaceholder: found %cursor" + num + "% at [" + matcher.start() + "," + matcher.end() + ")");
+ if (num < lowestNum) {
+ lowestNum = num;
+ bestStart = matcher.start();
+ bestEnd = matcher.end();
+ }
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ }
+
+ if (bestStart != -1) {
+ mConnection.finishComposingText();
+ mConnection.tryFixIncorrectCursorPosition();
+ resetComposingState(true);
+
+ final CharSequence before2 = mConnection.getTextBeforeCursor(1000, 0);
+ final String beforeStr2 = before2 != null ? before2.toString() : "";
+ final int cursorPositionInFull = beforeStr2.length();
+
+ final int currentSelectionEnd = mConnection.getExpectedSelectionEnd();
+ final int targetStart = currentSelectionEnd - cursorPositionInFull + bestStart;
+ final int targetEnd = currentSelectionEnd - cursorPositionInFull + bestEnd;
+ Log.d(TAG, "tryJumpToNextPlaceholder: jumping to [" + targetStart + "," + targetEnd + ") currentSelEnd=" + currentSelectionEnd);
+ mConnection.beginBatchEdit();
+ mConnection.setSelection(targetStart, targetEnd);
+ mConnection.commitText("", 1);
+ mConnection.endBatchEdit();
+ return true;
+ }
+ Log.d(TAG, "tryJumpToNextPlaceholder: no placeholder found");
+ return false;
+ }
+
private void commitExpandedText(final String shortcut, final String expanded) {
final int cursorOffset = expanded.indexOf("%cursor%");
- final String finalExpandedText = cursorOffset != -1 ? expanded.replace("%cursor%", "") : expanded;
-
- mConnection.commitText(finalExpandedText, 1);
-
- mLastExpandedText = finalExpandedText;
- mLastShortcutText = shortcut;
- mLastExpandedCursorOffset = cursorOffset != -1 ? cursorOffset : finalExpandedText.length();
-
if (cursorOffset != -1) {
+ final String finalExpandedText = expanded.replace("%cursor%", "");
+ mConnection.commitText(finalExpandedText, 1);
+ mLastExpandedText = finalExpandedText;
+ mLastShortcutText = shortcut;
+ mLastExpandedCursorOffset = cursorOffset;
final int moveBackAmount = finalExpandedText.length() - cursorOffset;
if (moveBackAmount > 0) {
final int newCursorPos = mConnection.getExpectedSelectionEnd() - moveBackAmount;
mConnection.setSelection(newCursorPos, newCursorPos);
}
+ mLastExpandedCursorPosition = mConnection.getExpectedSelectionEnd();
+ return;
+ }
+
+ final java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("%cursor(\\d+)%");
+ final java.util.regex.Matcher matcher = pattern.matcher(expanded);
+ int bestStart = -1;
+ int bestEnd = -1;
+ int lowestNum = Integer.MAX_VALUE;
+ while (matcher.find()) {
+ try {
+ final int num = Integer.parseInt(matcher.group(1));
+ if (num < lowestNum) {
+ lowestNum = num;
+ bestStart = matcher.start();
+ bestEnd = matcher.end();
+ }
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ }
+
+ if (bestStart != -1) {
+ final String finalExpandedText = expanded.substring(0, bestStart) + expanded.substring(bestEnd);
+ mConnection.commitText(finalExpandedText, 1);
+ mLastExpandedText = finalExpandedText;
+ mLastShortcutText = shortcut;
+ mLastExpandedCursorOffset = bestStart;
+ final int moveBackAmount = finalExpandedText.length() - bestStart;
+ if (moveBackAmount > 0) {
+ final int newCursorPos = mConnection.getExpectedSelectionEnd() - moveBackAmount;
+ mConnection.setSelection(newCursorPos, newCursorPos);
+ }
+ } else {
+ mConnection.commitText(expanded, 1);
+ mLastExpandedText = expanded;
+ mLastShortcutText = shortcut;
+ mLastExpandedCursorOffset = expanded.length();
}
-
mLastExpandedCursorPosition = mConnection.getExpectedSelectionEnd();
}
}
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 15398e7c8..75f15a0d6 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
@@ -42,9 +42,15 @@ object Defaults {
LayoutType.SHORTCUT_BOTTOM -> "shortcut_bottom"
LayoutType.HANDWRITING_BOTTOM -> "handwriting_bottom_row"
LayoutType.EDITING -> "editing"
+ LayoutType.CUSTOM1 -> "symbols"
+ LayoutType.CUSTOM2 -> "symbols"
+ LayoutType.CUSTOM3 -> "symbols"
+ LayoutType.CUSTOM4 -> "symbols"
+ LayoutType.CUSTOM5 -> "symbols"
}
const val PREF_SPLIT_TOOLBAR = false
+ const val PREF_SHOW_DOWNLOAD_BUTTON_IN_TOOLBAR = true
private const val DEFAULT_SIZE_SCALE = 1.0f // 100%
@@ -69,6 +75,7 @@ object Defaults {
@JvmField
var PREF_POPUP_ON = true
const val PREF_AUTO_CORRECTION = false
+ const val PREF_AUTO_CORRECT_TRIGGER = "both"
const val PREF_MORE_AUTO_CORRECTION = false
const val PREF_AUTO_CORRECT_THRESHOLD = 0.185f
const val PREF_AUTOCORRECT_SHORTCUTS = true
@@ -82,6 +89,7 @@ object Defaults {
const val PREF_BLOCK_POTENTIALLY_OFFENSIVE = true
const val PREF_SHOW_LANGUAGE_SWITCH_KEY = false
const val PREF_LANGUAGE_SWITCH_KEY = "internal"
+ const val PREF_DIRECT_IME_SWITCH_TARGET = ""
const val PREF_SHOW_EMOJI_KEY = false
const val PREF_VARIABLE_TOOLBAR_DIRECTION = true
const val PREF_ADDITIONAL_SUBTYPES = "de${Separators.SET}${ExtraValue.KEYBOARD_LAYOUT_SET}=MAIN:qwerty${Separators.SETS}" +
@@ -92,6 +100,8 @@ object Defaults {
const val PREF_PERSIST_FLOATING_KEYBOARD = false
// ponytail: persist text edit mode default
const val PREF_PERSIST_TEXT_EDIT_MODE = false
+ // ponytail: default value to disable multi-word suggestions is false
+ const val PREF_DISABLE_MULTI_WORD_SUGGESTIONS = false
@JvmField
val PREF_SPLIT_SPACER_SCALE = Array(2) { DEFAULT_SIZE_SCALE }
@JvmField
@@ -117,6 +127,7 @@ object Defaults {
const val PREF_AUTOSPACE_AFTER_GESTURE_TYPING = true
const val PREF_AUTOSPACE_BEFORE_GESTURE_TYPING = true
const val PREF_SHIFT_REMOVES_AUTOSPACE = false
+ const val PREF_PRESERVE_SPACE_BEFORE_PUNCTUATION = false
const val PREF_ALWAYS_INCOGNITO_MODE = false
const val PREF_BIGRAM_PREDICTIONS = true
const val PREF_SUGGEST_PUNCTUATION = false
@@ -199,7 +210,7 @@ object Defaults {
const val PREF_OFFLINE_TOP_K = 40
const val PREF_OFFLINE_MIN_P = 0.05f
const val PREF_OFFLINE_SHOW_THINKING = false
- const val PREF_OFFLINE_SYSTEM_PROMPT = "Correct the grammar and spelling. Output only the corrected text."
+ const val PREF_OFFLINE_SYSTEM_PROMPT = "Correct the grammar and spelling. Keep the same language as the input. Do not translate. Output only the corrected text."
const val PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT = "Translate the following text to {lang}. Output only the translation, nothing else:\n\n"
const val PREF_OFFLINE_MAX_TOKENS = 64 // Accurate (64 tokens) default
const val PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE = "French"
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 ebbebde33..c3433d26a 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
@@ -78,6 +78,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
public static final String PREF_SHOW_EMOJI_DESCRIPTIONS = "show_emoji_descriptions";
public static final String PREF_POPUP_ON = "popup_on";
public static final String PREF_AUTO_CORRECTION = "auto_correction";
+ public static final String PREF_AUTO_CORRECT_TRIGGER = "auto_correction_trigger";
public static final String PREF_MORE_AUTO_CORRECTION = "more_auto_correction";
public static final String PREF_AUTO_CORRECT_THRESHOLD = "auto_correct_threshold";
public static final String PREF_AUTOCORRECT_SHORTCUTS = "autocorrect_shortcuts";
@@ -91,6 +92,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
public static final String PREF_BLOCK_POTENTIALLY_OFFENSIVE = "block_potentially_offensive";
public static final String PREF_SHOW_LANGUAGE_SWITCH_KEY = "show_language_switch_key";
public static final String PREF_LANGUAGE_SWITCH_KEY = "language_switch_key";
+ public static final String PREF_DIRECT_IME_SWITCH_TARGET = "direct_ime_switch_target";
public static final String PREF_SHOW_EMOJI_KEY = "show_emoji_key";
public static final String PREF_VARIABLE_TOOLBAR_DIRECTION = "var_toolbar_direction";
public static final String PREF_ADDITIONAL_SUBTYPES = "additional_subtypes";
@@ -120,6 +122,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
public static final String PREF_AUTOSPACE_AFTER_GESTURE_TYPING = "autospace_after_gesture_typing";
public static final String PREF_AUTOSPACE_BEFORE_GESTURE_TYPING = "autospace_before_gesture_typing";
public static final String PREF_SHIFT_REMOVES_AUTOSPACE = "shift_removes_autospace";
+ public static final String PREF_PRESERVE_SPACE_BEFORE_PUNCTUATION = "preserve_space_before_punctuation";
public static final String PREF_ALWAYS_INCOGNITO_MODE = "always_incognito_mode";
public static final String PREF_BIGRAM_PREDICTIONS = "next_word_prediction";
public static final String PREF_SUGGEST_PUNCTUATION = "suggest_punctuation";
@@ -196,6 +199,8 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
public static final String PREF_USE_CONTACTS = "use_contacts";
public static final String PREF_USE_APPS = "use_apps";
public static final String PREFS_LONG_PRESS_SYMBOLS_FOR_NUMPAD = "long_press_symbols_for_numpad";
+ // ponytail: preference key to disable multi-word suggestions
+ public static final String PREF_DISABLE_MULTI_WORD_SUGGESTIONS = "disable_multi_word_suggestions";
public static final String PREF_ONE_HANDED_MODE_PREFIX = "one_handed_mode_enabled";
public static final String PREF_ONE_HANDED_GRAVITY_PREFIX = "one_handed_mode_gravity";
@@ -278,6 +283,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
public static final String PREF_SPLIT_TOOLBAR = "split_toolbar";
public static final String PREF_TOOLBAR_SWIPE_DOWN_TO_HIDE = "toolbar_swipe_down_to_hide";
public static final String PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD = "only_toolbar_with_hw_keyboard";
+ public static final String PREF_SHOW_DOWNLOAD_BUTTON_IN_TOOLBAR = "show_download_button_in_toolbar";
// Emoji
public static final String PREF_EMOJI_MAX_SDK = "emoji_max_sdk";
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 d8b6cdc3b..656bcf397 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
@@ -98,6 +98,7 @@ public class SettingsValues {
public final boolean mAutospaceAfterGestureTyping;
public final boolean mAutospaceBeforeGestureTyping;
public final boolean mShiftRemovesAutospace;
+ public final boolean mPreserveSpaceBeforePunctuation;
public final boolean mClipboardHistoryEnabled;
public final long mClipboardHistoryRetentionTime;
public final boolean mClipboardHistoryPinnedFirst;
@@ -172,6 +173,7 @@ public class SettingsValues {
public final ToolbarMode mToolbarMode;
public final boolean mToolbarHidingGlobal;
public final boolean mSplitToolbar;
+ public final boolean mShowDownloadButtonInToolbar;
public final boolean mAutoShowToolbar;
public final boolean mAutoShowToolbarOnSelect;
public final boolean mAutoHideToolbar;
@@ -200,6 +202,7 @@ public class SettingsValues {
public final int mKeypressVibrationAmplitude;
public final float mKeypressSoundVolume;
public final boolean mAutoCorrectionEnabledPerUserSettings;
+ public final String mAutoCorrectTrigger;
public final boolean mAutoCorrectEnabled;
public final float mAutoCorrectionThreshold;
public final boolean mAutoCorrectShortcuts;
@@ -207,6 +210,7 @@ public class SettingsValues {
// ponytail: persist text edit mode field
public final boolean mPersistTextEditMode;
public final boolean mBackspaceRevertsAutocorrect;
+ public final boolean mDisableMultiWordSuggestions;
public final int mScoreLimitForAutocorrect;
private final boolean mSuggestionsEnabledPerUserSettings;
private final boolean mOverrideShowingSuggestions;
@@ -238,6 +242,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
mToolbarHidingGlobal = prefs.getBoolean(Settings.PREF_TOOLBAR_HIDING_GLOBAL,
Defaults.PREF_TOOLBAR_HIDING_GLOBAL);
mSplitToolbar = prefs.getBoolean(Settings.PREF_SPLIT_TOOLBAR, Defaults.PREF_SPLIT_TOOLBAR);
+ mShowDownloadButtonInToolbar = prefs.getBoolean(Settings.PREF_SHOW_DOWNLOAD_BUTTON_IN_TOOLBAR,
+ Defaults.PREF_SHOW_DOWNLOAD_BUTTON_IN_TOOLBAR);
mAutoCap = prefs.getBoolean(Settings.PREF_AUTO_CAP, Defaults.PREF_AUTO_CAP)
&& ScriptUtils.scriptSupportsUppercase(mLocale);
mVibrateOn = Settings.readVibrationEnabled(prefs);
@@ -289,6 +295,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
mUrlDetectionEnabled = prefs.getBoolean(Settings.PREF_URL_DETECTION, Defaults.PREF_URL_DETECTION);
mAutoCorrectionEnabledPerUserSettings = prefs.getBoolean(Settings.PREF_AUTO_CORRECTION,
Defaults.PREF_AUTO_CORRECTION);
+ mAutoCorrectTrigger = prefs.getString(Settings.PREF_AUTO_CORRECT_TRIGGER,
+ Defaults.PREF_AUTO_CORRECT_TRIGGER);
mAutoCorrectEnabled = mAutoCorrectionEnabledPerUserSettings
&& (mInputAttributes.mInputTypeShouldAutoCorrect
|| prefs.getBoolean(Settings.PREF_MORE_AUTO_CORRECTION,
@@ -312,6 +320,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
Defaults.PREF_PERSIST_TEXT_EDIT_MODE);
mBackspaceRevertsAutocorrect = prefs.getBoolean(Settings.PREF_BACKSPACE_REVERTS_AUTOCORRECT,
Defaults.PREF_BACKSPACE_REVERTS_AUTOCORRECT);
+ mDisableMultiWordSuggestions = prefs.getBoolean(Settings.PREF_DISABLE_MULTI_WORD_SUGGESTIONS,
+ Defaults.PREF_DISABLE_MULTI_WORD_SUGGESTIONS);
mBigramPredictionEnabled = prefs.getBoolean(Settings.PREF_BIGRAM_PREDICTIONS,
Defaults.PREF_BIGRAM_PREDICTIONS);
mSuggestPunctuation = prefs.getBoolean(Settings.PREF_SUGGEST_PUNCTUATION,
@@ -476,6 +486,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
Defaults.PREF_AUTOSPACE_BEFORE_GESTURE_TYPING);
mShiftRemovesAutospace = prefs.getBoolean(Settings.PREF_SHIFT_REMOVES_AUTOSPACE,
Defaults.PREF_SHIFT_REMOVES_AUTOSPACE);
+ mPreserveSpaceBeforePunctuation = prefs.getBoolean(Settings.PREF_PRESERVE_SPACE_BEFORE_PUNCTUATION,
+ Defaults.PREF_PRESERVE_SPACE_BEFORE_PUNCTUATION);
mClipboardHistoryEnabled = prefs.getBoolean(Settings.PREF_ENABLE_CLIPBOARD_HISTORY,
Defaults.PREF_ENABLE_CLIPBOARD_HISTORY);
mClipboardHistoryRetentionTime = prefs.getInt(Settings.PREF_CLIPBOARD_HISTORY_RETENTION_TIME,
diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/MoreSuggestionsView.kt b/app/src/main/java/helium314/keyboard/latin/suggestions/MoreSuggestionsView.kt
index 40c354291..69aa1001c 100644
--- a/app/src/main/java/helium314/keyboard/latin/suggestions/MoreSuggestionsView.kt
+++ b/app/src/main/java/helium314/keyboard/latin/suggestions/MoreSuggestionsView.kt
@@ -5,6 +5,7 @@
*/
package helium314.keyboard.latin.suggestions
+import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.GestureDetector
@@ -203,6 +204,96 @@ class MoreSuggestionsView @JvmOverloads constructor(
onHoverEvent(motionEvent)
}
+ private val longPressHandler = android.os.Handler(android.os.Looper.getMainLooper())
+ private var pendingLongPressRunnable: Runnable? = null
+ private var activeKeyForLongPress: Key? = null
+
+ private fun startLongPressTimer(key: Key) {
+ cancelLongPressTimer()
+ activeKeyForLongPress = key
+ val runnable = Runnable {
+ onLongPressKey(key)
+ cancelLongPressTimer()
+ }
+ pendingLongPressRunnable = runnable
+ longPressHandler.postDelayed(runnable, 500)
+ }
+
+ private fun cancelLongPressTimer() {
+ pendingLongPressRunnable?.let {
+ longPressHandler.removeCallbacks(it)
+ }
+ pendingLongPressRunnable = null
+ activeKeyForLongPress = null
+ }
+
+ private fun findKeyAt(x: Int, y: Int): Key? {
+ val keyboard = keyboard ?: return null
+ for (key in keyboard.sortedKeys) {
+ if (key.isOnKey(x, y)) {
+ return key
+ }
+ }
+ return null
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onTouchEvent(me: MotionEvent): Boolean {
+ val action = me.actionMasked
+ val index = me.actionIndex
+ val x = me.getX(index).toInt()
+ val y = me.getY(index).toInt()
+
+ when (action) {
+ MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
+ val key = findKeyAt(x, y)
+ if (key != null) {
+ startLongPressTimer(key)
+ }
+ }
+ MotionEvent.ACTION_MOVE -> {
+ val key = findKeyAt(x, y)
+ if (key != activeKeyForLongPress) {
+ cancelLongPressTimer()
+ }
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> {
+ cancelLongPressTimer()
+ }
+ }
+ return super.onTouchEvent(me)
+ }
+
+ fun onLongPressKey(key: Key) {
+ if (key !is MoreSuggestionKey) return
+ val keyboard = keyboard
+ if (keyboard !is MoreSuggestions) return
+ val suggestedWords = keyboard.mSuggestedWords
+ val index = key.mSuggestedWordIndex
+ if (index < 0 || index >= suggestedWords.size()) return
+ val word = suggestedWords.getInfo(index).word
+
+ val themeContext = helium314.keyboard.latin.utils.getPlatformDialogThemeContext(context)
+ val dialog = android.app.AlertDialog.Builder(themeContext)
+ .setMessage(context.getString(R.string.delete_confirmation, word))
+ .setPositiveButton(R.string.delete) { _, _ ->
+ listener.removeSuggestion(word)
+ dismissPopupKeysPanel()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .create()
+
+ val window = dialog.window
+ if (window != null) {
+ val layoutParams = window.attributes
+ layoutParams.token = windowToken
+ layoutParams.type = android.view.WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG
+ window.attributes = layoutParams
+ window.addFlags(android.view.WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
+ }
+ dialog.show()
+ }
+
companion object {
private val TAG = MoreSuggestionsView::class.java.simpleName
}
diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java
index 13c737a0f..54f0ef664 100644
--- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java
+++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripLayoutHelper.java
@@ -372,6 +372,7 @@ public int layoutAndReturnStartIndexOfMoreSuggestions(
suggestedWords, mSuggestionsCountInStrip);
final TextView centerWordView = mWordViews.get(mCenterPositionInStrip);
final int stripWidth = stripView.getWidth();
+
final int centerWidth = getSuggestionWidth(mCenterPositionInStrip, stripWidth);
if (wordCountToShow == 1 || getTextScaleX(centerWordView.getText(), centerWidth,
centerWordView.getPaint()) < MIN_TEXT_XSCALE) {
@@ -542,10 +543,11 @@ private int setupWordViewsAndReturnStartIndexOfMoreSuggestions(
}
}
- if (emojiTypeface != null && StringUtilsKt.isEmoji(wordView.getText()))
+ if (emojiTypeface != null && StringUtilsKt.isEmoji(wordView.getText())) {
wordView.setTypeface(emojiTypeface);
- else
- wordView.setTypeface(Typeface.DEFAULT); // todo: maybe use user-provided typeface here?
+ } else {
+ wordView.setTypeface(getTextTypeface(wordView.getText()));
+ }
if (SuggestionStripView.DEBUG_SUGGESTIONS) {
mDebugInfoViews.get(positionInStrip).setText(suggestedWords.getDebugString(indexInSuggestedWords));
}
@@ -667,6 +669,11 @@ private static int getTextWidth(@Nullable final CharSequence text, final TextPai
}
private static Typeface getTextTypeface(@Nullable final CharSequence text) {
- return hasStyleSpan(text, BOLD_SPAN) ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT;
+ final Typeface customTypeface = Settings.getInstance().getCustomTypeface();
+ final boolean isBold = hasStyleSpan(text, BOLD_SPAN);
+ if (customTypeface != null) {
+ return isBold ? Typeface.create(customTypeface, Typeface.BOLD) : customTypeface;
+ }
+ return isBold ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT;
}
}
diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt
index b0b04da06..ac4a6b84a 100644
--- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt
+++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt
@@ -68,7 +68,6 @@ import helium314.keyboard.latin.utils.removePinnedKey
import helium314.keyboard.latin.utils.setToolbarButtonsActivatedState
import helium314.keyboard.latin.utils.setToolbarButtonsActivatedStateOnPrefChange
import helium314.keyboard.latin.utils.isMainDictionaryMissing
-import helium314.keyboard.latin.utils.showMissingDictionaryComposeDialog
import helium314.keyboard.latin.utils.SubtypeSettings
import helium314.keyboard.latin.utils.locale
import helium314.keyboard.settings.SettingsWithoutKey
@@ -383,6 +382,10 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
}
fun setSuggestions(suggestions: SuggestedWords, isRtlLanguage: Boolean) {
+
+ if (isShowingEmojiSuggestions && !helium314.keyboard.keyboard.KeyboardSwitcher.getInstance().isShowingEmojiPalettes) {
+ isShowingEmojiSuggestions = false
+ }
if (isShowingEmojiSuggestions) return
if (isExternalSuggestionVisible && (suggestions.isEmpty || suggestions.isPunctuationSuggestions)) {
// Keep external suggestion (clipboard/screenshot) if new suggestions are empty or just punctuation
@@ -405,6 +408,9 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
}
fun setExternalSuggestionView(view: View?, addCloseButton: Boolean) {
+ if (isShowingEmojiSuggestions && !helium314.keyboard.keyboard.KeyboardSwitcher.getInstance().isShowingEmojiPalettes) {
+ isShowingEmojiSuggestions = false
+ }
if (isShowingEmojiSuggestions) return
clear()
if (view == null) {
@@ -516,11 +522,14 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
|| key == Settings.PREF_QUICK_PIN_TOOLBAR_KEYS
|| key == Settings.PREF_AUTO_HIDE_PINNED_KEYS
|| key == Settings.PREF_SPLIT_TOOLBAR
+ || key == Settings.PREF_SHOW_DOWNLOAD_BUTTON_IN_TOOLBAR
|| key == "pref_custom_ai_show_tags_on_toolbar"
- || key?.startsWith("pref_custom_ai_tag_") == true) {
+ || key?.startsWith("pref_custom_ai_tag_") == true
+ || key?.startsWith("pref_dict_download_link_") == true) {
rebuildToolbarKeys()
// Update visibility with auto-hide logic
setToolbarVisibility(isToolbarManuallyOpen, false)
+ updateKeys()
}
}
@@ -806,9 +815,16 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
val languageCodes = resources.getStringArray(R.array.translate_language_codes)
val prefs = context.prefs()
+ val list = languageNames.zip(languageCodes).toMutableList()
+ val currentLanguageCode = prefs.getString(SettingsWithoutKey.GEMINI_TARGET_LANGUAGE, "English") ?: "English"
+ val currentLanguageName = prefs.getString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, currentLanguageCode) ?: currentLanguageCode
+
+ if (!languageCodes.contains(currentLanguageCode) && currentLanguageCode.isNotEmpty()) {
+ list.add(0, currentLanguageName to currentLanguageCode)
+ }
+
// Create a button for each language
- for ((index, languageName) in languageNames.withIndex()) {
- val languageCode = languageCodes.getOrNull(index) ?: return
+ for ((languageName, languageCode) in list) {
val button = android.widget.TextView(context, null, R.attr.suggestionWordStyle).apply {
text = languageName
gravity = android.view.Gravity.CENTER
@@ -816,7 +832,6 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setSingleLine()
ellipsize = android.text.TextUtils.TruncateAt.END
- // Set minimum width for consistent appearance
minimumWidth = 100.dpToPx(resources)
}
button.layoutParams = LinearLayout.LayoutParams(
@@ -828,13 +843,10 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
// Set the selected language and start translation
context.prefs().edit().apply {
putString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, languageName)
- // Also update Gemini target language
putString(SettingsWithoutKey.GEMINI_TARGET_LANGUAGE, languageCode)
}.apply()
helium314.keyboard.latin.utils.ProofreadService(context).setTargetLanguage(languageCode)
- // Hide selector and trigger translation
hideTranslateLanguageSelector()
- // Trigger translation with new language
listener.onCodeInput(KeyCode.TRANSLATE, Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE, false)
}
Settings.getValues().mColors.setColor(button.background, ColorType.TOOL_BAR_KEY)
@@ -842,6 +854,49 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
languageList.addView(button)
}
+ // Setup Custom... button
+ val customButton = android.widget.TextView(context, null, R.attr.suggestionWordStyle).apply {
+ text = "Custom..."
+ gravity = android.view.Gravity.CENTER
+ setPadding(8.dpToPx(resources), 0, 8.dpToPx(resources), 0)
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
+ setSingleLine()
+ ellipsize = android.text.TextUtils.TruncateAt.END
+ minimumWidth = 100.dpToPx(resources)
+ }
+ customButton.layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.MATCH_PARENT
+ ).apply { gravity = android.view.Gravity.CENTER_VERTICAL }
+
+ customButton.setOnClickListener {
+ val builder = android.app.AlertDialog.Builder(context)
+ builder.setTitle("Custom Target Language")
+ val input = android.widget.EditText(context).apply {
+ setText(if (currentLanguageCode == "custom") "" else currentLanguageCode)
+ setSingleLine()
+ }
+ builder.setView(input)
+ builder.setPositiveButton("OK") { dialog, _ ->
+ val customLang = input.text.toString().trim()
+ if (customLang.isNotEmpty()) {
+ prefs.edit().apply {
+ putString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, customLang)
+ putString(SettingsWithoutKey.GEMINI_TARGET_LANGUAGE, customLang)
+ }.apply()
+ helium314.keyboard.latin.utils.ProofreadService(context).setTargetLanguage(customLang)
+ hideTranslateLanguageSelector()
+ listener.onCodeInput(KeyCode.TRANSLATE, Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE, false)
+ }
+ dialog.dismiss()
+ }
+ builder.setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() }
+ builder.show()
+ }
+ Settings.getValues().mColors.setColor(customButton.background, ColorType.TOOL_BAR_KEY)
+ customButton.setBackgroundResource(R.drawable.toolbar_key_background)
+ languageList.addView(customButton)
+
// Setup close button
translateLanguageCloseButton.isVisible = true
translateLanguageCloseButton.setBackgroundResource(R.drawable.toolbar_key_background)
@@ -914,7 +969,8 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
// ponytail: show/hide dictionary download button if dictionary is missing
if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard" || helium314.keyboard.latin.BuildConfig.FLAVOR == "standardfull") {
val currentLocale = SubtypeSettings.getSelectedSubtype(context.prefs()).locale()
- if (isMainDictionaryMissing(context, currentLocale) && !hideToolbarKeys) {
+ val showDownloadButton = Settings.getValues().mShowDownloadButtonInToolbar
+ if (showDownloadButton && isMainDictionaryMissing(context, currentLocale) && !hideToolbarKeys) {
if (dictDownloadButton == null) {
dictDownloadButton = ImageButton(context, null, R.attr.suggestionWordStyle).apply {
scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE
@@ -923,15 +979,20 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
setImageResource(R.drawable.ic_dictionary)
contentDescription = context.getString(R.string.download)
setOnClickListener {
- val token = this.windowToken
- if (token != null) {
- showMissingDictionaryComposeDialog(context, currentLocale, token) {
- updateKeys()
- }
+ val intent = android.content.Intent().apply {
+ setClass(context, helium314.keyboard.settings.SettingsActivity2::class.java)
+ putExtra("screen", "dictionaries")
+ putExtra("from_ime", true)
+ setFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+ or android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
+ or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
+ context.startActivity(intent)
}
}
- val toolbarHeight = min(toolbarExpandKey.layoutParams.height, resources.getDimension(R.dimen.config_suggestions_strip_height).toInt())
+ val configHeight = resources.getDimension(R.dimen.config_suggestions_strip_height).toInt()
+ val rawHeight = toolbarExpandKey.layoutParams.height
+ val toolbarHeight = if (rawHeight > 0) min(rawHeight, configHeight) else configHeight
dictDownloadButton?.layoutParams = LinearLayout.LayoutParams(toolbarHeight, toolbarHeight).apply {
gravity = android.view.Gravity.CENTER_VERTICAL
}
@@ -1111,13 +1172,26 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int)
btn.isAllCaps = false
btn.isEnabled = !isDownloading
btn.layoutParams = LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT
- )
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ gravity = android.view.Gravity.CENTER
+ }
btn.setOnClickListener {
onClick.run()
}
- suggestionsStrip.addView(btn)
+
+ // Wrap button in a container that properly constrains its height
+ val container = LinearLayout(context)
+ container.orientation = LinearLayout.HORIZONTAL
+ container.gravity = android.view.Gravity.CENTER
+ container.layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.MATCH_PARENT
+ )
+ container.addView(btn)
+
+ suggestionsStrip.addView(container)
suggestionsStrip.isVisible = true
}
diff --git a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt
index 03ab34a48..c5d1ad87b 100644
--- a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt
+++ b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryInfoUtils.kt
@@ -112,6 +112,31 @@ object DictionaryInfoUtils {
fun getCachedDictForLocaleAndType(locale: Locale, type: String, context: Context): File? =
getCachedDictsForLocale(locale, context).firstOrNull { it.name.substringBefore("_") == type }
+ fun getFallbackVariantDirectory(locale: Locale, context: Context): File? {
+ if (locale.country.isEmpty() && locale.variant.isEmpty()) return null
+ val cacheDir = File(getWordListCacheDirectory(context))
+ if (!cacheDir.exists() || !cacheDir.isDirectory) return null
+ val subDirs = cacheDir.listFiles { file -> file.isDirectory } ?: return null
+ val matchedDirs = subDirs.filter {
+ val dirLocale = it.name.constructLocale()
+ dirLocale.language == locale.language
+ }.sortedWith { d1, d2 ->
+ val n1 = d1.name.lowercase()
+ val n2 = d2.name.lowercase()
+ val lang = locale.language.lowercase()
+ val p1 = if (n1 == "${lang}-gb") 0 else if (n1 == "${lang}-us") 1 else 2
+ val p2 = if (n2 == "${lang}-gb") 0 else if (n2 == "${lang}-us") 1 else 2
+ p1.compareTo(p2)
+ }
+ for (dir in matchedDirs) {
+ val files = dir.listFiles()
+ if (files?.any { it.name.endsWith(USER_DICTIONARY_SUFFIX) || it.name.startsWith(MAIN_DICT_PREFIX) || it.name == MAIN_DICT_FILE_NAME || it.name.endsWith(".dict") } == true) {
+ return dir
+ }
+ }
+ return null
+ }
+
fun getCachedDictsForLocale(locale: Locale, context: Context): Array {
val exactDir = getCacheDirectoryForLocale(locale, context)?.let { File(it) }
val exactFiles = exactDir?.listFiles()
@@ -125,6 +150,10 @@ object DictionaryInfoUtils {
if (fallbackFiles?.any { it.name.endsWith(USER_DICTIONARY_SUFFIX) || it.name.startsWith(MAIN_DICT_PREFIX) || it.name == MAIN_DICT_FILE_NAME } == true) {
return fallbackFiles
}
+ val variantDir = getFallbackVariantDirectory(locale, context)
+ if (variantDir != null) {
+ return variantDir.listFiles() ?: emptyArray()
+ }
}
return exactFiles ?: emptyArray()
}
diff --git a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt
index cc52af5af..91c572fc9 100644
--- a/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt
+++ b/app/src/main/java/helium314/keyboard/latin/utils/DictionaryUtils.kt
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -116,9 +117,7 @@ fun MissingDictionaryDialog(onDismissRequest: () -> Unit, locale: Locale, inline
}
val availableDicts = createDictionaryTextAnnotated(locale)
val repositoryLink = stringResource(R.string.dictionary_link_text).withHtmlLink(Links.DICTIONARY_URL)
- val dictUrl = "${Links.DICTIONARY_URL}${Links.DICTIONARY_DOWNLOAD_SUFFIX}dictionaries/main_$locale.dict"
- val dictionaryLink = stringResource(R.string.dictionary_link_text).withHtmlLink(dictUrl)
- val message = stringResource(R.string.no_dictionary_message, repositoryLink, locale.toString(), dictionaryLink)
+ val message = stringResource(R.string.no_dictionary_message, repositoryLink)
var annotatedString = message.htmlToAnnotated()
// ponytail: in standard flavor, if there are known dicts we show them as downloadable rows instead of bullet links
val knownDicts = remember {
@@ -295,7 +294,7 @@ fun downloadDictionary(context: Context, locale: Locale, type: String, linkUrl:
}
@Composable
-fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, onRefresh: () -> Unit) {
+fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, refreshTrigger: Int = 0, onRefresh: () -> Unit) {
val ctx = LocalContext.current
val type = remember(link) { link.substringAfterLast("/").substringBefore("_") }
// ponytail: extract the specific dictionary locale from the download link to avoid directory collision
@@ -306,7 +305,12 @@ fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, onRefr
val cacheDir = remember(dictLocale) { DictionaryInfoUtils.getCacheDirectoryForLocale(dictLocale, ctx) }
val file = remember(cacheDir, type) { cacheDir?.let { File(it, "$type.dict") } }
var downloading by remember { mutableStateOf(false) }
- var exists by remember(file) { mutableStateOf(file?.exists() == true) }
+ val downloadedLink = remember(link, refreshTrigger) { ctx.prefs().getString("pref_dict_download_link_${type}_${dictLocale}", "") ?: "" }
+ var exists by remember(file, downloadedLink, refreshTrigger) {
+ mutableStateOf(
+ file?.exists() == true && (downloadedLink == link || (downloadedLink.isEmpty() && link.contains("/dictionaries/")))
+ )
+ }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -315,21 +319,22 @@ fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, onRefr
) {
Text(desc, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
if (exists) {
- var showDeleteDialog by remember { mutableStateOf(false) }
- androidx.compose.material3.TextButton(onClick = { showDeleteDialog = true }) {
- Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error)
- }
- if (showDeleteDialog) {
- ConfirmationDialog(
- onDismissRequest = { showDeleteDialog = false },
- confirmButtonText = stringResource(R.string.remove),
- onConfirmed = {
- file?.delete()
- exists = false
- onRefresh()
- },
- content = { Text(stringResource(R.string.remove_dictionary_message, type)) }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "✓ " + stringResource(R.string.installed),
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(end = 8.dp)
)
+ helium314.keyboard.settings.DeleteButton(
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.primary
+ ) {
+ file?.delete()
+ ctx.prefs().edit().remove("pref_dict_download_link_${type}_${dictLocale}").apply()
+ exists = false
+ onRefresh()
+ }
}
} else if (downloading) {
Text(
@@ -343,6 +348,7 @@ fun DownloadableDictionaryRow(locale: Locale, desc: String, link: String, onRefr
downloadDictionary(ctx, dictLocale, type, link) { success ->
downloading = false
if (success) {
+ ctx.prefs().edit().putString("pref_dict_download_link_${type}_${dictLocale}", link).apply()
exists = true
onRefresh()
} else {
@@ -366,13 +372,17 @@ fun isMainDictionaryMissing(context: Context, locale: Locale): Boolean {
}
if (best != null) return false
}
- // 2. check if cache directory has a main.dict file
+ // 2. check if cache directory has a main.dict or main_user.dict file
var cacheDir = DictionaryInfoUtils.getCacheDirectoryForLocale(locale, context)?.let { File(it) }
- var hasMain = cacheDir?.exists() == true && cacheDir.isDirectory && cacheDir.listFiles()?.any { it.name == "main.dict" } == true
+ var hasMain = cacheDir?.exists() == true && cacheDir.isDirectory && cacheDir.listFiles()?.any { it.name.startsWith("main") && it.name.endsWith(".dict") } == true
if (!hasMain && (locale.country.isNotEmpty() || locale.variant.isNotEmpty())) {
val fallbackLocale = Locale(locale.language)
cacheDir = DictionaryInfoUtils.getCacheDirectoryForLocale(fallbackLocale, context)?.let { File(it) }
- hasMain = cacheDir?.exists() == true && cacheDir.isDirectory && cacheDir.listFiles()?.any { it.name == "main.dict" } == true
+ hasMain = cacheDir?.exists() == true && cacheDir.isDirectory && cacheDir.listFiles()?.any { it.name.startsWith("main") && it.name.endsWith(".dict") } == true
+ if (!hasMain) {
+ val variantDir = DictionaryInfoUtils.getFallbackVariantDirectory(locale, context)
+ hasMain = variantDir?.exists() == true && variantDir.isDirectory && variantDir.listFiles()?.any { it.name.startsWith("main") && it.name.endsWith(".dict") } == true
+ }
}
if (hasMain) return false
// 3. check if there is a known downloadable main dictionary for this locale
diff --git a/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt b/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt
index fb0e267cf..b21e50a56 100644
--- a/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt
+++ b/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt
@@ -9,7 +9,8 @@ import java.util.EnumMap
enum class LayoutType {
MAIN, SYMBOLS, MORE_SYMBOLS, FUNCTIONAL, NUMBER, NUMBER_ROW, NUMPAD,
NUMPAD_LANDSCAPE, PHONE, PHONE_SYMBOLS, EMOJI_BOTTOM, CLIPBOARD_BOTTOM,
- SHORTCUT_TOP, SHORTCUT_BOTTOM, HANDWRITING_BOTTOM, EDITING;
+ SHORTCUT_TOP, SHORTCUT_BOTTOM, HANDWRITING_BOTTOM, EDITING,
+ CUSTOM1, CUSTOM2, CUSTOM3, CUSTOM4, CUSTOM5;
companion object {
fun EnumMap.toExtraValue() = map { it.key.name + Separators.KV + it.value }.joinToString(Separators.ENTRY)
@@ -42,6 +43,11 @@ enum class LayoutType {
SHORTCUT_BOTTOM -> R.string.layout_shortcut_bottom
HANDWRITING_BOTTOM -> R.string.layout_emoji_bottom_row
EDITING -> R.string.text_edit
+ CUSTOM1 -> R.string.layout_custom1
+ CUSTOM2 -> R.string.layout_custom2
+ CUSTOM3 -> R.string.layout_custom3
+ CUSTOM4 -> R.string.layout_custom4
+ CUSTOM5 -> R.string.layout_custom5
}
fun getMainLayoutFromExtraValue(extraValue: String): String? {
diff --git a/app/src/main/java/helium314/keyboard/latin/utils/LayoutUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/LayoutUtils.kt
index 13bcb24c0..40bc778ae 100644
--- a/app/src/main/java/helium314/keyboard/latin/utils/LayoutUtils.kt
+++ b/app/src/main/java/helium314/keyboard/latin/utils/LayoutUtils.kt
@@ -13,8 +13,10 @@ import java.util.Locale
// for layouts provided by the app
object LayoutUtils {
fun getAvailableLayouts(layoutType: LayoutType, context: Context, locale: Locale? = null): Collection {
- if (layoutType != LayoutType.MAIN)
+ if (layoutType != LayoutType.MAIN) {
+ if (layoutType.name.startsWith("CUSTOM")) return emptyList()
return context.assets.list(layoutType.folder)?.map { it.substringBefore(".") }.orEmpty()
+ }
if (locale == null)
return SubtypeSettings.getAllAvailableSubtypes()
.mapTo(HashSet()) { it.mainLayoutNameOrQwerty().substringBefore("+") }
@@ -30,6 +32,9 @@ object LayoutUtils {
/** gets content for built-in (non-custom) layout [layoutName], with fallback to qwerty */
fun getContent(layoutType: LayoutType, layoutName: String, context: Context): String {
+ if (layoutType.name.startsWith("CUSTOM")) {
+ return getContent(LayoutType.SYMBOLS, "symbols", context)
+ }
val layouts = context.assets.list(layoutType.folder)!!
layouts.firstOrNull { it.startsWith("$layoutName.") }
?.let { return context.assets.open(layoutType.folder + File.separator + it).reader().readText() }
diff --git a/app/src/main/java/helium314/keyboard/latin/utils/NgramContextUtils.java b/app/src/main/java/helium314/keyboard/latin/utils/NgramContextUtils.java
index 3fbf1a5bb..4029c294e 100644
--- a/app/src/main/java/helium314/keyboard/latin/utils/NgramContextUtils.java
+++ b/app/src/main/java/helium314/keyboard/latin/utils/NgramContextUtils.java
@@ -49,6 +49,26 @@ private NgramContextUtils() {
public static NgramContext getNgramContextFromNthPreviousWord(final CharSequence prev,
final SpacingAndPunctuations spacingAndPunctuations, final int n) {
if (prev == null) return NgramContext.EMPTY_PREV_WORDS_INFO;
+ int lastNewlineIdx = -1;
+ for (int i = prev.length() - 1; i >= 0; i--) {
+ char c = prev.charAt(i);
+ if (c == '\n' || c == '\r') {
+ lastNewlineIdx = i;
+ break;
+ }
+ }
+ if (lastNewlineIdx != -1) {
+ boolean hasNonWhitespaceAfter = false;
+ for (int i = lastNewlineIdx + 1; i < prev.length(); i++) {
+ if (!Character.isWhitespace(prev.charAt(i))) {
+ hasNonWhitespaceAfter = true;
+ break;
+ }
+ }
+ if (!hasNonWhitespaceAfter) {
+ return new NgramContext(WordInfo.BEGINNING_OF_SENTENCE_WORD_INFO);
+ }
+ }
final String[] lines = NEWLINE_REGEX.split(prev);
if (lines.length == 0) {
return new NgramContext(WordInfo.BEGINNING_OF_SENTENCE_WORD_INFO);
diff --git a/app/src/main/java/helium314/keyboard/latin/utils/UncachedInputMethodManagerUtils.java b/app/src/main/java/helium314/keyboard/latin/utils/UncachedInputMethodManagerUtils.java
index fb4da9c4d..283efd56a 100644
--- a/app/src/main/java/helium314/keyboard/latin/utils/UncachedInputMethodManagerUtils.java
+++ b/app/src/main/java/helium314/keyboard/latin/utils/UncachedInputMethodManagerUtils.java
@@ -48,9 +48,14 @@ public static boolean isThisImeEnabled(final Context context,
public static boolean isThisImeCurrent(final Context context,
final InputMethodManager imm) {
final InputMethodInfo imi = getInputMethodInfoOf(context.getPackageName(), imm);
+ if (imi == null) return false;
final String currentImeId = Settings.Secure.getString(
context.getContentResolver(), Settings.Secure.DEFAULT_INPUT_METHOD);
- return imi != null && imi.getId().equals(currentImeId);
+ if (android.text.TextUtils.isEmpty(currentImeId)) {
+ final java.util.List enabled = imm.getEnabledInputMethodList();
+ return enabled != null && enabled.size() == 1 && enabled.get(0).getPackageName().equals(context.getPackageName());
+ }
+ return imi.getId().equals(currentImeId);
}
/**
diff --git a/app/src/main/java/helium314/keyboard/settings/Icons.kt b/app/src/main/java/helium314/keyboard/settings/Icons.kt
index 172c5ee9b..1138e0a1b 100644
--- a/app/src/main/java/helium314/keyboard/settings/Icons.kt
+++ b/app/src/main/java/helium314/keyboard/settings/Icons.kt
@@ -32,8 +32,18 @@ fun EditButton(enabled: Boolean = true, onClick: () -> Unit) {
}
@Composable
-fun DeleteButton(onClick: () -> Unit) {
- IconButton(onClick) { Icon(painterResource(R.drawable.ic_bin), stringResource(R.string.delete)) }
+fun DeleteButton(
+ modifier: Modifier = Modifier,
+ tint: androidx.compose.ui.graphics.Color = androidx.compose.material3.LocalContentColor.current,
+ onClick: () -> Unit
+) {
+ IconButton(onClick, modifier = modifier) {
+ Icon(
+ painterResource(R.drawable.ic_bin),
+ stringResource(R.string.delete),
+ tint = tint
+ )
+ }
}
@Composable
diff --git a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt
index a9dd8b6dc..7dd1c06e1 100644
--- a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt
@@ -53,6 +53,8 @@ import androidx.compose.ui.unit.dp
import helium314.keyboard.latin.R
import helium314.keyboard.settings.preferences.PreferenceCategory
+import helium314.keyboard.latin.utils.prefs
+
@Composable
fun SearchSettingsScreen(
onClickBack: () -> Unit,
@@ -60,6 +62,8 @@ fun SearchSettingsScreen(
settings: List,
content: @Composable (ColumnScope.() -> Unit)? = null // overrides settings if not null
) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val customCount = context.prefs().getInt("custom_layouts_count", 0)
SearchScreen(
onClickBack = onClickBack,
title = {
@@ -111,7 +115,7 @@ fun SearchSettingsScreen(
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
- )
+ )
) {
Column {
if (titleRes != null) {
@@ -133,6 +137,11 @@ fun SearchSettingsScreen(
filteredItems = {
SettingsActivity.settingsContainer.filter(it).filter { setting ->
val key = setting.key
+ if (key.startsWith(helium314.keyboard.latin.settings.Settings.PREF_LAYOUT_PREFIX + "CUSTOM")) {
+ val index = key.removePrefix(helium314.keyboard.latin.settings.Settings.PREF_LAYOUT_PREFIX + "CUSTOM").toIntOrNull() ?: 0
+ if (index > customCount) return@filter false
+ }
+ if (key == "add_custom_layout") return@filter false
when (helium314.keyboard.latin.BuildConfig.FLAVOR) {
"offlinelite" -> {
!key.startsWith("gemini") &&
diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt b/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt
index b6680c28f..cdda7a066 100644
--- a/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt
+++ b/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt
@@ -86,9 +86,12 @@ open class SettingsActivity : ComponentActivity(), SharedPreferences.OnSharedPre
val dictUri by dictUriFlow.collectAsState()
val crashReports by crashReportFiles.collectAsState()
val crashFilePicker = filePicker { saveCrashReports(it) }
+ val launchedFromIme = intent?.getBooleanExtra("from_ime", false) ?: false
var showWelcomeWizard by rememberSaveable { mutableStateOf(
- !UncachedInputMethodManagerUtils.isThisImeCurrent(this, imm)
- || !UncachedInputMethodManagerUtils.isThisImeEnabled(this, imm)
+ !launchedFromIme && (
+ !UncachedInputMethodManagerUtils.isThisImeCurrent(this, imm)
+ || !UncachedInputMethodManagerUtils.isThisImeEnabled(this, imm)
+ )
) }
val snackbarHostState = androidx.compose.runtime.remember { androidx.compose.material3.SnackbarHostState() }
androidx.compose.runtime.LaunchedEffect(Unit) {
diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt
index 7360609e4..6be9dd066 100644
--- a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt
+++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt
@@ -88,7 +88,7 @@ object SettingsWithoutKey {
const val HIDDEN_FEATURES = "hidden_features"
const val GITHUB = "github"
const val SPONSOR = "sponsor"
- const val GITHUB_WIKI = "github_wiki"
+ const val GITHUB_FEATURES = "github_features"
const val SAVE_LOG = "save_log"
const val BACKUP_RESTORE = "backup_restore"
const val PERSIST_FLOATING_KEYBOARD = "persist_floating_keyboard"
diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt
index b9d7e04c1..f560f8c37 100644
--- a/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt
+++ b/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt
@@ -32,6 +32,7 @@ import helium314.keyboard.compat.locale
import helium314.keyboard.latin.dictionary.Dictionary
import helium314.keyboard.latin.R
import helium314.keyboard.latin.common.LocaleUtils
+import helium314.keyboard.latin.common.LocaleUtils.constructLocale
import helium314.keyboard.latin.common.LocaleUtils.localizedDisplayName
import helium314.keyboard.latin.utils.DictionaryInfoUtils
import helium314.keyboard.latin.utils.prefs
@@ -130,7 +131,7 @@ fun DictionaryDialog(
style = MaterialTheme.typography.titleSmall
)
knownDicts.forEach { (desc, link) ->
- DownloadableDictionaryRow(locale, desc, link) {
+ DownloadableDictionaryRow(locale, desc, link, refreshTrigger) {
refreshTrigger++
}
}
@@ -160,9 +161,9 @@ fun DictionaryDialog(
@Composable
private fun DictionaryDetails(dict: File, onDelete: () -> Unit) {
+ val ctx = LocalContext.current
val header = DictionaryInfoUtils.getDictionaryFileHeaderOrNull(dict) ?: return
val type = header.mIdString.substringBefore(":")
- val ctx = LocalContext.current
val prefs = ctx.prefs()
val prefKey = "pref_dict_enabled_${header.mIdString}"
var enabled by remember { mutableStateOf(prefs.getBoolean(prefKey, true)) }
@@ -188,6 +189,9 @@ private fun DictionaryDetails(dict: File, onDelete: () -> Unit) {
Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f))
DeleteButton {
dict.delete()
+ dict.parentFile?.name?.constructLocale()?.let { dictLocale ->
+ ctx.prefs().edit().remove("pref_dict_download_link_${type}_${dictLocale}").apply()
+ }
onDelete()
}
ExpandButton { showDetails = !showDetails }
diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/NewDictionaryDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/NewDictionaryDialog.kt
index af5243428..6597e6141 100644
--- a/app/src/main/java/helium314/keyboard/settings/dialogs/NewDictionaryDialog.kt
+++ b/app/src/main/java/helium314/keyboard/settings/dialogs/NewDictionaryDialog.kt
@@ -27,6 +27,7 @@ import helium314.keyboard.latin.makedict.DictionaryHeader
import helium314.keyboard.latin.utils.DictionaryInfoUtils
import helium314.keyboard.latin.utils.ScriptUtils.script
import helium314.keyboard.latin.utils.SubtypeSettings
+import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.latin.utils.locale
import helium314.keyboard.settings.DropDownField
import helium314.keyboard.settings.WithSmallTitle
@@ -82,6 +83,13 @@ fun NewDictionaryDialog(
val internalMainDictFile = File(cacheDir, DictionaryInfoUtils.MAIN_DICT_FILE_NAME)
internalMainDictFile.delete()
}
+ val prefs = ctx.prefs()
+ val localeTag = locale.toLanguageTag().lowercase().replace("-", "_")
+ prefs.edit()
+ .putBoolean("pref_dict_enabled_main:$localeTag", true)
+ .putBoolean("pref_dict_enabled_${header.mIdString}", true)
+ .putBoolean("pref_dict_enabled_main:${header.mIdString.substringAfter(":")}", true)
+ .apply()
val newDictBroadcast = Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION)
ctx.sendBroadcast(newDictBroadcast)
},
diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt
index 94a70790f..45eb7c09b 100644
--- a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt
+++ b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt
@@ -290,6 +290,7 @@ private fun restoreLauncher(
if (selectedCategories.contains(BackupCategory.DICTIONARY_HISTORY)) {
File(filesDir, "dicts").deleteRecursively()
File(filesDir, "blacklists").deleteRecursively()
+ File(deviceProtectedFilesDir, "blacklists").deleteRecursively()
filesDir.listFiles()?.forEach {
if (it.name.startsWith("UserHistoryDictionary")) it.delete()
}
@@ -536,7 +537,7 @@ private fun getCategoryForPrefKey(key: String): BackupCategory {
"autocorrect_shortcuts", "backspace_reverts_autocorrect", "suggest_punctuation",
"add_to_personal_dictionary"
)
- if (dictKeys.contains(key)) return BackupCategory.DICTIONARY_HISTORY
+ if (dictKeys.contains(key) || key.startsWith("pref_text_expander_")) return BackupCategory.DICTIONARY_HISTORY
val clipboardKeys = setOf(
"enable_clipboard_history", "suggest_screenshots", "compress_screenshots",
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt
index 4e0c7eb32..ab3d08a20 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt
@@ -56,7 +56,7 @@ fun AboutScreen(
SettingsWithoutKey.VERSION,
SettingsWithoutKey.LICENSE,
SettingsWithoutKey.HIDDEN_FEATURES,
- SettingsWithoutKey.GITHUB_WIKI,
+ SettingsWithoutKey.GITHUB_FEATURES,
SettingsWithoutKey.GITHUB,
SettingsWithoutKey.SPONSOR,
SettingsWithoutKey.SAVE_LOG,
@@ -133,14 +133,14 @@ fun createAboutSettings(context: Context) = listOf(
icon = R.drawable.ic_settings_about_hidden_features
)
},
- Setting(context, SettingsWithoutKey.GITHUB_WIKI, R.string.about_wiki_link, R.string.about_wiki_link_description) {
+ Setting(context, SettingsWithoutKey.GITHUB_FEATURES, R.string.about_features_link, R.string.about_features_link_description) {
val ctx = LocalContext.current
Preference(
name = it.title,
description = it.description,
onClick = {
val intent = Intent()
- intent.data = Links.WIKI_URL.toUri()
+ intent.data = Links.FEATURES_URL.toUri()
intent.action = Intent.ACTION_VIEW
ctx.startActivity(intent)
},
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 dd432d6b9..f6ac8a25c 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt
@@ -463,17 +463,54 @@ fun createAdvancedSettings(context: Context) = listOfNotNull(
val service = remember { helium314.keyboard.latin.utils.ProofreadService(ctx) }
val languageNames = ctx.resources.getStringArray(helium314.keyboard.latin.R.array.translate_language_names)
val languageCodes = ctx.resources.getStringArray(helium314.keyboard.latin.R.array.translate_language_codes)
- val items = languageNames.zip(languageCodes)
var selectedLanguage by remember { mutableStateOf(service.getTargetLanguage()) }
+ var showCustomDialog by remember { mutableStateOf(false) }
+
+ val items = remember(selectedLanguage) {
+ val zipped = languageNames.zip(languageCodes).toMutableList()
+ if (!languageCodes.contains(selectedLanguage) && selectedLanguage.isNotEmpty()) {
+ zipped.add(0, "Custom ($selectedLanguage)" to selectedLanguage)
+ }
+ zipped.add("Custom..." to "custom")
+ zipped
+ }
+
ListPreference(
setting = setting,
items = items,
default = selectedLanguage,
onChanged = { newLanguage ->
- service.setTargetLanguage(newLanguage)
- selectedLanguage = newLanguage
+ if (newLanguage == "custom") {
+ showCustomDialog = true
+ } else {
+ service.setTargetLanguage(newLanguage)
+ selectedLanguage = newLanguage
+ }
}
)
+
+ if (showCustomDialog) {
+ TextInputDialog(
+ onDismissRequest = {
+ ctx.prefs().edit().putString(setting.key, selectedLanguage).apply()
+ showCustomDialog = false
+ },
+ textInputLabel = { Text("Language name or code (e.g. Esperanto, de)") },
+ initialText = if (selectedLanguage == "custom") "" else selectedLanguage,
+ onConfirmed = { customLang ->
+ val trimmed = customLang.trim()
+ if (trimmed.isNotEmpty()) {
+ service.setTargetLanguage(trimmed)
+ ctx.prefs().edit().putString(setting.key, trimmed).apply()
+ selectedLanguage = trimmed
+ } else {
+ ctx.prefs().edit().putString(setting.key, selectedLanguage).apply()
+ }
+ showCustomDialog = false
+ },
+ title = { Text("Custom Target Language") }
+ )
+ }
},
Setting(context, SettingsWithoutKey.TRANSLATE_GEMINI_MODEL, R.string.translate_model_title, R.string.translate_model_summary) { setting ->
val ctx = LocalContext.current
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt
index de9c1357c..d7c49765d 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt
@@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import helium314.keyboard.latin.R
import helium314.keyboard.latin.utils.Log
+import helium314.keyboard.latin.utils.DeviceProtectedUtils
import helium314.keyboard.keyboard.KeyboardSwitcher
import helium314.keyboard.settings.DropDownField
import helium314.keyboard.settings.SearchScreen
@@ -45,13 +46,13 @@ import java.util.Locale
private data class BlockedWord(val word: String, val locale: Locale)
private fun getBlacklistFile(context: Context, locale: Locale): File {
- val dir = File(context.filesDir, "blacklists")
+ val dir = File(DeviceProtectedUtils.getFilesDir(context), "blacklists")
if (!dir.exists()) dir.mkdirs()
return File(dir, "${locale.toLanguageTag()}.txt")
}
private fun loadBlockedWords(context: Context): List {
- val dir = File(context.filesDir, "blacklists")
+ val dir = File(DeviceProtectedUtils.getFilesDir(context), "blacklists")
if (!dir.exists() || !dir.isDirectory) return emptyList()
val list = mutableListOf()
dir.listFiles()?.forEach { file ->
@@ -107,7 +108,7 @@ private fun removeBlockedWord(context: Context, word: String, locale: Locale) {
}
private fun notifyKeyboardToReload() {
- KeyboardSwitcher.getInstance().getLatinIME()?.getDictionaryFacilitator()?.reloadBlacklist()
+ KeyboardSwitcher.getInstance().getLatinIME()?.reloadBlacklist()
}
@Composable
@@ -188,7 +189,7 @@ fun BlockedWordsScreen(
onDismissRequest = { showClearAllDialog = false },
onConfirmed = {
showClearAllDialog = false
- val dir = File(ctx.filesDir, "blacklists")
+ val dir = File(DeviceProtectedUtils.getFilesDir(ctx), "blacklists")
if (dir.exists() && dir.isDirectory) {
dir.listFiles()?.forEach { it.delete() }
}
@@ -215,7 +216,7 @@ private fun EditBlockedWordDialog(
val alreadyExists = remember(wordText, wordLocale) {
if (wordText.isBlank()) false
else {
- val file = File(ctx.filesDir, "blacklists/${wordLocale.toLanguageTag()}.txt")
+ val file = File(DeviceProtectedUtils.getFilesDir(ctx), "blacklists/${wordLocale.toLanguageTag()}.txt")
if (file.exists()) {
val cleanLower = wordText.trim().lowercase(wordLocale)
file.readLines().map { it.trim().lowercase(wordLocale) }.contains(cleanLower)
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt
index 2ab69070e..a18f7a0b2 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt
@@ -448,8 +448,21 @@ fun getUserAndInternalDictionaries(context: Context, locale: Locale): Pair
+ DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(dict)
}
}
+ val hasAsset = best != null
if (userLocaleDir?.exists() == true && userLocaleDir.isDirectory) {
userLocaleDir.listFiles()?.forEach {
@@ -458,22 +471,25 @@ fun getUserAndInternalDictionaries(context: Context, locale: Locale): Pair asset.startsWith("emoji") } == true
+ if (!hasEmojiAsset) {
+ userDicts.add(it)
+ } else {
+ hasInternalDict = true
+ }
} else {
- hasInternalDict = true
+ userDicts.add(it)
}
}
}
}
- val internalDicts = DictionaryInfoUtils.getAssetsDictionaryList(context)
- val best = internalDicts?.let {
- LocaleUtils.getBestMatch(locale, it.toList()) { dict ->
- DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(dict)
- }
- }
- val hasAsset = best != null
return userDicts to (hasInternalDict || hasAsset)
}
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt
index 84fdbcd7b..be038544a 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/PreferencesScreen.kt
@@ -21,6 +21,8 @@ import helium314.keyboard.latin.utils.SubtypeSettings
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.locale
import helium314.keyboard.latin.utils.prefs
+import helium314.keyboard.latin.RichInputMethodManager
+import helium314.keyboard.latin.utils.SubtypeLocaleUtils.displayName
import helium314.keyboard.settings.preferences.ListPreference
import helium314.keyboard.settings.Setting
import helium314.keyboard.settings.preferences.ReorderSwitchPreference
@@ -78,6 +80,7 @@ fun PreferencesScreen(
Settings.PREF_COMPACT_NUMBER_ROW_IN_SYMBOLS else null,
Settings.PREF_SHOW_LANGUAGE_SWITCH_KEY,
Settings.PREF_LANGUAGE_SWITCH_KEY,
+ Settings.PREF_DIRECT_IME_SWITCH_TARGET,
Settings.PREF_SHOW_EMOJI_KEY,
Settings.PREF_REMOVE_REDUNDANT_POPUPS,
R.string.settings_category_clipboard_history,
@@ -163,6 +166,13 @@ fun createPreferencesSettings(context: Context) = listOf(
Defaults.PREF_LANGUAGE_SWITCH_KEY
) { KeyboardSwitcher.getInstance().setThemeNeedsReload() }
},
+ Setting(context, Settings.PREF_DIRECT_IME_SWITCH_TARGET, R.string.direct_ime_switch_title, R.string.direct_ime_switch_summary) {
+ ListPreference(
+ it,
+ getDirectImeSwitchItems(context),
+ Defaults.PREF_DIRECT_IME_SWITCH_TARGET
+ )
+ },
Setting(context, Settings.PREF_SHOW_EMOJI_KEY, R.string.show_emoji_key) {
SwitchPreference(it, Defaults.PREF_SHOW_EMOJI_KEY) { KeyboardSwitcher.getInstance().reloadKeyboard() }
},
@@ -274,3 +284,39 @@ private fun Preview() {
}
}
}
+
+private fun getDirectImeSwitchItems(context: Context): List> {
+ val pm = context.packageManager
+ val richImm = RichInputMethodManager.getInstance()
+ val thisImi = richImm.inputMethodInfoOfThisIme
+ val enabledImis = richImm.inputMethodManager.enabledInputMethodList
+ .sortedBy { it.hashCode() }.sortedBy { it.loadLabel(pm).toString() }
+
+ val items = mutableListOf>()
+ items.add(context.getString(R.string.direct_ime_switch_none) to "")
+
+ enabledImis.forEach { imi ->
+ val subtypes = if (imi != thisImi) richImm.getEnabledInputMethodSubtypes(imi, true)
+ else richImm.getEnabledInputMethodSubtypes(imi, true).sortedBy { it.displayName() }
+ if (subtypes.isEmpty()) {
+ val label = imi.loadLabel(pm).toString()
+ val value = "${imi.id};"
+ items.add(label to value)
+ } else {
+ subtypes.forEach { subtype ->
+ if (!subtype.isAuxiliary) {
+ val subtypeName = if (imi == thisImi) {
+ subtype.displayName()
+ } else {
+ subtype.getDisplayName(context, imi.packageName, imi.serviceInfo.applicationInfo)
+ }
+ val label = if (subtypeName.isBlank()) imi.loadLabel(pm).toString()
+ else "$subtypeName (${imi.loadLabel(pm)})"
+ val value = "${imi.id};${subtype.hashCode()}"
+ items.add(label to value)
+ }
+ }
+ }
+ }
+ return items
+}
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/SecondaryLayoutScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/SecondaryLayoutScreen.kt
index 13acd9482..d7440bb4b 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/SecondaryLayoutScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/SecondaryLayoutScreen.kt
@@ -3,7 +3,12 @@ package helium314.keyboard.settings.screens
import android.content.Context
import androidx.compose.material3.Surface
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.res.painterResource
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -34,38 +39,121 @@ import helium314.keyboard.settings.previewDark
fun SecondaryLayoutScreen(
onClickBack: () -> Unit,
) {
- // no main layouts in here
- // could be added later, but need to decide how to do it (showing all main layouts is too much)
+ val ctx = LocalContext.current
+ val prefs = ctx.prefs()
+ val b = (ctx.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
+ if ((b?.value ?: 0) < 0)
+ Log.v("irrelevant", "recomposition trigger")
+
+ val customCount = prefs.getInt("custom_layouts_count", 0)
+
+ val settingsList = remember(customCount, b?.value) {
+ val list = mutableListOf()
+ // Add non-main and non-custom layouts
+ LayoutType.entries.filter { it != LayoutType.MAIN && !it.name.startsWith("CUSTOM") }.forEach {
+ list.add(Settings.PREF_LAYOUT_PREFIX + it.name)
+ }
+ // Add configured custom layouts
+ for (i in 1..customCount) {
+ list.add(Settings.PREF_LAYOUT_PREFIX + "CUSTOM$i")
+ }
+ if (customCount < 5) {
+ list.add("add_custom_layout")
+ }
+ list
+ }
+
SearchSettingsScreen(
onClickBack = onClickBack,
title = stringResource(R.string.settings_screen_secondary_layouts),
- settings = LayoutType.entries.filter { it != LayoutType.MAIN }.map { Settings.PREF_LAYOUT_PREFIX + it.name }
+ settings = settingsList
)
}
-fun createLayoutSettings(context: Context) = LayoutType.entries.filter { it != LayoutType.MAIN }.map { layoutType ->
- Setting(context, Settings.PREF_LAYOUT_PREFIX + layoutType, layoutType.displayNameId) { setting ->
- val ctx = LocalContext.current
- val prefs = ctx.prefs()
- val b = (ctx.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
- if ((b?.value ?: 0) < 0)
- Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
- var showDialog by rememberSaveable { mutableStateOf(false) }
- val currentLayout = Settings.readDefaultLayoutName(layoutType, prefs)
- val displayName = if (LayoutUtilsCustom.isCustomLayout(currentLayout)) LayoutUtilsCustom.getDisplayName(currentLayout)
- else currentLayout.getStringResourceOrName("layout_", ctx)
- Preference(
- name = setting.title,
- description = displayName,
- onClick = { showDialog = true }
- )
- if (showDialog)
- LayoutPickerDialog(
- onDismissRequest = { showDialog = false },
- setting = setting,
- layoutType = layoutType
+fun createLayoutSettings(context: Context): List {
+ val list = LayoutType.entries.filter { it != LayoutType.MAIN }.map { layoutType ->
+ Setting(context, Settings.PREF_LAYOUT_PREFIX + layoutType.name, layoutType.displayNameId) { setting ->
+ val ctx = LocalContext.current
+ val prefs = ctx.prefs()
+ val b = (ctx.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState()
+ if ((b?.value ?: 0) < 0)
+ Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
+ var showDialog by rememberSaveable { mutableStateOf(false) }
+ val currentLayout = Settings.readDefaultLayoutName(layoutType, prefs)
+ val displayName = if (LayoutUtilsCustom.isCustomLayout(currentLayout)) LayoutUtilsCustom.getDisplayName(currentLayout)
+ else currentLayout.getStringResourceOrName("layout_", ctx)
+ val isCustom = layoutType.name.startsWith("CUSTOM")
+ Preference(
+ name = setting.title,
+ description = displayName,
+ onClick = { showDialog = true },
+ value = if (isCustom) {
+ {
+ IconButton(
+ onClick = {
+ val index = layoutType.name.removePrefix("CUSTOM").toIntOrNull() ?: 0
+ val count = prefs.getInt("custom_layouts_count", 0)
+ if (index in 1..count) {
+ val edit = prefs.edit()
+ for (i in index until count) {
+ val nextVal = prefs.getString(Settings.PREF_LAYOUT_PREFIX + "CUSTOM${i + 1}", null)
+ if (nextVal != null) {
+ edit.putString(Settings.PREF_LAYOUT_PREFIX + "CUSTOM$i", nextVal)
+ } else {
+ edit.remove(Settings.PREF_LAYOUT_PREFIX + "CUSTOM$i")
+ }
+ }
+ edit.remove(Settings.PREF_LAYOUT_PREFIX + "CUSTOM$count")
+ edit.putInt("custom_layouts_count", count - 1)
+ edit.apply()
+ // Trigger recomposition
+ (ctx.getActivity() as? SettingsActivity)?.let {
+ it.prefChanged.value = it.prefChanged.value + 1
+ }
+ }
+ }
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_bin),
+ contentDescription = "delete",
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ } else null
)
- }
+ if (showDialog)
+ LayoutPickerDialog(
+ onDismissRequest = { showDialog = false },
+ setting = setting,
+ layoutType = layoutType
+ )
+ }
+ }.toMutableList()
+
+ // Add the "add_custom_layout" Setting
+ list.add(
+ Setting(context, "add_custom_layout", R.string.add_custom_layout) { setting ->
+ val ctx = LocalContext.current
+ val prefs = ctx.prefs()
+ Preference(
+ name = setting.title,
+ icon = R.drawable.ic_plus,
+ onClick = {
+ val count = prefs.getInt("custom_layouts_count", 0)
+ if (count < 5) {
+ prefs.edit().putInt("custom_layouts_count", count + 1).apply()
+ // Trigger preference update so settings screen recomposes
+ (ctx.getActivity() as? SettingsActivity)?.let {
+ it.prefChanged.value = it.prefChanged.value + 1
+ }
+ }
+ }
+ )
+ }
+ )
+
+ return list
}
@Preview
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt
index 260557a4e..c6e6a11d7 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt
@@ -67,6 +67,7 @@ fun TextCorrectionScreen(
R.string.settings_category_correction,
Settings.PREF_BLOCK_POTENTIALLY_OFFENSIVE,
Settings.PREF_AUTO_CORRECTION,
+ if (autocorrectEnabled) Settings.PREF_AUTO_CORRECT_TRIGGER else null,
if (autocorrectEnabled) Settings.PREF_MORE_AUTO_CORRECTION else null,
if (autocorrectEnabled) Settings.PREF_AUTOCORRECT_SHORTCUTS else null,
if (autocorrectEnabled) Settings.PREF_AUTO_CORRECT_THRESHOLD else null,
@@ -81,6 +82,7 @@ fun TextCorrectionScreen(
if (gestureEnabled && !manualGestureSpacing) Settings.PREF_AUTOSPACE_BEFORE_GESTURE_TYPING else null,
if (gestureEnabled && !manualGestureSpacing) Settings.PREF_AUTOSPACE_AFTER_GESTURE_TYPING else null,
Settings.PREF_SHIFT_REMOVES_AUTOSPACE,
+ Settings.PREF_PRESERVE_SPACE_BEFORE_PUNCTUATION,
R.string.settings_category_suggestions,
if (suggestionsVisible) Settings.PREF_SHOW_SUGGESTIONS else null,
if (suggestionsEnabled) Settings.PREF_ALWAYS_SHOW_SUGGESTIONS else null,
@@ -91,6 +93,7 @@ fun TextCorrectionScreen(
if (suggestionsEnabled || autocorrectEnabled) Settings.PREF_INLINE_EMOJI_SEARCH else null,
Settings.PREF_KEY_USE_PERSONALIZED_DICTS,
Settings.PREF_BIGRAM_PREDICTIONS,
+ if (suggestionsEnabled) Settings.PREF_DISABLE_MULTI_WORD_SUGGESTIONS else null,
Settings.PREF_SUGGEST_PUNCTUATION,
Settings.PREF_SUGGEST_CLIPBOARD_CONTENT,
Settings.PREF_SUGGEST_SCREENSHOTS,
@@ -119,6 +122,14 @@ fun createCorrectionSettings(context: Context) = listOf(
) {
SwitchPreference(it, Defaults.PREF_AUTO_CORRECTION)
},
+ Setting(context, Settings.PREF_AUTO_CORRECT_TRIGGER, R.string.auto_correction_trigger) {
+ val items = listOf(
+ stringResource(R.string.auto_correction_trigger_both) to "both",
+ stringResource(R.string.auto_correction_trigger_space) to "space",
+ stringResource(R.string.auto_correction_trigger_punctuation) to "punctuation",
+ )
+ ListPreference(it, items, Defaults.PREF_AUTO_CORRECT_TRIGGER)
+ },
Setting(context, Settings.PREF_MORE_AUTO_CORRECTION,
R.string.more_autocorrect, R.string.more_autocorrect_summary
) {
@@ -176,6 +187,9 @@ fun createCorrectionSettings(context: Context) = listOf(
Setting(context, Settings.PREF_SHIFT_REMOVES_AUTOSPACE, R.string.shift_removes_autospace, R.string.shift_removes_autospace_summary) {
SwitchPreference(it, Defaults.PREF_SHIFT_REMOVES_AUTOSPACE)
},
+ Setting(context, Settings.PREF_PRESERVE_SPACE_BEFORE_PUNCTUATION, R.string.preserve_space_before_punctuation, R.string.preserve_space_before_punctuation_summary) {
+ SwitchPreference(it, Defaults.PREF_PRESERVE_SPACE_BEFORE_PUNCTUATION)
+ },
Setting(context, Settings.PREF_SHOW_SUGGESTIONS,
R.string.prefs_show_suggestions, R.string.prefs_show_suggestions_summary
) {
@@ -304,6 +318,11 @@ fun createCorrectionSettings(context: Context) = listOf(
) { setting ->
SwitchPreference(setting, Defaults.PREF_USE_APPS)
},
+ Setting(context, Settings.PREF_DISABLE_MULTI_WORD_SUGGESTIONS,
+ R.string.disable_multi_word_suggestions_title, R.string.disable_multi_word_suggestions_summary
+ ) {
+ SwitchPreference(it, Defaults.PREF_DISABLE_MULTI_WORD_SUGGESTIONS)
+ },
Setting(
context, Settings.PREF_SUGGEST_EMOJIS, R.string.suggest_emojis, R.string.suggest_emojis_summary
) {
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt
index 1aba20bd9..564b59b65 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt
@@ -212,6 +212,18 @@ fun createToolbarSettings(context: Context): List {
{
SwitchPreference(it, Defaults.PREF_SHOW_ONLY_TOOLBAR_WITH_HARDWARE_KEYBOARD)
},
+ if (helium314.keyboard.latin.BuildConfig.FLAVOR == "standard" || helium314.keyboard.latin.BuildConfig.FLAVOR == "standardfull") {
+ Setting(
+ context,
+ Settings.PREF_SHOW_DOWNLOAD_BUTTON_IN_TOOLBAR,
+ R.string.show_download_button_in_toolbar,
+ R.string.show_download_button_in_toolbar_summary
+ ) {
+ SwitchPreference(it, Defaults.PREF_SHOW_DOWNLOAD_BUTTON_IN_TOOLBAR) {
+ KeyboardSwitcher.getInstance().setThemeNeedsReload()
+ }
+ }
+ } else null
)
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 388fc1b1e..21511250c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -171,6 +171,10 @@
Auto-correctionSpacebar and punctuation automatically correct mistyped words
+ Auto-correct on
+ Spacebar and punctuation
+ Spacebar only
+ Punctuation onlyMore auto-correction
@@ -493,6 +497,10 @@
No autospace when pressing shiftShift removes pending autospace
+
+ Preserve space before punctuation
+
+ Do not automatically delete space before punctuation marks: ]}):;!?,.Show more letters with diacritics in popup
@@ -641,6 +649,8 @@
Google GeminiSplit toolbarSeparate suggestions from toolbar
+ Show download button in toolbar
+ Show dictionary download icon in suggestion strip when main dictionary is missingGroqHF/OpenAI-compatibleAPI Token
@@ -933,12 +943,18 @@ language, hence "No language". -->
Really delete custom layout %s?Warning: layout is in currently use
+ Add custom layoutLayout error: %sTap to edit raw layoutSecondary layouts
+ Custom layout 1
+ Custom layout 2
+ Custom layout 3
+ Custom layout 4
+ Custom layout 5Functional keys
@@ -1077,7 +1093,7 @@ New dictionary:
%1$s will be replaced by dictionary_link_text, %2$s by the language code, %3$s by dictionary_link_text again.
This string will be interpreted as HTML -->
"Without a dictionary, you will only get suggestions for text you entered before.<br>
- You can download dictionaries %1$s, or check whether a dictionary for \"%2$s\" can be downloaded directly %3$s."
+ You can download dictionaries %1$s.""Don't show again"
@@ -1229,10 +1245,10 @@ New dictionary:
Never show againSponsorNot now
-
- Go to Wiki
-
- The Wiki can be improved by any GitHub user!
+
+ Go to FEATURES.md
+
+ List of main features and keyboard manualLibraries
@@ -1257,6 +1273,9 @@ New dictionary:
Tap the language to open settingsChoose input method
+ Direct Switch Target IME
+ Input Method to switch to directly when using the custom keycode option
+ NoneAppearance
@@ -1470,4 +1489,6 @@ New dictionary:
Persist text editing modeDo not exit text editing mode when input finishes
+ Disable multi-word suggestions
+ Prevent suggestions that consist of multiple combined words (recommended for Turkish)
diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt
index 992920ac3..e042208a5 100644
--- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt
+++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt
@@ -9,6 +9,7 @@ import android.content.SharedPreferences
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
+import helium314.keyboard.latin.RichInputMethodManager
import helium314.keyboard.latin.settings.Defaults
import helium314.keyboard.latin.settings.Settings
import kotlinx.coroutines.Dispatchers
@@ -385,14 +386,18 @@ private const val TAG = "LlamaProofreadService"
}
} else {
// Default proofreading with few-shot examples for better local model guidance
- val instruction = systemPrompt.ifBlank { "Correct the grammar and spelling of the input text. Output only the corrected text, nothing else." }
- "Instruction: ${instruction.trim()}\n\n" +
- "Input: heko hw r u\n" +
- "Output: Hello, how are you?\n\n" +
- "Input: what you name\n" +
- "Output: What is your name?\n\n" +
- "Input: $text\n" +
- "Output:"
+ val instruction = systemPrompt.ifBlank { "Correct the grammar and spelling of the input text. Keep the SAME language as the input. Do NOT translate. Output only the corrected text, nothing else." }
+ val currentLocale = try {
+ RichInputMethodManager.getInstance().currentSubtype.locale.toString()
+ } catch (_: Exception) { "" }
+ val localExamples = getProofreadFewShot(currentLocale)
+ val builder = StringBuilder("Instruction: ${instruction.trim()}\n\n")
+ builder.append("Input: heko hw r u\nOutput: Hello, how are you?\n\n")
+ for (ex in localExamples) {
+ builder.append("Input: ${ex.first}\nOutput: ${ex.second}\n\n")
+ }
+ builder.append("Input: $text\nOutput:")
+ builder.toString()
}
// Collect generated text from the flow
@@ -641,6 +646,56 @@ private const val TAG = "LlamaProofreadService"
}
}
+ private fun getProofreadFewShot(languageTag: String): List> {
+ val lang = languageTag.lowercase()
+ return when {
+ lang.startsWith("en") -> emptyList() // English example already included
+ lang.startsWith("fr") -> listOf(
+ "je sui content de te voire" to "Je suis content de te voir."
+ )
+ lang.startsWith("es") -> listOf(
+ "hola como estas tu vien" to "Hola, ¿cómo estás? Bien."
+ )
+ lang.startsWith("de") -> listOf(
+ "ich habe ein grose Haus" to "Ich habe ein großes Haus."
+ )
+ lang.startsWith("it") -> listOf(
+ "io sono molto contento di vederte" to "Io sono molto contento di vederti."
+ )
+ lang.startsWith("pt") -> listOf(
+ "eu estou muito felis hoje" to "Eu estou muito feliz hoje."
+ )
+ lang.startsWith("nl") -> listOf(
+ "ik ben heel blei om je te zien" to "Ik ben heel blij om je te zien."
+ )
+ lang.startsWith("ru") -> listOf(
+ "привет как дила у тебя" to "Привет, как дела у тебя?"
+ )
+ lang.startsWith("tr") -> listOf(
+ "ben bugün çok mutluyım" to "Ben bugün çok mutluyum."
+ )
+ lang.startsWith("pl") -> listOf(
+ "jestem bardzo szczesliwy dzisiaj" to "Jestem bardzo szczęśliwy dzisiaj."
+ )
+ lang.startsWith("hi") -> listOf(
+ "मैं बहुत खुस हूं आज" to "मैं बहुत खुश हूं आज।"
+ )
+ lang.startsWith("ar") -> listOf(
+ "انا سعيد جدا اليوم" to "أنا سعيد جداً اليوم."
+ )
+ lang.startsWith("ja") -> listOf(
+ "きょう は とても いい てんき です" to "今日はとてもいい天気です。"
+ )
+ lang.startsWith("zh") -> listOf(
+ "我今天很高心" to "我今天很高兴。"
+ )
+ lang.startsWith("ko") -> listOf(
+ "오늘 날씨가 너무 조아요" to "오늘 날씨가 너무 좋아요."
+ )
+ else -> emptyList()
+ }
+ }
+
class ProofreadException(message: String) : Exception(message)
class TranslateException(message: String) : Exception(message)
diff --git a/app/src/test/java/helium314/keyboard/KeySpecParserTest.kt b/app/src/test/java/helium314/keyboard/KeySpecParserTest.kt
index cc6b1fc97..1b767a57e 100644
--- a/app/src/test/java/helium314/keyboard/KeySpecParserTest.kt
+++ b/app/src/test/java/helium314/keyboard/KeySpecParserTest.kt
@@ -25,4 +25,18 @@ class KeySpecParserTest {
assertEquals('c'.code, KeySpecParser.getCode("a\\|b|c"))
assertEquals('d'.code, KeySpecParser.getCode("a\\|b|c|d"))
}
+ @Test fun keyCodeValuesAreUnique() {
+ val duplicates = KeyCode::class.java.declaredFields
+ .filter { it.type == Int::class.javaPrimitiveType && !it.isSynthetic && it.name.matches(Regex("[A-Z][A-Z0-9_]*")) }
+ .groupBy({ it.getInt(null) }, { it.name })
+ .filterValues { it.size > 1 }
+ .mapValues { (_, names) -> names.toSet() }
+
+ assertEquals(
+ emptyMap(),
+ duplicates,
+ "Runtime KeyCode values must be unique",
+ )
+ }
+
}
diff --git a/app/src/test/java/helium314/keyboard/Shadows.kt b/app/src/test/java/helium314/keyboard/Shadows.kt
index 8f7d7d942..37675018f 100644
--- a/app/src/test/java/helium314/keyboard/Shadows.kt
+++ b/app/src/test/java/helium314/keyboard/Shadows.kt
@@ -27,13 +27,36 @@ object ShadowLocaleManagerCompat {
@Implements(InputMethodManager::class)
class ShadowInputMethodManager2 : ShadowInputMethodManager() {
@Implementation
- override fun getInputMethodList() = listOf(
- if (BuildConfig.BUILD_TYPE == "debug" || BuildConfig.BUILD_TYPE == "debugNoMinify")
- InputMethodInfo("helium314.keyboard.debug", "LatinIME", "LeanType debug", null)
- else InputMethodInfo("helium314.keyboard", "LatinIME", "LeanType", null),
- )
+ override fun getInputMethodList() = inputMethods
+
+ @Implementation
+ override fun getEnabledInputMethodList() = inputMethods
+
+ @Implementation
+ fun getEnabledInputMethodSubtypeList(
+ imi: InputMethodInfo?,
+ allowsImplicitlySelectedSubtypes: Boolean,
+ ) = imi?.let { enabledSubtypes[it.id] }.orEmpty()
+
@Implementation
fun getShortcutInputMethodsAndSubtypes() = emptyMap>()
+
+ companion object {
+ private fun defaultInputMethod() = InputMethodInfo(
+ BuildConfig.APPLICATION_ID,
+ "helium314.keyboard.latin.LatinIME",
+ if (BuildConfig.BUILD_TYPE == "debug" || BuildConfig.BUILD_TYPE == "debugNoMinify") "LeanType debug" else "LeanType",
+ null,
+ )
+
+ var inputMethods: List = listOf(defaultInputMethod())
+ val enabledSubtypes = mutableMapOf>()
+
+ fun reset() {
+ inputMethods = listOf(defaultInputMethod())
+ enabledSubtypes.clear()
+ }
+ }
}
@Implements(BinaryDictionaryUtils::class)
diff --git a/app/src/test/java/helium314/keyboard/keyboard/internal/KeyboardStateTest.kt b/app/src/test/java/helium314/keyboard/keyboard/internal/KeyboardStateTest.kt
new file mode 100644
index 000000000..6d97ad94b
--- /dev/null
+++ b/app/src/test/java/helium314/keyboard/keyboard/internal/KeyboardStateTest.kt
@@ -0,0 +1,59 @@
+package helium314.keyboard.keyboard.internal
+
+import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode
+import helium314.keyboard.latin.utils.RecapitalizeMode
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class KeyboardStateTest {
+ @Test
+ fun customLayoutRestoresAfterSymbolsAndKeyboardReload() {
+ val actions = RecordingSwitchActions()
+ val state = KeyboardState(actions)
+ state.onLoadKeyboard(0, null, false)
+ actions.customLayouts.clear()
+
+ state.onEvent(functionalEvent(KeyCode.CUSTOM2), 0, null)
+ state.onPressKey(KeyCode.SYMBOL_ALPHA, true, 0, null)
+ state.onReleaseKey(KeyCode.SYMBOL_ALPHA, false, 0, null)
+ state.onSaveKeyboardState()
+
+ state.onLoadKeyboard(0, null, false)
+ state.onPressKey(KeyCode.SYMBOL_ALPHA, true, 0, null)
+
+ assertEquals(listOf(2, 2), actions.customLayouts)
+ }
+
+ private fun functionalEvent(code: Int) = helium314.keyboard.event.Event.createSoftwareKeypressEvent(
+ helium314.keyboard.event.Event.NOT_A_CODE_POINT,
+ code,
+ 0,
+ helium314.keyboard.latin.common.Constants.NOT_A_COORDINATE,
+ helium314.keyboard.latin.common.Constants.NOT_A_COORDINATE,
+ false,
+ )
+
+ private class RecordingSwitchActions : KeyboardState.SwitchActions {
+ val customLayouts = mutableListOf()
+
+ override fun setAlphabetKeyboard() = Unit
+ override fun setAlphabetManualShiftedKeyboard() = Unit
+ override fun setAlphabetAutomaticShiftedKeyboard() = Unit
+ override fun setAlphabetShiftLockedKeyboard() = Unit
+ override fun setAlphabetShiftLockShiftedKeyboard() = Unit
+ override fun setEmojiKeyboard() = Unit
+ override fun setClipboardKeyboard() = Unit
+ override fun setNumpadKeyboard() = Unit
+ override fun toggleNumpad(withSliding: Boolean, autoCapsFlags: Int, recapitalizeMode: RecapitalizeMode?, forceReturnToAlpha: Boolean) = Unit
+ override fun setSymbolsKeyboard() = Unit
+ override fun setSymbolsShiftedKeyboard() = Unit
+ override fun setCustomKeyboard(customIndex: Int) { customLayouts += customIndex }
+ override fun requestUpdatingShiftState(autoCapsFlags: Int, recapitalizeMode: RecapitalizeMode?) = Unit
+ override fun startDoubleTapShiftKeyTimer() = Unit
+ override val isInDoubleTapShiftKeyTimeout = false
+ override fun cancelDoubleTapShiftKeyTimer() = Unit
+ override fun setOneHandedModeEnabled(enabled: Boolean) = Unit
+ override fun switchOneHandedMode() = Unit
+ override fun toggleFloatingKeyboard() = Unit
+ }
+}
diff --git a/app/src/test/java/helium314/keyboard/latin/DirectImeSwitchTest.kt b/app/src/test/java/helium314/keyboard/latin/DirectImeSwitchTest.kt
new file mode 100644
index 000000000..cd87ae301
--- /dev/null
+++ b/app/src/test/java/helium314/keyboard/latin/DirectImeSwitchTest.kt
@@ -0,0 +1,137 @@
+package helium314.keyboard.latin
+
+import android.inputmethodservice.InputMethodService
+import android.view.inputmethod.InputMethodInfo
+import android.view.inputmethod.InputMethodSubtype
+import helium314.keyboard.ShadowInputMethodManager2
+import helium314.keyboard.latin.settings.Settings
+import helium314.keyboard.latin.utils.prefs
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.Implementation
+import org.robolectric.annotation.Implements
+
+@RunWith(RobolectricTestRunner::class)
+@Config(shadows = [ShadowInputMethodManager2::class, DirectImeServiceShadow::class])
+class DirectImeSwitchTest {
+ private lateinit var latinIME: LatinIME
+
+ @BeforeTest
+ fun setUp() {
+ ShadowInputMethodManager2.reset()
+ DirectImeServiceShadow.reset()
+ latinIME = Robolectric.setupService(LatinIME::class.java)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ ShadowInputMethodManager2.reset()
+ DirectImeServiceShadow.reset()
+ }
+
+ @Test
+ fun emptyAndMissingTargetsAreNoOps() {
+ latinIME.prefs().edit().putString(Settings.PREF_DIRECT_IME_SWITCH_TARGET, "").commit()
+ latinIME.switchToUserIme()
+ assertNull(DirectImeServiceShadow.switchedImeId)
+
+ latinIME.prefs().edit().putString(Settings.PREF_DIRECT_IME_SWITCH_TARGET, "missing/IME").commit()
+ latinIME.switchToUserIme()
+ assertNull(DirectImeServiceShadow.switchedImeId)
+ }
+
+ @Test
+ fun enabledExternalImeWithoutOrWithInvalidSubtypeUsesImeFallback() {
+ val external = externalIme
+ ShadowInputMethodManager2.inputMethods = listOf(ShadowInputMethodManager2.inputMethods.first(), external)
+
+ latinIME.prefs().edit().putString(Settings.PREF_DIRECT_IME_SWITCH_TARGET, external.id).commit()
+ latinIME.switchToUserIme()
+ assertEquals(external.id, DirectImeServiceShadow.switchedImeId)
+ assertNull(DirectImeServiceShadow.switchedSubtype)
+
+ DirectImeServiceShadow.reset()
+ latinIME.prefs().edit().putString(Settings.PREF_DIRECT_IME_SWITCH_TARGET, "${external.id};123456").commit()
+ latinIME.switchToUserIme()
+ assertEquals(external.id, DirectImeServiceShadow.switchedImeId)
+ assertNull(DirectImeServiceShadow.switchedSubtype)
+ }
+
+ @Test
+ fun enabledExternalImeWithValidSubtypeSwitchesImeAndSubtype() {
+ val external = externalIme
+ val subtype = externalSubtype
+ ShadowInputMethodManager2.inputMethods = listOf(ShadowInputMethodManager2.inputMethods.first(), external)
+ ShadowInputMethodManager2.enabledSubtypes[external.id] = listOf(subtype)
+
+ latinIME.prefs().edit().putString(
+ Settings.PREF_DIRECT_IME_SWITCH_TARGET,
+ "${external.id};${subtype.hashCode()}",
+ ).commit()
+ latinIME.switchToUserIme()
+
+ assertEquals(external.id, DirectImeServiceShadow.switchedImeId)
+ assertEquals(subtype, DirectImeServiceShadow.switchedSubtype)
+ }
+
+ @Test
+ fun sameImeWithValidSubtypeSelectsSubtypeInternally() {
+ val richImm = RichInputMethodManager.getInstance()
+ val thisIme = richImm.inputMethodInfoOfThisIme
+ val subtype = helium314.keyboard.latin.utils.SubtypeSettings.getEnabledSubtypes(true).first()
+ ShadowInputMethodManager2.inputMethods = listOf(thisIme)
+
+ latinIME.prefs().edit().putString(
+ Settings.PREF_DIRECT_IME_SWITCH_TARGET,
+ "${thisIme.id};${subtype.hashCode()}",
+ ).commit()
+ latinIME.switchToUserIme()
+
+ assertEquals(subtype, richImm.currentSubtype.rawSubtype)
+ assertNull(DirectImeServiceShadow.switchedImeId)
+ }
+ companion object {
+ val externalIme = InputMethodInfo("example.ime", "example.ime.Service", "Example IME", null)
+ val externalSubtype: InputMethodSubtype = InputMethodSubtype.InputMethodSubtypeBuilder()
+ .setSubtypeId(202)
+ .setLanguageTag("fr-FR")
+ .setSubtypeLocale("fr_FR")
+ .setSubtypeMode("keyboard")
+ .build()
+ }
+}
+
+@Implements(InputMethodService::class)
+class DirectImeServiceShadow {
+ @Implementation
+ fun getCurrentInputEditorInfo() = android.view.inputmethod.EditorInfo()
+
+ @Implementation
+ fun switchInputMethod(id: String) {
+ switchedImeId = id
+ switchedSubtype = null
+ }
+
+ @Implementation
+ fun switchInputMethod(id: String, subtype: InputMethodSubtype) {
+ switchedImeId = id
+ switchedSubtype = subtype
+ }
+
+ companion object {
+ var switchedImeId: String? = null
+ var switchedSubtype: InputMethodSubtype? = null
+
+ fun reset() {
+ switchedImeId = null
+ switchedSubtype = null
+ }
+ }
+}
diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
index d3f2accea..df2119ea0 100644
--- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
+++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
@@ -1563,6 +1563,53 @@ class InputLogicTest {
}
assertEquals("", text)
}
+ private fun typeNoAssert(text: String) {
+ text.forEach {
+ latinIME.onEvent(Event.createEventForCodePointFromUnknownSource(it.code))
+ handleMessages()
+ }
+ }
+
+ @Test fun testTextExpanderPlaceholders() {
+ reset()
+ // Enable text expander
+ latinIME.prefs().edit().apply {
+ putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_ENABLED, true)
+ putBoolean(helium314.keyboard.latin.utils.TextExpanderUtils.PREF_IMMEDIATE, true)
+ }.commit()
+
+ // Define a shortcut
+ val shortcuts = mapOf("exp" to helium314.keyboard.latin.utils.TextExpanderUtils.ShortcutEntry("Hi %cursor1%,your order %cursor2% is ready for %cursor3%.", ""))
+ helium314.keyboard.latin.utils.TextExpanderUtils.saveShortcuts(latinIME, shortcuts)
+
+ // Type the shortcut
+ typeNoAssert("exp")
+
+ // Type bob at %cursor1%
+ typeNoAssert("bob")
+
+ // Press ENTER to jump to %cursor2%
+ latinIME.onEvent(Event.createEventForCodePointFromUnknownSource(Constants.CODE_ENTER))
+ handleMessages()
+
+ // Type pizza at %cursor2%
+ typeNoAssert("pizza")
+
+ // Press ENTER to jump to %cursor3%
+ latinIME.onEvent(Event.createEventForCodePointFromUnknownSource(Constants.CODE_ENTER))
+ handleMessages()
+
+ // Type takeout at %cursor3%
+ typeNoAssert("takeout")
+
+ // Press ENTER (no more placeholders)
+ latinIME.onEvent(Event.createEventForCodePointFromUnknownSource(Constants.CODE_ENTER))
+ handleMessages()
+
+ assertEquals("Hi bob,your order pizza is ready for takeout.", getText())
+ }
+
+
// ------- helper functions ---------
@@ -2024,7 +2071,7 @@ private val ic = object : InputConnection {
override fun getCursorCapsMode(p0: Int): Int = TODO("Not yet implemented")
override fun deleteSurroundingTextInCodePoints(p0: Int, p1: Int): Boolean = TODO("Not yet implemented")
override fun commitCompletion(p0: CompletionInfo?): Boolean = TODO("Not yet implemented")
- override fun performEditorAction(p0: Int): Boolean = TODO("Not yet implemented")
+ override fun performEditorAction(p0: Int): Boolean = true
override fun performContextMenuAction(p0: Int): Boolean = TODO("Not yet implemented")
override fun clearMetaKeyStates(p0: Int): Boolean = TODO("Not yet implemented")
override fun reportFullscreenMode(p0: Boolean): Boolean = TODO("Not yet implemented")
diff --git a/app/src/test/java/helium314/keyboard/latin/SuggestTest.kt b/app/src/test/java/helium314/keyboard/latin/SuggestTest.kt
index 0d129ad24..87bcc66ff 100644
--- a/app/src/test/java/helium314/keyboard/latin/SuggestTest.kt
+++ b/app/src/test/java/helium314/keyboard/latin/SuggestTest.kt
@@ -270,12 +270,39 @@ class SuggestTest {
assert(!result.last()) // should not be corrected
}
+ @Test fun `multi-word filter removes phrases only when enabled`() {
+ fun results() = SuggestionResults(3, false, false).apply {
+ add(suggestion("single", 100, Locale.ENGLISH))
+ add(suggestion("two words", 90, Locale.ENGLISH))
+ add(suggestion("another", 80, Locale.ENGLISH))
+ }
+
+ val disabled = results()
+ filterMultiWordSuggestions(disabled, false)
+ assertEquals(listOf("single", "two words", "another"), disabled.map { it.mWord })
+
+ val enabled = results()
+ filterMultiWordSuggestions(enabled, true)
+ assertEquals(listOf("single", "another"), enabled.map { it.mWord })
+ }
+
@Test fun `quotes are added to suggestions when needed`() {
val result = Suggest.getTransformedSuggestedWordInfo(suggestion("word", 1, Locale.ENGLISH, true),
Locale.ENGLISH, false, false, 1)
assertEquals("word'", result.mWord)
}
+ @Test fun `fallback lowercase candidate uses Suggest presentation casing`() {
+ val candidate = suggestion("hello", 1, Locale.ENGLISH, true)
+
+ assertEquals("hello", Suggest.getTransformedSuggestedWordInfo(
+ candidate, Locale.ENGLISH, false, false, 0).mWord)
+ assertEquals("Hello", Suggest.getTransformedSuggestedWordInfo(
+ candidate, Locale.ENGLISH, false, true, 0).mWord)
+ assertEquals("HELLO", Suggest.getTransformedSuggestedWordInfo(
+ candidate, Locale.ENGLISH, true, false, 0).mWord)
+ }
+
@Test fun `misspelled word is corrected using relaxed threshold even with low score`() {
val locale = Locale.ENGLISH
// typed word: "recpa" (length 5) -> not in dictionary
diff --git a/app/src/test/java/helium314/keyboard/latin/gesture/SwipeGestureEngineTest.kt b/app/src/test/java/helium314/keyboard/latin/gesture/SwipeGestureEngineTest.kt
new file mode 100644
index 000000000..98b52023f
--- /dev/null
+++ b/app/src/test/java/helium314/keyboard/latin/gesture/SwipeGestureEngineTest.kt
@@ -0,0 +1,77 @@
+package helium314.keyboard.latin.gesture
+
+import helium314.keyboard.ShadowInputMethodManager2
+import helium314.keyboard.ShadowProximityInfo
+import helium314.keyboard.keyboard.Key
+import helium314.keyboard.keyboard.Keyboard
+import helium314.keyboard.keyboard.KeyboardId
+import helium314.keyboard.keyboard.KeyboardLayoutSet
+import helium314.keyboard.keyboard.internal.KeyboardParams
+import helium314.keyboard.latin.DictionaryFacilitator
+import helium314.keyboard.latin.LatinIME
+import helium314.keyboard.latin.common.InputPointers
+import java.util.function.BiConsumer
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import org.mockito.Mockito
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.junit.runner.RunWith
+
+@RunWith(RobolectricTestRunner::class)
+@Config(shadows = [ShadowInputMethodManager2::class, ShadowProximityInfo::class])
+class SwipeGestureEngineTest {
+ @BeforeTest
+ fun setUp() {
+ Robolectric.setupService(LatinIME::class.java)
+ }
+
+ @Test
+ fun fallbackOutputUsesCanonicalLowercaseBeforeSuggestPresentationCasing() {
+ val keyboard = keyboardFor("helo")
+ val facilitator = Mockito.mock(DictionaryFacilitator::class.java)
+ Mockito.`when`(facilitator.isBlacklisted(Mockito.anyString())).thenReturn(false)
+ Mockito.doAnswer { invocation ->
+ @Suppress("UNCHECKED_CAST")
+ val consumer = invocation.arguments[0] as BiConsumer
+ consumer.accept("Hello", 100)
+ null
+ }.`when`(facilitator).forEachMainDictionaryWord(Mockito.any())
+
+ val index = SwipeGestureEngine.buildIndex(facilitator, keyboard)
+ val pointers = InputPointers(4).apply {
+ addPointer(50, 50, 0, 0)
+ addPointer(150, 50, 0, 10)
+ addPointer(250, 50, 0, 20)
+ addPointer(350, 50, 0, 30)
+ }
+
+ val result = SwipeGestureEngine.rankByIndex(index, pointers, keyboard, 1, emptySet())
+
+ assertEquals("hello", result.iterator().next().mWord)
+ }
+
+ private fun keyboardFor(letters: String): Keyboard {
+ val params = KeyboardParams().apply {
+ mId = KeyboardLayoutSet.getFakeKeyboardId(KeyboardId.ELEMENT_ALPHABET)
+ mOccupiedWidth = letters.length * 100
+ mOccupiedHeight = 100
+ mBaseWidth = mOccupiedWidth
+ mBaseHeight = mOccupiedHeight
+ mMostCommonKeyWidth = 100
+ mMostCommonKeyHeight = 100
+ GRID_WIDTH = letters.length
+ GRID_HEIGHT = 1
+ }
+ letters.forEachIndexed { index, letter ->
+ params.onAddKey(Key(
+ letter.toString(), null, letter.code, null, null,
+ 0, Key.BACKGROUND_TYPE_NORMAL,
+ index * 100, 0, 100, 100, 0, 0,
+ ))
+ }
+ return Keyboard(params)
+ }
+}
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 0db34dadc..b64b0bdfa 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -17,6 +17,9 @@ LeanType integrates with AI providers to offer advanced proofreading and transla
| 📝 **[Text Expander](#6-text-expander)** | Custom text shortcut expansion. |
| 🖱️ **[Touchpad Mode](#7-touchpad-mode)** | Full-screen touchpad gestures and controls. |
| ✍️ **[Handwriting Input](#8-handwriting-input)** | Use handwriting recognition to draw letters directly on a canvas. |
+| 👆 **[Built-in Gesture Typing](#9-built-in-gesture-typing)** | Use gesture typing without downloading native libraries. |
+| ⌨️ **[Direct Switch Target IME](#10-direct-switch-target-ime)** | Switch directly to another input method using custom keycode `-10076`. |
+| 🎨 **[Custom Layouts Customization](#11-custom-layouts-customization)** | Persistent custom layout profiles and management. |
## Summary of New Features
@@ -42,6 +45,9 @@ LeanType integrates with AI providers to offer advanced proofreading and transla
| **Two-thumb Typing** | Mix taps and swipes naturally, multi-tap then swipe, manual spacing, recognition tweaks. All experimental and opt-in. | `Two-thumb typing (experimental)` |
| **Text Expander** | Expand custom shortcuts using dynamic template variables (date, time, clipboard, custom placeholders). | `Text correction > Text Expander` |
| **Handwriting Input** | Draw letters or words directly on the screen keyboard space to type (standard variant, requires plugin). | `Libraries > Handwriting Input Plugin` |
+| **Built-in Gesture Typing** | Gesture typing works out of the box using our new built-in pure-Java fallback engine, removing the strict dependency on native Google libraries. | `Gesture typing` |
+| **Direct Switch Target IME** | Direct input method switching using custom keycode `-10076` assigned to toolbar keys. | `Preferences > Direct Switch Target IME` |
+| **Custom Layouts** | Supports up to 5 custom layouts with persistent layout index tracking. | `Languages > Custom layouts` |
---
@@ -423,3 +429,39 @@ LeanType integrates a handwriting recognition canvas that allows you to write ch
3. Draw characters, words, or punctuation symbols on the canvas. The keyboard will automatically inputs recognized characters.
4. Tap the **Clear (X)** button on the bottom row to clear the current drawing canvas.
5. Tap the **Handwriting** icon again to toggle back to the standard keyboard layout.
+---
+
+## 9. Built-in Gesture Typing
+
+* **Functionality**: Gesture typing (swipe/glide typing) works out of the box without requiring any external or native libraries.
+* **Engine**: Powered by a pure-Java fallback gesture engine (`SwipeGestureEngine`) ported from HeliBoard.
+* **Accuracy & Ranking**: Includes end-point-weighted L2 path scoring, shape length mismatch penalty, sequence matching penalty, next-word bigram prediction boost, and forgiving start/end matching.
+* **Optional Native Engine**: Users can still choose to toggle to the native swipe library. If using the Standard flavor, the library can be downloaded automatically via the built-in downloader.
+* **Settings Configuration**:
+ 1. Go to **Settings > Gesture typing**.
+ 2. Toggle **Use fallback gesture engine** to select between the pure-Java engine and the native library.
+ 3. Self-learning can be toggled via **Enable gesture self-learning**.
+---
+
+## 10. Direct Switch Target IME
+
+* **Functionality**: Switch directly to another configured input method (and subtype) instead of opening the system input method picker.
+* **Behavior**:
+ * Map the custom keycode `-10076` (`SWITCH_TO_USER_IME`) to any toolbar key (supports click or long-press).
+ * Tapping/long-pressing the key immediately switches input methods.
+* **How to Setup**:
+ 1. Go to **Settings > Preferences**.
+ 2. Tap **Direct Switch Target IME** and select the target keyboard/subtype from the list of enabled inputs.
+ 3. Go to **Settings > Toolbar > Customize toolbar key codes** to map `-10076` to a toolbar key.
+
+---
+
+## 11. Custom Layouts Customization
+
+* **Functionality**: Save up to five custom layout profiles with persistent active slot tracking.
+* **Behavior**:
+ * The active custom layout slot index is preserved across orientation changes and switching between alphabet and symbol states.
+ * Unused custom layout profiles can be directly deleted from settings.
+* **How to Setup**:
+ 1. Go to **Settings > Languages > Custom layouts**.
+ 2. Manage custom layouts and slots as needed.
diff --git a/docs/badges/download.svg b/docs/badges/download.svg
index 77090f9ec..3cb9672b3 100644
--- a/docs/badges/download.svg
+++ b/docs/badges/download.svg
@@ -1 +1 @@
-
+
diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg
index 5a96ab422..7bfd4e0ee 100644
--- a/docs/badges/downloads.svg
+++ b/docs/badges/downloads.svg
@@ -1 +1 @@
-
+
diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg
index ff3982034..2b07e6a5b 100644
--- a/docs/badges/stars.svg
+++ b/docs/badges/stars.svg
@@ -1 +1 @@
-
+
diff --git a/docs/images/1.png b/docs/images/1.png
index 8210f2a6d..f6dedb108 100644
Binary files a/docs/images/1.png and b/docs/images/1.png differ
diff --git a/docs/images/2.png b/docs/images/2.png
index 21a32b143..868a208c6 100644
Binary files a/docs/images/2.png and b/docs/images/2.png differ
diff --git a/docs/images/3.png b/docs/images/3.png
index b1c80fcbd..ade38d1fe 100644
Binary files a/docs/images/3.png and b/docs/images/3.png differ
diff --git a/docs/images/4.png b/docs/images/4.png
index a341a4aa7..9968c2b81 100644
Binary files a/docs/images/4.png and b/docs/images/4.png differ
diff --git a/docs/images/5.png b/docs/images/5.png
index bb92f0e2b..4564c24de 100644
Binary files a/docs/images/5.png and b/docs/images/5.png differ
diff --git a/docs/images/6.png b/docs/images/6.png
index 34d7ae6b6..e3a69d5f3 100644
Binary files a/docs/images/6.png and b/docs/images/6.png differ
diff --git a/docs/releasenote/release_notes_v3.9.2.md b/docs/releasenote/release_notes_v3.9.2.md
new file mode 100644
index 000000000..9ade16cd2
--- /dev/null
+++ b/docs/releasenote/release_notes_v3.9.2.md
@@ -0,0 +1,28 @@
+### 💖 Support Our Work
+* We are committed to making our apps as powerful and polished as possible. As an entirely community-funded project, we rely on your support to keep going, please consider becoming a [sponsor](https://github.com/sponsors/LeanBitLab). A huge thank you to all our current supporters!
+
+## 🚀 What's New
+
+### 👆 Gesture & Swipe Engine
+* **Accuracy Fix (Hitbox Center)**: Updated mapping logic to use key hitbox center coordinates rather than visual bounds, resolving rightward touch offsets (misrecognitions like "just" registering as "jest").
+* **Performance Optimization**: Added dynamic threshold early-exit bounds to the L2 gesture matching loop, significantly reducing CPU usage by skipping poor candidates.
+
+### 🛠️ Visual & Keyboard Settings
+* **Accent-colored direct deletion**: Added a direct delete button (trash bin icon) in the downloadable dictionary lists to allow quick deletion of other layout dictionaries.
+* **Separate Experimental Dictionaries**: Fixed a naming/path collision where installing a main dictionary caused the experimental version to show as installed.
+* **Missing Dictionary Toolbar Redirect**: Redirects missing dictionary button directly to settings rather than showing a placeholder.
+* **Reset Prediction Context**: Reset word prediction context to the beginning of the sentence on new lines.
+* **Backspace Emoji Grouping**: Prevented non-emoji symbols (e.g. mathematical operators, box drawings) from being grouped and deleted together under backspace.
+* **Custom Translation Target**: Added support for custom translation target language options.
+
+### 📦 Build & Package Size
+* **Exclude English Assets**: Excluded all prepackaged English dictionary assets from the standard flavor APK builds to optimize package size.
+
+## 📦 Downloads (Choose Your Flavor)
+
+| File | Description | Permissions |
+| :--- | :--- | :--- |
+| **`1-LeanType_3.9.2-standardfull-release.apk`** | **Recommended**. Cloud AI + Handwrite | Internet |
+| **`1-LeanType_3.9.2-standard-release.apk`** | **Fdroid Build**. Standard - Foss only | Internet |
+| **`2-LeanType_3.9.2-offline-release.apk`** | **Privacy Focused**. Offline AI | No Internet |
+| **`3-LeanType_3.9.2-offlinelite-release.apk`** | **Minimalist**. Pure FOSS. No AI Integration. | No Internet |
diff --git a/docs/releasenote/release_notes_v3.9.3.md b/docs/releasenote/release_notes_v3.9.3.md
new file mode 100644
index 000000000..9762a942b
--- /dev/null
+++ b/docs/releasenote/release_notes_v3.9.3.md
@@ -0,0 +1,25 @@
+### 💖 Support Our Work
+* We are committed to making our apps as powerful and polished as possible. As an entirely community-funded project, we rely on your support to keep going, please consider becoming a [sponsor](https://github.com/sponsors/LeanBitLab). A huge thank you to all our current supporters!
+
+## 🚀 What's New
+
+### 📖 Dictionary & Typing Predictions
+* **Next-Word Predictions Fallback**: Personal dictionary and user history words are now automatically queried and suggested as next-word predictions before you start typing, resolving empty suggestion slots.
+* **Same-Language Variant Fallback**: Added layout fallback dictionary loading (e.g., English India `en_IN` can now automatically fallback to use downloaded `en_US` or `en_GB` main dictionaries if installed).
+
+### 👆 Gesture & Swipe Engine (Pure-Java)
+* **Gesture Match Optimization**: Precomputed string caches and log-frequency calculations, removing GC allocation pressure and speeding up matching loops.
+* **Fly-over Segment Matching**: Implemented segment-distance checks to accurately match keys crossed during fast, straight gestures.
+
+### 🛠️ Toolbar & Layout Settings
+* **Toolbar Download Button Toggle**: Added a new settings toggle to show/hide the dictionary download shortcut button directly in the toolbar.
+* **Dictionary Dialog Polish**: Cleaned up the dictionary download prompt by removing redundant raw download links.
+
+## 📦 Downloads (Choose Your Flavor)
+
+| File | Description | Permissions |
+| :--- | :--- | :--- |
+| **`1-LeanType_3.9.3-standardfull-release.apk`** | **Recommended**. Cloud AI + Handwrite | Internet |
+| **`1-LeanType_3.9.3-standard-release.apk`** | **Fdroid Build**. Standard - Foss only | Internet |
+| **`2-LeanType_3.9.3-offline-release.apk`** | **Privacy Focused**. Offline AI | No Internet |
+| **`3-LeanType_3.9.3-offlinelite-release.apk`** | **Minimalist**. Pure FOSS. No AI Integration. | No Internet |
diff --git a/docs/releasenote/release_notes_v3.9.4.md b/docs/releasenote/release_notes_v3.9.4.md
new file mode 100644
index 000000000..e85eabaef
--- /dev/null
+++ b/docs/releasenote/release_notes_v3.9.4.md
@@ -0,0 +1,31 @@
+### 💖 Support Our Work
+* We are committed to making our apps as powerful and polished as possible. As an entirely community-funded project, we rely on your support to keep going, please consider becoming a [sponsor](https://github.com/sponsors/LeanBitLab). A huge thank you to all our current supporters!
+
+## 🚀 What's New
+
+### 👆 Gesture & Swipe Engine (Pure-Java)
+- **OutOfMemoryError Fixes**: Fixed OOM crashes during gesture index construction. Dictionary words are now streamed directly to avoid massive intermediate maps, and gesture path coordinates are packed into primitive variables to drastically reduce object allocations and GC pressure.
+- **Blacklist/Blocked Words Isolation**: Prevented blocked words from leaking into user history, next-word suggestions cache, and gesture recognition indexes. Settings changes/removals now trigger immediate cache and gesture index reloads.
+- **Cursor Selection Fix**: Fixed a bug where moving the cursor under automatic shift mode (such as auto-capitalization at the start of a sentence) would cause text to be unintentionally selected.
+
+### 📝 Text Expander & Placeholders
+- **Sequential Placeholders**: Added support for sequential template placeholders in text expander macros.
+- **Synchronous Placeholder Deletion**: Rewrote placeholder navigation/deletion to execute synchronously via `deleteSurroundingText` and `setSelection` to prevent IPC selection desync.
+- **Data Backup**: Added text expander data backup and restore capabilities, linking preference keys directly to the database backup category.
+
+### 🎨 Keyboards & Custom Layouts
+- **Dynamic Layout Slots**: Added support for up to five dynamic custom secondary layouts (`custom1` to `custom5`) with a direct deletion option in layout settings.
+- **Layout Compatibility**: Resolved issues involving blocked words, custom fonts, and the symbols number row.
+
+### 🛠️ Suggestions & Settings
+- **Long-Press Suggestion Deletion**: Enabled long-press on suggestions in `MoreSuggestionsView` to directly delete/block suggestions. This dialog is wrapped in the platform dialog theme and resolves the `BadTokenException` by binding to the correct window token.
+- **Auto-Correction Triggers**: Added a new settings preference to configure whether auto-correction is triggered by the Spacebar, Punctuation, or both.
+
+## 📦 Downloads (Choose Your Flavor)
+
+| File | Description | Permissions |
+| :--- | :--- | :--- |
+| **`1-LeanType_3.9.4-standardfull-release.apk`** | **Recommended**. Cloud AI + Handwrite | Internet |
+| **`1-LeanType_3.9.4-standard-release.apk`** | **Fdroid Build**. Standard - Foss only | Internet |
+| **`2-LeanType_3.9.4-offline-release.apk`** | **Privacy Focused**. Offline AI | No Internet |
+| **`3-LeanType_3.9.4-offlinelite-release.apk`** | **Minimalist**. Pure FOSS. No AI Integration. | No Internet |
diff --git a/docs/releasenote/release_notes_v3.9.5.md b/docs/releasenote/release_notes_v3.9.5.md
new file mode 100644
index 000000000..bb0b3c478
--- /dev/null
+++ b/docs/releasenote/release_notes_v3.9.5.md
@@ -0,0 +1,25 @@
+### 💖 Support Our Work
+* We are committed to making our apps as powerful and polished as possible. As an entirely community-funded project, we rely on your support to keep going, please consider becoming a [sponsor](https://github.com/sponsors/LeanBitLab). A huge thank you to all our current supporters!
+
+## 🚀 What's New
+
+### ⌨️ Input Method & Keyboard Switching
+- **Direct Switch Target IME**: Added a new settings preference to configure a target input method (and subtype) to switch to directly.
+- **Custom Switching Keycode**: Added a new custom keycode `-10076` (`SWITCH_TO_USER_IME`) that can be mapped to any toolbar key (either click or long-press) to trigger the direct IME switch immediately without showing the system picker.
+
+### 🎨 Custom Layouts & Navigation
+- **Custom Layout Persistence**: Track the active custom layout index to ensure that custom layouts are correctly restored after switching between alphabet/symbols or after device orientation changes.
+- **Shift Key Behavior**: Improved shift key press and release tracking for custom layout states.
+
+### 🛠️ Bug Fixes & Toggles
+- **Toggles & Settings**: Added some toggles for more customization
+- **Bug Fixes**: Resolved some other minor bugs
+
+## 📦 Downloads (Choose Your Flavor)
+
+| File | Description | Permissions |
+| :--- | :--- | :--- |
+| **`1-LeanType_3.9.5-standardfull-release.apk`** | **Recommended**. Cloud AI + Handwrite | Internet |
+| **`1-LeanType_3.9.5-standard-release.apk`** | **Fdroid Build**. Standard - Foss only | Internet |
+| **`2-LeanType_3.9.5-offline-release.apk`** | **Privacy Focused**. Offline AI | No Internet |
+| **`3-LeanType_3.9.5-offlinelite-release.apk`** | **Minimalist**. Pure FOSS. No AI Integration. | No Internet |
diff --git a/fastlane/metadata/android/en-US/changelogs/3920.txt b/fastlane/metadata/android/en-US/changelogs/3920.txt
new file mode 100644
index 000000000..56cb42017
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/3920.txt
@@ -0,0 +1,8 @@
+- Use key hitbox centers to fix gesture mapping offsets
+- Performance: L2 early exit bounds on poor gesture candidates
+- Prevent grouping of non-emoji symbols on delete
+- Exclude prepackaged English dictionary assets from standard builds
+- Allow direct deletion of downloaded dictionaries and separate experimental status checks
+- Redirect missing dictionary button directly to settings
+- Reset word prediction context to sentence start on new lines
+- Support custom translation target languages
diff --git a/fastlane/metadata/android/en-US/changelogs/3930.txt b/fastlane/metadata/android/en-US/changelogs/3930.txt
new file mode 100644
index 000000000..2cce7e327
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/3930.txt
@@ -0,0 +1,5 @@
+- Query and suggest personal dictionary words as next-word predictions before typing.
+- Same-language variant dictionary fallbacks (e.g., en_IN fallbacks to en_US/en_GB main).
+- Optimizations and fly-over accuracy improvements in pure-Java gesture engine.
+- Toggle to show/hide download dict button in toolbar.
+- Clean up redundant links on download page.
diff --git a/fastlane/metadata/android/en-US/changelogs/3940.txt b/fastlane/metadata/android/en-US/changelogs/3940.txt
new file mode 100644
index 000000000..8dcc18e06
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/3940.txt
@@ -0,0 +1,7 @@
+- Fixed swipe gesture OOM crashes via packed coordinates & sequential word streaming.
+- Blocked blacklisted words from history, suggestion cache, and gesture index.
+- Fixed automatic shift text selection bug.
+- Added text expander sequential placeholders & settings backup integration.
+- Added dynamic secondary layouts (custom1-5) with deletion option.
+- Added long-press deletion of suggestions with window crash fixes.
+- Added auto-correction triggers configuration (space/punctuation/both).
diff --git a/fastlane/metadata/android/en-US/changelogs/3950.txt b/fastlane/metadata/android/en-US/changelogs/3950.txt
new file mode 100644
index 000000000..23662a718
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/3950.txt
@@ -0,0 +1,4 @@
+- Added direct Switch Target IME feature to switch directly to another input method/subtype.
+- Added custom keycode (-10076) that can be assigned to toolbar keys (including long-press).
+- Track active custom layout index to properly restore custom layouts on alphabet/orientation changes.
+- Improved shift key behavior and action handling on custom layouts.