Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/main/java/com/osfans/trime/data/prefs/AppPrefs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,13 @@ class AppPrefs(
companion object {
const val COMPOSING_TEXT_MODE = "composing_text_mode"
const val ASCII_SWITCH_TIPS = "ascii_switch_tips"
const val INLINE_SUGGESTIONS = "inline_suggestions"
const val PREFERRED_VOICE_INPUT = "preferred_voice_input"
}

val composingTextMode = enum(R.string.composing_text_mode, COMPOSING_TEXT_MODE, ComposingTextMode.DISABLE)
val asciiSwitchTips = switch(R.string.ascii_switch_tips, ASCII_SWITCH_TIPS, true)
val inlineSuggestions = switch(R.string.inline_suggestions, INLINE_SUGGESTIONS, true)

val preferredVoiceInput = list(
R.string.preferred_voice_input,
Expand Down
174 changes: 115 additions & 59 deletions app/src/main/java/com/osfans/trime/ime/bar/QuickBar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,56 @@

package com.osfans.trime.ime.bar

import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.util.Size
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InlineSuggestion
import android.view.inputmethod.InlineSuggestionsResponse
import android.widget.ViewAnimator
import android.widget.inline.InlineContentView
import androidx.annotation.Keep
import androidx.annotation.RequiresApi
import androidx.lifecycle.lifecycleScope
import com.osfans.trime.R
import com.osfans.trime.core.CandidateItem
import com.osfans.trime.core.RimeMessage
import com.osfans.trime.daemon.RimeSession
import com.osfans.trime.data.db.ClipboardHelper
import com.osfans.trime.data.prefs.AppPrefs
import com.osfans.trime.data.theme.ColorManager
import com.osfans.trime.data.theme.KeyActionManager
import com.osfans.trime.data.theme.Theme
import com.osfans.trime.ime.bar.ui.AlwaysUi
import com.osfans.trime.ime.bar.ui.CandidateUi
import com.osfans.trime.ime.bar.ui.ClipboardSuggestionUi
import com.osfans.trime.ime.bar.ui.InlineSuggestionUi
import com.osfans.trime.ime.bar.ui.TabUi
import com.osfans.trime.ime.broadcast.InputBroadcastReceiver
import com.osfans.trime.ime.candidates.CandidateModule
import com.osfans.trime.ime.candidates.compact.CompactCandidateModule
import com.osfans.trime.ime.candidates.unrolled.window.FlexboxUnrolledCandidateWindow
import com.osfans.trime.ime.core.TrimeInputMethodService
import com.osfans.trime.ime.dependency.InputScope
import com.osfans.trime.ime.keyboard.CommonKeyboardActionListener
import com.osfans.trime.ime.keyboard.GestureFrame
import com.osfans.trime.ime.keyboard.InputFeedbackManager
import com.osfans.trime.ime.keyboard.KeyboardWindow
import com.osfans.trime.ime.switches.SwitchOptionWindow
import com.osfans.trime.ime.window.BoardWindow
import com.osfans.trime.ime.window.BoardWindowManager
import com.osfans.trime.ui.main.ClipEditActivity
import com.osfans.trime.util.AppUtils
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.tatarka.inject.annotations.Inject
import splitties.dimensions.dp
import splitties.systemservices.clipboardManager
import splitties.views.dsl.core.add
import splitties.views.dsl.core.lParams
import splitties.views.dsl.core.matchParent
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

@InputScope
@Inject
Expand All @@ -57,23 +64,16 @@ class QuickBar(
private val rime: RimeSession,
private val theme: Theme,
private val windowManager: BoardWindowManager,
lazyCandidate: Lazy<CandidateModule>,
lazyCandidate: Lazy<CompactCandidateModule>,
lazyCommonKeyboardActionListener: Lazy<CommonKeyboardActionListener>,
) : InputBroadcastReceiver {

@Keep
private val clipboardListener = ClipboardManager.OnPrimaryClipChangedListener {
val clip = clipboardManager.primaryClip
val content = clip?.getItemAt(0)?.text?.toString()?.takeIf { it.isNotBlank() }
content?.let { handleClipboardContent(it) }
}

private var clipboardTimeoutJob: Job? = null

private val candidate by lazyCandidate

private val commonKeyboardActionListener by lazyCommonKeyboardActionListener

val themedHeight = theme.generalStyle.run { candidateViewHeight + commentHeight }

private val prefs = AppPrefs.defaultInstance()

private val hideQuickBar by prefs.keyboard.hideQuickBar
Expand All @@ -82,12 +82,42 @@ class QuickBar(

private val clipboardSuggestionTimeout by prefs.clipboard.clipboardSuggestionTimeout

val themedHeight =
theme.generalStyle.candidateViewHeight + theme.generalStyle.commentHeight
private var clipboardTimeoutJob: Job? = null

private var isClipboardFresh: Boolean = false
private var isInlineSuggestionPresent: Boolean = false

@Keep
private val onClipboardUpdateListener = ClipboardHelper.OnClipboardUpdateListener {
if (!clipboardSuggestion) return@OnClipboardUpdateListener
service.lifecycleScope.launch {
if (it.text.isNullOrEmpty()) {
isClipboardFresh = false
} else {
alwaysUi.clipboardUi.text.text = it.text.take(42)
isClipboardFresh = true
launchClipboardTimeoutJob()
}
evalAlwaysUiState()
}
}

private fun launchClipboardTimeoutJob() {
clipboardTimeoutJob?.cancel()
val timeout = clipboardSuggestionTimeout * 1000L
if (timeout < 0L) return
clipboardTimeoutJob = service.lifecycleScope.launch {
delay(timeout)
isClipboardFresh = false
clipboardTimeoutJob = null
}
}

private fun evalAlwaysUiState() {
val newState =
when {
isClipboardFresh -> AlwaysUi.State.Clipboard
isInlineSuggestionPresent -> AlwaysUi.State.InlineSuggestion
else -> AlwaysUi.State.Toolbar
}
if (newState == alwaysUi.currentState) return
Expand All @@ -109,37 +139,37 @@ class QuickBar(
setOnClickListener { service.requestHideSelf(0) }
onSwipeListener = swipeDownHideKeyboardCallback
}
clipboardUi.suggestionView.apply {
setOnClickListener {
val content = ClipboardHelper.lastBean?.text
content?.let { service.commitText(it) }
clipboardTimeoutJob?.cancel()
clipboardTimeoutJob = null
isClipboardFresh = false
evalAlwaysUiState()
}
setOnLongClickListener {
ClipboardHelper.lastBean?.let {
AppUtils.launchClipEdit(context, it.id, ClipEditActivity.FROM_CLIPBOARD)
}
true
}
}
}
}

private val candidateUi by lazy {
CandidateUi(context, candidate.compactCandidateModule.view).apply {
CandidateUi(context, candidate.view).apply {
unrollButton.apply {
onSwipeListener = swipeDownHideKeyboardCallback
}
}
}

private val inlineSuggestionUi by lazy {
InlineSuggestionUi(context, candidate.suggestionCandidateModule.view)
}

private val tabUi by lazy {
TabUi(context, theme)
}

private val clipboardSuggestionUi by lazy {
ClipboardSuggestionUi(context).apply {
root.setOnClickListener {
val content = text.text.toString()
if (content.isNotEmpty()) {
service.commitText(content)
handleClipboardContent(null)
}
}
}
}

private val barStateMachine =
QuickBarStateMachine.new {
switchUiByState(it)
Expand All @@ -165,7 +195,7 @@ class QuickBar(
private fun setUnrollButtonToAttach() {
candidateUi.unrollButton.setOnClickListener { view ->
windowManager.attachWindow(
FlexboxUnrolledCandidateWindow(context, service, rime, theme, this, windowManager, candidate.compactCandidateModule),
FlexboxUnrolledCandidateWindow(context, service, rime, theme, this, windowManager, candidate),
)
}
candidateUi.unrollButton.setIcon(R.drawable.ic_baseline_expand_more_24)
Expand Down Expand Up @@ -218,12 +248,9 @@ class QuickBar(
add(alwaysUi.root, lParams(matchParent, matchParent))
add(candidateUi.root, lParams(matchParent, matchParent))
add(tabUi.root, lParams(matchParent, matchParent))
add(inlineSuggestionUi.root, lParams(matchParent, matchParent))
add(clipboardSuggestionUi.root, lParams(matchParent, matchParent))

evalAlwaysUiState()

clipboardManager.addPrimaryClipChangedListener(clipboardListener)
ClipboardHelper.addOnUpdateListener(onClipboardUpdateListener)
}
}

Expand All @@ -245,30 +272,59 @@ class QuickBar(
barStateMachine.push(QuickBarStateMachine.TransitionEvent.WindowDetached)
}

@RequiresApi(Build.VERSION_CODES.R)
fun handleInlineSuggestions(isEmpty: Boolean) {
barStateMachine.push(
QuickBarStateMachine.TransitionEvent.SuggestionUpdated,
QuickBarStateMachine.BooleanKey.SuggestionEmpty to isEmpty,
)
private val suggestionSize by lazy {
Size(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(themedHeight))
}

fun handleClipboardContent(content: String?) {
val isEmpty = content.isNullOrEmpty() || !clipboardSuggestion
private val directExecutor by lazy {
Executor { it.run() }
}

if (!isEmpty) {
clipboardSuggestionUi.text.text = content
clipboardTimeoutJob?.cancel()
clipboardTimeoutJob = service.lifecycleScope.launch {
delay(clipboardSuggestionTimeout * 1000L)
handleClipboardContent(null)
@RequiresApi(Build.VERSION_CODES.R)
fun handleInlineSuggestions(response: InlineSuggestionsResponse): Boolean {
val suggestions = response.inlineSuggestions
if (suggestions.isEmpty()) {
isInlineSuggestionPresent = false
return true
}
var pinned: InlineSuggestion? = null
val scrollable = mutableListOf<InlineSuggestion>()
var extraPinnedCount = 0
suggestions.forEach {
if (it.info.isPinned) {
if (pinned == null) {
pinned = it
} else {
scrollable.add(extraPinnedCount++, it)
}
} else {
scrollable.add(it)
}
}
service.lifecycleScope.launch {
alwaysUi.inlineSuggestionsUi.setPinnedView(
pinned?.let { inflateInlineContentView(it) },
)
}
service.lifecycleScope.launch {
val views = scrollable.map { s ->
service.lifecycleScope.async {
inflateInlineContentView(s)
}
}.awaitAll()
alwaysUi.inlineSuggestionsUi.setScrollableViews(views)
}
isInlineSuggestionPresent = true
evalAlwaysUiState()
return true
}

barStateMachine.push(
QuickBarStateMachine.TransitionEvent.ClipboardUpdated,
QuickBarStateMachine.BooleanKey.ClipboardEmpty to isEmpty,
)
@RequiresApi(Build.VERSION_CODES.R)
private suspend fun inflateInlineContentView(suggestion: InlineSuggestion): InlineContentView? = suspendCoroutine { c ->
// callback view might be null
suggestion.inflate(context, suggestionSize, directExecutor) { v ->
c.resume(v)
}
}

override fun onRimeOptionUpdated(value: RimeMessage.OptionMessage.Data) {
Expand Down
34 changes: 4 additions & 30 deletions app/src/main/java/com/osfans/trime/ime/bar/QuickBarStateMachine.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
// Copyright 2021 - 2023 Fcitx5 for Android Contributors
// Copyright 2021-2023 Fcitx5 for Android Contributors
// SPDX-FileCopyrightText: 2024 Rime community
//
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-License-Identifier: LGPL-2.1-or-later
/*
* SPDX-FileCopyrightText: 2015 - 2025 Rime community
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package com.osfans.trime.ime.bar

import com.osfans.trime.ime.bar.QuickBarStateMachine.BooleanKey.CandidateEmpty
import com.osfans.trime.ime.bar.QuickBarStateMachine.BooleanKey.ClipboardEmpty
import com.osfans.trime.ime.bar.QuickBarStateMachine.BooleanKey.SuggestionEmpty
import com.osfans.trime.ime.bar.QuickBarStateMachine.State.Always
import com.osfans.trime.ime.bar.QuickBarStateMachine.State.Candidate
import com.osfans.trime.ime.bar.QuickBarStateMachine.State.Clipboard
import com.osfans.trime.ime.bar.QuickBarStateMachine.State.Suggestion
import com.osfans.trime.ime.bar.QuickBarStateMachine.State.Tab
import com.osfans.trime.util.BuildTransitionEvent
import com.osfans.trime.util.EventStateMachine
Expand All @@ -24,14 +18,10 @@ object QuickBarStateMachine {
Always,
Candidate,
Tab,
Suggestion,
Clipboard,
}

enum class BooleanKey : EventStateMachine.BooleanStateKey {
CandidateEmpty,
SuggestionEmpty,
ClipboardEmpty,
}

enum class TransitionEvent(
Expand All @@ -40,39 +30,23 @@ object QuickBarStateMachine {
CandidatesUpdated({
from(Always) transitTo Candidate on (CandidateEmpty to false)
from(Candidate) transitTo Always on (CandidateEmpty to true)
from(Suggestion) transitTo Candidate on (CandidateEmpty to false)
from(Clipboard) transitTo Candidate on (CandidateEmpty to false)
}),
BarBoardWindowAttached({
from(Always) transitTo Tab
from(Candidate) transitTo Tab
from(Suggestion) transitTo Tab
from(Clipboard) transitTo Tab
}),
WindowDetached({
// candidate state has higher priority so here it goes first
from(Tab) transitTo Candidate on (CandidateEmpty to false)
from(Tab) transitTo Suggestion on (SuggestionEmpty to false)
from(Tab) transitTo Clipboard on (ClipboardEmpty to false)
from(Tab) transitTo Always
}),
SuggestionUpdated({
from(Always) transitTo Suggestion on (SuggestionEmpty to false)
from(Suggestion) transitTo Always on (SuggestionEmpty to true)
}),
ClipboardUpdated({
from(Always) transitTo Clipboard on (ClipboardEmpty to false)
from(Clipboard) transitTo Always on (ClipboardEmpty to true)
}),
}

fun new(block: (State) -> Unit) = EventStateMachine<State, TransitionEvent, BooleanKey>(
initialState = Always,
externalBooleanStates =
mutableMapOf(
CandidateEmpty to true,
SuggestionEmpty to true,
ClipboardEmpty to true,
),
).apply {
onNewStateListener = block
Expand Down
Loading
Loading