From edfb2ed433c78e53d260e1ccf4f7372683710859 Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Sun, 14 Jun 2026 22:27:46 -0400 Subject: [PATCH 1/8] add resize shortcut toolbar --- .../ime_service/IMEService.kt | 40 ++- .../ime_service/ImePreferencesSnapshot.kt | 6 + .../ime_service/adapters/ShortcutAdapter.kt | 38 +++ .../setting_activity/AppPreference.kt | 68 +++++ ...CandidateHeightLandscapeSettingFragment.kt | 31 +- .../CandidateViewHeightSettingFragment.kt | 31 +- .../ui/setting/CommonPreferenceFragment.kt | 9 + .../ui/setting/SettingDestination.kt | 3 + .../ui/setting/SettingSearchIndex.kt | 15 + .../ShortcutToolbarSizeSettingFragment.kt | 282 ++++++++++++++++++ ...fragment_shortcut_toolbar_size_setting.xml | 170 +++++++++++ .../main/res/navigation/mobile_navigation.xml | 5 + app/src/main/res/values-ja/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + .../main/res/xml/pref_clipboard_shortcut.xml | 6 + app/src/main/res/xml/pref_common_legacy.xml | 6 + 16 files changed, 703 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/shortcut_toolbar_size/ShortcutToolbarSizeSettingFragment.kt create mode 100644 app/src/main/res/layout/fragment_shortcut_toolbar_size_setting.xml 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 31c07c72..0615a399 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 @@ -846,6 +846,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var landscapeForceQwertyRomajiPreference: Boolean? = false private var shortcutTollbarVisibility: Boolean? = false private var shortcutToolbarIntegratedInSuggestion: Boolean? = false + private var shortcutToolbarHeightDp: Int = AppPreference.SHORTCUT_TOOLBAR_HEIGHT_DEFAULT_DP + private var shortcutToolbarIconSizeDp: Int = + AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_DEFAULT_DP private var shortcutToolbarHiddenForCandidates: Boolean = false private var clipboardPreviewVisibility: Boolean? = true private var clipboardPreviewTapToDelete: Boolean? = false @@ -1756,6 +1759,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, landscapeForceQwertyRomajiPreference = preferences.landscapeForceQwertyRomajiPreference shortcutTollbarVisibility = preferences.shortcutTollbarVisibility shortcutToolbarIntegratedInSuggestion = preferences.shortcutToolbarIntegratedInSuggestion + shortcutToolbarHeightDp = preferences.shortcutToolbarHeightDp + shortcutToolbarIconSizeDp = preferences.shortcutToolbarIconSizeDp + mainLayoutBinding?.let { applyShortcutToolbarSize(it) } isDeleteLeftFlickPreference = preferences.isDeleteLeftFlickPreference isDeleteUpFlickPreference = preferences.isDeleteUpFlickPreference isDeleteDownFlickPreference = preferences.isDeleteDownFlickPreference @@ -3328,6 +3334,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, landscapeForceQwertyRomajiPreference = null shortcutTollbarVisibility = null shortcutToolbarIntegratedInSuggestion = null + shortcutToolbarHeightDp = AppPreference.SHORTCUT_TOOLBAR_HEIGHT_DEFAULT_DP + shortcutToolbarIconSizeDp = AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_DEFAULT_DP shortcutToolbarHiddenForCandidates = false clipboardPreviewVisibility = null clipboardPreviewTapToDelete = null @@ -14055,6 +14063,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, val density = resources.displayMetrics.density val screenWidth = resources.displayMetrics.widthPixels val isSymbol = isSymbolOverride ?: keyboardSymbolViewState.value.isShown + applyShortcutToolbarSize(mainView) // 2. ピクセル値の計算 val heightPx = when { @@ -14120,7 +14129,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, baseKeyboardHeight + candidateTabOffset !addCandidateTabHeight && presentation.showIndependentShortcutToolbar && !isSymbol -> - baseKeyboardHeight + shortcutToolbarHeightPx(mainView) + baseKeyboardHeight + shortcutToolbarHeightPx() else -> baseKeyboardHeight } @@ -17099,8 +17108,33 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, return resolvedFixedHeightPx(mainView.candidateTabLayout, fallbackDp = 36f) } - private fun shortcutToolbarHeightPx(mainView: MainLayoutBinding): Int { - return resolvedFixedHeightPx(mainView.shortcutToolbarRecyclerview, fallbackDp = 36f) + private fun shortcutToolbarHeightPx(): Int { + val toolbarHeightDp = shortcutToolbarHeightDp.coerceIn( + AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MIN_DP, + AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MAX_DP + ) + return applicationContext.dpToPx(toolbarHeightDp) + } + + private fun applyShortcutToolbarSize(mainView: MainLayoutBinding) { + val toolbarHeightDp = shortcutToolbarHeightDp.coerceIn( + AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MIN_DP, + AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MAX_DP + ) + val iconSizeDp = appPreference.resolveShortcutToolbarIconSizeDp( + toolbarHeightDp = toolbarHeightDp, + iconSizeDp = shortcutToolbarIconSizeDp + ) + val toolbarHeightPx = applicationContext.dpToPx(toolbarHeightDp) + val iconSizePx = applicationContext.dpToPx(iconSizeDp) + mainView.shortcutToolbarRecyclerview.layoutParams = + mainView.shortcutToolbarRecyclerview.layoutParams.apply { + height = toolbarHeightPx + } + shortcutAdapter?.setShortcutToolbarSize( + toolbarHeightPx = toolbarHeightPx, + iconSizePx = iconSizePx + ) } private fun applyCandidateTabSuggestionOffset( diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt index 6a0b85ca..d2cec1b8 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt @@ -108,6 +108,8 @@ data class ImePreferencesSnapshot( val landscapeForceQwertyRomajiPreference: Boolean, val shortcutTollbarVisibility: Boolean, val shortcutToolbarIntegratedInSuggestion: Boolean, + val shortcutToolbarHeightDp: Int, + val shortcutToolbarIconSizeDp: Int, val isDeleteLeftFlickPreference: Boolean, val isDeleteUpFlickPreference: Boolean, val isDeleteDownFlickPreference: Boolean, @@ -387,6 +389,10 @@ data class ImePreferencesSnapshot( appPreference.shortcut_toolbar_visibility_preference, shortcutToolbarIntegratedInSuggestion = appPreference.shortcut_toolbar_integrated_in_suggestion_preference, + shortcutToolbarHeightDp = + appPreference.shortcut_toolbar_height_dp_preference, + shortcutToolbarIconSizeDp = + appPreference.shortcut_toolbar_icon_size_dp_preference, isDeleteLeftFlickPreference = appPreference.delete_key_left_flick_preference, isDeleteUpFlickPreference = appPreference.delete_key_up_flick_preference, isDeleteDownFlickPreference = appPreference.delete_key_down_flick_preference, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/ShortcutAdapter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/ShortcutAdapter.kt index 2811d500..4c624203 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/ShortcutAdapter.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/ShortcutAdapter.kt @@ -8,6 +8,7 @@ import android.widget.ImageView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import com.kazumaproject.core.domain.extensions.dpToPx import com.kazumaproject.markdownhelperkeyboard.R import com.kazumaproject.markdownhelperkeyboard.short_cut.ShortcutType @@ -32,6 +33,8 @@ class ShortcutAdapter : ListAdapter(Di private val iconColorState = ShortcutIconColorState() private var activeShortcutTypes: Set = emptySet() + private var toolbarHeightPx: Int = 0 + private var iconSizePx: Int = 0 /** * ViewHolder now captures clicks and calls the adapter's listener. @@ -58,6 +61,7 @@ class ShortcutAdapter : ListAdapter(Di override fun onBindViewHolder(holder: ViewHolder, position: Int) { val item = getItem(position) + applyShortcutToolbarSize(holder) holder.imageView.setImageResource(item.resolveIconResId()) // Enumからアイコン取得 // ★追加: 色が設定されていれば適用し、なければ解除する @@ -68,6 +72,23 @@ class ShortcutAdapter : ListAdapter(Di } } + fun setShortcutToolbarSize( + toolbarHeightPx: Int, + iconSizePx: Int + ) { + if ( + this.toolbarHeightPx == toolbarHeightPx && + this.iconSizePx == iconSizePx + ) { + return + } + this.toolbarHeightPx = toolbarHeightPx + this.iconSizePx = iconSizePx + if (itemCount > 0) { + notifyItemRangeChanged(0, itemCount) + } + } + // ★追加: 外部から色を設定するメソッド fun setIconColor(color: Int) { if (!iconColorState.setIconColor(color)) return @@ -97,6 +118,23 @@ class ShortcutAdapter : ListAdapter(Di ) } + private fun applyShortcutToolbarSize(holder: ViewHolder) { + if (toolbarHeightPx <= 0 || iconSizePx <= 0) return + val context = holder.itemView.context + val itemMinWidthPx = context.dpToPx(64) + val horizontalPaddingPx = context.dpToPx(36) + val itemWidthPx = maxOf(itemMinWidthPx, iconSizePx + horizontalPaddingPx) + + holder.itemView.layoutParams = holder.itemView.layoutParams.apply { + width = itemWidthPx + height = toolbarHeightPx + } + holder.imageView.layoutParams = holder.imageView.layoutParams.apply { + width = iconSizePx + height = iconSizePx + } + } + private fun ShortcutType.resolveIconResId(): Int { return if (this in activeShortcutTypes) { activeIconResId ?: iconResId 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 8e7cdd51..0fe60a36 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 @@ -34,6 +34,12 @@ object AppPreference { const val DEFAULT_CUSTOM_THEME_CANDIDATE_ITEM_BG_COLOR = 0x00000000 const val DEFAULT_CUSTOM_THEME_CANDIDATE_ITEM_PRESSED_BG_COLOR = 0xFFF0F0F3.toInt() + const val SHORTCUT_TOOLBAR_HEIGHT_DEFAULT_DP = 36 + const val SHORTCUT_TOOLBAR_HEIGHT_MIN_DP = 32 + const val SHORTCUT_TOOLBAR_HEIGHT_MAX_DP = 72 + const val SHORTCUT_TOOLBAR_ICON_SIZE_DEFAULT_DP = 28 + const val SHORTCUT_TOOLBAR_ICON_SIZE_MIN_DP = 18 + const val SHORTCUT_TOOLBAR_ICON_SIZE_MAX_DP = 56 private const val MIN_CANDIDATE_VISIBLE_HEIGHT_DP = 30 private const val MAX_CANDIDATE_VISIBLE_HEIGHT_DP = 300 @@ -299,6 +305,10 @@ object AppPreference { Pair("shortcut_toolbar_visibility_preference", false) private val SHORTCUT_TOOLBAR_INTEGRATED_IN_SUGGESTION_PREFERENCE = Pair("shortcut_toolbar_integrated_in_suggestion_preference", false) + private val SHORTCUT_TOOLBAR_HEIGHT_DP_PREFERENCE = + Pair("shortcut_toolbar_height_dp_preference", SHORTCUT_TOOLBAR_HEIGHT_DEFAULT_DP) + private val SHORTCUT_TOOLBAR_ICON_SIZE_DP_PREFERENCE = + Pair("shortcut_toolbar_icon_size_dp_preference", SHORTCUT_TOOLBAR_ICON_SIZE_DEFAULT_DP) private val APP_THEME_SEED_COLOR = Pair("app_theme_seed_color_preference", 0x00000000) @@ -696,6 +706,28 @@ object AppPreference { }.getOrDefault(defaultValue) } + fun resolveShortcutToolbarIconSizeDp( + toolbarHeightDp: Int = shortcut_toolbar_height_dp_preference, + iconSizeDp: Int = shortcut_toolbar_icon_size_dp_preference + ): Int { + val resolvedToolbarHeightDp = toolbarHeightDp.coerceIn( + SHORTCUT_TOOLBAR_HEIGHT_MIN_DP, + SHORTCUT_TOOLBAR_HEIGHT_MAX_DP + ) + val resolvedIconSizeDp = iconSizeDp.coerceIn( + SHORTCUT_TOOLBAR_ICON_SIZE_MIN_DP, + SHORTCUT_TOOLBAR_ICON_SIZE_MAX_DP + ) + val maxIconSizeForHeightDp = (resolvedToolbarHeightDp - 8) + .coerceAtLeast(SHORTCUT_TOOLBAR_ICON_SIZE_MIN_DP) + return resolvedIconSizeDp + .coerceAtMost(maxIconSizeForHeightDp) + .coerceIn( + SHORTCUT_TOOLBAR_ICON_SIZE_MIN_DP, + SHORTCUT_TOOLBAR_ICON_SIZE_MAX_DP + ) + } + private fun normalizeCandidateColumn(column: String): String = if (column in setOf("1", "2", "3")) column else "1" @@ -1939,6 +1971,42 @@ object AppPreference { it.putBoolean(SHORTCUT_TOOLBAR_INTEGRATED_IN_SUGGESTION_PREFERENCE.first, value) } + var shortcut_toolbar_height_dp_preference: Int + get() = preferences.getInt( + SHORTCUT_TOOLBAR_HEIGHT_DP_PREFERENCE.first, + SHORTCUT_TOOLBAR_HEIGHT_DP_PREFERENCE.second + ).coerceIn( + SHORTCUT_TOOLBAR_HEIGHT_MIN_DP, + SHORTCUT_TOOLBAR_HEIGHT_MAX_DP + ) + set(value) = preferences.edit { + it.putInt( + SHORTCUT_TOOLBAR_HEIGHT_DP_PREFERENCE.first, + value.coerceIn( + SHORTCUT_TOOLBAR_HEIGHT_MIN_DP, + SHORTCUT_TOOLBAR_HEIGHT_MAX_DP + ) + ) + } + + var shortcut_toolbar_icon_size_dp_preference: Int + get() = preferences.getInt( + SHORTCUT_TOOLBAR_ICON_SIZE_DP_PREFERENCE.first, + SHORTCUT_TOOLBAR_ICON_SIZE_DP_PREFERENCE.second + ).coerceIn( + SHORTCUT_TOOLBAR_ICON_SIZE_MIN_DP, + SHORTCUT_TOOLBAR_ICON_SIZE_MAX_DP + ) + set(value) = preferences.edit { + it.putInt( + SHORTCUT_TOOLBAR_ICON_SIZE_DP_PREFERENCE.first, + value.coerceIn( + SHORTCUT_TOOLBAR_ICON_SIZE_MIN_DP, + SHORTCUT_TOOLBAR_ICON_SIZE_MAX_DP + ) + ) + } + var seedColor: Int get() = preferences.getInt( APP_THEME_SEED_COLOR.first, APP_THEME_SEED_COLOR.second diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_landscape_setting/CandidateHeightLandscapeSettingFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_landscape_setting/CandidateHeightLandscapeSettingFragment.kt index 3cd86891..ca327a5e 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_landscape_setting/CandidateHeightLandscapeSettingFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_landscape_setting/CandidateHeightLandscapeSettingFragment.kt @@ -16,7 +16,7 @@ import android.widget.SeekBar import androidx.annotation.AttrRes import androidx.appcompat.R as AppCompatR import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.AppCompatImageButton +import androidx.appcompat.widget.AppCompatImageView import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.core.view.isInvisible @@ -465,7 +465,7 @@ class CandidateHeightLandscapeSettingFragment : Fragment() { binding.independentShortcutToolbarPreviewContainer.layoutParams = (binding.independentShortcutToolbarPreviewContainer.layoutParams as FrameLayout.LayoutParams).apply { gravity = Gravity.BOTTOM - height = independentToolbarHeightPx.coerceAtLeast(36.dpToPx()) + height = independentToolbarHeightPx bottomMargin = keyboardHeightPx + candidateHeightPx } binding.candidateTabPreviewContainer.layoutParams = @@ -904,7 +904,7 @@ class CandidateHeightLandscapeSettingFragment : Fragment() { presentation.showIndependentShortcutToolbar || presentation.reserveIndependentShortcutToolbarSpace ) { - 36.dpToPx() + appPreference.shortcut_toolbar_height_dp_preference.dpToPx() } else { 0 } @@ -930,19 +930,30 @@ class CandidateHeightLandscapeSettingFragment : Fragment() { private fun populateShortcutToolbarPreview(container: LinearLayout) { container.removeAllViews() + val toolbarHeightPx = appPreference.shortcut_toolbar_height_dp_preference.dpToPx() + val iconSizePx = appPreference.resolveShortcutToolbarIconSizeDp().dpToPx() + val itemWidthPx = maxOf(64.dpToPx(), iconSizePx + 36.dpToPx()) + container.layoutParams = container.layoutParams.apply { + height = toolbarHeightPx + } previewShortcutItems().forEach { shortcut -> - val button = AppCompatImageButton(requireContext()).apply { + val itemView = FrameLayout(requireContext()).apply { + isClickable = false + isFocusable = false + } + val iconView = AppCompatImageView(requireContext()).apply { setImageResource(shortcut.iconResId) imageTintList = ColorStateList.valueOf(resolveThemeColor(MaterialR.attr.colorOnSurface)) - background = null + scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE contentDescription = shortcut.description - isClickable = false - isFocusable = false - setPadding(8.dpToPx(), 6.dpToPx(), 8.dpToPx(), 6.dpToPx()) } + itemView.addView( + iconView, + FrameLayout.LayoutParams(iconSizePx, iconSizePx, Gravity.CENTER) + ) container.addView( - button, - LinearLayout.LayoutParams(42.dpToPx(), ViewGroup.LayoutParams.MATCH_PARENT) + itemView, + LinearLayout.LayoutParams(itemWidthPx, toolbarHeightPx) ) } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateViewHeightSettingFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateViewHeightSettingFragment.kt index 078a5164..8ea4fc58 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateViewHeightSettingFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateViewHeightSettingFragment.kt @@ -16,7 +16,7 @@ import android.widget.SeekBar import androidx.annotation.AttrRes import androidx.appcompat.R as AppCompatR import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.AppCompatImageButton +import androidx.appcompat.widget.AppCompatImageView import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.core.view.isInvisible @@ -457,7 +457,7 @@ class CandidateViewHeightSettingFragment : Fragment() { binding.independentShortcutToolbarPreviewContainer.layoutParams = (binding.independentShortcutToolbarPreviewContainer.layoutParams as FrameLayout.LayoutParams).apply { gravity = Gravity.BOTTOM - height = independentToolbarHeightPx.coerceAtLeast(36.dpToPx()) + height = independentToolbarHeightPx bottomMargin = keyboardHeightPx + candidateHeightPx } binding.candidateTabPreviewContainer.layoutParams = @@ -896,7 +896,7 @@ class CandidateViewHeightSettingFragment : Fragment() { presentation.showIndependentShortcutToolbar || presentation.reserveIndependentShortcutToolbarSpace ) { - 36.dpToPx() + appPreference.shortcut_toolbar_height_dp_preference.dpToPx() } else { 0 } @@ -922,19 +922,30 @@ class CandidateViewHeightSettingFragment : Fragment() { private fun populateShortcutToolbarPreview(container: LinearLayout) { container.removeAllViews() + val toolbarHeightPx = appPreference.shortcut_toolbar_height_dp_preference.dpToPx() + val iconSizePx = appPreference.resolveShortcutToolbarIconSizeDp().dpToPx() + val itemWidthPx = maxOf(64.dpToPx(), iconSizePx + 36.dpToPx()) + container.layoutParams = container.layoutParams.apply { + height = toolbarHeightPx + } previewShortcutItems().forEach { shortcut -> - val button = AppCompatImageButton(requireContext()).apply { + val itemView = FrameLayout(requireContext()).apply { + isClickable = false + isFocusable = false + } + val iconView = AppCompatImageView(requireContext()).apply { setImageResource(shortcut.iconResId) imageTintList = ColorStateList.valueOf(resolveThemeColor(MaterialR.attr.colorOnSurface)) - background = null + scaleType = android.widget.ImageView.ScaleType.CENTER_INSIDE contentDescription = shortcut.description - isClickable = false - isFocusable = false - setPadding(8.dpToPx(), 6.dpToPx(), 8.dpToPx(), 6.dpToPx()) } + itemView.addView( + iconView, + FrameLayout.LayoutParams(iconSizePx, iconSizePx, Gravity.CENTER) + ) container.addView( - button, - LinearLayout.LayoutParams(42.dpToPx(), ViewGroup.LayoutParams.MATCH_PARENT) + itemView, + LinearLayout.LayoutParams(itemWidthPx, toolbarHeightPx) ) } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt index 27764094..8a621e29 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt @@ -380,6 +380,15 @@ open class CommonPreferenceFragment : PreferenceFragmentCompat() { } } + findPreference("shortcut_toolbar_size_setting_fragment_preference")?.apply { + setOnPreferenceClickListener { + navigateSafely( + R.id.shortcutToolbarSizeSettingFragment + ) + true + } + } + val candidateTabOrderPreference = findPreference("candidate_tab_order_preference") candidateTabOrderPreference?.apply { diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt index ffded2a8..141e0825 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt @@ -428,6 +428,7 @@ object SettingDestinations { "shortcut_toolbar_visibility_preference", "shortcut_toolbar_integrated_in_suggestion_preference", "shortcut_toolbar_item_preference", + "shortcut_toolbar_size_setting_fragment_preference", "symbol_mode_preference", "default_emoji_skin_tone_preference", "system_user_dictionary_builder_preference", @@ -661,6 +662,8 @@ object SettingDestinations { "setting_route_gemma_preferences" -> R.id.gemmaPreferenceFragment.takeIf { AppVariantConfig.hasGemma } "custom_romaji_preference" -> R.id.romajiMapFragment "shortcut_toolbar_item_preference" -> R.id.shortcutSettingFragment + "shortcut_toolbar_size_setting_fragment_preference" -> + R.id.shortcutToolbarSizeSettingFragment "candidate_tab_order_preference" -> R.id.candidateTabOrderFragment "keyboard_selection_preference" -> R.id.keyboardSelectionFragment "keyboard_key_letter_size_fragment_preference" -> R.id.keyCandidateLetterSizeFragment diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt index 9cdfd2c4..fb0fbbb8 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt @@ -16,6 +16,20 @@ object SettingSearchIndex { private const val APP_NS = "http://schemas.android.com/apk/res-auto" private const val SINGLE_CHARACTER_RESULT_LIMIT = 20 private const val MULTI_CHARACTER_RESULT_LIMIT = 50 + private val extraKeywordsByKey = mapOf( + "shortcut_toolbar_size_setting_fragment_preference" to listOf( + "shortcut", + "toolbar", + "height", + "icon", + "size", + "ショートカット", + "ツールバー", + "高さ", + "アイコン", + "サイズ", + ) + ) private data class PreferenceXmlSource( @XmlRes val xmlRes: Int, @@ -649,5 +663,6 @@ object SettingSearchIndex { add(title) add(summary) addAll(key.split('_', '-', '.')) + addAll(extraKeywordsByKey[key].orEmpty()) } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/shortcut_toolbar_size/ShortcutToolbarSizeSettingFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/shortcut_toolbar_size/ShortcutToolbarSizeSettingFragment.kt new file mode 100644 index 00000000..72559670 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/shortcut_toolbar_size/ShortcutToolbarSizeSettingFragment.kt @@ -0,0 +1,282 @@ +package com.kazumaproject.markdownhelperkeyboard.setting_activity.ui.shortcut_toolbar_size + +import android.content.res.ColorStateList +import android.graphics.Color +import android.os.Bundle +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.SeekBar +import androidx.annotation.AttrRes +import androidx.appcompat.R as AppCompatR +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import com.google.android.material.R as MaterialR +import com.google.android.material.textfield.TextInputEditText +import com.kazumaproject.markdownhelperkeyboard.R +import com.kazumaproject.markdownhelperkeyboard.databinding.FragmentShortcutToolbarSizeSettingBinding +import com.kazumaproject.markdownhelperkeyboard.setting_activity.AppPreference +import com.kazumaproject.markdownhelperkeyboard.short_cut.ShortcutType +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlin.math.roundToInt + +@AndroidEntryPoint +class ShortcutToolbarSizeSettingFragment : Fragment() { + + @Inject + lateinit var appPreference: AppPreference + + private var _binding: FragmentShortcutToolbarSizeSettingBinding? = null + private val binding get() = _binding!! + + private var isSyncingControls = false + private var toolbarHeightDp = AppPreference.SHORTCUT_TOOLBAR_HEIGHT_DEFAULT_DP + private var iconSizeDp = AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_DEFAULT_DP + + private val previewShortcutItems = listOf( + ShortcutType.SETTINGS, + ShortcutType.EMOJI, + ShortcutType.TEMPLATE, + ShortcutType.KEYBOARD_PICKER, + ShortcutType.PASTE + ) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentShortcutToolbarSizeSettingBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + (activity as? AppCompatActivity)?.supportActionBar?.hide() + setupToolbar() + setupControls() + loadCurrentValues() + } + + override fun onDestroyView() { + super.onDestroyView() + (activity as? AppCompatActivity)?.supportActionBar?.show() + _binding = null + } + + private fun setupToolbar() { + binding.toolbar.setNavigationIcon(AppCompatR.drawable.abc_ic_ab_back_material) + binding.toolbar.setNavigationOnClickListener { + findNavController().popBackStack() + } + } + + private fun setupControls() { + binding.toolbarHeightSeekBar.max = + AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MAX_DP - + AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MIN_DP + binding.iconSizeSeekBar.max = + AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_MAX_DP - + AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_MIN_DP + + binding.toolbarHeightSeekBar.setOnSeekBarChangeListener( + seekBarChangeListener { progress -> + applyToolbarHeightDp( + AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MIN_DP + progress, + persist = true + ) + } + ) + binding.iconSizeSeekBar.setOnSeekBarChangeListener( + seekBarChangeListener { progress -> + applyIconSizeDp( + AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_MIN_DP + progress, + persist = true + ) + } + ) + + binding.toolbarHeightEditText.doAfterTextChanged { editable -> + if (isSyncingControls) return@doAfterTextChanged + handleEditTextChange( + rawValue = editable?.toString().orEmpty(), + min = AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MIN_DP, + max = AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MAX_DP, + applyValue = { applyToolbarHeightDp(it, persist = true) } + ) + } + binding.iconSizeEditText.doAfterTextChanged { editable -> + if (isSyncingControls) return@doAfterTextChanged + handleEditTextChange( + rawValue = editable?.toString().orEmpty(), + min = AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_MIN_DP, + max = AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_MAX_DP, + applyValue = { applyIconSizeDp(it, persist = true) } + ) + } + + binding.toolbarHeightEditText.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + applyEditTextOrSync(binding.toolbarHeightEditText, ::applyToolbarHeightDp) + } + } + binding.iconSizeEditText.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + applyEditTextOrSync(binding.iconSizeEditText, ::applyIconSizeDp) + } + } + + binding.resetButton.setOnClickListener { + applyToolbarHeightDp(AppPreference.SHORTCUT_TOOLBAR_HEIGHT_DEFAULT_DP, persist = true) + applyIconSizeDp(AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_DEFAULT_DP, persist = true) + } + } + + private fun loadCurrentValues() { + toolbarHeightDp = appPreference.shortcut_toolbar_height_dp_preference + iconSizeDp = appPreference.shortcut_toolbar_icon_size_dp_preference + syncControls() + updatePreview() + } + + private fun applyToolbarHeightDp(value: Int, persist: Boolean) { + toolbarHeightDp = value.coerceIn( + AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MIN_DP, + AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MAX_DP + ) + if (persist) { + appPreference.shortcut_toolbar_height_dp_preference = toolbarHeightDp + } + syncControls() + updatePreview() + } + + private fun applyIconSizeDp(value: Int, persist: Boolean) { + iconSizeDp = value.coerceIn( + AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_MIN_DP, + AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_MAX_DP + ) + if (persist) { + appPreference.shortcut_toolbar_icon_size_dp_preference = iconSizeDp + } + syncControls() + updatePreview() + } + + private fun syncControls() { + isSyncingControls = true + binding.toolbarHeightSeekBar.progress = + toolbarHeightDp - AppPreference.SHORTCUT_TOOLBAR_HEIGHT_MIN_DP + binding.iconSizeSeekBar.progress = + iconSizeDp - AppPreference.SHORTCUT_TOOLBAR_ICON_SIZE_MIN_DP + syncText(binding.toolbarHeightEditText, toolbarHeightDp) + syncText(binding.iconSizeEditText, iconSizeDp) + isSyncingControls = false + } + + private fun syncText(editText: TextInputEditText, value: Int) { + val next = value.toString() + if (editText.text?.toString() == next) return + editText.setText(next) + editText.setSelection(next.length) + } + + private fun applyEditTextOrSync( + editText: TextInputEditText, + applyValue: (value: Int, persist: Boolean) -> Unit + ) { + val value = editText.text?.toString()?.toIntOrNull() + if (value == null) { + syncControls() + } else { + applyValue(value, true) + } + } + + private fun handleEditTextChange( + rawValue: String, + min: Int, + max: Int, + applyValue: (Int) -> Unit + ) { + if (rawValue.isBlank()) return + val value = rawValue.toIntOrNull() ?: return + val shouldApply = value in min..max || + value > max || + rawValue.length >= max.toString().length + if (shouldApply) { + applyValue(value) + } + } + + private fun seekBarChangeListener(onUserProgressChanged: (Int) -> Unit) = + object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (!fromUser || isSyncingControls) return + onUserProgressChanged(progress) + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit + + override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit + } + + private fun updatePreview() { + val toolbarHeightPx = toolbarHeightDp.dpToPx() + val resolvedIconSizeDp = appPreference.resolveShortcutToolbarIconSizeDp( + toolbarHeightDp = toolbarHeightDp, + iconSizeDp = iconSizeDp + ) + val iconSizePx = resolvedIconSizeDp.dpToPx() + val itemMinWidthPx = 64.dpToPx() + val horizontalPaddingPx = 36.dpToPx() + val itemWidthPx = maxOf(itemMinWidthPx, iconSizePx + horizontalPaddingPx) + val iconTint = ColorStateList.valueOf(resolveThemeColor(MaterialR.attr.colorOnSurface)) + + binding.shortcutToolbarPreviewContainer.layoutParams = + binding.shortcutToolbarPreviewContainer.layoutParams.apply { + height = toolbarHeightPx + } + binding.shortcutToolbarPreviewContainer.removeAllViews() + previewShortcutItems.forEach { shortcut -> + val itemView = FrameLayout(requireContext()).apply { + isClickable = false + isFocusable = false + } + val iconView = AppCompatImageView(requireContext()).apply { + setImageResource(shortcut.iconResId) + imageTintList = iconTint + scaleType = ImageView.ScaleType.CENTER_INSIDE + contentDescription = shortcut.description + } + itemView.addView( + iconView, + FrameLayout.LayoutParams(iconSizePx, iconSizePx, Gravity.CENTER) + ) + binding.shortcutToolbarPreviewContainer.addView( + itemView, + LinearLayout.LayoutParams(itemWidthPx, toolbarHeightPx) + ) + } + } + + private fun resolveThemeColor(@AttrRes attr: Int): Int { + val typedArray = requireContext().obtainStyledAttributes(intArrayOf(attr)) + return try { + typedArray.getColor(0, Color.TRANSPARENT) + } finally { + typedArray.recycle() + } + } + + private fun Int.dpToPx(): Int = + (this * resources.displayMetrics.density).roundToInt() +} diff --git a/app/src/main/res/layout/fragment_shortcut_toolbar_size_setting.xml b/app/src/main/res/layout/fragment_shortcut_toolbar_size_setting.xml new file mode 100644 index 00000000..1a66e1a2 --- /dev/null +++ b/app/src/main/res/layout/fragment_shortcut_toolbar_size_setting.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 60d7061f..25d9e1ee 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -370,6 +370,11 @@ android:id="@+id/shortcutSettingFragment" android:name="com.kazumaproject.markdownhelperkeyboard.short_cut.ui.ShortcutSettingFragment" android:label="@string/shortcutsettingfragment" /> + ショートカットツールバー ショートカットツールバーのカスタマイズ ツールバーに表示するアイコンを編集する画面を開きます + ショートカットツールバーのサイズ + ショートカットツールバーの高さとアイコンサイズを調整します + 独立表示されるショートカットツールバーの高さとアイコンサイズを調整できます。 + プレビュー + ツールバーの高さ + アイコンサイズ + デフォルトに戻す + この設定は独立表示のショートカットツールバーに適用されます。候補欄に統合されたショートカットの見た目は変更されません。 ローマ字キーボードの全角スペース オンにすると、ローマ字キーボードの空白キーで全角スペースを入力します。 ローマ字入力時の半角数字 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d7c8b6c..25101617 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -723,6 +723,14 @@ Toolbar Setting Customize shortcut toolbar Open the screen to edit the icons shown on the toolbar. + Shortcut toolbar size + Adjust shortcut toolbar height and icon size + Adjust the height and icon size of the independent shortcut toolbar. + Preview + Toolbar height + Icon size + Reset to default + This setting applies to the independent shortcut toolbar. Shortcuts integrated into the suggestion area are not changed. Full-width space on QWERTY keyboard When enabled, the space key on the romaji (QWERTY) keyboard inserts a full-width space. Half-width numbers in Romaji input diff --git a/app/src/main/res/xml/pref_clipboard_shortcut.xml b/app/src/main/res/xml/pref_clipboard_shortcut.xml index 9d2848d9..f82eca9d 100644 --- a/app/src/main/res/xml/pref_clipboard_shortcut.xml +++ b/app/src/main/res/xml/pref_clipboard_shortcut.xml @@ -62,6 +62,12 @@ android:title="@string/shortcut_toolbar_preference_title" app:summary="@string/shortcut_toolbar_preference_summary" /> + + diff --git a/app/src/main/res/xml/pref_common_legacy.xml b/app/src/main/res/xml/pref_common_legacy.xml index b4d0058b..fd9ca6a5 100644 --- a/app/src/main/res/xml/pref_common_legacy.xml +++ b/app/src/main/res/xml/pref_common_legacy.xml @@ -474,6 +474,12 @@ android:key="shortcut_toolbar_item_preference" android:title="@string/shortcut_toolbar_preference_title" app:summary="@string/shortcut_toolbar_preference_summary" /> + + From b4575ed27fb3daa427acaa1678c612c19d8c2958 Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Mon, 15 Jun 2026 03:54:22 -0400 Subject: [PATCH 2/8] add light orbe --- .../ime_service/IMEService.kt | 102 ++++ .../image_effect/KeyboardTouchEffectType.kt | 6 + .../image_effect/LuminousBlobEffectView.kt | 180 ++++++ .../image_effect/LuminousBlobInputCommand.kt | 57 ++ .../LuminousBlobInputCommandQueue.kt | 86 +++ .../image_effect/LuminousBlobInputInjector.kt | 185 ++++++ .../LuminousBlobPerformanceGovernor.kt | 187 ++++++ .../image_effect/LuminousBlobRenderer.kt | 542 ++++++++++++++++++ .../LuminousBlobRendererController.kt | 27 + .../image_effect/LuminousBlobSettings.kt | 126 ++++ .../image_effect/LuminousBlobSimulation.kt | 522 +++++++++++++++++ .../ui/setting/CommonPreferenceFragment.kt | 3 +- app/src/main/res/layout-land/main_layout.xml | 9 + .../main/res/layout-sw600dp/main_layout.xml | 9 + .../res/layout/floating_keyboard_layout.xml | 9 + app/src/main/res/layout/main_layout.xml | 9 + app/src/main/res/values-ja/arrays.xml | 2 + app/src/main/res/values-ja/strings.xml | 2 + app/src/main/res/values/arrays.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../KeyboardTouchEffectResourceTest.kt | 5 +- .../KeyboardTouchEffectTypeTest.kt | 6 + .../LuminousBlobInputCommandQueueTest.kt | 76 +++ .../LuminousBlobInputInjectorTest.kt | 71 +++ .../LuminousBlobPerformanceGovernorTest.kt | 38 ++ .../layout/SumireJapanese109AContractTest.kt | 36 +- ...oardTouchEffectPreferenceVisibilityTest.kt | 21 + .../zenz/ZenzBridgePromptContractTest.kt | 34 +- 28 files changed, 2329 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobEffectView.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommand.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommandQueue.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputInjector.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobPerformanceGovernor.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRenderer.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRendererController.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSettings.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulation.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommandQueueTest.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputInjectorTest.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobPerformanceGovernorTest.kt diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index 0615a399..b6aeea50 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 @@ -203,6 +203,8 @@ import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.InkTouc import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.KeyboardTouchEffectQuality import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.KeyboardTouchEffectType import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.LiquidRippleEffectView +import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.LuminousBlobEffectView +import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.LuminousBlobSettings import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.SprayPaintEffectView import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.SprayPaintSettings import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.SuminagashiInkView @@ -322,6 +324,7 @@ import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import java.util.regex.Pattern import javax.inject.Inject +import androidx.appcompat.R as AppCompatR import com.google.android.material.R as MaterialR @AndroidEntryPoint @@ -2168,6 +2171,13 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, palette = keyboardTouchEffectPalettePreference, quality = keyboardTouchEffectQualityPreference ) + mainView.luminousBlobEffectView.clearBlob() + mainView.luminousBlobEffectView.configure( + enabled = false, + colorMode = keyboardTouchEffectColorModePreference, + fixedColor = resolveKeyboardTouchEffectBaseColor(mainView.root), + quality = keyboardTouchEffectQualityPreference + ) (mainView.root as? InkTouchDispatchFrameLayout)?.touchEffectMotionEventListener = null mainView.keyboardBackgroundVideo.isVisible = false @@ -2261,6 +2271,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, clearSpray() pauseSpray() } + mainLayoutBinding?.luminousBlobEffectView?.apply { + clearBlob() + pauseBlob() + } floatingKeyboardBinding?.floatingSuminagashiInkView?.apply { clearInk() pauseInk() @@ -2273,15 +2287,21 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, clearSpray() pauseSpray() } + floatingKeyboardBinding?.floatingLuminousBlobEffectView?.apply { + clearBlob() + pauseBlob() + } } private fun releaseKeyboardTouchEffects() { mainLayoutBinding?.suminagashiInkView?.releaseInk() mainLayoutBinding?.liquidRippleEffectView?.releaseRipple() mainLayoutBinding?.sprayPaintEffectView?.releaseSpray() + mainLayoutBinding?.luminousBlobEffectView?.releaseBlob() floatingKeyboardBinding?.floatingSuminagashiInkView?.releaseInk() floatingKeyboardBinding?.floatingLiquidRippleEffectView?.releaseRipple() floatingKeyboardBinding?.floatingSprayPaintEffectView?.releaseSpray() + floatingKeyboardBinding?.floatingLuminousBlobEffectView?.releaseBlob() } private fun setupMainKeyboardTouchEffect(mainView: MainLayoutBinding) { @@ -2301,6 +2321,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainSurfaceActive && KeyboardTouchEffectType.isLiquidRipple(effectType) val sprayPaintEnabled = mainSurfaceActive && KeyboardTouchEffectType.isSprayPaint(effectType) + val luminousBlobEnabled = + mainSurfaceActive && KeyboardTouchEffectType.isLuminousBlob(effectType) + val effectBaseColor = resolveKeyboardTouchEffectBaseColor(mainView.root) mainView.suminagashiInkView.configure( enabled = inkEnabled, @@ -2320,6 +2343,12 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, palette = keyboardTouchEffectPalettePreference, quality = keyboardTouchEffectQualityPreference ) + mainView.luminousBlobEffectView.configure( + enabled = luminousBlobEnabled, + colorMode = keyboardTouchEffectColorModePreference, + fixedColor = effectBaseColor, + quality = keyboardTouchEffectQualityPreference + ) val root = mainView.root as? InkTouchDispatchFrameLayout root?.touchEffectMotionEventListener = when { @@ -2356,6 +2385,17 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + luminousBlobEnabled -> { + { event -> + dispatchLuminousBlobMotionEvent( + event = event, + sourceRoot = mainView.root, + targetContainer = mainView.keyboardBackgroundContainer, + blobView = mainView.luminousBlobEffectView + ) + } + } + else -> null } } @@ -2379,6 +2419,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, floatingSurfaceActive && KeyboardTouchEffectType.isLiquidRipple(effectType) val sprayPaintEnabled = floatingSurfaceActive && KeyboardTouchEffectType.isSprayPaint(effectType) + val luminousBlobEnabled = + floatingSurfaceActive && KeyboardTouchEffectType.isLuminousBlob(effectType) + val effectBaseColor = resolveKeyboardTouchEffectBaseColor(floatingView.root) floatingView.floatingSuminagashiInkView.configure( enabled = inkEnabled, @@ -2398,6 +2441,12 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, palette = keyboardTouchEffectPalettePreference, quality = keyboardTouchEffectQualityPreference ) + floatingView.floatingLuminousBlobEffectView.configure( + enabled = luminousBlobEnabled, + colorMode = keyboardTouchEffectColorModePreference, + fixedColor = effectBaseColor, + quality = keyboardTouchEffectQualityPreference + ) val root = floatingView.root as? InkTouchDispatchFrameLayout root?.touchEffectMotionEventListener = when { @@ -2434,10 +2483,36 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + luminousBlobEnabled -> { + { event -> + dispatchLuminousBlobMotionEvent( + event = event, + sourceRoot = floatingView.root, + targetContainer = floatingView.floatingKeyboardBackgroundContainer, + blobView = floatingView.floatingLuminousBlobEffectView + ) + } + } + else -> null } } + @ColorInt + private fun resolveKeyboardTouchEffectBaseColor(host: View): Int { + if (keyboardTouchEffectColorModePreference != LuminousBlobSettings.COLOR_MODE_THEME) { + return keyboardTouchEffectColorPreference + } + val fallbackColor = LuminousBlobSettings.DEFAULT_BASE_COLOR + return when (keyboardThemeMode) { + "custom" -> customThemeSpecialKeyColor ?: fallbackColor + else -> host.context.getThemeColorOrFallback( + attrRes = AppCompatR.attr.colorPrimary, + fallbackColor = fallbackColor + ) + } + } + private fun dispatchInkMotionEvent( event: MotionEvent, sourceRoot: View, @@ -2519,6 +2594,33 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) } + private fun dispatchLuminousBlobMotionEvent( + event: MotionEvent, + sourceRoot: View, + targetContainer: View, + blobView: LuminousBlobEffectView + ) { + dispatchTouchEffectMotionEvent( + event = event, + sourceRoot = sourceRoot, + targetContainer = targetContainer, + isEffectShown = { blobView.isShown }, + onPointerDown = { pointerId, x, y -> + blobView.onPointerDown(pointerId = pointerId, x = x, y = y) + }, + onPointerMove = { pointerId, x, y -> + blobView.onPointerMove(pointerId = pointerId, x = x, y = y) + }, + onPointerUp = { pointerId, x, y -> + blobView.onPointerUp(pointerId = pointerId, x = x, y = y) + }, + onPointerUpOutside = { pointerId -> + blobView.onPointerUp(pointerId) + }, + onCancel = { blobView.onCancel() } + ) + } + private fun dispatchTouchEffectMotionEvent( event: MotionEvent, sourceRoot: View, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectType.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectType.kt index 1f2b8824..701b45cf 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectType.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectType.kt @@ -12,6 +12,7 @@ object KeyboardTouchEffectType { const val LIQUID_RIPPLE = "liquid_ripple" const val SPRAY_PAINT = "spray_paint" + const val LUMINOUS_BLOB = "luminous_blob" fun normalize(value: String?): String { return when (value) { @@ -22,6 +23,7 @@ object KeyboardTouchEffectType { AURORA_INK -> AURORA_INK LIQUID_RIPPLE -> LIQUID_RIPPLE SPRAY_PAINT -> SPRAY_PAINT + LUMINOUS_BLOB -> LUMINOUS_BLOB else -> NONE } } @@ -49,4 +51,8 @@ object KeyboardTouchEffectType { fun isSprayPaint(value: String): Boolean { return normalize(value) == SPRAY_PAINT } + + fun isLuminousBlob(value: String): Boolean { + return normalize(value) == LUMINOUS_BLOB + } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobEffectView.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobEffectView.kt new file mode 100644 index 00000000..7be5c448 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobEffectView.kt @@ -0,0 +1,180 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import android.content.Context +import android.graphics.SurfaceTexture +import android.util.AttributeSet +import android.view.TextureView +import android.view.View +import androidx.annotation.ColorInt +import androidx.annotation.VisibleForTesting +import timber.log.Timber + +class LuminousBlobEffectView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TextureView(context, attrs, defStyleAttr), TextureView.SurfaceTextureListener { + + private val inputQueue = LuminousBlobInputCommandQueue() + private val inputInjector = LuminousBlobInputInjector(inputQueue) + + @VisibleForTesting + internal var rendererFactory: LuminousBlobRendererFactory = + LuminousBlobRendererFactory { queue, callback -> + LuminousBlobRenderer( + inputQueue = queue, + callback = callback + ) + } + + private var renderer: LuminousBlobRendererController? = null + private var effectEnabled = false + private var currentSettings = LuminousBlobSettings.Disabled + private var attachedSurfaceTexture: SurfaceTexture? = null + + init { + surfaceTextureListener = this + setOpaque(false) + isClickable = false + isFocusable = false + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + visibility = View.GONE + } + + fun configure( + enabled: Boolean, + colorMode: String, + @ColorInt fixedColor: Int, + quality: String = KeyboardTouchEffectQuality.HIGH + ) { + currentSettings = LuminousBlobSettings( + enabled = enabled, + colorMode = colorMode, + fixedColor = LuminousBlobSettings.withoutTransparentAlpha(fixedColor), + quality = KeyboardTouchEffectQuality.normalize(quality) + ) + + if (!enabled) { + effectEnabled = false + inputInjector.disable() + renderer?.clear() + renderer?.release() + renderer = null + visibility = View.GONE + return + } + + effectEnabled = true + inputInjector.configure(currentSettings) + visibility = View.VISIBLE + + val activeRenderer = ensureRenderer() + activeRenderer.configure(currentSettings) + activeRenderer.resume() + val surface = attachedSurfaceTexture ?: surfaceTexture + if (surface != null && width > 0 && height > 0) { + activeRenderer.attachSurface(surface, width, height) + } + activeRenderer.requestRender() + } + + fun onPointerDown(pointerId: Int, x: Float, y: Float) { + if (!canForwardInput()) return + if (inputInjector.onPointerDown(pointerId, x, y)) { + renderer?.requestRender() + } + } + + fun onPointerMove(pointerId: Int, x: Float, y: Float) { + if (!canForwardInput()) return + if (inputInjector.onPointerMove(pointerId, x, y)) { + renderer?.requestRender() + } + } + + fun onPointerUp(pointerId: Int, x: Float? = null, y: Float? = null) { + if (!effectEnabled) return + if (inputInjector.onPointerUp(pointerId, x, y)) { + renderer?.requestRender() + } + } + + fun onCancel() { + if (!effectEnabled) return + if (inputInjector.onCancel()) { + renderer?.requestRender() + } + } + + fun clearBlob() { + inputInjector.clearActivePointers() + inputQueue.clear() + renderer?.clear() + } + + fun pauseBlob() { + inputInjector.clearActivePointers() + renderer?.pause() + } + + fun releaseBlob() { + inputInjector.disable() + renderer?.release() + renderer = null + effectEnabled = false + visibility = View.GONE + } + + override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { + attachedSurfaceTexture = surface + if (!effectEnabled) return + ensureRenderer().attachSurface(surface, width, height) + } + + override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { + attachedSurfaceTexture = surface + if (!effectEnabled) return + renderer?.resizeSurface(width, height) + } + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + attachedSurfaceTexture = null + renderer?.detachSurface() + return true + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit + + @VisibleForTesting + internal fun pointerStateCountForTesting(): Int = inputInjector.pointerStateCountForTesting() + + @VisibleForTesting + internal fun queuedInputCountForTesting(): Int = inputQueue.sizeForTesting() + + @VisibleForTesting + internal fun hasRendererForTesting(): Boolean = renderer != null + + private fun canForwardInput(): Boolean { + return effectEnabled && visibility == View.VISIBLE + } + + private fun ensureRenderer(): LuminousBlobRendererController { + renderer?.let { return it } + return rendererFactory.create( + inputQueue, + LuminousBlobRendererCallback { reason, throwable -> + Timber.w(throwable, "Keyboard luminous blob effect disabled: %s", reason) + disableAfterRendererFailure() + } + ).also { renderer = it } + } + + private fun disableAfterRendererFailure() { + if (!effectEnabled && renderer == null) return + inputInjector.disable() + renderer?.release() + renderer = null + effectEnabled = false + visibility = View.GONE + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommand.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommand.kt new file mode 100644 index 00000000..e29ae6cf --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommand.kt @@ -0,0 +1,57 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +internal enum class LuminousBlobInputKind { + Down, + Move, + Up +} + +internal data class LuminousBlobActivePointer( + val pointerId: Int, + val x: Float, + val y: Float, + val velocityX: Float, + val velocityY: Float, + val colorSet: LuminousBlobColorSet, + val downTimeMillis: Long, + val lastEventTimeMillis: Long +) + +internal data class LuminousBlobPointerSnapshot( + val x: Float, + val y: Float, + val velocityX: Float, + val velocityY: Float, + val colorSet: LuminousBlobColorSet +) + +internal sealed class LuminousBlobInputCommand { + abstract val eventTimeMillis: Long + + data class Pointer( + val pointerId: Int, + val x: Float, + val y: Float, + val previousX: Float, + val previousY: Float, + val velocityX: Float, + val velocityY: Float, + val colorSet: LuminousBlobColorSet, + val kind: LuminousBlobInputKind, + override val eventTimeMillis: Long + ) : LuminousBlobInputCommand() + + data class PointerUp( + val pointerId: Int, + override val eventTimeMillis: Long + ) : LuminousBlobInputCommand() + + data class PointerCancel( + val pointerId: Int, + override val eventTimeMillis: Long + ) : LuminousBlobInputCommand() + + data class CancelAll( + override val eventTimeMillis: Long + ) : LuminousBlobInputCommand() +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommandQueue.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommandQueue.kt new file mode 100644 index 00000000..40533b5d --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommandQueue.kt @@ -0,0 +1,86 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +internal class LuminousBlobInputCommandQueue( + private val maxSize: Int = DEFAULT_MAX_SIZE +) { + private val lock = Any() + private val commands = ArrayDeque() + + fun offer(command: LuminousBlobInputCommand): Boolean = synchronized(lock) { + if ( + command is LuminousBlobInputCommand.Pointer && + command.kind == LuminousBlobInputKind.Move + ) { + val replaced = replaceLatestMoveForPointerLocked(command) + if (replaced) return@synchronized true + } + + while (commands.size >= maxSize) { + val removedMove = removeOldestMoveLocked() + if (!removedMove) { + if ( + command is LuminousBlobInputCommand.Pointer && + command.kind == LuminousBlobInputKind.Move + ) { + return@synchronized false + } + break + } + } + + commands.addLast(command) + true + } + + fun drain(maxCommands: Int = Int.MAX_VALUE): List = + synchronized(lock) { + if (commands.isEmpty()) return@synchronized emptyList() + val count = minOf(maxCommands, commands.size) + val drained = ArrayList(count) + repeat(count) { + drained.add(commands.removeFirst()) + } + drained + } + + fun clear() = synchronized(lock) { + commands.clear() + } + + fun sizeForTesting(): Int = synchronized(lock) { + commands.size + } + + private fun replaceLatestMoveForPointerLocked( + command: LuminousBlobInputCommand.Pointer + ): Boolean { + for (index in commands.indices.reversed()) { + val existing = commands[index] + if ( + existing is LuminousBlobInputCommand.Pointer && + existing.kind == LuminousBlobInputKind.Move && + existing.pointerId == command.pointerId + ) { + commands[index] = command.copy( + previousX = existing.previousX, + previousY = existing.previousY + ) + return true + } + } + return false + } + + private fun removeOldestMoveLocked(): Boolean { + val index = commands.indexOfFirst { + it is LuminousBlobInputCommand.Pointer && it.kind == LuminousBlobInputKind.Move + } + if (index < 0) return false + commands.removeAt(index) + return true + } + + companion object { + const val DEFAULT_MAX_SIZE = 96 + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputInjector.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputInjector.kt new file mode 100644 index 00000000..99afc700 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputInjector.kt @@ -0,0 +1,185 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import android.graphics.Color +import android.os.SystemClock +import androidx.annotation.ColorInt +import java.util.Random +import kotlin.math.sqrt + +internal class LuminousBlobInputInjector( + private val queue: LuminousBlobInputCommandQueue, + private val clock: () -> Long = { SystemClock.uptimeMillis() }, + private val random: Random = Random() +) { + private data class PointerState( + val colorSet: LuminousBlobColorSet, + val downTimeMillis: Long, + var lastX: Float, + var lastY: Float, + var lastEventTimeMillis: Long + ) + + private val pointerStates = HashMap() + private var settings = LuminousBlobSettings.Disabled + + fun configure(settings: LuminousBlobSettings) { + this.settings = settings.copy( + fixedColor = LuminousBlobSettings.withoutTransparentAlpha(settings.fixedColor) + ) + if (!settings.enabled) { + clearActivePointers() + queue.clear() + } + } + + fun disable() { + settings = LuminousBlobSettings.Disabled + clearActivePointers() + queue.clear() + } + + fun onPointerDown(pointerId: Int, x: Float, y: Float): Boolean { + if (!settings.enabled) return false + val now = clock() + val colorSet = resolveColorSet() + pointerStates[pointerId] = PointerState( + colorSet = colorSet, + downTimeMillis = now, + lastX = x, + lastY = y, + lastEventTimeMillis = now + ) + return queue.offer( + LuminousBlobInputCommand.Pointer( + pointerId = pointerId, + x = x, + y = y, + previousX = x, + previousY = y, + velocityX = 0f, + velocityY = 0f, + colorSet = colorSet, + kind = LuminousBlobInputKind.Down, + eventTimeMillis = now + ) + ) + } + + fun onPointerMove(pointerId: Int, x: Float, y: Float): Boolean { + if (!settings.enabled) return false + val state = pointerStates[pointerId] ?: return false + val now = clock() + val dx = x - state.lastX + val dy = y - state.lastY + val distance = sqrt(dx * dx + dy * dy) + if (distance < MOVE_DISTANCE_EPSILON_PX) return false + + val previousX = state.lastX + val previousY = state.lastY + val dtMillis = (now - state.lastEventTimeMillis).coerceAtLeast(1L) + state.lastX = x + state.lastY = y + state.lastEventTimeMillis = now + + return queue.offer( + LuminousBlobInputCommand.Pointer( + pointerId = pointerId, + x = x, + y = y, + previousX = previousX, + previousY = previousY, + velocityX = (dx / dtMillis).coerceIn(-MAX_SPEED_PX_PER_MS, MAX_SPEED_PX_PER_MS), + velocityY = (dy / dtMillis).coerceIn(-MAX_SPEED_PX_PER_MS, MAX_SPEED_PX_PER_MS), + colorSet = state.colorSet, + kind = LuminousBlobInputKind.Move, + eventTimeMillis = now + ) + ) + } + + fun onPointerUp(pointerId: Int, x: Float? = null, y: Float? = null): Boolean { + val state = pointerStates.remove(pointerId) ?: return false + val now = clock().coerceAtLeast(state.lastEventTimeMillis) + var queued = false + if (settings.enabled && x != null && y != null) { + queued = queue.offer( + LuminousBlobInputCommand.Pointer( + pointerId = pointerId, + x = x, + y = y, + previousX = state.lastX, + previousY = state.lastY, + velocityX = 0f, + velocityY = 0f, + colorSet = state.colorSet, + kind = LuminousBlobInputKind.Up, + eventTimeMillis = now + ) + ) + } + queued = queue.offer( + LuminousBlobInputCommand.PointerUp( + pointerId = pointerId, + eventTimeMillis = now + ) + ) || queued + return queued + } + + fun onPointerCancel(pointerId: Int): Boolean { + val state = pointerStates.remove(pointerId) ?: return false + return queue.offer( + LuminousBlobInputCommand.PointerCancel( + pointerId = pointerId, + eventTimeMillis = clock().coerceAtLeast(state.lastEventTimeMillis) + ) + ) + } + + fun onCancel(): Boolean { + if (!settings.enabled && pointerStates.isEmpty()) return false + pointerStates.clear() + return queue.offer(LuminousBlobInputCommand.CancelAll(eventTimeMillis = clock())) + } + + fun clearActivePointers() { + pointerStates.clear() + } + + fun pointerStateCountForTesting(): Int = pointerStates.size + + private fun resolveColorSet(): LuminousBlobColorSet { + val color = when (settings.normalizedColorMode) { + LuminousBlobSettings.COLOR_MODE_FIXED, + LuminousBlobSettings.COLOR_MODE_THEME -> settings.fixedColor + + else -> randomGoldColor() + } + return LuminousBlobColorSet.fromBase(LuminousBlobColor.fromColorInt(color)) + } + + @ColorInt + private fun randomGoldColor(): Int { + val base = GOLD_COLORS[random.nextInt(GOLD_COLORS.size)] + val shade = 0.94f + random.nextFloat() * 0.16f + return Color.argb( + 255, + (Color.red(base) * shade).toInt().coerceIn(0, 255), + (Color.green(base) * shade).toInt().coerceIn(0, 255), + (Color.blue(base) * shade).toInt().coerceIn(0, 255) + ) + } + + companion object { + private const val MOVE_DISTANCE_EPSILON_PX = 0.7f + private const val MAX_SPEED_PX_PER_MS = 3.2f + + private val GOLD_COLORS = intArrayOf( + Color.rgb(255, 243, 106), + Color.rgb(255, 232, 72), + Color.rgb(255, 214, 41), + Color.rgb(255, 248, 156), + Color.rgb(230, 205, 18) + ) + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobPerformanceGovernor.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobPerformanceGovernor.kt new file mode 100644 index 00000000..3d981019 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobPerformanceGovernor.kt @@ -0,0 +1,187 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +internal enum class LuminousBlobRendererState { + Ambient, + Active, + Settling, + Idle +} + +internal data class LuminousBlobStepParams( + val renderScale: Float, + val edgeSharpness: Float, + val innerStrength: Float, + val shadowStrength: Float, + val pointerGlowRadiusScale: Float, + val pointerRisePerSecond: Float, + val pointerDecayPerSecond: Float, + val driftSpeedScale: Float, + val maxAlpha: Float, + val idleStrengthThreshold: Float +) + +internal class LuminousBlobPerformanceGovernor { + private var userQuality = KeyboardTouchEffectQuality.HIGH + private var qualityLevel = HIGHEST_QUALITY + + fun configureQuality(value: String) { + val normalized = KeyboardTouchEffectQuality.normalize(value) + if (normalized == userQuality) return + userQuality = normalized + qualityLevel = HIGHEST_QUALITY + } + + fun frameIntervalMillis(state: LuminousBlobRendererState): Long { + val profile = qualityProfile(userQuality) + return when (state) { + LuminousBlobRendererState.Active -> fpsToIntervalMillis(profile.activeFps) + LuminousBlobRendererState.Settling -> fpsToIntervalMillis(profile.settlingFps) + LuminousBlobRendererState.Ambient -> fpsToIntervalMillis(profile.ambientFps) + LuminousBlobRendererState.Idle -> 160L + } + } + + fun maxCommandsPerFrame(): Int { + val scale = qualityScale() + return (BASE_MAX_COMMANDS_PER_FRAME * scale).toInt() + .coerceAtLeast(MIN_COMMANDS_PER_FRAME) + } + + fun qualityLevel(): Int = qualityLevel + + fun stepParams(state: LuminousBlobRendererState): LuminousBlobStepParams { + val profile = qualityProfile(userQuality) + val runtimeScale = qualityScale() + val stateAlpha = when (state) { + LuminousBlobRendererState.Active -> 1f + LuminousBlobRendererState.Settling -> 0.96f + LuminousBlobRendererState.Ambient -> 0.9f + LuminousBlobRendererState.Idle -> 0f + } + return LuminousBlobStepParams( + renderScale = (profile.renderScale * runtimeScale) + .coerceIn(MIN_RENDER_SCALE, 1f), + edgeSharpness = profile.edgeSharpness, + innerStrength = profile.innerStrength, + shadowStrength = profile.shadowStrength, + pointerGlowRadiusScale = profile.pointerGlowRadiusScale, + pointerRisePerSecond = profile.pointerRisePerSecond, + pointerDecayPerSecond = profile.pointerDecayPerSecond, + driftSpeedScale = profile.driftSpeedScale, + maxAlpha = profile.maxAlpha * stateAlpha, + idleStrengthThreshold = DEFAULT_IDLE_STRENGTH_THRESHOLD + ) + } + + fun reportFrameTime(frameMillis: Long, state: LuminousBlobRendererState): Boolean { + if (state == LuminousBlobRendererState.Idle) return false + val budget = frameIntervalMillis(state) + return if (frameMillis > budget && qualityLevel > LOWEST_QUALITY) { + qualityLevel -= 1 + true + } else { + false + } + } + + private fun qualityScale(): Float { + return when (qualityLevel.coerceIn(LOWEST_QUALITY, HIGHEST_QUALITY)) { + 0 -> 1f + -1 -> 0.86f + -2 -> 0.72f + else -> 0.58f + } + } + + private fun fpsToIntervalMillis(fps: Int): Long { + return (1000f / fps.coerceAtLeast(1)).toLong().coerceAtLeast(1L) + } + + companion object { + const val HIGHEST_QUALITY = 0 + const val LOWEST_QUALITY = -3 + private const val BASE_MAX_COMMANDS_PER_FRAME = 48 + private const val MIN_COMMANDS_PER_FRAME = 12 + private const val MIN_RENDER_SCALE = 0.35f + private const val DEFAULT_IDLE_STRENGTH_THRESHOLD = 0.018f + + private data class QualityProfile( + val renderScale: Float, + val ambientFps: Int, + val activeFps: Int, + val settlingFps: Int, + val edgeSharpness: Float, + val innerStrength: Float, + val shadowStrength: Float, + val pointerGlowRadiusScale: Float, + val pointerRisePerSecond: Float, + val pointerDecayPerSecond: Float, + val driftSpeedScale: Float, + val maxAlpha: Float + ) + + private fun qualityProfile(userQuality: String): QualityProfile { + return when (KeyboardTouchEffectQuality.normalize(userQuality)) { + KeyboardTouchEffectQuality.BALANCED -> QualityProfile( + renderScale = 0.5f, + ambientFps = 20, + activeFps = 45, + settlingFps = 30, + edgeSharpness = 14.5f, + innerStrength = 0.88f, + shadowStrength = 0.88f, + pointerGlowRadiusScale = 0.20f, + pointerRisePerSecond = 13.5f, + pointerDecayPerSecond = 2.8f, + driftSpeedScale = 0.92f, + maxAlpha = 0.48f + ) + + KeyboardTouchEffectQuality.ULTRA -> QualityProfile( + renderScale = 1f, + ambientFps = 30, + activeFps = 60, + settlingFps = 45, + edgeSharpness = 18.5f, + innerStrength = 1.04f, + shadowStrength = 1.02f, + pointerGlowRadiusScale = 0.24f, + pointerRisePerSecond = 16f, + pointerDecayPerSecond = 2.45f, + driftSpeedScale = 1f, + maxAlpha = 0.56f + ) + + KeyboardTouchEffectQuality.EXTREME -> QualityProfile( + renderScale = 1f, + ambientFps = 30, + activeFps = 60, + settlingFps = 45, + edgeSharpness = 21f, + innerStrength = 1.12f, + shadowStrength = 1.16f, + pointerGlowRadiusScale = 0.26f, + pointerRisePerSecond = 17.5f, + pointerDecayPerSecond = 2.25f, + driftSpeedScale = 1.08f, + maxAlpha = 0.6f + ) + + else -> QualityProfile( + renderScale = 0.75f, + ambientFps = 24, + activeFps = 60, + settlingFps = 40, + edgeSharpness = 16.5f, + innerStrength = 0.98f, + shadowStrength = 0.96f, + pointerGlowRadiusScale = 0.22f, + pointerRisePerSecond = 15f, + pointerDecayPerSecond = 2.6f, + driftSpeedScale = 1f, + maxAlpha = 0.52f + ) + } + } + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRenderer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRenderer.kt new file mode 100644 index 00000000..1f4afade --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRenderer.kt @@ -0,0 +1,542 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import android.graphics.SurfaceTexture +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.EGLContext +import android.opengl.EGLDisplay +import android.opengl.EGLSurface +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.os.Process +import android.view.Surface +import timber.log.Timber +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +internal class LuminousBlobRenderer( + private val inputQueue: LuminousBlobInputCommandQueue, + private val callback: LuminousBlobRendererCallback, + private val mainHandler: Handler = Handler(Looper.getMainLooper()), + private val simulationFactory: () -> LuminousBlobSimulation = { LuminousBlobSimulation() }, + private val clockNanos: () -> Long = { System.nanoTime() } +) : LuminousBlobRendererController { + + private val rendererThread = HandlerThread( + "$THREAD_NAME_PREFIX-${threadIds.incrementAndGet()}", + Process.THREAD_PRIORITY_DISPLAY + ) + private val handler: Handler + private val frameRunnable = Runnable { renderFrameOnRendererThread() } + + private var settings = LuminousBlobSettings.Disabled + private var egl: EglEnvironment? = null + private var simulation: LuminousBlobSimulation? = null + private val performanceGovernor = LuminousBlobPerformanceGovernor() + private var surfaceWidth = 0 + private var surfaceHeight = 0 + private var paused = true + private var released = false + private var frameScheduled = false + private val activePointers = HashMap() + private var pendingReleasedPointer: LuminousBlobPointerSnapshot? = null + private var lastFrameTimeNanos = 0L + private var state = LuminousBlobRendererState.Idle + + init { + rendererThread.start() + handler = Handler(rendererThread.looper) + } + + override fun configure(settings: LuminousBlobSettings) { + postOnRenderer { + val qualityChanged = this.settings.normalizedQuality != settings.normalizedQuality + this.settings = settings + performanceGovernor.configureQuality(settings.normalizedQuality) + simulation?.configure(settings) + if (!settings.enabled) { + clearOnRendererThread() + paused = true + state = LuminousBlobRendererState.Idle + return@postOnRenderer + } + if (qualityChanged && surfaceWidth > 0 && surfaceHeight > 0 && simulation != null) { + simulation?.resizeSurface( + surfaceWidth = surfaceWidth, + surfaceHeight = surfaceHeight, + params = performanceGovernor.stepParams(resolveRendererState()) + ) + } + } + } + + override fun attachSurface(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + postOnRenderer { + if (released || !settings.enabled) return@postOnRenderer + runRendererCatching("attach EGL surface") { + surfaceWidth = width + surfaceHeight = height + state = LuminousBlobRendererState.Ambient + if (egl == null) { + egl = EglEnvironment(surfaceTexture) + simulation = simulationFactory() + simulation?.initialize( + surfaceWidth = width, + surfaceHeight = height, + params = performanceGovernor.stepParams(state), + settings = settings + ) + } else { + simulation?.resizeSurface( + surfaceWidth = width, + surfaceHeight = height, + params = performanceGovernor.stepParams(state) + ) + } + paused = false + requestRenderOnRendererThread(forceSoon = true) + } + } + } + + override fun resizeSurface(width: Int, height: Int) { + postOnRenderer { + if (released || egl == null || !settings.enabled) return@postOnRenderer + runRendererCatching("resize luminous blob surface") { + surfaceWidth = width + surfaceHeight = height + simulation?.resizeSurface( + surfaceWidth = width, + surfaceHeight = height, + params = performanceGovernor.stepParams(resolveRendererState()) + ) + requestRenderOnRendererThread(forceSoon = true) + } + } + } + + override fun detachSurface() { + runBlockingOnRendererThread(maxWaitMillis = 120L) { + handler.removeCallbacks(frameRunnable) + frameScheduled = false + paused = true + state = LuminousBlobRendererState.Idle + simulation?.release() + simulation = null + releaseEglSurfaceOnly() + } + } + + override fun resume() { + postOnRenderer { + if (released || !settings.enabled) return@postOnRenderer + paused = false + requestRenderOnRendererThread(forceSoon = true) + } + } + + override fun requestRender() { + postOnRenderer { + requestRenderOnRendererThread(forceSoon = true) + } + } + + override fun clear() { + postOnRenderer { + clearOnRendererThread() + } + } + + override fun pause() { + postOnRenderer { + paused = true + handler.removeCallbacks(frameRunnable) + frameScheduled = false + activePointers.clear() + pendingReleasedPointer = null + inputQueue.clear() + } + } + + override fun release() { + runBlockingOnRendererThread(maxWaitMillis = 250L) { + if (released) return@runBlockingOnRendererThread + released = true + handler.removeCallbacks(frameRunnable) + frameScheduled = false + inputQueue.clear() + activePointers.clear() + pendingReleasedPointer = null + simulation?.release() + simulation = null + releaseEglSurfaceOnly() + } + rendererThread.quitSafely() + } + + override fun isRendererThreadAliveForTesting(): Boolean { + return rendererThread.isAlive && !released + } + + private fun renderFrameOnRendererThread() { + frameScheduled = false + val activeSimulation = simulation + if (released || paused || !settings.enabled || egl == null || activeSimulation == null) { + return + } + + runRendererCatching("render luminous blob frame") { + val frameStart = clockNanos() + val dtSeconds = if (lastFrameTimeNanos == 0L) { + 1f / 60f + } else { + ((frameStart - lastFrameTimeNanos) / NANOS_PER_SECOND_FLOAT) + .coerceIn(1f / 120f, 1f / 18f) + } + lastFrameTimeNanos = frameStart + + val commands = inputQueue.drain(performanceGovernor.maxCommandsPerFrame()) + val cancelTouch = updatePointerState(commands) + if (cancelTouch) { + activeSimulation.cancelTouch() + } + state = resolveRendererState() + val params = performanceGovernor.stepParams(state) + activeSimulation.render( + pointer = resolvePointerSnapshotForFrame(), + dtSeconds = dtSeconds, + params = params + ) + egl?.swapBuffers() + + val frameMillis = (clockNanos() - frameStart) / 1_000_000L + val qualityChanged = performanceGovernor.reportFrameTime(frameMillis, state) + if (qualityChanged) { + activeSimulation.resizeSurface( + surfaceWidth = surfaceWidth, + surfaceHeight = surfaceHeight, + params = performanceGovernor.stepParams(state) + ) + Timber.d( + "Reduced luminous blob quality to %d", + performanceGovernor.qualityLevel() + ) + } + if (settings.enabled && egl != null && !paused) { + requestRenderOnRendererThread(forceSoon = false) + } + } + } + + private fun updatePointerState(commands: List): Boolean { + var cancelTouch = false + commands.forEach { command -> + when (command) { + is LuminousBlobInputCommand.Pointer -> { + val nextPointer = LuminousBlobActivePointer( + pointerId = command.pointerId, + x = command.x, + y = command.y, + velocityX = command.velocityX, + velocityY = command.velocityY, + colorSet = command.colorSet, + downTimeMillis = command.eventTimeMillis, + lastEventTimeMillis = command.eventTimeMillis + ) + activePointers[command.pointerId] = when (command.kind) { + LuminousBlobInputKind.Down -> nextPointer + LuminousBlobInputKind.Move, + LuminousBlobInputKind.Up -> { + val current = activePointers[command.pointerId] + nextPointer.copy( + downTimeMillis = current?.downTimeMillis + ?: command.eventTimeMillis + ) + } + } + if (command.kind == LuminousBlobInputKind.Up) { + pendingReleasedPointer = activePointers[command.pointerId]?.toSnapshot() + } + } + + is LuminousBlobInputCommand.PointerUp -> { + val removed = activePointers.remove(command.pointerId) + if (activePointers.isEmpty() && pendingReleasedPointer == null) { + pendingReleasedPointer = removed?.toSnapshot() + } + } + + is LuminousBlobInputCommand.PointerCancel -> { + activePointers.remove(command.pointerId) + if (activePointers.isEmpty()) { + pendingReleasedPointer = null + cancelTouch = true + } + } + + is LuminousBlobInputCommand.CancelAll -> { + activePointers.clear() + pendingReleasedPointer = null + cancelTouch = true + } + } + } + return cancelTouch + } + + private fun resolveRendererState(): LuminousBlobRendererState { + val residual = simulation?.hasResidualTouch( + performanceGovernor.stepParams(state).idleStrengthThreshold + ) ?: false + return when { + !settings.enabled || egl == null -> LuminousBlobRendererState.Idle + activePointers.isNotEmpty() -> LuminousBlobRendererState.Active + residual -> LuminousBlobRendererState.Settling + else -> LuminousBlobRendererState.Ambient + } + } + + private fun resolvePointerSnapshotForFrame(): LuminousBlobPointerSnapshot? { + val activeSnapshot = resolveActivePointerSnapshot() + if (activeSnapshot != null) return activeSnapshot + return pendingReleasedPointer.also { + pendingReleasedPointer = null + } + } + + private fun resolveActivePointerSnapshot(): LuminousBlobPointerSnapshot? { + if (activePointers.isEmpty()) return null + var x = 0f + var y = 0f + var velocityX = 0f + var velocityY = 0f + var newest: LuminousBlobActivePointer? = null + activePointers.values.forEach { pointer -> + x += pointer.x + y += pointer.y + velocityX += pointer.velocityX + velocityY += pointer.velocityY + val currentNewest = newest + if (currentNewest == null || pointer.lastEventTimeMillis >= currentNewest.lastEventTimeMillis) { + newest = pointer + } + } + val count = activePointers.size.toFloat() + val colorSet = newest?.colorSet ?: LuminousBlobColorSet.Default + return LuminousBlobPointerSnapshot( + x = x / count, + y = y / count, + velocityX = velocityX / count, + velocityY = velocityY / count, + colorSet = colorSet + ) + } + + private fun LuminousBlobActivePointer.toSnapshot(): LuminousBlobPointerSnapshot { + return LuminousBlobPointerSnapshot( + x = x, + y = y, + velocityX = velocityX, + velocityY = velocityY, + colorSet = colorSet + ) + } + + private fun requestRenderOnRendererThread(forceSoon: Boolean) { + if (released || paused || egl == null || !settings.enabled) return + if (frameScheduled) { + if (!forceSoon) return + handler.removeCallbacks(frameRunnable) + } + val delayMillis = if (forceSoon) 0L else performanceGovernor.frameIntervalMillis(state) + frameScheduled = true + handler.postDelayed(frameRunnable, delayMillis) + } + + private fun clearOnRendererThread() { + inputQueue.clear() + activePointers.clear() + pendingReleasedPointer = null + lastFrameTimeNanos = 0L + simulation?.clear() + } + + private fun postOnRenderer(action: () -> Unit) { + if (released && Looper.myLooper() != handler.looper) return + if (Looper.myLooper() == handler.looper) { + action() + } else { + handler.post(action) + } + } + + private fun runBlockingOnRendererThread(maxWaitMillis: Long, action: () -> Unit) { + if (Looper.myLooper() == handler.looper) { + action() + return + } + if (!rendererThread.isAlive) return + val latch = CountDownLatch(1) + handler.post { + runCatching(action).onFailure { + Timber.w(it, "Failed to run luminous blob renderer cleanup.") + } + latch.countDown() + } + latch.await(maxWaitMillis, TimeUnit.MILLISECONDS) + } + + private fun runRendererCatching(operation: String, action: () -> Unit) { + runCatching(action).onFailure { throwable -> + Timber.w(throwable, "Luminous blob renderer failed during %s", operation) + releaseAfterFailureOnRendererThread() + mainHandler.post { + callback.onRendererDisabled(operation, throwable) + } + } + } + + private fun releaseAfterFailureOnRendererThread() { + handler.removeCallbacks(frameRunnable) + frameScheduled = false + paused = true + state = LuminousBlobRendererState.Idle + inputQueue.clear() + activePointers.clear() + pendingReleasedPointer = null + runCatching { + simulation?.release() + } + simulation = null + releaseEglSurfaceOnly() + } + + private fun releaseEglSurfaceOnly() { + egl?.release() + egl = null + } + + private class EglEnvironment(surfaceTexture: SurfaceTexture) { + private val surface = Surface(surfaceTexture) + private var display: EGLDisplay = EGL14.EGL_NO_DISPLAY + private var context: EGLContext = EGL14.EGL_NO_CONTEXT + private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE + + init { + display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + check(display != EGL14.EGL_NO_DISPLAY) { "eglGetDisplay failed" } + + val version = IntArray(2) + check(EGL14.eglInitialize(display, version, 0, version, 1)) { + "eglInitialize failed: 0x${EGL14.eglGetError().toString(16)}" + } + + val config = chooseConfig(display) + context = EGL14.eglCreateContext( + display, + config, + EGL14.EGL_NO_CONTEXT, + intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE), + 0 + ) + check(context != EGL14.EGL_NO_CONTEXT) { + "eglCreateContext failed: 0x${EGL14.eglGetError().toString(16)}" + } + + eglSurface = EGL14.eglCreateWindowSurface( + display, + config, + surface, + intArrayOf(EGL14.EGL_NONE), + 0 + ) + check(eglSurface != EGL14.EGL_NO_SURFACE) { + "eglCreateWindowSurface failed: 0x${EGL14.eglGetError().toString(16)}" + } + + makeCurrent() + } + + fun swapBuffers() { + check(EGL14.eglSwapBuffers(display, eglSurface)) { + "eglSwapBuffers failed: 0x${EGL14.eglGetError().toString(16)}" + } + } + + fun release() { + if (display != EGL14.EGL_NO_DISPLAY) { + EGL14.eglMakeCurrent( + display, + EGL14.EGL_NO_SURFACE, + EGL14.EGL_NO_SURFACE, + EGL14.EGL_NO_CONTEXT + ) + if (eglSurface != EGL14.EGL_NO_SURFACE) { + EGL14.eglDestroySurface(display, eglSurface) + } + if (context != EGL14.EGL_NO_CONTEXT) { + EGL14.eglDestroyContext(display, context) + } + EGL14.eglTerminate(display) + } + surface.release() + display = EGL14.EGL_NO_DISPLAY + context = EGL14.EGL_NO_CONTEXT + eglSurface = EGL14.EGL_NO_SURFACE + } + + private fun makeCurrent() { + check(EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context)) { + "eglMakeCurrent failed: 0x${EGL14.eglGetError().toString(16)}" + } + } + + private fun chooseConfig(display: EGLDisplay): EGLConfig { + val configs = arrayOfNulls(1) + val numConfigs = IntArray(1) + val attributes = intArrayOf( + EGL14.EGL_RENDERABLE_TYPE, + EGL_OPENGL_ES3_BIT, + EGL14.EGL_SURFACE_TYPE, + EGL14.EGL_WINDOW_BIT, + EGL14.EGL_RED_SIZE, + 8, + EGL14.EGL_GREEN_SIZE, + 8, + EGL14.EGL_BLUE_SIZE, + 8, + EGL14.EGL_ALPHA_SIZE, + 8, + EGL14.EGL_DEPTH_SIZE, + 0, + EGL14.EGL_STENCIL_SIZE, + 0, + EGL14.EGL_NONE + ) + check( + EGL14.eglChooseConfig( + display, + attributes, + 0, + configs, + 0, + configs.size, + numConfigs, + 0 + ) && numConfigs[0] > 0 + ) { + "eglChooseConfig failed: 0x${EGL14.eglGetError().toString(16)}" + } + return configs[0] ?: error("eglChooseConfig returned null") + } + } + + companion object { + const val THREAD_NAME_PREFIX = "LuminousBlobRenderer" + private const val EGL_OPENGL_ES3_BIT = 0x00000040 + private const val NANOS_PER_SECOND_FLOAT = 1_000_000_000f + private val threadIds = AtomicInteger(0) + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRendererController.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRendererController.kt new file mode 100644 index 00000000..b4690891 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobRendererController.kt @@ -0,0 +1,27 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import android.graphics.SurfaceTexture + +internal fun interface LuminousBlobRendererFactory { + fun create( + inputQueue: LuminousBlobInputCommandQueue, + callback: LuminousBlobRendererCallback + ): LuminousBlobRendererController +} + +internal fun interface LuminousBlobRendererCallback { + fun onRendererDisabled(reason: String, throwable: Throwable?) +} + +internal interface LuminousBlobRendererController { + fun configure(settings: LuminousBlobSettings) + fun attachSurface(surfaceTexture: SurfaceTexture, width: Int, height: Int) + fun resizeSurface(width: Int, height: Int) + fun detachSurface() + fun resume() + fun requestRender() + fun clear() + fun pause() + fun release() + fun isRendererThreadAliveForTesting(): Boolean +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSettings.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSettings.kt new file mode 100644 index 00000000..425c634a --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSettings.kt @@ -0,0 +1,126 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import androidx.annotation.ColorInt + +internal data class LuminousBlobSettings( + val enabled: Boolean, + val colorMode: String, + @ColorInt val fixedColor: Int, + val quality: String = KeyboardTouchEffectQuality.HIGH +) { + val normalizedColorMode: String = normalizeColorMode(colorMode) + val normalizedQuality: String = KeyboardTouchEffectQuality.normalize(quality) + + companion object { + const val COLOR_MODE_RANDOM = "random" + const val COLOR_MODE_FIXED = "fixed" + const val COLOR_MODE_PALETTE = "palette" + const val COLOR_MODE_THEME = "theme" + + @ColorInt + const val DEFAULT_BASE_COLOR: Int = 0xFFFFF36A.toInt() + + @ColorInt + const val DEFAULT_EDGE_COLOR: Int = 0xFFFFF9A8.toInt() + + @ColorInt + const val DEFAULT_DEEP_COLOR: Int = 0xFFD6C800.toInt() + + val Disabled = LuminousBlobSettings( + enabled = false, + colorMode = COLOR_MODE_RANDOM, + fixedColor = DEFAULT_BASE_COLOR, + quality = KeyboardTouchEffectQuality.HIGH + ) + + fun normalizeColorMode(value: String?): String { + return when (value) { + COLOR_MODE_FIXED -> COLOR_MODE_FIXED + COLOR_MODE_THEME -> COLOR_MODE_THEME + COLOR_MODE_PALETTE -> COLOR_MODE_PALETTE + else -> COLOR_MODE_RANDOM + } + } + + @ColorInt + fun withoutTransparentAlpha(@ColorInt color: Int): Int { + val alpha = color ushr 24 + return if (alpha == 0) { + FULL_ALPHA_MASK.toInt() or (color and RGB_MASK) + } else { + color + } + } + + private const val FULL_ALPHA_MASK = 0xFF000000 + private const val RGB_MASK = 0x00FFFFFF + } +} + +internal data class LuminousBlobColor( + val red: Float, + val green: Float, + val blue: Float, + val alpha: Float +) { + fun edgeColor(): LuminousBlobColor = mix(White, 0.42f).withMinimumAlpha(0.72f) + + fun deepColor(): LuminousBlobColor = mix(LuminousBlobColor(0.84f, 0.78f, 0f, alpha), 0.42f) + .withMinimumAlpha(0.62f) + + private fun mix(other: LuminousBlobColor, amount: Float): LuminousBlobColor { + val t = amount.coerceIn(0f, 1f) + return LuminousBlobColor( + red = red + (other.red - red) * t, + green = green + (other.green - green) * t, + blue = blue + (other.blue - blue) * t, + alpha = alpha + (other.alpha - alpha) * t + ) + } + + private fun withMinimumAlpha(minimum: Float): LuminousBlobColor { + return copy(alpha = alpha.coerceAtLeast(minimum)) + } + + companion object { + val DefaultBase = fromColorInt(LuminousBlobSettings.DEFAULT_BASE_COLOR) + val DefaultEdge = fromColorInt(LuminousBlobSettings.DEFAULT_EDGE_COLOR) + val DefaultDeep = fromColorInt(LuminousBlobSettings.DEFAULT_DEEP_COLOR) + private val White = LuminousBlobColor(1f, 1f, 1f, 1f) + + fun fromColorInt(@ColorInt color: Int): LuminousBlobColor { + return LuminousBlobColor( + red = ((color shr 16) and 0xFF) / 255f, + green = ((color shr 8) and 0xFF) / 255f, + blue = (color and 0xFF) / 255f, + alpha = ((color ushr 24) / 255f).coerceIn(0.44f, 1f) + ) + } + } +} + +internal data class LuminousBlobColorSet( + val base: LuminousBlobColor, + val edge: LuminousBlobColor, + val deep: LuminousBlobColor +) { + companion object { + val Default = LuminousBlobColorSet( + base = LuminousBlobColor.DefaultBase, + edge = LuminousBlobColor.DefaultEdge, + deep = LuminousBlobColor.DefaultDeep + ) + + fun fromBase(base: LuminousBlobColor): LuminousBlobColorSet { + return if (base == LuminousBlobColor.DefaultBase) { + Default + } else { + LuminousBlobColorSet( + base = base, + edge = base.edgeColor(), + deep = base.deepColor() + ) + } + } + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulation.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulation.kt new file mode 100644 index 00000000..38902e2f --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulation.kt @@ -0,0 +1,522 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import android.opengl.GLES30 +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.max +import kotlin.math.roundToInt +import kotlin.math.sin + +internal class LuminousBlobSimulation { + private data class RenderTarget( + val texture: Int, + val framebuffer: Int, + val width: Int, + val height: Int + ) + + private var surfaceWidth = 0 + private var surfaceHeight = 0 + private var renderTarget: RenderTarget? = null + private var timeSeconds = 0f + private var pointerX = 0.5f + private var pointerY = 0.5f + private var pointerStrength = 0f + private var pointerInitialized = false + private var colorSet = LuminousBlobColorSet.Default + + private var blobProgram = 0 + private var copyProgram = 0 + + private val quadVertices: FloatBuffer = ByteBuffer + .allocateDirect(QUAD.size * Float.SIZE_BYTES) + .order(ByteOrder.nativeOrder()) + .asFloatBuffer() + .apply { + put(QUAD) + position(0) + } + + fun initialize( + surfaceWidth: Int, + surfaceHeight: Int, + params: LuminousBlobStepParams, + settings: LuminousBlobSettings + ) { + require(surfaceWidth > 0 && surfaceHeight > 0) { + "Invalid luminous blob surface size ${surfaceWidth}x$surfaceHeight" + } + if (blobProgram == 0) { + createPrograms() + } + configure(settings) + this.surfaceWidth = surfaceWidth + this.surfaceHeight = surfaceHeight + resizeTarget(params.renderScale) + clearSurface() + } + + fun configure(settings: LuminousBlobSettings) { + colorSet = defaultColorSetFor(settings) + } + + fun resizeSurface( + surfaceWidth: Int, + surfaceHeight: Int, + params: LuminousBlobStepParams + ) { + if (blobProgram == 0) return + this.surfaceWidth = surfaceWidth + this.surfaceHeight = surfaceHeight + resizeTarget(params.renderScale) + } + + fun render( + pointer: LuminousBlobPointerSnapshot?, + dtSeconds: Float, + params: LuminousBlobStepParams + ): Boolean { + if (surfaceWidth <= 0 || surfaceHeight <= 0 || blobProgram == 0) return false + resizeTarget(params.renderScale) + val target = renderTarget ?: return false + val clampedDt = dtSeconds.coerceIn(MIN_DT_SECONDS, MAX_DT_SECONDS) + timeSeconds = (timeSeconds + clampedDt).let { + if (it > TIME_WRAP_SECONDS) it - TIME_WRAP_SECONDS else it + } + updatePointer(pointer, clampedDt, params) + drawBlob(target, params) + blitToSurface(target) + return true + } + + fun hasResidualTouch(threshold: Float): Boolean { + return pointerStrength > threshold + } + + fun cancelTouch() { + pointerStrength = 0f + pointerInitialized = false + } + + fun clear() { + pointerStrength = 0f + pointerInitialized = false + clearRenderTarget() + clearSurface() + } + + fun release() { + releaseTarget(renderTarget) + renderTarget = null + val programs = intArrayOf(blobProgram, copyProgram).filter { it != 0 }.toIntArray() + programs.forEach(GLES30::glDeleteProgram) + blobProgram = 0 + copyProgram = 0 + } + + private fun updatePointer( + pointer: LuminousBlobPointerSnapshot?, + dtSeconds: Float, + params: LuminousBlobStepParams + ) { + if (pointer != null) { + val targetX = (pointer.x / surfaceWidth).coerceIn(0f, 1f) + val targetY = (1f - pointer.y / surfaceHeight).coerceIn(0f, 1f) + val speedX = pointer.velocityX * POINTER_VELOCITY_LEAD_MS / surfaceWidth + val speedY = -pointer.velocityY * POINTER_VELOCITY_LEAD_MS / surfaceHeight + val ledX = (targetX + speedX).coerceIn(0f, 1f) + val ledY = (targetY + speedY).coerceIn(0f, 1f) + val follow = exponentialApproach(params.pointerRisePerSecond, dtSeconds) + if (!pointerInitialized) { + pointerX = ledX + pointerY = ledY + pointerInitialized = true + } else { + pointerX += (ledX - pointerX) * follow + pointerY += (ledY - pointerY) * follow + } + pointerStrength += (1f - pointerStrength) * follow + colorSet = pointer.colorSet + } else { + val decay = exp(-params.pointerDecayPerSecond * dtSeconds) + pointerStrength *= decay + if (pointerStrength < MIN_POINTER_STRENGTH) { + pointerStrength = 0f + pointerInitialized = false + } + } + } + + private fun exponentialApproach(ratePerSecond: Float, dtSeconds: Float): Float { + return (1f - exp(-ratePerSecond * dtSeconds)).coerceIn(0f, 1f) + } + + private fun drawBlob(target: RenderTarget, params: LuminousBlobStepParams) { + GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, target.framebuffer) + GLES30.glViewport(0, 0, target.width, target.height) + GLES30.glDisable(GLES30.GL_DEPTH_TEST) + GLES30.glDisable(GLES30.GL_CULL_FACE) + GLES30.glDisable(GLES30.GL_BLEND) + GLES30.glClearColor(0f, 0f, 0f, 0f) + GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT) + GLES30.glUseProgram(blobProgram) + + val baseRadius = minOf(target.width, target.height) * BASE_RADIUS_SCALE + val centerX = target.width * 0.5f + val centerY = target.height * 0.52f + val pointerPx = pointerX * target.width + val pointerPy = pointerY * target.height + val shadow1X = 0.22f + sin(timeSeconds * 0.23f) * 0.08f + val shadow1Y = -0.16f + cos(timeSeconds * 0.19f) * 0.07f + val shadow2X = -0.28f + cos(timeSeconds * 0.17f) * 0.08f + val shadow2Y = 0.20f + sin(timeSeconds * 0.29f) * 0.06f + + uniform2f(blobProgram, "uResolution", target.width.toFloat(), target.height.toFloat()) + uniform2f(blobProgram, "uCenter", centerX, centerY) + uniform2f(blobProgram, "uPointer", pointerPx, pointerPy) + uniform2f(blobProgram, "uShadow1", shadow1X, shadow1Y) + uniform2f(blobProgram, "uShadow2", shadow2X, shadow2Y) + uniform3f(blobProgram, "uBaseColor", colorSet.base) + uniform3f(blobProgram, "uEdgeColor", colorSet.edge) + uniform3f(blobProgram, "uDeepColor", colorSet.deep) + uniform1f(blobProgram, "uTime", timeSeconds) + uniform1f(blobProgram, "uBaseRadius", baseRadius) + uniform1f(blobProgram, "uPointerStrength", pointerStrength) + uniform1f( + blobProgram, + "uPointerGlowRadius", + minOf(target.width, target.height) * params.pointerGlowRadiusScale + ) + uniform1f(blobProgram, "uEdgeSharpness", params.edgeSharpness) + uniform1f(blobProgram, "uInnerStrength", params.innerStrength) + uniform1f(blobProgram, "uShadowStrength", params.shadowStrength) + uniform1f(blobProgram, "uDriftSpeedScale", params.driftSpeedScale) + uniform1f(blobProgram, "uMaxAlpha", params.maxAlpha * colorSet.base.alpha) + drawQuad() + } + + private fun blitToSurface(target: RenderTarget) { + GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, 0) + GLES30.glViewport(0, 0, surfaceWidth, surfaceHeight) + GLES30.glDisable(GLES30.GL_DEPTH_TEST) + GLES30.glDisable(GLES30.GL_CULL_FACE) + GLES30.glDisable(GLES30.GL_BLEND) + GLES30.glClearColor(0f, 0f, 0f, 0f) + GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT) + GLES30.glUseProgram(copyProgram) + bindTexture(0, target.texture, copyProgram, "uTexture") + drawQuad() + } + + private fun resizeTarget(renderScale: Float) { + val targetWidth = (surfaceWidth * renderScale).roundToInt().coerceAtLeast(MIN_TARGET_SIDE) + val targetHeight = (surfaceHeight * renderScale).roundToInt().coerceAtLeast(MIN_TARGET_SIDE) + val current = renderTarget + if (current != null && current.width == targetWidth && current.height == targetHeight) { + return + } + releaseTarget(current) + renderTarget = createTarget(targetWidth, targetHeight) + } + + private fun clearRenderTarget() { + val target = renderTarget ?: return + GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, target.framebuffer) + GLES30.glViewport(0, 0, target.width, target.height) + GLES30.glClearColor(0f, 0f, 0f, 0f) + GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT) + } + + private fun clearSurface() { + GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, 0) + if (surfaceWidth > 0 && surfaceHeight > 0) { + GLES30.glViewport(0, 0, surfaceWidth, surfaceHeight) + } + GLES30.glClearColor(0f, 0f, 0f, 0f) + GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT) + } + + private fun createTarget(width: Int, height: Int): RenderTarget { + val textures = IntArray(1) + val framebuffers = IntArray(1) + GLES30.glGenTextures(1, textures, 0) + GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, textures[0]) + GLES30.glTexParameteri( + GLES30.GL_TEXTURE_2D, + GLES30.GL_TEXTURE_MIN_FILTER, + GLES30.GL_LINEAR + ) + GLES30.glTexParameteri( + GLES30.GL_TEXTURE_2D, + GLES30.GL_TEXTURE_MAG_FILTER, + GLES30.GL_LINEAR + ) + GLES30.glTexParameteri( + GLES30.GL_TEXTURE_2D, + GLES30.GL_TEXTURE_WRAP_S, + GLES30.GL_CLAMP_TO_EDGE + ) + GLES30.glTexParameteri( + GLES30.GL_TEXTURE_2D, + GLES30.GL_TEXTURE_WRAP_T, + GLES30.GL_CLAMP_TO_EDGE + ) + GLES30.glTexImage2D( + GLES30.GL_TEXTURE_2D, + 0, + GLES30.GL_RGBA8, + width, + height, + 0, + GLES30.GL_RGBA, + GLES30.GL_UNSIGNED_BYTE, + null + ) + + GLES30.glGenFramebuffers(1, framebuffers, 0) + GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, framebuffers[0]) + GLES30.glFramebufferTexture2D( + GLES30.GL_FRAMEBUFFER, + GLES30.GL_COLOR_ATTACHMENT0, + GLES30.GL_TEXTURE_2D, + textures[0], + 0 + ) + val status = GLES30.glCheckFramebufferStatus(GLES30.GL_FRAMEBUFFER) + if (status != GLES30.GL_FRAMEBUFFER_COMPLETE) { + releaseTextureAndFramebuffer(textures[0], framebuffers[0]) + error("Luminous blob framebuffer incomplete: 0x${status.toString(16)}") + } + return RenderTarget( + texture = textures[0], + framebuffer = framebuffers[0], + width = width, + height = height + ) + } + + private fun releaseTarget(target: RenderTarget?) { + if (target == null) return + releaseTextureAndFramebuffer(target.texture, target.framebuffer) + } + + private fun releaseTextureAndFramebuffer(texture: Int, framebuffer: Int) { + GLES30.glDeleteTextures(1, intArrayOf(texture), 0) + GLES30.glDeleteFramebuffers(1, intArrayOf(framebuffer), 0) + } + + private fun bindTexture(unit: Int, texture: Int, program: Int, uniform: String) { + GLES30.glActiveTexture(GLES30.GL_TEXTURE0 + unit) + GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texture) + GLES30.glUniform1i(GLES30.glGetUniformLocation(program, uniform), unit) + } + + private fun uniform1f(program: Int, name: String, value: Float) { + GLES30.glUniform1f(GLES30.glGetUniformLocation(program, name), value) + } + + private fun uniform2f(program: Int, name: String, x: Float, y: Float) { + GLES30.glUniform2f(GLES30.glGetUniformLocation(program, name), x, y) + } + + private fun uniform3f(program: Int, name: String, color: LuminousBlobColor) { + GLES30.glUniform3f(GLES30.glGetUniformLocation(program, name), color.red, color.green, color.blue) + } + + private fun drawQuad() { + quadVertices.position(0) + GLES30.glEnableVertexAttribArray(0) + GLES30.glVertexAttribPointer( + 0, + 2, + GLES30.GL_FLOAT, + false, + 0, + quadVertices + ) + GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4) + GLES30.glDisableVertexAttribArray(0) + } + + private fun createPrograms() { + blobProgram = createProgram(BLOB_FRAGMENT_SHADER) + copyProgram = createProgram(COPY_FRAGMENT_SHADER) + } + + private fun createProgram(fragmentShaderSource: String): Int { + val vertexShader = compileShader(GLES30.GL_VERTEX_SHADER, VERTEX_SHADER) + val fragmentShader = compileShader(GLES30.GL_FRAGMENT_SHADER, fragmentShaderSource) + val program = GLES30.glCreateProgram() + GLES30.glAttachShader(program, vertexShader) + GLES30.glAttachShader(program, fragmentShader) + GLES30.glBindAttribLocation(program, 0, "aPosition") + GLES30.glLinkProgram(program) + val linkStatus = IntArray(1) + GLES30.glGetProgramiv(program, GLES30.GL_LINK_STATUS, linkStatus, 0) + GLES30.glDeleteShader(vertexShader) + GLES30.glDeleteShader(fragmentShader) + if (linkStatus[0] == 0) { + val log = GLES30.glGetProgramInfoLog(program) + GLES30.glDeleteProgram(program) + error("Luminous blob shader program link failed: $log") + } + return program + } + + private fun compileShader(type: Int, source: String): Int { + val shader = GLES30.glCreateShader(type) + GLES30.glShaderSource(shader, source.trimIndent()) + GLES30.glCompileShader(shader) + val compileStatus = IntArray(1) + GLES30.glGetShaderiv(shader, GLES30.GL_COMPILE_STATUS, compileStatus, 0) + if (compileStatus[0] == 0) { + val log = GLES30.glGetShaderInfoLog(shader) + GLES30.glDeleteShader(shader) + error("Luminous blob shader compile failed: $log") + } + return shader + } + + private fun defaultColorSetFor(settings: LuminousBlobSettings): LuminousBlobColorSet { + return when (settings.normalizedColorMode) { + LuminousBlobSettings.COLOR_MODE_FIXED, + LuminousBlobSettings.COLOR_MODE_THEME -> LuminousBlobColorSet.fromBase( + LuminousBlobColor.fromColorInt(settings.fixedColor) + ) + + else -> LuminousBlobColorSet.Default + } + } + + companion object { + private const val BASE_RADIUS_SCALE = 0.64f + private const val MIN_DT_SECONDS = 1f / 120f + private const val MAX_DT_SECONDS = 1f / 18f + private const val TIME_WRAP_SECONDS = 600f + private const val MIN_TARGET_SIDE = 16 + private const val MIN_POINTER_STRENGTH = 0.002f + private const val POINTER_VELOCITY_LEAD_MS = 72f + + private val QUAD = floatArrayOf( + -1f, -1f, + 1f, -1f, + -1f, 1f, + 1f, 1f + ) + + private const val VERTEX_SHADER = """ + #version 300 es + precision highp float; + layout(location = 0) in vec2 aPosition; + out vec2 vUv; + void main() { + vUv = aPosition * 0.5 + 0.5; + gl_Position = vec4(aPosition, 0.0, 1.0); + } + """ + + private const val BLOB_FRAGMENT_SHADER = """ + #version 300 es + precision highp float; + in vec2 vUv; + uniform vec2 uResolution; + uniform vec2 uCenter; + uniform vec2 uPointer; + uniform vec2 uShadow1; + uniform vec2 uShadow2; + uniform vec3 uBaseColor; + uniform vec3 uEdgeColor; + uniform vec3 uDeepColor; + uniform float uTime; + uniform float uBaseRadius; + uniform float uPointerStrength; + uniform float uPointerGlowRadius; + uniform float uEdgeSharpness; + uniform float uInnerStrength; + uniform float uShadowStrength; + uniform float uDriftSpeedScale; + uniform float uMaxAlpha; + out vec4 fragColor; + + void main() { + vec2 fragCoord = vUv * uResolution; + vec2 drift = vec2( + sin(uTime * 0.17 * uDriftSpeedScale), + cos(uTime * 0.13 * uDriftSpeedScale) + ) * uBaseRadius * 0.055; + vec2 center = uCenter + drift; + vec2 p = (fragCoord - center) / max(uBaseRadius, 1.0); + float angle = atan(p.y, p.x); + float radius = length(p); + + vec2 pointerDir = normalize(uPointer - center + vec2(0.0001, 0.0)); + vec2 dir = normalize(fragCoord - center + vec2(0.0001, 0.0)); + float alignment = max(dot(pointerDir, dir), 0.0); + float pull = alignment * uPointerStrength * 0.28; + + float boundary = + 1.0 + + 0.12 * sin(angle * 2.0 + uTime * 0.7 * uDriftSpeedScale) + + 0.07 * sin(angle * 3.0 - uTime * 0.45 * uDriftSpeedScale) + + 0.04 * sin(angle * 5.0 + uTime * 0.25 * uDriftSpeedScale); + boundary += pull; + + float sdf = radius - boundary; + float edgeGlow = exp(-abs(sdf) * uEdgeSharpness); + float outerGlow = exp(-max(sdf, 0.0) * 4.8) * smoothstep(0.42, 0.0, abs(sdf)); + + float inside = smoothstep(0.03, -0.18, sdf); + float cloud = + 0.55 + + 0.25 * sin(p.x * 4.0 + uTime * 0.8 * uDriftSpeedScale) + + 0.20 * sin(p.y * 3.0 - uTime * 0.6 * uDriftSpeedScale) + + 0.15 * sin((p.x + p.y) * 5.0 + uTime * 0.35 * uDriftSpeedScale); + float innerGlow = inside * clamp(cloud, 0.0, 1.0) * uInnerStrength; + + float shadow1 = exp(-length(p - uShadow1) * 4.0); + float shadow2 = exp(-length(p - uShadow2) * 4.5); + float shadow = clamp(shadow1 + shadow2, 0.0, 1.0) * uShadowStrength; + innerGlow *= 1.0 - shadow * 0.75; + + float pointerGlow = + exp(-distance(fragCoord, uPointer) / max(uPointerGlowRadius, 1.0)) * + uPointerStrength; + innerGlow += pointerGlow * 0.35; + + float membrane = clamp(innerGlow, 0.0, 1.0); + float rim = pow(clamp(edgeGlow, 0.0, 1.0), 1.12); + float shadowAlpha = inside * shadow * 0.10; + vec3 color = + uDeepColor * membrane * 0.35 + + uBaseColor * membrane * 0.65 + + uEdgeColor * rim * 0.92 + + uEdgeColor * pointerGlow * 0.28 + + vec3(0.012, 0.010, 0.0) * shadowAlpha; + float alpha = clamp( + membrane * 0.32 + + rim * 0.34 + + outerGlow * 0.08 + + pointerGlow * 0.12 + + shadowAlpha, + 0.0, + uMaxAlpha + ); + fragColor = vec4(color * alpha, alpha); + } + """ + + private const val COPY_FRAGMENT_SHADER = """ + #version 300 es + precision mediump float; + uniform sampler2D uTexture; + in vec2 vUv; + out vec4 fragColor; + void main() { + fragColor = texture(uTexture, vUv); + } + """ + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt index 8a621e29..356a1713 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt @@ -52,8 +52,9 @@ internal fun resolveKeyboardTouchEffectPreferenceVisibility( val isInk = KeyboardTouchEffectType.isLiquidInk(normalizedEffect) || KeyboardTouchEffectType.isAuroraInk(normalizedEffect) val isSprayPaint = KeyboardTouchEffectType.isSprayPaint(normalizedEffect) + val isLuminousBlob = KeyboardTouchEffectType.isLuminousBlob(normalizedEffect) val isEffectEnabled = KeyboardTouchEffectType.isEnabled(normalizedEffect) - val supportsColor = isInk || isSprayPaint + val supportsColor = isInk || isSprayPaint || isLuminousBlob return KeyboardTouchEffectPreferenceVisibility( showQuality = isEffectEnabled, showColorMode = supportsColor, diff --git a/app/src/main/res/layout-land/main_layout.xml b/app/src/main/res/layout-land/main_layout.xml index 96107146..785a2794 100644 --- a/app/src/main/res/layout-land/main_layout.xml +++ b/app/src/main/res/layout-land/main_layout.xml @@ -58,6 +58,15 @@ android:focusable="false" android:importantForAccessibility="no" android:visibility="gone" /> + + + + diff --git a/app/src/main/res/layout/floating_keyboard_layout.xml b/app/src/main/res/layout/floating_keyboard_layout.xml index 923eb62e..c82555df 100644 --- a/app/src/main/res/layout/floating_keyboard_layout.xml +++ b/app/src/main/res/layout/floating_keyboard_layout.xml @@ -62,6 +62,15 @@ android:focusable="false" android:importantForAccessibility="no" android:visibility="gone" /> + + + + diff --git a/app/src/main/res/values-ja/arrays.xml b/app/src/main/res/values-ja/arrays.xml index 8b6a0b5a..787efc86 100644 --- a/app/src/main/res/values-ja/arrays.xml +++ b/app/src/main/res/values-ja/arrays.xml @@ -40,6 +40,7 @@ @string/keyboard_touch_effect_aurora_ink @string/keyboard_touch_effect_liquid_ripple @string/keyboard_touch_effect_type_spray_paint + @string/keyboard_touch_effect_type_luminous_blob @@ -48,6 +49,7 @@ aurora_ink liquid_ripple spray_paint + luminous_blob diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 7f5a4a66..e83c3fdf 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1018,6 +1018,7 @@ オーロラインク 水面の波紋 スプレーペイント + 光の膜 タッチエフェクト品質 キーボードのタッチエフェクトの描画品質を選択します。 標準 @@ -1028,6 +1029,7 @@ 触れた位置からリキッドインクが広がります。 指を離した後も淡いオーロラインクがゆっくり漂います。 触れた位置から、水面のようなやわらかい波紋が広がります。 + 黄色い発光膜がゆっくり漂い、タッチ位置に反応して柔らかく歪みます。 タップ時のエフェクト キーボードに触れた位置から、インクが広がる背景エフェクトを表示します。 インクの色 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 5a50ab2e..505f8c1e 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -50,6 +50,7 @@ @string/keyboard_touch_effect_aurora_ink @string/keyboard_touch_effect_liquid_ripple @string/keyboard_touch_effect_type_spray_paint + @string/keyboard_touch_effect_type_luminous_blob @@ -58,6 +59,7 @@ aurora_ink liquid_ripple spray_paint + luminous_blob diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 25101617..76a799d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1029,6 +1029,7 @@ Aurora Ink Liquid Ripple Spray Paint + Luminous Blob Touch effect quality Choose the rendering quality for keyboard touch effects. Balanced @@ -1039,6 +1040,7 @@ Liquid ink spreads from the touched position. Soft aurora ink keeps drifting after touch and lightly tints the keyboard. Gentle water ripples spread from the touched position. + A glowing luminous membrane drifts softly and deforms around your touch. Liquid Ink Background Shows an ink-diffusion background effect from the touched position on the keyboard. Ink Color diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt index 2867a743..c2cda25c 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt @@ -30,7 +30,8 @@ class KeyboardTouchEffectResourceTest { KeyboardTouchEffectType.LIQUID_INK, KeyboardTouchEffectType.AURORA_INK, KeyboardTouchEffectType.LIQUID_RIPPLE, - KeyboardTouchEffectType.SPRAY_PAINT + KeyboardTouchEffectType.SPRAY_PAINT, + KeyboardTouchEffectType.LUMINOUS_BLOB ), values.toList() ) @@ -54,8 +55,10 @@ class KeyboardTouchEffectResourceTest { assertTrue(englishEntries.contains("Liquid Ink")) assertTrue(englishEntries.contains("Aurora Ink")) + assertTrue(englishEntries.contains("Luminous Blob")) assertTrue(japaneseEntries.contains("リキッドインク")) assertTrue(japaneseEntries.contains("オーロラインク")) + assertTrue(japaneseEntries.contains("光の膜")) val legacyJapaneseLabel = "\u58a8\u6d41\u3057" (englishEntries + japaneseEntries).forEach { label -> diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectTypeTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectTypeTest.kt index 9e08d176..cb63501c 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectTypeTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectTypeTest.kt @@ -41,6 +41,10 @@ class KeyboardTouchEffectTypeTest { KeyboardTouchEffectType.SPRAY_PAINT, KeyboardTouchEffectType.normalize("spray_paint") ) + assertEquals( + KeyboardTouchEffectType.LUMINOUS_BLOB, + KeyboardTouchEffectType.normalize("luminous_blob") + ) } @Test @@ -53,7 +57,9 @@ class KeyboardTouchEffectTypeTest { assertTrue(KeyboardTouchEffectType.isSuminagashi("suminagashi_ink")) assertTrue(KeyboardTouchEffectType.isLiquidRipple("liquid_ripple")) assertTrue(KeyboardTouchEffectType.isSprayPaint("spray_paint")) + assertTrue(KeyboardTouchEffectType.isLuminousBlob("luminous_blob")) assertFalse(KeyboardTouchEffectType.isLiquidRipple("aurora_ink")) assertFalse(KeyboardTouchEffectType.isSprayPaint("aurora_ink")) + assertFalse(KeyboardTouchEffectType.isLuminousBlob("aurora_ink")) } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommandQueueTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommandQueueTest.kt new file mode 100644 index 00000000..3541c397 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputCommandQueueTest.kt @@ -0,0 +1,76 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LuminousBlobInputCommandQueueTest { + + @Test + fun latestMoveForSamePointerReplacesQueuedMoveAndPreservesSegmentStart() { + val queue = LuminousBlobInputCommandQueue(maxSize = 8) + + queue.offer(down(pointerId = 1)) + queue.offer(move(pointerId = 1, previousX = 0f, x = 12f)) + queue.offer(move(pointerId = 1, previousX = 12f, x = 30f)) + + val commands = queue.drain() + + assertEquals(2, commands.size) + val latestMove = commands[1] as LuminousBlobInputCommand.Pointer + assertEquals(LuminousBlobInputKind.Move, latestMove.kind) + assertEquals(0f, latestMove.previousX) + assertEquals(30f, latestMove.x) + } + + @Test + fun downUpAndCancelCommandsArePreservedEvenWhenQueueIsFull() { + val queue = LuminousBlobInputCommandQueue(maxSize = 1) + + queue.offer(down(pointerId = 1)) + assertTrue(queue.offer(LuminousBlobInputCommand.PointerUp(pointerId = 1, eventTimeMillis = 2L))) + assertTrue(queue.offer(LuminousBlobInputCommand.CancelAll(eventTimeMillis = 3L))) + + val commands = queue.drain() + + assertTrue(commands.any { + it is LuminousBlobInputCommand.Pointer && it.kind == LuminousBlobInputKind.Down + }) + assertTrue(commands.any { it is LuminousBlobInputCommand.PointerUp }) + assertTrue(commands.any { it is LuminousBlobInputCommand.CancelAll }) + } + + private fun down(pointerId: Int): LuminousBlobInputCommand.Pointer { + return LuminousBlobInputCommand.Pointer( + pointerId = pointerId, + x = 0f, + y = 0f, + previousX = 0f, + previousY = 0f, + velocityX = 0f, + velocityY = 0f, + colorSet = LuminousBlobColorSet.Default, + kind = LuminousBlobInputKind.Down, + eventTimeMillis = 1L + ) + } + + private fun move( + pointerId: Int, + previousX: Float, + x: Float + ): LuminousBlobInputCommand.Pointer { + return LuminousBlobInputCommand.Pointer( + pointerId = pointerId, + x = x, + y = 0f, + previousX = previousX, + previousY = 0f, + velocityX = 1f, + velocityY = 0f, + colorSet = LuminousBlobColorSet.Default, + kind = LuminousBlobInputKind.Move, + eventTimeMillis = 2L + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputInjectorTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputInjectorTest.kt new file mode 100644 index 00000000..228028c6 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobInputInjectorTest.kt @@ -0,0 +1,71 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Random + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class LuminousBlobInputInjectorTest { + + @Test + fun fixedColorModeCarriesFixedColorIntoDownCommand() { + val queue = LuminousBlobInputCommandQueue() + val injector = LuminousBlobInputInjector( + queue = queue, + clock = { 100L }, + random = Random(0L) + ) + + injector.configure( + LuminousBlobSettings( + enabled = true, + colorMode = LuminousBlobSettings.COLOR_MODE_FIXED, + fixedColor = 0xFF8844CC.toInt() + ) + ) + injector.onPointerDown(pointerId = 7, x = 12f, y = 34f) + + val command = queue.drain().single() as LuminousBlobInputCommand.Pointer + assertEquals(LuminousBlobInputKind.Down, command.kind) + assertEquals(0x88 / 255f, command.colorSet.base.red) + assertEquals(0x44 / 255f, command.colorSet.base.green) + assertEquals(0xCC / 255f, command.colorSet.base.blue) + } + + @Test + fun moveUpdatesVelocityAndCancelClearsPointerState() { + var now = 100L + val queue = LuminousBlobInputCommandQueue() + val injector = LuminousBlobInputInjector( + queue = queue, + clock = { now }, + random = Random(0L) + ) + + injector.configure( + LuminousBlobSettings( + enabled = true, + colorMode = LuminousBlobSettings.COLOR_MODE_RANDOM, + fixedColor = LuminousBlobSettings.DEFAULT_BASE_COLOR + ) + ) + injector.onPointerDown(pointerId = 3, x = 10f, y = 20f) + now = 116L + injector.onPointerMove(pointerId = 3, x = 26f, y = 28f) + injector.onCancel() + + val commands = queue.drain() + val move = commands.filterIsInstance() + .single { it.kind == LuminousBlobInputKind.Move } + + assertEquals(1f, move.velocityX) + assertEquals(0.5f, move.velocityY) + assertEquals(0, injector.pointerStateCountForTesting()) + assertTrue(commands.any { it is LuminousBlobInputCommand.CancelAll }) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobPerformanceGovernorTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobPerformanceGovernorTest.kt new file mode 100644 index 00000000..a2a04e8d --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobPerformanceGovernorTest.kt @@ -0,0 +1,38 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LuminousBlobPerformanceGovernorTest { + + @Test + fun balancedProfileUsesLowerRenderScaleAndAmbientFps() { + val governor = LuminousBlobPerformanceGovernor() + + governor.configureQuality(KeyboardTouchEffectQuality.BALANCED) + + assertEquals( + 0.5f, + governor.stepParams(LuminousBlobRendererState.Ambient).renderScale + ) + assertEquals(50L, governor.frameIntervalMillis(LuminousBlobRendererState.Ambient)) + assertEquals(22L, governor.frameIntervalMillis(LuminousBlobRendererState.Active)) + } + + @Test + fun heavyFrameDropsRuntimeQualityForNextFrame() { + val governor = LuminousBlobPerformanceGovernor() + governor.configureQuality(KeyboardTouchEffectQuality.HIGH) + val before = governor.stepParams(LuminousBlobRendererState.Active).renderScale + + val changed = governor.reportFrameTime( + frameMillis = 200L, + state = LuminousBlobRendererState.Active + ) + val after = governor.stepParams(LuminousBlobRendererState.Active).renderScale + + assertTrue(changed) + assertTrue(after < before) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/physical_keyboard/layout/SumireJapanese109AContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/physical_keyboard/layout/SumireJapanese109AContractTest.kt index 91099c69..d2155ff7 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/physical_keyboard/layout/SumireJapanese109AContractTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/physical_keyboard/layout/SumireJapanese109AContractTest.kt @@ -77,9 +77,9 @@ class SumireJapanese109AContractTest { assertKey(keys, "0", base = "0", shift = null) assertKey(keys, "MINUS", base = "-", shift = "=") assertKey(keys, "EQUALS", base = "^", shift = "~") - assertKey(keys, "LEFT_BRACKET", base = "@", shift = "`") - assertKey(keys, "RIGHT_BRACKET", base = "[", shift = "{") - assertKey(keys, "BACKSLASH", base = "]", shift = "}") + assertKey(keys, "AT", base = "@", shift = "`") + assertKey(keys, "LEFT_BRACKET", base = "[", shift = "{") + assertKey(keys, "RIGHT_BRACKET", base = "]", shift = "}") assertKey(keys, "SEMICOLON", base = ";", shift = "+") assertKey(keys, "APOSTROPHE", base = ":", shift = "*") assertKey(keys, "COMMA", base = ",", shift = "<") @@ -118,29 +118,41 @@ class SumireJapanese109AContractTest { private fun readKcmKeys(): Map { val text = mainResFile("raw/keyboard_layout_japanese_109a.kcm").readText() - return text.lineSequence() - .mapNotNull { Regex("""^\s*key\s+([A-Z0-9_]+)\s*\{(.*)}\s*$""").find(it) } + return Regex("""(?ms)^\s*key\s+([A-Z0-9_]+)\s*\{(.*?)^\s*}\s*$""") + .findAll(text) .associate { match -> val name = match.groupValues[1] val body = match.groupValues[2] val capslock = attribute(body, "capslock") name to KcmKey( base = attribute(body, "base"), - shift = attribute(body, "shift") ?: capslock.takeIf { - body.contains(Regex("""\bshift\s*,""")) - }, + shift = attribute(body, "shift"), capslock = capslock ) } } private fun attribute(body: String, name: String): String? { - return Regex("""\b$name\s*:\s*'((?:\\\\|\\'|[^'])*)'""") - .find(body) + val direct = Regex("""\b$name\s*:\s*'((?:\\\\|\\'|[^'])*)'""") + val combinedShiftCaps = Regex("""\b(?:shift,\s*capslock|capslock,\s*shift)\s*:\s*'((?:\\\\|\\'|[^'])*)'""") + val match = direct.find(body) ?: if (name == "shift" || name == "capslock") { + combinedShiftCaps.find(body) + } else { + null + } + return match ?.groupValues ?.get(1) - ?.replace("\\'", "'") - ?.replace("\\\\", "\\") + ?.let(::unescapeKcmString) + } + + private fun unescapeKcmString(value: String): String { + return Regex("""\\u([0-9a-fA-F]{4})""") + .replace(value) { match -> + match.groupValues[1].toInt(16).toChar().toString() + } + .replace("\\'", "'") + .replace("\\\\", "\\") } private fun readXml(path: String) = DocumentBuilderFactory diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/KeyboardTouchEffectPreferenceVisibilityTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/KeyboardTouchEffectPreferenceVisibilityTest.kt index b477fb04..2b3854a9 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/KeyboardTouchEffectPreferenceVisibilityTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/KeyboardTouchEffectPreferenceVisibilityTest.kt @@ -67,4 +67,25 @@ class KeyboardTouchEffectPreferenceVisibilityTest { assertFalse(none.showFixedColor) assertFalse(none.showPalette) } + + @Test + fun luminousBlobShowsColorModeAndFixedColorOnlyWhenFixedWithoutPalette() { + val random = resolveKeyboardTouchEffectPreferenceVisibility( + effectType = KeyboardTouchEffectType.LUMINOUS_BLOB, + colorMode = "random" + ) + val fixed = resolveKeyboardTouchEffectPreferenceVisibility( + effectType = KeyboardTouchEffectType.LUMINOUS_BLOB, + colorMode = "fixed" + ) + + assertTrue(random.showQuality) + assertTrue(random.showColorMode) + assertFalse(random.showFixedColor) + assertFalse(random.showPalette) + assertTrue(fixed.showQuality) + assertTrue(fixed.showColorMode) + assertTrue(fixed.showFixedColor) + assertFalse(fixed.showPalette) + } } diff --git a/zenz/src/test/java/com/kazumaproject/zenz/ZenzBridgePromptContractTest.kt b/zenz/src/test/java/com/kazumaproject/zenz/ZenzBridgePromptContractTest.kt index 56394565..3acdd8f9 100644 --- a/zenz/src/test/java/com/kazumaproject/zenz/ZenzBridgePromptContractTest.kt +++ b/zenz/src/test/java/com/kazumaproject/zenz/ZenzBridgePromptContractTest.kt @@ -12,15 +12,27 @@ class ZenzBridgePromptContractTest { val source = bridgeSource().readText() val builder = promptBuilder(source) - val leftIndex = builder.indexOf("prompt += leftContextTag + leftContext;") - val rightIndex = builder.indexOf("prompt += rightContextTag + rightContext;") - val inputIndex = builder.indexOf("prompt += inputTag + input + outputTag;") - - assertTrue("left context tag must be appended", leftIndex >= 0) - assertTrue("right context tag must be appended", rightIndex >= 0) - assertTrue("input/output tags must be appended", inputIndex >= 0) - assertTrue("left context must come before right context", leftIndex < rightIndex) - assertTrue("right context must come before input", rightIndex < inputIndex) + val leftTagIndex = builder.indexOf("prompt += leftContextTag;") + val leftContextIndex = builder.indexOf("prompt += leftContext;") + val rightTagIndex = builder.indexOf("prompt += rightContextTag;") + val rightContextIndex = builder.indexOf("prompt += rightContext;") + val inputTagIndex = builder.indexOf("prompt += inputTag;") + val inputIndex = builder.indexOf("prompt += input;") + val outputTagIndex = builder.indexOf("prompt += outputTag;") + + assertTrue("left context tag must be appended", leftTagIndex >= 0) + assertTrue("left context must be appended", leftContextIndex >= 0) + assertTrue("right context tag must be appended", rightTagIndex >= 0) + assertTrue("right context must be appended", rightContextIndex >= 0) + assertTrue("input tag must be appended", inputTagIndex >= 0) + assertTrue("input must be appended", inputIndex >= 0) + assertTrue("output tag must be appended", outputTagIndex >= 0) + assertTrue("left tag must come before left context", leftTagIndex < leftContextIndex) + assertTrue("left context must come before right tag", leftContextIndex < rightTagIndex) + assertTrue("right tag must come before right context", rightTagIndex < rightContextIndex) + assertTrue("right context must come before input tag", rightContextIndex < inputTagIndex) + assertTrue("input tag must come before input", inputTagIndex < inputIndex) + assertTrue("input must come before output tag", inputIndex < outputTagIndex) } @Test @@ -28,9 +40,9 @@ class ZenzBridgePromptContractTest { val source = bridgeSource().readText() val builder = promptBuilder(source) - val tagIndex = source.indexOf("const std::string rightContextTag = u8\"\\uEE07\";") + val tagIndex = Regex("""rightContextTag\[\]\s*=\s*u8"\\uEE07"""").find(source)?.range?.first ?: -1 val guardIndex = builder.indexOf("if (!rightContext.empty())") - val appendIndex = builder.indexOf("prompt += rightContextTag + rightContext;") + val appendIndex = builder.indexOf("prompt += rightContextTag;") assertTrue("right context tag must be U+EE07", tagIndex >= 0) assertTrue("right context append must be guarded", guardIndex >= 0) From 87528e8b60c5beb25c791186685f081c15f525d4 Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Mon, 15 Jun 2026 04:33:37 -0400 Subject: [PATCH 3/8] refactor light orbe --- .../ime_service/IMEService.kt | 84 +++++++++++++- .../image_effect/LuminousBlobSimulation.kt | 104 ++++++++++++++++-- ...uminousBlobImeServiceLayoutContractTest.kt | 36 ++++++ .../LuminousBlobSimulationTest.kt | 101 +++++++++++++++++ ...TouchEffectSimulationResizeContractTest.kt | 6 +- 5 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobImeServiceLayoutContractTest.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulationTest.kt diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index b6aeea50..e584a1b6 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 @@ -2390,7 +2390,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, dispatchLuminousBlobMotionEvent( event = event, sourceRoot = mainView.root, - targetContainer = mainView.keyboardBackgroundContainer, + targetContainer = mainView.luminousBlobEffectView, blobView = mainView.luminousBlobEffectView ) } @@ -2488,7 +2488,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, dispatchLuminousBlobMotionEvent( event = event, sourceRoot = floatingView.root, - targetContainer = floatingView.floatingKeyboardBackgroundContainer, + targetContainer = floatingView.floatingLuminousBlobEffectView, blobView = floatingView.floatingLuminousBlobEffectView ) } @@ -2763,13 +2763,85 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, val chromeHeightPx = (106 * resources.displayMetrics.density).toInt() keyboardHeight + chromeHeightPx } + updateLuminousBlobEffectBounds( + blobView = floatingView.floatingLuminousBlobEffectView, + heightPx = resolveFloatingLuminousBlobKeyboardAreaHeight( + floatingView = floatingView, + fallbackKeyboardHeightPx = fallbackKeyboardHeightPx + ) + ) applyHeight(floatingView.floatingKeyboardContent.height.takeIf { it > 0 } ?: fallbackHeight ?: 0) floatingView.root.post { applyHeight(floatingView.floatingKeyboardContent.height) + updateLuminousBlobEffectBounds( + blobView = floatingView.floatingLuminousBlobEffectView, + heightPx = resolveFloatingLuminousBlobKeyboardAreaHeight( + floatingView = floatingView, + fallbackKeyboardHeightPx = fallbackKeyboardHeightPx + ) + ) } } + private fun updateLuminousBlobEffectBounds( + blobView: LuminousBlobEffectView, + heightPx: Int + ) { + if (heightPx <= 0) return + val currentParams = blobView.layoutParams as? FrameLayout.LayoutParams + val params = currentParams ?: FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + heightPx, + Gravity.BOTTOM + ) + var changed = currentParams == null + + if (params.width != ViewGroup.LayoutParams.MATCH_PARENT) { + params.width = ViewGroup.LayoutParams.MATCH_PARENT + changed = true + } + if (params.height != heightPx) { + params.height = heightPx + changed = true + } + if (params.gravity != Gravity.BOTTOM) { + params.gravity = Gravity.BOTTOM + changed = true + } + if (changed) { + blobView.layoutParams = params + } + } + + private fun resolveFloatingLuminousBlobKeyboardAreaHeight( + floatingView: FloatingKeyboardLayoutBinding, + fallbackKeyboardHeightPx: Int? + ): Int { + fun fixedHeight(view: View): Int { + val layoutHeight = view.layoutParams?.height ?: 0 + return when { + view.height > 0 -> view.height + view.measuredHeight > 0 -> view.measuredHeight + layoutHeight > 0 -> layoutHeight + else -> 0 + } + } + + return when { + floatingView.floatingSymbolKeyboard.isVisible -> + fixedHeight(floatingView.floatingSymbolKeyboard) + + floatingView.candidatesRowView.isVisible -> + fixedHeight(floatingView.candidatesRowView) + + else -> + fixedHeight(floatingView.floatingKeyboardContainer) + }.takeIf { it > 0 } + ?: fallbackKeyboardHeightPx + ?: applicationContext.dpToPx(200) + } + private fun updateFloatingFullCandidatesHeight( floatingView: FloatingKeyboardLayoutBinding, heightPx: Int @@ -14388,6 +14460,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView = mainView, heightPx = backgroundSurfaceHeight ) + updateLuminousBlobEffectBounds( + blobView = mainView.luminousBlobEffectView, + heightPx = heightPx + ) mainView.root.setPadding(0, 0, 0, systemBottomInset) } @@ -14557,6 +14633,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView = mainView, heightPx = backgroundSurfaceHeight ) + updateLuminousBlobEffectBounds( + blobView = mainView.luminousBlobEffectView, + heightPx = heightPx + ) // Adjust suggestion view constraints since it's no longer attached to the parent bottom val params = mainView.suggestionVisibility.layoutParams as ConstraintLayout.LayoutParams diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulation.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulation.kt index 38902e2f..861e9322 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulation.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulation.kt @@ -6,7 +6,7 @@ import java.nio.ByteOrder import java.nio.FloatBuffer import kotlin.math.cos import kotlin.math.exp -import kotlin.math.max +import kotlin.math.abs import kotlin.math.roundToInt import kotlin.math.sin @@ -26,6 +26,7 @@ internal class LuminousBlobSimulation { private var pointerY = 0.5f private var pointerStrength = 0f private var pointerInitialized = false + private var stableBaseRadiusSurfacePx = 0f private var colorSet = LuminousBlobColorSet.Default private var blobProgram = 0 @@ -55,6 +56,7 @@ internal class LuminousBlobSimulation { configure(settings) this.surfaceWidth = surfaceWidth this.surfaceHeight = surfaceHeight + stableBaseRadiusSurfacePx = calculateContainedBaseRadius(surfaceWidth, surfaceHeight) resizeTarget(params.renderScale) clearSurface() } @@ -69,8 +71,17 @@ internal class LuminousBlobSimulation { params: LuminousBlobStepParams ) { if (blobProgram == 0) return + val previousWidth = this.surfaceWidth + val previousHeight = this.surfaceHeight this.surfaceWidth = surfaceWidth this.surfaceHeight = surfaceHeight + stableBaseRadiusSurfacePx = resolveStableBaseRadiusAfterResize( + currentRadius = stableBaseRadiusSurfacePx, + previousWidth = previousWidth, + previousHeight = previousHeight, + newWidth = surfaceWidth, + newHeight = surfaceHeight + ) resizeTarget(params.renderScale) } @@ -111,6 +122,7 @@ internal class LuminousBlobSimulation { fun release() { releaseTarget(renderTarget) renderTarget = null + stableBaseRadiusSurfacePx = 0f val programs = intArrayOf(blobProgram, copyProgram).filter { it != 0 }.toIntArray() programs.forEach(GLES30::glDeleteProgram) blobProgram = 0 @@ -164,9 +176,9 @@ internal class LuminousBlobSimulation { GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT) GLES30.glUseProgram(blobProgram) - val baseRadius = minOf(target.width, target.height) * BASE_RADIUS_SCALE + val baseRadius = baseRadiusForTarget(target) val centerX = target.width * 0.5f - val centerY = target.height * 0.52f + val centerY = target.height * 0.5f val pointerPx = pointerX * target.width val pointerPy = pointerY * target.height val shadow1X = 0.22f + sin(timeSeconds * 0.23f) * 0.08f @@ -190,6 +202,7 @@ internal class LuminousBlobSimulation { "uPointerGlowRadius", minOf(target.width, target.height) * params.pointerGlowRadiusScale ) + uniform1f(blobProgram, "uOuterGlowReach", OUTER_GLOW_REACH) uniform1f(blobProgram, "uEdgeSharpness", params.edgeSharpness) uniform1f(blobProgram, "uInnerStrength", params.innerStrength) uniform1f(blobProgram, "uShadowStrength", params.shadowStrength) @@ -198,6 +211,22 @@ internal class LuminousBlobSimulation { drawQuad() } + private fun baseRadiusForTarget(target: RenderTarget): Float { + val radiusSurfacePx = stableBaseRadiusSurfacePx.takeIf { it > 0f } + ?: calculateContainedBaseRadius(surfaceWidth, surfaceHeight) + val scaleX = if (surfaceWidth > 0) { + target.width / surfaceWidth.toFloat() + } else { + 1f + } + val scaleY = if (surfaceHeight > 0) { + target.height / surfaceHeight.toFloat() + } else { + 1f + } + return radiusSurfacePx * minOf(scaleX, scaleY) + } + private fun blitToSurface(target: RenderTarget) { GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, 0) GLES30.glViewport(0, 0, surfaceWidth, surfaceHeight) @@ -392,7 +421,13 @@ internal class LuminousBlobSimulation { } companion object { - private const val BASE_RADIUS_SCALE = 0.64f + private const val PREFERRED_BASE_RADIUS_SCALE = 0.34f + private const val MAX_BOUNDARY_WOBBLE = 0.23f + private const val MAX_POINTER_PULL = 0.28f + private const val OUTER_GLOW_REACH = 0.22f + private const val CENTER_SAFETY_SCALE = 0.94f + private const val STRUCTURAL_WIDTH_CHANGE_RATIO = 0.08f + private const val MIN_STRUCTURAL_WIDTH_CHANGE_PX = 12 private const val MIN_DT_SECONDS = 1f / 120f private const val MAX_DT_SECONDS = 1f / 18f private const val TIME_WRAP_SECONDS = 600f @@ -400,6 +435,53 @@ internal class LuminousBlobSimulation { private const val MIN_POINTER_STRENGTH = 0.002f private const val POINTER_VELOCITY_LEAD_MS = 72f + internal fun calculateContainedBaseRadius(width: Int, height: Int): Float { + if (width <= 0 || height <= 0) return 0f + val shortSide = minOf(width, height).toFloat() + val preferredRadius = shortSide * PREFERRED_BASE_RADIUS_SCALE + val maxVisualExtent = + 1f + MAX_BOUNDARY_WOBBLE + MAX_POINTER_PULL + OUTER_GLOW_REACH + val containedRadius = (shortSide * 0.5f * CENTER_SAFETY_SCALE) / maxVisualExtent + return minOf(preferredRadius, containedRadius).coerceAtLeast(1f) + } + + internal fun resolveStableBaseRadiusAfterResize( + currentRadius: Float, + previousWidth: Int, + previousHeight: Int, + newWidth: Int, + newHeight: Int + ): Float { + return if ( + currentRadius <= 0f || + shouldRecalculateStableBaseRadius(previousWidth, previousHeight, newWidth, newHeight) + ) { + calculateContainedBaseRadius(newWidth, newHeight) + } else { + currentRadius + } + } + + internal fun shouldRecalculateStableBaseRadius( + previousWidth: Int, + previousHeight: Int, + newWidth: Int, + newHeight: Int + ): Boolean { + if (previousWidth <= 0 || previousHeight <= 0 || newWidth <= 0 || newHeight <= 0) { + return true + } + val orientationChanged = (previousWidth >= previousHeight) != (newWidth >= newHeight) + if (orientationChanged) return true + + val widthDelta = abs(newWidth - previousWidth) + val structuralWidthDelta = maxOf( + MIN_STRUCTURAL_WIDTH_CHANGE_PX, + (previousWidth * STRUCTURAL_WIDTH_CHANGE_RATIO).roundToInt() + ) + return widthDelta >= structuralWidthDelta + } + private val QUAD = floatArrayOf( -1f, -1f, 1f, -1f, @@ -434,6 +516,7 @@ internal class LuminousBlobSimulation { uniform float uBaseRadius; uniform float uPointerStrength; uniform float uPointerGlowRadius; + uniform float uOuterGlowReach; uniform float uEdgeSharpness; uniform float uInnerStrength; uniform float uShadowStrength; @@ -443,11 +526,7 @@ internal class LuminousBlobSimulation { void main() { vec2 fragCoord = vUv * uResolution; - vec2 drift = vec2( - sin(uTime * 0.17 * uDriftSpeedScale), - cos(uTime * 0.13 * uDriftSpeedScale) - ) * uBaseRadius * 0.055; - vec2 center = uCenter + drift; + vec2 center = uCenter; vec2 p = (fragCoord - center) / max(uBaseRadius, 1.0); float angle = atan(p.y, p.x); float radius = length(p); @@ -466,7 +545,10 @@ internal class LuminousBlobSimulation { float sdf = radius - boundary; float edgeGlow = exp(-abs(sdf) * uEdgeSharpness); - float outerGlow = exp(-max(sdf, 0.0) * 4.8) * smoothstep(0.42, 0.0, abs(sdf)); + float outerGlowMask = 1.0 - smoothstep(0.0, uOuterGlowReach, abs(sdf)); + float outerGlow = + exp(-max(sdf, 0.0) * 5.8) * + outerGlowMask; float inside = smoothstep(0.03, -0.18, sdf); float cloud = @@ -484,6 +566,8 @@ internal class LuminousBlobSimulation { float pointerGlow = exp(-distance(fragCoord, uPointer) / max(uPointerGlowRadius, 1.0)) * uPointerStrength; + float pointerMembraneMask = 1.0 - smoothstep(-0.04, uOuterGlowReach, sdf); + pointerGlow *= pointerMembraneMask; innerGlow += pointerGlow * 0.35; float membrane = clamp(innerGlow, 0.0, 1.0); diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobImeServiceLayoutContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobImeServiceLayoutContractTest.kt new file mode 100644 index 00000000..548621a4 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobImeServiceLayoutContractTest.kt @@ -0,0 +1,36 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class LuminousBlobImeServiceLayoutContractTest { + + @Test + fun luminousBlobUsesOwnStableViewAsTouchTarget() { + val text = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt" + ).readText() + + assertTrue(text.contains("targetContainer = mainView.luminousBlobEffectView")) + assertTrue(text.contains("targetContainer = floatingView.floatingLuminousBlobEffectView")) + } + + @Test + fun luminousBlobViewIsPinnedToKeyboardAreaNotBackgroundChrome() { + val text = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt" + ).readText() + + assertTrue(text.contains("private fun updateLuminousBlobEffectBounds")) + assertTrue(text.contains("blobView = mainView.luminousBlobEffectView")) + assertTrue(text.contains("blobView = floatingView.floatingLuminousBlobEffectView")) + assertTrue(text.contains("params.height = heightPx")) + assertTrue(text.contains("params.gravity = Gravity.BOTTOM")) + } + + private fun mainFile(path: String): File { + val moduleFile = File("src/main/$path") + return if (moduleFile.exists()) moduleFile else File("app/src/main/$path") + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulationTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulationTest.kt new file mode 100644 index 00000000..cd280d2e --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LuminousBlobSimulationTest.kt @@ -0,0 +1,101 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LuminousBlobSimulationTest { + + @Test + fun containedBaseRadiusUsesKeyboardShortSide() { + val wideKeyboardRadius = LuminousBlobSimulation.calculateContainedBaseRadius( + width = 1080, + height = 300 + ) + val tallKeyboardRadius = LuminousBlobSimulation.calculateContainedBaseRadius( + width = 300, + height = 1080 + ) + + assertEquals(wideKeyboardRadius, tallKeyboardRadius, 0.001f) + } + + @Test + fun containedBaseRadiusScalesWithKeyboardSize() { + val compactKeyboardRadius = LuminousBlobSimulation.calculateContainedBaseRadius( + width = 1080, + height = 300 + ) + val tallerKeyboardRadius = LuminousBlobSimulation.calculateContainedBaseRadius( + width = 1080, + height = 600 + ) + + assertTrue(tallerKeyboardRadius > compactKeyboardRadius) + assertEquals(compactKeyboardRadius * 2f, tallerKeyboardRadius, 0.01f) + } + + @Test + fun containedBaseRadiusLeavesRoomForPullAndGlow() { + val keyboardHeight = 300 + val radius = LuminousBlobSimulation.calculateContainedBaseRadius( + width = 1080, + height = keyboardHeight + ) + + assertTrue(radius <= keyboardHeight * 0.28f) + } + + @Test + fun stableRadiusIgnoresHeightOnlyResizeFromComposingText() { + val currentRadius = LuminousBlobSimulation.calculateContainedBaseRadius( + width = 1080, + height = 360 + ) + + val resolvedRadius = LuminousBlobSimulation.resolveStableBaseRadiusAfterResize( + currentRadius = currentRadius, + previousWidth = 1080, + previousHeight = 360, + newWidth = 1080, + newHeight = 300 + ) + + assertEquals(currentRadius, resolvedRadius, 0.001f) + } + + @Test + fun stableRadiusRecalculatesForStructuralWidthResize() { + val currentRadius = LuminousBlobSimulation.calculateContainedBaseRadius( + width = 1080, + height = 360 + ) + val expectedRadius = LuminousBlobSimulation.calculateContainedBaseRadius( + width = 300, + height = 360 + ) + + val resolvedRadius = LuminousBlobSimulation.resolveStableBaseRadiusAfterResize( + currentRadius = currentRadius, + previousWidth = 1080, + previousHeight = 360, + newWidth = 300, + newHeight = 360 + ) + + assertEquals(expectedRadius, resolvedRadius, 0.001f) + assertTrue(resolvedRadius < currentRadius) + } + + @Test + fun stableRadiusRecalculatesForOrientationChange() { + assertTrue( + LuminousBlobSimulation.shouldRecalculateStableBaseRadius( + previousWidth = 1080, + previousHeight = 360, + newWidth = 360, + newHeight = 1080 + ) + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/TouchEffectSimulationResizeContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/TouchEffectSimulationResizeContractTest.kt index 734f5cb2..aa7a3a86 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/TouchEffectSimulationResizeContractTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/TouchEffectSimulationResizeContractTest.kt @@ -57,7 +57,8 @@ class TouchEffectSimulationResizeContractTest { fun rendererResizeAndPauseDoNotClearVisibleSimulationState() { listOf( "LiquidRippleRenderer.kt", - "SprayPaintRenderer.kt" + "SprayPaintRenderer.kt", + "LuminousBlobRenderer.kt" ).forEach { fileName -> val lines = mainFile( "java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/$fileName" @@ -75,7 +76,8 @@ class TouchEffectSimulationResizeContractTest { fun effectViewSizeChangedOnlyResizesRenderer() { listOf( "LiquidRippleEffectView.kt", - "SprayPaintEffectView.kt" + "SprayPaintEffectView.kt", + "LuminousBlobEffectView.kt" ).forEach { fileName -> val lines = mainFile( "java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/$fileName" From 4e9083c5e3b73af6e1c6b627ecf81d822ae3721c Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:41:26 -0400 Subject: [PATCH 4/8] fix flickering --- .../ime_service/IMEService.kt | 115 ++++++++++++- .../image_effect/FluidInkRenderer.kt | 2 + .../image_effect/LiquidRippleRenderer.kt | 2 + .../image_effect/SprayPaintRenderer.kt | 2 + app/src/main/res/layout-land/main_layout.xml | 10 ++ .../main/res/layout-sw600dp/main_layout.xml | 10 ++ .../res/layout/floating_keyboard_layout.xml | 9 + app/src/main/res/layout/main_layout.xml | 10 ++ ...eyboardTouchEffectContainerContractTest.kt | 154 ++++++++++++++++++ 9 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectContainerContractTest.kt diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index e584a1b6..ef56e12a 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 @@ -2357,7 +2357,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, dispatchInkMotionEvent( event = event, sourceRoot = mainView.root, - targetContainer = mainView.keyboardBackgroundContainer, + targetContainer = mainView.keyboardTouchEffectContainer, inkView = mainView.suminagashiInkView ) } @@ -2368,7 +2368,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, dispatchLiquidRippleMotionEvent( event = event, sourceRoot = mainView.root, - targetContainer = mainView.keyboardBackgroundContainer, + targetContainer = mainView.keyboardTouchEffectContainer, rippleView = mainView.liquidRippleEffectView ) } @@ -2379,7 +2379,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, dispatchSprayPaintMotionEvent( event = event, sourceRoot = mainView.root, - targetContainer = mainView.keyboardBackgroundContainer, + targetContainer = mainView.keyboardTouchEffectContainer, sprayView = mainView.sprayPaintEffectView ) } @@ -2455,7 +2455,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, dispatchInkMotionEvent( event = event, sourceRoot = floatingView.root, - targetContainer = floatingView.floatingKeyboardBackgroundContainer, + targetContainer = floatingView.floatingKeyboardTouchEffectContainer, inkView = floatingView.floatingSuminagashiInkView ) } @@ -2466,7 +2466,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, dispatchLiquidRippleMotionEvent( event = event, sourceRoot = floatingView.root, - targetContainer = floatingView.floatingKeyboardBackgroundContainer, + targetContainer = floatingView.floatingKeyboardTouchEffectContainer, rippleView = floatingView.floatingLiquidRippleEffectView ) } @@ -2477,7 +2477,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, dispatchSprayPaintMotionEvent( event = event, sourceRoot = floatingView.root, - targetContainer = floatingView.floatingKeyboardBackgroundContainer, + targetContainer = floatingView.floatingKeyboardTouchEffectContainer, sprayView = floatingView.floatingSprayPaintEffectView ) } @@ -2772,8 +2772,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) applyHeight(floatingView.floatingKeyboardContent.height.takeIf { it > 0 } ?: fallbackHeight ?: 0) + updateFloatingKeyboardTouchEffectBounds(floatingView) floatingView.root.post { applyHeight(floatingView.floatingKeyboardContent.height) + updateFloatingKeyboardTouchEffectBounds(floatingView) updateLuminousBlobEffectBounds( blobView = floatingView.floatingLuminousBlobEffectView, heightPx = resolveFloatingLuminousBlobKeyboardAreaHeight( @@ -2784,6 +2786,62 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + private fun updateFloatingKeyboardTouchEffectBounds( + floatingView: FloatingKeyboardLayoutBinding + ) { + val target = when { + floatingView.floatingSymbolKeyboard.isVisible -> floatingView.floatingSymbolKeyboard + floatingView.candidatesRowView.isVisible -> floatingView.candidatesRowView + else -> floatingView.floatingKeyboardContainer + } + + fun applyBounds() { + if (target.width <= 0 || target.height <= 0) return + + val rootLocation = IntArray(2) + val targetLocation = IntArray(2) + floatingView.root.getLocationInWindow(rootLocation) + target.getLocationInWindow(targetLocation) + + val params = + floatingView.floatingKeyboardTouchEffectContainer.layoutParams as? FrameLayout.LayoutParams + ?: return + var changed = false + val left = targetLocation[0] - rootLocation[0] + val top = targetLocation[1] - rootLocation[1] + + if (params.width != target.width) { + params.width = target.width + changed = true + } + if (params.height != target.height) { + params.height = target.height + changed = true + } + if (params.leftMargin != left) { + params.leftMargin = left + changed = true + } + if (params.topMargin != top) { + params.topMargin = top + changed = true + } + + val expectedGravity = Gravity.TOP or Gravity.START + if (params.gravity != expectedGravity) { + params.gravity = expectedGravity + changed = true + } + + if (changed) { + floatingView.floatingKeyboardTouchEffectContainer.layoutParams = params + } + } + + applyBounds() + floatingView.root.post { applyBounds() } + } + private fun updateLuminousBlobEffectBounds( blobView: LuminousBlobEffectView, heightPx: Int @@ -4275,6 +4333,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } updateFloatingKeyboardBackgroundBounds(floatingKeyboardLayoutBinding, heightPx) updateFloatingFullCandidatesHeight(floatingKeyboardLayoutBinding, heightPx) + updateFloatingKeyboardTouchEffectBounds(floatingKeyboardLayoutBinding) } keyboardContainer?.let { container -> @@ -13282,6 +13341,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, floatingKeyboardLayoutBinding.floatingSymbolKeyboard.isVisible = false renderCurrentKeyboardStateOnActiveSurface() } + updateFloatingKeyboardTouchEffectBounds(floatingKeyboardLayoutBinding) } } else { setKeyboardSizeForHeightSymbol(mainView, isSymbolKeyboardShow.isShown) @@ -14410,6 +14470,31 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + private fun updateNormalKeyboardTouchEffectBounds( + mainView: MainLayoutBinding, + keyboardBodyHeightPx: Int + ) { + if (keyboardBodyHeightPx <= 0) return + + val container = mainView.keyboardTouchEffectContainer + val params = container.layoutParams as? FrameLayout.LayoutParams ?: return + var changed = false + + if (params.height != keyboardBodyHeightPx) { + params.height = keyboardBodyHeightPx + changed = true + } + + if (params.gravity != Gravity.BOTTOM) { + params.gravity = Gravity.BOTTOM + changed = true + } + + if (changed) { + container.layoutParams = params + } + } + private fun applyKeyboardLayoutParameters( mainView: MainLayoutBinding, heightPx: Int, @@ -14460,6 +14545,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView = mainView, heightPx = backgroundSurfaceHeight ) + updateNormalKeyboardTouchEffectBounds( + mainView = mainView, + keyboardBodyHeightPx = heightPx + ) updateLuminousBlobEffectBounds( blobView = mainView.luminousBlobEffectView, heightPx = heightPx @@ -14633,6 +14722,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView = mainView, heightPx = backgroundSurfaceHeight ) + updateNormalKeyboardTouchEffectBounds( + mainView = mainView, + keyboardBodyHeightPx = heightPx + ) updateLuminousBlobEffectBounds( blobView = mainView.luminousBlobEffectView, heightPx = heightPx @@ -14711,6 +14804,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, floatingKeyboardLayoutBinding.suggestionVisibility.apply { this.setImageDrawable(if (isVisible) cachedArrowDropDownDrawable else cachedArrowDropUpDrawable) } + updateFloatingKeyboardTouchEffectBounds(floatingKeyboardLayoutBinding) + floatingKeyboardLayoutBinding.root.postDelayed( + { updateFloatingKeyboardTouchEffectBounds(floatingKeyboardLayoutBinding) }, + 240L + ) } } animateViewVisibility(mainView.candidatesRowView, !isVisible) @@ -16917,7 +17015,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, if (keyboardSymbolViewState.value.isShown) { _keyboardSymbolViewState.value = SymbolKeyboardState() } - floatingKeyboardBinding?.floatingSymbolKeyboard?.isVisible = false + floatingKeyboardBinding?.let { floatingView -> + floatingView.floatingSymbolKeyboard.isVisible = false + updateFloatingKeyboardTouchEffectBounds(floatingView) + } } private fun currentKeyboardLayoutEditOrientation(): KeyboardLayoutEditOrientation { diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/FluidInkRenderer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/FluidInkRenderer.kt index 4e37e26d..5e764cf0 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/FluidInkRenderer.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/FluidInkRenderer.kt @@ -102,6 +102,8 @@ internal class FluidInkRenderer( override fun resizeSurface(width: Int, height: Int) { postOnRenderer { + if (width <= 0 || height <= 0) return@postOnRenderer + if (width == surfaceWidth && height == surfaceHeight) return@postOnRenderer if (released || egl == null || !settings.enabled) return@postOnRenderer runRendererCatching("resize fluid surface") { surfaceWidth = width diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LiquidRippleRenderer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LiquidRippleRenderer.kt index 27b84406..1cb90c9b 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LiquidRippleRenderer.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/LiquidRippleRenderer.kt @@ -101,6 +101,8 @@ internal class LiquidRippleRenderer( override fun resizeSurface(width: Int, height: Int) { postOnRenderer { + if (width <= 0 || height <= 0) return@postOnRenderer + if (width == surfaceWidth && height == surfaceHeight) return@postOnRenderer if (released || egl == null || !settings.enabled) return@postOnRenderer runRendererCatching("resize liquid ripple surface") { surfaceWidth = width diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/SprayPaintRenderer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/SprayPaintRenderer.kt index 7c524a67..1f9872cc 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/SprayPaintRenderer.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/SprayPaintRenderer.kt @@ -103,6 +103,8 @@ internal class SprayPaintRenderer( override fun resizeSurface(width: Int, height: Int) { postOnRenderer { + if (width <= 0 || height <= 0) return@postOnRenderer + if (width == surfaceWidth && height == surfaceHeight) return@postOnRenderer if (released || egl == null || !settings.enabled) return@postOnRenderer runRendererCatching("resize spray paint surface") { surfaceWidth = width diff --git a/app/src/main/res/layout-land/main_layout.xml b/app/src/main/res/layout-land/main_layout.xml index 785a2794..9363d349 100644 --- a/app/src/main/res/layout-land/main_layout.xml +++ b/app/src/main/res/layout-land/main_layout.xml @@ -31,6 +31,16 @@ android:contentDescription="@null" android:scaleType="fitCenter" android:visibility="gone" /> + + + + + + + + + + + + + val document = parseMainXml(path) + val backgroundChildren = childIdsOf(document, "keyboard_background_container") + val touchEffectChildren = childIdsOf(document, "keyboard_touch_effect_container") + + assertTrue( + "$path background container should keep only background media", + backgroundChildren == listOf("keyboard_background_video", "keyboard_background_image") + ) + normalEffectIds.forEach { id -> + assertFalse("$path background container must not contain $id", id in backgroundChildren) + assertTrue("$path touch effect container must contain $id", id in touchEffectChildren) + } + } + } + + @Test + fun floatingKeyboardTouchEffectsAreOutsideBackgroundContainer() { + val document = parseMainXml("res/layout/floating_keyboard_layout.xml") + val backgroundChildren = childIdsOf(document, "floating_keyboard_background_container") + val touchEffectChildren = childIdsOf(document, "floating_keyboard_touch_effect_container") + + assertTrue( + "floating background container should keep only background media", + backgroundChildren == listOf( + "floating_keyboard_background_video", + "floating_keyboard_background_image" + ) + ) + floatingEffectIds.forEach { id -> + assertFalse("floating background container must not contain $id", id in backgroundChildren) + assertTrue("floating touch effect container must contain $id", id in touchEffectChildren) + } + } + + @Test + fun touchDispatchUsesTouchEffectContainers() { + val lines = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt" + ).readLines() + val mainSetup = functionBody(lines, "setupMainKeyboardTouchEffect").joinToString("\n") + val floatingSetup = functionBody(lines, "setupFloatingKeyboardTouchEffect").joinToString("\n") + + assertTrue(mainSetup.contains("targetContainer = mainView.keyboardTouchEffectContainer")) + assertFalse(mainSetup.contains("targetContainer = mainView.keyboardBackgroundContainer")) + assertTrue(floatingSetup.contains("targetContainer = floatingView.floatingKeyboardTouchEffectContainer")) + assertFalse(floatingSetup.contains("targetContainer = floatingView.floatingKeyboardBackgroundContainer")) + } + + @Test + fun rendererResizeSurfaceHasNoOpGuards() { + listOf( + "FluidInkRenderer.kt", + "LiquidRippleRenderer.kt", + "SprayPaintRenderer.kt" + ).forEach { fileName -> + val lines = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/$fileName" + ).readLines() + val resizeSurface = functionBody(lines, "resizeSurface").joinToString("\n") + + assertTrue(resizeSurface.contains("if (width <= 0 || height <= 0) return@postOnRenderer")) + assertTrue(resizeSurface.contains("if (width == surfaceWidth && height == surfaceHeight) return@postOnRenderer")) + } + } + + private fun parseMainXml(path: String): Element { + val document = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(mainFile(path)) + document.documentElement.normalize() + return document.documentElement + } + + private fun childIdsOf(root: Element, parentId: String): List { + val parent = elementWithId(root, parentId) + val children = parent.childNodes + return buildList { + for (index in 0 until children.length) { + val child = children.item(index) as? Element ?: continue + idName(child)?.let(::add) + } + } + } + + private fun elementWithId(root: Element, id: String): Element { + val nodes = root.ownerDocument.getElementsByTagName("*") + for (index in 0 until nodes.length) { + val element = nodes.item(index) as? Element ?: continue + if (idName(element) == id) return element + } + error("Missing view id $id") + } + + private fun idName(element: Element): String? { + val value = element.getAttribute("android:id").takeIf { it.isNotBlank() } + ?: element.getAttribute("id").takeIf { it.isNotBlank() } + ?: return null + return value.substringAfter("@+id/").substringAfter("@id/") + } + + private fun functionBody(lines: List, functionName: String): List { + val start = lines.indexOfFirst { it.contains("fun $functionName") } + assertTrue("Missing function $functionName", start >= 0) + + var depth = 0 + var seenBody = false + for (index in start until lines.size) { + val line = lines[index] + depth += line.count { it == '{' } + seenBody = seenBody || line.contains('{') + depth -= line.count { it == '}' } + if (seenBody && depth == 0) { + return lines.subList(start, index + 1) + } + } + error("Missing function body end for $functionName") + } + + private fun mainFile(path: String): File { + val moduleFile = File("src/main/$path") + return if (moduleFile.exists()) moduleFile else File("app/src/main/$path") + } + + private companion object { + val normalEffectIds = listOf( + "suminagashi_ink_view", + "liquid_ripple_effect_view", + "spray_paint_effect_view" + ) + val floatingEffectIds = listOf( + "floating_suminagashi_ink_view", + "floating_liquid_ripple_effect_view", + "floating_spray_paint_effect_view" + ) + } +} From e25105118c35938e5bee649281f9ceaa0d648683 Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:27:14 -0400 Subject: [PATCH 5/8] initial cinematic wave --- .../ime_service/IMEService.kt | 183 ++++++ .../ime_service/ImePreferencesSnapshot.kt | 28 + .../image_effect/CinematicWaveEffectView.kt | 197 ++++++ .../image_effect/CinematicWaveInputCommand.kt | 51 ++ .../CinematicWaveInputCommandQueue.kt | 84 +++ .../CinematicWaveInputInjector.kt | 127 ++++ .../CinematicWavePerformanceGovernor.kt | 146 +++++ .../image_effect/CinematicWaveRenderer.kt | 566 ++++++++++++++++++ .../CinematicWaveRendererController.kt | 27 + .../image_effect/CinematicWaveSettings.kt | 306 ++++++++++ .../image_effect/CinematicWaveSimulation.kt | 377 ++++++++++++ .../image_effect/KeyboardTouchEffectType.kt | 6 + .../setting_activity/AppPreference.kt | 151 +++++ .../ui/setting/CommonPreferenceFragment.kt | 259 +++++++- app/src/main/res/layout-land/main_layout.xml | 9 + .../main/res/layout-sw600dp/main_layout.xml | 9 + .../res/layout/floating_keyboard_layout.xml | 9 + app/src/main/res/layout/main_layout.xml | 9 + app/src/main/res/values-ja/arrays.xml | 48 ++ app/src/main/res/values-ja/strings.xml | 33 + app/src/main/res/values/arrays.xml | 48 ++ app/src/main/res/values/strings.xml | 33 + .../main/res/xml/pref_keyboard_display.xml | 66 ++ ...mePreferencesSnapshotSuminagashiInkTest.kt | 55 ++ .../image_effect/CinematicWaveContractTest.kt | 64 ++ ...eyboardTouchEffectContainerContractTest.kt | 8 +- .../KeyboardTouchEffectResourceTest.kt | 5 +- .../KeyboardTouchEffectTypeTest.kt | 6 + .../AppPreferenceSuminagashiInkTest.kt | 103 ++++ ...oardTouchEffectPreferenceVisibilityTest.kt | 22 + 30 files changed, 3027 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveEffectView.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputCommand.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputCommandQueue.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputInjector.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWavePerformanceGovernor.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRendererController.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt create mode 100644 app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index ef56e12a..c6c0eda9 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 @@ -198,6 +198,8 @@ import com.kazumaproject.markdownhelperkeyboard.ime_service.feedback.VibrationTi import com.kazumaproject.markdownhelperkeyboard.ime_service.floating_view.BubbleTextView import com.kazumaproject.markdownhelperkeyboard.ime_service.floating_view.FloatingDockListener import com.kazumaproject.markdownhelperkeyboard.ime_service.floating_view.FloatingDockView +import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.CinematicWaveEffectView +import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.CinematicWaveSettings import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.FluidInkTransportMode import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.InkTouchDispatchFrameLayout import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.KeyboardTouchEffectQuality @@ -948,6 +950,24 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, @ColorInt private var keyboardTouchEffectColorPreference: Int = Color.rgb(17, 17, 17) + private var cinematicWaveColorModePreference: String = + CinematicWaveSettings.COLOR_MODE_CINEMATIC_RANDOM + + @ColorInt + private var cinematicWavePrimaryColorPreference: Int = + CinematicWaveSettings.DEFAULT_PRIMARY_COLOR + + @ColorInt + private var cinematicWaveSecondaryColorPreference: Int = + CinematicWaveSettings.DEFAULT_SECONDARY_COLOR + + private var cinematicWaveSecondaryColorAutoPreference: Boolean = true + private var cinematicWaveOpacityPercentPreference: Int = 46 + private var cinematicWaveIntensityPercentPreference: Int = 100 + private var cinematicWaveMotionPreference: String = CinematicWaveSettings.MOTION_ELEGANT + private var cinematicWaveTouchResponsePreference: String = + CinematicWaveSettings.TOUCH_RESPONSE_NORMAL + private var cinematicWaveQualityPreference: String = CinematicWaveSettings.QUALITY_BALANCED private var customKeyBorderEnablePreference: Boolean? = false private var customKeyBorderEnableColor: Int? = Color.BLACK @@ -1874,6 +1894,22 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, keyboardTouchEffectColorModePreference = preferences.keyboardTouchEffectColorModePreference keyboardTouchEffectColorPreference = preferences.keyboardTouchEffectColorPreference keyboardTouchEffectPalettePreference = preferences.keyboardTouchEffectPalettePreference + cinematicWaveColorModePreference = + CinematicWaveSettings.normalizeColorMode(preferences.cinematicWaveColorModePreference) + cinematicWavePrimaryColorPreference = preferences.cinematicWavePrimaryColorPreference + cinematicWaveSecondaryColorPreference = preferences.cinematicWaveSecondaryColorPreference + cinematicWaveSecondaryColorAutoPreference = + preferences.cinematicWaveSecondaryColorAutoPreference + cinematicWaveOpacityPercentPreference = preferences.cinematicWaveOpacityPercentPreference + cinematicWaveIntensityPercentPreference = preferences.cinematicWaveIntensityPercentPreference + cinematicWaveMotionPreference = + CinematicWaveSettings.normalizeMotion(preferences.cinematicWaveMotionPreference) + cinematicWaveTouchResponsePreference = + CinematicWaveSettings.normalizeTouchResponse( + preferences.cinematicWaveTouchResponsePreference + ) + cinematicWaveQualityPreference = + CinematicWaveSettings.normalizeQuality(preferences.cinematicWaveQualityPreference) customKeyBorderEnablePreference = preferences.customKeyBorderEnablePreference customKeyBorderEnableColor = preferences.customKeyBorderEnableColor customComposingTextPreference = preferences.customComposingTextPreference @@ -2178,6 +2214,19 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, fixedColor = resolveKeyboardTouchEffectBaseColor(mainView.root), quality = keyboardTouchEffectQualityPreference ) + mainView.cinematicWaveEffectView.clearWave() + mainView.cinematicWaveEffectView.configure( + enabled = false, + colorMode = cinematicWaveColorModePreference, + primaryColor = cinematicWavePrimaryColorPreference, + secondaryColor = cinematicWaveSecondaryColorPreference, + secondaryColorAuto = cinematicWaveSecondaryColorAutoPreference, + opacityPercent = cinematicWaveOpacityPercentPreference, + intensityPercent = cinematicWaveIntensityPercentPreference, + motion = cinematicWaveMotionPreference, + touchResponse = cinematicWaveTouchResponsePreference, + quality = cinematicWaveQualityPreference + ) (mainView.root as? InkTouchDispatchFrameLayout)?.touchEffectMotionEventListener = null mainView.keyboardBackgroundVideo.isVisible = false @@ -2275,6 +2324,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, clearBlob() pauseBlob() } + mainLayoutBinding?.cinematicWaveEffectView?.apply { + clearWave() + pauseWave() + } floatingKeyboardBinding?.floatingSuminagashiInkView?.apply { clearInk() pauseInk() @@ -2291,6 +2344,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, clearBlob() pauseBlob() } + floatingKeyboardBinding?.floatingCinematicWaveEffectView?.apply { + clearWave() + pauseWave() + } } private fun releaseKeyboardTouchEffects() { @@ -2298,10 +2355,12 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainLayoutBinding?.liquidRippleEffectView?.releaseRipple() mainLayoutBinding?.sprayPaintEffectView?.releaseSpray() mainLayoutBinding?.luminousBlobEffectView?.releaseBlob() + mainLayoutBinding?.cinematicWaveEffectView?.releaseWave() floatingKeyboardBinding?.floatingSuminagashiInkView?.releaseInk() floatingKeyboardBinding?.floatingLiquidRippleEffectView?.releaseRipple() floatingKeyboardBinding?.floatingSprayPaintEffectView?.releaseSpray() floatingKeyboardBinding?.floatingLuminousBlobEffectView?.releaseBlob() + floatingKeyboardBinding?.floatingCinematicWaveEffectView?.releaseWave() } private fun setupMainKeyboardTouchEffect(mainView: MainLayoutBinding) { @@ -2323,6 +2382,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainSurfaceActive && KeyboardTouchEffectType.isSprayPaint(effectType) val luminousBlobEnabled = mainSurfaceActive && KeyboardTouchEffectType.isLuminousBlob(effectType) + val cinematicWaveEnabled = + mainSurfaceActive && KeyboardTouchEffectType.isCinematicWave(effectType) val effectBaseColor = resolveKeyboardTouchEffectBaseColor(mainView.root) mainView.suminagashiInkView.configure( @@ -2349,6 +2410,18 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, fixedColor = effectBaseColor, quality = keyboardTouchEffectQualityPreference ) + mainView.cinematicWaveEffectView.configure( + enabled = cinematicWaveEnabled, + colorMode = cinematicWaveColorModePreference, + primaryColor = cinematicWavePrimaryColorPreference, + secondaryColor = cinematicWaveSecondaryColorPreference, + secondaryColorAuto = cinematicWaveSecondaryColorAutoPreference, + opacityPercent = cinematicWaveOpacityPercentPreference, + intensityPercent = cinematicWaveIntensityPercentPreference, + motion = cinematicWaveMotionPreference, + touchResponse = cinematicWaveTouchResponsePreference, + quality = cinematicWaveQualityPreference + ) val root = mainView.root as? InkTouchDispatchFrameLayout root?.touchEffectMotionEventListener = when { @@ -2396,6 +2469,17 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + cinematicWaveEnabled -> { + { event -> + dispatchCinematicWaveMotionEvent( + event = event, + sourceRoot = mainView.root, + targetContainer = mainView.keyboardTouchEffectContainer, + waveView = mainView.cinematicWaveEffectView + ) + } + } + else -> null } } @@ -2421,6 +2505,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, floatingSurfaceActive && KeyboardTouchEffectType.isSprayPaint(effectType) val luminousBlobEnabled = floatingSurfaceActive && KeyboardTouchEffectType.isLuminousBlob(effectType) + val cinematicWaveEnabled = + floatingSurfaceActive && KeyboardTouchEffectType.isCinematicWave(effectType) val effectBaseColor = resolveKeyboardTouchEffectBaseColor(floatingView.root) floatingView.floatingSuminagashiInkView.configure( @@ -2447,6 +2533,18 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, fixedColor = effectBaseColor, quality = keyboardTouchEffectQualityPreference ) + floatingView.floatingCinematicWaveEffectView.configure( + enabled = cinematicWaveEnabled, + colorMode = cinematicWaveColorModePreference, + primaryColor = cinematicWavePrimaryColorPreference, + secondaryColor = cinematicWaveSecondaryColorPreference, + secondaryColorAuto = cinematicWaveSecondaryColorAutoPreference, + opacityPercent = cinematicWaveOpacityPercentPreference, + intensityPercent = cinematicWaveIntensityPercentPreference, + motion = cinematicWaveMotionPreference, + touchResponse = cinematicWaveTouchResponsePreference, + quality = cinematicWaveQualityPreference + ) val root = floatingView.root as? InkTouchDispatchFrameLayout root?.touchEffectMotionEventListener = when { @@ -2494,6 +2592,17 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + cinematicWaveEnabled -> { + { event -> + dispatchCinematicWaveMotionEvent( + event = event, + sourceRoot = floatingView.root, + targetContainer = floatingView.floatingKeyboardTouchEffectContainer, + waveView = floatingView.floatingCinematicWaveEffectView + ) + } + } + else -> null } } @@ -2621,6 +2730,71 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) } + private fun dispatchCinematicWaveMotionEvent( + event: MotionEvent, + sourceRoot: View, + targetContainer: View, + waveView: CinematicWaveEffectView + ) { + if (!waveView.isShown) return + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> { + val index = event.actionIndex + if (!mapMotionEventToTarget(event, index, sourceRoot, targetContainer, inkMappedPoint)) { + return + } + waveView.onPointerDown( + pointerId = event.getPointerId(index), + x = inkMappedPoint[0], + y = inkMappedPoint[1], + pressure = event.getPressure(index) + ) + } + + MotionEvent.ACTION_MOVE -> { + for (index in 0 until event.pointerCount) { + if (!mapMotionEventToTarget( + event, + index, + sourceRoot, + targetContainer, + inkMappedPoint + ) + ) { + continue + } + waveView.onPointerMove( + pointerId = event.getPointerId(index), + x = inkMappedPoint[0], + y = inkMappedPoint[1], + pressure = event.getPressure(index) + ) + } + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> { + val index = event.actionIndex + if (mapMotionEventToTarget(event, index, sourceRoot, targetContainer, inkMappedPoint)) { + waveView.onPointerUp( + pointerId = event.getPointerId(index), + x = inkMappedPoint[0], + y = inkMappedPoint[1], + pressure = event.getPressure(index) + ) + } else { + waveView.onPointerUp(event.getPointerId(index)) + } + } + + MotionEvent.ACTION_CANCEL -> { + waveView.onCancel() + } + } + } + private fun dispatchTouchEffectMotionEvent( event: MotionEvent, sourceRoot: View, @@ -3717,6 +3891,15 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, keyboardTouchEffectColorModePreference = "random" keyboardTouchEffectColorPreference = Color.rgb(17, 17, 17) keyboardTouchEffectPalettePreference = SprayPaintSettings.PALETTE_PAINT_SPLASH + cinematicWaveColorModePreference = CinematicWaveSettings.COLOR_MODE_CINEMATIC_RANDOM + cinematicWavePrimaryColorPreference = CinematicWaveSettings.DEFAULT_PRIMARY_COLOR + cinematicWaveSecondaryColorPreference = CinematicWaveSettings.DEFAULT_SECONDARY_COLOR + cinematicWaveSecondaryColorAutoPreference = true + cinematicWaveOpacityPercentPreference = 46 + cinematicWaveIntensityPercentPreference = 100 + cinematicWaveMotionPreference = CinematicWaveSettings.MOTION_ELEGANT + cinematicWaveTouchResponsePreference = CinematicWaveSettings.TOUCH_RESPONSE_NORMAL + cinematicWaveQualityPreference = CinematicWaveSettings.QUALITY_BALANCED customKeyBorderEnablePreference = null customKeyBorderEnableColor = null diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt index d2cec1b8..c9df7fcc 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt @@ -185,6 +185,15 @@ data class ImePreferencesSnapshot( val keyboardTouchEffectColorModePreference: String, val keyboardTouchEffectColorPreference: Int, val keyboardTouchEffectPalettePreference: String, + val cinematicWaveColorModePreference: String, + val cinematicWavePrimaryColorPreference: Int, + val cinematicWaveSecondaryColorPreference: Int, + val cinematicWaveSecondaryColorAutoPreference: Boolean, + val cinematicWaveOpacityPercentPreference: Int, + val cinematicWaveIntensityPercentPreference: Int, + val cinematicWaveMotionPreference: String, + val cinematicWaveTouchResponsePreference: String, + val cinematicWaveQualityPreference: String, val customKeyBorderEnablePreference: Boolean, val customKeyBorderEnableColor: Int, val customComposingTextPreference: Boolean, @@ -519,6 +528,25 @@ data class ImePreferencesSnapshot( keyboardTouchEffectColorPreference = appPreference.keyboard_touch_effect_color_preference, keyboardTouchEffectPalettePreference = appPreference.keyboard_touch_effect_palette_preference, + cinematicWaveColorModePreference = + appPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference, + cinematicWavePrimaryColorPreference = + appPreference.keyboard_touch_effect_cinematic_wave_primary_color_preference, + cinematicWaveSecondaryColorPreference = + appPreference.keyboard_touch_effect_cinematic_wave_secondary_color_preference, + cinematicWaveSecondaryColorAutoPreference = + appPreference + .keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference, + cinematicWaveOpacityPercentPreference = + appPreference.keyboard_touch_effect_cinematic_wave_opacity_percent_preference, + cinematicWaveIntensityPercentPreference = + appPreference.keyboard_touch_effect_cinematic_wave_intensity_percent_preference, + cinematicWaveMotionPreference = + appPreference.keyboard_touch_effect_cinematic_wave_motion_preference, + cinematicWaveTouchResponsePreference = + appPreference.keyboard_touch_effect_cinematic_wave_touch_response_preference, + cinematicWaveQualityPreference = + appPreference.keyboard_touch_effect_cinematic_wave_quality_preference, customKeyBorderEnablePreference = appPreference.custom_theme_border_enable, customKeyBorderEnableColor = appPreference.custom_theme_border_color, customComposingTextPreference = appPreference.custom_theme_input_color_enable, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveEffectView.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveEffectView.kt new file mode 100644 index 00000000..becfbd69 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveEffectView.kt @@ -0,0 +1,197 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import android.content.Context +import android.graphics.SurfaceTexture +import android.util.AttributeSet +import android.view.TextureView +import android.view.View +import androidx.annotation.ColorInt +import androidx.annotation.VisibleForTesting +import timber.log.Timber + +class CinematicWaveEffectView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TextureView(context, attrs, defStyleAttr), TextureView.SurfaceTextureListener { + + private val inputQueue = CinematicWaveInputCommandQueue() + private val inputInjector = CinematicWaveInputInjector(inputQueue) + + @VisibleForTesting + internal var rendererFactory: CinematicWaveRendererFactory = + CinematicWaveRendererFactory { queue, callback -> + CinematicWaveRenderer( + inputQueue = queue, + callback = callback + ) + } + + private var renderer: CinematicWaveRendererController? = null + private var effectEnabled = false + private var currentSettings = CinematicWaveSettings.Disabled + private var attachedSurfaceTexture: SurfaceTexture? = null + + init { + surfaceTextureListener = this + setOpaque(false) + isClickable = false + isFocusable = false + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + visibility = View.GONE + } + + fun configure( + enabled: Boolean, + colorMode: String, + @ColorInt primaryColor: Int, + @ColorInt secondaryColor: Int, + secondaryColorAuto: Boolean, + opacityPercent: Int, + intensityPercent: Int, + motion: String, + touchResponse: String, + quality: String + ) { + currentSettings = CinematicWaveSettings( + enabled = enabled, + colorMode = colorMode, + primaryColor = CinematicWaveSettings.withoutTransparentAlpha(primaryColor), + secondaryColor = CinematicWaveSettings.withoutTransparentAlpha(secondaryColor), + secondaryColorAuto = secondaryColorAuto, + opacityPercent = opacityPercent, + intensityPercent = intensityPercent, + motion = motion, + touchResponse = touchResponse, + quality = quality + ) + + if (!enabled) { + effectEnabled = false + inputInjector.disable() + renderer?.clear() + renderer?.release() + renderer = null + visibility = View.GONE + return + } + + effectEnabled = true + inputInjector.configure(enabled = true) + visibility = View.VISIBLE + + val activeRenderer = ensureRenderer() + activeRenderer.configure(currentSettings) + activeRenderer.resume() + val surface = attachedSurfaceTexture ?: surfaceTexture + if (surface != null && width > 0 && height > 0) { + activeRenderer.attachSurface(surface, width, height) + } + activeRenderer.requestRender() + } + + fun onPointerDown(pointerId: Int, x: Float, y: Float, pressure: Float = 1f) { + if (!canForwardInput()) return + if (inputInjector.onPointerDown(pointerId, x, y, pressure)) { + renderer?.requestRender() + } + } + + fun onPointerMove(pointerId: Int, x: Float, y: Float, pressure: Float = 1f) { + if (!canForwardInput()) return + if (inputInjector.onPointerMove(pointerId, x, y, pressure)) { + renderer?.requestRender() + } + } + + fun onPointerUp( + pointerId: Int, + x: Float? = null, + y: Float? = null, + pressure: Float = 1f + ) { + if (!effectEnabled) return + if (inputInjector.onPointerUp(pointerId, x, y, pressure)) { + renderer?.requestRender() + } + } + + fun onCancel() { + if (!effectEnabled) return + if (inputInjector.onCancel()) { + renderer?.requestRender() + } + } + + fun clearWave() { + inputInjector.clearActivePointers() + inputQueue.clear() + renderer?.clear() + } + + fun pauseWave() { + inputInjector.clearActivePointers() + renderer?.pause() + } + + fun releaseWave() { + inputInjector.disable() + renderer?.release() + renderer = null + effectEnabled = false + visibility = View.GONE + } + + override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { + attachedSurfaceTexture = surface + if (!effectEnabled) return + ensureRenderer().attachSurface(surface, width, height) + } + + override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { + attachedSurfaceTexture = surface + if (!effectEnabled) return + renderer?.resizeSurface(width, height) + } + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + attachedSurfaceTexture = null + renderer?.detachSurface() + return true + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit + + @VisibleForTesting + internal fun pointerStateCountForTesting(): Int = inputInjector.pointerStateCountForTesting() + + @VisibleForTesting + internal fun queuedInputCountForTesting(): Int = inputQueue.sizeForTesting() + + @VisibleForTesting + internal fun hasRendererForTesting(): Boolean = renderer != null + + private fun canForwardInput(): Boolean { + return effectEnabled && visibility == View.VISIBLE + } + + private fun ensureRenderer(): CinematicWaveRendererController { + renderer?.let { return it } + return rendererFactory.create( + inputQueue, + CinematicWaveRendererCallback { reason, throwable -> + Timber.w(throwable, "Keyboard cinematic wave effect disabled: %s", reason) + disableAfterRendererFailure() + } + ).also { renderer = it } + } + + private fun disableAfterRendererFailure() { + if (!effectEnabled && renderer == null) return + inputInjector.disable() + renderer?.release() + renderer = null + effectEnabled = false + visibility = View.GONE + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputCommand.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputCommand.kt new file mode 100644 index 00000000..13672ace --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputCommand.kt @@ -0,0 +1,51 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +internal enum class CinematicWaveInputKind { + Down, + Move, + Up +} + +internal data class CinematicWaveTouchPoint( + val pointerId: Int, + var x: Float, + var y: Float, + var startTimeMs: Long, + var lastUpdateTimeMs: Long, + var pressure: Float, + var strength: Float +) + +internal data class CinematicWaveTouchSnapshot( + val x: Float, + val y: Float, + val ageSeconds: Float, + val strength: Float +) + +internal sealed class CinematicWaveInputCommand { + abstract val eventTimeMs: Long + + data class Pointer( + val pointerId: Int, + val x: Float, + val y: Float, + val pressure: Float, + val kind: CinematicWaveInputKind, + override val eventTimeMs: Long + ) : CinematicWaveInputCommand() + + data class PointerUp( + val pointerId: Int, + override val eventTimeMs: Long + ) : CinematicWaveInputCommand() + + data class PointerCancel( + val pointerId: Int, + override val eventTimeMs: Long + ) : CinematicWaveInputCommand() + + data class CancelAll( + override val eventTimeMs: Long + ) : CinematicWaveInputCommand() +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputCommandQueue.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputCommandQueue.kt new file mode 100644 index 00000000..50d8c7d3 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputCommandQueue.kt @@ -0,0 +1,84 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +internal class CinematicWaveInputCommandQueue( + private val maxSize: Int = DEFAULT_MAX_SIZE +) { + private val lock = Any() + private val commands = ArrayDeque() + + fun offer(command: CinematicWaveInputCommand): Boolean = synchronized(lock) { + if ( + command is CinematicWaveInputCommand.Pointer && + command.kind == CinematicWaveInputKind.Move && + replaceLatestMoveForPointerLocked(command) + ) { + return@synchronized true + } + + while (commands.size >= maxSize) { + val removedMove = removeOldestMoveLocked() + if (!removedMove) { + if ( + command is CinematicWaveInputCommand.Pointer && + command.kind == CinematicWaveInputKind.Move + ) { + return@synchronized false + } + break + } + } + + commands.addLast(command) + true + } + + fun drain(maxCommands: Int = Int.MAX_VALUE): List = + synchronized(lock) { + if (commands.isEmpty()) return@synchronized emptyList() + val count = minOf(maxCommands, commands.size) + val drained = ArrayList(count) + repeat(count) { + drained.add(commands.removeFirst()) + } + drained + } + + fun clear() = synchronized(lock) { + commands.clear() + } + + fun sizeForTesting(): Int = synchronized(lock) { + commands.size + } + + private fun replaceLatestMoveForPointerLocked( + command: CinematicWaveInputCommand.Pointer + ): Boolean { + for (index in commands.indices.reversed()) { + val existing = commands[index] + if ( + existing is CinematicWaveInputCommand.Pointer && + existing.kind == CinematicWaveInputKind.Move && + existing.pointerId == command.pointerId + ) { + commands[index] = command + return true + } + } + return false + } + + private fun removeOldestMoveLocked(): Boolean { + val index = commands.indexOfFirst { + it is CinematicWaveInputCommand.Pointer && + it.kind == CinematicWaveInputKind.Move + } + if (index < 0) return false + commands.removeAt(index) + return true + } + + companion object { + const val DEFAULT_MAX_SIZE = 96 + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputInjector.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputInjector.kt new file mode 100644 index 00000000..6e6fb9c9 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveInputInjector.kt @@ -0,0 +1,127 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import android.os.SystemClock +import kotlin.math.sqrt + +internal class CinematicWaveInputInjector( + private val queue: CinematicWaveInputCommandQueue, + private val clock: () -> Long = { SystemClock.uptimeMillis() } +) { + private data class PointerState( + var lastX: Float, + var lastY: Float, + var lastEventTimeMs: Long + ) + + private val pointerStates = HashMap() + private var enabled = false + + fun configure(enabled: Boolean) { + this.enabled = enabled + if (!enabled) { + clearActivePointers() + queue.clear() + } + } + + fun disable() { + enabled = false + clearActivePointers() + queue.clear() + } + + fun onPointerDown(pointerId: Int, x: Float, y: Float, pressure: Float): Boolean { + if (!enabled || pointerStates.size >= MAX_TOUCH_POINTS) return false + val now = clock() + pointerStates[pointerId] = PointerState( + lastX = x, + lastY = y, + lastEventTimeMs = now + ) + return queue.offer( + CinematicWaveInputCommand.Pointer( + pointerId = pointerId, + x = x, + y = y, + pressure = pressure.coerceIn(0.35f, 1.5f), + kind = CinematicWaveInputKind.Down, + eventTimeMs = now + ) + ) + } + + fun onPointerMove(pointerId: Int, x: Float, y: Float, pressure: Float): Boolean { + if (!enabled) return false + val state = pointerStates[pointerId] ?: return false + val dx = x - state.lastX + val dy = y - state.lastY + if (sqrt(dx * dx + dy * dy) < MOVE_DISTANCE_EPSILON_PX) return false + + val now = clock().coerceAtLeast(state.lastEventTimeMs) + state.lastX = x + state.lastY = y + state.lastEventTimeMs = now + return queue.offer( + CinematicWaveInputCommand.Pointer( + pointerId = pointerId, + x = x, + y = y, + pressure = pressure.coerceIn(0.35f, 1.5f), + kind = CinematicWaveInputKind.Move, + eventTimeMs = now + ) + ) + } + + fun onPointerUp(pointerId: Int, x: Float? = null, y: Float? = null, pressure: Float = 1f): Boolean { + val state = pointerStates.remove(pointerId) ?: return false + val now = clock().coerceAtLeast(state.lastEventTimeMs) + var queued = false + if (enabled && x != null && y != null) { + queued = queue.offer( + CinematicWaveInputCommand.Pointer( + pointerId = pointerId, + x = x, + y = y, + pressure = pressure.coerceIn(0.35f, 1.5f), + kind = CinematicWaveInputKind.Up, + eventTimeMs = now + ) + ) + } + queued = queue.offer( + CinematicWaveInputCommand.PointerUp( + pointerId = pointerId, + eventTimeMs = now + ) + ) || queued + return queued + } + + fun onPointerCancel(pointerId: Int): Boolean { + val state = pointerStates.remove(pointerId) ?: return false + return queue.offer( + CinematicWaveInputCommand.PointerCancel( + pointerId = pointerId, + eventTimeMs = clock().coerceAtLeast(state.lastEventTimeMs) + ) + ) + } + + fun onCancel(): Boolean { + if (!enabled && pointerStates.isEmpty()) return false + pointerStates.clear() + return queue.offer(CinematicWaveInputCommand.CancelAll(eventTimeMs = clock())) + } + + fun clearActivePointers() { + pointerStates.clear() + } + + fun pointerStateCountForTesting(): Int = pointerStates.size + + companion object { + const val MAX_TOUCH_POINTS = 5 + private const val MOVE_DISTANCE_EPSILON_PX = 0.7f + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWavePerformanceGovernor.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWavePerformanceGovernor.kt new file mode 100644 index 00000000..0467c6b9 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWavePerformanceGovernor.kt @@ -0,0 +1,146 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +internal enum class CinematicWaveRendererState { + Ambient, + Active, + Settling, + Idle +} + +internal data class CinematicWaveStepParams( + val renderScale: Float, + val frameIntervalMs: Long, + val noiseOctaves: Int, + val glowStrength: Float, + val warpStrength: Float, + val motionSpeed: Float, + val touchResponse: Float, + val maxCommandsPerFrame: Int +) + +internal class CinematicWavePerformanceGovernor { + private var quality = CinematicWaveSettings.QUALITY_BALANCED + private var qualityLevel = HIGHEST_QUALITY + + fun configureQuality(value: String) { + val normalized = CinematicWaveSettings.normalizeQuality(value) + if (normalized == quality) return + quality = normalized + qualityLevel = HIGHEST_QUALITY + } + + fun stepParams(settings: CinematicWaveSettings, state: CinematicWaveRendererState): + CinematicWaveStepParams { + val profile = profile(quality) + val runtimeScale = runtimeScale() + val motionScale = when (settings.normalizedMotion) { + CinematicWaveSettings.MOTION_CALM -> 0.64f + CinematicWaveSettings.MOTION_DYNAMIC -> 1.32f + else -> 1f + } + val touchScale = when (settings.normalizedTouchResponse) { + CinematicWaveSettings.TOUCH_RESPONSE_SUBTLE -> 0.70f + CinematicWaveSettings.TOUCH_RESPONSE_DEEP -> 1.34f + else -> 1f + } + val stateFpsScale = when (state) { + CinematicWaveRendererState.Active -> 1f + CinematicWaveRendererState.Settling -> 0.72f + CinematicWaveRendererState.Ambient -> profile.ambientFpsScale + CinematicWaveRendererState.Idle -> 0.25f + } + val fps = (profile.maxFps * stateFpsScale).toInt().coerceAtLeast(12) + return CinematicWaveStepParams( + renderScale = (profile.renderScale * runtimeScale).coerceIn(MIN_RENDER_SCALE, 1f), + frameIntervalMs = fpsToIntervalMillis(fps), + noiseOctaves = profile.noiseOctaves, + glowStrength = profile.glowStrength, + warpStrength = profile.warpStrength, + motionSpeed = profile.motionSpeed * motionScale, + touchResponse = touchScale, + maxCommandsPerFrame = (BASE_MAX_COMMANDS_PER_FRAME * runtimeScale).toInt() + .coerceAtLeast(MIN_COMMANDS_PER_FRAME) + ) + } + + fun reportFrameTime(frameMillis: Long, state: CinematicWaveRendererState): Boolean { + if (state == CinematicWaveRendererState.Idle) return false + return if (frameMillis > profile(quality).budgetMs && qualityLevel > LOWEST_QUALITY) { + qualityLevel -= 1 + true + } else { + false + } + } + + fun qualityLevel(): Int = qualityLevel + + private fun runtimeScale(): Float { + return when (qualityLevel.coerceIn(LOWEST_QUALITY, HIGHEST_QUALITY)) { + 0 -> 1f + -1 -> 0.86f + -2 -> 0.72f + else -> 0.58f + } + } + + private fun fpsToIntervalMillis(fps: Int): Long { + return (1000f / fps.coerceAtLeast(1)).toLong().coerceAtLeast(1L) + } + + private data class QualityProfile( + val renderScale: Float, + val maxFps: Int, + val ambientFpsScale: Float, + val noiseOctaves: Int, + val glowStrength: Float, + val warpStrength: Float, + val motionSpeed: Float, + val budgetMs: Long + ) + + private fun profile(quality: String): QualityProfile { + return when (CinematicWaveSettings.normalizeQuality(quality)) { + CinematicWaveSettings.QUALITY_BATTERY_SAVER -> QualityProfile( + renderScale = 0.5f, + maxFps = 30, + ambientFpsScale = 0.84f, + noiseOctaves = 2, + glowStrength = 0.78f, + warpStrength = 0.76f, + motionSpeed = 0.86f, + budgetMs = 34L + ) + + CinematicWaveSettings.QUALITY_CINEMATIC -> QualityProfile( + renderScale = 1f, + maxFps = 60, + ambientFpsScale = 1f, + noiseOctaves = 4, + glowStrength = 1.18f, + warpStrength = 1.12f, + motionSpeed = 1f, + budgetMs = 17L + ) + + else -> QualityProfile( + renderScale = 0.75f, + maxFps = 60, + ambientFpsScale = 0.72f, + noiseOctaves = 3, + glowStrength = 1f, + warpStrength = 1f, + motionSpeed = 0.94f, + budgetMs = 18L + ) + } + } + + companion object { + const val HIGHEST_QUALITY = 0 + const val LOWEST_QUALITY = -3 + private const val MIN_RENDER_SCALE = 0.45f + private const val BASE_MAX_COMMANDS_PER_FRAME = 48 + private const val MIN_COMMANDS_PER_FRAME = 12 + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt new file mode 100644 index 00000000..e9fb1333 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt @@ -0,0 +1,566 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import android.graphics.SurfaceTexture +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.EGLContext +import android.opengl.EGLDisplay +import android.opengl.EGLSurface +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.os.Process +import android.os.SystemClock +import android.view.Surface +import timber.log.Timber +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.exp +import kotlin.math.roundToInt + +internal class CinematicWaveRenderer( + private val inputQueue: CinematicWaveInputCommandQueue, + private val callback: CinematicWaveRendererCallback, + private val mainHandler: Handler = Handler(Looper.getMainLooper()), + private val simulationFactory: () -> CinematicWaveSimulation = { CinematicWaveSimulation() }, + private val clockNanos: () -> Long = { System.nanoTime() }, + private val clockMillis: () -> Long = { SystemClock.uptimeMillis() } +) : CinematicWaveRendererController { + + private val rendererThread = HandlerThread( + "$THREAD_NAME_PREFIX-${threadIds.incrementAndGet()}", + Process.THREAD_PRIORITY_DISPLAY + ) + private val handler: Handler + private val frameRunnable = Runnable { renderFrameOnRendererThread() } + + private val activePointers = HashMap() + private val releasedPointers = ArrayList() + private val performanceGovernor = CinematicWavePerformanceGovernor() + private val colorController = CinematicWaveColorController() + + private var settings = CinematicWaveSettings.Disabled + private var egl: EglEnvironment? = null + private var simulation: CinematicWaveSimulation? = null + private var surfaceTexture: SurfaceTexture? = null + private var viewWidth = 0 + private var viewHeight = 0 + private var renderWidth = 0 + private var renderHeight = 0 + private var paused = true + private var released = false + private var frameScheduled = false + private var lastFrameTimeNanos = 0L + private var state = CinematicWaveRendererState.Idle + + init { + rendererThread.start() + handler = Handler(rendererThread.looper) + } + + override fun configure(settings: CinematicWaveSettings) { + postOnRenderer { + val previousQuality = this.settings.normalizedQuality + this.settings = settings + performanceGovernor.configureQuality(settings.normalizedQuality) + colorController.configure(settings, clockMillis()) + simulation?.configure(settings) + if (!settings.enabled) { + clearOnRendererThread() + paused = true + state = CinematicWaveRendererState.Idle + return@postOnRenderer + } + if ( + previousQuality != settings.normalizedQuality && + surfaceTexture != null && + viewWidth > 0 && + viewHeight > 0 + ) { + recreateSurfaceForCurrentSettings() + } + requestRenderOnRendererThread(forceSoon = true) + } + } + + override fun attachSurface(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + postOnRenderer { + if (released || !settings.enabled || width <= 0 || height <= 0) return@postOnRenderer + this.surfaceTexture = surfaceTexture + viewWidth = width + viewHeight = height + state = CinematicWaveRendererState.Ambient + runRendererCatching("attach cinematic wave EGL surface") { + recreateSurfaceForCurrentSettings() + paused = false + requestRenderOnRendererThread(forceSoon = true) + } + } + } + + override fun resizeSurface(width: Int, height: Int) { + postOnRenderer { + if (width <= 0 || height <= 0) return@postOnRenderer + if (width == viewWidth && height == viewHeight) return@postOnRenderer + if (released || surfaceTexture == null || !settings.enabled) return@postOnRenderer + viewWidth = width + viewHeight = height + runRendererCatching("resize cinematic wave surface") { + recreateSurfaceForCurrentSettings() + requestRenderOnRendererThread(forceSoon = true) + } + } + } + + override fun detachSurface() { + runBlockingOnRendererThread(maxWaitMillis = 120L) { + handler.removeCallbacks(frameRunnable) + frameScheduled = false + paused = true + state = CinematicWaveRendererState.Idle + clearOnRendererThread() + simulation?.release() + simulation = null + releaseEglSurfaceOnly() + surfaceTexture = null + viewWidth = 0 + viewHeight = 0 + renderWidth = 0 + renderHeight = 0 + } + } + + override fun resume() { + postOnRenderer { + if (released || !settings.enabled) return@postOnRenderer + paused = false + requestRenderOnRendererThread(forceSoon = true) + } + } + + override fun requestRender() { + postOnRenderer { + requestRenderOnRendererThread(forceSoon = true) + } + } + + override fun clear() { + postOnRenderer { + clearOnRendererThread() + } + } + + override fun pause() { + postOnRenderer { + paused = true + handler.removeCallbacks(frameRunnable) + frameScheduled = false + activePointers.clear() + releasedPointers.clear() + inputQueue.clear() + } + } + + override fun release() { + runBlockingOnRendererThread(maxWaitMillis = 250L) { + if (released) return@runBlockingOnRendererThread + released = true + handler.removeCallbacks(frameRunnable) + frameScheduled = false + inputQueue.clear() + activePointers.clear() + releasedPointers.clear() + simulation?.release() + simulation = null + releaseEglSurfaceOnly() + surfaceTexture = null + } + rendererThread.quitSafely() + } + + override fun isRendererThreadAliveForTesting(): Boolean { + return rendererThread.isAlive && !released + } + + private fun recreateSurfaceForCurrentSettings() { + val texture = surfaceTexture ?: return + val params = performanceGovernor.stepParams(settings, resolveRendererState()) + val nextRenderWidth = (viewWidth * params.renderScale).roundToInt().coerceAtLeast(1) + val nextRenderHeight = (viewHeight * params.renderScale).roundToInt().coerceAtLeast(1) + if ( + egl != null && + simulation != null && + renderWidth == nextRenderWidth && + renderHeight == nextRenderHeight + ) { + return + } + + simulation?.release() + simulation = null + releaseEglSurfaceOnly() + + texture.setDefaultBufferSize(nextRenderWidth, nextRenderHeight) + renderWidth = nextRenderWidth + renderHeight = nextRenderHeight + egl = EglEnvironment(texture) + simulation = simulationFactory().also { + it.initialize( + surfaceWidth = renderWidth, + surfaceHeight = renderHeight, + settings = settings + ) + } + } + + private fun renderFrameOnRendererThread() { + frameScheduled = false + val activeSimulation = simulation + if (released || paused || !settings.enabled || egl == null || activeSimulation == null) { + return + } + + runRendererCatching("render cinematic wave frame") { + val frameStartNanos = clockNanos() + val frameStartMillis = clockMillis() + val dtSeconds = if (lastFrameTimeNanos == 0L) { + 1f / 60f + } else { + ((frameStartNanos - lastFrameTimeNanos) / NANOS_PER_SECOND_FLOAT) + .coerceIn(1f / 120f, 1f / 18f) + } + lastFrameTimeNanos = frameStartNanos + + val commands = inputQueue.drain( + performanceGovernor.stepParams(settings, state).maxCommandsPerFrame + ) + updatePointerState(commands) + state = resolveRendererState() + val params = performanceGovernor.stepParams(settings, state) + val palette = colorController.paletteAt(frameStartMillis) + activeSimulation.render( + dtSeconds = dtSeconds, + params = params, + palette = palette, + touches = resolveTouchSnapshots(frameStartMillis) + ) + egl?.swapBuffers() + + val frameMillis = (clockNanos() - frameStartNanos) / 1_000_000L + val qualityChanged = performanceGovernor.reportFrameTime(frameMillis, state) + if (qualityChanged) { + Timber.d( + "Reduced cinematic wave quality to %d", + performanceGovernor.qualityLevel() + ) + } + if (settings.enabled && egl != null && !paused) { + requestRenderOnRendererThread(forceSoon = false) + } + } + } + + private fun updatePointerState(commands: List) { + commands.forEach { command -> + when (command) { + is CinematicWaveInputCommand.Pointer -> { + val current = activePointers[command.pointerId] + val startTime = current?.startTimeMs ?: command.eventTimeMs + val nextPointer = CinematicWaveTouchPoint( + pointerId = command.pointerId, + x = command.x, + y = command.y, + startTimeMs = startTime, + lastUpdateTimeMs = command.eventTimeMs, + pressure = command.pressure, + strength = command.pressure + ) + activePointers[command.pointerId] = nextPointer + if (command.kind == CinematicWaveInputKind.Up) { + releasePointer(command.pointerId, command.eventTimeMs) + } + } + + is CinematicWaveInputCommand.PointerUp -> { + releasePointer(command.pointerId, command.eventTimeMs) + } + + is CinematicWaveInputCommand.PointerCancel -> { + activePointers.remove(command.pointerId) + } + + is CinematicWaveInputCommand.CancelAll -> { + activePointers.clear() + releasedPointers.clear() + } + } + } + } + + private fun releasePointer(pointerId: Int, eventTimeMs: Long) { + val pointer = activePointers.remove(pointerId) ?: return + releasedPointers.add( + pointer.copy( + lastUpdateTimeMs = eventTimeMs, + strength = pointer.strength.coerceAtLeast(0.55f) + ) + ) + while (releasedPointers.size > MAX_RELEASED_TOUCHES) { + releasedPointers.removeAt(0) + } + } + + private fun resolveTouchSnapshots(nowMs: Long): List { + val snapshots = ArrayList(MAX_TOUCHES) + activePointers.values + .sortedByDescending { it.lastUpdateTimeMs } + .take(MAX_TOUCHES) + .forEach { pointer -> + snapshots.add(pointer.toSnapshot(nowMs, pointer.strength.coerceIn(0.35f, 1.5f))) + } + + val iterator = releasedPointers.iterator() + while (iterator.hasNext()) { + val pointer = iterator.next() + val elapsedMs = (nowMs - pointer.lastUpdateTimeMs).coerceAtLeast(0L) + val decay = exp(-elapsedMs / RELEASE_DECAY_MS) + val strength = pointer.strength * decay + if (strength < RELEASE_MIN_STRENGTH || elapsedMs > RELEASE_MAX_AGE_MS) { + iterator.remove() + continue + } + if (snapshots.size < MAX_TOUCHES) { + snapshots.add(pointer.toSnapshot(nowMs, strength)) + } + } + return snapshots + } + + private fun CinematicWaveTouchPoint.toSnapshot( + nowMs: Long, + resolvedStrength: Float + ): CinematicWaveTouchSnapshot { + val safeWidth = viewWidth.coerceAtLeast(1).toFloat() + val safeHeight = viewHeight.coerceAtLeast(1).toFloat() + return CinematicWaveTouchSnapshot( + x = (x / safeWidth).coerceIn(0f, 1f), + y = (1f - y / safeHeight).coerceIn(0f, 1f), + ageSeconds = ((nowMs - startTimeMs).coerceAtLeast(0L) / 1000f), + strength = resolvedStrength.coerceIn(0f, 1.6f) + ) + } + + private fun resolveRendererState(): CinematicWaveRendererState { + return when { + !settings.enabled || egl == null -> CinematicWaveRendererState.Idle + activePointers.isNotEmpty() -> CinematicWaveRendererState.Active + releasedPointers.isNotEmpty() -> CinematicWaveRendererState.Settling + else -> CinematicWaveRendererState.Ambient + } + } + + private fun requestRenderOnRendererThread(forceSoon: Boolean) { + if (released || paused || egl == null || !settings.enabled) return + if (frameScheduled) { + if (!forceSoon) return + handler.removeCallbacks(frameRunnable) + } + val delayMillis = if (forceSoon) { + 0L + } else { + performanceGovernor.stepParams(settings, state).frameIntervalMs + } + frameScheduled = true + handler.postDelayed(frameRunnable, delayMillis) + } + + private fun clearOnRendererThread() { + inputQueue.clear() + activePointers.clear() + releasedPointers.clear() + lastFrameTimeNanos = 0L + simulation?.clear() + } + + private fun postOnRenderer(action: () -> Unit) { + if (released && Looper.myLooper() != handler.looper) return + if (Looper.myLooper() == handler.looper) { + action() + } else { + handler.post(action) + } + } + + private fun runBlockingOnRendererThread(maxWaitMillis: Long, action: () -> Unit) { + if (Looper.myLooper() == handler.looper) { + action() + return + } + if (!rendererThread.isAlive) return + val latch = CountDownLatch(1) + handler.post { + runCatching(action).onFailure { + Timber.w(it, "Failed to run cinematic wave renderer cleanup.") + } + latch.countDown() + } + latch.await(maxWaitMillis, TimeUnit.MILLISECONDS) + } + + private fun runRendererCatching(operation: String, action: () -> Unit) { + runCatching(action).onFailure { throwable -> + Timber.w(throwable, "Cinematic wave renderer failed during %s", operation) + releaseAfterFailureOnRendererThread() + mainHandler.post { + callback.onRendererDisabled(operation, throwable) + } + } + } + + private fun releaseAfterFailureOnRendererThread() { + handler.removeCallbacks(frameRunnable) + frameScheduled = false + paused = true + state = CinematicWaveRendererState.Idle + inputQueue.clear() + activePointers.clear() + releasedPointers.clear() + runCatching { + simulation?.release() + } + simulation = null + releaseEglSurfaceOnly() + } + + private fun releaseEglSurfaceOnly() { + egl?.release() + egl = null + } + + private class EglEnvironment(surfaceTexture: SurfaceTexture) { + private val surface = Surface(surfaceTexture) + private var display: EGLDisplay = EGL14.EGL_NO_DISPLAY + private var context: EGLContext = EGL14.EGL_NO_CONTEXT + private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE + + init { + display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + check(display != EGL14.EGL_NO_DISPLAY) { "eglGetDisplay failed" } + + val version = IntArray(2) + check(EGL14.eglInitialize(display, version, 0, version, 1)) { + "eglInitialize failed: 0x${EGL14.eglGetError().toString(16)}" + } + + val config = chooseConfig(display) + context = EGL14.eglCreateContext( + display, + config, + EGL14.EGL_NO_CONTEXT, + intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE), + 0 + ) + check(context != EGL14.EGL_NO_CONTEXT) { + "eglCreateContext failed: 0x${EGL14.eglGetError().toString(16)}" + } + + eglSurface = EGL14.eglCreateWindowSurface( + display, + config, + surface, + intArrayOf(EGL14.EGL_NONE), + 0 + ) + check(eglSurface != EGL14.EGL_NO_SURFACE) { + "eglCreateWindowSurface failed: 0x${EGL14.eglGetError().toString(16)}" + } + makeCurrent() + } + + fun swapBuffers() { + check(EGL14.eglSwapBuffers(display, eglSurface)) { + "eglSwapBuffers failed: 0x${EGL14.eglGetError().toString(16)}" + } + } + + fun release() { + if (display != EGL14.EGL_NO_DISPLAY) { + EGL14.eglMakeCurrent( + display, + EGL14.EGL_NO_SURFACE, + EGL14.EGL_NO_SURFACE, + EGL14.EGL_NO_CONTEXT + ) + if (eglSurface != EGL14.EGL_NO_SURFACE) { + EGL14.eglDestroySurface(display, eglSurface) + } + if (context != EGL14.EGL_NO_CONTEXT) { + EGL14.eglDestroyContext(display, context) + } + EGL14.eglTerminate(display) + } + surface.release() + display = EGL14.EGL_NO_DISPLAY + context = EGL14.EGL_NO_CONTEXT + eglSurface = EGL14.EGL_NO_SURFACE + } + + private fun makeCurrent() { + check(EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context)) { + "eglMakeCurrent failed: 0x${EGL14.eglGetError().toString(16)}" + } + } + + private fun chooseConfig(display: EGLDisplay): EGLConfig { + val configs = arrayOfNulls(1) + val numConfigs = IntArray(1) + val attributes = intArrayOf( + EGL14.EGL_RENDERABLE_TYPE, + EGL_OPENGL_ES3_BIT, + EGL14.EGL_SURFACE_TYPE, + EGL14.EGL_WINDOW_BIT, + EGL14.EGL_RED_SIZE, + 8, + EGL14.EGL_GREEN_SIZE, + 8, + EGL14.EGL_BLUE_SIZE, + 8, + EGL14.EGL_ALPHA_SIZE, + 8, + EGL14.EGL_DEPTH_SIZE, + 0, + EGL14.EGL_STENCIL_SIZE, + 0, + EGL14.EGL_NONE + ) + check( + EGL14.eglChooseConfig( + display, + attributes, + 0, + configs, + 0, + configs.size, + numConfigs, + 0 + ) && numConfigs[0] > 0 + ) { + "eglChooseConfig failed: 0x${EGL14.eglGetError().toString(16)}" + } + return configs[0] ?: error("eglChooseConfig returned null") + } + } + + companion object { + const val THREAD_NAME_PREFIX = "CinematicWaveRenderer" + private const val EGL_OPENGL_ES3_BIT = 0x00000040 + private const val NANOS_PER_SECOND_FLOAT = 1_000_000_000f + private const val MAX_TOUCHES = 5 + private const val MAX_RELEASED_TOUCHES = 5 + private const val RELEASE_DECAY_MS = 720f + private const val RELEASE_MAX_AGE_MS = 1_800L + private const val RELEASE_MIN_STRENGTH = 0.018f + private val threadIds = AtomicInteger(0) + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRendererController.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRendererController.kt new file mode 100644 index 00000000..1ca7f983 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRendererController.kt @@ -0,0 +1,27 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import android.graphics.SurfaceTexture + +internal fun interface CinematicWaveRendererFactory { + fun create( + inputQueue: CinematicWaveInputCommandQueue, + callback: CinematicWaveRendererCallback + ): CinematicWaveRendererController +} + +internal fun interface CinematicWaveRendererCallback { + fun onRendererDisabled(reason: String, throwable: Throwable?) +} + +internal interface CinematicWaveRendererController { + fun configure(settings: CinematicWaveSettings) + fun attachSurface(surfaceTexture: SurfaceTexture, width: Int, height: Int) + fun resizeSurface(width: Int, height: Int) + fun detachSurface() + fun resume() + fun requestRender() + fun clear() + fun pause() + fun release() + fun isRendererThreadAliveForTesting(): Boolean +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt new file mode 100644 index 00000000..8bbcbe0a --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt @@ -0,0 +1,306 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import android.graphics.Color +import androidx.annotation.ColorInt +import java.util.Random +import kotlin.math.max +import kotlin.math.min + +internal data class CinematicWaveSettings( + val enabled: Boolean, + val colorMode: String, + @ColorInt val primaryColor: Int, + @ColorInt val secondaryColor: Int, + val secondaryColorAuto: Boolean, + val opacityPercent: Int, + val intensityPercent: Int, + val motion: String, + val touchResponse: String, + val quality: String +) { + val normalizedColorMode: String = normalizeColorMode(colorMode) + val normalizedMotion: String = normalizeMotion(motion) + val normalizedTouchResponse: String = normalizeTouchResponse(touchResponse) + val normalizedQuality: String = normalizeQuality(quality) + val opacity: Float = (opacityPercent / 100f).coerceIn(0.18f, 0.68f) + val intensity: Float = (intensityPercent / 100f).coerceIn(0.35f, 1.8f) + + companion object { + const val COLOR_MODE_CINEMATIC_RANDOM = "cinematic_random" + const val COLOR_MODE_CUSTOM = "custom" + + const val MOTION_CALM = "calm" + const val MOTION_ELEGANT = "elegant" + const val MOTION_DYNAMIC = "dynamic" + + const val TOUCH_RESPONSE_SUBTLE = "subtle" + const val TOUCH_RESPONSE_NORMAL = "normal" + const val TOUCH_RESPONSE_DEEP = "deep" + + const val QUALITY_BATTERY_SAVER = "battery_saver" + const val QUALITY_BALANCED = "balanced" + const val QUALITY_CINEMATIC = "cinematic" + + @ColorInt + const val DEFAULT_PRIMARY_COLOR: Int = 0xFF41D9FF.toInt() + + @ColorInt + const val DEFAULT_SECONDARY_COLOR: Int = 0xFF8B5CFF.toInt() + + val Disabled = CinematicWaveSettings( + enabled = false, + colorMode = COLOR_MODE_CINEMATIC_RANDOM, + primaryColor = DEFAULT_PRIMARY_COLOR, + secondaryColor = DEFAULT_SECONDARY_COLOR, + secondaryColorAuto = true, + opacityPercent = 46, + intensityPercent = 100, + motion = MOTION_ELEGANT, + touchResponse = TOUCH_RESPONSE_NORMAL, + quality = QUALITY_BALANCED + ) + + fun normalizeColorMode(value: String?): String { + return when (value) { + COLOR_MODE_CUSTOM -> COLOR_MODE_CUSTOM + else -> COLOR_MODE_CINEMATIC_RANDOM + } + } + + fun normalizeMotion(value: String?): String { + return when (value) { + MOTION_CALM -> MOTION_CALM + MOTION_DYNAMIC -> MOTION_DYNAMIC + else -> MOTION_ELEGANT + } + } + + fun normalizeTouchResponse(value: String?): String { + return when (value) { + TOUCH_RESPONSE_SUBTLE -> TOUCH_RESPONSE_SUBTLE + TOUCH_RESPONSE_DEEP -> TOUCH_RESPONSE_DEEP + else -> TOUCH_RESPONSE_NORMAL + } + } + + fun normalizeQuality(value: String?): String { + return when (value) { + QUALITY_BATTERY_SAVER -> QUALITY_BATTERY_SAVER + QUALITY_CINEMATIC -> QUALITY_CINEMATIC + else -> QUALITY_BALANCED + } + } + + @ColorInt + fun withoutTransparentAlpha(@ColorInt color: Int): Int { + return if (Color.alpha(color) == 0) { + 0xFF000000.toInt() or (color and 0x00FFFFFF) + } else { + color + } + } + } +} + +internal data class CinematicWaveColor( + val red: Float, + val green: Float, + val blue: Float +) { + fun mix(other: CinematicWaveColor, amount: Float): CinematicWaveColor { + val t = amount.coerceIn(0f, 1f) + return CinematicWaveColor( + red = red + (other.red - red) * t, + green = green + (other.green - green) * t, + blue = blue + (other.blue - blue) * t + ) + } + + fun scale(amount: Float): CinematicWaveColor { + return CinematicWaveColor( + red = (red * amount).coerceIn(0f, 1f), + green = (green * amount).coerceIn(0f, 1f), + blue = (blue * amount).coerceIn(0f, 1f) + ) + } + + companion object { + val NearBlack = CinematicWaveColor(0.018f, 0.024f, 0.036f) + val SoftWhite = CinematicWaveColor(0.86f, 0.94f, 1f) + + fun fromColorInt(@ColorInt color: Int): CinematicWaveColor { + val normalized = CinematicWaveSettings.withoutTransparentAlpha(color) + return CinematicWaveColor( + red = Color.red(normalized) / 255f, + green = Color.green(normalized) / 255f, + blue = Color.blue(normalized) / 255f + ) + } + } +} + +internal data class CinematicWavePalette( + val name: String, + val base: CinematicWaveColor, + val primary: CinematicWaveColor, + val secondary: CinematicWaveColor, + val highlight: CinematicWaveColor +) { + fun mix(other: CinematicWavePalette, amount: Float): CinematicWavePalette { + return CinematicWavePalette( + name = "$name/${other.name}", + base = base.mix(other.base, amount), + primary = primary.mix(other.primary, amount), + secondary = secondary.mix(other.secondary, amount), + highlight = highlight.mix(other.highlight, amount) + ) + } +} + +internal class CinematicWaveColorController( + private val random: Random = Random() +) { + private var settings = CinematicWaveSettings.Disabled + private var currentPalette: CinematicWavePalette = PRESET_PALETTES[0] + private var nextPalette: CinematicWavePalette = PRESET_PALETTES[1] + private var transitionStartMs = 0L + private var transitionDurationMs = DEFAULT_TRANSITION_MS + + fun configure(settings: CinematicWaveSettings, nowMs: Long) { + val previousMode = this.settings.normalizedColorMode + val nextSettings = settings.copy( + primaryColor = CinematicWaveSettings.withoutTransparentAlpha(settings.primaryColor), + secondaryColor = CinematicWaveSettings.withoutTransparentAlpha(settings.secondaryColor) + ) + this.settings = nextSettings + + if (nextSettings.normalizedColorMode == CinematicWaveSettings.COLOR_MODE_CUSTOM) { + val customPalette = customPaletteFromSettings(nextSettings) + currentPalette = customPalette + nextPalette = customPalette + transitionStartMs = nowMs + transitionDurationMs = DEFAULT_TRANSITION_MS + return + } + + if (previousMode != CinematicWaveSettings.COLOR_MODE_CINEMATIC_RANDOM || + currentPalette.name == nextPalette.name + ) { + currentPalette = choosePreset(excludingName = null) + nextPalette = choosePreset(excludingName = currentPalette.name) + transitionStartMs = nowMs + transitionDurationMs = nextTransitionDurationMs() + } + } + + fun paletteAt(nowMs: Long): CinematicWavePalette { + if (settings.normalizedColorMode == CinematicWaveSettings.COLOR_MODE_CUSTOM) { + return currentPalette + } + val duration = max(1L, transitionDurationMs) + val t = ((nowMs - transitionStartMs).toFloat() / duration).coerceIn(0f, 1f) + if (t >= 1f) { + currentPalette = nextPalette + nextPalette = choosePreset(excludingName = currentPalette.name) + transitionStartMs = nowMs + transitionDurationMs = nextTransitionDurationMs() + return currentPalette + } + val smoothed = t * t * (3f - 2f * t) + return currentPalette.mix(nextPalette, smoothed) + } + + private fun choosePreset(excludingName: String?): CinematicWavePalette { + val candidates = PRESET_PALETTES.filterNot { it.name == excludingName } + return candidates[random.nextInt(candidates.size)] + } + + private fun nextTransitionDurationMs(): Long { + return MIN_TRANSITION_MS + random.nextInt((MAX_TRANSITION_MS - MIN_TRANSITION_MS).toInt()) + } + + private fun customPaletteFromSettings(settings: CinematicWaveSettings): CinematicWavePalette { + val primary = CinematicWaveColor.fromColorInt(settings.primaryColor) + val secondary = if (settings.secondaryColorAuto) { + deriveSecondary(primary) + } else { + CinematicWaveColor.fromColorInt(settings.secondaryColor) + } + val base = deriveBase(primary, secondary) + val highlight = primary.mix(CinematicWaveColor.SoftWhite, 0.72f) + return CinematicWavePalette( + name = "Custom", + base = base, + primary = primary.mix(CinematicWaveColor.SoftWhite, 0.08f), + secondary = secondary.mix(base, 0.12f), + highlight = highlight + ) + } + + private fun deriveBase( + primary: CinematicWaveColor, + secondary: CinematicWaveColor + ): CinematicWaveColor { + return primary.mix(secondary, 0.38f) + .mix(CinematicWaveColor.NearBlack, 0.86f) + .scale(0.58f) + } + + private fun deriveSecondary(primary: CinematicWaveColor): CinematicWaveColor { + val maxChannel = max(primary.red, max(primary.green, primary.blue)) + val minChannel = min(primary.red, min(primary.green, primary.blue)) + return if (maxChannel - minChannel < 0.16f) { + CinematicWaveColor(0.48f, 0.42f, 0.72f) + } else { + CinematicWaveColor( + red = (primary.blue * 0.72f + 0.25f).coerceIn(0f, 1f), + green = (primary.red * 0.28f + primary.green * 0.18f + 0.10f).coerceIn(0f, 1f), + blue = (primary.green * 0.72f + 0.25f).coerceIn(0f, 1f) + ) + } + } + + companion object { + private const val MIN_TRANSITION_MS = 12_000L + private const val MAX_TRANSITION_MS = 30_000L + private const val DEFAULT_TRANSITION_MS = 18_000L + + val PRESET_PALETTES = listOf( + CinematicWavePalette( + name = "Midnight Aurora", + base = CinematicWaveColor(0.012f, 0.018f, 0.050f), + primary = CinematicWaveColor(0.20f, 0.88f, 1.00f), + secondary = CinematicWaveColor(0.52f, 0.28f, 0.92f), + highlight = CinematicWaveColor(0.78f, 0.92f, 1.00f) + ), + CinematicWavePalette( + name = "Deep Ocean", + base = CinematicWaveColor(0.000f, 0.040f, 0.070f), + primary = CinematicWaveColor(0.16f, 0.86f, 0.86f), + secondary = CinematicWaveColor(0.10f, 0.22f, 0.76f), + highlight = CinematicWaveColor(0.70f, 1.00f, 0.96f) + ), + CinematicWavePalette( + name = "Purple Nebula", + base = CinematicWaveColor(0.035f, 0.010f, 0.060f), + primary = CinematicWaveColor(0.86f, 0.22f, 0.82f), + secondary = CinematicWaveColor(0.30f, 0.24f, 0.92f), + highlight = CinematicWaveColor(0.88f, 0.74f, 1.00f) + ), + CinematicWavePalette( + name = "Golden Dusk", + base = CinematicWaveColor(0.055f, 0.032f, 0.018f), + primary = CinematicWaveColor(1.00f, 0.62f, 0.20f), + secondary = CinematicWaveColor(0.90f, 0.42f, 0.46f), + highlight = CinematicWaveColor(1.00f, 0.90f, 0.72f) + ), + CinematicWavePalette( + name = "Silver Mist", + base = CinematicWaveColor(0.035f, 0.040f, 0.048f), + primary = CinematicWaveColor(0.58f, 0.74f, 0.90f), + secondary = CinematicWaveColor(0.42f, 0.34f, 0.56f), + highlight = CinematicWaveColor(0.90f, 0.94f, 0.96f) + ) + ) + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt new file mode 100644 index 00000000..a57d05d0 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt @@ -0,0 +1,377 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import android.opengl.GLES30 +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer + +internal class CinematicWaveSimulation { + private var program = 0 + private var surfaceWidth = 0 + private var surfaceHeight = 0 + private var timeSeconds = 0f + private var settings = CinematicWaveSettings.Disabled + + private var uResolution = -1 + private var uTime = -1 + private var uOpacity = -1 + private var uIntensity = -1 + private var uMotion = -1 + private var uNoiseOctaves = -1 + private var uGlowStrength = -1 + private var uWarpStrength = -1 + private var uBaseColor = -1 + private var uPrimaryColor = -1 + private var uSecondaryColor = -1 + private var uHighlightColor = -1 + private var uTouchCount = -1 + private var uTouchPositions = -1 + private var uTouchAges = -1 + private var uTouchStrengths = -1 + + private val quadVertices: FloatBuffer = ByteBuffer + .allocateDirect(QUAD.size * Float.SIZE_BYTES) + .order(ByteOrder.nativeOrder()) + .asFloatBuffer() + .apply { + put(QUAD) + position(0) + } + + fun initialize( + surfaceWidth: Int, + surfaceHeight: Int, + settings: CinematicWaveSettings + ) { + require(surfaceWidth > 0 && surfaceHeight > 0) { + "Invalid cinematic wave surface size ${surfaceWidth}x$surfaceHeight" + } + if (program == 0) { + createProgram() + } + this.surfaceWidth = surfaceWidth + this.surfaceHeight = surfaceHeight + configure(settings) + GLES30.glDisable(GLES30.GL_DEPTH_TEST) + GLES30.glDisable(GLES30.GL_CULL_FACE) + GLES30.glEnable(GLES30.GL_BLEND) + GLES30.glBlendFunc(GLES30.GL_ONE, GLES30.GL_ONE_MINUS_SRC_ALPHA) + GLES30.glViewport(0, 0, surfaceWidth, surfaceHeight) + clear() + } + + fun configure(settings: CinematicWaveSettings) { + this.settings = settings + } + + fun resizeSurface(surfaceWidth: Int, surfaceHeight: Int) { + if (program == 0) { + initialize(surfaceWidth, surfaceHeight, settings) + return + } + if (surfaceWidth <= 0 || surfaceHeight <= 0) return + this.surfaceWidth = surfaceWidth + this.surfaceHeight = surfaceHeight + GLES30.glViewport(0, 0, surfaceWidth, surfaceHeight) + } + + fun render( + dtSeconds: Float, + params: CinematicWaveStepParams, + palette: CinematicWavePalette, + touches: List + ) { + if (program == 0 || surfaceWidth <= 0 || surfaceHeight <= 0) return + val clampedDt = dtSeconds.coerceIn(MIN_DT_SECONDS, MAX_DT_SECONDS) + timeSeconds = (timeSeconds + clampedDt * params.motionSpeed).let { + if (it > TIME_WRAP_SECONDS) it - TIME_WRAP_SECONDS else it + } + + GLES30.glViewport(0, 0, surfaceWidth, surfaceHeight) + GLES30.glClearColor(0f, 0f, 0f, 0f) + GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT) + GLES30.glUseProgram(program) + + GLES30.glUniform2f(uResolution, surfaceWidth.toFloat(), surfaceHeight.toFloat()) + GLES30.glUniform1f(uTime, timeSeconds) + GLES30.glUniform1f(uOpacity, settings.opacity) + GLES30.glUniform1f(uIntensity, settings.intensity) + GLES30.glUniform1f(uMotion, params.motionSpeed) + GLES30.glUniform1i(uNoiseOctaves, params.noiseOctaves) + GLES30.glUniform1f(uGlowStrength, params.glowStrength) + GLES30.glUniform1f(uWarpStrength, params.warpStrength) + palette.base.uniform(uBaseColor) + palette.primary.uniform(uPrimaryColor) + palette.secondary.uniform(uSecondaryColor) + palette.highlight.uniform(uHighlightColor) + uploadTouches(touches, params) + + quadVertices.position(0) + GLES30.glEnableVertexAttribArray(0) + GLES30.glVertexAttribPointer( + 0, + 2, + GLES30.GL_FLOAT, + false, + 2 * Float.SIZE_BYTES, + quadVertices + ) + GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4) + GLES30.glDisableVertexAttribArray(0) + GLES30.glUseProgram(0) + } + + fun clear() { + if (surfaceWidth <= 0 || surfaceHeight <= 0) return + GLES30.glViewport(0, 0, surfaceWidth, surfaceHeight) + GLES30.glClearColor(0f, 0f, 0f, 0f) + GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT) + } + + fun release() { + if (program != 0) { + GLES30.glDeleteProgram(program) + program = 0 + } + surfaceWidth = 0 + surfaceHeight = 0 + } + + private fun uploadTouches( + touches: List, + params: CinematicWaveStepParams + ) { + val count = minOf(MAX_TOUCHES, touches.size) + GLES30.glUniform1i(uTouchCount, count) + val positions = FloatArray(MAX_TOUCHES * 2) + val ages = FloatArray(MAX_TOUCHES) + val strengths = FloatArray(MAX_TOUCHES) + for (index in 0 until count) { + val touch = touches[index] + positions[index * 2] = touch.x + positions[index * 2 + 1] = touch.y + ages[index] = touch.ageSeconds + strengths[index] = touch.strength * params.touchResponse + } + GLES30.glUniform2fv(uTouchPositions, MAX_TOUCHES, positions, 0) + GLES30.glUniform1fv(uTouchAges, MAX_TOUCHES, ages, 0) + GLES30.glUniform1fv(uTouchStrengths, MAX_TOUCHES, strengths, 0) + } + + private fun CinematicWaveColor.uniform(location: Int) { + GLES30.glUniform3f(location, red, green, blue) + } + + private fun createProgram() { + program = linkProgram(VERTEX_SHADER, FRAGMENT_SHADER) + uResolution = GLES30.glGetUniformLocation(program, "uResolution") + uTime = GLES30.glGetUniformLocation(program, "uTime") + uOpacity = GLES30.glGetUniformLocation(program, "uOpacity") + uIntensity = GLES30.glGetUniformLocation(program, "uIntensity") + uMotion = GLES30.glGetUniformLocation(program, "uMotion") + uNoiseOctaves = GLES30.glGetUniformLocation(program, "uNoiseOctaves") + uGlowStrength = GLES30.glGetUniformLocation(program, "uGlowStrength") + uWarpStrength = GLES30.glGetUniformLocation(program, "uWarpStrength") + uBaseColor = GLES30.glGetUniformLocation(program, "uBaseColor") + uPrimaryColor = GLES30.glGetUniformLocation(program, "uPrimaryColor") + uSecondaryColor = GLES30.glGetUniformLocation(program, "uSecondaryColor") + uHighlightColor = GLES30.glGetUniformLocation(program, "uHighlightColor") + uTouchCount = GLES30.glGetUniformLocation(program, "uTouchCount") + uTouchPositions = GLES30.glGetUniformLocation(program, "uTouchPositions[0]") + uTouchAges = GLES30.glGetUniformLocation(program, "uTouchAges[0]") + uTouchStrengths = GLES30.glGetUniformLocation(program, "uTouchStrengths[0]") + } + + private fun linkProgram(vertexSource: String, fragmentSource: String): Int { + val vertexShader = compileShader(GLES30.GL_VERTEX_SHADER, vertexSource) + val fragmentShader = compileShader(GLES30.GL_FRAGMENT_SHADER, fragmentSource) + val nextProgram = GLES30.glCreateProgram() + GLES30.glAttachShader(nextProgram, vertexShader) + GLES30.glAttachShader(nextProgram, fragmentShader) + GLES30.glBindAttribLocation(nextProgram, 0, "aPosition") + GLES30.glLinkProgram(nextProgram) + + val linkStatus = IntArray(1) + GLES30.glGetProgramiv(nextProgram, GLES30.GL_LINK_STATUS, linkStatus, 0) + GLES30.glDeleteShader(vertexShader) + GLES30.glDeleteShader(fragmentShader) + if (linkStatus[0] == 0) { + val log = GLES30.glGetProgramInfoLog(nextProgram) + GLES30.glDeleteProgram(nextProgram) + error("Cinematic wave shader program link failed: $log") + } + return nextProgram + } + + private fun compileShader(type: Int, source: String): Int { + val shader = GLES30.glCreateShader(type) + GLES30.glShaderSource(shader, source.trimIndent()) + GLES30.glCompileShader(shader) + + val compileStatus = IntArray(1) + GLES30.glGetShaderiv(shader, GLES30.GL_COMPILE_STATUS, compileStatus, 0) + if (compileStatus[0] == 0) { + val log = GLES30.glGetShaderInfoLog(shader) + GLES30.glDeleteShader(shader) + error("Cinematic wave shader compile failed: $log") + } + return shader + } + + companion object { + private const val MAX_TOUCHES = 5 + private const val MIN_DT_SECONDS = 1f / 120f + private const val MAX_DT_SECONDS = 1f / 18f + private const val TIME_WRAP_SECONDS = 600f + + private val QUAD = floatArrayOf( + -1f, + -1f, + 1f, + -1f, + -1f, + 1f, + 1f, + 1f + ) + + private const val VERTEX_SHADER = """ + #version 300 es + layout(location = 0) in vec2 aPosition; + out vec2 vUv; + + void main() { + vUv = aPosition * 0.5 + 0.5; + gl_Position = vec4(aPosition, 0.0, 1.0); + } + """ + + private const val FRAGMENT_SHADER = """ + #version 300 es + precision highp float; + + in vec2 vUv; + out vec4 fragColor; + + uniform vec2 uResolution; + uniform float uTime; + uniform float uOpacity; + uniform float uIntensity; + uniform float uMotion; + uniform int uNoiseOctaves; + uniform float uGlowStrength; + uniform float uWarpStrength; + uniform vec3 uBaseColor; + uniform vec3 uPrimaryColor; + uniform vec3 uSecondaryColor; + uniform vec3 uHighlightColor; + uniform int uTouchCount; + uniform vec2 uTouchPositions[5]; + uniform float uTouchAges[5]; + uniform float uTouchStrengths[5]; + + float hash(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); + } + + float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); + } + + float fbm(vec2 p) { + float value = 0.0; + float amplitude = 0.5; + mat2 rotate = mat2(0.82, -0.58, 0.58, 0.82); + for (int i = 0; i < 5; i++) { + if (i >= uNoiseOctaves) { + break; + } + value += amplitude * noise(p); + p = rotate * p * 2.04 + vec2(7.1, 3.4); + amplitude *= 0.52; + } + return value; + } + + float ridge(float x) { + float v = 1.0 - abs(x); + return smoothstep(0.08, 0.96, v); + } + + void main() { + vec2 uv = gl_FragCoord.xy / max(uResolution, vec2(1.0)); + vec2 centered = uv - 0.5; + centered.x *= uResolution.x / max(uResolution.y, 1.0); + + float slowTime = uTime * uMotion; + vec2 warp = vec2( + fbm(uv * 2.0 + vec2(slowTime * 0.050, -slowTime * 0.018)), + fbm(uv * 2.5 - vec2(slowTime * 0.034, slowTime * 0.043)) + ) - 0.5; + vec2 warpedUv = uv + warp * 0.072 * uWarpStrength; + + float touchGlow = 0.0; + float touchShadow = 0.0; + for (int i = 0; i < 5; i++) { + if (i >= uTouchCount) { + break; + } + vec2 touchUv = uTouchPositions[i]; + vec2 delta = warpedUv - touchUv; + delta.x *= uResolution.x / max(uResolution.y, 1.0); + float d = length(delta); + vec2 dir = normalize(delta + vec2(0.0001)); + float ageSoftness = 1.0 / (1.0 + uTouchAges[i] * 0.18); + float strength = clamp(uTouchStrengths[i], 0.0, 1.8) * ageSoftness; + float lens = exp(-d * d * 42.0) * strength; + warpedUv += dir * lens * 0.040; + warpedUv += vec2(-dir.y, dir.x) * lens * 0.018; + touchGlow += exp(-d * d * 52.0) * strength; + touchShadow += exp(-d * d * 18.0) * strength; + } + + vec2 drift = warpedUv; + drift += vec2( + fbm(warpedUv * 4.2 + slowTime * 0.045), + fbm(warpedUv * 3.6 - slowTime * 0.052) + ) * 0.060 * uWarpStrength; + + float w1 = sin(drift.x * 8.0 + drift.y * 3.0 + slowTime * 0.35); + float w2 = sin(drift.x * 14.0 - drift.y * 2.0 - slowTime * 0.22); + float w3 = sin((drift.x + drift.y) * 10.5 + fbm(drift * 2.7) * 3.8 + slowTime * 0.18); + float field = w1 * 0.50 + w2 * 0.31 + w3 * 0.19 + (fbm(drift * 4.0) - 0.5) * 0.54; + float ridgeA = ridge(field); + float ridgeB = ridge(field * 0.74 + fbm(drift * 5.8 + slowTime * 0.055) * 0.72 - 0.18); + float glow = pow(clamp(ridgeA * 0.76 + ridgeB * 0.42, 0.0, 1.0), 2.12); + + float depth = smoothstep(0.0, 1.0, uv.y); + vec3 base = mix(uBaseColor * 0.50, uBaseColor * 1.34, depth); + base += vec3(0.018, 0.022, 0.030) * (1.0 - length(centered) * 0.36); + + float veil = smoothstep(-0.34, 0.72, field) * (1.0 - smoothstep(0.10, 1.22, abs(centered.y))); + vec3 color = base; + color += uPrimaryColor * glow * 0.58 * uGlowStrength * uIntensity; + color += uSecondaryColor * ridgeB * 0.30 * uIntensity; + color += mix(uPrimaryColor, uSecondaryColor, fbm(drift * 2.1)) * veil * 0.16; + color += uHighlightColor * pow(glow, 3.0) * 0.30 * uGlowStrength; + color += uHighlightColor * touchGlow * 0.16 * uIntensity; + color -= uBaseColor * touchShadow * 0.045; + + float glass = 0.085 + (1.0 - smoothstep(0.0, 0.92, length(centered))) * 0.055; + float alpha = glass + glow * 0.42 * uIntensity + veil * 0.10 + touchGlow * 0.10; + alpha = clamp(alpha * uOpacity, 0.0, 0.62); + color = clamp(color, vec3(0.0), vec3(1.0)); + fragColor = vec4(color * alpha, alpha); + } + """ + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectType.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectType.kt index 701b45cf..7fe99c30 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectType.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectType.kt @@ -13,6 +13,7 @@ object KeyboardTouchEffectType { const val LIQUID_RIPPLE = "liquid_ripple" const val SPRAY_PAINT = "spray_paint" const val LUMINOUS_BLOB = "luminous_blob" + const val CINEMATIC_WAVE = "cinematic_wave" fun normalize(value: String?): String { return when (value) { @@ -24,6 +25,7 @@ object KeyboardTouchEffectType { LIQUID_RIPPLE -> LIQUID_RIPPLE SPRAY_PAINT -> SPRAY_PAINT LUMINOUS_BLOB -> LUMINOUS_BLOB + CINEMATIC_WAVE -> CINEMATIC_WAVE else -> NONE } } @@ -55,4 +57,8 @@ object KeyboardTouchEffectType { fun isLuminousBlob(value: String): Boolean { return normalize(value) == LUMINOUS_BLOB } + + fun isCinematicWave(value: String): Boolean { + return normalize(value) == CINEMATIC_WAVE + } } 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 0fe60a36..8ff9648e 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 @@ -14,6 +14,7 @@ import com.kazumaproject.custom_keyboard.data.buildEvenCircularRanges import com.kazumaproject.domain.EmojiSkinToneSupport import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.KeyboardTouchEffectQuality import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.KeyboardTouchEffectType +import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.CinematicWaveSettings import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.SprayPaintSettings import com.kazumaproject.markdownhelperkeyboard.ime_service.state.CandidateTab import com.kazumaproject.markdownhelperkeyboard.ime_service.state.KeyboardType @@ -640,6 +641,42 @@ object AppPreference { "keyboard_touch_effect_palette_preference", SprayPaintSettings.PALETTE_PAINT_SPLASH ) + private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_COLOR_MODE = + Pair( + "keyboard_touch_effect_cinematic_wave_color_mode_preference", + CinematicWaveSettings.COLOR_MODE_CINEMATIC_RANDOM + ) + private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_PRIMARY_COLOR = + Pair( + "keyboard_touch_effect_cinematic_wave_primary_color_preference", + CinematicWaveSettings.DEFAULT_PRIMARY_COLOR + ) + private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_SECONDARY_COLOR = + Pair( + "keyboard_touch_effect_cinematic_wave_secondary_color_preference", + CinematicWaveSettings.DEFAULT_SECONDARY_COLOR + ) + private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_SECONDARY_COLOR_AUTO = + Pair("keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference", true) + private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_OPACITY = + Pair("keyboard_touch_effect_cinematic_wave_opacity_percent_preference", 46) + private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_INTENSITY = + Pair("keyboard_touch_effect_cinematic_wave_intensity_percent_preference", 100) + private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_MOTION = + Pair( + "keyboard_touch_effect_cinematic_wave_motion_preference", + CinematicWaveSettings.MOTION_ELEGANT + ) + private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_TOUCH_RESPONSE = + Pair( + "keyboard_touch_effect_cinematic_wave_touch_response_preference", + CinematicWaveSettings.TOUCH_RESPONSE_NORMAL + ) + private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_QUALITY = + Pair( + "keyboard_touch_effect_cinematic_wave_quality_preference", + CinematicWaveSettings.QUALITY_BALANCED + ) private val SAVE_LAST_USED_KEYBOARD = Pair("save_last_used_keyboard", false) private val SAVE_LAST_USED_KEYBOARD_POSITION = Pair("save_last_used_keyboard_int", 0) @@ -3319,6 +3356,120 @@ object AppPreference { ) } + var keyboard_touch_effect_cinematic_wave_color_mode_preference: String + get() { + val value = preferences.getString( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_COLOR_MODE.first, + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_COLOR_MODE.second + ) + return CinematicWaveSettings.normalizeColorMode(value) + } + set(value) = preferences.edit { + it.putString( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_COLOR_MODE.first, + CinematicWaveSettings.normalizeColorMode(value) + ) + } + + var keyboard_touch_effect_cinematic_wave_primary_color_preference: Int + get() = preferences.getInt( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_PRIMARY_COLOR.first, + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_PRIMARY_COLOR.second + ) + set(value) = preferences.edit { + it.putInt( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_PRIMARY_COLOR.first, + CinematicWaveSettings.withoutTransparentAlpha(value) + ) + } + + var keyboard_touch_effect_cinematic_wave_secondary_color_preference: Int + get() = preferences.getInt( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_SECONDARY_COLOR.first, + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_SECONDARY_COLOR.second + ) + set(value) = preferences.edit { + it.putInt( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_SECONDARY_COLOR.first, + CinematicWaveSettings.withoutTransparentAlpha(value) + ) + } + + var keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference: Boolean + get() = preferences.getBoolean( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_SECONDARY_COLOR_AUTO.first, + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_SECONDARY_COLOR_AUTO.second + ) + set(value) = preferences.edit { + it.putBoolean(KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_SECONDARY_COLOR_AUTO.first, value) + } + + var keyboard_touch_effect_cinematic_wave_opacity_percent_preference: Int + get() = preferences.getInt( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_OPACITY.first, + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_OPACITY.second + ).coerceIn(18, 68) + set(value) = preferences.edit { + it.putInt(KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_OPACITY.first, value.coerceIn(18, 68)) + } + + var keyboard_touch_effect_cinematic_wave_intensity_percent_preference: Int + get() = preferences.getInt( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_INTENSITY.first, + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_INTENSITY.second + ).coerceIn(35, 180) + set(value) = preferences.edit { + it.putInt( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_INTENSITY.first, + value.coerceIn(35, 180) + ) + } + + var keyboard_touch_effect_cinematic_wave_motion_preference: String + get() { + val value = preferences.getString( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_MOTION.first, + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_MOTION.second + ) + return CinematicWaveSettings.normalizeMotion(value) + } + set(value) = preferences.edit { + it.putString( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_MOTION.first, + CinematicWaveSettings.normalizeMotion(value) + ) + } + + var keyboard_touch_effect_cinematic_wave_touch_response_preference: String + get() { + val value = preferences.getString( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_TOUCH_RESPONSE.first, + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_TOUCH_RESPONSE.second + ) + return CinematicWaveSettings.normalizeTouchResponse(value) + } + set(value) = preferences.edit { + it.putString( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_TOUCH_RESPONSE.first, + CinematicWaveSettings.normalizeTouchResponse(value) + ) + } + + var keyboard_touch_effect_cinematic_wave_quality_preference: String + get() { + val value = preferences.getString( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_QUALITY.first, + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_QUALITY.second + ) + return CinematicWaveSettings.normalizeQuality(value) + } + set(value) = preferences.edit { + it.putString( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_QUALITY.first, + CinematicWaveSettings.normalizeQuality(value) + ) + } + private fun normalizeTouchEffectColorMode(value: String?): String { return when (value) { "fixed" -> "fixed" diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt index 356a1713..2b7a02e3 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt @@ -26,6 +26,7 @@ import com.afollestad.materialdialogs.color.colorChooser import com.google.android.material.color.DynamicColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.kazumaproject.markdownhelperkeyboard.R +import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.CinematicWaveSettings import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.KeyboardTouchEffectQuality import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.KeyboardTouchEffectType import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.SprayPaintSettings @@ -41,7 +42,10 @@ internal data class KeyboardTouchEffectPreferenceVisibility( val showQuality: Boolean, val showColorMode: Boolean, val showFixedColor: Boolean, - val showPalette: Boolean + val showPalette: Boolean, + val showCinematicWaveSettings: Boolean, + val showCinematicWaveCustomColors: Boolean, + val showCinematicWaveSecondaryColor: Boolean ) internal fun resolveKeyboardTouchEffectPreferenceVisibility( @@ -53,13 +57,19 @@ internal fun resolveKeyboardTouchEffectPreferenceVisibility( KeyboardTouchEffectType.isAuroraInk(normalizedEffect) val isSprayPaint = KeyboardTouchEffectType.isSprayPaint(normalizedEffect) val isLuminousBlob = KeyboardTouchEffectType.isLuminousBlob(normalizedEffect) + val isCinematicWave = KeyboardTouchEffectType.isCinematicWave(normalizedEffect) val isEffectEnabled = KeyboardTouchEffectType.isEnabled(normalizedEffect) val supportsColor = isInk || isSprayPaint || isLuminousBlob + val isCinematicCustom = + colorMode == CinematicWaveSettings.COLOR_MODE_CUSTOM || colorMode == "custom" return KeyboardTouchEffectPreferenceVisibility( - showQuality = isEffectEnabled, + showQuality = isEffectEnabled && !isCinematicWave, showColorMode = supportsColor, showFixedColor = supportsColor && colorMode == "fixed", - showPalette = isSprayPaint + showPalette = isSprayPaint, + showCinematicWaveSettings = isCinematicWave, + showCinematicWaveCustomColors = isCinematicWave && isCinematicCustom, + showCinematicWaveSecondaryColor = isCinematicWave && isCinematicCustom ) } @@ -260,12 +270,22 @@ open class CommonPreferenceFragment : PreferenceFragmentCompat() { private fun updateKeyboardTouchEffectPreferenceState( effectType: String = appPreference.keyboard_touch_effect_type_preference, - colorMode: String = appPreference.keyboard_touch_effect_color_mode_preference + colorMode: String = appPreference.keyboard_touch_effect_color_mode_preference, + cinematicWaveColorMode: String = + appPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference, + cinematicWaveSecondaryAuto: Boolean = + appPreference.keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference ) { val normalizedEffect = KeyboardTouchEffectType.normalize(effectType) + val normalizedCinematicColorMode = + CinematicWaveSettings.normalizeColorMode(cinematicWaveColorMode) val visibility = resolveKeyboardTouchEffectPreferenceVisibility( effectType = normalizedEffect, - colorMode = colorMode + colorMode = if (KeyboardTouchEffectType.isCinematicWave(normalizedEffect)) { + normalizedCinematicColorMode + } else { + colorMode + } ) findPreference("keyboard_touch_effect_quality_preference")?.isVisible = visibility.showQuality @@ -288,6 +308,66 @@ open class CommonPreferenceFragment : PreferenceFragmentCompat() { findPreference("keyboard_touch_effect_palette_preference")?.isVisible = visibility.showPalette + + findPreference( + "keyboard_touch_effect_cinematic_wave_color_mode_preference" + )?.isVisible = visibility.showCinematicWaveSettings + + val showCinematicCustomColors = visibility.showCinematicWaveCustomColors + val primaryPreference = + findPreference( + "keyboard_touch_effect_cinematic_wave_primary_color_preference" + ) + primaryPreference?.isVisible = showCinematicCustomColors + primaryPreference?.summary = if (showCinematicCustomColors) { + getString( + R.string.keyboard_touch_effect_cinematic_wave_primary_color_summary_current, + String.format( + "#%08X", + appPreference.keyboard_touch_effect_cinematic_wave_primary_color_preference + ) + ) + } else { + getString(R.string.keyboard_touch_effect_cinematic_wave_primary_color_summary) + } + + findPreference( + "keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference" + )?.isVisible = showCinematicCustomColors + + val secondaryPreference = + findPreference( + "keyboard_touch_effect_cinematic_wave_secondary_color_preference" + ) + val showSecondaryColor = showCinematicCustomColors && !cinematicWaveSecondaryAuto + secondaryPreference?.isVisible = showSecondaryColor + secondaryPreference?.summary = if (showSecondaryColor) { + getString( + R.string.keyboard_touch_effect_cinematic_wave_secondary_color_summary_current, + String.format( + "#%08X", + appPreference.keyboard_touch_effect_cinematic_wave_secondary_color_preference + ) + ) + } else { + getString(R.string.keyboard_touch_effect_cinematic_wave_secondary_color_summary) + } + + findPreference( + "keyboard_touch_effect_cinematic_wave_opacity_percent_preference" + )?.isVisible = visibility.showCinematicWaveSettings + findPreference( + "keyboard_touch_effect_cinematic_wave_intensity_percent_preference" + )?.isVisible = visibility.showCinematicWaveSettings + findPreference( + "keyboard_touch_effect_cinematic_wave_motion_preference" + )?.isVisible = visibility.showCinematicWaveSettings + findPreference( + "keyboard_touch_effect_cinematic_wave_touch_response_preference" + )?.isVisible = visibility.showCinematicWaveSettings + findPreference( + "keyboard_touch_effect_cinematic_wave_quality_preference" + )?.isVisible = visibility.showCinematicWaveSettings } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -529,6 +609,128 @@ open class CommonPreferenceFragment : PreferenceFragmentCompat() { } } + findPreference( + "keyboard_touch_effect_cinematic_wave_color_mode_preference" + )?.apply { + summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() + val normalizedMode = + appPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference + if (value != normalizedMode) { + value = normalizedMode + } + setOnPreferenceChangeListener { _, newValue -> + val nextMode = CinematicWaveSettings.normalizeColorMode(newValue as? String) + appPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference = + nextMode + updateKeyboardTouchEffectPreferenceState(cinematicWaveColorMode = nextMode) + true + } + } + + findPreference( + "keyboard_touch_effect_cinematic_wave_primary_color_preference" + )?.apply { + setOnPreferenceClickListener { + showCinematicWaveColorPickerDialog(primary = true) + true + } + } + + findPreference( + "keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference" + )?.apply { + isChecked = + appPreference.keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference + setOnPreferenceChangeListener { _, newValue -> + val enabled = newValue as? Boolean ?: true + appPreference.keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference = + enabled + updateKeyboardTouchEffectPreferenceState(cinematicWaveSecondaryAuto = enabled) + true + } + } + + findPreference( + "keyboard_touch_effect_cinematic_wave_secondary_color_preference" + )?.apply { + setOnPreferenceClickListener { + showCinematicWaveColorPickerDialog(primary = false) + true + } + } + + findPreference( + "keyboard_touch_effect_cinematic_wave_opacity_percent_preference" + )?.apply { + value = appPreference.keyboard_touch_effect_cinematic_wave_opacity_percent_preference + setOnPreferenceChangeListener { _, newValue -> + val nextValue = (newValue as? Int ?: value).coerceIn(18, 68) + appPreference.keyboard_touch_effect_cinematic_wave_opacity_percent_preference = + nextValue + true + } + } + + findPreference( + "keyboard_touch_effect_cinematic_wave_intensity_percent_preference" + )?.apply { + value = appPreference.keyboard_touch_effect_cinematic_wave_intensity_percent_preference + setOnPreferenceChangeListener { _, newValue -> + val nextValue = (newValue as? Int ?: value).coerceIn(35, 180) + appPreference.keyboard_touch_effect_cinematic_wave_intensity_percent_preference = + nextValue + true + } + } + + findPreference( + "keyboard_touch_effect_cinematic_wave_motion_preference" + )?.apply { + summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() + val normalizedMotion = + appPreference.keyboard_touch_effect_cinematic_wave_motion_preference + if (value != normalizedMotion) { + value = normalizedMotion + } + setOnPreferenceChangeListener { _, newValue -> + appPreference.keyboard_touch_effect_cinematic_wave_motion_preference = + CinematicWaveSettings.normalizeMotion(newValue as? String) + true + } + } + + findPreference( + "keyboard_touch_effect_cinematic_wave_touch_response_preference" + )?.apply { + summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() + val normalizedResponse = + appPreference.keyboard_touch_effect_cinematic_wave_touch_response_preference + if (value != normalizedResponse) { + value = normalizedResponse + } + setOnPreferenceChangeListener { _, newValue -> + appPreference.keyboard_touch_effect_cinematic_wave_touch_response_preference = + CinematicWaveSettings.normalizeTouchResponse(newValue as? String) + true + } + } + + findPreference( + "keyboard_touch_effect_cinematic_wave_quality_preference" + )?.apply { + summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() + val normalizedQuality = + appPreference.keyboard_touch_effect_cinematic_wave_quality_preference + if (value != normalizedQuality) { + value = normalizedQuality + } + setOnPreferenceChangeListener { _, newValue -> + appPreference.keyboard_touch_effect_cinematic_wave_quality_preference = + CinematicWaveSettings.normalizeQuality(newValue as? String) + true + } + } + updateKeyboardBackgroundImagePreferenceState() updateKeyboardBackgroundVideoPreferenceState() updateKeyboardTouchEffectPreferenceState() @@ -887,6 +1089,53 @@ open class CommonPreferenceFragment : PreferenceFragmentCompat() { } } + @SuppressLint("CheckResult") + private fun showCinematicWaveColorPickerDialog(primary: Boolean) { + val titleRes = if (primary) { + R.string.keyboard_touch_effect_cinematic_wave_primary_color_title + } else { + R.string.keyboard_touch_effect_cinematic_wave_secondary_color_title + } + val initialColor = if (primary) { + appPreference.keyboard_touch_effect_cinematic_wave_primary_color_preference + } else { + appPreference.keyboard_touch_effect_cinematic_wave_secondary_color_preference + } + + MaterialDialog(requireContext()).show { + title(text = getString(titleRes)) + colorChooser( + colors = intArrayOf( + Color.rgb(65, 217, 255), + Color.rgb(139, 92, 255), + Color.rgb(210, 62, 134), + Color.rgb(255, 174, 64), + Color.rgb(140, 170, 190) + ), + initialSelection = initialColor, + allowCustomArgb = true + ) { _, color -> + if (primary) { + appPreference.keyboard_touch_effect_cinematic_wave_primary_color_preference = + color + } else { + appPreference.keyboard_touch_effect_cinematic_wave_secondary_color_preference = + color + } + appPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference = + CinematicWaveSettings.COLOR_MODE_CUSTOM + findPreference( + "keyboard_touch_effect_cinematic_wave_color_mode_preference" + )?.value = CinematicWaveSettings.COLOR_MODE_CUSTOM + updateKeyboardTouchEffectPreferenceState( + cinematicWaveColorMode = CinematicWaveSettings.COLOR_MODE_CUSTOM + ) + } + positiveButton(android.R.string.ok) + negativeButton(android.R.string.cancel) + } + } + private fun updateKeySoundVolumeSummary(value: Int) { findPreference("key_sound_volume_percent_preference")?.summary = if (value == 0) { diff --git a/app/src/main/res/layout-land/main_layout.xml b/app/src/main/res/layout-land/main_layout.xml index 9363d349..7ed78b6a 100644 --- a/app/src/main/res/layout-land/main_layout.xml +++ b/app/src/main/res/layout-land/main_layout.xml @@ -77,6 +77,15 @@ android:focusable="false" android:importantForAccessibility="no" android:visibility="gone" /> + + + + diff --git a/app/src/main/res/layout/floating_keyboard_layout.xml b/app/src/main/res/layout/floating_keyboard_layout.xml index cddf7a07..7f901619 100644 --- a/app/src/main/res/layout/floating_keyboard_layout.xml +++ b/app/src/main/res/layout/floating_keyboard_layout.xml @@ -80,6 +80,15 @@ android:focusable="false" android:importantForAccessibility="no" android:visibility="gone" /> + + + + diff --git a/app/src/main/res/values-ja/arrays.xml b/app/src/main/res/values-ja/arrays.xml index 787efc86..48277ef9 100644 --- a/app/src/main/res/values-ja/arrays.xml +++ b/app/src/main/res/values-ja/arrays.xml @@ -41,6 +41,7 @@ @string/keyboard_touch_effect_liquid_ripple @string/keyboard_touch_effect_type_spray_paint @string/keyboard_touch_effect_type_luminous_blob + @string/keyboard_touch_effect_type_cinematic_wave @@ -50,6 +51,7 @@ liquid_ripple spray_paint luminous_blob + cinematic_wave @@ -106,6 +108,52 @@ flower_petals + + @string/keyboard_touch_effect_cinematic_wave_color_mode_random + @string/keyboard_touch_effect_cinematic_wave_color_mode_custom + + + + cinematic_random + custom + + + + @string/keyboard_touch_effect_cinematic_wave_motion_calm + @string/keyboard_touch_effect_cinematic_wave_motion_elegant + @string/keyboard_touch_effect_cinematic_wave_motion_dynamic + + + + calm + elegant + dynamic + + + + @string/keyboard_touch_effect_cinematic_wave_touch_response_subtle + @string/keyboard_touch_effect_cinematic_wave_touch_response_normal + @string/keyboard_touch_effect_cinematic_wave_touch_response_deep + + + + subtle + normal + deep + + + + @string/keyboard_touch_effect_cinematic_wave_quality_battery_saver + @string/keyboard_touch_effect_cinematic_wave_quality_balanced + @string/keyboard_touch_effect_cinematic_wave_quality_cinematic + + + + battery_saver + balanced + cinematic + + @string/default_emoji_skin_tone_default @string/default_emoji_skin_tone_light diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e83c3fdf..d7459d22 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1019,6 +1019,7 @@ 水面の波紋 スプレーペイント 光の膜 + Cinematic Wave タッチエフェクト品質 キーボードのタッチエフェクトの描画品質を選択します。 標準 @@ -1030,6 +1031,7 @@ 指を離した後も淡いオーロラインクがゆっくり漂います。 触れた位置から、水面のようなやわらかい波紋が広がります。 黄色い発光膜がゆっくり漂い、タッチ位置に反応して柔らかく歪みます。 + キーの奥でオーロラのような光の波が流れ、タッチ位置で柔らかく歪みます。 タップ時のエフェクト キーボードに触れた位置から、インクが広がる背景エフェクトを表示します。 インクの色 @@ -1054,6 +1056,37 @@ グラフィティ 液体ペンキ 花が舞い散る + Cinematic Wave の色 + Cinematic Wave のパレットの選び方を設定します。 + Cinematic Random + カスタム + Primary Color + メインの発光色を選択します。 + 現在: %1$s + Secondary Color Auto + Primary Color から合う Secondary Color を自動生成します。 + Secondary Color + 補助の発光色を選択します。 + 現在: %1$s + Opacity + キーボード上での波の見え方を調整します。 + Intensity + 発光と波のコントラストを調整します。 + Motion + 光の膜が流れる速さを選択します。 + Calm + Elegant + Dynamic + Touch Response + タッチ時の歪みの深さを選択します。 + Subtle + Normal + Deep + Visual Quality + Cinematic Wave の描画品質を選択します。 + Battery Saver + Balanced + Cinematic ドーナツフリック 空のフラグメント キータイプ diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 505f8c1e..d88b3709 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -51,6 +51,7 @@ @string/keyboard_touch_effect_liquid_ripple @string/keyboard_touch_effect_type_spray_paint @string/keyboard_touch_effect_type_luminous_blob + @string/keyboard_touch_effect_type_cinematic_wave @@ -60,6 +61,7 @@ liquid_ripple spray_paint luminous_blob + cinematic_wave @@ -116,6 +118,52 @@ flower_petals + + @string/keyboard_touch_effect_cinematic_wave_color_mode_random + @string/keyboard_touch_effect_cinematic_wave_color_mode_custom + + + + cinematic_random + custom + + + + @string/keyboard_touch_effect_cinematic_wave_motion_calm + @string/keyboard_touch_effect_cinematic_wave_motion_elegant + @string/keyboard_touch_effect_cinematic_wave_motion_dynamic + + + + calm + elegant + dynamic + + + + @string/keyboard_touch_effect_cinematic_wave_touch_response_subtle + @string/keyboard_touch_effect_cinematic_wave_touch_response_normal + @string/keyboard_touch_effect_cinematic_wave_touch_response_deep + + + + subtle + normal + deep + + + + @string/keyboard_touch_effect_cinematic_wave_quality_battery_saver + @string/keyboard_touch_effect_cinematic_wave_quality_balanced + @string/keyboard_touch_effect_cinematic_wave_quality_cinematic + + + + battery_saver + balanced + cinematic + + @string/default_emoji_skin_tone_default @string/default_emoji_skin_tone_light diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76a799d7..38bc2818 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1030,6 +1030,7 @@ Liquid Ripple Spray Paint Luminous Blob + Cinematic Wave Touch effect quality Choose the rendering quality for keyboard touch effects. Balanced @@ -1041,6 +1042,7 @@ Soft aurora ink keeps drifting after touch and lightly tints the keyboard. Gentle water ripples spread from the touched position. A glowing luminous membrane drifts softly and deforms around your touch. + A cinematic aurora-like wave drifts behind the keys and bends softly around your touch. Liquid Ink Background Shows an ink-diffusion background effect from the touched position on the keyboard. Ink Color @@ -1065,6 +1067,37 @@ Graffiti Liquid Paint Flower Petals + Cinematic Wave Color Mode + Choose the palette source for Cinematic Wave. + Cinematic Random + Custom + Primary Color + Choose the main glow color. + Current: %1$s + Secondary Color Auto + Derive a matching secondary color from the primary color. + Secondary Color + Choose the supporting glow color. + Current: %1$s + Opacity + Adjust how strongly the wave appears over the keyboard. + Intensity + Adjust glow and wave contrast. + Motion + Choose the speed of the flowing membrane. + Calm + Elegant + Dynamic + Touch Response + Choose how deeply touches distort the wave. + Subtle + Normal + Deep + Visual Quality + Choose the rendering quality for Cinematic Wave. + Battery Saver + Balanced + Cinematic Donuts Flick Key size Change the size of keys, text, and special key icons. diff --git a/app/src/main/res/xml/pref_keyboard_display.xml b/app/src/main/res/xml/pref_keyboard_display.xml index c016c2b9..45b20a74 100644 --- a/app/src/main/res/xml/pref_keyboard_display.xml +++ b/app/src/main/res/xml/pref_keyboard_display.xml @@ -128,6 +128,72 @@ android:summary="@string/keyboard_touch_effect_palette_summary" android:title="@string/keyboard_touch_effect_palette_title" /> + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshotSuminagashiInkTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshotSuminagashiInkTest.kt index 5d1cf7bb..3eb10e4c 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshotSuminagashiInkTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshotSuminagashiInkTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Color import androidx.preference.PreferenceManager import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.CinematicWaveSettings import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.KeyboardTouchEffectQuality import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.KeyboardTouchEffectType import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.SprayPaintSettings @@ -46,6 +47,14 @@ class ImePreferencesSnapshotSuminagashiInkTest { SprayPaintSettings.PALETTE_PAINT_SPLASH, snapshot.keyboardTouchEffectPalettePreference ) + assertEquals( + CinematicWaveSettings.COLOR_MODE_CINEMATIC_RANDOM, + snapshot.cinematicWaveColorModePreference + ) + assertEquals( + CinematicWaveSettings.QUALITY_BALANCED, + snapshot.cinematicWaveQualityPreference + ) } @Test @@ -121,4 +130,50 @@ class ImePreferencesSnapshotSuminagashiInkTest { ) assertFalse(snapshot.suminagashiInkEffectPreference) } + + @Test + fun snapshotContainsSavedCinematicWavePreferences() { + val primary = Color.rgb(70, 210, 255) + val secondary = Color.rgb(160, 90, 255) + AppPreference.keyboard_touch_effect_type_preference = KeyboardTouchEffectType.CINEMATIC_WAVE + AppPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference = + CinematicWaveSettings.COLOR_MODE_CUSTOM + AppPreference.keyboard_touch_effect_cinematic_wave_primary_color_preference = primary + AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_preference = secondary + AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference = false + AppPreference.keyboard_touch_effect_cinematic_wave_opacity_percent_preference = 50 + AppPreference.keyboard_touch_effect_cinematic_wave_intensity_percent_preference = 120 + AppPreference.keyboard_touch_effect_cinematic_wave_motion_preference = + CinematicWaveSettings.MOTION_DYNAMIC + AppPreference.keyboard_touch_effect_cinematic_wave_touch_response_preference = + CinematicWaveSettings.TOUCH_RESPONSE_DEEP + AppPreference.keyboard_touch_effect_cinematic_wave_quality_preference = + CinematicWaveSettings.QUALITY_CINEMATIC + + val snapshot = ImePreferencesSnapshot.from(AppPreference) + + assertEquals( + KeyboardTouchEffectType.CINEMATIC_WAVE, + snapshot.keyboardTouchEffectTypePreference + ) + assertFalse(snapshot.suminagashiInkEffectPreference) + assertEquals( + CinematicWaveSettings.COLOR_MODE_CUSTOM, + snapshot.cinematicWaveColorModePreference + ) + assertEquals(primary, snapshot.cinematicWavePrimaryColorPreference) + assertEquals(secondary, snapshot.cinematicWaveSecondaryColorPreference) + assertFalse(snapshot.cinematicWaveSecondaryColorAutoPreference) + assertEquals(50, snapshot.cinematicWaveOpacityPercentPreference) + assertEquals(120, snapshot.cinematicWaveIntensityPercentPreference) + assertEquals(CinematicWaveSettings.MOTION_DYNAMIC, snapshot.cinematicWaveMotionPreference) + assertEquals( + CinematicWaveSettings.TOUCH_RESPONSE_DEEP, + snapshot.cinematicWaveTouchResponsePreference + ) + assertEquals( + CinematicWaveSettings.QUALITY_CINEMATIC, + snapshot.cinematicWaveQualityPreference + ) + } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt new file mode 100644 index 00000000..1d3bd051 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt @@ -0,0 +1,64 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class CinematicWaveContractTest { + + @Test + fun rendererUsesOpenGlRendererThreadAndSafeFailureCallback() { + val renderer = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt" + ).readText() + + assertTrue(renderer.contains("HandlerThread")) + assertTrue(renderer.contains("CinematicWaveRenderer")) + assertTrue(renderer.contains("callback.onRendererDisabled")) + assertTrue(renderer.contains("EGL_OPENGL_ES3_BIT")) + assertFalse(renderer.contains("Dispatchers.")) + assertFalse(renderer.contains("CoroutineScope")) + } + + @Test + fun shaderUsesDomainWarpFbmLayeredWaveAndTouchLensDistortion() { + val simulation = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt" + ).readText() + + assertTrue(simulation.contains("float fbm")) + assertTrue(simulation.contains("vec2 warp")) + assertTrue(simulation.contains("warpedUv")) + assertTrue(simulation.contains("float w1 = sin")) + assertTrue(simulation.contains("float w2 = sin")) + assertTrue(simulation.contains("ridge")) + assertTrue(simulation.contains("uTouchPositions[5]")) + assertTrue(simulation.contains("uTouchStrengths[5]")) + assertTrue(simulation.contains("lens")) + assertTrue(simulation.contains("fragColor = vec4(color * alpha, alpha);")) + assertFalse(simulation.contains("android.graphics.Canvas")) + assertFalse(simulation.contains("android.graphics.Bitmap")) + assertFalse(simulation.contains("drawCircle")) + assertFalse(simulation.contains("drawLine")) + } + + @Test + fun imeClearsPausesAndReleasesCinematicWaveWithOtherTouchEffects() { + val imeService = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt" + ).readText() + + assertTrue(imeService.contains("KeyboardTouchEffectType.isCinematicWave(effectType)")) + assertTrue(imeService.contains("cinematicWaveEffectView.configure")) + assertTrue(imeService.contains("dispatchCinematicWaveMotionEvent")) + assertTrue(imeService.contains("clearWave()")) + assertTrue(imeService.contains("pauseWave()")) + assertTrue(imeService.contains("releaseWave()")) + } + + private fun mainFile(path: String): File { + val moduleFile = File("src/main/$path") + return if (moduleFile.exists()) moduleFile else File("app/src/main/$path") + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectContainerContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectContainerContractTest.kt index 12b613ef..5df00ad9 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectContainerContractTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectContainerContractTest.kt @@ -143,12 +143,16 @@ class KeyboardTouchEffectContainerContractTest { val normalEffectIds = listOf( "suminagashi_ink_view", "liquid_ripple_effect_view", - "spray_paint_effect_view" + "spray_paint_effect_view", + "luminous_blob_effect_view", + "cinematic_wave_effect_view" ) val floatingEffectIds = listOf( "floating_suminagashi_ink_view", "floating_liquid_ripple_effect_view", - "floating_spray_paint_effect_view" + "floating_spray_paint_effect_view", + "floating_luminous_blob_effect_view", + "floating_cinematic_wave_effect_view" ) } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt index c2cda25c..dc0f8f0c 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt @@ -31,7 +31,8 @@ class KeyboardTouchEffectResourceTest { KeyboardTouchEffectType.AURORA_INK, KeyboardTouchEffectType.LIQUID_RIPPLE, KeyboardTouchEffectType.SPRAY_PAINT, - KeyboardTouchEffectType.LUMINOUS_BLOB + KeyboardTouchEffectType.LUMINOUS_BLOB, + KeyboardTouchEffectType.CINEMATIC_WAVE ), values.toList() ) @@ -56,9 +57,11 @@ class KeyboardTouchEffectResourceTest { assertTrue(englishEntries.contains("Liquid Ink")) assertTrue(englishEntries.contains("Aurora Ink")) assertTrue(englishEntries.contains("Luminous Blob")) + assertTrue(englishEntries.contains("Cinematic Wave")) assertTrue(japaneseEntries.contains("リキッドインク")) assertTrue(japaneseEntries.contains("オーロラインク")) assertTrue(japaneseEntries.contains("光の膜")) + assertTrue(japaneseEntries.contains("Cinematic Wave")) val legacyJapaneseLabel = "\u58a8\u6d41\u3057" (englishEntries + japaneseEntries).forEach { label -> diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectTypeTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectTypeTest.kt index cb63501c..3f458f60 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectTypeTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectTypeTest.kt @@ -45,6 +45,10 @@ class KeyboardTouchEffectTypeTest { KeyboardTouchEffectType.LUMINOUS_BLOB, KeyboardTouchEffectType.normalize("luminous_blob") ) + assertEquals( + KeyboardTouchEffectType.CINEMATIC_WAVE, + KeyboardTouchEffectType.normalize("cinematic_wave") + ) } @Test @@ -58,8 +62,10 @@ class KeyboardTouchEffectTypeTest { assertTrue(KeyboardTouchEffectType.isLiquidRipple("liquid_ripple")) assertTrue(KeyboardTouchEffectType.isSprayPaint("spray_paint")) assertTrue(KeyboardTouchEffectType.isLuminousBlob("luminous_blob")) + assertTrue(KeyboardTouchEffectType.isCinematicWave("cinematic_wave")) assertFalse(KeyboardTouchEffectType.isLiquidRipple("aurora_ink")) assertFalse(KeyboardTouchEffectType.isSprayPaint("aurora_ink")) assertFalse(KeyboardTouchEffectType.isLuminousBlob("aurora_ink")) + assertFalse(KeyboardTouchEffectType.isCinematicWave("aurora_ink")) } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt index 4428f7fb..8e30f58c 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Color import androidx.preference.PreferenceManager import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.CinematicWaveSettings import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.KeyboardTouchEffectType import com.kazumaproject.markdownhelperkeyboard.ime_service.image_effect.SprayPaintSettings import org.junit.Assert.assertEquals @@ -40,6 +41,33 @@ class AppPreferenceSuminagashiInkTest { SprayPaintSettings.PALETTE_PAINT_SPLASH, AppPreference.keyboard_touch_effect_palette_preference ) + assertEquals( + CinematicWaveSettings.COLOR_MODE_CINEMATIC_RANDOM, + AppPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference + ) + assertEquals( + CinematicWaveSettings.DEFAULT_PRIMARY_COLOR, + AppPreference.keyboard_touch_effect_cinematic_wave_primary_color_preference + ) + assertEquals( + CinematicWaveSettings.DEFAULT_SECONDARY_COLOR, + AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_preference + ) + assertTrue(AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference) + assertEquals(46, AppPreference.keyboard_touch_effect_cinematic_wave_opacity_percent_preference) + assertEquals(100, AppPreference.keyboard_touch_effect_cinematic_wave_intensity_percent_preference) + assertEquals( + CinematicWaveSettings.MOTION_ELEGANT, + AppPreference.keyboard_touch_effect_cinematic_wave_motion_preference + ) + assertEquals( + CinematicWaveSettings.TOUCH_RESPONSE_NORMAL, + AppPreference.keyboard_touch_effect_cinematic_wave_touch_response_preference + ) + assertEquals( + CinematicWaveSettings.QUALITY_BALANCED, + AppPreference.keyboard_touch_effect_cinematic_wave_quality_preference + ) } @Test @@ -225,4 +253,79 @@ class AppPreferenceSuminagashiInkTest { assertEquals(expectedValue, AppPreference.keyboard_touch_effect_palette_preference) } } + + @Test + fun cinematicWavePreferencesSaveAndNormalizeValues() { + val primary = Color.argb(0, 255, 32, 64) + val secondary = Color.rgb(80, 120, 220) + + AppPreference.keyboard_touch_effect_type_preference = KeyboardTouchEffectType.CINEMATIC_WAVE + AppPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference = "custom" + AppPreference.keyboard_touch_effect_cinematic_wave_primary_color_preference = primary + AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_preference = secondary + AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference = false + AppPreference.keyboard_touch_effect_cinematic_wave_opacity_percent_preference = 200 + AppPreference.keyboard_touch_effect_cinematic_wave_intensity_percent_preference = 1 + AppPreference.keyboard_touch_effect_cinematic_wave_motion_preference = "dynamic" + AppPreference.keyboard_touch_effect_cinematic_wave_touch_response_preference = "deep" + AppPreference.keyboard_touch_effect_cinematic_wave_quality_preference = "cinematic" + + assertEquals( + KeyboardTouchEffectType.CINEMATIC_WAVE, + AppPreference.keyboard_touch_effect_type_preference + ) + assertFalse(AppPreference.suminagashi_ink_effect_preference) + assertEquals( + CinematicWaveSettings.COLOR_MODE_CUSTOM, + AppPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference + ) + assertEquals( + Color.rgb(255, 32, 64), + AppPreference.keyboard_touch_effect_cinematic_wave_primary_color_preference + ) + assertEquals( + secondary, + AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_preference + ) + assertFalse(AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference) + assertEquals(68, AppPreference.keyboard_touch_effect_cinematic_wave_opacity_percent_preference) + assertEquals(35, AppPreference.keyboard_touch_effect_cinematic_wave_intensity_percent_preference) + assertEquals( + CinematicWaveSettings.MOTION_DYNAMIC, + AppPreference.keyboard_touch_effect_cinematic_wave_motion_preference + ) + assertEquals( + CinematicWaveSettings.TOUCH_RESPONSE_DEEP, + AppPreference.keyboard_touch_effect_cinematic_wave_touch_response_preference + ) + assertEquals( + CinematicWaveSettings.QUALITY_CINEMATIC, + AppPreference.keyboard_touch_effect_cinematic_wave_quality_preference + ) + } + + @Test + fun cinematicWavePreferencesNormalizeUnexpectedValuesToDefaults() { + AppPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference = "surprise" + AppPreference.keyboard_touch_effect_cinematic_wave_motion_preference = "fast" + AppPreference.keyboard_touch_effect_cinematic_wave_touch_response_preference = "massive" + AppPreference.keyboard_touch_effect_cinematic_wave_quality_preference = "ultra" + + assertEquals( + CinematicWaveSettings.COLOR_MODE_CINEMATIC_RANDOM, + AppPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference + ) + assertEquals( + CinematicWaveSettings.MOTION_ELEGANT, + AppPreference.keyboard_touch_effect_cinematic_wave_motion_preference + ) + assertEquals( + CinematicWaveSettings.TOUCH_RESPONSE_NORMAL, + AppPreference.keyboard_touch_effect_cinematic_wave_touch_response_preference + ) + assertEquals( + CinematicWaveSettings.QUALITY_BALANCED, + AppPreference.keyboard_touch_effect_cinematic_wave_quality_preference + ) + } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/KeyboardTouchEffectPreferenceVisibilityTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/KeyboardTouchEffectPreferenceVisibilityTest.kt index 2b3854a9..7652a316 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/KeyboardTouchEffectPreferenceVisibilityTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/KeyboardTouchEffectPreferenceVisibilityTest.kt @@ -88,4 +88,26 @@ class KeyboardTouchEffectPreferenceVisibilityTest { assertTrue(fixed.showFixedColor) assertFalse(fixed.showPalette) } + + @Test + fun cinematicWaveUsesDedicatedSettingsInsteadOfSharedColorControls() { + val random = resolveKeyboardTouchEffectPreferenceVisibility( + effectType = KeyboardTouchEffectType.CINEMATIC_WAVE, + colorMode = "cinematic_random" + ) + val custom = resolveKeyboardTouchEffectPreferenceVisibility( + effectType = KeyboardTouchEffectType.CINEMATIC_WAVE, + colorMode = "custom" + ) + + assertFalse(random.showQuality) + assertFalse(random.showColorMode) + assertFalse(random.showFixedColor) + assertFalse(random.showPalette) + assertTrue(random.showCinematicWaveSettings) + assertFalse(random.showCinematicWaveCustomColors) + + assertTrue(custom.showCinematicWaveSettings) + assertTrue(custom.showCinematicWaveCustomColors) + } } From 866f03cd0712c4bead92d3998a4f17146ea35dea Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:00:48 -0400 Subject: [PATCH 6/8] add silky sine wave --- .../ime_service/IMEService.kt | 8 ++ .../ime_service/ImePreferencesSnapshot.kt | 3 + .../image_effect/CinematicWaveEffectView.kt | 2 + .../image_effect/CinematicWaveRenderer.kt | 43 ++++---- .../image_effect/CinematicWaveSettings.kt | 13 +++ .../image_effect/CinematicWaveSimulation.kt | 66 ++++++++++++ .../setting_activity/AppPreference.kt | 20 ++++ .../ui/setting/CommonPreferenceFragment.kt | 20 ++++ .../ui/setting/SettingDestination.kt | 11 ++ .../ui/setting/SettingSearchIndex.kt | 4 + app/src/main/res/values-ja/arrays.xml | 10 ++ app/src/main/res/values-ja/strings.xml | 50 +++++---- app/src/main/res/values/arrays.xml | 10 ++ app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/pref_common_legacy.xml | 74 +++++++++++++ .../main/res/xml/pref_keyboard_display.xml | 8 ++ ...mePreferencesSnapshotSuminagashiInkTest.kt | 10 ++ .../image_effect/CinematicWaveContractTest.kt | 44 ++++++++ ...eyboardTouchEffectContainerContractTest.kt | 22 ++++ .../KeyboardTouchEffectResourceTest.kt | 34 +++++- ...TouchEffectSimulationResizeContractTest.kt | 6 +- .../AppPreferenceSuminagashiInkTest.kt | 14 +++ .../CinematicWaveSettingsEntryPointTest.kt | 100 ++++++++++++++++++ 23 files changed, 530 insertions(+), 46 deletions(-) create mode 100644 app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CinematicWaveSettingsEntryPointTest.kt diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt index c6c0eda9..f0edff91 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 @@ -962,6 +962,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, CinematicWaveSettings.DEFAULT_SECONDARY_COLOR private var cinematicWaveSecondaryColorAutoPreference: Boolean = true + private var cinematicWaveTypePreference: String = + CinematicWaveSettings.WAVE_TYPE_AURORA_MEMBRANE private var cinematicWaveOpacityPercentPreference: Int = 46 private var cinematicWaveIntensityPercentPreference: Int = 100 private var cinematicWaveMotionPreference: String = CinematicWaveSettings.MOTION_ELEGANT @@ -1900,6 +1902,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, cinematicWaveSecondaryColorPreference = preferences.cinematicWaveSecondaryColorPreference cinematicWaveSecondaryColorAutoPreference = preferences.cinematicWaveSecondaryColorAutoPreference + cinematicWaveTypePreference = + CinematicWaveSettings.normalizeWaveType(preferences.cinematicWaveTypePreference) cinematicWaveOpacityPercentPreference = preferences.cinematicWaveOpacityPercentPreference cinematicWaveIntensityPercentPreference = preferences.cinematicWaveIntensityPercentPreference cinematicWaveMotionPreference = @@ -2221,6 +2225,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, primaryColor = cinematicWavePrimaryColorPreference, secondaryColor = cinematicWaveSecondaryColorPreference, secondaryColorAuto = cinematicWaveSecondaryColorAutoPreference, + waveType = cinematicWaveTypePreference, opacityPercent = cinematicWaveOpacityPercentPreference, intensityPercent = cinematicWaveIntensityPercentPreference, motion = cinematicWaveMotionPreference, @@ -2416,6 +2421,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, primaryColor = cinematicWavePrimaryColorPreference, secondaryColor = cinematicWaveSecondaryColorPreference, secondaryColorAuto = cinematicWaveSecondaryColorAutoPreference, + waveType = cinematicWaveTypePreference, opacityPercent = cinematicWaveOpacityPercentPreference, intensityPercent = cinematicWaveIntensityPercentPreference, motion = cinematicWaveMotionPreference, @@ -2539,6 +2545,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, primaryColor = cinematicWavePrimaryColorPreference, secondaryColor = cinematicWaveSecondaryColorPreference, secondaryColorAuto = cinematicWaveSecondaryColorAutoPreference, + waveType = cinematicWaveTypePreference, opacityPercent = cinematicWaveOpacityPercentPreference, intensityPercent = cinematicWaveIntensityPercentPreference, motion = cinematicWaveMotionPreference, @@ -3895,6 +3902,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, cinematicWavePrimaryColorPreference = CinematicWaveSettings.DEFAULT_PRIMARY_COLOR cinematicWaveSecondaryColorPreference = CinematicWaveSettings.DEFAULT_SECONDARY_COLOR cinematicWaveSecondaryColorAutoPreference = true + cinematicWaveTypePreference = CinematicWaveSettings.WAVE_TYPE_AURORA_MEMBRANE cinematicWaveOpacityPercentPreference = 46 cinematicWaveIntensityPercentPreference = 100 cinematicWaveMotionPreference = CinematicWaveSettings.MOTION_ELEGANT diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt index c9df7fcc..9f3f9ce8 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt @@ -189,6 +189,7 @@ data class ImePreferencesSnapshot( val cinematicWavePrimaryColorPreference: Int, val cinematicWaveSecondaryColorPreference: Int, val cinematicWaveSecondaryColorAutoPreference: Boolean, + val cinematicWaveTypePreference: String, val cinematicWaveOpacityPercentPreference: Int, val cinematicWaveIntensityPercentPreference: Int, val cinematicWaveMotionPreference: String, @@ -537,6 +538,8 @@ data class ImePreferencesSnapshot( cinematicWaveSecondaryColorAutoPreference = appPreference .keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference, + cinematicWaveTypePreference = + appPreference.keyboard_touch_effect_cinematic_wave_type_preference, cinematicWaveOpacityPercentPreference = appPreference.keyboard_touch_effect_cinematic_wave_opacity_percent_preference, cinematicWaveIntensityPercentPreference = diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveEffectView.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveEffectView.kt index becfbd69..8325a7c1 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveEffectView.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveEffectView.kt @@ -47,6 +47,7 @@ class CinematicWaveEffectView @JvmOverloads constructor( @ColorInt primaryColor: Int, @ColorInt secondaryColor: Int, secondaryColorAuto: Boolean, + waveType: String, opacityPercent: Int, intensityPercent: Int, motion: String, @@ -59,6 +60,7 @@ class CinematicWaveEffectView @JvmOverloads constructor( primaryColor = CinematicWaveSettings.withoutTransparentAlpha(primaryColor), secondaryColor = CinematicWaveSettings.withoutTransparentAlpha(secondaryColor), secondaryColorAuto = secondaryColorAuto, + waveType = waveType, opacityPercent = opacityPercent, intensityPercent = intensityPercent, motion = motion, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt index e9fb1333..c362822b 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveRenderer.kt @@ -17,7 +17,6 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.math.exp -import kotlin.math.roundToInt internal class CinematicWaveRenderer( private val inputQueue: CinematicWaveInputCommandQueue, @@ -78,7 +77,7 @@ internal class CinematicWaveRenderer( viewWidth > 0 && viewHeight > 0 ) { - recreateSurfaceForCurrentSettings() + resizeSurfaceForCurrentSettings() } requestRenderOnRendererThread(forceSoon = true) } @@ -92,7 +91,7 @@ internal class CinematicWaveRenderer( viewHeight = height state = CinematicWaveRendererState.Ambient runRendererCatching("attach cinematic wave EGL surface") { - recreateSurfaceForCurrentSettings() + ensureSurfaceForCurrentSize() paused = false requestRenderOnRendererThread(forceSoon = true) } @@ -107,7 +106,7 @@ internal class CinematicWaveRenderer( viewWidth = width viewHeight = height runRendererCatching("resize cinematic wave surface") { - recreateSurfaceForCurrentSettings() + ensureSurfaceForCurrentSize() requestRenderOnRendererThread(forceSoon = true) } } @@ -183,11 +182,10 @@ internal class CinematicWaveRenderer( return rendererThread.isAlive && !released } - private fun recreateSurfaceForCurrentSettings() { + private fun ensureSurfaceForCurrentSize() { val texture = surfaceTexture ?: return - val params = performanceGovernor.stepParams(settings, resolveRendererState()) - val nextRenderWidth = (viewWidth * params.renderScale).roundToInt().coerceAtLeast(1) - val nextRenderHeight = (viewHeight * params.renderScale).roundToInt().coerceAtLeast(1) + val nextRenderWidth = viewWidth.coerceAtLeast(1) + val nextRenderHeight = viewHeight.coerceAtLeast(1) if ( egl != null && simulation != null && @@ -197,23 +195,28 @@ internal class CinematicWaveRenderer( return } - simulation?.release() - simulation = null - releaseEglSurfaceOnly() - - texture.setDefaultBufferSize(nextRenderWidth, nextRenderHeight) renderWidth = nextRenderWidth renderHeight = nextRenderHeight - egl = EglEnvironment(texture) - simulation = simulationFactory().also { - it.initialize( - surfaceWidth = renderWidth, - surfaceHeight = renderHeight, - settings = settings - ) + if (egl == null || simulation == null) { + if (egl == null) { + egl = EglEnvironment(texture) + } + simulation = simulationFactory().also { + it.initialize( + surfaceWidth = renderWidth, + surfaceHeight = renderHeight, + settings = settings + ) + } + } else { + simulation?.resizeSurface(renderWidth, renderHeight) } } + private fun resizeSurfaceForCurrentSettings() { + ensureSurfaceForCurrentSize() + } + private fun renderFrameOnRendererThread() { frameScheduled = false val activeSimulation = simulation diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt index 8bbcbe0a..9f376004 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt @@ -12,6 +12,7 @@ internal data class CinematicWaveSettings( @ColorInt val primaryColor: Int, @ColorInt val secondaryColor: Int, val secondaryColorAuto: Boolean, + val waveType: String, val opacityPercent: Int, val intensityPercent: Int, val motion: String, @@ -19,6 +20,7 @@ internal data class CinematicWaveSettings( val quality: String ) { val normalizedColorMode: String = normalizeColorMode(colorMode) + val normalizedWaveType: String = normalizeWaveType(waveType) val normalizedMotion: String = normalizeMotion(motion) val normalizedTouchResponse: String = normalizeTouchResponse(touchResponse) val normalizedQuality: String = normalizeQuality(quality) @@ -29,6 +31,9 @@ internal data class CinematicWaveSettings( const val COLOR_MODE_CINEMATIC_RANDOM = "cinematic_random" const val COLOR_MODE_CUSTOM = "custom" + const val WAVE_TYPE_AURORA_MEMBRANE = "aurora_membrane" + const val WAVE_TYPE_SILK_SINE = "silk_sine" + const val MOTION_CALM = "calm" const val MOTION_ELEGANT = "elegant" const val MOTION_DYNAMIC = "dynamic" @@ -53,6 +58,7 @@ internal data class CinematicWaveSettings( primaryColor = DEFAULT_PRIMARY_COLOR, secondaryColor = DEFAULT_SECONDARY_COLOR, secondaryColorAuto = true, + waveType = WAVE_TYPE_AURORA_MEMBRANE, opacityPercent = 46, intensityPercent = 100, motion = MOTION_ELEGANT, @@ -67,6 +73,13 @@ internal data class CinematicWaveSettings( } } + fun normalizeWaveType(value: String?): String { + return when (value) { + WAVE_TYPE_SILK_SINE -> WAVE_TYPE_SILK_SINE + else -> WAVE_TYPE_AURORA_MEMBRANE + } + } + fun normalizeMotion(value: String?): String { return when (value) { MOTION_CALM -> MOTION_CALM diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt index a57d05d0..997ae085 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt @@ -14,6 +14,7 @@ internal class CinematicWaveSimulation { private var uResolution = -1 private var uTime = -1 + private var uWaveType = -1 private var uOpacity = -1 private var uIntensity = -1 private var uMotion = -1 @@ -94,6 +95,10 @@ internal class CinematicWaveSimulation { GLES30.glUniform2f(uResolution, surfaceWidth.toFloat(), surfaceHeight.toFloat()) GLES30.glUniform1f(uTime, timeSeconds) + GLES30.glUniform1i( + uWaveType, + if (settings.normalizedWaveType == CinematicWaveSettings.WAVE_TYPE_SILK_SINE) 1 else 0 + ) GLES30.glUniform1f(uOpacity, settings.opacity) GLES30.glUniform1f(uIntensity, settings.intensity) GLES30.glUniform1f(uMotion, params.motionSpeed) @@ -166,6 +171,7 @@ internal class CinematicWaveSimulation { program = linkProgram(VERTEX_SHADER, FRAGMENT_SHADER) uResolution = GLES30.glGetUniformLocation(program, "uResolution") uTime = GLES30.glGetUniformLocation(program, "uTime") + uWaveType = GLES30.glGetUniformLocation(program, "uWaveType") uOpacity = GLES30.glGetUniformLocation(program, "uOpacity") uIntensity = GLES30.glGetUniformLocation(program, "uIntensity") uMotion = GLES30.glGetUniformLocation(program, "uMotion") @@ -255,6 +261,7 @@ internal class CinematicWaveSimulation { uniform vec2 uResolution; uniform float uTime; + uniform int uWaveType; uniform float uOpacity; uniform float uIntensity; uniform float uMotion; @@ -307,6 +314,11 @@ internal class CinematicWaveSimulation { return smoothstep(0.08, 0.96, v); } + float ribbon(float y, float center, float width) { + float d = (y - center) / max(width, 0.0001); + return exp(-d * d); + } + void main() { vec2 uv = gl_FragCoord.xy / max(uResolution, vec2(1.0)); vec2 centered = uv - 0.5; @@ -345,6 +357,60 @@ internal class CinematicWaveSimulation { fbm(warpedUv * 3.6 - slowTime * 0.052) ) * 0.060 * uWarpStrength; + if (uWaveType == 1) { + vec2 ribbonUv = drift; + float aspect = uResolution.x / max(uResolution.y, 1.0); + float silkNoise = fbm( + ribbonUv * vec2(2.2, 3.4) + vec2(slowTime * 0.035, -slowTime * 0.021) + ); + float phase = ribbonUv.x * (2.35 + aspect * 0.16) + silkNoise * 1.8; + float centerA = 0.56 + sin(phase * 3.15 + slowTime * 0.20) * 0.20; + float centerB = 0.45 + sin(phase * 2.52 - slowTime * 0.17 + 1.72) * 0.17; + float centerC = 0.64 + sin(phase * 3.86 + slowTime * 0.13 + 3.38) * 0.14; + float centerD = 0.36 + sin(phase * 2.04 + slowTime * 0.11 + 4.95) * 0.18; + + float bandA = ribbon(uv.y, centerA, 0.115); + float bandB = ribbon(uv.y, centerB, 0.092); + float bandC = ribbon(uv.y, centerC, 0.076); + float bandD = ribbon(uv.y, centerD, 0.104); + float coreA = ribbon(uv.y, centerA, 0.030); + float coreB = ribbon(uv.y, centerB, 0.026); + float coreC = ribbon(uv.y, centerC, 0.022); + + float crossing = smoothstep(0.05, 0.92, bandA * bandB + bandA * bandC + bandB * bandD); + float haze = clamp(bandA * 0.48 + bandB * 0.42 + bandC * 0.34 + bandD * 0.30, 0.0, 1.0); + float core = clamp(coreA * 0.70 + coreB * 0.58 + coreC * 0.46, 0.0, 1.0); + float depth = smoothstep(0.0, 1.0, uv.y); + vec3 base = mix(uBaseColor * 0.46, uBaseColor * 1.22, depth); + base += vec3(0.018, 0.020, 0.026) * (1.0 - smoothstep(0.08, 0.92, length(centered))); + + vec3 tertiary = clamp( + vec3( + uSecondaryColor.b * 0.72 + uHighlightColor.r * 0.28, + uPrimaryColor.g * 0.58 + uSecondaryColor.g * 0.24 + 0.10, + uPrimaryColor.r * 0.34 + uHighlightColor.b * 0.48 + ), + vec3(0.0), + vec3(1.0) + ); + vec3 color = base; + color += uPrimaryColor * bandA * 0.44 * uIntensity; + color += uSecondaryColor * bandB * 0.38 * uIntensity; + color += tertiary * bandD * 0.32 * uIntensity; + color += mix(uPrimaryColor, uHighlightColor, 0.68) * bandC * 0.28 * uIntensity; + color += uHighlightColor * pow(core, 2.0) * 0.62 * uGlowStrength; + color += uHighlightColor * crossing * 0.20 * uGlowStrength; + color += uHighlightColor * touchGlow * 0.15 * uIntensity; + color -= uBaseColor * touchShadow * 0.035; + + float glass = 0.075 + (1.0 - smoothstep(0.0, 0.96, length(centered))) * 0.050; + float alpha = glass + haze * 0.25 * uIntensity + core * 0.30 * uGlowStrength + crossing * 0.11 + touchGlow * 0.085; + alpha = clamp(alpha * uOpacity, 0.0, 0.64); + color = clamp(color, vec3(0.0), vec3(1.0)); + fragColor = vec4(color * alpha, alpha); + return; + } + float w1 = sin(drift.x * 8.0 + drift.y * 3.0 + slowTime * 0.35); float w2 = sin(drift.x * 14.0 - drift.y * 2.0 - slowTime * 0.22); float w3 = sin((drift.x + drift.y) * 10.5 + fbm(drift * 2.7) * 3.8 + slowTime * 0.18); 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 8ff9648e..22c34817 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 @@ -658,6 +658,11 @@ object AppPreference { ) private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_SECONDARY_COLOR_AUTO = Pair("keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference", true) + private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_TYPE = + Pair( + "keyboard_touch_effect_cinematic_wave_type_preference", + CinematicWaveSettings.WAVE_TYPE_AURORA_MEMBRANE + ) private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_OPACITY = Pair("keyboard_touch_effect_cinematic_wave_opacity_percent_preference", 46) private val KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_INTENSITY = @@ -3404,6 +3409,21 @@ object AppPreference { it.putBoolean(KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_SECONDARY_COLOR_AUTO.first, value) } + var keyboard_touch_effect_cinematic_wave_type_preference: String + get() { + val value = preferences.getString( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_TYPE.first, + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_TYPE.second + ) + return CinematicWaveSettings.normalizeWaveType(value) + } + set(value) = preferences.edit { + it.putString( + KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_TYPE.first, + CinematicWaveSettings.normalizeWaveType(value) + ) + } + var keyboard_touch_effect_cinematic_wave_opacity_percent_preference: Int get() = preferences.getInt( KEYBOARD_TOUCH_EFFECT_CINEMATIC_WAVE_OPACITY.first, diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt index 2b7a02e3..8da8082c 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt @@ -335,6 +335,10 @@ open class CommonPreferenceFragment : PreferenceFragmentCompat() { "keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference" )?.isVisible = showCinematicCustomColors + findPreference( + "keyboard_touch_effect_cinematic_wave_type_preference" + )?.isVisible = visibility.showCinematicWaveSettings + val secondaryPreference = findPreference( "keyboard_touch_effect_cinematic_wave_secondary_color_preference" @@ -650,6 +654,22 @@ open class CommonPreferenceFragment : PreferenceFragmentCompat() { } } + findPreference( + "keyboard_touch_effect_cinematic_wave_type_preference" + )?.apply { + summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() + val normalizedType = + appPreference.keyboard_touch_effect_cinematic_wave_type_preference + if (value != normalizedType) { + value = normalizedType + } + setOnPreferenceChangeListener { _, newValue -> + appPreference.keyboard_touch_effect_cinematic_wave_type_preference = + CinematicWaveSettings.normalizeWaveType(newValue as? String) + true + } + } + findPreference( "keyboard_touch_effect_cinematic_wave_secondary_color_preference" )?.apply { diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt index 141e0825..89266d60 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingDestination.kt @@ -398,6 +398,17 @@ object SettingDestinations { "keyboard_key_letter_size_fragment_preference", "keyboard_background_image_select_preference", "keyboard_background_video_select_preference", + "keyboard_touch_effect_type_preference", + "keyboard_touch_effect_cinematic_wave_color_mode_preference", + "keyboard_touch_effect_cinematic_wave_type_preference", + "keyboard_touch_effect_cinematic_wave_primary_color_preference", + "keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference", + "keyboard_touch_effect_cinematic_wave_secondary_color_preference", + "keyboard_touch_effect_cinematic_wave_opacity_percent_preference", + "keyboard_touch_effect_cinematic_wave_intensity_percent_preference", + "keyboard_touch_effect_cinematic_wave_motion_preference", + "keyboard_touch_effect_cinematic_wave_touch_response_preference", + "keyboard_touch_effect_cinematic_wave_quality_preference", "round_corner_keyboard_preference", "keyboard_corner_radius_dp_preference", "candidate_column_preference", diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt index fb0fbbb8..83a79b14 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/SettingSearchIndex.kt @@ -436,6 +436,10 @@ object SettingSearchIndex { if (key == "shortcut_toolbar_integrated_in_suggestion_preference") { add("shortcut_toolbar_visibility_preference") } + if (key.startsWith("keyboard_touch_effect_cinematic_wave_")) { + add("keyboard_touch_effect_type_preference") + add("keyboard_touch_effect_cinematic_wave_color_mode_preference") + } if (!dependencyKey.isNullOrBlank()) { add(dependencyKey) } diff --git a/app/src/main/res/values-ja/arrays.xml b/app/src/main/res/values-ja/arrays.xml index 48277ef9..19150754 100644 --- a/app/src/main/res/values-ja/arrays.xml +++ b/app/src/main/res/values-ja/arrays.xml @@ -118,6 +118,16 @@ custom + + @string/keyboard_touch_effect_cinematic_wave_type_aurora_membrane + @string/keyboard_touch_effect_cinematic_wave_type_silk_sine + + + + aurora_membrane + silk_sine + + @string/keyboard_touch_effect_cinematic_wave_motion_calm @string/keyboard_touch_effect_cinematic_wave_motion_elegant diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d7459d22..ea8761d1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1019,7 +1019,7 @@ 水面の波紋 スプレーペイント 光の膜 - Cinematic Wave + シネマティックウェーブ タッチエフェクト品質 キーボードのタッチエフェクトの描画品質を選択します。 標準 @@ -1056,37 +1056,41 @@ グラフィティ 液体ペンキ 花が舞い散る - Cinematic Wave の色 - Cinematic Wave のパレットの選び方を設定します。 - Cinematic Random + ウェーブの色 + シネマティックウェーブのパレットの選び方を設定します。 + シネマティックランダム カスタム - Primary Color + ウェーブのタイプ + ウェーブの見た目のタイプを選択します。 + オーロラ膜 + シルキーサインウェーブ + メインカラー メインの発光色を選択します。 現在: %1$s - Secondary Color Auto - Primary Color から合う Secondary Color を自動生成します。 - Secondary Color + サブカラーを自動生成 + メインカラーに合うサブカラーを自動生成します。 + サブカラー 補助の発光色を選択します。 現在: %1$s - Opacity + 透明度 キーボード上での波の見え方を調整します。 - Intensity + 強さ 発光と波のコントラストを調整します。 - Motion + 動き 光の膜が流れる速さを選択します。 - Calm - Elegant - Dynamic - Touch Response + 穏やか + 上品 + ダイナミック + タッチ反応 タッチ時の歪みの深さを選択します。 - Subtle - Normal - Deep - Visual Quality - Cinematic Wave の描画品質を選択します。 - Battery Saver - Balanced - Cinematic + 控えめ + 標準 + 深い + 描画品質 + シネマティックウェーブの描画品質を選択します。 + 省電力 + 標準 + シネマティック ドーナツフリック 空のフラグメント キータイプ diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index d88b3709..b06dadd4 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -128,6 +128,16 @@ custom + + @string/keyboard_touch_effect_cinematic_wave_type_aurora_membrane + @string/keyboard_touch_effect_cinematic_wave_type_silk_sine + + + + aurora_membrane + silk_sine + + @string/keyboard_touch_effect_cinematic_wave_motion_calm @string/keyboard_touch_effect_cinematic_wave_motion_elegant diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 38bc2818..12f82593 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1071,6 +1071,10 @@ Choose the palette source for Cinematic Wave. Cinematic Random Custom + Wave Type + Choose the visual wave model. + Aurora Membrane + Silk Sine Primary Color Choose the main glow color. Current: %1$s diff --git a/app/src/main/res/xml/pref_common_legacy.xml b/app/src/main/res/xml/pref_common_legacy.xml index fd9ca6a5..11cd5f1f 100644 --- a/app/src/main/res/xml/pref_common_legacy.xml +++ b/app/src/main/res/xml/pref_common_legacy.xml @@ -145,6 +145,80 @@ android:summary="@string/keyboard_touch_effect_palette_summary" android:title="@string/keyboard_touch_effect_palette_title" /> + + + + + + + + + + + + + + + + + + + + + + , functionName: String): List { + val start = lines.indexOfFirst { it.contains("fun $functionName") } + require(start >= 0) { "Missing function $functionName" } + var depth = 0 + var seenOpen = false + val body = mutableListOf() + for (index in start until lines.size) { + val line = lines[index] + body += line + line.forEach { char -> + when (char) { + '{' -> { + depth++ + seenOpen = true + } + + '}' -> depth-- + } + } + if (seenOpen && depth == 0) break + } + return body + } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectContainerContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectContainerContractTest.kt index 5df00ad9..3d25461f 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectContainerContractTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectContainerContractTest.kt @@ -64,6 +64,28 @@ class KeyboardTouchEffectContainerContractTest { assertFalse(floatingSetup.contains("targetContainer = floatingView.floatingKeyboardBackgroundContainer")) } + @Test + fun touchEffectBoundsStayOnKeyboardBodyToAvoidCandidateHeightResizeFlicker() { + val lines = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt" + ).readLines() + val source = lines.joinToString("\n") + val normalBounds = + functionBody(lines, "updateNormalKeyboardTouchEffectBounds").joinToString("\n") + val floatingBounds = + functionBody(lines, "updateFloatingKeyboardTouchEffectBounds").joinToString("\n") + + assertTrue(normalBounds.contains("keyboardBodyHeightPx: Int")) + assertTrue(source.contains("keyboardBodyHeightPx = heightPx")) + assertFalse(source.contains("touchEffectHeightPx = backgroundSurfaceHeight")) + assertFalse(source.contains("keyboardBodyHeightPx = backgroundSurfaceHeight")) + + assertTrue(floatingBounds.contains("floatingView.floatingSymbolKeyboard")) + assertTrue(floatingBounds.contains("floatingView.candidatesRowView")) + assertTrue(floatingBounds.contains("floatingView.floatingKeyboardContainer")) + assertFalse(floatingBounds.contains("floatingView.floatingKeyboardBackgroundContainer")) + } + @Test fun rendererResizeSurfaceHasNoOpGuards() { listOf( diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt index dc0f8f0c..91d0a5c4 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt @@ -61,7 +61,7 @@ class KeyboardTouchEffectResourceTest { assertTrue(japaneseEntries.contains("リキッドインク")) assertTrue(japaneseEntries.contains("オーロラインク")) assertTrue(japaneseEntries.contains("光の膜")) - assertTrue(japaneseEntries.contains("Cinematic Wave")) + assertTrue(japaneseEntries.contains("シネマティックウェーブ")) val legacyJapaneseLabel = "\u58a8\u6d41\u3057" (englishEntries + japaneseEntries).forEach { label -> @@ -69,4 +69,36 @@ class KeyboardTouchEffectResourceTest { assertFalse(label.contains(legacyJapaneseLabel)) } } + + @Test + fun cinematicWaveTypeEntriesAndJapaneseLabelsStayAvailable() { + val context = ApplicationProvider.getApplicationContext() + val values = + context.resources.getStringArray(R.array.keyboard_touch_effect_cinematic_wave_type_values) + .toList() + val englishEntries = + context.resources.getStringArray(R.array.keyboard_touch_effect_cinematic_wave_type_entries) + .toList() + val japaneseContext = context.createConfigurationContext( + Configuration(context.resources.configuration).apply { + setLocale(Locale.JAPANESE) + } + ) + val japaneseEntries = + japaneseContext.resources.getStringArray( + R.array.keyboard_touch_effect_cinematic_wave_type_entries + ).toList() + + assertEquals( + listOf( + CinematicWaveSettings.WAVE_TYPE_AURORA_MEMBRANE, + CinematicWaveSettings.WAVE_TYPE_SILK_SINE + ), + values + ) + assertTrue(englishEntries.contains("Aurora Membrane")) + assertTrue(englishEntries.contains("Silk Sine")) + assertTrue(japaneseEntries.contains("オーロラ膜")) + assertTrue(japaneseEntries.contains("シルキーサインウェーブ")) + } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/TouchEffectSimulationResizeContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/TouchEffectSimulationResizeContractTest.kt index aa7a3a86..f2b2872d 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/TouchEffectSimulationResizeContractTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/TouchEffectSimulationResizeContractTest.kt @@ -58,7 +58,8 @@ class TouchEffectSimulationResizeContractTest { listOf( "LiquidRippleRenderer.kt", "SprayPaintRenderer.kt", - "LuminousBlobRenderer.kt" + "LuminousBlobRenderer.kt", + "CinematicWaveRenderer.kt" ).forEach { fileName -> val lines = mainFile( "java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/$fileName" @@ -77,7 +78,8 @@ class TouchEffectSimulationResizeContractTest { listOf( "LiquidRippleEffectView.kt", "SprayPaintEffectView.kt", - "LuminousBlobEffectView.kt" + "LuminousBlobEffectView.kt", + "CinematicWaveEffectView.kt" ).forEach { fileName -> val lines = mainFile( "java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/$fileName" diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt index 8e30f58c..fc9d3567 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt @@ -54,6 +54,10 @@ class AppPreferenceSuminagashiInkTest { AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_preference ) assertTrue(AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference) + assertEquals( + CinematicWaveSettings.WAVE_TYPE_AURORA_MEMBRANE, + AppPreference.keyboard_touch_effect_cinematic_wave_type_preference + ) assertEquals(46, AppPreference.keyboard_touch_effect_cinematic_wave_opacity_percent_preference) assertEquals(100, AppPreference.keyboard_touch_effect_cinematic_wave_intensity_percent_preference) assertEquals( @@ -264,6 +268,7 @@ class AppPreferenceSuminagashiInkTest { AppPreference.keyboard_touch_effect_cinematic_wave_primary_color_preference = primary AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_preference = secondary AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference = false + AppPreference.keyboard_touch_effect_cinematic_wave_type_preference = "silk_sine" AppPreference.keyboard_touch_effect_cinematic_wave_opacity_percent_preference = 200 AppPreference.keyboard_touch_effect_cinematic_wave_intensity_percent_preference = 1 AppPreference.keyboard_touch_effect_cinematic_wave_motion_preference = "dynamic" @@ -288,6 +293,10 @@ class AppPreferenceSuminagashiInkTest { AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_preference ) assertFalse(AppPreference.keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference) + assertEquals( + CinematicWaveSettings.WAVE_TYPE_SILK_SINE, + AppPreference.keyboard_touch_effect_cinematic_wave_type_preference + ) assertEquals(68, AppPreference.keyboard_touch_effect_cinematic_wave_opacity_percent_preference) assertEquals(35, AppPreference.keyboard_touch_effect_cinematic_wave_intensity_percent_preference) assertEquals( @@ -307,6 +316,7 @@ class AppPreferenceSuminagashiInkTest { @Test fun cinematicWavePreferencesNormalizeUnexpectedValuesToDefaults() { AppPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference = "surprise" + AppPreference.keyboard_touch_effect_cinematic_wave_type_preference = "visualizer" AppPreference.keyboard_touch_effect_cinematic_wave_motion_preference = "fast" AppPreference.keyboard_touch_effect_cinematic_wave_touch_response_preference = "massive" AppPreference.keyboard_touch_effect_cinematic_wave_quality_preference = "ultra" @@ -315,6 +325,10 @@ class AppPreferenceSuminagashiInkTest { CinematicWaveSettings.COLOR_MODE_CINEMATIC_RANDOM, AppPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference ) + assertEquals( + CinematicWaveSettings.WAVE_TYPE_AURORA_MEMBRANE, + AppPreference.keyboard_touch_effect_cinematic_wave_type_preference + ) assertEquals( CinematicWaveSettings.MOTION_ELEGANT, AppPreference.keyboard_touch_effect_cinematic_wave_motion_preference diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CinematicWaveSettingsEntryPointTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CinematicWaveSettingsEntryPointTest.kt new file mode 100644 index 00000000..04904495 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CinematicWaveSettingsEntryPointTest.kt @@ -0,0 +1,100 @@ +package com.kazumaproject.markdownhelperkeyboard.setting_activity.ui.setting + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.markdownhelperkeyboard.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.xmlpull.v1.XmlPullParser + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CinematicWaveSettingsEntryPointTest { + + @Test + fun cinematicWaveSettingsExistInNewAndLegacyPreferenceXml() { + val context = ApplicationProvider.getApplicationContext() + val newKeys = preferenceKeys(context, R.xml.pref_keyboard_display) + val legacyKeys = preferenceKeys(context, R.xml.pref_common_legacy) + + requiredCinematicWaveKeys.forEach { key -> + assertTrue("Missing $key in pref_keyboard_display.xml", key in newKeys) + assertTrue("Missing $key in pref_common_legacy.xml", key in legacyKeys) + } + } + + @Test + fun cinematicWaveSettingsCanBeAddedFromNewFrequentSettingsScreen() { + val context = ApplicationProvider.getApplicationContext() + val candidateKeys = SettingDestinations.frequentCandidates(context) + .map { it.key } + .toSet() + + requiredCinematicWaveKeys.forEach { key -> + assertTrue("Missing $key from frequent setting candidates", key in candidateKeys) + } + } + + @Test + fun cinematicWaveLegacySearchKeepsParentControlsVisible() { + val context = ApplicationProvider.getApplicationContext() + val legacyDestinations = SettingSearchIndex.legacySearchable(context) + .associateBy { it.key } + + val colorMode = legacyDestinations[ + "keyboard_touch_effect_cinematic_wave_color_mode_preference" + ] ?: error("Missing Cinematic Wave color mode from legacy search") + val waveType = legacyDestinations[ + "keyboard_touch_effect_cinematic_wave_type_preference" + ] ?: error("Missing Cinematic Wave type from legacy search") + val waveTypeLegacyTarget = waveType.legacyTarget + ?: error("Cinematic Wave type should use a legacy target") + + assertEquals(SettingSearchScope.LEGACY_TABS, colorMode.searchScope) + assertEquals(SettingSearchScope.LEGACY_TABS, waveType.searchScope) + assertTrue( + "Cinematic Wave search result should keep effect type visible", + "keyboard_touch_effect_type_preference" in waveTypeLegacyTarget.relatedPreferenceKeys + ) + assertTrue( + "Custom-color search result should keep color mode visible", + "keyboard_touch_effect_cinematic_wave_color_mode_preference" in + waveTypeLegacyTarget.relatedPreferenceKeys + ) + } + + private fun preferenceKeys(context: Context, xmlRes: Int): Set { + val parser = context.resources.getXml(xmlRes) + try { + return buildSet { + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.eventType != XmlPullParser.START_TAG) continue + parser.getAttributeValue(ANDROID_NS, "key")?.let(::add) + } + } + } finally { + parser.close() + } + } + + private companion object { + private const val ANDROID_NS = "http://schemas.android.com/apk/res/android" + + private val requiredCinematicWaveKeys = listOf( + "keyboard_touch_effect_cinematic_wave_color_mode_preference", + "keyboard_touch_effect_cinematic_wave_type_preference", + "keyboard_touch_effect_cinematic_wave_primary_color_preference", + "keyboard_touch_effect_cinematic_wave_secondary_color_auto_preference", + "keyboard_touch_effect_cinematic_wave_secondary_color_preference", + "keyboard_touch_effect_cinematic_wave_opacity_percent_preference", + "keyboard_touch_effect_cinematic_wave_intensity_percent_preference", + "keyboard_touch_effect_cinematic_wave_motion_preference", + "keyboard_touch_effect_cinematic_wave_touch_response_preference", + "keyboard_touch_effect_cinematic_wave_quality_preference" + ) + } +} From 6fe3e6f006c47ec8cd2f5ee53e7a4d433339498b Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:44:49 -0400 Subject: [PATCH 7/8] add sine wave --- .../image_effect/CinematicWaveSettings.kt | 6 + .../image_effect/CinematicWaveSimulation.kt | 200 +++++++++++++++++- app/src/main/res/values-ja/arrays.xml | 6 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values/arrays.xml | 6 + app/src/main/res/values/strings.xml | 3 + .../image_effect/CinematicWaveContractTest.kt | 6 + .../KeyboardTouchEffectResourceTest.kt | 11 +- .../AppPreferenceSuminagashiInkTest.kt | 18 ++ 9 files changed, 254 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt index 9f376004..5d29cca4 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt @@ -33,6 +33,9 @@ internal data class CinematicWaveSettings( const val WAVE_TYPE_AURORA_MEMBRANE = "aurora_membrane" const val WAVE_TYPE_SILK_SINE = "silk_sine" + const val WAVE_TYPE_PRISMATIC_SINE = "prismatic_sine" + const val WAVE_TYPE_LUMINOUS_STACK = "luminous_stack" + const val WAVE_TYPE_AURORA_FLOW = "aurora_flow" const val MOTION_CALM = "calm" const val MOTION_ELEGANT = "elegant" @@ -76,6 +79,9 @@ internal data class CinematicWaveSettings( fun normalizeWaveType(value: String?): String { return when (value) { WAVE_TYPE_SILK_SINE -> WAVE_TYPE_SILK_SINE + WAVE_TYPE_PRISMATIC_SINE -> WAVE_TYPE_PRISMATIC_SINE + WAVE_TYPE_LUMINOUS_STACK -> WAVE_TYPE_LUMINOUS_STACK + WAVE_TYPE_AURORA_FLOW -> WAVE_TYPE_AURORA_FLOW else -> WAVE_TYPE_AURORA_MEMBRANE } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt index 997ae085..53fc8011 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt @@ -95,10 +95,7 @@ internal class CinematicWaveSimulation { GLES30.glUniform2f(uResolution, surfaceWidth.toFloat(), surfaceHeight.toFloat()) GLES30.glUniform1f(uTime, timeSeconds) - GLES30.glUniform1i( - uWaveType, - if (settings.normalizedWaveType == CinematicWaveSettings.WAVE_TYPE_SILK_SINE) 1 else 0 - ) + GLES30.glUniform1i(uWaveType, waveTypeUniformValue()) GLES30.glUniform1f(uOpacity, settings.opacity) GLES30.glUniform1f(uIntensity, settings.intensity) GLES30.glUniform1f(uMotion, params.motionSpeed) @@ -167,6 +164,16 @@ internal class CinematicWaveSimulation { GLES30.glUniform3f(location, red, green, blue) } + private fun waveTypeUniformValue(): Int { + return when (settings.normalizedWaveType) { + CinematicWaveSettings.WAVE_TYPE_SILK_SINE -> 1 + CinematicWaveSettings.WAVE_TYPE_PRISMATIC_SINE -> 2 + CinematicWaveSettings.WAVE_TYPE_LUMINOUS_STACK -> 3 + CinematicWaveSettings.WAVE_TYPE_AURORA_FLOW -> 4 + else -> 0 + } + } + private fun createProgram() { program = linkProgram(VERTEX_SHADER, FRAGMENT_SHADER) uResolution = GLES30.glGetUniformLocation(program, "uResolution") @@ -357,6 +364,191 @@ internal class CinematicWaveSimulation { fbm(warpedUv * 3.6 - slowTime * 0.052) ) * 0.060 * uWarpStrength; + if (uWaveType == 2) { + vec2 sineUv = mix(uv, warpedUv, 0.42); + float x = sineUv.x; + float y = sineUv.y; + float centerA = 0.54 + sin(x * 5.35 - slowTime * 0.32 + 0.20) * 0.245; + float centerB = 0.47 + sin(x * 4.68 + slowTime * 0.26 + 2.12) * 0.220; + float centerC = 0.61 + sin(x * 6.10 - slowTime * 0.21 + 3.78) * 0.175; + float centerD = 0.38 + sin(x * 3.92 + slowTime * 0.18 + 5.18) * 0.205; + + float bandA = ribbon(y, centerA, 0.135); + float bandB = ribbon(y, centerB, 0.122); + float bandC = ribbon(y, centerC, 0.108); + float bandD = ribbon(y, centerD, 0.116); + float coreA = ribbon(y, centerA, 0.025); + float coreB = ribbon(y, centerB, 0.023); + float coreC = ribbon(y, centerC, 0.020); + float coreD = ribbon(y, centerD, 0.022); + + float prismaticCrossing = smoothstep( + 0.035, + 0.62, + bandA * bandB + bandA * bandC + bandB * bandD + bandC * bandD + ); + float satinBody = clamp( + bandA * 0.46 + bandB * 0.42 + bandC * 0.36 + bandD * 0.34, + 0.0, + 1.0 + ); + float brightCore = clamp( + coreA * 0.74 + coreB * 0.68 + coreC * 0.56 + coreD * 0.50, + 0.0, + 1.0 + ); + + vec3 blueRibbon = mix(uPrimaryColor, vec3(0.30, 0.66, 1.00), 0.58); + vec3 greenRibbon = mix(uSecondaryColor, vec3(0.24, 0.96, 0.48), 0.46); + vec3 roseRibbon = mix(uSecondaryColor, vec3(1.00, 0.24, 0.42), 0.55); + vec3 goldRibbon = mix(uHighlightColor, vec3(1.00, 0.70, 0.20), 0.28); + float vignette = 1.0 - smoothstep(0.26, 1.06, length(centered)); + vec3 base = uBaseColor * (0.32 + 0.34 * vignette); + + vec3 color = base; + color += blueRibbon * bandA * 0.54 * uIntensity; + color += greenRibbon * bandB * 0.42 * uIntensity; + color += roseRibbon * bandC * 0.48 * uIntensity; + color += goldRibbon * bandD * 0.25 * uIntensity; + color += uHighlightColor * pow(brightCore, 1.85) * 0.72 * uGlowStrength; + color += uHighlightColor * prismaticCrossing * 0.78 * uGlowStrength; + color += uHighlightColor * touchGlow * 0.13 * uIntensity; + color -= uBaseColor * touchShadow * 0.030; + + float glass = 0.055 + vignette * 0.040; + float alpha = glass + satinBody * 0.33 * uIntensity + brightCore * 0.28 * uGlowStrength; + alpha += prismaticCrossing * 0.18 + touchGlow * 0.075; + alpha = clamp(alpha * uOpacity, 0.0, 0.66); + color = clamp(color, vec3(0.0), vec3(1.0)); + fragColor = vec4(color * alpha, alpha); + return; + } + + if (uWaveType == 3) { + vec2 stackUv = drift; + float x = stackUv.x; + float y = stackUv.y; + float waveA = sin(x * 6.20 + slowTime * 0.22) * 0.18 + + sin(x * 11.10 - slowTime * 0.14 + 1.42) * 0.055; + float waveB = sin(x * 5.15 - slowTime * 0.19 + 2.45) * 0.16 + + sin(x * 9.70 + slowTime * 0.11 + 0.76) * 0.050; + float waveC = sin(x * 7.05 + slowTime * 0.16 + 4.20) * 0.13 + + sin(x * 12.40 - slowTime * 0.10 + 3.30) * 0.044; + float centerA = 0.52 + waveA; + float centerB = 0.42 + waveB; + float centerC = 0.63 + waveC; + float centerD = 0.34 + sin(x * 4.55 + slowTime * 0.13 + 5.52) * 0.12; + + float sheetA = ribbon(y, centerA, 0.155); + float sheetB = ribbon(y, centerB, 0.136); + float sheetC = ribbon(y, centerC, 0.118); + float sheetD = ribbon(y, centerD, 0.130); + float rimA = ribbon(y, centerA, 0.034); + float rimB = ribbon(y, centerB, 0.030); + float rimC = ribbon(y, centerC, 0.028); + float rimD = ribbon(y, centerD, 0.032); + + float luminousStackBody = clamp( + sheetA * 0.44 + sheetB * 0.39 + sheetC * 0.34 + sheetD * 0.30, + 0.0, + 1.0 + ); + float luminousStackRim = clamp( + rimA * 0.64 + rimB * 0.56 + rimC * 0.48 + rimD * 0.40, + 0.0, + 1.0 + ); + float stackCrossing = smoothstep( + 0.04, + 0.70, + sheetA * sheetB + sheetA * sheetC + sheetB * sheetD + ); + float vignette = 1.0 - smoothstep(0.24, 1.02, length(centered)); + vec3 base = mix(uBaseColor * 0.34, uBaseColor * 1.12, smoothstep(0.0, 1.0, uv.y)); + base += vec3(0.020, 0.024, 0.032) * vignette; + + vec3 color = base; + color += uPrimaryColor * sheetA * 0.36 * uIntensity; + color += mix(uSecondaryColor, uPrimaryColor, 0.28) * sheetB * 0.34 * uIntensity; + color += mix(uHighlightColor, uPrimaryColor, 0.52) * sheetC * 0.25 * uIntensity; + color += mix(uSecondaryColor, uHighlightColor, 0.25) * sheetD * 0.24 * uIntensity; + color += uHighlightColor * pow(luminousStackRim, 2.05) * 0.58 * uGlowStrength; + color += uHighlightColor * stackCrossing * 0.34 * uGlowStrength; + color += uHighlightColor * touchGlow * 0.14 * uIntensity; + color -= uBaseColor * touchShadow * 0.036; + + float alpha = 0.060 + luminousStackBody * 0.29 * uIntensity + + luminousStackRim * 0.22 * uGlowStrength + stackCrossing * 0.13 + + vignette * 0.035 + touchGlow * 0.082; + alpha = clamp(alpha * uOpacity, 0.0, 0.64); + color = clamp(color, vec3(0.0), vec3(1.0)); + fragColor = vec4(color * alpha, alpha); + return; + } + + if (uWaveType == 4) { + vec2 flowUv = drift + vec2( + fbm(drift * 1.55 + vec2(slowTime * 0.030, 2.10)), + fbm(drift * 1.90 - vec2(1.60, slowTime * 0.026)) + ) * 0.070 * uWarpStrength; + float flowNoiseA = fbm(flowUv * vec2(2.1, 3.2) + slowTime * 0.030); + float flowNoiseB = fbm(flowUv * vec2(3.4, 2.4) - slowTime * 0.025); + float centerA = 0.50 + sin(flowUv.x * 4.25 + flowUv.y * 1.20 + flowNoiseA * 2.75 + slowTime * 0.16) * 0.205; + float centerB = 0.42 + sin(flowUv.x * 5.65 - flowUv.y * 1.55 + flowNoiseB * 2.25 - slowTime * 0.13 + 2.35) * 0.175; + float centerC = 0.61 + sin(flowUv.x * 3.55 + flowUv.y * 2.05 + flowNoiseA * 2.10 + slowTime * 0.11 + 4.15) * 0.150; + float centerD = 0.36 + sin(flowUv.x * 6.05 - flowUv.y * 0.90 + flowNoiseB * 1.80 + slowTime * 0.09 + 5.20) * 0.130; + + float auroraCurtainA = ribbon(flowUv.y, centerA, 0.178); + float auroraCurtainB = ribbon(flowUv.y, centerB, 0.145); + float auroraCurtainC = ribbon(flowUv.y, centerC, 0.126); + float auroraCurtainD = ribbon(flowUv.y, centerD, 0.154); + float auroraRidge = clamp( + ribbon(flowUv.y, centerA, 0.040) * 0.60 + + ribbon(flowUv.y, centerB, 0.034) * 0.52 + + ribbon(flowUv.y, centerC, 0.030) * 0.46, + 0.0, + 1.0 + ); + float auroraInterference = smoothstep( + 0.06, + 0.76, + auroraCurtainA * auroraCurtainB + auroraCurtainA * auroraCurtainC + + auroraCurtainB * auroraCurtainD + ); + float shimmer = smoothstep( + 0.35, + 0.95, + fbm(flowUv * vec2(8.0, 2.2) + vec2(slowTime * 0.045, -slowTime * 0.020)) + ); + float vignette = 1.0 - smoothstep(0.20, 1.08, length(centered)); + vec3 base = mix(uBaseColor * 0.42, uBaseColor * 1.26, smoothstep(0.0, 1.0, uv.y)); + base += vec3(0.012, 0.020, 0.030) * vignette; + + vec3 color = base; + color += uPrimaryColor * auroraCurtainA * 0.38 * uIntensity; + color += mix(uPrimaryColor, uSecondaryColor, 0.60) * auroraCurtainB * 0.33 * uIntensity; + color += mix(uSecondaryColor, uHighlightColor, 0.38) * auroraCurtainC * 0.27 * uIntensity; + color += mix(uPrimaryColor, uHighlightColor, 0.30) * auroraCurtainD * 0.22 * uIntensity; + color += uHighlightColor * auroraRidge * (0.36 + shimmer * 0.22) * uGlowStrength; + color += uHighlightColor * auroraInterference * 0.28 * uGlowStrength; + color += uHighlightColor * touchGlow * 0.14 * uIntensity; + color -= uBaseColor * touchShadow * 0.038; + + float auroraBody = clamp( + auroraCurtainA * 0.42 + auroraCurtainB * 0.36 + + auroraCurtainC * 0.30 + auroraCurtainD * 0.28, + 0.0, + 1.0 + ); + float alpha = 0.065 + auroraBody * 0.27 * uIntensity + + auroraRidge * 0.20 * uGlowStrength + auroraInterference * 0.12 + + shimmer * auroraBody * 0.055 + touchGlow * 0.078; + alpha = clamp(alpha * uOpacity, 0.0, 0.63); + color = clamp(color, vec3(0.0), vec3(1.0)); + fragColor = vec4(color * alpha, alpha); + return; + } + if (uWaveType == 1) { vec2 ribbonUv = drift; float aspect = uResolution.x / max(uResolution.y, 1.0); diff --git a/app/src/main/res/values-ja/arrays.xml b/app/src/main/res/values-ja/arrays.xml index 19150754..86f1ad30 100644 --- a/app/src/main/res/values-ja/arrays.xml +++ b/app/src/main/res/values-ja/arrays.xml @@ -121,11 +121,17 @@ @string/keyboard_touch_effect_cinematic_wave_type_aurora_membrane @string/keyboard_touch_effect_cinematic_wave_type_silk_sine + @string/keyboard_touch_effect_cinematic_wave_type_prismatic_sine + @string/keyboard_touch_effect_cinematic_wave_type_luminous_stack + @string/keyboard_touch_effect_cinematic_wave_type_aurora_flow aurora_membrane silk_sine + prismatic_sine + luminous_stack + aurora_flow diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ea8761d1..ed0ee72a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1064,6 +1064,9 @@ ウェーブの見た目のタイプを選択します。 オーロラ膜 シルキーサインウェーブ + プリズムサインウェーブ + 光のウェーブレイヤー + オーロラフロー メインカラー メインの発光色を選択します。 現在: %1$s diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index b06dadd4..d00fe0b8 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -131,11 +131,17 @@ @string/keyboard_touch_effect_cinematic_wave_type_aurora_membrane @string/keyboard_touch_effect_cinematic_wave_type_silk_sine + @string/keyboard_touch_effect_cinematic_wave_type_prismatic_sine + @string/keyboard_touch_effect_cinematic_wave_type_luminous_stack + @string/keyboard_touch_effect_cinematic_wave_type_aurora_flow aurora_membrane silk_sine + prismatic_sine + luminous_stack + aurora_flow diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 12f82593..657587fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1075,6 +1075,9 @@ Choose the visual wave model. Aurora Membrane Silk Sine + Prismatic Sine + Luminous Wave Stack + Aurora Flow Primary Color Choose the main glow color. Current: %1$s diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt index 99885ed0..4a7242d6 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt @@ -37,7 +37,13 @@ class CinematicWaveContractTest { assertTrue(simulation.contains("uTouchStrengths[5]")) assertTrue(simulation.contains("uniform int uWaveType")) assertTrue(simulation.contains("if (uWaveType == 1)")) + assertTrue(simulation.contains("if (uWaveType == 2)")) + assertTrue(simulation.contains("if (uWaveType == 3)")) + assertTrue(simulation.contains("if (uWaveType == 4)")) assertTrue(simulation.contains("float ribbon")) + assertTrue(simulation.contains("prismaticCrossing")) + assertTrue(simulation.contains("luminousStackBody")) + assertTrue(simulation.contains("auroraCurtainA")) assertTrue(simulation.contains("lens")) assertTrue(simulation.contains("fragColor = vec4(color * alpha, alpha);")) assertFalse(simulation.contains("android.graphics.Canvas")) diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt index 91d0a5c4..45935769 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt @@ -92,13 +92,22 @@ class KeyboardTouchEffectResourceTest { assertEquals( listOf( CinematicWaveSettings.WAVE_TYPE_AURORA_MEMBRANE, - CinematicWaveSettings.WAVE_TYPE_SILK_SINE + CinematicWaveSettings.WAVE_TYPE_SILK_SINE, + CinematicWaveSettings.WAVE_TYPE_PRISMATIC_SINE, + CinematicWaveSettings.WAVE_TYPE_LUMINOUS_STACK, + CinematicWaveSettings.WAVE_TYPE_AURORA_FLOW ), values ) assertTrue(englishEntries.contains("Aurora Membrane")) assertTrue(englishEntries.contains("Silk Sine")) + assertTrue(englishEntries.contains("Prismatic Sine")) + assertTrue(englishEntries.contains("Luminous Wave Stack")) + assertTrue(englishEntries.contains("Aurora Flow")) assertTrue(japaneseEntries.contains("オーロラ膜")) assertTrue(japaneseEntries.contains("シルキーサインウェーブ")) + assertTrue(japaneseEntries.contains("プリズムサインウェーブ")) + assertTrue(japaneseEntries.contains("光のウェーブレイヤー")) + assertTrue(japaneseEntries.contains("オーロラフロー")) } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt index fc9d3567..cf7ecf6c 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt @@ -313,6 +313,24 @@ class AppPreferenceSuminagashiInkTest { ) } + @Test + fun cinematicWavePreferencesAcceptAllSupportedWaveTypes() { + listOf( + CinematicWaveSettings.WAVE_TYPE_AURORA_MEMBRANE, + CinematicWaveSettings.WAVE_TYPE_SILK_SINE, + CinematicWaveSettings.WAVE_TYPE_PRISMATIC_SINE, + CinematicWaveSettings.WAVE_TYPE_LUMINOUS_STACK, + CinematicWaveSettings.WAVE_TYPE_AURORA_FLOW + ).forEach { waveType -> + AppPreference.keyboard_touch_effect_cinematic_wave_type_preference = waveType + + assertEquals( + waveType, + AppPreference.keyboard_touch_effect_cinematic_wave_type_preference + ) + } + } + @Test fun cinematicWavePreferencesNormalizeUnexpectedValuesToDefaults() { AppPreference.keyboard_touch_effect_cinematic_wave_color_mode_preference = "surprise" From efd5937e774a83119ebe204e0a14d7cb6bb116af Mon Sep 17 00:00:00 2001 From: KazumaProject <59742125+KazumaProject@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:35:28 -0400 Subject: [PATCH 8/8] add more wave effects --- .../image_effect/CinematicWaveSettings.kt | 9 +- .../image_effect/CinematicWaveSimulation.kt | 307 ++++++++++++------ app/src/main/res/values-ja/arrays.xml | 8 +- app/src/main/res/values-ja/strings.xml | 4 +- app/src/main/res/values/arrays.xml | 8 +- app/src/main/res/values/strings.xml | 4 +- .../image_effect/CinematicWaveContractTest.kt | 37 ++- .../KeyboardTouchEffectResourceTest.kt | 16 +- .../AppPreferenceSuminagashiInkTest.kt | 4 +- 9 files changed, 273 insertions(+), 124 deletions(-) diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt index 5d29cca4..7945a7ee 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSettings.kt @@ -34,8 +34,9 @@ internal data class CinematicWaveSettings( const val WAVE_TYPE_AURORA_MEMBRANE = "aurora_membrane" const val WAVE_TYPE_SILK_SINE = "silk_sine" const val WAVE_TYPE_PRISMATIC_SINE = "prismatic_sine" - const val WAVE_TYPE_LUMINOUS_STACK = "luminous_stack" - const val WAVE_TYPE_AURORA_FLOW = "aurora_flow" + const val WAVE_TYPE_SPECTRUM_SINE = "spectrum_sine" + const val WAVE_TYPE_CHROMA_FOLD = "chroma_fold" + private const val LEGACY_CHROMA_FOLD_VALUE = "oled_ribbon" const val MOTION_CALM = "calm" const val MOTION_ELEGANT = "elegant" @@ -80,8 +81,8 @@ internal data class CinematicWaveSettings( return when (value) { WAVE_TYPE_SILK_SINE -> WAVE_TYPE_SILK_SINE WAVE_TYPE_PRISMATIC_SINE -> WAVE_TYPE_PRISMATIC_SINE - WAVE_TYPE_LUMINOUS_STACK -> WAVE_TYPE_LUMINOUS_STACK - WAVE_TYPE_AURORA_FLOW -> WAVE_TYPE_AURORA_FLOW + WAVE_TYPE_SPECTRUM_SINE -> WAVE_TYPE_SPECTRUM_SINE + WAVE_TYPE_CHROMA_FOLD, LEGACY_CHROMA_FOLD_VALUE -> WAVE_TYPE_CHROMA_FOLD else -> WAVE_TYPE_AURORA_MEMBRANE } } diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt index 53fc8011..11ac2bb2 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveSimulation.kt @@ -168,8 +168,8 @@ internal class CinematicWaveSimulation { return when (settings.normalizedWaveType) { CinematicWaveSettings.WAVE_TYPE_SILK_SINE -> 1 CinematicWaveSettings.WAVE_TYPE_PRISMATIC_SINE -> 2 - CinematicWaveSettings.WAVE_TYPE_LUMINOUS_STACK -> 3 - CinematicWaveSettings.WAVE_TYPE_AURORA_FLOW -> 4 + CinematicWaveSettings.WAVE_TYPE_SPECTRUM_SINE -> 3 + CinematicWaveSettings.WAVE_TYPE_CHROMA_FOLD -> 4 else -> 0 } } @@ -425,61 +425,105 @@ internal class CinematicWaveSimulation { } if (uWaveType == 3) { - vec2 stackUv = drift; - float x = stackUv.x; - float y = stackUv.y; - float waveA = sin(x * 6.20 + slowTime * 0.22) * 0.18 + - sin(x * 11.10 - slowTime * 0.14 + 1.42) * 0.055; - float waveB = sin(x * 5.15 - slowTime * 0.19 + 2.45) * 0.16 + - sin(x * 9.70 + slowTime * 0.11 + 0.76) * 0.050; - float waveC = sin(x * 7.05 + slowTime * 0.16 + 4.20) * 0.13 + - sin(x * 12.40 - slowTime * 0.10 + 3.30) * 0.044; - float centerA = 0.52 + waveA; - float centerB = 0.42 + waveB; - float centerC = 0.63 + waveC; - float centerD = 0.34 + sin(x * 4.55 + slowTime * 0.13 + 5.52) * 0.12; - - float sheetA = ribbon(y, centerA, 0.155); - float sheetB = ribbon(y, centerB, 0.136); - float sheetC = ribbon(y, centerC, 0.118); - float sheetD = ribbon(y, centerD, 0.130); - float rimA = ribbon(y, centerA, 0.034); - float rimB = ribbon(y, centerB, 0.030); - float rimC = ribbon(y, centerC, 0.028); - float rimD = ribbon(y, centerD, 0.032); - - float luminousStackBody = clamp( - sheetA * 0.44 + sheetB * 0.39 + sheetC * 0.34 + sheetD * 0.30, + vec2 spectrumSheetUv = uv + (warpedUv - uv) * 0.58; + float x = spectrumSheetUv.x; + float y = spectrumSheetUv.y; + float horizon = smoothstep(0.06, 0.92, x) * (1.0 - smoothstep(0.82, 1.04, x)); + + float blueAxis = 0.62 + sin(x * 5.02 - slowTime * 0.20 + 0.05) * 0.255; + float cyanAxis = 0.55 + sin(x * 5.82 + slowTime * 0.16 + 1.16) * 0.225; + float greenAxis = 0.36 + sin(x * 4.72 - slowTime * 0.13 + 2.54) * 0.238; + float roseAxis = 0.46 + sin(x * 5.30 + slowTime * 0.18 + 4.04) * 0.205; + float goldAxis = 0.29 + sin(x * 6.18 - slowTime * 0.11 + 5.64) * 0.112; + + float blueD = y - blueAxis; + float cyanD = y - cyanAxis; + float greenD = y - greenAxis; + float roseD = y - roseAxis; + float goldD = y - goldAxis; + float blueAbs = abs(blueD); + float cyanAbs = abs(cyanD); + float greenAbs = abs(greenD); + float roseAbs = abs(roseD); + float goldAbs = abs(goldD); + + float blueWidth = 0.136 + 0.026 * sin(x * 3.4 + slowTime * 0.08); + float cyanWidth = 0.118 + 0.018 * sin(x * 3.8 - slowTime * 0.07 + 1.2); + float greenWidth = 0.132 + 0.020 * sin(x * 3.1 + slowTime * 0.05 + 2.1); + float roseWidth = 0.113 + 0.016 * sin(x * 4.0 - slowTime * 0.06 + 3.7); + float goldWidth = 0.054 + 0.010 * sin(x * 4.6 + slowTime * 0.05 + 1.7); + + float blueBody = (1.0 - smoothstep(blueWidth, blueWidth + 0.082, blueAbs)) * horizon; + float cyanBody = (1.0 - smoothstep(cyanWidth, cyanWidth + 0.072, cyanAbs)) * horizon; + float greenBody = (1.0 - smoothstep(greenWidth, greenWidth + 0.075, greenAbs)) * horizon; + float roseBody = (1.0 - smoothstep(roseWidth, roseWidth + 0.070, roseAbs)) * horizon; + float goldBody = (1.0 - smoothstep(goldWidth, goldWidth + 0.040, goldAbs)) * horizon; + + float blueEdgeD = (blueAbs - blueWidth) * 28.0; + float cyanEdgeD = (cyanAbs - cyanWidth) * 31.0; + float greenEdgeD = (greenAbs - greenWidth) * 29.0; + float roseEdgeD = (roseAbs - roseWidth) * 32.0; + float goldEdgeD = (goldAbs - goldWidth) * 42.0; + float spectrumFilmEdge = clamp( + exp(-(blueEdgeD * blueEdgeD)) * 0.50 + + exp(-(cyanEdgeD * cyanEdgeD)) * 0.56 + + exp(-(greenEdgeD * greenEdgeD)) * 0.42 + + exp(-(roseEdgeD * roseEdgeD)) * 0.46 + + exp(-(goldEdgeD * goldEdgeD)) * 0.44, 0.0, 1.0 ); - float luminousStackRim = clamp( - rimA * 0.64 + rimB * 0.56 + rimC * 0.48 + rimD * 0.40, + float spectrumSheetField = clamp( + blueBody * 0.44 + cyanBody * 0.42 + greenBody * 0.40 + + roseBody * 0.38 + goldBody * 0.26, 0.0, 1.0 ); - float stackCrossing = smoothstep( - 0.04, - 0.70, - sheetA * sheetB + sheetA * sheetC + sheetB * sheetD + float spectrumIntersectionGlow = smoothstep( + 0.020, + 0.46, + blueBody * cyanBody + cyanBody * greenBody + + greenBody * roseBody + roseBody * blueBody + + goldBody * roseBody + ); + float spectrumWhiteWindow = smoothstep( + 0.08, + 0.66, + cyanBody * greenBody + blueBody * roseBody + ) * smoothstep(0.16, 0.86, y); + float upperLight = clamp( + smoothstep(-0.02, blueWidth, blueD) * blueBody * 0.25 + + smoothstep(-0.02, cyanWidth, cyanD) * cyanBody * 0.30 + + smoothstep(-0.02, greenWidth, greenD) * greenBody * 0.22 + + smoothstep(-0.02, roseWidth, roseD) * roseBody * 0.20, + 0.0, + 1.0 ); - float vignette = 1.0 - smoothstep(0.24, 1.02, length(centered)); - vec3 base = mix(uBaseColor * 0.34, uBaseColor * 1.12, smoothstep(0.0, 1.0, uv.y)); - base += vec3(0.020, 0.024, 0.032) * vignette; - vec3 color = base; - color += uPrimaryColor * sheetA * 0.36 * uIntensity; - color += mix(uSecondaryColor, uPrimaryColor, 0.28) * sheetB * 0.34 * uIntensity; - color += mix(uHighlightColor, uPrimaryColor, 0.52) * sheetC * 0.25 * uIntensity; - color += mix(uSecondaryColor, uHighlightColor, 0.25) * sheetD * 0.24 * uIntensity; - color += uHighlightColor * pow(luminousStackRim, 2.05) * 0.58 * uGlowStrength; - color += uHighlightColor * stackCrossing * 0.34 * uGlowStrength; - color += uHighlightColor * touchGlow * 0.14 * uIntensity; - color -= uBaseColor * touchShadow * 0.036; - - float alpha = 0.060 + luminousStackBody * 0.29 * uIntensity + - luminousStackRim * 0.22 * uGlowStrength + stackCrossing * 0.13 + - vignette * 0.035 + touchGlow * 0.082; + vec3 glassBase = mix( + uBaseColor * 0.22, + vec3(0.18, 0.18, 0.17), + smoothstep(0.34, 1.0, uv.y) + ); + glassBase *= 0.72 + 0.20 * (1.0 - smoothstep(0.20, 1.12, length(centered))); + + vec3 color = glassBase; + color += mix(uPrimaryColor, vec3(0.22, 0.50, 1.00), 0.72) * blueBody * 0.48 * uIntensity; + color += mix(uHighlightColor, vec3(0.42, 1.00, 0.94), 0.68) * cyanBody * 0.43 * uIntensity; + color += mix(uSecondaryColor, vec3(0.20, 0.93, 0.28), 0.66) * greenBody * 0.40 * uIntensity; + color += mix(uSecondaryColor, vec3(1.00, 0.24, 0.46), 0.62) * roseBody * 0.42 * uIntensity; + color += mix(uHighlightColor, vec3(1.00, 0.58, 0.20), 0.46) * goldBody * 0.28 * uIntensity; + color += vec3(0.88, 0.98, 1.00) * spectrumWhiteWindow * 0.50 * uGlowStrength; + color += uHighlightColor * spectrumFilmEdge * 0.30 * uGlowStrength; + color += vec3(0.96, 0.99, 1.00) * spectrumIntersectionGlow * 0.42 * uGlowStrength; + color += vec3(0.90, 0.96, 1.00) * upperLight * 0.18 * uGlowStrength; + color += uHighlightColor * touchGlow * 0.12 * uIntensity; + color -= uBaseColor * touchShadow * 0.032; + + float alpha = 0.044 + spectrumSheetField * 0.31 * uIntensity + + spectrumFilmEdge * 0.15 * uGlowStrength + + spectrumIntersectionGlow * 0.15 + spectrumWhiteWindow * 0.11 + + touchGlow * 0.070; alpha = clamp(alpha * uOpacity, 0.0, 0.64); color = clamp(color, vec3(0.0), vec3(1.0)); fragColor = vec4(color * alpha, alpha); @@ -487,63 +531,130 @@ internal class CinematicWaveSimulation { } if (uWaveType == 4) { - vec2 flowUv = drift + vec2( - fbm(drift * 1.55 + vec2(slowTime * 0.030, 2.10)), - fbm(drift * 1.90 - vec2(1.60, slowTime * 0.026)) - ) * 0.070 * uWarpStrength; - float flowNoiseA = fbm(flowUv * vec2(2.1, 3.2) + slowTime * 0.030); - float flowNoiseB = fbm(flowUv * vec2(3.4, 2.4) - slowTime * 0.025); - float centerA = 0.50 + sin(flowUv.x * 4.25 + flowUv.y * 1.20 + flowNoiseA * 2.75 + slowTime * 0.16) * 0.205; - float centerB = 0.42 + sin(flowUv.x * 5.65 - flowUv.y * 1.55 + flowNoiseB * 2.25 - slowTime * 0.13 + 2.35) * 0.175; - float centerC = 0.61 + sin(flowUv.x * 3.55 + flowUv.y * 2.05 + flowNoiseA * 2.10 + slowTime * 0.11 + 4.15) * 0.150; - float centerD = 0.36 + sin(flowUv.x * 6.05 - flowUv.y * 0.90 + flowNoiseB * 1.80 + slowTime * 0.09 + 5.20) * 0.130; - - float auroraCurtainA = ribbon(flowUv.y, centerA, 0.178); - float auroraCurtainB = ribbon(flowUv.y, centerB, 0.145); - float auroraCurtainC = ribbon(flowUv.y, centerC, 0.126); - float auroraCurtainD = ribbon(flowUv.y, centerD, 0.154); - float auroraRidge = clamp( - ribbon(flowUv.y, centerA, 0.040) * 0.60 + - ribbon(flowUv.y, centerB, 0.034) * 0.52 + - ribbon(flowUv.y, centerC, 0.030) * 0.46, + vec2 vividUv = uv + (warpedUv - uv) * 0.66; + float x = vividUv.x; + float y = vividUv.y; + float vividTime = slowTime * 0.17; + float visibleSpan = smoothstep(0.03, 0.13, x) * + (1.0 - smoothstep(0.88, 1.04, x)); + + float cobaltAxis = 0.66 + + sin(x * 4.72 - vividTime * 1.08 + 0.18) * 0.244 + + sin(x * 9.15 + vividTime * 0.36 + 1.42) * 0.026; + float cyanAxis = 0.57 + + sin(x * 5.44 + vividTime * 0.92 + 1.02) * 0.218 + + sin(x * 10.20 - vividTime * 0.31 + 2.70) * 0.024; + float emeraldAxis = 0.43 + + sin(x * 4.98 - vividTime * 0.72 + 2.28) * 0.230 + + sin(x * 8.70 + vividTime * 0.28 + 0.64) * 0.028; + float limeAxis = 0.35 + + sin(x * 6.22 + vividTime * 0.64 + 3.35) * 0.172; + float coralAxis = 0.48 + + sin(x * 5.88 + vividTime * 1.02 + 4.18) * 0.198 + + sin(x * 11.35 - vividTime * 0.25 + 1.80) * 0.023; + float magentaAxis = 0.54 + + sin(x * 6.64 - vividTime * 0.86 + 5.05) * 0.177; + float amberAxis = 0.30 + + sin(x * 7.18 - vividTime * 0.54 + 5.82) * 0.118; + + float cobaltD = y - cobaltAxis; + float cyanD = y - cyanAxis; + float emeraldD = y - emeraldAxis; + float limeD = y - limeAxis; + float coralD = y - coralAxis; + float magentaD = y - magentaAxis; + float amberD = y - amberAxis; + + float cobaltW = 0.118 + sin(x * 3.7 + vividTime * 0.32) * 0.020; + float cyanW = 0.106 + sin(x * 4.0 - vividTime * 0.28 + 1.1) * 0.017; + float emeraldW = 0.111 + sin(x * 3.3 + vividTime * 0.24 + 2.0) * 0.018; + float limeW = 0.074 + sin(x * 4.9 - vividTime * 0.22 + 0.8) * 0.012; + float coralW = 0.102 + sin(x * 4.2 + vividTime * 0.25 + 3.3) * 0.016; + float magentaW = 0.082 + sin(x * 5.0 - vividTime * 0.21 + 2.4) * 0.014; + float amberW = 0.055 + sin(x * 5.6 + vividTime * 0.18 + 1.9) * 0.010; + + float cobaltBody = (1.0 - smoothstep(cobaltW, cobaltW + 0.058, abs(cobaltD))) * visibleSpan; + float cyanBody = (1.0 - smoothstep(cyanW, cyanW + 0.052, abs(cyanD))) * visibleSpan; + float emeraldBody = (1.0 - smoothstep(emeraldW, emeraldW + 0.054, abs(emeraldD))) * visibleSpan; + float limeBody = (1.0 - smoothstep(limeW, limeW + 0.038, abs(limeD))) * visibleSpan; + float coralBody = (1.0 - smoothstep(coralW, coralW + 0.050, abs(coralD))) * visibleSpan; + float magentaBody = (1.0 - smoothstep(magentaW, magentaW + 0.042, abs(magentaD))) * visibleSpan; + float amberBody = (1.0 - smoothstep(amberW, amberW + 0.034, abs(amberD))) * visibleSpan; + + float vividCobaltEdgeD = (abs(cobaltD) - cobaltW) * 34.0; + float vividCyanEdgeD = (abs(cyanD) - cyanW) * 36.0; + float vividEmeraldEdgeD = (abs(emeraldD) - emeraldW) * 35.0; + float vividLimeEdgeD = (abs(limeD) - limeW) * 44.0; + float vividCoralEdgeD = (abs(coralD) - coralW) * 37.0; + float vividMagentaEdgeD = (abs(magentaD) - magentaW) * 42.0; + float vividAmberEdgeD = (abs(amberD) - amberW) * 48.0; + float vividSpectrumEdge = clamp( + exp(-(vividCobaltEdgeD * vividCobaltEdgeD)) * 0.48 + + exp(-(vividCyanEdgeD * vividCyanEdgeD)) * 0.54 + + exp(-(vividEmeraldEdgeD * vividEmeraldEdgeD)) * 0.48 + + exp(-(vividLimeEdgeD * vividLimeEdgeD)) * 0.36 + + exp(-(vividCoralEdgeD * vividCoralEdgeD)) * 0.46 + + exp(-(vividMagentaEdgeD * vividMagentaEdgeD)) * 0.38 + + exp(-(vividAmberEdgeD * vividAmberEdgeD)) * 0.30, 0.0, 1.0 ); - float auroraInterference = smoothstep( - 0.06, - 0.76, - auroraCurtainA * auroraCurtainB + auroraCurtainA * auroraCurtainC + - auroraCurtainB * auroraCurtainD + float vividSpectrumField = clamp( + cobaltBody * 0.36 + cyanBody * 0.36 + emeraldBody * 0.35 + + limeBody * 0.24 + coralBody * 0.35 + magentaBody * 0.27 + + amberBody * 0.20, + 0.0, + 1.0 ); - float shimmer = smoothstep( - 0.35, - 0.95, - fbm(flowUv * vec2(8.0, 2.2) + vec2(slowTime * 0.045, -slowTime * 0.020)) + float vividSpectrumCrossGlow = smoothstep( + 0.030, + 0.58, + cobaltBody * cyanBody + cyanBody * emeraldBody + + emeraldBody * coralBody + coralBody * magentaBody + + cobaltBody * coralBody + limeBody * amberBody ); - float vignette = 1.0 - smoothstep(0.20, 1.08, length(centered)); - vec3 base = mix(uBaseColor * 0.42, uBaseColor * 1.26, smoothstep(0.0, 1.0, uv.y)); - base += vec3(0.012, 0.020, 0.030) * vignette; - - vec3 color = base; - color += uPrimaryColor * auroraCurtainA * 0.38 * uIntensity; - color += mix(uPrimaryColor, uSecondaryColor, 0.60) * auroraCurtainB * 0.33 * uIntensity; - color += mix(uSecondaryColor, uHighlightColor, 0.38) * auroraCurtainC * 0.27 * uIntensity; - color += mix(uPrimaryColor, uHighlightColor, 0.30) * auroraCurtainD * 0.22 * uIntensity; - color += uHighlightColor * auroraRidge * (0.36 + shimmer * 0.22) * uGlowStrength; - color += uHighlightColor * auroraInterference * 0.28 * uGlowStrength; - color += uHighlightColor * touchGlow * 0.14 * uIntensity; - color -= uBaseColor * touchShadow * 0.038; - - float auroraBody = clamp( - auroraCurtainA * 0.42 + auroraCurtainB * 0.36 + - auroraCurtainC * 0.30 + auroraCurtainD * 0.28, + float vividSpectrumWhite = smoothstep( + 0.06, + 0.48, + cyanBody * emeraldBody + cobaltBody * coralBody + magentaBody * cyanBody + ) * smoothstep(0.14, 0.82, y); + float vividSpectrumClarity = clamp( + smoothstep(-0.01, cobaltW, cobaltD) * cobaltBody * 0.20 + + smoothstep(-0.01, cyanW, cyanD) * cyanBody * 0.26 + + smoothstep(-0.01, emeraldW, emeraldD) * emeraldBody * 0.22 + + smoothstep(-0.01, coralW, coralD) * coralBody * 0.22 + + vividSpectrumEdge * 0.34, 0.0, 1.0 ); - float alpha = 0.065 + auroraBody * 0.27 * uIntensity + - auroraRidge * 0.20 * uGlowStrength + auroraInterference * 0.12 + - shimmer * auroraBody * 0.055 + touchGlow * 0.078; - alpha = clamp(alpha * uOpacity, 0.0, 0.63); + + vec3 base = mix( + uBaseColor * 0.18, + vec3(0.12, 0.12, 0.125), + smoothstep(0.24, 1.0, uv.y) + ); + base *= 0.70 + 0.24 * (1.0 - smoothstep(0.18, 1.08, length(centered))); + + vec3 color = base; + color += mix(uPrimaryColor, vec3(0.12, 0.38, 1.00), 0.76) * cobaltBody * 0.52 * uIntensity; + color += mix(uHighlightColor, vec3(0.20, 0.96, 1.00), 0.76) * cyanBody * 0.50 * uIntensity; + color += mix(uSecondaryColor, vec3(0.10, 0.95, 0.38), 0.72) * emeraldBody * 0.46 * uIntensity; + color += vec3(0.72, 1.00, 0.24) * limeBody * 0.30 * uIntensity; + color += vec3(1.00, 0.23, 0.24) * coralBody * 0.48 * uIntensity; + color += vec3(1.00, 0.16, 0.82) * magentaBody * 0.36 * uIntensity; + color += vec3(1.00, 0.66, 0.16) * amberBody * 0.28 * uIntensity; + color += vec3(0.96, 1.00, 1.00) * vividSpectrumWhite * 0.54 * uGlowStrength; + color += uHighlightColor * vividSpectrumEdge * 0.34 * uGlowStrength; + color += vec3(0.92, 0.98, 1.00) * vividSpectrumCrossGlow * 0.44 * uGlowStrength; + color += vec3(0.90, 0.98, 1.00) * vividSpectrumClarity * 0.14 * uGlowStrength; + color += uHighlightColor * touchGlow * 0.12 * uIntensity; + color -= uBaseColor * touchShadow * 0.030; + + float alpha = 0.046 + vividSpectrumField * 0.34 * uIntensity + + vividSpectrumEdge * 0.17 * uGlowStrength + + vividSpectrumCrossGlow * 0.15 + vividSpectrumWhite * 0.11 + + touchGlow * 0.070; + alpha = clamp(alpha * uOpacity, 0.0, 0.68); color = clamp(color, vec3(0.0), vec3(1.0)); fragColor = vec4(color * alpha, alpha); return; diff --git a/app/src/main/res/values-ja/arrays.xml b/app/src/main/res/values-ja/arrays.xml index 86f1ad30..dd27e70c 100644 --- a/app/src/main/res/values-ja/arrays.xml +++ b/app/src/main/res/values-ja/arrays.xml @@ -122,16 +122,16 @@ @string/keyboard_touch_effect_cinematic_wave_type_aurora_membrane @string/keyboard_touch_effect_cinematic_wave_type_silk_sine @string/keyboard_touch_effect_cinematic_wave_type_prismatic_sine - @string/keyboard_touch_effect_cinematic_wave_type_luminous_stack - @string/keyboard_touch_effect_cinematic_wave_type_aurora_flow + @string/keyboard_touch_effect_cinematic_wave_type_spectrum_sine + @string/keyboard_touch_effect_cinematic_wave_type_chroma_fold aurora_membrane silk_sine prismatic_sine - luminous_stack - aurora_flow + spectrum_sine + chroma_fold diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ed0ee72a..e55f79fd 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1065,8 +1065,8 @@ オーロラ膜 シルキーサインウェーブ プリズムサインウェーブ - 光のウェーブレイヤー - オーロラフロー + スペクトラムサインウェーブ + ビビッドスペクトラム メインカラー メインの発光色を選択します。 現在: %1$s diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index d00fe0b8..00b9d759 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -132,16 +132,16 @@ @string/keyboard_touch_effect_cinematic_wave_type_aurora_membrane @string/keyboard_touch_effect_cinematic_wave_type_silk_sine @string/keyboard_touch_effect_cinematic_wave_type_prismatic_sine - @string/keyboard_touch_effect_cinematic_wave_type_luminous_stack - @string/keyboard_touch_effect_cinematic_wave_type_aurora_flow + @string/keyboard_touch_effect_cinematic_wave_type_spectrum_sine + @string/keyboard_touch_effect_cinematic_wave_type_chroma_fold aurora_membrane silk_sine prismatic_sine - luminous_stack - aurora_flow + spectrum_sine + chroma_fold diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 657587fd..121e5dc3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1076,8 +1076,8 @@ Aurora Membrane Silk Sine Prismatic Sine - Luminous Wave Stack - Aurora Flow + Spectrum Sine + Vivid Spectrum Primary Color Choose the main glow color. Current: %1$s diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt index 4a7242d6..7f8dc144 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/CinematicWaveContractTest.kt @@ -42,14 +42,35 @@ class CinematicWaveContractTest { assertTrue(simulation.contains("if (uWaveType == 4)")) assertTrue(simulation.contains("float ribbon")) assertTrue(simulation.contains("prismaticCrossing")) - assertTrue(simulation.contains("luminousStackBody")) - assertTrue(simulation.contains("auroraCurtainA")) + assertTrue(simulation.contains("spectrumSheetField")) + assertTrue(simulation.contains("spectrumIntersectionGlow")) + assertTrue(simulation.contains("vividSpectrumField")) + assertTrue(simulation.contains("vividSpectrumEdge")) + assertTrue(simulation.contains("vividSpectrumCrossGlow")) assertTrue(simulation.contains("lens")) assertTrue(simulation.contains("fragColor = vec4(color * alpha, alpha);")) assertFalse(simulation.contains("android.graphics.Canvas")) assertFalse(simulation.contains("android.graphics.Bitmap")) assertFalse(simulation.contains("drawCircle")) assertFalse(simulation.contains("drawLine")) + + val spectrumBranch = shaderBranch( + simulation = simulation, + startMarker = "if (uWaveType == 3)", + endMarker = "if (uWaveType == 4)" + ) + val vividSpectrumBranch = shaderBranch( + simulation = simulation, + startMarker = "if (uWaveType == 4)", + endMarker = "if (uWaveType == 1)" + ) + assertFalse(spectrumBranch.contains("ribbon(")) + assertFalse(vividSpectrumBranch.contains("ribbon(")) + assertFalse(spectrumBranch.contains("prismatic")) + assertFalse(vividSpectrumBranch.contains("prismatic")) + assertFalse(vividSpectrumBranch.contains("oledRibbon")) + assertFalse(vividSpectrumBranch.contains("fold")) + assertFalse(vividSpectrumBranch.contains("Fold")) } @Test @@ -111,4 +132,16 @@ class CinematicWaveContractTest { } return body } + + private fun shaderBranch( + simulation: String, + startMarker: String, + endMarker: String + ): String { + val start = simulation.indexOf(startMarker) + require(start >= 0) { "Missing shader branch $startMarker" } + val end = simulation.indexOf(endMarker, start + startMarker.length) + require(end > start) { "Missing shader branch end $endMarker" } + return simulation.substring(start, end) + } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt index 45935769..87004021 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/image_effect/KeyboardTouchEffectResourceTest.kt @@ -94,20 +94,24 @@ class KeyboardTouchEffectResourceTest { CinematicWaveSettings.WAVE_TYPE_AURORA_MEMBRANE, CinematicWaveSettings.WAVE_TYPE_SILK_SINE, CinematicWaveSettings.WAVE_TYPE_PRISMATIC_SINE, - CinematicWaveSettings.WAVE_TYPE_LUMINOUS_STACK, - CinematicWaveSettings.WAVE_TYPE_AURORA_FLOW + CinematicWaveSettings.WAVE_TYPE_SPECTRUM_SINE, + CinematicWaveSettings.WAVE_TYPE_CHROMA_FOLD ), values ) assertTrue(englishEntries.contains("Aurora Membrane")) assertTrue(englishEntries.contains("Silk Sine")) assertTrue(englishEntries.contains("Prismatic Sine")) - assertTrue(englishEntries.contains("Luminous Wave Stack")) - assertTrue(englishEntries.contains("Aurora Flow")) + assertTrue(englishEntries.contains("Spectrum Sine")) + assertTrue(englishEntries.contains("Vivid Spectrum")) assertTrue(japaneseEntries.contains("オーロラ膜")) assertTrue(japaneseEntries.contains("シルキーサインウェーブ")) assertTrue(japaneseEntries.contains("プリズムサインウェーブ")) - assertTrue(japaneseEntries.contains("光のウェーブレイヤー")) - assertTrue(japaneseEntries.contains("オーロラフロー")) + assertTrue(japaneseEntries.contains("スペクトラムサインウェーブ")) + assertTrue(japaneseEntries.contains("ビビッドスペクトラム")) + assertFalse(englishEntries.contains("OLED Ribbon")) + assertFalse(englishEntries.contains("Chroma Fold")) + assertFalse(japaneseEntries.contains("OLEDリボンウェーブ")) + assertFalse(japaneseEntries.contains("クロマフォールド")) } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt index cf7ecf6c..5b93e921 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceSuminagashiInkTest.kt @@ -319,8 +319,8 @@ class AppPreferenceSuminagashiInkTest { CinematicWaveSettings.WAVE_TYPE_AURORA_MEMBRANE, CinematicWaveSettings.WAVE_TYPE_SILK_SINE, CinematicWaveSettings.WAVE_TYPE_PRISMATIC_SINE, - CinematicWaveSettings.WAVE_TYPE_LUMINOUS_STACK, - CinematicWaveSettings.WAVE_TYPE_AURORA_FLOW + CinematicWaveSettings.WAVE_TYPE_SPECTRUM_SINE, + CinematicWaveSettings.WAVE_TYPE_CHROMA_FOLD ).forEach { waveType -> AppPreference.keyboard_touch_effect_cinematic_wave_type_preference = waveType