diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidateTabActionsAdapter.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidateTabActionsAdapter.kt new file mode 100644 index 00000000..bca7ae92 --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/CandidateTabActionsAdapter.kt @@ -0,0 +1,150 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.input.candidates.expanded + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.StateListDrawable +import android.graphics.drawable.shapes.RectShape +import android.view.Gravity +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.fcitx.fcitx5.android.core.CandidateAction +import org.fcitx.fcitx5.android.data.theme.Theme +import org.fcitx.fcitx5.android.input.AutoScaleTextView +import org.fcitx.fcitx5.android.input.keyboard.CustomGestureView +import org.fcitx.fcitx5.android.utils.pressHighlightDrawable +import splitties.dimensions.dp +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.view +import splitties.views.gravityCenter + +abstract class CandidateTabActionsAdapter(val theme: Theme, val indicatorStyle: Boolean = false) : + RecyclerView.Adapter() { + + private var tabs = listOf() + + @SuppressLint("NotifyDataSetChanged") + fun updateTabs(newTabs: List) { + tabs = newTabs + notifyDataSetChanged() + } + + abstract fun onTriggerTabAction(id: Int) + + override fun getItemCount() = tabs.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabActionViewHolder { + val ui = TabActionUi(parent.context, theme, indicatorStyle) + ui.root.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + ) + return TabActionViewHolder(ui) + } + + override fun onBindViewHolder(holder: TabActionViewHolder, position: Int) { + val tab = tabs.getOrNull(position) ?: return + holder.update(tab) + } + + override fun onViewRecycled(holder: TabActionViewHolder) { + holder.clear() + } + + inner class TabActionViewHolder(val ui: TabActionUi) : RecyclerView.ViewHolder(ui.root) { + var tabAction: CandidateAction? = null + private set + + fun update(tab: CandidateAction) { + tabAction = tab + ui.update(tab) + ui.root.setOnClickListener { + onTriggerTabAction(tab.id) + } + } + + fun clear() { + tabAction = null + ui.root.setOnClickListener(null) + } + } + + class TabActionUi( + override val ctx: Context, + val theme: Theme, + val indicatorStyle: Boolean = false + ) : Ui { + + private val label = view(::AutoScaleTextView) { + scaleMode = AutoScaleTextView.Mode.Proportional + textSize = 16f // sp + isSingleLine = true + gravity = gravityCenter + setTextColor(theme.candidateTextColor) + } + + override val root = view(::CustomGestureView) { + background = createBackground(false, indicatorStyle) + add(label, lParams(matchParent, ctx.dp(36)) { + gravity = gravityCenter + }) + } + + var checkable = false + set(value) { + if (field == value) return + field = value + root.background = createBackground(value, indicatorStyle) + } + + fun update(tab: CandidateAction) { + label.text = tab.text + checkable = tab.isCheckable + root.isActivated = tab.isChecked + } + + private fun createBackground(checkable: Boolean, indicatorStyle: Boolean): Drawable { + if (!checkable) { + return pressHighlightDrawable(theme.keyPressHighlightColor) + } + if (!indicatorStyle) { + val highlight = ColorDrawable(theme.keyPressHighlightColor) + return StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_pressed), highlight) + addState(intArrayOf(android.R.attr.state_activated), highlight) + } + } + val states = arrayOf( + intArrayOf(android.R.attr.state_activated), + intArrayOf() + ) + val colors = intArrayOf( + theme.accentKeyBackgroundColor, + theme.altKeyBackgroundColor + ) + return LayerDrawable( + arrayOf( + ShapeDrawable(RectShape()).apply { + setTintList(ColorStateList(states, colors)) + }, + pressHighlightDrawable(theme.keyPressHighlightColor) + ) + ).apply { + setLayerSize(0, ctx.dp(11), ctx.dp(2)) + setLayerGravity(0, Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL) + setLayerInsetBottom(0, ctx.dp(4)) + } + } + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt index a4151e09..d59cc91a 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/ExpandedCandidateLayout.kt @@ -6,6 +6,8 @@ package org.fcitx.fcitx5.android.input.candidates.expanded import android.annotation.SuppressLint import android.content.Context +import android.content.res.Configuration +import android.view.Gravity import androidx.constraintlayout.widget.ConstraintLayout import org.fcitx.fcitx5.android.R import org.fcitx.fcitx5.android.data.theme.Theme @@ -16,8 +18,14 @@ import org.fcitx.fcitx5.android.input.keyboard.ImageKeyView import org.fcitx.fcitx5.android.input.keyboard.ImageLayoutSwitchKey import org.fcitx.fcitx5.android.input.keyboard.KeyDef import org.fcitx.fcitx5.android.input.keyboard.ReturnKey +import org.fcitx.fcitx5.android.utils.setVerticalScrollbarThumbColor +import org.fcitx.fcitx5.android.utils.singleSideBorderDrawable +import splitties.dimensions.dp import splitties.views.backgroundColor +import splitties.views.dsl.constraintlayout.above import splitties.views.dsl.constraintlayout.bottomOfParent +import splitties.views.dsl.constraintlayout.centerHorizontally +import splitties.views.dsl.constraintlayout.constraintLayout import splitties.views.dsl.constraintlayout.lParams import splitties.views.dsl.constraintlayout.leftOfParent import splitties.views.dsl.constraintlayout.leftToRightOf @@ -25,6 +33,7 @@ import splitties.views.dsl.constraintlayout.rightOfParent import splitties.views.dsl.constraintlayout.rightToLeftOf import splitties.views.dsl.constraintlayout.topOfParent import splitties.views.dsl.core.add +import splitties.views.dsl.core.wrapContent import splitties.views.dsl.recyclerview.recyclerView import splitties.views.imageResource @@ -74,8 +83,30 @@ class ExpandedCandidateLayout(context: Context, theme: Theme) : ConstraintLayout } private val keyBorder by ThemeManager.prefs.keyBorder + private val keyVerticalMargin by ThemeManager.prefs.keyVerticalMargin + private val keyVerticalMarginLandscape by ThemeManager.prefs.keyVerticalMarginLandscape val recyclerView = recyclerView { + // disable item cross-fade animation + itemAnimator = null + isVerticalScrollBarEnabled = false + } + + val tabsContainer = constraintLayout() + + val scrollableTabs = recyclerView { + itemAnimator = null + // always show scrollbar + isScrollbarFadingEnabled = false + scrollBarSize = dp(1) + setVerticalScrollbarThumbColor(theme.candidateTextColor) + } + + val pinnedTabs = recyclerView { + itemAnimator = null + // prevent scrolling in pinned tabs at bottom + overScrollMode = OVER_SCROLL_NEVER + isNestedScrollingEnabled = false isVerticalScrollBarEnabled = false } @@ -89,13 +120,37 @@ class ExpandedCandidateLayout(context: Context, theme: Theme) : ConstraintLayout init { id = R.id.expanded_candidate_view + + val landscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val inset = dp(if (landscape) keyVerticalMarginLandscape else keyVerticalMargin) + tabsContainer.background = + singleSideBorderDrawable(dp(1), theme.dividerColor, Gravity.RIGHT, inset) if (!keyBorder) { backgroundColor = theme.barColor + embeddedKeyboard.background = + singleSideBorderDrawable(dp(1), theme.dividerColor, Gravity.LEFT, inset) } - add(recyclerView, lParams { + add(tabsContainer, lParams { + matchConstraintPercentWidth = 0.15f topOfParent() leftOfParent() + bottomOfParent() + }) + tabsContainer.apply { + add(scrollableTabs, lParams { + topOfParent() + centerHorizontally() + above(pinnedTabs) + }) + add(pinnedTabs, lParams(height = wrapContent) { + bottomOfParent() + centerHorizontally() + }) + } + add(recyclerView, lParams { + topOfParent() + leftToRightOf(tabsContainer) rightToLeftOf(embeddedKeyboard) bottomOfParent() }) diff --git a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt index 4baece04..95194b12 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/input/candidates/expanded/window/BaseExpandedCandidateWindow.kt @@ -15,6 +15,8 @@ import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import org.fcitx.fcitx5.android.core.CandidateAction +import org.fcitx.fcitx5.android.core.FcitxEvent import org.fcitx.fcitx5.android.daemon.launchOnReady import org.fcitx.fcitx5.android.data.prefs.AppPrefs import org.fcitx.fcitx5.android.input.bar.ExpandButtonStateMachine.BooleanKey.ExpandedCandidatesEmpty @@ -24,6 +26,7 @@ import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent import org.fcitx.fcitx5.android.input.broadcast.InputBroadcastReceiver import org.fcitx.fcitx5.android.input.broadcast.ReturnKeyDrawableComponent import org.fcitx.fcitx5.android.input.candidates.CandidateViewHolder +import org.fcitx.fcitx5.android.input.candidates.expanded.CandidateTabActionsAdapter import org.fcitx.fcitx5.android.input.candidates.expanded.CandidatesPagingSource import org.fcitx.fcitx5.android.input.candidates.expanded.ExpandedCandidateLayout import org.fcitx.fcitx5.android.input.candidates.expanded.PagingCandidateViewAdapter @@ -40,6 +43,7 @@ import org.fcitx.fcitx5.android.input.wm.InputWindow import org.fcitx.fcitx5.android.input.wm.InputWindowManager import org.mechdancer.dependency.manager.must import splitties.dimensions.dp +import splitties.views.recyclerview.verticalLayoutManager import kotlin.math.max abstract class BaseExpandedCandidateWindow> : @@ -72,9 +76,13 @@ abstract class BaseExpandedCandidateWindow> : final override fun onCreateView(): View { candidateLayout = onCreateCandidateLayout().apply { - recyclerView.apply { - // disable item cross-fade animation - itemAnimator = null + scrollableTabs.apply { + adapter = tabsAdapter + layoutManager = verticalLayoutManager() + } + pinnedTabs.apply { + adapter = pinnedTabsAdapter + layoutManager = verticalLayoutManager() } } return candidateLayout @@ -94,6 +102,34 @@ abstract class BaseExpandedCandidateWindow> : abstract val adapter: PagingCandidateViewAdapter abstract val layoutManager: RecyclerView.LayoutManager + val tabsAdapter by lazy { + object : CandidateTabActionsAdapter(theme, false) { + override fun onTriggerTabAction(id: Int) { + fcitx.launchOnReady { it.triggerCandidateListTabAction(id) } + } + } + } + + val pinnedTabsAdapter by lazy { + object : CandidateTabActionsAdapter(theme, true) { + override fun onTriggerTabAction(id: Int) { + fcitx.launchOnReady { it.triggerCandidateListTabAction(id) } + } + } + } + + private fun updateTabs(newTabs: Array) { + val tabs = newTabs.takeWhile { !it.isSeparator } + val pinnedTabs = newTabs.drop(tabs.size + 1).filter { !it.isSeparator } + if (tabs.isEmpty() && pinnedTabs.isEmpty()) { + candidateLayout.tabsContainer.visibility = View.GONE + } else { + candidateLayout.tabsContainer.visibility = View.VISIBLE + } + tabsAdapter.updateTabs(tabs) + pinnedTabsAdapter.updateTabs(pinnedTabs) + } + private var offsetJob: Job? = null private val candidatesPager by lazy { @@ -123,6 +159,7 @@ abstract class BaseExpandedCandidateWindow> : it.onReturnDrawableUpdate(returnKeyDrawable.resourceId) it.keyActionListener = keyActionListener } + updateTabs(fcitx.runImmediately { inputPanelCached.tabs }) offsetJob = service.lifecycleScope.launch { horizontalCandidate.expandedCandidateOffset.collect { if (it <= 0) { @@ -171,4 +208,8 @@ abstract class BaseExpandedCandidateWindow> : } } + override fun onInputPanelUpdate(data: FcitxEvent.InputPanelEvent.Data) { + updateTabs(data.tabs) + } + } \ No newline at end of file diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt index f5368506..46c04a45 100644 --- a/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/Drawable.kt @@ -9,11 +9,14 @@ import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.LayerDrawable import android.graphics.drawable.RippleDrawable import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.StateListDrawable import android.graphics.drawable.shapes.OvalShape +import android.view.Gravity import androidx.annotation.ColorInt +import androidx.annotation.GravityInt fun rippleDrawable( @ColorInt color: Int, @@ -50,3 +53,23 @@ fun borderDrawable( setStroke(width, stroke) setColor(background) } + +fun singleSideBorderDrawable( + width: Int, + @ColorInt stroke: Int, + @GravityInt gravity: Int, + inset: Int = 0 +): Drawable = LayerDrawable( + arrayOf( + GradientDrawable().apply { setColor(stroke) } + ) +).apply { + setLayerGravity(0, gravity) + if (Gravity.isVertical(gravity)) { + setLayerHeight(0, width) + setLayerInset(0, inset, 0, inset, 0) + } else { + setLayerWidth(0, width) + setLayerInset(0, 0, inset, 0, inset) + } +} diff --git a/app/src/main/java/org/fcitx/fcitx5/android/utils/ScrollbarDrawable.kt b/app/src/main/java/org/fcitx/fcitx5/android/utils/ScrollbarDrawable.kt new file mode 100644 index 00000000..094c2ccb --- /dev/null +++ b/app/src/main/java/org/fcitx/fcitx5/android/utils/ScrollbarDrawable.kt @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors + */ + +package org.fcitx.fcitx5.android.utils + +import android.annotation.SuppressLint +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RectShape +import android.os.Build +import android.view.View +import androidx.annotation.ColorInt +import timber.log.Timber +import java.lang.reflect.Field + +// android.view.View.ScrollabilityCache +private val scrollCacheField by lazy { + @SuppressLint("DiscouragedPrivateApi") + View::class.java.getDeclaredField("mScrollCache").apply { isAccessible = true } +} + +// android.widget.ScrollBarDrawable +private val scrollBarField by lazy { + val scrollCacheClass = scrollCacheField.type + scrollCacheClass.getDeclaredField("scrollBar").apply { isAccessible = true } +} + +// android.graphics.drawable.Drawable +private val verticalThumbField by lazy { + val scrollBarClass = scrollBarField.type + scrollBarClass.getDeclaredField("mVerticalThumb").apply { isAccessible = true } +} + +fun View.setVerticalScrollbarThumbColor(@ColorInt color: Int) { + val drawable = ShapeDrawable(RectShape()).apply { + this.paint.color = color + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + verticalScrollbarThumbDrawable = drawable + } else { + try { + val scrollCache = scrollCacheField.get(this) + val scrollBar = scrollBarField.get(scrollCache) + verticalThumbField.set(scrollBar, drawable) + } catch (e: Exception) { + Timber.w(e, "Cannot set scrollbar color") + } + } +} diff --git a/lib/fcitx5-chinese-addons/src/main/cpp/fcitx5-chinese-addons b/lib/fcitx5-chinese-addons/src/main/cpp/fcitx5-chinese-addons index 3a94a960..19ccd67d 160000 --- a/lib/fcitx5-chinese-addons/src/main/cpp/fcitx5-chinese-addons +++ b/lib/fcitx5-chinese-addons/src/main/cpp/fcitx5-chinese-addons @@ -1 +1 @@ -Subproject commit 3a94a96006f8e67665fa9bb1b9ef4602415ddd04 +Subproject commit 19ccd67d9e1348d6d14f23e224dc40bb13738bf7