diff --git a/app/src/main/java/org/jyutping/jyutping/JyutpingInputMethodService.kt b/app/src/main/java/org/jyutping/jyutping/JyutpingInputMethodService.kt index ed61670..be934bf 100644 --- a/app/src/main/java/org/jyutping/jyutping/JyutpingInputMethodService.kt +++ b/app/src/main/java/org/jyutping/jyutping/JyutpingInputMethodService.kt @@ -21,7 +21,9 @@ import org.jyutping.jyutping.extensions.empty import org.jyutping.jyutping.extensions.isReverseLookupTrigger import org.jyutping.jyutping.extensions.keyboardLightBackground import org.jyutping.jyutping.extensions.separator +import org.jyutping.jyutping.extensions.separatorChar import org.jyutping.jyutping.extensions.space +import org.jyutping.jyutping.extensions.toneConverted import org.jyutping.jyutping.keyboard.Candidate import org.jyutping.jyutping.keyboard.Engine import org.jyutping.jyutping.keyboard.InputMethodMode @@ -193,11 +195,12 @@ class JyutpingInputMethodService: LifecycleInputMethodService(), } } else -> { - val segmentation = Segmentor.segment(value, db) - val suggestions = Engine.suggest(text = value, segmentation = segmentation, db = db) + val processingText: String = value.toneConverted() + val segmentation = Segmentor.segment(processingText, db) + val suggestions = Engine.suggest(text = processingText, segmentation = segmentation, db = db) val mark: String = run { val firstCandidate = suggestions.firstOrNull() - if (firstCandidate != null && firstCandidate.input.length == value.length) firstCandidate.mark else value + if (firstCandidate != null && firstCandidate.input.length == processingText.length) firstCandidate.mark else processingText } currentInputConnection.setComposingText(mark, mark.length) candidates.value = suggestions.map { it.transformed(characterStandard.value) }.distinct() @@ -243,7 +246,12 @@ class JyutpingInputMethodService: LifecycleInputMethodService(), } } else -> { - bufferText = bufferText.drop(candidate.input.length) + val inputLength: Int = candidate.input.replace(Regex("([456])"), "RR").length + var tail = bufferText.drop(inputLength) + while (tail.startsWith(Char.separatorChar)) { + tail = tail.drop(1) + } + bufferText = tail } } } diff --git a/app/src/main/java/org/jyutping/jyutping/extensions/CharExtensions.kt b/app/src/main/java/org/jyutping/jyutping/extensions/CharExtensions.kt index 94c8c68..4f63c50 100644 --- a/app/src/main/java/org/jyutping/jyutping/extensions/CharExtensions.kt +++ b/app/src/main/java/org/jyutping/jyutping/extensions/CharExtensions.kt @@ -1,5 +1,10 @@ package org.jyutping.jyutping.extensions +val Char.Companion.separatorChar: Char + get() = '\'' + +fun Char.isSeparatorChar(): Boolean = this == Char.separatorChar + fun Char.isReverseLookupTrigger(): Boolean = reverseLookupTriggers.contains(this) private val reverseLookupTriggers: Set = setOf('r', 'v', 'x', 'q') diff --git a/app/src/main/java/org/jyutping/jyutping/extensions/StringExtensions.kt b/app/src/main/java/org/jyutping/jyutping/extensions/StringExtensions.kt index 23fe494..79cecdf 100644 --- a/app/src/main/java/org/jyutping/jyutping/extensions/StringExtensions.kt +++ b/app/src/main/java/org/jyutping/jyutping/extensions/StringExtensions.kt @@ -13,3 +13,15 @@ val String.Companion.space: String val String.Companion.separator: String get() = "'" + +/** + * Convert v/x/q to the tone digits + * @return Converted text with digital tones + */ +fun String.toneConverted(): String = this + .replace("vv", "4") + .replace("xx", "5") + .replace("qq", "6") + .replace('v', '1') + .replace('x', '2') + .replace('q', '3') diff --git a/app/src/main/java/org/jyutping/jyutping/keyboard/Engine.kt b/app/src/main/java/org/jyutping/jyutping/keyboard/Engine.kt index 557d4ab..7f068e7 100644 --- a/app/src/main/java/org/jyutping/jyutping/keyboard/Engine.kt +++ b/app/src/main/java/org/jyutping/jyutping/keyboard/Engine.kt @@ -1,16 +1,205 @@ package org.jyutping.jyutping.keyboard import org.jyutping.jyutping.extensions.empty +import org.jyutping.jyutping.extensions.isSeparatorChar +import org.jyutping.jyutping.extensions.separatorChar import org.jyutping.jyutping.extensions.space import org.jyutping.jyutping.utilities.DatabaseHelper object Engine { fun suggest(text: String, segmentation: Segmentation, db: DatabaseHelper): List { - if (segmentation.maxSchemeLength() < 1) { - return processVerbatim(text, db) - } else { - return process(text, segmentation, db) + return when (text.length) { + 0 -> emptyList() + 1 -> when (text) { + "a" -> db.match(text, input = text) + db.match(text = "aa", input = text) + db.shortcut(text) + "o", "m", "e" -> db.match(text = text, input = text) + db.shortcut(text) + else -> db.shortcut(text) + } + else -> dispatch(text, segmentation, db) + } + } + private fun dispatch(text: String, segmentation: Segmentation, db: DatabaseHelper): List { + val hasSeparators: Boolean = text.contains(Char.separatorChar) + val hasTones: Boolean = text.contains(Regex("[1-6]")) + return when { + hasSeparators && hasTones -> { + val syllable = text.filter { it.isLetter() } + db.match(text = syllable, input = text, mark = text).filter { text.startsWith(it.romanization) } + } + !hasSeparators && hasTones -> processWithTones(text, segmentation, db) + hasSeparators && !hasTones -> processWithSeparators(text, segmentation, db) + else -> { + if (segmentation.maxSchemeLength() < 1) { + processVerbatim(text, db) + } else { + process(text, segmentation, db) + } + } + } + } + private fun processWithTones(text: String, segmentation: Segmentation, db: DatabaseHelper): List { + val textTones = text.filter { it.isDigit() } + val textToneCount = textTones.length + val rawText: String = text.filterNot { it.isDigit() } + val candidates= search(rawText, segmentation, db) + val qualified: MutableList = mutableListOf() + for (item in candidates) { + val continuous = item.romanization.filterNot { it.isWhitespace() } + val continuousTones = continuous.filter { it.isDigit() } + val continuousToneCount = continuousTones.length + when { + textToneCount == 1 && continuousToneCount == 1 -> { + if (textTones != continuousTones) continue + val isCorrectPosition: Boolean = text.drop(item.input.length).firstOrNull()?.isDigit() ?: false + if (!isCorrectPosition) continue + val combinedInput = item.input + textTones + val newItem = Candidate(text = item.text, romanization = item.romanization, input = combinedInput) + qualified.add(newItem) + } + textToneCount == 1 && continuousToneCount == 2 -> { + val isToneLast: Boolean = text.lastOrNull()?.isDigit() ?: false + if (isToneLast) { + if (!(continuousTones.endsWith(textTones))) continue + val isCorrectPosition: Boolean = text.drop(item.input.length).firstOrNull()?.isDigit() ?: false + if (!isCorrectPosition) continue + val newItem = Candidate(text = item.text, romanization = item.romanization, input = text) + qualified.add(newItem) + } else { + if (!(continuousTones.startsWith(textTones))) continue + val combinedInput = item.input + textTones + val newItem = Candidate(text = item.text, romanization = item.romanization, input = combinedInput) + qualified.add(newItem) + } + } + textToneCount == 2 && continuousToneCount == 1 -> { + if (!(textTones.startsWith(continuousTones))) continue + val isCorrectPosition: Boolean = text.drop(item.input.length).firstOrNull()?.isDigit() ?: false + if (!isCorrectPosition) continue + val combinedInput = item.input + continuousTones + val newItem = Candidate(text = item.text, romanization = item.romanization, input = combinedInput) + qualified.add(newItem) + } + textToneCount == 2 && continuousToneCount == 2 -> { + if (textTones != continuousTones) continue + val isLastTone: Boolean = text.lastOrNull()?.isDigit() ?: false + if (isLastTone) { + if (item.input.length != (text.length - 2)) continue + val newItem = Candidate(text = item.text, romanization = item.romanization, input = text, mark = text) + qualified.add(newItem) + } else { + val tail = text.drop(item.input.length + 1) + val isCorrectPosition: Boolean = tail.firstOrNull() == textTones.lastOrNull() + if (!isCorrectPosition) continue + val combinedInput = item.input + textTones + val newItem = Candidate(text = item.text, romanization = item.romanization, input = combinedInput, mark = item.input) + qualified.add(newItem) + } + } + else -> { + if (continuous.startsWith(text)) { + val newItem = Candidate(text = item.text, romanization = item.romanization, input = text) + qualified.add(newItem) + } else if (text.startsWith(continuous)) { + val newItem = Candidate(text = item.text, romanization = item.romanization, input = continuous, mark = item.input) + qualified.add(newItem) + } else { + continue + } + } + } } + return qualified + } + private fun processWithSeparators(text: String, segmentation: Segmentation, db: DatabaseHelper): List { + val separatorCount = text.count { it.isSeparatorChar() } + val textParts = text.split(Char.separatorChar).filter { it.isNotEmpty() } + val isHeadingSeparator: Boolean = text.firstOrNull()?.isSeparatorChar() ?: false + val isTrailingSeparator: Boolean = text.lastOrNull()?.isSeparatorChar() ?: false + val rawText = text.filter { !(it.isSeparatorChar()) } + val candidates = search(rawText, segmentation, db) + val qualified: MutableList = mutableListOf() + for (item in candidates) { + val syllables = item.romanization.filterNot { it.isDigit() }.split(' ') + if (syllables == textParts) { + val newItem = Candidate(text = item.text, romanization = item.romanization, input = text) + qualified.add(newItem) + continue + } + if (isHeadingSeparator) continue + when { + separatorCount == 1 && isTrailingSeparator -> { + if (syllables.size != 1) continue + val isLengthNotMatched: Boolean = item.input.length != (text.length - 1) + if (isLengthNotMatched) continue + val newItem = Candidate(text = item.text, romanization = item.romanization, input = text, mark = text) + qualified.add(newItem) + } + separatorCount == 1 -> { + when (syllables.size) { + 1 -> { + if (item.input != textParts.firstOrNull()) continue + val combinedInput: String = item.input + Char.separatorChar + val newItem = Candidate(text = item.text, romanization = item.romanization, input = combinedInput) + qualified.add(newItem) + } + 2 -> { + if (syllables.firstOrNull() != textParts.firstOrNull()) continue + val combinedInput: String = item.input + Char.separatorChar + val newItem = Candidate(text = item.text, romanization = item.romanization, input = combinedInput) + qualified.add(newItem) + } + else -> continue + } + } + separatorCount == 2 && isTrailingSeparator -> { + when (syllables.size) { + 1 -> { + if (item.input != textParts.firstOrNull()) continue + val combinedInput: String = item.input + Char.separatorChar + val newItem = Candidate(text = item.text, romanization = item.romanization, input = combinedInput) + qualified.add(newItem) + } + 2 -> { + val isLengthNotMatched: Boolean = item.input.length != (text.length - 2) + if (isLengthNotMatched) continue + if (syllables.firstOrNull() != textParts.firstOrNull()) continue + val newItem = Candidate(text = item.text, romanization = item.romanization, input = text) + qualified.add(newItem) + } + else -> continue + } + } + else -> { + if (syllables.size >= textParts.size) continue + val checks = syllables.indices.map { syllables[it] == textParts[it] } + val isMatched = checks.reduce { acc, b -> acc && b } + if (!isMatched) continue + val tail = List(syllables.size - 1) { 'i' } + val combinedInput = item.input + tail + val newItem = Candidate(text = item.text, romanization = item.romanization, input = combinedInput) + qualified.add(newItem) + } + } + } + if (qualified.isNotEmpty()) return qualified + val anchors = textParts.mapNotNull { it.firstOrNull() } + return db.shortcut(anchors.toString()) + .filter { item -> + val syllables = item.romanization.filterNot { it.isDigit() }.split(' ') + if (syllables.size != anchors.size) { + false + } else { + val checks = anchors.indices.map { index -> + val part = textParts[index] + val isAnchorOnly = (part.length == 1) + if (isAnchorOnly) syllables[index].startsWith(part) else syllables[index] == part + } + checks.reduce { acc, b -> acc && b } + } + } + .map { + Candidate(text = it.text, romanization = it.romanization, input = text) + } } private fun processVerbatim(text: String, db: DatabaseHelper, limit: Int? = null): List { val rounds: MutableList> = mutableListOf() diff --git a/app/src/main/java/org/jyutping/jyutping/keyboard/Segmentor.kt b/app/src/main/java/org/jyutping/jyutping/keyboard/Segmentor.kt index 69bc249..a9d03f5 100644 --- a/app/src/main/java/org/jyutping/jyutping/keyboard/Segmentor.kt +++ b/app/src/main/java/org/jyutping/jyutping/keyboard/Segmentor.kt @@ -36,34 +36,32 @@ private fun SegmentScheme.isValid(): Boolean { } private fun Segmentation.descended(): Segmentation = this.sortedWith(compareBy({-it.length()}, {it.size})) -/* -private fun Segmentation.descended(): Segmentation = run { - val comparator = Comparator { lhs, rhs -> - val lengthComparison = lhs.length().compareTo(rhs.length()) - if (lengthComparison != 0) return@Comparator -lengthComparison - val elementSizeComparison = lhs.size.compareTo(rhs.size) - return@Comparator elementSizeComparison - } - return this.sortedWith(comparator) -} -*/ object Segmentor { fun segment(text: String, db: DatabaseHelper): Segmentation { - return when (text.length) { - 0 -> emptyList() - 1 -> when (text) { + val textLength = text.length + return when { + textLength == 0 -> emptyList() + textLength == 1 -> when (text) { "a" -> letterA "o" -> letterO "m" -> letterM else -> emptyList() } - 4 -> when (text) { - "mama" -> mama - "mami" -> mami - else -> split(text, db) + textLength == 4 && text == "mama" -> mama + textLength == 4 && text == "mami" -> mami + else -> { + val rawText: String = text.filter { it.isLetter() } + val key: Int = rawText.hashCode() + val cached = cachedSegmentations[key] + if (cached != null) { + cached + } else { + val segmented = split(rawText, db) + cache(key, segmented) + segmented + } } - else -> split(text, db) } } private fun split(text: String, db: DatabaseHelper): Segmentation { @@ -104,4 +102,13 @@ object Segmentor { private val letterM: Segmentation = listOf(listOf(SegmentToken(text = "m", origin = "m"))) private val mama: Segmentation = listOf(listOf(SegmentToken(text = "ma", origin = "maa"), SegmentToken(text = "ma", origin = "maa"))) private val mami: Segmentation = listOf(listOf(SegmentToken(text = "ma", origin = "maa"), SegmentToken(text = "mi", origin = "mi"))) + + private const val maxCachedCount: Int = 5000 + private val cachedSegmentations: HashMap = hashMapOf() + private fun cache(key: Int, segmentation: Segmentation) { + if (cachedSegmentations.size > maxCachedCount) { + cachedSegmentations.clear() + } + cachedSegmentations[key] = segmentation + } } diff --git a/app/src/main/java/org/jyutping/jyutping/ui/common/TextCard.kt b/app/src/main/java/org/jyutping/jyutping/ui/common/TextCard.kt index 6111f76..cb36f6e 100644 --- a/app/src/main/java/org/jyutping/jyutping/ui/common/TextCard.kt +++ b/app/src/main/java/org/jyutping/jyutping/ui/common/TextCard.kt @@ -3,7 +3,6 @@ package org.jyutping.jyutping.ui.common import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape @@ -21,8 +20,8 @@ fun TextCard( heading: String, content: String, subContent: String? = null, - shouldContentMonospaced: Boolean = false, - shouldSubContentMonospaced: Boolean = false + shouldMonospaceContent: Boolean = false, + shouldMonospaceSubContent: Boolean = false ) { Column( modifier = Modifier @@ -33,13 +32,13 @@ fun TextCard( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text(text = heading, fontWeight = FontWeight.Medium) - if (shouldContentMonospaced) { + if (shouldMonospaceContent) { Text(text = content, fontFamily = FontFamily.Monospace) } else { Text(text = content) } if (subContent != null) { - if (shouldSubContentMonospaced) { + if (shouldMonospaceSubContent) { Text(text = subContent, fontFamily = FontFamily.Monospace) } else { Text(text = subContent) diff --git a/app/src/main/java/org/jyutping/jyutping/ui/home/HomeScreen.kt b/app/src/main/java/org/jyutping/jyutping/ui/home/HomeScreen.kt index fd792a2..f1b534d 100644 --- a/app/src/main/java/org/jyutping/jyutping/ui/home/HomeScreen.kt +++ b/app/src/main/java/org/jyutping/jyutping/ui/home/HomeScreen.kt @@ -114,16 +114,14 @@ fun HomeScreen(navController: NavHostController) { GwongWanView(gwongWanEntries.value) } } - /* item { TextCard( heading = stringResource(id = R.string.home_heading_tone_input), content = stringResource(id = R.string.home_content_tones_input), subContent = stringResource(id = R.string.home_subcontent_tones_input_examples), - shouldContentMonospaced = true + shouldMonospaceContent = true ) } - */ item { TextCard( heading = stringResource(id = R.string.home_heading_pinyin_reverse_lookup), @@ -147,7 +145,7 @@ fun HomeScreen(navController: NavHostController) { TextCard( heading = stringResource(id = R.string.home_heading_stroke_code), content = "w = 橫(waang)\ns = 豎(syu)\na = 撇\nd = 點(dim)\nz = 折(zit)", - shouldContentMonospaced = true + shouldMonospaceContent = true ) } */