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 5a402b712..96da68840 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.kt @@ -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%", "") @@ -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, 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 */ 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..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,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 -> @@ -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) { @@ -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 @@ -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) } } } @@ -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 } 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 4099a3bbe..36474f065 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -811,6 +811,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 9f23b6daf..4c873a1b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1776,6 +1776,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) + } +} 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")) + } }