From e8424ad22ac2e4904fc3b91ad78d5b63bd2211cf Mon Sep 17 00:00:00 2001 From: 4di-hello <4di-hello@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:09:04 +0200 Subject: [PATCH 1/2] Text expander: edit all shortcuts as one text, key press placeholders Long templates such as prompts are hard to edit in the small field, and shortcuts can only be moved between devices with a full backup. "Edit all as text" (Text expander screen) opens all shortcuts in one full screen editor, each starting with a line "=== shortcut ===": - copy all, paste all, share, save to and load from a text file - a jump bar with all shortcuts - saving applies exactly the text, after checking for errors (with line numbers) and confirming new, changed and deleted shortcuts - the state before the last save can be restored Prefixes are kept (characters before the first letter, e.g. ";cic"), regex shortcuts are written as "regex: pattern". New placeholders: %tab% inserts a tab character, %tabkey% presses the tab key (next field), %enter% presses enter (the editor action, e.g. send). With key presses, text and keys are sent one after another with a short pause, so the app can move to the next field first. No new settings. The parsing is covered by TextExpanderTextTest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VuiaMhLXwhErf9shhrr1uD --- .../keyboard/latin/inputlogic/InputLogic.kt | 65 ++++ .../keyboard/latin/utils/TextExpanderText.kt | 117 +++++++ .../keyboard/latin/utils/TextExpanderUtils.kt | 3 + .../settings/screens/TextExpanderScreen.kt | 104 +++--- .../screens/TextExpanderTextEditor.kt | 328 ++++++++++++++++++ app/src/main/res/drawable/ic_open_in_full.xml | 12 + app/src/main/res/values-de/strings.xml | 28 ++ app/src/main/res/values/strings.xml | 28 ++ .../latin/utils/TextExpanderTextTest.kt | 78 +++++ 9 files changed, 723 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/helium314/keyboard/latin/utils/TextExpanderText.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/TextExpanderTextEditor.kt create mode 100644 app/src/main/res/drawable/ic_open_in_full.xml create mode 100644 app/src/test/java/helium314/keyboard/latin/utils/TextExpanderTextTest.kt diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt index 921d142f6..954a8f26f 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt @@ -3076,7 +3076,12 @@ class InputLogic( return false } + /** @return whether the cursor was moved back into the expanded text */ private fun commitExpandedText(shortcut: String, expanded: String): Boolean { + if (KEY_PRESS_PLACEHOLDER.containsMatchIn(expanded)) { + commitExpandedTextWithKeys(expanded) + return false + } val cursorOffset = expanded.indexOf("%cursor%") if (cursorOffset != -1) { val finalExpandedText = expanded.replace("%cursor%", "") @@ -3133,8 +3138,68 @@ class InputLogic( } } + /** + * Expanded text with key presses (%enter%, %tabkey%): 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. + */ + private fun commitExpandedTextWithKeys(expanded: String) { + mLastExpandedText = null + mLastShortcutText = null + val steps = ArrayList() + var last = 0 + for (match in KEY_PRESS_PLACEHOLDER.findAll(expanded)) { + if (match.range.first > last) steps.add(expanded.substring(last, match.range.first)) + steps.add(match.value) + last = match.range.last + 1 + } + if (last < expanded.length) steps.add(expanded.substring(last)) + val cleaned = steps.map { if (KEY_PRESS_PLACEHOLDER.matches(it)) it else it.replace(CURSOR_PATTERN.toRegex(), "").replace("%cursor%", "") } + .filter { it.isNotEmpty() } + // the first part goes through the rich connection, so its caches stay correct + if (cleaned.isEmpty()) return + if (KEY_PRESS_PLACEHOLDER.matches(cleaned[0])) runExpansionSteps(cleaned, 0) + else { + mConnection.commitText(cleaned[0], 1) + runExpansionSteps(cleaned, 1) + } + } + + private fun runExpansionSteps(steps: List, index: Int) { + if (index >= steps.size) return + val step = steps[index] + val ic = mLatinIME.currentInputConnection ?: return + when (step) { + ENTER_PLACEHOLDER -> { + 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) + } + } + TAB_KEY_PLACEHOLDER -> sendKeyPress(ic, KeyEvent.KEYCODE_TAB) + else -> ic.commitText(step, 1) + } + val delay = if (step == ENTER_PLACEHOLDER || step == TAB_KEY_PLACEHOLDER) KEY_PRESS_STEP_DELAY_MS else 0L + expansionHandler.postDelayed({ runExpansionSteps(steps, index + 1) }, 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" + const val ENTER_PLACEHOLDER = "%enter%" + const val TAB_KEY_PLACEHOLDER = "%tabkey%" + private val KEY_PRESS_PLACEHOLDER = Regex("%enter%|%tabkey%") + 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 */ diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderText.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderText.kt new file mode 100644 index 000000000..279414d28 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderText.kt @@ -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, val errors: List) + + data class Diff(val added: List, val changed: List, val removed: List) { + 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 = + 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> { + val result = ArrayList>() + 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): Result { + val entries = LinkedHashMap() + val errors = ArrayList() + var headerLine = 0 + var header: String? = null + val body = ArrayList() + + 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, new: Map) = 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) + ) +} diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index 335882e79..f2e14bb13 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -371,6 +371,9 @@ object TextExpanderUtils { fun expand(template: String, context: Context): String { var result = template + // tab character; %tabkey% (key press) and %enter% are handled when committing + if (result.contains("%tab%")) result = result.replace("%tab%", "\t") + // Resolve %date[:modifiers]% if (result.contains("%date")) { result = DATE_REGEX.replace(result) { match -> diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index 4e6056e59..3670f76d8 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -96,6 +96,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { var editingTemplate by remember { mutableStateOf(TextFieldValue("")) } var originalShortcutToEdit by remember { mutableStateOf(null) } var editingIsRegex by remember { mutableStateOf(false) } + var showTextEditor by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxSize()) { SearchScreen( @@ -351,6 +352,14 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp) ) + androidx.compose.material3.OutlinedButton( + onClick = { showTextEditor = true }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(painterResource(R.drawable.ic_open_in_full), null, Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.text_expander_all_as_text)) + } // 4. List of saved shortcuts if (shortcutsMap.isEmpty()) { @@ -450,6 +459,13 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { } } + if (showTextEditor) { + TextExpanderTextEditor { changed -> + showTextEditor = false + if (changed) shortcutsMap = TextExpanderUtils.getShortcuts(context) + } + } + // Add / Edit Shortcut Dialog if (showAddDialog) { val focusRequester = remember { FocusRequester() } @@ -564,52 +580,60 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { modifier = Modifier.padding(top = 4.dp) ) - androidx.compose.foundation.layout.Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val tags = listOf( - "%clipboard%", "%clipboard:clean%", "%clipboard:singleline%", - "%clipboard:title%", "%clipboard:slug%", "%clipboard:upper%", - "%date%", "%date:yyyy/MM/dd%", "%date:dd-MM-yyyy%", "%time%", "%time12%", - "%day%", "%month%", "%year%", "%week%", - "%battery%", "%language%", "%cursor%", "%greeting%", - "%tomorrow%", "%bullets%", "%list%" - ) - tags.forEach { tag -> - Box( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)) - .clickable { - val text = editingTemplate.text - val selection = editingTemplate.selection - val start = selection.start - val end = selection.end - val newText = text.substring(0, start) + tag + text.substring(end) - val newSelectionRange = androidx.compose.ui.text.TextRange(start + tag.length) - editingTemplate = TextFieldValue(text = newText, selection = newSelectionRange) - } - .padding(horizontal = 8.dp, vertical = 6.dp) - ) { - Text( - text = tag, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace - ) - } - } - } + PlaceholderTagsRow(editingTemplate) { editingTemplate = it } } } ) } } +/** Placeholder tags, a tap inserts the tag at the cursor */ +@Composable +internal fun PlaceholderTagsRow(value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit) { + val editingTemplate = value + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val tags = listOf( + "%clipboard%", "%clipboard:clean%", "%clipboard:singleline%", + "%clipboard:title%", "%clipboard:slug%", "%clipboard:upper%", + "%date%", "%date:yyyy/MM/dd%", "%date:dd-MM-yyyy%", "%time%", "%time12%", + "%day%", "%month%", "%year%", "%week%", + "%battery%", "%language%", "%cursor%", "%greeting%", + "%tomorrow%", "%bullets%", "%list%", + "%tab%", "%tabkey%", "%enter%" + ) + tags.forEach { tag -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)) + .clickable { + val text = editingTemplate.text + val selection = editingTemplate.selection + val start = selection.start + val end = selection.end + val newText = text.substring(0, start) + tag + text.substring(end) + val newSelectionRange = androidx.compose.ui.text.TextRange(start + tag.length) + onValueChange(TextFieldValue(text = newText, selection = newSelectionRange)) + } + .padding(horizontal = 8.dp, vertical = 6.dp) + ) { + Text( + text = tag, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + } + } + } +} + @Composable private fun ShortcutItem( shortcut: String, diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderTextEditor.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderTextEditor.kt new file mode 100644 index 000000000..1db7bcbc6 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderTextEditor.kt @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.settings.screens + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.edit +import helium314.keyboard.latin.R +import helium314.keyboard.latin.utils.TextExpanderText +import helium314.keyboard.latin.utils.TextExpanderUtils +import helium314.keyboard.latin.utils.prefs +import kotlinx.coroutines.launch + +/** previous state of the text expander data, kept when saving in the text editor, for restoring it */ +private const val PREF_DATA_PREVIOUS = "pref_text_expander_data_previous" +private const val MAX_FILE_SIZE = 2 * 1024 * 1024 + +/** + * All text expander shortcuts as one text, see [TextExpanderText]. + * [onClose] gets whether the shortcuts were changed. + */ +@Composable +fun TextExpanderTextEditor(onClose: (Boolean) -> Unit) { + val context = LocalContext.current + val prefs = context.prefs() + val scope = rememberCoroutineScope() + val scroll = rememberScrollState() + + var current by remember { mutableStateOf(TextExpanderUtils.getShortcuts(context)) } + var original by remember { mutableStateOf(TextExpanderText.format(current)) } + var value by remember { mutableStateOf(TextFieldValue(original)) } + var layout by remember { mutableStateOf(null) } + var changedSomething by remember { mutableStateOf(false) } + var hasPrevious by remember { mutableStateOf(prefs.contains(PREF_DATA_PREVIOUS)) } + + var errors by remember { mutableStateOf?>(null) } + var pending by remember { mutableStateOf?>(null) } + var askDiscard by remember { mutableStateOf(false) } + var askRestore by remember { mutableStateOf(false) } + + fun close() { if (value.text != original) askDiscard = true else onClose(changedSomething) } + fun replaceText(text: String) { value = TextFieldValue(text.replace("\r\n", "\n")) } + fun reload() { + current = TextExpanderUtils.getShortcuts(context) + original = TextExpanderText.format(current) + value = TextFieldValue(original) + hasPrevious = prefs.contains(PREF_DATA_PREVIOUS) + } + + val saveFile = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(value.text.toByteArray()) } } + .onFailure { toast(context, R.string.text_expander_file_error) } + } + val loadFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.openInputStream(uri)?.use { input -> + val bytes = input.readNBytesCompat(MAX_FILE_SIZE + 1) + require(bytes.size <= MAX_FILE_SIZE) + replaceText(String(bytes)) + } + }.onFailure { toast(context, R.string.text_expander_file_error) } + } + + Dialog(onDismissRequest = ::close, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Column(modifier = Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) { + // title bar + Row(modifier = Modifier.fillMaxWidth().padding(4.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = ::close) { + Icon(painterResource(R.drawable.ic_arrow_back), stringResource(android.R.string.cancel)) + } + Text( + stringResource(R.string.text_expander_all_title), style = MaterialTheme.typography.titleLarge, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) + ) + TextButton(onClick = { + val result = TextExpanderText.parse(value.text, current) + if (result.errors.isNotEmpty()) errors = result.errors + else { + val diff = TextExpanderText.diff(current, result.entries) + if (diff.isEmpty) { toast(context, R.string.text_expander_no_changes); original = value.text } + else pending = result to diff + } + }) { Text(stringResource(R.string.text_expander_save)) } + } + // actions + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton(onClick = { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("LeanType", value.text)) + toast(context, R.string.text_expander_copied) + }) { Text(stringResource(R.string.text_expander_copy_all)) } + OutlinedButton(onClick = { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val text = cm.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString() + if (text.isNullOrEmpty()) toast(context, R.string.text_expander_clipboard_empty) else replaceText(text) + }) { Text(stringResource(R.string.text_expander_paste_all)) } + OutlinedButton(onClick = { + val intent = Intent(Intent.ACTION_SEND).setType("text/plain") + .putExtra(Intent.EXTRA_TEXT, value.text) + .putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.text_expander_all_title)) + context.startActivity(Intent.createChooser(intent, null)) + }) { Text(stringResource(R.string.text_expander_share)) } + OutlinedButton(onClick = { saveFile.launch(context.getString(R.string.text_expander_file_name)) }) { + Text(stringResource(R.string.text_expander_save_file)) + } + OutlinedButton(onClick = { loadFile.launch(arrayOf("text/*")) }) { Text(stringResource(R.string.text_expander_load_file)) } + OutlinedButton(onClick = { askRestore = true }, enabled = hasPrevious) { + Text(stringResource(R.string.text_expander_restore_previous)) + } + } + // jump bar + val headers = remember(value.text) { TextExpanderText.headers(value.text) } + if (headers.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + headers.forEach { (name, offset) -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable { + val l = layout ?: return@clickable + val line = l.getLineForOffset(offset.coerceAtMost(value.text.length)) + val lineEnd = value.text.indexOf('\n', offset).let { if (it < 0) value.text.length else it + 1 } + value = value.copy(selection = TextRange(lineEnd)) + scope.launch { scroll.animateScrollTo(l.getLineTop(line).toInt()) } + } + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Text(name.ifEmpty { "?" }, style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer) + } + } + } + } + // the text + Box(modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp) + .clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))) { + Column(modifier = Modifier.fillMaxSize().verticalScroll(scroll).padding(12.dp)) { + val headerStyle = SpanStyle(color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + BasicTextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + visualTransformation = remember(headerStyle) { HeaderHighlight(headerStyle) }, + onTextLayout = { layout = it }, + ) + } + } + Text( + stringResource(R.string.text_expander_all_hint), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + ) + Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) { + PlaceholderTagsRow(value) { value = it } + } + } + } + } + + errors?.let { list -> + AlertDialog( + onDismissRequest = { errors = null }, + confirmButton = { TextButton(onClick = { errors = null }) { Text(stringResource(android.R.string.ok)) } }, + title = { Text(stringResource(R.string.text_expander_errors_title)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(6.dp)) { + list.forEach { Text(errorText(it)) } + } + } + ) + } + pending?.let { (result, diff) -> + AlertDialog( + onDismissRequest = { pending = null }, + confirmButton = { + TextButton(onClick = { + prefs.edit { putString(PREF_DATA_PREVIOUS, prefs.getString(TextExpanderUtils.PREF_DATA, null) ?: "{}") } + TextExpanderUtils.saveShortcuts(context, result.entries) + changedSomething = true + pending = null + reload() + toast(context, R.string.text_expander_saved) + }) { Text(stringResource(R.string.text_expander_save)) } + }, + dismissButton = { TextButton(onClick = { pending = null }) { Text(stringResource(android.R.string.cancel)) } }, + title = { Text(stringResource(R.string.text_expander_save_question)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.text_expander_changes, diff.added.size, diff.changed.size, diff.removed.size)) + if (diff.removed.isNotEmpty()) + Text(stringResource(R.string.text_expander_changes_deleted, diff.removed.joinToString(", ")), + color = MaterialTheme.colorScheme.error) + } + } + ) + } + if (askRestore) { + AlertDialog( + onDismissRequest = { askRestore = false }, + confirmButton = { + TextButton(onClick = { + val now = prefs.getString(TextExpanderUtils.PREF_DATA, null) ?: "{}" + val previous = prefs.getString(PREF_DATA_PREVIOUS, null) ?: "{}" + prefs.edit { putString(TextExpanderUtils.PREF_DATA, previous); putString(PREF_DATA_PREVIOUS, now) } + changedSomething = true + askRestore = false + reload() + }) { Text(stringResource(R.string.text_expander_restore_previous)) } + }, + dismissButton = { TextButton(onClick = { askRestore = false }) { Text(stringResource(android.R.string.cancel)) } }, + text = { Text(stringResource(R.string.text_expander_restore_previous_confirm)) } + ) + } + if (askDiscard) { + AlertDialog( + onDismissRequest = { askDiscard = false }, + confirmButton = { + TextButton(onClick = { askDiscard = false; onClose(changedSomething) }) { Text(stringResource(R.string.text_expander_discard)) } + }, + dismissButton = { TextButton(onClick = { askDiscard = false }) { Text(stringResource(android.R.string.cancel)) } }, + text = { Text(stringResource(R.string.text_expander_discard_title)) } + ) + } +} + +@Composable +private fun errorText(error: TextExpanderText.Error): String = when (error.kind) { + TextExpanderText.ErrorKind.TEXT_BEFORE_FIRST -> stringResource(R.string.text_expander_error_text_before, error.line) + TextExpanderText.ErrorKind.EMPTY_SHORTCUT -> stringResource(R.string.text_expander_error_empty_shortcut, error.line) + TextExpanderText.ErrorKind.SPACE_IN_SHORTCUT -> stringResource(R.string.text_expander_error_space, error.line, error.name) + TextExpanderText.ErrorKind.EMPTY_TEXT -> stringResource(R.string.text_expander_error_empty_text, error.line, error.name) + TextExpanderText.ErrorKind.DUPLICATE -> stringResource(R.string.text_expander_error_duplicate, error.line, error.name) + TextExpanderText.ErrorKind.INVALID_REGEX -> stringResource(R.string.text_expander_error_regex, error.line, error.name) +} + +/** Shows the "=== shortcut ===" lines highlighted, the text itself is unchanged */ +private class HeaderHighlight(private val style: SpanStyle) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val styled = buildAnnotatedString { + append(text) + TextExpanderText.headers(text.text).forEach { (_, offset) -> + val end = text.text.indexOf('\n', offset).let { if (it < 0) text.length else it } + addStyle(style, offset, end) + } + } + return TransformedText(styled, OffsetMapping.Identity) + } +} + +private fun toast(context: Context, res: Int) = Toast.makeText(context, res, Toast.LENGTH_SHORT).show() + +private fun java.io.InputStream.readNBytesCompat(limit: Int): ByteArray { + val out = java.io.ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (out.size() < limit) { + val read = read(buffer, 0, minOf(buffer.size, limit - out.size())) + if (read < 0) break + out.write(buffer, 0, read) + } + return out.toByteArray() +} diff --git a/app/src/main/res/drawable/ic_open_in_full.xml b/app/src/main/res/drawable/ic_open_in_full.xml new file mode 100644 index 000000000..38cdd6f87 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_full.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 940b9c4f2..2d9d7bce2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -809,6 +809,34 @@ Regex-Muster Regulärer Ausdruck ⚠️ Ungültiges reguläres Ausdrucksmuster + Alle als Text bearbeiten + Alle Textbausteine + Jeder Baustein beginnt mit einer Zeile === Kürzel ===, darunter steht sein Text. Speichern übernimmt genau diesen Text: neue Blöcke werden angelegt, geänderte aktualisiert, entfernte gelöscht. + Alles kopieren + Alles einfügen + Teilen + Als Datei speichern + Aus Datei laden + Textbausteine.txt + Datei konnte nicht gelesen oder geschrieben werden + Letzten Stand + Die Bausteine so wiederherstellen, wie sie vor dem letzten Speichern waren? Der aktuelle Stand wird gemerkt, Du kannst also zurückwechseln. + Kopiert + Zwischenablage ist leer + Keine Änderungen + Gespeichert + Änderungen speichern? + %1$d neu, %2$d geändert, %3$d gelöscht + Gelöscht werden: %s + Bitte korrigieren + Zeile %d: Text vor der ersten Zeile === Kürzel === + Zeile %d: Kürzel fehlt + Zeile %1$d: keine Leerzeichen in „%2$s“ erlaubt + Zeile %1$d: kein Text für „%2$s“ + Zeile %1$d: „%2$s“ kommt doppelt vor + Zeile %1$d: ungültiger Regex „%2$s“ + Deine Änderungen verwerfen? + Verwerfen Vorlagenerweiterung Be right back! oder Meine E-Mail ist %clipboard% Schnelle Platzhalter (zum Einfügen am Cursor tippen): diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a94615445..c6fe2d48b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1783,6 +1783,34 @@ New dictionary: Regex Pattern Regular Expression ⚠️ Invalid regular expression pattern + Edit all as text + All text shortcuts + Each shortcut starts with a line === shortcut ===, followed by its text. Saving applies exactly this text: new blocks are added, changed ones updated, removed ones deleted. + Copy all + Paste all + Share + Save as file + Load from file + text_shortcuts.txt + Could not read or write the file + Restore previous + Restore the shortcuts as they were before the last save? The current state is kept, so you can switch back. + Copied + Clipboard is empty + No changes + Saved + Save changes? + %1$d new, %2$d changed, %3$d deleted + Deleted: %s + Please correct + Line %d: text before the first === shortcut === line + Line %d: shortcut missing + Line %1$d: no spaces allowed in \"%2$s\" + Line %1$d: no text for \"%2$s\" + Line %1$d: \"%2$s\" appears twice + Line %1$d: invalid regex \"%2$s\" + Discard your changes? + Discard Template Expansion Be right back! or My email is %clipboard% Quick Placeholders (tap to insert at cursor): diff --git a/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderTextTest.kt b/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderTextTest.kt new file mode 100644 index 000000000..0ca20f8ed --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderTextTest.kt @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.utils + +import helium314.keyboard.latin.utils.TextExpanderText.ErrorKind +import helium314.keyboard.latin.utils.TextExpanderUtils.ShortcutEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TextExpanderTextTest { + private val sample = linkedMapOf( + ";cic" to ShortcutEntry("Du bist ein Experte.\n\n## Aufgabe\nFasse zusammen:\n%clipboard%", ";"), + "mfg" to ShortcutEntry("Mit freundlichen Grüßen\nMax"), + TextExpanderUtils.REGEX_PREFIX + "\\d+eur" to ShortcutEntry("Euro"), + ) + + @Test fun `format and parse give the same shortcuts`() { + val text = TextExpanderText.format(sample) + assertTrue(text.startsWith("=== ;cic ===\nDu bist")) + assertTrue(text.contains("\n\n=== regex: \\d+eur ===\nEuro\n")) + val result = TextExpanderText.parse(text, sample) + assertEquals(emptyList(), result.errors) + assertEquals(sample, result.entries) + assertTrue(TextExpanderText.diff(sample, result.entries).isEmpty) + } + + @Test fun `empty lines inside a text stay, around it they are removed`() { + val result = TextExpanderText.parse("\n=== a ===\n\nline 1\n\nline 2\n\n\n=== b ===\nx", emptyMap()) + assertEquals("line 1\n\nline 2", result.entries["a"]!!.template) + assertEquals("x", result.entries["b"]!!.template) + } + + @Test fun `prefix is taken from leading non letters for new shortcuts`() { + val result = TextExpanderText.parse("=== ;cic ===\nx\n=== ->x ===\ny\n=== :: ===\nz", emptyMap()) + assertEquals(";", result.entries[";cic"]!!.prefix) + assertEquals("->", result.entries["->x"]!!.prefix) + assertEquals("", result.entries["::"]!!.prefix) + } + + @Test fun `existing text with trailing line break is unchanged`() { + val existing = mapOf("a" to ShortcutEntry("text\n")) + val result = TextExpanderText.parse(TextExpanderText.format(existing), existing) + assertEquals("text\n", result.entries["a"]!!.template) + assertTrue(TextExpanderText.diff(existing, result.entries).isEmpty) + } + + @Test fun `diff lists new, changed and removed shortcuts`() { + val text = "=== ;cic ===\nneu\n=== neu ===\nx" + val diff = TextExpanderText.diff(sample, TextExpanderText.parse(text, sample).entries) + assertEquals(listOf("neu"), diff.added) + assertEquals(listOf(";cic"), diff.changed) + assertEquals(listOf("mfg", "regex: \\d+eur"), diff.removed) + } + + @Test fun `errors are reported with line numbers`() { + val text = "vorher\n=== ===\nx\n=== a b ===\nx\n=== leer ===\n\n=== d ===\n1\n=== d ===\n2\n=== regex: ( ===\nx" + val errors = TextExpanderText.parse(text, emptyMap()).errors + assertEquals(listOf( + TextExpanderText.Error(1, ErrorKind.TEXT_BEFORE_FIRST), + TextExpanderText.Error(2, ErrorKind.EMPTY_SHORTCUT), + TextExpanderText.Error(4, ErrorKind.SPACE_IN_SHORTCUT, "a b"), + TextExpanderText.Error(6, ErrorKind.EMPTY_TEXT, "leer"), + TextExpanderText.Error(10, ErrorKind.DUPLICATE, "d"), + TextExpanderText.Error(12, ErrorKind.INVALID_REGEX, "("), + ), errors) + } + + @Test fun `headers give names and line offsets`() { + val text = "=== a ===\nx\n=== b ===\ny" + assertEquals(listOf("a" to 0, "b" to 12), TextExpanderText.headers(text)) + } + + @Test fun `empty text means no shortcuts`() { + val result = TextExpanderText.parse(" \n", sample) + assertTrue(result.entries.isEmpty()) + assertEquals(3, TextExpanderText.diff(sample, result.entries).removed.size) + } +} From 8fb5b8029f7c6b9d83695cad052af5dd8957ea5d Mon Sep 17 00:00:00 2001 From: 4di-hello <4di-hello@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:23:57 +0200 Subject: [PATCH 2/2] Text expander: key presses only from the template, not from inserted text Key press placeholders (%enter%, %tabkey%) were looked for in the fully expanded text. Text from the clipboard (%clipboard%) containing "%enter%" could therefore press enter, e.g. send a message too early. Now the placeholders are replaced by internal markers in the template, before anything is inserted, and markers are removed from clipboard text. Regex shortcuts are skipped if the text before the cursor contains a marker. The remaining steps of an expansion with key presses are dropped if another app gets the focus in the meantime, or a new expansion starts, so the rest is never typed into another app. Tests in TextExpanderUtilsTest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VuiaMhLXwhErf9shhrr1uD --- .../keyboard/latin/inputlogic/InputLogic.kt | 60 ++++++++++--------- .../keyboard/latin/utils/TextExpanderUtils.kt | 52 ++++++++++++++-- .../latin/utils/TextExpanderUtilsTest.kt | 26 ++++++++ 3 files changed, 104 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt index 954a8f26f..0ed931107 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt @@ -3078,7 +3078,8 @@ class InputLogic( /** @return whether the cursor was moved back into the expanded text */ private fun commitExpandedText(shortcut: String, expanded: String): Boolean { - if (KEY_PRESS_PLACEHOLDER.containsMatchIn(expanded)) { + mKeyExpansionId++ // a new expansion stops the rest of an earlier one with key presses + if (expanded.any(TextExpanderUtils::isKeyPressMarker)) { commitExpandedTextWithKeys(expanded) return false } @@ -3138,39 +3139,45 @@ class InputLogic( } } + /** 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%): 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. + * 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 = ArrayList() - var last = 0 - for (match in KEY_PRESS_PLACEHOLDER.findAll(expanded)) { - if (match.range.first > last) steps.add(expanded.substring(last, match.range.first)) - steps.add(match.value) - last = match.range.last + 1 - } - if (last < expanded.length) steps.add(expanded.substring(last)) - val cleaned = steps.map { if (KEY_PRESS_PLACEHOLDER.matches(it)) it else it.replace(CURSOR_PATTERN.toRegex(), "").replace("%cursor%", "") } + 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 (cleaned.isEmpty()) return - if (KEY_PRESS_PLACEHOLDER.matches(cleaned[0])) runExpansionSteps(cleaned, 0) + if (isKeyPressStep(steps[0])) runExpansionSteps(steps, 0, id, packageName) else { - mConnection.commitText(cleaned[0], 1) - runExpansionSteps(cleaned, 1) + mConnection.commitText(steps[0], 1) + runExpansionSteps(steps, 1, id, packageName) } } - private fun runExpansionSteps(steps: List, index: Int) { - if (index >= steps.size) return - val step = steps[index] + private fun isKeyPressStep(step: String) = step.length == 1 && TextExpanderUtils.isKeyPressMarker(step[0]) + + private fun runExpansionSteps(steps: List, 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 - when (step) { - ENTER_PLACEHOLDER -> { + 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 { @@ -3179,11 +3186,11 @@ class InputLogic( else -> sendKeyPress(ic, KeyEvent.KEYCODE_ENTER) } } - TAB_KEY_PLACEHOLDER -> sendKeyPress(ic, KeyEvent.KEYCODE_TAB) + step[0] == TextExpanderUtils.TAB_KEY_MARKER && isKeyPress -> sendKeyPress(ic, KeyEvent.KEYCODE_TAB) else -> ic.commitText(step, 1) } - val delay = if (step == ENTER_PLACEHOLDER || step == TAB_KEY_PLACEHOLDER) KEY_PRESS_STEP_DELAY_MS else 0L - expansionHandler.postDelayed({ runExpansionSteps(steps, index + 1) }, delay) + 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) { @@ -3196,9 +3203,6 @@ class InputLogic( companion object { private const val TAG = "InputLogic" - const val ENTER_PLACEHOLDER = "%enter%" - const val TAB_KEY_PLACEHOLDER = "%tabkey%" - private val KEY_PRESS_PLACEHOLDER = Regex("%enter%|%tabkey%") private const val KEY_PRESS_STEP_DELAY_MS = 150L private const val INLINE_EMOJI_SEARCH_MARKER = ':' private const val KEY_REPEAT_INTERVAL_MS = 50 diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index f2e14bb13..1af5c1236 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -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 { + val steps = ArrayList() + 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*)\]""", @@ -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) { @@ -371,7 +409,7 @@ object TextExpanderUtils { fun expand(template: String, context: Context): String { var result = template - // tab character; %tabkey% (key press) and %enter% are handled when committing + // tab character; key presses (%enter%, %tabkey%) are already marked, see markKeyPresses if (result.contains("%tab%")) result = result.replace("%tab%", "\t") // Resolve %date[:modifiers]% @@ -540,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) { @@ -556,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 @@ -584,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) } } } @@ -596,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 } diff --git a/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderUtilsTest.kt b/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderUtilsTest.kt index 999f7171f..ebe5264da 100644 --- a/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderUtilsTest.kt +++ b/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderUtilsTest.kt @@ -80,4 +80,30 @@ class TextExpanderUtilsTest { val tokens = TextExpanderUtils.parseModifierTokens("format(yyyy-MM-dd, HH:mm):upper:trim") assertEquals(listOf("format(yyyy-MM-dd, HH:mm)", "upper", "trim"), tokens) } + + private val enter = TextExpanderUtils.ENTER_KEY_MARKER.toString() + private val tab = TextExpanderUtils.TAB_KEY_MARKER.toString() + + @Test + fun testKeyPressStepsInOrder() { + val steps = TextExpanderUtils.splitKeyPresses(TextExpanderUtils.markKeyPresses("user%tabkey%%tabkey%pw%enter%")) + assertEquals(listOf("user", tab, tab, "pw", enter), steps) + } + + @Test + fun testInsertedTextCannotPressKeys() { + // like %clipboard%: the template is marked first, then the text is inserted + val template = TextExpanderUtils.markKeyPresses("Hi %clipboard%%enter%") + val clipboard = TextExpanderUtils.withoutKeyPressMarkers("x %enter% %tabkey% y$enter$tab") + val steps = TextExpanderUtils.splitKeyPresses(template.replace("%clipboard%", clipboard)) + assertEquals(listOf("Hi x %enter% %tabkey% y", enter), steps) + } + + @Test + fun testOnlyVisiblePlaceholdersPressKeys() { + // a marker character in the template itself (e.g. from an imported file) is not a key press + val steps = TextExpanderUtils.splitKeyPresses(TextExpanderUtils.markKeyPresses("a${enter}b")) + assertEquals(listOf("ab"), steps) + assertEquals("plain text", TextExpanderUtils.markKeyPresses("plain text")) + } }