[Overview] Add an Automated Text Replacement feature that lets users define abbreviation → expansion pairs in Settings, and automatically replaces typed abbreviations with their full text when a trigger character (space, period, comma, etc.) is typed.
This feature is similar to "Text Shortcuts" found on iOS and macOS keyboards. When a user defines "omw" → "On my way!", typing omw followed by a space will instantly replace it with On my way! . The feature integrates with the existing KeyboardManager text-commit pipeline, stores its rules persistently in the existing AppPrefs / SharedPreferences system, and exposes a dedicated settings screen that fits the existing Jetpack Compose + Screen {} UI pattern.
The implementation is split into four layers:
- Storage — two new preference keys in a new
AppPrefs.TextReplacementinner class - Core logic — a new
TextReplacementManagerclass with pure-Kotlin helper methods (testable without Android) - IME integration — hook into
KeyboardManager.handleText()and wire the manager throughVIM8Application - Settings UI — a new
TextReplacementScreencomposable with an add-entry dialog, entry list with delete, and a master enable switch; new route and home-screen entry
[Types]
No new Kotlin types or sealed classes are needed; one AppPrefs.TextReplacement inner class with two PreferenceData fields is introduced.
AppPrefs.TextReplacement (inner class)
enabled: PreferenceData<Boolean>— master on/off switch; keyprefs_text_replacement_enabled; defaultfalseentries: PreferenceData<Set<String>>— set of encoded pairs; keyprefs_text_replacement_entries; defaultemptySet()
Encoding convention (not a Kotlin type)
Each element in the entries stringSet is "<abbreviation>|||<expansion>" where ||| is the fixed separator constant ENTRY_SEPARATOR = "|||". Abbreviations must not contain ||| (validated on add). This convention is internal to TextReplacementManager.
TextReplacementManager companion constants
ENTRY_SEPARATOR: String = "|||"— field separator used in encoded entriesTRIGGER_CHARS: Set<Char> = setOf(' ', '.', ',', '!', '?')— characters that end a word and trigger replacement check
[Files] New and modified files for the complete feature implementation.
New files to create:
8vim/src/main/kotlin/inc/flide/vim8/ime/text/TextReplacementManager.kt
Core logic: parse entries, encode entries, add/remove/list entries, find replacement, commit replacement via InputConnection.8vim/src/main/kotlin/inc/flide/vim8/app/settings/TextReplacementScreen.kt
Compose settings screen: enable switch, list of shortcut entries, add/edit dialog, delete per entry.8vim/src/main/res/drawable/ic_find_replace.xml
Simple vector drawable for the HomeScreen navigation icon (24dp, find-replace style icon).8vim/src/test/kotlin/inc/flide/vim8/ime/text/TextReplacementManagerSpec.kt
KotestFunSpecunit tests covering pure logic:parseEntries,encodeEntry,findReplacement,addEntry,removeEntry.
Existing files to modify:
8vim/src/main/kotlin/inc/flide/vim8/AppPrefs.kt
Addval textReplacement = TextReplacement()property and newinner class TextReplacementwithenabledandentriespreferences.8vim/src/main/kotlin/inc/flide/vim8/VIM8Application.kt
Addval textReplacementManager = lazy { TextReplacementManager(this) }lazy property andfun Context.textReplacementManager()extension function.8vim/src/main/kotlin/inc/flide/vim8/ime/keyboard/text/KeyboardManager.kt
InjecttextReplacementManagervia context extension, add trigger check at end ofhandleText().8vim/src/main/kotlin/inc/flide/vim8/app/Routes.kt
Addconst val TEXT_REPLACEMENT = "settings/text-replacement"route constant andcomposableregistration.8vim/src/main/kotlin/inc/flide/vim8/app/settings/HomeScreen.kt
AddPreferencenavigation item for Text Replacement screen, withic_find_replaceicon.8vim/src/main/res/values/strings.xml
Add all string resources for the Text Replacement settings screen.
[Functions] New functions and modifications to existing ones.
New functions in TextReplacementManager.kt:
| Function | Visibility | Signature | Purpose |
|---|---|---|---|
parseEntries |
internal |
(rawSet: Set<String>): Map<String, String> |
Splits each `"abbr |
encodeEntry |
internal |
(abbreviation: String, expansion: String): String |
Returns "$abbreviation$ENTRY_SEPARATOR$expansion". |
findReplacement |
internal |
(textBeforeCursor: String, map: Map<String, String>): Pair<Int, String>? |
Returns (charsToDelete, replacementText) if the word ending at the last TRIGGER_CHAR matches a key in map, else null. charsToDelete = abbreviation.length + 1 (for the boundary char). |
getEntries |
fun |
(): List<Pair<String, String>> |
Returns all entries as sorted list of (abbreviation, expansion) pairs. |
addEntry |
fun |
(abbreviation: String, expansion: String) |
Validates abbreviation is non-empty and does not contain ENTRY_SEPARATOR, removes any existing entry for the same abbreviation, appends the new encoded entry to the preference set. |
removeEntry |
fun |
(abbreviation: String) |
Removes the encoded entry whose abbreviation prefix matches from the preference set. |
checkAndReplace |
fun |
() |
Gets currentInputConnection() and textBeforeCursor(500), calls findReplacement, then calls deleteSurroundingText and commitText on the InputConnection if a match is found. |
Modified functions:
| Function | File | Change |
|---|---|---|
handleText |
KeyboardManager.kt |
After editorInstance.commitText(text) and shift-reset, add: if prefs.textReplacement.enabled.get() is true AND text.isNotEmpty() AND text.last() in TextReplacementManager.TRIGGER_CHARS, call textReplacementManager.checkAndReplace(). |
AppNavHost |
Routes.kt |
Register new composable(Settings.TEXT_REPLACEMENT) { TextReplacementScreen() }. |
HomeScreen |
HomeScreen.kt |
Add Preference block with iconId = R.drawable.ic_find_replace, title from string res, onClick = { navController.navigate(Routes.Settings.TEXT_REPLACEMENT) }. |
[Classes] New and modified classes with complete descriptions.
New class: TextReplacementManager
- File:
8vim/src/main/kotlin/inc/flide/vim8/ime/text/TextReplacementManager.kt - Package:
inc.flide.vim8.ime.text - Constructor:
(context: Context)— stores nothing from context; context is used only forappPreferenceModel()delegation - Key property:
private val prefs by appPreferenceModel()— follows the same delegation pattern asKeyboardManager - Companion object: declares
ENTRY_SEPARATORandTRIGGER_CHARSconstants - Does not extend any base class or implement any interface
- All pure-logic methods (
parseEntries,encodeEntry,findReplacement) areinternalso they can be directly tested via a test-subject pattern matchingSuggestionsManagerSpec
Modified class: AppPrefs
- File:
8vim/src/main/kotlin/inc/flide/vim8/AppPrefs.kt - Add
val textReplacement = TextReplacement()field in the body (alongsidelayout,keyboard, etc.) - Add
inner class TextReplacementwithenabled(boolean) andentries(stringSet) preference fields. No version bump required (new keys default gracefully on first access).
Modified class: VIM8Application
- File:
8vim/src/main/kotlin/inc/flide/vim8/VIM8Application.kt - Add
val textReplacementManager = lazy { TextReplacementManager(this) }lazy property - Add companion extension function
fun Context.textReplacementManager() = this.vim8Application().textReplacementManager(at bottom of file, alongsidesuggestionsManager,editorInstance, etc.)
Modified class: KeyboardManager
- File:
8vim/src/main/kotlin/inc/flide/vim8/ime/keyboard/text/KeyboardManager.kt - Add
private val textReplacementManager by context.textReplacementManager()field (same delegation pattern assuggestionsManager) - Modify
handleText()to calltextReplacementManager.checkAndReplace()when appropriate (see Functions section)
New composable: TextReplacementScreen
- File:
8vim/src/main/kotlin/inc/flide/vim8/app/settings/TextReplacementScreen.kt - Pattern:
Screen { title = ...; previewFieldVisible = true; content { ... } }matching all other settings screens - Inside
content {}:PreferenceGroupwithSwitchPreference(prefs.textReplacement.enabled, ...), thenPreferenceGroup(title = "Shortcuts")containing a "Add shortcut"Preferenceitem with a trailing+icon and per-entry items that are clickable (to edit) and have a trailing deleteIconButton - Dialog state managed with
remember { mutableStateOf(...) }— a single reusableAlertDialogwith twoOutlinedTextFieldinputs (abbreviation, expansion) used for both adding new and editing existing entries - Dialog mode tracking:
editingAbbreviation: String?(null= add mode, non-null = edit mode with the original abbreviation). Dialog title reads "Add Shortcut" or "Edit Shortcut" accordingly - On dialog save: if
editingAbbreviation != null && editingAbbreviation != abbreviationInput, calltextReplacementManager.removeEntry(editingAbbreviation)first (to handle abbreviation rename), then calltextReplacementManager.addEntry(abbreviationInput, expansionInput) - Entry list built from
prefs.textReplacement.entries.observeAsState()→remember(entries) { textReplacementManager.parseEntries(entries).toList().sortedBy { it.first } }for reactive updates - Context accessed via
LocalContext.current, manager viaremember { context.textReplacementManager().value }
[Dependencies] No new external library dependencies are required.
All logic uses:
- Existing Kotlin stdlib (
Set,Map, string operations) - Existing Android SDK (
InputConnection,SharedPreferences— already used throughout) - Existing Compose Material3 (
AlertDialog,OutlinedTextField,IconButton— already in the Compose dependency) - The
ic_find_replace.xmldrawable is a new first-party vector resource (no external icon library needed)
No changes to gradle/libs.versions.toml or 8vim/build.gradle.kts are required.
[Testing]
Unit tests covering all pure-Kotlin logic in TextReplacementManager; Android-dependent methods (checkAndReplace) are left for integration testing.
New test file:
8vim/src/test/kotlin/inc/flide/vim8/ime/text/TextReplacementManagerSpec.kt
Tests follow the SuggestionsManagerSpec pattern: a private TextReplacementManagerTestSubject class replicates the internal pure logic to avoid constructing the real class (which needs Android Context for appPreferenceModel()).
Test contexts:
parseEntries — valid entries—"btw|||By the way"→ map{"btw" → "By the way"}parseEntries — malformed entries are skipped— entries without separator are ignoredparseEntries — empty set returns empty mapencodeEntry — round-trip—encodeEntry("btw", "By the way")round-trips throughparseEntriesfindReplacement — no match returns null— unknown abbreviation, no entriesfindReplacement — match at end of text—"btw "with map{"btw" → "By the way"}→(4, "By the way ")findReplacement — match after other words—"hello btw "→ still matches"btw"findReplacement — boundary char preserved— period, comma,!,?all preserved in replacementfindReplacement — no match when text ends with non-trigger char—"btw"(no trailing space) → nullfindReplacement — case-sensitive—"BTW"does not match"btw"mappingaddEntry / removeEntry logic— viaparseEntriesafter encode: add "omw", remove "omw", double-add overwrites
[Implementation Order] Implement in the sequence below to ensure each change compiles and can be tested before the next depends on it.
-
Add
AppPrefs.TextReplacement— Addinner class TextReplacementandval textReplacement = TextReplacement()toAppPrefs.kt. Build verifies the model compiles. -
Create
TextReplacementManager.kt— New file with all logic:parseEntries,encodeEntry,findReplacement,getEntries,addEntry,removeEntry,checkAndReplace. Companion constants. -
Write
TextReplacementManagerSpec.kt— Unit tests for all internal/pure-logic functions usingTextReplacementManagerTestSubject. Run./gradlew testDebugUnitTestto verify all tests pass. -
Wire into
VIM8Application.kt— AddtextReplacementManagerlazy property andContext.textReplacementManager()extension function. -
Integrate into
KeyboardManager.kt— Addprivate val textReplacementManager by context.textReplacementManager()field and modifyhandleText()to callcheckAndReplace()on trigger chars. -
Create
ic_find_replace.xml— New vector drawable for the HomeScreen icon. -
Add string resources to
strings.xml— All screen title, group titles, preference labels, dialog strings. -
Create
TextReplacementScreen.kt— Full Compose screen followingScreen {}pattern with switch preference, entry list, and add-entry dialog. -
Register route in
Routes.kt— AddTEXT_REPLACEMENTconstant andcomposableregistration inAppNavHost. -
Add HomeScreen navigation entry in
HomeScreen.kt— AddPreferenceitem that navigates to the new route. -
Full build + test verification — Run
./gradlew testDebugUnitTest lintDebug ktlintCheckand verify the build is clean. -
Update memory bank — Update
activeContext.mdandprogress.mdto reflect the new feature and its patterns.