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..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 @@ -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,9 @@ 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, + AppPreference.FLICK_TFBI_FLICK_START_POSITION_KEY ) private val runtimeInputPreferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> @@ -2454,6 +2457,8 @@ 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 @@ -2501,6 +2506,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertyView.setFlickThresholdShape(thresholdShape) qwertyView.setLongPressTimeout(longPressTimeout.toLong()) applyCurrentFlickGuidePreference(customLayoutDefault) + customLayoutDefault.setTfbiPopupPresentationMode(tfbiPopupPresentationMode) + customLayoutDefault.setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) } floatingKeyboardBinding?.apply { keyboardViewFloating.setFlickSensitivityValue(sensitivity) @@ -2515,6 +2522,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertyViewFloating.setFlickThresholdShape(thresholdShape) qwertyViewFloating.setLongPressTimeout(longPressTimeout.toLong()) applyCurrentFlickGuidePreference(customLayoutFloating) + customLayoutFloating.setTfbiPopupPresentationMode(tfbiPopupPresentationMode) + customLayoutFloating.setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) } } @@ -11374,6 +11383,10 @@ 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 6f99fb5f3..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,8 @@ 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 import com.kazumaproject.custom_keyboard.data.buildEvenCircularRanges @@ -67,6 +69,9 @@ 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_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" @@ -524,6 +529,13 @@ 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 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) @@ -3080,6 +3092,28 @@ 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 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 9d0869cfb..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 @@ -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,8 @@ 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 @@ -385,6 +389,72 @@ private fun Fragment.styleEditorSection( ViewGroup.LayoutParams.WRAP_CONTENT ) 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(getString(R.string.tfbi_popup_presentation_title))) + val modeGroup = RadioGroup(context).apply { + orientation = RadioGroup.VERTICAL + } + val legacyId = View.generateViewId() + val guideId = View.generateViewId() + modeGroup.addView(RadioButton(context).apply { + id = legacyId + text = getString(R.string.tfbi_popup_presentation_legacy) + }) + modeGroup.addView(RadioButton(context).apply { + id = guideId + 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) + 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(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) 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..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 @@ -3,13 +3,18 @@ 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 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, @@ -117,9 +122,22 @@ 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 + 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 @@ -132,6 +150,16 @@ class FlickPopupStylePreviewView @JvmOverloads constructor( invalidate() } + fun setTfbiPopupPresentationMode(mode: TfbiPopupPresentationMode) { + tfbiPresentationMode = mode + invalidate() + } + + fun setTfbiGuideBackgroundColor(color: Int?) { + tfbiGuideBackgroundColor = color + invalidate() + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val desiredHeight = dpToPx(190f).toInt() setMeasuredDimension( @@ -153,7 +181,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 +269,122 @@ 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 { + val color = this@FlickPopupStylePreviewView.style.backgroundColor + ?: tfbiGuideBackgroundColor + ?: defaultTfbiGuideBackgroundColor + 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) + 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, 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), + 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, 1f) + + 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, + 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) { val y = rect.centerY() - (textPaint.descent() + textPaint.ascent()) / 2f canvas.drawText(text, rect.centerX(), y, textPaint) @@ -246,6 +394,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/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..5aa512e19 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1292,6 +1292,14 @@ QWERTY/ローマ字のキー preview と長押し候補 PopupView のサイズと文字サイズを設定します タップ・フリック PopupView のサイズ 方向別・十字・サークル・2段/3段フリック PopupView のサイズと文字サイズを設定します + TFBiフリック開始位置 + ガイドカーソルを指の位置から始めるか、キー中央から始めるかを設定します + TFBiフリック開始位置を選択 + 指を置いた位置を反映(従来) + キー中央から開始 + TFBiポップアップ表示 + 従来の3×3ポップアップ + キー上部ガイド QWERTY 数字キーの上・下フリックで出力する文字を設定します 数字キーのフリック設定 数字入力欄で使用する diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 976827210..477de11f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1338,6 +1338,14 @@ 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 + 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 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 new file mode 100644 index 000000000..e4d029fd5 --- /dev/null +++ b/core/src/test/java/com/kazumaproject/core/data/popup/TfbiPopupPresentationModeTest.kt @@ -0,0 +1,43 @@ +package com.kazumaproject.core.data.popup + +import org.junit.Assert.assertEquals +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( + 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/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/TfbiGuidePopupHost.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt new file mode 100644 index 000000000..938e7d4b9 --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt @@ -0,0 +1,243 @@ +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 +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 + private var currentState: TfbiGuidePopupState? = null + private var configuredBackgroundColor: Int? = null + private var configuredHighlightedColor: Int? = null + private var configuredTextColor: Int? = null + + 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) + applyConfiguredColors(panel, arrow) + currentState = state + 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 + currentState = 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? + ) { + 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) + } + + fun applyPopupViewStyle(style: PopupViewStyle) { + guideView?.let { panel -> + panel.applyPopupViewStyle(style) + applyConfiguredColors(panel, arrowView) + } + } + + fun setColors( + backgroundColor: Int, + highlightedColor: Int, + textColor: Int + ) { + configuredBackgroundColor = backgroundColor + configuredHighlightedColor = highlightedColor + configuredTextColor = textColor + applyConfiguredColors(guideView, arrowView) + } + + fun dismiss() { + overlay.dismissAll() + anchorView = null + currentState = 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) + + 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() + } +} + +/** Converts a key-local touch coordinate into the guide panel's normalized coordinate space. */ +internal fun resolveTfbiGuideFingerPosition( + anchor: View, + event: MotionEvent +): TfbiGuideFingerPosition? { + 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 = (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/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..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 @@ -9,7 +9,9 @@ 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 import com.kazumaproject.core.domain.flick.FlickGestureMath import com.kazumaproject.core.domain.flick.GestureSessionConfig @@ -17,6 +19,8 @@ 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 import java.util.ArrayDeque @@ -101,6 +105,9 @@ class TfbiHierarchicalFlickController( private var currentHighlight: TfbiFlickDirection = TfbiFlickDirection.TAP 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 @@ -109,6 +116,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 +144,7 @@ class TfbiHierarchicalFlickController( this.popupBackgroundColor = backgroundColor this.popupHighlightedColor = highlightedColor this.popupTextColor = textColor + guidePopupHost.setColors(backgroundColor, highlightedColor, textColor) } fun applyPopupViewStyle(style: PopupViewStyle) { @@ -139,6 +155,20 @@ 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 setTfbiFlickStartPositionMode(mode: TfbiFlickStartPositionMode) { + tfbiFlickStartPositionMode = mode } fun setModeSwitchAngleMargin(margin: Double) { @@ -152,6 +182,7 @@ class TfbiHierarchicalFlickController( fun setInputTextTransform(transform: (String) -> String) { inputTextTransform = transform popupView?.setInputTextTransform(transform) + guidePopupHost.setInputTextTransform(transform) } /** @@ -224,7 +255,19 @@ class TfbiHierarchicalFlickController( centerStack.push(event.x to event.y) mapStack.push(rMap) highlightStack.push(TfbiFlickDirection.TAP) + activeTfbiFlickStartPositionMode = tfbiFlickStartPositionMode + currentFingerPosition = resolveTfbiGuideFingerPosition( + anchor = view, + event = event, + startPositionMode = activeTfbiFlickStartPositionMode, + downX = event.x, + downY = event.y + ) 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) @@ -235,13 +278,14 @@ class TfbiHierarchicalFlickController( } private fun handleTouchMove(event: MotionEvent, view: View) { + updateGuideFingerPosition(view, event) // 現在の中心座標とマップをスタックの先頭から取得 val (centerX, centerY) = centerStack.peek() ?: return val currentM = currentMap ?: return val dx = event.x - centerX val dy = event.y - centerY - val enabledDirections = currentM.keys + val enabledDirections = effectiveStageMap(currentM).keys // 現在のフリック方向を計算 val direction = calculateDirection( @@ -279,6 +323,11 @@ class TfbiHierarchicalFlickController( // 親のマップとハイライト状態を復元 currentMap = mapStack.peek() currentHighlight = highlightStack.peek() // 親のハイライト(TAP) + guideSelectedOption = currentHighlight.takeUnless { + it == TfbiFlickDirection.TAP + } + guideArrowDirection = guideSelectedOption + guideStageJustOpened = false // UIを親マップに戻す setupStageUI(currentMap!!) @@ -303,14 +352,33 @@ 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] + + // 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) { + guideRootDirection = direction + } + guideArrowDirection = direction if (direction == currentHighlight) { if (node is TfbiFlickNode.Input && isModeSwitchGestureConfident( dx = dx, dy = dy, targetDirection = direction, - currentMap = currentM + currentMap = selectionMap ) ) { updateInternalState(node.triggersMode, event) @@ -325,9 +393,11 @@ class TfbiHierarchicalFlickController( is TfbiFlickNode.Input -> { // 終端ノード(文字)の場合:ハイライトを更新 popupView?.highlightDirection(currentHighlight) + guideSelectedOption = direction + updateGuideForSelection(node.char, selectionMap) // 状態更新 - if (isModeSwitchGestureConfident(dx, dy, currentHighlight, currentM)) { + if (isModeSwitchGestureConfident(dx, dy, currentHighlight, selectionMap)) { updateInternalState(node.triggersMode, event) } } @@ -344,6 +414,8 @@ class TfbiHierarchicalFlickController( // ハイライトは開いた方向 (currentHighlight) を維持 // ただし、ジッターガードを有効にする isJitterGuardActive = true + guideSelectedOption = null + guideStageJustOpened = true // ポップアップをサブメニューの内容で更新 setupStageUI(currentMap!!) @@ -353,6 +425,7 @@ class TfbiHierarchicalFlickController( null -> { // マップにない無効な方向(主にTAPに戻る途中) popupView?.highlightDirection(currentHighlight) + guideSelectedOption = direction } is TfbiFlickNode.StatefulKey -> { @@ -363,6 +436,7 @@ class TfbiHierarchicalFlickController( } private fun handleTouchUp(event: MotionEvent) { + attachedView?.let { updateGuideFingerPosition(it, event) } val currentM = currentMap ?: return val finalDirection = currentHighlight val node = currentM[finalDirection] @@ -420,6 +494,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 @@ -564,42 +678,56 @@ 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, + fingerPosition = currentFingerPosition + ), + 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() } @@ -638,44 +766,109 @@ class TfbiHierarchicalFlickController( * 第2階層以降のUI(ポップアップの内容)を設定します。 */ private fun setupStageUI(map: Map) { + val displayMap = effectiveStageMap(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 ?: "" + val tapCharacter = nodeDisplayText(displayMap[TfbiFlickDirection.TAP]) + val stageBaseCharacter = stageBaseCharacter(displayMap, tapCharacter) - null -> "" - is TfbiFlickNode.StatefulKey -> { - Log.e(TAG, "Illegal state: StatefulKey found at TAP in SubMenu map.") - "" + // 周囲に表示する文字 + val petalChars = displayMap + .filterKeys { it != TfbiFlickDirection.TAP } + .mapValues { (_, node) -> nodeDisplayText(node) } + + popupView?.setCharacters(tapCharacter, petalChars) + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + val currentText = if (guideStageJustOpened) { + stageBaseCharacter + } else { + nodeDisplayText(displayMap[currentHighlight]).ifEmpty { stageBaseCharacter } } + val currentSlot = if (mapStack.size > 1) guideRootDirection else currentHighlight + guidePopupHost.update( + state = TfbiGuidePopupState( + currentText = currentText, + currentSlot = currentSlot, + optionLabels = petalChars.mapValues { (_, output) -> + guideOptionLabel(stageBaseCharacter, output) + }, + selectedOption = if (guideStageJustOpened) null else guideSelectedOption + ), + direction = guideArrowDirection + ) + guideStageJustOpened = false } + } - // 周囲に表示する文字 - val petalChars = map + private fun updateGuideForSelection( + currentText: String, + map: Map + ) { + if (popupPresentationMode != TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) return + val displayMap = effectiveStageMap(map) + val tapCharacter = nodeDisplayText(displayMap[TfbiFlickDirection.TAP]) + val stageBaseCharacter = stageBaseCharacter(displayMap, tapCharacter) + val optionLabels = displayMap .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) -> guideOptionLabel(stageBaseCharacter, nodeDisplayText(node)) } + guidePopupHost.update( + state = TfbiGuidePopupState( + currentText = currentText, + currentSlot = if (mapStack.size > 1) guideRootDirection else currentHighlight, + optionLabels = optionLabels, + selectedOption = guideSelectedOption + ), + direction = guideArrowDirection + ) + } - is TfbiFlickNode.StatefulKey -> { - Log.e(TAG, "Illegal state: StatefulKey found at Petal in SubMenu map.") - "" - } - } - } + /** + * 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]) + } - popupView?.setCharacters(tapCharacter, petalChars) + 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 updateGuideFingerPosition(view: View, event: MotionEvent) { + 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) + } } private fun resetState() { attachedView?.removeCallbacks(longPressRunnable) popupWindow?.dismiss() + guidePopupHost.dismiss() popupWindow = null popupView = null centerStack.clear() @@ -683,8 +876,13 @@ class TfbiHierarchicalFlickController( highlightStack.clear() currentMap = null currentHighlight = TfbiFlickDirection.TAP + guideRootDirection = TfbiFlickDirection.TAP + guideSelectedOption = null + guideArrowDirection = null + guideStageJustOpened = false isJitterGuardActive = false activeGestureConfig = null + currentFingerPosition = null if (currentMode != KeyMode.NORMAL) { currentMode = KeyMode.NORMAL @@ -726,6 +924,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 bdb8e0360..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,12 +8,20 @@ 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 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 kotlin.math.abs import kotlin.math.atan2 import kotlin.math.hypot @@ -74,6 +82,9 @@ class TfbiInputController( private var intermediateTouchY = 0f 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 @@ -85,6 +96,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 +129,7 @@ class TfbiInputController( this.popupBackgroundColor = backgroundColor this.popupHighlightedColor = highlightedColor this.popupTextColor = textColor + guidePopupHost.setColors(backgroundColor, highlightedColor, textColor) } fun applyPopupViewStyle(style: PopupViewStyle) { @@ -124,6 +140,20 @@ 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 setTfbiFlickStartPositionMode(mode: TfbiFlickStartPositionMode) { + tfbiFlickStartPositionMode = mode } fun setPopupWindowAnchorProvider(provider: (() -> View?)?) { @@ -133,6 +163,7 @@ class TfbiInputController( fun setInputTextTransform(transform: (String) -> String) { inputTextTransform = transform popupView?.setInputTextTransform(transform) + guidePopupHost.setInputTextTransform(transform) } fun attach( @@ -194,6 +225,14 @@ class TfbiInputController( isTouchActive = true initialTouchX = event.x initialTouchY = event.y + 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) @@ -202,6 +241,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 @@ -221,17 +261,25 @@ class TfbiInputController( setupSecondStageUI(firstFlickDirection) popupView?.highlightDirection(determinedDirection) currentSecondFlickDirection = determinedDirection + updateGuideSecondStage() } } else { val distanceFromInitial = hypot( (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 } + val fingerPosition = currentFingerPosition resetState() + currentFingerPosition = fingerPosition showPopup(view, TfbiFlickDirection.TAP, false) listener?.onSelectionChanged( TfbiFlickDirection.TAP, @@ -254,6 +302,7 @@ class TfbiInputController( if (highlightTargetDirection != currentSecondFlickDirection) { popupView?.highlightDirection(highlightTargetDirection) currentSecondFlickDirection = highlightTargetDirection + updateGuideSecondStage() } } listener?.onSelectionChanged( @@ -266,6 +315,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) { @@ -325,6 +375,45 @@ 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, + fingerPosition = currentFingerPosition + ), + 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 +467,43 @@ 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 updateGuideFingerPosition(view: View, event: MotionEvent) { + currentFingerPosition = resolveTfbiGuideFingerPosition( + anchor = view, + event = event, + startPositionMode = activeTfbiFlickStartPositionMode, + downX = initialTouchX, + downY = initialTouchY + ) + 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() @@ -387,12 +513,14 @@ class TfbiInputController( private fun resetState() { clearLongPressCallback(attachedView) popupWindow?.dismiss() + guidePopupHost.dismiss() popupWindow = null popupView = null flickState = FlickState.NEUTRAL firstFlickDirection = TfbiFlickDirection.TAP currentSecondFlickDirection = TfbiFlickDirection.TAP isLongPressModeActive = false + currentFingerPosition = null } private fun clearLongPressCallback(view: View?) { @@ -447,6 +575,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 3eda5618b..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 @@ -9,13 +9,17 @@ 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 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.TfbiGuideFingerPosition +import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState import kotlin.math.abs import kotlin.math.atan2 @@ -63,6 +67,9 @@ class TfbiStickyFlickController( private var intermediateTouchX = 0f 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 @@ -72,8 +79,15 @@ 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 + private val guidePopupHost by lazy { + TfbiGuidePopupHost(context) { popupWindowAnchorProvider?.invoke() } + } private val longPressRunnable = Runnable { val view = attachedView ?: return@Runnable @@ -97,9 +111,30 @@ class TfbiStickyFlickController( popupWindowAnchorProvider = provider } + fun setPopupPresentationMode(mode: TfbiPopupPresentationMode) { + if (popupPresentationMode == mode) return + popupWindow?.dismiss() + guidePopupHost.dismiss() + popupWindow = null + popupView = null + popupPresentationMode = mode + } + + fun setTfbiFlickStartPositionMode(mode: TfbiFlickStartPositionMode) { + tfbiFlickStartPositionMode = mode + } + fun setInputTextTransform(transform: (String) -> String) { inputTextTransform = transform popupView?.setInputTextTransform(transform) + 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) { @@ -110,6 +145,7 @@ class TfbiStickyFlickController( textColor = style.textColor ) popupView?.applyPopupViewStyle(popupStyle) + guidePopupHost.applyPopupViewStyle(popupStyle) } fun cancel() { @@ -137,6 +173,14 @@ class TfbiStickyFlickController( flickState = FlickState.NEUTRAL initialTouchX = event.x initialTouchY = event.y + activeTfbiFlickStartPositionMode = tfbiFlickStartPositionMode + currentFingerPosition = resolveTfbiGuideFingerPosition( + anchor = view, + event = event, + startPositionMode = activeTfbiFlickStartPositionMode, + downX = initialTouchX, + downY = initialTouchY + ) listener?.onPress(TfbiFlickDirection.TAP, TfbiFlickDirection.TAP) // 最初は必ず花びらなしのポップアップを表示する @@ -145,6 +189,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 @@ -164,6 +209,7 @@ class TfbiStickyFlickController( setupSecondStageUI(firstFlickDirection) popupView?.highlightDirection(determinedDirection) currentSecondFlickDirection = determinedDirection + updateGuideSecondStage() } } else { // ===== ★ 変更点 1 ===== @@ -199,6 +245,7 @@ class TfbiStickyFlickController( if (highlightTargetDirection != currentSecondFlickDirection) { popupView?.highlightDirection(highlightTargetDirection) currentSecondFlickDirection = highlightTargetDirection + updateGuideSecondStage() } } } @@ -207,6 +254,8 @@ class TfbiStickyFlickController( // (この関数内のロジックは元の TfbiInputController と同じで、変更不要です) // ログのタグだけ "TfbStickyInput" に変更しています。 + attachedView?.let { updateGuideFingerPosition(it, event) } + Log.d( "TfbStickyInput", // ★ ログタグ "handleTouchUp: START. Current state = $flickState" @@ -266,6 +315,45 @@ 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, + fingerPosition = currentFingerPosition + ), + 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 @@ -282,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) @@ -315,16 +406,55 @@ 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 updateGuideFingerPosition(view: View, event: MotionEvent) { + currentFingerPosition = resolveTfbiGuideFingerPosition( + anchor = view, + event = event, + startPositionMode = activeTfbiFlickStartPositionMode, + downX = initialTouchX, + downY = initialTouchY + ) + if (popupPresentationMode == TfbiPopupPresentationMode.GUIDE_ABOVE_KEY) { + guidePopupHost.updateFingerPosition(currentFingerPosition) + } + } + private fun resetState() { attachedView?.removeCallbacks(longPressRunnable) popupWindow?.dismiss() + guidePopupHost.dismiss() popupWindow = null popupView = null flickState = FlickState.NEUTRAL firstFlickDirection = TfbiFlickDirection.TAP currentSecondFlickDirection = TfbiFlickDirection.TAP activeGestureConfig = null + currentFingerPosition = null } private fun currentFlickThreshold(): Float { @@ -380,6 +510,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/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/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/data/TfbiGuidePopupState.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuidePopupState.kt new file mode 100644 index 000000000..13d8da15d --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/data/TfbiGuidePopupState.kt @@ -0,0 +1,28 @@ +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, + 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/FlickKeyboardView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt index fe2d520bd..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 @@ -30,8 +30,10 @@ 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 import com.kazumaproject.core.domain.extensions.isDarkThemeOn import com.kazumaproject.core.domain.extensions.setBorder import com.kazumaproject.core.domain.extensions.setDrawableAlpha @@ -136,6 +138,8 @@ 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 @@ -286,6 +290,20 @@ 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 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), @@ -2212,6 +2230,8 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setPopupPresentationMode(tfbiPopupPresentationMode) + setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.tfbi) this.listener = object : TfbiInputController.TfbiListener { @@ -2380,6 +2400,8 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setPopupPresentationMode(tfbiPopupPresentationMode) + setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.tfbi) this.listener = object : TfbiStickyFlickController.TfbiListener { @@ -2417,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 } @@ -2440,6 +2472,8 @@ class FlickKeyboardView @JvmOverloads constructor( gestureConfigSource = gestureSessionConfigSource ).apply { setPopupWindowAnchorProvider(popupWindowAnchorProvider) + setPopupPresentationMode(tfbiPopupPresentationMode) + setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) 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..a2a3eff89 --- /dev/null +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt @@ -0,0 +1,274 @@ +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.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.TfbiGuideFingerPosition +import com.kazumaproject.custom_keyboard.data.TfbiGuideGrid +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 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 { + 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 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) + 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 = defaultTextColor + private var activeTextColor: Int = Color.WHITE + private var inputTextTransform: (String) -> String = { it } + + 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 ?: configuredBackgroundColor + activeColor = configuredHighlightedColor ?: DEFAULT_ACTIVE_COLOR + popupTextColor = style.textColor ?: configuredTextColor ?: defaultTextColor + activeTextColor = readableForeground(activeColor) + invalidate() + } + + fun setColors( + backgroundColor: Int, + highlightedBackgroundColor: Int, + textColor: Int + ) { + configuredBackgroundColor = backgroundColor + configuredHighlightedColor = highlightedBackgroundColor + configuredTextColor = textColor + popupBackgroundColor = popupStyle.backgroundColor ?: backgroundColor + activeColor = highlightedBackgroundColor + popupTextColor = popupStyle.textColor ?: 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) + val panelColor = popupBackgroundColor ?: defaultPanelColor + 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 + 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) + } + } + + 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( + direction: TfbiFlickDirection, + panel: RectF, + cellWidth: Float, + cellHeight: Float + ): RectF { + 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) + } + + private fun drawActiveLabel( + canvas: Canvas, + text: String, + rect: RectF, + compact: Boolean = false + ) { + if (text.isEmpty()) return + 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 = 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( + 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 + ) + 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) + } + + private fun drawPlainLabel(canvas: Canvas, text: String, rect: RectF) { + if (text.isEmpty()) return + textPaint.color = popupTextColor + 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, + scale: Float, + minimumSp: Float + ): Float { + val configured = sp(popupStyle.textSizeSp * scale) + textPaint.textSize = configured + val measured = textPaint.measureText(text) + 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 { + 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 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 + } +} 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() + ) + ) + } +} 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/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) + } +} 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/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) } 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..9dfcb5f65 --- /dev/null +++ b/custom_keyboard/src/test/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardViewTfbiAttachTest.kt @@ -0,0 +1,557 @@ +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.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 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) + + assertEquals("な", state.currentText) + assertEquals(0.5f, state.fingerPosition?.x ?: -1f, 0.001f) + assertEquals(0.5f, state.fingerPosition?.y ?: -1f, 0.001f) + } + + @Test + fun thirdFlickGuideShowsNaOnTouchDown() { + 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 + fun thirdFlickGuideShowsNaStageCharacterOnLeftFlick() { + val state = touchStateAfterLeftFlick( + "third-flick", + TfbiHierarchicalFlickController::class.java + ) + + 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( + "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 -> + 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 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) + + private fun createLayout(inputStyle: String) = KeyboardDefaultLayouts.createFinalLayout( + mode = KeyboardInputMode.HIRAGANA, + dynamicKeyStates = emptyMap(), + inputLayoutType = "flick", + 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( + 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 + } +}