diff --git a/app/build.gradle b/app/build.gradle index 6c2cfee1d..60c900386 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { applicationId "com.kazumaproject.markdownhelperkeyboard" minSdk 24 targetSdk 36 - versionCode 800 - versionName "1.7.107" + versionCode 801 + versionName "1.7.108" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/keyboard/KeyboardSkinPickerInstrumentedTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/keyboard/KeyboardSkinPickerInstrumentedTest.kt new file mode 100644 index 000000000..ce70ea965 --- /dev/null +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/keyboard/KeyboardSkinPickerInstrumentedTest.kt @@ -0,0 +1,237 @@ +package com.kazumaproject.markdownhelperkeyboard.keyboard + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.SystemClock +import android.widget.TextView +import android.view.View +import androidx.navigation.Navigation +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.markdownhelperkeyboard.R +import com.kazumaproject.markdownhelperkeyboard.setting_activity.MainActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.FileOutputStream + +@RunWith(AndroidJUnit4::class) +class KeyboardSkinPickerInstrumentedTest { + + @Test + fun cupertinoLightAndDarkLabelsRenderWithoutEllipsis() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = ApplicationProvider.getApplicationContext() + val scenario = ActivityScenario.launch( + Intent(context, MainActivity::class.java), + ) + + try { + scenario.onActivity { activity -> + Navigation.findNavController( + activity, + R.id.nav_host_fragment_activity_main, + ).navigate(R.id.keyboardSkinPickerFragment) + } + awaitSkinGrid(scenario, instrumentation) + + // Position is intentionally not derived from enum ordinal: imported cards are listed + // first and the built-in order is an implementation detail. + val light = findSkinCard(scenario, instrumentation, KeyboardSkinId.CUPERTINO.preferenceValue) + val dark = findSkinCard(scenario, instrumentation, KeyboardSkinId.CUPERTINO_DARK.preferenceValue) + + scenario.onActivity { activity -> + assertLabelIsComplete( + light, + activity.getString(R.string.keyboard_skin_cupertino), + ) + assertLabelIsComplete( + dark, + activity.getString(R.string.keyboard_skin_cupertino_dark), + ) + + val root = activity.window.decorView.rootView + val bitmap = Bitmap.createBitmap( + root.width, + root.height, + Bitmap.Config.ARGB_8888, + ).also { root.draw(Canvas(it)) } + val output = File(activity.filesDir, "keyboard-skin-picker-cupertino.png") + FileOutputStream(output).use { stream -> + assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)) + } + bitmap.recycle() + assertTrue(output.length() > 8_000L) + } + } finally { + scenario.close() + } + } + + @Test + fun tactileConceptSkinsCanAllBeSelectedAndPersisted() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = ApplicationProvider.getApplicationContext() + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val previousSkin = preferences.getString(KEYBOARD_SKIN_KEY, KeyboardSkinId.DEFAULT.preferenceValue) + val skins = listOf( + KeyboardSkinId.SUMI_HANSHI, + KeyboardSkinId.LETTERPRESS, + KeyboardSkinId.PORCELAIN, + KeyboardSkinId.URUSHI, + KeyboardSkinId.CHALKBOARD, + KeyboardSkinId.LINEN, + KeyboardSkinId.MONOCHROME_LCD, + ) + val nameResources = listOf( + R.string.keyboard_skin_sumi_hanshi, + R.string.keyboard_skin_letterpress, + R.string.keyboard_skin_porcelain, + R.string.keyboard_skin_urushi, + R.string.keyboard_skin_chalkboard, + R.string.keyboard_skin_linen, + R.string.keyboard_skin_monochrome_lcd, + ) + val scenario = ActivityScenario.launch(Intent(context, MainActivity::class.java)) + + try { + scenario.onActivity { activity -> + Navigation.findNavController( + activity, + R.id.nav_host_fragment_activity_main, + ).navigate(R.id.keyboardSkinPickerFragment) + } + awaitSkinGrid(scenario, instrumentation) + + skins.forEachIndexed { index, skin -> + val item = findSkinCard(scenario, instrumentation, skin.preferenceValue) + scenario.onActivity { activity -> + val label = item.findViewById(R.id.keyboard_skin_name) + assertEquals(activity.getString(nameResources[index]), label.text.toString()) + assertLabelHasNoEllipsis(label) + item.performClick() + } + instrumentation.waitForIdleSync() + assertEquals( + skin.preferenceValue, + preferences.getString(KEYBOARD_SKIN_KEY, null), + ) + } + SystemClock.sleep(240) + instrumentation.waitForIdleSync() + scenario.onActivity { activity -> + captureActivity(activity, "keyboard-skin-picker-new-materials.png") + } + } finally { + preferences.edit().putString(KEYBOARD_SKIN_KEY, previousSkin).commit() + scenario.close() + } + } + + @Test + fun legacySkinArraysStaySynchronizedWithCatalog() { + val context = ApplicationProvider.getApplicationContext() + val values = context.resources.getStringArray(R.array.keyboard_skin_values).toList() + val entries = context.resources.getStringArray(R.array.keyboard_skin_entries).toList() + + assertEquals(KeyboardSkinId.entries.map { it.preferenceValue }, values) + assertEquals(KeyboardSkinId.entries.size, entries.size) + } + + private fun assertLabelIsComplete( + item: View, + expectedText: String, + ) { + val label = item.findViewById(R.id.keyboard_skin_name) + assertEquals(expectedText, label.text.toString()) + assertLabelHasNoEllipsis(label) + } + + private fun findSkinCard( + scenario: ActivityScenario, + instrumentation: android.app.Instrumentation, + preferenceValue: String, + ): View { + var itemCount = 0 + scenario.onActivity { activity -> + itemCount = activity.findViewById(R.id.keyboard_skin_grid).adapter?.itemCount ?: 0 + } + repeat(itemCount) { position -> + scenario.onActivity { activity -> + activity.findViewById(R.id.keyboard_skin_grid).scrollToPosition(position) + } + instrumentation.waitForIdleSync() + SystemClock.sleep(80) + var match: View? = null + scenario.onActivity { activity -> + val grid = activity.findViewById(R.id.keyboard_skin_grid) + for (index in 0 until grid.childCount) { + val child = grid.getChildAt(index) + if (child.tag == preferenceValue) { + match = child + break + } + } + } + if (match != null) return match as View + } + throw AssertionError("Skin card $preferenceValue must be visible") + } + + private fun assertLabelHasNoEllipsis(label: TextView) { + val layout = label.layout + assertNotNull(layout) + checkNotNull(layout) + for (line in 0 until layout.lineCount) { + assertEquals(0, layout.getEllipsisCount(line)) + } + assertEquals(label.text.length, layout.getLineEnd(layout.lineCount - 1)) + } + + private fun captureActivity(activity: MainActivity, fileName: String) { + val root = activity.window.decorView.rootView + val bitmap = Bitmap.createBitmap( + root.width, + root.height, + Bitmap.Config.ARGB_8888, + ).also { root.draw(Canvas(it)) } + val output = File(activity.filesDir, fileName) + FileOutputStream(output).use { stream -> + assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)) + } + bitmap.recycle() + assertTrue(output.length() > 8_000L) + } + + private fun awaitSkinGrid( + scenario: ActivityScenario, + instrumentation: android.app.Instrumentation, + ) { + repeat(GRID_WAIT_ATTEMPTS) { + instrumentation.waitForIdleSync() + var gridReady = false + scenario.onActivity { activity -> + gridReady = activity.findViewById(R.id.keyboard_skin_grid) != null + } + if (gridReady) return + SystemClock.sleep(GRID_WAIT_STEP_MS) + } + throw AssertionError("Keyboard skin grid did not appear") + } + + companion object { + private const val KEYBOARD_SKIN_KEY = "keyboard_skin_preference" + private const val GRID_WAIT_ATTEMPTS = 30 + private const val GRID_WAIT_STEP_MS = 100L + } +} diff --git a/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/keyboard/KeyboardSkinRenderInstrumentedTest.kt b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/keyboard/KeyboardSkinRenderInstrumentedTest.kt new file mode 100644 index 000000000..3fa93ef45 --- /dev/null +++ b/app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/keyboard/KeyboardSkinRenderInstrumentedTest.kt @@ -0,0 +1,332 @@ +package com.kazumaproject.markdownhelperkeyboard.keyboard + +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.util.Log +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.kazumaproject.core.data.keyboard.KeyboardElementRole +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinMotionMode +import com.kazumaproject.core.data.keyboard.KeyboardSkinPreviewView +import com.kazumaproject.core.data.keyboard.KeyboardSkinViewStyler +import com.kazumaproject.markdownhelperkeyboard.R +import com.kazumaproject.tenkey.TenKey +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.FileOutputStream +import kotlin.math.abs + +@RunWith(AndroidJUnit4::class) +class KeyboardSkinRenderInstrumentedTest { + + @Test + fun allSkinsRenderAsDistinctPixel6Previews() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val outputDirectory = File(context.filesDir, OUTPUT_DIRECTORY).apply { mkdirs() } + val hashes = linkedSetOf() + + KeyboardSkinId.entries.forEach { skin -> + var bitmap: Bitmap? = null + instrumentation.runOnMainSync { + val preview = KeyboardSkinPreviewView(context).apply { + setSkin(skin, KeyboardSkinMotionMode.OFF) + measure( + View.MeasureSpec.makeMeasureSpec(WIDTH_PX, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HEIGHT_PX, View.MeasureSpec.EXACTLY), + ) + layout(0, 0, WIDTH_PX, HEIGHT_PX) + } + bitmap = Bitmap.createBitmap(WIDTH_PX, HEIGHT_PX, Bitmap.Config.ARGB_8888) + .also { preview.draw(Canvas(it)) } + } + + val rendered = checkNotNull(bitmap) + val pixels = IntArray(WIDTH_PX * HEIGHT_PX) + rendered.getPixels(pixels, 0, WIDTH_PX, 0, 0, WIDTH_PX, HEIGHT_PX) + hashes += pixels.contentHashCode() + val output = File(outputDirectory, "skin-${skin.preferenceValue}.png") + FileOutputStream(output).use { stream -> + assertTrue(rendered.compress(Bitmap.CompressFormat.PNG, 100, stream)) + } + assertTrue(output.length() > 8_000L) + rendered.recycle() + } + + assertEquals(KeyboardSkinId.entries.size, hashes.size) + Log.i(TAG, "Rendered distinct skin previews to ${outputDirectory.absolutePath}") + } + + @Test + fun allSkinsRenderAsDistinctPixel6TenKeyViews() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val themedContext = ContextThemeWrapper(context, R.style.Theme_MarkdownKeyboard) + val outputDirectory = File(context.filesDir, OUTPUT_DIRECTORY).apply { mkdirs() } + val hashes = linkedSetOf() + + KeyboardSkinId.entries.forEach { skin -> + var bitmap: Bitmap? = null + instrumentation.runOnMainSync { + val root = LayoutInflater.from(themedContext).inflate(R.layout.keyboard_settings, null) + val tenKey = root.findViewById(R.id.keyboard_view).apply { + applyKeyboardTheme( + themeMode = "custom", + currentNightMode = Configuration.UI_MODE_NIGHT_NO, + isDynamicColorEnabled = false, + customBgColor = Color.rgb(225, 228, 234), + customKeyColor = Color.rgb(248, 249, 251), + customSpecialKeyColor = Color.rgb(207, 212, 220), + customKeyTextColor = Color.rgb(25, 28, 34), + customSpecialKeyTextColor = Color.rgb(25, 28, 34), + liquidGlassEnable = true, + customBorderEnable = true, + customBorderColor = Color.MAGENTA, + liquidGlassKeyAlphaEnable = 35, + borderWidth = 12, + keyboardSkin = skin.preferenceValue, + keyboardSkinMotion = KeyboardSkinMotionMode.OFF.preferenceValue, + ) + measure( + View.MeasureSpec.makeMeasureSpec(TENKEY_WIDTH_PX, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(TENKEY_HEIGHT_PX, View.MeasureSpec.EXACTLY), + ) + layout(0, 0, TENKEY_WIDTH_PX, TENKEY_HEIGHT_PX) + } + bitmap = Bitmap.createBitmap( + TENKEY_WIDTH_PX, + TENKEY_HEIGHT_PX, + Bitmap.Config.ARGB_8888, + ).also { tenKey.draw(Canvas(it)) } + } + + val rendered = checkNotNull(bitmap) + val pixels = IntArray(TENKEY_WIDTH_PX * TENKEY_HEIGHT_PX) + rendered.getPixels( + pixels, + 0, + TENKEY_WIDTH_PX, + 0, + 0, + TENKEY_WIDTH_PX, + TENKEY_HEIGHT_PX, + ) + hashes += pixels.contentHashCode() + val output = File(outputDirectory, "tenkey-${skin.preferenceValue}.png") + FileOutputStream(output).use { stream -> + assertTrue(rendered.compress(Bitmap.CompressFormat.PNG, 100, stream)) + } + assertTrue(output.length() > 8_000L) + rendered.recycle() + } + + assertEquals(KeyboardSkinId.entries.size, hashes.size) + Log.i(TAG, "Rendered distinct TenKey skins to ${outputDirectory.absolutePath}") + } + + @Test + fun tactileConceptSkinsHaveStrongPairwiseVisualDifferences() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val skins = listOf( + KeyboardSkinId.SUMI_HANSHI, + KeyboardSkinId.LETTERPRESS, + KeyboardSkinId.PORCELAIN, + KeyboardSkinId.URUSHI, + KeyboardSkinId.CHALKBOARD, + KeyboardSkinId.LINEN, + KeyboardSkinId.MONOCHROME_LCD, + ) + val rendered = linkedMapOf() + + skins.forEach { skin -> + var bitmap: Bitmap? = null + instrumentation.runOnMainSync { + val preview = KeyboardSkinPreviewView(context).apply { + setSkin(skin, KeyboardSkinMotionMode.OFF) + measure( + View.MeasureSpec.makeMeasureSpec(WIDTH_PX, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HEIGHT_PX, View.MeasureSpec.EXACTLY), + ) + layout(0, 0, WIDTH_PX, HEIGHT_PX) + } + bitmap = Bitmap.createBitmap(WIDTH_PX, HEIGHT_PX, Bitmap.Config.ARGB_8888) + .also { preview.draw(Canvas(it)) } + } + val image = checkNotNull(bitmap) + rendered[skin] = IntArray(WIDTH_PX * HEIGHT_PX).also { pixels -> + image.getPixels(pixels, 0, WIDTH_PX, 0, 0, WIDTH_PX, HEIGHT_PX) + } + image.recycle() + } + + skins.forEachIndexed { firstIndex, first -> + for (secondIndex in firstIndex + 1 until skins.size) { + val second = skins[secondIndex] + val firstPixels = checkNotNull(rendered[first]) + val secondPixels = checkNotNull(rendered[second]) + var stronglyChanged = 0 + firstPixels.indices.forEach { pixelIndex -> + val firstColor = firstPixels[pixelIndex] + val secondColor = secondPixels[pixelIndex] + val rgbDistance = + abs(Color.red(firstColor) - Color.red(secondColor)) + + abs(Color.green(firstColor) - Color.green(secondColor)) + + abs(Color.blue(firstColor) - Color.blue(secondColor)) + if (rgbDistance >= MIN_STRONG_RGB_DISTANCE) stronglyChanged += 1 + } + val changedRatio = stronglyChanged.toFloat() / firstPixels.size + assertTrue( + "$first and $second only differ strongly across $changedRatio of the preview", + changedRatio >= MIN_STRONGLY_CHANGED_RATIO, + ) + } + } + } + + @Test + fun reducedMotionKeepsAStateTransitionWhileOffRemovesAnimations() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + var reducedHasAnimator = false + var offHasAnimator = true + var fullHasAnimator = false + + instrumentation.runOnMainSync { + val key = View(context) + KeyboardSkinViewStyler.applyKey( + key, + KeyboardSkinId.CUPERTINO, + KeyboardElementRole.CHARACTER, + KeyboardSkinMotionMode.REDUCED, + ) + reducedHasAnimator = key.stateListAnimator != null + + KeyboardSkinViewStyler.applyKey( + key, + KeyboardSkinId.CUPERTINO, + KeyboardElementRole.CHARACTER, + KeyboardSkinMotionMode.OFF, + ) + offHasAnimator = key.stateListAnimator != null + + KeyboardSkinViewStyler.applyKey( + key, + KeyboardSkinId.CUPERTINO, + KeyboardElementRole.CHARACTER, + KeyboardSkinMotionMode.FULL, + ) + fullHasAnimator = key.stateListAnimator != null + } + + assertTrue(reducedHasAnimator) + assertTrue(!offHasAnimator) + assertTrue(fullHasAnimator) + } + + @Test + fun flatChromeControlsNeverDrawKeycapGeometry() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + + KeyboardSkinId.entries.filterNot { it == KeyboardSkinId.DEFAULT }.forEach { skin -> + var idlePixels = IntArray(0) + var pressedPixels = IntArray(0) + var hasStateAnimator = false + var pressedAlpha = 1f + var releasedAlpha = 0f + var backgroundIsNull = false + var geometryStayedFlat = false + instrumentation.runOnMainSync { + val control = View(context).apply { + isEnabled = true + KeyboardSkinViewStyler.applyFlatControl( + this, + skin, + KeyboardElementRole.TOOLBAR, + ) + measure( + View.MeasureSpec.makeMeasureSpec( + FLAT_CONTROL_WIDTH_PX, + View.MeasureSpec.EXACTLY, + ), + View.MeasureSpec.makeMeasureSpec( + FLAT_CONTROL_HEIGHT_PX, + View.MeasureSpec.EXACTLY, + ), + ) + layout(0, 0, FLAT_CONTROL_WIDTH_PX, FLAT_CONTROL_HEIGHT_PX) + } + hasStateAnimator = control.stateListAnimator != null + backgroundIsNull = control.background == null + idlePixels = renderPixels(control, FLAT_CONTROL_WIDTH_PX, FLAT_CONTROL_HEIGHT_PX) + control.isPressed = true + control.refreshDrawableState() + control.stateListAnimator?.jumpToCurrentState() + pressedAlpha = control.alpha + pressedPixels = renderPixels( + control, + FLAT_CONTROL_WIDTH_PX, + FLAT_CONTROL_HEIGHT_PX, + ) + geometryStayedFlat = + control.translationX == 0f && + control.translationY == 0f && + control.scaleX == 1f && + control.scaleY == 1f && + control.elevation == 0f + control.isPressed = false + control.refreshDrawableState() + control.stateListAnimator?.jumpToCurrentState() + releasedAlpha = control.alpha + } + + assertTrue( + "$skin must not draw a resting keycap", + idlePixels.all { Color.alpha(it) == 0 }, + ) + assertTrue( + "$skin must not draw pressed keycap geometry", + pressedPixels.all { Color.alpha(it) == 0 }, + ) + assertTrue("$skin flat control background must stay absent", backgroundIsNull) + assertTrue("$skin needs content-only pressed feedback", hasStateAnimator) + assertEquals(FLAT_CONTROL_PRESSED_ALPHA, pressedAlpha, ALPHA_TOLERANCE) + assertEquals(1f, releasedAlpha, ALPHA_TOLERANCE) + assertTrue("$skin flat control must not move or lift", geometryStayedFlat) + } + } + + private fun renderPixels(view: View, width: Int, height: Int): IntArray { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + view.draw(Canvas(bitmap)) + return IntArray(width * height).also { pixels -> + bitmap.getPixels(pixels, 0, width, 0, 0, width, height) + bitmap.recycle() + } + } + + companion object { + private const val TAG = "KeyboardSkinRender" + private const val OUTPUT_DIRECTORY = "keyboard-skin-render-report" + private const val WIDTH_PX = 720 + private const val HEIGHT_PX = 420 + private const val TENKEY_WIDTH_PX = 1080 + private const val TENKEY_HEIGHT_PX = 760 + private const val MIN_STRONG_RGB_DISTANCE = 45 + private const val MIN_STRONGLY_CHANGED_RATIO = 0.60f + private const val FLAT_CONTROL_WIDTH_PX = 240 + private const val FLAT_CONTROL_HEIGHT_PX = 72 + private const val FLAT_CONTROL_PRESSED_ALPHA = 0.72f + private const val ALPHA_TOLERANCE = 0.01f + } +} 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 befc5377c..dd7f7b999 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 @@ -103,6 +103,19 @@ import com.kazumaproject.android.flexbox.JustifyContent import com.kazumaproject.core.data.clicked_symbol.SymbolMode import com.kazumaproject.core.data.clipboard.ClipboardItem import com.kazumaproject.core.data.floating_candidate.CandidateItem +import com.kazumaproject.core.data.keyboard.KeyboardElementRole +import com.kazumaproject.core.data.keyboard.KeyboardSkinCatalog +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinRuntime +import com.kazumaproject.core.data.keyboard.KeyboardSkinStore +import com.kazumaproject.core.data.keyboard.KeyboardSkinMotionMode +import com.kazumaproject.core.data.keyboard.KeyboardSkinRendererRegistry +import com.kazumaproject.core.data.keyboard.KeyboardSkinViewStyler +import com.kazumaproject.core.data.keyboard.KeyboardSurfaceRole +import com.kazumaproject.core.data.keyboard.resolveKeyboardSkinPalette +import com.kazumaproject.core.data.keyboard.isDefault +import com.kazumaproject.core.data.keyboard.resolvedOrDefault import com.kazumaproject.core.data.popup.FlickPopupViewStyleSet import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.data.popup.QwertyPopupViewStyleSet @@ -335,6 +348,7 @@ import com.kazumaproject.tenkey.extensions.getNextInputChar import com.kazumaproject.tenkey.extensions.getNextReturnInputChar import com.kazumaproject.tenkey.extensions.isHiragana import com.kazumaproject.tenkey.extensions.isLatinAlphabet +import com.kazumaproject.tabletkey.TabletKeyboardView import com.kazumaproject.tenkey.extensions.toggleDakutenWithSeion import com.kazumaproject.tenkey.extensions.toggleHandakutenWithSeion import dagger.Lazy @@ -807,11 +821,30 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, AppPreference.FLICK_TFBI_POPUP_PRESENTATION_KEY, AppPreference.FLICK_TFBI_FLICK_START_POSITION_KEY ) + private val keyboardSkinPreferenceKeys = setOf( + AppPreference.KEYBOARD_SKIN_KEY, + AppPreference.KEYBOARD_SKIN_MOTION_KEY, + KeyboardSkinStore.REVISION_PREF_KEY, + ) private val runtimeInputPreferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key != null && key in runtimeInputPreferenceKeys) { + if (key != null && + (key in runtimeInputPreferenceKeys || key in keyboardSkinPreferenceKeys) + ) { runOnMainThread { - syncRuntimeInputPreferences() + if (key in runtimeInputPreferenceKeys) { + syncRuntimeInputPreferences() + } + if (key == KeyboardSkinStore.REVISION_PREF_KEY) { + ioScope.launch { + KeyboardSkinRuntime.reloadFromDisk(applicationContext) + withContext(Dispatchers.Main.immediate) { + syncKeyboardSkinPreferences() + } + } + } else if (key in keyboardSkinPreferenceKeys) { + syncKeyboardSkinPreferences() + } } } } @@ -1636,6 +1669,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private var qwertySpecialKeyIconSize: Float? = 18.0f private var keyboardThemeMode: String? = "default" + private var keyboardSkinMode: String? = "default" + private var keyboardSkinMotionMode: String? = "full" private var customThemeBgColor: Int? = Color.WHITE private var customThemeKeyColor: Int? = Color.LTGRAY private var customThemeSpecialKeyColor: Int? = Color.GRAY @@ -2211,6 +2246,12 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) runtimeInputPreferenceListenerRegistered = true syncRuntimeInputPreferences() + ioScope.launch { + KeyboardSkinRuntime.reloadFromDisk(applicationContext) + withContext(Dispatchers.Main.immediate) { + if (isInputViewActive) syncKeyboardSkinPreferences() + } + } startKanaKanjiEngineLoad() if (AppVariantConfig.hasGemma) { @@ -2511,6 +2552,143 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) } + /** + * Refreshes skin state on views that survive while the settings Activity is open. + * + * Returning to the same editor can call onStartInputView() without onStartInput(), so relying + * on the full preference snapshot leaves candidate tabs and adapters on the previous skin. + */ + private fun syncKeyboardSkinPreferences() { + assertMainThread("syncKeyboardSkinPreferences") + + val nextSkin = KeyboardSkinRef.fromPreference(appPreference.keyboard_skin).resolvedOrDefault() + val nextMotion = KeyboardSkinMotionMode.fromPreference( + appPreference.keyboard_skin_motion + ) + + keyboardSkinMode = nextSkin.preferenceValue + keyboardSkinMotionMode = nextMotion.preferenceValue + + // Reapply even when the values match. A preference listener may have updated the cached + // values while the input view was absent, and the newly inflated views still need styling. + reapplyKeyboardSkinToKeyboardViews() + applyKeyboardSkinThemeToCandidateAdapters() + shortcutAdapter?.setKeyboardSkin( + skinValue = nextSkin.preferenceValue, + motionValue = nextMotion.preferenceValue, + ) + mainLayoutBinding?.let { mainView -> + applyKeyboardContainerBackgrounds(mainView) + applyKeyboardSkinToCandidateTabs(mainView.candidateTabLayout) + applyKeyboardSkinThemeToSymbolKeyboards(mainView, floatingKeyboardBinding) + } + floatingKeyboardBinding?.let(::applyFloatingKeyboardContainerBackgrounds) + } + + /** + * Rebuilds every already-inflated keyboard surface from the immutable runtime skin snapshot. + * This is intentionally separate from the listener-binding methods: changing a skin must not + * replace IME callbacks, but it must also invalidate a same-ID imported definition update. + */ + private fun reapplyKeyboardSkinToKeyboardViews() { + val skinValue = keyboardSkinMode ?: KeyboardSkinId.DEFAULT.preferenceValue + val motionValue = keyboardSkinMotionMode ?: KeyboardSkinMotionMode.FULL.preferenceValue + val liquidGlass = + (liquidGlassThemePreference ?: false) && !isBuiltInKeyboardSkinActive() + + fun applyTenKey(view: TenKey) { + view.applyKeyboardTheme( + themeMode = keyboardThemeMode ?: "default", + currentNightMode = currentNightMode, + isDynamicColorEnabled = DynamicColors.isDynamicColorAvailable(), + customBgColor = customThemeBgColor ?: Color.WHITE, + customKeyColor = customThemeKeyColor ?: Color.WHITE, + customSpecialKeyColor = customThemeSpecialKeyColor ?: Color.GRAY, + customKeyTextColor = customThemeKeyTextColor ?: Color.BLACK, + customSpecialKeyTextColor = customThemeSpecialKeyTextColor ?: Color.BLACK, + liquidGlassEnable = liquidGlass, + customBorderEnable = customKeyBorderEnablePreference ?: false, + customBorderColor = customKeyBorderEnableColor ?: Color.BLACK, + liquidGlassKeyAlphaEnable = liquidGlassKeyBlurRadiousPreference ?: 255, + borderWidth = customKeyBorderWidth ?: 1, + keyboardSkin = skinValue, + keyboardSkinMotion = motionValue, + ) + } + + fun applyQwerty(view: QWERTYKeyboardView) { + view.applyKeyboardTheme( + themeMode = keyboardThemeMode ?: "default", + currentNightMode = currentNightMode, + isDynamicColorEnabled = DynamicColors.isDynamicColorAvailable(), + customBgColor = customThemeBgColor ?: Color.WHITE, + customKeyColor = customThemeKeyColor ?: Color.WHITE, + customSpecialKeyColor = customThemeSpecialKeyColor ?: Color.GRAY, + customKeyTextColor = customThemeKeyTextColor ?: Color.BLACK, + customSpecialKeyTextColor = customThemeSpecialKeyTextColor ?: Color.BLACK, + liquidGlassEnable = liquidGlass, + customBorderEnable = customKeyBorderEnablePreference ?: false, + customBorderColor = customKeyBorderEnableColor ?: Color.BLACK, + liquidGlassKeyAlphaEnable = liquidGlassKeyBlurRadiousPreference ?: 255, + borderWidth = customKeyBorderWidth ?: 1, + keyboardSkin = skinValue, + keyboardSkinMotion = motionValue, + ) + } + + fun applyTablet(view: TabletKeyboardView) { + view.applyKeyboardTheme( + themeMode = keyboardThemeMode ?: "default", + currentNightMode = currentNightMode, + isDynamicColorEnabled = DynamicColors.isDynamicColorAvailable(), + customBgColor = customThemeBgColor ?: Color.WHITE, + customKeyColor = customThemeKeyColor ?: Color.WHITE, + customSpecialKeyColor = customThemeSpecialKeyColor ?: Color.GRAY, + customKeyTextColor = customThemeKeyTextColor ?: Color.BLACK, + customSpecialKeyTextColor = customThemeSpecialKeyTextColor ?: Color.BLACK, + liquidGlassEnable = liquidGlass, + customBorderEnable = customKeyBorderEnablePreference ?: false, + customBorderColor = customKeyBorderEnableColor ?: Color.BLACK, + liquidGlassKeyAlphaEnable = liquidGlassKeyBlurRadiousPreference ?: 255, + borderWidth = customKeyBorderWidth ?: 1, + keyboardSkin = skinValue, + keyboardSkinMotion = motionValue, + ) + } + + fun applyFlick(view: FlickKeyboardView) { + view.applyKeyboardTheme( + themeMode = keyboardThemeMode ?: "default", + currentNightMode = currentNightMode, + isDynamicColorEnabled = DynamicColors.isDynamicColorAvailable(), + customBgColor = customThemeBgColor ?: Color.WHITE, + customKeyColor = customThemeKeyColor ?: Color.WHITE, + customSpecialKeyColor = customThemeSpecialKeyColor ?: Color.GRAY, + customKeyTextColor = customThemeKeyTextColor ?: Color.BLACK, + customSpecialKeyTextColor = customThemeSpecialKeyTextColor ?: Color.BLACK, + liquidGlassEnable = liquidGlass, + customBorderEnable = customKeyBorderEnablePreference ?: false, + customBorderColor = customKeyBorderEnableColor ?: Color.BLACK, + liquidGlassKeyAlphaEnable = liquidGlassKeyBlurRadiousPreference ?: 255, + borderWidth = customKeyBorderWidth ?: 1, + keyboardSkin = skinValue, + keyboardSkinMotion = motionValue, + ) + } + + mainLayoutBinding?.let { mainView -> + applyTenKey(mainView.keyboardView) + applyQwerty(mainView.qwertyView) + applyTablet(mainView.tabletView) + applyFlick(mainView.customLayoutDefault) + } + floatingKeyboardBinding?.let { floatingView -> + applyTenKey(floatingView.keyboardViewFloating) + applyQwerty(floatingView.qwertyViewFloating) + applyFlick(floatingView.customLayoutFloating) + } + } + /** * Keeps settings that users expect to take effect immediately in sync with every * already-inflated keyboard surface. The same AppPreference keys used by the settings @@ -2872,6 +3050,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertySpecialKeyTextSize = preferences.qwertySpecialKeyTextSize qwertySpecialKeyIconSize = preferences.qwertySpecialKeyIconSize keyboardThemeMode = preferences.keyboardThemeMode + keyboardSkinMode = preferences.keyboardSkin + keyboardSkinMotionMode = preferences.keyboardSkinMotion customThemeBgColor = preferences.customThemeBgColor customThemeKeyColor = preferences.customThemeKeyColor customThemeSpecialKeyColor = preferences.customThemeSpecialKeyColor @@ -3111,6 +3291,15 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, imageView.isVisible = false } + private fun currentKeyboardSkin(): KeyboardSkinRef = + KeyboardSkinRef.fromPreference(keyboardSkinMode).resolvedOrDefault() + + private fun currentKeyboardSkinMotion(): KeyboardSkinMotionMode = + KeyboardSkinMotionMode.fromPreference(keyboardSkinMotionMode) + + private fun isBuiltInKeyboardSkinActive(): Boolean = + !currentKeyboardSkin().isDefault() + private fun applyKeyboardBackgroundImageToViewIfNeeded( imageView: ImageView, onApplied: (Boolean) -> Unit = {} @@ -3358,6 +3547,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView.keyboardBackgroundVideo.isVisible = false mainView.keyboardBackgroundImage.isVisible = false + mainView.keyboardSkinBackdrop.setSkin(KeyboardSkinId.DEFAULT) mainView.root.background = null mainView.suggestionViewParent.background = null @@ -3380,6 +3570,16 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, clearNormalKeyboardBackgroundForFloatingMode(mainView) return } + if (isBuiltInKeyboardSkinActive()) { + releaseKeyboardBackgroundVideoPlayer() + clearKeyboardBackgroundImage(mainView.keyboardBackgroundImage) + mainView.keyboardSkinBackdrop.setSkin( + currentKeyboardSkin(), + currentKeyboardSkinMotion(), + ) + return + } + mainView.keyboardSkinBackdrop.setSkin(KeyboardSkinId.DEFAULT) val isBackgroundVideoApplied = applyKeyboardBackgroundVideoIfNeeded(mainView) if (isBackgroundVideoApplied) { applyKeyboardContainerTransparencyForVideo(mainView, enabled = true) @@ -3394,6 +3594,20 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, ) { applyFloatingKeyboardRoundedClipping(floatingView) updateFloatingKeyboardBackgroundBounds(floatingView) + if (isBuiltInKeyboardSkinActive()) { + releaseFloatingKeyboardBackgroundVideoPlayer() + clearKeyboardBackgroundImage(floatingView.floatingKeyboardBackgroundImage) + floatingView.floatingKeyboardSkinBackdrop.setSkin( + currentKeyboardSkin(), + currentKeyboardSkinMotion(), + ) + applyFloatingKeyboardContainerTransparencyForBackgroundMedia( + floatingView, + enabled = false, + ) + return + } + floatingView.floatingKeyboardSkinBackdrop.setSkin(KeyboardSkinId.DEFAULT) val isBackgroundVideoApplied = applyFloatingKeyboardBackgroundVideoIfNeeded(floatingView) if (isBackgroundVideoApplied) { clearKeyboardBackgroundImage(floatingView.floatingKeyboardBackgroundImage) @@ -3425,6 +3639,13 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView: MainLayoutBinding, floatingView: FloatingKeyboardLayoutBinding? ) { + if (isBuiltInKeyboardSkinActive()) { + clearAndPauseKeyboardTouchEffects() + (mainView.root as? InkTouchDispatchFrameLayout)?.touchEffectMotionEventListener = null + (floatingView?.root as? InkTouchDispatchFrameLayout) + ?.touchEffectMotionEventListener = null + return + } refreshFluidInkDensityPreferences() setupMainKeyboardTouchEffect(mainView) floatingView?.let { setupFloatingKeyboardTouchEffect(it) } @@ -4285,7 +4506,70 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } private fun applyKeyboardContainerBackgrounds(mainView: MainLayoutBinding) { + val selectedSkin = currentKeyboardSkin() + if (!selectedSkin.isDefault()) { + val renderer = KeyboardSkinRendererRegistry.rendererFor(selectedSkin) + mainView.root.background = renderer.createSurfaceDrawable( + this, + KeyboardSurfaceRole.DECK, + ) + mainView.suggestionViewParent.background = renderer.createSurfaceDrawable( + this, + KeyboardSurfaceRole.CANDIDATE_STRIP, + ) + mainView.candidateTabLayout.background = renderer.createSurfaceDrawable( + this, + KeyboardSurfaceRole.TOOLBAR, + ) + mainView.shortcutToolbarRecyclerview.background = renderer.createSurfaceDrawable( + this, + KeyboardSurfaceRole.TOOLBAR, + ) + mainView.candidatesRowView.background = renderer.createSurfaceDrawable( + this, + KeyboardSurfaceRole.CANDIDATE_PANEL, + ) + KeyboardSkinViewStyler.applyKey( + mainView.suggestionVisibility, + selectedSkin, + KeyboardElementRole.TOOLBAR, + currentKeyboardSkinMotion(), + ) + mainView.keyboardSkinBackdrop.setSkin(selectedSkin, currentKeyboardSkinMotion()) + return + } + mainView.keyboardSkinBackdrop.setSkin(KeyboardSkinId.DEFAULT) + KeyboardSkinViewStyler.clearTransientStyle(mainView.suggestionVisibility) + mainView.suggestionVisibility.imageTintList = null + mainView.suggestionVisibility.clearColorFilter() + mainView.suggestionVisibility.setColorFilter( + if (keyboardThemeMode == "custom") { + customThemeKeyTextColor ?: Color.BLACK + } else { + getColor(com.kazumaproject.core.R.color.keyboard_icon_color) + } + ) + mainView.shortcutToolbarRecyclerview.background = null + mainView.candidatesRowView.background = null val isDynamic = DynamicColors.isDynamicColorAvailable() + when { + keyboardThemeMode == "custom" -> { + mainView.suggestionVisibility.setBackgroundResource( + com.kazumaproject.core.R.drawable.recyclerview_size_button_bg_material + ) + mainView.suggestionVisibility.setDrawableSolidColor( + customThemeSpecialKeyColor ?: Color.GRAY + ) + } + + isDynamic -> mainView.suggestionVisibility.setBackgroundResource( + com.kazumaproject.core.R.drawable.recyclerview_size_button_bg_material + ) + + else -> mainView.suggestionVisibility.setBackgroundResource( + com.kazumaproject.core.R.drawable.recyclerview_size_button_bg + ) + } if (isKeyboardRounded == true) { val fallbackColor = getColor(com.kazumaproject.core.R.color.keyboard_bg) val defaultColor = if (isDynamic) { @@ -4372,7 +4656,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun applyNormalKeyboardChrome(mainView: MainLayoutBinding) { applyKeyboardContainerBackgrounds(mainView) - if (liquidGlassThemePreference == true) { + if (liquidGlassThemePreference == true && !isBuiltInKeyboardSkinActive()) { mainView.root.setDrawableAlpha(liquidGlassBlurRadiousPreference ?: 220) mainView.suggestionViewParent.setDrawableAlpha(0) mainView.candidateTabLayout.setDrawableAlpha(0) @@ -4385,6 +4669,45 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, private fun applyFloatingKeyboardContainerBackgrounds( floatingView: FloatingKeyboardLayoutBinding ) { + val selectedSkin = currentKeyboardSkin() + if (!selectedSkin.isDefault()) { + val renderer = KeyboardSkinRendererRegistry.rendererFor(selectedSkin) + floatingView.root.background = renderer.createSurfaceDrawable( + this, + KeyboardSurfaceRole.DECK, + ) + floatingView.suggestionViewParent.background = renderer.createSurfaceDrawable( + this, + KeyboardSurfaceRole.CANDIDATE_STRIP, + ) + floatingView.candidatesRowView.background = renderer.createSurfaceDrawable( + this, + KeyboardSurfaceRole.CANDIDATE_PANEL, + ) + KeyboardSkinViewStyler.applyKey( + floatingView.suggestionVisibility, + selectedSkin, + KeyboardElementRole.TOOLBAR, + currentKeyboardSkinMotion(), + ) + floatingView.floatingKeyboardSkinBackdrop.setSkin( + selectedSkin, + currentKeyboardSkinMotion(), + ) + return + } + floatingView.floatingKeyboardSkinBackdrop.setSkin(KeyboardSkinId.DEFAULT) + KeyboardSkinViewStyler.clearTransientStyle(floatingView.suggestionVisibility) + floatingView.suggestionVisibility.imageTintList = null + floatingView.suggestionVisibility.clearColorFilter() + floatingView.suggestionVisibility.setColorFilter( + if (keyboardThemeMode == "custom") { + customThemeKeyTextColor ?: Color.BLACK + } else { + getColor(com.kazumaproject.core.R.color.keyboard_icon_color) + } + ) + floatingView.candidatesRowView.background = null val isDynamic = DynamicColors.isDynamicColorAvailable() when (keyboardThemeMode) { "default" -> { @@ -4393,7 +4716,13 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, floatingView.suggestionViewParent.setBackgroundResource(com.kazumaproject.core.R.drawable.keyboard_root_material_floating) floatingView.suggestionVisibility.setBackgroundResource(com.kazumaproject.core.R.drawable.recyclerview_size_button_bg_material) } else { + floatingView.root.setBackgroundResource( + com.kazumaproject.core.R.drawable.rounded_corners_bg + ) floatingView.suggestionViewParent.background = null + floatingView.suggestionVisibility.setBackgroundResource( + com.kazumaproject.core.R.drawable.recyclerview_size_button_bg + ) } } @@ -4417,7 +4746,13 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, floatingView.suggestionViewParent.setBackgroundResource(com.kazumaproject.core.R.drawable.keyboard_root_material_floating) floatingView.suggestionVisibility.setBackgroundResource(com.kazumaproject.core.R.drawable.recyclerview_size_button_bg_material) } else { + floatingView.root.setBackgroundResource( + com.kazumaproject.core.R.drawable.rounded_corners_bg + ) floatingView.suggestionViewParent.background = null + floatingView.suggestionVisibility.setBackgroundResource( + com.kazumaproject.core.R.drawable.recyclerview_size_button_bg + ) } } } @@ -4430,6 +4765,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, // The input view can restart without onStartInput() after returning from settings. // Re-read preferences that may change while the existing input session is retained. syncRuntimeInputPreferences() + syncKeyboardSkinPreferences() syncCustomKeyboardSuggestionPreference() syncQwertyEnglishDirectInputPreference() syncNgramDictionaryPreferences() @@ -4562,11 +4898,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, customSpecialKeyColor = customThemeSpecialKeyColor ?: Color.GRAY, customKeyTextColor = customThemeKeyTextColor ?: Color.BLACK, customSpecialKeyTextColor = customThemeSpecialKeyTextColor ?: Color.BLACK, - liquidGlassEnable = liquidGlassThemePreference ?: false, + liquidGlassEnable = + (liquidGlassThemePreference ?: false) && !isBuiltInKeyboardSkinActive(), customBorderEnable = customKeyBorderEnablePreference ?: false, customBorderColor = customKeyBorderEnableColor ?: Color.BLACK, liquidGlassKeyAlphaEnable = liquidGlassKeyBlurRadiousPreference ?: 255, - borderWidth = customKeyBorderWidth ?: 1 + borderWidth = customKeyBorderWidth ?: 1, + keyboardSkin = keyboardSkinMode ?: "default", + keyboardSkinMotion = keyboardSkinMotionMode ?: "full", ) floatingKeyboardLayoutBinding.keyboardViewFloating.setUseThreeStateKeyboard( tenkeyUseThreeStateKeyboard @@ -5014,6 +5353,8 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, qwertySpecialKeyIconSize = null keyboardThemeMode = null + keyboardSkinMode = null + keyboardSkinMotionMode = null customThemeBgColor = null customThemeKeyColor = null customThemeSpecialKeyColor = null @@ -5630,6 +5971,104 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } + private fun applyKeyboardSkinThemeToCandidateAdapters() { + val adapters = listOfNotNull(suggestionAdapter, suggestionAdapterFull) + val skin = currentKeyboardSkin() + adapters.forEach { adapter -> + adapter.setKeyboardSkin( + skinValue = skin.preferenceValue, + motionValue = currentKeyboardSkinMotion().preferenceValue, + ) + } + if (skin.isDefault()) { + adapters.forEach { adapter -> + if (keyboardThemeMode == "custom") { + adapter.setCandidateTextColor(customThemeCandidateTextColor ?: Color.BLACK) + adapter.setCandidateItemColors( + customThemeCandidateItemBgColor ?: Color.TRANSPARENT, + customThemeCandidateItemPressedBgColor ?: ContextCompat.getColor( + this, + com.kazumaproject.core.R.color.qwety_key_bg_color, + ), + ) + } else { + adapter.clearCandidateAppearanceColors() + } + } + val shortcutColor = if (keyboardThemeMode == "custom") { + customThemeShortcutIconColor ?: Color.BLACK + } else { + getColor(com.kazumaproject.core.R.color.keyboard_icon_color) + } + shortcutAdapter?.setIconColor(shortcutColor) + adapters.forEach { it.setShortcutIconColor(shortcutColor) } + applyCandidateEmptyPopupThemeToAdapters() + return + } + + val palette = KeyboardSkinCatalog.specFor(skin).palette + adapters.forEach { adapter -> + adapter.setCandidateTextColor(palette.candidateTextColor) + adapter.setCandidateItemColors( + backgroundColor = palette.candidateSurfaceColor, + pressedColor = palette.accentColor, + ) + adapter.setCandidateEmptyPopupColors( + backgroundColor = palette.specialKeyColor, + textColor = palette.specialKeyTextColor, + ) + } + } + + private fun applyKeyboardSkinThemeToSymbolKeyboards( + mainView: MainLayoutBinding, + floatingView: FloatingKeyboardLayoutBinding?, + ) { + val selectedSkin = currentKeyboardSkin() + if (selectedSkin.isDefault() && keyboardThemeMode != "custom") { + mainView.keyboardSymbolView.resetKeyboardTheme() + floatingView?.floatingSymbolKeyboard?.resetKeyboardTheme() + return + } + + val palette = resolveKeyboardSkinPalette( + context = this, + themeMode = keyboardThemeMode ?: "default", + customBackgroundColor = customThemeBgColor ?: Color.WHITE, + customKeyColor = customThemeKeyColor ?: Color.LTGRAY, + customSpecialKeyColor = customThemeSpecialKeyColor ?: Color.GRAY, + customKeyTextColor = customThemeKeyTextColor ?: Color.BLACK, + customSpecialKeyTextColor = customThemeSpecialKeyTextColor ?: Color.BLACK, + skinId = selectedSkin, + ) + val isLegacyCustom = + selectedSkin.isDefault() && keyboardThemeMode == "custom" + val symbolBackgroundColor = if (isLegacyCustom) { + manipulateColor(palette.normalKeyColor, 1.2f) + } else { + palette.backgroundColor + } + val symbolSelectedIconColor = if (isLegacyCustom) { + manipulateColor(palette.normalKeyTextColor, 0.6f) + } else { + palette.specialKeyTextColor + } + val applyToView: (CustomSymbolKeyboardView) -> Unit = { symbolView -> + symbolView.setKeyboardTheme( + backgroundColor = symbolBackgroundColor, + iconColor = palette.normalKeyTextColor, + selectedIconColor = symbolSelectedIconColor, + keyBackgroundColor = palette.normalKeyColor, + liquidGlassEnable = + (liquidGlassThemePreference ?: false) && !isBuiltInKeyboardSkinActive(), + keyboardSkin = selectedSkin.preferenceValue, + keyboardSkinMotion = keyboardSkinMotionMode ?: "full", + ) + } + applyToView(mainView.keyboardSymbolView) + floatingView?.floatingSymbolKeyboard?.let(applyToView) + } + private fun setupKeyboardView() { Timber.d("setupKeyboardView: Called") val isDynamicColorsEnable = DynamicColors.isDynamicColorAvailable() @@ -5785,6 +6224,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, container.addView(newRootView) gemmaMediaPanelController?.attachTo(container) mainLayoutBinding?.let { mainView -> + applyKeyboardSkinThemeToSymbolKeyboards( + mainView, + floatingKeyboardBinding, + ) when (keyboardThemeMode) { "default" -> { if (isDynamicColorsEnable) { @@ -5808,17 +6251,6 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, suggestionViewParent.setBackgroundResource(com.kazumaproject.core.R.drawable.keyboard_root_material) suggestionVisibility.setBackgroundResource(com.kazumaproject.core.R.drawable.recyclerview_size_button_bg_material) candidateTabLayout.setBackgroundResource(com.kazumaproject.core.R.drawable.keyboard_root_material) - val symbolKeyBg = - customThemeKeyColor ?: Color.WHITE - keyboardSymbolView.setKeyboardTheme( - backgroundColor = manipulateColor(symbolKeyBg, 1.2f), - iconColor = customThemeKeyTextColor ?: Color.BLACK, - selectedIconColor = manipulateColor( - customThemeKeyTextColor ?: Color.BLACK, 0.6f - ), - keyBackgroundColor = symbolKeyBg, - liquidGlassEnable = liquidGlassThemePreference ?: false - ) listOfNotNull(suggestionAdapter, suggestionAdapterFull) .forEach { adapter -> adapter.setCandidateTextColor( @@ -5880,7 +6312,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, } } } - applyCandidateEmptyPopupThemeToAdapters() + applyKeyboardContainerBackgrounds(mainView) + floatingKeyboardBinding?.let(::applyFloatingKeyboardContainerBackgrounds) + applyKeyboardSkinThemeToCandidateAdapters() mainView.root.outlineProvider = ViewOutlineProvider.BACKGROUND mainView.root.clipToOutline = isKeyboardRounded == true applyKeyboardBackgroundIfNeeded(mainView) @@ -8256,11 +8690,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, customSpecialKeyColor = customThemeSpecialKeyColor ?: Color.GRAY, customKeyTextColor = customThemeKeyTextColor ?: Color.BLACK, customSpecialKeyTextColor = customThemeSpecialKeyTextColor ?: Color.BLACK, - liquidGlassEnable = liquidGlassThemePreference ?: false, + liquidGlassEnable = + (liquidGlassThemePreference ?: false) && !isBuiltInKeyboardSkinActive(), customBorderEnable = customKeyBorderEnablePreference ?: false, customBorderColor = customKeyBorderEnableColor ?: Color.BLACK, liquidGlassKeyAlphaEnable = liquidGlassKeyBlurRadiousPreference ?: 255, - borderWidth = customKeyBorderWidth ?: 1 + borderWidth = customKeyBorderWidth ?: 1, + keyboardSkin = keyboardSkinMode ?: "default", + keyboardSkinMotion = keyboardSkinMotionMode ?: "full", ) floatingKeyboardLayoutBinding.keyboardViewFloating.setUseThreeStateKeyboard( tenkeyUseThreeStateKeyboard @@ -8403,11 +8840,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, customSpecialKeyColor = customThemeSpecialKeyColor ?: Color.GRAY, customKeyTextColor = customThemeKeyTextColor ?: Color.BLACK, customSpecialKeyTextColor = customThemeSpecialKeyTextColor ?: Color.BLACK, - liquidGlassEnable = liquidGlassThemePreference ?: false, + liquidGlassEnable = + (liquidGlassThemePreference ?: false) && !isBuiltInKeyboardSkinActive(), customBorderEnable = customKeyBorderEnablePreference ?: false, customBorderColor = customKeyBorderEnableColor ?: Color.BLACK, liquidGlassKeyAlphaEnable = liquidGlassKeyBlurRadiousPreference ?: 255, - borderWidth = customKeyBorderWidth ?: 1 + borderWidth = customKeyBorderWidth ?: 1, + keyboardSkin = keyboardSkinMode ?: "default", + keyboardSkinMotion = keyboardSkinMotionMode ?: "full", ) setUseThreeStateKeyboard(tenkeyUseThreeStateKeyboard) setUseQwertyNumberWhenThreeStateOff(tenkeySwitchNumberToQwertyNumberPreference) @@ -8596,11 +9036,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, customSpecialKeyColor = customThemeSpecialKeyColor ?: Color.GRAY, customKeyTextColor = customThemeKeyTextColor ?: Color.BLACK, customSpecialKeyTextColor = customThemeSpecialKeyTextColor ?: Color.BLACK, - liquidGlassEnable = liquidGlassThemePreference ?: false, + liquidGlassEnable = + (liquidGlassThemePreference ?: false) && !isBuiltInKeyboardSkinActive(), customBorderEnable = customKeyBorderEnablePreference ?: false, customBorderColor = customKeyBorderEnableColor ?: Color.BLACK, liquidGlassKeyAlphaEnable = liquidGlassKeyBlurRadiousPreference ?: 255, - borderWidth = customKeyBorderWidth ?: 1 + borderWidth = customKeyBorderWidth ?: 1, + keyboardSkin = keyboardSkinMode ?: "default", + keyboardSkinMotion = keyboardSkinMotionMode ?: "full", ) setOnFlickListener(object : FlickListener { override fun onFlick(gestureType: GestureType, key: Key, char: Char?) { @@ -11524,11 +11967,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, customSpecialKeyColor = customThemeSpecialKeyColor ?: Color.GRAY, customKeyTextColor = customThemeKeyTextColor ?: Color.BLACK, customSpecialKeyTextColor = customThemeSpecialKeyTextColor ?: Color.BLACK, - liquidGlassEnable = liquidGlassThemePreference ?: false, + liquidGlassEnable = + (liquidGlassThemePreference ?: false) && !isBuiltInKeyboardSkinActive(), customBorderEnable = customKeyBorderEnablePreference ?: false, customBorderColor = customKeyBorderEnableColor ?: Color.BLACK, liquidGlassKeyAlphaEnable = liquidGlassKeyBlurRadiousPreference ?: 255, - borderWidth = customKeyBorderWidth ?: 1 + borderWidth = customKeyBorderWidth ?: 1, + keyboardSkin = keyboardSkinMode ?: "default", + keyboardSkinMotion = keyboardSkinMotionMode ?: "full", ) flickView.setAngleAndRange( @@ -18251,6 +18697,61 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, tab.text = getCandidateTabDisplayName(tabType) mainView.candidateTabLayout.addTab(tab) } + applyKeyboardSkinToCandidateTabs(mainView.candidateTabLayout) + } + + private fun applyKeyboardSkinToCandidateTabs(tabLayout: TabLayout) { + val skin = currentKeyboardSkin() + updateCandidateTabChildSkins(tabLayout, skin) + if (skin.isDefault()) { + val fallbackNormal = getColor(com.kazumaproject.core.R.color.keyboard_icon_color) + val normalColor = if (keyboardThemeMode == "custom") { + customThemeKeyTextColor ?: fallbackNormal + } else { + tabLayout.context.getThemeColorOrFallback( + attrRes = MaterialR.attr.colorOnSurface, + fallbackColor = fallbackNormal, + ) + } + val selectedColor = if (keyboardThemeMode == "custom") { + customThemeSpecialKeyTextColor ?: normalColor + } else { + tabLayout.context.getThemeColorOrFallback( + attrRes = AppCompatR.attr.colorPrimary, + fallbackColor = getColor(com.kazumaproject.core.R.color.enter_key_bg), + ) + } + tabLayout.setTabTextColors(normalColor, selectedColor) + tabLayout.setSelectedTabIndicatorColor(selectedColor) + return + } + val palette = KeyboardSkinCatalog.specFor(skin).palette + tabLayout.setTabTextColors(palette.candidateTextColor, palette.accentColor) + tabLayout.setSelectedTabIndicatorColor(palette.accentColor) + tabLayout.tabRippleColor = null + } + + private fun updateCandidateTabChildSkins( + tabLayout: TabLayout, + skin: KeyboardSkinRef, + ) { + tabLayout.post { + val strip = tabLayout.getChildAt(0) as? ViewGroup ?: return@post + for (index in 0 until strip.childCount) { + val tabView = strip.getChildAt(index) + KeyboardSkinViewStyler.clearTransientStyle(tabView) + tabView.background = null + tabView.backgroundTintList = null + if (!skin.isDefault()) { + KeyboardSkinViewStyler.applyFlatControl( + tabView, + skin, + KeyboardElementRole.TOOLBAR, + tintContent = false, + ) + } + } + } } private fun getCandidateTabDisplayName(candidateTab: CandidateTab): String { @@ -18265,13 +18766,20 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, mainView: MainLayoutBinding ) { mainView.candidateTabLayout.apply { + if (isBuiltInKeyboardSkinActive()) { + val palette = KeyboardSkinCatalog.specFor(currentKeyboardSkin()).palette + setSelectedTabIndicatorColor(palette.accentColor) + setTabTextColors(palette.candidateTextColor, palette.accentColor) + } when (keyboardThemeMode) { "custom" -> { - setSelectedTabIndicatorColor(customThemeSpecialKeyTextColor ?: Color.BLACK) - setTabTextColors( - customThemeKeyTextColor ?: Color.BLACK, - customThemeSpecialKeyTextColor ?: Color.BLACK - ) + if (!isBuiltInKeyboardSkinActive()) { + setSelectedTabIndicatorColor(customThemeSpecialKeyTextColor ?: Color.BLACK) + setTabTextColors( + customThemeKeyTextColor ?: Color.BLACK, + customThemeSpecialKeyTextColor ?: Color.BLACK + ) + } } else -> {} @@ -19786,10 +20294,18 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, LinearLayoutManager(this@IMEService, LinearLayoutManager.HORIZONTAL, false) adapter = shortcutAdapter } + shortcutAdapter?.setKeyboardSkin( + skinValue = currentKeyboardSkin().preferenceValue, + motionValue = currentKeyboardSkinMotion().preferenceValue, + ) when (keyboardThemeMode) { "custom" -> { - shortcutAdapter?.setIconColor(customThemeShortcutIconColor ?: Color.BLACK) - suggestionAdapter?.setShortcutIconColor(customThemeShortcutIconColor ?: Color.BLACK) + if (!isBuiltInKeyboardSkinActive()) { + shortcutAdapter?.setIconColor(customThemeShortcutIconColor ?: Color.BLACK) + suggestionAdapter?.setShortcutIconColor( + customThemeShortcutIconColor ?: Color.BLACK + ) + } } else -> { @@ -19974,11 +20490,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection, customSpecialKeyColor = customThemeSpecialKeyColor ?: Color.GRAY, customKeyTextColor = customThemeKeyTextColor ?: Color.BLACK, customSpecialKeyTextColor = customThemeSpecialKeyTextColor ?: Color.BLACK, - liquidGlassEnable = liquidGlassThemePreference ?: false, + liquidGlassEnable = + (liquidGlassThemePreference ?: false) && !isBuiltInKeyboardSkinActive(), customBorderEnable = customKeyBorderEnablePreference ?: false, customBorderColor = customKeyBorderEnableColor ?: Color.BLACK, liquidGlassKeyAlphaEnable = liquidGlassKeyBlurRadiousPreference ?: 255, - borderWidth = customKeyBorderWidth ?: 1 + borderWidth = customKeyBorderWidth ?: 1, + keyboardSkin = keyboardSkinMode ?: "default", + keyboardSkinMotion = keyboardSkinMotionMode ?: "full", ) setFlickSensitivityValue(flickSensitivityPreferenceValue ?: 100) setFlickThresholdShape(flickThresholdShapePreferenceValue) 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 0749c5e51..05b8665a1 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,8 @@ data class ImePreferencesSnapshot( val qwertySpecialKeyTextSize: Float, val qwertySpecialKeyIconSize: Float, val keyboardThemeMode: String, + val keyboardSkin: String, + val keyboardSkinMotion: String, val customThemeBgColor: Int, val customThemeKeyColor: Int, val customThemeSpecialKeyColor: Int, @@ -582,6 +584,8 @@ data class ImePreferencesSnapshot( qwertySpecialKeyTextSize = appPreference.qwerty_special_key_text_size ?: 12.0f, qwertySpecialKeyIconSize = appPreference.qwerty_special_key_icon_size ?: 18.0f, keyboardThemeMode = appPreference.theme_mode, + keyboardSkin = appPreference.keyboard_skin, + keyboardSkinMotion = appPreference.keyboard_skin_motion, customThemeBgColor = appPreference.custom_theme_bg_color, customThemeKeyColor = appPreference.custom_theme_key_color, customThemeSpecialKeyColor = appPreference.custom_theme_special_key_color, 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 4c624203d..4f6ceda96 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,13 @@ import android.widget.ImageView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import com.kazumaproject.core.data.keyboard.KeyboardElementRole +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.isDefault +import com.kazumaproject.core.data.keyboard.resolvedOrDefault +import com.kazumaproject.core.data.keyboard.KeyboardSkinMotionMode +import com.kazumaproject.core.data.keyboard.KeyboardSkinViewStyler import com.kazumaproject.core.domain.extensions.dpToPx import com.kazumaproject.markdownhelperkeyboard.R import com.kazumaproject.markdownhelperkeyboard.short_cut.ShortcutType @@ -35,6 +42,8 @@ class ShortcutAdapter : ListAdapter(Di private var activeShortcutTypes: Set = emptySet() private var toolbarHeightPx: Int = 0 private var iconSizePx: Int = 0 + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var keyboardSkinMotionMode: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL /** * ViewHolder now captures clicks and calls the adapter's listener. @@ -64,6 +73,17 @@ class ShortcutAdapter : ListAdapter(Di applyShortcutToolbarSize(holder) holder.imageView.setImageResource(item.resolveIconResId()) // Enumからアイコン取得 + if (!keyboardSkinId.isDefault()) { + KeyboardSkinViewStyler.applyFlatControl( + holder.itemView, + keyboardSkinId, + KeyboardElementRole.TOOLBAR, + ) + return + } + KeyboardSkinViewStyler.clearTransientStyle(holder.itemView) + holder.itemView.background = null + // ★追加: 色が設定されていれば適用し、なければ解除する iconColorState.iconColor?.let { color -> holder.imageView.setColorFilter(color, PorterDuff.Mode.SRC_IN) @@ -95,6 +115,14 @@ class ShortcutAdapter : ListAdapter(Di notifyItemRangeChanged(0, itemCount) } + fun setKeyboardSkin(skinValue: String?, motionValue: String?) { + val nextSkin = KeyboardSkinRef.fromPreference(skinValue).resolvedOrDefault() + val nextMotion = KeyboardSkinMotionMode.fromPreference(motionValue) + keyboardSkinId = nextSkin + keyboardSkinMotionMode = nextMotion + notifyItemRangeChanged(0, itemCount) + } + fun setActiveShortcutTypes(activeTypes: Set) { if (activeShortcutTypes == activeTypes) return val oldActive = activeShortcutTypes diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt index 2e8909519..5cb1f8e29 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt @@ -5,6 +5,7 @@ import android.graphics.Color import android.graphics.PorterDuff import android.graphics.drawable.GradientDrawable import android.graphics.drawable.StateListDrawable +import android.graphics.Typeface import android.text.SpannableString import android.text.Spanned import android.text.style.RelativeSizeSpan @@ -12,6 +13,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView +import android.widget.TextView import androidx.appcompat.widget.AppCompatImageButton import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat @@ -23,6 +25,14 @@ import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView import com.google.android.material.color.DynamicColors import com.google.android.material.textview.MaterialTextView +import com.kazumaproject.core.data.keyboard.KeyboardElementRole +import com.kazumaproject.core.data.keyboard.KeyboardSkinCatalog +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.isDefault +import com.kazumaproject.core.data.keyboard.resolvedOrDefault +import com.kazumaproject.core.data.keyboard.KeyboardSkinMotionMode +import com.kazumaproject.core.data.keyboard.KeyboardSkinViewStyler import com.kazumaproject.core.domain.extensions.isAllFullWidthNumericSymbol import com.kazumaproject.core.domain.extensions.isAllHalfWidthNumericSymbol import com.kazumaproject.core.domain.extensions.isDarkThemeOn @@ -83,6 +93,13 @@ internal class CandidateItemColorState { this.pressedBackgroundColor = pressedBackgroundColor return true } + + fun clear(): Boolean { + if (backgroundColor == null && pressedBackgroundColor == null) return false + backgroundColor = null + pressedBackgroundColor = null + return true + } } internal data class CandidateYomiPresentation( @@ -278,6 +295,8 @@ class SuggestionAdapter internal constructor( private var candidateTextSize: Float = 14f private var candidateTextColor: Int? = null + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var keyboardSkinMotionMode: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL private var showCandidateYomiForLiveConversion: Boolean = false private var showDictionaryCandidateLabels: Boolean = false private val candidateItemColorState = CandidateItemColorState() @@ -1370,10 +1389,11 @@ class SuggestionAdapter internal constructor( holder.imageView.apply { setImageResource(shortcutType.resolveShortcutIconResId()) contentDescription = shortcutType.description - shortcutIconColor?.let { color -> - setColorFilter(color, PorterDuff.Mode.SRC_IN) - } ?: clearColorFilter() } + applyShortcutSkin( + itemView = holder.itemView, + imageView = holder.imageView, + ) holder.itemView.contentDescription = shortcutType.description holder.itemView.setOnClickListener { val adapterPosition = holder.bindingAdapterPosition @@ -1392,10 +1412,11 @@ class SuggestionAdapter internal constructor( holder.imageView.apply { setImageResource(R.drawable.more_horiz_24px) contentDescription = context.getString(R.string.shortcut_entry_content_description) - shortcutIconColor?.let { color -> - setColorFilter(color, PorterDuff.Mode.SRC_IN) - } ?: clearColorFilter() } + applyShortcutSkin( + itemView = holder.itemView, + imageView = holder.imageView, + ) holder.itemView.contentDescription = holder.itemView.context.getString(R.string.shortcut_entry_content_description) holder.itemView.setOnClickListener { @@ -1403,6 +1424,23 @@ class SuggestionAdapter internal constructor( } } + private fun applyShortcutSkin(itemView: View, imageView: ImageView) { + if (!keyboardSkinId.isDefault()) { + KeyboardSkinViewStyler.applyFlatControl( + itemView, + keyboardSkinId, + KeyboardElementRole.TOOLBAR, + ) + return + } + + KeyboardSkinViewStyler.clearTransientStyle(itemView) + itemView.background = null + shortcutIconColor?.let { color -> + imageView.setColorFilter(color, PorterDuff.Mode.SRC_IN) + } ?: imageView.clearColorFilter() + } + private fun ShortcutType.resolveShortcutIconResId(): Int { return if (this in activeShortcutTypes) { activeIconResId ?: iconResId @@ -1445,6 +1483,21 @@ class SuggestionAdapter internal constructor( notifyItemRangeChanged(0, itemCount) } + fun setKeyboardSkin(skinValue: String?, motionValue: String?) { + val nextSkin = KeyboardSkinRef.fromPreference(skinValue).resolvedOrDefault() + val nextMotion = KeyboardSkinMotionMode.fromPreference(motionValue) + keyboardSkinId = nextSkin + keyboardSkinMotionMode = nextMotion + notifyItemRangeChanged(0, itemCount) + } + + fun clearCandidateAppearanceColors() { + val itemColorsChanged = candidateItemColorState.clear() + val changed = candidateTextColor != null || itemColorsChanged + candidateTextColor = null + if (changed) notifyItemRangeChanged(0, itemCount) + } + fun setCandidateItemBackgroundColor(color: Int) { if (!candidateItemColorState.setBackgroundColor(color)) return notifyItemRangeChanged(0, itemCount) @@ -1744,6 +1797,19 @@ class SuggestionAdapter internal constructor( } private fun applyCandidateItemBackground(itemView: View) { + if (!keyboardSkinId.isDefault()) { + KeyboardSkinViewStyler.applyKey( + itemView, + keyboardSkinId, + KeyboardElementRole.CANDIDATE, + keyboardSkinMotionMode, + stableKey = itemView.id, + ) + applyBuiltInCandidateTypography(itemView) + return + } + KeyboardSkinViewStyler.clearTransientStyle(itemView) + clearBuiltInCandidateTypography(itemView) val backgroundColor = candidateItemColorState.backgroundColor val pressedColor = candidateItemColorState.pressedBackgroundColor if (backgroundColor == null && pressedColor == null) { @@ -1772,6 +1838,43 @@ class SuggestionAdapter internal constructor( } } + private fun applyBuiltInCandidateTypography(view: View) { + val spec = KeyboardSkinCatalog.specFor(keyboardSkinId) + when (view) { + is TextView -> { + view.setTextColor(spec.palette.candidateTextColor) + view.typeface = Typeface.create( + spec.typography.familyName, + if (spec.typography.bold) Typeface.BOLD else Typeface.NORMAL, + ) + view.letterSpacing = spec.typography.letterSpacing + } + + is ViewGroup -> for (index in 0 until view.childCount) { + applyBuiltInCandidateTypography(view.getChildAt(index)) + } + } + } + + private fun clearBuiltInCandidateTypography(view: View) { + when (view) { + is TextView -> { + view.typeface = Typeface.DEFAULT + view.letterSpacing = 0f + view.setTextColor( + ContextCompat.getColor( + view.context, + com.kazumaproject.core.R.color.keyboard_icon_color, + ) + ) + } + + is ViewGroup -> for (index in 0 until view.childCount) { + clearBuiltInCandidateTypography(view.getChildAt(index)) + } + } + } + private fun defaultCandidateItemBackgroundRes(): Int { return if (DynamicColors.isDynamicColorAvailable()) { com.kazumaproject.core.R.drawable.recyclerview_item_bg_material 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 ad0447e68..ef284736b 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 @@ -87,6 +87,9 @@ object AppPreference { const val VIBRATION_TIMING_KEY = "vibration_timing" const val KEY_SOUND_KEY = "key_sound_preference" const val KEY_SOUND_VOLUME_PERCENT_KEY = "key_sound_volume_percent_preference" + const val KEYBOARD_SKIN_KEY = "keyboard_skin_preference" + const val KEYBOARD_SKIN_MOTION_KEY = "keyboard_skin_motion_preference" + const val KEYBOARD_SKIN_REVISION_KEY = "keyboard_skin_revision" const val ALLOW_FULLSCREEN_MODE_KEY = "allow_fullscreen_mode_preference" private const val MIN_CANDIDATE_VISIBLE_HEIGHT_DP = 30 private const val MAX_CANDIDATE_VISIBLE_HEIGHT_DP = 300 @@ -439,6 +442,10 @@ object AppPreference { private val KEYBOARD_THEME_MODE = Pair("keyboard_theme_mode_preference", "default") // default, light, dark, custom + private val KEYBOARD_SKIN = + Pair(KEYBOARD_SKIN_KEY, "default") + private val KEYBOARD_SKIN_MOTION = + Pair(KEYBOARD_SKIN_MOTION_KEY, "full") private val CUSTOM_THEME_BG_COLOR = Pair("custom_theme_bg_color_preference", Color.WHITE) private val CUSTOM_THEME_KEY_COLOR = Pair("custom_theme_key_color_preference", Color.LTGRAY) private val CUSTOM_THEME_SPECIAL_KEY_COLOR = @@ -2675,6 +2682,28 @@ object AppPreference { it.putString(KEYBOARD_THEME_MODE.first, value) } + var keyboard_skin: String + get() = preferences.getString(KEYBOARD_SKIN.first, KEYBOARD_SKIN.second) + ?: KEYBOARD_SKIN.second + set(value) = preferences.edit { + it.putString(KEYBOARD_SKIN.first, value) + } + + var keyboard_skin_motion: String + get() = preferences.getString( + KEYBOARD_SKIN_MOTION.first, + KEYBOARD_SKIN_MOTION.second, + ) ?: KEYBOARD_SKIN_MOTION.second + set(value) = preferences.edit { + it.putString(KEYBOARD_SKIN_MOTION.first, value) + } + + var keyboard_skin_revision: Long + get() = preferences.getLong(KEYBOARD_SKIN_REVISION_KEY, 0L) + set(value) = preferences.edit { + it.putLong(KEYBOARD_SKIN_REVISION_KEY, value) + } + var custom_theme_bg_color: Int get() = readIntPreference(CUSTOM_THEME_BG_COLOR.first, CUSTOM_THEME_BG_COLOR.second) set(value) = preferences.edit { diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateHeightPreviewUtils.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateHeightPreviewUtils.kt index 55c56ee4e..9ce39686e 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateHeightPreviewUtils.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/CandidateHeightPreviewUtils.kt @@ -468,7 +468,9 @@ private fun configureTenKeyPreview( customBorderEnable = appPreference.custom_theme_border_enable, customBorderColor = appPreference.custom_theme_border_color, liquidGlassKeyAlphaEnable = appPreference.liquid_glass_key_alpha, - borderWidth = appPreference.custom_theme_border_width + borderWidth = appPreference.custom_theme_border_width, + keyboardSkin = appPreference.keyboard_skin, + keyboardSkinMotion = appPreference.keyboard_skin_motion, ) tenKey.setFlickSensitivityValue(appPreference.flick_sensitivity_preference ?: 100) tenKey.setFlickThresholdShape( @@ -528,7 +530,9 @@ private fun configureQwertyPreview( customBorderEnable = appPreference.custom_theme_border_enable, customBorderColor = appPreference.custom_theme_border_color, liquidGlassKeyAlphaEnable = appPreference.liquid_glass_key_alpha, - borderWidth = appPreference.custom_theme_border_width + borderWidth = appPreference.custom_theme_border_width, + keyboardSkin = appPreference.keyboard_skin, + keyboardSkinMotion = appPreference.keyboard_skin_motion, ) qwertyView.setLongPressTimeout((appPreference.long_press_timeout_preference ?: 300).toLong()) qwertyView.applyPopupViewStyleSet( @@ -608,7 +612,9 @@ private fun configureFlickKeyboardPreview( customBorderEnable = appPreference.custom_theme_border_enable, customBorderColor = appPreference.custom_theme_border_color, liquidGlassKeyAlphaEnable = appPreference.liquid_glass_key_alpha, - borderWidth = appPreference.custom_theme_border_width + borderWidth = appPreference.custom_theme_border_width, + keyboardSkin = appPreference.keyboard_skin, + keyboardSkinMotion = appPreference.keyboard_skin_motion, ) flickView.setAngleAndRange( appPreference.getCircularFlickRanges(), diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/keyboard_theme/KeyboardSkinPickerFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/keyboard_theme/KeyboardSkinPickerFragment.kt new file mode 100644 index 000000000..f761cb420 --- /dev/null +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/keyboard_theme/KeyboardSkinPickerFragment.kt @@ -0,0 +1,399 @@ +package com.kazumaproject.markdownhelperkeyboard.setting_activity.ui.keyboard_theme + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.google.android.material.button.MaterialButtonToggleGroup +import com.google.android.material.card.MaterialCardView +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.kazumaproject.core.data.keyboard.ImportedKeyboardSkinDefinition +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinJsonParser +import com.kazumaproject.core.data.keyboard.KeyboardSkinMotionMode +import com.kazumaproject.core.data.keyboard.KeyboardSkinParseResult +import com.kazumaproject.core.data.keyboard.KeyboardSkinPreviewView +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinRuntime +import com.kazumaproject.core.data.keyboard.KeyboardSkinStore +import com.kazumaproject.core.data.keyboard.KeyboardSkinValidationError +import com.kazumaproject.core.data.keyboard.StoreWriteResult +import com.kazumaproject.markdownhelperkeyboard.R +import com.kazumaproject.markdownhelperkeyboard.setting_activity.AppPreference +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import javax.inject.Inject + +@AndroidEntryPoint +class KeyboardSkinPickerFragment : Fragment(R.layout.fragment_keyboard_skin_picker) { + + @Inject + lateinit var appPreference: AppPreference + + private lateinit var adapter: SkinAdapter + private lateinit var store: KeyboardSkinStore + + private val openSkinDocument = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) importUri(uri) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + store = KeyboardSkinStore.fromContext(requireContext().applicationContext) + val selectedSkin = KeyboardSkinRef.fromPreference(appPreference.keyboard_skin) + val motionMode = KeyboardSkinMotionMode.fromPreference(appPreference.keyboard_skin_motion) + adapter = SkinAdapter(selectedSkin, motionMode) { skin -> + appPreference.keyboard_skin = skin.preferenceValue + } + view.findViewById(R.id.keyboard_skin_import_button).setOnClickListener { + openSkinDocument.launch(arrayOf("application/json", "text/json", "text/plain")) + } + view.findViewById(R.id.keyboard_skin_grid).apply { + layoutManager = GridLayoutManager(requireContext(), columnCount()) + adapter = this@KeyboardSkinPickerFragment.adapter + itemAnimator = null + setHasFixedSize(true) + } + val motionGroup = view.findViewById(R.id.keyboard_skin_motion_group) + motionGroup.check(buttonIdFor(motionMode)) + motionGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (!isChecked) return@addOnButtonCheckedListener + val selectedMotion = motionForButtonId(checkedId) + appPreference.keyboard_skin_motion = selectedMotion.preferenceValue + adapter.updateMotion(selectedMotion) + } + loadImportedSkins() + } + + private fun loadImportedSkins() { + val appContext = requireContext().applicationContext + viewLifecycleOwner.lifecycleScope.launch { + val imported = withContext(Dispatchers.IO) { + val stored = KeyboardSkinStore.fromContext(appContext).list() + KeyboardSkinRuntime.replace(stored.map { it.definition }) + stored.map { it.definition } + } + adapter.setImported(imported) + } + } + + private fun importUri(uri: Uri) { + val appContext = requireContext().applicationContext + viewLifecycleOwner.lifecycleScope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + val bytes = appContext.contentResolver.openInputStream(uri)?.use(::readLimited) + ?: return@runCatching KeyboardSkinParseResult.Failure( + listOf(KeyboardSkinValidationError("$", "ファイルを開けません")), + ) + KeyboardSkinJsonParser.parse(bytes) + }.getOrElse { + KeyboardSkinParseResult.Failure( + listOf(KeyboardSkinValidationError("$", it.message ?: "ファイルを読み込めません")), + ) + } + } + val success = result as? KeyboardSkinParseResult.Success + if (success == null) { + showValidationError((result as KeyboardSkinParseResult.Failure).errors) + return@launch + } + val duplicate = withContext(Dispatchers.IO) { + store.fileFor(success.definition.id).exists() + } + if (duplicate || success.definition.warnings.isNotEmpty()) { + showImportConfirmation(success.definition, duplicate) + } else { + saveImported(success.definition, replace = false) + } + } + } + + private fun showImportConfirmation(definition: ImportedKeyboardSkinDefinition, duplicate: Boolean) { + val warningText = definition.warnings.takeIf { it.isNotEmpty() }?.let { warnings -> + "コントラスト警告:\n" + warnings.joinToString("\n") { "・${it.path}: ${it.message}" } + } + val message = buildList { + add(definition.name) + if (duplicate) add("同じID(${definition.id})が存在します。承認するとアプリ内のコピーを更新します。") + if (warningText != null) add(warningText) + }.joinToString("\n\n") + MaterialAlertDialogBuilder(requireContext()) + .setTitle(if (duplicate) R.string.keyboard_skin_update_confirm_title else R.string.keyboard_skin_warning_confirm_title) + .setMessage(message) + .setNegativeButton(R.string.keyboard_skin_cancel, null) + .setPositiveButton(if (duplicate) R.string.keyboard_skin_update else R.string.keyboard_skin_import) { _, _ -> + saveImported(definition, replace = duplicate) + } + .show() + } + + private fun saveImported(definition: ImportedKeyboardSkinDefinition, replace: Boolean) { + val appContext = requireContext().applicationContext + viewLifecycleOwner.lifecycleScope.launch { + val outcome = withContext(Dispatchers.IO) { + val result = store.save(definition, replace) + if (result is StoreWriteResult.Saved) { + KeyboardSkinRuntime.reloadFromDisk(appContext) + } + result + } + when (outcome) { + is StoreWriteResult.Saved -> { + appPreference.keyboard_skin = definition.reference.preferenceValue + adapter.setImported(KeyboardSkinRuntime.all()) + } + is StoreWriteResult.Duplicate -> showValidationError( + listOf(KeyboardSkinValidationError("id", "同じIDが存在します。更新確認が必要です")), + ) + is StoreWriteResult.Failure -> showValidationError( + listOf(KeyboardSkinValidationError("$", "保存できません: ${outcome.error.message ?: "I/Oエラー"}")), + ) + } + } + } + + private fun confirmDelete(definition: ImportedKeyboardSkinDefinition) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.keyboard_skin_delete_confirm_title) + .setMessage(getString(R.string.keyboard_skin_delete_confirm_message, definition.name, definition.id)) + .setNegativeButton(R.string.keyboard_skin_cancel, null) + .setPositiveButton(R.string.keyboard_skin_delete) { _, _ -> deleteImported(definition) } + .show() + } + + private fun deleteImported(definition: ImportedKeyboardSkinDefinition) { + val appContext = requireContext().applicationContext + viewLifecycleOwner.lifecycleScope.launch { + val deleted = withContext(Dispatchers.IO) { + val deleted = store.delete(definition.id) + if (deleted) { + KeyboardSkinRuntime.reloadFromDisk(appContext) + } + deleted + } + if (!deleted) return@launch + if (appPreference.keyboard_skin == definition.reference.preferenceValue) { + appPreference.keyboard_skin = KeyboardSkinId.DEFAULT.preferenceValue + adapter.selectWithoutCallback(KeyboardSkinRef.DEFAULT) + } + adapter.setImported(KeyboardSkinRuntime.all()) + } + } + + private fun showValidationError(errors: List) { + val message = errors.joinToString("\n") { it.toString() } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.keyboard_skin_import_error) + .setMessage(message) + .setNegativeButton(R.string.keyboard_skin_close, null) + .setPositiveButton(R.string.keyboard_skin_copy_error) { _, _ -> + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("keyboard skin error", message)) + } + .show() + } + + private fun readLimited(input: java.io.InputStream): ByteArray { + val output = ByteArrayOutputStream() + val buffer = ByteArray(8192) + var total = 0 + while (true) { + val count = input.read(buffer) + if (count < 0) break + total += count + if (total > KeyboardSkinJsonParser.MAX_UTF8_BYTES) { + return ByteArray(KeyboardSkinJsonParser.MAX_UTF8_BYTES + 1) + } + output.write(buffer, 0, count) + } + return output.toByteArray() + } + + private fun buttonIdFor(mode: KeyboardSkinMotionMode): Int = when (mode) { + KeyboardSkinMotionMode.FULL -> R.id.keyboard_skin_motion_full + KeyboardSkinMotionMode.REDUCED -> R.id.keyboard_skin_motion_reduced + KeyboardSkinMotionMode.OFF -> R.id.keyboard_skin_motion_off + } + + private fun motionForButtonId(buttonId: Int): KeyboardSkinMotionMode = when (buttonId) { + R.id.keyboard_skin_motion_reduced -> KeyboardSkinMotionMode.REDUCED + R.id.keyboard_skin_motion_off -> KeyboardSkinMotionMode.OFF + else -> KeyboardSkinMotionMode.FULL + } + + private sealed interface SkinCardItem { + val ref: KeyboardSkinRef + + data class BuiltIn(val id: KeyboardSkinId) : SkinCardItem { + override val ref: KeyboardSkinRef = KeyboardSkinRef.BuiltIn(id) + } + + data class Imported(val definition: ImportedKeyboardSkinDefinition) : SkinCardItem { + override val ref: KeyboardSkinRef.Imported = definition.reference + } + } + + private inner class SkinAdapter( + private var selectedSkin: KeyboardSkinRef, + private var motionMode: KeyboardSkinMotionMode, + private val onSelected: (KeyboardSkinRef) -> Unit, + ) : RecyclerView.Adapter() { + private var imported: List = emptyList() + private val builtIns = KeyboardSkinId.entries.toList() + private val items: List + get() = imported.map { SkinCardItem.Imported(it) } + builtIns.map { SkinCardItem.BuiltIn(it) } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + SkinViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_keyboard_skin, parent, false)) + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder as SkinViewHolder).bind(items[position]) + } + + override fun getItemCount(): Int = items.size + + fun setImported(value: List) { + imported = value.sortedWith(compareBy { it.name.lowercase() }.thenBy { it.id }) + notifyDataSetChanged() + } + + fun updateMotion(value: KeyboardSkinMotionMode) { + if (motionMode == value) return + motionMode = value + notifyItemRangeChanged(0, itemCount) + } + + fun selectWithoutCallback(ref: KeyboardSkinRef) { + selectedSkin = ref + notifyDataSetChanged() + } + + private fun select(ref: KeyboardSkinRef) { + if (ref == selectedSkin) return + selectedSkin = ref + onSelected(ref) + notifyDataSetChanged() + } + + inner class SkinViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val card = itemView.findViewById(R.id.keyboard_skin_card) + private val preview = itemView.findViewById(R.id.keyboard_skin_preview) + private val name = itemView.findViewById(R.id.keyboard_skin_name) + private val material = itemView.findViewById(R.id.keyboard_skin_material) + private val badge = itemView.findViewById(R.id.keyboard_skin_selected_badge) + private val menu = itemView.findViewById(R.id.keyboard_skin_menu) + + fun bind(item: SkinCardItem) { + val isSelected = item.ref == selectedSkin + val displayName: String + val subtitle: String + val definition: ImportedKeyboardSkinDefinition? + when (item) { + is SkinCardItem.BuiltIn -> { + displayName = getString(nameResource(item.id)) + subtitle = getString(materialResource(item.id)) + definition = null + } + is SkinCardItem.Imported -> { + displayName = item.definition.name + subtitle = item.definition.author ?: getString(R.string.keyboard_skin_imported) + definition = item.definition + } + } + name.text = displayName + material.text = subtitle + badge.isVisible = isSelected + menu.isVisible = definition != null + preview.setSkin(item.ref, if (isSelected) motionMode else KeyboardSkinMotionMode.OFF) + val selectedColor = MaterialColors.getColor(card, androidx.appcompat.R.attr.colorPrimary) + val outlineColor = MaterialColors.getColor(card, com.google.android.material.R.attr.colorOutline) + card.strokeColor = if (isSelected) selectedColor else outlineColor + card.strokeWidth = resources.displayMetrics.density.times(if (isSelected) 3f else 1f).toInt().coerceAtLeast(1) + card.contentDescription = getString( + R.string.keyboard_skin_accessibility_description, + displayName, + subtitle, + if (isSelected) getString(R.string.keyboard_skin_selected) else "", + ) + card.tag = item.ref.preferenceValue + card.setOnClickListener { select(item.ref) } + menu.setOnClickListener { anchor -> + val importedDefinition = definition ?: return@setOnClickListener + androidx.appcompat.widget.PopupMenu(requireContext(), anchor).apply { + menu.add(R.string.keyboard_skin_delete) + setOnMenuItemClickListener { + confirmDelete(importedDefinition) + true + } + }.show() + } + } + } + } + + private fun nameResource(skin: KeyboardSkinId): Int = when (skin) { + KeyboardSkinId.DEFAULT -> R.string.keyboard_skin_default + KeyboardSkinId.FLAT -> R.string.keyboard_skin_flat + KeyboardSkinId.GLASS -> R.string.keyboard_skin_glass + KeyboardSkinId.NEUMORPHISM -> R.string.keyboard_skin_neumorphism + KeyboardSkinId.MECHANICAL -> R.string.keyboard_skin_mechanical + KeyboardSkinId.WASHI -> R.string.keyboard_skin_washi + KeyboardSkinId.NEON -> R.string.keyboard_skin_neon + KeyboardSkinId.TERMINAL -> R.string.keyboard_skin_terminal + KeyboardSkinId.CUPERTINO -> R.string.keyboard_skin_cupertino + KeyboardSkinId.CUPERTINO_DARK -> R.string.keyboard_skin_cupertino_dark + KeyboardSkinId.SUMI_HANSHI -> R.string.keyboard_skin_sumi_hanshi + KeyboardSkinId.LETTERPRESS -> R.string.keyboard_skin_letterpress + KeyboardSkinId.PORCELAIN -> R.string.keyboard_skin_porcelain + KeyboardSkinId.URUSHI -> R.string.keyboard_skin_urushi + KeyboardSkinId.CHALKBOARD -> R.string.keyboard_skin_chalkboard + KeyboardSkinId.LINEN -> R.string.keyboard_skin_linen + KeyboardSkinId.MONOCHROME_LCD -> R.string.keyboard_skin_monochrome_lcd + } + + private fun materialResource(skin: KeyboardSkinId): Int = when (skin) { + KeyboardSkinId.DEFAULT -> R.string.keyboard_skin_material_default + KeyboardSkinId.FLAT -> R.string.keyboard_skin_material_flat + KeyboardSkinId.GLASS -> R.string.keyboard_skin_material_glass + KeyboardSkinId.NEUMORPHISM -> R.string.keyboard_skin_material_neumorphism + KeyboardSkinId.MECHANICAL -> R.string.keyboard_skin_material_mechanical + KeyboardSkinId.WASHI -> R.string.keyboard_skin_material_washi + KeyboardSkinId.NEON -> R.string.keyboard_skin_material_neon + KeyboardSkinId.TERMINAL -> R.string.keyboard_skin_material_terminal + KeyboardSkinId.CUPERTINO -> R.string.keyboard_skin_material_cupertino + KeyboardSkinId.CUPERTINO_DARK -> R.string.keyboard_skin_material_cupertino_dark + KeyboardSkinId.SUMI_HANSHI -> R.string.keyboard_skin_material_sumi_hanshi + KeyboardSkinId.LETTERPRESS -> R.string.keyboard_skin_material_letterpress + KeyboardSkinId.PORCELAIN -> R.string.keyboard_skin_material_porcelain + KeyboardSkinId.URUSHI -> R.string.keyboard_skin_material_urushi + KeyboardSkinId.CHALKBOARD -> R.string.keyboard_skin_material_chalkboard + KeyboardSkinId.LINEN -> R.string.keyboard_skin_material_linen + KeyboardSkinId.MONOCHROME_LCD -> R.string.keyboard_skin_material_monochrome_lcd + } + + private fun columnCount(): Int = if (resources.configuration.screenWidthDp >= TABLET_MIN_WIDTH_DP) TABLET_COLUMN_COUNT else PHONE_COLUMN_COUNT + + companion object { + private const val PHONE_COLUMN_COUNT = 2 + private const val TABLET_COLUMN_COUNT = 3 + private const val TABLET_MIN_WIDTH_DP = 600 + } +} diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/keyboard_theme/KeyboardThemeFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/keyboard_theme/KeyboardThemeFragment.kt index 3c910113f..6748f115f 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/keyboard_theme/KeyboardThemeFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/keyboard_theme/KeyboardThemeFragment.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.view.View import androidx.core.content.ContextCompat import androidx.core.graphics.toColorInt +import androidx.navigation.fragment.findNavController import androidx.preference.CheckBoxPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory @@ -16,6 +17,11 @@ import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.color.colorChooser import com.google.android.material.color.DynamicColors import com.kazumaproject.markdownhelperkeyboard.R +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinRuntime +import com.kazumaproject.core.data.keyboard.resolvedOrDefault +import com.kazumaproject.core.data.keyboard.isDefault import com.kazumaproject.markdownhelperkeyboard.setting_activity.AppPreference import com.kazumaproject.markdownhelperkeyboard.setting_activity.ui.setting.CommonPreferenceFragment import dagger.hilt.android.AndroidEntryPoint @@ -30,6 +36,7 @@ class KeyboardThemeFragment : PreferenceFragmentCompat() { companion object { // System Theme Keys private const val PREF_KEY_DEFAULT = "theme_default" + private const val PREF_KEY_SKIN = "keyboard_skin_preference" private const val PREF_KEY_ROUND_CORNER = "round_corner_keyboard_preference" private const val PREF_KEY_POPUP_USE_CUSTOM_COLOR = "key_popup_use_custom_color_preference" @@ -82,6 +89,8 @@ class KeyboardThemeFragment : PreferenceFragmentCompat() { } private var pendingHighlightPreferenceKey: String? = null + private var skinPreference: Preference? = null + private var skinOverriddenCategories: List = emptyList() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { val context = preferenceManager.context @@ -89,6 +98,27 @@ class KeyboardThemeFragment : PreferenceFragmentCompat() { pendingHighlightPreferenceKey = arguments?.getString(CommonPreferenceFragment.ARG_HIGHLIGHT_PREFERENCE_KEY) + val skinCategory = PreferenceCategory(context).apply { + title = getString(R.string.keyboard_skin_category) + } + screen.addPreference(skinCategory) + + val skinPreference = Preference(context).apply { + key = PREF_KEY_SKIN + title = getString(R.string.keyboard_skin_title) + isPersistent = false + onPreferenceClickListener = Preference.OnPreferenceClickListener { + // This fragment is hosted inside SettingMainFragment's ViewPager, so the + // NavController's current destination is SettingMainFragment rather than + // keyboardThemeFragment. Navigate to the destination directly instead of + // using an action that only belongs to keyboardThemeFragment. + findNavController().navigate(R.id.keyboardSkinPickerFragment) + true + } + } + this.skinPreference = skinPreference + skinCategory.addPreference(skinPreference) + // ------------------------------------------------------- // System Category // ------------------------------------------------------- @@ -424,10 +454,17 @@ class KeyboardThemeFragment : PreferenceFragmentCompat() { } preferenceScreen = screen + skinOverriddenCategories = listOf(systemCategory, customCategory, inputCategory) // Initialize state based on current preference updateCheckStates(appPreference.theme_mode) updateCustomColorsVisibility(appPreference.theme_mode == MODE_CUSTOM) + updateSkinOverrideState() + } + + override fun onResume() { + super.onResume() + updateSkinOverrideState() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -502,6 +539,52 @@ class KeyboardThemeFragment : PreferenceFragmentCompat() { //findPreference(CATEGORY_KEY_CUSTOM_INPUT)?.isVisible = isVisible } + private fun updateSkinOverrideState() { + val skin = KeyboardSkinRef.fromPreference(appPreference.keyboard_skin).resolvedOrDefault() + val skinName = when (skin) { + is KeyboardSkinRef.BuiltIn -> getString(keyboardSkinNameResource(skin.id)) + is KeyboardSkinRef.Imported -> KeyboardSkinRuntime.definitionFor(skin.id)?.name + ?: skin.id + } + skinPreference?.summary = if (skin.isDefault()) { + getString(R.string.keyboard_skin_summary) + } else { + getString( + R.string.keyboard_skin_summary_selected, + skinName, + ) + } + val themeControlsEnabled = skin.isDefault() + skinOverriddenCategories.forEach { category -> + category.isEnabled = themeControlsEnabled + category.summary = if (themeControlsEnabled) { + null + } else { + getString(R.string.keyboard_skin_overrides_theme_summary) + } + } + } + + private fun keyboardSkinNameResource(skin: KeyboardSkinId): Int = when (skin) { + KeyboardSkinId.DEFAULT -> R.string.keyboard_skin_default + KeyboardSkinId.FLAT -> R.string.keyboard_skin_flat + KeyboardSkinId.GLASS -> R.string.keyboard_skin_glass + KeyboardSkinId.NEUMORPHISM -> R.string.keyboard_skin_neumorphism + KeyboardSkinId.MECHANICAL -> R.string.keyboard_skin_mechanical + KeyboardSkinId.WASHI -> R.string.keyboard_skin_washi + KeyboardSkinId.NEON -> R.string.keyboard_skin_neon + KeyboardSkinId.TERMINAL -> R.string.keyboard_skin_terminal + KeyboardSkinId.CUPERTINO -> R.string.keyboard_skin_cupertino + KeyboardSkinId.CUPERTINO_DARK -> R.string.keyboard_skin_cupertino_dark + KeyboardSkinId.SUMI_HANSHI -> R.string.keyboard_skin_sumi_hanshi + KeyboardSkinId.LETTERPRESS -> R.string.keyboard_skin_letterpress + KeyboardSkinId.PORCELAIN -> R.string.keyboard_skin_porcelain + KeyboardSkinId.URUSHI -> R.string.keyboard_skin_urushi + KeyboardSkinId.CHALKBOARD -> R.string.keyboard_skin_chalkboard + KeyboardSkinId.LINEN -> R.string.keyboard_skin_linen + KeyboardSkinId.MONOCHROME_LCD -> R.string.keyboard_skin_monochrome_lcd + } + private fun saveCustomColor(key: String, color: Int) { when (key) { // Base Colors 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 d45b5855d..f947de113 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 @@ -189,6 +189,26 @@ object SettingDestinations { iconRes = CoreR.drawable.table_lamp_24px, ) ) + add( + destination( + key = "keyboard_skin_preference", + title = context.getString(R.string.keyboard_skin_title), + summary = context.getString(R.string.keyboard_skin_summary), + category = SettingCategory.KEYBOARD_DISPLAY, + keywords = listOf( + "skin", + "appearance", + "keyboard", + "texture", + "style", + "スキン", + "見た目", + "キーボード", + ), + destinationId = R.id.keyboardSkinPickerFragment, + iconRes = CoreR.drawable.table_lamp_24px, + ) + ) add( destination( key = "theme_custom_candidate_empty_popup_bg_color", @@ -745,6 +765,7 @@ object SettingDestinations { "setting_management_custom_keyboard" -> R.id.keyboardListFragment "setting_route_legacy_settings" -> R.id.settingMainFragment "setting_route_keyboard_theme" -> R.id.keyboardThemeFragment + "keyboard_skin_preference" -> R.id.keyboardSkinPickerFragment "theme_custom_candidate_empty_popup_bg_color" -> R.id.keyboardThemeFragment "theme_custom_candidate_empty_popup_text_color" -> R.id.keyboardThemeFragment "key_popup_use_custom_color_preference" -> R.id.keyboardThemeFragment diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/sumire_custom_key_setting/FlickKeyboardSizeSettingsFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/sumire_custom_key_setting/FlickKeyboardSizeSettingsFragment.kt index 0497ad3d6..22aedec28 100644 --- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/sumire_custom_key_setting/FlickKeyboardSizeSettingsFragment.kt +++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/sumire_custom_key_setting/FlickKeyboardSizeSettingsFragment.kt @@ -169,7 +169,9 @@ class FlickKeyboardSizeSettingsFragment : Fragment() { customBorderEnable = AppPreference.custom_theme_border_enable, customBorderColor = AppPreference.custom_theme_border_color, liquidGlassKeyAlphaEnable = AppPreference.liquid_glass_key_alpha, - borderWidth = AppPreference.custom_theme_border_width + borderWidth = AppPreference.custom_theme_border_width, + keyboardSkin = AppPreference.keyboard_skin, + keyboardSkinMotion = AppPreference.keyboard_skin_motion, ) keyboardView.applyKeySizing( diff --git a/app/src/main/res/drawable/keyboard_skin_selected_badge.xml b/app/src/main/res/drawable/keyboard_skin_selected_badge.xml new file mode 100644 index 000000000..b48995404 --- /dev/null +++ b/app/src/main/res/drawable/keyboard_skin_selected_badge.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/layout-land/main_layout.xml b/app/src/main/res/layout-land/main_layout.xml index 777266507..11fd3c501 100644 --- a/app/src/main/res/layout-land/main_layout.xml +++ b/app/src/main/res/layout-land/main_layout.xml @@ -15,6 +15,15 @@ android:clickable="false" android:focusable="false"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_keyboard_skin.xml b/app/src/main/res/layout/item_keyboard_skin.xml new file mode 100644 index 000000000..617480db1 --- /dev/null +++ b/app/src/main/res/layout/item_keyboard_skin.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/main_layout.xml b/app/src/main/res/layout/main_layout.xml index a50c44d62..2c45efa61 100644 --- a/app/src/main/res/layout/main_layout.xml +++ b/app/src/main/res/layout/main_layout.xml @@ -15,6 +15,15 @@ android:clickable="false" android:focusable="false"> + + + android:label="@string/keyboardthemefragment"> + + + + + @string/keyboard_skin_default + @string/keyboard_skin_flat + @string/keyboard_skin_glass + @string/keyboard_skin_neumorphism + @string/keyboard_skin_mechanical + @string/keyboard_skin_washi + @string/keyboard_skin_neon + @string/keyboard_skin_terminal + @string/keyboard_skin_cupertino + @string/keyboard_skin_cupertino_dark + @string/keyboard_skin_sumi_hanshi + @string/keyboard_skin_letterpress + @string/keyboard_skin_porcelain + @string/keyboard_skin_urushi + @string/keyboard_skin_chalkboard + @string/keyboard_skin_linen + @string/keyboard_skin_monochrome_lcd + + + + default + flat + glass + neumorphism + mechanical + washi + neon + terminal + cupertino + cupertino_dark + sumi_hanshi + letterpress + porcelain + urushi + chalkboard + linen + monochrome_lcd + + + + @string/keyboard_skin_motion_full + @string/keyboard_skin_motion_reduced + @string/keyboard_skin_motion_off + + + + full + reduced + off + + @string/type_null_input_behavior_default @string/type_null_input_behavior_direct_commit diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d4e7d92e0..511a16299 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -911,6 +911,67 @@ プレビュー キーの設定 システム + キーボードスキン + キーボードスキン + キーの色とは独立して、キーの形状・質感・影・押下表現を変更します。 + デフォルト + フラット + ガラス + ニューモーフィズム + メカニカル + 和紙 + ネオン + ターミナル + クパチーノ L + クパチーノ D + 墨筆(半紙) + 活版印刷 + 染付磁器 + 漆塗り + 黒板チョーク + リネン刺繍 + モノクロ液晶 + キーボードスキン + 各スキンは、配色・キー形状・質感・影・文字・押下表現まで専用設計です。デフォルトは現在のキーボードテーマをそのまま維持します。 + JSONスキンをインポート + インポート済み + インポート済みスキンのメニュー + 削除 + 更新 + 中止 + 閉じる + エラーをコピー + スキンを読み込めません + コントラスト警告を確認 + JSONスキンを更新 + インポート済みスキンを削除 + 「%1$s」(%2$s)を削除します。アプリ内のコピーだけを削除し、取り消せません。元のJSONは削除しません。 + 選択中: %1$s。スキン適用中はテーマ色や他のキーボード装飾を一時停止します。 + デフォルト以外のスキン適用中は一時停止します。現在の設定値は保持されます。 + 既存テーマ + 大胆な色面 + すりガラス + 柔らかな凹凸 + 金属キーキャップ + 紙と墨 + 発光ライン + CRTグリッド + ライトモード + ダークモード + 半紙と墨のにじみ + 厚紙への凹版 + 呉須の釉薬タイル + 黒漆と朱漆 + 石板とチョーク粉 + 縫い付けた麻布 + 反射型ピクセル表示 + モーション + キー押下の動きと背景アニメーションを調整します。「軽減」は移動を止め、状態変化だけを残します。 + フル + 軽減 + オフ + 選択中 + %1$s、%2$s。%3$s デフォルト ライト ダーク diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 1f688d490..384312d91 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -1,5 +1,57 @@ + + @string/keyboard_skin_default + @string/keyboard_skin_flat + @string/keyboard_skin_glass + @string/keyboard_skin_neumorphism + @string/keyboard_skin_mechanical + @string/keyboard_skin_washi + @string/keyboard_skin_neon + @string/keyboard_skin_terminal + @string/keyboard_skin_cupertino + @string/keyboard_skin_cupertino_dark + @string/keyboard_skin_sumi_hanshi + @string/keyboard_skin_letterpress + @string/keyboard_skin_porcelain + @string/keyboard_skin_urushi + @string/keyboard_skin_chalkboard + @string/keyboard_skin_linen + @string/keyboard_skin_monochrome_lcd + + + + default + flat + glass + neumorphism + mechanical + washi + neon + terminal + cupertino + cupertino_dark + sumi_hanshi + letterpress + porcelain + urushi + chalkboard + linen + monochrome_lcd + + + + @string/keyboard_skin_motion_full + @string/keyboard_skin_motion_reduced + @string/keyboard_skin_motion_off + + + + full + reduced + off + + @string/delete_long_press_conversion_behavior_deferred @string/delete_long_press_conversion_behavior_continuous diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 814fdaa95..edcf7577d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1034,6 +1034,67 @@ Preview Key Settings System + Keyboard Skin + Keyboard Skin + Change the keyboard shape, surface, shadows, and press effect independently from colors. + Default + Flat + Glass + Neumorphism + Mechanical + Washi + Neon + Terminal + Cupertino L + Cupertino D + Sumi Hanshi + Letterpress + Sometsuke Porcelain + Urushi Lacquer + Chalkboard + Linen Embroidery + Monochrome LCD + Keyboard Skin + Each skin has its own colors, key shape, surface, shadows, typography, and press response. Default keeps the existing keyboard theme exactly as configured. + Import JSON skin + Imported + Imported skin menu + Delete + Update + Cancel + Close + Copy error + Could not import skin + Review contrast warning + Update JSON skin + Delete imported skin + Delete “%1$s” (%2$s)? Only the in-app copy will be deleted. This cannot be undone; the original JSON is not deleted. + Selected: %1$s. Theme colors and other keyboard decorations are paused while this skin is active. + Paused while a non-default keyboard skin is active. Your settings are preserved. + Existing theme + Bold color blocks + Frosted glass + Soft extrusion + Metal keycaps + Paper & ink + Emissive glow + CRT grid + Light mode + Dark mode + Hanshi paper & bleeding ink + Debossed cotton stock + Cobalt glazed tile + Black and vermilion lacquer + Slate and chalk powder + Stitched linen patches + Reflective pixel display + Motion + Controls key press movement and animated surfaces. Reduced keeps state changes without movement. + Full + Reduced + Off + Selected + %1$s, %2$s. %3$s Default Light Dark diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceKeyboardSkinRuntimeSyncContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceKeyboardSkinRuntimeSyncContractTest.kt new file mode 100644 index 000000000..586edc1a6 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceKeyboardSkinRuntimeSyncContractTest.kt @@ -0,0 +1,95 @@ +package com.kazumaproject.markdownhelperkeyboard.ime_service + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class IMEServiceKeyboardSkinRuntimeSyncContractTest { + + @Test + fun skinChangesRefreshSurvivingCandidateTabs() { + val source = imeServiceSource() + val syncBody = source.substringAfter("private fun syncKeyboardSkinPreferences()") + .substringBefore("private fun syncRuntimeInputPreferences()") + + assertTrue(syncBody.contains("keyboardSkinMode = nextSkin.preferenceValue")) + assertTrue(syncBody.contains("applyKeyboardSkinToCandidateTabs")) + assertTrue(syncBody.contains("applyKeyboardSkinThemeToCandidateAdapters")) + } + + @Test + fun inputViewRecreationReappliesSkinEvenWhenCachedValueAlreadyMatches() { + val source = imeServiceSource() + val syncBody = source.substringAfter("private fun syncKeyboardSkinPreferences()") + .substringBefore("private fun syncRuntimeInputPreferences()") + + assertTrue(syncBody.contains("newly inflated views still need styling")) + assertTrue(!syncBody.contains("if (!changed) return")) + } + + @Test + fun skinChangesAreObservedAndReloadedWhenInputViewReturns() { + val source = imeServiceSource() + val runtimeKeys = source.substringAfter("private val keyboardSkinPreferenceKeys = setOf(") + .substringBefore(")") + val startInputView = source.substringAfter( + "override fun onStartInputView(editorInfo: EditorInfo?, restarting: Boolean)" + ).substringBefore("override fun onFinishInputView") + + assertTrue(runtimeKeys.contains("AppPreference.KEYBOARD_SKIN_KEY")) + assertTrue(runtimeKeys.contains("AppPreference.KEYBOARD_SKIN_MOTION_KEY")) + assertTrue(startInputView.contains("syncKeyboardSkinPreferences()")) + } + + @Test + fun returningToDefaultClearsPreviousTabBackgrounds() { + val source = imeServiceSource() + val childSkinBody = source.substringAfter("private fun updateCandidateTabChildSkins(") + .substringBefore("private fun getCandidateTabDisplayName") + + assertTrue(childSkinBody.contains("KeyboardSkinViewStyler.clearTransientStyle(tabView)")) + assertTrue(childSkinBody.contains("tabView.background = null")) + assertTrue(childSkinBody.contains("if (!skin.isDefault())")) + } + + @Test + fun candidateTabsUseFlatChromeInsteadOfKeycapGeometry() { + val source = imeServiceSource() + val childSkinBody = source.substringAfter("private fun updateCandidateTabChildSkins(") + .substringBefore("private fun getCandidateTabDisplayName") + + assertTrue(childSkinBody.contains("KeyboardSkinViewStyler.applyFlatControl(")) + assertTrue(childSkinBody.contains("tintContent = false")) + assertTrue(!childSkinBody.contains("KeyboardSkinViewStyler.applyKey(")) + } + + @Test + fun shortcutIconsUseFlatChromeInsteadOfKeycapGeometry() { + val shortcutAdapter = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/ShortcutAdapter.kt" + ).readText().substringAfter("override fun onBindViewHolder(") + .substringBefore("fun setShortcutToolbarSize(") + val suggestionAdapter = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt" + ).readText().substringAfter("private fun applyShortcutSkin(") + .substringBefore("private fun ShortcutType.resolveShortcutIconResId") + + listOf(shortcutAdapter, suggestionAdapter).forEach { body -> + assertTrue(body.contains("KeyboardSkinViewStyler.applyFlatControl(")) + assertTrue(!body.contains("KeyboardSkinViewStyler.applyKey(")) + } + } + + private fun imeServiceSource(): String = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt" + ).readText() + + private fun mainFile(relativePath: String): File { + val candidates = listOf( + File("app/src/main/$relativePath"), + File("src/main/$relativePath"), + ) + return candidates.firstOrNull(File::exists) + ?: error("Unable to locate $relativePath") + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshotCustomThemeTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshotCustomThemeTest.kt index 79642dbe3..61e5b4fdc 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshotCustomThemeTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshotCustomThemeTest.kt @@ -17,5 +17,7 @@ class ImePreferencesSnapshotCustomThemeTest { assertTrue(fieldNames.contains("customThemeCandidateEmptyPopupBgColor")) assertTrue(fieldNames.contains("customThemeCandidateEmptyPopupTextColor")) assertTrue(fieldNames.contains("customThemeShortcutIconColor")) + assertTrue(fieldNames.contains("keyboardSkin")) + assertTrue(fieldNames.contains("keyboardSkinMotion")) } } diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterThemeColorTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterThemeColorTest.kt index bec639b6f..47e47e71d 100644 --- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterThemeColorTest.kt +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterThemeColorTest.kt @@ -26,6 +26,17 @@ class SuggestionAdapterThemeColorTest { assertEquals(0x05060708, state.pressedBackgroundColor) } + @Test + fun candidateItemColorsCanBeClearedWhenReturningToDefaultSkin() { + val state = CandidateItemColorState() + state.setColors(0x01020304, 0x05060708) + + state.clear() + + assertEquals(null, state.backgroundColor) + assertEquals(null, state.pressedBackgroundColor) + } + @Test fun candidateEmptyPopupColorsPreferDedicatedCustomColors() { val colors = resolveCandidateEmptyPopupThemeColors( 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 3d25461f4..dfd20a4da 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 @@ -21,8 +21,12 @@ class KeyboardTouchEffectContainerContractTest { 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") + "$path background container should keep only skin and background media", + backgroundChildren == listOf( + "keyboard_skin_backdrop", + "keyboard_background_video", + "keyboard_background_image" + ) ) normalEffectIds.forEach { id -> assertFalse("$path background container must not contain $id", id in backgroundChildren) @@ -38,8 +42,9 @@ class KeyboardTouchEffectContainerContractTest { val touchEffectChildren = childIdsOf(document, "floating_keyboard_touch_effect_container") assertTrue( - "floating background container should keep only background media", + "floating background container should keep only skin and background media", backgroundChildren == listOf( + "floating_keyboard_skin_backdrop", "floating_keyboard_background_video", "floating_keyboard_background_image" ) diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceKeyboardSkinTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceKeyboardSkinTest.kt new file mode 100644 index 000000000..687cb375f --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreferenceKeyboardSkinTest.kt @@ -0,0 +1,48 @@ +package com.kazumaproject.markdownhelperkeyboard.setting_activity + +import android.content.Context +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinMotionMode +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AppPreferenceKeyboardSkinTest { + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + AppPreference.init(context) + } + + @Test + fun keyboardSkinDefaultsToExistingAppearanceWithFullMotion() { + assertEquals(KeyboardSkinId.DEFAULT.preferenceValue, AppPreference.keyboard_skin) + assertEquals( + KeyboardSkinMotionMode.FULL.preferenceValue, + AppPreference.keyboard_skin_motion, + ) + } + + @Test + fun cupertinoDarkAndMotionModePersistIndependentlyFromThemeMode() { + AppPreference.theme_mode = "custom" + AppPreference.keyboard_skin = KeyboardSkinId.CUPERTINO_DARK.preferenceValue + AppPreference.keyboard_skin_motion = KeyboardSkinMotionMode.REDUCED.preferenceValue + + assertEquals("custom", AppPreference.theme_mode) + assertEquals(KeyboardSkinId.CUPERTINO_DARK.preferenceValue, AppPreference.keyboard_skin) + assertEquals( + KeyboardSkinMotionMode.REDUCED.preferenceValue, + AppPreference.keyboard_skin_motion, + ) + } +} diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/keyboard_theme/KeyboardSkinNavigationContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/keyboard_theme/KeyboardSkinNavigationContractTest.kt new file mode 100644 index 000000000..136222161 --- /dev/null +++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/keyboard_theme/KeyboardSkinNavigationContractTest.kt @@ -0,0 +1,33 @@ +package com.kazumaproject.markdownhelperkeyboard.setting_activity.ui.keyboard_theme + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class KeyboardSkinNavigationContractTest { + + @Test + fun embeddedThemeTabNavigatesDirectlyToSkinPickerDestination() { + val source = mainFile( + "java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/keyboard_theme/KeyboardThemeFragment.kt" + ).readText() + + assertTrue(source.contains("navigate(R.id.keyboardSkinPickerFragment)")) + assertFalse( + source.contains("R.id.action_keyboardThemeFragment_to_keyboardSkinPickerFragment") + ) + } + + @Test + fun skinPickerIsRegisteredAsANavigationDestination() { + val graph = mainFile("res/navigation/mobile_navigation.xml").readText() + + assertTrue(graph.contains("android:id=\"@+id/keyboardSkinPickerFragment\"")) + } + + 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/core/build.gradle b/core/build.gradle index 97bb7de98..9aeb2fd54 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -36,8 +36,11 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.core:core-ktx:1.16.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + implementation 'com.google.code.gson:gson:2.13.1' testImplementation 'junit:junit:4.13.2' + testImplementation 'androidx.test:core:1.6.1' + testImplementation 'org.robolectric:robolectric:4.14.1' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' } diff --git a/core/src/main/java/com/kazumaproject/core/data/keyboard/ImportedKeyboardSkin.kt b/core/src/main/java/com/kazumaproject/core/data/keyboard/ImportedKeyboardSkin.kt new file mode 100644 index 000000000..8f3c933c2 --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/data/keyboard/ImportedKeyboardSkin.kt @@ -0,0 +1,201 @@ +package com.kazumaproject.core.data.keyboard + +import androidx.annotation.ColorInt + +/** + * A persisted keyboard skin reference. Built-in preference values are intentionally unchanged; + * imported values are namespaced so a user file can never shadow a built-in skin. + */ +sealed interface KeyboardSkinRef { + val preferenceValue: String + + data class BuiltIn(val id: KeyboardSkinId) : KeyboardSkinRef { + override val preferenceValue: String = id.preferenceValue + } + + data class Imported(val id: String) : KeyboardSkinRef { + override val preferenceValue: String = "$PREFERENCE_PREFIX$id" + } + + companion object { + const val PREFERENCE_PREFIX = "imported:" + val DEFAULT: KeyboardSkinRef = BuiltIn(KeyboardSkinId.DEFAULT) + + fun fromPreference(value: String?): KeyboardSkinRef { + val builtIn = KeyboardSkinId.entries.firstOrNull { it.preferenceValue == value } + if (builtIn != null) return BuiltIn(builtIn) + val importedId = value?.removePrefix(PREFERENCE_PREFIX) + return if (value?.startsWith(PREFERENCE_PREFIX) == true && + importedId != null && + KeyboardSkinIdPattern.matches(importedId) + ) { + Imported(importedId) + } else { + DEFAULT + } + } + } +} + +private val KeyboardSkinIdPattern = Regex("[a-z][a-z0-9._-]{2,63}") + +fun KeyboardSkinRef.isBuiltIn(id: KeyboardSkinId): Boolean = + this is KeyboardSkinRef.BuiltIn && this.id == id + +fun KeyboardSkinRef.isDefault(): Boolean = isBuiltIn(KeyboardSkinId.DEFAULT) + +fun KeyboardSkinRef.importedIdOrNull(): String? = + (this as? KeyboardSkinRef.Imported)?.id + +/** Resolves a persisted reference against the in-memory store without touching disk. */ +fun KeyboardSkinRef.resolvedOrDefault(): KeyboardSkinRef = when (this) { + is KeyboardSkinRef.BuiltIn -> this + is KeyboardSkinRef.Imported -> if (KeyboardSkinRuntime.definitionFor(id) == null) { + KeyboardSkinRef.DEFAULT + } else { + this + } +} + +enum class KeyboardSkinShape { + ROUNDED_RECT, + CAPSULE, + CUT_CORNER, + HEXAGON, + PIXEL_NOTCHED, + ROUGH_RECT, +} + +sealed interface KeyboardSkinFill { + data class Solid(@ColorInt val color: Int) : KeyboardSkinFill + + data class LinearGradient( + val colors: List, + val stops: List, + val angleDegrees: Float, + ) : KeyboardSkinFill + + data class RadialGradient( + val colors: List, + val stops: List, + val centerX: Float, + val centerY: Float, + val radius: Float, + ) : KeyboardSkinFill +} + +data class KeyboardSkinStroke( + @ColorInt val color: Int, + val widthDp: Float, +) + +data class KeyboardSkinShadow( + @ColorInt val color: Int, + val offsetXDp: Float, + val offsetYDp: Float, + val blurDp: Float, +) + +enum class KeyboardSkinDecorationType { + NONE, + DOTS, + GRID, + STRIPES, + SCANLINES, + SPECKLES, + WEAVE, +} + +enum class KeyboardSkinBackgroundAnimation { + NONE, + PULSE, + SWEEP, + SHIFT, +} + +data class KeyboardSkinDecoration( + val type: KeyboardSkinDecorationType, + @ColorInt val color: Int, + val opacity: Float, + val sizeDp: Float, + val spacingDp: Float, + val angleDegrees: Float, +) + +/** Immutable, already validated style consumed by the generic Canvas renderer. */ +data class KeyboardSkinShapeStyle( + val shape: KeyboardSkinShape = KeyboardSkinShape.ROUNDED_RECT, + val fill: KeyboardSkinFill = KeyboardSkinFill.Solid(0x00000000), + val cornerRadiusDp: Float = 0f, + val insetDp: Float = 0f, + val roughnessDp: Float = 0f, + val cutSizeDp: Float = 0f, + val stroke: KeyboardSkinStroke? = null, + val shadows: List = emptyList(), + val decoration: KeyboardSkinDecoration? = null, +) { + fun merge(override: KeyboardSkinShapeStyleOverride): KeyboardSkinShapeStyle = copy( + shape = override.shape ?: shape, + fill = override.fill ?: fill, + cornerRadiusDp = override.cornerRadiusDp ?: cornerRadiusDp, + insetDp = override.insetDp ?: insetDp, + roughnessDp = override.roughnessDp ?: roughnessDp, + cutSizeDp = override.cutSizeDp ?: cutSizeDp, + stroke = override.stroke ?: stroke, + shadows = override.shadows ?: shadows, + decoration = override.decoration ?: decoration, + ) +} + +data class KeyboardSkinShapeStyleOverride( + val shape: KeyboardSkinShape? = null, + val fill: KeyboardSkinFill? = null, + val cornerRadiusDp: Float? = null, + val insetDp: Float? = null, + val roughnessDp: Float? = null, + val cutSizeDp: Float? = null, + val stroke: KeyboardSkinStroke? = null, + val shadows: List? = null, + val decoration: KeyboardSkinDecoration? = null, +) + +data class ImportedKeyboardSkinDefinition( + val id: String, + val name: String, + val author: String?, + val description: String?, + val spec: KeyboardSkinSpec, + val warnings: List, + val normalizedJson: String, +) { + val reference: KeyboardSkinRef.Imported = KeyboardSkinRef.Imported(id) +} + +data class KeyboardSkinValidationWarning( + val path: String, + val message: String, +) + +data class KeyboardSkinValidationError( + val path: String, + val message: String, +) { + override fun toString(): String = "$path: $message" +} + +sealed interface KeyboardSkinParseResult { + data class Success(val definition: ImportedKeyboardSkinDefinition) : KeyboardSkinParseResult + + data class Failure(val errors: List) : KeyboardSkinParseResult { + init { + require(errors.isNotEmpty()) + } + + val summary: String get() = errors.joinToString("\n") + } +} + +data class StoredImportedKeyboardSkin( + val definition: ImportedKeyboardSkinDefinition, + val file: java.io.File, +) diff --git a/core/src/main/java/com/kazumaproject/core/data/keyboard/ImportedKeyboardSkinRenderer.kt b/core/src/main/java/com/kazumaproject/core/data/keyboard/ImportedKeyboardSkinRenderer.kt new file mode 100644 index 000000000..3b1175fe1 --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/data/keyboard/ImportedKeyboardSkinRenderer.kt @@ -0,0 +1,368 @@ +package com.kazumaproject.core.data.keyboard + +import android.content.Context +import android.content.res.Resources +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.RadialGradient +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.drawable.Drawable +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +/** Renderer for validated v1 definitions. It only consumes immutable compiled data. */ +internal class ImportedKeyboardSkinRenderer( + private val skinSpec: KeyboardSkinSpec, +) : KeyboardSkinRenderer { + override val spec: KeyboardSkinSpec = skinSpec + + override fun createKeyDrawable( + context: Context, + role: KeyboardElementRole, + stableKey: Int, + ): Drawable = ImportedKeyboardSkinDrawable( + context = context.applicationContext, + spec = spec, + style = spec.keyStyles[role] ?: spec.keyStyles[KeyboardElementRole.CHARACTER] + ?: KeyboardSkinShapeStyle(), + stableKey = stableKey, + ) + + override fun createSurfaceDrawable( + context: Context, + role: KeyboardSurfaceRole, + ): Drawable = ImportedKeyboardSkinDrawable( + context = context.applicationContext, + spec = spec, + style = spec.surfaceStyles[role] + ?: spec.surfaceStyles[KeyboardSurfaceRole.DECK] + ?: KeyboardSkinShapeStyle(), + stableKey = role.ordinal, + ) + + override fun createPopupDrawable( + context: Context, + kind: KeyboardSkinPopupKind, + direction: KeyboardSkinPopupDirection, + selected: Boolean, + ): Drawable = checkNotNull( + KeyboardSkinPopupRenderer.createDrawable( + context = context, + skinId = spec.reference, + kind = kind, + direction = direction, + selected = selected, + ) + ) +} + +private class ImportedKeyboardSkinDrawable( + private val context: Context, + private val spec: KeyboardSkinSpec, + private val style: KeyboardSkinShapeStyle, + private val stableKey: Int, +) : Drawable(), PhasedKeyboardSkinDrawable { + private val density = context.resources.displayMetrics.density + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val decorationPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val path = Path() + private val boundsF = RectF() + private val workBounds = RectF() + private val shaderMatrix = Matrix() + private var shader: Shader? = null + private var pressed = false + private var enabled = true + private var phase = 0f + private var drawableAlpha = 255 + private var colorFilter: ColorFilter? = null + + override fun isStateful(): Boolean = true + + override fun onStateChange(state: IntArray): Boolean { + val nextPressed = state.contains(android.R.attr.state_pressed) + val nextEnabled = state.isEmpty() || state.contains(android.R.attr.state_enabled) + if (nextPressed == pressed && nextEnabled == enabled) return false + pressed = nextPressed + enabled = nextEnabled + invalidateSelf() + return true + } + + override fun onBoundsChange(bounds: Rect) { + boundsF.set(bounds) + rebuildPath() + shader = createShader() + } + + override fun draw(canvas: Canvas) { + if (boundsF.isEmpty) return + val baseAlpha = if (enabled) drawableAlpha else (drawableAlpha * 0.48f).toInt() + fillPaint.alpha = baseAlpha + fillPaint.colorFilter = colorFilter + strokePaint.alpha = baseAlpha + strokePaint.colorFilter = colorFilter + + style.shadows.forEach { shadow -> + canvas.save() + canvas.translate(dp(shadow.offsetXDp), dp(shadow.offsetYDp)) + fillPaint.shader = null + fillPaint.color = shadow.color + fillPaint.alpha = baseAlpha * Color.alpha(shadow.color) / 255 + canvas.drawPath(path, fillPaint) + canvas.restore() + } + + fillPaint.shader = shader + fillPaint.color = baseColor() + canvas.drawPath(path, fillPaint) + fillPaint.shader = null + if (pressed) { + fillPaint.color = spec.palette.accentColor + fillPaint.alpha = (baseAlpha * PRESSED_ACCENT_ALPHA).toInt().coerceIn(0, 255) + canvas.drawPath(path, fillPaint) + } + if (!pressed && spec.motion.backgroundAnimation == KeyboardSkinBackgroundAnimation.PULSE) { + fillPaint.color = spec.palette.accentColor + fillPaint.alpha = (baseAlpha * (0.08f + 0.10f * kotlin.math.sin(phase * Math.PI * 2.0).toFloat())).toInt().coerceIn(0, 255) + canvas.drawPath(path, fillPaint) + } + drawDecoration(canvas, baseAlpha) + + style.stroke?.let { stroke -> + strokePaint.color = stroke.color + strokePaint.strokeWidth = dp(stroke.widthDp) + canvas.drawPath(path, strokePaint) + } + } + + override fun setPhase(value: Float) { + phase = value.coerceIn(0f, 1f) + shader = createShader() + invalidateSelf() + } + + private fun baseColor(): Int = when (val fill = style.fill) { + is KeyboardSkinFill.Solid -> fill.color + is KeyboardSkinFill.LinearGradient -> fill.colors[fill.colors.size / 2] + is KeyboardSkinFill.RadialGradient -> fill.colors[fill.colors.size / 2] + } + + private fun createShader(): Shader? { + val left = boundsF.left + val top = boundsF.top + return when (val fill = style.fill) { + is KeyboardSkinFill.Solid -> null + is KeyboardSkinFill.LinearGradient -> { + val angle = Math.toRadians(fill.angleDegrees.toDouble()) + val vectorX = cos(angle).toFloat() + val vectorY = sin(angle).toFloat() + val length = min(boundsF.width(), boundsF.height()).coerceAtLeast(1f) + val movement = if ( + spec.motion.backgroundAnimation == KeyboardSkinBackgroundAnimation.SWEEP || + spec.motion.backgroundAnimation == KeyboardSkinBackgroundAnimation.SHIFT + ) phase - 0.5f else 0f + val centerX = boundsF.centerX() + movement * boundsF.width() * 0.35f + val centerY = boundsF.centerY() + val halfX = vectorX * length + val halfY = vectorY * length + LinearGradient( + centerX - halfX, + centerY - halfY, + centerX + halfX, + centerY + halfY, + fill.colors.toIntArray(), + fill.stops.toFloatArray(), + Shader.TileMode.CLAMP, + ) + } + is KeyboardSkinFill.RadialGradient -> RadialGradient( + left + boundsF.width() * fill.centerX, + top + boundsF.height() * fill.centerY, + min(boundsF.width(), boundsF.height()) * fill.radius, + fill.colors.toIntArray(), + fill.stops.toFloatArray(), + Shader.TileMode.CLAMP, + ).also { + shaderMatrix.reset() + val movement = if (spec.motion.backgroundAnimation == KeyboardSkinBackgroundAnimation.SHIFT) phase - 0.5f else 0f + shaderMatrix.setTranslate(movement * boundsF.width() * 0.15f, 0f) + it.setLocalMatrix(shaderMatrix) + } + } + } + + private fun drawDecoration(canvas: Canvas, baseAlpha: Int) { + val decoration = style.decoration ?: return + if (decoration.type == KeyboardSkinDecorationType.NONE || decoration.opacity <= 0f) return + canvas.save() + canvas.clipPath(path) + decorationPaint.color = decoration.color + decorationPaint.alpha = (baseAlpha * decoration.opacity).toInt().coerceIn(0, 255) + decorationPaint.style = if ( + decoration.type == KeyboardSkinDecorationType.DOTS || + decoration.type == KeyboardSkinDecorationType.SPECKLES + ) Paint.Style.FILL else Paint.Style.STROKE + decorationPaint.strokeWidth = dp(decoration.sizeDp.coerceAtMost(2f)) + val step = dp(decoration.spacingDp.coerceAtLeast(1f)) + when (decoration.type) { + KeyboardSkinDecorationType.DOTS, + KeyboardSkinDecorationType.SPECKLES -> { + var row = 0 + var y = boundsF.top + while (y <= boundsF.bottom) { + var x = boundsF.left + if (row % 2 == 0) 0f else step / 2f + while (x <= boundsF.right) { + val radius = dp( + if (decoration.type == KeyboardSkinDecorationType.SPECKLES) { + 0.45f + (stableKey % 3) * 0.2f + } else { + decoration.sizeDp / 2f + }, + ) + canvas.drawCircle(x, y, radius, decorationPaint) + x += step + } + y += step + row++ + } + } + KeyboardSkinDecorationType.GRID, + KeyboardSkinDecorationType.SCANLINES, + KeyboardSkinDecorationType.WEAVE -> { + var y = boundsF.top + while (y <= boundsF.bottom) { + canvas.drawLine(boundsF.left, y, boundsF.right, y, decorationPaint) + y += step + } + if (decoration.type != KeyboardSkinDecorationType.SCANLINES) { + var x = boundsF.left + while (x <= boundsF.right) { + canvas.drawLine(x, boundsF.top, x, boundsF.bottom, decorationPaint) + x += step + } + } + } + KeyboardSkinDecorationType.STRIPES -> { + val angle = Math.toRadians(decoration.angleDegrees.toDouble()) + val dx = cos(angle).toFloat() * step + val dy = sin(angle).toFloat() * step + var x = boundsF.left - boundsF.height() + while (x < boundsF.right + boundsF.height()) { + canvas.drawLine(x, boundsF.bottom, x + boundsF.height(), boundsF.top, decorationPaint) + x += maxOf(abs(dx), abs(dy), dp(1f)) + } + } + KeyboardSkinDecorationType.NONE -> Unit + } + canvas.restore() + } + + private fun rebuildPath() { + path.reset() + val inset = dp(style.insetDp) + workBounds.set(boundsF) + workBounds.inset(inset, inset) + when (style.shape) { + KeyboardSkinShape.ROUNDED_RECT -> path.addRoundRect(workBounds, dp(style.cornerRadiusDp), dp(style.cornerRadiusDp), Path.Direction.CW) + KeyboardSkinShape.CAPSULE -> path.addRoundRect(workBounds, workBounds.height() / 2f, workBounds.height() / 2f, Path.Direction.CW) + KeyboardSkinShape.CUT_CORNER -> addCutCornerPath(workBounds, dp(style.cutSizeDp.coerceAtLeast(style.cornerRadiusDp))) + KeyboardSkinShape.HEXAGON -> addHexagonPath(workBounds) + KeyboardSkinShape.PIXEL_NOTCHED -> addPixelNotchedPath(workBounds) + KeyboardSkinShape.ROUGH_RECT -> addRoughRectPath(workBounds) + } + } + + private fun addCutCornerPath(rect: RectF, cut: Float) { + val c = cut.coerceAtMost(min(rect.width(), rect.height()) / 2f) + path.moveTo(rect.left + c, rect.top) + path.lineTo(rect.right - c, rect.top) + path.lineTo(rect.right, rect.top + c) + path.lineTo(rect.right, rect.bottom - c) + path.lineTo(rect.right - c, rect.bottom) + path.lineTo(rect.left + c, rect.bottom) + path.lineTo(rect.left, rect.bottom - c) + path.lineTo(rect.left, rect.top + c) + path.close() + } + + private fun addHexagonPath(rect: RectF) { + val inset = rect.width() * 0.18f + path.moveTo(rect.left + inset, rect.top) + path.lineTo(rect.right - inset, rect.top) + path.lineTo(rect.right, rect.centerY()) + path.lineTo(rect.right - inset, rect.bottom) + path.lineTo(rect.left + inset, rect.bottom) + path.lineTo(rect.left, rect.centerY()) + path.close() + } + + private fun addPixelNotchedPath(rect: RectF) { + val notch = dp(style.cutSizeDp.coerceAtLeast(2f)).coerceAtMost(min(rect.width(), rect.height()) / 3f) + path.moveTo(rect.left + notch, rect.top) + path.lineTo(rect.right - notch, rect.top) + path.lineTo(rect.right - notch, rect.top + notch) + path.lineTo(rect.right, rect.top + notch) + path.lineTo(rect.right, rect.bottom - notch) + path.lineTo(rect.right - notch, rect.bottom - notch) + path.lineTo(rect.right - notch, rect.bottom) + path.lineTo(rect.left + notch, rect.bottom) + path.lineTo(rect.left + notch, rect.bottom - notch) + path.lineTo(rect.left, rect.bottom - notch) + path.lineTo(rect.left, rect.top + notch) + path.lineTo(rect.left + notch, rect.top + notch) + path.close() + } + + private fun addRoughRectPath(rect: RectF) { + val rough = dp(style.roughnessDp) + val n = (stableKey and 3) * rough * 0.2f + path.moveTo(rect.left + n, rect.top + rough) + path.lineTo(rect.right - rough, rect.top + n) + path.lineTo(rect.right - n, rect.bottom - rough) + path.lineTo(rect.left + rough, rect.bottom - n) + path.close() + } + + private fun dp(value: Float): Float = value * density + + override fun setAlpha(alpha: Int) { + drawableAlpha = alpha.coerceIn(0, 255) + invalidateSelf() + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + this.colorFilter = colorFilter + invalidateSelf() + } + + @Deprecated("Drawable opacity is not used by the skin renderer") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun getConstantState(): ConstantState = State(context, spec, style, stableKey) + + private companion object { + const val PRESSED_ACCENT_ALPHA = 0.24f + } + + private class State( + private val context: Context, + private val spec: KeyboardSkinSpec, + private val style: KeyboardSkinShapeStyle, + private val stableKey: Int, + ) : ConstantState() { + override fun newDrawable(): Drawable = ImportedKeyboardSkinDrawable(context, spec, style, stableKey) + override fun newDrawable(resources: Resources?): Drawable = newDrawable() + override fun getChangingConfigurations(): Int = 0 + } +} diff --git a/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkin.kt b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkin.kt new file mode 100644 index 000000000..bb8ad3f11 --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkin.kt @@ -0,0 +1,638 @@ +package com.kazumaproject.core.data.keyboard + +import android.content.Context +import androidx.annotation.ColorInt + +/** Stable preference values for the built-in keyboard appearances. */ +enum class KeyboardSkinId(val preferenceValue: String) { + DEFAULT("default"), + FLAT("flat"), + GLASS("glass"), + NEUMORPHISM("neumorphism"), + MECHANICAL("mechanical"), + WASHI("washi"), + NEON("neon"), + TERMINAL("terminal"), + CUPERTINO("cupertino"), + CUPERTINO_DARK("cupertino_dark"), + SUMI_HANSHI("sumi_hanshi"), + LETTERPRESS("letterpress"), + PORCELAIN("porcelain"), + URUSHI("urushi"), + CHALKBOARD("chalkboard"), + LINEN("linen"), + MONOCHROME_LCD("monochrome_lcd"); + + companion object { + fun fromPreference(value: String?): KeyboardSkinId = + entries.firstOrNull { it.preferenceValue == value } ?: DEFAULT + } +} + +enum class KeyboardSkinMotionMode(val preferenceValue: String) { + FULL("full"), + REDUCED("reduced"), + OFF("off"); + + companion object { + fun fromPreference(value: String?): KeyboardSkinMotionMode = + entries.firstOrNull { it.preferenceValue == value } ?: FULL + } +} + +/** Semantic roles allow every keyboard implementation to share one visual language. */ +enum class KeyboardElementRole { + CHARACTER, + MODIFIER, + ACTION, + SPACE, + CANDIDATE, + TOOLBAR, + POPUP, +} + +enum class KeyboardSurfaceRole { + DECK, + CANDIDATE_STRIP, + CANDIDATE_PANEL, + TOOLBAR, + POPUP, +} + +enum class KeyboardSkinMaterial { + DEFAULT, + FLAT, + GLASS, + SOFT_EXTRUSION, + MECHANICAL, + WASHI, + NEON, + TERMINAL, + CUPERTINO, + SUMI_HANSHI, + LETTERPRESS, + PORCELAIN, + URUSHI, + CHALKBOARD, + LINEN, + MONOCHROME_LCD, +} + +enum class KeyboardSkinDepthModel { + LEGACY, + NONE, + REFRACTIVE, + DUAL_SHADOW, + KEYCAP_SIDEWALL, + PAPER, + EMISSIVE, + GRID, + SHORT_SHADOW, + INK_ABSORPTION, + DEBOSSED_STOCK, + GLAZED_TILE, + LACQUERED_DOME, + POWDER_OUTLINE, + STITCHED_PATCH, + PIXEL_PLANE, +} + +data class KeyboardSkinPalette( + @ColorInt val backgroundColor: Int, + @ColorInt val normalKeyColor: Int, + @ColorInt val specialKeyColor: Int, + @ColorInt val actionKeyColor: Int, + @ColorInt val normalKeyTextColor: Int, + @ColorInt val specialKeyTextColor: Int, + @ColorInt val actionKeyTextColor: Int, + @ColorInt val accentColor: Int, + @ColorInt val secondaryAccentColor: Int, + @ColorInt val candidateSurfaceColor: Int, + @ColorInt val candidateTextColor: Int, +) { + @ColorInt + fun keyColor(role: KeyboardElementRole): Int = when (role) { + KeyboardElementRole.CHARACTER, + KeyboardElementRole.SPACE -> normalKeyColor + + KeyboardElementRole.ACTION -> actionKeyColor + KeyboardElementRole.CANDIDATE -> candidateSurfaceColor + KeyboardElementRole.MODIFIER, + KeyboardElementRole.TOOLBAR, + KeyboardElementRole.POPUP -> specialKeyColor + } + + @ColorInt + fun textColor(role: KeyboardElementRole): Int = when (role) { + KeyboardElementRole.CHARACTER, + KeyboardElementRole.SPACE -> normalKeyTextColor + + KeyboardElementRole.ACTION -> actionKeyTextColor + KeyboardElementRole.CANDIDATE -> candidateTextColor + KeyboardElementRole.MODIFIER, + KeyboardElementRole.TOOLBAR, + KeyboardElementRole.POPUP -> specialKeyTextColor + } +} + +data class KeyboardSkinGeometry( + val cornerRadiusDp: Float, + val visualInsetDp: Float, + val strokeWidthDp: Float, + val depthDp: Float, + val irregularityDp: Float = 0f, +) + +data class KeyboardSkinTypography( + val familyName: String, + val bold: Boolean, + val letterSpacing: Float = 0f, +) + +data class KeyboardSkinMotionSpec( + val pressScale: Float, + val pressTranslationYDp: Float, + val pressTranslationXDp: Float = 0f, + val pressDurationMs: Long, + val releaseDurationMs: Long, + /** Zero means that the skin has no continuously animated backdrop. */ + val continuousPeriodMs: Long, + val backgroundAnimation: KeyboardSkinBackgroundAnimation = KeyboardSkinBackgroundAnimation.NONE, +) + +/** + * Popup surfaces are intentionally separate from keycaps. iOS uses a larger, flatter surface for + * previews and flick candidates, so reusing the key drawable alone cannot reproduce its geometry. + */ +enum class KeyboardSkinPopupKind { + KEY_PREVIEW, + VARIATION, + FLICK_STANDARD, + FLICK_DIRECTIONAL, + FLICK_CROSS, + FLICK_CIRCLE, + FLICK_GUIDE, +} + +enum class KeyboardSkinPopupDirection { + CENTER, + UP, + DOWN, + LEFT, + RIGHT, +} + +data class KeyboardSkinPopupSpec( + @ColorInt val surfaceColor: Int, + @ColorInt val selectedSurfaceColor: Int, + @ColorInt val textColor: Int, + @ColorInt val selectedTextColor: Int, + @ColorInt val secondaryTextColor: Int, + @ColorInt val shadowColor: Int, + val shadowAlpha: Int, + val selectedShadowAlpha: Int, + val cornerRadiusDp: Float, + val strokeWidthDp: Float, + val stemWidthDp: Float, + val stemHeightDp: Float, + val contentPaddingHorizontalDp: Float, + val contentPaddingVerticalDp: Float, + val itemGapDp: Float, + val keyPreviewWidthScale: Float, + val keyPreviewHeightScale: Float, + val keyPreviewTextSizeSp: Float, + val variationTextSizeSp: Float, + val flickTextSizeSp: Float, +) + +data class KeyboardSkinSpec( + val id: KeyboardSkinId, + val palette: KeyboardSkinPalette, + val geometry: KeyboardSkinGeometry, + val typography: KeyboardSkinTypography, + val material: KeyboardSkinMaterial, + val depthModel: KeyboardSkinDepthModel, + val motion: KeyboardSkinMotionSpec, + val popup: KeyboardSkinPopupSpec? = null, + /** Imported skins keep the built-in-compatible [id] field but carry their real reference here. */ + val reference: KeyboardSkinRef = KeyboardSkinRef.BuiltIn(id), + val keyStyles: Map = emptyMap(), + val surfaceStyles: Map = emptyMap(), + val displayName: String? = null, + val author: String? = null, + val description: String? = null, +) + +/** + * Authoritative built-in skin catalog. Non-default palettes are deliberately independent from + * keyboard theme colors so skins remain recognizable on a physical device. + */ +object KeyboardSkinCatalog { + private val specs: Map = listOf( + spec( + KeyboardSkinId.DEFAULT, + palette( + 0xFFF1F2F5, 0xFFFFFFFF, 0xFFD8DADE, 0xFF2864DC, + 0xFF16181C, 0xFF16181C, 0xFFFFFFFF, + 0xFF2864DC, 0xFF7A7F89, 0xFFF5F6F8, 0xFF16181C, + ), + KeyboardSkinGeometry(8f, 2f, 1f, 2f), + KeyboardSkinTypography("sans-serif", false), + KeyboardSkinMaterial.DEFAULT, + KeyboardSkinDepthModel.LEGACY, + KeyboardSkinMotionSpec(0.98f, 1f, pressDurationMs = 80, releaseDurationMs = 110, continuousPeriodMs = 0), + ), + spec( + KeyboardSkinId.FLAT, + palette( + 0xFF1E4ED8, 0xFFFFF4DA, 0xFFFFD166, 0xFFC9343A, + 0xFF17213B, 0xFF17213B, 0xFFFFFFFF, + 0xFFFFFFFF, 0xFFFF5A4F, 0xFF163FAF, 0xFFFFFFFF, + ), + KeyboardSkinGeometry(2f, 1f, 0f, 0f), + KeyboardSkinTypography("sans-serif", true, 0.015f), + KeyboardSkinMaterial.FLAT, + KeyboardSkinDepthModel.NONE, + KeyboardSkinMotionSpec(0.96f, 0f, pressDurationMs = 70, releaseDurationMs = 90, continuousPeriodMs = 0), + ), + spec( + KeyboardSkinId.GLASS, + palette( + 0xFF06152F, 0x52213F67, 0x6611325C, 0x7000D9FF, + 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, + 0xFF00D9FF, 0xFFFF3CAC, 0xB3142346, 0xFFFFFFFF, + ), + KeyboardSkinGeometry(12f, 2f, 1.5f, 0f), + KeyboardSkinTypography("sans-serif-medium", false, 0.01f), + KeyboardSkinMaterial.GLASS, + KeyboardSkinDepthModel.REFRACTIVE, + KeyboardSkinMotionSpec(0.97f, 1f, pressDurationMs = 120, releaseDurationMs = 150, continuousPeriodMs = 8_000), + ), + spec( + KeyboardSkinId.NEUMORPHISM, + palette( + 0xFFE9E4D9, 0xFFE9E4D9, 0xFFDAD3C5, 0xFF9E4937, + 0xFF26231F, 0xFF26231F, 0xFFFFFFFF, + 0xFFFFFFFF, 0xFF9D9689, 0xFFE4DED2, 0xFF26231F, + ), + KeyboardSkinGeometry(14f, 3f, 0f, 5f), + KeyboardSkinTypography("sans-serif-medium", false), + KeyboardSkinMaterial.SOFT_EXTRUSION, + KeyboardSkinDepthModel.DUAL_SHADOW, + KeyboardSkinMotionSpec(0.985f, 1.5f, pressDurationMs = 110, releaseDurationMs = 140, continuousPeriodMs = 0), + ), + spec( + KeyboardSkinId.MECHANICAL, + palette( + 0xFF16181D, 0xFF2A2D34, 0xFF3B3F48, 0xFFB72C36, + 0xFFF5E9D8, 0xFFF5E9D8, 0xFFFFFFFF, + 0xFF00E5FF, 0xFFFF3D71, 0xFF202229, 0xFFF5E9D8, + ), + KeyboardSkinGeometry(4f, 2f, 1f, 3f), + KeyboardSkinTypography("sans-serif-condensed", true, 0.025f), + KeyboardSkinMaterial.MECHANICAL, + KeyboardSkinDepthModel.KEYCAP_SIDEWALL, + KeyboardSkinMotionSpec(1f, 3f, pressDurationMs = 70, releaseDurationMs = 105, continuousPeriodMs = 9_000), + ), + spec( + KeyboardSkinId.WASHI, + palette( + 0xFF162A4C, 0xFFF2E3C1, 0xFFD8C69F, 0xFFA9362D, + 0xFF1D3763, 0xFF1D3763, 0xFFFFF7E7, + 0xFFB6402B, 0xFFF2E3C1, 0xFF21395D, 0xFFF2E3C1, + ), + KeyboardSkinGeometry(5f, 2f, 1f, 1f, irregularityDp = 1.5f), + KeyboardSkinTypography("serif", true, 0.015f), + KeyboardSkinMaterial.WASHI, + KeyboardSkinDepthModel.PAPER, + KeyboardSkinMotionSpec(0.975f, 0.5f, pressDurationMs = 160, releaseDurationMs = 180, continuousPeriodMs = 14_000), + ), + spec( + KeyboardSkinId.NEON, + palette( + 0xFF090018, 0xFF120626, 0xFF1A0A34, 0xFF32102D, + 0xFFF7FBFF, 0xFFF7FBFF, 0xFFFFFFFF, + 0xFF00EFFF, 0xFFFF2BD6, 0xFF100622, 0xFFF7FBFF, + ), + KeyboardSkinGeometry(4f, 2f, 1.5f, 0f), + KeyboardSkinTypography("sans-serif-medium", false, 0.02f), + KeyboardSkinMaterial.NEON, + KeyboardSkinDepthModel.EMISSIVE, + KeyboardSkinMotionSpec(0.98f, 0f, pressDurationMs = 90, releaseDurationMs = 125, continuousPeriodMs = 6_000), + ), + spec( + KeyboardSkinId.TERMINAL, + palette( + 0xFF020806, 0xFF03140C, 0xFF082A18, 0xFF39FF88, + 0xFF5CFF9C, 0xFF5CFF9C, 0xFF001A0D, + 0xFF39FF88, 0xFF0B7A3B, 0xFF04150D, 0xFF5CFF9C, + ), + KeyboardSkinGeometry(0f, 1f, 1f, 0f), + KeyboardSkinTypography("monospace", true, 0.035f), + KeyboardSkinMaterial.TERMINAL, + KeyboardSkinDepthModel.GRID, + KeyboardSkinMotionSpec(1f, 0f, pressTranslationXDp = 1.5f, pressDurationMs = 80, releaseDurationMs = 70, continuousPeriodMs = 4_000), + ), + spec( + KeyboardSkinId.CUPERTINO, + // Measured from the light keyboard on an iPhone 17 Pro Max, + // iOS 26.4 Simulator. Keep this palette independent from the app theme. + palette( + 0xFFE8E9ED, 0xFFFFFFFF, 0xFFFFFFFF, 0xFF0091FF, + 0xFF000000, 0xFF000000, 0xFFFFFFFF, + 0xFF0091FF, 0xFF636366, 0xFFE8E9ED, 0xFF000000, + ), + KeyboardSkinGeometry(7f, 1.5f, 0f, 0f), + KeyboardSkinTypography("sans-serif", false), + KeyboardSkinMaterial.CUPERTINO, + KeyboardSkinDepthModel.NONE, + KeyboardSkinMotionSpec(1f, 0f, pressDurationMs = 60, releaseDurationMs = 80, continuousPeriodMs = 0), + popup = cupertinoPopup(isDark = false), + ), + spec( + KeyboardSkinId.CUPERTINO_DARK, + // Measured from the dark keyboard on an iPhone 17 Pro Max, + // iOS 26.4 Simulator. This is a separate skin, not a theme-dependent variant. + palette( + 0xFF171717, 0xFF3D3D3D, 0xFF3D3D3D, 0xFF007AFF, + 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, + 0xFF007AFF, 0xFF8E8E93, 0xFF171717, 0xFFFFFFFF, + ), + KeyboardSkinGeometry(7f, 1.5f, 0f, 0f), + KeyboardSkinTypography("sans-serif", false), + KeyboardSkinMaterial.CUPERTINO, + KeyboardSkinDepthModel.NONE, + KeyboardSkinMotionSpec(1f, 0f, pressDurationMs = 60, releaseDurationMs = 80, continuousPeriodMs = 0), + popup = cupertinoPopup(isDark = true), + ), + spec( + KeyboardSkinId.SUMI_HANSHI, + palette( + 0xFFF6F1E4, 0xFFFFFCF3, 0xFFEAE4D8, 0xFFB33A2E, + 0xFF181512, 0xFF181512, 0xFFFFF9ED, + 0xFF6E685E, 0xFFB33A2E, 0xFFF6F1E4, 0xFF181512, + ), + KeyboardSkinGeometry(3f, 2f, 0.55f, 0.8f, irregularityDp = 0.35f), + KeyboardSkinTypography("serif", true, 0.012f), + KeyboardSkinMaterial.SUMI_HANSHI, + KeyboardSkinDepthModel.INK_ABSORPTION, + KeyboardSkinMotionSpec(0.98f, 0.4f, pressDurationMs = 150, releaseDurationMs = 190, continuousPeriodMs = 0), + ), + spec( + KeyboardSkinId.LETTERPRESS, + palette( + 0xFFE8DDC4, 0xFFF3EAD7, 0xFFDDD0B8, 0xFFB33B2E, + 0xFF201C17, 0xFF201C17, 0xFFFFF8E9, + 0xFF6F6558, 0xFFB33B2E, 0xFFE8DDC4, 0xFF201C17, + ), + KeyboardSkinGeometry(2f, 2f, 1f, 1.5f, irregularityDp = 0.2f), + KeyboardSkinTypography("serif", false, 0.01f), + KeyboardSkinMaterial.LETTERPRESS, + KeyboardSkinDepthModel.DEBOSSED_STOCK, + KeyboardSkinMotionSpec(0.99f, 1.2f, pressDurationMs = 90, releaseDurationMs = 120, continuousPeriodMs = 0), + ), + spec( + KeyboardSkinId.PORCELAIN, + palette( + 0xFF24384A, 0xFFF7F3E8, 0xFFE1E9E8, 0xFFA94738, + 0xFF244F7A, 0xFF244F7A, 0xFFFFF7EA, + 0xFF244F7A, 0xFFAFC2C8, 0xFFF7F3E8, 0xFF244F7A, + ), + KeyboardSkinGeometry(8f, 2.5f, 1.2f, 2.4f), + KeyboardSkinTypography("serif", false, 0.008f), + KeyboardSkinMaterial.PORCELAIN, + KeyboardSkinDepthModel.GLAZED_TILE, + KeyboardSkinMotionSpec(0.985f, 1f, pressDurationMs = 105, releaseDurationMs = 140, continuousPeriodMs = 0), + ), + spec( + KeyboardSkinId.URUSHI, + palette( + 0xFF0D0C0B, 0xFF1B1715, 0xFF251B17, 0xFFA92D22, + 0xFFF1E7D1, 0xFFF1E7D1, 0xFFFFF4DF, + 0xFFB99A58, 0xFFA92D22, 0xFF0D0C0B, 0xFFF1E7D1, + ), + KeyboardSkinGeometry(8f, 2f, 0.9f, 2.6f), + KeyboardSkinTypography("serif", false, 0.012f), + KeyboardSkinMaterial.URUSHI, + KeyboardSkinDepthModel.LACQUERED_DOME, + KeyboardSkinMotionSpec(0.98f, 1.4f, pressDurationMs = 100, releaseDurationMs = 145, continuousPeriodMs = 0), + ), + spec( + KeyboardSkinId.CHALKBOARD, + palette( + 0xFF202725, 0xFF202725, 0xFF27312E, 0xFFD5AA4D, + 0xFFF0EBDD, 0xFFC8DCD8, 0xFF202725, + 0xFFF0EBDD, 0xFF8FB8B2, 0xFF202725, 0xFFF0EBDD, + ), + KeyboardSkinGeometry(7f, 2f, 1f, 0f, irregularityDp = 0.25f), + KeyboardSkinTypography("sans-serif", false, 0.008f), + KeyboardSkinMaterial.CHALKBOARD, + KeyboardSkinDepthModel.POWDER_OUTLINE, + KeyboardSkinMotionSpec(0.985f, 0f, pressDurationMs = 115, releaseDurationMs = 155, continuousPeriodMs = 0), + ), + spec( + KeyboardSkinId.LINEN, + palette( + 0xFFD7C6A8, 0xFFEFE5D0, 0xFFA9AA91, 0xFF9F443B, + 0xFF2A2926, 0xFF2A2926, 0xFFFFF4E2, + 0xFF2A2926, 0xFF6F7659, 0xFFD7C6A8, 0xFF2A2926, + ), + KeyboardSkinGeometry(7f, 2.5f, 1f, 2f, irregularityDp = 0.25f), + KeyboardSkinTypography("sans-serif-medium", false, 0.008f), + KeyboardSkinMaterial.LINEN, + KeyboardSkinDepthModel.STITCHED_PATCH, + KeyboardSkinMotionSpec(0.97f, 1f, pressDurationMs = 130, releaseDurationMs = 170, continuousPeriodMs = 0), + ), + spec( + KeyboardSkinId.MONOCHROME_LCD, + palette( + 0xFFB5B58B, 0xFFC6C79D, 0xFF9FA17B, 0xFF7D493B, + 0xFF273126, 0xFF273126, 0xFFF2E9CA, + 0xFF273126, 0xFF59604B, 0xFFA9AA81, 0xFF273126, + ), + KeyboardSkinGeometry(0f, 1.5f, 1f, 0f), + KeyboardSkinTypography("monospace", true, 0.02f), + KeyboardSkinMaterial.MONOCHROME_LCD, + KeyboardSkinDepthModel.PIXEL_PLANE, + KeyboardSkinMotionSpec(1f, 0f, pressDurationMs = 35, releaseDurationMs = 45, continuousPeriodMs = 0), + ), + ).associateBy(KeyboardSkinSpec::id) + + fun specFor(id: KeyboardSkinId): KeyboardSkinSpec = + checkNotNull(specs[id]) { "Missing keyboard skin specification for $id" } + + fun specFor(reference: KeyboardSkinRef): KeyboardSkinSpec = when (reference) { + is KeyboardSkinRef.BuiltIn -> specFor(reference.id) + is KeyboardSkinRef.Imported -> KeyboardSkinRuntime.specFor(reference.id) + ?: specFor(KeyboardSkinId.DEFAULT) + } + + fun all(): List = KeyboardSkinId.entries.map(::specFor) + + private fun palette( + background: Long, + normal: Long, + special: Long, + action: Long, + normalText: Long, + specialText: Long, + actionText: Long, + accent: Long, + secondaryAccent: Long, + candidateSurface: Long, + candidateText: Long, + ) = KeyboardSkinPalette( + background.toInt(), normal.toInt(), special.toInt(), action.toInt(), + normalText.toInt(), specialText.toInt(), actionText.toInt(), + accent.toInt(), secondaryAccent.toInt(), candidateSurface.toInt(), candidateText.toInt(), + ) + + private fun spec( + id: KeyboardSkinId, + palette: KeyboardSkinPalette, + geometry: KeyboardSkinGeometry, + typography: KeyboardSkinTypography, + material: KeyboardSkinMaterial, + depthModel: KeyboardSkinDepthModel, + motion: KeyboardSkinMotionSpec, + popup: KeyboardSkinPopupSpec? = null, + ) = KeyboardSkinSpec(id, palette, geometry, typography, material, depthModel, motion, popup) + + private fun cupertinoPopup(isDark: Boolean): KeyboardSkinPopupSpec = KeyboardSkinPopupSpec( + surfaceColor = if (isDark) 0xFF5A5A5E.toInt() else 0xFFFFFFFF.toInt(), + selectedSurfaceColor = if (isDark) 0xFF8E8E93.toInt() else 0xFFD1D1D6.toInt(), + textColor = if (isDark) 0xFFFFFFFF.toInt() else 0xFF000000.toInt(), + selectedTextColor = if (isDark) 0xFFFFFFFF.toInt() else 0xFF000000.toInt(), + secondaryTextColor = if (isDark) 0xFFD1D1D6.toInt() else 0xFF636366.toInt(), + shadowColor = 0xFF000000.toInt(), + shadowAlpha = if (isDark) 72 else 42, + selectedShadowAlpha = if (isDark) 88 else 30, + cornerRadiusDp = 7f, + strokeWidthDp = 0f, + stemWidthDp = 10f, + stemHeightDp = 6f, + contentPaddingHorizontalDp = 12f, + contentPaddingVerticalDp = 8f, + itemGapDp = 4f, + keyPreviewWidthScale = 2f, + keyPreviewHeightScale = 2f, + keyPreviewTextSizeSp = 28f, + variationTextSizeSp = 28f, + flickTextSizeSp = 22f, + ) +} + +sealed interface KeyboardAppearance { + data class Legacy(val palette: KeyboardSkinPalette) : KeyboardAppearance + + data class BuiltIn( + val spec: KeyboardSkinSpec, + val motionMode: KeyboardSkinMotionMode, + val reference: KeyboardSkinRef = KeyboardSkinRef.BuiltIn(spec.id), + ) : KeyboardAppearance +} + +object KeyboardAppearanceResolver { + fun resolve( + context: Context, + skinValue: String?, + motionValue: String?, + themeMode: String, + customBackgroundColor: Int, + customKeyColor: Int, + customSpecialKeyColor: Int, + customKeyTextColor: Int, + customSpecialKeyTextColor: Int, + ): KeyboardAppearance { + val skinRef = KeyboardSkinRef.fromPreference(skinValue).resolvedOrDefault() + if (!skinRef.isDefault()) { + return KeyboardAppearance.BuiltIn( + spec = KeyboardSkinCatalog.specFor(skinRef), + motionMode = KeyboardSkinMotionMode.fromPreference(motionValue), + reference = skinRef, + ) + } + return KeyboardAppearance.Legacy( + resolveKeyboardSkinPalette( + context = context, + themeMode = themeMode, + customBackgroundColor = customBackgroundColor, + customKeyColor = customKeyColor, + customSpecialKeyColor = customSpecialKeyColor, + customKeyTextColor = customKeyTextColor, + customSpecialKeyTextColor = customSpecialKeyTextColor, + ) + ) + } +} + +/** Legacy/default palette resolution. Built-in non-default skins never consume these colors. */ +fun resolveKeyboardSkinPalette( + context: Context, + themeMode: String, + customBackgroundColor: Int, + customKeyColor: Int, + customSpecialKeyColor: Int, + customKeyTextColor: Int, + customSpecialKeyTextColor: Int, + skinId: KeyboardSkinId = KeyboardSkinId.DEFAULT, +): KeyboardSkinPalette { + if (skinId != KeyboardSkinId.DEFAULT) return KeyboardSkinCatalog.specFor(skinId).palette + + if (themeMode == "custom") { + return KeyboardSkinPalette( + backgroundColor = customBackgroundColor, + normalKeyColor = customKeyColor, + specialKeyColor = customSpecialKeyColor, + actionKeyColor = customSpecialKeyColor, + normalKeyTextColor = customKeyTextColor, + specialKeyTextColor = customSpecialKeyTextColor, + actionKeyTextColor = customSpecialKeyTextColor, + accentColor = customSpecialKeyColor, + secondaryAccentColor = customKeyColor, + candidateSurfaceColor = customBackgroundColor, + candidateTextColor = customKeyTextColor, + ) + } + + val background = context.getColor(com.kazumaproject.core.R.color.qwety_bg_color) + val normal = context.getColor(com.kazumaproject.core.R.color.qwety_key_bg_color) + val special = context.getColor(com.kazumaproject.core.R.color.qwety_key_bg_color_2) + val text = context.getColor(com.kazumaproject.core.R.color.keyboard_icon_color) + return KeyboardSkinPalette( + backgroundColor = background, + normalKeyColor = normal, + specialKeyColor = special, + actionKeyColor = special, + normalKeyTextColor = text, + specialKeyTextColor = text, + actionKeyTextColor = text, + accentColor = special, + secondaryAccentColor = normal, + candidateSurfaceColor = background, + candidateTextColor = text, + ) +} + +fun resolveKeyboardSkinPalette( + context: Context, + themeMode: String, + customBackgroundColor: Int, + customKeyColor: Int, + customSpecialKeyColor: Int, + customKeyTextColor: Int, + customSpecialKeyTextColor: Int, + skinId: KeyboardSkinRef, +): KeyboardSkinPalette { + if (!skinId.isDefault()) return KeyboardSkinCatalog.specFor(skinId).palette + return resolveKeyboardSkinPalette( + context = context, + themeMode = themeMode, + customBackgroundColor = customBackgroundColor, + customKeyColor = customKeyColor, + customSpecialKeyColor = customSpecialKeyColor, + customKeyTextColor = customKeyTextColor, + customSpecialKeyTextColor = customSpecialKeyTextColor, + skinId = KeyboardSkinId.DEFAULT, + ) +} diff --git a/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinBackdropView.kt b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinBackdropView.kt new file mode 100644 index 000000000..644b65da5 --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinBackdropView.kt @@ -0,0 +1,173 @@ +package com.kazumaproject.core.data.keyboard + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.os.Build +import android.util.AttributeSet +import android.view.Choreographer +import android.view.View +import java.util.concurrent.atomic.AtomicInteger + +/** + * Single low-frequency animated deck per visible keyboard. Key views only animate on interaction, + * so rich skins do not create an animator for every key. + */ +class KeyboardSkinBackdropView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : View(context, attrs), Choreographer.FrameCallback { + private var skinRef: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var motionMode: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL + private var runtimeGeneration = KeyboardSkinRuntime.generation() + private var deckDrawable: Drawable? = null + private var startFrameNanos = 0L + private var lastDrawNanos = 0L + private var frameScheduled = false + private var countedAsRunning = false + + init { + setWillNotDraw(false) + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + isClickable = false + isFocusable = false + } + + fun setSkin( + skin: KeyboardSkinId, + motion: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL, + ) = setSkin(KeyboardSkinRef.BuiltIn(skin), motion) + + fun setSkin( + skin: KeyboardSkinRef, + motion: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL, + ) { + val nextRuntimeGeneration = runtimeGenerationFor(skin) + if ( + skin == skinRef && + motion == motionMode && + deckDrawable != null && + nextRuntimeGeneration == runtimeGeneration + ) { + updateFrameLoop() + return + } + stopFrameLoop() + skinRef = skin + motionMode = motion + runtimeGeneration = nextRuntimeGeneration + deckDrawable = if (skin.isDefault()) { + null + } else { + KeyboardSkinRendererRegistry.rendererFor(skin) + .createSurfaceDrawable(context, KeyboardSurfaceRole.DECK) + } + visibility = if (skin.isDefault()) GONE else VISIBLE + invalidate() + updateFrameLoop() + } + + private fun runtimeGenerationFor(skin: KeyboardSkinRef): Long = + if (skin is KeyboardSkinRef.Imported) KeyboardSkinRuntime.generation() else 0L + + fun activeSkin(): KeyboardSkinId = (skinRef as? KeyboardSkinRef.BuiltIn)?.id + ?: KeyboardSkinId.DEFAULT + + fun activeSkinRef(): KeyboardSkinRef = skinRef + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + deckDrawable?.let { drawable -> + drawable.setBounds(0, 0, width, height) + drawable.draw(canvas) + } + } + + override fun doFrame(frameTimeNanos: Long) { + frameScheduled = false + if (!shouldAnimate()) { + updateRunningCount(false) + return + } + if (startFrameNanos == 0L) startFrameNanos = frameTimeNanos + if (frameTimeNanos - lastDrawNanos >= FRAME_INTERVAL_NANOS) { + val periodNanos = KeyboardSkinCatalog.specFor(skinRef).motion.continuousPeriodMs * 1_000_000L + val phase = if (periodNanos > 0L) { + ((frameTimeNanos - startFrameNanos) % periodNanos).toFloat() / periodNanos.toFloat() + } else { + 0f + } + (deckDrawable as? PhasedKeyboardSkinDrawable)?.setPhase(phase) + invalidate() + lastDrawNanos = frameTimeNanos + } + scheduleFrame() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + updateFrameLoop() + } + + override fun onDetachedFromWindow() { + stopFrameLoop() + super.onDetachedFromWindow() + } + + override fun onWindowVisibilityChanged(visibility: Int) { + super.onWindowVisibilityChanged(visibility) + updateFrameLoop() + } + + override fun onVisibilityChanged(changedView: View, visibility: Int) { + super.onVisibilityChanged(changedView, visibility) + updateFrameLoop() + } + + private fun updateFrameLoop() { + if (shouldAnimate()) { + scheduleFrame() + updateRunningCount(true) + } else { + stopFrameLoop() + } + } + + private fun shouldAnimate(): Boolean { + if (!isAttachedToWindow || visibility != VISIBLE || windowVisibility != VISIBLE) return false + if (motionMode != KeyboardSkinMotionMode.FULL || skinRef.isDefault()) return false + if (KeyboardSkinCatalog.specFor(skinRef).motion.continuousPeriodMs <= 0L) return false + return Build.VERSION.SDK_INT < Build.VERSION_CODES.O || ValueAnimator.areAnimatorsEnabled() + } + + private fun scheduleFrame() { + if (frameScheduled) return + frameScheduled = true + Choreographer.getInstance().postFrameCallback(this) + } + + private fun stopFrameLoop() { + if (frameScheduled) { + Choreographer.getInstance().removeFrameCallback(this) + frameScheduled = false + } + startFrameNanos = 0L + lastDrawNanos = 0L + updateRunningCount(false) + } + + private fun updateRunningCount(running: Boolean) { + if (countedAsRunning == running) return + countedAsRunning = running + if (running) activeAnimatorCount.incrementAndGet() else activeAnimatorCount.decrementAndGet() + } + + companion object { + private const val FRAME_INTERVAL_NANOS = 33_333_333L + private val activeAnimatorCount = AtomicInteger(0) + + @JvmStatic + fun activeAnimatorCountForTesting(): Int = activeAnimatorCount.get() + } +} diff --git a/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinJsonParser.kt b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinJsonParser.kt new file mode 100644 index 000000000..03022f216 --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinJsonParser.kt @@ -0,0 +1,581 @@ +package com.kazumaproject.core.data.keyboard + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.Strictness +import com.google.gson.stream.JsonReader +import java.nio.ByteBuffer +import java.nio.CharBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets +import java.io.StringReader +import kotlin.math.abs +import kotlin.math.pow + +/** Strict parser for Sumire's offline, declarative keyboard skin format v1. */ +object KeyboardSkinJsonParser { + const val FORMAT = "sumire-keyboard-skin" + const val FORMAT_VERSION = 1 + const val MAX_UTF8_BYTES = 256 * 1024 + + fun parse(text: String): KeyboardSkinParseResult = parse(text.toByteArray(StandardCharsets.UTF_8)) + + fun parse(bytes: ByteArray): KeyboardSkinParseResult { + if (bytes.size > MAX_UTF8_BYTES) { + return failure("$", "UTF-8 JSON must be at most 256 KiB") + } + val decoded = try { + decodeUtf8(bytes) + } catch (_: CharacterCodingException) { + return failure("$", "入力はUTF-8である必要があります") + } + val stripped = stripAllowedWrapper(decoded) + ?: return failure("$", "JSON以外の説明文、または許可されていないコードフェンスがあります") + val root = try { + JsonReader(StringReader(stripped)).use { reader -> + reader.setStrictness(Strictness.STRICT) + JsonParser.parseReader(reader) + } + } catch (error: RuntimeException) { + return failure("$", "JSONを解析できません: ${error.message ?: "構文エラー"}") + } + if (!root.isJsonObject) return failure("$", "トップレベルはJSONオブジェクトである必要があります") + + return try { + parseRoot(root.asJsonObject, stripped) + } catch (error: ValidationFailure) { + KeyboardSkinParseResult.Failure(listOf(error.validationError)) + } catch (error: RuntimeException) { + failure("$", "JSONを検証できません: ${error.message ?: "不明なエラー"}") + } + } + + private fun parseRoot(root: JsonObject, normalizedJson: String): KeyboardSkinParseResult.Success { + requireFields( + root, + "", + setOf( + "format", "formatVersion", "id", "name", "author", "description", + "palette", "keys", "surfaces", "typography", "motion", + ), + ) + requireString(root, "format", "format").also { + if (it != FORMAT) fail("format", "must be \"$FORMAT\"") + } + requireInt(root, "formatVersion", "formatVersion").also { + if (it != FORMAT_VERSION) fail("formatVersion", "unsupported formatVersion: $it") + } + val id = requireString(root, "id", "id") + if (!KeyboardSkinIdPattern.matches(id)) { + fail("id", "must match [a-z][a-z0-9._-]{2,63}") + } + val name = requireString(root, "name", "name") + if (name.length !in 1..50) fail("name", "length must be 1..50 characters") + val author = optionalString(root, "author", "author")?.also { + if (it.length > 50) fail("author", "length must be at most 50 characters") + }?.takeIf(String::isNotEmpty) + val description = optionalString(root, "description", "description")?.also { + if (it.length > 200) fail("description", "length must be at most 200 characters") + } + + val paletteObject = requireObject(root, "palette", "palette") + val palette = parsePalette(paletteObject) + val keysObject = requireObject(root, "keys", "keys") + val baseKey = parseBaseStyle(requireObject(keysObject, "base", "keys.base"), "keys.base", palette) + val keyStyles = linkedMapOf() + keyStyles[KeyboardElementRole.CHARACTER] = baseKey + for ((jsonName, role) in KEY_ROLE_NAMES) { + val overrideObject = keysObject.get(jsonName) + keyStyles[role] = if (overrideObject == null) { + baseKey + } else { + parseStyleOverride(overrideObject.asObjectOrFail("keys.$jsonName"), "keys.$jsonName", palette) + .let(baseKey::merge) + } + } + + val surfacesObject = requireObject(root, "surfaces", "surfaces") + requireFields(surfacesObject, "surfaces", SURFACE_ROLE_NAMES.keys) + val surfaceStyles = SURFACE_ROLE_NAMES.entries.associate { (jsonName, role) -> + role to parseBaseStyle( + requireObject(surfacesObject, jsonName, "surfaces.$jsonName"), + "surfaces.$jsonName", + palette, + ) + } + + val typography = parseTypography(requireObject(root, "typography", "typography")) + val motion = parseMotion(requireObject(root, "motion", "motion")) + val warnings = contrastWarnings(palette) + val geometry = KeyboardSkinGeometry( + cornerRadiusDp = baseKey.cornerRadiusDp, + visualInsetDp = baseKey.insetDp, + strokeWidthDp = baseKey.stroke?.widthDp ?: 0f, + depthDp = baseKey.shadows.maxOfOrNull { maxOf(abs(it.offsetXDp), abs(it.offsetYDp)) } ?: 0f, + irregularityDp = baseKey.roughnessDp, + ) + val spec = KeyboardSkinSpec( + id = KeyboardSkinId.DEFAULT, + palette = palette, + geometry = geometry, + typography = typography, + material = KeyboardSkinMaterial.FLAT, + depthModel = KeyboardSkinDepthModel.NONE, + motion = motion, + popup = genericPopupSpec(palette, typography), + reference = KeyboardSkinRef.Imported(id), + keyStyles = keyStyles, + surfaceStyles = surfaceStyles, + displayName = name, + author = author, + description = description, + ) + return KeyboardSkinParseResult.Success( + ImportedKeyboardSkinDefinition(id, name, author, description, spec, warnings, normalizedJson) + ) + } + + private fun parsePalette(objectValue: JsonObject): KeyboardSkinPalette { + requireFields(objectValue, "palette", PALETTE_FIELDS) + return KeyboardSkinPalette( + backgroundColor = color(objectValue, "background", "palette.background", emptyMap()), + normalKeyColor = color(objectValue, "normalKey", "palette.normalKey", emptyMap()), + specialKeyColor = color(objectValue, "specialKey", "palette.specialKey", emptyMap()), + actionKeyColor = color(objectValue, "actionKey", "palette.actionKey", emptyMap()), + normalKeyTextColor = color(objectValue, "normalKeyText", "palette.normalKeyText", emptyMap()), + specialKeyTextColor = color(objectValue, "specialKeyText", "palette.specialKeyText", emptyMap()), + actionKeyTextColor = color(objectValue, "actionKeyText", "palette.actionKeyText", emptyMap()), + accentColor = color(objectValue, "accent", "palette.accent", emptyMap()), + secondaryAccentColor = color(objectValue, "secondaryAccent", "palette.secondaryAccent", emptyMap()), + candidateSurfaceColor = color(objectValue, "candidateSurface", "palette.candidateSurface", emptyMap()), + candidateTextColor = color(objectValue, "candidateText", "palette.candidateText", emptyMap()), + ) + } + + private fun parseBaseStyle( + objectValue: JsonObject, + path: String, + palette: KeyboardSkinPalette, + ): KeyboardSkinShapeStyle { + requireFields(objectValue, path, STYLE_FIELDS) + val shape = parseShape(requireString(objectValue, "shape", "$path.shape"), "$path.shape") + val fill = parseFill(requireObject(objectValue, "fill", "$path.fill"), "$path.fill", palette) + return KeyboardSkinShapeStyle( + shape = shape, + fill = fill, + cornerRadiusDp = number(objectValue, "cornerRadiusDp", "$path.cornerRadiusDp", 0.0, 32.0).toFloat(), + insetDp = number(objectValue, "insetDp", "$path.insetDp", 0.0, 8.0).toFloat(), + roughnessDp = number(objectValue, "roughnessDp", "$path.roughnessDp", 0.0, 3.0).toFloat(), + cutSizeDp = number(objectValue, "cutSizeDp", "$path.cutSizeDp", 0.0, 32.0).toFloat(), + stroke = objectValue.get("stroke")?.let { + parseStroke(it.asObjectOrFail("$path.stroke"), "$path.stroke", palette) + }, + shadows = objectValue.get("shadows")?.let { + parseShadows(it.asArrayOrFail("$path.shadows"), "$path.shadows", palette) + } ?: emptyList(), + decoration = objectValue.get("decoration")?.let { + parseDecoration(it.asObjectOrFail("$path.decoration"), "$path.decoration", palette) + }, + ) + } + + private fun parseStyleOverride( + objectValue: JsonObject, + path: String, + palette: KeyboardSkinPalette, + ): KeyboardSkinShapeStyleOverride { + requireFields(objectValue, path, STYLE_FIELDS) + return KeyboardSkinShapeStyleOverride( + shape = objectValue.get("shape")?.let { + parseShape(it.asStringOrFail("$path.shape"), "$path.shape") + }, + fill = objectValue.get("fill")?.let { + parseFill(it.asObjectOrFail("$path.fill"), "$path.fill", palette) + }, + cornerRadiusDp = objectValue.get("cornerRadiusDp")?.let { + number(it, "$path.cornerRadiusDp", 0.0, 32.0).toFloat() + }, + insetDp = objectValue.get("insetDp")?.let { + number(it, "$path.insetDp", 0.0, 8.0).toFloat() + }, + roughnessDp = objectValue.get("roughnessDp")?.let { + number(it, "$path.roughnessDp", 0.0, 3.0).toFloat() + }, + cutSizeDp = objectValue.get("cutSizeDp")?.let { + number(it, "$path.cutSizeDp", 0.0, 32.0).toFloat() + }, + stroke = objectValue.get("stroke")?.let { + parseStroke(it.asObjectOrFail("$path.stroke"), "$path.stroke", palette) + }, + shadows = objectValue.get("shadows")?.let { + parseShadows(it.asArrayOrFail("$path.shadows"), "$path.shadows", palette) + }, + decoration = objectValue.get("decoration")?.let { + parseDecoration(it.asObjectOrFail("$path.decoration"), "$path.decoration", palette) + }, + ) + } + + private fun parseFill(objectValue: JsonObject, path: String, palette: KeyboardSkinPalette): KeyboardSkinFill { + val type = requireString(objectValue, "type", "$path.type") + return when (type) { + "solid" -> { + requireFields(objectValue, path, setOf("type", "color")) + KeyboardSkinFill.Solid(color(objectValue, "color", "$path.color", paletteRefs(palette))) + } + "linearGradient" -> { + requireFields(objectValue, path, setOf("type", "colors", "stops", "angleDegrees")) + val colors = colors(objectValue, "colors", "$path.colors", paletteRefs(palette), 2..4) + val stops = stops(objectValue, "stops", "$path.stops", colors.size) + KeyboardSkinFill.LinearGradient( + colors, + stops, + number(objectValue, "angleDegrees", "$path.angleDegrees", 0.0, 360.0).toFloat(), + ) + } + "radialGradient" -> { + requireFields(objectValue, path, setOf("type", "colors", "stops", "centerX", "centerY", "radius")) + val colors = colors(objectValue, "colors", "$path.colors", paletteRefs(palette), 2..4) + val stops = stops(objectValue, "stops", "$path.stops", colors.size) + KeyboardSkinFill.RadialGradient( + colors, + stops, + number(objectValue, "centerX", "$path.centerX", 0.0, 1.0).toFloat(), + number(objectValue, "centerY", "$path.centerY", 0.0, 1.0).toFloat(), + number(objectValue, "radius", "$path.radius", 0.01, 1.0).toFloat(), + ) + } + else -> fail("$path.type", "unknown fill type: $type") + } + } + + private fun parseStroke(objectValue: JsonObject, path: String, palette: KeyboardSkinPalette): KeyboardSkinStroke { + requireFields(objectValue, path, setOf("color", "widthDp")) + return KeyboardSkinStroke( + color(objectValue, "color", "$path.color", paletteRefs(palette)), + number(objectValue, "widthDp", "$path.widthDp", 0.0, 4.0).toFloat(), + ) + } + + private fun parseShadows(array: JsonArray, path: String, palette: KeyboardSkinPalette): List { + if (array.size() > 2) fail(path, "at most 2 shadows are allowed") + return array.mapIndexed { index, element -> + val shadowPath = "$path[$index]" + val objectValue = element.asObjectOrFail(shadowPath) + requireFields(objectValue, shadowPath, setOf("color", "offsetXDp", "offsetYDp", "blurDp")) + KeyboardSkinShadow( + color(objectValue, "color", "$shadowPath.color", paletteRefs(palette)), + number(objectValue, "offsetXDp", "$shadowPath.offsetXDp", -8.0, 8.0).toFloat(), + number(objectValue, "offsetYDp", "$shadowPath.offsetYDp", -8.0, 8.0).toFloat(), + number(objectValue, "blurDp", "$shadowPath.blurDp", 0.0, 12.0).toFloat(), + ) + } + } + + private fun parseDecoration(objectValue: JsonObject, path: String, palette: KeyboardSkinPalette): KeyboardSkinDecoration { + requireFields(objectValue, path, setOf("type", "color", "opacity", "sizeDp", "spacingDp", "angleDegrees")) + val type = when (val value = requireString(objectValue, "type", "$path.type")) { + "none" -> KeyboardSkinDecorationType.NONE + "dots" -> KeyboardSkinDecorationType.DOTS + "grid" -> KeyboardSkinDecorationType.GRID + "stripes" -> KeyboardSkinDecorationType.STRIPES + "scanlines" -> KeyboardSkinDecorationType.SCANLINES + "speckles" -> KeyboardSkinDecorationType.SPECKLES + "weave" -> KeyboardSkinDecorationType.WEAVE + else -> fail("$path.type", "unknown decoration type: $value") + } + return KeyboardSkinDecoration( + type, + color(objectValue, "color", "$path.color", paletteRefs(palette)), + number(objectValue, "opacity", "$path.opacity", 0.0, 1.0).toFloat(), + number(objectValue, "sizeDp", "$path.sizeDp", 0.1, 8.0).toFloat(), + number(objectValue, "spacingDp", "$path.spacingDp", 0.5, 32.0).toFloat(), + number(objectValue, "angleDegrees", "$path.angleDegrees", 0.0, 360.0).toFloat(), + ) + } + + private fun parseTypography(objectValue: JsonObject): KeyboardSkinTypography { + requireFields(objectValue, "typography", setOf("font", "weight", "letterSpacing")) + val font = requireString(objectValue, "font", "typography.font") + if (font !in setOf("sans", "sansMedium", "sansCondensed", "serif", "monospace")) { + fail("typography.font", "unknown font: $font") + } + val weight = requireString(objectValue, "weight", "typography.weight") + if (weight !in setOf("normal", "medium", "bold")) fail("typography.weight", "unknown weight: $weight") + val family = when (font) { + "sans" -> "sans-serif" + "sansMedium" -> "sans-serif-medium" + "sansCondensed" -> "sans-serif-condensed" + "serif" -> "serif" + else -> "monospace" + } + return KeyboardSkinTypography( + familyName = family, + bold = weight == "bold", + letterSpacing = number(objectValue, "letterSpacing", "typography.letterSpacing", -0.1, 0.2).toFloat(), + ) + } + + private fun parseMotion(objectValue: JsonObject): KeyboardSkinMotionSpec { + requireFields(objectValue, "motion", setOf("press", "background")) + val press = requireObject(objectValue, "press", "motion.press") + requireFields(press, "motion.press", setOf("scale", "translationXDp", "translationYDp", "durationMs", "releaseDurationMs")) + val background = requireObject(objectValue, "background", "motion.background") + requireFields(background, "motion.background", setOf("type", "periodSeconds")) + val backgroundType = requireString(background, "type", "motion.background.type") + if (backgroundType !in setOf("none", "pulse", "sweep", "shift")) { + fail("motion.background.type", "unknown background animation: $backgroundType") + } + val periodSeconds = number(background, "periodSeconds", "motion.background.periodSeconds", 0.0, 30.0) + if (backgroundType != "none" && periodSeconds !in 2.0..30.0) { + fail("motion.background.periodSeconds", "animated backgrounds require 2..30 seconds") + } + return KeyboardSkinMotionSpec( + pressScale = number(press, "scale", "motion.press.scale", 0.90, 1.05).toFloat(), + pressTranslationYDp = number(press, "translationYDp", "motion.press.translationYDp", -4.0, 4.0).toFloat(), + pressTranslationXDp = number(press, "translationXDp", "motion.press.translationXDp", -4.0, 4.0).toFloat(), + pressDurationMs = integer(press, "durationMs", "motion.press.durationMs", 0, 500).toLong(), + releaseDurationMs = integer(press, "releaseDurationMs", "motion.press.releaseDurationMs", 0, 500).toLong(), + continuousPeriodMs = if (backgroundType == "none") 0L else (periodSeconds * 1000.0).toLong(), + backgroundAnimation = when (backgroundType) { + "none" -> KeyboardSkinBackgroundAnimation.NONE + "pulse" -> KeyboardSkinBackgroundAnimation.PULSE + "sweep" -> KeyboardSkinBackgroundAnimation.SWEEP + else -> KeyboardSkinBackgroundAnimation.SHIFT + }, + ) + } + + private fun contrastWarnings(palette: KeyboardSkinPalette): List = buildList { + listOf( + "palette.normalKeyText" to ratio(palette.normalKeyTextColor, palette.normalKeyColor), + "palette.specialKeyText" to ratio(palette.specialKeyTextColor, palette.specialKeyColor), + "palette.actionKeyText" to ratio(palette.actionKeyTextColor, palette.actionKeyColor), + "palette.candidateText" to ratio(palette.candidateTextColor, palette.candidateSurfaceColor), + ).forEach { (path, value) -> + if (value < 4.5) add(KeyboardSkinValidationWarning(path, "コントラスト比 ${"%.2f".format(value)}:1 は4.5:1未満です")) + } + } + + private fun ratio(foreground: Int, background: Int): Double { + fun composite(color: Int, over: Int): Int { + val alpha = alpha(color) / 255.0 + return argb( + 255, + (red(color) * alpha + red(over) * (1 - alpha)).toInt(), + (green(color) * alpha + green(over) * (1 - alpha)).toInt(), + (blue(color) * alpha + blue(over) * (1 - alpha)).toInt(), + ) + } + fun luminance(color: Int): Double { + fun channel(value: Int): Double { + val normalized = value / 255.0 + return if (normalized <= 0.03928) normalized / 12.92 else ((normalized + 0.055) / 1.055).pow(2.4) + } + return 0.2126 * channel(red(color)) + 0.7152 * channel(green(color)) + 0.0722 * channel(blue(color)) + } + val foregroundLuminance = luminance(composite(foreground, background)) + val backgroundLuminance = luminance(background) + val light = maxOf(foregroundLuminance, backgroundLuminance) + val dark = minOf(foregroundLuminance, backgroundLuminance) + return (light + 0.05) / (dark + 0.05) + } + + private fun genericPopupSpec(palette: KeyboardSkinPalette, typography: KeyboardSkinTypography) = KeyboardSkinPopupSpec( + surfaceColor = palette.specialKeyColor, + selectedSurfaceColor = palette.accentColor, + textColor = palette.specialKeyTextColor, + selectedTextColor = palette.actionKeyTextColor, + secondaryTextColor = palette.normalKeyTextColor, + shadowColor = 0xFF000000.toInt(), + shadowAlpha = 48, + selectedShadowAlpha = 64, + cornerRadiusDp = 8f, + strokeWidthDp = 0f, + stemWidthDp = 10f, + stemHeightDp = 6f, + contentPaddingHorizontalDp = 10f, + contentPaddingVerticalDp = 6f, + itemGapDp = 4f, + keyPreviewWidthScale = 2f, + keyPreviewHeightScale = 2f, + keyPreviewTextSizeSp = if (typography.bold) 28f else 26f, + variationTextSizeSp = 26f, + flickTextSizeSp = 22f, + ) + + private fun paletteRefs(palette: KeyboardSkinPalette): Map = mapOf( + "background" to palette.backgroundColor, + "normalKey" to palette.normalKeyColor, + "specialKey" to palette.specialKeyColor, + "actionKey" to palette.actionKeyColor, + "normalKeyText" to palette.normalKeyTextColor, + "specialKeyText" to palette.specialKeyTextColor, + "actionKeyText" to palette.actionKeyTextColor, + "accent" to palette.accentColor, + "secondaryAccent" to palette.secondaryAccentColor, + "candidateSurface" to palette.candidateSurfaceColor, + "candidateText" to palette.candidateTextColor, + ) + + private fun colors( + objectValue: JsonObject, + key: String, + path: String, + refs: Map, + range: IntRange, + ): List { + val array = requireArray(objectValue, key, path) + if (array.size() !in range) fail(path, "must contain ${range.first}..${range.last} colors") + return array.mapIndexed { index, item -> parseColor(item, "$path[$index]", refs) } + } + + private fun stops(objectValue: JsonObject, key: String, path: String, expected: Int): List { + val array = requireArray(objectValue, key, path) + if (array.size() != expected) fail(path, "must contain one stop per color") + val values = array.mapIndexed { index, item -> number(item, "$path[$index]", 0.0, 1.0).toFloat() } + if (values.firstOrNull() != 0f || values.lastOrNull() != 1f || values.zipWithNext().any { it.first >= it.second }) { + fail(path, "stops must start at 0, end at 1, and be strictly increasing") + } + return values + } + + private fun color(objectValue: JsonObject, key: String, path: String, refs: Map): Int = + parseColor(objectValue.get(key) ?: fail(path, "is required"), path, refs) + + private fun parseColor(element: JsonElement, path: String, refs: Map): Int { + val value = element.asStringOrFail(path) + if (value.startsWith("@palette.")) { + return refs[value.removePrefix("@palette.")] ?: fail(path, "unknown palette reference: $value") + } + if (!value.matches(Regex("#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?"))) { + fail(path, "must be #RRGGBB, #AARRGGBB, or @palette.") + } + val hex = value.removePrefix("#") + val argbHex = if (hex.length == 6) "FF$hex" else hex + return argbHex.toLong(16).toInt() + } + + private fun parseShape(value: String, path: String): KeyboardSkinShape = when (value) { + "roundedRect" -> KeyboardSkinShape.ROUNDED_RECT + "capsule" -> KeyboardSkinShape.CAPSULE + "cutCorner" -> KeyboardSkinShape.CUT_CORNER + "hexagon" -> KeyboardSkinShape.HEXAGON + "pixelNotched" -> KeyboardSkinShape.PIXEL_NOTCHED + "roughRect" -> KeyboardSkinShape.ROUGH_RECT + else -> fail(path, "unknown shape: $value") + } + + private fun requireFields(objectValue: JsonObject, path: String, allowed: Set) { + objectValue.keySet().firstOrNull { it !in allowed }?.let { + fail(if (path.isEmpty()) it else "$path.$it", "unknown field") + } + } + + private fun requireString(objectValue: JsonObject, key: String, path: String): String = + (objectValue.get(key) ?: fail(path, "is required")).asStringOrFail(path) + + private fun optionalString(objectValue: JsonObject, key: String, path: String): String? = + objectValue.get(key)?.asStringOrFail(path) + + private fun requireInt(objectValue: JsonObject, key: String, path: String): Int = + integer(objectValue, key, path, Int.MIN_VALUE, Int.MAX_VALUE) + + private fun integer(objectValue: JsonObject, key: String, path: String, min: Int, max: Int): Int = + number(objectValue, key, path, min.toDouble(), max.toDouble()).let { + if (it % 1.0 != 0.0) fail(path, "must be an integer") + it.toInt() + } + + private fun number(objectValue: JsonObject, key: String, path: String, min: Double, max: Double): Double = + number(objectValue.get(key) ?: fail(path, "is required"), path, min, max) + + private fun number(element: JsonElement, path: String, min: Double, max: Double): Double { + if (!element.isJsonPrimitive || !element.asJsonPrimitive.isNumber) fail(path, "must be a number") + val value = element.asDouble + if (!value.isFinite()) fail(path, "must be finite") + if (value !in min..max) fail(path, "must be in $min..$max") + return value + } + + private fun requireObject(objectValue: JsonObject, key: String, path: String): JsonObject = + (objectValue.get(key) ?: fail(path, "is required")).asObjectOrFail(path) + + private fun requireArray(objectValue: JsonObject, key: String, path: String): JsonArray = + (objectValue.get(key) ?: fail(path, "is required")).asArrayOrFail(path) + + private fun JsonElement.asObjectOrFail(path: String): JsonObject = + if (isJsonObject) asJsonObject else fail(path, "must be an object") + + private fun JsonElement.asArrayOrFail(path: String): JsonArray = + if (isJsonArray) asJsonArray else fail(path, "must be an array") + + private fun JsonElement.asStringOrFail(path: String): String = + if (isJsonPrimitive && asJsonPrimitive.isString) asString else fail(path, "must be a string") + + private fun decodeUtf8(bytes: ByteArray): String { + val decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + val buffer: CharBuffer = decoder.decode(ByteBuffer.wrap(bytes)) + return buffer.toString() + } + + private fun stripAllowedWrapper(value: String): String? { + var result = value.removePrefix("\uFEFF").trim() + if (result.startsWith("```") || result.endsWith("```")) { + val prefix = "```json" + if (!result.startsWith(prefix) || !result.endsWith("```")) return null + result = result.substring(prefix.length, result.length - 3).trim() + } + if (result.contains("```")) return null + return result + } + + private fun failure(path: String, message: String) = + KeyboardSkinParseResult.Failure(listOf(KeyboardSkinValidationError(path, message))) + + private fun fail(path: String, message: String): Nothing = + throw ValidationFailure(KeyboardSkinValidationError(path.ifEmpty { "$" }, message)) + + private class ValidationFailure(val validationError: KeyboardSkinValidationError) : RuntimeException() + + private val PALETTE_FIELDS = setOf( + "background", "normalKey", "specialKey", "actionKey", "normalKeyText", "specialKeyText", + "actionKeyText", "accent", "secondaryAccent", "candidateSurface", "candidateText", + ) + private val STYLE_FIELDS = setOf( + "shape", "fill", "cornerRadiusDp", "insetDp", "roughnessDp", "cutSizeDp", "stroke", "shadows", "decoration", + ) + private val KEY_ROLE_NAMES = linkedMapOf( + "character" to KeyboardElementRole.CHARACTER, + "modifier" to KeyboardElementRole.MODIFIER, + "action" to KeyboardElementRole.ACTION, + "space" to KeyboardElementRole.SPACE, + "candidate" to KeyboardElementRole.CANDIDATE, + "toolbar" to KeyboardElementRole.TOOLBAR, + "popup" to KeyboardElementRole.POPUP, + ) + private val SURFACE_ROLE_NAMES = linkedMapOf( + "deck" to KeyboardSurfaceRole.DECK, + "candidateStrip" to KeyboardSurfaceRole.CANDIDATE_STRIP, + "candidatePanel" to KeyboardSurfaceRole.CANDIDATE_PANEL, + "toolbar" to KeyboardSurfaceRole.TOOLBAR, + "popup" to KeyboardSurfaceRole.POPUP, + ) + + private fun alpha(color: Int): Int = color ushr 24 and 0xFF + private fun red(color: Int): Int = color ushr 16 and 0xFF + private fun green(color: Int): Int = color ushr 8 and 0xFF + private fun blue(color: Int): Int = color and 0xFF + private fun argb(alpha: Int, red: Int, green: Int, blue: Int): Int = + (alpha.coerceIn(0, 255) shl 24) or + (red.coerceIn(0, 255) shl 16) or + (green.coerceIn(0, 255) shl 8) or + blue.coerceIn(0, 255) +} + +private val KeyboardSkinIdPattern = Regex("[a-z][a-z0-9._-]{2,63}") diff --git a/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinPopupRenderer.kt b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinPopupRenderer.kt new file mode 100644 index 000000000..4ccfaa2da --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinPopupRenderer.kt @@ -0,0 +1,515 @@ +package com.kazumaproject.core.data.keyboard + +import android.content.Context +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.os.Build +import android.util.TypedValue +import android.view.View +import android.widget.TextView +import kotlin.math.max +import kotlin.math.min + +/** Shared popup factory used by all built-in keyboard implementations. */ +object KeyboardSkinPopupRenderer { + + fun specFor(skinId: KeyboardSkinId): KeyboardSkinPopupSpec? = + KeyboardSkinCatalog.specFor(skinId).popup + + fun specFor(skinRef: KeyboardSkinRef): KeyboardSkinPopupSpec? = + KeyboardSkinCatalog.specFor(skinRef).popup + + fun isFixedCupertino(skinId: KeyboardSkinId): Boolean = + specFor(skinId) != null + + fun isFixedCupertino(skinRef: KeyboardSkinRef): Boolean = + specFor(skinRef) != null + + fun createDrawable( + context: Context, + skinId: KeyboardSkinId, + kind: KeyboardSkinPopupKind, + direction: KeyboardSkinPopupDirection = KeyboardSkinPopupDirection.CENTER, + selected: Boolean = false, + ): Drawable? { + val popup = specFor(skinId) ?: return null + return KeyboardSkinPopupDrawable(context, popup, kind, direction, selected) + } + + fun createDrawable( + context: Context, + skinId: KeyboardSkinRef, + kind: KeyboardSkinPopupKind, + direction: KeyboardSkinPopupDirection = KeyboardSkinPopupDirection.CENTER, + selected: Boolean = false, + ): Drawable? { + val popup = specFor(skinId) ?: return null + val importedSurface = if (skinId is KeyboardSkinRef.Imported) { + KeyboardSkinRendererRegistry.rendererFor(skinId) + .createSurfaceDrawable(context, KeyboardSurfaceRole.POPUP) + } else { + null + } + return KeyboardSkinPopupDrawable( + context = context, + spec = popup, + kind = kind, + direction = direction, + selected = selected, + surfaceDrawable = importedSurface, + ) + } + + /** Applies the fixed iOS typography. Returns false when the skin has no popup spec. */ + fun applyTextStyle( + view: TextView, + skinId: KeyboardSkinId, + kind: KeyboardSkinPopupKind, + selected: Boolean = false, + ): Boolean { + val popup = specFor(skinId) ?: return false + val skin = KeyboardSkinCatalog.specFor(skinId) + view.setTextColor(if (selected) popup.selectedTextColor else popup.textColor) + view.typeface = Typeface.create( + skin.typography.familyName, + if (skin.typography.bold) Typeface.BOLD else Typeface.NORMAL, + ) + view.includeFontPadding = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + view.letterSpacing = skin.typography.letterSpacing + } + view.setTextSize( + TypedValue.COMPLEX_UNIT_SP, + when (kind) { + KeyboardSkinPopupKind.KEY_PREVIEW -> popup.keyPreviewTextSizeSp + KeyboardSkinPopupKind.VARIATION -> popup.variationTextSizeSp + else -> popup.flickTextSizeSp + }, + ) + val horizontal = dp(view.context, popup.contentPaddingHorizontalDp).toInt() + val vertical = dp(view.context, popup.contentPaddingVerticalDp).toInt() + view.setPadding(horizontal, vertical, horizontal, vertical) + return true + } + + fun applyTextStyle( + view: TextView, + skinId: KeyboardSkinRef, + kind: KeyboardSkinPopupKind, + selected: Boolean = false, + ): Boolean { + val popup = specFor(skinId) ?: return false + val skin = KeyboardSkinCatalog.specFor(skinId) + view.setTextColor(if (selected) popup.selectedTextColor else popup.textColor) + view.typeface = Typeface.create( + skin.typography.familyName, + if (skin.typography.bold) Typeface.BOLD else Typeface.NORMAL, + ) + view.includeFontPadding = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + view.letterSpacing = skin.typography.letterSpacing + } + view.setTextSize( + TypedValue.COMPLEX_UNIT_SP, + when (kind) { + KeyboardSkinPopupKind.KEY_PREVIEW -> popup.keyPreviewTextSizeSp + KeyboardSkinPopupKind.VARIATION -> popup.variationTextSizeSp + else -> popup.flickTextSizeSp + }, + ) + val horizontal = dp(view.context, popup.contentPaddingHorizontalDp).toInt() + val vertical = dp(view.context, popup.contentPaddingVerticalDp).toInt() + view.setPadding(horizontal, vertical, horizontal, vertical) + return true + } + + fun popupTextSizeSp(skinId: KeyboardSkinId, kind: KeyboardSkinPopupKind): Float? { + val popup = specFor(skinId) ?: return null + return when (kind) { + KeyboardSkinPopupKind.KEY_PREVIEW -> popup.keyPreviewTextSizeSp + KeyboardSkinPopupKind.VARIATION -> popup.variationTextSizeSp + else -> popup.flickTextSizeSp + } + } + + fun popupTextSizeSp(skinId: KeyboardSkinRef, kind: KeyboardSkinPopupKind): Float? { + val popup = specFor(skinId) ?: return null + return when (kind) { + KeyboardSkinPopupKind.KEY_PREVIEW -> popup.keyPreviewTextSizeSp + KeyboardSkinPopupKind.VARIATION -> popup.variationTextSizeSp + else -> popup.flickTextSizeSp + } + } + + /** Draws a shared popup surface while preserving a caller-owned operation path. */ + @Suppress("UNUSED_PARAMETER") + fun drawPath( + canvas: Canvas, + context: Context, + skinId: KeyboardSkinId, + _kind: KeyboardSkinPopupKind, + path: Path, + selected: Boolean = false, + ): Boolean { + val popup = specFor(skinId) ?: return false + val density = context.resources.displayMetrics.density + val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = popup.shadowColor + alpha = (if (selected) popup.selectedShadowAlpha else popup.shadowAlpha) + style = Paint.Style.FILL + } + val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = if (selected) popup.selectedSurfaceColor else popup.surfaceColor + style = Paint.Style.FILL + } + canvas.save() + canvas.translate(0f, density) + canvas.drawPath(path, shadowPaint) + canvas.restore() + canvas.drawPath(path, fillPaint) + if (popup.strokeWidthDp > 0f) { + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = if (selected) popup.selectedSurfaceColor else popup.surfaceColor + style = Paint.Style.STROKE + strokeWidth = popup.strokeWidthDp * density + }.also { canvas.drawPath(path, it) } + } + return true + } + + fun drawPath( + canvas: Canvas, + context: Context, + skinId: KeyboardSkinRef, + _kind: KeyboardSkinPopupKind, + path: Path, + selected: Boolean = false, + ): Boolean { + val popup = specFor(skinId) ?: return false + val density = context.resources.displayMetrics.density + val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = popup.shadowColor + alpha = if (selected) popup.selectedShadowAlpha else popup.shadowAlpha + style = Paint.Style.FILL + } + val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = if (selected) popup.selectedSurfaceColor else popup.surfaceColor + style = Paint.Style.FILL + } + canvas.save() + canvas.translate(0f, density) + canvas.drawPath(path, shadowPaint) + canvas.restore() + canvas.drawPath(path, fillPaint) + if (popup.strokeWidthDp > 0f) { + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = if (selected) popup.selectedSurfaceColor else popup.surfaceColor + style = Paint.Style.STROKE + strokeWidth = popup.strokeWidthDp * density + }.also { canvas.drawPath(path, it) } + } + return true + } + + /** Draws a shared rounded surface for Canvas-based custom popup variants. */ + fun drawRoundRect( + canvas: Canvas, + context: Context, + skinId: KeyboardSkinId, + kind: KeyboardSkinPopupKind, + bounds: RectF, + selected: Boolean = false, + direction: KeyboardSkinPopupDirection = KeyboardSkinPopupDirection.CENTER, + ): Boolean { + val drawable = createDrawable(context, skinId, kind, direction, selected) ?: return false + drawable.setBounds( + bounds.left.toInt(), + bounds.top.toInt(), + bounds.right.toInt(), + bounds.bottom.toInt(), + ) + drawable.draw(canvas) + return true + } + + fun drawRoundRect( + canvas: Canvas, + context: Context, + skinId: KeyboardSkinRef, + kind: KeyboardSkinPopupKind, + bounds: RectF, + selected: Boolean = false, + direction: KeyboardSkinPopupDirection = KeyboardSkinPopupDirection.CENTER, + ): Boolean { + val drawable = createDrawable(context, skinId, kind, direction, selected) ?: return false + drawable.setBounds(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()) + drawable.draw(canvas) + return true + } + + /** Applies the shared popup typography to a Canvas paint. */ + fun applyPaintStyle( + paint: Paint, + context: Context, + skinId: KeyboardSkinId, + kind: KeyboardSkinPopupKind, + selected: Boolean = false, + ): Boolean { + val popup = specFor(skinId) ?: return false + val skin = KeyboardSkinCatalog.specFor(skinId) + paint.color = if (selected) popup.selectedTextColor else popup.textColor + paint.typeface = Typeface.create( + skin.typography.familyName, + if (skin.typography.bold) Typeface.BOLD else Typeface.NORMAL, + ) + paint.textSize = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + popupTextSizeSp(skinId, kind) ?: return false, + context.resources.displayMetrics, + ) + return true + } + + fun applyPaintStyle( + paint: Paint, + context: Context, + skinId: KeyboardSkinRef, + kind: KeyboardSkinPopupKind, + selected: Boolean = false, + ): Boolean { + val popup = specFor(skinId) ?: return false + val skin = KeyboardSkinCatalog.specFor(skinId) + paint.color = if (selected) popup.selectedTextColor else popup.textColor + paint.typeface = Typeface.create( + skin.typography.familyName, + if (skin.typography.bold) Typeface.BOLD else Typeface.NORMAL, + ) + paint.textSize = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + popupTextSizeSp(skinId, kind) ?: return false, + context.resources.displayMetrics, + ) + return true + } + + fun dp(context: Context, value: Float): Float = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value, + context.resources.displayMetrics, + ) + + fun centeredXOffset(anchorWidth: Int, popupWidth: Int): Int = + (anchorWidth - popupWidth) / 2 + + fun aboveYOffset(popupHeight: Int, gapDp: Float, context: Context): Int = + -popupHeight - dp(context, gapDp).toInt() + + fun clampXOffset( + anchorLeft: Int, + popupWidth: Int, + viewportWidth: Int, + marginDp: Float, + context: Context, + ): Int { + val margin = dp(context, marginDp).toInt() + return anchorLeft.coerceIn(margin, max(margin, viewportWidth - popupWidth - margin)) + } +} + +/** + * Canvas implementation of the Cupertino popup surfaces. The drawable deliberately does not use + * Material gradients or the legacy KeyWindowLayout arrow renderer. + */ +class KeyboardSkinPopupDrawable( + context: Context, + private val spec: KeyboardSkinPopupSpec, + private val kind: KeyboardSkinPopupKind, + direction: KeyboardSkinPopupDirection, + selected: Boolean, + private val surfaceDrawable: Drawable? = null, +) : Drawable() { + + private val density = context.resources.displayMetrics.density + private val path = Path() + private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val boundsF = RectF() + private var direction = direction + private var selected = selected + private var drawableAlpha = 255 + private var drawableColorFilter: ColorFilter? = null + + override fun draw(canvas: Canvas) { + if (boundsF.isEmpty) return + surfaceDrawable?.let { importedSurface -> + importedSurface.setBounds(bounds) + importedSurface.alpha = drawableAlpha + importedSurface.colorFilter = drawableColorFilter + importedSurface.state = if (selected) { + intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled) + } else { + intArrayOf() + } + importedSurface.draw(canvas) + return + } + val shadowAlpha = if (selected) spec.selectedShadowAlpha else spec.shadowAlpha + shadowPaint.color = spec.shadowColor + shadowPaint.alpha = shadowAlpha * drawableAlpha / 255 + shadowPaint.colorFilter = drawableColorFilter + canvas.save() + canvas.translate(0f, dp(1f)) + canvas.drawPath(path, shadowPaint) + canvas.restore() + + fillPaint.color = if (selected) spec.selectedSurfaceColor else spec.surfaceColor + fillPaint.alpha = drawableAlpha + fillPaint.colorFilter = drawableColorFilter + canvas.drawPath(path, fillPaint) + + if (spec.strokeWidthDp > 0f) { + strokePaint.color = if (selected) spec.selectedSurfaceColor else spec.surfaceColor + strokePaint.alpha = drawableAlpha + strokePaint.strokeWidth = dp(spec.strokeWidthDp) + strokePaint.colorFilter = drawableColorFilter + canvas.drawPath(path, strokePaint) + } + } + + override fun onBoundsChange(bounds: Rect) { + boundsF.set(bounds) + surfaceDrawable?.bounds = bounds + rebuildPath() + } + + fun setDirection(direction: KeyboardSkinPopupDirection) { + if (this.direction == direction) return + this.direction = direction + rebuildPath() + invalidateSelf() + } + + fun setSelected(selected: Boolean) { + if (this.selected == selected) return + this.selected = selected + surfaceDrawable?.state = if (selected) { + intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled) + } else { + intArrayOf() + } + invalidateSelf() + } + + private fun rebuildPath() { + path.reset() + if (boundsF.isEmpty) return + val radius = dp(spec.cornerRadiusDp) + val stemWidth = dp(spec.stemWidthDp) + val stemHeight = dp(spec.stemHeightDp) + + when { + kind == KeyboardSkinPopupKind.KEY_PREVIEW -> { + val bodyBottom = boundsF.bottom - stemHeight + path.addRoundRect( + boundsF.left, + boundsF.top, + boundsF.right, + bodyBottom, + radius, + radius, + Path.Direction.CW, + ) + addBottomStem(boundsF.centerX(), bodyBottom, stemWidth, stemHeight) + } + + kind == KeyboardSkinPopupKind.FLICK_DIRECTIONAL -> { + addDirectionalPath(radius, stemWidth, stemHeight) + } + + else -> path.addRoundRect(boundsF, radius, radius, Path.Direction.CW) + } + } + + private fun addBottomStem(centerX: Float, bodyBottom: Float, width: Float, height: Float) { + path.moveTo(centerX - width / 2f, bodyBottom - dp(0.5f)) + path.lineTo(centerX, bodyBottom + height) + path.lineTo(centerX + width / 2f, bodyBottom - dp(0.5f)) + path.close() + } + + private fun addDirectionalPath(radius: Float, pointerWidth: Float, pointerHeight: Float) { + val left = boundsF.left + val top = boundsF.top + val right = boundsF.right + val bottom = boundsF.bottom + when (direction) { + KeyboardSkinPopupDirection.UP -> { + val bodyTop = top + pointerHeight + path.addRoundRect(left, bodyTop, right, bottom, radius, radius, Path.Direction.CW) + path.moveTo(boundsF.centerX() - pointerWidth / 2f, bodyTop) + path.lineTo(boundsF.centerX(), top) + path.lineTo(boundsF.centerX() + pointerWidth / 2f, bodyTop) + path.close() + } + + KeyboardSkinPopupDirection.DOWN -> { + val bodyBottom = bottom - pointerHeight + path.addRoundRect(left, top, right, bodyBottom, radius, radius, Path.Direction.CW) + path.moveTo(boundsF.centerX() - pointerWidth / 2f, bodyBottom) + path.lineTo(boundsF.centerX(), bottom) + path.lineTo(boundsF.centerX() + pointerWidth / 2f, bodyBottom) + path.close() + } + + KeyboardSkinPopupDirection.LEFT -> { + val bodyLeft = left + pointerHeight + path.addRoundRect(bodyLeft, top, right, bottom, radius, radius, Path.Direction.CW) + path.moveTo(bodyLeft, boundsF.centerY() - pointerWidth / 2f) + path.lineTo(left, boundsF.centerY()) + path.lineTo(bodyLeft, boundsF.centerY() + pointerWidth / 2f) + path.close() + } + + KeyboardSkinPopupDirection.RIGHT -> { + val bodyRight = right - pointerHeight + path.addRoundRect(left, top, bodyRight, bottom, radius, radius, Path.Direction.CW) + path.moveTo(bodyRight, boundsF.centerY() - pointerWidth / 2f) + path.lineTo(right, boundsF.centerY()) + path.lineTo(bodyRight, boundsF.centerY() + pointerWidth / 2f) + path.close() + } + + KeyboardSkinPopupDirection.CENTER -> path.addRoundRect( + boundsF, + radius, + radius, + Path.Direction.CW, + ) + } + } + + override fun setAlpha(alpha: Int) { + drawableAlpha = alpha.coerceIn(0, 255) + surfaceDrawable?.alpha = drawableAlpha + invalidateSelf() + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + drawableColorFilter = colorFilter + surfaceDrawable?.colorFilter = colorFilter + invalidateSelf() + } + + @Deprecated("Drawable opacity is not used by the skin renderer") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + private fun dp(value: Float): Float = value * density +} diff --git a/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinPreviewView.kt b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinPreviewView.kt new file mode 100644 index 000000000..cd70f32de --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinPreviewView.kt @@ -0,0 +1,333 @@ +package com.kazumaproject.core.data.keyboard + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.os.Build +import android.util.AttributeSet +import android.view.Choreographer +import android.view.View + +/** Compact preview that renders through the same catalog and drawables as the real IME. */ +class KeyboardSkinPreviewView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : View(context, attrs), Choreographer.FrameCallback { + private val density = resources.displayMetrics.density + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER } + private val keyRect = RectF() + private val popupRect = RectF() + private var skinRef: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var motionMode = KeyboardSkinMotionMode.OFF + private var runtimeGeneration = KeyboardSkinRuntime.generation() + private var deck = renderer().createSurfaceDrawable(context, KeyboardSurfaceRole.DECK) + private var candidateStrip = renderer().createSurfaceDrawable(context, KeyboardSurfaceRole.CANDIDATE_STRIP) + private var characterKey = renderer().createKeyDrawable(context, KeyboardElementRole.CHARACTER, 1) + private var pressedCharacterKey = renderer().createKeyDrawable(context, KeyboardElementRole.CHARACTER, 2).apply { + state = intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled) + } + private var modifierKey = renderer().createKeyDrawable(context, KeyboardElementRole.MODIFIER, 3) + private var actionKey = renderer().createKeyDrawable(context, KeyboardElementRole.ACTION, 4) + private var spaceKey = renderer().createKeyDrawable(context, KeyboardElementRole.SPACE, 5) + private var popupKey = renderer().createKeyDrawable(context, KeyboardElementRole.POPUP, 6) + private var frameScheduled = false + private var startNanos = 0L + private var lastDrawNanos = 0L + + init { + setWillNotDraw(false) + isClickable = false + isFocusable = false + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + updateTypography() + } + + fun setSkin( + skin: KeyboardSkinId, + motion: KeyboardSkinMotionMode = motionMode, + ) = setSkin(KeyboardSkinRef.BuiltIn(skin), motion) + + fun setSkin( + skin: KeyboardSkinRef, + motion: KeyboardSkinMotionMode = motionMode, + ) { + val nextRuntimeGeneration = runtimeGenerationFor(skin) + if ( + skin == skinRef && + motion == motionMode && + nextRuntimeGeneration == runtimeGeneration + ) return + stopFrames() + skinRef = skin + motionMode = motion + runtimeGeneration = nextRuntimeGeneration + val renderer = renderer() + deck = renderer.createSurfaceDrawable(context, KeyboardSurfaceRole.DECK) + candidateStrip = renderer.createSurfaceDrawable(context, KeyboardSurfaceRole.CANDIDATE_STRIP) + characterKey = renderer.createKeyDrawable(context, KeyboardElementRole.CHARACTER, 1) + pressedCharacterKey = renderer.createKeyDrawable(context, KeyboardElementRole.CHARACTER, 2).apply { + state = intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled) + } + modifierKey = renderer.createKeyDrawable(context, KeyboardElementRole.MODIFIER, 3) + actionKey = renderer.createKeyDrawable(context, KeyboardElementRole.ACTION, 4) + spaceKey = renderer.createKeyDrawable(context, KeyboardElementRole.SPACE, 5) + popupKey = renderer.createKeyDrawable(context, KeyboardElementRole.POPUP, 6) + updateTypography() + invalidate() + updateFrames() + } + + private fun runtimeGenerationFor(skin: KeyboardSkinRef): Long = + if (skin is KeyboardSkinRef.Imported) KeyboardSkinRuntime.generation() else 0L + + fun setMotionMode(mode: KeyboardSkinMotionMode) { + setSkin(skinRef, mode) + } + + fun currentSkin(): KeyboardSkinId = (skinRef as? KeyboardSkinRef.BuiltIn)?.id + ?: KeyboardSkinId.DEFAULT + + fun currentSkinRef(): KeyboardSkinRef = skinRef + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (width <= 0 || height <= 0) return + deck.setBounds(0, 0, width, height) + deck.draw(canvas) + + val outer = dp(5f) + val candidateHeight = height * 0.19f + candidateStrip.setBounds( + outer.toInt(), + outer.toInt(), + (width - outer).toInt(), + (outer + candidateHeight).toInt(), + ) + candidateStrip.draw(canvas) + drawCandidateText(canvas, candidateHeight, outer) + + val top = outer + candidateHeight + dp(3f) + val bottom = height - outer + val availableHeight = bottom - top + val rowGap = dp(1.5f) + val rowHeight = (availableHeight - rowGap * 3f) / 4f + drawFiveKeyRow(canvas, 0, top, rowHeight, ROW_ONE) + drawFiveKeyRow(canvas, 1, top + rowHeight + rowGap, rowHeight, ROW_TWO) + drawFunctionRow(canvas, top + (rowHeight + rowGap) * 2f, rowHeight) + drawBottomRow(canvas, top + (rowHeight + rowGap) * 3f, rowHeight) + + if (showsPressedKeyPopup(skinRef)) { + drawPressedKeyPopup(canvas, top, rowHeight) + } + } + + private fun drawCandidateText(canvas: Canvas, candidateHeight: Float, outer: Float) { + val spec = KeyboardSkinCatalog.specFor(skinRef) + textPaint.color = spec.palette.candidateTextColor + textPaint.textSize = (candidateHeight * 0.34f).coerceAtLeast(dp(7f)) + val baseline = outer + candidateHeight * 0.64f - (textPaint.descent() + textPaint.ascent()) * 0.08f + canvas.drawText("こんにちは", width * 0.2f, baseline, textPaint) + canvas.drawText("今日は", width * 0.5f, baseline, textPaint) + canvas.drawText("ありがとう", width * 0.8f, baseline, textPaint) + } + + private fun drawFiveKeyRow( + canvas: Canvas, + row: Int, + top: Float, + height: Float, + labels: Array, + ) { + val gap = dp(1.2f) + val outer = dp(4f) + val keyWidth = (width - outer * 2f - gap * 4f) / 5f + for (column in 0 until 5) { + val left = outer + column * (keyWidth + gap) + keyRect.set(left, top, left + keyWidth, top + height) + val isPressed = row == 0 && column == 3 + drawKey( + canvas, + if (isPressed) pressedCharacterKey else characterKey, + KeyboardElementRole.CHARACTER, + labels[column], + keyRect, + pressed = isPressed, + ) + } + } + + private fun drawFunctionRow(canvas: Canvas, top: Float, height: Float) { + val labels = FUNCTION_LABELS + val gap = dp(1.2f) + val outer = dp(4f) + val keyWidth = (width - outer * 2f - gap * (labels.size - 1)) / labels.size + for (column in labels.indices) { + val left = outer + column * (keyWidth + gap) + keyRect.set(left, top, left + keyWidth, top + height) + val role = if (column == 3) KeyboardElementRole.SPACE else KeyboardElementRole.MODIFIER + drawKey(canvas, if (role == KeyboardElementRole.SPACE) spaceKey else modifierKey, role, labels[column], keyRect) + } + } + + private fun drawBottomRow(canvas: Canvas, top: Float, height: Float) { + val outer = dp(4f) + val gap = dp(1.2f) + val units = BOTTOM_UNITS + val totalUnits = units.sum() + val unitWidth = (width - outer * 2f - gap * 4f) / totalUnits + var left = outer + for (index in units.indices) { + val keyWidth = unitWidth * units[index] + keyRect.set(left, top, left + keyWidth, top + height) + val role = when (index) { + 2 -> KeyboardElementRole.SPACE + 4 -> KeyboardElementRole.ACTION + else -> KeyboardElementRole.MODIFIER + } + val drawable = when (role) { + KeyboardElementRole.SPACE -> spaceKey + KeyboardElementRole.ACTION -> actionKey + else -> modifierKey + } + drawKey(canvas, drawable, role, BOTTOM_LABELS[index], keyRect) + left += keyWidth + gap + } + } + + private fun drawKey( + canvas: Canvas, + drawable: android.graphics.drawable.Drawable, + role: KeyboardElementRole, + label: String, + bounds: RectF, + pressed: Boolean = false, + ) { + drawable.setBounds(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()) + drawable.draw(canvas) + val spec = KeyboardSkinCatalog.specFor(skinRef) + textPaint.color = if (pressed && skinRef.isBuiltIn(KeyboardSkinId.FLAT)) { + Color.WHITE + } else if (pressed && skinRef.isBuiltIn(KeyboardSkinId.TERMINAL)) { + spec.palette.backgroundColor + } else if (pressed && skinRef.isBuiltIn(KeyboardSkinId.MONOCHROME_LCD)) { + spec.palette.normalKeyColor + } else { + spec.palette.textColor(role) + } + textPaint.textSize = (bounds.height() * 0.33f).coerceAtLeast(dp(6f)) + val baseline = bounds.centerY() - (textPaint.ascent() + textPaint.descent()) / 2f + canvas.drawText(label, bounds.centerX(), baseline, textPaint) + } + + private fun drawPressedKeyPopup(canvas: Canvas, keyTop: Float, keyHeight: Float) { + val gap = dp(1.2f) + val outer = dp(4f) + val keyWidth = (width - outer * 2f - gap * 4f) / 5f + val left = outer + 3f * (keyWidth + gap) - keyWidth * 0.15f + popupRect.set( + left, + keyTop - keyHeight * 0.82f, + left + keyWidth * 1.3f, + keyTop + keyHeight * 0.18f, + ) + popupKey.setBounds( + popupRect.left.toInt(), + popupRect.top.toInt(), + popupRect.right.toInt(), + popupRect.bottom.toInt(), + ) + popupKey.draw(canvas) + textPaint.color = KeyboardSkinCatalog.specFor(skinRef).palette.specialKeyTextColor + textPaint.textSize = keyHeight * 0.48f + val baseline = popupRect.centerY() - (textPaint.ascent() + textPaint.descent()) / 2f - dp(1f) + canvas.drawText("た", popupRect.centerX(), baseline, textPaint) + } + + private fun updateTypography() { + val typography = KeyboardSkinCatalog.specFor(skinRef).typography + textPaint.typeface = Typeface.create( + typography.familyName, + if (typography.bold) Typeface.BOLD else Typeface.NORMAL, + ) + } + + private fun renderer(): KeyboardSkinRenderer = KeyboardSkinRendererRegistry.rendererFor(skinRef) + + private fun showsPressedKeyPopup(skin: KeyboardSkinRef): Boolean = + skin is KeyboardSkinRef.Imported || skin.isBuiltIn(KeyboardSkinId.CUPERTINO) || + skin.isBuiltIn(KeyboardSkinId.CUPERTINO_DARK) || + skin.isBuiltIn(KeyboardSkinId.SUMI_HANSHI) || + skin.isBuiltIn(KeyboardSkinId.LETTERPRESS) || + skin.isBuiltIn(KeyboardSkinId.PORCELAIN) || + skin.isBuiltIn(KeyboardSkinId.URUSHI) || + skin.isBuiltIn(KeyboardSkinId.CHALKBOARD) || + skin.isBuiltIn(KeyboardSkinId.LINEN) || + skin.isBuiltIn(KeyboardSkinId.MONOCHROME_LCD) + + override fun doFrame(frameTimeNanos: Long) { + frameScheduled = false + if (!shouldAnimate()) return + if (startNanos == 0L) startNanos = frameTimeNanos + if (frameTimeNanos - lastDrawNanos >= FRAME_INTERVAL_NANOS) { + val period = KeyboardSkinCatalog.specFor(skinRef).motion.continuousPeriodMs * 1_000_000L + val phase = ((frameTimeNanos - startNanos) % period).toFloat() / period.toFloat() + (deck as? PhasedKeyboardSkinDrawable)?.setPhase(phase) + invalidate() + lastDrawNanos = frameTimeNanos + } + scheduleFrame() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + updateFrames() + } + + override fun onDetachedFromWindow() { + stopFrames() + super.onDetachedFromWindow() + } + + override fun onVisibilityChanged(changedView: View, visibility: Int) { + super.onVisibilityChanged(changedView, visibility) + updateFrames() + } + + private fun updateFrames() { + if (shouldAnimate()) scheduleFrame() else stopFrames() + } + + private fun shouldAnimate(): Boolean { + if (!isAttachedToWindow || !isShown || motionMode != KeyboardSkinMotionMode.FULL) return false + if (KeyboardSkinCatalog.specFor(skinRef).motion.continuousPeriodMs <= 0L) return false + return Build.VERSION.SDK_INT < Build.VERSION_CODES.O || ValueAnimator.areAnimatorsEnabled() + } + + private fun scheduleFrame() { + if (frameScheduled) return + frameScheduled = true + Choreographer.getInstance().postFrameCallback(this) + } + + private fun stopFrames() { + if (frameScheduled) Choreographer.getInstance().removeFrameCallback(this) + frameScheduled = false + startNanos = 0L + lastDrawNanos = 0L + } + + private fun dp(value: Float): Float = value * density + + companion object { + private const val FRAME_INTERVAL_NANOS = 33_333_333L + private val ROW_ONE = arrayOf("あ", "か", "さ", "た", "な") + private val ROW_TWO = arrayOf("は", "ま", "や", "ら", "⌫") + private val FUNCTION_LABELS = arrayOf("あa1", "^^", "、。?!", "日本語", "◀", "▶") + private val BOTTOM_LABELS = arrayOf("◎", "♩", "", "変換", "↵") + private val BOTTOM_UNITS = floatArrayOf(1f, 1f, 2.3f, 1f, 1.15f) + } +} diff --git a/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinRenderer.kt b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinRenderer.kt new file mode 100644 index 000000000..34b394ce2 --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinRenderer.kt @@ -0,0 +1,1621 @@ +package com.kazumaproject.core.data.keyboard + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.DashPathEffect +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.RadialGradient +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.SweepGradient +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.StateListDrawable +import android.util.TypedValue +import androidx.core.graphics.ColorUtils +import com.kazumaproject.core.R +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +interface KeyboardSkinRenderer { + val spec: KeyboardSkinSpec + + fun createKeyDrawable( + context: Context, + role: KeyboardElementRole, + stableKey: Int = 0, + ): Drawable + + fun createSurfaceDrawable( + context: Context, + role: KeyboardSurfaceRole = KeyboardSurfaceRole.DECK, + ): Drawable + + fun createPopupDrawable( + context: Context, + kind: KeyboardSkinPopupKind, + direction: KeyboardSkinPopupDirection = KeyboardSkinPopupDirection.CENTER, + selected: Boolean = false, + ): Drawable? = KeyboardSkinPopupRenderer.createDrawable( + context = context, + skinId = spec.reference, + kind = kind, + direction = direction, + selected = selected, + ) +} + +/** One registered renderer per skin. Shared classes below are low-level drawing primitives only. */ +object KeyboardSkinRendererRegistry { + private val renderers: Map = listOf( + DefaultSkinRenderer, + FlatSkinRenderer, + GlassSkinRenderer, + NeumorphismSkinRenderer, + MechanicalSkinRenderer, + WashiSkinRenderer, + NeonSkinRenderer, + TerminalSkinRenderer, + CupertinoSkinRenderer, + CupertinoDarkSkinRenderer, + SumiHanshiSkinRenderer, + LetterpressSkinRenderer, + PorcelainSkinRenderer, + UrushiSkinRenderer, + ChalkboardSkinRenderer, + LinenSkinRenderer, + MonochromeLcdSkinRenderer, + ).associateBy { it.spec.id } + + fun rendererFor(id: KeyboardSkinId): KeyboardSkinRenderer = + checkNotNull(renderers[id]) { "Missing renderer for $id" } + + fun rendererFor(reference: KeyboardSkinRef): KeyboardSkinRenderer = when (reference) { + is KeyboardSkinRef.BuiltIn -> rendererFor(reference.id) + is KeyboardSkinRef.Imported -> KeyboardSkinRuntime.specFor(reference.id) + ?.let(::ImportedKeyboardSkinRenderer) + ?: rendererFor(KeyboardSkinId.DEFAULT) + } + + fun rendererFor(spec: KeyboardSkinSpec): KeyboardSkinRenderer = when (spec.reference) { + is KeyboardSkinRef.BuiltIn -> rendererFor(spec.id) + is KeyboardSkinRef.Imported -> ImportedKeyboardSkinRenderer(spec) + } + + private abstract class DedicatedRenderer(id: KeyboardSkinId) : KeyboardSkinRenderer { + final override val spec: KeyboardSkinSpec = KeyboardSkinCatalog.specFor(id) + + override fun createKeyDrawable( + context: Context, + role: KeyboardElementRole, + stableKey: Int, + ): Drawable = KeyboardSkinKeyDrawable(context.applicationContext, spec, role, stableKey) + + override fun createSurfaceDrawable( + context: Context, + role: KeyboardSurfaceRole, + ): Drawable = KeyboardSkinSurfaceDrawable(context.applicationContext, spec, role) + } + + private object DefaultSkinRenderer : DedicatedRenderer(KeyboardSkinId.DEFAULT) + private object FlatSkinRenderer : DedicatedRenderer(KeyboardSkinId.FLAT) + private object GlassSkinRenderer : DedicatedRenderer(KeyboardSkinId.GLASS) + private object NeumorphismSkinRenderer : DedicatedRenderer(KeyboardSkinId.NEUMORPHISM) + private object MechanicalSkinRenderer : DedicatedRenderer(KeyboardSkinId.MECHANICAL) + private object WashiSkinRenderer : DedicatedRenderer(KeyboardSkinId.WASHI) + private object NeonSkinRenderer : DedicatedRenderer(KeyboardSkinId.NEON) + private object TerminalSkinRenderer : DedicatedRenderer(KeyboardSkinId.TERMINAL) + private object CupertinoSkinRenderer : DedicatedRenderer(KeyboardSkinId.CUPERTINO) + private object CupertinoDarkSkinRenderer : DedicatedRenderer(KeyboardSkinId.CUPERTINO_DARK) + private object SumiHanshiSkinRenderer : DedicatedRenderer(KeyboardSkinId.SUMI_HANSHI) + private object LetterpressSkinRenderer : DedicatedRenderer(KeyboardSkinId.LETTERPRESS) + private object PorcelainSkinRenderer : DedicatedRenderer(KeyboardSkinId.PORCELAIN) + private object UrushiSkinRenderer : DedicatedRenderer(KeyboardSkinId.URUSHI) + private object ChalkboardSkinRenderer : DedicatedRenderer(KeyboardSkinId.CHALKBOARD) + private object LinenSkinRenderer : DedicatedRenderer(KeyboardSkinId.LINEN) + private object MonochromeLcdSkinRenderer : DedicatedRenderer(KeyboardSkinId.MONOCHROME_LCD) +} + +/** + * Compatibility facade for existing module APIs. New code should supply a semantic role through + * [KeyboardSkinRendererRegistry]; unlike the previous implementation, non-default colors are never + * derived from the active theme. + */ +object KeyboardSkinDrawableFactory { + fun keyCornerRadiusDp(skinId: KeyboardSkinId): Float = + KeyboardSkinCatalog.specFor(skinId).geometry.cornerRadiusDp + + fun keyCornerRadiusDp(skinId: KeyboardSkinRef): Float = + KeyboardSkinCatalog.specFor(skinId).geometry.cornerRadiusDp + + fun createKeyDrawable( + context: Context, + skinId: KeyboardSkinId, + baseColor: Int, + cornerRadiusDp: Float = keyCornerRadiusDp(skinId), + ): Drawable { + if (skinId == KeyboardSkinId.DEFAULT) { + return createLegacyNeumorphismDrawable(context, baseColor, cornerRadiusDp) + } + val palette = KeyboardSkinCatalog.specFor(skinId).palette + val role = when (baseColor) { + palette.actionKeyColor -> KeyboardElementRole.ACTION + palette.specialKeyColor -> KeyboardElementRole.MODIFIER + palette.candidateSurfaceColor -> KeyboardElementRole.CANDIDATE + else -> KeyboardElementRole.CHARACTER + } + return KeyboardSkinRendererRegistry.rendererFor(skinId) + .createKeyDrawable(context, role, baseColor) + } + + fun createKeyDrawable( + context: Context, + skinId: KeyboardSkinRef, + baseColor: Int, + cornerRadiusDp: Float = keyCornerRadiusDp(skinId), + ): Drawable { + if (skinId.isDefault()) { + return createLegacyNeumorphismDrawable(context, baseColor, cornerRadiusDp) + } + val palette = KeyboardSkinCatalog.specFor(skinId).palette + val role = when (baseColor) { + palette.actionKeyColor -> KeyboardElementRole.ACTION + palette.specialKeyColor -> KeyboardElementRole.MODIFIER + palette.candidateSurfaceColor -> KeyboardElementRole.CANDIDATE + else -> KeyboardElementRole.CHARACTER + } + return KeyboardSkinRendererRegistry.rendererFor(skinId) + .createKeyDrawable(context, role, baseColor) + } + + fun createSurfaceDrawable( + context: Context, + skinId: KeyboardSkinId, + baseColor: Int, + ): Drawable { + if (skinId == KeyboardSkinId.DEFAULT) { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dp(context.resources, 10f) + setColor(baseColor) + } + } + return KeyboardSkinRendererRegistry.rendererFor(skinId) + .createSurfaceDrawable(context, KeyboardSurfaceRole.DECK) + } + + fun createSurfaceDrawable( + context: Context, + skinId: KeyboardSkinRef, + baseColor: Int, + ): Drawable { + if (skinId.isDefault()) { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dp(context.resources, 10f) + setColor(baseColor) + } + } + return KeyboardSkinRendererRegistry.rendererFor(skinId) + .createSurfaceDrawable(context, KeyboardSurfaceRole.DECK) + } + + private fun createLegacyNeumorphismDrawable( + context: Context, + baseColor: Int, + cornerRadiusDp: Float, + ): Drawable { + val radius = dp(context.resources, cornerRadiusDp) + val offset = dp(context.resources, 4f).toInt() + val inset = dp(context.resources, 2f).toInt() + val shadow = rounded(adjust(baseColor, 0.8f), radius) + val highlight = rounded(adjust(baseColor, 1.2f), radius) + val face = rounded(baseColor, radius) + val idle = LayerDrawable(arrayOf(shadow, highlight, face)).apply { + setLayerInset(0, offset, offset, 0, 0) + setLayerInset(1, 0, 0, offset, offset) + setLayerInset(2, inset, inset, inset, inset) + } + val pressed = LayerDrawable(arrayOf(rounded(adjust(baseColor, 0.95f), radius))).apply { + setLayerInset(0, inset, inset, inset, inset) + } + return StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_pressed), pressed) + addState(intArrayOf(), idle) + } + } + + private fun rounded(color: Int, radius: Float) = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = radius + setColor(color) + } +} + +internal interface PhasedKeyboardSkinDrawable { + fun setPhase(value: Float) +} + +private class KeyboardSkinKeyDrawable( + private val context: Context, + private val spec: KeyboardSkinSpec, + private val role: KeyboardElementRole, + private val stableKey: Int, +) : Drawable() { + private val density = context.resources.displayMetrics.density + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val texturePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val rect = RectF() + private val workRect = RectF() + private val irregularPath = Path() + private val popupPath = Path() + private val roundedPath = Path() + private val pixelPath = Path() + private val shaderMatrix = Matrix() + private var faceGradient: Shader? = null + private var textureShader: BitmapShader? = null + private var pressed = false + private var enabled = true + private var drawableAlpha = 255 + private var drawableColorFilter: ColorFilter? = null + + init { + textureShader = textureResourceFor(spec.id)?.let { resourceId -> + BitmapShader( + KeyboardSkinTextureStore.get(context, resourceId), + Shader.TileMode.REPEAT, + Shader.TileMode.REPEAT, + ) + } + } + + override fun isStateful(): Boolean = true + + override fun onStateChange(state: IntArray): Boolean { + val nextPressed = state.contains(android.R.attr.state_pressed) + val nextEnabled = state.isEmpty() || state.contains(android.R.attr.state_enabled) + if (nextPressed == pressed && nextEnabled == enabled) return false + pressed = nextPressed + enabled = nextEnabled + invalidateSelf() + return true + } + + override fun onBoundsChange(bounds: Rect) { + val inset = spec.geometry.visualInsetDp * density + rect.set(bounds) + rect.inset(inset, inset) + roundedPath.reset() + roundedPath.addRoundRect(rect, radius(), radius(), Path.Direction.CW) + buildIrregularPath() + buildPopupPath() + buildPixelPath() + faceGradient = when (spec.material) { + KeyboardSkinMaterial.GLASS -> LinearGradient( + rect.left, + rect.top, + rect.right, + rect.bottom, + intArrayOf(withAlpha(spec.palette.accentColor, 175), roleColor(), withAlpha(spec.palette.secondaryAccentColor, 155)), + floatArrayOf(0f, 0.48f, 1f), + Shader.TileMode.CLAMP, + ) + + KeyboardSkinMaterial.MECHANICAL -> LinearGradient( + rect.left, + rect.top, + rect.left, + rect.bottom, + intArrayOf(adjust(roleColor(), 1.32f), roleColor(), adjust(roleColor(), 0.72f)), + null, + Shader.TileMode.CLAMP, + ) + + KeyboardSkinMaterial.PORCELAIN -> LinearGradient( + rect.left, + rect.top, + rect.right, + rect.bottom, + intArrayOf( + adjust(roleColor(), 1.08f), + roleColor(), + ColorUtils.blendARGB(roleColor(), spec.palette.secondaryAccentColor, 0.15f), + ), + floatArrayOf(0f, 0.58f, 1f), + Shader.TileMode.CLAMP, + ) + + KeyboardSkinMaterial.URUSHI -> LinearGradient( + rect.left, + rect.top, + rect.left, + rect.bottom, + intArrayOf( + adjust(roleColor(), 1.55f), + roleColor(), + adjust(roleColor(), 0.58f), + ), + floatArrayOf(0f, 0.42f, 1f), + Shader.TileMode.CLAMP, + ) + + else -> null + } + textureShader?.let { + shaderMatrix.reset() + shaderMatrix.setScale(0.55f, 0.55f) + it.setLocalMatrix(shaderMatrix) + } + } + + override fun draw(canvas: Canvas) { + if (rect.isEmpty) return + fillPaint.alpha = if (enabled) drawableAlpha else (drawableAlpha * 0.48f).toInt() + fillPaint.colorFilter = drawableColorFilter + strokePaint.alpha = fillPaint.alpha + strokePaint.colorFilter = drawableColorFilter + when (spec.material) { + KeyboardSkinMaterial.DEFAULT -> drawDefault(canvas) + KeyboardSkinMaterial.FLAT -> drawFlat(canvas) + KeyboardSkinMaterial.GLASS -> drawGlass(canvas) + KeyboardSkinMaterial.SOFT_EXTRUSION -> drawNeumorphism(canvas) + KeyboardSkinMaterial.MECHANICAL -> drawMechanical(canvas) + KeyboardSkinMaterial.WASHI -> drawWashi(canvas) + KeyboardSkinMaterial.NEON -> drawNeon(canvas) + KeyboardSkinMaterial.TERMINAL -> drawTerminal(canvas) + KeyboardSkinMaterial.CUPERTINO -> drawCupertino(canvas) + KeyboardSkinMaterial.SUMI_HANSHI -> drawSumiHanshi(canvas) + KeyboardSkinMaterial.LETTERPRESS -> drawLetterpress(canvas) + KeyboardSkinMaterial.PORCELAIN -> drawPorcelain(canvas) + KeyboardSkinMaterial.URUSHI -> drawUrushi(canvas) + KeyboardSkinMaterial.CHALKBOARD -> drawChalkboard(canvas) + KeyboardSkinMaterial.LINEN -> drawLinen(canvas) + KeyboardSkinMaterial.MONOCHROME_LCD -> drawMonochromeLcd(canvas) + } + } + + private fun drawDefault(canvas: Canvas) { + val radius = radius() + workRect.set(rect) + fillPaint.shader = null + fillPaint.color = withAlpha(Color.BLACK, if (pressed) 52 else 30) + workRect.offset(0f, dp(1.5f)) + canvas.drawRoundRect(workRect, radius, radius, fillPaint) + workRect.set(rect) + fillPaint.color = if (pressed) adjust(roleColor(), 0.92f) else roleColor() + canvas.drawRoundRect(workRect, radius, radius, fillPaint) + } + + private fun drawFlat(canvas: Canvas) { + fillPaint.shader = null + fillPaint.color = when { + !pressed -> roleColor() + role == KeyboardElementRole.CHARACTER || role == KeyboardElementRole.SPACE -> spec.palette.backgroundColor + else -> adjust(roleColor(), 0.78f) + } + canvas.drawRoundRect(rect, radius(), radius(), fillPaint) + if (role == KeyboardElementRole.ACTION) { + strokePaint.strokeWidth = dp(1f) + strokePaint.color = withAlpha(Color.WHITE, if (pressed) 120 else 70) + canvas.drawLine(rect.left + dp(5f), rect.top + dp(5f), rect.right - dp(5f), rect.top + dp(5f), strokePaint) + } + } + + private fun drawGlass(canvas: Canvas) { + val radius = radius() + fillPaint.shader = null + fillPaint.color = withAlpha(roleColor(), if (pressed) 205 else 145) + canvas.drawRoundRect(rect, radius, radius, fillPaint) + + strokePaint.shader = null + strokePaint.color = withAlpha(spec.palette.accentColor, if (pressed) 150 else 60) + strokePaint.strokeWidth = dp(if (pressed) 4f else 3f) + canvas.drawRoundRect(rect, radius, radius, strokePaint) + strokePaint.shader = faceGradient + strokePaint.strokeWidth = dp(spec.geometry.strokeWidthDp) + canvas.drawRoundRect(rect, radius, radius, strokePaint) + strokePaint.shader = null + + textureShader?.let { shader -> + val save = canvas.save() + canvas.clipPath(roundedPath) + texturePaint.shader = shader + texturePaint.alpha = if (pressed) 44 else 27 + canvas.drawRect(rect, texturePaint) + texturePaint.shader = null + canvas.restoreToCount(save) + } + strokePaint.color = withAlpha(Color.WHITE, if (pressed) 205 else 125) + strokePaint.strokeWidth = dp(0.8f) + canvas.drawLine(rect.left + radius, rect.top + dp(1.2f), rect.right - radius, rect.top + dp(1.2f), strokePaint) + } + + private fun drawNeumorphism(canvas: Canvas) { + val radius = radius() + val offset = dp(if (pressed) 1.5f else 3.2f) + fillPaint.shader = null + if (!pressed) { + workRect.set(rect) + workRect.offset(offset, offset) + fillPaint.color = adjust(roleColor(), 0.70f) + canvas.drawRoundRect(workRect, radius, radius, fillPaint) + workRect.set(rect) + workRect.offset(-offset, -offset) + fillPaint.color = adjust(roleColor(), 1.18f) + canvas.drawRoundRect(workRect, radius, radius, fillPaint) + workRect.set(rect) + fillPaint.color = roleColor() + canvas.drawRoundRect(workRect, radius, radius, fillPaint) + } else { + fillPaint.color = adjust(roleColor(), 0.95f) + canvas.drawRoundRect(rect, radius, radius, fillPaint) + strokePaint.strokeWidth = dp(2.2f) + strokePaint.color = withAlpha(adjust(roleColor(), 0.55f), 145) + canvas.drawArc(rect, 195f, 150f, false, strokePaint) + strokePaint.color = withAlpha(Color.WHITE, 165) + canvas.drawArc(rect, 15f, 150f, false, strokePaint) + } + } + + private fun drawMechanical(canvas: Canvas) { + val radius = radius() + val depth = dp(spec.geometry.depthDp) + fillPaint.shader = null + workRect.set(rect) + workRect.offset(0f, if (pressed) depth * 0.25f else depth) + fillPaint.color = 0xFF090A0D.toInt() + canvas.drawRoundRect(workRect, radius, radius, fillPaint) + + workRect.set(rect) + if (pressed) workRect.offset(0f, depth * 0.72f) + fillPaint.shader = faceGradient + canvas.drawRoundRect(workRect, radius, radius, fillPaint) + fillPaint.shader = null + strokePaint.strokeWidth = dp(1f) + strokePaint.color = withAlpha(Color.WHITE, if (pressed) 50 else 95) + canvas.drawRoundRect(workRect, radius, radius, strokePaint) + strokePaint.color = withAlpha(spec.palette.accentColor, if (pressed) 210 else 80) + strokePaint.strokeWidth = dp(if (pressed) 2f else 1f) + canvas.drawLine(workRect.left + radius, workRect.bottom - dp(1f), workRect.right - radius, workRect.bottom - dp(1f), strokePaint) + } + + private fun drawWashi(canvas: Canvas) { + fillPaint.shader = null + fillPaint.color = if (pressed) ColorUtils.blendARGB(roleColor(), spec.palette.accentColor, 0.28f) else roleColor() + canvas.drawPath(irregularPath, fillPaint) + textureShader?.let { shader -> + val save = canvas.save() + canvas.clipPath(irregularPath) + texturePaint.shader = shader + texturePaint.alpha = if (role == KeyboardElementRole.ACTION) 34 else 105 + canvas.drawRect(rect, texturePaint) + texturePaint.shader = null + canvas.restoreToCount(save) + } + strokePaint.shader = null + strokePaint.strokeWidth = dp(1f) + strokePaint.color = withAlpha(spec.palette.normalKeyTextColor, 82) + canvas.drawPath(irregularPath, strokePaint) + if (pressed) { + fillPaint.shader = RadialGradient( + rect.centerX(), rect.centerY(), rect.width() * 0.48f, + withAlpha(spec.palette.accentColor, 92), Color.TRANSPARENT, Shader.TileMode.CLAMP, + ) + canvas.drawPath(irregularPath, fillPaint) + fillPaint.shader = null + } + } + + private fun drawNeon(canvas: Canvas) { + val radius = radius() + val accent = if ((stableKey and 1) == 0 || role == KeyboardElementRole.CHARACTER) { + spec.palette.accentColor + } else { + spec.palette.secondaryAccentColor + } + fillPaint.shader = null + fillPaint.color = if (pressed) ColorUtils.blendARGB(roleColor(), accent, 0.34f) else roleColor() + canvas.drawRoundRect(rect, radius, radius, fillPaint) + strokePaint.shader = null + drawNeonStroke(canvas, radius, accent, 5f, 36) + drawNeonStroke(canvas, radius, accent, 3f, 72) + drawNeonStroke( + canvas, + radius, + accent, + spec.geometry.strokeWidthDp, + if (pressed) 255 else 220, + ) + strokePaint.strokeWidth = dp(0.8f) + strokePaint.color = withAlpha(Color.WHITE, if (pressed) 220 else 130) + canvas.drawRoundRect(rect, radius, radius, strokePaint) + } + + private fun drawNeonStroke( + canvas: Canvas, + radius: Float, + accent: Int, + widthDp: Float, + alpha: Int, + ) { + strokePaint.strokeWidth = dp(widthDp) + strokePaint.color = withAlpha( + accent, + if (pressed) (alpha * 1.25f).toInt().coerceAtMost(255) else alpha, + ) + canvas.drawRoundRect(rect, radius, radius, strokePaint) + } + + private fun drawTerminal(canvas: Canvas) { + fillPaint.shader = null + fillPaint.color = if (pressed) spec.palette.accentColor else roleColor() + canvas.drawRect(rect, fillPaint) + strokePaint.shader = null + strokePaint.strokeWidth = dp(0.75f) + strokePaint.color = withAlpha(if (pressed) spec.palette.backgroundColor else spec.palette.accentColor, 185) + canvas.drawRect(rect, strokePaint) + strokePaint.color = withAlpha(if (pressed) spec.palette.backgroundColor else spec.palette.accentColor, 44) + val step = dp(6f).coerceAtLeast(2f) + var x = rect.left + step + while (x < rect.right) { + canvas.drawLine(x, rect.top, x, rect.bottom, strokePaint) + x += step + } + var y = rect.top + step + while (y < rect.bottom) { + canvas.drawLine(rect.left, y, rect.right, y, strokePaint) + y += step + } + } + + private fun drawCupertino(canvas: Canvas) { + val radius = radius() + fillPaint.shader = null + if (role == KeyboardElementRole.POPUP) { + fillPaint.color = withAlpha(Color.BLACK, if (pressed) 28 else 42) + canvas.save() + canvas.translate(0f, dp(1f)) + canvas.drawPath(popupPath, fillPaint) + canvas.restore() + fillPaint.color = roleColor() + canvas.drawPath(popupPath, fillPaint) + return + } + fillPaint.color = if (pressed) { + ColorUtils.blendARGB(roleColor(), spec.palette.backgroundColor, 0.42f) + } else { + roleColor() + } + canvas.drawRoundRect(rect, radius, radius, fillPaint) + } + + private fun drawSumiHanshi(canvas: Canvas) { + val shape = materialShape(irregular = true) + fillPaint.shader = null + fillPaint.color = withAlpha(Color.BLACK, if (pressed) 20 else 14) + canvas.save() + canvas.translate(0f, dp(if (pressed) 0.35f else 0.8f)) + canvas.drawPath(shape, fillPaint) + canvas.restore() + + fillPaint.color = when { + pressed && role == KeyboardElementRole.ACTION -> adjust(roleColor(), 0.78f) + pressed -> ColorUtils.blendARGB(roleColor(), spec.palette.accentColor, 0.12f) + else -> roleColor() + } + canvas.drawPath(shape, fillPaint) + drawPaperFibers(canvas, shape, spec.palette.accentColor, 7, 14) + + strokePaint.shader = null + strokePaint.pathEffect = DashPathEffect( + floatArrayOf(dp(8f), dp(0.9f), dp(2.8f), dp(1.1f)), + (stableKey and 3) * dp(0.65f), + ) + strokePaint.strokeWidth = dp(spec.geometry.strokeWidthDp) + strokePaint.color = withAlpha( + if (role == KeyboardElementRole.ACTION) Color.BLACK else spec.palette.normalKeyTextColor, + if (pressed) 108 else 70, + ) + canvas.drawPath(shape, strokePaint) + strokePaint.pathEffect = null + + if (pressed && role != KeyboardElementRole.ACTION) { + fillPaint.shader = RadialGradient( + rect.centerX(), + rect.centerY(), + rect.width() * 0.46f, + withAlpha(spec.palette.accentColor, 105), + Color.TRANSPARENT, + Shader.TileMode.CLAMP, + ) + canvas.drawPath(shape, fillPaint) + fillPaint.shader = null + } + } + + private fun drawLetterpress(canvas: Canvas) { + val shape = materialShape() + fillPaint.shader = null + if (role == KeyboardElementRole.POPUP) { + fillPaint.color = withAlpha(Color.BLACK, 28) + canvas.save() + canvas.translate(0f, dp(1.1f)) + canvas.drawPath(shape, fillPaint) + canvas.restore() + } + fillPaint.color = if (pressed) adjust(roleColor(), 0.94f) else roleColor() + canvas.drawPath(shape, fillPaint) + drawPaperFibers(canvas, shape, spec.palette.accentColor, 7, 18) + + strokePaint.shader = null + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(if (pressed) 2.2f else 1.25f) + strokePaint.color = withAlpha( + if (role == KeyboardElementRole.ACTION) Color.BLACK else spec.palette.accentColor, + if (pressed) 165 else 112, + ) + canvas.drawPath(shape, strokePaint) + canvas.save() + canvas.scale(0.94f, 0.9f, rect.centerX(), rect.centerY()) + strokePaint.strokeWidth = dp(0.75f) + strokePaint.color = withAlpha(Color.WHITE, if (pressed) 52 else 105) + canvas.drawPath(shape, strokePaint) + canvas.restore() + } + + private fun drawPorcelain(canvas: Canvas) { + val shape = materialShape() + fillPaint.shader = null + fillPaint.color = withAlpha(Color.BLACK, if (pressed) 48 else 78) + canvas.save() + canvas.translate(0f, dp(if (pressed) 0.9f else spec.geometry.depthDp)) + canvas.drawPath(shape, fillPaint) + canvas.restore() + + fillPaint.shader = null + fillPaint.color = roleColor() + canvas.drawPath(shape, fillPaint) + fillPaint.shader = LinearGradient( + rect.left, + rect.top, + rect.left, + rect.bottom, + withAlpha(Color.WHITE, 90), + Color.TRANSPARENT, + Shader.TileMode.CLAMP, + ) + canvas.drawPath(shape, fillPaint) + fillPaint.shader = null + if (pressed && role != KeyboardElementRole.ACTION) { + fillPaint.shader = RadialGradient( + rect.centerX(), + rect.centerY(), + rect.width() * 0.5f, + withAlpha(spec.palette.secondaryAccentColor, 155), + Color.TRANSPARENT, + Shader.TileMode.CLAMP, + ) + canvas.drawPath(shape, fillPaint) + fillPaint.shader = null + } + + val edge = if (role == KeyboardElementRole.ACTION) { + spec.palette.actionKeyTextColor + } else { + spec.palette.accentColor + } + strokePaint.shader = null + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(if (pressed) 1.6f else spec.geometry.strokeWidthDp) + strokePaint.color = withAlpha(edge, if (pressed) 245 else 215) + canvas.drawPath(shape, strokePaint) + canvas.save() + canvas.scale(0.94f, 0.88f, rect.centerX(), rect.centerY()) + strokePaint.strokeWidth = dp(0.55f) + strokePaint.color = withAlpha(edge, 105) + canvas.drawPath(shape, strokePaint) + canvas.restore() + if (role != KeyboardElementRole.POPUP) drawPorcelainCornerMarks(canvas, edge) + } + + private fun drawUrushi(canvas: Canvas) { + val shape = materialShape() + fillPaint.shader = null + fillPaint.color = withAlpha(Color.BLACK, 160) + canvas.save() + canvas.translate(0f, dp(if (pressed) 0.8f else spec.geometry.depthDp)) + canvas.drawPath(shape, fillPaint) + canvas.restore() + + fillPaint.shader = faceGradient + canvas.drawPath(shape, fillPaint) + fillPaint.shader = null + if (pressed && role != KeyboardElementRole.ACTION) { + fillPaint.color = withAlpha(spec.palette.secondaryAccentColor, 72) + canvas.drawPath(shape, fillPaint) + } + strokePaint.shader = null + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(if (pressed) 1.25f else spec.geometry.strokeWidthDp) + strokePaint.color = withAlpha(spec.palette.accentColor, if (pressed) 235 else 188) + canvas.drawPath(shape, strokePaint) + strokePaint.strokeWidth = dp(0.7f) + strokePaint.color = withAlpha(Color.WHITE, if (pressed) 70 else 125) + canvas.drawLine( + rect.left + radius(), + rect.top + dp(1.4f), + rect.right - radius(), + rect.top + dp(1.4f), + strokePaint, + ) + } + + private fun drawChalkboard(canvas: Canvas) { + val shape = materialShape(irregular = true) + fillPaint.shader = null + fillPaint.color = if (pressed && role != KeyboardElementRole.ACTION) { + ColorUtils.blendARGB(roleColor(), spec.palette.normalKeyTextColor, 0.08f) + } else if (pressed) { + adjust(roleColor(), 0.82f) + } else { + roleColor() + } + canvas.drawPath(shape, fillPaint) + if (pressed && role != KeyboardElementRole.ACTION) { + fillPaint.shader = RadialGradient( + rect.centerX(), + rect.centerY(), + rect.width() * 0.52f, + withAlpha(spec.palette.normalKeyTextColor, 86), + Color.TRANSPARENT, + Shader.TileMode.CLAMP, + ) + canvas.drawPath(shape, fillPaint) + fillPaint.shader = null + } + + val chalk = when (role) { + KeyboardElementRole.MODIFIER, KeyboardElementRole.SPACE -> spec.palette.secondaryAccentColor + KeyboardElementRole.ACTION -> spec.palette.backgroundColor + else -> spec.palette.accentColor + } + strokePaint.shader = null + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(if (pressed) 1.55f else spec.geometry.strokeWidthDp) + strokePaint.color = withAlpha(chalk, if (pressed) 245 else 210) + canvas.drawPath(shape, strokePaint) + canvas.save() + canvas.scale(0.94f, 0.88f, rect.centerX(), rect.centerY()) + strokePaint.strokeWidth = dp(0.45f) + strokePaint.color = withAlpha(chalk, 78) + canvas.drawPath(shape, strokePaint) + canvas.restore() + strokePaint.pathEffect = null + } + + private fun drawLinen(canvas: Canvas) { + val shape = materialShape() + fillPaint.shader = null + fillPaint.color = withAlpha(Color.BLACK, if (pressed) 32 else 50) + canvas.save() + canvas.translate(0f, dp(if (pressed) 0.65f else spec.geometry.depthDp)) + canvas.drawPath(shape, fillPaint) + canvas.restore() + + fillPaint.color = if (pressed) adjust(roleColor(), 0.91f) else roleColor() + canvas.drawPath(shape, fillPaint) + drawLinenWeave(canvas, shape) + if (pressed) { + fillPaint.shader = RadialGradient( + rect.centerX(), + rect.centerY(), + rect.width() * 0.5f, + withAlpha(Color.BLACK, 48), + Color.TRANSPARENT, + Shader.TileMode.CLAMP, + ) + canvas.drawPath(shape, fillPaint) + fillPaint.shader = null + } + + val thread = if (role == KeyboardElementRole.ACTION) { + spec.palette.actionKeyTextColor + } else { + spec.palette.accentColor + } + strokePaint.shader = null + strokePaint.pathEffect = DashPathEffect(floatArrayOf(dp(2.1f), dp(1.7f)), dp((stableKey and 3) * 0.4f)) + strokePaint.strokeWidth = dp(0.9f) + strokePaint.color = withAlpha(thread, if (pressed) 235 else 190) + canvas.save() + canvas.scale(0.91f, 0.82f, rect.centerX(), rect.centerY()) + canvas.drawPath(shape, strokePaint) + canvas.restore() + strokePaint.pathEffect = null + } + + private fun drawMonochromeLcd(canvas: Canvas) { + val shape = if (role == KeyboardElementRole.POPUP) popupPath else pixelPath + fillPaint.shader = null + fillPaint.color = when { + pressed && role == KeyboardElementRole.ACTION -> adjust(roleColor(), 0.72f) + pressed -> spec.palette.normalKeyTextColor + else -> roleColor() + } + canvas.drawPath(shape, fillPaint) + val lineColor = if (pressed && role != KeyboardElementRole.ACTION) { + spec.palette.normalKeyColor + } else { + spec.palette.accentColor + } + strokePaint.shader = null + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(if (pressed) 1.5f else spec.geometry.strokeWidthDp) + strokePaint.color = withAlpha(lineColor, 245) + canvas.drawPath(shape, strokePaint) + canvas.save() + canvas.scale(0.94f, 0.84f, rect.centerX(), rect.centerY()) + strokePaint.strokeWidth = dp(0.55f) + strokePaint.color = withAlpha(lineColor, 92) + canvas.drawPath(shape, strokePaint) + canvas.restore() + } + + private fun materialShape(irregular: Boolean = false): Path = when { + role == KeyboardElementRole.POPUP -> popupPath + irregular -> irregularPath + else -> roundedPath + } + + private fun drawPaperFibers( + canvas: Canvas, + clip: Path, + color: Int, + count: Int, + alpha: Int, + ) { + val save = canvas.save() + canvas.clipPath(clip) + strokePaint.shader = null + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(0.45f) + strokePaint.color = withAlpha(color, alpha) + repeat(count) { index -> + val fraction = (index + 1f) / (count + 1f) + val y = rect.top + rect.height() * fraction + jitterValue(100 + index, dp(0.8f)) + val startFraction = ((index * 37 + stableKey * 11) and 0x7F) / 160f + val start = rect.left + rect.width() * startFraction.coerceIn(0.04f, 0.78f) + val length = rect.width() * (0.08f + (index % 4) * 0.045f) + val end = (start + length).coerceAtMost(rect.right - dp(2f)) + canvas.drawLine(start, y, end, y + jitterValue(160 + index, dp(0.7f)), strokePaint) + } + canvas.restoreToCount(save) + } + + private fun drawPorcelainCornerMarks(canvas: Canvas, color: Int) { + val inset = dp(5f) + val length = dp(3.5f) + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(0.8f) + strokePaint.color = withAlpha(color, 145) + canvas.drawLine(rect.left + inset, rect.top + inset, rect.left + inset + length, rect.top + inset, strokePaint) + canvas.drawLine(rect.left + inset, rect.top + inset, rect.left + inset, rect.top + inset + length, strokePaint) + canvas.drawLine(rect.right - inset, rect.bottom - inset, rect.right - inset - length, rect.bottom - inset, strokePaint) + canvas.drawLine(rect.right - inset, rect.bottom - inset, rect.right - inset, rect.bottom - inset - length, strokePaint) + } + + private fun drawLinenWeave(canvas: Canvas, clip: Path) { + val save = canvas.save() + canvas.clipPath(clip) + strokePaint.shader = null + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(0.45f) + val step = dp(3.6f).coerceAtLeast(2f) + strokePaint.color = withAlpha(spec.palette.accentColor, 23) + var x = rect.left + while (x <= rect.right) { + canvas.drawLine(x, rect.top, x, rect.bottom, strokePaint) + x += step + } + strokePaint.color = withAlpha(Color.WHITE, 30) + var y = rect.top + while (y <= rect.bottom) { + canvas.drawLine(rect.left, y, rect.right, y, strokePaint) + y += step + } + canvas.restoreToCount(save) + } + + private fun buildIrregularPath() { + irregularPath.reset() + if (rect.isEmpty) return + val jitter = spec.geometry.irregularityDp * density + val steps = 8 + irregularPath.moveTo(rect.left + jitterValue(0, jitter), rect.top + jitterValue(1, jitter)) + for (i in 1..steps) { + val x = rect.left + rect.width() * i / steps + irregularPath.lineTo(x + jitterValue(i * 2, jitter), rect.top + jitterValue(i * 2 + 1, jitter)) + } + for (i in 1..steps) { + val y = rect.top + rect.height() * i / steps + irregularPath.lineTo(rect.right + jitterValue(20 + i * 2, jitter), y + jitterValue(21 + i * 2, jitter)) + } + for (i in 1..steps) { + val x = rect.right - rect.width() * i / steps + irregularPath.lineTo(x + jitterValue(40 + i * 2, jitter), rect.bottom + jitterValue(41 + i * 2, jitter)) + } + for (i in 1..steps) { + val y = rect.bottom - rect.height() * i / steps + irregularPath.lineTo(rect.left + jitterValue(60 + i * 2, jitter), y + jitterValue(61 + i * 2, jitter)) + } + irregularPath.close() + } + + private fun buildPopupPath() { + popupPath.reset() + if (rect.isEmpty) return + val stem = dp(5f) + val radius = radius() + popupPath.addRoundRect(rect.left, rect.top, rect.right, rect.bottom - stem, radius, radius, Path.Direction.CW) + popupPath.moveTo(rect.centerX() - stem, rect.bottom - stem) + popupPath.lineTo(rect.centerX(), rect.bottom) + popupPath.lineTo(rect.centerX() + stem, rect.bottom - stem) + popupPath.close() + } + + private fun buildPixelPath() { + pixelPath.reset() + if (rect.isEmpty) return + val notch = dp(2.5f).coerceAtMost(minOf(rect.width(), rect.height()) * 0.18f) + pixelPath.moveTo(rect.left + notch, rect.top) + pixelPath.lineTo(rect.right - notch, rect.top) + pixelPath.lineTo(rect.right - notch, rect.top + notch) + pixelPath.lineTo(rect.right, rect.top + notch) + pixelPath.lineTo(rect.right, rect.bottom - notch) + pixelPath.lineTo(rect.right - notch, rect.bottom - notch) + pixelPath.lineTo(rect.right - notch, rect.bottom) + pixelPath.lineTo(rect.left + notch, rect.bottom) + pixelPath.lineTo(rect.left + notch, rect.bottom - notch) + pixelPath.lineTo(rect.left, rect.bottom - notch) + pixelPath.lineTo(rect.left, rect.top + notch) + pixelPath.lineTo(rect.left + notch, rect.top + notch) + pixelPath.close() + } + + private fun jitterValue(index: Int, amplitude: Float): Float { + if (amplitude == 0f) return 0f + var value = stableKey * 1103515245 + index * 12345 + 0x6D2B79F5 + value = value xor (value ushr 16) + return (((value and 0xFFFF) / 65535f) * 2f - 1f) * amplitude + } + + private fun roleColor(): Int = spec.palette.keyColor(role) + private fun radius(): Float = dp(spec.geometry.cornerRadiusDp) + private fun dp(value: Float): Float = value * density + + override fun setAlpha(alpha: Int) { + drawableAlpha = alpha.coerceIn(0, 255) + invalidateSelf() + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + drawableColorFilter = colorFilter + invalidateSelf() + } + + @Deprecated("Drawable opacity is not used by the skin renderer") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun getConstantState(): ConstantState = KeyConstantState(context, spec, role, stableKey) + + private class KeyConstantState( + private val context: Context, + private val spec: KeyboardSkinSpec, + private val role: KeyboardElementRole, + private val stableKey: Int, + ) : ConstantState() { + override fun newDrawable(): Drawable = KeyboardSkinKeyDrawable(context, spec, role, stableKey) + override fun newDrawable(res: Resources?): Drawable = newDrawable() + override fun getChangingConfigurations(): Int = 0 + } +} + +internal class KeyboardSkinSurfaceDrawable( + private val context: Context, + private val spec: KeyboardSkinSpec, + private val role: KeyboardSurfaceRole, +) : Drawable(), PhasedKeyboardSkinDrawable { + private val density = context.resources.displayMetrics.density + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val texturePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val rect = RectF() + private val wavePath = Path() + private val shaderMatrix = Matrix() + private val fastenerPoints = FloatArray(8) + private val fastenerShader by lazy { + RadialGradient( + 0f, + 0f, + dp(3f), + Color.WHITE, + 0xFF33363D.toInt(), + Shader.TileMode.CLAMP, + ) + } + private var baseShader: Shader? = null + private var textureShader: BitmapShader? = null + private var phase = 0f + private var drawableAlpha = 255 + private var drawableColorFilter: ColorFilter? = null + + init { + textureShader = textureResourceFor(spec.id)?.let { resourceId -> + BitmapShader( + KeyboardSkinTextureStore.get(context, resourceId), + Shader.TileMode.REPEAT, + Shader.TileMode.REPEAT, + ) + } + } + + override fun setPhase(value: Float) { + val normalized = value - value.toInt() + if (normalized == phase) return + phase = normalized + invalidateSelf() + } + + override fun onBoundsChange(bounds: Rect) { + rect.set(bounds) + baseShader = when (spec.material) { + KeyboardSkinMaterial.GLASS -> SweepGradient( + rect.centerX(), rect.centerY(), + intArrayOf( + spec.palette.backgroundColor, + withAlpha(spec.palette.accentColor, 190), + 0xFF7C3AED.toInt(), + withAlpha(spec.palette.secondaryAccentColor, 210), + spec.palette.backgroundColor, + ), + floatArrayOf(0f, 0.24f, 0.5f, 0.76f, 1f), + ) + + KeyboardSkinMaterial.MECHANICAL -> LinearGradient( + rect.left, rect.top, rect.right, rect.bottom, + intArrayOf(0xFF0E0F12.toInt(), spec.palette.backgroundColor, 0xFF262930.toInt()), + null, Shader.TileMode.CLAMP, + ) + + KeyboardSkinMaterial.NEON -> LinearGradient( + rect.left, rect.top, rect.right, rect.bottom, + intArrayOf(0xFF03020A.toInt(), spec.palette.backgroundColor, 0xFF15032A.toInt()), + null, Shader.TileMode.CLAMP, + ) + + KeyboardSkinMaterial.PORCELAIN -> LinearGradient( + rect.left, + rect.top, + rect.right, + rect.bottom, + intArrayOf( + adjust(spec.palette.backgroundColor, 0.78f), + spec.palette.backgroundColor, + adjust(spec.palette.backgroundColor, 1.18f), + ), + floatArrayOf(0f, 0.55f, 1f), + Shader.TileMode.CLAMP, + ) + + KeyboardSkinMaterial.URUSHI -> LinearGradient( + rect.left, + rect.top, + rect.right, + rect.bottom, + intArrayOf( + 0xFF050505.toInt(), + spec.palette.backgroundColor, + 0xFF261815.toInt(), + 0xFF080706.toInt(), + ), + floatArrayOf(0f, 0.38f, 0.7f, 1f), + Shader.TileMode.CLAMP, + ) + + else -> null + } + textureShader?.let { + shaderMatrix.reset() + shaderMatrix.setScale(if (spec.id == KeyboardSkinId.GLASS) 0.5f else 0.72f, if (spec.id == KeyboardSkinId.GLASS) 0.5f else 0.72f) + it.setLocalMatrix(shaderMatrix) + } + } + + override fun draw(canvas: Canvas) { + if (rect.isEmpty) return + fillPaint.alpha = drawableAlpha + fillPaint.colorFilter = drawableColorFilter + strokePaint.alpha = drawableAlpha + strokePaint.colorFilter = drawableColorFilter + when (spec.material) { + KeyboardSkinMaterial.DEFAULT -> drawSimple(canvas, surfaceColor()) + KeyboardSkinMaterial.FLAT -> drawFlat(canvas) + KeyboardSkinMaterial.GLASS -> drawGlass(canvas) + KeyboardSkinMaterial.SOFT_EXTRUSION -> drawNeumorphism(canvas) + KeyboardSkinMaterial.MECHANICAL -> drawMechanical(canvas) + KeyboardSkinMaterial.WASHI -> drawWashi(canvas) + KeyboardSkinMaterial.NEON -> drawNeon(canvas) + KeyboardSkinMaterial.TERMINAL -> drawTerminal(canvas) + KeyboardSkinMaterial.CUPERTINO -> drawCupertino(canvas) + KeyboardSkinMaterial.SUMI_HANSHI -> drawSumiHanshi(canvas) + KeyboardSkinMaterial.LETTERPRESS -> drawLetterpress(canvas) + KeyboardSkinMaterial.PORCELAIN -> drawPorcelain(canvas) + KeyboardSkinMaterial.URUSHI -> drawUrushi(canvas) + KeyboardSkinMaterial.CHALKBOARD -> drawChalkboard(canvas) + KeyboardSkinMaterial.LINEN -> drawLinen(canvas) + KeyboardSkinMaterial.MONOCHROME_LCD -> drawMonochromeLcd(canvas) + } + } + + private fun drawSimple(canvas: Canvas, color: Int) { + fillPaint.shader = null + fillPaint.color = color + canvas.drawRect(rect, fillPaint) + } + + private fun drawFlat(canvas: Canvas) { + drawSimple(canvas, surfaceColor()) + if (role == KeyboardSurfaceRole.DECK) { + fillPaint.color = withAlpha(spec.palette.secondaryAccentColor, 52) + wavePath.reset() + wavePath.moveTo(rect.left, rect.bottom) + wavePath.lineTo(rect.right * 0.36f, rect.top) + wavePath.lineTo(rect.right * 0.58f, rect.top) + wavePath.lineTo(rect.left + rect.width() * 0.25f, rect.bottom) + wavePath.close() + canvas.drawPath(wavePath, fillPaint) + } + } + + private fun drawGlass(canvas: Canvas) { + drawSimple(canvas, spec.palette.backgroundColor) + val shader = baseShader + if (shader != null) { + shaderMatrix.reset() + shaderMatrix.setRotate(phase * 360f, rect.centerX(), rect.centerY()) + shader.setLocalMatrix(shaderMatrix) + fillPaint.shader = shader + fillPaint.alpha = (drawableAlpha * 0.82f).toInt() + canvas.drawRect(rect, fillPaint) + fillPaint.shader = null + fillPaint.alpha = drawableAlpha + } + textureShader?.let { + texturePaint.shader = it + texturePaint.alpha = 25 + canvas.drawRect(rect, texturePaint) + texturePaint.shader = null + } + strokePaint.strokeWidth = dp(1f) + strokePaint.color = withAlpha(Color.WHITE, 65) + canvas.drawRect(rect, strokePaint) + } + + private fun drawNeumorphism(canvas: Canvas) { + drawSimple(canvas, surfaceColor()) + strokePaint.strokeWidth = dp(2f) + strokePaint.color = withAlpha(Color.WHITE, 135) + canvas.drawLine(rect.left, rect.top + dp(1f), rect.right, rect.top + dp(1f), strokePaint) + strokePaint.color = withAlpha(Color.BLACK, 42) + canvas.drawLine(rect.left, rect.bottom - dp(1f), rect.right, rect.bottom - dp(1f), strokePaint) + } + + private fun drawMechanical(canvas: Canvas) { + fillPaint.shader = baseShader + canvas.drawRect(rect, fillPaint) + fillPaint.shader = null + textureShader?.let { shader -> + shaderMatrix.reset() + shaderMatrix.setScale(0.72f, 0.72f) + shader.setLocalMatrix(shaderMatrix) + texturePaint.shader = shader + texturePaint.alpha = 150 + canvas.drawRect(rect, texturePaint) + texturePaint.shader = null + } + val pulse = (0.5f + 0.5f * sin(phase * 2f * PI).toFloat()) + strokePaint.strokeWidth = dp(2f) + strokePaint.color = withAlpha(spec.palette.accentColor, (45 + pulse * 110).toInt()) + canvas.drawLine(rect.left, rect.bottom - dp(2f), rect.centerX(), rect.bottom - dp(2f), strokePaint) + strokePaint.color = withAlpha(spec.palette.secondaryAccentColor, (45 + (1f - pulse) * 110).toInt()) + canvas.drawLine(rect.centerX(), rect.bottom - dp(2f), rect.right, rect.bottom - dp(2f), strokePaint) + drawCornerFasteners(canvas) + } + + private fun drawWashi(canvas: Canvas) { + drawSimple(canvas, surfaceColor()) + textureShader?.let { shader -> + shaderMatrix.reset() + shaderMatrix.setScale(0.72f, 0.72f) + shaderMatrix.postTranslate(phase * dp(12f), phase * dp(5f)) + shader.setLocalMatrix(shaderMatrix) + texturePaint.shader = shader + texturePaint.alpha = if (role == KeyboardSurfaceRole.DECK) 52 else 95 + texturePaint.colorFilter = if (role == KeyboardSurfaceRole.DECK) { + PorterDuffColorFilter(0xFF28466A.toInt(), PorterDuff.Mode.MULTIPLY) + } else { + null + } + canvas.drawRect(rect, texturePaint) + texturePaint.shader = null + texturePaint.colorFilter = null + } + if (role == KeyboardSurfaceRole.DECK) drawSeigaiha(canvas) + } + + private fun drawNeon(canvas: Canvas) { + fillPaint.shader = baseShader + canvas.drawRect(rect, fillPaint) + fillPaint.shader = null + val yBase = rect.top + rect.height() * (0.45f + 0.08f * sin(phase * 2f * PI).toFloat()) + repeat(2) { wave -> + wavePath.reset() + wavePath.moveTo(rect.left, yBase + wave * dp(10f)) + var x = rect.left + val step = dp(8f) + while (x <= rect.right) { + val normalized = (x - rect.left) / rect.width().coerceAtLeast(1f) + val y = yBase + wave * dp(10f) + sin((normalized * 4f + phase) * 2f * PI).toFloat() * dp(5f) + wavePath.lineTo(x, y) + x += step + } + strokePaint.strokeWidth = dp(if (wave == 0) 1.6f else 1.2f) + strokePaint.color = withAlpha(if (wave == 0) spec.palette.accentColor else spec.palette.secondaryAccentColor, 105) + canvas.drawPath(wavePath, strokePaint) + } + strokePaint.strokeWidth = dp(2f) + strokePaint.color = withAlpha(spec.palette.accentColor, 125) + canvas.drawRect(rect, strokePaint) + } + + private fun drawTerminal(canvas: Canvas) { + drawSimple(canvas, surfaceColor()) + strokePaint.strokeWidth = dp(0.6f) + strokePaint.color = withAlpha(spec.palette.accentColor, 42) + val step = dp(12f).coerceAtLeast(4f) + var x = rect.left + while (x < rect.right) { + canvas.drawLine(x, rect.top, x, rect.bottom, strokePaint) + x += step + } + var y = rect.top + while (y < rect.bottom) { + canvas.drawLine(rect.left, y, rect.right, y, strokePaint) + y += step + } + strokePaint.color = withAlpha(spec.palette.accentColor, 35) + strokePaint.strokeWidth = dp(1f) + y = rect.top + dp(3f) + while (y < rect.bottom) { + canvas.drawLine(rect.left, y, rect.right, y, strokePaint) + y += dp(4f) + } + fillPaint.color = withAlpha(spec.palette.accentColor, 52) + fillPaint.shader = null + val scanY = rect.top + rect.height() * phase + canvas.drawRect(rect.left, scanY - dp(6f), rect.right, scanY + dp(6f), fillPaint) + strokePaint.color = withAlpha(spec.palette.accentColor, 150) + strokePaint.strokeWidth = dp(1f) + canvas.drawRect(rect, strokePaint) + } + + private fun drawCupertino(canvas: Canvas) { + fillPaint.shader = null + fillPaint.color = surfaceColor() + if (role == KeyboardSurfaceRole.DECK) { + val radius = dp(24f) + wavePath.reset() + wavePath.moveTo(rect.left, rect.bottom) + wavePath.lineTo(rect.left, rect.top + radius) + wavePath.quadTo(rect.left, rect.top, rect.left + radius, rect.top) + wavePath.lineTo(rect.right - radius, rect.top) + wavePath.quadTo(rect.right, rect.top, rect.right, rect.top + radius) + wavePath.lineTo(rect.right, rect.bottom) + wavePath.close() + canvas.drawPath(wavePath, fillPaint) + } else { + canvas.drawRect(rect, fillPaint) + } + fillPaint.shader = null + } + + private fun drawSumiHanshi(canvas: Canvas) { + drawSimple(canvas, surfaceColor()) + drawPaperSurfaceFibers(canvas, spec.palette.accentColor, lineAlpha = 12, fiberCount = 24) + if (role == KeyboardSurfaceRole.CANDIDATE_STRIP) { + strokePaint.pathEffect = DashPathEffect(floatArrayOf(dp(5f), dp(3f)), 0f) + strokePaint.strokeWidth = dp(0.7f) + strokePaint.color = withAlpha(spec.palette.accentColor, 62) + canvas.drawLine(rect.left + dp(8f), rect.bottom - dp(2f), rect.right - dp(8f), rect.bottom - dp(2f), strokePaint) + strokePaint.pathEffect = null + } + } + + private fun drawLetterpress(canvas: Canvas) { + drawSimple(canvas, surfaceColor()) + drawPaperSurfaceFibers(canvas, spec.palette.accentColor, lineAlpha = 18, fiberCount = 25) + if (role != KeyboardSurfaceRole.DECK) { + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(0.8f) + strokePaint.color = withAlpha(spec.palette.accentColor, 86) + canvas.drawRect(rect, strokePaint) + strokePaint.color = withAlpha(Color.WHITE, 80) + canvas.drawLine(rect.left, rect.bottom - dp(1f), rect.right, rect.bottom - dp(1f), strokePaint) + } + } + + private fun drawPorcelain(canvas: Canvas) { + if (role == KeyboardSurfaceRole.DECK) { + fillPaint.shader = baseShader + canvas.drawRect(rect, fillPaint) + fillPaint.shader = null + drawCeramicSpeckles(canvas, withAlpha(Color.WHITE, 18)) + } else { + drawSimple(canvas, surfaceColor()) + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(1f) + strokePaint.color = withAlpha(spec.palette.accentColor, 185) + canvas.drawRect(rect, strokePaint) + } + } + + private fun drawUrushi(canvas: Canvas) { + fillPaint.shader = baseShader + if (baseShader == null) fillPaint.color = surfaceColor() + canvas.drawRect(rect, fillPaint) + fillPaint.shader = null + val sheen = LinearGradient( + rect.left, + rect.top, + rect.right, + rect.bottom, + intArrayOf(Color.TRANSPARENT, withAlpha(Color.WHITE, 22), Color.TRANSPARENT), + floatArrayOf(0.25f, 0.52f, 0.76f), + Shader.TileMode.CLAMP, + ) + fillPaint.shader = sheen + canvas.drawRect(rect, fillPaint) + fillPaint.shader = null + if (role != KeyboardSurfaceRole.DECK) { + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(0.8f) + strokePaint.color = withAlpha(spec.palette.accentColor, 155) + canvas.drawRect(rect, strokePaint) + } + } + + private fun drawChalkboard(canvas: Canvas) { + drawSimple(canvas, surfaceColor()) + strokePaint.shader = null + strokePaint.strokeWidth = dp(0.8f) + strokePaint.pathEffect = DashPathEffect(floatArrayOf(dp(1.5f), dp(4.5f)), dp(1f)) + repeat(14) { index -> + val y = rect.top + rect.height() * ((index * 37 % 101) / 101f) + val start = rect.left + rect.width() * ((index * 19 % 83) / 100f) + val length = rect.width() * (0.08f + (index % 5) * 0.025f) + strokePaint.color = withAlpha(spec.palette.normalKeyTextColor, 13 + index % 3 * 5) + canvas.drawLine(start, y, (start + length).coerceAtMost(rect.right), y + dp((index % 3 - 1) * 0.4f), strokePaint) + } + strokePaint.pathEffect = null + if (role == KeyboardSurfaceRole.CANDIDATE_STRIP) { + strokePaint.strokeWidth = dp(0.8f) + strokePaint.color = withAlpha(spec.palette.normalKeyTextColor, 52) + canvas.drawLine(rect.left + dp(7f), rect.bottom - dp(2f), rect.right - dp(7f), rect.bottom - dp(2f), strokePaint) + } + } + + private fun drawLinen(canvas: Canvas) { + drawSimple(canvas, surfaceColor()) + strokePaint.shader = null + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(0.5f) + val step = dp(5.2f).coerceAtLeast(3f) + strokePaint.color = withAlpha(spec.palette.accentColor, 25) + var x = rect.left + while (x <= rect.right) { + canvas.drawLine(x, rect.top, x, rect.bottom, strokePaint) + x += step + } + strokePaint.color = withAlpha(Color.WHITE, 35) + var y = rect.top + while (y <= rect.bottom) { + canvas.drawLine(rect.left, y, rect.right, y, strokePaint) + y += step + } + } + + private fun drawMonochromeLcd(canvas: Canvas) { + drawSimple(canvas, surfaceColor()) + strokePaint.shader = null + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(0.45f) + strokePaint.color = withAlpha(spec.palette.accentColor, 22) + val step = dp(5f).coerceAtLeast(3f) + var x = rect.left + while (x <= rect.right) { + canvas.drawLine(x, rect.top, x, rect.bottom, strokePaint) + x += step + } + var y = rect.top + while (y <= rect.bottom) { + canvas.drawLine(rect.left, y, rect.right, y, strokePaint) + y += step + } + strokePaint.strokeWidth = dp(1f) + strokePaint.color = withAlpha(spec.palette.accentColor, 205) + canvas.drawRect(rect, strokePaint) + } + + private fun drawPaperSurfaceFibers( + canvas: Canvas, + color: Int, + lineAlpha: Int, + fiberCount: Int, + ) { + strokePaint.shader = null + strokePaint.pathEffect = null + strokePaint.strokeWidth = dp(0.45f) + strokePaint.color = withAlpha(color, lineAlpha) + repeat(fiberCount) { index -> + val y = rect.top + rect.height() * ((index * 43 % 101) / 101f) + val start = rect.left + rect.width() * ((index * 17 % 71) / 100f) + val length = rect.width() * (0.12f + (index % 7) * 0.025f) + canvas.drawLine( + start, + y, + (start + length).coerceAtMost(rect.right), + y + dp((index % 5 - 2) * 0.18f), + strokePaint, + ) + } + } + + private fun drawCeramicSpeckles(canvas: Canvas, color: Int) { + fillPaint.shader = null + fillPaint.color = color + repeat(30) { index -> + val x = rect.left + rect.width() * ((index * 29 % 97) / 97f) + val y = rect.top + rect.height() * ((index * 47 % 103) / 103f) + canvas.drawCircle(x, y, dp(if ((index and 1) == 0) 0.35f else 0.55f), fillPaint) + } + } + + private fun drawSeigaiha(canvas: Canvas) { + strokePaint.strokeWidth = dp(0.8f) + strokePaint.color = withAlpha(0xFFF2E3C1.toInt(), 38) + val radius = dp(14f) + var row = 0 + var cy = rect.top + radius + while (cy < rect.bottom + radius) { + var cx = rect.left + if ((row and 1) == 0) 0f else radius + while (cx < rect.right + radius * 2) { + canvas.drawArc(cx - radius, cy - radius, cx + radius, cy + radius, 180f, 180f, false, strokePaint) + canvas.drawArc(cx - radius * 0.66f, cy - radius * 0.66f, cx + radius * 0.66f, cy + radius * 0.66f, 180f, 180f, false, strokePaint) + cx += radius * 2f + } + cy += radius + row += 1 + } + } + + private fun drawCornerFasteners(canvas: Canvas) { + fillPaint.shader = fastenerShader + val inset = dp(6f) + val radius = dp(2.5f) + fastenerPoints[0] = rect.left + inset + fastenerPoints[1] = rect.top + inset + fastenerPoints[2] = rect.right - inset + fastenerPoints[3] = rect.top + inset + fastenerPoints[4] = rect.left + inset + fastenerPoints[5] = rect.bottom - inset + fastenerPoints[6] = rect.right - inset + fastenerPoints[7] = rect.bottom - inset + var i = 0 + while (i < fastenerPoints.size) { + shaderMatrix.reset() + shaderMatrix.setTranslate(fastenerPoints[i], fastenerPoints[i + 1]) + fillPaint.shader?.setLocalMatrix(shaderMatrix) + canvas.drawCircle(fastenerPoints[i], fastenerPoints[i + 1], radius, fillPaint) + i += 2 + } + fillPaint.shader = null + } + + private fun surfaceColor(): Int = when (role) { + KeyboardSurfaceRole.DECK -> spec.palette.backgroundColor + KeyboardSurfaceRole.CANDIDATE_STRIP, + KeyboardSurfaceRole.CANDIDATE_PANEL, + KeyboardSurfaceRole.TOOLBAR -> spec.palette.candidateSurfaceColor + KeyboardSurfaceRole.POPUP -> spec.palette.specialKeyColor + } + + private fun dp(value: Float): Float = value * density + + override fun setAlpha(alpha: Int) { + drawableAlpha = alpha.coerceIn(0, 255) + invalidateSelf() + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + drawableColorFilter = colorFilter + invalidateSelf() + } + + @Deprecated("Drawable opacity is not used by the skin renderer") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun getConstantState(): ConstantState = SurfaceConstantState(context, spec, role) + + private class SurfaceConstantState( + private val context: Context, + private val spec: KeyboardSkinSpec, + private val role: KeyboardSurfaceRole, + ) : ConstantState() { + override fun newDrawable(): Drawable = KeyboardSkinSurfaceDrawable(context, spec, role) + override fun newDrawable(res: Resources?): Drawable = newDrawable() + override fun getChangingConfigurations(): Int = 0 + } +} + +private object KeyboardSkinTextureStore { + private val cache = ConcurrentHashMap() + + fun get(context: Context, resourceId: Int): Bitmap = cache.getOrPut(resourceId) { + BitmapFactory.decodeResource( + context.resources, + resourceId, + BitmapFactory.Options().apply { inScaled = false }, + ) + } +} + +private fun textureResourceFor(id: KeyboardSkinId): Int? = when (id) { + KeyboardSkinId.GLASS -> R.drawable.keyboard_skin_glass_frost + + KeyboardSkinId.MECHANICAL -> R.drawable.keyboard_skin_mechanical_metal + KeyboardSkinId.WASHI -> R.drawable.keyboard_skin_washi_fiber + else -> null +} + +private fun dp(resources: Resources, value: Float): Float = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + value, + resources.displayMetrics, +) + +private fun adjust(color: Int, factor: Float): Int = Color.argb( + Color.alpha(color), + (Color.red(color) * factor).toInt().coerceIn(0, 255), + (Color.green(color) * factor).toInt().coerceIn(0, 255), + (Color.blue(color) * factor).toInt().coerceIn(0, 255), +) + +private fun withAlpha(color: Int, alpha: Int): Int = + ColorUtils.setAlphaComponent(color, alpha.coerceIn(0, 255)) diff --git a/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinStore.kt b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinStore.kt new file mode 100644 index 000000000..846d0e3e0 --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinStore.kt @@ -0,0 +1,134 @@ +package com.kazumaproject.core.data.keyboard + +import android.content.Context +import androidx.core.util.AtomicFile +import java.io.File +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +/** Offline, app-private storage for validated imported skin JSON. */ +class KeyboardSkinStore( + private val directory: File, + private val revisionContext: Context? = null, +) { + init { + require(directory.isAbsolute) { "Keyboard skin directory must be absolute" } + } + + fun list(): List { + val files = directory.listFiles { file -> file.isFile && file.extension == "json" } + ?: return emptyList() + return files.mapNotNull { file -> + val definition = runCatching { + KeyboardSkinJsonParser.parse(file.readBytes()).successOrNull() + }.getOrNull() ?: return@mapNotNull null + if (definition.id != file.nameWithoutExtension) return@mapNotNull null + StoredImportedKeyboardSkin(definition, file) + }.sortedWith(compareBy { it.definition.name.lowercase() }.thenBy { it.definition.id }) + } + + @Synchronized + fun save(definition: ImportedKeyboardSkinDefinition, replace: Boolean): StoreWriteResult { + val file = fileFor(definition.id) + if (file.exists() && !replace) return StoreWriteResult.Duplicate(definition.id) + directory.mkdirs() + val atomicFile = AtomicFile(file) + return try { + val output = atomicFile.startWrite() + try { + output.write(definition.normalizedJson.toByteArray(StandardCharsets.UTF_8)) + output.flush() + atomicFile.finishWrite(output) + } catch (error: Throwable) { + atomicFile.failWrite(output) + throw error + } + revisionContext?.let(::incrementRevision) + StoreWriteResult.Saved(file) + } catch (error: IOException) { + StoreWriteResult.Failure(error) + } + } + + @Synchronized + fun delete(id: String): Boolean { + if (!KeyboardSkinIdPattern.matches(id)) return false + val file = fileFor(id) + val atomicFile = AtomicFile(file) + val existed = file.exists() + atomicFile.delete() + if (existed) revisionContext?.let(::incrementRevision) + return existed + } + + fun fileFor(id: String): File { + require(KeyboardSkinIdPattern.matches(id)) { "Invalid imported skin id" } + return File(directory, "$id.json") + } + + companion object { + const val DIRECTORY_NAME = "keyboard_skins/v1" + const val REVISION_PREF_KEY = "keyboard_skin_revision" + + fun fromContext(context: Context): KeyboardSkinStore = + KeyboardSkinStore(File(context.filesDir, DIRECTORY_NAME), context.applicationContext) + + fun revision(context: Context): Long = preferences(context).getLong(REVISION_PREF_KEY, 0L) + + @Synchronized + fun incrementRevision(context: Context): Long { + val next = revision(context) + 1L + check(preferences(context).edit().putLong(REVISION_PREF_KEY, next).commit()) + return next + } + + private fun preferences(context: Context) = + context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE) + } +} + +sealed interface StoreWriteResult { + data class Saved(val file: File) : StoreWriteResult + data class Duplicate(val id: String) : StoreWriteResult + data class Failure(val error: Throwable) : StoreWriteResult +} + +/** In-memory compiled definitions. Drawables only read this map and never touch disk or JSON. */ +object KeyboardSkinRuntime { + private val definitions = AtomicReference>(emptyMap()) + private val generationCounter = AtomicLong(0L) + + fun replace(definitions: Collection) { + this.definitions.set(definitions.associateBy { it.id }) + generationCounter.incrementAndGet() + } + + fun clear() { + definitions.set(emptyMap()) + generationCounter.incrementAndGet() + } + + /** Changes whenever the immutable imported-definition snapshot is replaced. */ + fun generation(): Long = generationCounter.get() + + fun definitionFor(id: String): ImportedKeyboardSkinDefinition? = definitions.get()[id] + + fun specFor(id: String): KeyboardSkinSpec? = definitionFor(id)?.spec + + fun all(): List = definitions.get().values + .sortedWith(compareBy { it.name.lowercase() }.thenBy { it.id }) + + /** Must be called from an IO dispatcher. */ + fun reloadFromDisk(context: Context): List { + val stored = KeyboardSkinStore.fromContext(context).list() + replace(stored.map { it.definition }) + return stored + } +} + +private val KeyboardSkinIdPattern = Regex("[a-z][a-z0-9._-]{2,63}") + +private fun KeyboardSkinParseResult.successOrNull(): ImportedKeyboardSkinDefinition? = + (this as? KeyboardSkinParseResult.Success)?.definition diff --git a/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinViewStyler.kt b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinViewStyler.kt new file mode 100644 index 000000000..8e73ca96b --- /dev/null +++ b/core/src/main/java/com/kazumaproject/core/data/keyboard/KeyboardSkinViewStyler.kt @@ -0,0 +1,374 @@ +package com.kazumaproject.core.data.keyboard + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.animation.StateListAnimator +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.PorterDuff +import android.graphics.Typeface +import android.os.Build +import android.view.View +import android.view.ViewGroup +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.OvershootInterpolator +import android.graphics.drawable.Drawable +import android.widget.ImageView +import android.widget.TextView +import androidx.core.graphics.ColorUtils +import androidx.core.widget.ImageViewCompat +import java.util.WeakHashMap + +/** Applies a renderer without replacing the keyboard's existing touch listeners. */ +object KeyboardSkinViewStyler { + private val pressedEnabledState = intArrayOf( + android.R.attr.state_pressed, + android.R.attr.state_enabled, + ) + private val defaultState = intArrayOf() + private val originalStyles = WeakHashMap() + + private data class OriginalStyle( + val background: Drawable?, + val backgroundState: Drawable.ConstantState?, + val backgroundTint: ColorStateList?, + val stateListAnimator: StateListAnimator?, + val elevation: Float, + val alpha: Float, + val translationX: Float, + val translationY: Float, + val scaleX: Float, + val scaleY: Float, + val textColors: ColorStateList?, + val typeface: Typeface?, + val letterSpacing: Float?, + val imageTint: ColorStateList?, + val imageColorFilter: ColorFilter?, + ) + + fun applyKey( + view: View, + skinId: KeyboardSkinId, + role: KeyboardElementRole, + motionMode: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL, + stableKey: Int = view.id, + ) = applyKey(view, KeyboardSkinRef.BuiltIn(skinId), role, motionMode, stableKey) + + fun applyKey( + view: View, + skinRef: KeyboardSkinRef, + role: KeyboardElementRole, + motionMode: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL, + stableKey: Int = view.id, + ) { + if (skinRef.isDefault()) { + clearTransientStyle(view) + return + } + rememberOriginalStyle(view) + clearTransientStyle(view) + val spec = KeyboardSkinCatalog.specFor(skinRef) + val renderer = KeyboardSkinRendererRegistry.rendererFor(skinRef) + view.backgroundTintList = null + view.background = renderer.createKeyDrawable(view.context, role, stableKey) + view.alpha = 1f + view.translationX = 0f + view.translationY = 0f + view.scaleX = 1f + view.scaleY = 1f + view.elevation = 0f + view.stateListAnimator = createStateAnimator(view, spec, motionMode) + + val normalText = spec.palette.textColor(role) + val pressedText = pressedTextColor(spec, role) + val textColors = ColorStateList( + arrayOf(pressedEnabledState, defaultState), + intArrayOf(pressedText, normalText), + ) + when (view) { + is TextView -> { + view.setTextColor(textColors) + view.typeface = Typeface.create( + spec.typography.familyName, + if (spec.typography.bold) Typeface.BOLD else Typeface.NORMAL, + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + view.letterSpacing = spec.typography.letterSpacing + } + view.includeFontPadding = false + } + + is ImageView -> ImageViewCompat.setImageTintList(view, textColors) + } + } + + fun applySurface( + view: View, + skinId: KeyboardSkinId, + role: KeyboardSurfaceRole, + ) = applySurface(view, KeyboardSkinRef.BuiltIn(skinId), role) + + fun applySurface( + view: View, + skinRef: KeyboardSkinRef, + role: KeyboardSurfaceRole, + ) { + if (skinRef.isDefault()) { + clearTransientStyle(view) + return + } + rememberOriginalStyle(view) + clearTransientStyle(view) + view.backgroundTintList = null + view.background = KeyboardSkinRendererRegistry.rendererFor(skinRef) + .createSurfaceDrawable(view.context, role) + } + + /** + * Applies skin color and typography to chrome controls without turning them into keycaps. + * No background geometry is drawn in either state. Press feedback only fades the content, + * so toolbar shortcuts and tabs never read as keycaps on their already styled surface. + */ + fun applyFlatControl( + view: View, + skinId: KeyboardSkinId, + role: KeyboardElementRole = KeyboardElementRole.TOOLBAR, + tintContent: Boolean = true, + ) = applyFlatControl(view, KeyboardSkinRef.BuiltIn(skinId), role, tintContent) + + fun applyFlatControl( + view: View, + skinRef: KeyboardSkinRef, + role: KeyboardElementRole = KeyboardElementRole.TOOLBAR, + tintContent: Boolean = true, + ) { + if (skinRef.isDefault()) { + clearTransientStyle(view) + return + } + rememberOriginalStyle(view) + clearTransientStyle(view) + view.backgroundTintList = null + view.background = null + view.elevation = 0f + + val spec = KeyboardSkinCatalog.specFor(skinRef) + view.stateListAnimator = createFlatControlStateAnimator(view) + if (tintContent) { + applyFlatControlContent(view, spec, role) + } + } + + fun clearTransientStyle(view: View) { + view.stateListAnimator = null + view.animate().cancel() + view.translationX = 0f + view.translationY = 0f + view.scaleX = 1f + view.scaleY = 1f + view.alpha = 1f + if (view is TextView) { + view.typeface = Typeface.DEFAULT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) view.letterSpacing = 0f + } + restoreOriginalStyle(view) + } + + private fun rememberOriginalStyle(view: View) { + if (originalStyles.containsKey(view)) return + originalStyles[view] = OriginalStyle( + background = view.background, + backgroundState = view.background?.constantState, + backgroundTint = view.backgroundTintList, + stateListAnimator = view.stateListAnimator, + elevation = view.elevation, + alpha = view.alpha, + translationX = view.translationX, + translationY = view.translationY, + scaleX = view.scaleX, + scaleY = view.scaleY, + textColors = (view as? TextView)?.textColors, + typeface = (view as? TextView)?.typeface, + letterSpacing = (view as? TextView)?.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP } + ?.letterSpacing, + imageTint = (view as? ImageView)?.let(ImageViewCompat::getImageTintList), + imageColorFilter = (view as? ImageView)?.colorFilter, + ) + } + + private fun restoreOriginalStyle(view: View) { + val original = originalStyles[view] ?: return + view.background = original.backgroundState?.newDrawable(view.resources)?.mutate() ?: original.background + view.backgroundTintList = original.backgroundTint + view.stateListAnimator = original.stateListAnimator + view.elevation = original.elevation + view.alpha = original.alpha + view.translationX = original.translationX + view.translationY = original.translationY + view.scaleX = original.scaleX + view.scaleY = original.scaleY + if (view is TextView) { + original.textColors?.let(view::setTextColor) + original.typeface?.let { view.typeface = it } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && original.letterSpacing != null) { + view.letterSpacing = original.letterSpacing + } + } + if (view is ImageView) { + ImageViewCompat.setImageTintList(view, original.imageTint) + view.colorFilter = original.imageColorFilter + } + } + + private fun createStateAnimator( + view: View, + spec: KeyboardSkinSpec, + mode: KeyboardSkinMotionMode, + ): StateListAnimator? { + if (mode == KeyboardSkinMotionMode.OFF) return null + if (mode == KeyboardSkinMotionMode.REDUCED) { + return StateListAnimator().apply { + addState( + pressedEnabledState, + propertyAnimator(view, View.ALPHA, REDUCED_PRESSED_ALPHA, REDUCED_PRESS_MS), + ) + addState( + defaultState, + propertyAnimator(view, View.ALPHA, 1f, REDUCED_RELEASE_MS), + ) + } + } + val density = view.resources.displayMetrics.density + val motion = spec.motion + val pressed = AnimatorSet().apply { + playTogether( + propertyAnimator(view, View.SCALE_X, motion.pressScale, motion.pressDurationMs), + propertyAnimator(view, View.SCALE_Y, motion.pressScale, motion.pressDurationMs), + propertyAnimator( + view, + View.TRANSLATION_Y, + motion.pressTranslationYDp * density, + motion.pressDurationMs, + ), + propertyAnimator( + view, + View.TRANSLATION_X, + motion.pressTranslationXDp * density, + motion.pressDurationMs, + ), + ) + interpolator = AccelerateDecelerateInterpolator() + } + val released = AnimatorSet().apply { + playTogether( + propertyAnimator(view, View.SCALE_X, 1f, motion.releaseDurationMs), + propertyAnimator(view, View.SCALE_Y, 1f, motion.releaseDurationMs), + propertyAnimator(view, View.TRANSLATION_Y, 0f, motion.releaseDurationMs), + propertyAnimator(view, View.TRANSLATION_X, 0f, motion.releaseDurationMs), + ) + interpolator = if (spec.material == KeyboardSkinMaterial.CUPERTINO) { + OvershootInterpolator(1.35f) + } else { + AccelerateDecelerateInterpolator() + } + } + return StateListAnimator().apply { + addState(pressedEnabledState, pressed) + addState(defaultState, released) + } + } + + private fun propertyAnimator( + view: View, + property: android.util.Property, + target: Float, + durationMs: Long, + ): Animator = ObjectAnimator.ofFloat(view, property, target).apply { + duration = durationMs + } + + private fun createFlatControlStateAnimator(view: View): StateListAnimator = + StateListAnimator().apply { + addState( + pressedEnabledState, + propertyAnimator( + view, + View.ALPHA, + FLAT_CONTROL_PRESSED_CONTENT_ALPHA, + FLAT_CONTROL_PRESS_MS, + ), + ) + addState( + defaultState, + propertyAnimator(view, View.ALPHA, 1f, FLAT_CONTROL_RELEASE_MS), + ) + } + + private fun applyFlatControlContent( + view: View, + spec: KeyboardSkinSpec, + role: KeyboardElementRole, + ) { + val contentColor = spec.palette.textColor(role) + when (view) { + is TextView -> { + view.setTextColor(contentColor) + view.typeface = Typeface.create( + spec.typography.familyName, + if (spec.typography.bold) Typeface.BOLD else Typeface.NORMAL, + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + view.letterSpacing = spec.typography.letterSpacing + } + } + + is ImageView -> view.setColorFilter(contentColor, PorterDuff.Mode.SRC_IN) + is ViewGroup -> for (index in 0 until view.childCount) { + applyFlatControlContent(view.getChildAt(index), spec, role) + } + } + } + + private fun pressedTextColor(spec: KeyboardSkinSpec, role: KeyboardElementRole): Int { + return when { + spec.reference is KeyboardSkinRef.Imported -> spec.palette.textColor(role) + spec.id == KeyboardSkinId.FLAT -> if ( + role == KeyboardElementRole.CHARACTER || role == KeyboardElementRole.SPACE + ) { + Color.WHITE + } else { + spec.palette.textColor(role) + } + + spec.id == KeyboardSkinId.TERMINAL -> if (role == KeyboardElementRole.ACTION) { + spec.palette.backgroundColor + } else { + spec.palette.backgroundColor + } + + spec.id == KeyboardSkinId.NEON -> Color.WHITE + spec.id == KeyboardSkinId.WASHI -> ColorUtils.blendARGB( + spec.palette.textColor(role), + Color.BLACK, + 0.18f, + ) + + spec.id == KeyboardSkinId.MONOCHROME_LCD -> if (role == KeyboardElementRole.ACTION) { + spec.palette.actionKeyTextColor + } else { + spec.palette.normalKeyColor + } + + else -> spec.palette.textColor(role) + } + } + + private const val REDUCED_PRESSED_ALPHA = 0.88f + private const val REDUCED_PRESS_MS = 55L + private const val REDUCED_RELEASE_MS = 75L + private const val FLAT_CONTROL_PRESSED_CONTENT_ALPHA = 0.72f + private const val FLAT_CONTROL_PRESS_MS = 45L + private const val FLAT_CONTROL_RELEASE_MS = 70L +} diff --git a/core/src/main/java/com/kazumaproject/core/ui/key_window/KeyWindowLayout.java b/core/src/main/java/com/kazumaproject/core/ui/key_window/KeyWindowLayout.java index 507a5c66a..92c76b03d 100644 --- a/core/src/main/java/com/kazumaproject/core/ui/key_window/KeyWindowLayout.java +++ b/core/src/main/java/com/kazumaproject/core/ui/key_window/KeyWindowLayout.java @@ -5,11 +5,14 @@ import android.graphics.Canvas; import android.graphics.Color; import android.graphics.RectF; +import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.widget.FrameLayout; import com.kazumaproject.core.R; +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupDirection; +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupDrawable; /** * Bubble View for Android with custom stroke width and color, arrow size, position and direction. @@ -29,6 +32,7 @@ public class KeyWindowLayout extends FrameLayout { private int mBubbleColor; private float mStrokeWidth; private int mStrokeColor; + private Drawable mCustomBubbleDrawable; public KeyWindowLayout(Context context) { this(context, null, 0); @@ -70,7 +74,12 @@ protected void onLayout(boolean changed, int left, int top, int right, int botto @Override protected void dispatchDraw(Canvas canvas) { - if (mKeyWindows != null) mKeyWindows.draw(canvas); + if (mCustomBubbleDrawable != null) { + mCustomBubbleDrawable.setBounds(0, 0, getWidth(), getHeight()); + mCustomBubbleDrawable.draw(canvas); + } else if (mKeyWindows != null) { + mKeyWindows.draw(canvas); + } super.dispatchDraw(canvas); } @@ -175,13 +184,66 @@ public KeyWindowLayout setArrowDirection(ArrowDirection arrowDirection) { resetPadding(); mArrowDirection = arrowDirection; initPadding(); + updateCustomPopupDirection(); + invalidate(); + return this; + } + + /** Replaces the legacy bubble path for built-in Cupertino skins. */ + public KeyWindowLayout setCustomBubbleDrawable(Drawable drawable) { + mCustomBubbleDrawable = drawable; + updateCustomPopupDirection(); + invalidate(); + return this; + } + + /** Restores the legacy bubble renderer after leaving a fixed-skin popup. */ + public KeyWindowLayout clearCustomBubbleDrawable() { + mCustomBubbleDrawable = null; + invalidate(); return this; } + private void updateCustomPopupDirection() { + if (!(mCustomBubbleDrawable instanceof KeyboardSkinPopupDrawable)) return; + if (mArrowWidth <= 0f || mArrowHeight <= 0f) { + ((KeyboardSkinPopupDrawable) mCustomBubbleDrawable) + .setDirection(KeyboardSkinPopupDirection.CENTER); + return; + } + KeyboardSkinPopupDirection direction; + switch (mArrowDirection) { + case LEFT: + case LEFT_CENTER: + direction = KeyboardSkinPopupDirection.LEFT; + break; + case RIGHT: + case RIGHT_CENTER: + direction = KeyboardSkinPopupDirection.RIGHT; + break; + case TOP: + case TOP_CENTER: + case TOP_RIGHT: + direction = KeyboardSkinPopupDirection.UP; + break; + case BOTTOM: + case BOTTOM_CENTER: + case BOTTOM_RIGHT: + direction = KeyboardSkinPopupDirection.DOWN; + break; + default: + direction = KeyboardSkinPopupDirection.CENTER; + break; + } + ((KeyboardSkinPopupDrawable) mCustomBubbleDrawable).setDirection(direction); + } + public KeyWindowLayout setArrowWidth(float arrowWidth) { resetPadding(); mArrowWidth = arrowWidth; initPadding(); + updateCustomPopupDirection(); + invalidate(); return this; } @@ -195,6 +257,8 @@ public KeyWindowLayout setArrowHeight(float arrowHeight) { resetPadding(); mArrowHeight = arrowHeight; initPadding(); + updateCustomPopupDirection(); + invalidate(); return this; } diff --git a/core/src/main/res/drawable-nodpi/keyboard_skin_glass_frost.png b/core/src/main/res/drawable-nodpi/keyboard_skin_glass_frost.png new file mode 100644 index 000000000..4dee57980 Binary files /dev/null and b/core/src/main/res/drawable-nodpi/keyboard_skin_glass_frost.png differ diff --git a/core/src/main/res/drawable-nodpi/keyboard_skin_mechanical_metal.png b/core/src/main/res/drawable-nodpi/keyboard_skin_mechanical_metal.png new file mode 100644 index 000000000..9e37376d6 Binary files /dev/null and b/core/src/main/res/drawable-nodpi/keyboard_skin_mechanical_metal.png differ diff --git a/core/src/main/res/drawable-nodpi/keyboard_skin_washi_fiber.png b/core/src/main/res/drawable-nodpi/keyboard_skin_washi_fiber.png new file mode 100644 index 000000000..24ee878ae Binary files /dev/null and b/core/src/main/res/drawable-nodpi/keyboard_skin_washi_fiber.png differ diff --git a/core/src/test/java/com/kazumaproject/core/data/keyboard/CupertinoSkinReferenceTest.kt b/core/src/test/java/com/kazumaproject/core/data/keyboard/CupertinoSkinReferenceTest.kt new file mode 100644 index 000000000..ff5392ca8 --- /dev/null +++ b/core/src/test/java/com/kazumaproject/core/data/keyboard/CupertinoSkinReferenceTest.kt @@ -0,0 +1,65 @@ +package com.kazumaproject.core.data.keyboard + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CupertinoSkinReferenceTest { + + @Test + fun lightPaletteAndGeometryMatchIos26SimulatorReference() { + val spec = KeyboardSkinCatalog.specFor(KeyboardSkinId.CUPERTINO) + + assertEquals(0xFFE8E9ED.toInt(), spec.palette.backgroundColor) + assertEquals(0xFFFFFFFF.toInt(), spec.palette.normalKeyColor) + assertEquals(0xFFFFFFFF.toInt(), spec.palette.specialKeyColor) + assertEquals(0xFF0091FF.toInt(), spec.palette.actionKeyColor) + assertEquals(0xFF000000.toInt(), spec.palette.normalKeyTextColor) + assertEquals(7f, spec.geometry.cornerRadiusDp) + assertEquals(0f, spec.geometry.depthDp) + assertEquals(KeyboardSkinDepthModel.NONE, spec.depthModel) + assertEquals(KeyboardSkinPopupKind.KEY_PREVIEW, KeyboardSkinPopupKind.entries.first()) + val popup = checkNotNull(spec.popup) + assertEquals(0xFFFFFFFF.toInt(), popup.surfaceColor) + assertEquals(0xFFD1D1D6.toInt(), popup.selectedSurfaceColor) + assertEquals(0xFF000000.toInt(), popup.textColor) + assertEquals(7f, popup.cornerRadiusDp) + assertEquals(10f, popup.stemWidthDp) + assertEquals(6f, popup.stemHeightDp) + } + + @Test + fun darkPaletteAndGeometryMatchIos26SimulatorReference() { + val spec = KeyboardSkinCatalog.specFor(KeyboardSkinId.CUPERTINO_DARK) + + assertEquals(0xFF171717.toInt(), spec.palette.backgroundColor) + assertEquals(0xFF3D3D3D.toInt(), spec.palette.normalKeyColor) + assertEquals(0xFF3D3D3D.toInt(), spec.palette.specialKeyColor) + assertEquals(0xFF007AFF.toInt(), spec.palette.actionKeyColor) + assertEquals(0xFFFFFFFF.toInt(), spec.palette.normalKeyTextColor) + assertEquals(7f, spec.geometry.cornerRadiusDp) + assertEquals(0f, spec.geometry.depthDp) + assertEquals(KeyboardSkinDepthModel.NONE, spec.depthModel) + assertEquals(KeyboardSkinMaterial.CUPERTINO, spec.material) + val popup = checkNotNull(spec.popup) + assertEquals(0xFF5A5A5E.toInt(), popup.surfaceColor) + assertEquals(0xFF8E8E93.toInt(), popup.selectedSurfaceColor) + assertEquals(0xFFFFFFFF.toInt(), popup.textColor) + assertEquals(7f, popup.cornerRadiusDp) + } + + @Test + fun popupKindsRemainStableForSharedRenderers() { + assertEquals( + listOf( + KeyboardSkinPopupKind.KEY_PREVIEW, + KeyboardSkinPopupKind.VARIATION, + KeyboardSkinPopupKind.FLICK_STANDARD, + KeyboardSkinPopupKind.FLICK_DIRECTIONAL, + KeyboardSkinPopupKind.FLICK_CROSS, + KeyboardSkinPopupKind.FLICK_CIRCLE, + KeyboardSkinPopupKind.FLICK_GUIDE, + ), + KeyboardSkinPopupKind.entries, + ) + } +} diff --git a/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinIdTest.kt b/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinIdTest.kt new file mode 100644 index 000000000..bda040ac7 --- /dev/null +++ b/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinIdTest.kt @@ -0,0 +1,98 @@ +package com.kazumaproject.core.data.keyboard + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class KeyboardSkinIdTest { + + @Test + fun unknownPreferenceFallsBackToDefault() { + assertEquals(KeyboardSkinId.DEFAULT, KeyboardSkinId.fromPreference("missing")) + assertEquals(KeyboardSkinId.DEFAULT, KeyboardSkinId.fromPreference(null)) + } + + @Test + fun allBuiltInSkinsHaveStableUniquePreferenceValues() { + val values = KeyboardSkinId.entries.map { it.preferenceValue } + + assertEquals(17, values.size) + assertEquals(values.size, values.toSet().size) + assertTrue(values.contains(KeyboardSkinId.DEFAULT.preferenceValue)) + assertNotEquals( + KeyboardSkinId.DEFAULT.preferenceValue, + KeyboardSkinId.GLASS.preferenceValue, + ) + assertTrue(values.contains(KeyboardSkinId.CUPERTINO.preferenceValue)) + assertTrue(values.contains(KeyboardSkinId.CUPERTINO_DARK.preferenceValue)) + assertTrue(values.contains(KeyboardSkinId.SUMI_HANSHI.preferenceValue)) + assertTrue(values.contains(KeyboardSkinId.LETTERPRESS.preferenceValue)) + assertTrue(values.contains(KeyboardSkinId.PORCELAIN.preferenceValue)) + assertTrue(values.contains(KeyboardSkinId.URUSHI.preferenceValue)) + assertTrue(values.contains(KeyboardSkinId.CHALKBOARD.preferenceValue)) + assertTrue(values.contains(KeyboardSkinId.LINEN.preferenceValue)) + assertTrue(values.contains(KeyboardSkinId.MONOCHROME_LCD.preferenceValue)) + } + + @Test + fun preferenceValuesRoundTrip() { + KeyboardSkinId.entries.forEach { skin -> + assertEquals(skin, KeyboardSkinId.fromPreference(skin.preferenceValue)) + } + } + + @Test + fun importedReferencesUseAStableNamespacedPreferenceValue() { + val reference = KeyboardSkinRef.fromPreference("imported:ai-sakura-cyber") + + assertEquals( + KeyboardSkinRef.Imported("ai-sakura-cyber"), + reference, + ) + assertEquals("imported:ai-sakura-cyber", reference.preferenceValue) + assertEquals(KeyboardSkinRef.DEFAULT, KeyboardSkinRef.fromPreference("imported:bad id")) + } + + @Test + fun nonDefaultSkinsOwnTheirPaletteAndVisualIdentity() { + val specs = KeyboardSkinCatalog.all().filter { it.id != KeyboardSkinId.DEFAULT } + val baseDesigns = specs.filter { it.id != KeyboardSkinId.CUPERTINO_DARK } + + assertEquals(16, specs.size) + assertEquals(specs.size, specs.map { it.palette }.toSet().size) + assertEquals(baseDesigns.size, baseDesigns.map { it.material }.toSet().size) + assertEquals( + KeyboardSkinCatalog.specFor(KeyboardSkinId.CUPERTINO).material, + KeyboardSkinCatalog.specFor(KeyboardSkinId.CUPERTINO_DARK).material, + ) + } + + @Test + fun tactileConceptSkinsHaveDedicatedMaterialsAndDepthModels() { + val ids = listOf( + KeyboardSkinId.SUMI_HANSHI, + KeyboardSkinId.LETTERPRESS, + KeyboardSkinId.PORCELAIN, + KeyboardSkinId.URUSHI, + KeyboardSkinId.CHALKBOARD, + KeyboardSkinId.LINEN, + KeyboardSkinId.MONOCHROME_LCD, + ) + val specs = ids.map(KeyboardSkinCatalog::specFor) + + assertEquals(ids.size, specs.map { it.material }.toSet().size) + assertEquals(ids.size, specs.map { it.depthModel }.toSet().size) + assertEquals(ids.size, specs.map { it.palette }.toSet().size) + assertTrue(specs.all { it.motion.continuousPeriodMs == 0L }) + } + + @Test + fun motionPreferenceFallsBackToFullAndRoundTrips() { + assertEquals(KeyboardSkinMotionMode.FULL, KeyboardSkinMotionMode.fromPreference(null)) + assertEquals(KeyboardSkinMotionMode.FULL, KeyboardSkinMotionMode.fromPreference("invalid")) + KeyboardSkinMotionMode.entries.forEach { mode -> + assertEquals(mode, KeyboardSkinMotionMode.fromPreference(mode.preferenceValue)) + } + } +} diff --git a/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinJsonParserTest.kt b/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinJsonParserTest.kt new file mode 100644 index 000000000..5890f6bec --- /dev/null +++ b/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinJsonParserTest.kt @@ -0,0 +1,146 @@ +package com.kazumaproject.core.data.keyboard + +import com.google.gson.JsonParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class KeyboardSkinJsonParserTest { + + @Test + fun publicTemplateAndExampleAreAcceptedByTheRuntimeParser() { + val template = KeyboardSkinJsonParser.parse(publicFile("template.json").readBytes()) + val example = KeyboardSkinJsonParser.parse(publicFile("example.json").readBytes()) + + assertTrue(template is KeyboardSkinParseResult.Success) + assertTrue(example is KeyboardSkinParseResult.Success) + val definition = (example as KeyboardSkinParseResult.Success).definition + assertEquals("ai-sakura-cyber", definition.id) + assertEquals(KeyboardElementRole.entries.toSet(), definition.spec.keyStyles.keys) + assertEquals(KeyboardSurfaceRole.entries.toSet(), definition.spec.surfaceStyles.keys) + assertEquals(KeyboardSkinBackgroundAnimation.SHIFT, definition.spec.motion.backgroundAnimation) + assertTrue(definition.spec.keyStyles.values.any { it.shape == KeyboardSkinShape.HEXAGON }) + assertTrue(definition.spec.keyStyles.values.any { it.fill is KeyboardSkinFill.LinearGradient }) + assertTrue(definition.spec.keyStyles.values.any { it.fill is KeyboardSkinFill.RadialGradient }) + assertTrue(definition.spec.surfaceStyles.values.any { it.decoration?.type == KeyboardSkinDecorationType.WEAVE }) + } + + @Test + fun acceptsBomAndOneOuterJsonFence() { + val json = publicFile("template.json").readText() + val result = KeyboardSkinJsonParser.parse("\uFEFF```json\n$json\n```") + + assertTrue(result is KeyboardSkinParseResult.Success) + } + + @Test + fun rejectsExplanationsAndExtraFences() { + val json = publicFile("template.json").readText() + + assertFailure(KeyboardSkinJsonParser.parse("Here is your skin:\n$json"), "$") + assertFailure(KeyboardSkinJsonParser.parse("```json\n$json\n```\n```"), "$") + } + + @Test + fun rejectsLenientJsonExtensions() { + val json = publicFile("template.json").readText() + assertFailure(KeyboardSkinJsonParser.parse("/* comment */$json"), "$") + assertFailure( + KeyboardSkinJsonParser.parse(json.replaceFirst("\"format\"", "format")), + "$", + ) + } + + @Test + fun rejectsUnknownFieldsWithAFieldPath() { + val json = publicFile("template.json").readText() + val changed = json.replaceFirst( + "\"format\": \"sumire-keyboard-skin\"", + "\"unexpected\": true,\n \"format\": \"sumire-keyboard-skin\"", + ) + + assertFailure(KeyboardSkinJsonParser.parse(changed), "unexpected") + } + + @Test + fun rejectsRangeAndGradientOrderViolationsWithoutCorrection() { + val json = publicFile("example.json").readText() + val rangeFailure = json.replaceFirst("\"cornerRadiusDp\": 12", "\"cornerRadiusDp\": 33") + val stopFailure = json.replaceFirst("\"stops\": [0, 0.55, 1]", "\"stops\": [0, 0.8, 0.7]") + + assertFailure(KeyboardSkinJsonParser.parse(rangeFailure), "keys.base.cornerRadiusDp") + assertFailure(KeyboardSkinJsonParser.parse(stopFailure), "keys.base.fill.stops") + } + + @Test + fun acceptsDocumentedNumericBoundaries() { + val json = publicFile("template.json").readText() + .replace("\"cornerRadiusDp\": 8", "\"cornerRadiusDp\": 32") + .replace("\"insetDp\": 1", "\"insetDp\": 8") + .replace("\"roughnessDp\": 0", "\"roughnessDp\": 3") + .replace("\"cutSizeDp\": 0", "\"cutSizeDp\": 32") + .replace("\"widthDp\": 0", "\"widthDp\": 4") + .replace("\"scale\": 0.97", "\"scale\": 0.90") + .replace("\"translationXDp\": 0", "\"translationXDp\": 4") + .replace("\"translationYDp\": 1", "\"translationYDp\": 4") + .replace("\"durationMs\": 80", "\"durationMs\": 500") + .replace("\"releaseDurationMs\": 110", "\"releaseDurationMs\": 500") + .replace( + "\"background\": {\n \"type\": \"none\",\n \"periodSeconds\": 0", + "\"background\": {\n \"type\": \"pulse\",\n \"periodSeconds\": 2", + ) + + assertTrue(KeyboardSkinJsonParser.parse(json) is KeyboardSkinParseResult.Success) + } + + @Test + fun contrastIsAWarningAndDoesNotBlockImport() { + val json = publicFile("template.json").readText() + .replaceFirst("\"normalKeyText\": \"#FFFFFF\"", "\"normalKeyText\": \"#303744\"") + val result = KeyboardSkinJsonParser.parse(json) + + assertTrue(result is KeyboardSkinParseResult.Success) + val warnings = (result as KeyboardSkinParseResult.Success).definition.warnings + assertTrue(warnings.any { it.path == "palette.normalKeyText" }) + } + + @Test + fun rejectsVersionSizeAndInvalidUtf8() { + val json = publicFile("template.json").readText() + .replaceFirst("\"formatVersion\": 1", "\"formatVersion\": 2") + assertFailure(KeyboardSkinJsonParser.parse(json), "formatVersion") + + val oversized = ByteArray(KeyboardSkinJsonParser.MAX_UTF8_BYTES + 1) + assertFailure(KeyboardSkinJsonParser.parse(oversized), "$") + assertFailure(KeyboardSkinJsonParser.parse(byteArrayOf(0xC3.toByte(), 0x28)), "$") + } + + @Test + fun schemaAndNoteDeclareTheSamePublicRuntimeContract() { + val schema = JsonParser.parseString(publicFile("sumire-keyboard-skin-v1.schema.json").readText()).asJsonObject + val properties = schema.getAsJsonObject("properties") + val note = publicFile("note-draft-ja.md").readText() + + assertEquals(KeyboardSkinJsonParser.FORMAT, properties.getAsJsonObject("format").get("const").asString) + assertEquals(KeyboardSkinJsonParser.FORMAT_VERSION, properties.getAsJsonObject("formatVersion").get("const").asInt) + assertTrue(note.contains("AIで自分だけのキーボードスキンを作り、Sumireに読み込む方法")) + assertTrue(note.contains("template.json")) + assertTrue(note.contains("example.json")) + assertTrue(note.contains("設定 → テーマ → キーボードスキン")) + } + + private fun assertFailure(result: KeyboardSkinParseResult, pathPart: String) { + assertTrue("Expected a parse failure, got $result", result is KeyboardSkinParseResult.Failure) + val failure = result as KeyboardSkinParseResult.Failure + assertTrue(failure.errors.any { it.path.contains(pathPart) }) + } + + private fun publicFile(name: String): File { + val relative = "docs/keyboard-skins/import-v1/$name" + return listOf(File(relative), File("../$relative"), File("../../$relative")) + .firstOrNull(File::isFile) + ?: error("Unable to locate $relative from ${File(".").absolutePath}") + } +} diff --git a/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinStoreRevisionTest.kt b/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinStoreRevisionTest.kt new file mode 100644 index 000000000..26f29498c --- /dev/null +++ b/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinStoreRevisionTest.kt @@ -0,0 +1,39 @@ +package com.kazumaproject.core.data.keyboard + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +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.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class KeyboardSkinStoreRevisionTest { + + @Test + fun appBoundStoreIncrementsRevisionForSaveAndDelete() { + val context = ApplicationProvider.getApplicationContext() + val directory = File(context.cacheDir, "keyboard-skin-revision-${System.nanoTime()}") + val store = KeyboardSkinStore(directory, context) + try { + val templateFile = listOf( + File("docs/keyboard-skins/import-v1/template.json"), + File("../docs/keyboard-skins/import-v1/template.json"), + File("../../docs/keyboard-skins/import-v1/template.json"), + ).firstOrNull(File::isFile) ?: error("template.json is missing") + val definition = (KeyboardSkinJsonParser.parse(templateFile.readBytes()) as KeyboardSkinParseResult.Success).definition + val beforeSave = KeyboardSkinStore.revision(context) + + assertTrue(store.save(definition, replace = false) is StoreWriteResult.Saved) + assertEquals(beforeSave + 1L, KeyboardSkinStore.revision(context)) + assertTrue(store.delete(definition.id)) + assertEquals(beforeSave + 2L, KeyboardSkinStore.revision(context)) + } finally { + directory.deleteRecursively() + } + } +} diff --git a/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinStoreTest.kt b/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinStoreTest.kt new file mode 100644 index 000000000..ce073c96c --- /dev/null +++ b/core/src/test/java/com/kazumaproject/core/data/keyboard/KeyboardSkinStoreTest.kt @@ -0,0 +1,81 @@ +package com.kazumaproject.core.data.keyboard + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.Rule +import java.io.File + +class KeyboardSkinStoreTest { + + @get:Rule + val temporaryFolder = TemporaryFolder() + + private lateinit var store: KeyboardSkinStore + private lateinit var definition: ImportedKeyboardSkinDefinition + + @Before + fun setUp() { + store = KeyboardSkinStore(temporaryFolder.newFolder("keyboard-skins")) + definition = parsedExample() + KeyboardSkinRuntime.clear() + } + + @After + fun tearDown() { + KeyboardSkinRuntime.clear() + } + + @Test + fun savesListsAtomicallyAndRejectsDuplicateUntilReplaceIsRequested() { + assertTrue(store.save(definition, replace = false) is StoreWriteResult.Saved) + assertEquals(listOf(definition.id), store.list().map { it.definition.id }) + assertTrue(store.fileFor(definition.id).readText().contains("ai-sakura-cyber")) + + assertTrue(store.save(definition, replace = false) is StoreWriteResult.Duplicate) + val updatedJson = definition.normalizedJson.replaceFirst("桜サイバー", "桜サイバー更新") + val updated = (KeyboardSkinJsonParser.parse(updatedJson) as KeyboardSkinParseResult.Success).definition + assertTrue(store.save(updated, replace = true) is StoreWriteResult.Saved) + assertEquals("桜サイバー更新", store.list().single().definition.name) + } + + @Test + fun skipsBrokenAndMismatchedFilesAndDeletesOnlyTheAppCopy() { + assertTrue(store.save(definition, replace = false) is StoreWriteResult.Saved) + File(store.fileFor("broken-skin").parentFile, "broken-skin.json").writeText("not json") + File(store.fileFor("wrong-name").parentFile, "wrong-name.json").writeText(definition.normalizedJson) + + assertEquals(listOf(definition.id), store.list().map { it.definition.id }) + assertTrue(store.delete(definition.id)) + assertFalse(store.fileFor(definition.id).exists()) + assertFalse(store.delete(definition.id)) + assertTrue(store.list().isEmpty()) + } + + @Test + fun runtimeKeepsOnlyCompiledImmutableDefinitionsAndMissingIdsFallBack() { + KeyboardSkinRuntime.replace(listOf(definition)) + + assertEquals(definition, KeyboardSkinRuntime.definitionFor(definition.id)) + assertEquals(definition.spec, KeyboardSkinCatalog.specFor(definition.reference)) + assertNull(KeyboardSkinRuntime.definitionFor("missing-skin")) + assertEquals( + KeyboardSkinId.DEFAULT, + KeyboardSkinCatalog.specFor(KeyboardSkinRef.Imported("missing-skin")).id, + ) + } + + private fun parsedExample(): ImportedKeyboardSkinDefinition { + val path = listOf( + File("docs/keyboard-skins/import-v1/example.json"), + File("../docs/keyboard-skins/import-v1/example.json"), + File("../../docs/keyboard-skins/import-v1/example.json"), + ).firstOrNull(File::isFile) ?: error("example.json is missing") + return (KeyboardSkinJsonParser.parse(path.readBytes()) as KeyboardSkinParseResult.Success).definition + } +} diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CenterGuideFlickInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CenterGuideFlickInputController.kt index 539f2b9ad..4080ee9e1 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CenterGuideFlickInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CenterGuideFlickInputController.kt @@ -6,6 +6,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef import com.kazumaproject.core.domain.flick.FlickGestureMath import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.GestureSessionConfig @@ -59,6 +60,7 @@ class CenterGuideFlickInputController( private var textMap: Map = emptyMap() private var inputTextTransform: (String) -> String = { it } private var popupStyle = PopupViewStyle(100, 20f) + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT private var activeGestureConfig: GestureSessionConfig? = null private var isTouchActive = false @@ -97,6 +99,11 @@ class CenterGuideFlickInputController( popupHost.setColors(backgroundColor, highlightedColor, textColor) } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + popupHost.setKeyboardSkin(skinId) + } + @SuppressLint("ClickableViewAccessibility") fun attach( view: View, diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputController.kt index 427884ffc..8eed949ca 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CrossFlickInputController.kt @@ -8,6 +8,8 @@ import android.view.View import android.view.ViewConfiguration import android.widget.Button import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickDirection as CoreFlickDirection import com.kazumaproject.core.domain.flick.FlickGestureMath @@ -125,6 +127,7 @@ class CrossFlickInputController( private val gridPopupView = CrossFlickPopupView(context).apply { elevation = 8f } + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT private val controllerScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var longPressJob: Job? = null @@ -145,21 +148,30 @@ class CrossFlickInputController( invalidateDirectionalPopupCache() } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + gridPopupView.setKeyboardSkin(skinId) + actionPopupViews.values.forEach { it.setKeyboardSkin(skinId) } + directionalPopupMap.values.forEach { it.setKeyboardSkin(skinId) } + invalidateDirectionalPopupCache() + } + fun applyPopupViewStyleSet( directional: PopupViewStyle, cross: PopupViewStyle ) { + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) directionalPopupStyle = PopupViewStyle( - sizeScalePercent = directional.sizeScalePercent.coerceIn(50, 200), - textSizeSp = directional.textSizeSp.coerceIn(8f, 48f), - backgroundColor = directional.backgroundColor, - textColor = directional.textColor + sizeScalePercent = if (popup == null) directional.sizeScalePercent.coerceIn(50, 200) else 100, + textSizeSp = popup?.flickTextSizeSp ?: directional.textSizeSp.coerceIn(8f, 48f), + backgroundColor = if (popup == null) directional.backgroundColor else null, + textColor = if (popup == null) directional.textColor else popup.textColor, ) crossPopupStyle = PopupViewStyle( - sizeScalePercent = cross.sizeScalePercent.coerceIn(50, 200), - textSizeSp = cross.textSizeSp.coerceIn(8f, 48f), - backgroundColor = cross.backgroundColor, - textColor = cross.textColor + sizeScalePercent = if (popup == null) cross.sizeScalePercent.coerceIn(50, 200) else 100, + textSizeSp = popup?.flickTextSizeSp ?: cross.textSizeSp.coerceIn(8f, 48f), + backgroundColor = if (popup == null) cross.backgroundColor else null, + textColor = if (popup == null) cross.textColor else popup.textColor, ) actionPopupViews.values.forEach { it.applyPopupViewStyle(crossPopupStyle) } gridPopupView.applyPopupViewStyle(crossPopupStyle) @@ -522,6 +534,7 @@ class CrossFlickInputController( if (!anchor.isAttachedToWindow) return val popupView = CrossFlickPopupView(context).apply { + setKeyboardSkin(keyboardSkinId) setInputTextTransform(inputTextTransform) applyPopupViewStyle(crossPopupStyle) val scale = crossPopupStyle.sizeScalePercent.coerceIn(50, 200) / 100f @@ -619,6 +632,7 @@ class CrossFlickInputController( if (text.isNullOrEmpty()) return@forEach val popupView = DirectionalKeyPopupView(context).apply { + setKeyboardSkin(keyboardSkinId) this.text = inputTextTransform(text) applyPopupViewStyle(directionalPopupStyle) popupColorTheme?.let { setColors(it) } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CustomAngleFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CustomAngleFlickController.kt index 96a8a22d3..941c9e794 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CustomAngleFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/CustomAngleFlickController.kt @@ -11,6 +11,7 @@ import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickGestureMath import com.kazumaproject.core.domain.flick.GestureSessionConfig import com.kazumaproject.core.domain.flick.GestureSessionConfigSource +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef import com.kazumaproject.custom_keyboard.data.CircularFlickDirection import com.kazumaproject.custom_keyboard.data.FlickAction import com.kazumaproject.custom_keyboard.data.FlickPopupColorTheme @@ -60,6 +61,7 @@ class CustomAngleFlickController( var listener: FlickListener? = null private var popupWindowAnchorProvider: (() -> View?)? = null + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT private val popupView = CustomAngleFlickPopupView(context) private val popupWindow = PopupWindow( @@ -122,6 +124,11 @@ class CustomAngleFlickController( popupView.setColors(theme) } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + popupView.setKeyboardSkin(skinId) + } + // 見た目用に centerRadius (px) を受け取れるようにする // これにより、flickSensitivity(判定) と centerRadius(見た目) を別々に管理可能 fun setPopupViewSize(orbit: Float, centerRadius: Float, textSize: Float) { diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/FlickLongPressInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/FlickLongPressInputController.kt index 255430603..321401955 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/FlickLongPressInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/FlickLongPressInputController.kt @@ -9,6 +9,7 @@ import android.view.ViewConfiguration import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickGestureMath import com.kazumaproject.core.domain.flick.GestureSessionConfig @@ -71,6 +72,7 @@ class FlickLongPressInputController( private var popupHighlightedColor: Int? = null private var popupTextColor: Int? = null private var popupStyle = PopupViewStyle(100, 20f) + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT private val longPressRunnable = Runnable { val direction = longPressDirection ?: return@Runnable @@ -88,6 +90,11 @@ class FlickLongPressInputController( popupTextColor = textColor } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + popupView?.setKeyboardSkin(skinId) + } + fun applyPopupViewStyle(style: PopupViewStyle) { popupStyle = PopupViewStyle( sizeScalePercent = style.sizeScalePercent.coerceIn(50, 200), @@ -267,6 +274,7 @@ class FlickLongPressInputController( private fun showPopup(anchorView: View) { popupWindow?.dismiss() popupView = TfbiFlickPopupView(context).apply { + setKeyboardSkin(keyboardSkinId) setInputTextTransform(inputTextTransform) if (popupBackgroundColor != null && popupHighlightedColor != null && popupTextColor != null) { setColors(popupBackgroundColor!!, popupHighlightedColor!!, popupTextColor!!) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/StandardFlickInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/StandardFlickInputController.kt index 14ab0fd2c..b15d5ca78 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/StandardFlickInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/StandardFlickInputController.kt @@ -11,6 +11,7 @@ import android.view.WindowManager import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickGestureMath import com.kazumaproject.core.domain.flick.GestureSessionConfig @@ -62,6 +63,7 @@ class StandardFlickInputController( private var popupTextColor: Int = Color.BLACK private var popupStrokeColor: Int = Color.LTGRAY private var popupStyle = PopupViewStyle(100, 19f) + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT init { popupWindow = PopupWindow( @@ -85,6 +87,11 @@ class StandardFlickInputController( this.popupStrokeColor = theme.separatorColor } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + popupView.setKeyboardSkin(skinId) + } + fun applyPopupViewStyle(style: PopupViewStyle) { popupStyle = PopupViewStyle( sizeScalePercent = style.sizeScalePercent.coerceIn(50, 200), diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt index cfda54ed6..cc602accb 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiGuidePopupHost.kt @@ -5,6 +5,7 @@ import android.view.MotionEvent import android.view.View import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition import com.kazumaproject.custom_keyboard.data.TfbiGuidePopupState import com.kazumaproject.custom_keyboard.view.TfbiFlickDirection @@ -39,6 +40,7 @@ internal class TfbiGuidePopupHost( private var configuredBackgroundColor: Int? = null private var configuredHighlightedColor: Int? = null private var configuredTextColor: Int? = null + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT fun show( anchor: View, @@ -51,6 +53,7 @@ internal class TfbiGuidePopupHost( val panel = guideView ?: TfbiGuidePopupView(context).also { guideView = it } val arrow = arrowView ?: TfbiGestureArrowView(context).also { arrowView = it } + panel.setKeyboardSkin(keyboardSkinId) panel.setInputTextTransform(inputTextTransform) panel.applyPopupViewStyle(style) applyConfiguredColors(panel, arrow) @@ -102,6 +105,11 @@ internal class TfbiGuidePopupHost( guideView?.setInputTextTransform(transform) } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + guideView?.setKeyboardSkin(skinId) + } + fun applyPopupViewStyle(style: PopupViewStyle) { guideView?.let { panel -> panel.applyPopupViewStyle(style) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt index dd4f65f2e..342b0f31d 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiHierarchicalFlickController.kt @@ -11,6 +11,7 @@ import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickGestureMath @@ -129,6 +130,7 @@ class TfbiHierarchicalFlickController( private var popupBackgroundColor: Int? = null private var popupHighlightedColor: Int? = null private var popupTextColor: Int? = null + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT private var modeSwitchAngleMargin = MODE_SWITCH_ANGLE_MARGIN private val longPressRunnable = Runnable { val view = attachedView ?: return@Runnable @@ -147,6 +149,12 @@ class TfbiHierarchicalFlickController( guidePopupHost.setColors(backgroundColor, highlightedColor, textColor) } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + popupView?.setKeyboardSkin(skinId) + guidePopupHost.setKeyboardSkin(skinId) + } + fun applyPopupViewStyle(style: PopupViewStyle) { popupStyle = PopupViewStyle( sizeScalePercent = style.sizeScalePercent.coerceIn(50, 200), @@ -733,6 +741,7 @@ class TfbiHierarchicalFlickController( } popupView = TfbiFlickPopupView(context).apply { + setKeyboardSkin(keyboardSkinId) setInputTextTransform(inputTextTransform) // ▼▼▼ 修正: 色設定があれば適用 ▼▼▼ if (popupBackgroundColor != null && popupHighlightedColor != null && popupTextColor != null) { diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt index 1d136d386..823e92f78 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiInputController.kt @@ -10,6 +10,7 @@ import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickGestureMath @@ -123,6 +124,7 @@ class TfbiInputController( private var popupHighlightedColor: Int? = null private var popupTextColor: Int? = null private var popupStyle = PopupViewStyle(100, 20f) + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT // ▼▼▼ 追加: 色を設定するメソッド ▼▼▼ fun setPopupColors(backgroundColor: Int, highlightedColor: Int, textColor: Int) { @@ -132,6 +134,12 @@ class TfbiInputController( guidePopupHost.setColors(backgroundColor, highlightedColor, textColor) } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + popupView?.setKeyboardSkin(skinId) + guidePopupHost.setKeyboardSkin(skinId) + } + fun applyPopupViewStyle(style: PopupViewStyle) { popupStyle = PopupViewStyle( sizeScalePercent = style.sizeScalePercent.coerceIn(50, 200), @@ -429,6 +437,7 @@ class TfbiInputController( } popupView = TfbiFlickPopupView(context).apply { + setKeyboardSkin(keyboardSkinId) setInputTextTransform(inputTextTransform) // ▼▼▼ 修正: 色設定があれば適用 ▼▼▼ if (popupBackgroundColor != null && popupHighlightedColor != null && popupTextColor != null) { diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt index 28fabeff0..9f5ee97bb 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/controller/TfbiStickyFlickController.kt @@ -11,6 +11,7 @@ import android.widget.PopupWindow import androidx.core.graphics.drawable.toDrawable import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode import com.kazumaproject.core.domain.flick.FixedGestureSessionConfigSource import com.kazumaproject.core.domain.flick.FlickGestureMath @@ -82,6 +83,7 @@ class TfbiStickyFlickController( private var popupBackgroundColor: Int? = null private var popupHighlightedColor: Int? = null private var popupTextColor: Int? = null + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT private var popupWindowAnchorProvider: (() -> View?)? = null private var popupPresentationMode = TfbiPopupPresentationMode.LEGACY_GRID @@ -137,6 +139,12 @@ class TfbiStickyFlickController( guidePopupHost.setColors(backgroundColor, highlightedColor, textColor) } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + popupView?.setKeyboardSkin(skinId) + guidePopupHost.setKeyboardSkin(skinId) + } + fun applyPopupViewStyle(style: PopupViewStyle) { popupStyle = PopupViewStyle( sizeScalePercent = style.sizeScalePercent.coerceIn(50, 200), @@ -369,6 +377,7 @@ class TfbiStickyFlickController( } popupView = TfbiFlickPopupView(context).apply { + setKeyboardSkin(keyboardSkinId) setInputTextTransform(inputTextTransform) if (popupBackgroundColor != null && popupHighlightedColor != null && popupTextColor != null) { setColors(popupBackgroundColor!!, popupHighlightedColor!!, popupTextColor!!) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CrossFlickPopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CrossFlickPopupView.kt index 854d6cafb..8e8ac7ad6 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CrossFlickPopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CrossFlickPopupView.kt @@ -14,6 +14,9 @@ import android.widget.TextView import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatTextView import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupKind +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer import com.kazumaproject.custom_keyboard.data.FlickAction import com.kazumaproject.custom_keyboard.data.FlickDirection import com.kazumaproject.custom_keyboard.data.FlickPopupColorTheme @@ -53,7 +56,7 @@ private fun FlickAction.toPopupCellContent(): PopupCellContent = when (this) { class CrossFlickPopupView(context: Context) : FrameLayout(context) { - private class CellView(context: Context) : FrameLayout(context) { + private inner class CellView(context: Context) : FrameLayout(context) { val textView: TextView = AppCompatTextView(context).apply { gravity = Gravity.CENTER setTextColor(Color.WHITE) @@ -106,6 +109,24 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { backgroundColor: Int?, textColor: Int? ) { + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) + if (popup != null) { + background = KeyboardSkinPopupRenderer.createDrawable( + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CROSS, + selected = highlighted, + ) + textView.setTextColor( + if (highlighted) popup.selectedTextColor else popup.textColor + ) + imageView.setColorFilter( + if (highlighted) popup.selectedTextColor else popup.textColor, + PorterDuff.Mode.SRC_IN, + ) + applyTextSize(popup.flickTextSizeSp) + return + } val resolvedTextColor = textColor ?: theme.textColor textView.setTextColor(resolvedTextColor) imageView.setColorFilter(resolvedTextColor, PorterDuff.Mode.SRC_IN) @@ -126,6 +147,24 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { backgroundColor: Int?, textColor: Int? ) { + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) + if (popup != null) { + background = KeyboardSkinPopupRenderer.createDrawable( + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CROSS, + selected = highlighted, + ) + val resolvedTextColor = if (highlighted) { + popup.selectedTextColor + } else { + popup.textColor + } + textView.setTextColor(resolvedTextColor) + imageView.setColorFilter(resolvedTextColor, PorterDuff.Mode.SRC_IN) + applyTextSize(popup.flickTextSizeSp) + return + } val typedValue = TypedValue() val color = if (highlighted) { context.theme.resolveAttribute(materialR.attr.colorSecondaryContainer, typedValue, true) @@ -143,7 +182,11 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { } fun applyTextSize(textSizeSp: Float) { - textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSizeSp.coerceIn(8f, 48f)) + val fixedSize = KeyboardSkinPopupRenderer.specFor(keyboardSkinId)?.flickTextSizeSp + textView.setTextSize( + TypedValue.COMPLEX_UNIT_SP, + (fixedSize ?: textSizeSp).coerceIn(8f, 48f), + ) } } @@ -160,6 +203,7 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { private var popupBackgroundColor: Int? = null private var popupTextColor: Int? = null private var inputTextTransform: (String) -> String = { it } + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT init { addView(gridLayout, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)) @@ -170,10 +214,18 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { updateCellColors() } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + updateCellColors() + updateCellTextSizes() + invalidate() + } + fun applyPopupViewStyle(style: PopupViewStyle) { - popupTextSizeSp = style.textSizeSp.coerceIn(8f, 48f) - popupBackgroundColor = style.backgroundColor - popupTextColor = style.textColor + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) + popupTextSizeSp = popup?.flickTextSizeSp ?: style.textSizeSp.coerceIn(8f, 48f) + popupBackgroundColor = if (popup == null) style.backgroundColor else null + popupTextColor = if (popup == null) style.textColor else popup.textColor updateCellTextSizes() updateCellColors() invalidate() @@ -239,7 +291,11 @@ class CrossFlickPopupView(context: Context) : FrameLayout(context) { gridPositions.forEach { (direction, pos) -> val action = map[direction] - val margin = (1 * context.resources.displayMetrics.density).toInt() + val margin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + KeyboardSkinPopupRenderer.specFor(keyboardSkinId)?.itemGapDp?.div(2f) ?: 1f, + resources.displayMetrics, + ).toInt() val params = GridLayout.LayoutParams( GridLayout.spec(pos.first), diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CustomAngleFlickPopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CustomAngleFlickPopupView.kt index 4eebc9fbb..9737721f6 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CustomAngleFlickPopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/CustomAngleFlickPopupView.kt @@ -11,6 +11,7 @@ import android.graphics.RectF import android.graphics.Shader import android.graphics.Typeface import android.util.AttributeSet +import android.util.TypedValue import android.view.View import androidx.core.graphics.toColorInt import com.kazumaproject.custom_keyboard.data.CircularFlickDirection @@ -19,6 +20,9 @@ import com.kazumaproject.custom_keyboard.data.FlickPopupColorTheme import com.kazumaproject.custom_keyboard.data.KeyAction import com.kazumaproject.custom_keyboard.data.ShapeType import com.kazumaproject.custom_keyboard.data.getDirectionForAngle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupKind +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer import kotlin.math.cos import kotlin.math.sin @@ -55,6 +59,7 @@ class CustomAngleFlickPopupView @JvmOverloads constructor( separatorColor = "#BDBDBD".toColorInt(), textColor = Color.BLACK ) + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT init { applyThemeToPaints() @@ -91,7 +96,18 @@ class CustomAngleFlickPopupView @JvmOverloads constructor( fun setUiSize(orbit: Float, centerRadius: Float, newTextSize: Float) { this.orbitRadius = orbit this.centerCircleRadius = centerRadius // ここで見た目のサイズを固定 - this.textPaint.textSize = newTextSize + this.textPaint.textSize = if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + KeyboardSkinPopupRenderer.popupTextSizeSp( + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CIRCLE, + ) ?: newTextSize, + resources.displayMetrics, + ) + } else { + newTextSize + } updateSizesAndRequestLayout() } @@ -115,12 +131,52 @@ class CustomAngleFlickPopupView @JvmOverloads constructor( } fun setColors(theme: FlickPopupColorTheme) { + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + applyCupertinoColors() + return + } this.colorTheme = theme applyThemeToPaints() if (width > 0) updateShaders() invalidate() } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + if (KeyboardSkinPopupRenderer.isFixedCupertino(skinId)) { + applyCupertinoColors() + KeyboardSkinPopupRenderer.applyPaintStyle( + textPaint, + context, + skinId, + KeyboardSkinPopupKind.FLICK_CIRCLE, + ) + separatorPaint.alpha = 0 + } else { + textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + separatorPaint.alpha = 255 + applyThemeToPaints() + } + invalidate() + } + + private fun applyCupertinoColors() { + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) ?: return + this.colorTheme = FlickPopupColorTheme( + segmentColor = popup.surfaceColor, + segmentHighlightGradientStartColor = popup.selectedSurfaceColor, + segmentHighlightGradientEndColor = popup.selectedSurfaceColor, + centerGradientStartColor = popup.surfaceColor, + centerGradientEndColor = popup.surfaceColor, + centerHighlightGradientStartColor = popup.selectedSurfaceColor, + centerHighlightGradientEndColor = popup.selectedSurfaceColor, + separatorColor = popup.surfaceColor, + textColor = popup.textColor, + ) + applyThemeToPaints() + if (width > 0) updateShaders() + } + fun setCharacterMap(map: Map) { characterMap.clear() characterMap.putAll(map) @@ -187,10 +243,19 @@ class CustomAngleFlickPopupView @JvmOverloads constructor( if (isFullUIModeActive || direction == currentFlickDirection) { val path = segmentPaths[direction] ?: return@forEach val isSelected = (direction == currentFlickDirection) - val paint = if (isSelected) targetHighlightPaint else targetPaint - - canvas.drawPath(path, paint) - canvas.drawPath(path, separatorPaint) + if (!KeyboardSkinPopupRenderer.drawPath( + canvas, + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CIRCLE, + path, + isSelected, + ) + ) { + val paint = if (isSelected) targetHighlightPaint else targetPaint + canvas.drawPath(path, paint) + canvas.drawPath(path, separatorPaint) + } val text = characterMap[direction].toDisplayLabel() val pos = targetPositions[direction] ?: centerPoint @@ -210,7 +275,20 @@ class CustomAngleFlickPopupView @JvmOverloads constructor( val isCenterSelected = (currentFlickDirection == CircularFlickDirection.TAP) val centerPaint = if (isCenterSelected) centerHighlightPaint else centerCirclePaint - canvas.drawCircle(centerPoint.x, centerPoint.y, centerCircleRadius, centerPaint) + val centerPath = Path().apply { + addCircle(centerPoint.x, centerPoint.y, centerCircleRadius, Path.Direction.CW) + } + if (!KeyboardSkinPopupRenderer.drawPath( + canvas, + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CIRCLE, + centerPath, + isCenterSelected, + ) + ) { + canvas.drawCircle(centerPoint.x, centerPoint.y, centerCircleRadius, centerPaint) + } val centerText = characterMap[currentFlickDirection].toDisplayLabel() .ifEmpty { characterMap[CircularFlickDirection.TAP].toDisplayLabel() } @@ -317,6 +395,7 @@ class CustomAngleFlickPopupView @JvmOverloads constructor( private fun applyThemeToPaints() { targetPaint.color = colorTheme.segmentColor separatorPaint.color = colorTheme.separatorColor + separatorPaint.alpha = if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) 0 else 255 textPaint.color = colorTheme.textColor } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupView.kt index 1cf3151d6..112278995 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/DirectionalKeyPopupView.kt @@ -11,6 +11,11 @@ import android.util.TypedValue import androidx.appcompat.widget.AppCompatTextView import androidx.core.graphics.toColorInt import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupDirection +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupDrawable +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupKind +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer import com.kazumaproject.custom_keyboard.data.FlickDirection import com.kazumaproject.custom_keyboard.data.FlickPopupColorTheme import kotlin.math.min @@ -44,6 +49,8 @@ class DirectionalKeyPopupView(context: Context) : AppCompatTextView(context) { private var separatorColor = Color.LTGRAY private var popupBackgroundColor: Int? = null private var popupTextColor: Int? = null + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var skinPopupDrawable: KeyboardSkinPopupDrawable? = null init { // init時のテキスト色はテーマで上書きされる前提 @@ -57,6 +64,10 @@ class DirectionalKeyPopupView(context: Context) : AppCompatTextView(context) { * ▼▼▼ 変更点: 枠線用の色もテーマから受け取るように変更 ▼▼▼ */ fun setColors(theme: FlickPopupColorTheme) { + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + applyCupertinoSkin() + return + } this.defaultColor = theme.centerGradientStartColor this.highlightColor = theme.centerGradientStartColor this.separatorColor = theme.separatorColor @@ -66,10 +77,29 @@ class DirectionalKeyPopupView(context: Context) : AppCompatTextView(context) { fun setFlickDirection(direction: FlickDirection) { this.currentDirection = direction + skinPopupDrawable?.setDirection(direction.toPopupDirection()) + invalidate() + } + + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + skinPopupDrawable = KeyboardSkinPopupRenderer.createDrawable( + context, + skinId, + KeyboardSkinPopupKind.FLICK_DIRECTIONAL, + currentDirection.toPopupDirection(), + ) as? KeyboardSkinPopupDrawable + if (skinPopupDrawable != null) { + applyCupertinoSkin() + } invalidate() } fun applyPopupViewStyle(style: PopupViewStyle) { + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + applyCupertinoSkin() + return + } popupBackgroundColor = style.backgroundColor popupTextColor = style.textColor setTextSize(TypedValue.COMPLEX_UNIT_SP, style.textSizeSp.coerceIn(8f, 48f)) @@ -85,6 +115,11 @@ class DirectionalKeyPopupView(context: Context) : AppCompatTextView(context) { val h = height.toFloat() if (w == 0f || h == 0f) return + if (skinPopupDrawable != null) { + skinPopupDrawable?.setBounds(0, 0, width, height) + skinPopupDrawable?.draw(canvas) + } + backgroundPaint.color = popupBackgroundColor ?: if (currentDirection == FlickDirection.TAP) { this.highlightColor } else { @@ -100,8 +135,10 @@ class DirectionalKeyPopupView(context: Context) : AppCompatTextView(context) { val saveCount = canvas.save() canvas.translate(strokeInset, strokeInset) - canvas.drawPath(backgroundPath, backgroundPaint) - canvas.drawPath(backgroundPath, strokePaint) + if (skinPopupDrawable == null) { + canvas.drawPath(backgroundPath, backgroundPaint) + canvas.drawPath(backgroundPath, strokePaint) + } canvas.restoreToCount(saveCount) val textToDraw = this.text.toString() @@ -249,4 +286,31 @@ class DirectionalKeyPopupView(context: Context) : AppCompatTextView(context) { context.resources.displayMetrics ) } + + private fun applyCupertinoSkin() { + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) ?: return + defaultColor = popup.surfaceColor + highlightColor = popup.selectedSurfaceColor + separatorColor = popup.surfaceColor + popupBackgroundColor = null + popupTextColor = popup.textColor + setTextColor(popup.textColor) + KeyboardSkinPopupRenderer.applyTextStyle( + this, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_DIRECTIONAL, + selected = currentDirection == FlickDirection.TAP, + ) + skinPopupDrawable?.setDirection(currentDirection.toPopupDirection()) + } + + private fun FlickDirection.toPopupDirection(): KeyboardSkinPopupDirection = when (this) { + FlickDirection.UP -> KeyboardSkinPopupDirection.UP + FlickDirection.DOWN -> KeyboardSkinPopupDirection.DOWN + FlickDirection.UP_LEFT, + FlickDirection.UP_LEFT_FAR -> KeyboardSkinPopupDirection.LEFT + FlickDirection.UP_RIGHT, + FlickDirection.UP_RIGHT_FAR -> KeyboardSkinPopupDirection.RIGHT + FlickDirection.TAP -> KeyboardSkinPopupDirection.CENTER + } } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickCirclePopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickCirclePopupView.kt index aa5f11a31..a33eadff9 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickCirclePopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickCirclePopupView.kt @@ -12,11 +12,15 @@ import android.graphics.Shader import android.graphics.Typeface import android.os.Build import android.util.AttributeSet +import android.util.TypedValue import android.view.View import androidx.core.graphics.toColorInt import com.kazumaproject.custom_keyboard.data.FlickDirection import com.kazumaproject.custom_keyboard.data.FlickPopupColorTheme import com.kazumaproject.custom_keyboard.data.ShapeType +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupKind +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer import java.util.EnumSet import kotlin.math.cos import kotlin.math.sin @@ -53,6 +57,7 @@ class FlickCirclePopupView @JvmOverloads constructor( separatorColor = "#BDBDBD".toColorInt(), textColor = Color.BLACK ) + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT init { applyThemeToPaints() @@ -91,6 +96,10 @@ class FlickCirclePopupView @JvmOverloads constructor( } fun setColors(theme: FlickPopupColorTheme) { + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + applyCupertinoColors() + return + } this.colorTheme = theme applyThemeToPaints() if (width > 0 && height > 0) { @@ -99,6 +108,42 @@ class FlickCirclePopupView @JvmOverloads constructor( invalidate() } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + if (KeyboardSkinPopupRenderer.isFixedCupertino(skinId)) { + applyCupertinoColors() + KeyboardSkinPopupRenderer.applyPaintStyle( + textPaint, + context, + skinId, + KeyboardSkinPopupKind.FLICK_CIRCLE, + ) + separatorPaint.alpha = 0 + } else { + textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + separatorPaint.alpha = 255 + applyThemeToPaints() + } + invalidate() + } + + private fun applyCupertinoColors() { + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) ?: return + this.colorTheme = FlickPopupColorTheme( + segmentColor = popup.surfaceColor, + segmentHighlightGradientStartColor = popup.selectedSurfaceColor, + segmentHighlightGradientEndColor = popup.selectedSurfaceColor, + centerGradientStartColor = popup.surfaceColor, + centerGradientEndColor = popup.surfaceColor, + centerHighlightGradientStartColor = popup.selectedSurfaceColor, + centerHighlightGradientEndColor = popup.selectedSurfaceColor, + separatorColor = popup.surfaceColor, + textColor = popup.textColor, + ) + applyThemeToPaints() + if (width > 0 && height > 0) updateShaders() + } + fun setUiSize( center: Float, target: Float, @@ -108,7 +153,18 @@ class FlickCirclePopupView @JvmOverloads constructor( ) { this.centerCircleRadius = center this.orbitRadius = orbit - this.textPaint.textSize = newTextSize + this.textPaint.textSize = if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + KeyboardSkinPopupRenderer.popupTextSizeSp( + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CIRCLE, + ) ?: newTextSize, + resources.displayMetrics, + ) + } else { + newTextSize + } newCornerRadius?.let { this.cornerRadius = it } updateSizesAndRequestLayout() } @@ -318,19 +374,44 @@ class FlickCirclePopupView @JvmOverloads constructor( private fun drawFullUI(canvas: Canvas) { segmentPaths.forEach { (direction, path) -> - val paint = - if (direction == currentFlickDirection) targetHighlightPaint else targetPaint - canvas.drawPath(path, paint) + val selected = direction == currentFlickDirection + if (!KeyboardSkinPopupRenderer.drawPath( + canvas, + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CIRCLE, + path, + selected, + ) + ) { + val paint = if (selected) targetHighlightPaint else targetPaint + canvas.drawPath(path, paint) + } } - segmentPaths.values.forEach { path -> - canvas.drawPath(path, separatorPaint) + if (!KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + segmentPaths.values.forEach { path -> + canvas.drawPath(path, separatorPaint) + } } val centerPoint = targetPositions[FlickDirection.TAP] ?: PointF(width / 2f, height / 2f) - val currentCenterPaint = - if (currentFlickDirection == FlickDirection.TAP) centerHighlightPaint else centerCirclePaint - canvas.drawCircle(centerPoint.x, centerPoint.y, centerCircleRadius, currentCenterPaint) + val centerSelected = currentFlickDirection == FlickDirection.TAP + val centerPath = Path().apply { + addCircle(centerPoint.x, centerPoint.y, centerCircleRadius, Path.Direction.CW) + } + if (!KeyboardSkinPopupRenderer.drawPath( + canvas, + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CIRCLE, + centerPath, + centerSelected, + ) + ) { + val currentCenterPaint = if (centerSelected) centerHighlightPaint else centerCirclePaint + canvas.drawCircle(centerPoint.x, centerPoint.y, centerCircleRadius, currentCenterPaint) + } characterMap.forEach { (direction, text) -> if (direction != FlickDirection.TAP) { @@ -351,11 +432,37 @@ class FlickCirclePopupView @JvmOverloads constructor( // Draw the center circle, highlighting it if TAP is the selected direction val currentCenterPaint = if (currentFlickDirection == FlickDirection.TAP) centerHighlightPaint else centerCirclePaint - canvas.drawCircle(centerPoint.x, centerPoint.y, centerCircleRadius, currentCenterPaint) + val centerSelected = currentFlickDirection == FlickDirection.TAP + val centerPath = Path().apply { + addCircle(centerPoint.x, centerPoint.y, centerCircleRadius, Path.Direction.CW) + } + if (!KeyboardSkinPopupRenderer.drawPath( + canvas, + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CIRCLE, + centerPath, + centerSelected, + ) + ) { + canvas.drawCircle(centerPoint.x, centerPoint.y, centerCircleRadius, currentCenterPaint) + } // If flicking (not tapping), draw the highlighted segment path and the character within it if (currentFlickDirection != FlickDirection.TAP) { - segmentPaths[currentFlickDirection]?.let { canvas.drawPath(it, targetHighlightPaint) } + segmentPaths[currentFlickDirection]?.let { path -> + if (!KeyboardSkinPopupRenderer.drawPath( + canvas, + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CIRCLE, + path, + selected = true, + ) + ) { + canvas.drawPath(path, targetHighlightPaint) + } + } val flickedCharacter = characterMap[currentFlickDirection] ?: "" drawTextOnTarget(canvas, flickedCharacter, currentFlickDirection) @@ -382,7 +489,7 @@ class FlickCirclePopupView @JvmOverloads constructor( private fun applyThemeToPaints() { targetPaint.color = colorTheme.segmentColor separatorPaint.color = colorTheme.separatorColor - separatorPaint.alpha = 100 + separatorPaint.alpha = if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) 0 else 100 textPaint.color = colorTheme.textColor } diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt index 19517a808..96c841cbf 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/FlickKeyboardView.kt @@ -34,6 +34,20 @@ import com.kazumaproject.core.data.popup.TfbiFlickStartPositionMode import com.kazumaproject.core.data.popup.FlickPopupViewStyleSet import com.kazumaproject.core.data.popup.PopupViewStyle import com.kazumaproject.core.data.popup.TfbiPopupPresentationMode +import com.kazumaproject.core.data.keyboard.KeyboardElementRole +import com.kazumaproject.core.data.keyboard.KeyboardSkinCatalog +import com.kazumaproject.core.data.keyboard.KeyboardSkinDrawableFactory +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinMotionMode +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer +import com.kazumaproject.core.data.keyboard.KeyboardSkinRendererRegistry +import com.kazumaproject.core.data.keyboard.KeyboardSkinRuntime +import com.kazumaproject.core.data.keyboard.KeyboardSkinViewStyler +import com.kazumaproject.core.data.keyboard.KeyboardSurfaceRole +import com.kazumaproject.core.data.keyboard.resolveKeyboardSkinPalette +import com.kazumaproject.core.data.keyboard.isDefault +import com.kazumaproject.core.data.keyboard.resolvedOrDefault import com.kazumaproject.core.domain.extensions.isDarkThemeOn import com.kazumaproject.core.domain.extensions.setBorder import com.kazumaproject.core.domain.extensions.setDrawableAlpha @@ -205,6 +219,9 @@ class FlickKeyboardView @JvmOverloads constructor( ) private var themeMode: String = "default" + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var keyboardSkinMotionMode: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL + private var keyboardSkinRuntimeGeneration: Long = 0L private var isNightMode: Boolean = false private var isDynamicColorEnabled: Boolean = false private var customBgColor: Int = Color.WHITE @@ -308,12 +325,22 @@ class FlickKeyboardView @JvmOverloads constructor( } fun applyPopupViewStyleSet(styleSet: FlickPopupViewStyleSet) { - popupViewStyleSet = FlickPopupViewStyleSet( - directional = clampPopupStyle(styleSet.directional), - cross = clampPopupStyle(styleSet.cross), - standard = clampPopupStyle(styleSet.standard), - tfbi = clampPopupStyle(styleSet.tfbi) - ) + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) + popupViewStyleSet = if (popup != null) { + FlickPopupViewStyleSet( + directional = PopupViewStyle(100, popup.flickTextSizeSp), + cross = PopupViewStyle(100, popup.flickTextSizeSp), + standard = PopupViewStyle(100, popup.flickTextSizeSp), + tfbi = PopupViewStyle(100, popup.flickTextSizeSp), + ) + } else { + FlickPopupViewStyleSet( + directional = clampPopupStyle(styleSet.directional), + cross = clampPopupStyle(styleSet.cross), + standard = clampPopupStyle(styleSet.standard), + tfbi = clampPopupStyle(styleSet.tfbi), + ) + } crossFlickControllers.forEach { it.applyPopupViewStyleSet(popupViewStyleSet.directional, popupViewStyleSet.cross) } @@ -480,33 +507,65 @@ class FlickKeyboardView @JvmOverloads constructor( customBorderEnable: Boolean, customBorderColor: Int, liquidGlassKeyAlphaEnable: Int, - borderWidth: Int + borderWidth: Int, + keyboardSkin: String = KeyboardSkinId.DEFAULT.preferenceValue, + keyboardSkinMotion: String = KeyboardSkinMotionMode.FULL.preferenceValue, ) { + val requestedSkin = KeyboardSkinRef.fromPreference(keyboardSkin).resolvedOrDefault() + val requestedMotion = KeyboardSkinMotionMode.fromPreference(keyboardSkinMotion) + val requestedRuntimeGeneration = + if (requestedSkin is KeyboardSkinRef.Imported) KeyboardSkinRuntime.generation() else 0L + val builtInPalette = requestedSkin + .takeIf { !it.isDefault() } + ?.let { KeyboardSkinCatalog.specFor(it).palette } + val effectiveThemeMode = if (builtInPalette != null) "custom" else themeMode + val effectiveBgColor = builtInPalette?.backgroundColor ?: customBgColor + val effectiveKeyColor = builtInPalette?.normalKeyColor ?: customKeyColor + val effectiveSpecialKeyColor = builtInPalette?.specialKeyColor ?: customSpecialKeyColor + val effectiveKeyTextColor = builtInPalette?.normalKeyTextColor ?: customKeyTextColor + val effectiveSpecialKeyTextColor = + builtInPalette?.specialKeyTextColor ?: customSpecialKeyTextColor + val effectiveLiquidGlass = liquidGlassEnable && builtInPalette == null + val effectiveCustomBorder = customBorderEnable && builtInPalette == null val renderConfigurationChanged = - this.themeMode != themeMode || + this.themeMode != effectiveThemeMode || + this.keyboardSkinId != requestedSkin || + this.keyboardSkinMotionMode != requestedMotion || + this.keyboardSkinRuntimeGeneration != requestedRuntimeGeneration || this.isNightMode != (currentNightMode == Configuration.UI_MODE_NIGHT_YES) || this.isDynamicColorEnabled != isDynamicColorEnabled || - this.customBgColor != customBgColor || - this.customKeyColor != customKeyColor || - this.customSpecialKeyColor != customSpecialKeyColor || - this.customKeyTextColor != customKeyTextColor || - this.customSpecialKeyTextColor != customSpecialKeyTextColor || - this.liquidGlassEnable != liquidGlassEnable || - this.customBorderEnable != customBorderEnable || + this.customBgColor != effectiveBgColor || + this.customKeyColor != effectiveKeyColor || + this.customSpecialKeyColor != effectiveSpecialKeyColor || + this.customKeyTextColor != effectiveKeyTextColor || + this.customSpecialKeyTextColor != effectiveSpecialKeyTextColor || + this.liquidGlassEnable != effectiveLiquidGlass || + this.customBorderEnable != effectiveCustomBorder || this.customBorderColor != customBorderColor || this.liquidGlassKeyAlphaEnable != liquidGlassKeyAlphaEnable || this.borderWidth != borderWidth - this.themeMode = themeMode + this.themeMode = effectiveThemeMode + this.keyboardSkinId = requestedSkin + this.keyboardSkinMotionMode = requestedMotion + this.keyboardSkinRuntimeGeneration = requestedRuntimeGeneration + KeyboardSkinPopupRenderer.specFor(requestedSkin)?.let { popup -> + popupViewStyleSet = FlickPopupViewStyleSet( + directional = PopupViewStyle(100, popup.flickTextSizeSp), + cross = PopupViewStyle(100, popup.flickTextSizeSp), + standard = PopupViewStyle(100, popup.flickTextSizeSp), + tfbi = PopupViewStyle(100, popup.flickTextSizeSp), + ) + } this.isNightMode = (currentNightMode == Configuration.UI_MODE_NIGHT_YES) this.isDynamicColorEnabled = isDynamicColorEnabled - this.customBgColor = customBgColor - this.customKeyColor = customKeyColor - this.customSpecialKeyColor = customSpecialKeyColor - this.customKeyTextColor = customKeyTextColor - this.customSpecialKeyTextColor = customSpecialKeyTextColor - this.liquidGlassEnable = liquidGlassEnable - this.customBorderEnable = customBorderEnable + this.customBgColor = effectiveBgColor + this.customKeyColor = effectiveKeyColor + this.customSpecialKeyColor = effectiveSpecialKeyColor + this.customKeyTextColor = effectiveKeyTextColor + this.customSpecialKeyTextColor = effectiveSpecialKeyTextColor + this.liquidGlassEnable = effectiveLiquidGlass + this.customBorderEnable = effectiveCustomBorder this.customBorderColor = customBorderColor this.liquidGlassKeyAlphaEnable = liquidGlassKeyAlphaEnable this.borderWidth = borderWidth @@ -514,9 +573,16 @@ class FlickKeyboardView @JvmOverloads constructor( keyboardRenderRevision += 1 } - if (liquidGlassEnable) { - this.setBackgroundColor(ColorUtils.setAlphaComponent(customBgColor, 0)) + if (!keyboardSkinId.isDefault()) { + background = KeyboardSkinRendererRegistry.rendererFor(keyboardSkinId) + .createSurfaceDrawable(context, KeyboardSurfaceRole.DECK) + } else if (effectiveLiquidGlass) { + this.setBackgroundColor(ColorUtils.setAlphaComponent(effectiveBgColor, 0)) + } else { + // Do not retain a previously selected skin surface after returning to Default. + background = null } + if (renderConfigurationChanged && currentLayout != null) rebuildCurrentKeyboard() } private fun manipulateColor(color: Int, factor: Float): Int { @@ -529,24 +595,38 @@ class FlickKeyboardView @JvmOverloads constructor( private fun resolveKeyVisualPalette(keyData: KeyData): KeyVisualPalette { val usesSpecialSurface = KeyVisualStyleResolver.usesSpecialSurface(keyData) + val palette = resolveKeyboardSkinPalette( + context = context, + themeMode = themeMode, + customBackgroundColor = customBgColor, + customKeyColor = customKeyColor, + customSpecialKeyColor = customSpecialKeyColor, + customKeyTextColor = customKeyTextColor, + customSpecialKeyTextColor = customSpecialKeyTextColor, + skinId = keyboardSkinId, + ) return if (usesSpecialSurface) { KeyVisualPalette( usesSpecialSurface = true, - baseColor = customSpecialKeyColor, - textColor = customSpecialKeyTextColor, - highlightColor = manipulateColor(customSpecialKeyColor, 1.2f) + baseColor = palette.specialKeyColor, + textColor = palette.specialKeyTextColor, + highlightColor = manipulateColor(palette.specialKeyColor, 1.2f) ) } else { KeyVisualPalette( usesSpecialSurface = false, - baseColor = customKeyColor, - textColor = customKeyTextColor, - highlightColor = customSpecialKeyColor + baseColor = palette.normalKeyColor, + textColor = palette.normalKeyTextColor, + highlightColor = palette.specialKeyColor ) } } private fun defaultKeyBackgroundDrawable(keyData: KeyData, isDarkTheme: Boolean): Drawable? { + if (!keyboardSkinId.isDefault()) { + return KeyboardSkinRendererRegistry.rendererFor(keyboardSkinId) + .createKeyDrawable(context, resolveElementRole(keyData), keyData.keyId.hashCode()) + } val drawableResId = when { resolveKeyVisualPalette(keyData).usesSpecialSurface -> { if (isDarkTheme) { @@ -1258,7 +1338,9 @@ class FlickKeyboardView @JvmOverloads constructor( val bottomInset = getScaledVerticalInsetDp(baseInsets[3]) val isDarkTheme = context.isDarkThemeOn() - val commonCornerRadius = dpToPx(8).toFloat() + val commonCornerRadius = dpToPx( + KeyboardSkinCatalog.specFor(keyboardSkinId).geometry.cornerRadiusDp.toInt() + ).toFloat() val visualPalette = resolveKeyVisualPalette(keyData) val keyView: View = if (KeyIconResolver.hasIcon(keyData)) { @@ -1290,8 +1372,8 @@ class FlickKeyboardView @JvmOverloads constructor( isPressed = true } - when (themeMode) { - "custom" -> { + when { + themeMode == "custom" || !keyboardSkinId.isDefault() -> { if (customBorderEnable) { setDrawableSolidColor(visualPalette.baseColor) setBorder(customBorderColor, borderWidth) @@ -1351,8 +1433,8 @@ class FlickKeyboardView @JvmOverloads constructor( background = insetBg } - when (themeMode) { - "custom" -> { + when { + themeMode == "custom" || !keyboardSkinId.isDefault() -> { if (customBorderEnable) { setDrawableSolidColor(visualPalette.baseColor) setTextColor(visualPalette.textColor) @@ -1388,10 +1470,98 @@ class FlickKeyboardView @JvmOverloads constructor( } } + if (!keyboardSkinId.isDefault()) { + applyBuiltInKeyStyle( + view = keyView, + keyData = keyData, + leftInset = leftInset, + topInset = topInset, + rightInset = rightInset, + bottomInset = bottomInset, + cornerRadius = commonCornerRadius, + ) + } + return keyView } + private fun applyBuiltInKeyStyle( + view: View, + keyData: KeyData, + leftInset: Int, + topInset: Int, + rightInset: Int, + bottomInset: Int, + cornerRadius: Float, + ) { + val role = resolveElementRole(keyData) + val spec = KeyboardSkinCatalog.specFor(keyboardSkinId) + KeyboardSkinViewStyler.applyKey( + view, + keyboardSkinId, + role, + keyboardSkinMotionMode, + keyData.keyId?.hashCode() ?: keyData.hashCode(), + ) + val base = view.background + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + view.background = android.graphics.drawable.InsetDrawable( + base, + leftInset, + topInset, + rightInset, + bottomInset, + ) + return + } + val segmented = SegmentedBackgroundDrawable( + label = "", + baseColor = Color.TRANSPARENT, + highlightColor = spec.palette.accentColor, + textColor = spec.palette.textColor(role), + cornerRadius = cornerRadius, + ) + val layer = LayerDrawable(arrayOf(base, segmented)).apply { + val innerHorizontal = dpToPx(getScaledHorizontalInsetDp(2)) + val innerVertical = dpToPx(getScaledVerticalInsetDp(2)) + setLayerInset(1, innerHorizontal, innerVertical, innerHorizontal, innerVertical) + } + view.background = android.graphics.drawable.InsetDrawable( + layer, + leftInset, + topInset, + rightInset, + bottomInset, + ) + } + + private fun resolveElementRole(keyData: KeyData): KeyboardElementRole = when (keyData.action) { + KeyAction.Enter, + KeyAction.Confirm, + KeyAction.NewLine, + KeyAction.ForceNewLine -> KeyboardElementRole.ACTION + + KeyAction.Space, + KeyAction.ForceHalfWidthSpace, + KeyAction.ForceFullWidthSpace -> KeyboardElementRole.SPACE + + else -> if (KeyVisualStyleResolver.usesSpecialSurface(keyData)) { + KeyboardElementRole.MODIFIER + } else { + KeyboardElementRole.CHARACTER + } + } + private fun getDynamicNeumorphDrawable(baseColor: Int, radius: Float): Drawable { + if (!keyboardSkinId.isDefault()) { + return KeyboardSkinDrawableFactory.createKeyDrawable( + context = context, + skinId = keyboardSkinId, + baseColor = baseColor, + cornerRadiusDp = radius / resources.displayMetrics.density, + ) + } + val highlightColor = manipulateColor(baseColor, 1.2f) val shadowColor = manipulateColor(baseColor, 0.8f) @@ -1456,6 +1626,7 @@ class FlickKeyboardView @JvmOverloads constructor( context = context, gestureConfigSource = gestureSessionConfigSource ).apply { + setKeyboardSkin(keyboardSkinId) setPopupWindowAnchorProvider(popupWindowAnchorProvider) setInputTextTransform(::transformInputTextForDisplay) val secondaryColor = @@ -1642,6 +1813,7 @@ class FlickKeyboardView @JvmOverloads constructor( context = context, gestureConfigSource = gestureSessionConfigSource ).apply { + setKeyboardSkin(keyboardSkinId) setPopupOverlayHostProvider(popupWindowAnchorProvider) setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyleSet( @@ -1878,6 +2050,7 @@ class FlickKeyboardView @JvmOverloads constructor( context = context, gestureConfigSource = gestureSessionConfigSource ).apply { + setKeyboardSkin(keyboardSkinId) setPopupWindowAnchorProvider(popupWindowAnchorProvider) setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.standard) @@ -1990,6 +2163,7 @@ class FlickKeyboardView @JvmOverloads constructor( context = context, gestureConfigSource = gestureSessionConfigSource ).apply { + setKeyboardSkin(keyboardSkinId) setPopupOverlayHostProvider(popupWindowAnchorProvider) setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyleSet( @@ -2142,6 +2316,7 @@ class FlickKeyboardView @JvmOverloads constructor( context = context, gestureConfigSource = gestureSessionConfigSource ).apply { + setKeyboardSkin(keyboardSkinId) setPopupWindowAnchorProvider(popupWindowAnchorProvider) setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.tfbi) @@ -2290,6 +2465,7 @@ class FlickKeyboardView @JvmOverloads constructor( context = context, gestureConfigSource = gestureSessionConfigSource ).apply { + setKeyboardSkin(keyboardSkinId) setPopupWindowAnchorProvider(popupWindowAnchorProvider) setPopupPresentationMode(tfbiPopupPresentationMode) setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) @@ -2409,6 +2585,7 @@ class FlickKeyboardView @JvmOverloads constructor( context = context, gestureConfigSource = gestureSessionConfigSource ).apply { + setKeyboardSkin(keyboardSkinId) setPopupWindowAnchorProvider(popupWindowAnchorProvider) setInputTextTransform(::transformInputTextForDisplay) applyPopupViewStyle(popupViewStyleSet.tfbi) @@ -2460,6 +2637,7 @@ class FlickKeyboardView @JvmOverloads constructor( context = context, gestureConfigSource = gestureSessionConfigSource ).apply { + setKeyboardSkin(keyboardSkinId) setPopupWindowAnchorProvider(popupWindowAnchorProvider) setPopupPresentationMode(tfbiPopupPresentationMode) setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) @@ -2532,6 +2710,7 @@ class FlickKeyboardView @JvmOverloads constructor( context = context, gestureConfigSource = gestureSessionConfigSource ).apply { + setKeyboardSkin(keyboardSkinId) setPopupWindowAnchorProvider(popupWindowAnchorProvider) setPopupPresentationMode(tfbiPopupPresentationMode) setTfbiFlickStartPositionMode(tfbiFlickStartPositionMode) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/StandardFlickPopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/StandardFlickPopupView.kt index c1c4eae7e..05ed85ec9 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/StandardFlickPopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/StandardFlickPopupView.kt @@ -19,6 +19,10 @@ import androidx.appcompat.widget.AppCompatTextView import androidx.core.graphics.toColorInt import androidx.core.text.inSpans import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupKind +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupDrawable import com.kazumaproject.custom_keyboard.data.FlickDirection import com.kazumaproject.custom_keyboard.data.FlickPopupColorTheme import kotlin.math.roundToInt @@ -47,6 +51,8 @@ class StandardFlickPopupView(context: Context) : AppCompatTextView(context) { private var popupBackgroundColor: Int? = null private var popupTextColor: Int? = null private var inputTextTransform: (String) -> String = { it } + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var skinPopupDrawable: KeyboardSkinPopupDrawable? = null private class YOffsetSpan(private val yOffset: Int) : ReplacementSpan() { override fun getSize( @@ -88,6 +94,10 @@ class StandardFlickPopupView(context: Context) : AppCompatTextView(context) { * 既存: 直接色指定 */ fun setColors(backgroundColor: Int, textColor: Int, strokeColor: Int) { + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + applyCupertinoSkin() + return + } lastBackgroundColor = backgroundColor lastTextColor = textColor lastStrokeColor = strokeColor @@ -108,9 +118,31 @@ class StandardFlickPopupView(context: Context) : AppCompatTextView(context) { */ fun setPopupColors(theme: FlickPopupColorTheme) { colorTheme = theme + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + applyCupertinoSkin() + return + } applyTheme(theme, flickDirection) } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + skinPopupDrawable = KeyboardSkinPopupRenderer.createDrawable( + context, + skinId, + KeyboardSkinPopupKind.FLICK_STANDARD, + ) as? KeyboardSkinPopupDrawable + if (skinPopupDrawable != null) { + background = skinPopupDrawable + applyCupertinoSkin() + } else { + skinPopupDrawable = null + background = backgroundDrawable + applyResolvedColors() + } + invalidate() + } + /** * 任意: 方向をセット(DirectionalKeyPopupView と同じ使い方ができる) */ @@ -232,6 +264,24 @@ class StandardFlickPopupView(context: Context) : AppCompatTextView(context) { } fun applyPopupViewStyle(style: PopupViewStyle) { + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + val popup = checkNotNull(KeyboardSkinPopupRenderer.specFor(keyboardSkinId)) + viewSize = dpToPx(72) + popupTextSizeSp = popup.flickTextSizeSp + popupBackgroundColor = null + popupTextColor = popup.textColor + background = skinPopupDrawable + KeyboardSkinPopupRenderer.applyTextStyle( + this, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_STANDARD, + ) + width = viewSize + height = viewSize + requestLayout() + invalidate() + return + } val scale = style.sizeScalePercent.coerceIn(50, 200) / 100f viewSize = (dpToPx(72) * scale).toInt().coerceAtLeast(1) popupTextSizeSp = style.textSizeSp.coerceIn(8f, 48f) @@ -250,6 +300,24 @@ class StandardFlickPopupView(context: Context) : AppCompatTextView(context) { backgroundDrawable.setStroke(dpToPx(1), lastStrokeColor) } + private fun applyCupertinoSkin() { + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) ?: return + lastBackgroundColor = popup.surfaceColor + lastTextColor = popup.textColor + lastStrokeColor = popup.surfaceColor + popupTextSizeSp = popup.flickTextSizeSp + popupBackgroundColor = null + popupTextColor = popup.textColor + background = skinPopupDrawable + KeyboardSkinPopupRenderer.applyTextStyle( + this, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_STANDARD, + ) + width = viewSize + height = viewSize + } + private fun createBackground(): GradientDrawable { return GradientDrawable().apply { shape = GradientDrawable.OVAL diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiFlickPopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiFlickPopupView.kt index c53c86eea..9f81331f9 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiFlickPopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiFlickPopupView.kt @@ -8,6 +8,9 @@ import android.util.TypedValue import android.view.View import androidx.core.content.ContextCompat import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupKind +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer import com.kazumaproject.core.domain.extensions.getThemeColor import com.kazumaproject.core.domain.extensions.isDarkThemeOn @@ -44,6 +47,7 @@ class TfbiFlickPopupView(context: Context) : View(context) { private var popupBackgroundColor: Int? = null private var popupTextColor: Int? = null private var inputTextTransform: (String) -> String = { it } + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT private val rects = mutableMapOf() private var cornerRadius = 20f @@ -67,6 +71,10 @@ class TfbiFlickPopupView(context: Context) : View(context) { * @param textColor テキストおよび枠線の色 */ fun setColors(backgroundColor: Int, highlightedBackgroundColor: Int, textColor: Int) { + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + applyCupertinoSkin() + return + } bgPaint.color = backgroundColor highlightBgPaint.color = highlightedBackgroundColor strokePaint.color = textColor @@ -75,6 +83,18 @@ class TfbiFlickPopupView(context: Context) : View(context) { invalidate() } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + if (KeyboardSkinPopupRenderer.isFixedCupertino(skinId)) { + applyCupertinoSkin() + } else { + strokePaint.alpha = 255 + textPaint.typeface = null + applyPopupColorOverrides() + } + invalidate() + } + fun setCharacters(tapChar: String, petalChars: Map) { this.tapCharacter = tapChar this.petalCharacters = petalChars @@ -94,9 +114,12 @@ class TfbiFlickPopupView(context: Context) : View(context) { } fun applyPopupViewStyle(style: PopupViewStyle) { - popupBackgroundColor = style.backgroundColor - popupTextColor = style.textColor - textPaint.textSize = spToPx(style.textSizeSp.coerceIn(8f, 48f)) + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) + popupBackgroundColor = if (popup == null) style.backgroundColor else null + popupTextColor = if (popup == null) style.textColor else popup.textColor + textPaint.textSize = spToPx( + (popup?.flickTextSizeSp ?: style.textSizeSp).coerceIn(8f, 48f) + ) applyPopupColorOverrides() invalidate() } @@ -109,6 +132,22 @@ class TfbiFlickPopupView(context: Context) : View(context) { } } + private fun applyCupertinoSkin() { + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) ?: return + bgPaint.color = popup.surfaceColor + highlightBgPaint.color = popup.selectedSurfaceColor + strokePaint.color = popup.surfaceColor + strokePaint.alpha = 0 + textPaint.color = popup.textColor + textPaint.typeface = android.graphics.Typeface.create( + "sans-serif", + android.graphics.Typeface.NORMAL, + ) + textPaint.textSize = spToPx(popup.flickTextSizeSp) + popupBackgroundColor = null + popupTextColor = popup.textColor + } + // ===== Viewのライフサイクル ===== override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { @@ -120,19 +159,42 @@ class TfbiFlickPopupView(context: Context) : View(context) { super.onDraw(canvas) rects[TfbiFlickDirection.TAP]?.let { rect -> - val paint = - if (highlightedDirection == TfbiFlickDirection.TAP) highlightBgPaint else bgPaint - canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) - canvas.drawRoundRect(rect, cornerRadius, cornerRadius, strokePaint) - drawTextCentered(canvas, inputTextTransform(tapCharacter), rect) + val selected = highlightedDirection == TfbiFlickDirection.TAP + val drawRect = popupRect(rect) + if (!KeyboardSkinPopupRenderer.drawRoundRect( + canvas, + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CROSS, + drawRect, + selected, + ) + ) { + val paint = if (selected) highlightBgPaint else bgPaint + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, strokePaint) + } + drawTextCentered(canvas, inputTextTransform(tapCharacter), drawRect) } for ((direction, char) in petalCharacters) { rects[direction]?.let { rect -> - val paint = if (highlightedDirection == direction) highlightBgPaint else bgPaint - canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) - canvas.drawRoundRect(rect, cornerRadius, cornerRadius, strokePaint) - drawTextCentered(canvas, inputTextTransform(char), rect) + val selected = highlightedDirection == direction + val drawRect = popupRect(rect) + if (!KeyboardSkinPopupRenderer.drawRoundRect( + canvas, + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_CROSS, + drawRect, + selected, + ) + ) { + val paint = if (selected) highlightBgPaint else bgPaint + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, strokePaint) + } + drawTextCentered(canvas, inputTextTransform(char), drawRect) } } } @@ -158,6 +220,16 @@ class TfbiFlickPopupView(context: Context) : View(context) { } } + private fun popupRect(rect: RectF): RectF { + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) ?: return rect + val inset = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + popup.itemGapDp / 2f, + resources.displayMetrics, + ) + return RectF(rect).apply { inset(inset, inset) } + } + private fun drawTextCentered(canvas: Canvas, text: String, rect: RectF) { if (text.isEmpty()) return val textY = rect.centerY() - ((textPaint.descent() + textPaint.ascent()) / 2) diff --git a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt index a2a3eff89..9651fb996 100644 --- a/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt +++ b/custom_keyboard/src/main/java/com/kazumaproject/custom_keyboard/view/TfbiGuidePopupView.kt @@ -12,6 +12,9 @@ import android.view.View import androidx.core.content.ContextCompat import androidx.core.graphics.ColorUtils import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupKind +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer import com.kazumaproject.core.domain.extensions.getThemeColor import com.kazumaproject.core.domain.extensions.isDarkThemeOn import com.kazumaproject.custom_keyboard.data.TfbiGuideFingerPosition @@ -70,6 +73,7 @@ class TfbiGuidePopupView(context: Context) : View(context) { private var popupTextColor: Int = defaultTextColor private var activeTextColor: Int = Color.WHITE private var inputTextTransform: (String) -> String = { it } + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT fun setState(state: TfbiGuidePopupState) { this.state = state @@ -82,24 +86,78 @@ class TfbiGuidePopupView(context: Context) : View(context) { } fun applyPopupViewStyle(style: PopupViewStyle) { + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) popupStyle = PopupViewStyle( - sizeScalePercent = style.sizeScalePercent.coerceIn(50, 200), - textSizeSp = style.textSizeSp.coerceIn(8f, 48f), - backgroundColor = style.backgroundColor, - textColor = style.textColor + sizeScalePercent = if (popup == null) style.sizeScalePercent.coerceIn(50, 200) else 100, + textSizeSp = popup?.flickTextSizeSp ?: style.textSizeSp.coerceIn(8f, 48f), + backgroundColor = if (popup == null) style.backgroundColor else null, + textColor = if (popup == null) style.textColor else popup.textColor, ) - popupBackgroundColor = style.backgroundColor ?: configuredBackgroundColor - activeColor = configuredHighlightedColor ?: DEFAULT_ACTIVE_COLOR - popupTextColor = style.textColor ?: configuredTextColor ?: defaultTextColor + popupBackgroundColor = if (popup == null) { + style.backgroundColor ?: configuredBackgroundColor + } else { + popup.surfaceColor + } + activeColor = if (popup == null) { + configuredHighlightedColor ?: DEFAULT_ACTIVE_COLOR + } else { + popup.selectedSurfaceColor + } + popupTextColor = if (popup == null) { + style.textColor ?: configuredTextColor ?: defaultTextColor + } else { + popup.textColor + } activeTextColor = readableForeground(activeColor) invalidate() } + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + val popup = KeyboardSkinPopupRenderer.specFor(skinId) + if (popup != null) { + popupBackgroundColor = popup.surfaceColor + activeColor = popup.selectedSurfaceColor + popupTextColor = popup.textColor + activeTextColor = popup.selectedTextColor + textPaint.typeface = android.graphics.Typeface.create( + "sans-serif", + android.graphics.Typeface.NORMAL, + ) + activeTextPaint.typeface = textPaint.typeface + KeyboardSkinPopupRenderer.applyPaintStyle( + textPaint, + context, + skinId, + KeyboardSkinPopupKind.FLICK_GUIDE, + ) + KeyboardSkinPopupRenderer.applyPaintStyle( + activeTextPaint, + context, + skinId, + KeyboardSkinPopupKind.FLICK_GUIDE, + selected = true, + ) + } else { + popupBackgroundColor = popupStyle.backgroundColor ?: configuredBackgroundColor + activeColor = configuredHighlightedColor ?: DEFAULT_ACTIVE_COLOR + popupTextColor = popupStyle.textColor ?: configuredTextColor ?: defaultTextColor + activeTextColor = readableForeground(activeColor) + textPaint.typeface = null + activeTextPaint.typeface = null + } + invalidate() + } + fun setColors( backgroundColor: Int, highlightedBackgroundColor: Int, textColor: Int ) { + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + setKeyboardSkin(keyboardSkinId) + return + } configuredBackgroundColor = backgroundColor configuredHighlightedColor = highlightedBackgroundColor configuredTextColor = textColor @@ -116,20 +174,33 @@ class TfbiGuidePopupView(context: Context) : View(context) { val inset = dp(1f) val panel = RectF(inset, inset, width - inset, height - inset) val panelColor = popupBackgroundColor ?: defaultPanelColor - panelPaint.shader = LinearGradient( - 0f, - panel.top, - 0f, - panel.bottom, - ColorUtils.blendARGB(panelColor, Color.WHITE, 0.16f), - ColorUtils.blendARGB(panelColor, Color.BLACK, 0.06f), - Shader.TileMode.CLAMP - ) - borderPaint.color = ColorUtils.setAlphaComponent(popupTextColor, 105) - gridPaint.color = ColorUtils.setAlphaComponent(popupTextColor, 70) - canvas.drawRoundRect(panel, dp(4f), dp(4f), panelPaint) - panelPaint.shader = null - canvas.drawRoundRect(panel, dp(4f), dp(4f), borderPaint) + val fixedCupertino = KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId) + if (fixedCupertino) { + KeyboardSkinPopupRenderer.drawRoundRect( + canvas, + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_GUIDE, + panel, + ) + borderPaint.color = Color.TRANSPARENT + gridPaint.color = Color.TRANSPARENT + } else { + panelPaint.shader = LinearGradient( + 0f, + panel.top, + 0f, + panel.bottom, + ColorUtils.blendARGB(panelColor, Color.WHITE, 0.16f), + ColorUtils.blendARGB(panelColor, Color.BLACK, 0.06f), + Shader.TileMode.CLAMP + ) + borderPaint.color = ColorUtils.setAlphaComponent(popupTextColor, 105) + gridPaint.color = ColorUtils.setAlphaComponent(popupTextColor, 70) + canvas.drawRoundRect(panel, dp(4f), dp(4f), panelPaint) + panelPaint.shader = null + canvas.drawRoundRect(panel, dp(4f), dp(4f), borderPaint) + } val cellWidth = panel.width() / 3f val cellHeight = panel.height() / 3f @@ -215,8 +286,32 @@ class TfbiGuidePopupView(context: Context) : View(context) { rect.centerX() + textWidth / 2f, rect.centerY() + (textSize + verticalPadding * 2) / 2f ) - activePaint.color = activeColor - canvas.drawRoundRect(pill, dp(if (compact) 7f else 9f), dp(if (compact) 7f else 9f), activePaint) + if (!KeyboardSkinPopupRenderer.drawRoundRect( + canvas, + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_GUIDE, + pill, + selected = true, + ) + ) { + activePaint.color = activeColor + canvas.drawRoundRect( + pill, + dp(if (compact) 7f else 9f), + dp(if (compact) 7f else 9f), + activePaint, + ) + } + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + KeyboardSkinPopupRenderer.applyPaintStyle( + activeTextPaint, + context, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_GUIDE, + selected = true, + ) + } val baseline = pill.centerY() - (activeTextPaint.ascent() + activeTextPaint.descent()) / 2f canvas.drawText(text, pill.centerX(), baseline, activeTextPaint) } @@ -240,7 +335,12 @@ class TfbiGuidePopupView(context: Context) : View(context) { scale: Float, minimumSp: Float ): Float { - val configured = sp(popupStyle.textSizeSp * scale) + val effectiveScale = if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + 1f + } else { + scale + } + val configured = sp(popupStyle.textSizeSp * effectiveScale) textPaint.textSize = configured val measured = textPaint.measureText(text) val safeWidth = maxWidth.coerceAtLeast(1f) diff --git a/docs/design/keyboard-skins/chalkboard-concept-v1.png b/docs/design/keyboard-skins/chalkboard-concept-v1.png new file mode 100644 index 000000000..e03dffddc Binary files /dev/null and b/docs/design/keyboard-skins/chalkboard-concept-v1.png differ diff --git a/docs/design/keyboard-skins/hanshi-brush-concept-v1.md b/docs/design/keyboard-skins/hanshi-brush-concept-v1.md new file mode 100644 index 000000000..ba3f96ab0 --- /dev/null +++ b/docs/design/keyboard-skins/hanshi-brush-concept-v1.md @@ -0,0 +1,42 @@ +# 墨筆(半紙)キーボードスキン — Concept v1 + +Status: design review only. No keyboard implementation is included in this change. + +![Concept mockup](./hanshi-brush-concept-v1.png) + +## Visual direction + +- The entire keyboard deck is a clean, warm-white sheet of Japanese hanshi paper. +- Key areas are separate pale paper patches with deckled fiber edges and broken dry-brush outlines. +- Labels use highly legible sumi brush strokes with visible pressure changes and restrained kasure. +- The pressed key receives a soft gray ink bloom; its popup is a slightly lifted paper tag. +- Vermilion is reserved for the action key, which is treated as a square hanko seal. +- Candidate text is written directly on the paper without glossy panels or decorative framing. + +## Design tokens + +| Role | Value | +| --- | --- | +| Deck paper | `#F6F1E4` | +| Key paper | `#FFFCF3` | +| Pressed ink wash | `#D8D2C7` | +| Sumi text | `#181512` | +| Diluted sumi | `#6E685E` | +| Vermilion action | `#B33A2E` | +| Action text | `#FFF9ED` | +| Key depth | Almost flat; hairline paper-lift shadow only | +| Key edge | Irregular dry-brush line with small gaps | + +## Interaction direction + +- Press: a controlled ink bloom expands from the touch point while the key moves down very slightly. +- Release: the bloom fades like ink being absorbed into paper; no elastic or glossy animation. +- Popup: a paper tag rises above the pressed key with a small folded pointer. +- Action key: becomes a darker vermilion when pressed instead of showing a gray ink bloom. + +## Deliberate exclusions + +- No seigaiha or other repeating Japanese pattern. +- No navy background, gold decoration, aged parchment, wood, or scroll motifs. +- No plastic keycaps, glass effects, strong shadows, or uncontrolled ink splashes. +- This must remain visually distinct from the existing blue-and-cream Washi skin. diff --git a/docs/design/keyboard-skins/hanshi-brush-concept-v1.png b/docs/design/keyboard-skins/hanshi-brush-concept-v1.png new file mode 100644 index 000000000..9c6218e47 Binary files /dev/null and b/docs/design/keyboard-skins/hanshi-brush-concept-v1.png differ diff --git a/docs/design/keyboard-skins/kappan-letterpress-concept-v1.png b/docs/design/keyboard-skins/kappan-letterpress-concept-v1.png new file mode 100644 index 000000000..07ef67df6 Binary files /dev/null and b/docs/design/keyboard-skins/kappan-letterpress-concept-v1.png differ diff --git a/docs/design/keyboard-skins/linen-embroidery-concept-v1.png b/docs/design/keyboard-skins/linen-embroidery-concept-v1.png new file mode 100644 index 000000000..005feb7da Binary files /dev/null and b/docs/design/keyboard-skins/linen-embroidery-concept-v1.png differ diff --git a/docs/design/keyboard-skins/monochrome-lcd-concept-v1.png b/docs/design/keyboard-skins/monochrome-lcd-concept-v1.png new file mode 100644 index 000000000..23110128f Binary files /dev/null and b/docs/design/keyboard-skins/monochrome-lcd-concept-v1.png differ diff --git a/docs/design/keyboard-skins/skin-concept-catalog-v1.md b/docs/design/keyboard-skins/skin-concept-catalog-v1.md new file mode 100644 index 000000000..f6674c371 --- /dev/null +++ b/docs/design/keyboard-skins/skin-concept-catalog-v1.md @@ -0,0 +1,116 @@ +# キーボードスキン デザインカタログ — v1 + +Status: design exploration only. No keyboard implementation is included. + +## 方針 + +スキンは既存のカラーテーマから完全に独立させる。色だけを替えた案は採用せず、次の5点のうち最低3点が既存スキンと異なる案だけを残す。 + +1. キーの輪郭と立体構造 +2. デッキとキーの素材 +3. 文字の作法 +4. 押下・ポップアップの反応 +5. 候補欄とアクションキーの扱い + +既存の Default / Flat / Glass / Neumorphism / Mechanical / Washi / Neon / Terminal / Cupertino、および新規の「墨筆(半紙)」とは視覚言語を重複させない。 + +## 今回ビジュアル化する6案 + +| ID | 名称 | 一目で分かる特徴 | 押下表現 | 可読性 | 実装規模 | +| --- | --- | --- | --- | --- | --- | +| 01 | 活版印刷 | 厚紙への凹版、組版の精密さ | 文字と枠が紙へ深く沈む | 高 | 中 | +| 02 | 染付磁器 | 白磁タイルと呉須の青 | 釉薬の青が中央へ溜まる | 高 | 中〜高 | +| 03 | 漆塗り | 黒漆、朱漆、細い金縁 | 反射が締まり朱が覗く | 高 | 中〜高 | +| 04 | 黒板チョーク | 石板と手描きの粉線 | チョーク粉が円状に広がる | 高 | 低〜中 | +| 05 | リネン刺繍 | 麻布パッチと縫い目 | 布がへこみ縫い目が張る | 高 | 中 | +| 06 | モノクロ液晶 | オリーブLCDとピクセル文字 | セグメントが反転する | 中〜高 | 低〜中 | + +## 01 活版印刷 + +![活版印刷](./kappan-letterpress-concept-v1.png) + +- デッキは生成りの厚いコットン紙。繊維は細かく、墨筆の半紙より密度が高い。 +- キーは別部品ではなく、紙に正確に空押しされた矩形。角は小さく、罫線は版圧で暗くなる。 +- 文字は明朝系の活字。筆のかすれではなく、インク量のわずかな不均一だけを出す。 +- 候補欄は校正紙、決定キーは赤い校正印として扱う。 +- 色: Paper `#E8DDC4`, Key `#F3EAD7`, Ink `#201C17`, Rule `#6F6558`, Proof red `#B33B2E`。 + +## 02 染付磁器 + +![染付磁器](./sometsuke-porcelain-concept-v1.png) + +- 白磁の薄いタイルを、落ち着いた藍色の陶板へ並べる。 +- 各キーは乳白色の釉薬、縁だけに細い呉須の手描き線。装飾文様は角へ限定する。 +- 文字は濃い藍の焼付け。アクションキーだけ辰砂風の赤磁器。 +- 押下時はキー中央へ青い釉薬が少し溜まるように見せる。 +- 色: Deck `#24384A`, Porcelain `#F7F3E8`, Cobalt `#244F7A`, Pale blue `#AFC2C8`, Cinnabar `#A94738`。 + +## 03 漆塗り + +![漆塗り](./urushi-lacquer-concept-v1.png) + +- 炭黒の漆デッキに、わずかに膨らんだ黒漆のキーを置く。 +- ラベルは金ではなく読みやすい象牙色を主役とし、細い金線は輪郭だけに使う。 +- 修飾キーは溜塗り、決定キーは深い朱漆。派手な蒔絵や和柄は入れない。 +- 押下時は反射ハイライトが細くなり、黒の奥から朱がわずかに透ける。 +- 色: Deck `#0D0C0B`, Key `#1B1715`, Ivory `#F1E7D1`, Gold edge `#B99A58`, Vermilion `#A92D22`。 + +## 04 黒板チョーク + +![黒板チョーク](./chalkboard-concept-v1.png) + +- 深緑を含む無光沢の石板。キーは塗り分けず、二重になったチョーク罫線だけで区切る。 +- 文字は均一なUIフォントではなく、読みやすさを保った手書きチョーク。 +- 候補欄には薄い横線、決定キーには黄土色のチョークを使う。 +- 押下時は粉が指先から円状にぼけ、離すと薄い跡だけ残して消える。 +- 色: Slate `#202725`, Chalk `#F0EBDD`, Dust `#9EA39A`, Blue chalk `#8FB8B2`, Ochre `#D5AA4D`。 + +## 05 リネン刺繍 + +![リネン刺繍](./linen-embroidery-concept-v1.png) + +- デッキは粗めの生成り麻布。キーは薄い麻布パッチを縫い付けた構造。 +- 文字は濃い木炭色の刺繍糸。輪郭は少し不揃いな返し縫いで表現する。 +- 修飾キーだけ灰緑、決定キーだけ錆朱の布を使い、色数を抑える。 +- 押下時は布が中央へへこみ、縫い目がわずかに張る。ポップアップは縫い付けタグ。 +- 色: Linen `#D7C6A8`, Patch `#EFE5D0`, Thread `#2A2926`, Moss `#6F7659`, Rust `#9F443B`。 + +## 06 モノクロ液晶 + +![モノクロ液晶](./monochrome-lcd-concept-v1.png) + +- 1990年代の携帯電子機器を思わせるオリーブ色の反射型LCD。 +- キー境界は1px相当の濃緑ピクセル。角は階段状で、影や光沢を使わない。 +- 文字は可読性を優先したピクセルかな。候補欄はステータス表示領域として一段濃くする。 +- 押下時は背景と文字を完全反転し、ポップアップもピクセル枠だけで表示する。 +- 色: LCD `#B5B58B`, Key `#C6C79D`, Pixel `#273126`, Mid `#59604B`, Accent `#7D493B`。 + +## 次点の6案 + +| ID | 名称 | 素材・構造 | 固有の反応 | +| --- | --- | --- | --- | +| 07 | 粘土スタンプ | 手で成形した淡色クレイに文字を刻印 | 指の圧痕が一瞬残る | +| 08 | 折り紙ファセット | 一枚の紙を幾何学的に折った面 | 押した面だけ折り目が反転 | +| 09 | 禅石庭 | 細砂のデッキと滑らかな小石キー | 押下点から砂紋が一周広がる | +| 10 | 真鍮計器 | ヘアライン真鍮、琺瑯ラベル、目盛り | 小さな指針がキー方向へ振れる | +| 11 | ジェリーポップ | 半透明ではなく拡散する柔らかなゲル | キーが潰れ、隣接面へ弾性波が伝わる | +| 12 | 切り紙コラージュ | 色紙の切断面と重なりでキーを構成 | 押した紙片が一層下へ滑る | + +## 拡張候補12案 + +13. リソグラフ — 2色の版ずれと網点。押下時だけ版が正位置へ揃う。 +14. 琺瑯サイン — 白い琺瑯板、濃紺の縁、欠けた部分に鉄色が覗く。 +15. レザークラフト — 植物タンニン革、刻印文字、手縫いの周囲線。 +16. 石材象嵌 — 明るい石板へ暗色の文字と輪郭を埋め込む。 +17. バウハウス — 円・矩形・三角の構成でキー役割そのものを形分けする。 +18. 漫画スクリーントーン — 白黒網点、集中線、押下ポップアップは吹き出し。 +19. 水彩庭園 — 余白を主役にした淡い顔料層。押下時だけ色が濃くなる。 +20. 実験計器盤 — 白い計器筐体、細い目盛り、状態灯で役割を示す。 +21. カセットシンセ — クリーム筐体、物理スイッチ、橙と青緑の機能色。 +22. 押し花樹脂 — 読み取りを邪魔しない位置に小さな植物片を封入する。 +23. コルクボード — コルク面、紙ラベル、色付き画鋲を機能インジケータにする。 +24. 雪氷マット — 霜ガラスではなく圧雪の粒子と足跡のような押下跡で構成する。 + +## 採用判定 + +実装候補へ進める条件は、実機で1秒見ただけで名称を当てられること、かな文字のコントラストを落とさないこと、押下状態が静止画でも判別できること、既存テーマ色が混ざってもスキンの印象が変わらないことの4点とする。 diff --git a/docs/design/keyboard-skins/sometsuke-porcelain-concept-v1.png b/docs/design/keyboard-skins/sometsuke-porcelain-concept-v1.png new file mode 100644 index 000000000..f6d15c22b Binary files /dev/null and b/docs/design/keyboard-skins/sometsuke-porcelain-concept-v1.png differ diff --git a/docs/design/keyboard-skins/urushi-lacquer-concept-v1.png b/docs/design/keyboard-skins/urushi-lacquer-concept-v1.png new file mode 100644 index 000000000..3c649437c Binary files /dev/null and b/docs/design/keyboard-skins/urushi-lacquer-concept-v1.png differ diff --git a/docs/keyboard-skins/import-v1/ai-instructions-ja.md b/docs/keyboard-skins/import-v1/ai-instructions-ja.md new file mode 100644 index 000000000..e57e01926 --- /dev/null +++ b/docs/keyboard-skins/import-v1/ai-instructions-ja.md @@ -0,0 +1,30 @@ +# Sumire用キーボードスキンを作るAIへの指示 + +次の指示を、任意のAIサービスへ貼り付けて使ってください。AIサービスの入力欄には、作りたい見た目の説明と、SumireのテンプレートJSONの内容を続けて渡します。 + +```text +あなたはSumireキーボードスキンJSON v1の作成アシスタントです。 + +目的: +- ユーザーの見た目の説明を、Sumireがオフラインで読み込める宣言型JSONへ変換する。 +- 正式仕様は https://github.com/KazumaProject/JapaneseKeyboard/blob/main/docs/keyboard-skins/import-v1/format-ja.md +- JSON Schemaは https://github.com/KazumaProject/JapaneseKeyboard/blob/main/docs/keyboard-skins/import-v1/sumire-keyboard-skin-v1.schema.json + +厳守すること: +1. 最終回答はJSONオブジェクトだけにする。Markdownコードフェンス、前置き、説明、注釈、後書きを出さない。 +2. formatは"sumire-keyboard-skin"、formatVersionは1に固定する。 +3. idは[a-z][a-z0-9._-]{2,63}、nameは1~50文字、authorは最大50文字、descriptionは最大200文字にする。 +4. テンプレートにある未知フィールドを追加しない。仕様にないURL、画像、ZIP、SVG、任意フォント、コード、シェーダー、スクリプトを使わない。 +5. shapeはroundedRect/capsule/cutCorner/hexagon/pixelNotched/roughRectだけ、fillはsolid/linearGradient/radialGradientだけにする。 +6. グラデーションのcolorsは2~4個、stopsは同じ個数で、0から始まり1で終わり、厳密に増加させる。 +7. 数値を上限外にしない。角丸32dp、インセット8dp、粗さ3dp、影のオフセット±8dp・ぼかし12dp、押下移動±4dp、押下倍率0.90~1.05、時間0~500ms、背景周期2~30秒を守る。 +8. 背景アニメーションがnoneでなければperiodSecondsを2~30、noneなら0~30にする。 +9. パレットの通常・特殊・アクション・候補欄の文字色は、対応する背景とのコントラスト比4.5:1以上を目標にする。 +10. パレットの色は#RRGGBBまたは#AARRGGBB、スタイル内の色は@palette.<名前>も使える。 +11. authorやdescriptionが不要なら空文字列にする。必須の構造を省略しない。 + +ユーザーが仕様外の表現を求めた場合は、許可された図形・塗り・装飾・色・フォントの組み合わせで近似する。 +ユーザーが既存JSONのエラーを渡した場合は、指定されたフィールドパスだけを直し、JSON全体をJSONだけで再出力する。 +``` + +AIがコードフェンスを付けた場合は、最初と最後の` ```json `だけを外し、中身がJSONだけになるよう保存してください。説明文が混ざった回答はそのままインポートせず、上の指示を添えて再生成します。 diff --git a/docs/keyboard-skins/import-v1/example.json b/docs/keyboard-skins/import-v1/example.json new file mode 100644 index 000000000..f07381397 --- /dev/null +++ b/docs/keyboard-skins/import-v1/example.json @@ -0,0 +1,242 @@ +{ + "format": "sumire-keyboard-skin", + "formatVersion": 1, + "id": "ai-sakura-cyber", + "name": "桜サイバー", + "author": "Sumire sample", + "description": "グラデーション、幾何学キー、装飾、モーションを使う検証済みサンプル", + "palette": { + "background": "#121629", + "normalKey": "#273254", + "specialKey": "#3B315D", + "actionKey": "#8A2145", + "normalKeyText": "#FFFFFF", + "specialKeyText": "#FFFFFF", + "actionKeyText": "#FFFFFF", + "accent": "#FFB3C7", + "secondaryAccent": "#63D8FF", + "candidateSurface": "#1B223A", + "candidateText": "#FFFFFF" + }, + "keys": { + "base": { + "shape": "roundedRect", + "fill": { + "type": "linearGradient", + "colors": ["@palette.normalKey", "#3D4D7C", "@palette.secondaryAccent"], + "stops": [0, 0.55, 1], + "angleDegrees": 135 + }, + "cornerRadiusDp": 12, + "insetDp": 1.5, + "roughnessDp": 0.5, + "cutSizeDp": 3, + "stroke": { "color": "@palette.accent", "widthDp": 1 }, + "shadows": [ + { "color": "#66000000", "offsetXDp": 1, "offsetYDp": 2, "blurDp": 5 }, + { "color": "#443E8CFF", "offsetXDp": -1, "offsetYDp": -1, "blurDp": 2 } + ], + "decoration": { + "type": "dots", + "color": "@palette.accent", + "opacity": 0.12, + "sizeDp": 1, + "spacingDp": 8, + "angleDegrees": 0 + } + }, + "character": {}, + "modifier": { + "shape": "capsule", + "fill": { "type": "solid", "color": "@palette.specialKey" }, + "decoration": { + "type": "grid", + "color": "@palette.secondaryAccent", + "opacity": 0.1, + "sizeDp": 0.8, + "spacingDp": 10, + "angleDegrees": 0 + } + }, + "action": { + "shape": "cutCorner", + "fill": { + "type": "radialGradient", + "colors": ["@palette.actionKey", "#4E1534"], + "stops": [0, 1], + "centerX": 0.35, + "centerY": 0.3, + "radius": 0.9 + }, + "cutSizeDp": 6, + "decoration": { + "type": "speckles", + "color": "@palette.accent", + "opacity": 0.14, + "sizeDp": 1.2, + "spacingDp": 7, + "angleDegrees": 0 + } + }, + "space": { + "shape": "hexagon", + "fill": { "type": "solid", "color": "@palette.normalKey" } + }, + "candidate": { + "shape": "pixelNotched", + "fill": { + "type": "linearGradient", + "colors": ["@palette.candidateSurface", "#293A60"], + "stops": [0, 1], + "angleDegrees": 90 + }, + "cutSizeDp": 3 + }, + "toolbar": { + "shape": "roughRect", + "fill": { "type": "solid", "color": "@palette.specialKey" }, + "roughnessDp": 1.2 + }, + "popup": { + "shape": "roundedRect", + "fill": { + "type": "radialGradient", + "colors": ["#5B477F", "@palette.specialKey"], + "stops": [0, 1], + "centerX": 0.5, + "centerY": 0.25, + "radius": 1 + }, + "cornerRadiusDp": 16 + } + }, + "surfaces": { + "deck": { + "shape": "roughRect", + "fill": { + "type": "radialGradient", + "colors": ["#29345B", "@palette.background"], + "stops": [0, 1], + "centerX": 0.25, + "centerY": 0.15, + "radius": 1 + }, + "cornerRadiusDp": 10, + "insetDp": 0, + "roughnessDp": 1, + "cutSizeDp": 0, + "stroke": { "color": "@palette.accent", "widthDp": 1 }, + "shadows": [], + "decoration": { + "type": "weave", + "color": "@palette.secondaryAccent", + "opacity": 0.08, + "sizeDp": 1, + "spacingDp": 14, + "angleDegrees": 0 + } + }, + "candidateStrip": { + "shape": "capsule", + "fill": { + "type": "linearGradient", + "colors": ["@palette.candidateSurface", "#293A60"], + "stops": [0, 1], + "angleDegrees": 0 + }, + "cornerRadiusDp": 16, + "insetDp": 0, + "roughnessDp": 0, + "cutSizeDp": 0, + "stroke": { "color": "@palette.secondaryAccent", "widthDp": 0.5 }, + "shadows": [], + "decoration": { + "type": "scanlines", + "color": "@palette.accent", + "opacity": 0.08, + "sizeDp": 0.8, + "spacingDp": 6, + "angleDegrees": 0 + } + }, + "candidatePanel": { + "shape": "cutCorner", + "fill": { "type": "solid", "color": "@palette.candidateSurface" }, + "cornerRadiusDp": 4, + "insetDp": 0, + "roughnessDp": 0, + "cutSizeDp": 4, + "stroke": { "color": "@palette.accent", "widthDp": 0.5 }, + "shadows": [], + "decoration": { + "type": "stripes", + "color": "@palette.secondaryAccent", + "opacity": 0.08, + "sizeDp": 1, + "spacingDp": 12, + "angleDegrees": 35 + } + }, + "toolbar": { + "shape": "pixelNotched", + "fill": { "type": "solid", "color": "@palette.specialKey" }, + "cornerRadiusDp": 2, + "insetDp": 0, + "roughnessDp": 0, + "cutSizeDp": 3, + "stroke": { "color": "@palette.accent", "widthDp": 0 }, + "shadows": [], + "decoration": { + "type": "grid", + "color": "@palette.accent", + "opacity": 0.08, + "sizeDp": 0.7, + "spacingDp": 10, + "angleDegrees": 0 + } + }, + "popup": { + "shape": "hexagon", + "fill": { + "type": "linearGradient", + "colors": ["#6B4E91", "@palette.specialKey", "#241D43"], + "stops": [0, 0.5, 1], + "angleDegrees": 90 + }, + "cornerRadiusDp": 6, + "insetDp": 0, + "roughnessDp": 0, + "cutSizeDp": 0, + "stroke": { "color": "@palette.accent", "widthDp": 1 }, + "shadows": [ + { "color": "#77000000", "offsetXDp": 0, "offsetYDp": 2, "blurDp": 6 } + ], + "decoration": { + "type": "speckles", + "color": "@palette.accent", + "opacity": 0.1, + "sizeDp": 1, + "spacingDp": 9, + "angleDegrees": 0 + } + } + }, + "typography": { + "font": "sansCondensed", + "weight": "medium", + "letterSpacing": 0.02 + }, + "motion": { + "press": { + "scale": 0.94, + "translationXDp": -2, + "translationYDp": 2.2, + "durationMs": 120, + "releaseDurationMs": 180 + }, + "background": { + "type": "shift", + "periodSeconds": 12 + } + } +} diff --git a/docs/keyboard-skins/import-v1/format-ja.md b/docs/keyboard-skins/import-v1/format-ja.md new file mode 100644 index 000000000..c1f2c96c4 --- /dev/null +++ b/docs/keyboard-skins/import-v1/format-ja.md @@ -0,0 +1,102 @@ +# SumireキーボードスキンJSON v1 + +この仕様は、Sumire(Markdownヘルパーキーボード)の設定画面からオフラインで読み込める宣言型スキンを定義します。Sumire自身はAIサービス、ネットワーク、画像・フォント・コード実行を使いません。 + +正式な機械検証用ファイルは [`sumire-keyboard-skin-v1.schema.json`](./sumire-keyboard-skin-v1.schema.json)、そのまま編集できる雛形は [`template.json`](./template.json)、機能を一通り使った検証済み例は [`example.json`](./example.json) です。 + +## トップレベル + +| パス | 型/必須 | 内容 | +| --- | --- | --- | +| `format` | 文字列・必須 | `sumire-keyboard-skin` 固定 | +| `formatVersion` | 整数・必須 | `1` 固定 | +| `id` | 文字列・必須 | `[a-z][a-z0-9._-]{2,63}`。保存名と設定値は `imported:` | +| `name` | 文字列・必須 | 1~50文字。設定画面に表示 | +| `author` | 文字列・任意 | 最大50文字。省略時は「インポート済み」 | +| `description` | 文字列・任意 | 最大200文字 | +| `palette` | オブジェクト・必須 | スタイルから参照できる色 | +| `keys` | オブジェクト・必須 | キーの基本スタイルと役割別上書き | +| `surfaces` | オブジェクト・必須 | デッキ、候補欄、ツールバー、ポップアップ | +| `typography` | オブジェクト・必須 | 許可された組み込みフォント | +| `motion` | オブジェクト・必須 | 押下と背景アニメーション | + +未知フィールドは受け付けません。将来のバージョンでフィールドが増えた場合も、v1のアプリは自動で無視せず、フィールドパスを示して拒否します。 + +## 色(`palette`) + +`background`、`normalKey`、`specialKey`、`actionKey`、`normalKeyText`、`specialKeyText`、`actionKeyText`、`accent`、`secondaryAccent`、`candidateSurface`、`candidateText` をすべて指定します。色は `#RRGGBB`、`#AARRGGBB`、またはスタイル内だけで使える `@palette.<名前>` です。`#RRGGBB` のアルファ値は`FF`として扱います。 + +候補欄を含む文字色はWCAGの相対輝度からコントラスト比を計算します。通常・特殊・アクション・候補欄のいずれかが4.5:1未満の場合は警告を表示します。警告を確認して続行すれば保存できます。警告はエラーではありません。 + +## スタイル + +`keys.base` と各 `surfaces` の値は次のスタイルオブジェクトです。`keys.character`、`modifier`、`action`、`space`、`candidate`、`toolbar`、`popup` は基本スタイルに対する部分上書きで、指定しなかった値を`base`から継承します。 + +```json +{ + "shape": "roundedRect", + "fill": { "type": "solid", "color": "@palette.normalKey" }, + "cornerRadiusDp": 8, + "insetDp": 1, + "roughnessDp": 0, + "cutSizeDp": 0, + "stroke": { "color": "@palette.accent", "widthDp": 1 }, + "shadows": [], + "decoration": { + "type": "none", + "color": "@palette.accent", + "opacity": 0, + "sizeDp": 1, + "spacingDp": 8, + "angleDegrees": 0 + } +} +``` + +### 図形 + +`roundedRect`、`capsule`、`cutCorner`、`hexagon`、`pixelNotched`、`roughRect` のみです。角丸は0~32dp、インセットは0~8dp、粗さは0~3dpです。`cutSizeDp`は0~32dpです。 + +### 塗り + +- `solid`: `color`を1色指定。 +- `linearGradient`: 2~4色の`colors`、同じ個数の`stops`、`angleDegrees`(0~360)を指定。 +- `radialGradient`: 2~4色の`colors`、同じ個数の`stops`、中心`centerX`・`centerY`(0~1)、`radius`(0.01~1)を指定。 + +停止点は0で始まり1で終わり、厳密に増加していなければなりません。範囲外の数値をアプリが補正することはありません。 + +### 線、影、装飾 + +線の幅は0~4dpです。影は最大2個で、オフセットは各方向-8~8dp、ぼかしは0~12dpです。装飾は`none`、`dots`、`grid`、`stripes`、`scanlines`、`speckles`、`weave`のいずれかです。装飾の不透明度は0~1、サイズは0.1~8dp、間隔は0.5~32dp、角度は0~360です。 + +## サーフェス + +`surfaces.deck`、`candidateStrip`、`candidatePanel`、`toolbar`、`popup` をすべて指定します。それぞれ通常のスタイルオブジェクトを持ち、役割ごとに背景や装飾を変えられます。 + +## 文字 + +`typography.font` は `sans`、`sansMedium`、`sansCondensed`、`serif`、`monospace` のみです。`weight` は `normal`、`medium`、`bold`、`letterSpacing` は-0.1~0.2です。任意フォントファイルやURLは指定できません。 + +## モーション + +`motion.press` の`scale`は0.90~1.05、`translationXDp`・`translationYDp`は-4~4dp、各時間は0~500msです。`motion.background.type` は `none`、`pulse`、`sweep`、`shift` のいずれかです。`none`の周期は0~30秒、その他は2~30秒です。 + +設定画面のモーション設定は次の意味です。 + +- **フル**:押下の拡縮・移動と背景アニメーションを有効にします。 +- **軽減**:移動と連続アニメーションを止め、押下時の状態変化だけを残します。 +- **オフ**:すべてのスキンモーションを止めます。 + +キーごとの常時アニメーションはありません。画面全体につき低頻度の背景アニメーションを1本だけ使います。 + +## 入力、保存、互換性 + +- UTF-8のJSONだけを受け付け、最大256KiBです。先頭のUTF-8 BOMと、JSON全体を一重に囲む` ```json ... ``` `だけは除去します。それ以外の説明文や複数フェンスは拒否します。 +- JSONは検証済みのままアプリ専用領域`filesDir/keyboard_skins/v1/.json`へ`AtomicFile`で保存します。選択した元URIの永続権限は保持しません。 +- 同じIDを再度読み込むと更新確認を表示し、承認したときだけ原子的に置換します。成功すると自動選択されます。 +- JSON、ZIP、SVG、画像、任意フォント、URL、コード、シェーダー、スクリプト、テンプレート生成、AI呼び出しはv1の入力ではありません。 +- 不在または壊れたインポートファイル、未知の参照、古い保存値は安全にデフォルトへフォールバックします。ストア変更ごとに`keyboard_skin_revision`を増やし、IMEを開いたままでも同じIDの更新・削除を再描画します。 + +## エラーの読み方 + +エラーは`keys.base.fill.colors[1]`のようなフィールドパスと理由で表示されます。パスをコピーしてAIへ返し、「そのパスだけを直し、仕様にない説明を出さず、JSON全体を再出力してください」と依頼してください。未知フィールドは削除し、範囲外の値は範囲内へ自分で選び直します。コントラスト警告だけなら、内容を確認してインポートを続行できます。 diff --git a/docs/keyboard-skins/import-v1/note-draft-ja.md b/docs/keyboard-skins/import-v1/note-draft-ja.md new file mode 100644 index 000000000..0ac86c527 --- /dev/null +++ b/docs/keyboard-skins/import-v1/note-draft-ja.md @@ -0,0 +1,111 @@ +# AIで自分だけのキーボードスキンを作り、Sumireに読み込む方法 + +Sumireのキーボードを、自分の好きな色や雰囲気に合わせてみませんか。Sumireでは、任意のAIサービスに見た目の希望を伝え、生成された宣言型JSONをアプリへオフラインで読み込めます。 + +## 最初に知っておくこと + +Sumire自身にAIは搭載されていません。SumireがAIへ問い合わせたり、スキン作成のために通信したりすることもありません。AIでJSONを作る工程と、Sumireへファイルを読み込む工程は別です。Sumireのインポートは端末内で完結し、ストレージ権限やネットワーク権限を追加で要求しません。 + +正式仕様とファイルはGitHubで公開しています。 + +- [v1仕様書(日本語)](https://github.com/KazumaProject/JapaneseKeyboard/blob/main/docs/keyboard-skins/import-v1/format-ja.md) +- [正式JSON Schema](https://github.com/KazumaProject/JapaneseKeyboard/blob/main/docs/keyboard-skins/import-v1/sumire-keyboard-skin-v1.schema.json) +- [AIへ渡すテンプレート](https://github.com/KazumaProject/JapaneseKeyboard/blob/main/docs/keyboard-skins/import-v1/template.json) +- [主要機能を使った検証済みサンプル](https://github.com/KazumaProject/JapaneseKeyboard/blob/main/docs/keyboard-skins/import-v1/example.json) +- [AI向け指示文](https://github.com/KazumaProject/JapaneseKeyboard/blob/main/docs/keyboard-skins/import-v1/ai-instructions-ja.md) + +## 1. 作りたい見た目をAIへ伝える + +まず、上のテンプレートJSONを開いて内容をコピーします。次に、普段使っている任意のAIサービスへ、次の3つを渡します。 + +1. [AI向け指示文](https://github.com/KazumaProject/JapaneseKeyboard/blob/main/docs/keyboard-skins/import-v1/ai-instructions-ja.md) +2. テンプレートJSONの内容 +3. 作りたい見た目の説明 + +説明は「夜の紺色を背景に、桜色のアクセント、少し丸いキー、候補欄は落ち着いた濃色」のように自然文で構いません。「押したときに少し沈む」「背景はゆっくり光らせる」といった希望も書けます。 + +v1で使えるのは、6種類の図形、単色・線形グラデーション・放射グラデーション、線、最大2個の影、ドットや格子などの装飾、5種類の組み込みフォントです。画像、ZIP、SVG、任意フォント、URL、コード、シェーダー、スクリプトは使えません。 + +AIには「最終回答はJSONだけ。コードフェンスや説明文を付けない」と明記してください。対応していない表現を求めた場合は、許可された構成要素で近似するよう指示します。 + +## 2. AIの回答をJSONだけで保存する + +AIの回答を保存するときは、説明文を含めずJSONオブジェクトだけをテキストファイルへ保存します。ファイル名は任意ですが、例えば`my-sakura-skin.json`のように`.json`を付けると分かりやすいでしょう。 + +AIが次のようなコードフェンスを付けた場合は、外側の2行だけを削除します。 + +```json +{ "format": "sumire-keyboard-skin", "formatVersion": 1 } +``` + +Sumireは先頭のUTF-8 BOMと、JSON全体を一重に囲む` ```json ... ``` `だけを取り除けます。回答の前後に説明文がある、フェンスが複数ある、JSONの途中に文章がある、といった場合は拒否されます。保存前に「先頭から`{`、末尾まで`}`」になっているか確認してください。 + +## 3. Sumireへインポートする + +Sumireで次の順に開きます。 + +`設定 → テーマ → キーボードスキン` + +画面上部の「JSONスキンをインポート」を押し、保存したJSONファイルを選びます。Sumireがファイルを読み込み、サイズ、UTF-8、バージョン、フィールド、数値範囲、グラデーション停止点などを検証します。選択した元ファイルの永続的な権限は保持せず、検証済みJSONのアプリ内コピーだけを使います。 + +正常に保存されると、そのスキンが自動選択され、キーボードへすぐ反映されます。TenKey、QWERTY、タブレット、カスタムキーボード、候補欄、ショートカット、ポップアップにも同じスキンが適用されます。 + +同じ`id`のJSONをもう一度読み込むと更新確認が表示されます。「更新」を承認したときだけアプリ内のコピーが原子的に置き換わり、同じIDを選択中でも表示が再描画されます。カードの三点メニューからは、アプリ内のコピーだけを削除できます。削除しても元のJSONファイルは削除されません。選択中のスキンを削除した場合は、組み込みのデフォルトへ戻ります。 + +## コントラスト警告とモーション + +通常キー、特殊キー、アクションキー、候補欄の文字色は、WCAGの相対輝度からコントラスト比を検査します。4.5:1未満は警告として表示されますが、内容を確認して続行できます。読みやすさを優先するなら、暗い背景には白、明るい背景には濃い文字を選んでください。 + +モーション設定には「フル」「軽減」「オフ」があります。 + +- フル:押下時の拡縮・移動と、指定した背景アニメーションを使います。 +- 軽減:移動と連続アニメーションを止め、押下時の状態変化だけを残します。 +- オフ:スキンのモーションをすべて止めます。 + +常時動くアニメーションはキーごとには作らず、画面全体で1本だけです。端末やユーザー補助設定に合わせて選んでください。 + +## エラーが出たとき + +ダイアログには概要と、例えば`keys.base.fill.colors[1]`のようなJSONフィールドパスが表示されます。「エラーをコピー」でパスをコピーし、そのパスとエラー全文をAIへ返してください。 + +AIには次のように依頼します。 + +> `keys.base.fill.colors[1]`でエラーになりました。SumireキーボードスキンJSON v1の仕様に合わせて、そのフィールドを直し、JSON全体をJSONだけで再出力してください。説明文とコードフェンスは不要です。 + +代表的なエラーの直し方は次のとおりです。 + +- `id`のエラー:先頭を小文字英字にし、英小文字・数字・`.`・`_`・`-`だけで3~64文字にします。 +- `unknown field`:仕様にないキーを削除します。v1は未知フィールドを無視しません。 +- `colors`と`stops`のエラー:色の個数と停止点の個数をそろえ、停止点を`0`から`1`まで厳密に増やします。 +- 数値範囲のエラー:値を仕様書の範囲内へ変更します。アプリは範囲外を自動補正しません。 +- `formatVersion`のエラー:現在は`1`だけが対応しています。 +- JSON構文エラー:AIの説明文やコードフェンスを削除し、JSONだけを保存します。 +- コントラスト警告:エラーではありません。文字色または対応する背景色を見直し、確認後に続行できます。 + +## プライバシーと保管 + +見た目の説明やテンプレートを外部AIサービスへ送る場合、その入力内容には利用したサービスの規約、プライバシーポリシー、保存・学習設定が適用されます。Sumireが通信しないことと、外部AIへ送信する内容の扱いは別の話です。個人情報、秘密情報、第三者に見られたくない文章を説明へ含めないでください。 + +初版は画像、任意フォント、エクスポートに対応していません。インポート後に元JSONを再び取り出す機能もないため、再利用や更新に備えて、AIから得た元JSONを自分で保管してください。アプリ内エディター、テンプレート生成、AI呼び出し、詳細ガイドへのリンクは初版の対象外です。 + +## FAQ + +### SumireからAIを使えますか? + +いいえ。SumireはAIを搭載せず、JSONの検証とオフラインの保存・描画だけを行います。AIサービスは別途利用してください。 + +### JSONを更新したのに見た目が変わりません + +同じ`id`で保存したか確認し、更新確認で承認してください。更新後は`設定 → テーマ → キーボードスキン`でカードが選択されているか確認します。なお、選択中の同一IDの更新でも再描画されます。 + +### 元のJSONファイルを消すとどうなりますか? + +インポート成功後はアプリ内コピーを使うため、元ファイルを消しても現在のスキンは残ります。ただし、再利用や更新のため元JSONは保管してください。 + +### スキンを削除すると元ファイルも消えますか? + +消えません。三点メニューの削除はアプリ内のコピーだけを削除します。選択中ならデフォルトへ戻ります。 + +### 画像をキーに貼りたいです + +v1では対応していません。許可された図形、グラデーション、装飾で近い雰囲気を表現してください。 diff --git a/docs/keyboard-skins/import-v1/sumire-keyboard-skin-v1.schema.json b/docs/keyboard-skins/import-v1/sumire-keyboard-skin-v1.schema.json new file mode 100644 index 000000000..3a62b555a --- /dev/null +++ b/docs/keyboard-skins/import-v1/sumire-keyboard-skin-v1.schema.json @@ -0,0 +1,250 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/KazumaProject/JapaneseKeyboard/blob/main/docs/keyboard-skins/import-v1/sumire-keyboard-skin-v1.schema.json", + "title": "Sumire Keyboard Skin v1", + "type": "object", + "additionalProperties": false, + "required": ["format", "formatVersion", "id", "name", "palette", "keys", "surfaces", "typography", "motion"], + "properties": { + "format": { "const": "sumire-keyboard-skin" }, + "formatVersion": { "const": 1 }, + "id": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{2,63}$" }, + "name": { "type": "string", "minLength": 1, "maxLength": 50 }, + "author": { "type": "string", "maxLength": 50 }, + "description": { "type": "string", "maxLength": 200 }, + "palette": { "$ref": "#/$defs/palette" }, + "keys": { + "type": "object", + "additionalProperties": false, + "required": ["base"], + "properties": { + "base": { "$ref": "#/$defs/style" }, + "character": { "$ref": "#/$defs/styleOverride" }, + "modifier": { "$ref": "#/$defs/styleOverride" }, + "action": { "$ref": "#/$defs/styleOverride" }, + "space": { "$ref": "#/$defs/styleOverride" }, + "candidate": { "$ref": "#/$defs/styleOverride" }, + "toolbar": { "$ref": "#/$defs/styleOverride" }, + "popup": { "$ref": "#/$defs/styleOverride" } + } + }, + "surfaces": { + "type": "object", + "additionalProperties": false, + "required": ["deck", "candidateStrip", "candidatePanel", "toolbar", "popup"], + "properties": { + "deck": { "$ref": "#/$defs/style" }, + "candidateStrip": { "$ref": "#/$defs/style" }, + "candidatePanel": { "$ref": "#/$defs/style" }, + "toolbar": { "$ref": "#/$defs/style" }, + "popup": { "$ref": "#/$defs/style" } + } + }, + "typography": { "$ref": "#/$defs/typography" }, + "motion": { "$ref": "#/$defs/motion" } + }, + "$defs": { + "paletteColor": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$" + }, + "color": { + "type": "string", + "pattern": "^(#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?|@palette\\.(background|normalKey|specialKey|actionKey|normalKeyText|specialKeyText|actionKeyText|accent|secondaryAccent|candidateSurface|candidateText))$" + }, + "palette": { + "type": "object", + "additionalProperties": false, + "required": ["background", "normalKey", "specialKey", "actionKey", "normalKeyText", "specialKeyText", "actionKeyText", "accent", "secondaryAccent", "candidateSurface", "candidateText"], + "properties": { + "background": { "$ref": "#/$defs/paletteColor" }, + "normalKey": { "$ref": "#/$defs/paletteColor" }, + "specialKey": { "$ref": "#/$defs/paletteColor" }, + "actionKey": { "$ref": "#/$defs/paletteColor" }, + "normalKeyText": { "$ref": "#/$defs/paletteColor" }, + "specialKeyText": { "$ref": "#/$defs/paletteColor" }, + "actionKeyText": { "$ref": "#/$defs/paletteColor" }, + "accent": { "$ref": "#/$defs/paletteColor" }, + "secondaryAccent": { "$ref": "#/$defs/paletteColor" }, + "candidateSurface": { "$ref": "#/$defs/paletteColor" }, + "candidateText": { "$ref": "#/$defs/paletteColor" } + } + }, + "shape": { + "type": "string", + "enum": ["roundedRect", "capsule", "cutCorner", "hexagon", "pixelNotched", "roughRect"] + }, + "fill": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "color"], + "properties": { + "type": { "const": "solid" }, + "color": { "$ref": "#/$defs/color" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "colors", "stops", "angleDegrees"], + "properties": { + "type": { "const": "linearGradient" }, + "colors": { "type": "array", "minItems": 2, "maxItems": 4, "items": { "$ref": "#/$defs/color" } }, + "stops": { "$ref": "#/$defs/stops" }, + "angleDegrees": { "type": "number", "minimum": 0, "maximum": 360 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "colors", "stops", "centerX", "centerY", "radius"], + "properties": { + "type": { "const": "radialGradient" }, + "colors": { "type": "array", "minItems": 2, "maxItems": 4, "items": { "$ref": "#/$defs/color" } }, + "stops": { "$ref": "#/$defs/stops" }, + "centerX": { "type": "number", "minimum": 0, "maximum": 1 }, + "centerY": { "type": "number", "minimum": 0, "maximum": 1 }, + "radius": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 } + } + } + ] + }, + "stops": { + "description": "The runtime additionally checks that every stop is strictly increasing.", + "oneOf": [ + { + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [{ "const": 0 }, { "const": 1 }], + "items": false + }, + { + "type": "array", + "minItems": 3, + "maxItems": 3, + "prefixItems": [ + { "const": 0 }, + { "type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1 }, + { "const": 1 } + ], + "items": false + }, + { + "type": "array", + "minItems": 4, + "maxItems": 4, + "prefixItems": [ + { "const": 0 }, + { "type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1 }, + { "type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1 }, + { "const": 1 } + ], + "items": false + } + ] + }, + "stroke": { + "type": "object", + "additionalProperties": false, + "required": ["color", "widthDp"], + "properties": { + "color": { "$ref": "#/$defs/color" }, + "widthDp": { "type": "number", "minimum": 0, "maximum": 4 } + } + }, + "shadow": { + "type": "object", + "additionalProperties": false, + "required": ["color", "offsetXDp", "offsetYDp", "blurDp"], + "properties": { + "color": { "$ref": "#/$defs/color" }, + "offsetXDp": { "type": "number", "minimum": -8, "maximum": 8 }, + "offsetYDp": { "type": "number", "minimum": -8, "maximum": 8 }, + "blurDp": { "type": "number", "minimum": 0, "maximum": 12 } + } + }, + "decoration": { + "type": "object", + "additionalProperties": false, + "required": ["type", "color", "opacity", "sizeDp", "spacingDp", "angleDegrees"], + "properties": { + "type": { "enum": ["none", "dots", "grid", "stripes", "scanlines", "speckles", "weave"] }, + "color": { "$ref": "#/$defs/color" }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, + "sizeDp": { "type": "number", "exclusiveMinimum": 0, "maximum": 8 }, + "spacingDp": { "type": "number", "exclusiveMinimum": 0, "maximum": 32 }, + "angleDegrees": { "type": "number", "minimum": 0, "maximum": 360 } + } + }, + "styleProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "shape": { "$ref": "#/$defs/shape" }, + "fill": { "$ref": "#/$defs/fill" }, + "cornerRadiusDp": { "type": "number", "minimum": 0, "maximum": 32 }, + "insetDp": { "type": "number", "minimum": 0, "maximum": 8 }, + "roughnessDp": { "type": "number", "minimum": 0, "maximum": 3 }, + "cutSizeDp": { "type": "number", "minimum": 0, "maximum": 32 }, + "stroke": { "$ref": "#/$defs/stroke" }, + "shadows": { "type": "array", "maxItems": 2, "items": { "$ref": "#/$defs/shadow" } }, + "decoration": { "$ref": "#/$defs/decoration" } + } + }, + "style": { + "allOf": [ + { "$ref": "#/$defs/styleProperties" }, + { "required": ["shape", "fill", "cornerRadiusDp", "insetDp", "roughnessDp", "cutSizeDp"] } + ] + }, + "styleOverride": { "$ref": "#/$defs/styleProperties" }, + "typography": { + "type": "object", + "additionalProperties": false, + "required": ["font", "weight", "letterSpacing"], + "properties": { + "font": { "enum": ["sans", "sansMedium", "sansCondensed", "serif", "monospace"] }, + "weight": { "enum": ["normal", "medium", "bold"] }, + "letterSpacing": { "type": "number", "minimum": -0.1, "maximum": 0.2 } + } + }, + "motion": { + "type": "object", + "additionalProperties": false, + "required": ["press", "background"], + "properties": { + "press": { + "type": "object", + "additionalProperties": false, + "required": ["scale", "translationXDp", "translationYDp", "durationMs", "releaseDurationMs"], + "properties": { + "scale": { "type": "number", "minimum": 0.9, "maximum": 1.05 }, + "translationXDp": { "type": "number", "minimum": -4, "maximum": 4 }, + "translationYDp": { "type": "number", "minimum": -4, "maximum": 4 }, + "durationMs": { "type": "integer", "minimum": 0, "maximum": 500 }, + "releaseDurationMs": { "type": "integer", "minimum": 0, "maximum": 500 } + } + }, + "background": { + "type": "object", + "additionalProperties": false, + "required": ["type", "periodSeconds"], + "properties": { + "type": { "enum": ["none", "pulse", "sweep", "shift"] }, + "periodSeconds": { "type": "number", "minimum": 0, "maximum": 30 } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "none" } } }, + "then": { "properties": { "periodSeconds": { "minimum": 0, "maximum": 30 } } }, + "else": { "properties": { "periodSeconds": { "minimum": 2, "maximum": 30 } } } + } + ] + } + } + } + } +} diff --git a/docs/keyboard-skins/import-v1/template.json b/docs/keyboard-skins/import-v1/template.json new file mode 100644 index 000000000..fbaf26fa6 --- /dev/null +++ b/docs/keyboard-skins/import-v1/template.json @@ -0,0 +1,158 @@ +{ + "format": "sumire-keyboard-skin", + "formatVersion": 1, + "id": "my-skin", + "name": "My Skin", + "author": "", + "description": "", + "palette": { + "background": "#20242C", + "normalKey": "#303744", + "specialKey": "#3E4858", + "actionKey": "#6D4CFF", + "normalKeyText": "#FFFFFF", + "specialKeyText": "#FFFFFF", + "actionKeyText": "#FFFFFF", + "accent": "#9D8CFF", + "secondaryAccent": "#56D8FF", + "candidateSurface": "#252B35", + "candidateText": "#FFFFFF" + }, + "keys": { + "base": { + "shape": "roundedRect", + "fill": { "type": "solid", "color": "@palette.normalKey" }, + "cornerRadiusDp": 8, + "insetDp": 1, + "roughnessDp": 0, + "cutSizeDp": 0, + "stroke": { "color": "@palette.accent", "widthDp": 0 }, + "shadows": [], + "decoration": { + "type": "none", + "color": "@palette.accent", + "opacity": 0, + "sizeDp": 1, + "spacingDp": 8, + "angleDegrees": 0 + } + }, + "character": {}, + "modifier": {}, + "action": {}, + "space": {}, + "candidate": {}, + "toolbar": {}, + "popup": {} + }, + "surfaces": { + "deck": { + "shape": "roundedRect", + "fill": { "type": "solid", "color": "@palette.background" }, + "cornerRadiusDp": 10, + "insetDp": 0, + "roughnessDp": 0, + "cutSizeDp": 0, + "stroke": { "color": "@palette.accent", "widthDp": 0 }, + "shadows": [], + "decoration": { + "type": "none", + "color": "@palette.accent", + "opacity": 0, + "sizeDp": 1, + "spacingDp": 8, + "angleDegrees": 0 + } + }, + "candidateStrip": { + "shape": "roundedRect", + "fill": { "type": "solid", "color": "@palette.candidateSurface" }, + "cornerRadiusDp": 8, + "insetDp": 0, + "roughnessDp": 0, + "cutSizeDp": 0, + "stroke": { "color": "@palette.accent", "widthDp": 0 }, + "shadows": [], + "decoration": { + "type": "none", + "color": "@palette.accent", + "opacity": 0, + "sizeDp": 1, + "spacingDp": 8, + "angleDegrees": 0 + } + }, + "candidatePanel": { + "shape": "roundedRect", + "fill": { "type": "solid", "color": "@palette.candidateSurface" }, + "cornerRadiusDp": 8, + "insetDp": 0, + "roughnessDp": 0, + "cutSizeDp": 0, + "stroke": { "color": "@palette.accent", "widthDp": 0 }, + "shadows": [], + "decoration": { + "type": "none", + "color": "@palette.accent", + "opacity": 0, + "sizeDp": 1, + "spacingDp": 8, + "angleDegrees": 0 + } + }, + "toolbar": { + "shape": "roundedRect", + "fill": { "type": "solid", "color": "@palette.specialKey" }, + "cornerRadiusDp": 8, + "insetDp": 0, + "roughnessDp": 0, + "cutSizeDp": 0, + "stroke": { "color": "@palette.accent", "widthDp": 0 }, + "shadows": [], + "decoration": { + "type": "none", + "color": "@palette.accent", + "opacity": 0, + "sizeDp": 1, + "spacingDp": 8, + "angleDegrees": 0 + } + }, + "popup": { + "shape": "roundedRect", + "fill": { "type": "solid", "color": "@palette.specialKey" }, + "cornerRadiusDp": 8, + "insetDp": 0, + "roughnessDp": 0, + "cutSizeDp": 0, + "stroke": { "color": "@palette.accent", "widthDp": 0 }, + "shadows": [], + "decoration": { + "type": "none", + "color": "@palette.accent", + "opacity": 0, + "sizeDp": 1, + "spacingDp": 8, + "angleDegrees": 0 + } + } + }, + "typography": { + "font": "sans", + "weight": "normal", + "letterSpacing": 0 + }, + "motion": { + "press": { + "scale": 0.97, + "translationXDp": 0, + "translationYDp": 1, + "durationMs": 80, + "releaseDurationMs": 110 + }, + "background": { + "type": "none", + "periodSeconds": 0 + } + } +} diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/README.md b/docs/test-results/keyboard-skins/pixel-6-pro-api35/README.md new file mode 100644 index 000000000..ba5e26e09 --- /dev/null +++ b/docs/test-results/keyboard-skins/pixel-6-pro-api35/README.md @@ -0,0 +1,67 @@ +# Keyboard skin verification + +## Implemented skins + +- Sumi Hanshi (`sumi_hanshi`) +- Letterpress (`letterpress`) +- Sometsuke Porcelain (`porcelain`) +- Urushi Lacquer (`urushi`) +- Chalkboard (`chalkboard`) +- Linen Embroidery (`linen`) +- Monochrome LCD (`monochrome_lcd`) + +Each skin has an independent palette, key geometry, surface treatment, depth model, +typography, and press response. The preview images are generated by the same procedural +renderer used by the actual keyboard; concept images are not bundled as runtime skins. + +## Automated verification + +Pixel 6 Pro AVD, Android 15 / API 35, 1440 x 3120: + +- `KeyboardSkinRenderInstrumentedTest`: passed + - all 17 registered previews render successfully and have distinct hashes + - all 17 TenKey surfaces render successfully and have distinct hashes + - every pair among the seven new skins differs strongly in at least 60% of pixels + - reduced/off motion behavior passed +- `KeyboardSkinPickerInstrumentedTest`: passed + - all seven new cards can be selected + - each stable preference value persists correctly + - labels render without ellipsis + - resource arrays remain synchronized with `KeyboardSkinId` +- Instrumentation result: `OK (7 tests)` +- Core and app unit tests, debug compilation, app APK, and test APK assembly: passed + +Pixel 6 physical device, Android 16 / API 36: + +- all four renderer tests and the resource-array synchronization test passed +- the two picker-fragment UI tests could not launch while the physical device was + secure-locked; the same tests passed on the unlocked AVD + +## Actual IME verification + +The full app was selected as the system IME in Android Settings. + +1. Selected Sumi Hanshi from the real app picker and confirmed that + `keyboard_skin_preference=sumi_hanshi` persisted. +2. Opened the Android Settings search field and confirmed Sumi Hanshi on the actual + TenKey keyboard and conversion-candidate row. +3. Switched to the app while the Settings input session remained open, selected + Sometsuke Porcelain, and returned through Recents. +4. The existing IME session redrew immediately as Porcelain without restarting the + keyboard service. +5. Logcat contained no `FATAL EXCEPTION` or app process error. + +## Evidence + +- [Picker](keyboard-skin-picker-new-materials.png) +- [Sumi Hanshi actual IME](ime-sumi-hanshi.png) +- [Sumi Hanshi actual candidate row](ime-sumi-hanshi-candidates.png) +- [Porcelain immediate runtime refresh](ime-porcelain-runtime.png) +- Preview renders: [Sumi Hanshi](skin-sumi_hanshi.png), [Letterpress](skin-letterpress.png), + [Porcelain](skin-porcelain.png), [Urushi](skin-urushi.png), + [Chalkboard](skin-chalkboard.png), [Linen](skin-linen.png), + [Monochrome LCD](skin-monochrome_lcd.png) +- TenKey renders: [Sumi Hanshi](tenkey-sumi_hanshi.png), + [Letterpress](tenkey-letterpress.png), [Porcelain](tenkey-porcelain.png), + [Urushi](tenkey-urushi.png), [Chalkboard](tenkey-chalkboard.png), + [Linen](tenkey-linen.png), [Monochrome LCD](tenkey-monochrome_lcd.png) diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/ime-porcelain-runtime.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/ime-porcelain-runtime.png new file mode 100644 index 000000000..286cdd4f6 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/ime-porcelain-runtime.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/ime-sumi-hanshi-candidates.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/ime-sumi-hanshi-candidates.png new file mode 100644 index 000000000..cc148c4b7 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/ime-sumi-hanshi-candidates.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/ime-sumi-hanshi.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/ime-sumi-hanshi.png new file mode 100644 index 000000000..1aefd57ef Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/ime-sumi-hanshi.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/keyboard-skin-picker-new-materials.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/keyboard-skin-picker-new-materials.png new file mode 100644 index 000000000..0e2cc2690 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/keyboard-skin-picker-new-materials.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-chalkboard.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-chalkboard.png new file mode 100644 index 000000000..dda6fa583 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-chalkboard.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-letterpress.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-letterpress.png new file mode 100644 index 000000000..0f25e2ad1 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-letterpress.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-linen.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-linen.png new file mode 100644 index 000000000..8f8472441 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-linen.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-monochrome_lcd.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-monochrome_lcd.png new file mode 100644 index 000000000..5d1fe5d4b Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-monochrome_lcd.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-porcelain.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-porcelain.png new file mode 100644 index 000000000..875c4d62e Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-porcelain.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-sumi_hanshi.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-sumi_hanshi.png new file mode 100644 index 000000000..f9c21caaf Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-sumi_hanshi.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-urushi.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-urushi.png new file mode 100644 index 000000000..a84048cb0 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/skin-urushi.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-chalkboard.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-chalkboard.png new file mode 100644 index 000000000..7f5adba75 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-chalkboard.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-letterpress.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-letterpress.png new file mode 100644 index 000000000..1ab4f2584 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-letterpress.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-linen.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-linen.png new file mode 100644 index 000000000..4c32f6e42 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-linen.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-monochrome_lcd.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-monochrome_lcd.png new file mode 100644 index 000000000..5980d3a46 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-monochrome_lcd.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-porcelain.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-porcelain.png new file mode 100644 index 000000000..65055f883 Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-porcelain.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-sumi_hanshi.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-sumi_hanshi.png new file mode 100644 index 000000000..4899098fe Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-sumi_hanshi.png differ diff --git a/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-urushi.png b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-urushi.png new file mode 100644 index 000000000..1df90c39f Binary files /dev/null and b/docs/test-results/keyboard-skins/pixel-6-pro-api35/tenkey-urushi.png differ diff --git a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt index 65ed86f30..a53de5d07 100644 --- a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt +++ b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/QWERTYKeyboardView.kt @@ -49,6 +49,21 @@ import com.kazumaproject.core.data.popup.QwertyPopupViewStyleSet import com.kazumaproject.core.data.qwerty.CapsLockState import com.kazumaproject.core.data.qwerty.QWERTYKeys import com.kazumaproject.core.data.qwerty.VariationInfo +import com.kazumaproject.core.data.keyboard.KeyboardSkinDrawableFactory +import com.kazumaproject.core.data.keyboard.KeyboardElementRole +import com.kazumaproject.core.data.keyboard.KeyboardSkinCatalog +import com.kazumaproject.core.data.keyboard.KeyboardSkinMotionMode +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupKind +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer +import com.kazumaproject.core.data.keyboard.KeyboardSkinRendererRegistry +import com.kazumaproject.core.data.keyboard.KeyboardSkinViewStyler +import com.kazumaproject.core.data.keyboard.KeyboardSurfaceRole +import com.kazumaproject.core.data.keyboard.resolveKeyboardSkinPalette +import com.kazumaproject.core.data.keyboard.isBuiltIn +import com.kazumaproject.core.data.keyboard.isDefault +import com.kazumaproject.core.data.keyboard.resolvedOrDefault import com.kazumaproject.core.domain.extensions.dpToPx import com.kazumaproject.core.domain.extensions.setBorder import com.kazumaproject.core.domain.extensions.setDrawableAlpha @@ -276,6 +291,8 @@ class QWERTYKeyboardView @JvmOverloads constructor( // Theme Variables (Initialized with defaults) private var themeMode: String = "default" + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var keyboardSkinMotionMode: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL private var isNightMode: Boolean = false private var isDynamicColorEnabled: Boolean = false private var customBgColor: Int = Color.WHITE @@ -426,10 +443,14 @@ class QWERTYKeyboardView @JvmOverloads constructor( customBorderEnable: Boolean, customBorderColor: Int, liquidGlassKeyAlphaEnable: Int, - borderWidth: Int + borderWidth: Int, + keyboardSkin: String = KeyboardSkinId.DEFAULT.preferenceValue, + keyboardSkinMotion: String = KeyboardSkinMotionMode.FULL.preferenceValue, ) { // メンバ変数に代入 this.themeMode = themeMode + this.keyboardSkinId = KeyboardSkinRef.fromPreference(keyboardSkin).resolvedOrDefault() + this.keyboardSkinMotionMode = KeyboardSkinMotionMode.fromPreference(keyboardSkinMotion) // Int型の currentNightMode から Boolean型の isNightMode を判定 this.isNightMode = (currentNightMode == Configuration.UI_MODE_NIGHT_YES) @@ -449,20 +470,35 @@ class QWERTYKeyboardView @JvmOverloads constructor( LayoutInflater.from(context) - when (this.themeMode) { - "default" -> { + when { + !this.keyboardSkinId.isDefault() -> { + applyBuiltInSkin() + } + + this.themeMode == "default" -> { + clearBuiltInSkinStyles() setBackgroundColor(Color.TRANSPARENT) setMaterialYouTheme(this.isNightMode, true) } - "custom" -> { + this.themeMode == "custom" -> { + clearBuiltInSkinStyles() + val palette = resolveKeyboardSkinPalette( + context = context, + themeMode = this.themeMode, + customBackgroundColor = customBgColor, + customKeyColor = customKeyColor, + customSpecialKeyColor = customSpecialKeyColor, + customKeyTextColor = customKeyTextColor, + customSpecialKeyTextColor = customSpecialKeyTextColor, + ) setFullCustomNeumorphismTheme( - backgroundColor = customBgColor, - normalKeyColor = customKeyColor, - specialKeyColor = customSpecialKeyColor, - normalKeyTextColor = customKeyTextColor, - specialKeyTextColor = customSpecialKeyTextColor, - borderWidth = borderWidth + backgroundColor = palette.backgroundColor, + normalKeyColor = palette.normalKeyColor, + specialKeyColor = palette.specialKeyColor, + normalKeyTextColor = palette.normalKeyTextColor, + specialKeyTextColor = palette.specialKeyTextColor, + borderWidth = borderWidth, ) } @@ -491,7 +527,7 @@ class QWERTYKeyboardView @JvmOverloads constructor( borderWidth: Int ) { val density = context.resources.displayMetrics.density - val radius = 8f * density // 角丸の半径 (8dp) + val radius = KeyboardSkinDrawableFactory.keyCornerRadiusDp(KeyboardSkinId.DEFAULT) * density // 1. 全体の背景色を設定 if (liquidGlassEnable) { @@ -564,90 +600,67 @@ class QWERTYKeyboardView @JvmOverloads constructor( * @param radius キーの角丸の半径 (px) */ private fun getDynamicNeumorphDrawable(baseColor: Int, radius: Float): Drawable { - // 1. 色の計算 - // ハイライト色: ベース色に白(#FFFFFF)を50%混ぜる(または明るくする) - val highlightColor = manipulateColor(baseColor, 1.2f) // 輝度を上げる簡易版 - // シャドウ色: ベース色に黒(#000000)を混ぜて暗くする - val shadowColor = manipulateColor(baseColor, 0.8f) // 輝度を下げる簡易版 - - // 2. ピクセル単位のオフセット量(4dpなどをpxに変換) - val density = context.resources.displayMetrics.density - val offset = (4 * density).toInt() // 影のずれ幅 - val padding = (2 * density).toInt() // メイン面の縮小幅 - - // --- A. 通常状態 (Idle) の作成 --- - - // レイヤー0: 暗い影 (右下に配置) - val shadowDrawable = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = radius - setColor(shadowColor) - } - - // レイヤー1: 明るいハイライト (左上に配置) - val highlightDrawable = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = radius - setColor(highlightColor) - } + return KeyboardSkinDrawableFactory.createKeyDrawable( + context = context, + skinId = KeyboardSkinId.DEFAULT, + baseColor = baseColor, + cornerRadiusDp = radius / context.resources.displayMetrics.density, + ) + } - // レイヤー2: メインの面 - val surfaceDrawable = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = radius - setColor(baseColor) + private fun applyBuiltInSkin() { + val renderer = KeyboardSkinRendererRegistry.rendererFor(keyboardSkinId) + background = renderer.createSurfaceDrawable(context, KeyboardSurfaceRole.DECK) + binding.apply { + val characterKeys = listOf( + key1, key2, key3, key4, key5, key6, key7, key8, key9, key0, + keyKuten, keyTouten, keyQ, keyW, keyE, keyR, keyT, keyY, keyU, keyI, keyO, keyP, + keyA, keyS, keyD, keyF, keyG, keyH, keyJ, keyK, keyAtMark, keyL, + keyZ, keyX, keyC, keyV, keyB, keyN, keyM, + ) + val modifierKeys = listOf( + keyShift, keyDelete, keySwitchDefault, keyEmoji, key123, + switchNumberLayout, cursorLeft, cursorRight, switchRomajiEnglish, + ) + characterKeys.forEach { view -> + KeyboardSkinViewStyler.applyKey( + view, + keyboardSkinId, + KeyboardElementRole.CHARACTER, + keyboardSkinMotionMode, + ) + } + modifierKeys.forEach { view -> + KeyboardSkinViewStyler.applyKey( + view, + keyboardSkinId, + KeyboardElementRole.MODIFIER, + keyboardSkinMotionMode, + ) + } + KeyboardSkinViewStyler.applyKey( + keySpace, + keyboardSkinId, + KeyboardElementRole.SPACE, + keyboardSkinMotionMode, + ) + KeyboardSkinViewStyler.applyKey( + keyReturn, + keyboardSkinId, + KeyboardElementRole.ACTION, + keyboardSkinMotionMode, + ) } + } - // LayerDrawableで重ねる (下から順に描画される) - val idleLayer = LayerDrawable(arrayOf(shadowDrawable, highlightDrawable, surfaceDrawable)) - - // インセット(余白)を設定して位置をずらす - // setLayerInset(index, left, top, right, bottom) - - // 影: 左と上を空けて、右下に表示させる - idleLayer.setLayerInset(0, offset, offset, 0, 0) - - // ハイライト: 右と下を空けて、左上に表示させる - idleLayer.setLayerInset(1, 0, 0, offset, offset) - - // メイン面: 全体に少し余白を入れて中央に配置(影が見えるようにする) - idleLayer.setLayerInset(2, padding, padding, padding, padding) - - - // --- B. 押下状態 (Pressed) の作成 --- - - // 押したときは凹む表現(影を消して少し暗くする、あるいは内側の影を擬似的に表現) - val pressedDrawable = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = radius - // ベース色より少し暗くすることで「押し込まれた」感を出す - setColor(manipulateColor(baseColor, 0.95f)) + private fun clearBuiltInSkinStyles() { + fun clearRecursively(view: View) { + KeyboardSkinViewStyler.clearTransientStyle(view) + if (view is ViewGroup) { + for (index in 0 until view.childCount) clearRecursively(view.getChildAt(index)) + } } - // Pressed状態はサイズを変えないため、IdleのSurfaceと同じ位置に合わせるためのInsetが必要ならLayerDrawableにする - val pressedLayer = LayerDrawable(arrayOf(pressedDrawable)) - pressedLayer.setLayerInset(0, padding, padding, padding, padding) - - - // --- C. StateListDrawable (Selector) にまとめる --- - val stateListDrawable = android.graphics.drawable.StateListDrawable() - - // 押された時 - stateListDrawable.addState( - intArrayOf(android.R.attr.state_pressed), - pressedLayer - ) - // 無効な時 (必要であれば) - stateListDrawable.addState( - intArrayOf(-android.R.attr.state_enabled), - pressedLayer // 簡易的にPressedと同じ、あるいは透明度を下げるなど - ) - // 通常時 - stateListDrawable.addState( - intArrayOf(), - idleLayer - ) - - return stateListDrawable + clearRecursively(binding.root) } /** @@ -2466,10 +2479,13 @@ class QWERTYKeyboardView @JvmOverloads constructor( val popupView = LayoutInflater.from(context).inflate(layoutRes, this, false) val tv = popupView.findViewById(R.id.preview_text) val iv = popupView.findViewById(R.id.preview_bubble_bg) - tv.setTextSize( - TypedValue.COMPLEX_UNIT_SP, - keyPreviewPopupStyle.textSizeSp.coerceIn(8f, 48f) - ) + val fixedCupertinoPopup = KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId) + if (!fixedCupertinoPopup) { + tv.setTextSize( + TypedValue.COMPLEX_UNIT_SP, + keyPreviewPopupStyle.textSizeSp.coerceIn(8f, 48f) + ) + } val isLandMode = (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) @@ -2491,24 +2507,46 @@ class QWERTYKeyboardView @JvmOverloads constructor( in rightKeyIds -> if (isDynamicColorsEnable) com.kazumaproject.core.R.drawable.key_preview_bubble_right_material else com.kazumaproject.core.R.drawable.key_preview_bubble_right else -> if (isDynamicColorsEnable) com.kazumaproject.core.R.drawable.key_preview_bubble_material else com.kazumaproject.core.R.drawable.key_preview_bubble } - iv.setBackgroundResource(drawableResIdForImageView) - when (themeMode) { - "custom" -> { - iv.setDrawableSolidColor(customSpecialKeyColor) - tv.setTextColor(customSpecialKeyTextColor) - } - - else -> { + if (fixedCupertinoPopup) { + iv.background = KeyboardSkinPopupRenderer.createDrawable( + context, + keyboardSkinId, + KeyboardSkinPopupKind.KEY_PREVIEW, + ) + KeyboardSkinPopupRenderer.applyTextStyle( + tv, + keyboardSkinId, + KeyboardSkinPopupKind.KEY_PREVIEW, + ) + } else if (!keyboardSkinId.isDefault()) { + val spec = KeyboardSkinCatalog.specFor(keyboardSkinId) + iv.background = KeyboardSkinRendererRegistry.rendererFor(keyboardSkinId) + .createKeyDrawable(context, KeyboardElementRole.POPUP, view.id) + tv.setTextColor(spec.palette.specialKeyTextColor) + tv.typeface = Typeface.create( + spec.typography.familyName, + if (spec.typography.bold) Typeface.BOLD else Typeface.NORMAL, + ) + } else { + iv.setBackgroundResource(drawableResIdForImageView) + when (themeMode) { + "custom" -> { + iv.setDrawableSolidColor(customSpecialKeyColor) + tv.setTextColor(customSpecialKeyTextColor) + } + else -> Unit + } + keyPreviewPopupStyle.backgroundColor?.let { backgroundColor -> + iv.setDrawableSolidColor(backgroundColor) + } + keyPreviewPopupStyle.textColor?.let { textColor -> + tv.setTextColor(textColor) } } - keyPreviewPopupStyle.backgroundColor?.let { backgroundColor -> - iv.setDrawableSolidColor(backgroundColor) - } - keyPreviewPopupStyle.textColor?.let { textColor -> - tv.setTextColor(textColor) + if (!fixedCupertinoPopup) { + popupView.rootView.layoutParams.height = previewHeight } - popupView.rootView.layoutParams.height = previewHeight when (view) { is QWERTYButton -> { @@ -2524,9 +2562,25 @@ class QWERTYKeyboardView @JvmOverloads constructor( else -> tv.text = "" } - val scale = keyPreviewPopupStyle.sizeScalePercent.coerceIn(50, 200) / 100f - val popupWidth = (view.width * 2 * scale).toInt().coerceAtLeast(1) - val popupHeight = ((view.height * 2 + 64) * scale).toInt().coerceAtLeast(1) + val popupSpec = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) + val scale = if (fixedCupertinoPopup) { + 1f + } else { + keyPreviewPopupStyle.sizeScalePercent.coerceIn(50, 200) / 100f + } + val popupWidth = if (popupSpec != null) { + (view.width * popupSpec.keyPreviewWidthScale * scale).toInt().coerceAtLeast(1) + } else { + (view.width * 2 * scale).toInt().coerceAtLeast(1) + } + val popupHeight = if (popupSpec != null) { + ( + view.height * popupSpec.keyPreviewHeightScale * scale + + context.dpToPx(popupSpec.stemHeightDp) + ).toInt().coerceAtLeast(1) + } else { + ((view.height * 2 + 64) * scale).toInt().coerceAtLeast(1) + } val popup = PopupWindow(popupView, popupWidth, popupHeight, false).apply { isTouchable = false @@ -2534,7 +2588,13 @@ class QWERTYKeyboardView @JvmOverloads constructor( elevation = 6f } - val xOffset = -((popupWidth - view.width) / 2) + val centeredOffset = -((popupWidth - view.width) / 2) + val absoluteLeft = view.left + centeredOffset + val clampedLeft = absoluteLeft.coerceIn( + 0, + (width - popupWidth).coerceAtLeast(0), + ) + val xOffset = clampedLeft - view.left val yOffset = -popupHeight popup.showAsDropDown(view, xOffset, yOffset) keyPreviewPopup = popup @@ -2746,10 +2806,12 @@ class QWERTYKeyboardView @JvmOverloads constructor( val context = this.context variationPopupView = VariationsPopupView(context).apply { applyPopupViewStyle(variationPopupStyle) + setKeyboardSkin(keyboardSkinId) setChars(variations) } - when (themeMode) { - "custom" -> { + when { + !keyboardSkinId.isDefault() -> Unit + themeMode == "custom" -> { variationPopupView?.setNeumorphicColors( bgColor = customSpecialKeyColor, selectedColor = manipulateColor(customSpecialKeyColor, 1.2f), @@ -2757,9 +2819,7 @@ class QWERTYKeyboardView @JvmOverloads constructor( ) } - else -> { - - } + else -> Unit } val maxColumns = 3 val scale = variationPopupStyle.sizeScalePercent.coerceIn(50, 200) / 100f @@ -2801,6 +2861,11 @@ class QWERTYKeyboardView @JvmOverloads constructor( backgroundColor = styleSet.variation.backgroundColor, textColor = styleSet.variation.textColor ) + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + val popup = checkNotNull(KeyboardSkinPopupRenderer.specFor(keyboardSkinId)) + keyPreviewPopupStyle = PopupViewStyle(100, popup.keyPreviewTextSizeSp) + variationPopupStyle = PopupViewStyle(100, popup.variationTextSizeSp) + } variationPopupView?.applyPopupViewStyle(variationPopupStyle) } diff --git a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/VariationsPopupView.kt b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/VariationsPopupView.kt index 238724eb9..95894fe59 100644 --- a/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/VariationsPopupView.kt +++ b/qwerty_keyboard/src/main/java/com/kazumaproject/qwerty_keyboard/ui/VariationsPopupView.kt @@ -6,10 +6,22 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.drawable.Drawable import android.util.TypedValue import android.view.View import androidx.annotation.ColorInt import androidx.core.content.ContextCompat +import com.kazumaproject.core.data.keyboard.KeyboardElementRole +import com.kazumaproject.core.data.keyboard.KeyboardSkinCatalog +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupKind +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer +import com.kazumaproject.core.data.keyboard.KeyboardSkinRendererRegistry +import com.kazumaproject.core.data.keyboard.KeyboardSurfaceRole +import com.kazumaproject.core.data.keyboard.isBuiltIn +import com.kazumaproject.core.data.keyboard.isDefault import com.kazumaproject.core.data.popup.PopupViewStyle import kotlin.math.ceil import kotlin.math.min @@ -36,6 +48,14 @@ class VariationsPopupView(context: Context) : View(context) { private var numRows = 1 private var popupBackgroundColor: Int? = null private var popupTextColor: Int? = null + private var popupTextSizeSp: Float = 28f + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var skinSurfaceDrawable: Drawable? = null + private var skinItemDrawable: Drawable? = null + private var skinSelectedItemDrawable: Drawable? = null + private val skinTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + } // ■■■ FLATモード用 (元のコードの変数) ■■■ private val flatTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { @@ -73,8 +93,22 @@ class VariationsPopupView(context: Context) : View(context) { private val itemCornerRadius = 15f fun applyPopupViewStyle(style: PopupViewStyle) { + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + val popup = checkNotNull(KeyboardSkinPopupRenderer.specFor(keyboardSkinId)) + popupTextSizeSp = popup.variationTextSizeSp + popupBackgroundColor = null + popupTextColor = popup.textColor + skinTextPaint.textSize = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + popup.variationTextSizeSp, + resources.displayMetrics, + ) + invalidate() + return + } popupBackgroundColor = style.backgroundColor popupTextColor = style.textColor + popupTextSizeSp = style.textSizeSp.coerceIn(8f, 48f) val textSizePx = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, style.textSizeSp.coerceIn(8f, 48f), @@ -93,6 +127,51 @@ class VariationsPopupView(context: Context) : View(context) { invalidate() } + fun setKeyboardSkin(skinId: KeyboardSkinId) = + setKeyboardSkin(KeyboardSkinRef.BuiltIn(skinId)) + + fun setKeyboardSkin(skinId: KeyboardSkinRef) { + keyboardSkinId = skinId + if (skinId.isDefault()) { + skinSurfaceDrawable = null + skinItemDrawable = null + skinSelectedItemDrawable = null + invalidate() + return + } + val renderer = KeyboardSkinRendererRegistry.rendererFor(skinId) + val spec = KeyboardSkinCatalog.specFor(skinId) + skinSurfaceDrawable = renderer.createPopupDrawable( + context, + KeyboardSkinPopupKind.VARIATION, + ) ?: renderer.createSurfaceDrawable(context, KeyboardSurfaceRole.POPUP) + skinItemDrawable = renderer.createPopupDrawable( + context, + KeyboardSkinPopupKind.VARIATION, + ) ?: renderer.createKeyDrawable(context, KeyboardElementRole.CANDIDATE, 1) + skinSelectedItemDrawable = renderer.createPopupDrawable( + context, + KeyboardSkinPopupKind.VARIATION, + selected = true, + ) ?: renderer.createKeyDrawable(context, KeyboardElementRole.CANDIDATE, 2).apply { + state = intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled) + } + val popup = KeyboardSkinPopupRenderer.specFor(skinId) + skinTextPaint.color = popup?.textColor ?: spec.palette.candidateTextColor + skinTextPaint.typeface = Typeface.create( + spec.typography.familyName, + if (spec.typography.bold) Typeface.BOLD else Typeface.NORMAL, + ) + skinTextPaint.textSize = popup?.let { + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + it.variationTextSizeSp, + resources.displayMetrics, + ) + } ?: flatTextPaint.textSize + invalidate() + } + // ニューモーフィズム用メソッド fun setNeumorphicColors( @ColorInt bgColor: Int, @@ -140,6 +219,11 @@ class VariationsPopupView(context: Context) : View(context) { super.onDraw(canvas) if (chars.isEmpty()) return + if (!keyboardSkinId.isDefault()) { + drawKeyboardSkinPopup(canvas) + return + } + // 共通:描画領域のクリップ clipPath.reset() clipPath.addRoundRect( @@ -183,6 +267,63 @@ class VariationsPopupView(context: Context) : View(context) { } } + private fun drawKeyboardSkinPopup(canvas: Canvas) { + skinSurfaceDrawable?.apply { + setBounds(0, 0, width, height) + draw(canvas) + } + val spec = KeyboardSkinCatalog.specFor(keyboardSkinId) + chars.forEachIndexed { index, char -> + val col = index % maxColumns + val row = index / maxColumns + val left = col * itemWidth + val top = row * itemHeight + val right = left + itemWidth + val bottom = top + itemHeight + val drawable = if (index == selectedIndex) { + skinSelectedItemDrawable + } else { + skinItemDrawable + } + val gap = KeyboardSkinPopupRenderer.specFor(keyboardSkinId)?.itemGapDp?.let { + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + it / 2f, + resources.displayMetrics, + ) + } ?: 0f + drawable?.apply { + setBounds( + (left + gap).toInt(), + (top + gap).toInt(), + (right - gap).toInt(), + (bottom - gap).toInt(), + ) + draw(canvas) + } + val popup = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) + skinTextPaint.color = when { + popup != null && index == selectedIndex -> popup.selectedTextColor + popup != null -> popup.textColor + index != selectedIndex -> spec.palette.candidateTextColor + keyboardSkinId.isBuiltIn(KeyboardSkinId.FLAT) || + keyboardSkinId.isBuiltIn(KeyboardSkinId.TERMINAL) -> spec.palette.backgroundColor + else -> spec.palette.candidateTextColor + } + skinTextPaint.textSize = popup?.let { + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + it.variationTextSizeSp, + resources.displayMetrics, + ) + } ?: flatTextPaint.textSize + val cx = left + itemWidth / 2f + val cy = top + itemHeight / 2f - + (skinTextPaint.descent() + skinTextPaint.ascent()) / 2f + canvas.drawText(char.toString(), cx, cy, skinTextPaint) + } + } + // ニューモーフィズムの凹み描画ロジックを分離 private fun drawNeumorphicSelection( canvas: Canvas, diff --git a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt index 031ab43fc..c00f56020 100644 --- a/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt +++ b/symbol_keyboard/src/main/java/com/kazumaproject/symbol_keyboard/CustomSymbolKeyboardView.kt @@ -39,6 +39,17 @@ import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.tabs.TabLayout import com.kazumaproject.core.data.clicked_symbol.SymbolMode import com.kazumaproject.core.data.clipboard.ClipboardItem +import com.kazumaproject.core.data.keyboard.KeyboardElementRole +import com.kazumaproject.core.data.keyboard.KeyboardSkinCatalog +import com.kazumaproject.core.data.keyboard.KeyboardSkinDrawableFactory +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinMotionMode +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinRendererRegistry +import com.kazumaproject.core.data.keyboard.KeyboardSkinViewStyler +import com.kazumaproject.core.data.keyboard.KeyboardSurfaceRole +import com.kazumaproject.core.data.keyboard.isDefault +import com.kazumaproject.core.data.keyboard.resolvedOrDefault import com.kazumaproject.data.clicked_symbol.ClickedSymbol import com.kazumaproject.data.emoji.Emoji import com.kazumaproject.data.emoji.EmojiCategory @@ -82,6 +93,8 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( private var themeIconColor: Int = Color.GRAY private var themeSelectedIconColor: Int = Color.BLUE private var themeKeyBackgroundColor: Int = Color.LTGRAY + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var keyboardSkinMotionMode: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL private var liquidGlassEnable: Boolean = false // Flag to check if custom theme is applied @@ -263,19 +276,34 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( @ColorInt selectedIconColor: Int, @ColorInt keyBackgroundColor: Int, liquidGlassEnable: Boolean, + keyboardSkin: String = KeyboardSkinId.DEFAULT.preferenceValue, + keyboardSkinMotion: String = KeyboardSkinMotionMode.FULL.preferenceValue, ) { - this.themeBackgroundColor = backgroundColor - this.themeIconColor = iconColor - this.themeSelectedIconColor = selectedIconColor - this.themeKeyBackgroundColor = keyBackgroundColor + this.keyboardSkinId = KeyboardSkinRef.fromPreference(keyboardSkin).resolvedOrDefault() + this.keyboardSkinMotionMode = KeyboardSkinMotionMode.fromPreference(keyboardSkinMotion) + val builtInPalette = keyboardSkinId + .takeIf { !it.isDefault() } + ?.let { KeyboardSkinCatalog.specFor(it).palette } + this.themeBackgroundColor = builtInPalette?.backgroundColor ?: backgroundColor + this.themeIconColor = builtInPalette?.normalKeyTextColor ?: iconColor + this.themeSelectedIconColor = builtInPalette?.accentColor ?: selectedIconColor + this.themeKeyBackgroundColor = builtInPalette?.normalKeyColor ?: keyBackgroundColor this.isCustomThemeApplied = true - this.liquidGlassEnable = liquidGlassEnable + this.liquidGlassEnable = liquidGlassEnable && keyboardSkinId.isDefault() + + val effectiveBackgroundColor = themeBackgroundColor + val effectiveIconColor = themeIconColor + val effectiveSelectedIconColor = themeSelectedIconColor + val effectiveKeyBackgroundColor = themeKeyBackgroundColor // 1. 全体の背景色 - if (liquidGlassEnable) { - this.setBackgroundColor(ColorUtils.setAlphaComponent(backgroundColor, 0)) + if (!keyboardSkinId.isDefault()) { + background = KeyboardSkinRendererRegistry.rendererFor(keyboardSkinId) + .createSurfaceDrawable(context, KeyboardSurfaceRole.DECK) + } else if (this.liquidGlassEnable) { + this.setBackgroundColor(ColorUtils.setAlphaComponent(effectiveBackgroundColor, 0)) } else { - this.setBackgroundColor(backgroundColor) + this.setBackgroundColor(effectiveBackgroundColor) } // 2. ColorStateList の作成 @@ -284,17 +312,19 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( intArrayOf(-android.R.attr.state_selected) ) val colors = intArrayOf( - selectedIconColor, - iconColor + effectiveSelectedIconColor, + effectiveIconColor ) val tabColorStateList = ColorStateList(states, colors) - val bgTintList = ColorStateList.valueOf(backgroundColor) + val bgTintList = ColorStateList.valueOf( + if (keyboardSkinId.isDefault()) effectiveBackgroundColor else Color.TRANSPARENT + ) // 3. Category Tab の全体設定 categoryTab.backgroundTintList = bgTintList categoryTab.tabIconTint = tabColorStateList - categoryTab.setTabTextColors(iconColor, selectedIconColor) + categoryTab.setTabTextColors(effectiveIconColor, effectiveSelectedIconColor) categoryTab.setSelectedTabIndicatorColor(Color.TRANSPARENT) categoryTab.tabRippleColor = null // リップル削除 @@ -303,7 +333,7 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( // ★重要: タブの生成完了を待ってから背景を適用 (postを使用) categoryTab.post { - applyThemeToTabs(categoryTab, backgroundColor) + applyThemeToTabs(categoryTab, effectiveBackgroundColor) } // 4. Mode Tab (Bottom Bar) の全体設定 @@ -315,29 +345,114 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( // ★重要: クリッピング無効化と遅延適用 disableClipping(modeTab) modeTab.post { - applyThemeToTabs(modeTab, backgroundColor) + applyThemeToTabs(modeTab, effectiveBackgroundColor) } // 5. 機能キー (Return/Delete) のニューモーフィズム設定 - val keyRadius = dpToPx(25).toFloat() - returnButton.background = getTabNeumorphDrawable(keyBackgroundColor, keyRadius) - deleteButton.background = getTabNeumorphDrawable(keyBackgroundColor, keyRadius) + if (!keyboardSkinId.isDefault()) { + KeyboardSkinViewStyler.applyKey( + returnButton, + keyboardSkinId, + KeyboardElementRole.ACTION, + keyboardSkinMotionMode, + ) + KeyboardSkinViewStyler.applyKey( + deleteButton, + keyboardSkinId, + KeyboardElementRole.MODIFIER, + keyboardSkinMotionMode, + ) + } else { + KeyboardSkinViewStyler.clearTransientStyle(returnButton) + KeyboardSkinViewStyler.clearTransientStyle(deleteButton) + val keyRadius = dpToPx( + (KeyboardSkinDrawableFactory.keyCornerRadiusDp(keyboardSkinId) * 2f).toInt() + ).toFloat() + returnButton.background = getTabNeumorphDrawable(effectiveKeyBackgroundColor, keyRadius) + deleteButton.background = getTabNeumorphDrawable(effectiveKeyBackgroundColor, keyRadius) + } val p = dpToPx(8) returnButton.setPadding(p, p, p, p) deleteButton.setPadding(p, p, p, p) - returnButton.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN) - deleteButton.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN) + returnButton.setColorFilter( + builtInPalette?.actionKeyTextColor ?: effectiveIconColor, + PorterDuff.Mode.SRC_IN, + ) + deleteButton.setColorFilter( + builtInPalette?.specialKeyTextColor ?: effectiveIconColor, + PorterDuff.Mode.SRC_IN, + ) if (currentMode == SymbolMode.CLIPBOARD) { buildCategoryTabs() } symbolAdapter.setThemeColors( - textColor = iconColor, - highlightColor = selectedIconColor + textColor = effectiveIconColor, + highlightColor = effectiveSelectedIconColor + ) + } + + /** Restores the layout/resource-driven appearance after leaving a built-in skin. */ + fun resetKeyboardTheme() { + if (keyboardSkinId.isDefault() && !isCustomThemeApplied) return + keyboardSkinId = KeyboardSkinRef.DEFAULT + keyboardSkinMotionMode = KeyboardSkinMotionMode.FULL + liquidGlassEnable = false + isCustomThemeApplied = false + themeBackgroundColor = Color.WHITE + themeIconColor = ContextCompat.getColor( + context, + com.kazumaproject.core.R.color.keyboard_icon_color, + ) + themeSelectedIconColor = ContextCompat.getColor( + context, + com.kazumaproject.core.R.color.enter_key_bg, + ) + themeKeyBackgroundColor = ContextCompat.getColor( + context, + com.kazumaproject.core.R.color.keyboard_bg, + ) + + background = null + val tabBackground = ColorStateList.valueOf( + ContextCompat.getColor( + context, + com.kazumaproject.core.R.color.suggestion_view_bg, + ) + ) + val tabIconColors = ContextCompat.getColorStateList( + context, + com.kazumaproject.core.R.color.tab_icon_color_selector, + ) + categoryTab.backgroundTintList = tabBackground + modeTab.backgroundTintList = tabBackground + categoryTab.tabIconTint = tabIconColors + modeTab.tabIconTint = tabIconColors + categoryTab.setTabTextColors(themeIconColor, themeSelectedIconColor) + categoryTab.setSelectedTabIndicatorColor(Color.TRANSPARENT) + modeTab.setSelectedTabIndicatorColor(Color.TRANSPARENT) + + val defaultIconColor = ContextCompat.getColor( + context, + com.kazumaproject.core.R.color.tab_unselected, + ) + listOf(returnButton, deleteButton).forEach { button -> + KeyboardSkinViewStyler.clearTransientStyle(button) + button.setBackgroundResource( + com.kazumaproject.core.R.drawable.symbol_keyboard_buttons_bg + ) + button.setColorFilter(defaultIconColor, PorterDuff.Mode.SRC_IN) + } + + symbolAdapter.setThemeColors( + textColor = themeIconColor, + highlightColor = themeSelectedIconColor, ) + buildModeTabs() + buildCategoryTabs() } /** @@ -370,8 +485,21 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( } // 背景を設定 - val radius = dpToPx(8).toFloat() - tabView.background = getTabNeumorphDrawable(baseColor, radius) + if (!keyboardSkinId.isDefault()) { + KeyboardSkinViewStyler.applyKey( + tabView, + keyboardSkinId, + KeyboardElementRole.TOOLBAR, + keyboardSkinMotionMode, + stableKey = i, + ) + } else { + KeyboardSkinViewStyler.clearTransientStyle(tabView) + val radius = dpToPx( + KeyboardSkinDrawableFactory.keyCornerRadiusDp(keyboardSkinId).toInt() + ).toFloat() + tabView.background = getTabNeumorphDrawable(baseColor, radius) + } // パディング調整 (Drawable内のpaddingとは別に、Viewのコンテンツ位置調整) // TenKeyのロジックではDrawable自体がpaddingを持つため、View自体のpaddingは少なめでOK @@ -388,6 +516,15 @@ class CustomSymbolKeyboardView @JvmOverloads constructor( * TenKeyの getDynamicNeumorphDrawable と同等の実装 */ private fun getTabNeumorphDrawable(@ColorInt baseColor: Int, radius: Float): Drawable { + if (!keyboardSkinId.isDefault()) { + return KeyboardSkinDrawableFactory.createKeyDrawable( + context = context, + skinId = keyboardSkinId, + baseColor = baseColor, + cornerRadiusDp = radius / resources.displayMetrics.density, + ) + } + // 1. 色の計算 (TenKeyと同じ係数を使用) // ハイライト色: 明るくする (1.2f) val highlightColor = manipulateColor(baseColor, 1.2f) diff --git a/tabletkey/src/main/java/com/kazumaproject/tabletkey/TabletKeyboardView.kt b/tabletkey/src/main/java/com/kazumaproject/tabletkey/TabletKeyboardView.kt index 7d4f38e98..5942458b7 100644 --- a/tabletkey/src/main/java/com/kazumaproject/tabletkey/TabletKeyboardView.kt +++ b/tabletkey/src/main/java/com/kazumaproject/tabletkey/TabletKeyboardView.kt @@ -26,6 +26,18 @@ import androidx.core.view.isVisible import androidx.core.widget.ImageViewCompat import com.google.android.material.color.DynamicColors import com.google.android.material.textview.MaterialTextView +import com.kazumaproject.core.data.keyboard.KeyboardElementRole +import com.kazumaproject.core.data.keyboard.KeyboardSkinCatalog +import com.kazumaproject.core.data.keyboard.KeyboardSkinDrawableFactory +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.KeyboardSkinMotionMode +import com.kazumaproject.core.data.keyboard.KeyboardSkinRendererRegistry +import com.kazumaproject.core.data.keyboard.KeyboardSkinViewStyler +import com.kazumaproject.core.data.keyboard.KeyboardSurfaceRole +import com.kazumaproject.core.data.keyboard.isDefault +import com.kazumaproject.core.data.keyboard.resolvedOrDefault +import com.kazumaproject.core.data.keyboard.resolveKeyboardSkinPalette import com.kazumaproject.core.data.tablet.TabletCapsLockState import com.kazumaproject.core.domain.extensions.hide import com.kazumaproject.core.domain.extensions.layoutXPosition @@ -310,6 +322,8 @@ class TabletKeyboardView @JvmOverloads constructor( // Theme Variables (Initialized with defaults) private var themeMode: String = "default" + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var keyboardSkinMotionMode: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL private var isNightMode: Boolean = false private var isDynamicColorEnabled: Boolean = false private var customBgColor: Int = Color.WHITE @@ -403,10 +417,14 @@ class TabletKeyboardView @JvmOverloads constructor( customBorderEnable: Boolean, customBorderColor: Int, liquidGlassKeyAlphaEnable: Int, - borderWidth: Int + borderWidth: Int, + keyboardSkin: String = KeyboardSkinId.DEFAULT.preferenceValue, + keyboardSkinMotion: String = KeyboardSkinMotionMode.FULL.preferenceValue, ) { // メンバ変数に代入 this.themeMode = themeMode + this.keyboardSkinId = KeyboardSkinRef.fromPreference(keyboardSkin).resolvedOrDefault() + this.keyboardSkinMotionMode = KeyboardSkinMotionMode.fromPreference(keyboardSkinMotion) // Int型の currentNightMode から Boolean型の isNightMode を判定 this.isNightMode = (currentNightMode == Configuration.UI_MODE_NIGHT_YES) @@ -426,23 +444,39 @@ class TabletKeyboardView @JvmOverloads constructor( LayoutInflater.from(context) - when (this.themeMode) { - "default" -> { + when { + !this.keyboardSkinId.isDefault() -> { + applyBuiltInPopupColors() + applyBuiltInSkin() + } + + this.themeMode == "default" -> { + clearBuiltInSkinStyles() setBackgroundColor(Color.TRANSPARENT) setMaterialYouTheme() // resetLayoutを呼んでデフォルトの角丸背景などを再適用する resetLayout() } - "custom" -> { + this.themeMode == "custom" -> { + clearBuiltInSkinStyles() + val palette = resolveKeyboardSkinPalette( + context = context, + themeMode = this.themeMode, + customBackgroundColor = customBgColor, + customKeyColor = customKeyColor, + customSpecialKeyColor = customSpecialKeyColor, + customKeyTextColor = customKeyTextColor, + customSpecialKeyTextColor = customSpecialKeyTextColor, + ) setCustomThemePopup() setFullCustomNeumorphismTheme( - backgroundColor = customBgColor, - normalKeyColor = customKeyColor, - specialKeyColor = customSpecialKeyColor, - normalKeyTextColor = customKeyTextColor, - specialKeyTextColor = customSpecialKeyTextColor, - borderWidth = borderWidth + backgroundColor = palette.backgroundColor, + normalKeyColor = palette.normalKeyColor, + specialKeyColor = palette.specialKeyColor, + normalKeyTextColor = palette.normalKeyTextColor, + specialKeyTextColor = palette.specialKeyTextColor, + borderWidth = borderWidth, ) } @@ -466,7 +500,7 @@ class TabletKeyboardView @JvmOverloads constructor( borderWidth: Int ) { val density = context.resources.displayMetrics.density - val radius = 8f * density // 角丸の半径 (8dp) + val radius = KeyboardSkinDrawableFactory.keyCornerRadiusDp(KeyboardSkinId.DEFAULT) * density // 1. 全体の背景色を設定 if (liquidGlassEnable) { @@ -576,6 +610,67 @@ class TabletKeyboardView @JvmOverloads constructor( return stateListDrawable } + private fun applyBuiltInSkin() { + val renderer = KeyboardSkinRendererRegistry.rendererFor(keyboardSkinId) + background = renderer.createSurfaceDrawable(context, KeyboardSurfaceRole.DECK) + binding.apply { + val characterKeys = listOf( + key1, key2, key3, key4, key5, key6, key7, key8, key9, key10, + key11, key12, key13, key14, key15, key16, key17, key18, key19, key20, + key21, key22, key23, key24, key25, key26, key27, key28, key29, key30, + key31, key32, key33, key34, key35, key36, key37, key38, key39, key40, + key41, key42, key43, key44, key45, key46, key47, key48, key49, key50, + key51, key52, key53, key54, key55, + ) + val modifierKeys = listOf( + keyKigou, keyPrevious, keySwitchKeyMode, keyLeftCursor, + keyRightCursor, keyDelete, + ) + characterKeys.forEach { view -> + KeyboardSkinViewStyler.applyKey( + view, keyboardSkinId, KeyboardElementRole.CHARACTER, keyboardSkinMotionMode + ) + } + modifierKeys.forEach { view -> + KeyboardSkinViewStyler.applyKey( + view, keyboardSkinId, KeyboardElementRole.MODIFIER, keyboardSkinMotionMode + ) + } + KeyboardSkinViewStyler.applyKey( + keySpace, keyboardSkinId, KeyboardElementRole.SPACE, keyboardSkinMotionMode + ) + KeyboardSkinViewStyler.applyKey( + keyEnter, keyboardSkinId, KeyboardElementRole.ACTION, keyboardSkinMotionMode + ) + } + } + + private fun clearBuiltInSkinStyles() { + binding.root.apply { + for (index in 0 until childCount) { + KeyboardSkinViewStyler.clearTransientStyle(getChildAt(index)) + } + } + } + + private fun applyBuiltInPopupColors() { + val spec = KeyboardSkinCatalog.specFor(keyboardSkinId) + listOf( + bubbleViewActive, bubbleViewLeft, bubbleViewTop, + bubbleViewRight, bubbleViewBottom, bubbleViewCenter, + ).forEach { it.setBubbleColor(spec.palette.specialKeyColor) } + listOf( + popTextActive, popTextLeft, popTextTop, + popTextRight, popTextBottom, popTextCenter, + ).forEach { textView -> + textView.setTextColor(spec.palette.specialKeyTextColor) + textView.typeface = android.graphics.Typeface.create( + spec.typography.familyName, + if (spec.typography.bold) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL, + ) + } + } + /** * 色の明るさを調整するヘルパー関数 */ diff --git a/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt b/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt index ea40e8575..2128a66b9 100644 --- a/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt +++ b/tenkey/src/main/java/com/kazumaproject/tenkey/TenKey.kt @@ -47,6 +47,19 @@ import com.kazumaproject.core.domain.state.TwoStateNumberReturnTarget import com.kazumaproject.core.domain.state.toInputMode import com.kazumaproject.core.domain.state.toTwoStateNumberReturnTargetOrNull import com.kazumaproject.core.data.popup.PopupViewStyle +import com.kazumaproject.core.data.keyboard.KeyboardElementRole +import com.kazumaproject.core.data.keyboard.KeyboardSkinDrawableFactory +import com.kazumaproject.core.data.keyboard.KeyboardSkinId +import com.kazumaproject.core.data.keyboard.KeyboardSkinRef +import com.kazumaproject.core.data.keyboard.isDefault +import com.kazumaproject.core.data.keyboard.resolvedOrDefault +import com.kazumaproject.core.data.keyboard.KeyboardSkinMotionMode +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupKind +import com.kazumaproject.core.data.keyboard.KeyboardSkinPopupRenderer +import com.kazumaproject.core.data.keyboard.KeyboardSkinRendererRegistry +import com.kazumaproject.core.data.keyboard.KeyboardSkinViewStyler +import com.kazumaproject.core.data.keyboard.KeyboardSurfaceRole +import com.kazumaproject.core.data.keyboard.resolveKeyboardSkinPalette import com.kazumaproject.core.domain.flick.FlickDirection as CoreFlickDirection import com.kazumaproject.core.domain.flick.FlickGestureMath import com.kazumaproject.core.domain.flick.FlickTextPreviewEmitter @@ -305,6 +318,8 @@ class TenKey(context: Context, attributeSet: AttributeSet) : // Theme Variables (Initialized with defaults) private var themeMode: String = "default" + private var keyboardSkinId: KeyboardSkinRef = KeyboardSkinRef.DEFAULT + private var keyboardSkinMotionMode: KeyboardSkinMotionMode = KeyboardSkinMotionMode.FULL private var isNightMode: Boolean = false private var isDynamicColorEnabled: Boolean = false private var customBgColor: Int = Color.WHITE @@ -600,6 +615,16 @@ class TenKey(context: Context, attributeSet: AttributeSet) : } fun applyPopupViewStyle(style: PopupViewStyle) { + if (KeyboardSkinPopupRenderer.isFixedCupertino(keyboardSkinId)) { + val popup = checkNotNull(KeyboardSkinPopupRenderer.specFor(keyboardSkinId)) + popupViewStyle = PopupViewStyle( + sizeScalePercent = 100, + textSizeSp = popup.flickTextSizeSp, + ) + applyPopupTextSize() + applyPopupColors() + return + } popupViewStyle = PopupViewStyle( sizeScalePercent = style.sizeScalePercent.coerceIn(50, 200), textSizeSp = style.textSizeSp.coerceIn(8f, 48f), @@ -612,6 +637,10 @@ class TenKey(context: Context, attributeSet: AttributeSet) : private fun applyPopupTextSize() { if (!::popTextActive.isInitialized) return + val fixedTextSize = KeyboardSkinPopupRenderer.popupTextSizeSp( + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_STANDARD, + ) listOf( popTextActive, popTextLeft, @@ -622,8 +651,15 @@ class TenKey(context: Context, attributeSet: AttributeSet) : ).forEach { textView -> textView.setTextSize( TypedValue.COMPLEX_UNIT_SP, - popupViewStyle.textSizeSp.coerceIn(8f, 48f) + (fixedTextSize ?: popupViewStyle.textSizeSp).coerceIn(8f, 48f) ) + if (fixedTextSize != null) { + KeyboardSkinPopupRenderer.applyTextStyle( + textView, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_STANDARD, + ) + } } } @@ -902,10 +938,14 @@ class TenKey(context: Context, attributeSet: AttributeSet) : customBorderEnable: Boolean, customBorderColor: Int, liquidGlassKeyAlphaEnable: Int, - borderWidth: Int + borderWidth: Int, + keyboardSkin: String = KeyboardSkinId.DEFAULT.preferenceValue, + keyboardSkinMotion: String = KeyboardSkinMotionMode.FULL.preferenceValue, ) { // メンバ変数に代入 this.themeMode = themeMode + this.keyboardSkinId = KeyboardSkinRef.fromPreference(keyboardSkin).resolvedOrDefault() + this.keyboardSkinMotionMode = KeyboardSkinMotionMode.fromPreference(keyboardSkinMotion) // Int型の currentNightMode から Boolean型の isNightMode を判定 this.isNightMode = (currentNightMode == Configuration.UI_MODE_NIGHT_YES) @@ -925,8 +965,20 @@ class TenKey(context: Context, attributeSet: AttributeSet) : val inflater = LayoutInflater.from(context) - when (this.themeMode) { - "default" -> { + when { + !this.keyboardSkinId.isDefault() -> { + KeyboardSkinPopupRenderer.specFor(this.keyboardSkinId)?.let { popup -> + popupViewStyle = PopupViewStyle(100, popup.flickTextSizeSp) + } + buildCustomPopupViews(inflater) + applyBuiltInSkin() + applyPopupTextSize() + applyPopupColors() + applyBuiltInPopupColors() + } + + this.themeMode == "default" -> { + clearBuiltInSkinStyles() setPopupViewTheme( isDynamicColorsEnable = isDynamicColorEnabled, isDarkMode = isNightMode, @@ -937,14 +989,19 @@ class TenKey(context: Context, attributeSet: AttributeSet) : } - "custom" -> { - val activeBinding = PopupLayoutActiveBinding.inflate(inflater, null, false) - popupWindowActive = PopupWindow( - activeBinding.root, - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT, - false + this.themeMode == "custom" -> { + clearBuiltInSkinStyles() + val palette = resolveKeyboardSkinPalette( + context = context, + themeMode = this.themeMode, + customBackgroundColor = customBgColor, + customKeyColor = customKeyColor, + customSpecialKeyColor = customSpecialKeyColor, + customKeyTextColor = customKeyTextColor, + customSpecialKeyTextColor = customSpecialKeyTextColor, ) + val activeBinding = PopupLayoutActiveBinding.inflate(inflater, null, false) + popupWindowActive = PopupWindow(activeBinding.root, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, false) bubbleViewActive = activeBinding.bubbleLayoutActive popTextActive = activeBinding.popupTextActive val activeColor = manipulateColor(customSpecialKeyColor, 1.2f) @@ -1010,12 +1067,12 @@ class TenKey(context: Context, attributeSet: AttributeSet) : popTextCenter.setTextColor(customSpecialKeyTextColor) setFullCustomNeumorphismTheme( - backgroundColor = customBgColor, - normalKeyColor = customKeyColor, - specialKeyColor = customSpecialKeyColor, - normalKeyTextColor = customKeyTextColor, - specialKeyTextColor = customSpecialKeyTextColor, - borderWidth = borderWidth + backgroundColor = palette.backgroundColor, + normalKeyColor = palette.normalKeyColor, + specialKeyColor = palette.specialKeyColor, + normalKeyTextColor = palette.normalKeyTextColor, + specialKeyTextColor = palette.specialKeyTextColor, + borderWidth = borderWidth, ) applyPopupTextSize() applyPopupColors() @@ -1051,7 +1108,7 @@ class TenKey(context: Context, attributeSet: AttributeSet) : borderWidth: Int ) { val density = context.resources.displayMetrics.density - val radius = 8f * density // 角丸の半径 (8dp) + val radius = KeyboardSkinDrawableFactory.keyCornerRadiusDp(KeyboardSkinId.DEFAULT) * density // 1. 全体の背景色を設定 if (liquidGlassEnable) { @@ -1220,6 +1277,159 @@ class TenKey(context: Context, attributeSet: AttributeSet) : return stateListDrawable } + private fun applyBuiltInSkin() { + val renderer = KeyboardSkinRendererRegistry.rendererFor(keyboardSkinId) + background = renderer.createSurfaceDrawable(context, KeyboardSurfaceRole.DECK) + binding.apply { + val characterKeys = listOf( + key1, key2, key3, key4, key5, key6, + key7, key8, key9, key11, key12, keySmallLetter, + ) + val modifierKeys = listOf( + keyReturn, keySoftLeft, keyDelete, keyMoveCursorRight, keySwitchKeyMode, + ) + characterKeys.forEach { view -> + KeyboardSkinViewStyler.applyKey( + view, + keyboardSkinId, + KeyboardElementRole.CHARACTER, + keyboardSkinMotionMode, + ) + } + modifierKeys.forEach { view -> + KeyboardSkinViewStyler.applyKey( + view, + keyboardSkinId, + KeyboardElementRole.MODIFIER, + keyboardSkinMotionMode, + ) + } + KeyboardSkinViewStyler.applyKey( + keySpace, + keyboardSkinId, + KeyboardElementRole.SPACE, + keyboardSkinMotionMode, + ) + KeyboardSkinViewStyler.applyKey( + keyEnter, + keyboardSkinId, + KeyboardElementRole.ACTION, + keyboardSkinMotionMode, + ) + val modifierDrawable = renderer.createKeyDrawable(context, KeyboardElementRole.MODIFIER) + val modifierTint = ColorStateList.valueOf( + com.kazumaproject.core.data.keyboard.KeyboardSkinCatalog + .specFor(keyboardSkinId).palette.specialKeyTextColor + ) + sideKeySymbolModeContainer.setKeyBackground(modifierDrawable) + sideKeySymbolModeContainer.setKeyTint(modifierTint) + sideKeySymbolModeContainer.setKeyDrawableAlpha(255) + } + } + + private fun clearBuiltInSkinStyles() { + binding.apply { + listOf( + key1, key2, key3, key4, key5, key6, key7, key8, key9, key11, key12, + keySmallLetter, keyReturn, keySoftLeft, keyDelete, keyMoveCursorRight, + keySpace, keyEnter, keySwitchKeyMode, + ).forEach(KeyboardSkinViewStyler::clearTransientStyle) + } + } + + private fun buildCustomPopupViews(inflater: LayoutInflater) { + val activeBinding = PopupLayoutActiveBinding.inflate(inflater, null, false) + popupWindowActive = PopupWindow(activeBinding.root, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, false) + bubbleViewActive = activeBinding.bubbleLayoutActive + popTextActive = activeBinding.popupTextActive + + val leftBinding = PopupLayoutBinding.inflate(inflater, null, false) + popupWindowLeft = PopupWindow(leftBinding.root, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, false) + bubbleViewLeft = leftBinding.bubbleLayout + popTextLeft = leftBinding.popupText + + val topBinding = PopupLayoutMaterialBinding.inflate(inflater, null, false) + popupWindowTop = PopupWindow(topBinding.root, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, false) + bubbleViewTop = topBinding.bubbleLayout + popTextTop = topBinding.popupText + + val rightBinding = PopupLayoutMaterialBinding.inflate(inflater, null, false) + popupWindowRight = PopupWindow(rightBinding.root, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, false) + bubbleViewRight = rightBinding.bubbleLayout + popTextRight = rightBinding.popupText + + val bottomBinding = PopupLayoutMaterialBinding.inflate(inflater, null, false) + popupWindowBottom = PopupWindow(bottomBinding.root, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, false) + bubbleViewBottom = bottomBinding.bubbleLayout + popTextBottom = bottomBinding.popupText + + val centerBinding = PopupLayoutMaterialBinding.inflate(inflater, null, false) + popupWindowCenter = PopupWindow(centerBinding.root, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, false) + bubbleViewCenter = centerBinding.bubbleLayout + popTextCenter = centerBinding.popupText + } + + private fun applyBuiltInPopupColors() { + if (!::bubbleViewActive.isInitialized) return + val renderer = KeyboardSkinRendererRegistry.rendererFor(keyboardSkinId) + val popupSpec = KeyboardSkinPopupRenderer.specFor(keyboardSkinId) + if (popupSpec != null) { + bubbleViewActive.setCustomBubbleDrawable( + renderer.createPopupDrawable( + context, + KeyboardSkinPopupKind.FLICK_DIRECTIONAL, + selected = true, + ) + ) + bubbleViewLeft.setCustomBubbleDrawable( + renderer.createPopupDrawable(context, KeyboardSkinPopupKind.FLICK_DIRECTIONAL) + ) + bubbleViewTop.setCustomBubbleDrawable( + renderer.createPopupDrawable(context, KeyboardSkinPopupKind.FLICK_DIRECTIONAL) + ) + bubbleViewRight.setCustomBubbleDrawable( + renderer.createPopupDrawable(context, KeyboardSkinPopupKind.FLICK_DIRECTIONAL) + ) + bubbleViewBottom.setCustomBubbleDrawable( + renderer.createPopupDrawable(context, KeyboardSkinPopupKind.FLICK_DIRECTIONAL) + ) + bubbleViewCenter.setCustomBubbleDrawable( + renderer.createPopupDrawable(context, KeyboardSkinPopupKind.FLICK_STANDARD) + ) + listOf( + popTextActive, popTextLeft, popTextTop, + popTextRight, popTextBottom, popTextCenter, + ).forEach { textView -> + KeyboardSkinPopupRenderer.applyTextStyle( + textView, + keyboardSkinId, + KeyboardSkinPopupKind.FLICK_STANDARD, + ) + } + return + } + val palette = com.kazumaproject.core.data.keyboard.KeyboardSkinCatalog + .specFor(keyboardSkinId).palette + listOf( + bubbleViewActive, bubbleViewLeft, bubbleViewTop, + bubbleViewRight, bubbleViewBottom, bubbleViewCenter, + ).forEach { + it.clearCustomBubbleDrawable() + it.setBubbleColor(palette.specialKeyColor) + } + listOf( + popTextActive, popTextLeft, popTextTop, + popTextRight, popTextBottom, popTextCenter, + ).forEach { textView -> + textView.setTextColor(palette.specialKeyTextColor) + textView.typeface = android.graphics.Typeface.create( + com.kazumaproject.core.data.keyboard.KeyboardSkinCatalog.specFor(keyboardSkinId) + .typography.familyName, + android.graphics.Typeface.BOLD, + ) + } + } + /** * 詳細な色指定によるニューモーフィズムテーマの適用 *