mirror of
https://github.com/fcitx5-android/fcitx5-android.git
synced 2026-08-02 04:44:35 +08:00
feat(clipboard): add common words and completion
This commit is contained in:
parent
d6190d133e
commit
f5216c5758
@ -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<List<QuickPhraseEntry>>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<QuickPhraseEntry>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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<QuickPhraseEntry>)
|
||||
}
|
||||
|
||||
private val commonWordsListeners = WeakHashSet<OnCommonWordsChangedListener>()
|
||||
|
||||
fun addOnCommonWordsChangedListener(listener: OnCommonWordsChangedListener) {
|
||||
commonWordsListeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeOnCommonWordsChangedListener(listener: OnCommonWordsChangedListener) {
|
||||
commonWordsListeners.remove(listener)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun loadCommonWords(): List<QuickPhraseEntry> = commonWords.loadData().toList()
|
||||
|
||||
@Synchronized
|
||||
fun saveCommonWords(entries: List<QuickPhraseEntry>) {
|
||||
commonWords.saveData(QuickPhraseData(entries))
|
||||
val snapshot = entries.toList()
|
||||
commonWordsListeners.forEach { it.onChanged(snapshot) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun deleteCommonWord(entry: QuickPhraseEntry): List<QuickPhraseEntry> {
|
||||
val entries = loadCommonWords().toMutableList()
|
||||
entries.remove(entry)
|
||||
saveCommonWords(entries)
|
||||
return entries
|
||||
}
|
||||
|
||||
fun listQuickPhrase(): List<QuickPhrase> {
|
||||
val builtin = listDir(builtinQuickPhraseDir) { file ->
|
||||
BuiltinQuickPhrase(file, File(customQuickPhraseDir, file.name))
|
||||
@ -74,5 +117,5 @@ object QuickPhraseManager {
|
||||
?.let { block(file) }
|
||||
} ?: listOf()
|
||||
|
||||
|
||||
}
|
||||
private const val COMMON_WORDS_FILE_NAME = "f5clipboard-common-words"
|
||||
}
|
||||
|
||||
@ -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<KawaiiBarComponent, FrameLayout>(
|
||||
private var clipboardTimeoutJob: Job? = null
|
||||
|
||||
private var isClipboardFresh: Boolean = false
|
||||
private var commonWords: List<QuickPhraseEntry> = 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<KawaiiBarComponent, FrameLayout>(
|
||||
|
||||
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<KawaiiBarComponent, FrameLayout>(
|
||||
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<KawaiiBarComponent, FrameLayout>(
|
||||
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<KawaiiBarComponent, FrameLayout>(
|
||||
}
|
||||
}
|
||||
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<KawaiiBarComponent, FrameLayout>(
|
||||
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<KawaiiBarComponent, FrameLayout>(
|
||||
|
||||
companion object {
|
||||
const val HEIGHT = 40
|
||||
private const val COMMON_WORD_LOOKBEHIND = 256
|
||||
}
|
||||
|
||||
fun onKeyboardLayoutSwitched(isNumber: Boolean) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -6,5 +6,6 @@ package org.fcitx.fcitx5.android.input.clipboard
|
||||
|
||||
enum class ClipboardPanelSection {
|
||||
Clipboard,
|
||||
Favorites
|
||||
Favorites,
|
||||
CommonWords
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ClipboardWindow>() {
|
||||
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<ClipboardWindow>() {
|
||||
}
|
||||
}
|
||||
|
||||
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<ClipboardWindow>() {
|
||||
topBar.deleteAllButton.setOnClickListener {
|
||||
promptDeleteAll()
|
||||
}
|
||||
topBar.addCommonWordButton.setOnClickListener {
|
||||
AppUtil.launchMainToCommonWords(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -281,6 +336,13 @@ class ClipboardWindow : InputWindow.ExtendedInputWindow<ClipboardWindow>() {
|
||||
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<ClipboardWindow>() {
|
||||
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<ClipboardWindow>() {
|
||||
}
|
||||
}
|
||||
|
||||
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<ClipboardWindow>() {
|
||||
}
|
||||
|
||||
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<ClipboardWindow>() {
|
||||
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<ClipboardWindow>() {
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<CommonWordsAdapter.ViewHolder>() {
|
||||
|
||||
private var entries: List<QuickPhraseEntry> = 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<QuickPhraseEntry>) {
|
||||
entries = newEntries
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun onDetached() {
|
||||
popupMenu?.dismiss()
|
||||
popupMenu = null
|
||||
}
|
||||
|
||||
abstract fun onPaste(entry: QuickPhraseEntry)
|
||||
|
||||
abstract fun onEdit()
|
||||
|
||||
abstract fun onDelete(entry: QuickPhraseEntry)
|
||||
}
|
||||
@ -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<QuickP
|
||||
args.param.quickPhrase
|
||||
}
|
||||
|
||||
private val isCommonWords by lazy {
|
||||
QuickPhraseManager.isCommonWords(quickPhrase)
|
||||
}
|
||||
|
||||
private lateinit var ui: BaseDynamicListUi<QuickPhraseEntry>
|
||||
|
||||
private val dustman = NaiveDustman<QuickPhraseEntry>()
|
||||
@ -87,8 +93,13 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener<QuickP
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
.onPositiveButtonClick onClick@{
|
||||
val keyword = keywordField.str.trim()
|
||||
// "keyword" cannot contain any black characters
|
||||
var keyword = keywordField.str.trim()
|
||||
val phrase = phraseField.str
|
||||
if (keyword.isBlank() && isCommonWords && phrase.isNotEmpty()) {
|
||||
val count = phrase.codePointCount(0, phrase.length).coerceAtMost(3)
|
||||
keyword = phrase.substring(0, phrase.offsetByCodePoints(0, count))
|
||||
}
|
||||
// "keyword" cannot contain any blank characters
|
||||
if (keyword.isBlank()) {
|
||||
keywordField.error = getString(
|
||||
R.string._cannot_be_empty,
|
||||
@ -100,7 +111,6 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener<QuickP
|
||||
keywordField.error = null
|
||||
}
|
||||
// "phrase" may contain blank characters
|
||||
val phrase = phraseField.str
|
||||
if (phrase.isEmpty()) {
|
||||
phraseField.error = getString(
|
||||
R.string._cannot_be_empty,
|
||||
@ -161,7 +171,14 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener<QuickP
|
||||
resetDustman()
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
quickPhrase.saveData(QuickPhraseData(ui.entries))
|
||||
if (isCommonWords) {
|
||||
QuickPhraseManager.saveCommonWords(ui.entries)
|
||||
} else {
|
||||
quickPhrase.saveData(QuickPhraseData(ui.entries))
|
||||
}
|
||||
}
|
||||
if (isCommonWords) {
|
||||
viewModel.fcitx.runIfReady { reloadQuickPhrase() }
|
||||
}
|
||||
// tell parent that we need to reload
|
||||
parentFragmentManager.setFragmentResult(
|
||||
@ -177,7 +194,9 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener<QuickP
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
viewModel.setToolbarTitle(quickPhrase.name)
|
||||
viewModel.setToolbarTitle(
|
||||
if (isCommonWords) getString(R.string.common_words) else quickPhrase.name
|
||||
)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
@ -199,4 +218,4 @@ class QuickPhraseEditFragment : ProgressFragment(), OnItemChangedListener<QuickP
|
||||
const val RESULT = "dirty"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import org.fcitx.fcitx5.android.R
|
||||
import org.fcitx.fcitx5.android.data.quickphrase.QuickPhraseManager
|
||||
import org.fcitx.fcitx5.android.ui.main.ClipboardEditActivity
|
||||
import org.fcitx.fcitx5.android.ui.main.MainActivity
|
||||
import org.fcitx.fcitx5.android.ui.main.settings.SettingsRoute
|
||||
@ -42,6 +43,9 @@ object AppUtil {
|
||||
fun launchMainToThemeList(context: Context) =
|
||||
launchMainToDest(context, SettingsRoute.Theme)
|
||||
|
||||
fun launchMainToCommonWords(context: Context) =
|
||||
launchMainToDest(context, SettingsRoute.QuickPhraseEdit(QuickPhraseManager.commonWords))
|
||||
|
||||
fun launchMainToInputMethodConfig(context: Context, uniqueName: String, displayName: String) =
|
||||
launchMainToDest(context, SettingsRoute.InputMethodConfig(displayName, uniqueName))
|
||||
|
||||
|
||||
9
app/src/main/res/drawable/ic_baseline_add_24.xml
Normal file
9
app/src/main/res/drawable/ic_baseline_add_24.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
|
||||
</vector>
|
||||
@ -62,6 +62,8 @@
|
||||
<string name="favorite">收藏</string>
|
||||
<string name="unfavorite">取消收藏</string>
|
||||
<string name="favorites">收藏</string>
|
||||
<string name="common_words">常用词</string>
|
||||
<string name="add_common_word">添加常用词</string>
|
||||
<string name="delete">删除</string>
|
||||
<string name="clipboard">剪贴板</string>
|
||||
<string name="keyboard_height">键盘高度</string>
|
||||
@ -84,6 +86,7 @@
|
||||
<string name="instruction_enable_clipboard_listening">要启用剪贴板管理,请在设置中开启“记录剪贴板历史”。</string>
|
||||
<string name="instruction_copy">试着复制些东西</string>
|
||||
<string name="instruction_favorite">长按剪贴板条目即可收藏</string>
|
||||
<string name="instruction_common_words">点击 + 添加常用文本</string>
|
||||
<string name="instruction_no_category">没有%1$s条目</string>
|
||||
<string name="instruction_no_favorite_category">没有已收藏的%1$s条目</string>
|
||||
<string name="clipboard_category_all">全部</string>
|
||||
|
||||
@ -62,6 +62,8 @@
|
||||
<string name="favorite">收藏</string>
|
||||
<string name="unfavorite">取消收藏</string>
|
||||
<string name="favorites">收藏</string>
|
||||
<string name="common_words">常用詞</string>
|
||||
<string name="add_common_word">新增常用詞</string>
|
||||
<string name="delete">刪除</string>
|
||||
<string name="clipboard">剪貼簿</string>
|
||||
<string name="keyboard_height">鍵盤高度</string>
|
||||
@ -84,6 +86,7 @@
|
||||
<string name="instruction_enable_clipboard_listening">要使用剪貼簿管理,請在設定中啟用監聽剪貼簿</string>
|
||||
<string name="instruction_copy">嘗試複製一些東西</string>
|
||||
<string name="instruction_favorite">長按剪貼簿項目即可收藏</string>
|
||||
<string name="instruction_common_words">點選 + 新增常用文字</string>
|
||||
<string name="instruction_no_category">沒有%1$s項目</string>
|
||||
<string name="instruction_no_favorite_category">沒有已收藏的%1$s項目</string>
|
||||
<string name="clipboard_category_all">全部</string>
|
||||
|
||||
@ -62,6 +62,8 @@
|
||||
<string name="favorite" tools:ignore="MissingTranslation">Add to favorites</string>
|
||||
<string name="unfavorite" tools:ignore="MissingTranslation">Remove from favorites</string>
|
||||
<string name="favorites" tools:ignore="MissingTranslation">Favorites</string>
|
||||
<string name="common_words" tools:ignore="MissingTranslation">Common words</string>
|
||||
<string name="add_common_word" tools:ignore="MissingTranslation">Add common word</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="clipboard">Clipboard</string>
|
||||
<string name="keyboard_height">Keyboard height</string>
|
||||
@ -84,6 +86,7 @@
|
||||
<string name="instruction_enable_clipboard_listening">To use clipboard management, please enable clipboard listening in settings</string>
|
||||
<string name="instruction_copy">Try copying something</string>
|
||||
<string name="instruction_favorite" tools:ignore="MissingTranslation">Long press a clipboard item to add it to favorites</string>
|
||||
<string name="instruction_common_words" tools:ignore="MissingTranslation">Tap + to add frequently used text</string>
|
||||
<string name="instruction_no_category" tools:ignore="MissingTranslation">No %1$s items</string>
|
||||
<string name="instruction_no_favorite_category" tools:ignore="MissingTranslation">No favorite %1$s items</string>
|
||||
<string name="clipboard_category_all" tools:ignore="MissingTranslation">All</string>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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。
|
||||
|
||||
4
docs/common-words-manual-test-samples.mb
Normal file
4
docs/common-words-manual-test-samples.mb
Normal file
@ -0,0 +1,4 @@
|
||||
address 北京市朝阳区示例路100号
|
||||
account Account-Example-001
|
||||
phone 13800138000
|
||||
email person@example.com
|
||||
Loading…
x
Reference in New Issue
Block a user