From 3ee77b0180cc7ad12cbbd444dd3f4e8271b26df9 Mon Sep 17 00:00:00 2001 From: Codex Mozc Autonomy Date: Sun, 9 Aug 2026 18:31:17 -0400 Subject: [PATCH 01/11] Add Arte-style TFBi guide popup --- .../ime_service/IMEService.kt | 9 +- .../setting_activity/AppPreference.kt | 15 ++ .../ui/popup_style/PopupStyleFragments.kt | 32 +++ .../ui/popup_style/PopupStylePreviewViews.kt | 96 +++++++- .../data/popup/TfbiPopupPresentationMode.kt | 21 ++ .../popup/TfbiPopupPresentationModeTest.kt | 25 +++ .../controller/TfbiGuidePopupHost.kt | 132 +++++++++++ .../controller/TfbiGuidePopupPlacement.kt | 36 +++ .../TfbiHierarchicalFlickController.kt | 193 +++++++++++----- .../controller/TfbiInputController.kt | 84 +++++++ .../controller/TfbiStickyFlickController.kt | 82 +++++++ .../data/TfbiGuidePopupState.kt | 16 ++ .../custom_keyboard/view/FlickKeyboardView.kt | 12 + .../view/TfbiGestureArrowView.kt | 94 ++++++++ .../view/TfbiGuidePopupView.kt | 210 ++++++++++++++++++ .../controller/TfbiGuidePopupPlacementTest.kt | 45 ++++ 16 files changed, 1049 insertions(+), 53 deletions(-) create mode 100644 core/src/main/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationMode.kt create mode 100644 core/src/test/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationModeTest.kt create mode 100644 custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt create mode 100644 custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupPlacement.kt create mode 100644 custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuidePopupState.kt create mode 100644 custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGestureArrowView.kt create mode 100644 custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt create mode 100644 custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupPlacementTest.kt diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index df899ab0b..cb028fc0d 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt @@ -106,6 +106,7 @@ import com.kazumaproject.core.data.floating_candidate.CandidateItem import com.kazumaproject.core.data.popup.FlickPopupViewStyleSet import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.data.popup.QwertyPopupViewStyleSet +import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.core.domain.extensions.dpToPx import com.kazumaproject.core.domain.extensions.getThemeColorOrFallback import com.kazumaproject.core.domain.extensions.hiraganaToKatakana @@ -794,7 +795,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, AppPreference.VIBRATION_KEY, AppPreference.VIBRATION_TIMING_KEY, AppPreference.KEY_SOUND_KEY, - AppPreference.KEY_SOUND_VOLUME_PERCENT_KEY + AppPreference.KEY_SOUND_VOLUME_PERCENT_KEY, + AppPreference.FLICK_TFBI_POPUP_PRESENTATION_KEY ) private val runtimeInputPreferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> @@ -2454,6 +2456,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) val longPressTimeout = (appPreference.long_press_timeout_preference ?: 300).coerceIn(100, 2000) + val tfbiPopupPresentationMode = appPreference.flick_tfbi_popup_presentation flickSensitivityPreferenceValue = sensitivity flickThresholdShapePreferenceValue = thresholdShape @@ -2501,6 +2504,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertyView.setFlickThresholdShape(thresholdShape) qwertyView.setLongPressTimeout(longPressTimeout.toLong()) applyCurrentFlickGuidePreference(customLayoutDefault) + customLayoutDefault.setTfbiPopupPresentationMode(tfbiPopupPresentationMode) } floatingKeyboardBinding?.apply { keyboardViewFloating.setFlickSensitivityValue(sensitivity) @@ -2515,6 +2519,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertyViewFloating.setFlickThresholdShape(thresholdShape) qwertyViewFloating.setLongPressTimeout(longPressTimeout.toLong()) applyCurrentFlickGuidePreference(customLayoutFloating) + customLayoutFloating.setTfbiPopupPresentationMode(tfbiPopupPresentationMode) } } @@ -11374,6 +11379,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) { flickView.setOnFlickTextPreviewListener(sumireFlickTextPreviewListener) flickView.bindRuntimeGestureSettings(runtimeGestureSettingsSource) + val tfbiPopupPresentationMode = appPreference.flick_tfbi_popup_presentation + flickView.setTfbiPopupPresentationMode(tfbiPopupPresentationMode) if (isFloatingView) { Timber.d("Configuring floating FlickKeyboardView mirror surface") // Floating ON のときだけ、popup の window anchor を IME decorView (or floating root) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt index 6f99fb5f3..8471835bb 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt @@ -9,6 +9,7 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.kazumaproject.core.data.clicked_symbol.SymbolMode import com.kazumaproject.core.domain.flick.FlickThresholdShape +import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.custom_keyboard.data.CircularFlickDirection import com.kazumaproject.custom_keyboard.data.KeyboardInputMode import com.kazumaproject.custom_keyboard.data.buildEvenCircularRanges @@ -67,6 +68,7 @@ object AppPreference { "gemma_handwriting_pen_color_preference" const val FLICK_SENSITIVITY_KEY = "flick_sensitivity_preference" const val FLICK_THRESHOLD_SHAPE_KEY = "flick_threshold_shape_preference" + const val FLICK_TFBI_POPUP_PRESENTATION_KEY = "flick_tfbi_popup_presentation_preference" const val FLICK_EDITOR_PREVIEW_KEY = "flick_editor_preview_preference" const val TENKEY_KEYMAP_GUIDE_JAPANESE_KEY = "tenkey_keymap_guide" const val TENKEY_KEYMAP_GUIDE_ENGLISH_KEY = "tenkey_keymap_guide_english" @@ -524,6 +526,8 @@ object AppPreference { Pair("flick_tfbi_popup_size_scale_percent_preference", 100) private val FLICK_TFBI_POPUP_TEXT_SIZE_SP = Pair("flick_tfbi_popup_text_size_sp_preference", 20.0f) + private val FLICK_TFBI_POPUP_PRESENTATION = + Pair(FLICK_TFBI_POPUP_PRESENTATION_KEY, TfbiPopupPresentationMode.LEGACY_GRID.preferenceValue) private val CANDIDATE_LETTER_SIZE = Pair("candidate_letter_size_preference", 14.0f) @@ -3080,6 +3084,17 @@ object AppPreference { ) } + var flick_tfbi_popup_presentation: TfbiPopupPresentationMode + get() = TfbiPopupPresentationMode.fromPreference( + preferences.getString( + FLICK_TFBI_POPUP_PRESENTATION.first, + FLICK_TFBI_POPUP_PRESENTATION.second + ) + ) + set(value) = preferences.edit { + it.putString(FLICK_TFBI_POPUP_PRESENTATION.first, value.preferenceValue) + } + var clipboard_preview_preference: Boolean get() = preferences.getBoolean( CLIP_BOARD_PREVIEW_PREFERENCE.first, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt index 9d0869cfb..6151986c4 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt @@ -9,6 +9,8 @@ import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.RadioGroup import android.widget.ScrollView import android.widget.SeekBar import android.widget.TextView @@ -19,6 +21,7 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.navigation.fragment.findNavController import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.markdownhelperkeyboard.R import com.kazumaproject.markdownhelperkeyboard.setting_activity.AppPreference @@ -385,6 +388,35 @@ private fun Fragment.styleEditorSection( ViewGroup.LayoutParams.WRAP_CONTENT ) root.addView(preview) + if (flickTarget == FlickTarget.TFBI && preview is FlickPopupStylePreviewView) { + root.addView(sectionTitle("TFBiポップアップ表示")) + val modeGroup = RadioGroup(context).apply { + orientation = RadioGroup.VERTICAL + } + val legacyId = View.generateViewId() + val guideId = View.generateViewId() + modeGroup.addView(RadioButton(context).apply { + id = legacyId + text = "従来の3×3ポップアップ" + }) + modeGroup.addView(RadioButton(context).apply { + id = guideId + text = "キー上部ガイド(アルテ/TFBi風)" + }) + val initialMode = AppPreference.flick_tfbi_popup_presentation + modeGroup.check(if (initialMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) guideId else legacyId) + modeGroup.setOnCheckedChangeListener { _, checkedId -> + val mode = if (checkedId == guideId) { + TfbiPopupPresentationMode.GUIDE_ABOVE_KEY + } else { + TfbiPopupPresentationMode.LEGACY_GRID + } + AppPreference.flick_tfbi_popup_presentation = mode + preview.setTfbiPopupPresentationMode(mode) + } + preview.setTfbiPopupPresentationMode(initialMode) + root.addView(modeGroup) + } root.addView(sizeLabel.first) root.addView(sizeSeek) root.addView(textLabel.first) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt index 04a61461a..b3abf043f 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt @@ -10,6 +10,7 @@ import android.util.AttributeSet import android.util.TypedValue import android.view.View import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode class PopupStylePreviewView @JvmOverloads constructor( context: Context, @@ -120,6 +121,7 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( private var style = PopupViewStyle(100, 28f) private var target = Target.DIRECTIONAL + private var tfbiPresentationMode = TfbiPopupPresentationMode.LEGACY_GRID fun applyStyle(target: Target, style: PopupViewStyle) { this.target = target @@ -132,6 +134,11 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( invalidate() } + fun setTfbiPopupPresentationMode(mode: TfbiPopupPresentationMode) { + tfbiPresentationMode = mode + invalidate() + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val desiredHeight = dpToPx(190f).toInt() setMeasuredDimension( @@ -153,7 +160,11 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( Target.DIRECTIONAL -> drawDirectionalPreview(canvas) Target.CROSS -> drawCrossPreview(canvas) Target.STANDARD -> drawStandardPreview(canvas) - Target.TFBI -> drawTfbiPreview(canvas) + Target.TFBI -> if (tfbiPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + drawTfbiGuidePreview(canvas) + } else { + drawTfbiPreview(canvas) + } } } @@ -237,6 +248,89 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( } } + private fun drawTfbiGuidePreview(canvas: Canvas) { + val scale = style.sizeScalePercent / 100f + val panelWidth = dpToPx(132f) * scale + val panelHeight = dpToPx(108f) * scale + val panelLeft = width / 2f - panelWidth / 2f + val panelTop = dpToPx(12f) + val panel = RectF(panelLeft, panelTop, panelLeft + panelWidth, panelTop + panelHeight) + val guideBackground = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = this@FlickPopupStylePreviewView.style.backgroundColor + ?: Color.rgb(232, 232, 232) + } + val guideLine = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(90, 60, 60, 60) + style = Paint.Style.STROKE + strokeWidth = dpToPx(1f) + } + val active = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = defaultHighlightColor + } + canvas.drawRoundRect(panel, dpToPx(4f), dpToPx(4f), guideBackground) + canvas.drawRoundRect(panel, dpToPx(4f), dpToPx(4f), guideLine) + + val cellW = panel.width() / 3f + val cellH = panel.height() / 3f + for (index in 1..2) { + canvas.drawLine(panel.left + cellW * index, panel.top, panel.left + cellW * index, panel.bottom, guideLine) + canvas.drawLine(panel.left, panel.top + cellH * index, panel.right, panel.top + cellH * index, guideLine) + } + + fun cell(row: Int, column: Int): RectF = RectF( + panel.left + column * cellW, + panel.top + row * cellH, + panel.left + (column + 1) * cellW, + panel.top + (row + 1) * cellH + ) + + val current = cell(1, 0) + canvas.drawRoundRect( + RectF(current.left + dpToPx(3f), current.top + dpToPx(5f), current.right - dpToPx(3f), current.bottom - dpToPx(5f)), + dpToPx(8f), + dpToPx(8f), + active + ) + drawGuideText(canvas, "き", current, Color.WHITE) + drawGuideText(canvas, "ゅ", cell(0, 1), Color.DKGRAY) + drawGuideText(canvas, "ゃ", cell(1, 2), Color.DKGRAY) + drawGuideText(canvas, "ょ", cell(2, 1), Color.DKGRAY) + + val key = RectF( + width / 2f - dpToPx(76f), + panel.bottom + dpToPx(18f), + width / 2f + dpToPx(76f), + panel.bottom + dpToPx(74f) + ) + val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(185, 185, 185) } + canvas.drawRect(key, keyPaint) + drawGuideText(canvas, "か", key, Color.GRAY) + + val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = defaultHighlightColor + style = Paint.Style.STROKE + strokeWidth = dpToPx(8f) + strokeCap = Paint.Cap.SQUARE + } + val arrowHead = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = defaultHighlightColor } + val centerY = key.centerY() + canvas.drawLine(key.centerX() + dpToPx(34f), centerY, key.left + dpToPx(24f), centerY, arrowPaint) + val head = Path().apply { + moveTo(key.left + dpToPx(12f), centerY) + lineTo(key.left + dpToPx(30f), centerY - dpToPx(11f)) + lineTo(key.left + dpToPx(30f), centerY + dpToPx(11f)) + close() + } + canvas.drawPath(head, arrowHead) + } + + private fun drawGuideText(canvas: Canvas, text: String, rect: RectF, color: Int) { + textPaint.color = color + textPaint.textSize = spToPx(style.textSizeSp.coerceAtMost(24f)) + val y = rect.centerY() - (textPaint.descent() + textPaint.ascent()) / 2f + canvas.drawText(text, rect.centerX(), y, textPaint) + } + private fun drawCenteredText(canvas: Canvas, text: String, rect: RectF) { val y = rect.centerY() - (textPaint.descent() + textPaint.ascent()) / 2f canvas.drawText(text, rect.centerX(), y, textPaint) diff --git a/core/src/main/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationMode.kt b/core/src/main/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationMode.kt new file mode 100644 index 000000000..c8ad3fec0 --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationMode.kt @@ -0,0 +1,21 @@ +package com.kazumaproject.core.data.popup + +/** + * Presentation modes for the two-step/three-step flick popup. + * + * The legacy mode is intentionally the default so existing installations keep their + * current popup behavior when the preference is not present yet. + */ +enum class TfbiPopupPresentationMode( + val preferenceValue: String +) { + LEGACY_GRID("legacy_grid"), + GUIDE_ABOVE_KEY("guide_above_key"); + + companion object { + fun fromPreference(value: String?): TfbiPopupPresentationMode { + return entries.firstOrNull { it.preferenceValue == value } + ?: LEGACY_GRID + } + } +} diff --git a/core/src/test/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationModeTest.kt b/core/src/test/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationModeTest.kt new file mode 100644 index 000000000..58e840fbe --- /dev/null +++ b/core/src/test/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationModeTest.kt @@ -0,0 +1,25 @@ +package com.kazumaproject.core.data.popup + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TfbiPopupPresentationModeTest { + + @Test + fun unknownPreferenceKeepsLegacyPresentation() { + assertEquals( + TfbiPopupPresentationMode.LEGACY_GRID, + TfbiPopupPresentationMode.fromPreference("unknown") + ) + } + + @Test + fun guideModeRoundTripsThroughPreferenceValue() { + assertEquals( + TfbiPopupPresentationMode.GUIDE_ABOVE_KEY, + TfbiPopupPresentationMode.fromPreference( + TfbiPopupPresentationMode.GUIDE_ABOVE_KEY.preferenceValue + ) + ) + } +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt new file mode 100644 index 000000000..47c3e815b --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt @@ -0,0 +1,132 @@ +package com.kazumaproject.custom_keyboard.controller + +import android.content.Context +import android.view.View +import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState +import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection +import com.kazumaproject.custom_keyboard.view.TfbiGestureArrowView +import com.kazumaproject.custom_keyboard.view.TfbiGuidePopupView +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Hosts the optional TFBi guide and the gesture arrow in the keyboard's own window. + * + * The guide is intentionally anchored to the pressed key. A normal PopupWindow is not used + * here because the IME can be resized while the gesture is in progress; [KeyboardPopupOverlay] + * keeps both layers in the same draw traversal as the key. + */ +internal class TfbiGuidePopupHost( + private val context: Context, + private val preferredHostProvider: () -> View? +) { + private val overlay = KeyboardPopupOverlay(::positionVisiblePopups) + private val keyLocation = IntArray(2) + private val hostLocation = IntArray(2) + private val relativeLocation = IntArray(2) + + private var anchorView: View? = null + private var guideView: TfbiGuidePopupView? = null + private var arrowView: TfbiGestureArrowView? = null + private var guideWidth = 1 + private var guideHeight = 1 + private var currentGapPx = 1 + + fun show( + anchor: View, + state: TfbiGuidePopupState, + direction: TfbiFlickDirection?, + style: PopupViewStyle, + inputTextTransform: (String) -> String + ) { + dismiss() + + val panel = guideView ?: TfbiGuidePopupView(context).also { guideView = it } + val arrow = arrowView ?: TfbiGestureArrowView(context).also { arrowView = it } + panel.setInputTextTransform(inputTextTransform) + panel.applyPopupViewStyle(style) + arrow.setColor(0xff1976d2.toInt()) + panel.setState(state) + arrow.setDirection(direction) + + anchorView = anchor + val scale = style.sizeScalePercent.coerceIn(50, 200) / 100f + val baseSize = min(anchor.width, anchor.height).coerceAtLeast(1) + guideWidth = (baseSize * 1.85f * scale).roundToInt().coerceAtLeast(dp(72f)) + guideHeight = (baseSize * 1.65f * scale).roundToInt().coerceAtLeast(dp(68f)) + currentGapPx = dp(4f) + + val host = preferredHostProvider() + if (!overlay.show(anchor, host, panel, guideWidth, guideHeight)) { + anchorView = null + return + } + // Add the arrow after the panel so it is drawn above the key and the panel edge. + overlay.show(anchor, host, arrow, anchor.width, anchor.height) + positionVisiblePopups() + } + + fun update( + state: TfbiGuidePopupState, + direction: TfbiFlickDirection? + ) { + guideView?.setState(state) + arrowView?.setDirection(direction) + positionVisiblePopups() + } + + fun setInputTextTransform(transform: (String) -> String) { + guideView?.setInputTextTransform(transform) + } + + fun applyPopupViewStyle(style: PopupViewStyle) { + guideView?.applyPopupViewStyle(style) + } + + fun setColors( + backgroundColor: Int, + highlightedColor: Int, + textColor: Int + ) { + guideView?.setColors(backgroundColor, highlightedColor, textColor) + arrowView?.setColor(highlightedColor) + } + + fun dismiss() { + overlay.dismissAll() + anchorView = null + } + + private fun positionVisiblePopups() { + val anchor = anchorView ?: return + val host = overlay.currentHost ?: return + if (host.width <= 0 || host.height <= 0 || anchor.width <= 0 || anchor.height <= 0) return + + getLocationRelativeToOverlayHost( + keyAnchor = anchor, + overlayHost = host, + outLocation = relativeLocation, + keyLocationScratch = keyLocation, + hostLocationScratch = hostLocation + ) + + val placement = resolveTfbiGuidePopupPlacement( + keyLeft = relativeLocation[0], + keyTop = relativeLocation[1], + keyWidth = anchor.width, + panelWidth = guideWidth, + panelHeight = guideHeight, + hostWidth = host.width, + hostHeight = host.height, + gap = currentGapPx + ) + guideView?.let { overlay.place(it, placement.panelLeft, placement.panelTop, guideWidth, guideHeight) } + arrowView?.let { + overlay.place(it, placement.arrowLeft, placement.arrowTop, anchor.width, anchor.height) + } + } + + private fun dp(value: Float): Int = + (value * context.resources.displayMetrics.density).roundToInt().coerceAtLeast(1) +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupPlacement.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupPlacement.kt new file mode 100644 index 000000000..e5e551033 --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupPlacement.kt @@ -0,0 +1,36 @@ +package com.kazumaproject.custom_keyboard.controller + +internal data class TfbiGuidePopupPlacement( + val panelLeft: Int, + val panelTop: Int, + val arrowLeft: Int, + val arrowTop: Int +) + +internal fun resolveTfbiGuidePopupPlacement( + keyLeft: Int, + keyTop: Int, + keyWidth: Int, + panelWidth: Int, + panelHeight: Int, + hostWidth: Int, + hostHeight: Int, + gap: Int +): TfbiGuidePopupPlacement { + var panelLeft = keyLeft + (keyWidth - panelWidth) / 2 + var panelTop = keyTop - panelHeight - gap + if (panelTop < 0) { + // The IME root can begin below the app content. Keep the guide at the root's top + // so it remains above the pressed key instead of falling below it. + panelTop = 0 + } + + panelLeft = panelLeft.coerceIn(0, (hostWidth - panelWidth).coerceAtLeast(0)) + panelTop = panelTop.coerceIn(0, (hostHeight - panelHeight).coerceAtLeast(0)) + return TfbiGuidePopupPlacement( + panelLeft = panelLeft, + panelTop = panelTop, + arrowLeft = keyLeft, + arrowTop = keyTop + ) +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt index 786e218c2..ca006b882 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt @@ -10,6 +10,7 @@ import android.view.ViewConfiguration import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickGestureMath import com.kazumaproject.core.domain.flick.GestureSessionConfig @@ -17,6 +18,7 @@ import com.kazumaproject.core.domain.flick.GestureSessionConfigSource import com.kazumaproject.custom_keyboard.data.KeyMode import com.kazumaproject.custom_keyboard.data.ModeSwitchBoundary import com.kazumaproject.custom_keyboard.data.TfbiFlickNode +import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection import com.kazumaproject.custom_keyboard.view.TfbiFlickPopupView import java.util.ArrayDeque @@ -109,6 +111,14 @@ class TfbiHierarchicalFlickController( private var popupStyle = PopupViewStyle(100, 20f) private var popupWindowAnchorProvider: (() -> View?)? = null + private var popupPresentationMode = TfbiPopupPresentationMode.LEGACY_GRID + private val guidePopupHost by lazy { + TfbiGuidePopupHost(context) { popupWindowAnchorProvider?.invoke() } + } + private var guideRootDirection = TfbiFlickDirection.TAP + private var guideSelectedOption: TfbiFlickDirection? = null + private var guideArrowDirection: TfbiFlickDirection? = null + private var guideStageJustOpened = false // ▼▼▼ 追加: 色設定保持用の変数 ▼▼▼ private var popupBackgroundColor: Int? = null @@ -129,6 +139,7 @@ class TfbiHierarchicalFlickController( this.popupBackgroundColor = backgroundColor this.popupHighlightedColor = highlightedColor this.popupTextColor = textColor + guidePopupHost.setColors(backgroundColor, highlightedColor, textColor) } fun applyPopupViewStyle(style: PopupViewStyle) { @@ -139,6 +150,16 @@ class TfbiHierarchicalFlickController( textColor = style.textColor ) popupView?.applyPopupViewStyle(popupStyle) + guidePopupHost.applyPopupViewStyle(popupStyle) + } + + fun setPopupPresentationMode(mode: TfbiPopupPresentationMode) { + if (popupPresentationMode == mode) return + popupWindow?.dismiss() + guidePopupHost.dismiss() + popupWindow = null + popupView = null + popupPresentationMode = mode } fun setModeSwitchAngleMargin(margin: Double) { @@ -152,6 +173,7 @@ class TfbiHierarchicalFlickController( fun setInputTextTransform(transform: (String) -> String) { inputTextTransform = transform popupView?.setInputTextTransform(transform) + guidePopupHost.setInputTextTransform(transform) } /** @@ -225,6 +247,10 @@ class TfbiHierarchicalFlickController( mapStack.push(rMap) highlightStack.push(TfbiFlickDirection.TAP) currentHighlight = TfbiFlickDirection.TAP + guideRootDirection = TfbiFlickDirection.TAP + guideSelectedOption = null + guideArrowDirection = null + guideStageJustOpened = false val tapNode = currentMap?.get(TfbiFlickDirection.TAP) if (tapNode is TfbiFlickNode.Input) { listener?.onPress(tapNode.char) @@ -279,6 +305,11 @@ class TfbiHierarchicalFlickController( // 親のマップとハイライト状態を復元 currentMap = mapStack.peek() currentHighlight = highlightStack.peek() // 親のハイライト(TAP) + guideSelectedOption = currentHighlight.takeUnless { + it == TfbiFlickDirection.TAP + } + guideArrowDirection = guideSelectedOption + guideStageJustOpened = false // UIを親マップに戻す setupStageUI(currentMap!!) @@ -305,6 +336,11 @@ class TfbiHierarchicalFlickController( // ハイライト対象のノードを取得 val node = currentM[direction] + val depthBeforeSelection = mapStack.size + if (depthBeforeSelection == 1) { + guideRootDirection = direction + } + guideArrowDirection = direction if (direction == currentHighlight) { if (node is TfbiFlickNode.Input && isModeSwitchGestureConfident( dx = dx, @@ -325,6 +361,8 @@ class TfbiHierarchicalFlickController( is TfbiFlickNode.Input -> { // 終端ノード(文字)の場合:ハイライトを更新 popupView?.highlightDirection(currentHighlight) + guideSelectedOption = direction + updateGuideForSelection(node.char, currentM) // 状態更新 if (isModeSwitchGestureConfident(dx, dy, currentHighlight, currentM)) { @@ -344,6 +382,8 @@ class TfbiHierarchicalFlickController( // ハイライトは開いた方向 (currentHighlight) を維持 // ただし、ジッターガードを有効にする isJitterGuardActive = true + guideSelectedOption = null + guideStageJustOpened = true // ポップアップをサブメニューの内容で更新 setupStageUI(currentMap!!) @@ -353,6 +393,7 @@ class TfbiHierarchicalFlickController( null -> { // マップにない無効な方向(主にTAPに戻る途中) popupView?.highlightDirection(currentHighlight) + guideSelectedOption = direction } is TfbiFlickNode.StatefulKey -> { @@ -564,42 +605,55 @@ class TfbiHierarchicalFlickController( private fun showPopup( anchorView: View, showPetals: Boolean + ) { + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + showGuidePopup(anchorView, showPetals) + } else { + showLegacyPopup(anchorView, showPetals) + } + } + + private fun showGuidePopup( + anchorView: View, + showPetals: Boolean + ) { + val rootM = rootMap ?: return + val tapCharacter = nodeDisplayText(rootM[TfbiFlickDirection.TAP]) + val optionLabels = if (showPetals) { + rootM + .filterKeys { it != TfbiFlickDirection.TAP } + .mapValues { (_, node) -> guideOptionLabel(tapCharacter, nodeDisplayText(node)) } + } else { + emptyMap() + } + guidePopupHost.show( + anchor = anchorView, + state = TfbiGuidePopupState( + currentText = tapCharacter, + currentSlot = TfbiFlickDirection.TAP, + optionLabels = optionLabels + ), + direction = null, + style = popupStyle, + inputTextTransform = inputTextTransform + ) + } + + private fun showLegacyPopup( + anchorView: View, + showPetals: Boolean ) { if (popupWindow?.isShowing == true && !showPetals) return val rootM = rootMap ?: return // TAP(中央)に表示する文字 - val tapCharacter = when (val tapNode = rootM[TfbiFlickDirection.TAP]) { - is TfbiFlickNode.Input -> tapNode.char - is TfbiFlickNode.SubMenu -> tapNode.label - ?: (tapNode.nextMap[TfbiFlickDirection.TAP] as? TfbiFlickNode.Input)?.char ?: "" - - null -> "" - is TfbiFlickNode.StatefulKey -> { - Log.e(TAG, "Illegal state: StatefulKey found at TAP in root map.") - "" - } - } + val tapCharacter = nodeDisplayText(rootM[TfbiFlickDirection.TAP]) // Petal(周囲)に表示する文字 val petalChars = if (showPetals) { rootM .filterKeys { it != TfbiFlickDirection.TAP } - .mapValues { (dir, node) -> - when (node) { - is TfbiFlickNode.Input -> node.char - is TfbiFlickNode.SubMenu -> { - node.label - ?: (node.nextMap[TfbiFlickDirection.TAP] as? TfbiFlickNode.Input)?.char - ?: "" - } - - is TfbiFlickNode.StatefulKey -> { - Log.e(TAG, "Illegal state: StatefulKey found at Petal in root map.") - "" - } - } - } + .mapValues { (_, node) -> nodeDisplayText(node) } } else { emptyMap() } @@ -639,43 +693,76 @@ class TfbiHierarchicalFlickController( */ private fun setupStageUI(map: Map) { // 中央に表示する文字 - val tapCharacter = when (val tapNode = map[TfbiFlickDirection.TAP]) { - is TfbiFlickNode.Input -> tapNode.char - is TfbiFlickNode.SubMenu -> tapNode.label - ?: (tapNode.nextMap[TfbiFlickDirection.TAP] as? TfbiFlickNode.Input)?.char ?: "" - - null -> "" - is TfbiFlickNode.StatefulKey -> { - Log.e(TAG, "Illegal state: StatefulKey found at TAP in SubMenu map.") - "" - } - } + val tapCharacter = nodeDisplayText(map[TfbiFlickDirection.TAP]) // 周囲に表示する文字 val petalChars = map .filterKeys { it != TfbiFlickDirection.TAP } - .mapValues { (dir, node) -> - when (node) { - is TfbiFlickNode.Input -> node.char - is TfbiFlickNode.SubMenu -> { - node.label - ?: (node.nextMap[TfbiFlickDirection.TAP] as? TfbiFlickNode.Input)?.char - ?: "" - } + .mapValues { (_, node) -> nodeDisplayText(node) } - is TfbiFlickNode.StatefulKey -> { - Log.e(TAG, "Illegal state: StatefulKey found at Petal in SubMenu map.") - "" - } - } + popupView?.setCharacters(tapCharacter, petalChars) + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + val currentText = if (guideStageJustOpened) { + tapCharacter + } else { + nodeDisplayText(map[currentHighlight]).ifEmpty { tapCharacter } } + val currentSlot = if (mapStack.size > 1) guideRootDirection else currentHighlight + guidePopupHost.update( + state = TfbiGuidePopupState( + currentText = currentText, + currentSlot = currentSlot, + optionLabels = petalChars.mapValues { (_, output) -> + guideOptionLabel(tapCharacter, output) + }, + selectedOption = if (guideStageJustOpened) null else guideSelectedOption + ), + direction = guideArrowDirection + ) + guideStageJustOpened = false + } + } - popupView?.setCharacters(tapCharacter, petalChars) + private fun updateGuideForSelection( + currentText: String, + map: Map + ) { + if (popupPresentationMode != TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) return + val tapCharacter = nodeDisplayText(map[TfbiFlickDirection.TAP]) + val optionLabels = map + .filterKeys { it != TfbiFlickDirection.TAP } + .mapValues { (_, node) -> guideOptionLabel(tapCharacter, nodeDisplayText(node)) } + guidePopupHost.update( + state = TfbiGuidePopupState( + currentText = currentText, + currentSlot = if (mapStack.size > 1) guideRootDirection else currentHighlight, + optionLabels = optionLabels, + selectedOption = guideSelectedOption + ), + direction = guideArrowDirection + ) + } + + private fun nodeDisplayText(node: TfbiFlickNode?): String { + return when (node) { + is TfbiFlickNode.Input -> node.char + is TfbiFlickNode.SubMenu -> node.label + ?: (node.nextMap[TfbiFlickDirection.TAP] as? TfbiFlickNode.Input)?.char.orEmpty() + + is TfbiFlickNode.StatefulKey -> "" + null -> "" + } + } + + private fun guideOptionLabel(currentText: String, output: String): String { + if (output.isEmpty()) return "" + return output.removePrefix(currentText).ifEmpty { output } } private fun resetState() { attachedView?.removeCallbacks(longPressRunnable) popupWindow?.dismiss() + guidePopupHost.dismiss() popupWindow = null popupView = null centerStack.clear() @@ -683,6 +770,10 @@ class TfbiHierarchicalFlickController( highlightStack.clear() currentMap = null currentHighlight = TfbiFlickDirection.TAP + guideRootDirection = TfbiFlickDirection.TAP + guideSelectedOption = null + guideArrowDirection = null + guideStageJustOpened = false isJitterGuardActive = false activeGestureConfig = null diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt index bdb8e0360..4a3d00312 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt @@ -9,11 +9,14 @@ import android.view.ViewConfiguration import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickGestureMath import com.kazumaproject.core.domain.flick.GestureSessionConfig import com.kazumaproject.core.domain.flick.GestureSessionConfigSource import com.kazumaproject.custom_keyboard.controller.getLocationRelativeToWindowAnchor +import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState +import com.kazumaproject.custom_keyboard.controller.TfbiGuidePopupHost import kotlin.math.abs import kotlin.math.atan2 import kotlin.math.hypot @@ -85,6 +88,10 @@ class TfbiInputController( private var inputTextTransform: (String) -> String = { it } private var popupWindowAnchorProvider: (() -> View?)? = null + private var popupPresentationMode = TfbiPopupPresentationMode.LEGACY_GRID + private val guidePopupHost by lazy { + TfbiGuidePopupHost(context) { popupWindowAnchorProvider?.invoke() } + } private var isTouchActive = false private val longPressRunnable = Runnable { @@ -114,6 +121,7 @@ class TfbiInputController( this.popupBackgroundColor = backgroundColor this.popupHighlightedColor = highlightedColor this.popupTextColor = textColor + guidePopupHost.setColors(backgroundColor, highlightedColor, textColor) } fun applyPopupViewStyle(style: PopupViewStyle) { @@ -124,6 +132,16 @@ class TfbiInputController( textColor = style.textColor ) popupView?.applyPopupViewStyle(popupStyle) + guidePopupHost.applyPopupViewStyle(popupStyle) + } + + fun setPopupPresentationMode(mode: TfbiPopupPresentationMode) { + if (popupPresentationMode == mode) return + popupWindow?.dismiss() + guidePopupHost.dismiss() + popupWindow = null + popupView = null + popupPresentationMode = mode } fun setPopupWindowAnchorProvider(provider: (() -> View?)?) { @@ -133,6 +151,7 @@ class TfbiInputController( fun setInputTextTransform(transform: (String) -> String) { inputTextTransform = transform popupView?.setInputTextTransform(transform) + guidePopupHost.setInputTextTransform(transform) } fun attach( @@ -221,6 +240,7 @@ class TfbiInputController( setupSecondStageUI(firstFlickDirection) popupView?.highlightDirection(determinedDirection) currentSecondFlickDirection = determinedDirection + updateGuideSecondStage() } } else { val distanceFromInitial = hypot( @@ -254,6 +274,7 @@ class TfbiInputController( if (highlightTargetDirection != currentSecondFlickDirection) { popupView?.highlightDirection(highlightTargetDirection) currentSecondFlickDirection = highlightTargetDirection + updateGuideSecondStage() } } listener?.onSelectionChanged( @@ -325,6 +346,44 @@ class TfbiInputController( anchorView: View, baseDirection: TfbiFlickDirection, showPetals: Boolean + ) { + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + showGuidePopup(anchorView, baseDirection, showPetals) + } else { + showLegacyPopup(anchorView, baseDirection, showPetals) + } + } + + private fun showGuidePopup( + anchorView: View, + baseDirection: TfbiFlickDirection, + showPetals: Boolean + ) { + val currentText = characterFor(baseDirection, TfbiFlickDirection.TAP) + val optionLabels = if (showPetals) { + getEnabledFirstFlickDirections().associateWith { direction -> + characterFor(direction, direction) + }.mapValues { (_, output) -> guideOptionLabel(currentText, output) } + } else { + emptyMap() + } + guidePopupHost.show( + anchor = anchorView, + state = TfbiGuidePopupState( + currentText = currentText, + currentSlot = TfbiFlickDirection.TAP, + optionLabels = optionLabels + ), + direction = baseDirection.takeUnless { it == TfbiFlickDirection.TAP && !showPetals }, + style = popupStyle, + inputTextTransform = inputTextTransform + ) + } + + private fun showLegacyPopup( + anchorView: View, + baseDirection: TfbiFlickDirection, + showPetals: Boolean ) { if (popupWindow?.isShowing == true && !showPetals) return @@ -378,6 +437,30 @@ class TfbiInputController( popupView?.setCharacters(tapCharacter, petalChars) } + private fun updateGuideSecondStage() { + if (popupPresentationMode != TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) return + val first = firstFlickDirection + val currentText = characterFor(first, currentSecondFlickDirection) + .ifEmpty { characterFor(first, TfbiFlickDirection.TAP) } + val options = getEnabledSecondFlickDirections(first).associateWith { direction -> + characterFor(first, direction) + }.mapValues { (_, output) -> guideOptionLabel(currentText, output) } + guidePopupHost.update( + state = TfbiGuidePopupState( + currentText = currentText, + currentSlot = first, + optionLabels = options, + selectedOption = currentSecondFlickDirection + ), + direction = currentSecondFlickDirection + ) + } + + private fun guideOptionLabel(currentText: String, output: String): String { + if (output.isEmpty()) return "" + return output.removePrefix(currentText).ifEmpty { output } + } + private fun characterFor(first: TfbiFlickDirection, second: TfbiFlickDirection): String { val normal = characterMapProvider?.invoke(first, second).orEmpty() val longPress = longPressCharacterMapProvider?.invoke(first, second).orEmpty() @@ -387,6 +470,7 @@ class TfbiInputController( private fun resetState() { clearLongPressCallback(attachedView) popupWindow?.dismiss() + guidePopupHost.dismiss() popupWindow = null popupView = null flickState = FlickState.NEUTRAL diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt index 3eda5618b..f2e192729 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt @@ -10,12 +10,14 @@ import android.view.ViewConfiguration import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickGestureMath import com.kazumaproject.core.domain.flick.GestureSessionConfig import com.kazumaproject.core.domain.flick.GestureSessionConfigSource import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection import com.kazumaproject.custom_keyboard.view.TfbiFlickPopupView +import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState import kotlin.math.abs import kotlin.math.atan2 @@ -74,6 +76,10 @@ class TfbiStickyFlickController( private var inputTextTransform: (String) -> String = { it } private var popupWindowAnchorProvider: (() -> View?)? = null + private var popupPresentationMode = TfbiPopupPresentationMode.LEGACY_GRID + private val guidePopupHost by lazy { + TfbiGuidePopupHost(context) { popupWindowAnchorProvider?.invoke() } + } private val longPressRunnable = Runnable { val view = attachedView ?: return@Runnable @@ -97,9 +103,19 @@ class TfbiStickyFlickController( popupWindowAnchorProvider = provider } + fun setPopupPresentationMode(mode: TfbiPopupPresentationMode) { + if (popupPresentationMode == mode) return + popupWindow?.dismiss() + guidePopupHost.dismiss() + popupWindow = null + popupView = null + popupPresentationMode = mode + } + fun setInputTextTransform(transform: (String) -> String) { inputTextTransform = transform popupView?.setInputTextTransform(transform) + guidePopupHost.setInputTextTransform(transform) } fun applyPopupViewStyle(style: PopupViewStyle) { @@ -110,6 +126,7 @@ class TfbiStickyFlickController( textColor = style.textColor ) popupView?.applyPopupViewStyle(popupStyle) + guidePopupHost.applyPopupViewStyle(popupStyle) } fun cancel() { @@ -164,6 +181,7 @@ class TfbiStickyFlickController( setupSecondStageUI(firstFlickDirection) popupView?.highlightDirection(determinedDirection) currentSecondFlickDirection = determinedDirection + updateGuideSecondStage() } } else { // ===== ★ 変更点 1 ===== @@ -199,6 +217,7 @@ class TfbiStickyFlickController( if (highlightTargetDirection != currentSecondFlickDirection) { popupView?.highlightDirection(highlightTargetDirection) currentSecondFlickDirection = highlightTargetDirection + updateGuideSecondStage() } } } @@ -266,6 +285,44 @@ class TfbiStickyFlickController( anchorView: View, baseDirection: TfbiFlickDirection, showPetals: Boolean + ) { + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + showGuidePopup(anchorView, baseDirection, showPetals) + } else { + showLegacyPopup(anchorView, baseDirection, showPetals) + } + } + + private fun showGuidePopup( + anchorView: View, + baseDirection: TfbiFlickDirection, + showPetals: Boolean + ) { + val currentText = characterMapProvider?.invoke(baseDirection, TfbiFlickDirection.TAP).orEmpty() + val optionLabels = if (showPetals) { + getEnabledFirstFlickDirections().associateWith { direction -> + characterMapProvider?.invoke(direction, direction).orEmpty() + }.mapValues { (_, output) -> guideOptionLabel(currentText, output) } + } else { + emptyMap() + } + guidePopupHost.show( + anchor = anchorView, + state = TfbiGuidePopupState( + currentText = currentText, + currentSlot = TfbiFlickDirection.TAP, + optionLabels = optionLabels + ), + direction = baseDirection.takeUnless { it == TfbiFlickDirection.TAP && !showPetals }, + style = popupStyle, + inputTextTransform = inputTextTransform + ) + } + + private fun showLegacyPopup( + anchorView: View, + baseDirection: TfbiFlickDirection, + showPetals: Boolean ) { if (popupWindow?.isShowing == true && !showPetals) return @@ -315,10 +372,35 @@ class TfbiStickyFlickController( popupView?.setCharacters(tapCharacter, petalChars) } + private fun updateGuideSecondStage() { + if (popupPresentationMode != TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) return + val first = firstFlickDirection + val currentText = characterMapProvider?.invoke(first, currentSecondFlickDirection).orEmpty() + .ifEmpty { characterMapProvider?.invoke(first, TfbiFlickDirection.TAP).orEmpty() } + val options = getEnabledSecondFlickDirections(first).associateWith { direction -> + characterMapProvider?.invoke(first, direction).orEmpty() + }.mapValues { (_, output) -> guideOptionLabel(currentText, output) } + guidePopupHost.update( + state = TfbiGuidePopupState( + currentText = currentText, + currentSlot = first, + optionLabels = options, + selectedOption = currentSecondFlickDirection + ), + direction = currentSecondFlickDirection + ) + } + + private fun guideOptionLabel(currentText: String, output: String): String { + if (output.isEmpty()) return "" + return output.removePrefix(currentText).ifEmpty { output } + } + private fun resetState() { attachedView?.removeCallbacks(longPressRunnable) popupWindow?.dismiss() + guidePopupHost.dismiss() popupWindow = null popupView = null flickState = FlickState.NEUTRAL diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuidePopupState.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuidePopupState.kt new file mode 100644 index 000000000..2ad50517d --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuidePopupState.kt @@ -0,0 +1,16 @@ +package com.kazumaproject.custom_keyboard.data + +import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection + +/** + * Render state for the compact TFBi/Arte-style guide shown above the touched key. + * + * [optionLabels] contains display-ready labels (usually the suffix after the current + * output), rather than the complete output stored in the input map. + */ +data class TfbiGuidePopupState( + val currentText: String, + val currentSlot: TfbiFlickDirection, + val optionLabels: Map = emptyMap(), + val selectedOption: TfbiFlickDirection? = null +) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt index fe2d520bd..41a20ce7b 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt @@ -32,6 +32,7 @@ import androidx.core.graphics.ColorUtils import com.google.android.material.R import com.kazumaproject.core.data.popup.FlickPopupViewStyleSet import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.core.domain.extensions.isDarkThemeOn import com.kazumaproject.core.domain.extensions.setBorder import com.kazumaproject.core.domain.extensions.setDrawableAlpha @@ -136,6 +137,7 @@ class FlickKeyboardView @JvmOverloads constructor( ) private var popupWindowAnchorProvider: (() -> View?)? = null + private var tfbiPopupPresentationMode = TfbiPopupPresentationMode.LEGACY_GRID private val hitRect = Rect() private var flickSensitivity: Int = 100 @@ -286,6 +288,13 @@ class FlickKeyboardView @JvmOverloads constructor( hierarchicalTfbiControllers.forEach { it.setPopupWindowAnchorProvider(provider) } } + fun setTfbiPopupPresentationMode(mode: TfbiPopupPresentationMode) { + tfbiPopupPresentationMode = mode + tfbiControllers.forEach { it.setPopupPresentationMode(mode) } + stickyTfbiControllers.forEach { it.setPopupPresentationMode(mode) } + hierarchicalTfbiControllers.forEach { it.setPopupPresentationMode(mode) } + } + fun applyPopupViewStyleSet(styleSet: FlickPopupViewStyleSet) { popupViewStyleSet = FlickPopupViewStyleSet( directional = clampPopupStyle(styleSet.directional), @@ -2212,6 +2221,7 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setPopupPresentationMode(tfbiPopupPresentationMode) setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.tfbi) this.listener = object : TfbiInputController.TfbiListener { @@ -2380,6 +2390,7 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setPopupPresentationMode(tfbiPopupPresentationMode) setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.tfbi) this.listener = object : TfbiStickyFlickController.TfbiListener { @@ -2440,6 +2451,7 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setPopupPresentationMode(tfbiPopupPresentationMode) setInputTextTransform(::transformInputTextForDisplay) setModeSwitchAngleMargin(hierarchicalFlickModeSwitchAngleMargin) applyPopupViewStyle(popupViewStyleSet.tfbi) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGestureArrowView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGestureArrowView.kt new file mode 100644 index 000000000..2a2f0e5d7 --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGestureArrowView.kt @@ -0,0 +1,94 @@ +package com.kazumaproject.custom_keyboard.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PointF +import android.util.TypedValue +import android.view.View +import androidx.appcompat.R as AppCompatR +import com.kazumaproject.core.domain.extensions.getThemeColor +import kotlin.math.min + +/** Draws the current flick direction over the touched key. */ +class TfbiGestureArrowView(context: Context) : View(context) { + + private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + strokeWidth = dp(9f) + color = context.getThemeColor(AppCompatR.attr.colorPrimary) + } + private val headPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = arrowPaint.color + } + private var direction: TfbiFlickDirection? = null + + fun setDirection(direction: TfbiFlickDirection?) { + this.direction = direction?.takeUnless { it == TfbiFlickDirection.TAP } + invalidate() + } + + fun setColor(color: Int) { + arrowPaint.color = color + headPaint.color = color + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val direction = direction ?: return + val center = PointF(width / 2f, height / 2f) + val radius = min(width, height) * 0.34f + val vector = vectorFor(direction) + val end = PointF(center.x + vector.x * radius, center.y + vector.y * radius) + val path = Path().apply { + moveTo(center.x - vector.x * radius * 0.25f, center.y - vector.y * radius * 0.25f) + lineTo(end.x, end.y) + } + canvas.drawPath(path, arrowPaint) + + val headLength = dp(18f) + val perpendicular = PointF(-vector.y, vector.x) + val base = PointF( + end.x - vector.x * headLength, + end.y - vector.y * headLength + ) + val triangle = Path().apply { + moveTo(end.x, end.y) + lineTo( + base.x + perpendicular.x * headLength * 0.55f, + base.y + perpendicular.y * headLength * 0.55f + ) + lineTo( + base.x - perpendicular.x * headLength * 0.55f, + base.y - perpendicular.y * headLength * 0.55f + ) + close() + } + canvas.drawPath(triangle, headPaint) + } + + private fun vectorFor(direction: TfbiFlickDirection): PointF { + return when (direction) { + TfbiFlickDirection.UP -> PointF(0f, -1f) + TfbiFlickDirection.DOWN -> PointF(0f, 1f) + TfbiFlickDirection.LEFT -> PointF(-1f, 0f) + TfbiFlickDirection.RIGHT -> PointF(1f, 0f) + TfbiFlickDirection.UP_LEFT -> PointF(-0.75f, -0.75f) + TfbiFlickDirection.UP_RIGHT -> PointF(0.75f, -0.75f) + TfbiFlickDirection.DOWN_LEFT -> PointF(-0.75f, 0.75f) + TfbiFlickDirection.DOWN_RIGHT -> PointF(0.75f, 0.75f) + TfbiFlickDirection.TAP -> PointF(0f, 0f) + } + } + + private fun dp(value: Float): Float = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value, + resources.displayMetrics + ) +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt new file mode 100644 index 000000000..f5690b1e7 --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt @@ -0,0 +1,210 @@ +package com.kazumaproject.custom_keyboard.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.util.TypedValue +import android.view.View +import androidx.core.graphics.ColorUtils +import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState + +/** + * Compact guide used by the optional TFBi/Arte-style popup presentation. + * + * Unlike [TfbiFlickPopupView], this view deliberately does not paint a filled cell for every + * direction. It paints a light guide card, a blue current-output pill, and only the available + * next labels, matching the guide figures used by TFBi documentation. + */ +class TfbiGuidePopupView(context: Context) : View(context) { + + private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = dp(1f) + } + private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = dp(1f) + } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + } + private val activeTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + } + + private var state = TfbiGuidePopupState("。", TfbiFlickDirection.TAP) + private var popupStyle = PopupViewStyle(100, 20f) + private var popupBackgroundColor: Int? = null + private var activeColor: Int = DEFAULT_ACTIVE_COLOR + private var popupTextColor: Int = DEFAULT_TEXT_COLOR + private var activeTextColor: Int = Color.WHITE + private var inputTextTransform: (String) -> String = { it } + + private val directionLayout = listOf( + listOf(TfbiFlickDirection.UP_LEFT, TfbiFlickDirection.UP, TfbiFlickDirection.UP_RIGHT), + listOf(TfbiFlickDirection.LEFT, TfbiFlickDirection.TAP, TfbiFlickDirection.RIGHT), + listOf(TfbiFlickDirection.DOWN_LEFT, TfbiFlickDirection.DOWN, TfbiFlickDirection.DOWN_RIGHT) + ) + + fun setState(state: TfbiGuidePopupState) { + this.state = state + invalidate() + } + + fun setInputTextTransform(transform: (String) -> String) { + inputTextTransform = transform + invalidate() + } + + fun applyPopupViewStyle(style: PopupViewStyle) { + popupStyle = PopupViewStyle( + sizeScalePercent = style.sizeScalePercent.coerceIn(50, 200), + textSizeSp = style.textSizeSp.coerceIn(8f, 48f), + backgroundColor = style.backgroundColor, + textColor = style.textColor + ) + popupBackgroundColor = style.backgroundColor + activeColor = DEFAULT_ACTIVE_COLOR + popupTextColor = style.textColor ?: DEFAULT_TEXT_COLOR + activeTextColor = readableForeground(activeColor) + invalidate() + } + + fun setColors( + backgroundColor: Int, + highlightedBackgroundColor: Int, + textColor: Int + ) { + popupBackgroundColor = backgroundColor + activeColor = highlightedBackgroundColor + popupTextColor = textColor + activeTextColor = readableForeground(activeColor) + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + val inset = dp(1f) + val panel = RectF(inset, inset, width - inset, height - inset) + backgroundPaint.color = popupBackgroundColor ?: DEFAULT_BACKGROUND_COLOR + borderPaint.color = ColorUtils.setAlphaComponent(popupTextColor, 70) + gridPaint.color = ColorUtils.setAlphaComponent(popupTextColor, 45) + canvas.drawRoundRect(panel, dp(4f), dp(4f), backgroundPaint) + canvas.drawRoundRect(panel, dp(4f), dp(4f), borderPaint) + + val cellWidth = panel.width() / 3f + val cellHeight = panel.height() / 3f + for (index in 1..2) { + val x = panel.left + cellWidth * index + val y = panel.top + cellHeight * index + canvas.drawLine(x, panel.top, x, panel.bottom, gridPaint) + canvas.drawLine(panel.left, y, panel.right, y, gridPaint) + } + + val currentRect = rectFor(state.currentSlot, panel, cellWidth, cellHeight) + drawActiveLabel(canvas, inputTextTransform(state.currentText), currentRect) + + state.optionLabels.forEach { (direction, label) -> + if (label.isEmpty() || direction == state.currentSlot) return@forEach + val rect = rectFor(direction, panel, cellWidth, cellHeight) + if (direction == state.selectedOption) { + drawActiveLabel(canvas, inputTextTransform(label), rect, compact = true) + } else { + drawPlainLabel(canvas, inputTextTransform(label), rect) + } + } + } + + private fun rectFor( + direction: TfbiFlickDirection, + panel: RectF, + cellWidth: Float, + cellHeight: Float + ): RectF { + val position = directionLayout + .asSequence() + .withIndex() + .flatMap { (row, directions) -> + directions.withIndex().map { (column, value) -> + Triple(row, column, value) + } + } + .firstOrNull { it.third == direction } + ?: Triple(1, 1, TfbiFlickDirection.TAP) + val left = panel.left + position.second * cellWidth + val top = panel.top + position.first * cellHeight + return RectF(left, top, left + cellWidth, top + cellHeight) + } + + private fun drawActiveLabel( + canvas: Canvas, + text: String, + rect: RectF, + compact: Boolean = false + ) { + if (text.isEmpty()) return + val horizontalPadding = if (compact) dp(8f) else dp(10f) + val verticalPadding = if (compact) dp(5f) else dp(7f) + val maxWidth = rect.width() - dp(4f) + val textSize = fitTextSize(text, maxWidth - horizontalPadding * 2) + activeTextPaint.color = activeTextColor + activeTextPaint.textSize = textSize + val textWidth = minOf( + maxWidth, + activeTextPaint.measureText(text) + horizontalPadding * 2 + ) + val pill = RectF( + rect.centerX() - textWidth / 2f, + rect.centerY() - (textSize + verticalPadding * 2) / 2f, + rect.centerX() + textWidth / 2f, + rect.centerY() + (textSize + verticalPadding * 2) / 2f + ) + backgroundPaint.color = activeColor + canvas.drawRoundRect(pill, dp(if (compact) 8f else 10f), dp(if (compact) 8f else 10f), backgroundPaint) + val baseline = pill.centerY() - (activeTextPaint.ascent() + activeTextPaint.descent()) / 2f + canvas.drawText(text, pill.centerX(), baseline, activeTextPaint) + } + + private fun drawPlainLabel(canvas: Canvas, text: String, rect: RectF) { + if (text.isEmpty()) return + textPaint.color = popupTextColor + textPaint.textSize = fitTextSize(text, rect.width() - dp(6f)) + val baseline = rect.centerY() - (textPaint.ascent() + textPaint.descent()) / 2f + canvas.drawText(text, rect.centerX(), baseline, textPaint) + } + + private fun fitTextSize(text: String, maxWidth: Float): Float { + val configured = sp(popupStyle.textSizeSp) + textPaint.textSize = configured + val measured = textPaint.measureText(text) + if (measured <= maxWidth || measured <= 0f) return configured + return (configured * maxWidth / measured).coerceAtLeast(sp(8f)) + } + + private fun readableForeground(background: Int): Int { + return if (ColorUtils.calculateLuminance(background) > 0.55) Color.BLACK else Color.WHITE + } + + private fun dp(value: Float): Float = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value, + resources.displayMetrics + ) + + private fun sp(value: Float): Float = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + value, + resources.displayMetrics + ) + + private companion object { + const val DEFAULT_ACTIVE_COLOR: Int = 0xff1976d2.toInt() + const val DEFAULT_TEXT_COLOR: Int = 0xff202124.toInt() + const val DEFAULT_BACKGROUND_COLOR: Int = 0xfff5f7fa.toInt() + } +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupPlacementTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupPlacementTest.kt new file mode 100644 index 000000000..278ed3038 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupPlacementTest.kt @@ -0,0 +1,45 @@ +package com.kazumaproject.custom_keyboard.controller + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TfbiGuidePopupPlacementTest { + + @Test + fun placesGuideAboveKeyAndCentersIt() { + val placement = resolveTfbiGuidePopupPlacement( + keyLeft = 200, + keyTop = 500, + keyWidth = 100, + panelWidth = 140, + panelHeight = 110, + hostWidth = 600, + hostHeight = 900, + gap = 8 + ) + + assertEquals(180, placement.panelLeft) + assertEquals(382, placement.panelTop) + assertEquals(200, placement.arrowLeft) + assertEquals(500, placement.arrowTop) + } + + @Test + fun clampsGuideToHostTopWhenTopHasNoRoom() { + val placement = resolveTfbiGuidePopupPlacement( + keyLeft = -20, + keyTop = 10, + keyWidth = 100, + panelWidth = 160, + panelHeight = 90, + hostWidth = 300, + hostHeight = 300, + gap = 6 + ) + + assertEquals(0, placement.panelLeft) + assertEquals(0, placement.panelTop) + assertEquals(-20, placement.arrowLeft) + assertEquals(10, placement.arrowTop) + } +} From 8f89c04d9059b48d1b8443140f6acc4d00fb5a14 Mon Sep 17 00:00:00 2001 From: Codex Mozc Autonomy Date: Sun, 9 Aug 2026 19:16:48 -0400 Subject: [PATCH 02/11] Refine TFBi guide popup visual hierarchy --- .../ui/popup_style/PopupStylePreviewViews.kt | 68 +++++++++++++++---- .../view/TfbiGuidePopupView.kt | 65 +++++++++++++----- 2 files changed, 105 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt index b3abf043f..c7d3f143c 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt @@ -3,9 +3,11 @@ package com.kazumaproject.markdownhelperkeyboard.setting_activity.ui.popup_style import android.content.Context import android.graphics.Canvas import android.graphics.Color +import android.graphics.LinearGradient import android.graphics.Paint import android.graphics.Path import android.graphics.RectF +import android.graphics.Shader import android.util.AttributeSet import android.util.TypedValue import android.view.View @@ -118,6 +120,11 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( textAlign = Paint.Align.CENTER typeface = android.graphics.Typeface.DEFAULT_BOLD } + private val guideTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = defaultTextColor + textAlign = Paint.Align.CENTER + typeface = android.graphics.Typeface.DEFAULT + } private var style = PopupViewStyle(100, 28f) private var target = Target.DIRECTIONAL @@ -256,8 +263,17 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( val panelTop = dpToPx(12f) val panel = RectF(panelLeft, panelTop, panelLeft + panelWidth, panelTop + panelHeight) val guideBackground = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = this@FlickPopupStylePreviewView.style.backgroundColor - ?: Color.rgb(232, 232, 232) + val color = this@FlickPopupStylePreviewView.style.backgroundColor + ?: Color.rgb(238, 240, 242) + shader = LinearGradient( + 0f, + panel.top, + 0f, + panel.bottom, + blendWithWhite(color, 0.16f), + blendWithBlack(color, 0.06f), + Shader.TileMode.CLAMP + ) } val guideLine = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(90, 60, 60, 60) @@ -291,10 +307,10 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( dpToPx(8f), active ) - drawGuideText(canvas, "き", current, Color.WHITE) - drawGuideText(canvas, "ゅ", cell(0, 1), Color.DKGRAY) - drawGuideText(canvas, "ゃ", cell(1, 2), Color.DKGRAY) - drawGuideText(canvas, "ょ", cell(2, 1), Color.DKGRAY) + drawGuideText(canvas, "き", current, Color.WHITE, MAIN_TEXT_SCALE) + drawGuideText(canvas, "ゅ", cell(0, 1), Color.DKGRAY, OPTION_TEXT_SCALE) + drawGuideText(canvas, "ゃ", cell(1, 2), Color.DKGRAY, OPTION_TEXT_SCALE) + drawGuideText(canvas, "ょ", cell(2, 1), Color.DKGRAY, OPTION_TEXT_SCALE) val key = RectF( width / 2f - dpToPx(76f), @@ -304,7 +320,7 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( ) val keyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(185, 185, 185) } canvas.drawRect(key, keyPaint) - drawGuideText(canvas, "か", key, Color.GRAY) + drawGuideText(canvas, "か", key, Color.GRAY, 1f) val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = defaultHighlightColor @@ -324,11 +340,34 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( canvas.drawPath(head, arrowHead) } - private fun drawGuideText(canvas: Canvas, text: String, rect: RectF, color: Int) { - textPaint.color = color - textPaint.textSize = spToPx(style.textSizeSp.coerceAtMost(24f)) - val y = rect.centerY() - (textPaint.descent() + textPaint.ascent()) / 2f - canvas.drawText(text, rect.centerX(), y, textPaint) + private fun drawGuideText( + canvas: Canvas, + text: String, + rect: RectF, + color: Int, + scale: Float + ) { + guideTextPaint.color = color + guideTextPaint.textSize = spToPx((style.textSizeSp * scale).coerceAtMost(24f)) + val y = rect.centerY() - (guideTextPaint.descent() + guideTextPaint.ascent()) / 2f + canvas.drawText(text, rect.centerX(), y, guideTextPaint) + } + + private fun blendWithWhite(color: Int, amount: Float): Int { + return Color.rgb( + (Color.red(color) + (255 - Color.red(color)) * amount).toInt(), + (Color.green(color) + (255 - Color.green(color)) * amount).toInt(), + (Color.blue(color) + (255 - Color.blue(color)) * amount).toInt() + ) + } + + private fun blendWithBlack(color: Int, amount: Float): Int { + val factor = 1f - amount + return Color.rgb( + (Color.red(color) * factor).toInt(), + (Color.green(color) * factor).toInt(), + (Color.blue(color) * factor).toInt() + ) } private fun drawCenteredText(canvas: Canvas, text: String, rect: RectF) { @@ -340,6 +379,11 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics) } + private companion object { + const val MAIN_TEXT_SCALE = 1.20f + const val OPTION_TEXT_SCALE = 0.68f + } + private fun spToPx(sp: Float): Float { return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics) } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt index f5690b1e7..5ff39e532 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt @@ -3,8 +3,10 @@ package com.kazumaproject.custom_keyboard.view import android.content.Context import android.graphics.Canvas import android.graphics.Color +import android.graphics.LinearGradient import android.graphics.Paint import android.graphics.RectF +import android.graphics.Shader import android.util.TypedValue import android.view.View import androidx.core.graphics.ColorUtils @@ -20,7 +22,8 @@ import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState */ class TfbiGuidePopupView(context: Context) : View(context) { - private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val panelPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val activePaint = Paint(Paint.ANTI_ALIAS_FLAG) private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeWidth = dp(1f) @@ -91,10 +94,20 @@ class TfbiGuidePopupView(context: Context) : View(context) { val inset = dp(1f) val panel = RectF(inset, inset, width - inset, height - inset) - backgroundPaint.color = popupBackgroundColor ?: DEFAULT_BACKGROUND_COLOR - borderPaint.color = ColorUtils.setAlphaComponent(popupTextColor, 70) - gridPaint.color = ColorUtils.setAlphaComponent(popupTextColor, 45) - canvas.drawRoundRect(panel, dp(4f), dp(4f), backgroundPaint) + val panelColor = popupBackgroundColor ?: DEFAULT_BACKGROUND_COLOR + panelPaint.shader = LinearGradient( + 0f, + panel.top, + 0f, + panel.bottom, + ColorUtils.blendARGB(panelColor, Color.WHITE, 0.16f), + ColorUtils.blendARGB(panelColor, Color.BLACK, 0.06f), + Shader.TileMode.CLAMP + ) + borderPaint.color = ColorUtils.setAlphaComponent(popupTextColor, 105) + gridPaint.color = ColorUtils.setAlphaComponent(popupTextColor, 70) + canvas.drawRoundRect(panel, dp(4f), dp(4f), panelPaint) + panelPaint.shader = null canvas.drawRoundRect(panel, dp(4f), dp(4f), borderPaint) val cellWidth = panel.width() / 3f @@ -148,10 +161,15 @@ class TfbiGuidePopupView(context: Context) : View(context) { compact: Boolean = false ) { if (text.isEmpty()) return - val horizontalPadding = if (compact) dp(8f) else dp(10f) - val verticalPadding = if (compact) dp(5f) else dp(7f) + val horizontalPadding = if (compact) dp(4f) else dp(8f) + val verticalPadding = if (compact) dp(3f) else dp(5f) val maxWidth = rect.width() - dp(4f) - val textSize = fitTextSize(text, maxWidth - horizontalPadding * 2) + val textSize = fitTextSize( + text = text, + maxWidth = maxWidth - horizontalPadding * 2, + scale = if (compact) OPTION_TEXT_SCALE else MAIN_TEXT_SCALE, + minimumSp = if (compact) OPTION_MIN_TEXT_SIZE_SP else MAIN_MIN_TEXT_SIZE_SP + ) activeTextPaint.color = activeTextColor activeTextPaint.textSize = textSize val textWidth = minOf( @@ -164,8 +182,8 @@ class TfbiGuidePopupView(context: Context) : View(context) { rect.centerX() + textWidth / 2f, rect.centerY() + (textSize + verticalPadding * 2) / 2f ) - backgroundPaint.color = activeColor - canvas.drawRoundRect(pill, dp(if (compact) 8f else 10f), dp(if (compact) 8f else 10f), backgroundPaint) + activePaint.color = activeColor + canvas.drawRoundRect(pill, dp(if (compact) 7f else 9f), dp(if (compact) 7f else 9f), activePaint) val baseline = pill.centerY() - (activeTextPaint.ascent() + activeTextPaint.descent()) / 2f canvas.drawText(text, pill.centerX(), baseline, activeTextPaint) } @@ -173,17 +191,28 @@ class TfbiGuidePopupView(context: Context) : View(context) { private fun drawPlainLabel(canvas: Canvas, text: String, rect: RectF) { if (text.isEmpty()) return textPaint.color = popupTextColor - textPaint.textSize = fitTextSize(text, rect.width() - dp(6f)) + textPaint.textSize = fitTextSize( + text = text, + maxWidth = rect.width() - dp(6f), + scale = OPTION_TEXT_SCALE, + minimumSp = OPTION_MIN_TEXT_SIZE_SP + ) val baseline = rect.centerY() - (textPaint.ascent() + textPaint.descent()) / 2f canvas.drawText(text, rect.centerX(), baseline, textPaint) } - private fun fitTextSize(text: String, maxWidth: Float): Float { - val configured = sp(popupStyle.textSizeSp) + private fun fitTextSize( + text: String, + maxWidth: Float, + scale: Float, + minimumSp: Float + ): Float { + val configured = sp(popupStyle.textSizeSp * scale) textPaint.textSize = configured val measured = textPaint.measureText(text) - if (measured <= maxWidth || measured <= 0f) return configured - return (configured * maxWidth / measured).coerceAtLeast(sp(8f)) + val safeWidth = maxWidth.coerceAtLeast(1f) + if (measured <= safeWidth || measured <= 0f) return configured + return (configured * safeWidth / measured).coerceAtLeast(sp(minimumSp)) } private fun readableForeground(background: Int): Int { @@ -205,6 +234,10 @@ class TfbiGuidePopupView(context: Context) : View(context) { private companion object { const val DEFAULT_ACTIVE_COLOR: Int = 0xff1976d2.toInt() const val DEFAULT_TEXT_COLOR: Int = 0xff202124.toInt() - const val DEFAULT_BACKGROUND_COLOR: Int = 0xfff5f7fa.toInt() + const val DEFAULT_BACKGROUND_COLOR: Int = 0xffeef0f2.toInt() + const val MAIN_TEXT_SCALE: Float = 1.20f + const val OPTION_TEXT_SCALE: Float = 0.68f + const val MAIN_MIN_TEXT_SIZE_SP: Float = 12f + const val OPTION_MIN_TEXT_SIZE_SP: Float = 8f } } From 25b592750ed32c79009ce7f98e5c041caef5950b Mon Sep 17 00:00:00 2001 From: Codex Mozc Autonomy Date: Sun, 9 Aug 2026 21:10:11 -0400 Subject: [PATCH 03/11] fix color --- .../ui/popup_style/PopupStyleFragments.kt | 5 + .../ui/popup_style/PopupStylePreviewViews.kt | 17 +- .../controller/TfbiGuidePopupHost.kt | 33 ++- .../TfbiHierarchicalFlickController.kt | 24 +- .../view/TfbiGuidePopupView.kt | 35 ++- .../view/FlickKeyboardViewTfbiAttachTest.kt | 280 ++++++++++++++++++ .../view/TfbiGuidePopupViewTest.kt | 68 +++++ 7 files changed, 444 insertions(+), 18 deletions(-) create mode 100644 custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt create mode 100644 custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupViewTest.kt diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt index 6151986c4..0f2e6c3c5 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt @@ -389,6 +389,11 @@ private fun Fragment.styleEditorSection( ) root.addView(preview) if (flickTarget == FlickTarget.TFBI && preview is FlickPopupStylePreviewView) { + preview.setTfbiGuideBackgroundColor( + AppPreference.key_popup_background_color.takeIf { + AppPreference.key_popup_use_custom_color + } + ) root.addView(sectionTitle("TFBiポップアップ表示")) val modeGroup = RadioGroup(context).apply { orientation = RadioGroup.VERTICAL diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt index c7d3f143c..e335879d6 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStylePreviewViews.kt @@ -13,6 +13,8 @@ import android.util.TypedValue import android.view.View import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode +import com.kazumaproject.core.domain.extensions.getThemeColor +import com.kazumaproject.core.domain.extensions.isDarkThemeOn class PopupStylePreviewView @JvmOverloads constructor( context: Context, @@ -129,6 +131,13 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( private var style = PopupViewStyle(100, 28f) private var target = Target.DIRECTIONAL private var tfbiPresentationMode = TfbiPopupPresentationMode.LEGACY_GRID + private var tfbiGuideBackgroundColor: Int? = null + + private val defaultTfbiGuideBackgroundColor = if (context.isDarkThemeOn()) { + context.getThemeColor(com.google.android.material.R.attr.colorSurfaceContainerHighest) + } else { + context.getThemeColor(com.google.android.material.R.attr.colorSurface) + } fun applyStyle(target: Target, style: PopupViewStyle) { this.target = target @@ -146,6 +155,11 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( invalidate() } + fun setTfbiGuideBackgroundColor(color: Int?) { + tfbiGuideBackgroundColor = color + invalidate() + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val desiredHeight = dpToPx(190f).toInt() setMeasuredDimension( @@ -264,7 +278,8 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( val panel = RectF(panelLeft, panelTop, panelLeft + panelWidth, panelTop + panelHeight) val guideBackground = Paint(Paint.ANTI_ALIAS_FLAG).apply { val color = this@FlickPopupStylePreviewView.style.backgroundColor - ?: Color.rgb(238, 240, 242) + ?: tfbiGuideBackgroundColor + ?: defaultTfbiGuideBackgroundColor shader = LinearGradient( 0f, panel.top, diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt index 47c3e815b..bbca93865 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt @@ -32,6 +32,9 @@ internal class TfbiGuidePopupHost( private var guideWidth = 1 private var guideHeight = 1 private var currentGapPx = 1 + private var configuredBackgroundColor: Int? = null + private var configuredHighlightedColor: Int? = null + private var configuredTextColor: Int? = null fun show( anchor: View, @@ -46,7 +49,7 @@ internal class TfbiGuidePopupHost( val arrow = arrowView ?: TfbiGestureArrowView(context).also { arrowView = it } panel.setInputTextTransform(inputTextTransform) panel.applyPopupViewStyle(style) - arrow.setColor(0xff1976d2.toInt()) + applyConfiguredColors(panel, arrow) panel.setState(state) arrow.setDirection(direction) @@ -81,7 +84,10 @@ internal class TfbiGuidePopupHost( } fun applyPopupViewStyle(style: PopupViewStyle) { - guideView?.applyPopupViewStyle(style) + guideView?.let { panel -> + panel.applyPopupViewStyle(style) + applyConfiguredColors(panel, arrowView) + } } fun setColors( @@ -89,8 +95,10 @@ internal class TfbiGuidePopupHost( highlightedColor: Int, textColor: Int ) { - guideView?.setColors(backgroundColor, highlightedColor, textColor) - arrowView?.setColor(highlightedColor) + configuredBackgroundColor = backgroundColor + configuredHighlightedColor = highlightedColor + configuredTextColor = textColor + applyConfiguredColors(guideView, arrowView) } fun dismiss() { @@ -129,4 +137,21 @@ internal class TfbiGuidePopupHost( private fun dp(value: Float): Int = (value * context.resources.displayMetrics.density).roundToInt().coerceAtLeast(1) + + private fun applyConfiguredColors( + panel: TfbiGuidePopupView?, + arrow: TfbiGestureArrowView? + ) { + val backgroundColor = configuredBackgroundColor + val highlightedColor = configuredHighlightedColor + val textColor = configuredTextColor + if (panel != null && backgroundColor != null && highlightedColor != null && textColor != null) { + panel.setColors(backgroundColor, highlightedColor, textColor) + } + arrow?.setColor(highlightedColor ?: DEFAULT_ARROW_COLOR) + } + + private companion object { + const val DEFAULT_ARROW_COLOR: Int = 0xff1976d2.toInt() + } } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt index ca006b882..979aab8ee 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt @@ -694,6 +694,7 @@ class TfbiHierarchicalFlickController( private fun setupStageUI(map: Map) { // 中央に表示する文字 val tapCharacter = nodeDisplayText(map[TfbiFlickDirection.TAP]) + val stageBaseCharacter = stageBaseCharacter(map, tapCharacter) // 周囲に表示する文字 val petalChars = map @@ -703,9 +704,9 @@ class TfbiHierarchicalFlickController( popupView?.setCharacters(tapCharacter, petalChars) if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { val currentText = if (guideStageJustOpened) { - tapCharacter + stageBaseCharacter } else { - nodeDisplayText(map[currentHighlight]).ifEmpty { tapCharacter } + nodeDisplayText(map[currentHighlight]).ifEmpty { stageBaseCharacter } } val currentSlot = if (mapStack.size > 1) guideRootDirection else currentHighlight guidePopupHost.update( @@ -713,7 +714,7 @@ class TfbiHierarchicalFlickController( currentText = currentText, currentSlot = currentSlot, optionLabels = petalChars.mapValues { (_, output) -> - guideOptionLabel(tapCharacter, output) + guideOptionLabel(stageBaseCharacter, output) }, selectedOption = if (guideStageJustOpened) null else guideSelectedOption ), @@ -729,9 +730,10 @@ class TfbiHierarchicalFlickController( ) { if (popupPresentationMode != TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) return val tapCharacter = nodeDisplayText(map[TfbiFlickDirection.TAP]) + val stageBaseCharacter = stageBaseCharacter(map, tapCharacter) val optionLabels = map .filterKeys { it != TfbiFlickDirection.TAP } - .mapValues { (_, node) -> guideOptionLabel(tapCharacter, nodeDisplayText(node)) } + .mapValues { (_, node) -> guideOptionLabel(stageBaseCharacter, nodeDisplayText(node)) } guidePopupHost.update( state = TfbiGuidePopupState( currentText = currentText, @@ -743,6 +745,20 @@ class TfbiHierarchicalFlickController( ) } + /** + * Some hierarchical stages intentionally omit TAP because the entry direction itself is + * the current character (for example な -> に -> にゅ). The guide still needs a base label + * for that stage; otherwise an empty TAP node makes the active label disappear. + */ + private fun stageBaseCharacter( + map: Map, + tapCharacter: String + ): String { + if (tapCharacter.isNotEmpty()) return tapCharacter + val entryDirection = highlightStack.peek() ?: currentHighlight + return nodeDisplayText(map[entryDirection]) + } + private fun nodeDisplayText(node: TfbiFlickNode?): String { return when (node) { is TfbiFlickNode.Input -> node.char diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt index 5ff39e532..ea0d9612e 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt @@ -9,8 +9,11 @@ import android.graphics.RectF import android.graphics.Shader import android.util.TypedValue import android.view.View +import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.domain.extensions.getThemeColor +import com.kazumaproject.core.domain.extensions.isDarkThemeOn import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState /** @@ -22,6 +25,16 @@ import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState */ class TfbiGuidePopupView(context: Context) : View(context) { + private val defaultPanelColor = if (context.isDarkThemeOn()) { + context.getThemeColor(com.google.android.material.R.attr.colorSurfaceContainerHighest) + } else { + context.getThemeColor(com.google.android.material.R.attr.colorSurface) + } + private val defaultTextColor = ContextCompat.getColor( + context, + com.kazumaproject.core.R.color.keyboard_icon_color + ) + private val panelPaint = Paint(Paint.ANTI_ALIAS_FLAG) private val activePaint = Paint(Paint.ANTI_ALIAS_FLAG) private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { @@ -41,9 +54,12 @@ class TfbiGuidePopupView(context: Context) : View(context) { private var state = TfbiGuidePopupState("。", TfbiFlickDirection.TAP) private var popupStyle = PopupViewStyle(100, 20f) + private var configuredBackgroundColor: Int? = null + private var configuredHighlightedColor: Int? = null + private var configuredTextColor: Int? = null private var popupBackgroundColor: Int? = null private var activeColor: Int = DEFAULT_ACTIVE_COLOR - private var popupTextColor: Int = DEFAULT_TEXT_COLOR + private var popupTextColor: Int = defaultTextColor private var activeTextColor: Int = Color.WHITE private var inputTextTransform: (String) -> String = { it } @@ -70,9 +86,9 @@ class TfbiGuidePopupView(context: Context) : View(context) { backgroundColor = style.backgroundColor, textColor = style.textColor ) - popupBackgroundColor = style.backgroundColor - activeColor = DEFAULT_ACTIVE_COLOR - popupTextColor = style.textColor ?: DEFAULT_TEXT_COLOR + popupBackgroundColor = style.backgroundColor ?: configuredBackgroundColor + activeColor = configuredHighlightedColor ?: DEFAULT_ACTIVE_COLOR + popupTextColor = style.textColor ?: configuredTextColor ?: defaultTextColor activeTextColor = readableForeground(activeColor) invalidate() } @@ -82,9 +98,12 @@ class TfbiGuidePopupView(context: Context) : View(context) { highlightedBackgroundColor: Int, textColor: Int ) { - popupBackgroundColor = backgroundColor + configuredBackgroundColor = backgroundColor + configuredHighlightedColor = highlightedBackgroundColor + configuredTextColor = textColor + popupBackgroundColor = popupStyle.backgroundColor ?: backgroundColor activeColor = highlightedBackgroundColor - popupTextColor = textColor + popupTextColor = popupStyle.textColor ?: textColor activeTextColor = readableForeground(activeColor) invalidate() } @@ -94,7 +113,7 @@ class TfbiGuidePopupView(context: Context) : View(context) { val inset = dp(1f) val panel = RectF(inset, inset, width - inset, height - inset) - val panelColor = popupBackgroundColor ?: DEFAULT_BACKGROUND_COLOR + val panelColor = popupBackgroundColor ?: defaultPanelColor panelPaint.shader = LinearGradient( 0f, panel.top, @@ -233,8 +252,6 @@ class TfbiGuidePopupView(context: Context) : View(context) { private companion object { const val DEFAULT_ACTIVE_COLOR: Int = 0xff1976d2.toInt() - const val DEFAULT_TEXT_COLOR: Int = 0xff202124.toInt() - const val DEFAULT_BACKGROUND_COLOR: Int = 0xffeef0f2.toInt() const val MAIN_TEXT_SCALE: Float = 1.20f const val OPTION_TEXT_SCALE: Float = 0.68f const val MAIN_MIN_TEXT_SIZE_SP: Float = 12f diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt new file mode 100644 index 000000000..426b87cb0 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt @@ -0,0 +1,280 @@ +package com.kazumaproject.custom_keyboard.view + +import android.app.Activity +import android.view.ContextThemeWrapper +import android.view.MotionEvent +import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode +import com.kazumaproject.custom_keyboard.data.KeyboardInputMode +import com.kazumaproject.custom_keyboard.data.KeyType +import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState +import com.kazumaproject.custom_keyboard.controller.TfbiHierarchicalFlickController +import com.kazumaproject.custom_keyboard.layout.KeyboardDefaultLayouts +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.robolectric.Robolectric +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class FlickKeyboardViewTfbiAttachTest { + + @Test + fun secondFlickAttachesControllerToNaKey() { + val layout = createLayout("second-flick") + val keyboard = createView() + + keyboard.setKeyboard(layout) + + assertNotNull(layout.twoStepFlickKeyMaps["な"]) + assertEquals(11, controllersOf(keyboard).size) + assertNotNull(keyboard.findControllerForLabel("な", TfbiInputController::class.java)) + } + + @Test + fun thirdFlickAttachesControllerToNaKey() { + val layout = createLayout("third-flick") + val keyboard = createView() + + keyboard.setKeyboard(layout) + + assertNotNull(layout.hierarchicalFlickMaps["な"]) + assertEquals(11, controllersOf(keyboard).size) + assertNotNull( + keyboard.findControllerForLabel("な", TfbiHierarchicalFlickController::class.java) + ) + } + + @Test + fun secondFlickGuideShowsNaOnTouchDown() { + val state = touchDownState("second-flick", TfbiInputController::class.java) + + assertEquals("な", state.currentText) + } + + @Test + fun thirdFlickGuideShowsNaOnTouchDown() { + val state = touchDownState("third-flick", TfbiHierarchicalFlickController::class.java) + + assertEquals("な", state.currentText) + } + + @Test + fun thirdFlickGuideShowsNaStageCharacterOnLeftFlick() { + val state = touchStateAfterLeftFlick( + "third-flick", + TfbiHierarchicalFlickController::class.java + ) + + assertEquals("に", state.currentText) + } + + @Test + fun allBuiltInKanaLayoutVariantsAttachNaController() { + listOf("toggle", "flick", "switch-mode-effective").forEach { layoutType -> + val secondLayout = KeyboardDefaultLayouts.createFinalLayout( + mode = KeyboardInputMode.HIRAGANA, + dynamicKeyStates = emptyMap(), + inputLayoutType = layoutType, + inputStyle = "second-flick" + ) + val thirdLayout = KeyboardDefaultLayouts.createFinalLayout( + mode = KeyboardInputMode.HIRAGANA, + dynamicKeyStates = emptyMap(), + inputLayoutType = layoutType, + inputStyle = "third-flick" + ) + + assertNotNull("$layoutType second-flick na map", secondLayout.twoStepFlickKeyMaps["な"]) + assertNotNull("$layoutType third-flick na map", thirdLayout.hierarchicalFlickMaps["な"]) + assertEquals( + "$layoutType second-flick key type", + KeyType.TWO_STEP_FLICK, + secondLayout.keys.first { it.label == "な" }.keyType + ) + assertEquals( + "$layoutType third-flick key type", + KeyType.HIERARCHICAL_FLICK, + thirdLayout.keys.first { it.label == "な" }.keyType + ) + } + } + + private fun createView(): FlickKeyboardView { + val context = ApplicationProvider.getApplicationContext() + return FlickKeyboardView( + ContextThemeWrapper( + context, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + ) + } + + private fun touchDownState( + inputStyle: String, + controllerClass: Class<*> + ): TfbiGuidePopupState { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val keyboard = FlickKeyboardView( + ContextThemeWrapper( + activity, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + ).apply { + setTfbiPopupPresentationMode(TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) + setKeyboard(createLayout(inputStyle)) + } + activity.setContentView(keyboard) + keyboard.measure(exactly(1080), exactly(1000)) + keyboard.layout(0, 0, 1080, 1000) + + val naButton = (0 until keyboard.childCount) + .map { keyboard.getChildAt(it) } + .filterIsInstance() + .first { it.text.toString() == "な" } + val down = MotionEvent.obtain( + 0L, + 0L, + MotionEvent.ACTION_DOWN, + naButton.width / 2f, + naButton.height / 2f, + 0 + ) + try { + assertNotNull(naButton.dispatchTouchEvent(down)) + } finally { + down.recycle() + } + + val controller = findControllerForLabel("な", controllerClass, keyboard) + val host = controller!!::class.java.getDeclaredMethod("getGuidePopupHost").apply { + isAccessible = true + }.invoke(controller) + val guideView = host!!::class.java.getDeclaredField("guideView").apply { + isAccessible = true + }.get(host) + val state = guideView!!::class.java.getDeclaredField("state").apply { + isAccessible = true + }.get(guideView) + return state as TfbiGuidePopupState + } + + private fun touchStateAfterLeftFlick( + inputStyle: String, + controllerClass: Class<*> + ): TfbiGuidePopupState { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val keyboard = FlickKeyboardView( + ContextThemeWrapper( + activity, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + ).apply { + setTfbiPopupPresentationMode(TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) + setKeyboard(createLayout(inputStyle)) + } + activity.setContentView(keyboard) + keyboard.measure(exactly(1080), exactly(1000)) + keyboard.layout(0, 0, 1080, 1000) + + val naButton = (0 until keyboard.childCount) + .map { keyboard.getChildAt(it) } + .filterIsInstance() + .first { it.text.toString() == "な" } + val centerX = naButton.width / 2f + val centerY = naButton.height / 2f + val down = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, centerX, centerY, 0) + val move = MotionEvent.obtain(0L, 10L, MotionEvent.ACTION_MOVE, 0f, centerY, 0) + try { + assertNotNull(naButton.dispatchTouchEvent(down)) + assertNotNull(naButton.dispatchTouchEvent(move)) + } finally { + down.recycle() + move.recycle() + } + + val controller = findControllerForLabel("な", controllerClass, keyboard) + val host = controller!!::class.java.getDeclaredMethod("getGuidePopupHost").apply { + isAccessible = true + }.invoke(controller) + val guideView = host!!::class.java.getDeclaredField("guideView").apply { + isAccessible = true + }.get(host) + val state = guideView!!::class.java.getDeclaredField("state").apply { + isAccessible = true + }.get(guideView) + return state as TfbiGuidePopupState + } + + private fun exactly(size: Int): Int = + android.view.View.MeasureSpec.makeMeasureSpec(size, android.view.View.MeasureSpec.EXACTLY) + + private fun createLayout(inputStyle: String) = KeyboardDefaultLayouts.createFinalLayout( + mode = KeyboardInputMode.HIRAGANA, + dynamicKeyStates = emptyMap(), + inputLayoutType = "flick", + inputStyle = inputStyle + ) + + @Suppress("UNCHECKED_CAST") + private inline fun controllersOf(keyboard: FlickKeyboardView): List { + val field = FlickKeyboardView::class.java.getDeclaredField( + when (T::class) { + TfbiInputController::class -> "tfbiControllers" + TfbiHierarchicalFlickController::class -> "hierarchicalTfbiControllers" + else -> error("Unsupported controller type") + } + ).apply { isAccessible = true } + return field.get(keyboard) as List + } + + private fun FlickKeyboardView.findControllerForLabel( + label: String, + controllerClass: Class + ): T? { + val keyInfos = FlickKeyboardView::class.java.getDeclaredField("keyInfos").apply { + isAccessible = true + }.get(this) as List<*> + return keyInfos.firstOrNull { info -> + val keyData = info!!::class.java.getDeclaredField("keyData").apply { + isAccessible = true + }.get(info) + val dataLabel = keyData!!::class.java.getDeclaredField("label").apply { + isAccessible = true + }.get(keyData) as String + dataLabel == label + }?.let { info -> + val controller = info!!::class.java.getDeclaredField("controller").apply { + isAccessible = true + }.get(info) + controller?.takeIf(controllerClass::isInstance) as T? + } + } + + private fun findControllerForLabel( + label: String, + controllerClass: Class<*>, + keyboard: FlickKeyboardView + ): Any? { + val keyInfos = FlickKeyboardView::class.java.getDeclaredField("keyInfos").apply { + isAccessible = true + }.get(keyboard) as List<*> + return keyInfos.firstOrNull { info -> + val keyData = info!!::class.java.getDeclaredField("keyData").apply { + isAccessible = true + }.get(info) + val dataLabel = keyData!!::class.java.getDeclaredField("label").apply { + isAccessible = true + }.get(keyData) as String + dataLabel == label + }?.let { info -> + val controller = info!!::class.java.getDeclaredField("controller").apply { + isAccessible = true + }.get(info) + controller?.takeIf(controllerClass::isInstance) + } + } +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupViewTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupViewTest.kt new file mode 100644 index 000000000..18f461d2f --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupViewTest.kt @@ -0,0 +1,68 @@ +package com.kazumaproject.custom_keyboard.view + +import android.graphics.Color +import android.view.ContextThemeWrapper +import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.core.data.popup.PopupViewStyle +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class TfbiGuidePopupViewTest { + + @Test + fun popupStyleBackgroundAndTextOverrideKeyboardThemeColors() { + val view = createView() + + view.setColors( + backgroundColor = Color.RED, + highlightedBackgroundColor = Color.BLUE, + textColor = Color.WHITE + ) + view.applyPopupViewStyle( + PopupViewStyle( + sizeScalePercent = 100, + textSizeSp = 20f, + backgroundColor = Color.GREEN, + textColor = Color.BLACK + ) + ) + + assertEquals(Color.GREEN, privateColor(view, "popupBackgroundColor")) + assertEquals(Color.BLUE, privateColor(view, "activeColor")) + assertEquals(Color.BLACK, privateColor(view, "popupTextColor")) + } + + @Test + fun keyboardThemeColorsAreUsedWhenPopupStyleDoesNotSpecifyColors() { + val view = createView() + + view.setColors( + backgroundColor = Color.RED, + highlightedBackgroundColor = Color.BLUE, + textColor = Color.WHITE + ) + view.applyPopupViewStyle(PopupViewStyle(100, 20f)) + + assertEquals(Color.RED, privateColor(view, "popupBackgroundColor")) + assertEquals(Color.BLUE, privateColor(view, "activeColor")) + assertEquals(Color.WHITE, privateColor(view, "popupTextColor")) + } + + private fun createView(): TfbiGuidePopupView = TfbiGuidePopupView( + ContextThemeWrapper( + ApplicationProvider.getApplicationContext(), + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + ) + + private fun privateColor(view: TfbiGuidePopupView, name: String): Int { + return TfbiGuidePopupView::class.java.getDeclaredField(name).apply { + isAccessible = true + }.get(view) as Int + } +} From 212953e7b24aefd5fd07d3994f50d557cafec635 Mon Sep 17 00:00:00 2001 From: Codex Mozc Autonomy Date: Tue, 11 Aug 2026 19:14:58 -0400 Subject: [PATCH 04/11] show current figer position --- .../controller/TfbiGuidePopupHost.kt | 33 ++++++++++++++++++- .../TfbiHierarchicalFlickController.kt | 15 ++++++++- .../controller/TfbiInputController.kt | 18 +++++++++- .../controller/TfbiStickyFlickController.kt | 15 ++++++++- .../data/TfbiGuidePopupState.kt | 14 +++++++- .../view/TfbiGuidePopupView.kt | 28 ++++++++++++++++ .../view/FlickKeyboardViewTfbiAttachTest.kt | 23 +++++++++++++ 7 files changed, 141 insertions(+), 5 deletions(-) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt index bbca93865..6cc29eb13 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt @@ -1,8 +1,10 @@ package com.kazumaproject.custom_keyboard.controller import android.content.Context +import android.view.MotionEvent import android.view.View import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection import com.kazumaproject.custom_keyboard.view.TfbiGestureArrowView @@ -32,6 +34,7 @@ internal class TfbiGuidePopupHost( private var guideWidth = 1 private var guideHeight = 1 private var currentGapPx = 1 + private var currentState: TfbiGuidePopupState? = null private var configuredBackgroundColor: Int? = null private var configuredHighlightedColor: Int? = null private var configuredTextColor: Int? = null @@ -50,6 +53,7 @@ internal class TfbiGuidePopupHost( panel.setInputTextTransform(inputTextTransform) panel.applyPopupViewStyle(style) applyConfiguredColors(panel, arrow) + currentState = state panel.setState(state) arrow.setDirection(direction) @@ -63,6 +67,7 @@ internal class TfbiGuidePopupHost( val host = preferredHostProvider() if (!overlay.show(anchor, host, panel, guideWidth, guideHeight)) { anchorView = null + currentState = null return } // Add the arrow after the panel so it is drawn above the key and the panel edge. @@ -74,11 +79,24 @@ internal class TfbiGuidePopupHost( state: TfbiGuidePopupState, direction: TfbiFlickDirection? ) { - guideView?.setState(state) + val resolvedState = state.copy( + fingerPosition = state.fingerPosition ?: currentState?.fingerPosition + ) + currentState = resolvedState + guideView?.setState(resolvedState) arrowView?.setDirection(direction) positionVisiblePopups() } + /** Updates the cursor without rebuilding the labels or the current selection. */ + fun updateFingerPosition(position: TfbiGuideFingerPosition?) { + val state = currentState ?: return + val updatedState = state.copy(fingerPosition = position) + currentState = updatedState + guideView?.setState(updatedState) + positionVisiblePopups() + } + fun setInputTextTransform(transform: (String) -> String) { guideView?.setInputTextTransform(transform) } @@ -104,6 +122,7 @@ internal class TfbiGuidePopupHost( fun dismiss() { overlay.dismissAll() anchorView = null + currentState = null } private fun positionVisiblePopups() { @@ -155,3 +174,15 @@ internal class TfbiGuidePopupHost( const val DEFAULT_ARROW_COLOR: Int = 0xff1976d2.toInt() } } + +/** Converts a key-local touch coordinate into the guide panel's normalized coordinate space. */ +internal fun resolveTfbiGuideFingerPosition( + anchor: View, + event: MotionEvent +): TfbiGuideFingerPosition? { + if (anchor.width <= 0 || anchor.height <= 0) return null + return TfbiGuideFingerPosition( + x = (event.x / anchor.width.toFloat()).coerceIn(0f, 1f), + y = (event.y / anchor.height.toFloat()).coerceIn(0f, 1f) + ) +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt index 979aab8ee..7bb4e324f 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt @@ -18,6 +18,7 @@ import com.kazumaproject.core.domain.flick.GestureSessionConfigSource import com.kazumaproject.custom_keyboard.data.KeyMode import com.kazumaproject.custom_keyboard.data.ModeSwitchBoundary import com.kazumaproject.custom_keyboard.data.TfbiFlickNode +import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection import com.kazumaproject.custom_keyboard.view.TfbiFlickPopupView @@ -103,6 +104,7 @@ class TfbiHierarchicalFlickController( private var currentHighlight: TfbiFlickDirection = TfbiFlickDirection.TAP private var isJitterGuardActive = false private var activeGestureConfig: GestureSessionConfig? = null + private var currentFingerPosition: TfbiGuideFingerPosition? = null // ポップアップView関連 private var popupView: TfbiFlickPopupView? = null @@ -246,6 +248,7 @@ class TfbiHierarchicalFlickController( centerStack.push(event.x to event.y) mapStack.push(rMap) highlightStack.push(TfbiFlickDirection.TAP) + currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) currentHighlight = TfbiFlickDirection.TAP guideRootDirection = TfbiFlickDirection.TAP guideSelectedOption = null @@ -261,6 +264,7 @@ class TfbiHierarchicalFlickController( } private fun handleTouchMove(event: MotionEvent, view: View) { + updateGuideFingerPosition(view, event) // 現在の中心座標とマップをスタックの先頭から取得 val (centerX, centerY) = centerStack.peek() ?: return val currentM = currentMap ?: return @@ -631,7 +635,8 @@ class TfbiHierarchicalFlickController( state = TfbiGuidePopupState( currentText = tapCharacter, currentSlot = TfbiFlickDirection.TAP, - optionLabels = optionLabels + optionLabels = optionLabels, + fingerPosition = currentFingerPosition ), direction = null, style = popupStyle, @@ -775,6 +780,13 @@ class TfbiHierarchicalFlickController( return output.removePrefix(currentText).ifEmpty { output } } + private fun updateGuideFingerPosition(view: View, event: MotionEvent) { + currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + guidePopupHost.updateFingerPosition(currentFingerPosition) + } + } + private fun resetState() { attachedView?.removeCallbacks(longPressRunnable) popupWindow?.dismiss() @@ -792,6 +804,7 @@ class TfbiHierarchicalFlickController( guideStageJustOpened = false isJitterGuardActive = false activeGestureConfig = null + currentFingerPosition = null if (currentMode != KeyMode.NORMAL) { currentMode = KeyMode.NORMAL diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt index 4a3d00312..336d32b88 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt @@ -15,6 +15,8 @@ import com.kazumaproject.core.domain.flick.FlickGestureMath import com.kazumaproject.core.domain.flick.GestureSessionConfig import com.kazumaproject.core.domain.flick.GestureSessionConfigSource import com.kazumaproject.custom_keyboard.controller.getLocationRelativeToWindowAnchor +import com.kazumaproject.custom_keyboard.controller.resolveTfbiGuideFingerPosition +import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState import com.kazumaproject.custom_keyboard.controller.TfbiGuidePopupHost import kotlin.math.abs @@ -77,6 +79,7 @@ class TfbiInputController( private var intermediateTouchY = 0f private var isLongPressModeActive = false private var activeGestureConfig: GestureSessionConfig? = null + private var currentFingerPosition: TfbiGuideFingerPosition? = null var listener: TfbiListener? = null private var characterMapProvider: ((TfbiFlickDirection, TfbiFlickDirection) -> String)? = null @@ -213,6 +216,7 @@ class TfbiInputController( isTouchActive = true initialTouchX = event.x initialTouchY = event.y + currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) listener?.onPress(TfbiFlickDirection.TAP, TfbiFlickDirection.TAP) showPopup(view, TfbiFlickDirection.TAP, false) @@ -221,6 +225,7 @@ class TfbiInputController( } private fun handleTouchMove(event: MotionEvent, view: View) { + updateGuideFingerPosition(view, event) if (flickState == FlickState.NEUTRAL) { val dx = event.x - initialTouchX val dy = event.y - initialTouchY @@ -251,7 +256,9 @@ class TfbiInputController( if (isLongPressModeActive) { return } + val fingerPosition = currentFingerPosition resetState() + currentFingerPosition = fingerPosition showPopup(view, TfbiFlickDirection.TAP, false) listener?.onSelectionChanged( TfbiFlickDirection.TAP, @@ -372,7 +379,8 @@ class TfbiInputController( state = TfbiGuidePopupState( currentText = currentText, currentSlot = TfbiFlickDirection.TAP, - optionLabels = optionLabels + optionLabels = optionLabels, + fingerPosition = currentFingerPosition ), direction = baseDirection.takeUnless { it == TfbiFlickDirection.TAP && !showPetals }, style = popupStyle, @@ -461,6 +469,13 @@ class TfbiInputController( return output.removePrefix(currentText).ifEmpty { output } } + private fun updateGuideFingerPosition(view: View, event: MotionEvent) { + currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + guidePopupHost.updateFingerPosition(currentFingerPosition) + } + } + private fun characterFor(first: TfbiFlickDirection, second: TfbiFlickDirection): String { val normal = characterMapProvider?.invoke(first, second).orEmpty() val longPress = longPressCharacterMapProvider?.invoke(first, second).orEmpty() @@ -477,6 +492,7 @@ class TfbiInputController( firstFlickDirection = TfbiFlickDirection.TAP currentSecondFlickDirection = TfbiFlickDirection.TAP isLongPressModeActive = false + currentFingerPosition = null } private fun clearLongPressCallback(view: View?) { diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt index f2e192729..41d85a3f5 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt @@ -17,6 +17,7 @@ import com.kazumaproject.core.domain.flick.GestureSessionConfig import com.kazumaproject.core.domain.flick.GestureSessionConfigSource import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection import com.kazumaproject.custom_keyboard.view.TfbiFlickPopupView +import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState import kotlin.math.abs import kotlin.math.atan2 @@ -65,6 +66,7 @@ class TfbiStickyFlickController( private var intermediateTouchX = 0f private var intermediateTouchY = 0f private var activeGestureConfig: GestureSessionConfig? = null + private var currentFingerPosition: TfbiGuideFingerPosition? = null var listener: TfbiListener? = null private var characterMapProvider: ((TfbiFlickDirection, TfbiFlickDirection) -> String)? = null @@ -154,6 +156,7 @@ class TfbiStickyFlickController( flickState = FlickState.NEUTRAL initialTouchX = event.x initialTouchY = event.y + currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) listener?.onPress(TfbiFlickDirection.TAP, TfbiFlickDirection.TAP) // 最初は必ず花びらなしのポップアップを表示する @@ -162,6 +165,7 @@ class TfbiStickyFlickController( } private fun handleTouchMove(event: MotionEvent, view: View) { + updateGuideFingerPosition(view, event) if (flickState == FlickState.NEUTRAL) { val dx = event.x - initialTouchX val dy = event.y - initialTouchY @@ -311,7 +315,8 @@ class TfbiStickyFlickController( state = TfbiGuidePopupState( currentText = currentText, currentSlot = TfbiFlickDirection.TAP, - optionLabels = optionLabels + optionLabels = optionLabels, + fingerPosition = currentFingerPosition ), direction = baseDirection.takeUnless { it == TfbiFlickDirection.TAP && !showPetals }, style = popupStyle, @@ -396,6 +401,13 @@ class TfbiStickyFlickController( return output.removePrefix(currentText).ifEmpty { output } } + private fun updateGuideFingerPosition(view: View, event: MotionEvent) { + currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + guidePopupHost.updateFingerPosition(currentFingerPosition) + } + } + private fun resetState() { attachedView?.removeCallbacks(longPressRunnable) @@ -407,6 +419,7 @@ class TfbiStickyFlickController( firstFlickDirection = TfbiFlickDirection.TAP currentSecondFlickDirection = TfbiFlickDirection.TAP activeGestureConfig = null + currentFingerPosition = null } private fun currentFlickThreshold(): Float { diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuidePopupState.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuidePopupState.kt index 2ad50517d..13d8da15d 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuidePopupState.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuidePopupState.kt @@ -12,5 +12,17 @@ data class TfbiGuidePopupState( val currentText: String, val currentSlot: TfbiFlickDirection, val optionLabels: Map = emptyMap(), - val selectedOption: TfbiFlickDirection? = null + val selectedOption: TfbiFlickDirection? = null, + val fingerPosition: TfbiGuideFingerPosition? = null +) + +/** + * The current touch position expressed as a normalized point in the guide's 3x3 area. + * + * The controllers derive this from the touched key's local coordinates. Keeping it normalized + * lets the guide render at any popup size without coupling the state to Android view dimensions. + */ +data class TfbiGuideFingerPosition( + val x: Float, + val y: Float ) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt index ea0d9612e..343a7d636 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt @@ -14,6 +14,7 @@ import androidx.core.graphics.ColorUtils import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.domain.extensions.getThemeColor import com.kazumaproject.core.domain.extensions.isDarkThemeOn +import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState /** @@ -51,6 +52,12 @@ class TfbiGuidePopupView(context: Context) : View(context) { private val activeTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER } + private val fingerMarkerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + } + private val fingerMarkerOutlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + } private var state = TfbiGuidePopupState("。", TfbiFlickDirection.TAP) private var popupStyle = PopupViewStyle(100, 20f) @@ -150,6 +157,27 @@ class TfbiGuidePopupView(context: Context) : View(context) { drawPlainLabel(canvas, inputTextTransform(label), rect) } } + + state.fingerPosition?.let { drawFingerMarker(canvas, panel, it) } + } + + private fun drawFingerMarker( + canvas: Canvas, + panel: RectF, + position: TfbiGuideFingerPosition + ) { + val x = panel.left + panel.width() * position.x.coerceIn(0f, 1f) + val y = panel.top + panel.height() * position.y.coerceIn(0f, 1f) + val radius = dp(5f).coerceAtMost(minOf(panel.width(), panel.height()) * 0.08f) + + // A contrasting ring keeps the cursor visible over both labels and the active pill. + fingerMarkerOutlinePaint.color = readableForeground(popupBackgroundColor ?: defaultPanelColor) + fingerMarkerOutlinePaint.strokeWidth = dp(2f) + canvas.drawCircle(x, y, radius + dp(2f), fingerMarkerOutlinePaint) + + fingerMarkerPaint.color = activeColor + fingerMarkerPaint.strokeWidth = dp(2f) + canvas.drawCircle(x, y, radius, fingerMarkerPaint) } private fun rectFor( diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt index 426b87cb0..3fa62c42e 100644 --- a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt @@ -53,6 +53,8 @@ class FlickKeyboardViewTfbiAttachTest { val state = touchDownState("second-flick", TfbiInputController::class.java) assertEquals("な", state.currentText) + assertEquals(0.5f, state.fingerPosition?.x ?: -1f, 0.001f) + assertEquals(0.5f, state.fingerPosition?.y ?: -1f, 0.001f) } @Test @@ -60,6 +62,27 @@ class FlickKeyboardViewTfbiAttachTest { val state = touchDownState("third-flick", TfbiHierarchicalFlickController::class.java) assertEquals("な", state.currentText) + assertEquals(0.5f, state.fingerPosition?.x ?: -1f, 0.001f) + assertEquals(0.5f, state.fingerPosition?.y ?: -1f, 0.001f) + } + + @Test + fun secondFlickGuideTracksFingerPosition() { + val state = touchStateAfterLeftFlick("second-flick", TfbiInputController::class.java) + + assertEquals(0f, state.fingerPosition?.x ?: -1f, 0.001f) + assertEquals(0.5f, state.fingerPosition?.y ?: -1f, 0.001f) + } + + @Test + fun thirdFlickGuideTracksFingerPosition() { + val state = touchStateAfterLeftFlick( + "third-flick", + TfbiHierarchicalFlickController::class.java + ) + + assertEquals(0f, state.fingerPosition?.x ?: -1f, 0.001f) + assertEquals(0.5f, state.fingerPosition?.y ?: -1f, 0.001f) } @Test From 491ef1fe76d5ba52affac8427bdbffdc8f3c445f Mon Sep 17 00:00:00 2001 From: Codex Mozc Autonomy Date: Tue, 11 Aug 2026 20:41:27 -0400 Subject: [PATCH 05/11] improve popup view on top --- .../controller/TfbiGuideGestureResolver.kt | 56 ++++++++++ .../TfbiHierarchicalFlickController.kt | 7 ++ .../controller/TfbiInputController.kt | 18 ++- .../controller/TfbiStickyFlickController.kt | 8 ++ .../custom_keyboard/data/TfbiGuideGrid.kt | 33 ++++++ .../view/TfbiGuidePopupView.kt | 22 +--- .../TfbiGuideGestureResolverTest.kt | 74 +++++++++++++ .../TfbiStickyFlickControllerGuideTest.kt | 104 ++++++++++++++++++ .../view/FlickKeyboardViewTfbiAttachTest.kt | 87 +++++++++++++++ 9 files changed, 389 insertions(+), 20 deletions(-) create mode 100644 custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideGestureResolver.kt create mode 100644 custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuideGrid.kt create mode 100644 custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideGestureResolverTest.kt create mode 100644 custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickControllerGuideTest.kt diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideGestureResolver.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideGestureResolver.kt new file mode 100644 index 000000000..ddef6a3c6 --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideGestureResolver.kt @@ -0,0 +1,56 @@ +package com.kazumaproject.custom_keyboard.controller + +import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition +import com.kazumaproject.custom_keyboard.data.TfbiGuideGrid +import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection + +/** + * Resolves the same 3x3 cells that the guide view renders. + * + * The guide is intentionally rectangular: the finger marker is placed from the key-local + * coordinate, so using the same cells for classification keeps the highlighted output and the + * committed output in agreement even when a key is wider than it is tall. + */ +internal fun resolveTfbiGuideGridDirection( + position: TfbiGuideFingerPosition, + enabledDirections: Set +): TfbiFlickDirection { + if (enabledDirections.isEmpty()) return TfbiFlickDirection.TAP + + val x = position.x.coerceIn(0f, 1f) + val y = position.y.coerceIn(0f, 1f) + val cellDirection = TfbiGuideGrid.directionAt(column(x), row(y)) + if (cellDirection == TfbiFlickDirection.TAP) return TfbiFlickDirection.TAP + if (cellDirection in enabledDirections) return cellDirection + + // Some maps omit a diagonal or another cell. Keep the gesture usable by selecting the + // enabled cell whose center is closest to the marker instead of returning an invalid node. + return enabledDirections + .minByOrNull { direction -> + val cell = TfbiGuideGrid.cellOf(direction) + val centerX = (cell.first + 0.5f) / 3f + val centerY = (cell.second + 0.5f) / 3f + val dx = x - centerX + val dy = y - centerY + dx * dx + dy * dy + } + ?: TfbiFlickDirection.TAP +} + +internal fun isTfbiGuideTapCell(position: TfbiGuideFingerPosition): Boolean { + val x = position.x.coerceIn(0f, 1f) + val y = position.y.coerceIn(0f, 1f) + return TfbiGuideGrid.directionAt(column(x), row(y)) == TfbiFlickDirection.TAP +} + +private fun column(value: Float): Int = when { + value < 1f / 3f -> 0 + value < 2f / 3f -> 1 + else -> 2 +} + +private fun row(value: Float): Int = when { + value < 1f / 3f -> 0 + value < 2f / 3f -> 1 + else -> 2 +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt index 7bb4e324f..5cbed018e 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt @@ -408,6 +408,7 @@ class TfbiHierarchicalFlickController( } private fun handleTouchUp(event: MotionEvent) { + attachedView?.let { updateGuideFingerPosition(it, event) } val currentM = currentMap ?: return val finalDirection = currentHighlight val node = currentM[finalDirection] @@ -846,6 +847,12 @@ class TfbiHierarchicalFlickController( return TfbiFlickDirection.TAP } + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + currentFingerPosition?.let { position -> + return resolveTfbiGuideGridDirection(position, enabledDirections) + } + } + val centerAngles = mapOf( TfbiFlickDirection.RIGHT to 0.0, TfbiFlickDirection.DOWN_RIGHT to 35.0, diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt index 336d32b88..c9131be80 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt @@ -14,11 +14,13 @@ import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickGestureMath import com.kazumaproject.core.domain.flick.GestureSessionConfig import com.kazumaproject.core.domain.flick.GestureSessionConfigSource +import com.kazumaproject.custom_keyboard.controller.TfbiGuidePopupHost import com.kazumaproject.custom_keyboard.controller.getLocationRelativeToWindowAnchor +import com.kazumaproject.custom_keyboard.controller.isTfbiGuideTapCell import com.kazumaproject.custom_keyboard.controller.resolveTfbiGuideFingerPosition +import com.kazumaproject.custom_keyboard.controller.resolveTfbiGuideGridDirection import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState -import com.kazumaproject.custom_keyboard.controller.TfbiGuidePopupHost import kotlin.math.abs import kotlin.math.atan2 import kotlin.math.hypot @@ -252,7 +254,12 @@ class TfbiInputController( (event.x - initialTouchX).toDouble(), (event.y - initialTouchY).toDouble() ).toFloat() - if (distanceFromInitial < CANCEL_THRESHOLD) { + val returnedToGuideCenter = popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY && + currentFingerPosition?.let(::isTfbiGuideTapCell) == true + if (returnedToGuideCenter || ( + popupPresentationMode != TfbiPopupPresentationMode.GUIDE_ABOVE_KEY && + distanceFromInitial < CANCEL_THRESHOLD + )) { if (isLongPressModeActive) { return } @@ -294,6 +301,7 @@ class TfbiInputController( private fun handleTouchUp(event: MotionEvent) { isTouchActive = false clearLongPressCallback(attachedView) + attachedView?.let { updateGuideFingerPosition(it, event) } var finalSecondDirection: TfbiFlickDirection if (flickState == FlickState.FIRST_FLICK_DETERMINED) { @@ -547,6 +555,12 @@ class TfbiInputController( return TfbiFlickDirection.TAP } + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + currentFingerPosition?.let { position -> + return resolveTfbiGuideGridDirection(position, enabledDirections) + } + } + val centerAngles = mapOf( TfbiFlickDirection.RIGHT to 0.0, TfbiFlickDirection.DOWN_RIGHT to 35.0, diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt index 41d85a3f5..0040c6c1d 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt @@ -230,6 +230,8 @@ class TfbiStickyFlickController( // (この関数内のロジックは元の TfbiInputController と同じで、変更不要です) // ログのタグだけ "TfbStickyInput" に変更しています。 + attachedView?.let { updateGuideFingerPosition(it, event) } + Log.d( "TfbStickyInput", // ★ ログタグ "handleTouchUp: START. Current state = $flickState" @@ -475,6 +477,12 @@ class TfbiStickyFlickController( return TfbiFlickDirection.TAP } + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + currentFingerPosition?.let { position -> + return resolveTfbiGuideGridDirection(position, enabledDirections) + } + } + val centerAngles = mapOf( TfbiFlickDirection.RIGHT to 0.0, TfbiFlickDirection.DOWN_RIGHT to 35.0, diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuideGrid.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuideGrid.kt new file mode 100644 index 000000000..8bb08cffe --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuideGrid.kt @@ -0,0 +1,33 @@ +package com.kazumaproject.custom_keyboard.data + +import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection + +/** + * The coordinate grid shared by the guide renderer and the guide gesture resolver. + * + * Keeping this mapping in one place is important: the marker is drawn in this 3x3 grid, so + * classification must use exactly the same cells. + */ +internal object TfbiGuideGrid { + val directionLayout = listOf( + listOf(TfbiFlickDirection.UP_LEFT, TfbiFlickDirection.UP, TfbiFlickDirection.UP_RIGHT), + listOf(TfbiFlickDirection.LEFT, TfbiFlickDirection.TAP, TfbiFlickDirection.RIGHT), + listOf(TfbiFlickDirection.DOWN_LEFT, TfbiFlickDirection.DOWN, TfbiFlickDirection.DOWN_RIGHT) + ) + + fun directionAt(column: Int, row: Int): TfbiFlickDirection = + directionLayout[row][column] + + fun cellOf(direction: TfbiFlickDirection): Pair = + directionLayout + .asSequence() + .withIndex() + .flatMap { (row, directions) -> + directions.withIndex().map { (column, value) -> + Triple(row, column, value) + } + } + .firstOrNull { it.third == direction } + ?.let { it.second to it.first } + ?: (1 to 1) +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt index 343a7d636..a2a3eff89 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt @@ -15,6 +15,7 @@ import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.domain.extensions.getThemeColor import com.kazumaproject.core.domain.extensions.isDarkThemeOn import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition +import com.kazumaproject.custom_keyboard.data.TfbiGuideGrid import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState /** @@ -70,12 +71,6 @@ class TfbiGuidePopupView(context: Context) : View(context) { private var activeTextColor: Int = Color.WHITE private var inputTextTransform: (String) -> String = { it } - private val directionLayout = listOf( - listOf(TfbiFlickDirection.UP_LEFT, TfbiFlickDirection.UP, TfbiFlickDirection.UP_RIGHT), - listOf(TfbiFlickDirection.LEFT, TfbiFlickDirection.TAP, TfbiFlickDirection.RIGHT), - listOf(TfbiFlickDirection.DOWN_LEFT, TfbiFlickDirection.DOWN, TfbiFlickDirection.DOWN_RIGHT) - ) - fun setState(state: TfbiGuidePopupState) { this.state = state invalidate() @@ -186,18 +181,9 @@ class TfbiGuidePopupView(context: Context) : View(context) { cellWidth: Float, cellHeight: Float ): RectF { - val position = directionLayout - .asSequence() - .withIndex() - .flatMap { (row, directions) -> - directions.withIndex().map { (column, value) -> - Triple(row, column, value) - } - } - .firstOrNull { it.third == direction } - ?: Triple(1, 1, TfbiFlickDirection.TAP) - val left = panel.left + position.second * cellWidth - val top = panel.top + position.first * cellHeight + val (column, row) = TfbiGuideGrid.cellOf(direction) + val left = panel.left + column * cellWidth + val top = panel.top + row * cellHeight return RectF(left, top, left + cellWidth, top + cellHeight) } diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideGestureResolverTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideGestureResolverTest.kt new file mode 100644 index 000000000..36a6413b8 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideGestureResolverTest.kt @@ -0,0 +1,74 @@ +package com.kazumaproject.custom_keyboard.controller + +import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition +import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection +import org.junit.Assert.assertEquals +import org.junit.Test + +class TfbiGuideGestureResolverTest { + + @Test + fun everyGuideCellResolvesToItsDirection() { + val positions = mapOf( + TfbiFlickDirection.UP_LEFT to TfbiGuideFingerPosition(0.1f, 0.1f), + TfbiFlickDirection.UP to TfbiGuideFingerPosition(0.5f, 0.1f), + TfbiFlickDirection.UP_RIGHT to TfbiGuideFingerPosition(0.9f, 0.1f), + TfbiFlickDirection.LEFT to TfbiGuideFingerPosition(0.1f, 0.5f), + TfbiFlickDirection.TAP to TfbiGuideFingerPosition(0.5f, 0.5f), + TfbiFlickDirection.RIGHT to TfbiGuideFingerPosition(0.9f, 0.5f), + TfbiFlickDirection.DOWN_LEFT to TfbiGuideFingerPosition(0.1f, 0.9f), + TfbiFlickDirection.DOWN to TfbiGuideFingerPosition(0.5f, 0.9f), + TfbiFlickDirection.DOWN_RIGHT to TfbiGuideFingerPosition(0.9f, 0.9f) + ) + + positions.forEach { (direction, position) -> + assertEquals( + direction, + resolveTfbiGuideGridDirection( + position = position, + enabledDirections = TfbiFlickDirection.entries.toSet() + ) + ) + } + } + + @Test + fun positionInBottomCenterCellResolvesToDown() { + // This is the real-device reproduction: the angle-based resolver used to return + // DOWN_LEFT here even though the guide marker is in the bottom-center cell. + val position = TfbiGuideFingerPosition(x = 80f / 212f, y = 110f / 134f) + + assertEquals( + TfbiFlickDirection.DOWN, + resolveTfbiGuideGridDirection( + position = position, + enabledDirections = setOf( + TfbiFlickDirection.DOWN, + TfbiFlickDirection.DOWN_LEFT + ) + ) + ) + } + + @Test + fun positionAtLeftEdgeResolvesToLeft() { + assertEquals( + TfbiFlickDirection.LEFT, + resolveTfbiGuideGridDirection( + position = TfbiGuideFingerPosition(x = 0f, y = 0.5f), + enabledDirections = setOf(TfbiFlickDirection.LEFT) + ) + ) + } + + @Test + fun centerCellRemainsTapWhenTapIsNotAnEnabledMapEntry() { + assertEquals( + TfbiFlickDirection.TAP, + resolveTfbiGuideGridDirection( + position = TfbiGuideFingerPosition(x = 0.5f, y = 0.5f), + enabledDirections = setOf(TfbiFlickDirection.DOWN) + ) + ) + } +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickControllerGuideTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickControllerGuideTest.kt new file mode 100644 index 000000000..f56c94a37 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickControllerGuideTest.kt @@ -0,0 +1,104 @@ +package com.kazumaproject.custom_keyboard.controller + +import android.app.Activity +import android.view.ContextThemeWrapper +import android.view.MotionEvent +import android.widget.Button +import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class TfbiStickyFlickControllerGuideTest { + + @Test + fun guideUsesTheSameGridForStickySecondStage() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val button = Button( + ContextThemeWrapper( + activity, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + ) + activity.setContentView(button) + button.measure(exactly(212), exactly(134)) + button.layout(0, 0, 212, 134) + + var result: Pair? = null + val controller = TfbiStickyFlickController(activity, flickSensitivity = 10f).apply { + setPopupPresentationMode( + com.kazumaproject.core.data.popup.TfbiPopupPresentationMode.GUIDE_ABOVE_KEY + ) + listener = object : TfbiStickyFlickController.TfbiListener { + override fun onPress( + first: TfbiFlickDirection, + second: TfbiFlickDirection + ) = Unit + + override fun onFlick( + first: TfbiFlickDirection, + second: TfbiFlickDirection + ) { + result = first to second + } + } + attach(button) { first, second -> + when (first to second) { + TfbiFlickDirection.LEFT to TfbiFlickDirection.TAP -> "し" + TfbiFlickDirection.LEFT to TfbiFlickDirection.DOWN -> "しょ" + TfbiFlickDirection.LEFT to TfbiFlickDirection.DOWN_LEFT -> "じ" + else -> "" + } + } + } + + val centerX = button.width / 2f + val centerY = button.height / 2f + val bottomCenterX = button.width * 0.65f + val bottomCenterY = button.height.toFloat() + val down = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, centerX, centerY, 0) + val firstMove = MotionEvent.obtain(0L, 10L, MotionEvent.ACTION_MOVE, 0f, centerY, 0) + val secondMove = MotionEvent.obtain( + 0L, + 20L, + MotionEvent.ACTION_MOVE, + bottomCenterX, + bottomCenterY, + 0 + ) + val up = MotionEvent.obtain( + 0L, + 30L, + MotionEvent.ACTION_UP, + bottomCenterX, + bottomCenterY, + 0 + ) + try { + assertNotNull(button.dispatchTouchEvent(down)) + assertNotNull(button.dispatchTouchEvent(firstMove)) + assertNotNull(button.dispatchTouchEvent(secondMove)) + assertNotNull(button.dispatchTouchEvent(up)) + } finally { + down.recycle() + firstMove.recycle() + secondMove.recycle() + up.recycle() + controller.cancel() + } + + assertEquals( + TfbiFlickDirection.LEFT to TfbiFlickDirection.DOWN, + result + ) + } + + private fun exactly(size: Int): Int = + android.view.View.MeasureSpec.makeMeasureSpec(size, android.view.View.MeasureSpec.EXACTLY) +} diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt index 3fa62c42e..5a35a4326 100644 --- a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt @@ -95,6 +95,32 @@ class FlickKeyboardViewTfbiAttachTest { assertEquals("に", state.currentText) } + @Test + fun secondFlickGuideUsesGuideCellForSecondStage() { + val state = touchStateAfterFlickToCell( + "second-flick", + TfbiInputController::class.java, + targetX = 0.1f, + targetY = 1f + ) + + assertEquals("じ", state.currentText) + assertEquals(TfbiFlickDirection.DOWN_LEFT, state.selectedOption) + } + + @Test + fun thirdFlickGuideUsesBottomCenterCellForSecondStage() { + val state = touchStateAfterFlickToCell( + "third-flick", + TfbiHierarchicalFlickController::class.java, + targetX = 0.65f, + targetY = 1f + ) + + assertEquals("しょ", state.currentText) + assertEquals("しょ", state.optionLabels[TfbiFlickDirection.DOWN]) + } + @Test fun allBuiltInKanaLayoutVariantsAttachNaController() { listOf("toggle", "flick", "switch-mode-effective").forEach { layoutType -> @@ -232,6 +258,67 @@ class FlickKeyboardViewTfbiAttachTest { return state as TfbiGuidePopupState } + private fun touchStateAfterFlickToCell( + inputStyle: String, + controllerClass: Class<*>, + targetX: Float, + targetY: Float + ): TfbiGuidePopupState { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val keyboard = FlickKeyboardView( + ContextThemeWrapper( + activity, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + ).apply { + setTfbiPopupPresentationMode(TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) + setKeyboard(createLayout(inputStyle)) + } + activity.setContentView(keyboard) + keyboard.measure(exactly(1080), exactly(1000)) + keyboard.layout(0, 0, 1080, 1000) + + val saButton = (0 until keyboard.childCount) + .map { keyboard.getChildAt(it) } + .filterIsInstance() + .first { it.text.toString() == "さ" } + val centerX = saButton.width / 2f + val centerY = saButton.height / 2f + val targetTouchX = saButton.width * targetX + val targetTouchY = saButton.height * targetY + val down = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, centerX, centerY, 0) + val firstMove = MotionEvent.obtain(0L, 10L, MotionEvent.ACTION_MOVE, 0f, centerY, 0) + val secondMove = MotionEvent.obtain( + 0L, + 20L, + MotionEvent.ACTION_MOVE, + targetTouchX, + targetTouchY, + 0 + ) + try { + assertNotNull(saButton.dispatchTouchEvent(down)) + assertNotNull(saButton.dispatchTouchEvent(firstMove)) + assertNotNull(saButton.dispatchTouchEvent(secondMove)) + } finally { + down.recycle() + firstMove.recycle() + secondMove.recycle() + } + + val controller = findControllerForLabel("さ", controllerClass, keyboard) + val host = controller!!::class.java.getDeclaredMethod("getGuidePopupHost").apply { + isAccessible = true + }.invoke(controller) + val guideView = host!!::class.java.getDeclaredField("guideView").apply { + isAccessible = true + }.get(host) + val state = guideView!!::class.java.getDeclaredField("state").apply { + isAccessible = true + }.get(guideView) + return state as TfbiGuidePopupState + } + private fun exactly(size: Int): Int = android.view.View.MeasureSpec.makeMeasureSpec(size, android.view.View.MeasureSpec.EXACTLY) From 95bf679682fc26b0f351e215e81467713d48f444 Mon Sep 17 00:00:00 2001 From: Codex Mozc Autonomy Date: Tue, 11 Aug 2026 20:42:02 -0400 Subject: [PATCH 06/11] update text --- .../setting_activity/ui/popup_style/PopupStyleFragments.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt index 0f2e6c3c5..adc5b5fc6 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt @@ -406,7 +406,7 @@ private fun Fragment.styleEditorSection( }) modeGroup.addView(RadioButton(context).apply { id = guideId - text = "キー上部ガイド(アルテ/TFBi風)" + text = "キー上部ガイド" }) val initialMode = AppPreference.flick_tfbi_popup_presentation modeGroup.check(if (initialMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) guideId else legacyId) From 1b1a6fb1c33808bce13e3c999831e7406bb69bcc Mon Sep 17 00:00:00 2001 From: Codex Mozc Autonomy Date: Tue, 11 Aug 2026 22:21:29 -0400 Subject: [PATCH 07/11] fix tfbi --- .../TfbiHierarchicalFlickController.kt | 75 +++++++++++++++---- .../data/FlickKeyDefinition.kt | 6 +- .../data/KanaRowFlickDefinitionGenerator.kt | 9 ++- .../custom_keyboard/data/TfbiFlickNode.kt | 8 +- .../KanaRowFlickDefinitionGeneratorTest.kt | 8 ++ 5 files changed, 88 insertions(+), 18 deletions(-) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt index 5cbed018e..db4f5324e 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt @@ -271,7 +271,7 @@ class TfbiHierarchicalFlickController( val dx = event.x - centerX val dy = event.y - centerY - val enabledDirections = currentM.keys + val enabledDirections = effectiveStageMap(currentM).keys // 現在のフリック方向を計算 val direction = calculateDirection( @@ -338,8 +338,15 @@ class TfbiHierarchicalFlickController( // (TAP 以外の方向に動いたので、ジッターガードは解除) isJitterGuardActive = false - // ハイライト対象のノードを取得 - val node = currentM[direction] + // ハイライト対象のノードを取得します。yoon の子階層では、親の同じ段にある + // じゃ/じゅ/じょの兄弟方向を継続候補として扱います。 + var selectionMap = currentM + var node = currentM[direction] + if (node == null && parentContinuationNode(direction) != null) { + popCurrentStageForParentContinuation() + selectionMap = currentMap ?: return + node = selectionMap[direction] + } val depthBeforeSelection = mapStack.size if (depthBeforeSelection == 1) { guideRootDirection = direction @@ -350,7 +357,7 @@ class TfbiHierarchicalFlickController( dx = dx, dy = dy, targetDirection = direction, - currentMap = currentM + currentMap = selectionMap ) ) { updateInternalState(node.triggersMode, event) @@ -366,10 +373,10 @@ class TfbiHierarchicalFlickController( // 終端ノード(文字)の場合:ハイライトを更新 popupView?.highlightDirection(currentHighlight) guideSelectedOption = direction - updateGuideForSelection(node.char, currentM) + updateGuideForSelection(node.char, selectionMap) // 状態更新 - if (isModeSwitchGestureConfident(dx, dy, currentHighlight, currentM)) { + if (isModeSwitchGestureConfident(dx, dy, currentHighlight, selectionMap)) { updateInternalState(node.triggersMode, event) } } @@ -466,6 +473,46 @@ class TfbiHierarchicalFlickController( }?.takeIf(String::isNotEmpty) } + /** + * Returns the explicitly allowed sibling entries from the parent stage. Continuations + * are metadata on the currently open submenu, so unrelated parent directions never leak + * into an arbitrary child stage. + */ + private fun parentContinuationNodes(): Map { + if (mapStack.size <= 1 || highlightStack.isEmpty()) return emptyMap() + + val entryDirection = highlightStack.peek() ?: return emptyMap() + val parentMap = mapStack.elementAt(1) + val sourceNode = parentMap[entryDirection] as? TfbiFlickNode.SubMenu + ?: return emptyMap() + + return sourceNode.parentContinuationDirections + .asSequence() + .filter { it != entryDirection } + .mapNotNull { direction -> + parentMap[direction]?.let { direction to it } + } + .toMap() + } + + private fun effectiveStageMap( + map: Map + ): Map { + val continuations = parentContinuationNodes() + return if (continuations.isEmpty()) map else continuations + map + } + + private fun parentContinuationNode( + direction: TfbiFlickDirection + ): TfbiFlickNode? = parentContinuationNodes()[direction] + + private fun popCurrentStageForParentContinuation() { + if (mapStack.size <= 1) return + mapStack.pop() + highlightStack.pop() + currentMap = mapStack.peek() + } + private fun updateInternalState(newMode: KeyMode?, event: MotionEvent) { // 状態遷移のトリガーがなければ何もしない if (newMode == null) return @@ -698,12 +745,13 @@ class TfbiHierarchicalFlickController( * 第2階層以降のUI(ポップアップの内容)を設定します。 */ private fun setupStageUI(map: Map) { + val displayMap = effectiveStageMap(map) // 中央に表示する文字 - val tapCharacter = nodeDisplayText(map[TfbiFlickDirection.TAP]) - val stageBaseCharacter = stageBaseCharacter(map, tapCharacter) + val tapCharacter = nodeDisplayText(displayMap[TfbiFlickDirection.TAP]) + val stageBaseCharacter = stageBaseCharacter(displayMap, tapCharacter) // 周囲に表示する文字 - val petalChars = map + val petalChars = displayMap .filterKeys { it != TfbiFlickDirection.TAP } .mapValues { (_, node) -> nodeDisplayText(node) } @@ -712,7 +760,7 @@ class TfbiHierarchicalFlickController( val currentText = if (guideStageJustOpened) { stageBaseCharacter } else { - nodeDisplayText(map[currentHighlight]).ifEmpty { stageBaseCharacter } + nodeDisplayText(displayMap[currentHighlight]).ifEmpty { stageBaseCharacter } } val currentSlot = if (mapStack.size > 1) guideRootDirection else currentHighlight guidePopupHost.update( @@ -735,9 +783,10 @@ class TfbiHierarchicalFlickController( map: Map ) { if (popupPresentationMode != TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) return - val tapCharacter = nodeDisplayText(map[TfbiFlickDirection.TAP]) - val stageBaseCharacter = stageBaseCharacter(map, tapCharacter) - val optionLabels = map + val displayMap = effectiveStageMap(map) + val tapCharacter = nodeDisplayText(displayMap[TfbiFlickDirection.TAP]) + val stageBaseCharacter = stageBaseCharacter(displayMap, tapCharacter) + val optionLabels = displayMap .filterKeys { it != TfbiFlickDirection.TAP } .mapValues { (_, node) -> guideOptionLabel(stageBaseCharacter, nodeDisplayText(node)) } guidePopupHost.update( diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/FlickKeyDefinition.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/FlickKeyDefinition.kt index 0048fd66a..679ea862a 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/FlickKeyDefinition.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/FlickKeyDefinition.kt @@ -43,7 +43,8 @@ sealed class FlickNodeDefinition { data class Branch( val label: String? = null, val stage: FlickStageDefinition, - val cancelOnTap: Boolean = true + val cancelOnTap: Boolean = true, + val parentContinuationDirections: Set = emptySet() ) : FlickNodeDefinition() } @@ -101,7 +102,8 @@ object FlickKeyDefinitionTfbiMapper { is FlickNodeDefinition.Branch -> TfbiFlickNode.SubMenu( nextMap = stage.toTfbiMap(), label = label, - cancelOnTap = cancelOnTap + cancelOnTap = cancelOnTap, + parentContinuationDirections = parentContinuationDirections ) } } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KanaRowFlickDefinitionGenerator.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KanaRowFlickDefinitionGenerator.kt index 1d3e85a9f..3fed0d273 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KanaRowFlickDefinitionGenerator.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/KanaRowFlickDefinitionGenerator.kt @@ -341,7 +341,10 @@ object KanaRowFlickDefinitionGenerator { } else { UP } - return branch(label = text) { + return branch( + label = text, + parentContinuationDirections = setOf(UP, RIGHT, DOWN) + ) { add(primaryDirection, input(text)) add(longDirection, input("${text}う")) } @@ -368,12 +371,14 @@ object KanaRowFlickDefinitionGenerator { private fun branch( label: String? = null, cancelOnTap: Boolean = true, + parentContinuationDirections: Set = emptySet(), overrides: KanaRowColumnOverrides = KanaRowColumnOverrides(), block: FlickStageBuilder.() -> Unit ): FlickNodeDefinition.Branch = FlickNodeDefinition.Branch( label = label, stage = stage(block), - cancelOnTap = overrides.cancelOnTap ?: cancelOnTap + cancelOnTap = overrides.cancelOnTap ?: cancelOnTap, + parentContinuationDirections = parentContinuationDirections ) @KanaRowFlickDefinitionDsl diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiFlickNode.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiFlickNode.kt index c8fb40e02..995c277c4 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiFlickNode.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiFlickNode.kt @@ -38,7 +38,13 @@ sealed class TfbiFlickNode { data class SubMenu( val nextMap: Map, val label: String? = null, - val cancelOnTap: Boolean = false + val cancelOnTap: Boolean = false, + /** + * Directions in the parent stage that may be selected continuously while this + * submenu is open. This is intentionally opt-in so an arbitrary submenu cannot + * accidentally expose unrelated parent entries. + */ + val parentContinuationDirections: Set = emptySet() ) : TfbiFlickNode() /** diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KanaRowFlickDefinitionGeneratorTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KanaRowFlickDefinitionGeneratorTest.kt index c910c298c..fb5572ffc 100644 --- a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KanaRowFlickDefinitionGeneratorTest.kt +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/data/KanaRowFlickDefinitionGeneratorTest.kt @@ -39,6 +39,10 @@ class KanaRowFlickDefinitionGeneratorTest { assertEquals("か", statefulKey.label) assertNull(statefulKey.handakutenMap) assertEquals("きょう", normalKyo.input(TfbiFlickDirection.DOWN_LEFT).char) + assertEquals( + setOf(TfbiFlickDirection.UP, TfbiFlickDirection.RIGHT, TfbiFlickDirection.DOWN), + normalKiNode.nextMap.subMenu(TfbiFlickDirection.DOWN).parentContinuationDirections + ) assertEquals("2", normalKo.input(TfbiFlickDirection.LEFT).char) assertFalse(normalKiNode.cancelOnTap) assertEquals("ぎ", gi.char) @@ -49,6 +53,10 @@ class KanaRowFlickDefinitionGeneratorTest { assertEquals("く", kuFromDakuten.char) assertEquals(KeyMode.NORMAL, kuFromDakuten.triggersMode) assertEquals("ぎょう", dakutenGyo.input(TfbiFlickDirection.DOWN_LEFT).char) + assertEquals( + setOf(TfbiFlickDirection.UP, TfbiFlickDirection.RIGHT, TfbiFlickDirection.DOWN), + dakutenGi.subMenu(TfbiFlickDirection.DOWN).parentContinuationDirections + ) assertEquals("ごう", dakutenGo.input(TfbiFlickDirection.RIGHT).char) } From 21175783e1cc129cdd6fe9e2db323a984e8955e7 Mon Sep 17 00:00:00 2001 From: Codex Mozc Autonomy Date: Wed, 12 Aug 2026 23:18:09 -0400 Subject: [PATCH 08/11] improve tfbi --- .../ime_service/IMEService.kt | 5 + .../setting_activity/AppPreference.kt | 19 ++++ .../ui/popup_style/PopupStyleFragments.kt | 33 +++++++ .../ui/setting/SumirePreferenceFragment.kt | 8 ++ app/src/main/res/values-ja/strings.xml | 5 + app/src/main/res/values/strings.xml | 5 + app/src/main/res/xml/pref_sumire.xml | 9 ++ .../data/popup/TfbiFlickStartPositionMode.kt | 22 +++++ core/src/main/res/values-ja/arrays.xml | 10 ++ core/src/main/res/values/arrays.xml | 10 ++ .../popup/TfbiPopupPresentationModeTest.kt | 18 ++++ .../controller/TfbiGuidePopupHost.kt | 61 ++++++++++++- .../TfbiHierarchicalFlickController.kt | 25 ++++- .../controller/TfbiInputController.kt | 24 ++++- .../controller/TfbiStickyFlickController.kt | 24 ++++- .../custom_keyboard/view/FlickKeyboardView.kt | 12 +++ .../controller/TfbiGuideFingerPositionTest.kt | 91 +++++++++++++++++++ 17 files changed, 372 insertions(+), 9 deletions(-) create mode 100644 core/src/main/java/com/kazumaproject/core/data/popup/TfbiFlickStartPositionMode.kt create mode 100644 custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideFingerPositionTest.kt diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index cb028fc0d..e88575211 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt @@ -2457,6 +2457,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, val longPressTimeout = (appPreference.long_press_timeout_preference ?: 300).coerceIn(100, 2000) val tfbiPopupPresentationMode = appPreference.flick_tfbi_popup_presentation + val tfbiFlickStartPositionMode = appPreference.flick_tfbi_flick_start_position flickSensitivityPreferenceValue = sensitivity flickThresholdShapePreferenceValue = thresholdShape @@ -2505,6 +2506,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertyView.setLongPressTimeout(longPressTimeout.toLong()) applyCurrentFlickGuidePreference(customLayoutDefault) customLayoutDefault.setTfbiPopupPresentationMode(tfbiPopupPresentationMode) + customLayoutDefault.setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) } floatingKeyboardBinding?.apply { keyboardViewFloating.setFlickSensitivityValue(sensitivity) @@ -2520,6 +2522,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertyViewFloating.setLongPressTimeout(longPressTimeout.toLong()) applyCurrentFlickGuidePreference(customLayoutFloating) customLayoutFloating.setTfbiPopupPresentationMode(tfbiPopupPresentationMode) + customLayoutFloating.setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) } } @@ -11380,7 +11383,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, flickView.setOnFlickTextPreviewListener(sumireFlickTextPreviewListener) flickView.bindRuntimeGestureSettings(runtimeGestureSettingsSource) val tfbiPopupPresentationMode = appPreference.flick_tfbi_popup_presentation + val tfbiFlickStartPositionMode = appPreference.flick_tfbi_flick_start_position flickView.setTfbiPopupPresentationMode(tfbiPopupPresentationMode) + flickView.setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) if (isFloatingView) { Timber.d("Configuring floating FlickKeyboardView mirror surface") // Floating ON のときだけ、popup の window anchor を IME decorView (or floating root) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt index 8471835bb..ad0447e68 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt @@ -9,6 +9,7 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.kazumaproject.core.data.clicked_symbol.SymbolMode import com.kazumaproject.core.domain.flick.FlickThresholdShape +import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.custom_keyboard.data.CircularFlickDirection import com.kazumaproject.custom_keyboard.data.KeyboardInputMode @@ -69,6 +70,8 @@ object AppPreference { const val FLICK_SENSITIVITY_KEY = "flick_sensitivity_preference" const val FLICK_THRESHOLD_SHAPE_KEY = "flick_threshold_shape_preference" const val FLICK_TFBI_POPUP_PRESENTATION_KEY = "flick_tfbi_popup_presentation_preference" + const val FLICK_TFBI_FLICK_START_POSITION_KEY = + "flick_tfbi_flick_start_position_preference" const val FLICK_EDITOR_PREVIEW_KEY = "flick_editor_preview_preference" const val TENKEY_KEYMAP_GUIDE_JAPANESE_KEY = "tenkey_keymap_guide" const val TENKEY_KEYMAP_GUIDE_ENGLISH_KEY = "tenkey_keymap_guide_english" @@ -528,6 +531,11 @@ object AppPreference { Pair("flick_tfbi_popup_text_size_sp_preference", 20.0f) private val FLICK_TFBI_POPUP_PRESENTATION = Pair(FLICK_TFBI_POPUP_PRESENTATION_KEY, TfbiPopupPresentationMode.LEGACY_GRID.preferenceValue) + private val FLICK_TFBI_FLICK_START_POSITION = + Pair( + FLICK_TFBI_FLICK_START_POSITION_KEY, + TfbiFlickStartPositionMode.TOUCH_POINT.preferenceValue + ) private val CANDIDATE_LETTER_SIZE = Pair("candidate_letter_size_preference", 14.0f) @@ -3095,6 +3103,17 @@ object AppPreference { it.putString(FLICK_TFBI_POPUP_PRESENTATION.first, value.preferenceValue) } + var flick_tfbi_flick_start_position: TfbiFlickStartPositionMode + get() = TfbiFlickStartPositionMode.fromPreference( + preferences.getString( + FLICK_TFBI_FLICK_START_POSITION.first, + FLICK_TFBI_FLICK_START_POSITION.second + ) + ) + set(value) = preferences.edit { + it.putString(FLICK_TFBI_FLICK_START_POSITION.first, value.preferenceValue) + } + var clipboard_preview_preference: Boolean get() = preferences.getBoolean( CLIP_BOARD_PREVIEW_PREFERENCE.first, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt index adc5b5fc6..f49a8f2fb 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt @@ -21,6 +21,7 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.navigation.fragment.findNavController import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.markdownhelperkeyboard.R import com.kazumaproject.markdownhelperkeyboard.setting_activity.AppPreference @@ -421,6 +422,38 @@ private fun Fragment.styleEditorSection( } preview.setTfbiPopupPresentationMode(initialMode) root.addView(modeGroup) + + root.addView(sectionTitle(getString(R.string.tfbi_flick_start_position_title))) + val startPositionGroup = RadioGroup(context).apply { + orientation = RadioGroup.VERTICAL + } + val touchPointId = View.generateViewId() + val keyCenterId = View.generateViewId() + startPositionGroup.addView(RadioButton(context).apply { + id = touchPointId + text = getString(R.string.tfbi_flick_start_position_touch_point) + }) + startPositionGroup.addView(RadioButton(context).apply { + id = keyCenterId + text = getString(R.string.tfbi_flick_start_position_key_center) + }) + val initialStartPosition = AppPreference.flick_tfbi_flick_start_position + startPositionGroup.check( + if (initialStartPosition == TfbiFlickStartPositionMode.KEY_CENTER) { + keyCenterId + } else { + touchPointId + } + ) + startPositionGroup.setOnCheckedChangeListener { _, checkedId -> + AppPreference.flick_tfbi_flick_start_position = + if (checkedId == keyCenterId) { + TfbiFlickStartPositionMode.KEY_CENTER + } else { + TfbiFlickStartPositionMode.TOUCH_POINT + } + } + root.addView(startPositionGroup) } root.addView(sizeLabel.first) root.addView(sizeSeek) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SumirePreferenceFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SumirePreferenceFragment.kt index b3feb3a44..b292ed64e 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SumirePreferenceFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SumirePreferenceFragment.kt @@ -27,6 +27,12 @@ class SumirePreferenceFragment : PreferenceFragmentCompat() { val sumireStylePreference = findPreference("sumire_keyboard_style_preference") + val tfbiFlickStartPositionPreference = + findPreference("flick_tfbi_flick_start_position_preference") + + fun isTfbiStyle(value: String?): Boolean { + return value == "second-flick" || value == "third-flick" + } sumireStylePreference?.apply { // Summaryの初期設定 @@ -36,6 +42,7 @@ class SumirePreferenceFragment : PreferenceFragmentCompat() { // 【追加】初期表示状態の設定 hierarchicalFlickAngleMarginPreference?.isVisible = (value == "third-flick") + tfbiFlickStartPositionPreference?.isVisible = isTfbiStyle(value) sumireCustomAnglePreference?.isVisible = (value == "sumire") circularSlotActionSettingPreference?.isVisible = (value == "sumire") @@ -51,6 +58,7 @@ class SumirePreferenceFragment : PreferenceFragmentCompat() { // 【追加】変更時の表示切り替え hierarchicalFlickAngleMarginPreference?.isVisible = (stringValue == "third-flick") + tfbiFlickStartPositionPreference?.isVisible = isTfbiStyle(stringValue) sumireCustomAnglePreference?.isVisible = (stringValue == "sumire") circularSlotActionSettingPreference?.isVisible = (stringValue == "sumire") diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 3580edabc..e9b8d2e3f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1292,6 +1292,11 @@ QWERTY/ローマ字のキー preview と長押し候補 PopupView のサイズと文字サイズを設定します タップ・フリック PopupView のサイズ 方向別・十字・サークル・2段/3段フリック PopupView のサイズと文字サイズを設定します + TFBiフリック開始位置 + ガイドカーソルを指の位置から始めるか、キー中央から始めるかを設定します + TFBiフリック開始位置を選択 + 指を置いた位置を反映(従来) + キー中央から開始 QWERTY 数字キーの上・下フリックで出力する文字を設定します 数字キーのフリック設定 数字入力欄で使用する diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 976827210..138e0789e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1338,6 +1338,11 @@ Configure the size and text size of QWERTY/Romaji key previews and long-press variation PopupViews. Tap/flick PopupView size Configure size and text size for directional, cross, circle, and 2-step/3-step flick PopupViews. + TFBi flick start position + Choose whether the guide cursor follows the touch point or starts from the key center. + Select TFBi flick start position + Follow finger position (legacy) + Start from key center Number key flick settings Configure the characters output by upward and downward flicks on QWERTY number keys. Use for number input fields diff --git a/app/src/main/res/xml/pref_sumire.xml b/app/src/main/res/xml/pref_sumire.xml index 770d13d53..9ea24d11d 100644 --- a/app/src/main/res/xml/pref_sumire.xml +++ b/app/src/main/res/xml/pref_sumire.xml @@ -17,6 +17,15 @@ android:title="@string/flick_keyboard_popup_view_style_preference_title" android:summary="@string/flick_keyboard_popup_view_style_preference_summary" /> + + sumire + + 指を置いた位置を反映(従来) + キー中央から開始 + + + + touch_point + key_center + + トグル入力 フリック入力 diff --git a/core/src/main/res/values/arrays.xml b/core/src/main/res/values/arrays.xml index bdfbe1413..2984c3b89 100644 --- a/core/src/main/res/values/arrays.xml +++ b/core/src/main/res/values/arrays.xml @@ -42,6 +42,16 @@ sumire + + Follow finger position (legacy) + Start from key center + + + + touch_point + key_center + + Toggle input Flick input diff --git a/core/src/test/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationModeTest.kt b/core/src/test/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationModeTest.kt index 58e840fbe..e4d029fd5 100644 --- a/core/src/test/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationModeTest.kt +++ b/core/src/test/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationModeTest.kt @@ -5,6 +5,24 @@ import org.junit.Test class TfbiPopupPresentationModeTest { + @Test + fun unknownStartPositionPreferenceKeepsTheLegacyTouchPointMode() { + assertEquals( + TfbiFlickStartPositionMode.TOUCH_POINT, + TfbiFlickStartPositionMode.fromPreference("unknown") + ) + } + + @Test + fun keyCenterModeRoundTripsThroughPreferenceValue() { + assertEquals( + TfbiFlickStartPositionMode.KEY_CENTER, + TfbiFlickStartPositionMode.fromPreference( + TfbiFlickStartPositionMode.KEY_CENTER.preferenceValue + ) + ) + } + @Test fun unknownPreferenceKeepsLegacyPresentation() { assertEquals( diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt index 6cc29eb13..938e7d4b9 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt @@ -3,6 +3,7 @@ package com.kazumaproject.custom_keyboard.controller import android.content.Context import android.view.MotionEvent import android.view.View +import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState @@ -180,9 +181,63 @@ internal fun resolveTfbiGuideFingerPosition( anchor: View, event: MotionEvent ): TfbiGuideFingerPosition? { - if (anchor.width <= 0 || anchor.height <= 0) return null + return resolveTfbiGuideFingerPosition( + keyWidth = anchor.width, + keyHeight = anchor.height, + eventX = event.x, + eventY = event.y, + startPositionMode = TfbiFlickStartPositionMode.TOUCH_POINT, + downX = event.x, + downY = event.y + ) +} + +/** + * Converts a key-local touch coordinate into the guide panel's normalized coordinate space. + * + * In KEY_CENTER mode the marker uses a virtual coordinate system. It starts at the key + * center and follows the displacement from the original ACTION_DOWN position. The raw touch + * displacement is still used by the controllers for the flick threshold, so touching an edge + * does not immediately produce a flick. + */ +internal fun resolveTfbiGuideFingerPosition( + anchor: View, + event: MotionEvent, + startPositionMode: TfbiFlickStartPositionMode, + downX: Float, + downY: Float +): TfbiGuideFingerPosition? { + return resolveTfbiGuideFingerPosition( + keyWidth = anchor.width, + keyHeight = anchor.height, + eventX = event.x, + eventY = event.y, + startPositionMode = startPositionMode, + downX = downX, + downY = downY + ) +} + +internal fun resolveTfbiGuideFingerPosition( + keyWidth: Int, + keyHeight: Int, + eventX: Float, + eventY: Float, + startPositionMode: TfbiFlickStartPositionMode, + downX: Float, + downY: Float +): TfbiGuideFingerPosition? { + if (keyWidth <= 0 || keyHeight <= 0) return null + + val (x, y) = when (startPositionMode) { + TfbiFlickStartPositionMode.TOUCH_POINT -> eventX to eventY + TfbiFlickStartPositionMode.KEY_CENTER -> { + keyWidth / 2f + (eventX - downX) to + keyHeight / 2f + (eventY - downY) + } + } return TfbiGuideFingerPosition( - x = (event.x / anchor.width.toFloat()).coerceIn(0f, 1f), - y = (event.y / anchor.height.toFloat()).coerceIn(0f, 1f) + x = (x / keyWidth.toFloat()).coerceIn(0f, 1f), + y = (y / keyHeight.toFloat()).coerceIn(0f, 1f) ) } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt index db4f5324e..34fb8b6cf 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt @@ -9,6 +9,7 @@ import android.view.View import android.view.ViewConfiguration import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable +import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource @@ -105,6 +106,8 @@ class TfbiHierarchicalFlickController( private var isJitterGuardActive = false private var activeGestureConfig: GestureSessionConfig? = null private var currentFingerPosition: TfbiGuideFingerPosition? = null + private var tfbiFlickStartPositionMode = TfbiFlickStartPositionMode.TOUCH_POINT + private var activeTfbiFlickStartPositionMode = TfbiFlickStartPositionMode.TOUCH_POINT // ポップアップView関連 private var popupView: TfbiFlickPopupView? = null @@ -164,6 +167,10 @@ class TfbiHierarchicalFlickController( popupPresentationMode = mode } + fun setTfbiFlickStartPositionMode(mode: TfbiFlickStartPositionMode) { + tfbiFlickStartPositionMode = mode + } + fun setModeSwitchAngleMargin(margin: Double) { modeSwitchAngleMargin = margin.coerceIn(0.0, 34.0) } @@ -248,7 +255,14 @@ class TfbiHierarchicalFlickController( centerStack.push(event.x to event.y) mapStack.push(rMap) highlightStack.push(TfbiFlickDirection.TAP) - currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) + activeTfbiFlickStartPositionMode = tfbiFlickStartPositionMode + currentFingerPosition = resolveTfbiGuideFingerPosition( + anchor = view, + event = event, + startPositionMode = activeTfbiFlickStartPositionMode, + downX = event.x, + downY = event.y + ) currentHighlight = TfbiFlickDirection.TAP guideRootDirection = TfbiFlickDirection.TAP guideSelectedOption = null @@ -831,7 +845,14 @@ class TfbiHierarchicalFlickController( } private fun updateGuideFingerPosition(view: View, event: MotionEvent) { - currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) + val (downX, downY) = centerStack.peek() ?: (event.x to event.y) + currentFingerPosition = resolveTfbiGuideFingerPosition( + anchor = view, + event = event, + startPositionMode = activeTfbiFlickStartPositionMode, + downX = downX, + downY = downY + ) if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { guidePopupHost.updateFingerPosition(currentFingerPosition) } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt index c9131be80..1d136d386 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt @@ -8,6 +8,7 @@ import android.view.View import android.view.ViewConfiguration import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable +import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource @@ -82,6 +83,8 @@ class TfbiInputController( private var isLongPressModeActive = false private var activeGestureConfig: GestureSessionConfig? = null private var currentFingerPosition: TfbiGuideFingerPosition? = null + private var tfbiFlickStartPositionMode = TfbiFlickStartPositionMode.TOUCH_POINT + private var activeTfbiFlickStartPositionMode = TfbiFlickStartPositionMode.TOUCH_POINT var listener: TfbiListener? = null private var characterMapProvider: ((TfbiFlickDirection, TfbiFlickDirection) -> String)? = null @@ -149,6 +152,10 @@ class TfbiInputController( popupPresentationMode = mode } + fun setTfbiFlickStartPositionMode(mode: TfbiFlickStartPositionMode) { + tfbiFlickStartPositionMode = mode + } + fun setPopupWindowAnchorProvider(provider: (() -> View?)?) { popupWindowAnchorProvider = provider } @@ -218,7 +225,14 @@ class TfbiInputController( isTouchActive = true initialTouchX = event.x initialTouchY = event.y - currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) + activeTfbiFlickStartPositionMode = tfbiFlickStartPositionMode + currentFingerPosition = resolveTfbiGuideFingerPosition( + anchor = view, + event = event, + startPositionMode = activeTfbiFlickStartPositionMode, + downX = initialTouchX, + downY = initialTouchY + ) listener?.onPress(TfbiFlickDirection.TAP, TfbiFlickDirection.TAP) showPopup(view, TfbiFlickDirection.TAP, false) @@ -478,7 +492,13 @@ class TfbiInputController( } private fun updateGuideFingerPosition(view: View, event: MotionEvent) { - currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) + currentFingerPosition = resolveTfbiGuideFingerPosition( + anchor = view, + event = event, + startPositionMode = activeTfbiFlickStartPositionMode, + downX = initialTouchX, + downY = initialTouchY + ) if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { guidePopupHost.updateFingerPosition(currentFingerPosition) } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt index 0040c6c1d..c4078327b 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt @@ -9,6 +9,7 @@ import android.view.View import android.view.ViewConfiguration import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable +import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource @@ -67,6 +68,8 @@ class TfbiStickyFlickController( private var intermediateTouchY = 0f private var activeGestureConfig: GestureSessionConfig? = null private var currentFingerPosition: TfbiGuideFingerPosition? = null + private var tfbiFlickStartPositionMode = TfbiFlickStartPositionMode.TOUCH_POINT + private var activeTfbiFlickStartPositionMode = TfbiFlickStartPositionMode.TOUCH_POINT var listener: TfbiListener? = null private var characterMapProvider: ((TfbiFlickDirection, TfbiFlickDirection) -> String)? = null @@ -114,6 +117,10 @@ class TfbiStickyFlickController( popupPresentationMode = mode } + fun setTfbiFlickStartPositionMode(mode: TfbiFlickStartPositionMode) { + tfbiFlickStartPositionMode = mode + } + fun setInputTextTransform(transform: (String) -> String) { inputTextTransform = transform popupView?.setInputTextTransform(transform) @@ -156,7 +163,14 @@ class TfbiStickyFlickController( flickState = FlickState.NEUTRAL initialTouchX = event.x initialTouchY = event.y - currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) + activeTfbiFlickStartPositionMode = tfbiFlickStartPositionMode + currentFingerPosition = resolveTfbiGuideFingerPosition( + anchor = view, + event = event, + startPositionMode = activeTfbiFlickStartPositionMode, + downX = initialTouchX, + downY = initialTouchY + ) listener?.onPress(TfbiFlickDirection.TAP, TfbiFlickDirection.TAP) // 最初は必ず花びらなしのポップアップを表示する @@ -404,7 +418,13 @@ class TfbiStickyFlickController( } private fun updateGuideFingerPosition(view: View, event: MotionEvent) { - currentFingerPosition = resolveTfbiGuideFingerPosition(view, event) + currentFingerPosition = resolveTfbiGuideFingerPosition( + anchor = view, + event = event, + startPositionMode = activeTfbiFlickStartPositionMode, + downX = initialTouchX, + downY = initialTouchY + ) if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { guidePopupHost.updateFingerPosition(currentFingerPosition) } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt index 41a20ce7b..499bccf2f 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt @@ -30,6 +30,7 @@ import androidx.appcompat.widget.AppCompatImageButton import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import com.google.android.material.R +import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.FlickPopupViewStyleSet import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode @@ -138,6 +139,7 @@ class FlickKeyboardView @JvmOverloads constructor( private var popupWindowAnchorProvider: (() -> View?)? = null private var tfbiPopupPresentationMode = TfbiPopupPresentationMode.LEGACY_GRID + private var tfbiFlickStartPositionMode = TfbiFlickStartPositionMode.TOUCH_POINT private val hitRect = Rect() private var flickSensitivity: Int = 100 @@ -295,6 +297,13 @@ class FlickKeyboardView @JvmOverloads constructor( hierarchicalTfbiControllers.forEach { it.setPopupPresentationMode(mode) } } + fun setTfbiFlickStartPositionMode(mode: TfbiFlickStartPositionMode) { + tfbiFlickStartPositionMode = mode + tfbiControllers.forEach { it.setTfbiFlickStartPositionMode(mode) } + stickyTfbiControllers.forEach { it.setTfbiFlickStartPositionMode(mode) } + hierarchicalTfbiControllers.forEach { it.setTfbiFlickStartPositionMode(mode) } + } + fun applyPopupViewStyleSet(styleSet: FlickPopupViewStyleSet) { popupViewStyleSet = FlickPopupViewStyleSet( directional = clampPopupStyle(styleSet.directional), @@ -2222,6 +2231,7 @@ class FlickKeyboardView @JvmOverloads constructor( ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) setPopupPresentationMode(tfbiPopupPresentationMode) + setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.tfbi) this.listener = object : TfbiInputController.TfbiListener { @@ -2391,6 +2401,7 @@ class FlickKeyboardView @JvmOverloads constructor( ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) setPopupPresentationMode(tfbiPopupPresentationMode) + setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.tfbi) this.listener = object : TfbiStickyFlickController.TfbiListener { @@ -2452,6 +2463,7 @@ class FlickKeyboardView @JvmOverloads constructor( ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) setPopupPresentationMode(tfbiPopupPresentationMode) + setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) setInputTextTransform(::transformInputTextForDisplay) setModeSwitchAngleMargin(hierarchicalFlickModeSwitchAngleMargin) applyPopupViewStyle(popupViewStyleSet.tfbi) diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideFingerPositionTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideFingerPositionTest.kt new file mode 100644 index 000000000..e9810055b --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/controller/TfbiGuideFingerPositionTest.kt @@ -0,0 +1,91 @@ +package com.kazumaproject.custom_keyboard.controller + +import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode +import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition +import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection +import org.junit.Assert.assertEquals +import org.junit.Test + +class TfbiGuideFingerPositionTest { + + @Test + fun touchPointModePreservesTheActualKeyLocalPosition() { + assertEquals( + TfbiGuideFingerPosition(x = 0.1f, y = 0.8f), + resolveTfbiGuideFingerPosition( + keyWidth = 200, + keyHeight = 100, + eventX = 20f, + eventY = 80f, + startPositionMode = TfbiFlickStartPositionMode.TOUCH_POINT, + downX = 20f, + downY = 80f + ) + ) + } + + @Test + fun keyCenterModeStartsAtTheCenterRegardlessOfTouchPoint() { + assertEquals( + TfbiGuideFingerPosition(x = 0.5f, y = 0.5f), + resolveTfbiGuideFingerPosition( + keyWidth = 200, + keyHeight = 100, + eventX = 180f, + eventY = 80f, + startPositionMode = TfbiFlickStartPositionMode.KEY_CENTER, + downX = 180f, + downY = 80f + ) + ) + } + + @Test + fun keyCenterModeFollowsDisplacementAndClampsAtTheKeyBounds() { + assertEquals( + TfbiGuideFingerPosition(x = 0.6f, y = 0.2f), + resolveTfbiGuideFingerPosition( + keyWidth = 200, + keyHeight = 100, + eventX = 200f, + eventY = 50f, + startPositionMode = TfbiFlickStartPositionMode.KEY_CENTER, + downX = 180f, + downY = 80f + ) + ) + assertEquals( + TfbiGuideFingerPosition(x = 0f, y = 1f), + resolveTfbiGuideFingerPosition( + keyWidth = 200, + keyHeight = 100, + eventX = -100f, + eventY = 130f, + startPositionMode = TfbiFlickStartPositionMode.KEY_CENTER, + downX = 180f, + downY = 80f + ) + ) + } + + @Test + fun centeredGuidePositionKeepsTheCenterCellAsTapBeforeMovement() { + val position = resolveTfbiGuideFingerPosition( + keyWidth = 200, + keyHeight = 100, + eventX = 180f, + eventY = 80f, + startPositionMode = TfbiFlickStartPositionMode.KEY_CENTER, + downX = 180f, + downY = 80f + ) + + assertEquals( + TfbiFlickDirection.TAP, + resolveTfbiGuideGridDirection( + position = position!!, + enabledDirections = TfbiFlickDirection.entries.toSet() + ) + ) + } +} From 4bbfd501edaeefafa0fc58a8c6f9959568d802cd Mon Sep 17 00:00:00 2001 From: Codex Mozc Autonomy Date: Thu, 13 Aug 2026 18:21:16 -0400 Subject: [PATCH 09/11] improve default --- .../ime_service/IMEService.kt | 3 +- .../IMEServiceTfbiPreferenceContractTest.kt | 32 +++++++++ .../TfbiHierarchicalFlickController.kt | 7 ++ .../view/FlickKeyboardViewTfbiAttachTest.kt | 70 +++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceTfbiPreferenceContractTest.kt diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index e88575211..35c47950d 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt @@ -796,7 +796,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, AppPreference.VIBRATION_TIMING_KEY, AppPreference.KEY_SOUND_KEY, AppPreference.KEY_SOUND_VOLUME_PERCENT_KEY, - AppPreference.FLICK_TFBI_POPUP_PRESENTATION_KEY + AppPreference.FLICK_TFBI_POPUP_PRESENTATION_KEY, + AppPreference.FLICK_TFBI_FLICK_START_POSITION_KEY ) private val runtimeInputPreferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceTfbiPreferenceContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceTfbiPreferenceContractTest.kt new file mode 100644 index 000000000..c83e24dcb --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceTfbiPreferenceContractTest.kt @@ -0,0 +1,32 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class IMEServiceTfbiPreferenceContractTest { + + @Test + fun tfbiStartPositionPreferenceTriggersRuntimeSync() { + val source = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt" + ).readText() + val runtimeKeys = source.substringAfter( + "private val runtimeInputPreferenceKeys = setOf(" + ).substringBefore(")") + + assertTrue( + "TFBi start-position changes must sync already-inflated keyboards", + runtimeKeys.contains("AppPreference.FLICK_TFBI_FLICK_START_POSITION_KEY") + ) + } + + private fun mainFile(relativePath: String): File { + val candidates = listOf( + File("app/src/main/$relativePath"), + File("src/main/$relativePath") + ) + return candidates.firstOrNull(File::exists) + ?: error("Unable to locate $relativePath") + } +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt index 34fb8b6cf..dd4f65f2e 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt @@ -360,6 +360,13 @@ class TfbiHierarchicalFlickController( popCurrentStageForParentContinuation() selectionMap = currentMap ?: return node = selectionMap[direction] + + // Parent-continuation input nodes are selected from the parent stage after the + // current child stage has been popped. Refresh the legacy popup before applying the + // new highlight; otherwise it keeps showing the child-stage labels. + if (popupPresentationMode == TfbiPopupPresentationMode.LEGACY_GRID) { + setupStageUI(selectionMap) + } } val depthBeforeSelection = mapStack.size if (depthBeforeSelection == 1) { diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt index 5a35a4326..6423f63ee 100644 --- a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt @@ -95,6 +95,76 @@ class FlickKeyboardViewTfbiAttachTest { assertEquals("に", state.currentText) } + @Test + fun legacyPopupRefreshesAfterParentContinuationToSiblingInput() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val keyboard = FlickKeyboardView( + ContextThemeWrapper( + activity, + com.google.android.material.R.style.Theme_Material3_DayNight_NoActionBar + ) + ).apply { + setKeyboard(createLayout("third-flick")) + } + activity.setContentView(keyboard) + keyboard.measure(exactly(1080), exactly(1000)) + keyboard.layout(0, 0, 1080, 1000) + + val naButton = (0 until keyboard.childCount) + .map { keyboard.getChildAt(it) } + .filterIsInstance() + .first { it.text.toString() == "な" } + val centerX = naButton.width / 2f + val centerY = naButton.height / 2f + val down = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, centerX, centerY, 0) + val firstMove = MotionEvent.obtain(0L, 10L, MotionEvent.ACTION_MOVE, 0f, centerY, 0) + val yoonMove = MotionEvent.obtain( + 0L, + 20L, + MotionEvent.ACTION_MOVE, + centerX, + naButton.height.toFloat(), + 0 + ) + val siblingMove = MotionEvent.obtain( + 0L, + 30L, + MotionEvent.ACTION_MOVE, + naButton.width.toFloat(), + centerY, + 0 + ) + + val controller = findControllerForLabel( + "な", + TfbiHierarchicalFlickController::class.java, + keyboard + ) as TfbiHierarchicalFlickController + try { + assertNotNull(naButton.dispatchTouchEvent(down)) + assertNotNull(naButton.dispatchTouchEvent(firstMove)) + assertNotNull(naButton.dispatchTouchEvent(yoonMove)) + assertNotNull(naButton.dispatchTouchEvent(siblingMove)) + + val popupView = controller::class.java.getDeclaredField("popupView").apply { + isAccessible = true + }.get(controller) + assertNotNull(popupView) + val petalCharacters = popupView!!::class.java + .getDeclaredField("petalCharacters") + .apply { isAccessible = true } + .get(popupView) as Map<*, *> + + assertEquals("にゃ", petalCharacters[TfbiFlickDirection.RIGHT]) + } finally { + down.recycle() + firstMove.recycle() + yoonMove.recycle() + siblingMove.recycle() + controller.cancel() + } + } + @Test fun secondFlickGuideUsesGuideCellForSecondStage() { val state = touchStateAfterFlickToCell( From 902c532c33c09a0a73392ba5d9b6cb19ac58e657 Mon Sep 17 00:00:00 2001 From: Codex Mozc Autonomy Date: Thu, 13 Aug 2026 18:54:06 -0400 Subject: [PATCH 10/11] update string --- .../setting_activity/ui/popup_style/PopupStyleFragments.kt | 6 +++--- app/src/main/res/values-ja/strings.xml | 3 +++ app/src/main/res/values/strings.xml | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt index f49a8f2fb..97b3b0fdb 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/popup_style/PopupStyleFragments.kt @@ -395,7 +395,7 @@ private fun Fragment.styleEditorSection( AppPreference.key_popup_use_custom_color } ) - root.addView(sectionTitle("TFBiポップアップ表示")) + root.addView(sectionTitle(getString(R.string.tfbi_popup_presentation_title))) val modeGroup = RadioGroup(context).apply { orientation = RadioGroup.VERTICAL } @@ -403,11 +403,11 @@ private fun Fragment.styleEditorSection( val guideId = View.generateViewId() modeGroup.addView(RadioButton(context).apply { id = legacyId - text = "従来の3×3ポップアップ" + text = getString(R.string.tfbi_popup_presentation_legacy) }) modeGroup.addView(RadioButton(context).apply { id = guideId - text = "キー上部ガイド" + text = getString(R.string.tfbi_popup_presentation_guide) }) val initialMode = AppPreference.flick_tfbi_popup_presentation modeGroup.check(if (initialMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) guideId else legacyId) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e9b8d2e3f..5aa512e19 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1297,6 +1297,9 @@ TFBiフリック開始位置を選択 指を置いた位置を反映(従来) キー中央から開始 + TFBiポップアップ表示 + 従来の3×3ポップアップ + キー上部ガイド QWERTY 数字キーの上・下フリックで出力する文字を設定します 数字キーのフリック設定 数字入力欄で使用する diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 138e0789e..477de11f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1343,6 +1343,9 @@ Select TFBi flick start position Follow finger position (legacy) Start from key center + TFBi popup presentation + Legacy 3x3 popup + Guide above key Number key flick settings Configure the characters output by upward and downward flicks on QWERTY number keys. Use for number input fields From cc415f6837aa03e2fab9eca1a4a759992db2fe02 Mon Sep 17 00:00:00 2001 From: Codex Mozc Autonomy Date: Thu, 13 Aug 2026 19:27:57 -0400 Subject: [PATCH 11/11] apply thema --- .../controller/TfbiStickyFlickController.kt | 13 +++ .../custom_keyboard/view/FlickKeyboardView.kt | 10 ++ .../view/FlickKeyboardViewTfbiAttachTest.kt | 99 ++++++++++++++++++- 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt index c4078327b..28fabeff0 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt @@ -79,6 +79,9 @@ class TfbiStickyFlickController( private var popupWindow: PopupWindow? = null private var popupStyle = PopupViewStyle(100, 20f) private var inputTextTransform: (String) -> String = { it } + private var popupBackgroundColor: Int? = null + private var popupHighlightedColor: Int? = null + private var popupTextColor: Int? = null private var popupWindowAnchorProvider: (() -> View?)? = null private var popupPresentationMode = TfbiPopupPresentationMode.LEGACY_GRID @@ -127,6 +130,13 @@ class TfbiStickyFlickController( guidePopupHost.setInputTextTransform(transform) } + fun setPopupColors(backgroundColor: Int, highlightedColor: Int, textColor: Int) { + popupBackgroundColor = backgroundColor + popupHighlightedColor = highlightedColor + popupTextColor = textColor + guidePopupHost.setColors(backgroundColor, highlightedColor, textColor) + } + fun applyPopupViewStyle(style: PopupViewStyle) { popupStyle = PopupViewStyle( sizeScalePercent = style.sizeScalePercent.coerceIn(50, 200), @@ -360,6 +370,9 @@ class TfbiStickyFlickController( popupView = TfbiFlickPopupView(context).apply { setInputTextTransform(inputTextTransform) + if (popupBackgroundColor != null && popupHighlightedColor != null && popupTextColor != null) { + setColors(popupBackgroundColor!!, popupHighlightedColor!!, popupTextColor!!) + } applyPopupViewStyle(popupStyle) setCharacters(tapCharacter, petalChars) highlightDirection(TfbiFlickDirection.TAP) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt index 499bccf2f..06ea2c4d9 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt @@ -2439,6 +2439,16 @@ class FlickKeyboardView @JvmOverloads constructor( ) } + when (themeMode) { + "custom" -> { + controller.setPopupColors( + backgroundColor = customSpecialKeyColor, + highlightedColor = manipulateColor(customSpecialKeyColor, 1.2f), + textColor = customSpecialKeyTextColor + ) + } + } + stickyTfbiControllers.add(controller) return controller } diff --git a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt index 6423f63ee..9dfcb5f65 100644 --- a/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt @@ -1,14 +1,20 @@ package com.kazumaproject.custom_keyboard.view import android.app.Activity +import android.content.res.Configuration +import android.graphics.Color import android.view.ContextThemeWrapper import android.view.MotionEvent import androidx.test.core.app.ApplicationProvider import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode +import com.kazumaproject.custom_keyboard.controller.TfbiHierarchicalFlickController +import com.kazumaproject.custom_keyboard.controller.TfbiStickyFlickController +import com.kazumaproject.custom_keyboard.data.KeyAction +import com.kazumaproject.custom_keyboard.data.KeyData import com.kazumaproject.custom_keyboard.data.KeyboardInputMode +import com.kazumaproject.custom_keyboard.data.KeyboardLayout import com.kazumaproject.custom_keyboard.data.KeyType import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState -import com.kazumaproject.custom_keyboard.controller.TfbiHierarchicalFlickController import com.kazumaproject.custom_keyboard.layout.KeyboardDefaultLayouts import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -48,6 +54,60 @@ class FlickKeyboardViewTfbiAttachTest { ) } + @Test + fun stickyTfbiGuideReceivesCustomThemePopupColors() { + val keyboard = createView().apply { + setTfbiPopupPresentationMode(TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) + applyKeyboardTheme( + themeMode = "custom", + currentNightMode = Configuration.UI_MODE_NIGHT_NO, + isDynamicColorEnabled = false, + customBgColor = Color.WHITE, + customKeyColor = 0xffd0d0d0.toInt(), + customSpecialKeyColor = 0xff204060.toInt(), + customKeyTextColor = Color.BLACK, + customSpecialKeyTextColor = 0xfff0e0d0.toInt(), + liquidGlassEnable = false, + customBorderEnable = false, + customBorderColor = Color.BLACK, + liquidGlassKeyAlphaEnable = 255, + borderWidth = 0 + ) + setKeyboard(createStickyLayout()) + } + + val controller = stickyControllersOf(keyboard).single() + assertEquals( + 0xff204060.toInt(), + privateField(controller, "popupBackgroundColor") + ) + assertEquals( + 0xff264c73.toInt(), + privateField(controller, "popupHighlightedColor") + ) + assertEquals( + 0xfff0e0d0.toInt(), + privateField(controller, "popupTextColor") + ) + + val guideHost = controller::class.java.getDeclaredMethod("getGuidePopupHost") + .apply { isAccessible = true } + .invoke(controller) + ?: error("Sticky TFBi guide host was not initialized") + assertEquals( + 0xff204060.toInt(), + privateField(guideHost, "configuredBackgroundColor") + ) + assertEquals( + 0xff264c73.toInt(), + privateField(guideHost, "configuredHighlightedColor") + ) + assertEquals( + 0xfff0e0d0.toInt(), + privateField(guideHost, "configuredTextColor") + ) + } + @Test fun secondFlickGuideShowsNaOnTouchDown() { val state = touchDownState("second-flick", TfbiInputController::class.java) @@ -399,6 +459,43 @@ class FlickKeyboardViewTfbiAttachTest { inputStyle = inputStyle ) + private fun createStickyLayout(): KeyboardLayout { + val key = KeyData( + label = "sticky", + row = 0, + column = 0, + isFlickable = true, + action = KeyAction.Text("a"), + isSpecialKey = true, + keyId = "sticky", + keyType = KeyType.STICKY_TWO_STEP_FLICK + ) + return KeyboardLayout( + keys = listOf(key), + flickKeyMaps = emptyMap(), + columnCount = 1, + rowCount = 1, + twoStepFlickKeyMaps = mapOf( + "sticky" to mapOf( + TfbiFlickDirection.TAP to mapOf( + TfbiFlickDirection.TAP to "a", + TfbiFlickDirection.RIGHT to "b" + ) + ) + ) + ) + } + + private fun stickyControllersOf(keyboard: FlickKeyboardView): List { + val field = FlickKeyboardView::class.java.getDeclaredField("stickyTfbiControllers") + .apply { isAccessible = true } + @Suppress("UNCHECKED_CAST") + return field.get(keyboard) as List + } + + private fun privateField(instance: Any, name: String): Any? = + instance::class.java.getDeclaredField(name).apply { isAccessible = true }.get(instance) + @Suppress("UNCHECKED_CAST") private inline fun controllersOf(keyboard: FlickKeyboardView): List { val field = FlickKeyboardView::class.java.getDeclaredField(