From f5216c5758ba5a169e74aa4fa9ae0cec5208bcbd Mon Sep 17 00:00:00 2001 From: fflow2023 Date: Thu, 30 Jul 2026 20:50:46 +0800 Subject: [PATCH] feat(clipboard): add common words and completion --- .../fcitx5/android/CommonWordsManagerTest.kt | 40 +++++++++ .../data/quickphrase/CommonWordMatcher.kt | 43 ++++++++++ .../data/quickphrase/QuickPhraseManager.kt | 49 ++++++++++- .../android/input/bar/KawaiiBarComponent.kt | 62 +++++++++++++- .../fcitx5/android/input/bar/ui/IdleUi.kt | 9 +- .../bar/ui/idle/CommonWordSuggestionUi.kt | 74 ++++++++++++++++ .../input/clipboard/ClipboardInstructionUi.kt | 28 ++++++ .../input/clipboard/ClipboardPanelSection.kt | 3 +- .../input/clipboard/ClipboardTopBarUi.kt | 21 +++++ .../android/input/clipboard/ClipboardUi.kt | 16 ++++ .../input/clipboard/ClipboardWindow.kt | 85 +++++++++++++++++++ .../input/clipboard/CommonWordEntryUi.kt | 72 ++++++++++++++++ .../input/clipboard/CommonWordsAdapter.kt | 82 ++++++++++++++++++ .../main/settings/QuickPhraseEditFragment.kt | 31 +++++-- .../org/fcitx/fcitx5/android/utils/AppUtil.kt | 4 + .../main/res/drawable/ic_baseline_add_24.xml | 9 ++ app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../android/ClipboardStateMachineTest.kt | 10 ++- .../fcitx5/android/CommonWordMatcherTest.kt | 56 ++++++++++++ docs/clipboard-enhancement-plan.md | 34 ++++++-- docs/common-words-manual-test-samples.mb | 4 + 23 files changed, 720 insertions(+), 21 deletions(-) create mode 100644 app/src/androidTest/java/org/fcitx/fcitx5/android/CommonWordsManagerTest.kt create mode 100644 app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/CommonWordMatcher.kt create mode 100644 app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/CommonWordSuggestionUi.kt create mode 100644 app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/CommonWordEntryUi.kt create mode 100644 app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/CommonWordsAdapter.kt create mode 100644 app/src/main/res/drawable/ic_baseline_add_24.xml create mode 100644 app/src/test/java/org/fcitx/fcitx5/android/CommonWordMatcherTest.kt create mode 100644 docs/common-words-manual-test-samples.mb diff --git a/app/src/androidTest/java/org/fcitx/fcitx5/android/CommonWordsManagerTest.kt b/app/src/androidTest/java/org/fcitx/fcitx5/android/CommonWordsManagerTest.kt new file mode 100644 index 00000000..75c5f3a4 --- /dev/null +++ b/app/src/androidTest/java/org/fcitx/fcitx5/android/CommonWordsManagerTest.kt @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android + +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseManager +import org.junit.Assert.assertEquals +import org.junit.Test + +class CommonWordsManagerTest { + @Test + fun commonWordsUseQuickPhraseStorageAndPublishUpdates() { + val original = QuickPhraseManager.loadCommonWords() + val updates = mutableListOf>() + val listener = QuickPhraseManager.OnCommonWordsChangedListener { + updates.add(it) + } + val address = QuickPhraseEntry("address", "北京市朝阳区示例路100号") + val account = QuickPhraseEntry("account", "Account-Example-001") + + QuickPhraseManager.addOnCommonWordsChangedListener(listener) + try { + QuickPhraseManager.saveCommonWords(listOf(address, account)) + assertEquals(listOf(address, account), QuickPhraseManager.loadCommonWords()) + assertEquals(listOf(address, account), updates.last()) + + assertEquals( + listOf(account), + QuickPhraseManager.deleteCommonWord(address) + ) + assertEquals(listOf(account), QuickPhraseManager.loadCommonWords()) + assertEquals(listOf(account), updates.last()) + } finally { + QuickPhraseManager.removeOnCommonWordsChangedListener(listener) + QuickPhraseManager.saveCommonWords(original) + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/CommonWordMatcher.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/CommonWordMatcher.kt new file mode 100644 index 00000000..d66736fa --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/CommonWordMatcher.kt @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.data.quickphrase + +object CommonWordMatcher { + const val MIN_PREFIX_CODE_POINTS = 3 + + data class Match( + val entry: QuickPhraseEntry, + val matchedPrefix: String, + val completion: String + ) + + fun bestMatch( + textBeforeCursor: String, + entries: List, + minPrefixCodePoints: Int = MIN_PREFIX_CODE_POINTS + ): Match? = entries + .mapNotNull { match(textBeforeCursor, it, minPrefixCodePoints) } + .maxByOrNull { it.matchedPrefix.codePointCount(0, it.matchedPrefix.length) } + + private fun match( + textBeforeCursor: String, + entry: QuickPhraseEntry, + minPrefixCodePoints: Int + ): Match? { + val phrase = entry.phrase + val phraseCodePoints = phrase.codePointCount(0, phrase.length) + if (phraseCodePoints <= minPrefixCodePoints) return null + val beforeCodePoints = textBeforeCursor.codePointCount(0, textBeforeCursor.length) + val maxPrefix = minOf(phraseCodePoints - 1, beforeCodePoints) + for (prefixLength in maxPrefix downTo minPrefixCodePoints) { + val prefixEnd = phrase.offsetByCodePoints(0, prefixLength) + val prefix = phrase.substring(0, prefixEnd) + if (textBeforeCursor.endsWith(prefix, ignoreCase = true)) { + return Match(entry, prefix, phrase.substring(prefixEnd)) + } + } + return null + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseManager.kt b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseManager.kt index 595e9ceb..9e53102a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseManager.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/data/quickphrase/QuickPhraseManager.kt @@ -8,6 +8,7 @@ import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.core.data.DataManager import org.fcitx.fcitx5.android.utils.appContext import org.fcitx.fcitx5.android.utils.errorRuntime +import org.fcitx.fcitx5.android.utils.WeakHashSet import org.fcitx.fcitx5.android.utils.withTempDir import java.io.File import java.io.InputStream @@ -19,9 +20,51 @@ object QuickPhraseManager { ) private val customQuickPhraseDir = File( - appContext.getExternalFilesDir(null)!!, "data/data/quickphrase.d" + appContext.getExternalFilesDir(null) ?: appContext.filesDir, + "data/data/quickphrase.d" ).also { it.mkdirs() } + val commonWords: CustomQuickPhrase by lazy { + val file = File(customQuickPhraseDir, "$COMMON_WORDS_FILE_NAME.${QuickPhrase.EXT}") + if (!file.exists()) file.createNewFile() + CustomQuickPhrase(file) + } + + fun isCommonWords(quickPhrase: QuickPhrase): Boolean = + quickPhrase.file.absolutePath == commonWords.file.absolutePath + + fun interface OnCommonWordsChangedListener { + fun onChanged(entries: List) + } + + private val commonWordsListeners = WeakHashSet() + + fun addOnCommonWordsChangedListener(listener: OnCommonWordsChangedListener) { + commonWordsListeners.add(listener) + } + + fun removeOnCommonWordsChangedListener(listener: OnCommonWordsChangedListener) { + commonWordsListeners.remove(listener) + } + + @Synchronized + fun loadCommonWords(): List = commonWords.loadData().toList() + + @Synchronized + fun saveCommonWords(entries: List) { + commonWords.saveData(QuickPhraseData(entries)) + val snapshot = entries.toList() + commonWordsListeners.forEach { it.onChanged(snapshot) } + } + + @Synchronized + fun deleteCommonWord(entry: QuickPhraseEntry): List { + val entries = loadCommonWords().toMutableList() + entries.remove(entry) + saveCommonWords(entries) + return entries + } + fun listQuickPhrase(): List { val builtin = listDir(builtinQuickPhraseDir) { file -> BuiltinQuickPhrase(file, File(customQuickPhraseDir, file.name)) @@ -74,5 +117,5 @@ object QuickPhraseManager { ?.let { block(file) } } ?: listOf() - -} \ No newline at end of file + private const val COMMON_WORDS_FILE_NAME = "f5clipboard-common-words" +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt index a4b09eb6..b0201939 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/KawaiiBarComponent.kt @@ -34,6 +34,9 @@ import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.quickphrase.CommonWordMatcher +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseManager import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.State.ClickToAttachWindow import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.State.ClickToDetachWindow @@ -109,6 +112,9 @@ class KawaiiBarComponent : UniqueViewComponent( private var clipboardTimeoutJob: Job? = null private var isClipboardFresh: Boolean = false + private var commonWords: List = emptyList() + private var commonWordMatch: CommonWordMatcher.Match? = null + private var commonWordSuggestionsAllowed: Boolean = true private var isInlineSuggestionPresent: Boolean = false private var isCapabilityFlagsPassword: Boolean = false private var isKeyboardLayoutNumber: Boolean = false @@ -118,6 +124,15 @@ class KawaiiBarComponent : UniqueViewComponent( private var numberRowState = NumberRowState.Auto + @Keep + private val onCommonWordsChangedListener = + QuickPhraseManager.OnCommonWordsChangedListener { + service.lifecycleScope.launch { + commonWords = it + refreshCommonWordSuggestion() + } + } + @Keep private val onClipboardUpdateListener = ClipboardManager.OnClipboardUpdateListener { @@ -176,6 +191,7 @@ class KawaiiBarComponent : UniqueViewComponent( private fun evalIdleUiState(fromUser: Boolean = false) { val newState = when { numberRowState == NumberRowState.ForceShow -> IdleUi.State.NumberRow + commonWordMatch != null -> IdleUi.State.CommonWord isClipboardFresh -> IdleUi.State.Clipboard isInlineSuggestionPresent -> IdleUi.State.InlineSuggestion isCapabilityFlagsPassword && !isKeyboardLayoutNumber && numberRowState != NumberRowState.ForceHide -> IdleUi.State.NumberRow @@ -324,6 +340,17 @@ class KawaiiBarComponent : UniqueViewComponent( true } } + commonWordUi.suggestionView.apply { + setOnClickListener { + commonWordMatch?.completion?.let { service.commitText(it) } + commonWordMatch = null + evalIdleUiState() + } + setOnLongClickListener { + AppUtil.launchMainToCommonWords(context) + true + } + } numberRow.apply { onCollapseListener = { numberRowState = NumberRowState.ForceHide @@ -427,14 +454,21 @@ class KawaiiBarComponent : UniqueViewComponent( } } ClipboardManager.addOnUpdateListener(onClipboardUpdateListener) + commonWords = QuickPhraseManager.loadCommonWords() + QuickPhraseManager.addOnCommonWordsChangedListener(onCommonWordsChangedListener) clipboardSuggestion.registerOnChangeListener(onClipboardSuggestionUpdateListener) clipboardItemTimeout.registerOnChangeListener(onClipboardTimeoutUpdateListener) } override fun onStartInput(info: EditorInfo, capFlags: CapabilityFlags) { + val privateMode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + info.imeOptions.hasFlag(EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - idleUi.privateMode(info.imeOptions.hasFlag(EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING)) + idleUi.privateMode(privateMode) } + commonWords = QuickPhraseManager.loadCommonWords() + commonWordSuggestionsAllowed = !privateMode && !capFlags.has(CapabilityFlag.Password) + commonWordMatch = null isCapabilityFlagsPassword = toolbarNumRowOnPassword && capFlags.has(CapabilityFlag.Password) isInlineSuggestionPresent = false numberRowState = NumberRowState.Auto @@ -449,6 +483,31 @@ class KawaiiBarComponent : UniqueViewComponent( if (shouldShowVoiceInput) switchToVoiceInputCallback else hideKeyboardCallback ) evalIdleUiState() + refreshCommonWordSuggestion() + } + + override fun onSelectionUpdate(start: Int, end: Int) { + if (start != end) { + commonWordMatch = null + evalIdleUiState() + return + } + refreshCommonWordSuggestion() + } + + private fun refreshCommonWordSuggestion() { + commonWordMatch = if (commonWordSuggestionsAllowed) { + val textBeforeCursor = runCatching { + service.currentInputConnection + ?.getTextBeforeCursor(COMMON_WORD_LOOKBEHIND, 0) + ?.toString() + }.getOrNull().orEmpty() + CommonWordMatcher.bestMatch(textBeforeCursor, commonWords) + } else { + null + } + commonWordMatch?.let { idleUi.commonWordUi.text.text = it.entry.phrase } + evalIdleUiState() } override fun onPreeditEmptyStateUpdate(empty: Boolean) { @@ -540,6 +599,7 @@ class KawaiiBarComponent : UniqueViewComponent( companion object { const val HEIGHT = 40 + private const val COMMON_WORD_LOOKBEHIND = 256 } fun onKeyboardLayoutSwitched(isNumber: Boolean) { diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt index 14c09e6e..94284a16 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/IdleUi.kt @@ -21,6 +21,7 @@ import org.fcitx.fcitx5.android.data.theme.Theme import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent import org.fcitx.fcitx5.android.input.bar.ui.idle.ButtonsBarUi import org.fcitx.fcitx5.android.input.bar.ui.idle.ClipboardSuggestionUi +import org.fcitx.fcitx5.android.input.bar.ui.idle.CommonWordSuggestionUi import org.fcitx.fcitx5.android.input.bar.ui.idle.InlineSuggestionsUi import org.fcitx.fcitx5.android.input.bar.ui.idle.NumberRow import org.fcitx.fcitx5.android.input.keyboard.CommonKeyActionListener @@ -50,7 +51,7 @@ class IdleUi( ) : Ui { enum class State { - Empty, Toolbar, Clipboard, NumberRow, InlineSuggestion + Empty, Toolbar, Clipboard, CommonWord, NumberRow, InlineSuggestion } var currentState = State.Empty @@ -83,6 +84,8 @@ class IdleUi( val clipboardUi = ClipboardSuggestionUi(ctx, theme) + val commonWordUi = CommonWordSuggestionUi(ctx, theme) + val numberRow = NumberRow(ctx, theme).apply { visibility = View.GONE } @@ -93,6 +96,7 @@ class IdleUi( add(emptyBar, lParams(matchParent, matchParent)) add(buttonsUi.root, lParams(matchParent, matchParent)) add(clipboardUi.root, lParams(matchParent, matchParent)) + add(commonWordUi.root, lParams(matchParent, matchParent)) add(inlineSuggestionsBar.root, lParams(matchParent, matchParent)) } @@ -221,8 +225,9 @@ class IdleUi( State.Empty -> animator.displayedChild = 0 State.Toolbar -> animator.displayedChild = 1 State.Clipboard -> animator.displayedChild = 2 + State.CommonWord -> animator.displayedChild = 3 State.NumberRow -> {} - State.InlineSuggestion -> animator.displayedChild = 3 + State.InlineSuggestion -> animator.displayedChild = 4 } if (state == State.NumberRow) { numberRow.keyActionListener = commonKeyActionListener.listener diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/CommonWordSuggestionUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/CommonWordSuggestionUi.kt new file mode 100644 index 00000000..e2ee1fde --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/bar/ui/idle/CommonWordSuggestionUi.kt @@ -0,0 +1,74 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.input.bar.ui.idle + +import android.content.Context +import android.text.TextUtils +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView +import org.fcitx.fcitx5.android.utils.rippleDrawable +import splitties.dimensions.dp +import splitties.resources.drawable +import splitties.views.dsl.constraintlayout.after +import splitties.views.dsl.constraintlayout.before +import splitties.views.dsl.constraintlayout.centerInParent +import splitties.views.dsl.constraintlayout.centerVertically +import splitties.views.dsl.constraintlayout.constraintLayout +import splitties.views.dsl.constraintlayout.endOfParent +import splitties.views.dsl.constraintlayout.lParams +import splitties.views.dsl.constraintlayout.matchConstraints +import splitties.views.dsl.constraintlayout.startOfParent +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.imageView +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.verticalMargin +import splitties.views.dsl.core.wrapContent +import splitties.views.imageDrawable + +class CommonWordSuggestionUi(override val ctx: Context, theme: Theme) : Ui { + + private val icon = imageView { + imageDrawable = drawable(R.drawable.ic_baseline_star_24)!!.apply { + setTint(theme.accentKeyBackgroundColor) + } + } + + val text = textView { + isSingleLine = true + maxWidth = dp(260) + ellipsize = TextUtils.TruncateAt.END + setTextColor(theme.altKeyTextColor) + } + + private val layout = constraintLayout { + val spacing = dp(4) + add(icon, lParams(dp(18), dp(18)) { + startOfParent(spacing) + before(text) + centerVertically() + }) + add(text, lParams(wrapContent, wrapContent) { + after(icon, spacing) + endOfParent(spacing) + centerVertically() + }) + } + + val suggestionView = CustomGestureView(ctx).apply { + add(layout, lParams(wrapContent, matchParent)) + background = rippleDrawable(theme.keyPressHighlightColor) + } + + override val root = constraintLayout { + add(suggestionView, lParams(wrapContent, matchConstraints) { + centerInParent() + verticalMargin = dp(4) + }) + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardInstructionUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardInstructionUi.kt index ff739198..95e47d71 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardInstructionUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardInstructionUi.kt @@ -109,6 +109,33 @@ sealed class ClipboardInstructionUi(override val ctx: Context, protected val the } } + class CommonWordsEmpty(ctx: Context, theme: Theme) : ClipboardInstructionUi(ctx, theme) { + + private val icon = imageView { + imageDrawable = drawable(R.drawable.ic_outline_star_24)!!.apply { + setTint(theme.altKeyTextColor) + } + } + + private val instructionText = textView { + setText(R.string.instruction_common_words) + setTextColor(theme.keyTextColor) + } + + override val root = constraintLayout { + add(icon, lParams(dp(90), dp(90)) { + topOfParent(dp(24)) + startOfParent() + endOfParent() + }) + add(instructionText, lParams(wrapContent, wrapContent) { + below(icon, dp(16)) + startOfParent() + endOfParent() + }) + } + } + class FilteredEmpty(ctx: Context, theme: Theme) : ClipboardInstructionUi(ctx, theme) { private val icon = imageView { @@ -138,6 +165,7 @@ sealed class ClipboardInstructionUi(override val ctx: Context, protected val the val format = when (section) { ClipboardPanelSection.Clipboard -> R.string.instruction_no_category ClipboardPanelSection.Favorites -> R.string.instruction_no_favorite_category + ClipboardPanelSection.CommonWords -> R.string.instruction_no_category } instructionText.text = ctx.getString(format, ctx.getString(category.stringRes)) } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardPanelSection.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardPanelSection.kt index 33a019a1..091084b9 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardPanelSection.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardPanelSection.kt @@ -6,5 +6,6 @@ package org.fcitx.fcitx5.android.input.clipboard enum class ClipboardPanelSection { Clipboard, - Favorites + Favorites, + CommonWords } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardTopBarUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardTopBarUi.kt index 34d41dae..9839dae1 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardTopBarUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardTopBarUi.kt @@ -66,11 +66,17 @@ class ClipboardTopBarUi(override val ctx: Context, private val theme: Theme) : U private val clipboardTab = Tab(ClipboardPanelSection.Clipboard, R.string.clipboard) private val favoritesTab = Tab(ClipboardPanelSection.Favorites, R.string.favorites) + private val commonWordsTab = Tab(ClipboardPanelSection.CommonWords, R.string.common_words) val deleteAllButton = ToolButton(ctx, R.drawable.ic_baseline_delete_sweep_24, theme).apply { contentDescription = ctx.getString(R.string.delete_all) } + val addCommonWordButton = ToolButton(ctx, R.drawable.ic_baseline_add_24, theme).apply { + contentDescription = ctx.getString(R.string.add_common_word) + visibility = View.INVISIBLE + } + private var onSectionSelected: ((ClipboardPanelSection) -> Unit)? = null override val root = constraintLayout { @@ -84,12 +90,22 @@ class ClipboardTopBarUi(override val ctx: Context, private val theme: Theme) : U topOfParent() after(clipboardTab.root) bottomOfParent() + before(commonWordsTab.root) + }) + add(commonWordsTab.root, lParams { + topOfParent() + after(favoritesTab.root) + bottomOfParent() before(deleteAllButton) }) add(deleteAllButton, lParams(dp(40), dp(40)) { centerVertically() endOfParent() }) + add(addCommonWordButton, lParams(dp(40), dp(40)) { + centerVertically() + endOfParent() + }) } init { @@ -103,9 +119,14 @@ class ClipboardTopBarUi(override val ctx: Context, private val theme: Theme) : U fun setActiveSection(section: ClipboardPanelSection) { clipboardTab.setActive(section == ClipboardPanelSection.Clipboard) favoritesTab.setActive(section == ClipboardPanelSection.Favorites) + commonWordsTab.setActive(section == ClipboardPanelSection.CommonWords) } fun setDeleteButtonShown(shown: Boolean) { deleteAllButton.visibility = if (shown) View.VISIBLE else View.INVISIBLE } + + fun setCommonWordButtonShown(shown: Boolean) { + addCommonWordButton.visibility = if (shown) View.VISIBLE else View.INVISIBLE + } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt index 99bb1196..63fc54d2 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardUi.kt @@ -40,6 +40,12 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui { val filteredEmptyUi = ClipboardInstructionUi.FilteredEmpty(ctx, theme) + val commonWordsRecyclerView = recyclerView { + addItemDecoration(SpacesItemDecoration(dp(4))) + } + + val commonWordsEmptyUi = ClipboardInstructionUi.CommonWordsEmpty(ctx, theme) + val categoryBar = ClipboardCategoryBarUi(ctx, theme) val viewAnimator = view(::ViewAnimator) { @@ -48,6 +54,8 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui { add(enableUi.root, lParams(matchParent, matchParent)) add(favoritesEmptyUi.root, lParams(matchParent, matchParent)) add(filteredEmptyUi.root, lParams(matchParent, matchParent)) + add(commonWordsRecyclerView, lParams(matchParent, matchParent)) + add(commonWordsEmptyUi.root, lParams(matchParent, matchParent)) } private val keyBorder by ThemeManager.prefs.keyBorder @@ -91,5 +99,13 @@ class ClipboardUi(override val ctx: Context, private val theme: Theme) : Ui { categoryBar.root.visibility = if (state == ClipboardStateMachine.State.EnableListening) View.GONE else View.VISIBLE topBar.setDeleteButtonShown(showDeleteButton) + topBar.setCommonWordButtonShown(false) + } + + fun showCommonWords(empty: Boolean) { + viewAnimator.displayedChild = if (empty) 6 else 5 + categoryBar.root.visibility = View.GONE + topBar.setDeleteButtonShown(false) + topBar.setCommonWordButtonShown(true) } } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt index dc189e8b..4f9d0293 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/ClipboardWindow.kt @@ -27,15 +27,20 @@ import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.SnackbarContentLayout import kotlinx.coroutines.Job +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.reloadQuickPhrase import org.fcitx.fcitx5.android.data.clipboard.ClipboardCategory import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager import org.fcitx.fcitx5.android.data.clipboard.ClipboardEntryFilter import org.fcitx.fcitx5.android.data.clipboard.db.ClipboardEntry import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.data.prefs.ManagedPreference +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseManager import org.fcitx.fcitx5.android.data.theme.ThemeManager import org.fcitx.fcitx5.android.input.FcitxInputMethodService import org.fcitx.fcitx5.android.input.dependency.inputMethodService @@ -75,11 +80,23 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { private val clipboardEntryRadius by ThemeManager.prefs.clipboardEntryRadius private var adapterSubmitJob: Job? = null + private var commonWordsLoadJob: Job? = null private var selectedSection = ClipboardPanelSection.Clipboard private var selectedCategory: ClipboardCategory? = null private var visibleEntriesEmpty = true private var deleteAvailable = false + private var commonWordsEmpty = true + + @Keep + private val commonWordsChangedListener = + QuickPhraseManager.OnCommonWordsChangedListener { entries -> + service.lifecycleScope.launch { + commonWordsAdapter.updateEntries(entries) + commonWordsEmpty = entries.isEmpty() + if (selectedSection == ClipboardPanelSection.CommonWords) renderUi() + } + } private val adapter: ClipboardAdapter by lazy { object : ClipboardAdapter( @@ -132,12 +149,47 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { } } + private val commonWordsAdapter: CommonWordsAdapter by lazy { + object : CommonWordsAdapter( + theme, + context.dp(clipboardEntryRadius.toFloat()) + ) { + override fun onPaste(entry: QuickPhraseEntry) { + service.commitText(entry.phrase) + if (clipboardReturnAfterPaste) windowManager.attachWindow(KeyboardWindow) + } + + override fun onEdit() { + AppUtil.launchMainToCommonWords(context) + } + + override fun onDelete(entry: QuickPhraseEntry) { + service.lifecycleScope.launch { + val entries = withContext(Dispatchers.IO) { + QuickPhraseManager.deleteCommonWord(entry) + } + updateEntries(entries) + commonWordsEmpty = entries.isEmpty() + renderUi() + service.postFcitxJob { reloadQuickPhrase() } + } + } + } + } + private val ui by lazy { ClipboardUi(context, theme).apply { recyclerView.apply { layoutManager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL) adapter = this@ClipboardWindow.adapter } + commonWordsRecyclerView.apply { + layoutManager = StaggeredGridLayoutManager( + 2, + StaggeredGridLayoutManager.VERTICAL + ) + adapter = commonWordsAdapter + } ItemTouchHelper(object : ItemTouchHelper.Callback() { override fun getMovementFlags( recyclerView: RecyclerView, @@ -174,6 +226,9 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { topBar.deleteAllButton.setOnClickListener { promptDeleteAll() } + topBar.addCommonWordButton.setOnClickListener { + AppUtil.launchMainToCommonWords(context) + } } } @@ -281,6 +336,13 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { private fun submitEntries() { visibleEntriesEmpty = false deleteAvailable = false + if (selectedSection == ClipboardPanelSection.CommonWords) { + adapterSubmitJob?.cancel() + adapterSubmitJob = null + renderUi() + loadCommonWords() + return + } renderUi() adapterSubmitJob?.cancel() adapterSubmitJob = service.lifecycleScope.launch { @@ -288,6 +350,7 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { scope = when (selectedSection) { ClipboardPanelSection.Clipboard -> ClipboardEntryFilter.Scope.All ClipboardPanelSection.Favorites -> ClipboardEntryFilter.Scope.Favorites + ClipboardPanelSection.CommonWords -> ClipboardEntryFilter.Scope.All }, category = selectedCategory ) @@ -297,8 +360,21 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { } } + private fun loadCommonWords() { + commonWordsLoadJob?.cancel() + commonWordsLoadJob = service.lifecycleScope.launch { + val entries = withContext(Dispatchers.IO) { + QuickPhraseManager.loadCommonWords() + } + commonWordsAdapter.updateEntries(entries) + commonWordsEmpty = entries.isEmpty() + renderUi() + } + } + private fun refreshDeleteAvailability() { val section = selectedSection + if (section == ClipboardPanelSection.CommonWords) return service.lifecycleScope.launch { val available = ClipboardManager.haveDeletable() if (selectedSection == section) { @@ -309,6 +385,10 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { } private fun renderUi() { + if (selectedSection == ClipboardPanelSection.CommonWords) { + ui.showCommonWords(commonWordsEmpty) + return + } selectedCategory?.let { ui.filteredEmptyUi.setFilter(selectedSection, it) } val state = ClipboardStateMachine.resolve( clipboardEnabledPref.getValue(), @@ -334,6 +414,7 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { ui.topBar.setActiveSection(selectedSection) ui.categoryBar.setActiveCategory(selectedCategory) adapter.addLoadStateListener(adapterLoadStateListener) + QuickPhraseManager.addOnCommonWordsChangedListener(commonWordsChangedListener) renderUi() showSection(selectedSection) clipboardEnabledPref.registerOnChangeListener(clipboardEnabledListener) @@ -341,10 +422,14 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow() { override fun onDetached() { clipboardEnabledPref.unregisterOnChangeListener(clipboardEnabledListener) + QuickPhraseManager.removeOnCommonWordsChangedListener(commonWordsChangedListener) adapter.removeLoadStateListener(adapterLoadStateListener) adapter.onDetached() + commonWordsAdapter.onDetached() adapterSubmitJob?.cancel() adapterSubmitJob = null + commonWordsLoadJob?.cancel() + commonWordsLoadJob = null promptMenu?.dismiss() snackbarInstance?.dismiss() } diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/CommonWordEntryUi.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/CommonWordEntryUi.kt new file mode 100644 index 00000000..ed7694f9 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/CommonWordEntryUi.kt @@ -0,0 +1,72 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.input.clipboard + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable +import android.text.TextUtils +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView +import org.fcitx.fcitx5.android.utils.alpha +import splitties.views.dsl.core.Ui +import splitties.views.dsl.core.add +import splitties.views.dsl.core.lParams +import splitties.views.dsl.core.matchParent +import splitties.views.dsl.core.textView +import splitties.views.dsl.core.verticalLayout +import splitties.views.dsl.core.wrapContent +import splitties.views.setPaddingDp + +class CommonWordEntryUi( + override val ctx: Context, + theme: Theme, + radius: Float +) : Ui { + private val phrase = textView { + maxLines = 3 + textSize = 14f + ellipsize = TextUtils.TruncateAt.END + setTextColor(theme.keyTextColor) + } + + private val keyword = textView { + isSingleLine = true + textSize = 11f + ellipsize = TextUtils.TruncateAt.END + setTextColor(theme.keyTextColor.alpha(0.55f)) + } + + private val content = verticalLayout { + setPaddingDp(8, 4, 8, 4) + add(phrase, lParams(matchParent, wrapContent)) + add(keyword, lParams(matchParent, wrapContent)) + } + + override val root = CustomGestureView(ctx).apply { + isClickable = true + foreground = RippleDrawable( + ColorStateList.valueOf(theme.keyPressHighlightColor), + null, + GradientDrawable().apply { + cornerRadius = radius + setColor(Color.WHITE) + } + ) + background = GradientDrawable().apply { + cornerRadius = radius + setColor(theme.clipboardEntryColor) + } + add(content, lParams(matchParent, wrapContent)) + } + + fun setEntry(entry: QuickPhraseEntry) { + phrase.text = ClipboardAdapter.excerptText(entry.phrase) + keyword.text = entry.keyword + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/CommonWordsAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/CommonWordsAdapter.kt new file mode 100644 index 00000000..a32b0030 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/clipboard/CommonWordsAdapter.kt @@ -0,0 +1,82 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android.input.clipboard + +import android.annotation.SuppressLint +import android.os.Build +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.recyclerview.widget.RecyclerView +import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.utils.DeviceUtil +import org.fcitx.fcitx5.android.utils.item +import splitties.resources.styledColor + +abstract class CommonWordsAdapter( + private val theme: Theme, + private val entryRadius: Float +) : RecyclerView.Adapter() { + + private var entries: List = emptyList() + private var popupMenu: PopupMenu? = null + + class ViewHolder(val ui: CommonWordEntryUi) : RecyclerView.ViewHolder(ui.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ViewHolder(CommonWordEntryUi(parent.context, theme, entryRadius)) + + override fun getItemCount() = entries.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val entry = entries[position] + holder.ui.apply { + setEntry(entry) + root.setOnClickListener { onPaste(entry) } + root.setOnLongClickListener { + val popup = PopupMenu(ctx, root) + val iconTint = ctx.styledColor(android.R.attr.colorControlNormal) + popup.menu.item(R.string.edit, R.drawable.ic_baseline_edit_24, iconTint) { + onEdit() + } + popup.menu.item(R.string.delete, R.drawable.ic_baseline_delete_24, iconTint) { + onDelete(entry) + } + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + !DeviceUtil.isSamsungOneUI && + !DeviceUtil.isFlyme + ) { + popup.setForceShowIcon(true) + } + popup.setOnDismissListener { + if (it === popupMenu) popupMenu = null + } + popupMenu?.dismiss() + popupMenu = popup + popup.show() + true + } + } + } + + @SuppressLint("NotifyDataSetChanged") + fun updateEntries(newEntries: List) { + entries = newEntries + notifyDataSetChanged() + } + + fun onDetached() { + popupMenu?.dismiss() + popupMenu = null + } + + abstract fun onPaste(entry: QuickPhraseEntry) + + abstract fun onEdit() + + abstract fun onDelete(entry: QuickPhraseEntry) +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/QuickPhraseEditFragment.kt b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/QuickPhraseEditFragment.kt index 60c51d4b..e5e39299 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/QuickPhraseEditFragment.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/ui/main/settings/QuickPhraseEditFragment.kt @@ -14,9 +14,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.fcitx.fcitx5.android.R +import org.fcitx.fcitx5.android.core.reloadQuickPhrase import org.fcitx.fcitx5.android.data.quickphrase.QuickPhrase import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseData import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseManager import org.fcitx.fcitx5.android.ui.common.BaseDynamicListUi import org.fcitx.fcitx5.android.ui.common.OnItemChangedListener import org.fcitx.fcitx5.android.ui.main.EditDeleteMenuProvider @@ -39,6 +41,10 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener private val dustman = NaiveDustman() @@ -87,8 +93,13 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 26c79c0a..d1e1acfc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -62,6 +62,8 @@ 收藏 取消收藏 收藏 + 常用词 + 添加常用词 删除 剪贴板 键盘高度 @@ -84,6 +86,7 @@ 要启用剪贴板管理,请在设置中开启“记录剪贴板历史”。 试着复制些东西 长按剪贴板条目即可收藏 + 点击 + 添加常用文本 没有%1$s条目 没有已收藏的%1$s条目 全部 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 9c44fd41..9327a508 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -62,6 +62,8 @@ 收藏 取消收藏 收藏 + 常用詞 + 新增常用詞 刪除 剪貼簿 鍵盤高度 @@ -84,6 +86,7 @@ 要使用剪貼簿管理,請在設定中啟用監聽剪貼簿 嘗試複製一些東西 長按剪貼簿項目即可收藏 + 點選 + 新增常用文字 沒有%1$s項目 沒有已收藏的%1$s項目 全部 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31fd2f61..4051385a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,8 @@ Add to favorites Remove from favorites Favorites + Common words + Add common word Delete Clipboard Keyboard height @@ -84,6 +86,7 @@ To use clipboard management, please enable clipboard listening in settings Try copying something Long press a clipboard item to add it to favorites + Tap + to add frequently used text No %1$s items No favorite %1$s items All diff --git a/app/src/test/java/org/fcitx/fcitx5/android/ClipboardStateMachineTest.kt b/app/src/test/java/org/fcitx/fcitx5/android/ClipboardStateMachineTest.kt index 23fd54c8..872c287d 100644 --- a/app/src/test/java/org/fcitx/fcitx5/android/ClipboardStateMachineTest.kt +++ b/app/src/test/java/org/fcitx/fcitx5/android/ClipboardStateMachineTest.kt @@ -10,10 +10,14 @@ import org.junit.Assert.assertEquals import org.junit.Test class ClipboardStateMachineTest { + private val clipboardSections = listOf( + ClipboardPanelSection.Clipboard, + ClipboardPanelSection.Favorites + ) @Test fun listeningDisabledAlwaysShowsEnableInstruction() { - ClipboardPanelSection.entries.forEach { section -> + clipboardSections.forEach { section -> listOf(true, false).forEach { empty -> assertEquals( ClipboardStateMachine.State.EnableListening, @@ -25,7 +29,7 @@ class ClipboardStateMachineTest { @Test fun nonEmptySectionShowsEntries() { - ClipboardPanelSection.entries.forEach { section -> + clipboardSections.forEach { section -> assertEquals( ClipboardStateMachine.State.Normal, ClipboardStateMachine.resolve(true, section, false, false) @@ -47,7 +51,7 @@ class ClipboardStateMachineTest { @Test fun emptyCategoryShowsFilteredInstructionInBothSections() { - ClipboardPanelSection.entries.forEach { section -> + clipboardSections.forEach { section -> assertEquals( ClipboardStateMachine.State.NoFilteredEntries, ClipboardStateMachine.resolve(true, section, true, true) diff --git a/app/src/test/java/org/fcitx/fcitx5/android/CommonWordMatcherTest.kt b/app/src/test/java/org/fcitx/fcitx5/android/CommonWordMatcherTest.kt new file mode 100644 index 00000000..bda7ca72 --- /dev/null +++ b/app/src/test/java/org/fcitx/fcitx5/android/CommonWordMatcherTest.kt @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors + */ +package org.fcitx.fcitx5.android + +import org.fcitx.fcitx5.android.data.quickphrase.CommonWordMatcher +import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CommonWordMatcherTest { + private val address = QuickPhraseEntry( + "address", + "北京市朝阳区示例路100号" + ) + + @Test + fun completesPhraseAfterThreeOrMoreMatchingCharacters() { + val three = CommonWordMatcher.bestMatch("收件地址:北京市", listOf(address))!! + assertEquals("北京市", three.matchedPrefix) + assertEquals("朝阳区示例路100号", three.completion) + + val longer = CommonWordMatcher.bestMatch("北京市朝阳区", listOf(address))!! + assertEquals("北京市朝阳区", longer.matchedPrefix) + assertEquals("示例路100号", longer.completion) + } + + @Test + fun doesNotSuggestBeforeThreeCharactersOrAfterFullPhrase() { + assertNull(CommonWordMatcher.bestMatch("北京", listOf(address))) + assertNull(CommonWordMatcher.bestMatch(address.phrase, listOf(address))) + assertNull(CommonWordMatcher.bestMatch("这是无关内容", listOf(address))) + } + + @Test + fun choosesLongestCurrentPrefixAndSupportsSupplementaryCharacters() { + val short = QuickPhraseEntry("short", "北京市海淀区") + val emoji = QuickPhraseEntry("emoji", "😀😁😂常用表情") + + assertEquals( + address, + CommonWordMatcher.bestMatch("北京市朝阳", listOf(short, address))?.entry + ) + val match = CommonWordMatcher.bestMatch("😀😁😂", listOf(emoji))!! + assertEquals("常用表情", match.completion) + } + + @Test + fun asciiMatchingIsCaseInsensitiveButPreservesStoredCompletion() { + val account = QuickPhraseEntry("account", "Account-Example-001") + val match = CommonWordMatcher.bestMatch("account", listOf(account))!! + assertEquals("-Example-001", match.completion) + } +} diff --git a/docs/clipboard-enhancement-plan.md b/docs/clipboard-enhancement-plan.md index 96b331a9..0cb793f8 100644 --- a/docs/clipboard-enhancement-plan.md +++ b/docs/clipboard-enhancement-plan.md @@ -6,7 +6,7 @@ - **阶段 1:顶栏与收藏**(功能完成;全仓既有门禁问题待处理) - **阶段 2:智能管理**(分类、底部筛选和可选自动清理均已完成) -- **阶段 3:常用词**(待办:自定义短语、三字符匹配与一键补全) +- **阶段 3:常用词**(已完成:自定义短语、三字符匹配与一键补全) 上游相关需求:[fcitx5-android/fcitx5-android#456](https://github.com/fcitx5-android/fcitx5-android/issues/456)。 @@ -16,7 +16,7 @@ - Room 数据库由 v4 升到 v5,为剪贴板条目增加 `favorite` 状态,并通过自动迁移保留旧数据。 - 使用 `ClipboardEntryFilter` 统一“全部”和“收藏”查询入口,为后续分类过滤保留扩展点。 -- 使用独立的 `ClipboardPanelSection` 表示键盘面板顶栏页面;阶段 3 再扩展“常用词”。 +- 使用独立的 `ClipboardPanelSection` 表示键盘面板顶栏页面,并扩展“常用词”页面。 - 阶段 1 不增加 `category`、`expiresAt` 等尚未使用的字段,也不修改插件 AIDL。 ### 收藏与删除语义 @@ -116,9 +116,13 @@ ### 阶段 3:常用词 - 顶栏扩展为“剪贴板 / 收藏 / 常用词”。 -- 复用现有 `QuickPhraseManager`、`QuickPhraseEntry` 和快速输入文件格式。 -- 提供键盘内新增、编辑、删除和直接上屏。 -- 三字符预测接入现有 preedit/candidate 广播链路;开始阶段 3 前确认匹配和候选优先级。 +- 复用现有 `QuickPhraseManager`、`QuickPhraseEntry` 和 `.mb` 快速输入文件格式,以独立的 `f5clipboard-common-words.mb` 保存常用词,不增加数据库表。 +- 常用词页支持点击直接上屏、长按删除;新增和编辑按钮从键盘打开专用快速输入编辑页,保存后立即刷新键盘数据和 Fcitx 快速输入。 +- 编辑常用词时若关键词留空,自动取短语最前面的三个 Unicode 字符作为关键词;关键词只用于兼容快速输入文件格式,补全匹配始终以短语正文为准。 +- 光标前已提交文本与某条常用词的开头连续匹配至少三个 Unicode 字符时,在空闲工具栏显示最佳匹配;点击只提交尚未输入的后缀,避免重复文本。 +- 多条短语同时匹配时优先选择当前匹配前缀最长的条目;ASCII 匹配不区分大小写,但补全文本保留已保存短语的大小写。 +- 密码输入框和 `IME_FLAG_NO_PERSONALIZED_LEARNING` 私密输入模式中禁用常用词预测。 +- 中文拼音仍由现有 Fcitx preedit/candidate 流程处理;常用词预测只检查编辑器中已经提交到光标前的文本,不抢占输入法候选。 ## 阶段 1 验证清单 @@ -182,6 +186,26 @@ - 管理器集成测试验证了复制 6 位验证码后生成有效期、收藏后取消有效期、再次复制后重新计时,以及关闭开关后清空有效期且后续复制不再安排删除。 - 模拟器设置页已检查:两个开关默认关闭,时长项仅在对应开关开启时可编辑,默认值分别为 10 分钟和 24 小时。 +## 阶段 3 验证清单 + +- [x] 常用词使用独立 `.mb` 文件保存,可被现有快速输入管理器读取和重新加载。 +- [x] 顶栏包含“常用词”页面,分类底栏在该页面隐藏,空列表显示独立提示。 +- [x] 点击条目直接上屏,长按可进入编辑或删除,右侧加号可新增。 +- [x] 三个及以上 Unicode 字符的连续前缀可触发预测,点击只提交缺失后缀。 +- [x] 多条匹配选择最长前缀,ASCII 匹配不区分大小写。 +- [x] 密码输入框和私密输入模式不显示常用词预测。 +- [x] `CommonWordMatcherTest` 的 4 项单元测试通过。 +- [x] `testDebugUnitTest`、`assembleDebug`、`assembleDebugAndroidTest` 通过。 +- [x] API 35 上常用词管理器及阶段 1、2 回归仪器测试共 14 项通过。 +- [x] `git diff --check` 通过。 + +### 2026-07-30 阶段 3 验收记录 + +- 常用词管理器仪器测试验证了保存、重新读取、变更通知、删除,以及测试结束后恢复原文件。 +- 模拟器中导入地址、账号、电话和邮箱样例后,输入 `Acc` 可见 `Account-Example-001` 的星标补全建议。 +- 新增和编辑复用应用内的快速输入编辑页;从键盘发起后会暂时离开输入界面,保存时立即通知键盘并重新加载 Fcitx 数据。 +- 预测只读取光标前已提交文本,不处理仍在拼音组合区中的 preedit 文本,以避免和现有中文候选竞争。 + ## 环境约定 - 使用 Android Platform 36、Build Tools 36.1.0、NDK 28.0.13004108、CMake 3.31.6。 diff --git a/docs/common-words-manual-test-samples.mb b/docs/common-words-manual-test-samples.mb new file mode 100644 index 00000000..5555e378 --- /dev/null +++ b/docs/common-words-manual-test-samples.mb @@ -0,0 +1,4 @@ +address 北京市朝阳区示例路100号 +account Account-Example-001 +phone 13800138000 +email person@example.com