From 175675b9b901a0d5ee3247ff08964035a3feec91 Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Tue, 5 May 2026 19:24:43 -0400 Subject: [PATCH 1/6] first glide input --- .../converter/engine/EnglishEngine.kt | 98 +++++ .../glide/QwertyGlideCandidateReranker.kt | 24 ++ .../glide/QwertyGlideDecodeOptions.kt | 16 + .../converter/glide/QwertyGlideDecoder.kt | 65 +++ .../glide/QwertyGlideDictionaryProvider.kt | 40 ++ .../glide/QwertyGlideKeyProbabilityBuilder.kt | 54 +++ .../glide/QwertyGlideStrokeNormalizer.kt | 127 ++++++ .../glide/QwertyGlideWordPathScorer.kt | 178 ++++++++ .../ime_service/IMEService.kt | 113 ++++++ .../ime_service/ImePreferencesSnapshot.kt | 2 + .../QwertyGlideInputCoordinator.kt | 96 +++++ .../QwertyGlideInputModeResolver.kt | 13 + .../setting_activity/AppPreference.kt | 12 + app/src/main/res/values-ja/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/pref_qwerty.xml | 6 + .../glide/QwertyGlideDecoderAccuracyTest.kt | 109 +++++ .../SyntheticQwertyGlideStrokeFactory.kt | 140 +++++++ .../QwertyGlideInputModeResolverTest.kt | 81 ++++ .../KeyboardRepositorySaveLayoutTest.kt | 12 +- .../glide/QwertyGlideGesturePolicy.kt | 19 + .../glide/QwertyGlideModels.kt | 47 +++ .../qwerty_keyboard/ui/QWERTYKeyboardView.kt | 379 ++++++++++++++++++ .../glide/QwertyGlideGesturePolicyTest.kt | 67 ++++ 24 files changed, 1696 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateReranker.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeOptions.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoder.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDictionaryProvider.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideKeyProbabilityBuilder.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideStrokeNormalizer.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWordPathScorer.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputModeResolver.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoderAccuracyTest.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/SyntheticQwertyGlideStrokeFactory.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputModeResolverTest.kt create mode 100644 qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideGesturePolicy.kt create mode 100644 qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideModels.kt create mode 100644 qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideGesturePolicyTest.kt diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt index cb859ae62..5717410e9 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt @@ -5,6 +5,12 @@ import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate import com.kazumaproject.markdownhelperkeyboard.converter.english.louds.LOUDS import com.kazumaproject.markdownhelperkeyboard.converter.english.louds.louds_with_term_id.LOUDSWithTermId import com.kazumaproject.markdownhelperkeyboard.converter.english.tokenArray.TokenArray +import com.kazumaproject.markdownhelperkeyboard.converter.glide.InMemoryQwertyGlideDictionaryProvider +import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideDecodeOptions +import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideDecoder +import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideDictionaryEntry +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo import timber.log.Timber class EnglishEngine { @@ -15,6 +21,8 @@ class EnglishEngine { private lateinit var succinctBitVectorReadingIsLeaf: SuccinctBitVector private lateinit var succinctBitVectorTokenArray: SuccinctBitVector private lateinit var succinctBitVectorLBSWord: SuccinctBitVector + @Volatile + private var qwertyGlideDecoder: QwertyGlideDecoder? = null companion object { const val LENGTH_MULTIPLY = 2000 @@ -38,6 +46,85 @@ class EnglishEngine { this.succinctBitVectorTokenArray = englishSuccinctBitVectorTokenArray } + fun getGlideCandidates( + inputPointers: QwertyInputPointers, + proximityInfo: QwertyKeyboardProximityInfo, + previousText: String, + limit: Int = 12 + ): List { + if (inputPointers.points.size < 2 || proximityInfo.keys.isEmpty()) return emptyList() + return getOrCreateQwertyGlideDecoder().decode( + inputPointers = inputPointers, + proximityInfo = proximityInfo, + previousText = previousText, + limit = limit + ) + } + + private fun getOrCreateQwertyGlideDecoder(): QwertyGlideDecoder { + qwertyGlideDecoder?.let { return it } + return synchronized(this) { + qwertyGlideDecoder ?: QwertyGlideDecoder( + dictionaryProvider = InMemoryQwertyGlideDictionaryProvider(buildGlideDictionaryEntries()), + options = QwertyGlideDecodeOptions() + ).also { qwertyGlideDecoder = it } + } + } + + private fun buildGlideDictionaryEntries(): List { + val entries = linkedMapOf() + val readings = readingLOUDS.predictiveSearch( + prefix = "", + succinctBitVector = succinctBitVectorLBSReading + ) + for (reading in readings) { + if (reading.length !in 2..24 || !reading.all { it in 'a'..'z' }) continue + val nodeIndex = readingLOUDS.getNodeIndex( + reading, + succinctBitVector = succinctBitVectorLBSReading + ) + if (nodeIndex <= 0) continue + val termId = readingLOUDS.getTermId( + nodeIndex, + succinctBitVector = succinctBitVectorReadingIsLeaf + ) + if (termId < 0) continue + val tokens = tokenArray.getListDictionaryByYomiTermId( + termId, + succinctBitVector = succinctBitVectorTokenArray + ) + if (tokens.isEmpty()) { + entries.mergeGlideEntry(reading, 9000) + } else { + for (entry in tokens) { + val word = when (entry.nodeId) { + -1 -> reading + else -> wordLOUDS.getLetter( + entry.nodeId, + succinctBitVector = succinctBitVectorLBSWord + ) + }.lowercase() + if (word.length in 2..24 && word.all { it in 'a'..'z' }) { + entries.mergeGlideEntry(word, entry.wordCost.toInt()) + } + } + } + } + listOf( + "hello", + "good", + "test", + "word", + "keyboard", + "android", + "sumire", + "coffee", + "letter", + "people" + ).forEach { word -> entries.mergeGlideEntry(word, 6000) } + return entries.values.toList() + } + fun getCandidates( input: String, enableTypoCorrection: Boolean = false, @@ -292,3 +379,14 @@ class EnglishEngine { } } + +private fun MutableMap.mergeGlideEntry( + word: String, + wordCost: Int +) { + val normalizedWord = word.lowercase() + val current = this[normalizedWord] + if (current == null || wordCost < current.wordCost) { + this[normalizedWord] = QwertyGlideDictionaryEntry(normalizedWord, wordCost) + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateReranker.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateReranker.kt new file mode 100644 index 000000000..348f80819 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateReranker.kt @@ -0,0 +1,24 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +class QwertyGlideCandidateReranker { + fun rerank( + candidates: List, + previousText: String, + limit: Int + ): List { + val contextAdjusted = candidates.map { scored -> + val contextCost = contextCost(scored.entry.word, previousText) + scored.copy(totalCost = scored.totalCost + contextCost) + } + return contextAdjusted + .groupBy { it.entry.word } + .map { (_, values) -> values.minBy { it.totalCost } } + .sortedWith(compareBy { it.totalCost }.thenBy { it.entry.word }) + .take(limit) + } + + private fun contextCost(word: String, previousText: String): Float { + if (previousText.isBlank() || word.isBlank()) return 0f + return 0f + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeOptions.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeOptions.kt new file mode 100644 index 000000000..380daeb60 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeOptions.kt @@ -0,0 +1,16 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +data class QwertyGlideDecodeOptions( + val maxResults: Int = 12, + val beamWidth: Int = 128, + val pointKeyTopK: Int = 5, + val maxWordLength: Int = 24, + val minWordLength: Int = 2, + val minSamplingDistanceRatio: Float = 0.22f, + val startEndWeight: Float = 4.8f, + val pathWeight: Float = 5.4f, + val proximityWeight: Float = 1.7f, + val lengthWeight: Float = 1.5f, + val dictionaryWeight: Float = 0.00008f, + val repeatedLetterWeight: Float = 0.45f +) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoder.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoder.kt new file mode 100644 index 000000000..f4f690b9d --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoder.kt @@ -0,0 +1,65 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo +import kotlin.math.ceil + +class QwertyGlideDecoder( + private val dictionaryProvider: QwertyGlideDictionaryProvider, + private val options: QwertyGlideDecodeOptions = QwertyGlideDecodeOptions(), + private val strokeNormalizer: QwertyGlideStrokeNormalizer = QwertyGlideStrokeNormalizer(options), + private val probabilityBuilder: QwertyGlideKeyProbabilityBuilder = QwertyGlideKeyProbabilityBuilder(options), + private val wordPathScorer: QwertyGlideWordPathScorer = QwertyGlideWordPathScorer(options), + private val reranker: QwertyGlideCandidateReranker = QwertyGlideCandidateReranker() +) { + fun decode( + inputPointers: QwertyInputPointers, + proximityInfo: QwertyKeyboardProximityInfo, + previousText: String, + limit: Int = options.maxResults + ): List { + if (inputPointers.points.size < 2 || proximityInfo.keys.isEmpty()) return emptyList() + val stroke = strokeNormalizer.normalize(inputPointers, proximityInfo) + if (stroke.points.size < 2) return emptyList() + val pointProbabilities = probabilityBuilder.build(stroke, proximityInfo) + if (pointProbabilities.isEmpty()) return emptyList() + + val startChars = pointProbabilities.first().take(3).map { it.char } + val endChars = pointProbabilities.last().take(3).map { it.char } + val rawUnits = stroke.rawLength / proximityInfo.averageKeyWidth.coerceAtLeast(1f) + val minLength = options.minWordLength + val maxLength = ceil(rawUnits * 1.8f + 5f).toInt().coerceIn( + options.minWordLength, + options.maxWordLength + ) + + val scored = ArrayList() + for (first in startChars) { + for (last in endChars) { + dictionaryProvider + .entriesFor(first, last, minLength, maxLength) + .forEach { entry -> + val score = wordPathScorer.score( + entry = entry, + stroke = stroke, + pointProbabilities = pointProbabilities, + proximityInfo = proximityInfo + ) + if (score != null) scored.add(score) + } + } + } + + return reranker + .rerank(scored, previousText, limit.coerceAtMost(options.maxResults)) + .map { scoredWord -> + Candidate( + string = scoredWord.entry.word, + type = 36.toByte(), + length = scoredWord.entry.word.length.toUByte(), + score = (scoredWord.totalCost * 1000f).toInt().coerceAtLeast(1) + ) + } + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDictionaryProvider.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDictionaryProvider.kt new file mode 100644 index 000000000..a94066636 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDictionaryProvider.kt @@ -0,0 +1,40 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +data class QwertyGlideDictionaryEntry( + val word: String, + val wordCost: Int +) + +interface QwertyGlideDictionaryProvider { + fun entriesFor( + firstChar: Char, + lastChar: Char, + minLength: Int, + maxLength: Int + ): Sequence +} + +class InMemoryQwertyGlideDictionaryProvider( + entries: Iterable +) : QwertyGlideDictionaryProvider { + private val indexedEntries: Map, List> = + entries + .asSequence() + .filter { it.word.length >= 2 } + .map { it.copy(word = it.word.lowercase()) } + .filter { it.word.all { ch -> ch in 'a'..'z' } } + .distinctBy { it.word } + .groupBy { it.word.first() to it.word.last() } + + override fun entriesFor( + firstChar: Char, + lastChar: Char, + minLength: Int, + maxLength: Int + ): Sequence { + return indexedEntries[firstChar to lastChar] + .orEmpty() + .asSequence() + .filter { it.word.length in minLength..maxLength } + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideKeyProbabilityBuilder.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideKeyProbabilityBuilder.kt new file mode 100644 index 000000000..4a2a8287b --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideKeyProbabilityBuilder.kt @@ -0,0 +1,54 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo +import kotlin.math.exp +import kotlin.math.hypot +import kotlin.math.ln + +data class PointKeyProbability( + val char: Char, + val cost: Float +) + +class QwertyGlideKeyProbabilityBuilder( + private val options: QwertyGlideDecodeOptions = QwertyGlideDecodeOptions() +) { + fun build( + stroke: NormalizedGlideStroke, + proximityInfo: QwertyKeyboardProximityInfo + ): List> { + val keyByChar = proximityInfo.keys.associateBy { it.char } + val scale = hypot( + proximityInfo.averageKeyWidth / proximityInfo.keyboardWidth.coerceAtLeast(1).toFloat(), + proximityInfo.averageKeyHeight / proximityInfo.keyboardHeight.coerceAtLeast(1).toFloat() + ).coerceAtLeast(0.01f) + + return stroke.points.map { point -> + proximityInfo.keys + .asSequence() + .map { key -> + val keyX = key.centerX / proximityInfo.keyboardWidth.coerceAtLeast(1).toFloat() + val keyY = key.centerY / proximityInfo.keyboardHeight.coerceAtLeast(1).toFloat() + val distance = hypot(point.x - keyX, point.y - keyY) + val neighborBoost = key.neighborChars + .mapNotNull { keyByChar[it] } + .minOfOrNull { neighbor -> + val nx = neighbor.centerX / proximityInfo.keyboardWidth.coerceAtLeast(1).toFloat() + val ny = neighbor.centerY / proximityInfo.keyboardHeight.coerceAtLeast(1).toFloat() + hypot(point.x - nx, point.y - ny) + } + ?.takeIf { it < distance } + ?.let { 0.08f } + ?: 0f + val probability = exp(-((distance / scale) * (distance / scale)).toDouble()) + .toFloat() + .coerceAtLeast(0.0001f) + key.char to (-ln(probability) - neighborBoost).coerceAtLeast(0f) + } + .sortedBy { (_, cost) -> cost } + .take(options.pointKeyTopK) + .map { (char, cost) -> PointKeyProbability(char, cost) } + .toList() + } + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideStrokeNormalizer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideStrokeNormalizer.kt new file mode 100644 index 000000000..83e92ec4d --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideStrokeNormalizer.kt @@ -0,0 +1,127 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointerPoint +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo +import kotlin.math.hypot + +data class NormalizedGlidePoint( + val x: Float, + val y: Float, + val time: Int +) + +data class NormalizedGlideStroke( + val points: List, + val rawLength: Float, + val normalizedLength: Float +) + +class QwertyGlideStrokeNormalizer( + private val options: QwertyGlideDecodeOptions = QwertyGlideDecodeOptions() +) { + fun normalize( + inputPointers: QwertyInputPointers, + proximityInfo: QwertyKeyboardProximityInfo + ): NormalizedGlideStroke { + val raw = inputPointers.points + .sortedBy { it.time } + .dedupeClosePoints( + minDistance = proximityInfo.averageKeyWidth + .coerceAtLeast(1f) * options.minSamplingDistanceRatio * 0.35f + ) + if (raw.isEmpty()) return NormalizedGlideStroke(emptyList(), 0f, 0f) + + val rawLength = raw.rawPointerPathLength() + val resampled = resample( + points = raw, + step = proximityInfo.averageKeyWidth + .coerceAtLeast(1f) * options.minSamplingDistanceRatio + ) + val normalizedPoints = resampled.map { + NormalizedGlidePoint( + x = it.x / proximityInfo.keyboardWidth.coerceAtLeast(1).toFloat(), + y = it.y / proximityInfo.keyboardHeight.coerceAtLeast(1).toFloat(), + time = it.time + ) + } + return NormalizedGlideStroke( + points = normalizedPoints, + rawLength = rawLength, + normalizedLength = normalizedPoints.normalizedPathLength() + ) + } + + private fun resample( + points: List, + step: Float + ): List { + if (points.size <= 2 || step <= 0f) return points + val result = ArrayList() + result.add(points.first()) + var carry = 0f + var previous = points.first() + + for (i in 1 until points.size) { + val current = points[i] + var segmentLength = distance(previous, current) + if (segmentLength <= 0f) continue + var start = previous + while (carry + segmentLength >= step) { + val remaining = step - carry + val ratio = (remaining / segmentLength).coerceIn(0f, 1f) + val x = start.x + (current.x - start.x) * ratio + val y = start.y + (current.y - start.y) * ratio + val time = start.time + ((current.time - start.time) * ratio).toInt() + val sample = QwertyInputPointerPoint( + x = x.toInt(), + y = y.toInt(), + time = time, + pointerId = current.pointerId + ) + result.add(sample) + start = sample + segmentLength = distance(start, current) + carry = 0f + if (segmentLength <= 0f) break + } + carry += segmentLength + previous = current + } + if (result.last() != points.last()) result.add(points.last()) + return result + } +} + +private fun List.dedupeClosePoints( + minDistance: Float +): List { + if (size <= 1) return this + val result = ArrayList() + result.add(first()) + for (point in drop(1)) { + if (distance(result.last(), point) >= minDistance) { + result.add(point) + } + } + if (result.last() != last()) result.add(last()) + return result +} + +private fun List.rawPointerPathLength(): Float { + var length = 0f + for (i in 1 until size) length += distance(this[i - 1], this[i]) + return length +} + +private fun List.normalizedPathLength(): Float { + var length = 0f + for (i in 1 until size) { + length += hypot(this[i].x - this[i - 1].x, this[i].y - this[i - 1].y) + } + return length +} + +private fun distance(a: QwertyInputPointerPoint, b: QwertyInputPointerPoint): Float { + return hypot((a.x - b.x).toFloat(), (a.y - b.y).toFloat()) +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWordPathScorer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWordPathScorer.kt new file mode 100644 index 000000000..3634cbed4 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWordPathScorer.kt @@ -0,0 +1,178 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyProximity +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo +import kotlin.math.abs +import kotlin.math.hypot + +data class QwertyGlideScoredWord( + val entry: QwertyGlideDictionaryEntry, + val totalCost: Float, + val spatialCost: Float, + val dictionaryCost: Float +) + +class QwertyGlideWordPathScorer( + private val options: QwertyGlideDecodeOptions = QwertyGlideDecodeOptions() +) { + fun score( + entry: QwertyGlideDictionaryEntry, + stroke: NormalizedGlideStroke, + pointProbabilities: List>, + proximityInfo: QwertyKeyboardProximityInfo + ): QwertyGlideScoredWord? { + if (stroke.points.size < 2) return null + val keyByChar = proximityInfo.keys.associateBy { it.char } + val wordKeys = entry.word.map { keyByChar[it] ?: return null } + val normalizedPath = wordKeys.toNormalizedPath(proximityInfo) + val rawPath = wordKeys.map { it.centerX to it.centerY } + val keyScale = hypot( + proximityInfo.averageKeyWidth / proximityInfo.keyboardWidth.coerceAtLeast(1).toFloat(), + proximityInfo.averageKeyHeight / proximityInfo.keyboardHeight.coerceAtLeast(1).toFloat() + ).coerceAtLeast(0.01f) + + val startCost = distance(stroke.points.first(), normalizedPath.first()) / keyScale + val endCost = distance(stroke.points.last(), normalizedPath.last()) / keyScale + if (startCost > 2.6f || endCost > 2.6f) return null + + val pathShapeCost = stroke.points + .map { point -> distanceToPolyline(point.x, point.y, normalizedPath) / keyScale } + .average() + .toFloat() + val keyPassCost = normalizedPath + .map { keyPoint -> distanceToStrokePolyline(keyPoint.first, keyPoint.second, stroke.points) / keyScale } + .average() + .toFloat() + val proximityCost = entry.word.map { ch -> + pointProbabilities.minOfOrNull { probs -> + probs.firstOrNull { it.char == ch }?.cost ?: 5.0f + } ?: 5.0f + }.average().toFloat() + val lengthCost = normalizedLengthCost(stroke, rawPath, proximityInfo, entry.word.length) + val repeatedLetterCost = repeatedLetterCost(entry.word) + val dictionaryCost = entry.wordCost.coerceAtLeast(0) * options.dictionaryWeight + val spatialCost = + options.startEndWeight * (startCost + endCost) + + options.pathWeight * (pathShapeCost + keyPassCost * 0.72f) + + options.proximityWeight * proximityCost + + options.lengthWeight * lengthCost + + options.repeatedLetterWeight * repeatedLetterCost + return QwertyGlideScoredWord( + entry = entry, + totalCost = spatialCost + dictionaryCost, + spatialCost = spatialCost, + dictionaryCost = dictionaryCost + ) + } + + private fun normalizedLengthCost( + stroke: NormalizedGlideStroke, + rawPath: List>, + proximityInfo: QwertyKeyboardProximityInfo, + wordLength: Int + ): Float { + val idealRawLength = rawPath.pathLength() + val keyWidth = proximityInfo.averageKeyWidth.coerceAtLeast(1f) + val strokeUnits = stroke.rawLength / keyWidth + val idealUnits = idealRawLength / keyWidth + val geometric = abs(strokeUnits - idealUnits) / wordLength.coerceAtLeast(1) + val expectedMin = (wordLength - 1).coerceAtLeast(1) * 0.22f + val tooShort = (expectedMin - strokeUnits).coerceAtLeast(0f) + return geometric + tooShort * 0.65f + } + + private fun repeatedLetterCost(word: String): Float { + var cost = 0f + for (i in 1 until word.length) { + if (word[i] == word[i - 1]) cost += 0.08f + } + return cost + } +} + +private fun List.toNormalizedPath( + proximityInfo: QwertyKeyboardProximityInfo +): List> { + return map { + it.centerX / proximityInfo.keyboardWidth.coerceAtLeast(1).toFloat() to + it.centerY / proximityInfo.keyboardHeight.coerceAtLeast(1).toFloat() + } +} + +private fun List>.pathLength(): Float { + var length = 0f + for (i in 1 until size) { + length += hypot(this[i].first - this[i - 1].first, this[i].second - this[i - 1].second) + } + return length +} + +private fun distance(point: NormalizedGlidePoint, keyPoint: Pair): Float { + return hypot(point.x - keyPoint.first, point.y - keyPoint.second) +} + +private fun distanceToStrokePolyline( + x: Float, + y: Float, + strokePoints: List +): Float { + if (strokePoints.isEmpty()) return Float.MAX_VALUE + if (strokePoints.size == 1) return hypot(x - strokePoints.first().x, y - strokePoints.first().y) + var best = Float.MAX_VALUE + for (i in 1 until strokePoints.size) { + best = minOf( + best, + distanceToSegment( + x, + y, + strokePoints[i - 1].x, + strokePoints[i - 1].y, + strokePoints[i].x, + strokePoints[i].y + ) + ) + } + return best +} + +private fun distanceToPolyline( + x: Float, + y: Float, + path: List> +): Float { + if (path.isEmpty()) return Float.MAX_VALUE + if (path.size == 1) return hypot(x - path.first().first, y - path.first().second) + var best = Float.MAX_VALUE + for (i in 1 until path.size) { + best = minOf( + best, + distanceToSegment( + x, + y, + path[i - 1].first, + path[i - 1].second, + path[i].first, + path[i].second + ) + ) + } + return best +} + +private fun distanceToSegment( + px: Float, + py: Float, + ax: Float, + ay: Float, + bx: Float, + by: Float +): Float { + val dx = bx - ax + val dy = by - ay + val lengthSquared = dx * dx + dy * dy + if (lengthSquared <= 0.000001f) return hypot(px - ax, py - ay) + val t = (((px - ax) * dx + (py - ay) * dy) / lengthSquared).coerceIn(0f, 1f) + val cx = ax + t * dx + val cy = ay + t * dy + return hypot(px - cx, py - cy) +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index 5084def56..2b831f983 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt @@ -155,6 +155,7 @@ import com.kazumaproject.markdownhelperkeyboard.clipboard_history.database.ItemT import com.kazumaproject.markdownhelperkeyboard.converter.candidate.BunsetsuCandidateResult import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate import com.kazumaproject.markdownhelperkeyboard.converter.candidate.ZenzCandidate +import com.kazumaproject.markdownhelperkeyboard.converter.engine.EnglishEngine import com.kazumaproject.markdownhelperkeyboard.converter.engine.KanaKanjiEngine import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeyboardLayout import com.kazumaproject.markdownhelperkeyboard.databinding.FloatingKeyboardLayoutBinding @@ -210,6 +211,9 @@ import com.kazumaproject.markdownhelperkeyboard.setting_activity.circular_slot.C import com.kazumaproject.markdownhelperkeyboard.short_cut.ShortcutType import com.kazumaproject.markdownhelperkeyboard.variant.AppVariantConfig import com.kazumaproject.qwerty_keyboard.ui.QWERTYKeyboardView +import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideInputListener +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo import com.kazumaproject.symbol_keyboard.CustomSymbolKeyboardView import com.kazumaproject.tenkey.TenKey import com.kazumaproject.tenkey.extensions.getDakutenFlickLeft @@ -344,6 +348,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, @Inject lateinit var kanaKanjiEngine: KanaKanjiEngine + @Inject + lateinit var englishEngine: EnglishEngine + @Inject lateinit var learnRepository: LearnRepository @@ -582,6 +589,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var qwertyRomajiHankakuNumberPreference: Boolean? = false private var qwertyRomajiHankakuSymbolPreference: Boolean? = false private var qwertyShowPopupWindowPreference: Boolean? = true + private var qwertyGlideInputPreference: Boolean = false private var qwertyShowCursorButtonsPreference: Boolean? = false private var qwertyShowNumberButtonsPreference: Boolean? = false private var qwertyShowSwitchRomajiEnglishPreference: Boolean? = false @@ -722,6 +730,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var customKeyBorderWidth: Int? = 1 private var qwertySwitchNumberKeyWithoutNumberPreference: Boolean? = false + private var qwertyGlideInputCoordinator: QwertyGlideInputCoordinator? = null + private var suppressNextQwertyGlideSuggestionRefresh: Boolean = false private var customRomajiZenkakuConversionEnablePreference: Boolean? = true @@ -1260,6 +1270,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertyShowNumberButtonsPreference = preferences.qwertyShowNumberButtonsPreference qwertyShowSwitchRomajiEnglishPreference = preferences.qwertyShowSwitchRomajiEnglishPreference + qwertyGlideInputPreference = preferences.qwertyGlideInputPreference qwertyShowPopupWindowPreference = preferences.qwertyShowPopupWindowPreference qwertyEnableFlickUpPreference = preferences.qwertyEnableFlickUpPreference qwertyEnableFlickDownPreference = preferences.qwertyEnableFlickDownPreference @@ -1434,6 +1445,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, preferences.enableTypoCorrectionQwertyEnglishKeyboardPreference enableGemmaTranslationPreference = preferences.enableGemmaTranslationPreference + updateQwertyGlideInputModeOnActiveSurface() refreshReconversionUi() } @@ -2376,6 +2388,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertyShowCursorButtonsPreference = null qwertyShowNumberButtonsPreference = null qwertyShowSwitchRomajiEnglishPreference = null + qwertyGlideInputPreference = false qwertyRomajiShiftConversionPreference = null qwertyShowPopupWindowPreference = null qwertyEnableFlickUpPreference = null @@ -4450,6 +4463,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mode = qwertyMode.value, isFloating = isKeyboardFloatingMode == true ) + updateQwertyGlideInputModeOnActiveSurface() } private fun updateDynamicKeyOnActiveSurface( @@ -4504,6 +4518,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun setCurrentQwertyRomajiModeForSession(enabled: Boolean) { currentQwertyRomajiModeForSession = enabled setQwertyRomajiModeOnActiveSurface(enabled) + updateQwertyGlideInputModeOnActiveSurface() } private fun setQwertyRomajiSwitchTextOnActiveSurface(isJapanese: Boolean) { @@ -4528,6 +4543,22 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertyMode.value == TenKeyQWERTYMode.TenKeyQWERTYRomaji && qwertyShowSwitchRomajiEnglishPreference == true ) + updateQwertyGlideInputModeOnActiveSurface() + } + + private fun calculateQwertyGlideInputMode(): Boolean { + val surface = getActiveKeyboardSurface() + return QwertyGlideInputModeResolver.resolve( + qwertyGlideInputPreference = qwertyGlideInputPreference, + isQwertySurfaceActive = surface?.qwertyView?.isVisible == true, + currentQwertyRomajiModeForSession = currentQwertyRomajiModeForSession + ) + } + + private fun updateQwertyGlideInputModeOnActiveSurface() { + getActiveKeyboardSurface() + ?.qwertyView + ?.setQwertyGlideInputMode(calculateQwertyGlideInputMode()) } private fun updateQwertyOnActiveSurface(block: QWERTYKeyboardView.() -> Unit) { @@ -11308,6 +11339,26 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, refreshReconversionUi() return } + if (suppressNextQwertyGlideSuggestionRefresh) { + suppressNextQwertyGlideSuggestionRefresh = false + setComposingTextAfterEdit( + inputString = string, + spannableString = createSpannableWithTail(string), + backgroundColor = if (customComposingTextPreference == true) { + inputCompositionAfterBackgroundColor + ?: getColor(com.kazumaproject.core.R.color.blue) + } else { + getColor(com.kazumaproject.core.R.color.blue) + }, + textColor = if (customComposingTextPreference == true) { + inputCompositionTextColor + } else { + null + } + ) + refreshReconversionUi() + return + } if (qwertyMode.value == TenKeyQWERTYMode.TenKeyQWERTY) { handleTenKeyQwertyInput(string) } else { @@ -13168,6 +13219,21 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, setDeleteLeftFlickEnabled(isDeleteLeftFlickPreference ?: true) setDeleteUpFlickEnabled(isDeleteUpFlickPreference ?: false) setDeleteDownFlickEnabled(isDeleteDownFlickPreference ?: false) + val glideCoordinator = qwertyGlideInputCoordinator + ?: QwertyGlideInputCoordinator( + scope = scope, + englishEngine = englishEngine, + previousTextProvider = { getPreviousTextForQwertyGlide() }, + onPreviewCandidates = { candidates -> + showQwertyGlideCandidates(candidates, applyFirstCandidate = false) + }, + onFinalCandidates = { candidates -> + showQwertyGlideCandidates(candidates, applyFirstCandidate = true) + }, + onCancel = {} + ).also { qwertyGlideInputCoordinator = it } + setQwertyGlideInputListener(glideCoordinator) + setQwertyGlideInputMode(calculateQwertyGlideInputMode()) setKeyMargins( verticalDp = qwertyKeyVerticalMargin ?: 5.0f, horizontalGapDp = qwertyKeyHorizontalGap ?: 2.0f, @@ -13703,6 +13769,53 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + private fun getPreviousTextForQwertyGlide(): String { + return currentInputConnection + ?.getTextBeforeCursor(64, 0) + ?.toString() + .orEmpty() + } + + private fun showQwertyGlideCandidates( + candidates: List, + applyFirstCandidate: Boolean + ) { + if (candidates.isEmpty()) return + if (currentQwertyRomajiModeForSession) return + suggestionClickNum = 0 + suggestionAdapter?.updateHighlightPosition(RecyclerView.NO_POSITION) + suggestionAdapter?.suggestions = candidates + suggestionAdapterFull?.suggestions = candidates + if (physicalKeyboardEnable.replayCache.firstOrNull() == true) { + updateSuggestionsForFloatingCandidate( + candidates.map { CandidateItem(word = it.string, length = it.length) } + ) + } + if (applyFirstCandidate) { + clearDeletedBuffer() + refreshEditHistoryUi() + val first = candidates.first() + suppressNextQwertyGlideSuggestionRefresh = true + _inputString.update { first.string } + val spannable = createSpannableWithTail(first.string) + setComposingTextAfterEdit( + inputString = first.string, + spannableString = spannable, + backgroundColor = if (customComposingTextPreference == true) { + inputCompositionAfterBackgroundColor + ?: getColor(com.kazumaproject.core.R.color.blue) + } else { + getColor(com.kazumaproject.core.R.color.blue) + }, + textColor = if (customComposingTextPreference == true) { + inputCompositionTextColor + } else { + null + } + ) + } + } + private suspend fun setSymbols(mainView: MainLayoutBinding) { coroutineScope { if (cachedEmoji == null || cachedEmoticons == null || cachedSymbols == null) { diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt index a8daadcd3..ee22ba9fb 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt @@ -32,6 +32,7 @@ data class ImePreferencesSnapshot( val qwertyShowCursorButtonsPreference: Boolean, val qwertyShowNumberButtonsPreference: Boolean, val qwertyShowSwitchRomajiEnglishPreference: Boolean, + val qwertyGlideInputPreference: Boolean, val qwertyShowPopupWindowPreference: Boolean, val qwertyEnableFlickUpPreference: Boolean, val qwertyEnableFlickDownPreference: Boolean, @@ -201,6 +202,7 @@ data class ImePreferencesSnapshot( ?: false, qwertyShowSwitchRomajiEnglishPreference = appPreference.qwerty_show_switch_romaji_english_button ?: true, + qwertyGlideInputPreference = appPreference.qwerty_glide_input_preference, qwertyShowPopupWindowPreference = appPreference.qwerty_show_popup_window ?: true, qwertyEnableFlickUpPreference = appPreference.qwerty_enable_flick_up_preference ?: false, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt new file mode 100644 index 000000000..f133a5c86 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt @@ -0,0 +1,96 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import com.kazumaproject.markdownhelperkeyboard.BuildConfig +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.converter.engine.EnglishEngine +import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideInputListener +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.concurrent.atomic.AtomicLong + +class QwertyGlideInputCoordinator( + private val scope: CoroutineScope, + private val englishEngine: EnglishEngine, + private val previousTextProvider: () -> String, + private val onPreviewCandidates: (List) -> Unit, + private val onFinalCandidates: (List) -> Unit, + private val onCancel: () -> Unit = {} +) : QwertyGlideInputListener { + private val generation = AtomicLong(0L) + private var previewJob: Job? = null + private var finalJob: Job? = null + + override fun onQwertyGlideStarted() { + generation.incrementAndGet() + previewJob?.cancel() + finalJob?.cancel() + } + + override fun onQwertyGlideUpdated( + inputPointers: QwertyInputPointers, + proximityInfo: QwertyKeyboardProximityInfo + ) { + val requestGeneration = generation.get() + previewJob?.cancel() + previewJob = scope.launch { + delay(PREVIEW_DEBOUNCE_MS) + val candidates = withContext(Dispatchers.Default) { + englishEngine.getGlideCandidates( + inputPointers = inputPointers, + proximityInfo = proximityInfo, + previousText = previousTextProvider(), + limit = 6 + ) + } + if (generation.get() == requestGeneration) { + if (BuildConfig.DEBUG) { + Timber.d("QWERTY glide preview: points=${inputPointers.points.size} top=${candidates.take(5).map { it.string }}") + } + onPreviewCandidates(candidates) + } + } + } + + override fun onQwertyGlideEnded( + inputPointers: QwertyInputPointers, + proximityInfo: QwertyKeyboardProximityInfo + ) { + val requestGeneration = generation.incrementAndGet() + previewJob?.cancel() + finalJob?.cancel() + finalJob = scope.launch { + val candidates = withContext(Dispatchers.Default) { + englishEngine.getGlideCandidates( + inputPointers = inputPointers, + proximityInfo = proximityInfo, + previousText = previousTextProvider(), + limit = 12 + ) + } + if (generation.get() == requestGeneration) { + if (BuildConfig.DEBUG) { + Timber.d("QWERTY glide final: points=${inputPointers.points.size} top=${candidates.take(8).map { it.string to it.score }}") + } + onFinalCandidates(candidates) + } + } + } + + override fun onQwertyGlideCancelled() { + generation.incrementAndGet() + previewJob?.cancel() + finalJob?.cancel() + onCancel() + } + + companion object { + private const val PREVIEW_DEBOUNCE_MS = 96L + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputModeResolver.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputModeResolver.kt new file mode 100644 index 000000000..ce4db40cd --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputModeResolver.kt @@ -0,0 +1,13 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +object QwertyGlideInputModeResolver { + fun resolve( + qwertyGlideInputPreference: Boolean, + isQwertySurfaceActive: Boolean, + currentQwertyRomajiModeForSession: Boolean + ): Boolean { + return qwertyGlideInputPreference && + isQwertySurfaceActive && + !currentQwertyRomajiModeForSession + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt index de48d2f57..41d21909b 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt @@ -90,6 +90,9 @@ object AppPreference { private val QWERTY_SHOW_SWITCH_ROMAJI_ENGLISH = Pair("qwerty_show_switch_romaji_english_preference", true) + private val QWERTY_GLIDE_INPUT_PREFERENCE = + Pair("qwerty_glide_input_preference", false) + private val QWERTY_ENABLE_FLICK_UP_WINDOW = Pair("qwerty_enable_flick_up_preference", false) private val QWERTY_ENABLE_FLICK_DOWN_WINDOW = Pair("qwerty_enable_flick_down_preference", false) @@ -652,6 +655,15 @@ object AppPreference { it.putBoolean(QWERTY_SHOW_SWITCH_ROMAJI_ENGLISH.first, value ?: true) } + var qwerty_glide_input_preference: Boolean + get() = preferences.getBoolean( + QWERTY_GLIDE_INPUT_PREFERENCE.first, + QWERTY_GLIDE_INPUT_PREFERENCE.second + ) + set(value) = preferences.edit { + it.putBoolean(QWERTY_GLIDE_INPUT_PREFERENCE.first, value) + } + var switch_qwerty_password: Boolean? get() = preferences.getBoolean( SWITCH_QWERTY_PASSWORD.first, SWITCH_QWERTY_PASSWORD.second diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ad8d8e3b4..98b600921 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -515,6 +515,8 @@ 絵文字キーボードへの切り替えなどの便利なショートカットアイコンを含むツールバーを表示します ローマ字/英語切り替えキー キーボード上にローマ字入力と英語入力を切り替えるキー(あa)を表示します + 英語 QWERTY のグライド入力 + 英語 QWERTY キーボードで、指を滑らせた軌跡から英単語候補を表示します。 キーボードテーマ 反映には一度キーボードを再起動する必要があります。 ベースカラーの選択 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ec54f7fe..ae36b793c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -513,6 +513,8 @@ Show a toolbar with useful shortcuts like switching Emoji keyboard Romaji/English Switch Key Show a key (あa) to switch between Romaji input and English input + Glide input for English QWERTY + Enable word suggestions from sliding gestures on the English QWERTY keyboard. Keyboard Theme Choose the keyboard theme color.\nTo apply changes, please switch the keyboard once. Select Base Color diff --git a/app/src/main/res/xml/pref_qwerty.xml b/app/src/main/res/xml/pref_qwerty.xml index 3c6bbe0c6..aed8ae825 100644 --- a/app/src/main/res/xml/pref_qwerty.xml +++ b/app/src/main/res/xml/pref_qwerty.xml @@ -111,6 +111,12 @@ app:defaultValue="true" app:summary="@string/qwerty_romaji_english_visibility_summary" /> + + + QwertyGlideDictionaryEntry(word, 5000 + index * 20) + } + ), + options = QwertyGlideDecodeOptions( + maxResults = 12, + beamWidth = 128, + pointKeyTopK = 6 + ) + ) + + @Test + fun idealStrokesPutExpectedWordsTop1() { + targetWords.forEach { word -> + val candidates = decoder.decode(strokeFactory.ideal(word), proximityInfo, previousText = "") + assertEquals("ideal $word candidates=${candidates.map { it.string to it.score }}", word, candidates.firstOrNull()?.string) + } + } + + @Test + fun noisyStrokesPutExpectedWordsTop3() { + targetWords.forEach { word -> + val candidates = decoder.decode(strokeFactory.noisy(word), proximityInfo, previousText = "") + assertTrue("noisy $word candidates=${candidates.map { it.string }}", candidates.take(3).any { it.string == word }) + } + } + + @Test + fun fastSparseStrokesPutExpectedWordsTop3() { + targetWords.forEach { word -> + val candidates = decoder.decode(strokeFactory.fastSparse(word), proximityInfo, previousText = "") + assertTrue("fastSparse $word candidates=${candidates.map { it.string }}", candidates.take(3).any { it.string == word }) + } + } + + @Test + fun repeatedLettersAreNotCollapsedAway() { + listOf("hello", "good", "coffee", "letter", "people").forEach { word -> + val candidates = decoder.decode(strokeFactory.repeatedLetter(word), proximityInfo, previousText = "") + assertTrue("repeated $word candidates=${candidates.map { it.string }}", candidates.take(3).any { it.string == word }) + } + } + + @Test + fun differentStartOrEndCompetitorDoesNotWin() { + val candidates = decoder.decode(strokeFactory.ideal("word"), proximityInfo, previousText = "") + assertNotEquals("work", candidates.firstOrNull()?.string) + assertEquals("word", candidates.firstOrNull()?.string) + } + + @Test + fun shortHighFrequencyWordsDoNotWinLongStroke() { + val candidates = decoder.decode(strokeFactory.ideal("keyboard"), proximityInfo, previousText = "") + assertEquals("keyboard", candidates.firstOrNull()?.string) + assertTrue(candidates.take(3).none { it.string in setOf("key", "to", "of", "in") }) + } + + companion object { + private val targetWords = listOf( + "hello", + "good", + "test", + "word", + "keyboard", + "android", + "sumire", + "coffee", + "letter", + "people" + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/SyntheticQwertyGlideStrokeFactory.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/SyntheticQwertyGlideStrokeFactory.kt new file mode 100644 index 000000000..0d1ab7320 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/SyntheticQwertyGlideStrokeFactory.kt @@ -0,0 +1,140 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointerPoint +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyProximity +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo +import kotlin.math.hypot +import kotlin.random.Random + +object FixedQwertyGeometryFactory { + fun create(): QwertyKeyboardProximityInfo { + val rows = listOf( + "qwertyuiop" to 0f, + "asdfghjkl" to 0.5f, + "zxcvbnm" to 1.5f + ) + val keyWidth = 100f + val keyHeight = 80f + val rowHeight = 100f + val keys = rows.flatMapIndexed { rowIndex, (letters, offset) -> + letters.mapIndexed { columnIndex, ch -> + QwertyKeyProximity( + char = ch, + centerX = (offset + columnIndex + 0.5f) * keyWidth, + centerY = (rowIndex + 0.5f) * rowHeight, + width = keyWidth, + height = keyHeight, + rowIndex = rowIndex, + columnIndex = columnIndex, + neighborChars = emptyList() + ) + } + } + val neighborRadius = hypot(keyWidth, keyHeight) * 1.35f + val withNeighbors = keys.map { key -> + key.copy( + neighborChars = keys + .filter { it.char != key.char } + .map { it.char to hypot(key.centerX - it.centerX, key.centerY - it.centerY) } + .filter { it.second <= neighborRadius } + .sortedBy { it.second } + .map { it.first } + .take(8) + ) + } + return QwertyKeyboardProximityInfo( + keys = withNeighbors, + keyboardWidth = 1000, + keyboardHeight = 300, + averageKeyWidth = keyWidth, + averageKeyHeight = keyHeight + ) + } +} + +class SyntheticQwertyGlideStrokeFactory( + private val proximityInfo: QwertyKeyboardProximityInfo, + private val random: Random = Random(7) +) { + private val keyByChar = proximityInfo.keys.associateBy { it.char } + + fun ideal(word: String): QwertyInputPointers { + return fromPath(word.map { center(it) }, stepsPerSegment = 6) + } + + fun noisy(word: String): QwertyInputPointers { + val noiseX = proximityInfo.averageKeyWidth * 0.18f + val noiseY = proximityInfo.averageKeyHeight * 0.18f + val points = ideal(word).points.mapIndexed { index, point -> + if (index == 0 || index == ideal(word).points.lastIndex) { + point + } else { + point.copy( + x = (point.x + random.nextDouble(-noiseX.toDouble(), noiseX.toDouble())).toInt(), + y = (point.y + random.nextDouble(-noiseY.toDouble(), noiseY.toDouble())).toInt() + ) + } + } + return QwertyInputPointers(points) + } + + fun fastSparse(word: String): QwertyInputPointers { + val centers = word.map { center(it) } + val sparse = centers.filterIndexed { index, _ -> + index == 0 || index == centers.lastIndex || index % 2 == 0 + } + return fromPath(sparse, stepsPerSegment = 1) + } + + fun overshoot(word: String): QwertyInputPointers { + val centers = word.map { center(it) }.toMutableList() + if (centers.size >= 2) { + val first = centers[0] + val second = centers[1] + centers[0] = first.first - (second.first - first.first) * 0.18f to + first.second - (second.second - first.second) * 0.18f + val lastIndex = centers.lastIndex + val previous = centers[lastIndex - 1] + val last = centers[lastIndex] + centers[lastIndex] = last.first + (last.first - previous.first) * 0.18f to + last.second + (last.second - previous.second) * 0.18f + } + return fromPath(centers, stepsPerSegment = 6) + } + + fun repeatedLetter(word: String): QwertyInputPointers { + return noisy(word) + } + + private fun center(ch: Char): Pair { + val key = keyByChar[ch.lowercaseChar()] ?: error("Missing key $ch") + return key.centerX to key.centerY + } + + private fun fromPath( + centers: List>, + stepsPerSegment: Int + ): QwertyInputPointers { + val result = ArrayList() + var time = 0 + result.add(QwertyInputPointerPoint(centers.first().first.toInt(), centers.first().second.toInt(), time, 0)) + for (i in 1 until centers.size) { + val a = centers[i - 1] + val b = centers[i] + for (step in 1..stepsPerSegment) { + val ratio = step / stepsPerSegment.toFloat() + time += 12 + result.add( + QwertyInputPointerPoint( + x = (a.first + (b.first - a.first) * ratio).toInt(), + y = (a.second + (b.second - a.second) * ratio).toInt(), + time = time, + pointerId = 0 + ) + ) + } + } + return QwertyInputPointers(result) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputModeResolverTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputModeResolverTest.kt new file mode 100644 index 000000000..29b5f468e --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputModeResolverTest.kt @@ -0,0 +1,81 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class QwertyGlideInputModeResolverTest { + @Test + fun preferenceDefaultFalseKeepsGlideModeFalse() { + assertFalse( + QwertyGlideInputModeResolver.resolve( + qwertyGlideInputPreference = false, + isQwertySurfaceActive = true, + currentQwertyRomajiModeForSession = false + ) + ) + } + + @Test + fun preferenceOnDoesNotRequireRomajiModeMutation() { + val romajiMode = true + val result = QwertyGlideInputModeResolver.resolve( + qwertyGlideInputPreference = true, + isQwertySurfaceActive = true, + currentQwertyRomajiModeForSession = romajiMode + ) + + assertTrue(romajiMode) + assertFalse(result) + } + + @Test + fun preferenceOnQwertyActiveEnglishModeEnablesGlideMode() { + assertTrue( + QwertyGlideInputModeResolver.resolve( + qwertyGlideInputPreference = true, + isQwertySurfaceActive = true, + currentQwertyRomajiModeForSession = false + ) + ) + } + + @Test + fun preferenceOnQwertyActiveRomajiModeDisablesGlideMode() { + assertFalse( + QwertyGlideInputModeResolver.resolve( + qwertyGlideInputPreference = true, + isQwertySurfaceActive = true, + currentQwertyRomajiModeForSession = true + ) + ) + } + + @Test + fun inactiveQwertySurfaceDisablesGlideMode() { + assertFalse( + QwertyGlideInputModeResolver.resolve( + qwertyGlideInputPreference = true, + isQwertySurfaceActive = false, + currentQwertyRomajiModeForSession = false + ) + ) + } + + @Test + fun switchingRomajiOffRestoresGlideModeWhenPreferenceIsOn() { + val romajiOn = QwertyGlideInputModeResolver.resolve( + qwertyGlideInputPreference = true, + isQwertySurfaceActive = true, + currentQwertyRomajiModeForSession = true + ) + val romajiOff = QwertyGlideInputModeResolver.resolve( + qwertyGlideInputPreference = true, + isQwertySurfaceActive = true, + currentQwertyRomajiModeForSession = false + ) + + assertFalse(romajiOn) + assertTrue(romajiOff) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositorySaveLayoutTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositorySaveLayoutTest.kt index dbf3caaef..e3fbd60e4 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositorySaveLayoutTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/repository/KeyboardRepositorySaveLayoutTest.kt @@ -54,7 +54,7 @@ class KeyboardRepositorySaveLayoutTest { // E. parent identity 維持 // --------------------------------------------------------- @Test - fun saveLayout_existingId_keepsStableIdAndIdentity() = runBlocking { + fun saveLayout_existingId_keepsStableIdAndIdentity(): Unit = runBlocking { val existingStableId = "stable-a" val existingCreatedAt = 1_700_000_000_000L val existingSortOrder = 7 @@ -106,7 +106,7 @@ class KeyboardRepositorySaveLayoutTest { // B. 既存レイアウト保存で sortOrder が変わらない (E と同じケースで保証) // --------------------------------------------------------- @Test - fun saveLayout_existingId_doesNotMoveLayoutToTopOfList() = runBlocking { + fun saveLayout_existingId_doesNotMoveLayoutToTopOfList(): Unit = runBlocking { whenever(dao.getFullLayoutOneShot(2L)).thenReturn( fullLayout( layoutId = 2, @@ -133,7 +133,7 @@ class KeyboardRepositorySaveLayoutTest { // C. MoveToCustomKeyboard が保存後も有効 // --------------------------------------------------------- @Test - fun saveLayout_targetLayoutEdit_preservesStableIdSoMoveToCustomKeyboardStaysValid() = runBlocking { + fun saveLayout_targetLayoutEdit_preservesStableIdSoMoveToCustomKeyboardStaysValid(): Unit = runBlocking { val targetStableId = "target-stable" whenever(dao.getFullLayoutOneShot(10L)).thenReturn( fullLayout( @@ -161,7 +161,7 @@ class KeyboardRepositorySaveLayoutTest { // I. 既存更新対象が存在しない場合 // --------------------------------------------------------- @Test - fun saveLayout_existingIdNotFound_throwsLayoutNotFoundException() = runBlocking { + fun saveLayout_existingIdNotFound_throwsLayoutNotFoundException(): Unit = runBlocking { whenever(dao.getFullLayoutOneShot(999L)).thenReturn(null) try { @@ -184,7 +184,7 @@ class KeyboardRepositorySaveLayoutTest { // 新規作成 (id == null) で stableId は新規生成され、insertFullKeyboardLayout が呼ばれる // --------------------------------------------------------- @Test - fun saveLayout_newLayout_generatesUniqueStableIdAndInserts() = runBlocking { + fun saveLayout_newLayout_generatesUniqueStableIdAndInserts(): Unit = runBlocking { whenever(dao.getMaxSortOrder()).thenReturn(3) whenever(dao.findLayoutByStableId(any())).thenReturn(null) whenever( @@ -212,7 +212,7 @@ class KeyboardRepositorySaveLayoutTest { } @Test - fun saveLayout_existingIdWithBlankStableId_repairsStableIdButNeverChangesValidOne() = runBlocking { + fun saveLayout_existingIdWithBlankStableId_repairsStableIdButNeverChangesValidOne(): Unit = runBlocking { // 旧データで stableId が blank だった既存 row。今回の保存で blank → 新 UUID に修復される。 whenever(dao.getFullLayoutOneShot(5L)).thenReturn( fullLayout( diff --git a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideGesturePolicy.kt b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideGesturePolicy.kt new file mode 100644 index 000000000..e1ce0477d --- /dev/null +++ b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideGesturePolicy.kt @@ -0,0 +1,19 @@ +package com.kazumaproject.qwerty_keyboard.glide + +object QwertyGlideGesturePolicy { + fun shouldStart( + pointCount: Int, + directDistance: Float, + elapsedMillis: Long, + distinctLetterKeysNearTrail: Int, + minMoveDistance: Float, + fastMoveDistance: Float, + minElapsedMillis: Long + ): Boolean { + if (pointCount < 3) return false + if (distinctLetterKeysNearTrail < 2) return false + val fastMove = directDistance >= fastMoveDistance + val deliberateMove = directDistance >= minMoveDistance && elapsedMillis >= minElapsedMillis + return fastMove || deliberateMove + } +} diff --git a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideModels.kt b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideModels.kt new file mode 100644 index 000000000..607ff1444 --- /dev/null +++ b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideModels.kt @@ -0,0 +1,47 @@ +package com.kazumaproject.qwerty_keyboard.glide + +data class QwertyInputPointerPoint( + val x: Int, + val y: Int, + val time: Int, + val pointerId: Int +) + +data class QwertyInputPointers( + val points: List +) + +data class QwertyKeyProximity( + val char: Char, + val centerX: Float, + val centerY: Float, + val width: Float, + val height: Float, + val rowIndex: Int, + val columnIndex: Int, + val neighborChars: List +) + +data class QwertyKeyboardProximityInfo( + val keys: List, + val keyboardWidth: Int, + val keyboardHeight: Int, + val averageKeyWidth: Float, + val averageKeyHeight: Float +) + +interface QwertyGlideInputListener { + fun onQwertyGlideStarted() + + fun onQwertyGlideUpdated( + inputPointers: QwertyInputPointers, + proximityInfo: QwertyKeyboardProximityInfo + ) + + fun onQwertyGlideEnded( + inputPointers: QwertyInputPointers, + proximityInfo: QwertyKeyboardProximityInfo + ) + + fun onQwertyGlideCancelled() +} diff --git a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt index bf65be6ad..3999b1522 100644 --- a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt +++ b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt @@ -4,7 +4,10 @@ import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.content.res.Configuration +import android.graphics.Canvas import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path import android.graphics.Rect import android.graphics.Typeface import android.graphics.drawable.Drawable @@ -57,6 +60,12 @@ import com.kazumaproject.core.domain.qwerty.QWERTYKey import com.kazumaproject.core.domain.qwerty.QWERTYKeyInfo import com.kazumaproject.core.domain.qwerty.QWERTYKeyMap import com.kazumaproject.core.domain.state.QWERTYMode +import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideGesturePolicy +import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideInputListener +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointerPoint +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyProximity +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo import com.kazumaproject.qwerty_keyboard.R import com.kazumaproject.qwerty_keyboard.databinding.QwertyLayoutBinding import kotlinx.coroutines.CoroutineScope @@ -72,6 +81,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.math.abs +import kotlin.math.hypot /** * A custom keyboard view with dynamic margins. @@ -160,6 +170,30 @@ class QWERTYKeyboardView @JvmOverloads constructor( private var liquidGlassEnable: Boolean = false + private var qwertyGlideInputMode: Boolean = false + private var qwertyGlideInputListener: QwertyGlideInputListener? = null + private var glideCandidatePointerId: Int? = null + private var glideStarted = false + private var glideDownTime = 0L + private var glideLastSampleX = 0f + private var glideLastSampleY = 0f + private var lastNonGlideKeyUpTime = 0L + private val glideRawPoints = mutableListOf() + private val glideTrailPoints = mutableListOf>() + private val glideTrailPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(170, 66, 133, 244) + strokeWidth = context.resources.displayMetrics.density * 5f + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + style = Paint.Style.STROKE + } + private val glideTrailPath = Path() + private val glideMinMoveDistance by lazy { ViewConfiguration.get(context).scaledTouchSlop * 2.4f } + private val glideFastMoveDistance by lazy { ViewConfiguration.get(context).scaledTouchSlop * 3.0f } + private val glideSamplingMinDistance by lazy { ViewConfiguration.get(context).scaledTouchSlop * 0.45f } + private val glideMinElapsedMillis = 45L + private val glideFastTypingSuppressMillis = 55L + // ★ ポップアップなしで長押しを有効にするキーのリストを追加 private val longPressEnabledKeys = setOf( QWERTYKey.QWERTYKeyDelete, @@ -1188,6 +1222,27 @@ class QWERTYKeyboardView @JvmOverloads constructor( ) } + private val qRowLetterViews: List by lazy { + listOf( + binding.keyQ, binding.keyW, binding.keyE, binding.keyR, binding.keyT, + binding.keyY, binding.keyU, binding.keyI, binding.keyO, binding.keyP + ) + } + + private val aRowLetterViews: List by lazy { + listOf( + binding.keyA, binding.keyS, binding.keyD, binding.keyF, binding.keyG, + binding.keyH, binding.keyJ, binding.keyK, binding.keyL + ) + } + + private val zRowLetterViews: List by lazy { + listOf( + binding.keyZ, binding.keyX, binding.keyC, binding.keyV, binding.keyB, + binding.keyN, binding.keyM + ) + } + private val defaultQWERTYButtonsRoman: Array by lazy { arrayOf( // Top row @@ -1240,6 +1295,72 @@ class QWERTYKeyboardView @JvmOverloads constructor( this.qwertyKeyListener = listener } + fun setQwertyGlideInputMode(enabled: Boolean) { + if (qwertyGlideInputMode == enabled) return + qwertyGlideInputMode = enabled + if (!enabled) { + cancelQwertyGlideCandidate(notify = false) + } + } + + fun setQwertyGlideInputListener(listener: QwertyGlideInputListener?) { + qwertyGlideInputListener = listener + } + + fun getQwertyKeyboardProximityInfo(): QwertyKeyboardProximityInfo { + val letterViews = getVisibleQwertyLetterViews() + val keysWithoutNeighbors = letterViews.mapIndexed { index, view -> + val row = when (view) { + in qRowLetterViews -> 0 + in aRowLetterViews -> 1 + else -> 2 + } + val column = when (row) { + 0 -> qRowLetterViews.indexOf(view) + 1 -> aRowLetterViews.indexOf(view) + else -> zRowLetterViews.indexOf(view) + } + val ch = view.text?.firstOrNull()?.lowercaseChar() ?: ('a' + index) + QwertyKeyProximity( + char = ch, + centerX = view.left + view.width / 2f, + centerY = view.top + view.height / 2f, + width = view.width.toFloat(), + height = view.height.toFloat(), + rowIndex = row, + columnIndex = column, + neighborChars = emptyList() + ) + }.filter { it.char in 'a'..'z' } + + val averageKeyWidth = keysWithoutNeighbors.map { it.width }.average().toFloatOrDefault() + val averageKeyHeight = keysWithoutNeighbors.map { it.height }.average().toFloatOrDefault() + val neighborRadius = hypot(averageKeyWidth, averageKeyHeight) * 1.35f + val keys = keysWithoutNeighbors.map { key -> + key.copy( + neighborChars = keysWithoutNeighbors + .asSequence() + .filter { it.char != key.char } + .map { other -> + other.char to hypot(key.centerX - other.centerX, key.centerY - other.centerY) + } + .filter { (_, distance) -> distance <= neighborRadius } + .sortedBy { (_, distance) -> distance } + .map { (char, _) -> char } + .take(8) + .toList() + ) + } + + return QwertyKeyboardProximityInfo( + keys = keys, + keyboardWidth = width, + keyboardHeight = height, + averageKeyWidth = averageKeyWidth, + averageKeyHeight = averageKeyHeight + ) + } + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { return event.actionMasked == MotionEvent.ACTION_DOWN } @@ -1280,6 +1401,10 @@ class QWERTYKeyboardView @JvmOverloads constructor( return true } + if (handleQwertyGlideTouchEvent(event)) { + return true + } + when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { if (event.pointerCount > 1 || pointerButtonMap.isNotEmpty()) { @@ -1451,6 +1576,7 @@ class QWERTYKeyboardView @JvmOverloads constructor( } } clearAllPressed() + lastNonGlideKeyUpTime = SystemClock.uptimeMillis() } MotionEvent.ACTION_CANCEL -> { @@ -1459,11 +1585,93 @@ class QWERTYKeyboardView @JvmOverloads constructor( variationPopupView = null longPressedPointerId = null clearAllPressed() + cancelQwertyGlideCandidate(notify = true) } } return true } + private fun handleQwertyGlideTouchEvent(event: MotionEvent): Boolean { + if (!qwertyGlideInputMode || romajiModeState.value) { + return false + } + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + beginQwertyGlideCandidateIfPossible(event, event.actionIndex) + return false + } + + MotionEvent.ACTION_POINTER_DOWN -> { + if (glideCandidatePointerId != null) { + cancelQwertyGlideCandidate(notify = glideStarted) + } + return false + } + + MotionEvent.ACTION_MOVE -> { + val pointerId = glideCandidatePointerId ?: return false + val pointerIndex = event.findPointerIndex(pointerId) + if (pointerIndex < 0) { + cancelQwertyGlideCandidate(notify = glideStarted) + return false + } + appendHistoricalQwertyGlidePoints(event, pointerIndex, pointerId) + appendQwertyGlidePoint( + x = event.getX(pointerIndex), + y = event.getY(pointerIndex), + eventTime = event.eventTime, + pointerId = pointerId + ) + if (!isInsideQwertyGlideGestureArea(event.getX(pointerIndex), event.getY(pointerIndex))) { + cancelQwertyGlideCandidate(notify = glideStarted) + return true + } + if (!glideStarted && shouldStartQwertyGlide(event)) { + startQwertyGlide(pointerId) + } + if (glideStarted) { + qwertyGlideInputListener?.onQwertyGlideUpdated( + inputPointers = QwertyInputPointers(glideRawPoints.toList()), + proximityInfo = getQwertyKeyboardProximityInfo() + ) + return true + } + return false + } + + MotionEvent.ACTION_UP -> { + val pointerId = glideCandidatePointerId ?: return false + val liftedId = event.getPointerId(event.actionIndex) + if (liftedId != pointerId) return false + appendQwertyGlidePoint( + x = event.getX(event.actionIndex), + y = event.getY(event.actionIndex), + eventTime = event.eventTime, + pointerId = pointerId + ) + return if (glideStarted) { + qwertyGlideInputListener?.onQwertyGlideEnded( + inputPointers = QwertyInputPointers(glideRawPoints.toList()), + proximityInfo = getQwertyKeyboardProximityInfo() + ) + clearQwertyGlideState(clearTrail = true) + clearAllPressed() + true + } else { + clearQwertyGlideState(clearTrail = true) + false + } + } + + MotionEvent.ACTION_CANCEL -> { + val consumed = glideStarted + cancelQwertyGlideCandidate(notify = glideStarted) + return consumed + } + } + return false + } + private fun setToggleShiftState(view: View) { if (view.id == binding.keyShift.id) { if (capsLockState.value.capsLockOn || capsLockState.value.shiftOn) { @@ -1478,7 +1686,141 @@ class QWERTYKeyboardView @JvmOverloads constructor( } } + private fun beginQwertyGlideCandidateIfPossible(event: MotionEvent, pointerIndex: Int) { + val pointerId = event.getPointerId(pointerIndex) + val x = event.getX(pointerIndex) + val y = event.getY(pointerIndex) + val downView = findButtonUnder(x.toInt(), y.toInt()) + if (!isQwertyGlideLetterView(downView)) return + if (SystemClock.uptimeMillis() - lastNonGlideKeyUpTime < glideFastTypingSuppressMillis) return + + glideCandidatePointerId = pointerId + glideStarted = false + glideDownTime = event.eventTime + glideRawPoints.clear() + glideTrailPoints.clear() + glideLastSampleX = x + glideLastSampleY = y + appendQwertyGlidePoint(x, y, event.eventTime, pointerId, force = true) + } + + private fun startQwertyGlide(pointerId: Int) { + val pressedView = pointerButtonMap[pointerId] + pressedView?.isPressed = false + dismissKeyPreview() + cancelLongPressForPointer(pointerId) + variationPopup?.dismiss() + variationPopup = null + variationPopupView = null + longPressedPointerId = null + pointerButtonMap.remove(pointerId) + pointerStartCoords.remove(pointerId) + flickLockedPointers.add(pointerId) + glideStarted = true + qwertyGlideInputListener?.onQwertyGlideStarted() + } + + private fun appendHistoricalQwertyGlidePoints( + event: MotionEvent, + pointerIndex: Int, + pointerId: Int + ) { + for (historyIndex in 0 until event.historySize) { + appendQwertyGlidePoint( + x = event.getHistoricalX(pointerIndex, historyIndex), + y = event.getHistoricalY(pointerIndex, historyIndex), + eventTime = event.getHistoricalEventTime(historyIndex), + pointerId = pointerId + ) + } + } + + private fun appendQwertyGlidePoint( + x: Float, + y: Float, + eventTime: Long, + pointerId: Int, + force: Boolean = false + ) { + if (!force && hypot(x - glideLastSampleX, y - glideLastSampleY) < glideSamplingMinDistance) { + return + } + glideLastSampleX = x + glideLastSampleY = y + val relativeTime = (eventTime - glideDownTime).coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + glideRawPoints.add( + QwertyInputPointerPoint( + x = x.toInt(), + y = y.toInt(), + time = relativeTime, + pointerId = pointerId + ) + ) + glideTrailPoints.add(x to y) + invalidate() + } + + private fun shouldStartQwertyGlide(event: MotionEvent): Boolean { + if (glideRawPoints.size < 3) return false + val first = glideRawPoints.first() + val last = glideRawPoints.last() + val directDistance = hypot( + (last.x - first.x).toFloat(), + (last.y - first.y).toFloat() + ) + val elapsed = event.eventTime - glideDownTime + return QwertyGlideGesturePolicy.shouldStart( + pointCount = glideRawPoints.size, + directDistance = directDistance, + elapsedMillis = elapsed, + distinctLetterKeysNearTrail = countDistinctQwertyLetterKeysNearTrail(), + minMoveDistance = glideMinMoveDistance, + fastMoveDistance = glideFastMoveDistance, + minElapsedMillis = glideMinElapsedMillis + ) + } + + private fun countDistinctQwertyLetterKeysNearTrail(): Int { + if (glideRawPoints.isEmpty()) return 0 + val proximityInfo = getQwertyKeyboardProximityInfo() + val radius = (proximityInfo.averageKeyWidth.coerceAtLeast(1f) * 0.75f) + return glideRawPoints.mapNotNull { point -> + proximityInfo.keys + .minByOrNull { key -> hypot(point.x - key.centerX, point.y - key.centerY) } + ?.takeIf { key -> hypot(point.x - key.centerX, point.y - key.centerY) <= radius } + ?.char + }.distinct().size + } + + private fun isInsideQwertyGlideGestureArea(x: Float, y: Float): Boolean { + val keys = getVisibleQwertyLetterViews() + if (keys.isEmpty()) return false + val left = keys.minOf { it.left }.toFloat() - keySideMarginDp * resources.displayMetrics.density * 3f + val right = keys.maxOf { it.right }.toFloat() + keySideMarginDp * resources.displayMetrics.density * 3f + val top = keys.minOf { it.top }.toFloat() - keyVerticalMarginDp * resources.displayMetrics.density * 3f + val bottom = keys.maxOf { it.bottom }.toFloat() + keyVerticalMarginDp * resources.displayMetrics.density * 3f + return x in left..right && y in top..bottom + } + + private fun cancelQwertyGlideCandidate(notify: Boolean) { + if (notify) qwertyGlideInputListener?.onQwertyGlideCancelled() + clearQwertyGlideState(clearTrail = true) + clearAllPressed() + } + + private fun clearQwertyGlideState(clearTrail: Boolean) { + glideCandidatePointerId = null + glideStarted = false + glideDownTime = 0L + glideRawPoints.clear() + if (clearTrail) { + glideTrailPoints.clear() + invalidate() + } + } + fun resetQWERTYKeyboard() { + cancelQwertyGlideCandidate(notify = glideStarted) clearShiftCaps() _qwertyMode.update { QWERTYMode.Default } _romajiModeState.update { false } @@ -1489,6 +1831,7 @@ class QWERTYKeyboardView @JvmOverloads constructor( } fun resetQWERTYKeyboard(enterKyeText: String) { + cancelQwertyGlideCandidate(notify = glideStarted) clearShiftCaps() _qwertyMode.update { QWERTYMode.Default } _romajiModeState.update { false } @@ -1500,6 +1843,7 @@ class QWERTYKeyboardView @JvmOverloads constructor( } fun setRomajiKeyboard(enterKeyText: String) { + cancelQwertyGlideCandidate(notify = glideStarted) clearShiftCaps() _qwertyMode.update { QWERTYMode.Default } _romajiModeState.update { true } @@ -1538,6 +1882,7 @@ class QWERTYKeyboardView @JvmOverloads constructor( * もう一方の QWERTYKeyboardView へ現在状態を伝搬する用途で利用する。 */ fun renderUiState(state: QwertyKeyboardUiState) { + cancelQwertyGlideCandidate(notify = glideStarted) // romaji を先に反映してから qwertyMode を反映することで、 // applyContentForMode で参照される romajiMode の値が正しい状態で // 各キーラベルが描画されるようにする。 @@ -1896,6 +2241,35 @@ class QWERTYKeyboardView @JvmOverloads constructor( return nearestView } + private fun getVisibleQwertyLetterViews(): List { + if (qwertyMode.value != QWERTYMode.Default) return emptyList() + return (qRowLetterViews + aRowLetterViews + zRowLetterViews) + .filter { it.isVisible && it.text?.singleOrNull()?.lowercaseChar() in 'a'..'z' } + } + + private fun isQwertyGlideLetterView(view: View?): Boolean { + if (view !is QWERTYButton) return false + return view in getVisibleQwertyLetterViews() + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + if (glideTrailPoints.size < 2) return + glideTrailPath.reset() + val first = glideTrailPoints.first() + glideTrailPath.moveTo(first.first, first.second) + for (i in 1 until glideTrailPoints.size) { + val previous = glideTrailPoints[i - 1] + val current = glideTrailPoints[i] + val midX = (previous.first + current.first) / 2f + val midY = (previous.second + current.second) / 2f + glideTrailPath.quadTo(previous.first, previous.second, midX, midY) + } + val last = glideTrailPoints.last() + glideTrailPath.lineTo(last.first, last.second) + canvas.drawPath(glideTrailPath, glideTrailPaint) + } + private fun logVariationIfNeeded(key: QWERTYKey) { if (key == QWERTYKey.QWERTYKeySwitchMode) { when (qwertyMode.value) { @@ -2064,6 +2438,7 @@ class QWERTYKeyboardView @JvmOverloads constructor( fun setRomajiMode(state: Boolean) { Log.d("QWERTY Keyboard Debug", "romaji: [$state]") + if (state) cancelQwertyGlideCandidate(notify = glideStarted) _romajiModeState.update { state } } @@ -2280,3 +2655,7 @@ class QWERTYKeyboardView @JvmOverloads constructor( _qwertyMode.update { QWERTYMode.Default } } } + +private fun Double.toFloatOrDefault(defaultValue: Float = 0f): Float { + return if (isNaN()) defaultValue else toFloat() +} diff --git a/qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideGesturePolicyTest.kt b/qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideGesturePolicyTest.kt new file mode 100644 index 000000000..b9e6b977e --- /dev/null +++ b/qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideGesturePolicyTest.kt @@ -0,0 +1,67 @@ +package com.kazumaproject.qwerty_keyboard.glide + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class QwertyGlideGesturePolicyTest { + @Test + fun shortTapDoesNotStartGlide() { + assertFalse( + QwertyGlideGesturePolicy.shouldStart( + pointCount = 2, + directDistance = 8f, + elapsedMillis = 30L, + distinctLetterKeysNearTrail = 1, + minMoveDistance = 24f, + fastMoveDistance = 36f, + minElapsedMillis = 45L + ) + ) + } + + @Test + fun deliberateMovementAcrossLettersStartsGlide() { + assertTrue( + QwertyGlideGesturePolicy.shouldStart( + pointCount = 5, + directDistance = 30f, + elapsedMillis = 70L, + distinctLetterKeysNearTrail = 2, + minMoveDistance = 24f, + fastMoveDistance = 36f, + minElapsedMillis = 45L + ) + ) + } + + @Test + fun fastMovementCanStartBeforeMinimumElapsedTime() { + assertTrue( + QwertyGlideGesturePolicy.shouldStart( + pointCount = 4, + directDistance = 48f, + elapsedMillis = 24L, + distinctLetterKeysNearTrail = 2, + minMoveDistance = 24f, + fastMoveDistance = 36f, + minElapsedMillis = 45L + ) + ) + } + + @Test + fun movementWithinSingleKeyDoesNotStartGlide() { + assertFalse( + QwertyGlideGesturePolicy.shouldStart( + pointCount = 4, + directDistance = 50f, + elapsedMillis = 80L, + distinctLetterKeysNearTrail = 1, + minMoveDistance = 24f, + fastMoveDistance = 36f, + minElapsedMillis = 45L + ) + ) + } +} From ea082ab24f02bf1eea14a1727185c350b150a5ec Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Wed, 6 May 2026 06:11:12 -0400 Subject: [PATCH 2/6] fix glide input 2 --- .../english/LoadEnglishLOUDSTest.kt | 25 +-- .../converter/engine/EnglishEngine.kt | 131 ++++++++++++--- .../glide/QwertyGlideCandidatePrefilter.kt | 156 ++++++++++++++++++ .../glide/QwertyGlideCandidateReranker.kt | 12 +- .../converter/glide/QwertyGlideDecodeCache.kt | 74 +++++++++ .../glide/QwertyGlideDecodeMetrics.kt | 14 ++ .../glide/QwertyGlideDecodeOptions.kt | 2 + .../converter/glide/QwertyGlideDecoder.kt | 138 ++++++++++++++-- .../glide/QwertyGlideDictionaryProvider.kt | 14 +- .../QwertyGlideIndexedDictionaryProvider.kt | 133 +++++++++++++++ .../glide/QwertyGlideTopKSelector.kt | 60 +++++++ .../glide/QwertyGlideWordPathScorer.kt | 75 ++++++--- .../gemma/GemmaTranslationManager.kt | 14 +- .../ime_service/IMEService.kt | 14 ++ .../QwertyGlideInputCoordinator.kt | 6 + .../ui/setting/SafeNavigation.kt | 9 +- app/src/main/res/values-ja/strings.xml | 3 + .../QwertyGlideDecoderPerformanceTest.kt | 58 +++++++ .../QwertyGlideDecoderStagedAccuracyTest.kt | 88 ++++++++++ ...wertyGlideIndexedDictionaryProviderTest.kt | 65 ++++++++ .../glide/QwertyGlideTestFixtures.kt | 136 +++++++++++++++ .../glide/QwertyGlideTopKSelectorTest.kt | 57 +++++++ .../QwertyGlideWarmupLifecycleCacheTest.kt | 94 +++++++++++ gradle.properties | 1 + 24 files changed, 1289 insertions(+), 90 deletions(-) create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidatePrefilter.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeCache.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeMetrics.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideIndexedDictionaryProvider.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTopKSelector.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoderPerformanceTest.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoderStagedAccuracyTest.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideIndexedDictionaryProviderTest.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTestFixtures.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTopKSelectorTest.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWarmupLifecycleCacheTest.kt diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/english/LoadEnglishLOUDSTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/english/LoadEnglishLOUDSTest.kt index 44256d725..6758da4ee 100644 --- a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/english/LoadEnglishLOUDSTest.kt +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/english/LoadEnglishLOUDSTest.kt @@ -1,10 +1,13 @@ package com.kazumaproject.markdownhelperkeyboard.english import androidx.test.platform.app.InstrumentationRegistry +import com.kazumaproject.markdownhelperkeyboard.converter.bitset.SuccinctBitVector +import com.kazumaproject.markdownhelperkeyboard.converter.english.louds.louds_with_term_id.LOUDSWithTermId import org.junit.Before import org.junit.Test import java.io.BufferedInputStream import java.io.ObjectInputStream +import java.util.zip.ZipInputStream class LoadEnglishLOUDSTest { @Before @@ -16,22 +19,24 @@ class LoadEnglishLOUDSTest { fun testLoadEnglishLOUDS() { val context = InstrumentationRegistry.getInstrumentation().targetContext - val objectInput = ObjectInputStream( - BufferedInputStream(context.assets.open("english/english.dat")) - ) - val result = EnglishLOUDS().readExternal(objectInput) - println("Loaded object: ${result.costListSave.size}") - objectInput.close() + val zipInputStream = ZipInputStream(context.assets.open("english/reading.dat.zip")) + zipInputStream.nextEntry + val result = ObjectInputStream(BufferedInputStream(zipInputStream)).use { objectInput -> + LOUDSWithTermId().readExternalNotCompress(objectInput) + } + val succinctBitVector = SuccinctBitVector(result.LBS) + val leafBitVector = SuccinctBitVector(result.isLeaf) + println("Loaded object: ${result.labels.size}") val text = "on" - val commonPrefixSearch = result.commonPrefixSearch(text) + val commonPrefixSearch = result.commonPrefixSearch(text, succinctBitVector) val searchResult = commonPrefixSearch.map { - Pair(it, result.getTermId(result.getNodeIndex(it))) + Pair(it, result.getTermId(result.getNodeIndex(it, succinctBitVector), leafBitVector)) } println(searchResult) - val suggestions = result.predictiveSearch("i", 4) + val suggestions = result.predictiveSearch("i", succinctBitVector, 4) val pairs = suggestions.map { term -> - term to result.getTermId(result.getNodeIndex(term)) + term to result.getTermId(result.getNodeIndex(term, succinctBitVector), leafBitVector) }.toMutableList() pairs.sortBy { it.second } println(pairs) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt index 5717410e9..dcd542d94 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt @@ -5,12 +5,20 @@ import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate import com.kazumaproject.markdownhelperkeyboard.converter.english.louds.LOUDS import com.kazumaproject.markdownhelperkeyboard.converter.english.louds.louds_with_term_id.LOUDSWithTermId import com.kazumaproject.markdownhelperkeyboard.converter.english.tokenArray.TokenArray -import com.kazumaproject.markdownhelperkeyboard.converter.glide.InMemoryQwertyGlideDictionaryProvider +import com.kazumaproject.markdownhelperkeyboard.BuildConfig import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideDecodeOptions import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideDecoder +import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideDecodeMetrics import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideDictionaryEntry +import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideIndexedDictionaryProvider import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import timber.log.Timber class EnglishEngine { @@ -23,6 +31,13 @@ class EnglishEngine { private lateinit var succinctBitVectorLBSWord: SuccinctBitVector @Volatile private var qwertyGlideDecoder: QwertyGlideDecoder? = null + @Volatile + private var qwertyFallbackGlideDecoder: QwertyGlideDecoder? = null + @Volatile + private var qwertyGlideDictionaryReady: Boolean = false + @Volatile + private var qwertyGlideWarmupJob: Job? = null + private val qwertyGlideWarmupScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) companion object { const val LENGTH_MULTIPLY = 2000 @@ -44,6 +59,12 @@ class EnglishEngine { this.succinctBitVectorLBSWord = englishSuccinctBitVectorLBSWord this.succinctBitVectorReadingIsLeaf = englishSuccinctBitVectorReadingIsLeaf this.succinctBitVectorTokenArray = englishSuccinctBitVectorTokenArray + qwertyGlideDictionaryReady = false + qwertyGlideDecoder = null + qwertyFallbackGlideDecoder = createQwertyGlideDecoder( + entries = fallbackGlideDictionaryEntries(), + dictionaryReady = false + ) } fun getGlideCandidates( @@ -53,7 +74,11 @@ class EnglishEngine { limit: Int = 12 ): List { if (inputPointers.points.size < 2 || proximityInfo.keys.isEmpty()) return emptyList() - return getOrCreateQwertyGlideDecoder().decode( + val decoder = qwertyGlideDecoder ?: run { + warmUpQwertyGlideDecoderAsync() + getOrCreateFallbackQwertyGlideDecoder() + } + return decoder.decode( inputPointers = inputPointers, proximityInfo = proximityInfo, previousText = previousText, @@ -61,16 +86,91 @@ class EnglishEngine { ) } - private fun getOrCreateQwertyGlideDecoder(): QwertyGlideDecoder { - qwertyGlideDecoder?.let { return it } + fun warmUpQwertyGlideDecoderAsync() { + if (qwertyGlideDictionaryReady && qwertyGlideDecoder != null) return + synchronized(this) { + val existing = qwertyGlideWarmupJob + if (existing?.isActive == true) return + qwertyGlideWarmupJob = qwertyGlideWarmupScope.launch { + val startedAt = System.nanoTime() + val entries = buildGlideDictionaryEntries() + val decoder = createQwertyGlideDecoder( + entries = entries, + dictionaryReady = true + ) + qwertyGlideDecoder = decoder + qwertyGlideDictionaryReady = true + if (BuildConfig.DEBUG) { + Timber.d( + "QWERTY glide dictionary warmup complete: entries=${entries.size} elapsed_ms=${(System.nanoTime() - startedAt) / 1_000_000L}" + ) + } + } + } + } + + fun isQwertyGlideDictionaryReady(): Boolean = qwertyGlideDictionaryReady + + fun cancelQwertyGlideWarmup() { + qwertyGlideWarmupJob?.cancel() + qwertyGlideWarmupJob = null + } + + fun releaseQwertyGlideResources() { + cancelQwertyGlideWarmup() + qwertyGlideDecoder = null + qwertyFallbackGlideDecoder = null + qwertyGlideDictionaryReady = false + } + + fun invalidateQwertyGlideCache() { + qwertyGlideDecoder?.clearCache() + qwertyFallbackGlideDecoder?.clearCache() + } + + private fun getOrCreateFallbackQwertyGlideDecoder(): QwertyGlideDecoder { + qwertyFallbackGlideDecoder?.let { return it } return synchronized(this) { - qwertyGlideDecoder ?: QwertyGlideDecoder( - dictionaryProvider = InMemoryQwertyGlideDictionaryProvider(buildGlideDictionaryEntries()), - options = QwertyGlideDecodeOptions() - ).also { qwertyGlideDecoder = it } + qwertyFallbackGlideDecoder ?: createQwertyGlideDecoder( + entries = fallbackGlideDictionaryEntries(), + dictionaryReady = false + ).also { qwertyFallbackGlideDecoder = it } } } + private fun createQwertyGlideDecoder( + entries: Iterable, + dictionaryReady: Boolean + ): QwertyGlideDecoder { + return QwertyGlideDecoder( + dictionaryProvider = QwertyGlideIndexedDictionaryProvider(entries), + options = QwertyGlideDecodeOptions(), + dictionaryReady = dictionaryReady, + metricsListener = ::logQwertyGlideMetrics + ) + } + + private fun logQwertyGlideMetrics(metrics: QwertyGlideDecodeMetrics) { + if (!BuildConfig.DEBUG) return + Timber.d( + "QWERTY glide decode: dictionary_ready=${metrics.dictionaryReady} " + + "raw_bucket_candidate_count=${metrics.rawBucketCandidateCount} " + + "prefilter_candidate_count=${metrics.prefilterCandidateCount} " + + "full_score_candidate_count=${metrics.fullScoreCandidateCount} " + + "rerank_candidate_count=${metrics.rerankCandidateCount} " + + "decode_total_ms=${metrics.decodeTotalMs} prefilter_ms=${metrics.prefilterMs} " + + "full_score_ms=${metrics.fullScoreMs} rerank_ms=${metrics.rerankMs} " + + "cache_hit=${metrics.cacheHit}" + ) + } + + private fun fallbackGlideDictionaryEntries(): List { + return listOf( + "hello", "good", "test", "word", "world", "keyboard", "android", "sumire", + "coffee", "letter", "people", "glide", "time", "home", "something" + ).map { word -> QwertyGlideDictionaryEntry(word, 6000) } + } + private fun buildGlideDictionaryEntries(): List { val entries = linkedMapOf() val readings = readingLOUDS.predictiveSearch( @@ -110,18 +210,9 @@ class EnglishEngine { } } } - listOf( - "hello", - "good", - "test", - "word", - "keyboard", - "android", - "sumire", - "coffee", - "letter", - "people" - ).forEach { word -> entries.mergeGlideEntry(word, 6000) } + fallbackGlideDictionaryEntries().forEach { entry -> + entries.mergeGlideEntry(entry.word, entry.wordCost) + } return entries.values.toList() } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidatePrefilter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidatePrefilter.kt new file mode 100644 index 000000000..374b77466 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidatePrefilter.kt @@ -0,0 +1,156 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyProximity +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo +import kotlin.math.abs +import kotlin.math.hypot + +data class QwertyGlidePrefilteredCandidate( + val entry: QwertyGlideIndexedEntry, + val cheapCost: Float +) + +class QwertyGlideCandidatePrefilter( + private val options: QwertyGlideDecodeOptions = QwertyGlideDecodeOptions(), + private val topKSelector: QwertyGlideTopKSelector = QwertyGlideTopKSelector() +) { + fun prefilter( + entries: List, + stroke: NormalizedGlideStroke, + pointProbabilities: List>, + proximityInfo: QwertyKeyboardProximityInfo, + targetCount: Int = options.fullScoreCandidateLimit + ): List { + if (entries.isEmpty()) return emptyList() + val keyByChar = proximityInfo.keys.associateBy { it.char } + val strokeMask = pointProbabilities.strokeCharacterMask() + val scored = ArrayList(entries.size) + for (entry in entries) { + val cheapCost = cheapScore( + entry = entry, + stroke = stroke, + pointProbabilities = pointProbabilities, + proximityInfo = proximityInfo, + keyByChar = keyByChar, + strokeMask = strokeMask + ) ?: continue + scored.add(QwertyGlidePrefilteredCandidate(entry, cheapCost)) + } + val count = targetCount.coerceAtLeast(options.maxResults) + return topKSelector.selectPrefiltered(scored, count) + } + + private fun cheapScore( + entry: QwertyGlideIndexedEntry, + stroke: NormalizedGlideStroke, + pointProbabilities: List>, + proximityInfo: QwertyKeyboardProximityInfo, + keyByChar: Map, + strokeMask: Int + ): Float? { + val firstKey = keyByChar[entry.firstChar] ?: return null + val lastKey = keyByChar[entry.lastChar] ?: return null + val keyScale = proximityInfo.normalizedKeyScale() + val startCost = distance(stroke.points.first(), firstKey.normalizedCenter(proximityInfo)) / keyScale + val endCost = distance(stroke.points.last(), lastKey.normalizedCenter(proximityInfo)) / keyScale + if (startCost > options.startEndRejectCost || endCost > options.startEndRejectCost) return null + + val missingMask = entry.characterMask and strokeMask.inv() + val missingCost = missingMask.countOneBits() * 0.42f + val proximityCost = entry.word.fastProximityCost(pointProbabilities) + val rawPathLength = entry.word.rawPathLength(keyByChar) ?: return null + val lengthCost = roughLengthCost(stroke, rawPathLength, proximityInfo, entry.length) + val directionCost = roughDirectionCost(stroke, keyByChar[entry.word.first()], keyByChar[entry.word.last()]) + val transitionCost = if (entry.transitionMask == 0L) 0.06f else 0f + return options.startEndWeight * (startCost + endCost) + + options.proximityWeight * proximityCost + + options.lengthWeight * lengthCost + + missingCost + + directionCost + + transitionCost + } + + private fun roughLengthCost( + stroke: NormalizedGlideStroke, + rawPathLength: Float, + proximityInfo: QwertyKeyboardProximityInfo, + wordLength: Int + ): Float { + val keyWidth = proximityInfo.averageKeyWidth.coerceAtLeast(1f) + val strokeUnits = stroke.rawLength / keyWidth + val idealUnits = rawPathLength / keyWidth + val expectedMin = (wordLength - 1).coerceAtLeast(1) * 0.22f + return abs(strokeUnits - idealUnits) / wordLength.coerceAtLeast(1) + + (expectedMin - strokeUnits).coerceAtLeast(0f) * 0.65f + } + + private fun roughDirectionCost( + stroke: NormalizedGlideStroke, + first: QwertyKeyProximity?, + last: QwertyKeyProximity? + ): Float { + if (first == null || last == null || stroke.points.size < 2) return 0f + val sx = stroke.points.last().x - stroke.points.first().x + val sy = stroke.points.last().y - stroke.points.first().y + val wx = last.centerX - first.centerX + val wy = last.centerY - first.centerY + if (hypot(sx, sy) <= 0.001f || hypot(wx, wy) <= 0.001f) return 0f + val dot = sx * wx + sy * wy + return if (dot < 0f) 0.35f else 0f + } +} + +private fun List>.strokeCharacterMask(): Int { + var mask = 0 + for (probs in this) { + for (prob in probs) { + if (prob.char in 'a'..'z') mask = mask or (1 shl (prob.char - 'a')) + } + } + return mask +} + +private fun String.fastProximityCost(pointProbabilities: List>): Float { + var total = 0f + for (ch in this) { + var best = 5.0f + for (probs in pointProbabilities) { + val cost = probs.firstOrNull { it.char == ch }?.cost ?: continue + if (cost < best) best = cost + } + total += best + } + return total / length.coerceAtLeast(1) +} + +private fun String.rawPathLength(keyByChar: Map): Float? { + var length = 0f + var previous: QwertyKeyProximity? = null + for (ch in this) { + val key = keyByChar[ch] ?: return null + val prev = previous + if (prev != null) { + length += hypot(key.centerX - prev.centerX, key.centerY - prev.centerY) + } + previous = key + } + return length +} + +internal fun QwertyKeyboardProximityInfo.normalizedKeyScale(): Float { + return hypot( + averageKeyWidth / keyboardWidth.coerceAtLeast(1).toFloat(), + averageKeyHeight / keyboardHeight.coerceAtLeast(1).toFloat() + ).coerceAtLeast(0.01f) +} + +private fun QwertyKeyProximity.normalizedCenter( + proximityInfo: QwertyKeyboardProximityInfo +): Pair { + return centerX / proximityInfo.keyboardWidth.coerceAtLeast(1).toFloat() to + centerY / proximityInfo.keyboardHeight.coerceAtLeast(1).toFloat() +} + +private fun distance(point: NormalizedGlidePoint, keyPoint: Pair): Float { + return hypot(point.x - keyPoint.first, point.y - keyPoint.second) +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateReranker.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateReranker.kt index 348f80819..ca262225c 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateReranker.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateReranker.kt @@ -1,20 +1,18 @@ package com.kazumaproject.markdownhelperkeyboard.converter.glide -class QwertyGlideCandidateReranker { +class QwertyGlideCandidateReranker( + private val topKSelector: QwertyGlideTopKSelector = QwertyGlideTopKSelector() +) { fun rerank( candidates: List, previousText: String, limit: Int ): List { - val contextAdjusted = candidates.map { scored -> + val contextAdjusted = candidates.asSequence().map { scored -> val contextCost = contextCost(scored.entry.word, previousText) scored.copy(totalCost = scored.totalCost + contextCost) } - return contextAdjusted - .groupBy { it.entry.word } - .map { (_, values) -> values.minBy { it.totalCost } } - .sortedWith(compareBy { it.totalCost }.thenBy { it.entry.word }) - .take(limit) + return topKSelector.selectScored(contextAdjusted.asIterable(), limit) } private fun contextCost(word: String, previousText: String): Float { diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeCache.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeCache.kt new file mode 100644 index 000000000..4e19383dc --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeCache.kt @@ -0,0 +1,74 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo +import kotlin.math.roundToInt + +class QwertyGlideDecodeCache( + private val maxEntries: Int = 8 +) { + private val cache = object : LinkedHashMap>(maxEntries, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>): Boolean { + return size > maxEntries + } + } + + @Synchronized + fun get(key: Key): List? = cache[key] + + @Synchronized + fun put(key: Key, candidates: List) { + cache[key] = candidates + } + + @Synchronized + fun clear() { + cache.clear() + } + + data class Key( + val strokeSignature: String, + val geometrySignature: Int, + val previousTextSignature: Int + ) + + companion object { + fun keyOf( + stroke: NormalizedGlideStroke, + proximityInfo: QwertyKeyboardProximityInfo, + previousText: String + ): Key { + val strokeSignature = buildString { + append(stroke.points.size) + append(':') + append((stroke.rawLength / proximityInfo.averageKeyWidth.coerceAtLeast(1f) * 8f).roundToInt()) + for (point in stroke.points) { + append('|') + append((point.x * 64f).roundToInt()) + append(',') + append((point.y * 64f).roundToInt()) + } + } + return Key( + strokeSignature = strokeSignature, + geometrySignature = proximityInfo.geometrySignature(), + previousTextSignature = previousText.trim().lowercase().hashCode() + ) + } + } +} + +internal fun QwertyKeyboardProximityInfo.geometrySignature(): Int { + var result = keyboardWidth + result = 31 * result + keyboardHeight + result = 31 * result + averageKeyWidth.roundToInt() + result = 31 * result + averageKeyHeight.roundToInt() + for (key in keys.sortedBy { it.char }) { + result = 31 * result + key.char.code + result = 31 * result + key.centerX.roundToInt() + result = 31 * result + key.centerY.roundToInt() + result = 31 * result + key.width.roundToInt() + result = 31 * result + key.height.roundToInt() + } + return result +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeMetrics.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeMetrics.kt new file mode 100644 index 000000000..31d6497a5 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeMetrics.kt @@ -0,0 +1,14 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +data class QwertyGlideDecodeMetrics( + val dictionaryReady: Boolean, + val rawBucketCandidateCount: Int, + val prefilterCandidateCount: Int, + val fullScoreCandidateCount: Int, + val rerankCandidateCount: Int, + val decodeTotalMs: Long, + val prefilterMs: Long, + val fullScoreMs: Long, + val rerankMs: Long, + val cacheHit: Boolean +) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeOptions.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeOptions.kt index 380daeb60..894f1c9b1 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeOptions.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecodeOptions.kt @@ -7,6 +7,8 @@ data class QwertyGlideDecodeOptions( val maxWordLength: Int = 24, val minWordLength: Int = 2, val minSamplingDistanceRatio: Float = 0.22f, + val fullScoreCandidateLimit: Int = 384, + val startEndRejectCost: Float = 2.6f, val startEndWeight: Float = 4.8f, val pathWeight: Float = 5.4f, val proximityWeight: Float = 1.7f, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoder.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoder.kt index f4f690b9d..a60fe9b22 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoder.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoder.kt @@ -11,7 +11,11 @@ class QwertyGlideDecoder( private val strokeNormalizer: QwertyGlideStrokeNormalizer = QwertyGlideStrokeNormalizer(options), private val probabilityBuilder: QwertyGlideKeyProbabilityBuilder = QwertyGlideKeyProbabilityBuilder(options), private val wordPathScorer: QwertyGlideWordPathScorer = QwertyGlideWordPathScorer(options), - private val reranker: QwertyGlideCandidateReranker = QwertyGlideCandidateReranker() + private val reranker: QwertyGlideCandidateReranker = QwertyGlideCandidateReranker(), + private val prefilter: QwertyGlideCandidatePrefilter = QwertyGlideCandidatePrefilter(options), + private val cache: QwertyGlideDecodeCache = QwertyGlideDecodeCache(), + private val dictionaryReady: Boolean = true, + private val metricsListener: ((QwertyGlideDecodeMetrics) -> Unit)? = null ) { fun decode( inputPointers: QwertyInputPointers, @@ -19,9 +23,26 @@ class QwertyGlideDecoder( previousText: String, limit: Int = options.maxResults ): List { + val startedAt = System.nanoTime() if (inputPointers.points.size < 2 || proximityInfo.keys.isEmpty()) return emptyList() val stroke = strokeNormalizer.normalize(inputPointers, proximityInfo) if (stroke.points.size < 2) return emptyList() + val cacheKey = QwertyGlideDecodeCache.keyOf(stroke, proximityInfo, previousText) + cache.get(cacheKey)?.let { cached -> + emitMetrics( + startedAt = startedAt, + rawBucketCandidateCount = cached.size, + prefilterCandidateCount = cached.size, + fullScoreCandidateCount = 0, + rerankCandidateCount = cached.size, + prefilterMs = 0L, + fullScoreMs = 0L, + rerankMs = 0L, + cacheHit = true + ) + return cached.take(limit) + } + val pointProbabilities = probabilityBuilder.build(stroke, proximityInfo) if (pointProbabilities.isEmpty()) return emptyList() @@ -34,25 +55,61 @@ class QwertyGlideDecoder( options.maxWordLength ) + val prefilterStartedAt = System.nanoTime() + val indexedProvider = dictionaryProvider as? QwertyGlideIndexedDictionaryProvider + val rawBucketCandidateCount: Int + val fullScoreInputs: List + if (indexedProvider != null) { + val rawEntries = indexedProvider.indexedEntriesFor(startChars, endChars, minLength, maxLength) + rawBucketCandidateCount = rawEntries.size + fullScoreInputs = prefilter + .prefilter( + entries = rawEntries, + stroke = stroke, + pointProbabilities = pointProbabilities, + proximityInfo = proximityInfo, + targetCount = options.fullScoreCandidateLimit + ) + .map { it.entry.asDictionaryEntry() } + } else { + val rawEntries = ArrayList() + val seen = HashSet() + for (first in startChars) { + for (last in endChars) { + dictionaryProvider + .entriesFor(first, last, minLength, maxLength) + .forEach { entry -> + if (seen.add(entry.word)) rawEntries.add(entry) + } + } + } + rawBucketCandidateCount = rawEntries.size + fullScoreInputs = rawEntries + } + val prefilterMs = elapsedMs(prefilterStartedAt) + + val fullScoreStartedAt = System.nanoTime() val scored = ArrayList() - for (first in startChars) { - for (last in endChars) { - dictionaryProvider - .entriesFor(first, last, minLength, maxLength) - .forEach { entry -> - val score = wordPathScorer.score( - entry = entry, - stroke = stroke, - pointProbabilities = pointProbabilities, - proximityInfo = proximityInfo - ) - if (score != null) scored.add(score) - } + val keyByChar = proximityInfo.keys.associateBy { it.char } + val keyScale = proximityInfo.normalizedKeyScale() + for (entry in fullScoreInputs) { + val score = wordPathScorer.score( + entry = entry, + stroke = stroke, + pointProbabilities = pointProbabilities, + proximityInfo = proximityInfo, + keyByChar = keyByChar, + keyScale = keyScale + ) + if (score != null) { + scored.add(score) } } + val fullScoreMs = elapsedMs(fullScoreStartedAt) - return reranker - .rerank(scored, previousText, limit.coerceAtMost(options.maxResults)) + val rerankStartedAt = System.nanoTime() + val candidates = reranker + .rerank(scored, previousText, options.maxResults) .map { scoredWord -> Candidate( string = scoredWord.entry.word, @@ -61,5 +118,54 @@ class QwertyGlideDecoder( score = (scoredWord.totalCost * 1000f).toInt().coerceAtLeast(1) ) } + val rerankMs = elapsedMs(rerankStartedAt) + cache.put(cacheKey, candidates) + emitMetrics( + startedAt = startedAt, + rawBucketCandidateCount = rawBucketCandidateCount, + prefilterCandidateCount = fullScoreInputs.size, + fullScoreCandidateCount = fullScoreInputs.size, + rerankCandidateCount = scored.size, + prefilterMs = prefilterMs, + fullScoreMs = fullScoreMs, + rerankMs = rerankMs, + cacheHit = false + ) + return candidates.take(limit.coerceAtMost(options.maxResults)) + } + + fun clearCache() { + cache.clear() + } + + private fun emitMetrics( + startedAt: Long, + rawBucketCandidateCount: Int, + prefilterCandidateCount: Int, + fullScoreCandidateCount: Int, + rerankCandidateCount: Int, + prefilterMs: Long, + fullScoreMs: Long, + rerankMs: Long, + cacheHit: Boolean + ) { + metricsListener?.invoke( + QwertyGlideDecodeMetrics( + dictionaryReady = dictionaryReady, + rawBucketCandidateCount = rawBucketCandidateCount, + prefilterCandidateCount = prefilterCandidateCount, + fullScoreCandidateCount = fullScoreCandidateCount, + rerankCandidateCount = rerankCandidateCount, + decodeTotalMs = elapsedMs(startedAt), + prefilterMs = prefilterMs, + fullScoreMs = fullScoreMs, + rerankMs = rerankMs, + cacheHit = cacheHit + ) + ) + } + + private fun elapsedMs(startedAt: Long): Long { + return (System.nanoTime() - startedAt) / 1_000_000L } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDictionaryProvider.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDictionaryProvider.kt index a94066636..126e4ebc0 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDictionaryProvider.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDictionaryProvider.kt @@ -17,14 +17,7 @@ interface QwertyGlideDictionaryProvider { class InMemoryQwertyGlideDictionaryProvider( entries: Iterable ) : QwertyGlideDictionaryProvider { - private val indexedEntries: Map, List> = - entries - .asSequence() - .filter { it.word.length >= 2 } - .map { it.copy(word = it.word.lowercase()) } - .filter { it.word.all { ch -> ch in 'a'..'z' } } - .distinctBy { it.word } - .groupBy { it.word.first() to it.word.last() } + private val indexedProvider = QwertyGlideIndexedDictionaryProvider(entries) override fun entriesFor( firstChar: Char, @@ -32,9 +25,6 @@ class InMemoryQwertyGlideDictionaryProvider( minLength: Int, maxLength: Int ): Sequence { - return indexedEntries[firstChar to lastChar] - .orEmpty() - .asSequence() - .filter { it.word.length in minLength..maxLength } + return indexedProvider.entriesFor(firstChar, lastChar, minLength, maxLength) } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideIndexedDictionaryProvider.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideIndexedDictionaryProvider.kt new file mode 100644 index 000000000..6000c49a2 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideIndexedDictionaryProvider.kt @@ -0,0 +1,133 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +data class QwertyGlideIndexedEntry( + val word: String, + val wordCost: Int, + val firstChar: Char, + val lastChar: Char, + val length: Int, + val characterMask: Int, + val transitionMask: Long +) { + fun asDictionaryEntry(): QwertyGlideDictionaryEntry { + return QwertyGlideDictionaryEntry(word = word, wordCost = wordCost) + } +} + +class QwertyGlideIndexedDictionaryProvider( + entries: Iterable +) : QwertyGlideDictionaryProvider { + private val indexedEntries: Map, List> + val entryCount: Int + + init { + val deduped = linkedMapOf() + entries.asSequence() + .mapNotNull { entry -> + val normalized = entry.word.lowercase() + if (normalized.length < 2 || normalized.any { it !in 'a'..'z' }) { + null + } else { + QwertyGlideDictionaryEntry( + word = normalized, + wordCost = entry.wordCost.coerceAtLeast(0) + ) + } + } + .forEach { entry -> + val existing = deduped[entry.word] + if (existing == null || entry.wordCost < existing.wordCost) { + deduped[entry.word] = entry + } + } + + val indexed = deduped.values + .map { it.toIndexedEntry() } + .sortedWith(compareBy { it.firstChar } + .thenBy { it.lastChar } + .thenBy { it.length } + .thenBy { it.word } + .thenBy { it.wordCost }) + + entryCount = indexed.size + indexedEntries = indexed.groupBy { it.firstChar to it.lastChar } + } + + override fun entriesFor( + firstChar: Char, + lastChar: Char, + minLength: Int, + maxLength: Int + ): Sequence { + return indexedEntries[firstChar.lowercaseChar() to lastChar.lowercaseChar()] + .orEmpty() + .asSequence() + .filter { it.length in minLength..maxLength } + .map { it.asDictionaryEntry() } + } + + fun indexedEntriesFor( + firstChars: Collection, + lastChars: Collection, + minLength: Int, + maxLength: Int + ): List { + if (firstChars.isEmpty() || lastChars.isEmpty() || minLength > maxLength) return emptyList() + val result = ArrayList() + val seen = HashSet() + for (first in firstChars.map { it.lowercaseChar() }.distinct()) { + for (last in lastChars.map { it.lowercaseChar() }.distinct()) { + indexedEntries[first to last].orEmpty() + .asSequence() + .filter { it.length in minLength..maxLength } + .forEach { entry -> + if (seen.add(entry.word)) result.add(entry) + } + } + } + result.sortWith(compareBy { it.word }.thenBy { it.wordCost }) + return result + } + + fun candidateCountFor( + firstChars: Collection, + lastChars: Collection, + minLength: Int, + maxLength: Int + ): Int { + return indexedEntriesFor(firstChars, lastChars, minLength, maxLength).size + } +} + +private fun QwertyGlideDictionaryEntry.toIndexedEntry(): QwertyGlideIndexedEntry { + return QwertyGlideIndexedEntry( + word = word, + wordCost = wordCost, + firstChar = word.first(), + lastChar = word.last(), + length = word.length, + characterMask = word.characterMask(), + transitionMask = word.transitionMask() + ) +} + +internal fun String.characterMask(): Int { + var mask = 0 + for (ch in this) { + if (ch in 'a'..'z') mask = mask or (1 shl (ch - 'a')) + } + return mask +} + +private fun String.transitionMask(): Long { + var mask = 0L + for (i in 1 until length) { + val from = this[i - 1] - 'a' + val to = this[i] - 'a' + if (from in 0..25 && to in 0..25) { + val bucket = ((from * 31 + to) and 63) + mask = mask or (1L shl bucket) + } + } + return mask +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTopKSelector.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTopKSelector.kt new file mode 100644 index 000000000..3ef12460d --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTopKSelector.kt @@ -0,0 +1,60 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import java.util.PriorityQueue + +class QwertyGlideTopKSelector { + fun selectScored( + candidates: Iterable, + limit: Int + ): List { + if (limit <= 0) return emptyList() + val bestByWord = linkedMapOf() + for (candidate in candidates) { + val existing = bestByWord[candidate.entry.word] + if (existing == null || finalComparator.compare(candidate, existing) < 0) { + bestByWord[candidate.entry.word] = candidate + } + } + return boundedTopK(bestByWord.values, limit, finalComparator) + } + + fun selectPrefiltered( + candidates: Iterable, + limit: Int + ): List { + if (limit <= 0) return emptyList() + return boundedTopK(candidates, limit, prefilterComparator) + } + + private fun boundedTopK( + candidates: Iterable, + limit: Int, + bestFirstComparator: Comparator + ): List { + val worstFirstComparator = Comparator { left, right -> + bestFirstComparator.compare(right, left) + } + val heap = PriorityQueue(limit + 1, worstFirstComparator) + for (candidate in candidates) { + if (heap.size < limit) { + heap.add(candidate) + } else if (bestFirstComparator.compare(candidate, heap.peek()) < 0) { + heap.poll() + heap.add(candidate) + } + } + return heap.toList().sortedWith(bestFirstComparator) + } + + companion object { + val finalComparator: Comparator = + compareBy { it.totalCost } + .thenBy { it.entry.word } + .thenBy { it.entry.wordCost } + + val prefilterComparator: Comparator = + compareBy { it.cheapCost } + .thenBy { it.entry.word } + .thenBy { it.entry.wordCost } + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWordPathScorer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWordPathScorer.kt index 3634cbed4..dab0d0933 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWordPathScorer.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWordPathScorer.kt @@ -20,34 +20,65 @@ class QwertyGlideWordPathScorer( stroke: NormalizedGlideStroke, pointProbabilities: List>, proximityInfo: QwertyKeyboardProximityInfo + ): QwertyGlideScoredWord? { + return score( + entry = entry, + stroke = stroke, + pointProbabilities = pointProbabilities, + proximityInfo = proximityInfo, + keyByChar = proximityInfo.keys.associateBy { it.char }, + keyScale = proximityInfo.normalizedKeyScale() + ) + } + + fun score( + entry: QwertyGlideDictionaryEntry, + stroke: NormalizedGlideStroke, + pointProbabilities: List>, + proximityInfo: QwertyKeyboardProximityInfo, + keyByChar: Map, + keyScale: Float ): QwertyGlideScoredWord? { if (stroke.points.size < 2) return null - val keyByChar = proximityInfo.keys.associateBy { it.char } - val wordKeys = entry.word.map { keyByChar[it] ?: return null } + val wordKeys = ArrayList(entry.word.length) + for (ch in entry.word) { + wordKeys.add(keyByChar[ch] ?: return null) + } val normalizedPath = wordKeys.toNormalizedPath(proximityInfo) - val rawPath = wordKeys.map { it.centerX to it.centerY } - val keyScale = hypot( - proximityInfo.averageKeyWidth / proximityInfo.keyboardWidth.coerceAtLeast(1).toFloat(), - proximityInfo.averageKeyHeight / proximityInfo.keyboardHeight.coerceAtLeast(1).toFloat() - ).coerceAtLeast(0.01f) + val rawPath = ArrayList>(wordKeys.size) + for (key in wordKeys) { + rawPath.add(key.centerX to key.centerY) + } val startCost = distance(stroke.points.first(), normalizedPath.first()) / keyScale val endCost = distance(stroke.points.last(), normalizedPath.last()) / keyScale - if (startCost > 2.6f || endCost > 2.6f) return null - - val pathShapeCost = stroke.points - .map { point -> distanceToPolyline(point.x, point.y, normalizedPath) / keyScale } - .average() - .toFloat() - val keyPassCost = normalizedPath - .map { keyPoint -> distanceToStrokePolyline(keyPoint.first, keyPoint.second, stroke.points) / keyScale } - .average() - .toFloat() - val proximityCost = entry.word.map { ch -> - pointProbabilities.minOfOrNull { probs -> - probs.firstOrNull { it.char == ch }?.cost ?: 5.0f - } ?: 5.0f - }.average().toFloat() + if (startCost > options.startEndRejectCost || endCost > options.startEndRejectCost) return null + + var pathShapeTotal = 0f + for (point in stroke.points) { + pathShapeTotal += distanceToPolyline(point.x, point.y, normalizedPath) / keyScale + } + val pathShapeCost = pathShapeTotal / stroke.points.size + + var keyPassTotal = 0f + for (keyPoint in normalizedPath) { + keyPassTotal += distanceToStrokePolyline(keyPoint.first, keyPoint.second, stroke.points) / keyScale + } + val keyPassCost = keyPassTotal / normalizedPath.size.coerceAtLeast(1) + + var proximityTotal = 0f + for (ch in entry.word) { + var best = 5.0f + for (probs in pointProbabilities) { + for (prob in probs) { + if (prob.char == ch && prob.cost < best) { + best = prob.cost + } + } + } + proximityTotal += best + } + val proximityCost = proximityTotal / entry.word.length.coerceAtLeast(1) val lengthCost = normalizedLengthCost(stroke, rawPath, proximityInfo, entry.word.length) val repeatedLetterCost = repeatedLetterCost(entry.word) val dictionaryCost = entry.wordCost.coerceAtLeast(0) * options.dictionaryWeight diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/gemma/GemmaTranslationManager.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/gemma/GemmaTranslationManager.kt index 269e3b4b8..ed6e6b2ac 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/gemma/GemmaTranslationManager.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/gemma/GemmaTranslationManager.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import timber.log.Timber import java.io.File +import java.io.InputStream import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -531,7 +532,7 @@ class GemmaTranslationManager @Inject constructor( } val header = modelFile.inputStream().use { input -> - input.readNBytes(MODEL_HEADER_SAMPLE_SIZE) + input.readAtMost(MODEL_HEADER_SAMPLE_SIZE) } require(header.isNotEmpty()) { context.getString(R.string.gemma_translation_model_import_invalid_size) @@ -562,6 +563,17 @@ class GemmaTranslationManager @Inject constructor( return value.startsWith(prefix) } + private fun InputStream.readAtMost(maxBytes: Int): ByteArray { + val buffer = ByteArray(maxBytes) + var offset = 0 + while (offset < maxBytes) { + val read = read(buffer, offset, maxBytes - offset) + if (read <= 0) break + offset += read + } + return buffer.copyOf(offset) + } + private fun modelDirectory(): File { val externalBase = context.getExternalFilesDir(null) val baseDir = externalBase ?: context.filesDir diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index 2b831f983..069615fe3 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt @@ -1243,6 +1243,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, isDeleteLeftFlickPreference != preferences.isDeleteLeftFlickPreference || isDeleteUpFlickPreference != preferences.isDeleteUpFlickPreference || isDeleteDownFlickPreference != preferences.isDeleteDownFlickPreference + val qwertyGlidePreferenceChanged = + qwertyGlideInputPreference != preferences.qwertyGlideInputPreference keyboardOrder = preferences.keyboardOrder candidateTabOrder = preferences.candidateTabOrder @@ -1271,6 +1273,15 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertyShowSwitchRomajiEnglishPreference = preferences.qwertyShowSwitchRomajiEnglishPreference qwertyGlideInputPreference = preferences.qwertyGlideInputPreference + if (qwertyGlidePreferenceChanged) { + qwertyGlideInputCoordinator?.cancelPending() + englishEngine.invalidateQwertyGlideCache() + } + if (preferences.qwertyGlideInputPreference) { + englishEngine.warmUpQwertyGlideDecoderAsync() + } else if (qwertyGlidePreferenceChanged) { + englishEngine.cancelQwertyGlideWarmup() + } qwertyShowPopupWindowPreference = preferences.qwertyShowPopupWindowPreference qwertyEnableFlickUpPreference = preferences.qwertyEnableFlickUpPreference qwertyEnableFlickDownPreference = preferences.qwertyEnableFlickDownPreference @@ -2324,6 +2335,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, super.onFinishInputView(finishingInput) Timber.d("onUpdate onFinishInputView") isInputViewActive = false + qwertyGlideInputCoordinator?.cancelPending() releaseKeyboardBackgroundVideoPlayer() releaseFloatingKeyboardBackgroundVideoPlayer() stopVoiceInput() @@ -2348,6 +2360,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, floatingSymbolKeyboard.release() } zenzEngine = null + qwertyGlideInputCoordinator?.cancelPending() + englishEngine.cancelQwertyGlideWarmup() suggestionAdapter?.release() suggestionAdapter = null shortcutAdapter = null diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt index f133a5c86..57df824e2 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt @@ -90,6 +90,12 @@ class QwertyGlideInputCoordinator( onCancel() } + fun cancelPending() { + generation.incrementAndGet() + previewJob?.cancel() + finalJob?.cancel() + } + companion object { private const val PREVIEW_DEBOUNCE_MS = 96L } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SafeNavigation.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SafeNavigation.kt index 86a18d1f0..a1e7ae750 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SafeNavigation.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SafeNavigation.kt @@ -3,6 +3,7 @@ package com.kazumaproject.markdownhelperkeyboard.setting_activity.ui.setting import androidx.annotation.IdRes import androidx.fragment.app.Fragment import androidx.navigation.NavController +import androidx.navigation.NavDestination import androidx.navigation.fragment.findNavController import timber.log.Timber @@ -23,7 +24,7 @@ internal fun NavController.navigateSafely(@IdRes resId: Int): Boolean { if (!hasAction && !hasDestination) { Timber.w( "Ignored navigation because action/destination was not available. current=%s target=%s", - destination.displayName, + destination.safeLogName(), resId ) return false @@ -40,9 +41,13 @@ internal fun NavController.navigateSafely(@IdRes resId: Int): Boolean { Timber.w( error, "Ignored duplicate or stale navigation. current=%s target=%s", - destination.displayName, + destination.safeLogName(), resId ) false } } + +private fun NavDestination.safeLogName(): String { + return label?.toString() ?: id.toString() +} diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 98b600921..088d6542c 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -826,4 +826,7 @@ キーボード背景動画を解除しました。 キーボード背景動画の設定に失敗しました: %1$s ドーナツフリック + 空のフラグメント + キータイプ + 完了 diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoderPerformanceTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoderPerformanceTest.kt new file mode 100644 index 000000000..ad6632a3a --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoderPerformanceTest.kt @@ -0,0 +1,58 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import org.junit.Assert.assertTrue +import org.junit.Test + +class QwertyGlideDecoderPerformanceTest { + @Test + fun benchmarkStyleDecodeTimesAndCandidateCountsStayBounded() { + val metrics = mutableListOf() + val entries = QwertyGlideTestFixtures.dictionary(extraNoiseCount = 50_000) + + QwertyGlideTestFixtures.sameBucketNoise(10_000, first = 'h', last = 'o') + + QwertyGlideTestFixtures.sameBucketNoise(10_000, first = 's', last = 'g') + val options = QwertyGlideDecodeOptions() + val decoder = QwertyGlideDecoder( + dictionaryProvider = QwertyGlideIndexedDictionaryProvider(entries), + options = options, + dictionaryReady = true, + metricsListener = metrics::add + ) + + val cases = listOf( + "short" to QwertyGlideTestFixtures.strokeFor("test"), + "medium" to QwertyGlideTestFixtures.strokeFor("hello"), + "long" to QwertyGlideTestFixtures.strokeFor("keyboard"), + "ambiguous" to QwertyGlideTestFixtures.strokeFor("something", 20f, -20f, -20f, 20f) + ) + + for ((name, stroke) in cases) { + decoder.clearCache() + repeat(5) { + decoder.decode(stroke, QwertyGlideTestFixtures.proximityInfo, "", 12) + decoder.clearCache() + } + val sampleTimes = LongArray(30) + val before = metrics.size + repeat(sampleTimes.size) { index -> + val startedAt = System.nanoTime() + decoder.decode(stroke, QwertyGlideTestFixtures.proximityInfo, "", 12) + sampleTimes[index] = (System.nanoTime() - startedAt) / 1_000_000L + decoder.clearCache() + } + val caseMetrics = metrics.drop(before) + val p50 = sampleTimes.percentile(50) + val p95 = sampleTimes.percentile(95) + val max = sampleTimes.maxOrNull() ?: 0L + println("QWERTY glide $name decode: p50=${p50}ms p95=${p95}ms max=${max}ms full_score_max=${caseMetrics.maxOf { it.fullScoreCandidateCount }}") + + assertTrue("$name full scorer input should be bounded", caseMetrics.all { it.fullScoreCandidateCount <= options.fullScoreCandidateLimit }) + assertTrue("$name decode should not regress grossly: p95=${p95}ms", p95 <= 180L) + } + } + + private fun LongArray.percentile(percent: Int): Long { + val sorted = sorted() + val index = ((percent / 100.0) * (sorted.size - 1)).toInt().coerceIn(0, sorted.lastIndex) + return sorted[index] + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoderStagedAccuracyTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoderStagedAccuracyTest.kt new file mode 100644 index 000000000..b908984b2 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideDecoderStagedAccuracyTest.kt @@ -0,0 +1,88 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QwertyGlideDecoderStagedAccuracyTest { + @Test + fun expectedWordsRemainInTopSixForGoldenStrokes() { + val decoder = decoder(QwertyGlideTestFixtures.dictionary(extraNoiseCount = 5000)) + val words = listOf("hello", "world", "test", "keyboard", "glide", "good", "time", "home", "something") + + for (word in words) { + val candidates = decoder.decode( + inputPointers = QwertyGlideTestFixtures.strokeFor(word), + proximityInfo = QwertyGlideTestFixtures.proximityInfo, + previousText = "", + limit = 6 + ).map { it.string } + + assertTrue("$word should remain in top6, actual=$candidates", word in candidates) + } + } + + @Test + fun startAndEndAmbiguityDoesNotDropExpectedCandidate() { + val decoder = decoder(QwertyGlideTestFixtures.dictionary(extraNoiseCount = 2000)) + + val candidates = decoder.decode( + inputPointers = QwertyGlideTestFixtures.strokeFor( + word = "hello", + startOffsetX = 24f, + startOffsetY = -18f, + endOffsetX = -22f, + endOffsetY = 18f + ), + proximityInfo = QwertyGlideTestFixtures.proximityInfo, + previousText = "", + limit = 6 + ).map { it.string } + + assertTrue("hello should survive ambiguous start/end, actual=$candidates", "hello" in candidates) + } + + @Test + fun stagedPrefilterReducesFullScorerInputAndKeepsCandidate() { + val metrics = mutableListOf() + val entries = QwertyGlideTestFixtures.dictionary() + + QwertyGlideTestFixtures.sameBucketNoise(5000, first = 'h', last = 'o') + val decoder = decoder(entries, metrics::add) + + val candidates = decoder.decode( + inputPointers = QwertyGlideTestFixtures.strokeFor("hello"), + proximityInfo = QwertyGlideTestFixtures.proximityInfo, + previousText = "", + limit = 6 + ).map { it.string } + val lastMetrics = metrics.last() + + assertTrue("hello should remain after cheap prefilter, actual=$candidates", "hello" in candidates) + assertTrue(lastMetrics.rawBucketCandidateCount > lastMetrics.fullScoreCandidateCount) + assertTrue(lastMetrics.fullScoreCandidateCount <= QwertyGlideDecodeOptions().fullScoreCandidateLimit) + } + + @Test + fun decodeIsDeterministicForSameInput() { + val decoder = decoder(QwertyGlideTestFixtures.dictionary(extraNoiseCount = 1000)) + val stroke = QwertyGlideTestFixtures.strokeFor("world") + + val first = decoder.decode(stroke, QwertyGlideTestFixtures.proximityInfo, "", 12) + decoder.clearCache() + val second = decoder.decode(stroke, QwertyGlideTestFixtures.proximityInfo, "", 12) + + assertEquals(first.map { it.string }, second.map { it.string }) + } + + private fun decoder( + entries: List, + metricsListener: ((QwertyGlideDecodeMetrics) -> Unit)? = null + ): QwertyGlideDecoder { + return QwertyGlideDecoder( + dictionaryProvider = QwertyGlideIndexedDictionaryProvider(entries), + options = QwertyGlideDecodeOptions(), + dictionaryReady = true, + metricsListener = metricsListener + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideIndexedDictionaryProviderTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideIndexedDictionaryProviderTest.kt new file mode 100644 index 000000000..e30fa2934 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideIndexedDictionaryProviderTest.kt @@ -0,0 +1,65 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QwertyGlideIndexedDictionaryProviderTest { + @Test + fun indexedDictionaryFiltersAndIndexesByFirstLastAndLength() { + val provider = QwertyGlideIndexedDictionaryProvider( + listOf( + QwertyGlideDictionaryEntry("Hello", 10), + QwertyGlideDictionaryEntry("help", 20), + QwertyGlideDictionaryEntry("h2o", 1), + QwertyGlideDictionaryEntry("a", 1), + QwertyGlideDictionaryEntry("world", 30) + ) + ) + + val entries = provider.indexedEntriesFor(listOf('h'), listOf('o', 'p'), 2, 5) + + assertEquals(listOf("hello", "help"), entries.map { it.word }) + assertEquals(3, provider.entryCount) + assertTrue(provider.entriesFor('w', 'd', 2, 5).toList().single().word == "world") + } + + @Test + fun duplicateWordsKeepLowestCostDeterministically() { + val provider = QwertyGlideIndexedDictionaryProvider( + listOf( + QwertyGlideDictionaryEntry("test", 900), + QwertyGlideDictionaryEntry("Test", 100), + QwertyGlideDictionaryEntry("tent", 200) + ) + ) + + val entries = provider.indexedEntriesFor(listOf('t'), listOf('t'), 2, 8) + + assertEquals(listOf("tent", "test"), entries.map { it.word }) + assertEquals(100, entries.single { it.word == "test" }.wordCost) + } + + @Test + fun emptyDictionaryAndLengthBoundsAreSafe() { + val provider = QwertyGlideIndexedDictionaryProvider(emptyList()) + + assertTrue(provider.indexedEntriesFor(listOf('a'), listOf('z'), 2, 12).isEmpty()) + assertTrue(provider.entriesFor('a', 'z', 2, 12).toList().isEmpty()) + } + + @Test + fun indexedEntriesExposeReusableFeatures() { + val provider = QwertyGlideIndexedDictionaryProvider( + listOf(QwertyGlideDictionaryEntry("keyboard", 42)) + ) + + val entry = provider.indexedEntriesFor(listOf('k'), listOf('d'), 2, 24).single() + + assertEquals('k', entry.firstChar) + assertEquals('d', entry.lastChar) + assertEquals(8, entry.length) + assertTrue(entry.characterMask and (1 shl ('k' - 'a')) != 0) + assertTrue(entry.transitionMask != 0L) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTestFixtures.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTestFixtures.kt new file mode 100644 index 000000000..95c503185 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTestFixtures.kt @@ -0,0 +1,136 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointerPoint +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyProximity +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo +import kotlin.math.roundToInt + +internal object QwertyGlideTestFixtures { + val proximityInfo: QwertyKeyboardProximityInfo by lazy { + val rows = listOf( + "qwertyuiop" to 0f, + "asdfghjkl" to 50f, + "zxcvbnm" to 150f + ) + val keys = rows.flatMapIndexed { rowIndex, (letters, offset) -> + letters.mapIndexed { columnIndex, ch -> + QwertyKeyProximity( + char = ch, + centerX = offset + 50f + columnIndex * 100f, + centerY = 50f + rowIndex * 100f, + width = 92f, + height = 92f, + rowIndex = rowIndex, + columnIndex = columnIndex, + neighborChars = emptyList() + ) + } + } + QwertyKeyboardProximityInfo( + keys = keys, + keyboardWidth = 1000, + keyboardHeight = 300, + averageKeyWidth = 100f, + averageKeyHeight = 100f + ) + } + + fun strokeFor( + word: String, + startOffsetX: Float = 0f, + startOffsetY: Float = 0f, + endOffsetX: Float = 0f, + endOffsetY: Float = 0f + ): QwertyInputPointers { + val keyByChar = proximityInfo.keys.associateBy { it.char } + val centers = word.mapIndexed { index, ch -> + val key = keyByChar[ch] ?: error("Missing test key: $ch") + val x = key.centerX + + if (index == 0) startOffsetX else if (index == word.lastIndex) endOffsetX else 0f + val y = key.centerY + + if (index == 0) startOffsetY else if (index == word.lastIndex) endOffsetY else 0f + x to y + } + val points = ArrayList() + var time = 0 + points.add(centers.first().toPoint(time)) + for (i in 1 until centers.size) { + val previous = centers[i - 1] + val current = centers[i] + for (step in 1..3) { + val ratio = step / 3f + val x = previous.first + (current.first - previous.first) * ratio + val y = previous.second + (current.second - previous.second) * ratio + time += 12 + points.add((x to y).toPoint(time)) + } + } + return QwertyInputPointers(points) + } + + fun dictionary(extraNoiseCount: Int = 0): List { + val base = listOf( + "hello", "hell", "help", "jello", "hero", + "world", "word", "would", "wild", + "test", "tent", "text", "toast", + "keyboard", "keypad", "keyboards", + "glide", "guide", "glove", + "good", "food", "goof", "gold", + "time", "tide", "tone", + "home", "hone", "hope", + "something", "smoothing", "soothing" + ).mapIndexed { index, word -> + QwertyGlideDictionaryEntry(word, 5000 + index) + } + if (extraNoiseCount <= 0) return base + return base + syntheticNoise(extraNoiseCount) + } + + fun sameBucketNoise( + count: Int, + first: Char, + last: Char + ): List { + return (0 until count).map { index -> + val middle = index.toBase26Word(minLength = 4 + (index % 6)) + QwertyGlideDictionaryEntry("$first$middle$last", 9000 + index) + } + } + + private fun syntheticNoise(count: Int): List { + val alphabet = "abcdefghijklmnopqrstuvwxyz" + return (0 until count).map { index -> + val length = 2 + (index % 12) + val word = buildString { + repeat(length) { pos -> + append(alphabet[(index * 13 + pos * 7 + pos * pos) % alphabet.length]) + } + } + QwertyGlideDictionaryEntry(word, 12000 + index) + } + } + + private fun Pair.toPoint(time: Int): QwertyInputPointerPoint { + return QwertyInputPointerPoint( + x = first.roundToInt(), + y = second.roundToInt(), + time = time, + pointerId = 0 + ) + } + + private fun Int.toBase26Word(minLength: Int): String { + val alphabet = "abcdefghijklmnopqrstuvwxyz" + var value = this + val chars = StringBuilder() + do { + chars.append(alphabet[value % alphabet.length]) + value /= alphabet.length + } while (value > 0) + while (chars.length < minLength) { + chars.append(alphabet[(this + chars.length * 5) % alphabet.length]) + } + return chars.toString() + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTopKSelectorTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTopKSelectorTest.kt new file mode 100644 index 000000000..3afc8c64b --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideTopKSelectorTest.kt @@ -0,0 +1,57 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QwertyGlideTopKSelectorTest { + private val selector = QwertyGlideTopKSelector() + + @Test + fun partialTopKMatchesSortedTakeWithStableTies() { + val candidates = listOf( + scored("delta", 4f), + scored("alpha", 2f), + scored("bravo", 2f), + scored("charlie", 3f) + ) + + val expected = candidates + .sortedWith(QwertyGlideTopKSelector.finalComparator) + .take(3) + .map { it.entry.word } + + assertEquals(expected, selector.selectScored(candidates, 3).map { it.entry.word }) + } + + @Test + fun duplicateWordsKeepBestCost() { + val result = selector.selectScored( + listOf( + scored("same", 9f), + scored("same", 1f), + scored("other", 2f) + ), + limit = 6 + ) + + assertEquals(listOf("same", "other"), result.map { it.entry.word }) + assertEquals(1f, result.first().totalCost, 0.0001f) + } + + @Test + fun emptyAndSmallInputsAreSafe() { + assertTrue(selector.selectScored(emptyList(), 10).isEmpty()) + assertTrue(selector.selectScored(listOf(scored("one", 1f)), 0).isEmpty()) + assertEquals(listOf("one"), selector.selectScored(listOf(scored("one", 1f)), 10).map { it.entry.word }) + } + + private fun scored(word: String, cost: Float): QwertyGlideScoredWord { + return QwertyGlideScoredWord( + entry = QwertyGlideDictionaryEntry(word, 100), + totalCost = cost, + spatialCost = cost, + dictionaryCost = 0f + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWarmupLifecycleCacheTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWarmupLifecycleCacheTest.kt new file mode 100644 index 000000000..9cd1917e1 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideWarmupLifecycleCacheTest.kt @@ -0,0 +1,94 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QwertyGlideWarmupLifecycleCacheTest { + @Test + fun fallbackDecoderBeforeWarmupDoesNotCrashAndReadyDecoderReportsReady() { + val fallbackMetrics = mutableListOf() + val fallbackDecoder = QwertyGlideDecoder( + dictionaryProvider = QwertyGlideIndexedDictionaryProvider(QwertyGlideTestFixtures.dictionary()), + dictionaryReady = false, + metricsListener = fallbackMetrics::add + ) + + val fallbackCandidates = fallbackDecoder.decode( + inputPointers = QwertyGlideTestFixtures.strokeFor("hello"), + proximityInfo = QwertyGlideTestFixtures.proximityInfo, + previousText = "", + limit = 6 + ) + + assertTrue(fallbackCandidates.isNotEmpty()) + assertFalse(fallbackMetrics.last().dictionaryReady) + + val readyMetrics = mutableListOf() + val readyDecoder = QwertyGlideDecoder( + dictionaryProvider = QwertyGlideIndexedDictionaryProvider( + QwertyGlideTestFixtures.dictionary(extraNoiseCount = 1000) + ), + dictionaryReady = true, + metricsListener = readyMetrics::add + ) + readyDecoder.decode(QwertyGlideTestFixtures.strokeFor("hello"), QwertyGlideTestFixtures.proximityInfo, "", 6) + + assertTrue(readyMetrics.last().dictionaryReady) + assertTrue(readyMetrics.last().rawBucketCandidateCount >= fallbackMetrics.last().rawBucketCandidateCount) + } + + @Test + fun cacheHitsSameStrokeAndInvalidatesOnClearOrGeometryChange() { + val metrics = mutableListOf() + val decoder = QwertyGlideDecoder( + dictionaryProvider = QwertyGlideIndexedDictionaryProvider(QwertyGlideTestFixtures.dictionary()), + dictionaryReady = true, + metricsListener = metrics::add + ) + val stroke = QwertyGlideTestFixtures.strokeFor("world") + + decoder.decode(stroke, QwertyGlideTestFixtures.proximityInfo, "", 6) + decoder.decode(stroke, QwertyGlideTestFixtures.proximityInfo, "", 12) + assertTrue(metrics.last().cacheHit) + + decoder.clearCache() + decoder.decode(stroke, QwertyGlideTestFixtures.proximityInfo, "", 6) + assertFalse(metrics.last().cacheHit) + + val changedGeometry = QwertyGlideTestFixtures.proximityInfo.copy(keyboardWidth = 1010) + decoder.decode(stroke, changedGeometry, "", 6) + assertFalse(metrics.last().cacheHit) + assertNotEquals(QwertyGlideTestFixtures.proximityInfo.geometrySignature(), changedGeometry.geometrySignature()) + } + + @Test + fun multipleDecoderWarmupsAndCancellationRecoveryAreRepresentedByFreshReadyDecoder() { + val cancelledLikeDecoder = QwertyGlideDecoder( + dictionaryProvider = QwertyGlideIndexedDictionaryProvider(QwertyGlideTestFixtures.dictionary()), + dictionaryReady = false + ) + val readyDecoder = QwertyGlideDecoder( + dictionaryProvider = QwertyGlideIndexedDictionaryProvider(QwertyGlideTestFixtures.dictionary(extraNoiseCount = 500)), + dictionaryReady = true + ) + + assertTrue( + cancelledLikeDecoder.decode( + QwertyGlideTestFixtures.strokeFor("test"), + QwertyGlideTestFixtures.proximityInfo, + "", + 6 + ).isNotEmpty() + ) + assertTrue( + readyDecoder.decode( + QwertyGlideTestFixtures.strokeFor("test"), + QwertyGlideTestFixtures.proximityInfo, + "", + 6 + ).isNotEmpty() + ) + } +} diff --git a/gradle.properties b/gradle.properties index 0f025bd79..54251e9ff 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx4096m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects From b663f1bd4ba4eb462c6247ba414d145210ef39a7 Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Wed, 6 May 2026 18:44:00 -0400 Subject: [PATCH 3/6] show progress bar when calculating candidates --- app/build.gradle | 4 +- .../converter/engine/EnglishEngine.kt | 16 +- .../glide/QwertyGlideCandidateCaseExpander.kt | 55 ++++ .../glide/QwertyGlideCandidateProvider.kt | 14 + .../ime_service/IMEService.kt | 66 ++++- .../QwertyGlideInputCoordinator.kt | 110 ++++++-- .../QwertyGlideCandidateCaseExpanderTest.kt | 108 ++++++++ .../QwertyGlideInputCoordinatorTest.kt | 241 ++++++++++++++++++ 8 files changed, 570 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateCaseExpander.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateProvider.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateCaseExpanderTest.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinatorTest.kt diff --git a/app/build.gradle b/app/build.gradle index 25f6ce8aa..7ca13ce0c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { applicationId "com.kazumaproject.markdownhelperkeyboard" minSdk 24 targetSdk 36 - versionCode 747 - versionName "1.7.53" + versionCode 748 + versionName "1.7.54" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt index dcd542d94..e279b6b84 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/engine/EnglishEngine.kt @@ -10,6 +10,8 @@ import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideDecod import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideDecoder import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideDecodeMetrics import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideDictionaryEntry +import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideCandidateCaseExpander +import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideCandidateProvider import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideIndexedDictionaryProvider import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo @@ -21,7 +23,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import timber.log.Timber -class EnglishEngine { +class EnglishEngine : QwertyGlideCandidateProvider { private lateinit var readingLOUDS: LOUDSWithTermId private lateinit var wordLOUDS: LOUDS private lateinit var tokenArray: TokenArray @@ -37,6 +39,7 @@ class EnglishEngine { private var qwertyGlideDictionaryReady: Boolean = false @Volatile private var qwertyGlideWarmupJob: Job? = null + private val qwertyGlideCandidateCaseExpander = QwertyGlideCandidateCaseExpander() private val qwertyGlideWarmupScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) companion object { @@ -67,23 +70,24 @@ class EnglishEngine { ) } - fun getGlideCandidates( + override suspend fun getGlideCandidates( inputPointers: QwertyInputPointers, proximityInfo: QwertyKeyboardProximityInfo, previousText: String, - limit: Int = 12 + limit: Int ): List { - if (inputPointers.points.size < 2 || proximityInfo.keys.isEmpty()) return emptyList() + if (limit <= 0 || inputPointers.points.size < 2 || proximityInfo.keys.isEmpty()) return emptyList() val decoder = qwertyGlideDecoder ?: run { warmUpQwertyGlideDecoderAsync() getOrCreateFallbackQwertyGlideDecoder() } - return decoder.decode( + val baseCandidates = decoder.decode( inputPointers = inputPointers, proximityInfo = proximityInfo, previousText = previousText, - limit = limit + limit = (limit * 3).coerceAtLeast(limit) ) + return qwertyGlideCandidateCaseExpander.expand(baseCandidates, limit) } fun warmUpQwertyGlideDecoderAsync() { diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateCaseExpander.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateCaseExpander.kt new file mode 100644 index 000000000..8b59eccb8 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateCaseExpander.kt @@ -0,0 +1,55 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import java.util.Locale + +class QwertyGlideCandidateCaseExpander { + fun expand( + candidates: List, + limit: Int + ): List { + if (candidates.isEmpty() || limit <= 0) return emptyList() + + val lowestScoreByString = LinkedHashMap() + fun add(candidate: Candidate) { + val existing = lowestScoreByString[candidate.string] + if (existing == null || candidate.score < existing.score) { + lowestScoreByString[candidate.string] = candidate + } + } + + for (candidate in candidates) { + add(candidate) + if (candidate.string.isEmpty()) continue + + val capitalized = candidate.string.replaceFirstChar { char -> + char.uppercase(Locale.ROOT) + } + add( + candidate.copy( + string = capitalized, + length = capitalized.length.toUByte(), + score = candidate.score + CAPITALIZED_SCORE_OFFSET + ) + ) + + val uppercase = candidate.string.uppercase(Locale.ROOT) + add( + candidate.copy( + string = uppercase, + length = uppercase.length.toUByte(), + score = candidate.score + UPPERCASE_SCORE_OFFSET + ) + ) + } + + return lowestScoreByString.values + .sortedBy { it.score } + .take(limit) + } + + companion object { + const val CAPITALIZED_SCORE_OFFSET = 1500 + const val UPPERCASE_SCORE_OFFSET = 3000 + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateProvider.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateProvider.kt new file mode 100644 index 000000000..381bfadc7 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateProvider.kt @@ -0,0 +1,14 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo + +interface QwertyGlideCandidateProvider { + suspend fun getGlideCandidates( + inputPointers: QwertyInputPointers, + proximityInfo: QwertyKeyboardProximityInfo, + previousText: String, + limit: Int = 12 + ): List +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index 069615fe3..7f5a63dfb 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt @@ -286,6 +286,12 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, object Close : CandidateLongPressAction() } + private enum class SuggestionProgressReason { + CandidateTranslation, + VoiceInput, + QwertyGlideDecode + } + private data class BunsetsuSegmentState( val reading: String, val displayText: String, @@ -532,6 +538,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var selectedTextGemmaSession: SelectedTextGemmaSession? = null private var mainLayoutBinding: MainLayoutBinding? = null + private val suggestionProgressReasons = mutableSetOf() private var isInputViewActive: Boolean = false private val _inputString = MutableStateFlow("") private val inputString = _inputString.asStateFlow() @@ -1136,7 +1143,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, speechRecognizer = SpeechRecognizer.createSpeechRecognizer(this).apply { setRecognitionListener(object : RecognitionListener { override fun onReadyForSpeech(params: Bundle?) { - mainLayoutBinding?.suggestionProgressbar?.isVisible = true + setSuggestionProgressVisible( + reason = SuggestionProgressReason.VoiceInput, + visible = true + ) } override fun onBeginningOfSpeech() { @@ -1146,12 +1156,18 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, override fun onRmsChanged(rmsdB: Float) {} override fun onBufferReceived(buffer: ByteArray?) {} override fun onEndOfSpeech() { - mainLayoutBinding?.suggestionProgressbar?.isVisible = false + setSuggestionProgressVisible( + reason = SuggestionProgressReason.VoiceInput, + visible = false + ) } override fun onError(error: Int) { isListening = false - mainLayoutBinding?.suggestionProgressbar?.isVisible = false + setSuggestionProgressVisible( + reason = SuggestionProgressReason.VoiceInput, + visible = false + ) } override fun onResults(results: Bundle?) { @@ -1160,7 +1176,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) val text = matches?.firstOrNull() ?: return _inputString.update { text } - mainLayoutBinding?.suggestionProgressbar?.isVisible = false + setSuggestionProgressVisible( + reason = SuggestionProgressReason.VoiceInput, + visible = false + ) } override fun onPartialResults(partialResults: Bundle?) { @@ -2229,7 +2248,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, setTabsToTabLayout(mainView) - suggestionProgressbar.isVisible = false + refreshSuggestionProgressVisibility() tabletView.setFlickSensitivityValue(flickSensitivityPreferenceValue ?: 100) tabletView.setLongPressTimeout((longPressTimeoutPreferenceValue ?: 300).toLong()) @@ -2770,7 +2789,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView: MainLayoutBinding ) { Timber.d("startVoiceInput: [$isListening] [$speechRecognizer]") - mainView.suggestionProgressbar.isVisible = false + setSuggestionProgressVisible( + reason = SuggestionProgressReason.VoiceInput, + visible = false + ) if (isListening) return if (speechRecognizer == null) return @@ -6259,7 +6281,27 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } private fun setCandidateTranslationProgressVisible(isVisible: Boolean) { - mainLayoutBinding?.suggestionProgressbar?.isVisible = isVisible + setSuggestionProgressVisible( + reason = SuggestionProgressReason.CandidateTranslation, + visible = isVisible + ) + } + + private fun setSuggestionProgressVisible( + reason: SuggestionProgressReason, + visible: Boolean + ) { + if (visible) { + suggestionProgressReasons += reason + } else { + suggestionProgressReasons -= reason + } + refreshSuggestionProgressVisibility() + } + + private fun refreshSuggestionProgressVisibility() { + mainLayoutBinding?.suggestionProgressbar?.isVisible = + suggestionProgressReasons.isNotEmpty() } private fun finishCandidateTranslation(requestId: Long) { @@ -13236,7 +13278,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, val glideCoordinator = qwertyGlideInputCoordinator ?: QwertyGlideInputCoordinator( scope = scope, - englishEngine = englishEngine, + candidateProvider = englishEngine, previousTextProvider = { getPreviousTextForQwertyGlide() }, onPreviewCandidates = { candidates -> showQwertyGlideCandidates(candidates, applyFirstCandidate = false) @@ -13244,7 +13286,13 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, onFinalCandidates = { candidates -> showQwertyGlideCandidates(candidates, applyFirstCandidate = true) }, - onCancel = {} + onCancel = {}, + onProcessingChanged = { isProcessing -> + setSuggestionProgressVisible( + reason = SuggestionProgressReason.QwertyGlideDecode, + visible = isProcessing + ) + } ).also { qwertyGlideInputCoordinator = it } setQwertyGlideInputListener(glideCoordinator) setQwertyGlideInputMode(calculateQwertyGlideInputMode()) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt index 57df824e2..48402d5bb 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinator.kt @@ -2,11 +2,13 @@ package com.kazumaproject.markdownhelperkeyboard.ime_service import com.kazumaproject.markdownhelperkeyboard.BuildConfig import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate -import com.kazumaproject.markdownhelperkeyboard.converter.engine.EnglishEngine +import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideCandidateProvider import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideInputListener import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -17,13 +19,18 @@ import java.util.concurrent.atomic.AtomicLong class QwertyGlideInputCoordinator( private val scope: CoroutineScope, - private val englishEngine: EnglishEngine, + private val candidateProvider: QwertyGlideCandidateProvider, private val previousTextProvider: () -> String, private val onPreviewCandidates: (List) -> Unit, private val onFinalCandidates: (List) -> Unit, - private val onCancel: () -> Unit = {} + private val onCancel: () -> Unit = {}, + private val onProcessingChanged: (Boolean) -> Unit = {}, + private val decodeDispatcher: CoroutineDispatcher = Dispatchers.Default ) : QwertyGlideInputListener { private val generation = AtomicLong(0L) + private val processingLock = Any() + private val processingTokenGeneration = AtomicLong(0L) + private val activeProcessingTokens = mutableSetOf() private var previewJob: Job? = null private var finalJob: Job? = null @@ -31,6 +38,7 @@ class QwertyGlideInputCoordinator( generation.incrementAndGet() previewJob?.cancel() finalJob?.cancel() + clearProcessing() } override fun onQwertyGlideUpdated( @@ -41,19 +49,28 @@ class QwertyGlideInputCoordinator( previewJob?.cancel() previewJob = scope.launch { delay(PREVIEW_DEBOUNCE_MS) - val candidates = withContext(Dispatchers.Default) { - englishEngine.getGlideCandidates( - inputPointers = inputPointers, - proximityInfo = proximityInfo, - previousText = previousTextProvider(), - limit = 6 - ) - } - if (generation.get() == requestGeneration) { - if (BuildConfig.DEBUG) { - Timber.d("QWERTY glide preview: points=${inputPointers.points.size} top=${candidates.take(5).map { it.string }}") + val processingToken = beginProcessing() + try { + val candidates = withContext(decodeDispatcher) { + candidateProvider.getGlideCandidates( + inputPointers = inputPointers, + proximityInfo = proximityInfo, + previousText = previousTextProvider(), + limit = 6 + ) + } + if (generation.get() == requestGeneration) { + if (BuildConfig.DEBUG) { + Timber.d("QWERTY glide preview: points=${inputPointers.points.size} top=${candidates.take(5).map { it.string }}") + } + onPreviewCandidates(candidates) } - onPreviewCandidates(candidates) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Timber.e(error, "QWERTY glide preview decode failed.") + } finally { + finishProcessing(processingToken) } } } @@ -63,22 +80,31 @@ class QwertyGlideInputCoordinator( proximityInfo: QwertyKeyboardProximityInfo ) { val requestGeneration = generation.incrementAndGet() + val processingToken = beginProcessing() previewJob?.cancel() finalJob?.cancel() finalJob = scope.launch { - val candidates = withContext(Dispatchers.Default) { - englishEngine.getGlideCandidates( - inputPointers = inputPointers, - proximityInfo = proximityInfo, - previousText = previousTextProvider(), - limit = 12 - ) - } - if (generation.get() == requestGeneration) { - if (BuildConfig.DEBUG) { - Timber.d("QWERTY glide final: points=${inputPointers.points.size} top=${candidates.take(8).map { it.string to it.score }}") + try { + val candidates = withContext(decodeDispatcher) { + candidateProvider.getGlideCandidates( + inputPointers = inputPointers, + proximityInfo = proximityInfo, + previousText = previousTextProvider(), + limit = 12 + ) + } + if (generation.get() == requestGeneration) { + if (BuildConfig.DEBUG) { + Timber.d("QWERTY glide final: points=${inputPointers.points.size} top=${candidates.take(8).map { it.string to it.score }}") + } + onFinalCandidates(candidates) } - onFinalCandidates(candidates) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Timber.e(error, "QWERTY glide final decode failed.") + } finally { + finishProcessing(processingToken) } } } @@ -87,6 +113,7 @@ class QwertyGlideInputCoordinator( generation.incrementAndGet() previewJob?.cancel() finalJob?.cancel() + clearProcessing() onCancel() } @@ -94,6 +121,35 @@ class QwertyGlideInputCoordinator( generation.incrementAndGet() previewJob?.cancel() finalJob?.cancel() + clearProcessing() + } + + private fun beginProcessing(): Long { + val token = processingTokenGeneration.incrementAndGet() + val shouldShow = synchronized(processingLock) { + val wasEmpty = activeProcessingTokens.isEmpty() + activeProcessingTokens += token + wasEmpty + } + if (shouldShow) onProcessingChanged(true) + return token + } + + private fun finishProcessing(token: Long) { + val shouldHide = synchronized(processingLock) { + val removed = activeProcessingTokens.remove(token) + removed && activeProcessingTokens.isEmpty() + } + if (shouldHide) onProcessingChanged(false) + } + + private fun clearProcessing() { + val shouldHide = synchronized(processingLock) { + val wasNotEmpty = activeProcessingTokens.isNotEmpty() + activeProcessingTokens.clear() + wasNotEmpty + } + if (shouldHide) onProcessingChanged(false) } companion object { diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateCaseExpanderTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateCaseExpanderTest.kt new file mode 100644 index 000000000..69a269600 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/glide/QwertyGlideCandidateCaseExpanderTest.kt @@ -0,0 +1,108 @@ +package com.kazumaproject.markdownhelperkeyboard.converter.glide + +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QwertyGlideCandidateCaseExpanderTest { + private val expander = QwertyGlideCandidateCaseExpander() + + @Test + fun lowercaseCandidateAddsCapitalizedAndUppercaseVariants() { + val result = expander.expand(listOf(candidate("hello", 1000)), limit = 10) + + assertEquals( + listOf("hello" to 1000, "Hello" to 2500, "HELLO" to 4000), + result.map { it.string to it.score } + ) + assertEquals("Hello".length.toUByte(), result.first { it.string == "Hello" }.length) + assertEquals("HELLO".length.toUByte(), result.first { it.string == "HELLO" }.length) + } + + @Test + fun returnsCandidatesSortedByAscendingScore() { + val result = expander.expand( + listOf( + candidate("world", 2000), + candidate("hello", 1000) + ), + limit = 10 + ) + + assertEquals(result.map { it.score }.sorted(), result.map { it.score }) + } + + @Test + fun appliesLimitAfterExpansion() { + val result = expander.expand( + listOf( + candidate("hello", 1000), + candidate("world", 2000) + ), + limit = 4 + ) + + assertEquals(listOf("hello", "world", "Hello", "World"), result.map { it.string }) + } + + @Test + fun duplicateStringsKeepLowestScore() { + val result = expander.expand( + listOf( + candidate("hello", 3000), + candidate("hello", 1000) + ), + limit = 10 + ) + + assertEquals(1, result.count { it.string == "hello" }) + assertEquals(1000, result.first { it.string == "hello" }.score) + assertEquals(2500, result.first { it.string == "Hello" }.score) + assertEquals(4000, result.first { it.string == "HELLO" }.score) + } + + @Test + fun emptyCandidateListReturnsEmptyList() { + assertTrue(expander.expand(emptyList(), limit = 10).isEmpty()) + } + + @Test + fun emptyStringCandidateDoesNotAddCaseVariants() { + val result = expander.expand(listOf(candidate("", 1000)), limit = 10) + + assertEquals(listOf("" to 1000), result.map { it.string to it.score }) + } + + @Test + fun existingCapitalizedAndUppercaseStringsAreNotDuplicated() { + val result = expander.expand( + listOf( + candidate("hello", 1000), + candidate("Hello", 1200), + candidate("HELLO", 1300) + ), + limit = 10 + ) + + assertEquals(1, result.count { it.string == "Hello" }) + assertEquals(1, result.count { it.string == "HELLO" }) + assertEquals(1200, result.first { it.string == "Hello" }.score) + assertEquals(1300, result.first { it.string == "HELLO" }.score) + } + + private fun candidate( + string: String, + score: Int + ): Candidate { + return Candidate( + string = string, + type = 36.toByte(), + length = string.length.toUByte(), + score = score, + yomi = "source", + leftId = 1, + rightId = 2 + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinatorTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinatorTest.kt new file mode 100644 index 000000000..d82d33a22 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/QwertyGlideInputCoordinatorTest.kt @@ -0,0 +1,241 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate +import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideCandidateProvider +import com.kazumaproject.markdownhelperkeyboard.converter.glide.QwertyGlideTestFixtures +import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers +import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class QwertyGlideInputCoordinatorTest { + private val pointers = QwertyGlideTestFixtures.strokeFor("hello") + private val proximityInfo = QwertyGlideTestFixtures.proximityInfo + + @Test + fun previewDecodeReportsProcessingTrueThenFalse() = runTest { + val requests = mutableListOf>>() + val events = mutableListOf() + val previews = mutableListOf>() + val coordinator = coordinator( + scope = this, + provider = deferredProvider(requests), + events = events, + previews = previews + ) + + coordinator.onQwertyGlideStarted() + coordinator.onQwertyGlideUpdated(pointers, proximityInfo) + advanceTimeBy(96) + runCurrent() + + assertEquals(listOf(true), events) + requests.single().complete(listOf(candidate("hello", 1000))) + runCurrent() + + assertEquals(listOf(true, false), events) + assertEquals(listOf("hello"), previews.single().map { it.string }) + } + + @Test + fun finalDecodeReportsProcessingTrueThenFalse() = runTest { + val requests = mutableListOf>>() + val events = mutableListOf() + val finals = mutableListOf>() + val coordinator = coordinator( + scope = this, + provider = deferredProvider(requests), + events = events, + finals = finals + ) + + coordinator.onQwertyGlideStarted() + coordinator.onQwertyGlideEnded(pointers, proximityInfo) + runCurrent() + + assertEquals(listOf(true), events) + requests.single().complete(listOf(candidate("hello", 1000))) + runCurrent() + + assertEquals(listOf(true, false), events) + assertEquals(listOf("hello"), finals.single().map { it.string }) + } + + @Test + fun cancelDuringDecodeReportsProcessingFalse() = runTest { + val requests = mutableListOf>>() + val events = mutableListOf() + val coordinator = coordinator( + scope = this, + provider = deferredProvider(requests), + events = events + ) + + coordinator.onQwertyGlideStarted() + coordinator.onQwertyGlideEnded(pointers, proximityInfo) + runCurrent() + coordinator.cancelPending() + runCurrent() + + assertEquals(listOf(true, false), events) + requests.single().complete(listOf(candidate("hello", 1000))) + runCurrent() + assertEquals(listOf(true, false), events) + } + + @Test + fun cancelledPreviewDoesNotHideNewFinalProcessing() = runTest { + val requests = mutableListOf>>() + val events = mutableListOf() + val coordinator = coordinator( + scope = this, + provider = deferredProvider(requests), + events = events + ) + + coordinator.onQwertyGlideStarted() + coordinator.onQwertyGlideUpdated(pointers, proximityInfo) + advanceTimeBy(96) + runCurrent() + assertEquals(listOf(true), events) + + coordinator.onQwertyGlideEnded(pointers, proximityInfo) + runCurrent() + assertEquals(2, requests.size) + assertEquals(listOf(true), events) + + requests[1].complete(listOf(candidate("hello", 1000))) + runCurrent() + assertEquals(listOf(true, false), events) + } + + @Test + fun emptyCandidatesStillClearProcessing() = runTest { + val events = mutableListOf() + val finals = mutableListOf>() + val coordinator = coordinator( + scope = this, + provider = immediateProvider(emptyList()), + events = events, + finals = finals + ) + + coordinator.onQwertyGlideStarted() + coordinator.onQwertyGlideEnded(pointers, proximityInfo) + runCurrent() + + assertEquals(listOf(true, false), events) + assertTrue(finals.single().isEmpty()) + } + + @Test + fun decodeExceptionStillClearsProcessing() = runTest { + val events = mutableListOf() + val coordinator = coordinator( + scope = this, + provider = throwingProvider(IllegalStateException("decode failed")), + events = events + ) + + coordinator.onQwertyGlideStarted() + coordinator.onQwertyGlideEnded(pointers, proximityInfo) + runCurrent() + + assertEquals(listOf(true, false), events) + } + + @Test + fun cancellationExceptionStillClearsProcessing() = runTest { + val events = mutableListOf() + val coordinator = coordinator( + scope = this, + provider = throwingProvider(CancellationException("decode cancelled")), + events = events + ) + + coordinator.onQwertyGlideStarted() + coordinator.onQwertyGlideEnded(pointers, proximityInfo) + runCurrent() + + assertEquals(listOf(true, false), events) + } + + private fun coordinator( + scope: TestScope, + provider: QwertyGlideCandidateProvider, + events: MutableList, + previews: MutableList> = mutableListOf(), + finals: MutableList> = mutableListOf() + ): QwertyGlideInputCoordinator { + val dispatcher = StandardTestDispatcher(scope.testScheduler) + return QwertyGlideInputCoordinator( + scope = scope, + candidateProvider = provider, + previousTextProvider = { "" }, + onPreviewCandidates = previews::add, + onFinalCandidates = finals::add, + onProcessingChanged = events::add, + decodeDispatcher = dispatcher + ) + } + + private fun deferredProvider( + requests: MutableList>> + ): QwertyGlideCandidateProvider { + return object : QwertyGlideCandidateProvider { + override suspend fun getGlideCandidates( + inputPointers: QwertyInputPointers, + proximityInfo: QwertyKeyboardProximityInfo, + previousText: String, + limit: Int + ): List { + val deferred = CompletableDeferred>() + requests += deferred + return deferred.await() + } + } + } + + private fun immediateProvider(candidates: List): QwertyGlideCandidateProvider { + return object : QwertyGlideCandidateProvider { + override suspend fun getGlideCandidates( + inputPointers: QwertyInputPointers, + proximityInfo: QwertyKeyboardProximityInfo, + previousText: String, + limit: Int + ): List = candidates + } + } + + private fun throwingProvider(error: Throwable): QwertyGlideCandidateProvider { + return object : QwertyGlideCandidateProvider { + override suspend fun getGlideCandidates( + inputPointers: QwertyInputPointers, + proximityInfo: QwertyKeyboardProximityInfo, + previousText: String, + limit: Int + ): List { + throw error + } + } + } + + private fun candidate(string: String, score: Int): Candidate { + return Candidate( + string = string, + type = 36.toByte(), + length = string.length.toUByte(), + score = score + ) + } +} From c0e13156d61079f1e2b7eec0fe9e437d4d51e64b Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Wed, 6 May 2026 19:00:53 -0400 Subject: [PATCH 4/6] fix preference --- app/src/main/res/values-ja/strings.xml | 8 +++++++- app/src/main/res/values/strings.xml | 8 +++++++- app/src/main/res/xml/pref_qwerty.xml | 24 ++++++++++++------------ 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 088d6542c..5debcd767 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -515,7 +515,7 @@ 絵文字キーボードへの切り替えなどの便利なショートカットアイコンを含むツールバーを表示します ローマ字/英語切り替えキー キーボード上にローマ字入力と英語入力を切り替えるキー(あa)を表示します - 英語 QWERTY のグライド入力 + 英語 QWERTY のグライド入力 β 英語 QWERTY キーボードで、指を滑らせた軌跡から英単語候補を表示します。 キーボードテーマ 反映には一度キーボードを再起動する必要があります。 @@ -829,4 +829,10 @@ 空のフラグメント キータイプ 完了 + キーのサイズ + キー、文字、特殊キーのアイコンサイズを変更します + ポップアップ表示 + QWERTY のキー preview と長押し popup のサイズと文字サイズを設定します + QWERTY 数字キーの上・下フリックで出力する文字を設定します + 数字キーのフリック設定 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae36b793c..2ba1857eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -513,7 +513,7 @@ Show a toolbar with useful shortcuts like switching Emoji keyboard Romaji/English Switch Key Show a key (あa) to switch between Romaji input and English input - Glide input for English QWERTY + Glide input for English QWERTY β Enable word suggestions from sliding gestures on the English QWERTY keyboard. Keyboard Theme Choose the keyboard theme color.\nTo apply changes, please switch the keyboard once. @@ -831,4 +831,10 @@ Keyboard background video has been cleared. Failed to set keyboard background video: %1$s Donuts Flick + Key size + Change the size of keys, text, and special key icons. + Popup display + Configure the size and text size of QWERTY key previews and long-press popups. + Number key flick settings + Configure the characters output by upward and downward flicks on QWERTY number keys. diff --git a/app/src/main/res/xml/pref_qwerty.xml b/app/src/main/res/xml/pref_qwerty.xml index aed8ae825..62f712a49 100644 --- a/app/src/main/res/xml/pref_qwerty.xml +++ b/app/src/main/res/xml/pref_qwerty.xml @@ -4,15 +4,21 @@ + + + android:title="@string/qwerty_button_size_preference_title" + app:summary="@string/qwerty_button_size_preference_summary" /> + android:summary="@string/qwerty_popup_view_style_preference_summary" + android:title="@string/qwerty_popup_view_style_preference_title" /> + android:summary="@string/qwerty_number_key_flick_setting_preference_summary" + android:title="@string/qwerty_number_key_flick_setting_preference_title" /> - - Date: Wed, 6 May 2026 20:27:01 -0400 Subject: [PATCH 5/6] fix glide input --- .../glide/QwertyGlideKeyClassifier.kt | 69 ++++++ .../qwerty_keyboard/ui/QWERTYKeyboardView.kt | 134 ++++++++++- .../glide/QwertyGlideKeyClassifierTest.kt | 226 ++++++++++++++++++ 3 files changed, 419 insertions(+), 10 deletions(-) create mode 100644 qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifier.kt create mode 100644 qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifierTest.kt diff --git a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifier.kt b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifier.kt new file mode 100644 index 000000000..7a5bba087 --- /dev/null +++ b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifier.kt @@ -0,0 +1,69 @@ +package com.kazumaproject.qwerty_keyboard.glide + +/** + * Pure helper for classifying a touch coordinate against the QWERTY layout from the + * point of view of the Glide gesture pipeline. + * + * The Glide pipeline must distinguish three cases for a given (x, y): + * 1. The coordinate falls *exactly* on a Glide-eligible alphabet key. The Glide + * gesture uses such points for trail / decoder input. + * 2. The coordinate falls *exactly* on a visible QWERTY key that is **not** a + * Glide-eligible alphabet key (numbers, space, return, cursor, shift, + * delete, mode switch keys, emoji, etc.). When this happens *during* an + * already-started Glide we want to ignore the point entirely — neither + * contributing it to the trail nor letting normal key dispatch fire. + * 3. The coordinate is in the keyboard background (key gaps, padding, etc.). + * Existing area-cancel logic should still decide whether to cancel the + * gesture in that case. + * + * Keeping this logic in a small, view-free helper makes it cheap to unit test + * without spinning up a real `QWERTYKeyboardView` instance. + */ +object QwertyGlideKeyClassifier { + + /** + * Lightweight rectangle representation used by the classifier. Mirrors the + * `View.getHitRect()` semantics: the rect contains a point when + * `left <= x < right && top <= y < bottom`. + */ + data class KeyRect( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int + ) { + fun contains(x: Int, y: Int): Boolean = + x in left until right && y in top until bottom + } + + /** + * Returns true if (x, y) falls exactly inside one of the supplied + * Glide-eligible alphabet key hit rects. + * + * Note: This is *not* a nearest-neighbor lookup. We deliberately avoid the + * fallback that `findButtonUnder()` performs, because letting a coordinate + * inside the number row "snap" to the closest letter key would defeat the + * purpose of ignoring number rows during Glide. + */ + fun isOnLetterKey(letterRects: List, x: Int, y: Int): Boolean { + return letterRects.any { it.contains(x, y) } + } + + /** + * Returns true if (x, y) is exactly inside a visible non-letter QWERTY key. + * + * Used by the Glide pipeline to decide whether a moving pointer is currently + * sitting on a non-Glide key (number / space / return / cursor / shift / + * delete / mode switch / emoji / etc.) so the event can be silently + * consumed. + */ + fun isOnNonGlideKey( + letterRects: List, + nonLetterRects: List, + x: Int, + y: Int + ): Boolean { + if (isOnLetterKey(letterRects, x, y)) return false + return nonLetterRects.any { it.contains(x, y) } + } +} diff --git a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt index 3999b1522..114511a34 100644 --- a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt +++ b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt @@ -1615,14 +1615,38 @@ class QWERTYKeyboardView @JvmOverloads constructor( cancelQwertyGlideCandidate(notify = glideStarted) return false } - appendHistoricalQwertyGlidePoints(event, pointerIndex, pointerId) + val x = event.getX(pointerIndex) + val y = event.getY(pointerIndex) + + // Glide 開始後に限り、数字キー / Space / Return / Cursor / Shift / + // Delete / mode switch などの非アルファベットキー上を通過した + // ケースは「無視して consume」する。Glide を cancel せず、 + // 通常キー処理にも流さないことで、誤入力を防ぐ。 + // + // 重要: Glide 開始前 (glideStarted == false) にここで早期 return すると + // 既存の Glide 発生条件 (pointCount / directDistance / elapsedMillis / + // distinctLetterKeysNearTrail) の計算に影響するため、glideStarted が + // true のときだけ適用する。 + if (glideStarted && isOnNonGlideQwertyKey(x, y)) { + return true + } + + if (glideStarted) { + // 既存の history 取り込みは「直線補完」のために有用だが、 + // history に非アルファベットキー上の座標が含まれていると + // raw points / proximity decoder へ混入してしまう。 + // Glide 中は letter key 上の history のみを採用する。 + appendHistoricalQwertyGlideLetterPoints(event, pointerIndex, pointerId) + } else { + appendHistoricalQwertyGlidePoints(event, pointerIndex, pointerId) + } appendQwertyGlidePoint( - x = event.getX(pointerIndex), - y = event.getY(pointerIndex), + x = x, + y = y, eventTime = event.eventTime, pointerId = pointerId ) - if (!isInsideQwertyGlideGestureArea(event.getX(pointerIndex), event.getY(pointerIndex))) { + if (!isInsideQwertyGlideGestureArea(x, y)) { cancelQwertyGlideCandidate(notify = glideStarted) return true } @@ -1643,12 +1667,20 @@ class QWERTYKeyboardView @JvmOverloads constructor( val pointerId = glideCandidatePointerId ?: return false val liftedId = event.getPointerId(event.actionIndex) if (liftedId != pointerId) return false - appendQwertyGlidePoint( - x = event.getX(event.actionIndex), - y = event.getY(event.actionIndex), - eventTime = event.eventTime, - pointerId = pointerId - ) + val upX = event.getX(event.actionIndex) + val upY = event.getY(event.actionIndex) + // Glide 開始後に非アルファベットキー上で指を離した場合、その座標を + // 末尾に append すると proximity decoder の判定に余計なノイズが + // 入るため、letter key 上の場合だけ append する。Glide 開始前 ( + // 通常 tap シナリオ) では既存挙動どおり常に append する。 + if (!glideStarted || findExactQwertyGlideLetterViewUnder(upX, upY) != null) { + appendQwertyGlidePoint( + x = upX, + y = upY, + eventTime = event.eventTime, + pointerId = pointerId + ) + } return if (glideStarted) { qwertyGlideInputListener?.onQwertyGlideEnded( inputPointers = QwertyInputPointers(glideRawPoints.toList()), @@ -1735,6 +1767,41 @@ class QWERTYKeyboardView @JvmOverloads constructor( } } + /** + * Glide 開始後に history を取り込むための関数。 + * + * 通常の [appendHistoricalQwertyGlidePoints] と異なり、history に含まれる + * 各座標が「Glide 用 alphabet key 上にある」ものだけを raw points / trail に + * 採用する。Space / 数字キー / Return など非アルファベットキー上の座標は、 + * 一瞬通過しただけでも history に乗っているケースがあり、それらが + * `glideRawPoints` や proximity decoder に混入して候補生成を歪める原因に + * なるため除外する。 + * + * 既存の [appendHistoricalQwertyGlidePoints] は Glide 開始前 (まだ + * shouldStart() による gesture 判定中) のフェーズで利用しており、 + * letter-only filtering を入れると既存の発火条件の計算に影響する。そのため + * 関数自体を分けて、Glide 開始後の経路だけ filtering を適用する。 + */ + private fun appendHistoricalQwertyGlideLetterPoints( + event: MotionEvent, + pointerIndex: Int, + pointerId: Int + ) { + for (historyIndex in 0 until event.historySize) { + val hx = event.getHistoricalX(pointerIndex, historyIndex) + val hy = event.getHistoricalY(pointerIndex, historyIndex) + if (findExactQwertyGlideLetterViewUnder(hx, hy) == null) { + continue + } + appendQwertyGlidePoint( + x = hx, + y = hy, + eventTime = event.getHistoricalEventTime(historyIndex), + pointerId = pointerId + ) + } + } + private fun appendQwertyGlidePoint( x: Float, y: Float, @@ -2252,6 +2319,53 @@ class QWERTYKeyboardView @JvmOverloads constructor( return view in getVisibleQwertyLetterViews() } + /** + * 共有 [hitRect] を使い回すと、Glide 中の判定が他のタッチ処理と競合する恐れが + * あるため、Glide 専用の Rect を別に確保する。 + */ + private val glideHitRect = Rect() + + /** + * 指定座標が「Glide で実際に使う visible なアルファベットキー」の hitRect 内に + * 入っているかを厳密に判定する。 + * + * [findButtonUnder] のような nearest-neighbor フォールバックは行わない。 + * 数字キー / Space / Return などの上にある座標を、近接するアルファベットキーへ + * 吸わせてしまうのを防ぐ目的で、矩形に *厳密に* 含まれているケースだけ true を + * 返す。 + */ + private fun findExactQwertyGlideLetterViewUnder(x: Float, y: Float): QWERTYButton? { + val xi = x.toInt() + val yi = y.toInt() + return getVisibleQwertyLetterViews().firstOrNull { key -> + if (!key.isVisible) return@firstOrNull false + key.getHitRect(glideHitRect) + glideHitRect.contains(xi, yi) + } + } + + /** + * 指定座標が「QWERTYButton としては存在するが、Glide 用の alphabet key では + * ない可視キー」の上にあるかを判定する。 + * + * Glide 中の ACTION_MOVE で、数字キー / Space / Return / Cursor / Shift / + * Delete / mode switch / Emoji などを通過したケースを「無視して consume」する + * ためだけに使うこと。通常キー入力全体のディスパッチには使わない。 + */ + private fun isOnNonGlideQwertyKey(x: Float, y: Float): Boolean { + val xi = x.toInt() + val yi = y.toInt() + if (findExactQwertyGlideLetterViewUnder(x, y) != null) return false + for (key in qwertyButtonMap.keys) { + if (!key.isVisible) continue + key.getHitRect(glideHitRect) + if (glideHitRect.contains(xi, yi)) { + return true + } + } + return false + } + override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) if (glideTrailPoints.size < 2) return diff --git a/qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifierTest.kt b/qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifierTest.kt new file mode 100644 index 000000000..a5ae53d2e --- /dev/null +++ b/qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifierTest.kt @@ -0,0 +1,226 @@ +package com.kazumaproject.qwerty_keyboard.glide + +import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideKeyClassifier.KeyRect +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 挙動の核となる「Glide 中の非アルファベットキー通過を識別する」判定ロジックの + * ユニットテスト。 + * + * `QWERTYKeyboardView` 自体は `View` ライフサイクル / レイアウト計測 / view binding + * を要するため Robolectric 無しでは生成できないが、このクラスの挙動の中心は + * 純粋な矩形判定であり、`QwertyGlideKeyClassifier` に切り出してテストできる。 + */ +class QwertyGlideKeyClassifierTest { + + /** + * 標準的な英語 QWERTY 配列を模した矩形セットを用意するヘルパー。 + * - top row : Q W E R T Y U I O P (y = 0 .. 100) + * - mid row : A S D F G H J K L (y = 100 .. 200) + * - bottom row : Z X C V B N M (y = 200 .. 300) + * - number row : 1 2 3 4 5 6 7 8 9 0 (y = -100 .. 0) ← Glide 対象外 + * - bottom func: shift / space / return (y = 300 .. 400) ← Glide 対象外 + */ + private data class Layout( + val letterRects: List, + val nonLetterRects: List + ) + + private fun stdLayout(): Layout { + val keyW = 100 + val keyH = 100 + // Letter rows + val letters = mutableListOf() + for (col in 0 until 10) { + // top row + letters += KeyRect(col * keyW, 0, (col + 1) * keyW, keyH) + } + for (col in 0 until 9) { + // mid row + letters += KeyRect(col * keyW + 50, keyH, (col + 1) * keyW + 50, 2 * keyH) + } + for (col in 0 until 7) { + // bottom row + letters += KeyRect(col * keyW + 100, 2 * keyH, (col + 1) * keyW + 100, 3 * keyH) + } + // Non-letter QWERTY keys + val nonLetters = mutableListOf() + // Number row above the letters (key_1 .. key_0) + for (col in 0 until 10) { + nonLetters += KeyRect(col * keyW, -keyH, (col + 1) * keyW, 0) + } + // Side keys + nonLetters += KeyRect(0, 2 * keyH, 100, 3 * keyH) // Shift + nonLetters += KeyRect(800, 2 * keyH, 1000, 3 * keyH) // Delete + // Bottom utility row: 123, switchDefault, space, return, cursors, emoji + nonLetters += KeyRect(0, 3 * keyH, 150, 4 * keyH) // 123 + nonLetters += KeyRect(150, 3 * keyH, 300, 4 * keyH) // switchDefault / emoji + nonLetters += KeyRect(300, 3 * keyH, 700, 4 * keyH) // space + nonLetters += KeyRect(700, 3 * keyH, 850, 4 * keyH) // cursorLeft / cursorRight + nonLetters += KeyRect(850, 3 * keyH, 1000, 4 * keyH) // return + return Layout(letters, nonLetters) + } + + @Test + fun coordinateOnLetterKeyIsClassifiedAsLetter() { + val layout = stdLayout() + // Center of "Q" at (50, 50) is a letter coordinate. + assertTrue(QwertyGlideKeyClassifier.isOnLetterKey(layout.letterRects, 50, 50)) + assertFalse( + QwertyGlideKeyClassifier.isOnNonGlideKey( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 50, + y = 50 + ) + ) + } + + @Test + fun coordinateOnNumberKeyIsClassifiedAsNonGlide() { + val layout = stdLayout() + // Center of "1" at (50, -50) + assertFalse(QwertyGlideKeyClassifier.isOnLetterKey(layout.letterRects, 50, -50)) + assertTrue( + QwertyGlideKeyClassifier.isOnNonGlideKey( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 50, + y = -50 + ) + ) + } + + @Test + fun coordinateOnSpaceIsClassifiedAsNonGlide() { + val layout = stdLayout() + // Center of "space" at (500, 350) + assertTrue( + QwertyGlideKeyClassifier.isOnNonGlideKey( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 500, + y = 350 + ) + ) + } + + @Test + fun coordinateOnReturnIsClassifiedAsNonGlide() { + val layout = stdLayout() + // Inside "return" at (900, 350) + assertTrue( + QwertyGlideKeyClassifier.isOnNonGlideKey( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 900, + y = 350 + ) + ) + } + + @Test + fun coordinateOnCursorKeyIsClassifiedAsNonGlide() { + val layout = stdLayout() + // Inside "cursorLeft / cursorRight" at (750, 350) + assertTrue( + QwertyGlideKeyClassifier.isOnNonGlideKey( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 750, + y = 350 + ) + ) + } + + @Test + fun coordinateOnShiftIsClassifiedAsNonGlide() { + val layout = stdLayout() + // Inside "Shift" at (50, 250) + assertTrue( + QwertyGlideKeyClassifier.isOnNonGlideKey( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 50, + y = 250 + ) + ) + } + + @Test + fun coordinateOnDeleteIsClassifiedAsNonGlide() { + val layout = stdLayout() + // Inside "Delete" at (900, 250) + assertTrue( + QwertyGlideKeyClassifier.isOnNonGlideKey( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 900, + y = 250 + ) + ) + } + + @Test + fun coordinateInBackgroundIsNeitherLetterNorNonGlideKey() { + // Use a layout with deliberate gaps between rects. + val layout = Layout( + letterRects = listOf(KeyRect(0, 0, 100, 100)), + nonLetterRects = listOf(KeyRect(200, 0, 300, 100)) + ) + // (150, 50) is in the gap between the two rects. + assertFalse(QwertyGlideKeyClassifier.isOnLetterKey(layout.letterRects, 150, 50)) + assertFalse( + QwertyGlideKeyClassifier.isOnNonGlideKey( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 150, + y = 50 + ) + ) + } + + @Test + fun lettersTakePrecedenceOverOverlappingNonLetterRect() { + // Some keyboards may have a non-letter rect that visually overlaps the + // letter row by a few pixels (e.g. extended hit-target). The classifier + // should still report letter-first so an in-progress glide on a letter + // key keeps contributing to the trail. + val letter = KeyRect(0, 0, 100, 100) + val overlap = KeyRect(0, 0, 100, 100) + + // Same rect for both — letter must win. + assertTrue(QwertyGlideKeyClassifier.isOnLetterKey(listOf(letter), 50, 50)) + assertFalse( + QwertyGlideKeyClassifier.isOnNonGlideKey( + letterRects = listOf(letter), + nonLetterRects = listOf(overlap), + x = 50, + y = 50 + ) + ) + } + + @Test + fun noNearestNeighborFallback() { + // Important: a number-row coordinate that is *closer* to a letter key + // than to its own non-letter rect must NOT be reclassified as a letter. + // This guards against a regression where someone replaces the helper + // with a nearest-neighbor lookup. + val letter = KeyRect(0, 0, 100, 100) + val numberKey = KeyRect(0, -100, 100, 0) + // (50, -1) is one pixel above the letter rect (still inside the number + // rect under our half-open semantics: -100 .. 0). + assertFalse(QwertyGlideKeyClassifier.isOnLetterKey(listOf(letter), 50, -1)) + assertTrue( + QwertyGlideKeyClassifier.isOnNonGlideKey( + letterRects = listOf(letter), + nonLetterRects = listOf(numberKey), + x = 50, + y = -1 + ) + ) + } +} From 669ba3a24cabc78cc93a7d1b2dc1e64ad3e38886 Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Wed, 6 May 2026 21:24:05 -0400 Subject: [PATCH 6/6] ignore special keys in qwerty glide input --- app/build.gradle | 4 +- .../glide/QwertyGlideKeyClassifier.kt | 53 ++++- .../qwerty_keyboard/ui/QWERTYKeyboardView.kt | 185 +++++++-------- .../glide/QwertyGlideKeyClassifierTest.kt | 213 ++++++++++++++++++ 4 files changed, 350 insertions(+), 105 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7ca13ce0c..da553d6e5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { applicationId "com.kazumaproject.markdownhelperkeyboard" minSdk 24 targetSdk 36 - versionCode 748 - versionName "1.7.54" + versionCode 749 + versionName "1.7.55" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifier.kt b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifier.kt index 7a5bba087..9f215b4d7 100644 --- a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifier.kt +++ b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifier.kt @@ -9,17 +9,28 @@ package com.kazumaproject.qwerty_keyboard.glide * gesture uses such points for trail / decoder input. * 2. The coordinate falls *exactly* on a visible QWERTY key that is **not** a * Glide-eligible alphabet key (numbers, space, return, cursor, shift, - * delete, mode switch keys, emoji, etc.). When this happens *during* an - * already-started Glide we want to ignore the point entirely — neither - * contributing it to the trail nor letting normal key dispatch fire. + * delete, mode switch keys, emoji, etc.). When this happens for a + * candidate / active Glide pointer we want to ignore the point entirely: + * neither contributing it to the trail nor letting normal key dispatch fire. * 3. The coordinate is in the keyboard background (key gaps, padding, etc.). - * Existing area-cancel logic should still decide whether to cancel the - * gesture in that case. + * Candidate / active Glide MOVE events still consume these points so they + * cannot rewrite normal key pointer state. * * Keeping this logic in a small, view-free helper makes it cheap to unit test * without spinning up a real `QWERTYKeyboardView` instance. */ object QwertyGlideKeyClassifier { + enum class KeyHit { + LETTER, + NON_GLIDE_KEY, + NONE + } + + enum class MoveHandling { + ROUTE_TO_NORMAL_KEY_HANDLER, + APPEND_TO_GLIDE_PATH, + IGNORE_AND_CONSUME + } /** * Lightweight rectangle representation used by the classifier. Mirrors the @@ -66,4 +77,36 @@ object QwertyGlideKeyClassifier { if (isOnLetterKey(letterRects, x, y)) return false return nonLetterRects.any { it.contains(x, y) } } + + fun classify( + letterRects: List, + nonLetterRects: List, + x: Int, + y: Int + ): KeyHit { + return when { + isOnLetterKey(letterRects, x, y) -> KeyHit.LETTER + isOnNonGlideKey(letterRects, nonLetterRects, x, y) -> KeyHit.NON_GLIDE_KEY + else -> KeyHit.NONE + } + } + + /** + * Decides whether an ACTION_MOVE for [pointerId] is owned by the Glide + * pipeline. A candidate / active Glide pointer must never fall through to + * normal key MOVE handling, because that path rewrites pointerButtonMap to + * whatever non-letter key the finger crosses. + */ + fun decideMoveHandling( + glidePointerId: Int?, + pointerId: Int, + keyHit: KeyHit + ): MoveHandling { + if (glidePointerId != pointerId) return MoveHandling.ROUTE_TO_NORMAL_KEY_HANDLER + return when (keyHit) { + KeyHit.LETTER -> MoveHandling.APPEND_TO_GLIDE_PATH + KeyHit.NON_GLIDE_KEY, + KeyHit.NONE -> MoveHandling.IGNORE_AND_CONSUME + } + } } diff --git a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt index 114511a34..905f1bb0e 100644 --- a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt +++ b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt @@ -43,9 +43,9 @@ import androidx.core.view.isVisible import androidx.core.widget.ImageViewCompat import com.google.android.material.color.DynamicColors import com.google.android.material.textview.MaterialTextView -import com.kazumaproject.core.data.qwerty.CapsLockState import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.data.popup.QwertyPopupViewStyleSet +import com.kazumaproject.core.data.qwerty.CapsLockState import com.kazumaproject.core.data.qwerty.QWERTYKeys import com.kazumaproject.core.data.qwerty.VariationInfo import com.kazumaproject.core.domain.extensions.dpToPx @@ -60,14 +60,15 @@ import com.kazumaproject.core.domain.qwerty.QWERTYKey import com.kazumaproject.core.domain.qwerty.QWERTYKeyInfo import com.kazumaproject.core.domain.qwerty.QWERTYKeyMap import com.kazumaproject.core.domain.state.QWERTYMode +import com.kazumaproject.qwerty_keyboard.R +import com.kazumaproject.qwerty_keyboard.databinding.QwertyLayoutBinding import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideGesturePolicy import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideInputListener +import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideKeyClassifier import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointerPoint import com.kazumaproject.qwerty_keyboard.glide.QwertyInputPointers import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyProximity import com.kazumaproject.qwerty_keyboard.glide.QwertyKeyboardProximityInfo -import com.kazumaproject.qwerty_keyboard.R -import com.kazumaproject.qwerty_keyboard.databinding.QwertyLayoutBinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -1613,43 +1614,37 @@ class QWERTYKeyboardView @JvmOverloads constructor( val pointerIndex = event.findPointerIndex(pointerId) if (pointerIndex < 0) { cancelQwertyGlideCandidate(notify = glideStarted) - return false + return true } val x = event.getX(pointerIndex) val y = event.getY(pointerIndex) + val keyHit = classifyQwertyGlideKeyHit(x, y) + val moveHandling = QwertyGlideKeyClassifier.decideMoveHandling( + glidePointerId = glideCandidatePointerId, + pointerId = pointerId, + keyHit = keyHit + ) - // Glide 開始後に限り、数字キー / Space / Return / Cursor / Shift / - // Delete / mode switch などの非アルファベットキー上を通過した - // ケースは「無視して consume」する。Glide を cancel せず、 - // 通常キー処理にも流さないことで、誤入力を防ぐ。 - // - // 重要: Glide 開始前 (glideStarted == false) にここで早期 return すると - // 既存の Glide 発生条件 (pointCount / directDistance / elapsedMillis / - // distinctLetterKeysNearTrail) の計算に影響するため、glideStarted が - // true のときだけ適用する。 - if (glideStarted && isOnNonGlideQwertyKey(x, y)) { - return true + if (moveHandling == QwertyGlideKeyClassifier.MoveHandling.ROUTE_TO_NORMAL_KEY_HANDLER) { + return false } - if (glideStarted) { - // 既存の history 取り込みは「直線補完」のために有用だが、 - // history に非アルファベットキー上の座標が含まれていると - // raw points / proximity decoder へ混入してしまう。 - // Glide 中は letter key 上の history のみを採用する。 - appendHistoricalQwertyGlideLetterPoints(event, pointerIndex, pointerId) - } else { - appendHistoricalQwertyGlidePoints(event, pointerIndex, pointerId) + // Glide candidate / active pointer の MOVE はここで所有する。 + // 通常 MOVE に落とすと pointerButtonMap が数字・句読点などへ + // 書き換わり、UP で通常 tap として commit されてしまう。 + if (moveHandling == QwertyGlideKeyClassifier.MoveHandling.IGNORE_AND_CONSUME) { + releasePressedKeyForGlideMove(pointerId) + return true } + + releasePressedKeyForGlideMove(pointerId) + appendHistoricalQwertyGlideLetterPoints(event, pointerIndex, pointerId) appendQwertyGlidePoint( x = x, y = y, eventTime = event.eventTime, pointerId = pointerId ) - if (!isInsideQwertyGlideGestureArea(x, y)) { - cancelQwertyGlideCandidate(notify = glideStarted) - return true - } if (!glideStarted && shouldStartQwertyGlide(event)) { startQwertyGlide(pointerId) } @@ -1663,40 +1658,16 @@ class QWERTYKeyboardView @JvmOverloads constructor( return false } + MotionEvent.ACTION_POINTER_UP -> { + return handleQwertyGlidePointerUp(event) + } + MotionEvent.ACTION_UP -> { - val pointerId = glideCandidatePointerId ?: return false - val liftedId = event.getPointerId(event.actionIndex) - if (liftedId != pointerId) return false - val upX = event.getX(event.actionIndex) - val upY = event.getY(event.actionIndex) - // Glide 開始後に非アルファベットキー上で指を離した場合、その座標を - // 末尾に append すると proximity decoder の判定に余計なノイズが - // 入るため、letter key 上の場合だけ append する。Glide 開始前 ( - // 通常 tap シナリオ) では既存挙動どおり常に append する。 - if (!glideStarted || findExactQwertyGlideLetterViewUnder(upX, upY) != null) { - appendQwertyGlidePoint( - x = upX, - y = upY, - eventTime = event.eventTime, - pointerId = pointerId - ) - } - return if (glideStarted) { - qwertyGlideInputListener?.onQwertyGlideEnded( - inputPointers = QwertyInputPointers(glideRawPoints.toList()), - proximityInfo = getQwertyKeyboardProximityInfo() - ) - clearQwertyGlideState(clearTrail = true) - clearAllPressed() - true - } else { - clearQwertyGlideState(clearTrail = true) - false - } + return handleQwertyGlidePointerUp(event) } MotionEvent.ACTION_CANCEL -> { - val consumed = glideStarted + val consumed = glideCandidatePointerId != null cancelQwertyGlideCandidate(notify = glideStarted) return consumed } @@ -1722,8 +1693,7 @@ class QWERTYKeyboardView @JvmOverloads constructor( val pointerId = event.getPointerId(pointerIndex) val x = event.getX(pointerIndex) val y = event.getY(pointerIndex) - val downView = findButtonUnder(x.toInt(), y.toInt()) - if (!isQwertyGlideLetterView(downView)) return + if (findExactQwertyGlideLetterViewUnder(x, y) == null) return if (SystemClock.uptimeMillis() - lastNonGlideKeyUpTime < glideFastTypingSuppressMillis) return glideCandidatePointerId = pointerId @@ -1752,35 +1722,48 @@ class QWERTYKeyboardView @JvmOverloads constructor( qwertyGlideInputListener?.onQwertyGlideStarted() } - private fun appendHistoricalQwertyGlidePoints( - event: MotionEvent, - pointerIndex: Int, - pointerId: Int - ) { - for (historyIndex in 0 until event.historySize) { + private fun handleQwertyGlidePointerUp(event: MotionEvent): Boolean { + val pointerId = glideCandidatePointerId ?: return false + val liftedId = event.getPointerId(event.actionIndex) + if (liftedId != pointerId) return false + val upX = event.getX(event.actionIndex) + val upY = event.getY(event.actionIndex) + if (findExactQwertyGlideLetterViewUnder(upX, upY) != null) { appendQwertyGlidePoint( - x = event.getHistoricalX(pointerIndex, historyIndex), - y = event.getHistoricalY(pointerIndex, historyIndex), - eventTime = event.getHistoricalEventTime(historyIndex), + x = upX, + y = upY, + eventTime = event.eventTime, pointerId = pointerId ) } + return if (glideStarted) { + qwertyGlideInputListener?.onQwertyGlideEnded( + inputPointers = QwertyInputPointers(glideRawPoints.toList()), + proximityInfo = getQwertyKeyboardProximityInfo() + ) + clearQwertyGlideState(clearTrail = true) + clearAllPressed() + true + } else { + clearQwertyGlideState(clearTrail = true) + false + } + } + + private fun releasePressedKeyForGlideMove(pointerId: Int) { + pointerButtonMap[pointerId]?.isPressed = false + dismissKeyPreview() + cancelLongPressForPointer(pointerId) } /** * Glide 開始後に history を取り込むための関数。 * - * 通常の [appendHistoricalQwertyGlidePoints] と異なり、history に含まれる - * 各座標が「Glide 用 alphabet key 上にある」ものだけを raw points / trail に - * 採用する。Space / 数字キー / Return など非アルファベットキー上の座標は、 + * history に含まれる各座標が「Glide 用 alphabet key 上にある」ものだけを + * raw points / trail に採用する。Space / 数字キー / Return など非アルファベットキー上の座標は、 * 一瞬通過しただけでも history に乗っているケースがあり、それらが * `glideRawPoints` や proximity decoder に混入して候補生成を歪める原因に * なるため除外する。 - * - * 既存の [appendHistoricalQwertyGlidePoints] は Glide 開始前 (まだ - * shouldStart() による gesture 判定中) のフェーズで利用しており、 - * letter-only filtering を入れると既存の発火条件の計算に影響する。そのため - * 関数自体を分けて、Glide 開始後の経路だけ filtering を適用する。 */ private fun appendHistoricalQwertyGlideLetterPoints( event: MotionEvent, @@ -2319,6 +2302,34 @@ class QWERTYKeyboardView @JvmOverloads constructor( return view in getVisibleQwertyLetterViews() } + private fun View.toQwertyGlideKeyRect(): QwertyGlideKeyClassifier.KeyRect { + getHitRect(glideHitRect) + return QwertyGlideKeyClassifier.KeyRect( + left = glideHitRect.left, + top = glideHitRect.top, + right = glideHitRect.right, + bottom = glideHitRect.bottom + ) + } + + private fun classifyQwertyGlideKeyHit( + x: Float, + y: Float + ): QwertyGlideKeyClassifier.KeyHit { + val letterViews = getVisibleQwertyLetterViews() + val letterViewSet = letterViews.toSet() + val letterRects = letterViews.map { it.toQwertyGlideKeyRect() } + val nonLetterRects = qwertyButtonMap.keys + .filter { it.isVisible && it !in letterViewSet } + .map { it.toQwertyGlideKeyRect() } + return QwertyGlideKeyClassifier.classify( + letterRects = letterRects, + nonLetterRects = nonLetterRects, + x = x.toInt(), + y = y.toInt() + ) + } + /** * 共有 [hitRect] を使い回すと、Glide 中の判定が他のタッチ処理と競合する恐れが * あるため、Glide 専用の Rect を別に確保する。 @@ -2344,28 +2355,6 @@ class QWERTYKeyboardView @JvmOverloads constructor( } } - /** - * 指定座標が「QWERTYButton としては存在するが、Glide 用の alphabet key では - * ない可視キー」の上にあるかを判定する。 - * - * Glide 中の ACTION_MOVE で、数字キー / Space / Return / Cursor / Shift / - * Delete / mode switch / Emoji などを通過したケースを「無視して consume」する - * ためだけに使うこと。通常キー入力全体のディスパッチには使わない。 - */ - private fun isOnNonGlideQwertyKey(x: Float, y: Float): Boolean { - val xi = x.toInt() - val yi = y.toInt() - if (findExactQwertyGlideLetterViewUnder(x, y) != null) return false - for (key in qwertyButtonMap.keys) { - if (!key.isVisible) continue - key.getHitRect(glideHitRect) - if (glideHitRect.contains(xi, yi)) { - return true - } - } - return false - } - override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) if (glideTrailPoints.size < 2) return diff --git a/qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifierTest.kt b/qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifierTest.kt index a5ae53d2e..6a33ed64a 100644 --- a/qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifierTest.kt +++ b/qwerty_keyboard/src/test/java/com/kazumaproject/qwerty_keyboard/glide/QwertyGlideKeyClassifierTest.kt @@ -1,7 +1,10 @@ package com.kazumaproject.qwerty_keyboard.glide import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideKeyClassifier.KeyRect +import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideKeyClassifier.KeyHit +import com.kazumaproject.qwerty_keyboard.glide.QwertyGlideKeyClassifier.MoveHandling import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -83,6 +86,15 @@ class QwertyGlideKeyClassifierTest { val layout = stdLayout() // Center of "1" at (50, -50) assertFalse(QwertyGlideKeyClassifier.isOnLetterKey(layout.letterRects, 50, -50)) + assertEquals( + KeyHit.NON_GLIDE_KEY, + QwertyGlideKeyClassifier.classify( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 50, + y = -50 + ) + ) assertTrue( QwertyGlideKeyClassifier.isOnNonGlideKey( letterRects = layout.letterRects, @@ -172,6 +184,15 @@ class QwertyGlideKeyClassifierTest { ) // (150, 50) is in the gap between the two rects. assertFalse(QwertyGlideKeyClassifier.isOnLetterKey(layout.letterRects, 150, 50)) + assertEquals( + KeyHit.NONE, + QwertyGlideKeyClassifier.classify( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 150, + y = 50 + ) + ) assertFalse( QwertyGlideKeyClassifier.isOnNonGlideKey( letterRects = layout.letterRects, @@ -182,6 +203,198 @@ class QwertyGlideKeyClassifierTest { ) } + @Test + fun glideCandidateMoveOnNumberKeyIsConsumedInsteadOfRoutedToNormalKeys() { + val handling = QwertyGlideKeyClassifier.decideMoveHandling( + glidePointerId = 7, + pointerId = 7, + keyHit = KeyHit.NON_GLIDE_KEY + ) + + assertEquals(MoveHandling.IGNORE_AND_CONSUME, handling) + } + + @Test + fun glideCandidateMoveOnKutenOrToutenIsConsumedByTheSameNonLetterRule() { + val layout = Layout( + letterRects = listOf( + KeyRect(0, 0, 100, 100), + KeyRect(200, 0, 300, 100) + ), + nonLetterRects = listOf( + KeyRect(0, 100, 100, 200), // keyKuten + KeyRect(100, 100, 200, 200) // keyTouten + ) + ) + + val keyKutenHit = QwertyGlideKeyClassifier.classify( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 50, + y = 150 + ) + val keyToutenHit = QwertyGlideKeyClassifier.classify( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = 150, + y = 150 + ) + + assertEquals(KeyHit.NON_GLIDE_KEY, keyKutenHit) + assertEquals(KeyHit.NON_GLIDE_KEY, keyToutenHit) + assertEquals( + MoveHandling.IGNORE_AND_CONSUME, + QwertyGlideKeyClassifier.decideMoveHandling( + glidePointerId = 1, + pointerId = 1, + keyHit = keyKutenHit + ) + ) + assertEquals( + MoveHandling.IGNORE_AND_CONSUME, + QwertyGlideKeyClassifier.decideMoveHandling( + glidePointerId = 1, + pointerId = 1, + keyHit = keyToutenHit + ) + ) + } + + @Test + fun glideCandidateMoveOnSpaceReturnOrCursorIsConsumedByTheSameNonLetterRule() { + val layout = stdLayout() + val nonLetterCoordinates = listOf( + 500 to 350, // space + 900 to 350, // return + 750 to 350 // cursorLeft / cursorRight + ) + + nonLetterCoordinates.forEach { (x, y) -> + val hit = QwertyGlideKeyClassifier.classify( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = x, + y = y + ) + assertEquals(KeyHit.NON_GLIDE_KEY, hit) + assertEquals( + MoveHandling.IGNORE_AND_CONSUME, + QwertyGlideKeyClassifier.decideMoveHandling( + glidePointerId = 3, + pointerId = 3, + keyHit = hit + ) + ) + } + } + + @Test + fun glideCandidateMoveOnLetterIsAppendedToGlidePath() { + assertEquals( + MoveHandling.APPEND_TO_GLIDE_PATH, + QwertyGlideKeyClassifier.decideMoveHandling( + glidePointerId = 2, + pointerId = 2, + keyHit = KeyHit.LETTER + ) + ) + } + + @Test + fun glideCandidateMoveInBackgroundIsConsumedInsteadOfRoutedToNormalKeys() { + assertEquals( + MoveHandling.IGNORE_AND_CONSUME, + QwertyGlideKeyClassifier.decideMoveHandling( + glidePointerId = 4, + pointerId = 4, + keyHit = KeyHit.NONE + ) + ) + } + + @Test + fun nonGlidePointerStillRoutesToNormalKeyHandling() { + listOf(KeyHit.LETTER, KeyHit.NON_GLIDE_KEY, KeyHit.NONE).forEach { hit -> + assertEquals( + MoveHandling.ROUTE_TO_NORMAL_KEY_HANDLER, + QwertyGlideKeyClassifier.decideMoveHandling( + glidePointerId = null, + pointerId = 8, + keyHit = hit + ) + ) + assertEquals( + MoveHandling.ROUTE_TO_NORMAL_KEY_HANDLER, + QwertyGlideKeyClassifier.decideMoveHandling( + glidePointerId = 9, + pointerId = 8, + keyHit = hit + ) + ) + } + } + + @Test + fun glidePathKeepsOnlyLettersWhenSequenceCrossesNumberKey() { + val layout = Layout( + letterRects = listOf( + KeyRect(0, 0, 100, 100), // Q + KeyRect(100, 0, 200, 100), // W + KeyRect(200, 0, 300, 100), // E + KeyRect(300, 0, 400, 100) // R + ), + nonLetterRects = listOf( + KeyRect(100, -100, 200, 0) // key1 + ) + ) + val sequence = listOf( + Triple("Q", 50, 50), + Triple("W", 150, 50), + Triple("1", 150, -50), + Triple("E", 250, 50), + Triple("R", 350, 50) + ) + + val appended = mutableListOf() + val routedToNormal = mutableListOf() + sequence.forEach { (label, x, y) -> + val hit = QwertyGlideKeyClassifier.classify( + letterRects = layout.letterRects, + nonLetterRects = layout.nonLetterRects, + x = x, + y = y + ) + when ( + QwertyGlideKeyClassifier.decideMoveHandling( + glidePointerId = 11, + pointerId = 11, + keyHit = hit + ) + ) { + MoveHandling.APPEND_TO_GLIDE_PATH -> appended += label + MoveHandling.ROUTE_TO_NORMAL_KEY_HANDLER -> routedToNormal += label + MoveHandling.IGNORE_AND_CONSUME -> Unit + } + } + + assertEquals(listOf("Q", "W", "E", "R"), appended) + assertTrue(routedToNormal.isEmpty()) + } + + @Test + fun qwertyGlidePreferenceOffScenarioKeepsNumberAndPunctuationMovesRoutedNormally() { + listOf(KeyHit.NON_GLIDE_KEY, KeyHit.NON_GLIDE_KEY).forEach { hit -> + assertEquals( + MoveHandling.ROUTE_TO_NORMAL_KEY_HANDLER, + QwertyGlideKeyClassifier.decideMoveHandling( + glidePointerId = null, + pointerId = 5, + keyHit = hit + ) + ) + } + } + @Test fun lettersTakePrecedenceOverOverlappingNonLetterRect() { // Some keyboards may have a non-letter rect that visually overlaps the