Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3060,6 +3060,11 @@ class InputLogic(
}

private fun commitExpandedText(shortcut: String, expanded: String) {
mKeyExpansionId++ // a new expansion stops the rest of an earlier one with key presses
if (expanded.any(TextExpanderUtils::isKeyPressMarker)) {
commitExpandedTextWithKeys(expanded)
return
}
val cursorOffset = expanded.indexOf("%cursor%")
if (cursorOffset != -1) {
val finalExpandedText = expanded.replace("%cursor%", "")
Expand Down Expand Up @@ -3113,8 +3118,71 @@ class InputLogic(
mLastExpandedCursorPosition = mConnection.expectedSelectionEnd
}

/** Counts expansions: when a new one starts, the rest of an earlier one with key presses is dropped */
private var mKeyExpansionId = 0

/**
* Expanded text with key presses (%enter%, %tabkey%, marked by [TextExpanderUtils.markKeyPresses]):
* text parts and key presses are sent one after another, with a short pause after each key press,
* so the app can e.g. move to the next field first. Cursor placeholders are ignored, and backspace
* can't revert this expansion.
* The steps only go to the app in which the expansion started: if another app gets the focus in
* the meantime, or the connection is gone, the remaining steps are dropped.
*/
private fun commitExpandedTextWithKeys(expanded: String) {
mLastExpandedText = null
mLastShortcutText = null
val steps = TextExpanderUtils.splitKeyPresses(expanded)
.map { if (isKeyPressStep(it)) it else it.replace(CURSOR_PATTERN.toRegex(), "").replace("%cursor%", "") }
.filter { it.isNotEmpty() }
if (steps.isEmpty()) return
val id = mKeyExpansionId
val packageName = mLatinIME.currentInputEditorInfo?.packageName
// the first part goes through the rich connection, so its caches stay correct
if (isKeyPressStep(steps[0])) runExpansionSteps(steps, 0, id, packageName)
else {
mConnection.commitText(steps[0], 1)
runExpansionSteps(steps, 1, id, packageName)
}
}

private fun isKeyPressStep(step: String) = step.length == 1 && TextExpanderUtils.isKeyPressMarker(step[0])

private fun runExpansionSteps(steps: List<String>, index: Int, id: Int, packageName: String?) {
if (index >= steps.size || id != mKeyExpansionId) return
// never type the rest into another app
if (mLatinIME.currentInputEditorInfo?.packageName != packageName) return
val ic = mLatinIME.currentInputConnection ?: return
val step = steps[index]
val isKeyPress = isKeyPressStep(step)
when {
step[0] == TextExpanderUtils.ENTER_KEY_MARKER && isKeyPress -> {
val editorInfo: EditorInfo? = mLatinIME.currentInputEditorInfo
val actionId = editorInfo?.let { InputTypeUtils.getImeOptionsActionIdFromEditorInfo(it) } ?: EditorInfo.IME_ACTION_NONE
when {
editorInfo != null && actionId == InputTypeUtils.IME_ACTION_CUSTOM_LABEL -> ic.performEditorAction(editorInfo.actionId)
actionId != EditorInfo.IME_ACTION_NONE -> ic.performEditorAction(actionId)
else -> sendKeyPress(ic, KeyEvent.KEYCODE_ENTER)
}
}
step[0] == TextExpanderUtils.TAB_KEY_MARKER && isKeyPress -> sendKeyPress(ic, KeyEvent.KEYCODE_TAB)
else -> ic.commitText(step, 1)
}
val delay = if (isKeyPress) KEY_PRESS_STEP_DELAY_MS else 0L
expansionHandler.postDelayed({ runExpansionSteps(steps, index + 1, id, packageName) }, delay)
}

private fun sendKeyPress(ic: android.view.inputmethod.InputConnection, keyCode: Int) {
val time = SystemClock.uptimeMillis()
ic.sendKeyEvent(KeyEvent(time, time, KeyEvent.ACTION_DOWN, keyCode, 0))
ic.sendKeyEvent(KeyEvent(time, time, KeyEvent.ACTION_UP, keyCode, 0))
}

private val expansionHandler by lazy { android.os.Handler(android.os.Looper.getMainLooper()) }

companion object {
private const val TAG = "InputLogic"
private const val KEY_PRESS_STEP_DELAY_MS = 150L
private const val INLINE_EMOJI_SEARCH_MARKER = ':'
private const val KEY_REPEAT_INTERVAL_MS = 50
/** interval before the second word, it gets shorter with each word until the minimum */
Expand Down
117 changes: 117 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/utils/TextExpanderText.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.latin.utils

import helium314.keyboard.latin.utils.TextExpanderUtils.ShortcutEntry

/**
* All text expander shortcuts as one plain text, for editing them together, copying and sharing.
*
* Each shortcut starts with a header line `=== shortcut ===`, followed by its text:
* ```
* === ;cic ===
* long prompt …
*
* === regex: \d+eur ===
* …
* ```
* Characters before the first letter or digit of a shortcut are its prefix (`;` above).
* Empty lines around a text are only for readability and are removed.
*/
object TextExpanderText {
private const val REGEX_LABEL = "regex:"
private val HEADER = Regex("""^===\s*(.*?)\s*===\s*$""")

enum class ErrorKind { TEXT_BEFORE_FIRST, EMPTY_SHORTCUT, SPACE_IN_SHORTCUT, EMPTY_TEXT, DUPLICATE, INVALID_REGEX }

/** [line] is 1-based */
data class Error(val line: Int, val kind: ErrorKind, val name: String = "")

class Result(val entries: LinkedHashMap<String, ShortcutEntry>, val errors: List<Error>)

data class Diff(val added: List<String>, val changed: List<String>, val removed: List<String>) {
val isEmpty get() = added.isEmpty() && changed.isEmpty() && removed.isEmpty()
}

/** Header name of a stored key, e.g. ";cic" or "regex: \d+eur" */
fun displayName(key: String): String =
if (key.startsWith(TextExpanderUtils.REGEX_PREFIX)) "$REGEX_LABEL ${key.removePrefix(TextExpanderUtils.REGEX_PREFIX)}"
else key

fun format(shortcuts: Map<String, ShortcutEntry>): String =
shortcuts.entries.joinToString("") { (key, entry) -> "=== ${displayName(key)} ===\n${entry.template.trimEnd('\n')}\n\n" }
.trimEnd('\n').let { if (it.isEmpty()) it else it + "\n" }

/** Header names with the offset of their line in [text], for jumping to a shortcut */
fun headers(text: String): List<Pair<String, Int>> {
val result = ArrayList<Pair<String, Int>>()
var offset = 0
for (line in text.split('\n')) {
HEADER.matchEntire(line)?.let { result.add(it.groupValues[1] to offset) }
offset += line.length + 1
}
return result
}

/**
* Shortcuts in [text]. [existing] shortcuts keep their prefix split and their exact text
* (e.g. a trailing line break) if the text is otherwise unchanged.
*/
fun parse(text: String, existing: Map<String, ShortcutEntry>): Result {
val entries = LinkedHashMap<String, ShortcutEntry>()
val errors = ArrayList<Error>()
var headerLine = 0
var header: String? = null
val body = ArrayList<String>()

fun finishBlock() {
val name = header ?: return
val template = body.dropWhile { it.isBlank() }.dropLastWhile { it.isBlank() }.joinToString("\n")
if (name.isEmpty()) { errors.add(Error(headerLine, ErrorKind.EMPTY_SHORTCUT)); return }
val isRegex = name.startsWith(REGEX_LABEL, ignoreCase = true)
val key: String
val prefix: String
if (isRegex) {
val pattern = name.substring(REGEX_LABEL.length).trim()
if (pattern.isEmpty()) { errors.add(Error(headerLine, ErrorKind.EMPTY_SHORTCUT, name)); return }
key = TextExpanderUtils.REGEX_PREFIX + pattern
prefix = existing[key]?.prefix ?: ""
if (runCatching { Regex(pattern.removePrefix(prefix)) }.isFailure) {
errors.add(Error(headerLine, ErrorKind.INVALID_REGEX, pattern)); return
}
} else {
if (name.any { it.isWhitespace() }) { errors.add(Error(headerLine, ErrorKind.SPACE_IN_SHORTCUT, name)); return }
key = name
val leading = name.takeWhile { !it.isLetterOrDigit() }
prefix = existing[key]?.prefix ?: if (leading.length < name.length) leading else ""
}
if (template.isEmpty()) { errors.add(Error(headerLine, ErrorKind.EMPTY_TEXT, name)); return }
if (entries.containsKey(key)) { errors.add(Error(headerLine, ErrorKind.DUPLICATE, name)); return }
val old = existing[key]
entries[key] = if (old != null && old.prefix == prefix && old.template.trimEnd('\n') == template) old
else ShortcutEntry(template, prefix)
}

text.replace("\r\n", "\n").split('\n').forEachIndexed { index, line ->
val match = HEADER.matchEntire(line)
if (match != null) {
finishBlock()
header = match.groupValues[1]
headerLine = index + 1
body.clear()
} else if (header == null) {
if (line.isNotBlank() && errors.none { it.kind == ErrorKind.TEXT_BEFORE_FIRST })
errors.add(Error(index + 1, ErrorKind.TEXT_BEFORE_FIRST))
} else {
body.add(line)
}
}
finishBlock()
return Result(entries, errors)
}

fun diff(old: Map<String, ShortcutEntry>, new: Map<String, ShortcutEntry>) = Diff(
added = new.keys.filter { it !in old }.map(::displayName),
changed = new.filter { (key, entry) -> old[key].let { it != null && it != entry } }.keys.map(::displayName),
removed = old.keys.filter { it !in new }.map(::displayName)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,44 @@ object TextExpanderUtils {
const val PREF_DATA = "pref_text_expander_data"
const val REGEX_PREFIX = "__regex__:"

const val ENTER_PLACEHOLDER = "%enter%"
const val TAB_KEY_PLACEHOLDER = "%tabkey%"
/**
* Key presses in expanded text are marked with these Unicode noncharacters, which are reserved
* for internal use and don't occur in normal text. The placeholders are replaced by them in the
* template, before any text is inserted, so inserted text (e.g. from the clipboard) can never
* press keys.
*/
const val ENTER_KEY_MARKER = '\uFDD0'
const val TAB_KEY_MARKER = '\uFDD1'

fun isKeyPressMarker(c: Char) = c == ENTER_KEY_MARKER || c == TAB_KEY_MARKER

/** [template] with the key press placeholders replaced by markers; only visible placeholders press keys */
fun markKeyPresses(template: String): String =
withoutKeyPressMarkers(template)
.replace(ENTER_PLACEHOLDER, ENTER_KEY_MARKER.toString())
.replace(TAB_KEY_PLACEHOLDER, TAB_KEY_MARKER.toString())

/** [text] without key press markers, for text that is inserted into an expansion */
fun withoutKeyPressMarkers(text: String): String =
if (text.any(::isKeyPressMarker)) text.filterNot(::isKeyPressMarker) else text

/** Expanded text split into text parts and key press markers (one per step), in order */
fun splitKeyPresses(expanded: String): List<String> {
val steps = ArrayList<String>()
val part = StringBuilder()
for (c in expanded) {
if (isKeyPressMarker(c)) {
if (part.isNotEmpty()) steps.add(part.toString())
part.clear()
steps.add(c.toString())
} else part.append(c)
}
if (part.isNotEmpty()) steps.add(part.toString())
return steps
}

// Pre-compiled regexes for performance
private val CITATION_REGEX = Regex(
"""\[(?:\s*\d+(?:\s*[,;\-–—]\s*\d+)*\s*|\s*note\s*\d+\s*|\s*citation\s+needed\s*|\s*edit\s*|\s*source\s*)\]""",
Expand Down Expand Up @@ -360,7 +398,7 @@ object TextExpanderUtils {
return try {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
if (clipboard?.hasPrimaryClip() == true) {
val rawText = clipboard.primaryClip?.getItemAt(0)?.text?.toString() ?: ""
val rawText = withoutKeyPressMarkers(clipboard.primaryClip?.getItemAt(0)?.text?.toString() ?: "")
if (rawText.length > 5000) rawText.substring(0, 5000) else rawText
} else ""
} catch (_: Exception) {
Expand All @@ -371,6 +409,9 @@ object TextExpanderUtils {
fun expand(template: String, context: Context): String {
var result = template

// tab character; key presses (%enter%, %tabkey%) are already marked, see markKeyPresses
if (result.contains("%tab%")) result = result.replace("%tab%", "\t")

// Resolve %date[:modifiers]%
if (result.contains("%date")) {
result = DATE_REGEX.replace(result) { match ->
Expand Down Expand Up @@ -537,11 +578,13 @@ object TextExpanderUtils {
val entry = item.entry
if (item.isRegex) {
val regex = item.regex ?: continue
// text from the input field must not press keys when it is inserted by the regex
if (fullText.any(::isKeyPressMarker)) continue
val prefix = entry.prefix
if (word != null) {
if (regex.matches(fullText)) {
try {
val replaced = regex.replace(fullText, entry.template)
val replaced = regex.replace(fullText, markKeyPresses(entry.template))
val prefixLength = if (fullText.length > word.length) fullText.length - word.length else prefix.length
return ExpandedResult(expand(replaced, context), prefixLength, fullText)
} catch (e: java.lang.Exception) {
Expand All @@ -553,7 +596,7 @@ object TextExpanderUtils {
val match = regex.findAll(fullText).lastOrNull { it.range.last == fullText.length - 1 }
if (match != null && match.value.isNotEmpty()) {
val matchedString = match.value
val replaced = regex.replace(matchedString, entry.template)
val replaced = regex.replace(matchedString, markKeyPresses(entry.template))
val extraPrefix = if (word != null && matchedString.endsWith(word, ignoreCase = true)) {
matchedString.length - word.length
} else 0
Expand Down Expand Up @@ -581,7 +624,7 @@ object TextExpanderUtils {
} else {
prefix.length
}
return ExpandedResult(expand(entry.template, context), effectivePrefixLength, expectedSuffix)
return ExpandedResult(expand(markKeyPresses(entry.template), context), effectivePrefixLength, expectedSuffix)
}
}
}
Expand All @@ -593,7 +636,7 @@ object TextExpanderUtils {
val shortcuts = getShortcuts(context)
val entry = shortcuts[word] ?: shortcuts[word.lowercase(Locale.getDefault())]
if (entry != null && entry.prefix.isEmpty()) {
return expand(entry.template, context)
return expand(markKeyPresses(entry.template), context)
}
return null
}
Expand Down
Loading
Loading