diff --git a/CHANGELOG.md b/CHANGELOG.md index 92c4a41b..ac455a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +## [0.0.4-beta.1] - 2026-10-04 + +第四个测试版:松开空格立即结束录音,已录音频继续完整转写;加入离线中英标点恢复,重新整理 App 首页与设置页。 + +- 修复 Streaming Paraformer 最后不足一块的已录音频没有解码,导致末尾丢字;松手只停止麦克风,排空 PCM 后再结束识别。取消仍丢弃本次语音。 +- 移除松手后的 300ms 实际录音,采集结束后即恢复原媒体音量;保留模型计算所需的纯静音尾垫,不再采集用户语音。 +- 新增 CT-Transformer INT8 本地标点模型,最终转写时恢复逗号与问号;“标点用空格代替”在标点恢复后生效,修复紧贴中文的英文标点漏转换,保留数字小数和网址。 +- PCM 批量拷贝、复用推理输入块;缓冲溢出报错,避免把缺字结果当成功上屏。 +- 重新设计 App 首页、偏好设置、键盘内设置面板和工具面板,浅色与暗色各一套。首页在设置完成前是三步引导(进度条、当前步骤高亮、底部按钮执行当前步骤),完成后收起为“已就绪”卡片和试打区。偏好设置改为分组卡片列表,每项有专属图标;键盘内设置首屏放四个快捷开关和外观、键盘高度。强调色只用于可交互元素和开启状态,图标、箭头和状态标签改为中性色,开关从绿色改为强调色。首页文案统一为中文,不再出现半中半英。键盘按键本身保持既有设计。详见 `docs/APP_UI_SPEC.md`。 +- 按键触感改为系统键盘同款短击(`KEYBOARD_TAP`),删除键手势和按住空格进入语音改为单下清脆点击,不再使用余震较长的长按震动。 +- 移除按键皮肤(自定义强调色、圆角、不透明度、按键字号)和单手模式。升级后已保存的这些值不再生效,键盘恢复默认外观和全宽布局;导出的数据不再包含这些字段,导入旧备份时忽略。 +- 新增真实 native 音频到编辑器的端到端验收和可重复脚本,保存 JSON、日志、截图与布局 XML。详见 `docs/BETA4_VOICE.md`。 + + ## [0.0.3-beta.1] - 2026-10-04 第三个公开测试版:空格滑动移光标、密码管理器的自动填充直接出现在键盘上、选词后的表情联想、26 键字母键上的数字和符号(上滑输入),以及语音输入的去语气词和“标点用空格代替”。仍是测试版,欢迎在 Issues 反馈问题。从 0.0.2-beta.1 覆盖安装即可,用户数据保留;升级后首次启动会重新部署词典,这段时间候选由备用词库提供。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aeebf175..e2f8e928 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,7 @@ 2. 执行 `git lfs pull`,确认语音模型和 AAR 不是文本指针。 3. 先阅读 [README.md](README.md)、[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) 和 [docs/TEST_ARCHITECTURE.md](docs/TEST_ARCHITECTURE.md)。 +4. 修改 App 界面时按 [App 界面开发规范](docs/APP_UI_SPEC.md) 实现尺寸、间距、对齐、点击区及大字体适配;先复用现有令牌和资源,不自行引入一套数值。 ## 分支与提交 @@ -47,6 +48,8 @@ python3 scripts/release_check.py check 如果改动了布局或 Insets,再运行 `visual_check.ps1`,并在 PR 中说明测试设备的 Android 版本、窗口宽度和是否使用浮动键盘。 +App 界面同时按 `docs/APP_UI_SPEC.md` 检查普通屏、320dp 大字体和宽屏;运行 +`scripts/beta4_e2e.py` 并保存截图、UI XML、结果 JSON 和日志作为可重复的验收工件。 ## Pull Request diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7311c725..a16eddfe 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -11,6 +11,7 @@ | Rime 基础数据 | `app/src/main/assets/rime/` | Rime / luna-pinyin / essay 等上游数据 | 以各目录 `AUTHORS` / 上游许可为准 | 已保留上游 AUTHORS;发布前不得删除这些归属文件 | | sherpa-onnx Android runtime | `app/libs/sherpa-onnx-1.13.6.aar` | https://github.com/k2-fsa/sherpa-onnx | Apache-2.0 | APK 包含 `assets/licenses/sherpa-onnx-Apache-2.0.txt` | | 中英双语 Streaming Paraformer INT8 语音模型 | `app/src/main/assets/models/voice/bilingual-paraformer/` | https://huggingface.co/csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en | Apache-2.0 | 只打包 `encoder.int8.onnx`、`decoder.int8.onnx` 和 `tokens.txt`;APK 包含 `assets/licenses/paraformer-model-Apache-2.0.txt`,`manifest.json` 固定模型 ID/版本与文件哈希 | +| CT-Transformer 中英标点 INT8 模型 | `app/src/main/assets/models/voice/punctuation/` | https://modelscope.cn/models/iic/punc_ct-transformer_zh-cn-common-vocab272727-pytorch 、https://github.com/k2-fsa/sherpa-onnx/releases/tag/punctuation-models | Apache-2.0 | 固定 `2024-04-12-int8` 导出,清单包含文件哈希;APK 附 `ct-transformer-Apache-2.0.txt` | | Microsoft Fluent Emoji | `app/src/main/assets/emoji/fluent/` | https://github.com/microsoft/fluentui-emoji | MIT | 仅打包当前实际使用的基础情绪表情资源;APK 包含 `assets/licenses/fluent-emoji-MIT.txt` | ## 发布前检查 diff --git a/VERSION b/VERSION index 4c98a1d8..47875e82 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.3-beta.1 +0.0.4-beta.1 diff --git a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt index 4b0217fb..1a3031ba 100644 --- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt @@ -23,7 +23,6 @@ import llc.slacker.openime.data.CustomSymbolRepository import llc.slacker.openime.data.QuickPhraseRepository import llc.slacker.openime.floating.FloatingWindowController import llc.slacker.openime.keyboard.ImeKeyboardView -import llc.slacker.openime.theme.AccentPalette import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeSurfacePolicy @@ -448,10 +447,9 @@ class AuditInteractionInstrumentedTest { @Test fun settingsSlidersExposeCurrentValuesToTouchAndAccessibility() = withKeyboard { harness, _, keyboard -> harness.awaitMain { - // Corner radius, opacity and key font size live on the skin sub-page. - keyboard.showPanel(Panel.SKIN_SETTINGS) + keyboard.showPanel(Panel.SETTINGS) val settings = keyboard.findViewWithTag("settings-panel") - listOf("圆角", "不透明度", "按键字号").forEach { label -> + listOf("键盘高度", "浮动宽度", "浮动透明度").forEach { label -> val slider = settings.findViewWithTag("settings-slider:$label") assertTrue("$label must keep a 48dp touch target", slider.minimumHeight >= keyboard.resources.displayMetrics.density * 48f) assertTrue("$label must expose its current value", slider.contentDescription.toString().contains(label)) @@ -545,30 +543,6 @@ class AuditInteractionInstrumentedTest { } } - @Test - fun customAccentControlExposesSelectionAndNoPresetClaimsIt() = withKeyboard { harness, _, keyboard -> - harness.awaitMain { - keyboard.setSkin(96, 10, 18, "#123456") - keyboard.showPanel(Panel.SKIN_SETTINGS) - val custom = keyboard.findViewWithTag("accent-custom") - assertTrue(custom.contentDescription.toString().contains("已选中")) - assertTrue("The custom row must stay a 48dp target", custom.minimumHeight >= keyboard.resources.displayMetrics.density * 48f) - AccentPalette.presets.forEach { (hex, label) -> - assertNull( - "$label must not look selected while a custom accent is active", - keyboard.findViewWithTag("accent-selected-mark:$hex"), - ) - } - // And the other way round: a preset takes the selection from the custom row. - keyboard.setSkin(96, 10, 18, "#1D9BF0") - keyboard.showPanel(Panel.SKIN_SETTINGS) - assertTrue( - keyboard.findViewWithTag("accent-custom").contentDescription.toString().contains("未选中"), - ) - true - } - } - @Test fun persistedSettingsSnapshotAppliesVisualAndInteractionStateTogether() = withKeyboard { harness, _, keyboard -> harness.awaitMain { @@ -579,53 +553,20 @@ class AuditInteractionInstrumentedTest { haptic = true, popup = true, fuzzy = true, - opacity = 92, - radius = 14, - fontSize = 19, - primaryColor = "#123456", - ) - keyboard.showPanel(Panel.SKIN_SETTINGS) - assertTrue( - "The persisted custom accent must be what the skin page reports as selected", - keyboard.findViewWithTag("accent-custom").contentDescription.toString().contains("已选中"), ) keyboard.showPanel(Panel.FUZZY_SETTINGS) val toggle = keyboard.findViewWithTag("toggle") assertTrue(toggle.contentDescription.toString().contains("已开启")) // An enabled switch is painted with the accent, so this proves the - // persisted colour reached the theme, not just the settings text. + // persisted appearance reached the theme, not just the settings text. assertEquals( - Color.parseColor("#123456"), - (toggle.background as GradientDrawable).color?.defaultColor, + ImeTheme.IOS.tokens(ImeAppearance.LIGHT).primary, + switchTrackColor(toggle), ) true } } - @Test - fun presetAccentSelectionHasAVisibleNonColorMark() = withKeyboard { harness, _, keyboard -> - harness.awaitMain { - keyboard.setSkin(96, 10, 18, "#1D9BF0") - keyboard.showPanel(Panel.SKIN_SETTINGS) - assertTrue( - "Selected accent must expose a visible check mark in addition to color", - keyboard.findViewWithTag("accent-selected-mark:#1D9BF0") != null, - ) - true - } - } - - @Test - fun presetAccentSwatchesExposeAVisibleKeyboardFocusState() = withKeyboard { harness, _, keyboard -> - harness.awaitMain { - keyboard.showPanel(Panel.SKIN_SETTINGS) - val swatch = keyboard.findViewWithTag("accent-swatch") - assertTrue("Accent swatches must be keyboard-focusable", swatch.isFocusable) - assertTrue("Accent swatches must expose a focusable background", swatch.background is StateListDrawable) - true - } - } - @Test fun emptyCandidateOverflowActionIsDisabledUntilCandidatesExist() = withKeyboard { harness, _, keyboard -> harness.awaitMain { @@ -712,9 +653,12 @@ class AuditInteractionInstrumentedTest { keyboard.showPanel(Panel.TOOLS) val card = keyboard.findViewWithTag("tool:表情") assertTrue("Tool card must be keyboard-focusable", card.isFocusable) - assertTrue("Tool card must expose pressed and focus feedback", card.background is StateListDrawable) + // The column is the target; its icon tile mirrors the column's state. + val tile = (card as ViewGroup).getChildAt(0) + assertTrue("Tool tile must expose pressed and focus feedback", tile.background is StateListDrawable) + assertTrue("Tool tile must follow the card's pressed state", tile.isDuplicateParentStateEnabled) assertEquals("表情", card.contentDescription) - val label = (card as ViewGroup).getChildAt(1) + val label = card.getChildAt(1) assertTrue( "Visual tool label must not create a duplicate node", label.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO, @@ -805,19 +749,23 @@ class AuditInteractionInstrumentedTest { val panel = keyboard.findViewWithTag("fuzzy-settings-panel") val row = panel.findViewWithTag("setting-row") val toggle = panel.findViewWithTag("toggle") - val icon = panel.findViewWithTag("setting-icon") + // The keyboard's compact rows carry no icon; the preferences page's do. + val icon: View? = panel.findViewWithTag("setting-icon") assertTrue("Settings row must be keyboard-focusable", row.isFocusable) assertTrue("Settings row must expose its current state", row.contentDescription.toString().contains("已关闭")) assertTrue("The visual switch must not create a duplicate accessibility node", toggle.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) assertTrue( - "Visual switch track minimum must be wider than tall", - toggle.minimumWidth > toggle.minimumHeight, + "Visual switch track must be wider than tall", + ImeGeometryTokens.SWITCH_WIDTH_DP > ImeGeometryTokens.SWITCH_HEIGHT_DP, ) - assertTrue("Setting icon must remain decorative", icon.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO) - val offColor = (toggle.background as GradientDrawable).color?.defaultColor + assertTrue("Switch keeps a 48dp touch target", toggle.minimumHeight >= keyboard.resources.displayMetrics.density * 48f) + if (icon != null) { + assertTrue("Setting icon must remain decorative", icon.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO) + } + val offColor = switchTrackColor(toggle) assertTrue(row.performClick()) assertTrue(row.contentDescription.toString().contains("已开启")) - val onColor = (toggle.background as GradientDrawable).color?.defaultColor + val onColor = switchTrackColor(toggle) assertTrue("Toggle background must follow its enabled state", offColor != onColor) true } @@ -1446,4 +1394,8 @@ class AuditInteractionInstrumentedTest { event.recycle() } } + + /** The switch track is painted inside its 48dp target by an inset wrapper. */ + private fun switchTrackColor(toggle: View): Int? = + ((toggle.background as android.graphics.drawable.InsetDrawable).drawable as GradientDrawable).color?.defaultColor } diff --git a/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt index a785dcc3..fb5c75cf 100644 --- a/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/ClipboardRetentionInstrumentedTest.kt @@ -355,6 +355,5 @@ class ClipboardRetentionInstrumentedTest { override fun onHapticChanged(enabled: Boolean) = Unit override fun onPopupChanged(enabled: Boolean) = Unit override fun onFuzzyChanged(enabled: Boolean) = Unit - override fun onSkinChanged(opacity: Int, radius: Int, fontSize: Int, primaryColor: String) = Unit } } diff --git a/app/src/androidTest/java/llc/slacker/openime/SetupAccessibilityInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SetupAccessibilityInstrumentedTest.kt index 49ed7fb1..76c02b3a 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SetupAccessibilityInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SetupAccessibilityInstrumentedTest.kt @@ -23,18 +23,19 @@ class SetupAccessibilityInstrumentedTest { R.id.open_app_settings, R.id.voice_permission, ).forEach { id -> - val card = activity.findViewById(id) + val card = activity.findViewById(id) assertTrue("Setup card must be clickable", card.isClickable) assertTrue("Setup card must be focusable", card.isFocusable) assertNotNull("Setup card must describe its current action", card.contentDescription) if (android.os.Build.VERSION.SDK_INT >= 30) { assertNotNull("Setup card must expose its current state", card.stateDescription) } - for (index in 0 until card.childCount) { + val children = (card as? ViewGroup)?.let { group -> (0 until group.childCount).map(group::getChildAt) }.orEmpty() + children.forEach { child -> assertEquals( "Setup card visuals must not create duplicate nodes", View.IMPORTANT_FOR_ACCESSIBILITY_NO, - card.getChildAt(index).importantForAccessibility, + child.importantForAccessibility, ) } } diff --git a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt index fac0c870..b00c6af0 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt @@ -453,7 +453,9 @@ class SpaceCursorAndLetterHintsInstrumentedTest { var found: View? = null fun walk(view: View) { if (found != null) return - if (view.tag == "toggle" && view.contentDescription?.startsWith("$label,") == true) found = view + // The keyboard panel shows some settings as quick-toggle tiles. + val switchLike = view.tag == "toggle" || view.tag == "quick-tile" + if (switchLike && view.contentDescription?.startsWith("$label,") == true) found = view if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i)) } walk(this) diff --git a/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt index fba18410..814beff7 100644 --- a/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/TextEditControlsInstrumentedTest.kt @@ -227,6 +227,5 @@ class TextEditControlsInstrumentedTest { override fun onHapticChanged(enabled: Boolean) = Unit override fun onPopupChanged(enabled: Boolean) = Unit override fun onFuzzyChanged(enabled: Boolean) = Unit - override fun onSkinChanged(opacity: Int, radius: Int, fontSize: Int, primaryColor: String) = Unit } } diff --git a/app/src/debug/assets/voice-beta4.wav b/app/src/debug/assets/voice-beta4.wav new file mode 100644 index 00000000..6ef8fc29 Binary files /dev/null and b/app/src/debug/assets/voice-beta4.wav differ diff --git a/app/src/debug/java/llc/slacker/openime/E2ETestReceiver.kt b/app/src/debug/java/llc/slacker/openime/E2ETestReceiver.kt index f34e3900..a740b34b 100644 --- a/app/src/debug/java/llc/slacker/openime/E2ETestReceiver.kt +++ b/app/src/debug/java/llc/slacker/openime/E2ETestReceiver.kt @@ -16,6 +16,11 @@ class E2ETestReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val command = intent.getStringExtra(EXTRA_COMMAND) ?: return + if (command == "voice-audio-e2e" || command == "voice-punctuation-e2e") { + val pending = goAsync() + VoiceAudioE2E.replay(context.applicationContext, command == "voice-punctuation-e2e", pending::finish) + return + } if (command.startsWith("clipboard64:")) { runCatching { val text = String( diff --git a/app/src/debug/java/llc/slacker/openime/VoiceAudioE2E.kt b/app/src/debug/java/llc/slacker/openime/VoiceAudioE2E.kt new file mode 100644 index 00000000..18f56e43 --- /dev/null +++ b/app/src/debug/java/llc/slacker/openime/VoiceAudioE2E.kt @@ -0,0 +1,92 @@ +package llc.slacker.openime + +import android.content.Context +import android.os.Handler +import android.os.Looper +import llc.slacker.openime.hotword.HotwordRuntime +import llc.slacker.openime.voice.VoiceCorrectionRepository +import llc.slacker.openime.voice.VoiceModelLifecycleManager +import llc.slacker.openime.voice.VoiceRecognitionEvents +import org.json.JSONObject +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest + +/** Debug-only real PCM -> native model -> actual lab InputConnection replay. + * The fixture and hook are absent from release builds. No fake ASR callbacks. + */ +internal object VoiceAudioE2E { + fun replay(context: Context, punctuationOnly: Boolean, finished: () -> Unit) { + val service = LocalVoiceImeService.activeInstance + val reportFile = File(context.getExternalFilesDir(null), "beta4-audio-e2e.json") + reportFile.delete() + val handler = Handler(Looper.getMainLooper()) + val editor = service?.currentInputEditorInfo + if (service == null || editor?.packageName != context.packageName || editor.fieldId != R.id.lab_single) { + reportFile.writeText(JSONObject().put("error", "live lab editor required").toString()) + finished(); return + } + service.onVoiceSessionStarted(true) + Thread({ + val report = JSONObject().put("passed", false) + try { + val manager = service.javaClass.getDeclaredField("voiceLifecycle").apply { isAccessible = true } + .get(service) as VoiceModelLifecycleManager + val runtime = checkNotNull(manager.awaitRuntime()) + var lastPartial = "" + runtime.openSession("zh-CN", object : VoiceRecognitionEvents { + override fun onPartial(text: String) { + lastPartial = text + handler.post { service.onVoicePartial(text) } + } + override fun onFinal(text: String) = Unit + override fun onRms(rms: Float) = Unit + override fun onError(message: String) { error(message) } + override fun onReady() = Unit + }).use { session -> + val raw: String + if (punctuationOnly) { + raw = "今天天气很好我们一起去公园散步吧" + report.put("asciiSpace", session.punctuate("你好,今天很好.明天见!")) + .put("literalSpace", session.punctuate("价格3.5,访问https://a.b。")) + } else { + val wav = context.assets.open("voice-beta4.wav").use { it.readBytes() } + report.put("fixtureSha256", MessageDigest.getInstance("SHA-256").digest(wav).joinToString("") { "%02x".format(it) }) + val buffer = ByteBuffer.wrap(wav).order(ByteOrder.LITTLE_ENDIAN) + var offset = 12 + while (String(wav, offset, 4, Charsets.US_ASCII) != "data") { + val size = buffer.getInt(offset + 4); offset += 8 + size + size % 2 + } + val size = buffer.getInt(offset + 4) + val pcm = ByteBuffer.wrap(wav, offset + 8, size).order(ByteOrder.LITTLE_ENDIAN) + report.put("samplesFed", size / 2) + while (pcm.hasRemaining()) { + session.acceptWaveform(FloatArray(minOf(320, pcm.remaining() / 2)) { pcm.short / 32768f }) + } + report.put("partialBeforeFinish", lastPartial) + raw = session.inputFinished() + report.put("question", session.punctuate("你今天有时间吗")) + .put("clauses", session.punctuate("今天天气很好我们一起去公园散步吧")) + } + report.put("rawFinal", raw) + val final = HotwordRuntime.apply(VoiceCorrectionRepository.apply(session.punctuate(raw).orEmpty())) + report.put("final", final) + handler.post { + try { + check(LocalVoiceImeService.activeInstance === service && service.currentInputEditorInfo?.fieldId == R.id.lab_single) + service.onVoiceFinal(final) + report.put("passed", true) + } catch (error: Throwable) { report.put("error", error.toString()) } + finally { reportFile.writeText(report.toString(2)); finished() } + } + } + } catch (error: Throwable) { + handler.post { + service.onVoiceCaptureStopped() + report.put("error", error.toString()); reportFile.writeText(report.toString(2)); finished() + } + } + }, "openime-e2e-pcm").apply { isDaemon = true }.start() + } +} diff --git a/app/src/main/assets/licenses/ct-transformer-Apache-2.0.txt b/app/src/main/assets/licenses/ct-transformer-Apache-2.0.txt new file mode 100644 index 00000000..9f7a310e --- /dev/null +++ b/app/src/main/assets/licenses/ct-transformer-Apache-2.0.txt @@ -0,0 +1,56 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + 1. You must give any other recipients of the Work or Derivative Works a copy of this License; and + + 2. You must cause any modified files to carry prominent notices stating that You changed the files; and + + 3. You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + 4. If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + diff --git a/app/src/main/assets/models/voice/manifest.json b/app/src/main/assets/models/voice/manifest.json index c2aa644e..5ff9a47b 100644 --- a/app/src/main/assets/models/voice/manifest.json +++ b/app/src/main/assets/models/voice/manifest.json @@ -1,15 +1,16 @@ { "modelId": "sherpa-onnx-streaming-paraformer-bilingual-zh-en-int8", - "modelVersion": "hf-8e40c43-int8", + "modelVersion": "hf-8e40c43-int8-punct-20240412", "language": "zh-CN,en-US", "modelType": "paraformer", "engineVersion": "sherpa-onnx-v1.13.6", - "fileHash": "b2a084021f5b4aa5293e750e0733b6a8819d37ddd6452518faffa0b3408c5263", - "supportsPunctuation": false, - "requiredMemory": 520000000, + "fileHash": "d0c0b806dffddbd953429b06dfd31197d19cb06d351c76a2f63d9b7c09fcd829", + "supportsPunctuation": true, + "requiredMemory": 620000000, "files": [ "models/voice/bilingual-paraformer/decoder.int8.onnx", "models/voice/bilingual-paraformer/encoder.int8.onnx", - "models/voice/bilingual-paraformer/tokens.txt" + "models/voice/bilingual-paraformer/tokens.txt", + "models/voice/punctuation/model.int8.onnx" ] } diff --git a/app/src/main/assets/models/voice/punctuation/model.int8.onnx b/app/src/main/assets/models/voice/punctuation/model.int8.onnx new file mode 100644 index 00000000..fbb56c3c --- /dev/null +++ b/app/src/main/assets/models/voice/punctuation/model.int8.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65a3fb9f5ad7bfb96bf69e0dc4481df97f6ee60513c1d94ce981ba6effd524b1 +size 75519198 diff --git a/app/src/main/assets/models/voice/trusted-downloads.tsv b/app/src/main/assets/models/voice/trusted-downloads.tsv index 55e8d2ca..8512cd6d 100644 --- a/app/src/main/assets/models/voice/trusted-downloads.tsv +++ b/app/src/main/assets/models/voice/trusted-downloads.tsv @@ -1,3 +1,3 @@ # openIME trusted downloaded voice model catalog v1 # publishermanifest_fingerprint_sha256file_sizes_in_manifest_order -Slacker-LLC 46e714643e31aa2cba5524cc69bcda2a3c4c2d7e6f826d025c2d9d81473d4366 71664561,165462184,75756 +Slacker-LLC 2c33bc75c3c01dba9ba205ac75977de4a82fb76ccd793f91ba7ed468bac59100 71664561,165462184,75756,75519198 diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt index b915279d..499fac40 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt @@ -11,17 +11,18 @@ import android.window.OnBackInvokedCallback import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel import llc.slacker.openime.core.ShiftState -import llc.slacker.openime.data.ImeHandedness import llc.slacker.openime.data.ImeSettingsRepository import llc.slacker.openime.keyboard.ImeKeyboardView import llc.slacker.openime.setup.SetupUi -import llc.slacker.openime.theme.AccentPalette import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeContrastPolicy import llc.slacker.openime.theme.ImeTheme class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { - companion object { const val EXTRA_EDIT_ACCENT = "edit_accent" } + companion object { + /** Open straight on the fuzzy-pinyin page; Back still returns to preferences. */ + const val EXTRA_OPEN_FUZZY = "open_fuzzy" + } override fun attachBaseContext(newBase: android.content.Context) { super.attachBaseContext(SetupUi.appearanceContext(newBase)) } @@ -62,13 +63,12 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { haptic = ImeSettingsRepository.loadHaptic(this@ImeSettingsActivity), popup = ImeSettingsRepository.loadPopup(this@ImeSettingsActivity), fuzzy = ImeSettingsRepository.loadFuzzy(this@ImeSettingsActivity), - opacity = ImeSettingsRepository.loadSkinOpacity(this@ImeSettingsActivity), - radius = ImeSettingsRepository.loadSkinRadius(this@ImeSettingsActivity), - fontSize = ImeSettingsRepository.loadSkinFont(this@ImeSettingsActivity), - primaryColor = ImeSettingsRepository.loadSkinColor(this@ImeSettingsActivity), ) showPanel(Panel.SETTINGS) savedInstanceState?.getInt("settings_scroll_y")?.let(::restoreSettingsScrollPosition) + if (savedInstanceState == null && intent.getBooleanExtra(EXTRA_OPEN_FUZZY, false)) { + showPanel(Panel.FUZZY_SETTINGS) + } } host.addView( keyboardView, @@ -79,9 +79,6 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { ) setContentView(host) refreshWindowChrome() - if (savedInstanceState == null && intent.getBooleanExtra(EXTRA_EDIT_ACCENT, false)) { - host.post { keyboardView.editAccentColor() } - } if (Build.VERSION.SDK_INT >= 33) { backCallback = OnBackInvokedCallback { handleBack() } onBackInvokedDispatcher.registerOnBackInvokedCallback( @@ -135,16 +132,8 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { } private fun refreshWindowChrome() { - val appearance = ImeSettingsRepository.loadAppearance(this) - val theme = ImeSettingsRepository.loadTheme(this) - val systemDark = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == - android.content.res.Configuration.UI_MODE_NIGHT_YES - val tokens = theme.tokens( - appearance = appearance, - systemDark = systemDark, - accentOverride = AccentPalette.parse(ImeSettingsRepository.loadSkinColor(this)), - ) - val chrome = tokens.expandedBackground + // The page shares the app palette; the status and navigation bars match it. + val chrome = getColor(R.color.setup_page_bg) host.setBackgroundColor(chrome) window.statusBarColor = chrome window.navigationBarColor = chrome @@ -211,15 +200,6 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { ImeSettingsRepository.saveFuzzy(this, enabled) refreshLiveIme() } - override fun onSkinChanged(opacity: Int, radius: Int, fontSize: Int, primaryColor: String) { - ImeSettingsRepository.saveSkin(this, opacity, radius, fontSize, primaryColor) - refreshWindowChrome() - refreshLiveIme() - } - override fun onHandednessChanged(handedness: ImeHandedness) { - ImeSettingsRepository.saveHandedness(this, handedness) - refreshLiveIme() - } override fun onKeyboardHeightChanged(percent: Int) { ImeSettingsRepository.saveKeyboardHeightPercent(this, percent) refreshLiveIme() diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index 6ac5072c..c35f8a8d 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -221,10 +221,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can hapticEnabled = ImeSettingsRepository.loadHaptic(this), popupEnabled = ImeSettingsRepository.loadPopup(this), fuzzyPinyinEnabled = ImeSettingsRepository.loadFuzzy(this), - skinOpacity = ImeSettingsRepository.loadSkinOpacity(this), - skinRadius = ImeSettingsRepository.loadSkinRadius(this), - skinFontSize = ImeSettingsRepository.loadSkinFont(this), - skinPrimaryColor = ImeSettingsRepository.loadSkinColor(this), ) } @@ -425,10 +421,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can hapticEnabled = ImeSettingsRepository.loadHaptic(this), popupEnabled = ImeSettingsRepository.loadPopup(this), fuzzyPinyinEnabled = ImeSettingsRepository.loadFuzzy(this), - skinOpacity = ImeSettingsRepository.loadSkinOpacity(this), - skinRadius = ImeSettingsRepository.loadSkinRadius(this), - skinFontSize = ImeSettingsRepository.loadSkinFont(this), - skinPrimaryColor = ImeSettingsRepository.loadSkinColor(this), ) keyboardView?.applyPersistedSettings( newTheme = state.theme, @@ -437,10 +429,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can haptic = state.hapticEnabled, popup = state.popupEnabled, fuzzy = state.fuzzyPinyinEnabled, - opacity = state.skinOpacity, - radius = state.skinRadius, - fontSize = state.skinFontSize, - primaryColor = state.skinPrimaryColor, ) } @@ -942,7 +930,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can keyboardView?.startVoiceFromSpace() } else { keyboardView?.stopVoiceFromSpace() - voiceMediaMute.restore() } } @@ -967,12 +954,15 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } override fun stopVoiceRecognition() { - // Covers both the normal touch-release callback and accessibility's - // direct stop path, which can bypass onVoicePressChanged(false). - voiceMediaMute.restore() + // Stop capture immediately. Decoding still drains recorded audio; + // restore media when AudioRecord has actually stopped. if (::voiceLifecycle.isInitialized) voiceLifecycle.stop() } + override fun onVoiceCaptureStopped() { + voiceMediaMute.restore() + } + override fun cancelVoiceRecognition() { voiceMediaMute.restore() if (::voiceLifecycle.isInitialized) voiceLifecycle.cancel() @@ -1313,16 +1303,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can if (::rime.isInitialized) rime.invalidateSettingsCache() } - override fun onSkinChanged(opacity: Int, radius: Int, fontSize: Int, primaryColor: String) { - state = state.copy( - skinOpacity = opacity, - skinRadius = radius, - skinFontSize = fontSize, - skinPrimaryColor = primaryColor, - ) - ImeSettingsRepository.saveSkin(this, opacity, radius, fontSize, primaryColor) - } - override fun onShiftStateChanged(state: ShiftState) { this.state = this.state.copy(shiftState = state) } diff --git a/app/src/main/java/llc/slacker/openime/MainActivity.kt b/app/src/main/java/llc/slacker/openime/MainActivity.kt index 21892556..e0ff7c87 100644 --- a/app/src/main/java/llc/slacker/openime/MainActivity.kt +++ b/app/src/main/java/llc/slacker/openime/MainActivity.kt @@ -5,13 +5,11 @@ import android.app.Activity import android.content.Intent import android.content.pm.PackageManager import android.content.res.ColorStateList -import android.graphics.drawable.StateListDrawable +import android.graphics.drawable.GradientDrawable import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings -import android.text.Editable -import android.text.TextWatcher import android.view.HapticFeedbackConstants import android.view.View import android.view.ViewGroup @@ -19,17 +17,15 @@ import android.view.WindowInsets import android.view.inputmethod.InputMethodManager import android.widget.EditText import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import llc.slacker.openime.core.CrashGuard import llc.slacker.openime.data.ImeSettingsRepository import llc.slacker.openime.setup.SetupUi import llc.slacker.openime.theme.ImeAppearance -import llc.slacker.openime.theme.ImeContrastPolicy import llc.slacker.openime.theme.ImeDrawableFactory import llc.slacker.openime.theme.ImeGeometryTokens -import llc.slacker.openime.theme.ImeSurfacePolicy -import llc.slacker.openime.theme.ImeTypographyTokens /** Match the selected IME by the exact package component, never by substring. */ internal fun matchesSelectedInputMethod(defaultInputMethodId: String, packageName: String): Boolean { @@ -37,6 +33,11 @@ internal fun matchesSelectedInputMethod(defaultInputMethodId: String, packageNam return separator > 0 && defaultInputMethodId.substring(0, separator) == packageName } +/** + * Home page. Until openIME is enabled and selected it is a three-step setup + * whose bottom button performs the current step; afterwards the steps fold + * into one "ready" card and the page becomes a place to try the keyboard. + */ class MainActivity : Activity() { override fun attachBaseContext(newBase: android.content.Context) { super.attachBaseContext(SetupUi.appearanceContext(newBase)) @@ -64,75 +65,33 @@ class MainActivity : Activity() { } insets } - setupClick(findViewById(R.id.open_ime_settings)) { - startActivity(Intent(Settings.ACTION_INPUT_METHOD_SETTINGS)) - } - setupClick(findViewById(R.id.choose_ime)) { - getSystemService(InputMethodManager::class.java).showInputMethodPicker() - } - setupClick(findViewById(R.id.open_app_settings)) { - if (!isImeReady()) { - Toast.makeText(this, R.string.setup_need_switch, Toast.LENGTH_SHORT).show() - return@setupClick - } - startActivity(Intent(this, ImeSettingsActivity::class.java)) - } - setupClick(findViewById(R.id.voice_permission)) { - if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { - return@setupClick + findViewById(R.id.main_scroll).addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + val width = minOf(view.width - view.paddingLeft - view.paddingRight, SetupUi.dp(this, 600)) + val content = findViewById(R.id.main_content) + (content.layoutParams as android.widget.FrameLayout.LayoutParams).let { params -> + if (params.width != width) { params.width = width; content.layoutParams = params } } - val prefs = getPreferences(MODE_PRIVATE) - prefs.edit().putBoolean("microphone_skipped", false).apply() - val requested = prefs.getBoolean("microphone_requested", false) - if (requested && !shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)) { - startActivity( - Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.parse("package:$packageName"), - ), - ) - } else { - prefs.edit().putBoolean("microphone_requested", true).apply() - requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1) + val button = findViewById(R.id.open_app_settings) + (button.layoutParams as LinearLayout.LayoutParams).let { params -> + val buttonWidth = (width - SetupUi.dp(this, 32)).coerceAtLeast(0) + if (params.width != buttonWidth) { + params.width = buttonWidth + params.gravity = android.view.Gravity.CENTER_HORIZONTAL + button.layoutParams = params + } } } - setupClick(findViewById(R.id.voice_permission_authorize)) { findViewById(R.id.voice_permission).performClick() } - setupClick(findViewById(R.id.voice_permission_skip)) { - getPreferences(MODE_PRIVATE) - .edit() - .putBoolean("microphone_skipped", true) - .apply() - refreshSetupState() - } - setupClick(findViewById(R.id.test_step)) { - val input = findViewById(R.id.test_input) - input.requestFocus() - getSystemService(InputMethodManager::class.java).showSoftInput( - input, - InputMethodManager.SHOW_IMPLICIT, - ) + styleStaticViews() + setupClick(findViewById(R.id.open_ime_settings)) { openInputMethodSettings() } + setupClick(findViewById(R.id.choose_ime)) { showInputMethodPicker() } + setupClick(findViewById(R.id.open_app_settings)) { + if (imeStatus().enabled) showInputMethodPicker() else openInputMethodSettings() } - findViewById(R.id.test_input).addTextChangedListener( - object : TextWatcher { - override fun beforeTextChanged( - s: CharSequence?, - start: Int, - count: Int, - after: Int, - ) = Unit - - override fun onTextChanged( - s: CharSequence?, - start: Int, - before: Int, - count: Int, - ) { - refreshSetupState() - } - - override fun afterTextChanged(s: Editable?) = Unit - }, - ) + setupClick(findViewById(R.id.open_settings_icon)) { openPreferences() } + setupClick(findViewById(R.id.shortcut_preferences)) { openPreferences() } + setupClick(findViewById(R.id.shortcut_fuzzy)) { openPreferences(fuzzy = true) } + setupClick(findViewById(R.id.voice_permission)) { requestMicrophone() } + setupClick(findViewById(R.id.voice_permission_authorize)) { requestMicrophone() } } override fun onResume() { @@ -149,157 +108,247 @@ class MainActivity : Activity() { if (hasFocus) refreshSetupState() } - private fun refreshSetupState() { - val status = imeStatus() - val enabled = status.enabled - val selected = status.selected - val accent = SetupUi.accent(this) - val prefs = getPreferences(MODE_PRIVATE) - val microphoneGranted = - checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED - val microphoneRequested = prefs.getBoolean("microphone_requested", false) - val microphoneSkipped = prefs.getBoolean("microphone_skipped", false) - val microphoneDeniedPermanently = - microphoneRequested && - !microphoneGranted && - !shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) - val testInput = findViewById(R.id.test_input) - val testDone = testInput.text?.isNotBlank() == true + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + refreshSetupState() + } - testInput.background = SetupUi.inputBackground(this) - testInput.setPadding(SetupUi.dp(this, 16), 0, SetupUi.dp(this, 16), 0) - SetupUi.styleCursor(this, testInput) + private fun openInputMethodSettings() { + startActivity(Intent(Settings.ACTION_INPUT_METHOD_SETTINGS)) + } - findViewById(R.id.status).setText( - when { - !enabled -> R.string.setup_enable - !selected -> R.string.setup_choose - testDone -> R.string.setup_ready - else -> R.string.test_step - }, - ) + private fun showInputMethodPicker() { + getSystemService(InputMethodManager::class.java).showInputMethodPicker() + } - styleStep( - row = findViewById(R.id.open_ime_settings), - mark = findViewById(R.id.open_ime_settings_mark), - label = findViewById(R.id.open_ime_settings_label), - chevron = findViewById(R.id.open_ime_settings_chevron), - done = enabled, - active = !enabled, - doneText = getString(R.string.open_ime_settings_done), - activeText = getString(R.string.open_ime_settings), - markText = "1", - accent = accent, + private fun openPreferences(fuzzy: Boolean = false) { + if (!isImeReady()) { + Toast.makeText(this, R.string.setup_need_switch, Toast.LENGTH_SHORT).show() + return + } + startActivity( + Intent(this, ImeSettingsActivity::class.java) + .putExtra(ImeSettingsActivity.EXTRA_OPEN_FUZZY, fuzzy), ) + } - styleStep( - row = findViewById(R.id.choose_ime), - mark = findViewById(R.id.choose_ime_mark), - label = findViewById(R.id.choose_ime_label), - chevron = findViewById(R.id.choose_ime_chevron), - done = selected, - active = enabled && !selected, - doneText = "已切换到 openIME", - activeText = getString(R.string.choose_ime), - markText = "2", - accent = accent, - ) - findViewById(R.id.choose_ime).apply { - isEnabled = enabled - alpha = 1f + private fun requestMicrophone() { + if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) return + val prefs = getPreferences(MODE_PRIVATE) + val requested = prefs.getBoolean("microphone_requested", false) + if (requested && !shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)) { + startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:$packageName"), + ), + ) + } else { + prefs.edit().putBoolean("microphone_requested", true).apply() + requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1) } + } - val voiceDone = microphoneGranted || microphoneSkipped - val voiceActive = selected && !voiceDone - val voiceActiveText = - if (microphoneDeniedPermanently) { - getString(R.string.voice_permission_settings) - } else { - getString(R.string.voice_permission_enable) - } - val voiceDoneText = - if (microphoneGranted) { - getString(R.string.voice_permission_ready) - } else { - getString(R.string.voice_permission_skipped) + /** Surfaces and tints that do not change with the setup state. */ + private fun styleStaticViews() { + val page = getColor(R.color.setup_page_bg) + window.statusBarColor = page + window.navigationBarColor = page + listOf(R.id.steps_card, R.id.voice_permission, R.id.shortcuts_card, R.id.ready_card).forEach { + findViewById(it).clipToOutline = true + } + listOf(R.id.open_ime_settings, R.id.choose_ime, R.id.shortcut_preferences, R.id.shortcut_fuzzy).forEach { + findViewById(it).background = SetupUi.rowBackground(this) + } + listOf(R.id.voice_permission_icon, R.id.shortcut_preferences_icon, R.id.shortcut_fuzzy_icon).forEach { + findViewById(it).apply { + background = SetupUi.rounded(getColor(R.color.setup_icon_tile), SetupUi.dp(this@MainActivity, 9).toFloat()) + imageTintList = ColorStateList.valueOf(getColor(R.color.setup_icon)) } - styleStep( - row = findViewById(R.id.voice_permission), - mark = findViewById(R.id.voice_permission_mark), - label = findViewById(R.id.voice_permission_label), - chevron = findViewById(R.id.voice_permission_chevron), - done = voiceDone, - active = voiceActive, - doneText = voiceDoneText, - activeText = voiceActiveText, - markText = "", - accent = accent, - ) - findViewById(R.id.voice_permission).apply { - isEnabled = selected && !microphoneGranted - alpha = 1f } - findViewById(R.id.voice_permission_description).setText( - when { - microphoneGranted -> R.string.voice_permission_ready_description - microphoneDeniedPermanently -> R.string.voice_permission_denied - else -> R.string.voice_permission_description - }, + findViewById(R.id.ready_mark).background = circle(getColor(R.color.setup_ready)) + findViewById(R.id.open_settings_icon).background = ImeDrawableFactory.statefulRounded( + getColor(R.color.setup_surface), + getColor(R.color.setup_muted), + SetupUi.dp(this, ImeGeometryTokens.PILL_RADIUS_DP), + SetupUi.accent(this), + SetupUi.dp(this, 1), ) - findViewById(R.id.voice_permission_actions).visibility = if (microphoneGranted || microphoneSkipped) View.GONE else View.VISIBLE - findViewById(R.id.voice_permission_authorize).background = SetupUi.secondaryBackground(this) - findViewById(R.id.voice_permission_skip).apply { - visibility = - if (selected && !microphoneGranted && !microphoneSkipped) { - View.VISIBLE - } else { - View.GONE - } - isEnabled = visibility == View.VISIBLE - background = null + val tips = findViewById(R.id.typing_tips) + for (index in 0 until tips.childCount) { + tips.getChildAt(index).background = + SetupUi.rounded(getColor(R.color.setup_surface), SetupUi.dp(this, ImeGeometryTokens.PILL_RADIUS_DP).toFloat()) } - - styleStep( - row = findViewById(R.id.test_step), - mark = findViewById(R.id.test_step_mark), - label = findViewById(R.id.test_step_label), - chevron = null, - done = testDone, - active = selected && !testDone, - doneText = getString(R.string.test_step_done), - activeText = getString(R.string.test_step), - markText = "3", - accent = accent, - ) - findViewById(R.id.test_step).apply { - isEnabled = selected - alpha = 1f + findViewById(R.id.test_input).apply { + background = ImeDrawableFactory.focusedRounded( + fillColor = getColor(R.color.setup_surface), + radiusPx = SetupUi.dp(this@MainActivity, 14).toFloat(), + focusedStrokeColor = SetupUi.accent(this@MainActivity), + defaultStrokeColor = getColor(R.color.setup_hairline), + strokeWidthPx = SetupUi.dp(this@MainActivity, 1).coerceAtLeast(1) + SetupUi.dp(this@MainActivity, 1) / 2, + ) + SetupUi.styleCursor(this@MainActivity, this) } - testInput.isEnabled = selected - testInput.alpha = 1f + findViewById(R.id.voice_permission_authorize).apply { + background = SetupUi.secondaryBackground(this@MainActivity) + setTextColor(SetupUi.secondaryTextColor(this@MainActivity)) + // The row is the one accessible action; the button repeats it. + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + findViewById(R.id.voice_permission_status).background = + SetupUi.rounded(getColor(R.color.setup_icon_tile), SetupUi.dp(this, ImeGeometryTokens.PILL_RADIUS_DP).toFloat()) + findViewById(R.id.open_app_settings).background = + SetupUi.buttonBackground(this, SetupUi.primaryButtonColor(this)) + } + private fun refreshSetupState() { + val status = imeStatus() + val enabled = status.enabled + val selected = status.selected val ready = enabled && selected - findViewById(R.id.open_app_settings).apply { - isEnabled = ready - alpha = if (ready) 1f else ImeSurfacePolicy.DISABLED_ALPHA - background = SetupUi.secondaryBackground(this@MainActivity) - contentDescription = getString( - if (ready) R.string.open_app_settings else R.string.setup_need_switch, + + findViewById(R.id.onboarding_group).visibility = if (ready) View.GONE else View.VISIBLE + findViewById(R.id.ready_group).visibility = if (ready) View.VISIBLE else View.GONE + findViewById(R.id.open_settings_icon).visibility = if (ready) View.VISIBLE else View.GONE + moveVoiceBlock(if (ready) R.id.ready_voice_slot else R.id.onboarding_voice_slot) + findViewById(R.id.voice_section_title).setText(if (ready) R.string.section_voice else R.string.section_optional) + + val currentStep = if (!enabled) 1 else 2 + findViewById(R.id.setup_progress_label).text = getString(R.string.setup_progress, currentStep) + listOf(R.id.setup_progress_1, R.id.setup_progress_2, R.id.setup_progress_3).forEachIndexed { index, id -> + findViewById(id).background = SetupUi.rounded( + getColor(if (index < currentStep) R.color.setup_primary else R.color.setup_progress_todo), + SetupUi.dp(this, 2).toFloat(), ) + } + + styleStep(R.id.open_ime_settings, R.id.open_ime_settings_mark, R.id.open_ime_settings_label, "1", done = enabled, active = !enabled) + styleStep(R.id.choose_ime, R.id.choose_ime_mark, R.id.choose_ime_label, "2", done = selected, active = enabled && !selected) + styleStep(R.id.test_step, R.id.test_step_mark, R.id.test_step_label, "3", done = false, active = false) + findViewById(R.id.choose_ime).isEnabled = enabled + findViewById(R.id.test_step).isClickable = false + + findViewById(R.id.open_app_settings).apply { + visibility = if (ready) View.GONE else View.VISIBLE + setText(if (enabled) R.string.choose_ime_action else R.string.open_ime_settings_action) + contentDescription = text + if (Build.VERSION.SDK_INT >= 30) { + stateDescription = getString(R.string.setup_progress, currentStep) + } + } + + refreshVoice(ready) + findViewById(R.id.ready_card).contentDescription = + "${getString(R.string.setup_ready)},${getString(R.string.setup_ready_detail)}" + } + + private fun refreshVoice(ready: Boolean) { + val granted = checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val requested = getPreferences(MODE_PRIVATE).getBoolean("microphone_requested", false) + val deniedPermanently = requested && !granted && + !shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) + val label = findViewById(R.id.voice_permission_label) + val description = findViewById(R.id.voice_permission_description) + label.setText(if (ready && granted) R.string.voice_title_ready else R.string.voice_title) + description.setText( + when { + granted -> R.string.voice_permission_ready_description + deniedPermanently -> R.string.voice_permission_denied + else -> R.string.voice_permission_description + }, + ) + val authorize = findViewById(R.id.voice_permission_authorize) + authorize.visibility = if (granted) View.GONE else View.VISIBLE + authorize.setText(if (deniedPermanently) R.string.voice_permission_settings else R.string.voice_permission_enable) + findViewById(R.id.voice_permission_status).visibility = if (granted) View.VISIBLE else View.GONE + findViewById(R.id.voice_permission).apply { + // Granted is a status, not an action: the row stops looking pressable. + background = if (granted) { + SetupUi.cardBackground(this@MainActivity) + } else { + android.graphics.drawable.LayerDrawable( + arrayOf(SetupUi.cardBackground(this@MainActivity), SetupUi.rowBackground(this@MainActivity)), + ) + } + contentDescription = "${label.text},${description.text}" if (Build.VERSION.SDK_INT >= 30) { - stateDescription = if (ready) "可用" else "需先完成输入法设置" + stateDescription = getString(if (granted) R.string.voice_permission_ready else R.string.voice_permission_enable) } hideDecorationFromAccessibility(this) } } + private fun moveVoiceBlock(slotId: Int) { + val block = findViewById(R.id.voice_block) + val slot = findViewById(slotId) + if (block.parent === slot) return + (block.parent as? ViewGroup)?.removeView(block) + slot.addView(block) + } + /** - * A setup card is one focusable, described node. Its number, label, - * hint, status pill and chevron are decoration for the same action, so a - * screen reader must not also land on each of them (and, being inside the - * card, they cannot be separate actions anyway). Applied in code because - * the status pill is added at runtime and a layout edit must not be able - * to bring the duplicates back. + * Step marker: the current step is the accent with its number, a finished + * step is a green check, a later step is an outlined grey number. + */ + private fun styleStep(rowId: Int, markId: Int, labelId: Int, number: String, done: Boolean, active: Boolean) { + val row = findViewById(rowId) + val mark = findViewById(markId) + val label = findViewById(labelId) + val stroke = SetupUi.dp(this, 1) + SetupUi.dp(this, 1) / 2 + when { + done -> { + mark.text = "" + mark.background = circle(getColor(R.color.setup_ready)) + val check = getDrawable(R.drawable.ic_pref_check)?.mutate()?.apply { + val size = SetupUi.dp(this@MainActivity, 16) + setBounds(0, 0, size, size) + setTint(android.graphics.Color.WHITE) + } + mark.setCompoundDrawablesRelative(check, null, null, null) + mark.setPaddingRelative(SetupUi.dp(this, 6), 0, 0, 0) + } + active -> { + mark.text = number + mark.setCompoundDrawablesRelative(null, null, null, null) + mark.setPadding(0, 0, 0, 0) + mark.background = circle(getColor(R.color.setup_primary)) + mark.setTextColor(getColor(R.color.setup_on_primary)) + } + else -> { + mark.text = number + mark.setCompoundDrawablesRelative(null, null, null, null) + mark.setPadding(0, 0, 0, 0) + mark.background = circle(android.graphics.Color.TRANSPARENT, getColor(R.color.setup_step_todo), stroke) + mark.setTextColor(getColor(R.color.setup_body)) + } + } + label.typeface = android.graphics.Typeface.create( + "sans-serif-medium", + if (active) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL, + ) + hideDecorationFromAccessibility(row) + row.contentDescription = if (active || done) label.text else "${label.text},${getString(R.string.setup_step_later)}" + if (Build.VERSION.SDK_INT >= 30) { + row.stateDescription = getString( + when { + done -> R.string.setup_step_done + active -> R.string.setup_step_current + else -> R.string.setup_step_later + }, + ) + } + } + + private fun circle(fill: Int, strokeColor: Int? = null, strokeWidth: Int = 0) = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(fill) + strokeColor?.let { setStroke(strokeWidth, it) } + } + + /** + * A setup card is one focusable, described node. Its marker, label, hint + * and button are decoration for the same action, so a screen reader must + * not also land on each of them. */ private fun hideDecorationFromAccessibility(card: View) { val group = card as? ViewGroup ?: return @@ -332,119 +381,4 @@ class MainActivity : Activity() { val enabled = selected || manager.enabledInputMethodList.any { it.packageName == packageName } return ImeStatus(enabled = enabled, selected = selected) } - - private fun styleStep( - row: View, - mark: TextView, - label: TextView, - chevron: ImageView?, - done: Boolean, - active: Boolean, - doneText: String, - activeText: String, - markText: String, - accent: Int, - ) { - // A completed setup step remains an action: users may need to revisit - // the system picker or input-method settings after initial setup. - row.isEnabled = true - row.background = null - label.text = if (done) doneText else activeText - label.setTextColor(getColor(if (done || active) R.color.setup_title else R.color.setup_body)) - // A completed step is easier to scan as a result than as an old - // step number. Keep the number for the current step so the flow still - // reads as 1 -> 2 while the completed state reads as a check. - mark.text = if (done) "" else markText - val markIcon = if (row.id == R.id.voice_permission) R.drawable.ic_mic else if (done) R.drawable.ic_check else 0 - val drawable = if (markIcon != 0) getDrawable(markIcon)?.apply { - setBounds(0, 0, SetupUi.dp(this@MainActivity, 16), SetupUi.dp(this@MainActivity, 16)) - } else null - mark.setCompoundDrawablesRelative(drawable, null, null, null) - mark.compoundDrawableTintList = ColorStateList.valueOf( - if (done) contrastText(getColor(R.color.setup_ready)) else getColor(R.color.setup_body), - ) - mark.background = android.graphics.drawable.GradientDrawable().apply { - shape = android.graphics.drawable.GradientDrawable.OVAL - setColor(if (done) getColor(R.color.setup_ready) else if (active) accent else getColor(R.color.setup_surface)) - if (!done && !active) setStroke(SetupUi.dp(this@MainActivity, 1), getColor(R.color.setup_input_line)) - } - mark.setTextColor( - when { - active -> contrastText(accent) - done -> contrastText(getColor(R.color.setup_ready)) - else -> getColor(R.color.setup_body) - }, - ) - chevron?.imageTintList = ColorStateList.valueOf( - getColor(R.color.setup_body), - ) - chevron?.visibility = View.GONE - if (row.id == R.id.open_ime_settings || row.id == R.id.choose_ime) { - val layout = row as android.widget.LinearLayout - (layout.getChildAt(1) as? android.widget.LinearLayout)?.getChildAt(1)?.visibility = if (done) View.GONE else View.VISIBLE - val status = (layout.findViewWithTag("setup-result") as? TextView) ?: TextView(this).apply { - tag = "setup-result"; textSize = ImeTypographyTokens.SMALL_SP; setTextColor(getColor(R.color.setup_body)) - layout.addView(this, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT)) - } - val enableStep = row.id == R.id.open_ime_settings - status.text = if (done) { if (enableStep) "已启用" else "已切换" } else { if (enableStep) "启用" else "切换" } - status.visibility = View.VISIBLE - status.gravity = android.view.Gravity.CENTER - status.typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) - status.layoutParams = android.widget.LinearLayout.LayoutParams( - if (done) android.widget.LinearLayout.LayoutParams.WRAP_CONTENT else SetupUi.dp(this, 66), - if (done) android.widget.LinearLayout.LayoutParams.WRAP_CONTENT else SetupUi.dp(this, 40), - ) - status.background = if (done) null else SetupUi.rounded( - if (active) accent else getColor(R.color.setup_disabled), SetupUi.dp(this, 12).toFloat(), - ) - status.setTextColor(if (done) getColor(R.color.setup_body) else if (active) contrastText(accent) else getColor(R.color.setup_disabled_text)) - - } - if (row.id == R.id.test_step) (row as android.widget.LinearLayout).getChildAt(2)?.visibility = View.GONE - hideDecorationFromAccessibility(row) - row.alpha = 1f - row.contentDescription = when { - done -> doneText - active -> activeText - else -> "$activeText,完成上一步后可用" - } - if (Build.VERSION.SDK_INT >= 30) { - row.stateDescription = when { - done -> "已完成,可再次打开" - active -> "当前步骤" - else -> "暂不可用" - } - } - } - - private fun completedBackground(accent: Int): StateListDrawable { - val surface = getColor(R.color.setup_muted) - val tint = ImeDrawableFactory.blend(surface, accent, 0.10f) - val pressed = ImeDrawableFactory.blend(surface, accent, 0.16f) - val radius = SetupUi.dp(this, ImeGeometryTokens.CONTROL_RADIUS_DP).toFloat() - return StateListDrawable().apply { - addState( - intArrayOf(android.R.attr.state_pressed), - ImeDrawableFactory.rounded(pressed, radius), - ) - addState( - intArrayOf(android.R.attr.state_focused), - ImeDrawableFactory.rounded( - color = tint, - radiusPx = radius, - strokeColor = accent, - strokeWidthPx = SetupUi.dp(this@MainActivity, 1), - ), - ) - addState( - intArrayOf(), - ImeDrawableFactory.rounded(tint, radius), - ) - } - } - - private fun contrastText(background: Int): Int { - return ImeContrastPolicy.contrastText(background) - } } diff --git a/app/src/main/java/llc/slacker/openime/core/ImeState.kt b/app/src/main/java/llc/slacker/openime/core/ImeState.kt index dfc79270..68970e2c 100644 --- a/app/src/main/java/llc/slacker/openime/core/ImeState.kt +++ b/app/src/main/java/llc/slacker/openime/core/ImeState.kt @@ -1,7 +1,6 @@ package llc.slacker.openime.core import android.view.inputmethod.EditorInfo -import llc.slacker.openime.theme.AccentPalette import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeTheme @@ -24,7 +23,6 @@ enum class Panel { TEXT_EDITOR, SETTINGS, FUZZY_SETTINGS, - SKIN_SETTINGS, CANDIDATE_EXPANDED, } @@ -57,10 +55,6 @@ data class ImeState( // First real emoji category tab (see ImeData.emojiByCategory order). val emojiCategory: String = "笑脸", val voiceState: VoiceUiState = VoiceUiState(), - val skinOpacity: Int = 100, - val skinRadius: Int = 8, - val skinFontSize: Int = 21, - val skinPrimaryColor: String = AccentPalette.DEFAULT, ) data class VoiceUiState( diff --git a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt index 0d615f58..7cda51ec 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -2,16 +2,9 @@ package llc.slacker.openime.data import android.content.Context import llc.slacker.openime.core.KeyboardMode -import llc.slacker.openime.theme.AccentPalette import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeTheme -enum class ImeHandedness(val label: String) { - STANDARD("标准"), - LEFT("左手"), - RIGHT("右手"), -} - /** Lightweight persistent IME settings. */ object ImeSettingsRepository { @@ -27,12 +20,7 @@ object ImeSettingsRepository { private const val KEY_EMOJI_ASSOCIATION = "emoji_association" private const val KEY_VOICE_STRIP_FILLERS = "voice_strip_fillers" private const val KEY_VOICE_PUNCTUATION_AS_SPACE = "voice_punctuation_as_space" - private const val KEY_SKIN_OPACITY = "skin_opacity" - private const val KEY_SKIN_RADIUS = "skin_radius" - private const val KEY_SKIN_FONT = "skin_font" - private const val KEY_SKIN_COLOR = "skin_color" private const val KEY_PREFERRED_CHINESE_MODE = "preferred_chinese_mode" - private const val KEY_HANDEDNESS = "handedness" private const val KEY_KEYBOARD_HEIGHT = "keyboard_height_percent" private const val KEY_FLOATING_WIDTH = "floating_width_percent" private const val KEY_FLOATING_OPACITY = "floating_opacity_percent" @@ -57,20 +45,6 @@ object ImeSettingsRepository { .edit().putString(KEY_PREFERRED_CHINESE_MODE, mode.name).apply() } - fun loadHandedness(context: Context): ImeHandedness = - runCatching { - ImeHandedness.valueOf( - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getString(KEY_HANDEDNESS, ImeHandedness.STANDARD.name) - ?: ImeHandedness.STANDARD.name, - ) - }.getOrDefault(ImeHandedness.STANDARD) - - fun saveHandedness(context: Context, handedness: ImeHandedness) { - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .edit().putString(KEY_HANDEDNESS, handedness.name).apply() - } - fun loadKeyboardHeightPercent(context: Context): Int = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getInt(KEY_KEYBOARD_HEIGHT, 100) @@ -218,30 +192,4 @@ object ImeSettingsRepository { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .edit().putBoolean(KEY_FUZZY, enabled).apply() } - - fun loadSkinOpacity(context: Context): Int = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getInt(KEY_SKIN_OPACITY, 100) - - fun loadSkinRadius(context: Context): Int = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getInt(KEY_SKIN_RADIUS, 8) - - fun loadSkinFont(context: Context): Int = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getInt(KEY_SKIN_FONT, 21) - - fun loadSkinColor(context: Context): String = - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .getString(KEY_SKIN_COLOR, AccentPalette.DEFAULT) ?: AccentPalette.DEFAULT - - fun saveSkin(context: Context, opacity: Int, radius: Int, fontSize: Int, primaryColor: String) { - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - .edit() - .putInt(KEY_SKIN_OPACITY, opacity) - .putInt(KEY_SKIN_RADIUS, radius) - .putInt(KEY_SKIN_FONT, fontSize) - .putString(KEY_SKIN_COLOR, AccentPalette.normalize(primaryColor)) - .apply() - } } diff --git a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt index 5272a6e5..fd0d2723 100644 --- a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt +++ b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt @@ -2,7 +2,6 @@ package llc.slacker.openime.data import android.content.Context import llc.slacker.openime.core.KeyboardMode -import llc.slacker.openime.theme.AccentPalette import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeTheme import org.json.JSONArray @@ -35,12 +34,7 @@ internal data class ArchiveSettings( val popup: Boolean, val fuzzy: Boolean, val swipeUpDigits: Boolean, - val skinOpacity: Int, - val skinRadius: Int, - val skinFont: Int, - val skinColor: String, val preferredChineseMode: String, - val handedness: String, val keyboardHeightPercent: Int, val floatingWidthPercent: Int, val floatingOpacityPercent: Int, @@ -183,12 +177,7 @@ internal object UserDataArchiveCodec { .put("popup", value.popup) .put("fuzzy", value.fuzzy) .put("swipe_up_digits", value.swipeUpDigits) - .put("skin_opacity", value.skinOpacity) - .put("skin_radius", value.skinRadius) - .put("skin_font", value.skinFont) - .put("skin_color", value.skinColor) .put("preferred_chinese_mode", value.preferredChineseMode) - .put("handedness", value.handedness) .put("keyboard_height_percent", value.keyboardHeightPercent) .put("floating_width_percent", value.floatingWidthPercent) .put("floating_opacity_percent", value.floatingOpacityPercent) @@ -206,15 +195,10 @@ internal object UserDataArchiveCodec { popup = value.optBoolean("popup", false), fuzzy = value.optBoolean("fuzzy", false), swipeUpDigits = value.optBoolean("swipe_up_digits", true), - skinOpacity = value.optInt("skin_opacity", 95).coerceIn(70, 100), - skinRadius = value.optInt("skin_radius", 8).coerceIn(0, 24), - skinFont = value.optInt("skin_font", 17).coerceIn(14, 22), - skinColor = AccentPalette.normalize(value.optString("skin_color", AccentPalette.DEFAULT)), preferredChineseMode = value.optString( "preferred_chinese_mode", KeyboardMode.PINYIN_26.name, ), - handedness = value.optString("handedness", ImeHandedness.STANDARD.name), keyboardHeightPercent = value.optInt("keyboard_height_percent", 100).coerceIn(80, 120), floatingWidthPercent = @@ -340,13 +324,8 @@ internal object UserDataRepository { popup = ImeSettingsRepository.loadPopup(context), fuzzy = ImeSettingsRepository.loadFuzzy(context), swipeUpDigits = ImeSettingsRepository.loadSwipeUpDigits(context), - skinOpacity = ImeSettingsRepository.loadSkinOpacity(context), - skinRadius = ImeSettingsRepository.loadSkinRadius(context), - skinFont = ImeSettingsRepository.loadSkinFont(context), - skinColor = ImeSettingsRepository.loadSkinColor(context), preferredChineseMode = ImeSettingsRepository.loadPreferredChineseMode(context).name, - handedness = ImeSettingsRepository.loadHandedness(context).name, keyboardHeightPercent = ImeSettingsRepository.loadKeyboardHeightPercent(context), floatingWidthPercent = @@ -427,23 +406,11 @@ internal object UserDataRepository { ImeSettingsRepository.savePopup(context, value.popup) ImeSettingsRepository.saveFuzzy(context, value.fuzzy) ImeSettingsRepository.saveSwipeUpDigits(context, value.swipeUpDigits) - ImeSettingsRepository.saveSkin( - context, - opacity = value.skinOpacity.coerceIn(70, 100), - radius = value.skinRadius.coerceIn(0, 24), - fontSize = value.skinFont.coerceIn(14, 22), - primaryColor = value.skinColor, - ) ImeSettingsRepository.savePreferredChineseMode( context, runCatching { KeyboardMode.valueOf(value.preferredChineseMode) } .getOrDefault(KeyboardMode.PINYIN_26), ) - ImeSettingsRepository.saveHandedness( - context, - runCatching { ImeHandedness.valueOf(value.handedness) } - .getOrDefault(ImeHandedness.STANDARD), - ) ImeSettingsRepository.saveKeyboardHeightPercent( context, value.keyboardHeightPercent, diff --git a/app/src/main/java/llc/slacker/openime/handwriting/HandwritingPadView.kt b/app/src/main/java/llc/slacker/openime/handwriting/HandwritingPadView.kt index 9d5f656b..1c6343dc 100644 --- a/app/src/main/java/llc/slacker/openime/handwriting/HandwritingPadView.kt +++ b/app/src/main/java/llc/slacker/openime/handwriting/HandwritingPadView.kt @@ -9,7 +9,7 @@ import android.graphics.Path import android.view.MotionEvent import android.view.View import llc.slacker.openime.data.ImeSettingsRepository -import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.theme.ImeTheme /** * Canvas handwriting pad matching the design's helper grid, smooth strokes, @@ -23,7 +23,8 @@ class HandwritingPadView( private val strokes = mutableListOf() private var currentPoints = mutableListOf() private val paint = Paint().apply { - color = AccentPalette.parse(ImeSettingsRepository.loadSkinColor(context)) + // The theme applier replaces this with the live accent once attached. + color = ImeTheme.IOS.tokens(ImeSettingsRepository.loadAppearance(context)).primary style = Paint.Style.STROKE strokeWidth = 5f * resources.displayMetrics.density strokeCap = Paint.Cap.ROUND diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt index dc12f19a..a3659982 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -35,7 +35,6 @@ import llc.slacker.openime.core.ImeState import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel import llc.slacker.openime.core.ShiftState -import llc.slacker.openime.data.ImeHandedness import llc.slacker.openime.data.ImeSettingsRepository import llc.slacker.openime.data.QuickPhrase import llc.slacker.openime.data.QuickPhraseRepository @@ -48,7 +47,7 @@ import llc.slacker.openime.panel.ImePanelRenderer import llc.slacker.openime.panel.PanelHeaderFactory import llc.slacker.openime.panel.SettingsPanelController import llc.slacker.openime.panel.TextEditorPanelController -import llc.slacker.openime.theme.AccentPalette +import llc.slacker.openime.setup.SetupUi import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeDrawableFactory import llc.slacker.openime.theme.ImeFocusRingPolicy @@ -128,8 +127,6 @@ open class ImeKeyboardView( fun onHapticChanged(enabled: Boolean) fun onPopupChanged(enabled: Boolean) fun onFuzzyChanged(enabled: Boolean) - fun onSkinChanged(opacity: Int, radius: Int, fontSize: Int, primaryColor: String) {} - fun onHandednessChanged(handedness: ImeHandedness) {} fun onKeyboardHeightChanged(percent: Int) {} fun onFloatingStyleChanged(widthPercent: Int, opacityPercent: Int) {} fun onOpenAboutData() {} @@ -147,6 +144,14 @@ open class ImeKeyboardView( /** How often a deferred row rebuild re-checks whether the press ended. */ private const val ROW_REBUILD_POLL_MS = 40L + + /** KEYBOARD_TAP needs API 27; API 26 falls back to the virtual-key click. */ + private val KEY_TAP_HAPTIC = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + HapticFeedbackConstants.KEYBOARD_TAP + } else { + HapticFeedbackConstants.VIRTUAL_KEY + } } @@ -289,11 +294,10 @@ open class ImeKeyboardView( private var appliedScreenWidthDp = resources.configuration.screenWidthDp private var appliedFontScale = resources.configuration.fontScale private var appliedDensityDpi = resources.displayMetrics.densityDpi - private var keyboardHandedness = ImeSettingsRepository.loadHandedness(context) private var keyboardHeightPercent = ImeSettingsRepository.loadKeyboardHeightPercent(context) private var floatingWidthPercent = ImeSettingsRepository.loadFloatingWidthPercent(context) private var floatingOpacityPercent = ImeSettingsRepository.loadFloatingOpacityPercent(context) - private var referenceScale = ImeReferenceSizing.scale(context) + private var referenceScale = if (standalonePanel) 1f else ImeReferenceSizing.scale(context) private var floatingWindowMode = false private var layoutMetrics = buildLayoutMetrics() /** Landscape uses compact rows, except in floating mode, which keeps portrait size. */ @@ -314,11 +318,6 @@ open class ImeKeyboardView( fun currentPanel(): Panel = panel /** Persist the standalone settings panel's viewport across Activity recreation. */ - internal fun editAccentColor() { - showPanel(Panel.SKIN_SETTINGS) - settingsPanelController.showCustomAccentDialog() - } - internal fun settingsScrollPosition(): Int = settingsPanelController.scrollPosition() @@ -356,10 +355,6 @@ open class ImeKeyboardView( } private var popupEnabled = ImeSettingsRepository.loadPopup(context) private var fuzzyEnabled = ImeSettingsRepository.loadFuzzy(context) - private var skinRadius = ImeSettingsRepository.loadSkinRadius(context) - private var skinOpacity = ImeSettingsRepository.loadSkinOpacity(context) - private var skinFontSize = ImeSettingsRepository.loadSkinFont(context) - private var skinPrimaryColor = ImeSettingsRepository.loadSkinColor(context) protected open fun onViewHierarchyRebuilt() = Unit @@ -391,11 +386,8 @@ open class ImeKeyboardView( ImeThemeApplier( toPx = ::dp, statefulRounded = ::statefulRounded, - keyMainTextScale = ::skinFontScale, + keyMainTextScale = { referenceScale }, referenceScale = { referenceScale }, - skinRadiusPx = { dp(skinRadius) }, - skinOpacity = { skinOpacity }, - skinPrimaryColor = { skinPrimaryColor }, toggleState = ::onState, isSideKey = { key -> key.getTag(MARK_SIDE_KEY) == true || @@ -415,11 +407,7 @@ open class ImeKeyboardView( dp = ::dp, contentInsetPx = { contentInsetPx }, tokens = { - theme.tokens( - appearance, - isNight(), - AccentPalette.parse(skinPrimaryColor), - ) + theme.tokens(appearance, isNight()) }, rounded = { color, radius -> ImeDrawableFactory.rounded(color, radius) }, statefulRounded = { normal, pressed, radius -> statefulRounded(normal, pressed, radius) }, @@ -594,11 +582,7 @@ open class ImeKeyboardView( status = { topZone.voiceInlineStatus }, waves = { topZone.voiceInlineWaves }, tokens = { - theme.tokens( - appearance, - isNight(), - AccentPalette.parse(skinPrimaryColor), - ) + theme.tokens(appearance, isNight()) }, isGestureSessionActive = { voiceGestureSession }, isComposing = { composition.text?.isNotEmpty() == true }, @@ -651,11 +635,6 @@ open class ImeKeyboardView( currentSwipeUpDigits = { ImeSettingsRepository.loadSwipeUpDigits(context) }, currentExtraToggle = ::onState, currentFuzzy = { fuzzyEnabled }, - currentSkinOpacity = { skinOpacity }, - currentSkinRadius = { skinRadius }, - currentSkinFontSize = { skinFontSize }, - currentSkinColor = { skinPrimaryColor }, - currentHandedness = { keyboardHandedness }, currentKeyboardHeightPercent = { keyboardHeightPercent }, currentFloatingWidthPercent = { floatingWidthPercent }, currentFloatingOpacityPercent = { floatingOpacityPercent }, @@ -665,24 +644,9 @@ open class ImeKeyboardView( listener.onAppearanceChanged(selected) }, onToggleChanged = ::updateSettingToggle, - onSkinChanged = { opacity, radius, fontSize, color -> - skinOpacity = opacity - skinRadius = radius - skinFontSize = fontSize - skinPrimaryColor = AccentPalette.normalize(color) - listener.onSkinChanged( - skinOpacity, - skinRadius, - skinFontSize, - skinPrimaryColor, - ) - applyTheme() - }, - onHandednessChanged = ::setHandedness, onKeyboardHeightChanged = ::setKeyboardHeightPercent, onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, - onShowSkinSettings = { showPanel(Panel.SKIN_SETTINGS) }, onOpenAboutData = listener::onOpenAboutData, onFeedback = ::feedback, applyTheme = ::applyTheme, @@ -1033,22 +997,13 @@ open class ImeKeyboardView( private fun updateResponsiveGeometry(measuredWidthPx: Int) { if (measuredWidthPx <= 0) return - val dockWidthPx = - if (floatingKeyboardController.enabled || keyboardHandedness == ImeHandedness.STANDARD) { - measuredWidthPx - } else { - (measuredWidthPx * 0.82f).toInt() - .coerceAtLeast(dp(280)) - .coerceAtMost(measuredWidthPx) - } - - val nextScale = ImeReferenceSizing.scale(context, dockWidthPx, landscapeCompact = !floatingWindowMode) + val nextScale = if (standalonePanel) 1f else ImeReferenceSizing.scale(context, measuredWidthPx, landscapeCompact = !floatingWindowMode) // Configuration.screenWidthDp is a whole number while the measured width // is not (411dp vs 411.43dp on a 1080px / 420dpi screen), so the two // scales differ by up to 1/390 for the same window. That rounding noise // used to cross the old 0.001 threshold and rebuilt every key from // onSizeChanged, in the middle of the first layout pass. Only a real - // change (one-handed dock, floating window, rotation) rescales. + // change (floating window, rotation) rescales. if (kotlin.math.abs(nextScale - referenceScale) > SCALE_CHANGE_EPSILON) { val ratio = nextScale / referenceScale referenceScale = nextScale @@ -1064,17 +1019,8 @@ open class ImeKeyboardView( } (mainDock.layoutParams as? FrameLayout.LayoutParams)?.let { params -> - if (dockWidthPx == measuredWidthPx) { - params.width = FrameLayout.LayoutParams.MATCH_PARENT - params.gravity = Gravity.TOP - } else { - params.width = dockWidthPx - params.gravity = Gravity.TOP or if (keyboardHandedness == ImeHandedness.LEFT) { - Gravity.START - } else { - Gravity.END - } - } + params.width = FrameLayout.LayoutParams.MATCH_PARENT + params.gravity = Gravity.TOP mainDock.layoutParams = params } @@ -1090,7 +1036,7 @@ open class ImeKeyboardView( // narrower than the display, so that cached width would clip // the first and last keys. Let the row fill its local window. (row.layoutParams as? LinearLayout.LayoutParams)?.let { params -> - params.width = (dockWidthPx * 0.9f).toInt() + params.width = (measuredWidthPx * 0.9f).toInt() params.gravity = Gravity.CENTER_HORIZONTAL row.layoutParams = params } @@ -1105,8 +1051,9 @@ open class ImeKeyboardView( return } val minimumInset = dp(0) - val maxWidth = (minOf(maxContentWidthDp.toFloat(), 390f * referenceScale) * resources.displayMetrics.density).toInt() - contentInsetPx = maxOf(minimumInset, (dockWidthPx - maxWidth) / 2) + val maxWidth = if (standalonePanel) dp(maxContentWidthDp) + else (minOf(maxContentWidthDp.toFloat(), 390f * referenceScale) * resources.displayMetrics.density).toInt() + contentInsetPx = maxOf(minimumInset, (measuredWidthPx - maxWidth) / 2) keyboardBody.setPadding( contentInsetPx, dp(6), @@ -1114,7 +1061,7 @@ open class ImeKeyboardView( dp(10), ) keyboardBody.findViewWithTag("key-row-secondary")?.let { row -> - val rowWidth = ((dockWidthPx - contentInsetPx * 2) * 0.9f).toInt() + val rowWidth = ((measuredWidthPx - contentInsetPx * 2) * 0.9f).toInt() val params = row.layoutParams as? LinearLayout.LayoutParams if (params != null) { params.gravity = Gravity.CENTER_HORIZONTAL @@ -1233,11 +1180,7 @@ open class ImeKeyboardView( createEmptyLabel = { title("暂无候选", small = true) }, applyTheme = ::applyTheme, tokens = { - theme.tokens( - appearance, - isNight(), - AccentPalette.parse(skinPrimaryColor), - ) + theme.tokens(appearance, isNight()) }, statefulBackground = ::statefulRounded, onFeedback = ::feedback, @@ -1531,7 +1474,7 @@ open class ImeKeyboardView( val composing = composition.text?.isNotEmpty() == true val label = if (composing) "确定" else if (mode == KeyboardMode.PINYIN_9 || mode == KeyboardMode.DIGITS) "↵" else enterKeyPresentationFor(options).label enter.setMainText(label) - enter.applyMainTextScale(skinFontScale()) + enter.applyMainTextScale(referenceScale) enter.contentDescription = label } @@ -1606,23 +1549,6 @@ open class ImeKeyboardView( listener.onThemeChanged(newTheme) } - /** - * Apply the complete persisted appearance in one render pass. - * - * The standalone settings page and the live IME can both refresh while a - * panel is visible. Updating theme, appearance and skin through separate - * setters briefly mixed old and new tokens and rebuilt the whole subtree - * several times. Keep the individual setters for user actions, but use - * this atomic boundary whenever a persisted snapshot is loaded. - */ - private fun setHandedness(next: ImeHandedness) { - if (keyboardHandedness == next) return - keyboardHandedness = next - ImeSettingsRepository.saveHandedness(context, next) - listener.onHandednessChanged(next) - updateResponsiveGeometry(width) - } - private fun setFloatingStyle(widthPercent: Int, opacityPercent: Int) { val width = widthPercent.coerceIn(72, 96) val opacity = opacityPercent.coerceIn(82, 100) @@ -1650,6 +1576,15 @@ open class ImeKeyboardView( } } + /** + * Apply the complete persisted appearance in one render pass. + * + * The standalone settings page and the live IME can both refresh while a + * panel is visible. Updating theme, appearance and height through separate + * setters briefly mixed old and new tokens and rebuilt the whole subtree + * several times. Keep the individual setters for user actions, but use + * this atomic boundary whenever a persisted snapshot is loaded. + */ internal fun applyPersistedSettings( newTheme: ImeTheme, newAppearance: ImeAppearance, @@ -1657,13 +1592,7 @@ open class ImeKeyboardView( haptic: Boolean, popup: Boolean, fuzzy: Boolean, - opacity: Int, - radius: Int, - fontSize: Int, - primaryColor: String, ) { - val normalizedColor = AccentPalette.normalize(primaryColor) - val persistedHandedness = ImeSettingsRepository.loadHandedness(context) val persistedHeight = ImeSettingsRepository.loadKeyboardHeightPercent(context) val persistedFloatingWidth = ImeSettingsRepository.loadFloatingWidthPercent(context) val persistedFloatingOpacity = ImeSettingsRepository.loadFloatingOpacityPercent(context) @@ -1671,13 +1600,7 @@ open class ImeKeyboardView( floatingWidthPercent != persistedFloatingWidth || floatingOpacityPercent != persistedFloatingOpacity val heightChanged = keyboardHeightPercent != persistedHeight - val geometryChanged = keyboardHandedness != persistedHandedness || heightChanged - val visualChanged = theme != newTheme || - appearance != newAppearance || - skinOpacity != opacity || - skinRadius != radius || - skinFontSize != fontSize || - skinPrimaryColor != normalizedColor + val visualChanged = theme != newTheme || appearance != newAppearance theme = newTheme appearance = newAppearance @@ -1685,14 +1608,9 @@ open class ImeKeyboardView( hapticEnabled = haptic popupEnabled = popup fuzzyEnabled = fuzzy - skinOpacity = opacity - skinRadius = radius - skinFontSize = fontSize - skinPrimaryColor = normalizedColor - if (geometryChanged) { - keyboardHandedness = persistedHandedness + if (heightChanged) { keyboardHeightPercent = persistedHeight - if (heightChanged) renderedMode = null + renderedMode = null layoutMetrics = buildLayoutMetrics() applyDynamicHeights() if (!standalonePanel && panel == Panel.NONE) { @@ -1737,23 +1655,6 @@ open class ImeKeyboardView( applyTheme() } - /** Apply persisted visual settings when another entry point changed them. */ - fun setSkin(opacity: Int, radius: Int, fontSize: Int, primaryColor: String) { - val normalizedColor = AccentPalette.normalize(primaryColor) - if (skinOpacity == opacity && - skinRadius == radius && - skinFontSize == fontSize && - skinPrimaryColor == normalizedColor - ) { - return - } - skinOpacity = opacity - skinRadius = radius - skinFontSize = fontSize - skinPrimaryColor = normalizedColor - applyTheme() - } - fun setSettings(sound: Boolean, haptic: Boolean, popup: Boolean) { soundEnabled = sound hapticEnabled = haptic @@ -2198,7 +2099,6 @@ open class ImeKeyboardView( Panel.TEXT_EDITOR -> textEditorPanelController.render() Panel.SETTINGS -> settingsPanelController.renderSettings() Panel.FUZZY_SETTINGS -> settingsPanelController.renderFuzzySettings() - Panel.SKIN_SETTINGS -> settingsPanelController.renderSettings(skinOnly = true) else -> closePanelToKeyboard() } applyTheme() @@ -2263,7 +2163,7 @@ open class ImeKeyboardView( text = textValue textSize = ImeTypographyTokens.SMALL_SP includeFontPadding = false - setPadding(dp(16), dp(4), 0, dp(8)) + setPadding(dp(16), dp(if (standalonePanel) 0 else 4), 0, dp(8)) tag = "panel-section-title" } @@ -2914,20 +2814,18 @@ open class ImeKeyboardView( /** Restyle only the Shift key after a label/icon change. */ private fun applyShiftTheme(shift: ImeKeyView?) { val target = shift ?: return - val night = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == - android.content.res.Configuration.UI_MODE_NIGHT_YES - themeApplier.apply(target, theme.tokens(appearance, night, AccentPalette.parse(skinPrimaryColor))) + themeApplier.apply(target, currentThemeTokens()) } private fun applyAssociationTheme() { if (!::topZone.isInitialized) return - val night = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == - android.content.res.Configuration.UI_MODE_NIGHT_YES - themeApplier.apply(associationRow, theme.tokens(appearance, night, AccentPalette.parse(skinPrimaryColor))) + themeApplier.apply(associationRow, currentThemeTokens()) } protected fun feedback() { - if (hapticEnabled) performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) + // KEYBOARD_TAP is the platform's 10–20 ms key click; CLOCK_TICK is a + // scroll texture and feels mushy on many actuators. + if (hapticEnabled) performHapticFeedback(KEY_TAP_HAPTIC) if (soundEnabled) { // View effects can be disabled by an IME host window even when the // app preference is on. Use the system keypress channel directly; @@ -2945,12 +2843,10 @@ open class ImeKeyboardView( /** Haptic-only confirmation (no key click sound), e.g. when voice arms. */ private fun hapticFeedback() { - if (hapticEnabled) performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + // A crisp click marks a gesture threshold; LONG_PRESS rings for too long. + if (hapticEnabled) performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) } - /** Key main-text size is scaled around the 17sp default from the skin font slider. */ - private fun skinFontScale(): Float = skinFontSize / 21f * referenceScale - private fun key( text: String, func: Boolean, @@ -3085,12 +2981,9 @@ open class ImeKeyboardView( */ internal fun scaledPx(dp: Int): Int = dp(dp) + /** The preferences page paints with the app palette, the keyboard with key colours. */ private fun currentThemeTokens(): ImeTheme.Tokens = - theme.tokens( - appearance, - isNight(), - AccentPalette.parse(skinPrimaryColor), - ) + if (standalonePanel) SetupUi.tokens(context) else theme.tokens(appearance, isNight()) protected fun applyTheme() { val t = currentThemeTokens() @@ -3098,7 +2991,7 @@ open class ImeKeyboardView( mainDock.setBackgroundColor(t.keyboardBackground) keyboardBody.setBackgroundColor(t.keyboardBackground) topZone.setBackgroundColor(t.toolbarBackground) - expandedPanel.setBackgroundColor(if (standalonePanel) t.toolbarBackground else t.keyboardBackground) + expandedPanel.setBackgroundColor(t.keyboardBackground) candidateOverlay.setBackgroundColor(t.expandedBackground) themeApplier.apply(this, t) topZone.applyTokens(t) @@ -3111,9 +3004,7 @@ open class ImeKeyboardView( /** Reuse the renderer's design tokens for views added by production decorators. */ internal fun applyThemeToSubtree(target: View) { - val night = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == - android.content.res.Configuration.UI_MODE_NIGHT_YES - themeApplier.apply(target, theme.tokens(appearance, night, AccentPalette.parse(skinPrimaryColor))) + themeApplier.apply(target, currentThemeTokens()) } private fun statefulRounded(normal: Int, pressed: Int, radius: Int): StateListDrawable = @@ -3127,15 +3018,11 @@ open class ImeKeyboardView( private fun focusStroke(color: Int): Int { val background = if (Color.alpha(color) == 0) { - theme.tokens( - appearance, - isNight(), - AccentPalette.parse(skinPrimaryColor), - ).keyboardBackground + theme.tokens(appearance, isNight()).keyboardBackground } else { color } - return ImeFocusRingPolicy.resolve(background, AccentPalette.parse(skinPrimaryColor)) + return ImeFocusRingPolicy.resolve(background, theme.tokens(appearance, isNight()).primary) } private fun dp(value: Int): Int = kotlin.math.round(value * resources.displayMetrics.density * referenceScale).toInt() diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt index 50ddb79c..8832cfd8 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -14,7 +14,6 @@ import android.widget.SeekBar import android.widget.TextView import llc.slacker.openime.R import llc.slacker.openime.handwriting.HandwritingPadView -import llc.slacker.openime.theme.AccentPalette import llc.slacker.openime.theme.ImeDrawableFactory import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeSurfacePolicy @@ -32,9 +31,6 @@ internal class ImeThemeApplier( private val statefulRounded: (Int, Int, Int) -> android.graphics.drawable.StateListDrawable, private val keyMainTextScale: () -> Float, private val referenceScale: () -> Float, - private val skinRadiusPx: () -> Int, - private val skinOpacity: () -> Int, - private val skinPrimaryColor: () -> String, private val toggleState: (String) -> Boolean, private val isSideKey: (ImeKeyView) -> Boolean, private val isFunctionKey: (ImeKeyView) -> Boolean, @@ -104,15 +100,11 @@ internal class ImeThemeApplier( } view.applyMainTextScale(keyMainTextScale()) - val keyFace = statefulRounded(color, pressedColor, skinRadiusPx()) - val keyEdge = ImeDrawableFactory.rounded(pressedColor, skinRadiusPx()) - val keyCap = LayerDrawable(arrayOf(keyEdge, keyFace)).apply { - setLayerInset(1, 0, 0, 0, toPx(1).coerceAtLeast(1)) - } + val keyRadius = toPx(ImeGeometryTokens.KEY_RADIUS_DP) + val keyFace = statefulRounded(color, pressedColor, keyRadius) + val keyCap = ImeDrawableFactory.keyCap(keyFace, pressedColor, keyRadius, toPx(1)) val halfGap = toPx(ImeGeometryTokens.KEY_GAP_DP) / 2 view.background = InsetDrawable(keyCap, halfGap, halfGap, halfGap, halfGap) - view.background?.alpha = - skinOpacity().coerceIn(70, 100) * 255 / 100 view.elevation = 0f when { @@ -137,6 +129,14 @@ internal class ImeThemeApplier( private fun applyLinearLayout(view: LinearLayout, t: ImeTheme.Tokens) { when (view.tag) { + "app-setting-key" -> { + val radius = toPx(ImeGeometryTokens.KEY_RADIUS_DP) + view.background = ImeDrawableFactory.keyCap( + statefulRounded(t.keyBackground, t.keyPressedBackground, radius), + t.border, radius, toPx(1), + ) + } + "app-setting-group" -> view.background = null "candidate-first-row" -> { val selected = t.keyBackground view.background = statefulRounded( @@ -152,11 +152,25 @@ internal class ImeThemeApplier( toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } + // Rows sit inside a card that clips to its corners, so the pressed + // fill is square and the card supplies the rounding. "setting-row" -> if (view.isClickable) { view.background = statefulRounded( Color.TRANSPARENT, ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + 0, + ) + } + "settings-card" -> view.background = ImeDrawableFactory.rounded( + t.toolCardBackground, + toPx(ImeGeometryTokens.CARD_RADIUS_DP), + ) + "quick-tile" -> { + val fill = if (view.isSelected) t.primary else t.toolCardBackground + view.background = statefulRounded( + fill, + if (view.isSelected) ImeSurfacePolicy.primaryPressed(t) else ImeSurfacePolicy.pressedSurface(fill, t), + toPx(14), ) } "nine-symbol-scroll-content", "digits-symbol-scroll-content" -> view.background = null @@ -166,10 +180,14 @@ internal class ImeThemeApplier( if (selected) t.primary else Color.TRANSPARENT, if (selected) toPx(2) else 0) view.invalidate() } - "segmented-track" -> view.background = ImeDrawableFactory.rounded(t.functionKeyBackground, toPx(12)) + "segmented-track" -> view.background = ImeDrawableFactory.rounded( + if (hasAncestorTag(view, "settings-panel")) ImeSurfacePolicy.controlTrack(t) else t.functionKeyBackground, + toPx(12), + ) "segmented-track-tall" -> view.background = paintedWithinTarget( - ImeDrawableFactory.rounded(t.functionKeyBackground, toPx(12)), + ImeDrawableFactory.rounded(ImeSurfacePolicy.controlTrack(t), toPx(10)), SEGMENT_PAINTED_DP, + padsView = false, ) "phrase-card" -> view.background = ImeDrawableFactory.rounded(t.toolCardBackground, toPx(14)) "setting-group" -> { @@ -188,21 +206,14 @@ internal class ImeThemeApplier( 0, ) } - "panel-head" -> view.setBackgroundColor(t.panelHeadBackground) + // A panel's title row shares the panel's ground: no separate band. + "panel-head" -> view.setBackgroundColor(t.keyboardBackground) "toolbar-row" -> view.setBackgroundColor(t.toolbarBackground) "compose-zone" -> view.setBackgroundColor(t.candidateBackground) "candidate-field" -> view.background = null else -> { - if ( - (view.tag as? String)?.startsWith("tool:") == true && - view.isClickable - ) { - view.background = statefulRounded( - t.toolCardBackground, - ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), - toPx(ImeGeometryTokens.CARD_RADIUS_DP), - ) - } + // A tool is a column (tile + name); its tile paints the press. + if ((view.tag as? String)?.startsWith("tool:") == true) view.background = null } } @@ -273,11 +284,26 @@ internal class ImeThemeApplier( toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } + // Row icons are decoration, so they stay neutral; the accent is + // reserved for controls and their on/current state. view.tag == "setting-icon" -> { - view.imageTintList = ColorStateList.valueOf(t.primary) - view.background = ImeDrawableFactory.rounded( - ImeSurfacePolicy.subtleAccentSurface(t), - toPx(ImeGeometryTokens.KEY_RADIUS_DP), + view.imageTintList = ColorStateList.valueOf(ImeSurfacePolicy.iconTint(t)) + view.background = ImeDrawableFactory.rounded(ImeSurfacePolicy.iconTile(t), toPx(9)) + } + view.tag == "setting-chevron" -> + view.imageTintList = ColorStateList.valueOf(ImeSurfacePolicy.chevron(t)) + view.tag == "quick-tile-icon" -> { + val selected = (view.parent as? View)?.isSelected == true + view.imageTintList = ColorStateList.valueOf( + if (selected) ImeDrawableFactory.contrastText(t.primary) else ImeSurfacePolicy.iconTint(t), + ) + } + view.tag == "tool-icon" -> { + view.imageTintList = ColorStateList.valueOf(ImeSurfacePolicy.iconTint(t)) + view.background = statefulRounded( + t.toolCardBackground, + ImeSurfacePolicy.pressedSurface(t.toolCardBackground, t), + toPx(16), ) } view.tag == "undo-toolbar" -> view.imageTintList = ColorStateList.valueOf(t.keySecondaryText) @@ -288,9 +314,9 @@ internal class ImeThemeApplier( view.tag == "key-panel-back" -> { view.imageTintList = ColorStateList.valueOf(t.keyText) view.background = statefulRounded( - t.panelHeadBackground, - ImeSurfacePolicy.pressedSurface(t.panelHeadBackground, t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), + Color.TRANSPARENT, + ImeSurfacePolicy.pressedSurface(t.keyboardBackground, t), + toPx(ImeGeometryTokens.PILL_RADIUS_DP), ) } ( @@ -312,8 +338,8 @@ internal class ImeThemeApplier( private fun applySeekBar(view: SeekBar, t: ImeTheme.Tokens) { view.progressDrawable = view.context.getDrawable(android.R.drawable.progress_horizontal)?.mutate() view.progressTintList = ColorStateList.valueOf(t.primary) - view.progressBackgroundTintList = ColorStateList.valueOf(t.functionKeyBackground) - view.secondaryProgressTintList = ColorStateList.valueOf(t.functionKeyBackground) + view.progressBackgroundTintList = ColorStateList.valueOf(ImeSurfacePolicy.controlTrack(t)) + view.secondaryProgressTintList = ColorStateList.valueOf(ImeSurfacePolicy.controlTrack(t)) if (android.os.Build.VERSION.SDK_INT >= 29) { view.maxHeight = toPx(4) view.minHeight = toPx(4) @@ -355,6 +381,7 @@ internal class ImeThemeApplier( view.setTextColor(t.candidateText) tag == "panel-note" || + tag == "setting-sub" || tag == "setting-value" || tag == "setting-chevron" || tag == "panel-section-title" -> @@ -363,6 +390,10 @@ internal class ImeThemeApplier( tag == "setting-label" -> view.setTextColor(t.keyText) + tag == "quick-tile-label" -> view.setTextColor( + if ((view.parent as? View)?.isSelected == true) ImeDrawableFactory.contrastText(t.primary) else t.keyText, + ) + tag == "panel-error" -> { val error = t.destructive view.setTextColor(error) @@ -451,17 +482,22 @@ internal class ImeThemeApplier( tag == "segment-selected" -> { view.setTextColor(t.keyText) - view.background = ImeDrawableFactory.rounded(t.toolCardBackground, toPx(10), t.border, toPx(1)) + view.background = if (hasAncestorTag(view, "settings-panel")) { + ImeDrawableFactory.rounded(ImeSurfacePolicy.segmentSelected(t), toPx(10)) + } else { + ImeDrawableFactory.rounded(t.toolCardBackground, toPx(10), t.border, toPx(1)) + } view.typeface = android.graphics.Typeface.DEFAULT_BOLD } tag == "segment-option" -> { view.setTextColor(t.keySecondaryText); view.background = null } tag == "segment-selected-tall" -> { view.setTextColor(t.keyText) view.background = paintedWithinTarget( - ImeDrawableFactory.rounded(t.toolCardBackground, toPx(10), t.border, toPx(1)), - SEGMENT_PAINTED_DP, + ImeDrawableFactory.rounded(ImeSurfacePolicy.segmentSelected(t), toPx(8)), + SEGMENT_PAINTED_DP - 4, + padsView = false, ) - view.typeface = android.graphics.Typeface.DEFAULT_BOLD + view.typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) } tag == "segment-option-tall" -> { view.setTextColor(t.keySecondaryText); view.background = null } tag == "keyboard-radio-selected" -> view.setTextColor(t.primary) @@ -504,16 +540,6 @@ internal class ImeThemeApplier( ) } - tag == "accent-custom" -> { view.setTextColor(t.keyText); view.background = null } - tag?.startsWith("accent-selected-mark:") == true -> { - val hex = tag.substringAfter(':') - view.setTextColor( - ImeDrawableFactory.contrastText( - AccentPalette.parse(hex), - ), - ) - } - tag == "key-panel-back" -> { view.setTextColor(t.keyText) view.background = statefulRounded( @@ -590,21 +616,17 @@ internal class ImeThemeApplier( toPx(ImeGeometryTokens.KEY_RADIUS_DP), ) } - "accent-swatch" -> { - view.background = statefulRounded( - Color.TRANSPARENT, - ImeSurfacePolicy.pressedSurface(t.sideKeyBackground, t), - toPx(ImeGeometryTokens.KEY_RADIUS_DP), - ) - } "toggle" -> { val seed = view.contentDescription?.toString() ?.substringBefore(',') .orEmpty() val enabled = toggleState(seed) - view.background = ImeDrawableFactory.rounded( - if (enabled) t.primary else t.panelHeadBackground, - toPx(ImeGeometryTokens.PILL_RADIUS_DP), + view.background = paintedWithinTarget( + ImeDrawableFactory.rounded( + ImeSurfacePolicy.switchTrack(enabled, t), + toPx(ImeGeometryTokens.PILL_RADIUS_DP), + ), + ImeGeometryTokens.SWITCH_HEIGHT_DP, ) } } @@ -625,19 +647,14 @@ internal class ImeThemeApplier( ) } "toggle-knob" -> { - val parent = view.parent as? FrameLayout - val seed = parent?.contentDescription?.toString() - ?.substringBefore(',') - .orEmpty() - val trackColor = - if (toggleState(seed)) t.primary else t.panelHeadBackground view.background = ImeDrawableFactory.rounded( - ImeDrawableFactory.contrastText(trackColor), + ImeSurfacePolicy.switchKnob(t), toPx(ImeGeometryTokens.PILL_RADIUS_DP), ) } "setting-divider" -> view.setBackgroundColor(ImeDrawableFactory.withAlpha(t.border, if (ImeSurfacePolicy.isDark(t)) 150 else 48)) + "row-hairline" -> view.setBackgroundColor(ImeSurfacePolicy.hairline(t)) } } @@ -649,9 +666,18 @@ internal class ImeThemeApplier( private fun paintedWithinTarget( drawable: android.graphics.drawable.Drawable, paintedDp: Int, + padsView: Boolean = true, ): InsetDrawable { - val inset = toPx((ImeGeometryTokens.TOUCH_TARGET_DP - paintedDp) / 2) - return InsetDrawable(drawable, 0, inset, 0, inset) + val extra = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) - toPx(paintedDp) + if (padsView) return InsetDrawable(drawable, 0, extra / 2, 0, extra - extra / 2) + // An InsetDrawable background also pads its view by the inset, which + // squeezed a segment's label into the painted pill and clipped it. + return object : InsetDrawable(drawable, 0, extra / 2, 0, extra - extra / 2) { + override fun getPadding(padding: android.graphics.Rect): Boolean { + padding.set(0, 0, 0, 0) + return false + } + } } private fun hasAncestorTag(view: View, tag: String): Boolean { diff --git a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt index 4da619ec..bba91702 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -27,9 +27,9 @@ import llc.slacker.openime.data.ImeSettingsRepository import llc.slacker.openime.handwriting.HandwritingPadView import llc.slacker.openime.handwriting.HandwritingResult import llc.slacker.openime.handwriting.UnavailableHandwritingProvider -import llc.slacker.openime.theme.AccentPalette import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeSurfacePolicy +import llc.slacker.openime.theme.ImeSpacingTokens import llc.slacker.openime.theme.ImeTheme import llc.slacker.openime.theme.ImeTypographyTokens import llc.slacker.openime.widget.ImeKeyView @@ -134,14 +134,14 @@ internal class ImePanelRenderer( } val grid = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } val cards = listOf( - ToolEntry("剪贴板", Panel.CLIPBOARD, R.drawable.ic_clipboard), - ToolEntry("表情", Panel.EMOJI, R.drawable.ic_emoji), - ToolEntry("符号", Panel.SYMBOLS, R.drawable.ic_symbols), - ToolEntry("语音输入", Panel.VOICE, R.drawable.ic_mic), - ToolEntry("切换键盘", Panel.KEYBOARD_SELECT, R.drawable.ic_keyboard), - ToolEntry("文本编辑", Panel.TEXT_EDITOR, R.drawable.ic_text_cursor), - ToolEntry("浮动键盘", iconRes = R.drawable.ic_floating, action = onEnableFloatingKeyboard), - ToolEntry("设置", Panel.SETTINGS, R.drawable.ic_settings), + ToolEntry("剪贴板", Panel.CLIPBOARD, R.drawable.ic_pref_clipboard), + ToolEntry("表情", Panel.EMOJI, R.drawable.ic_pref_emoji), + ToolEntry("符号", Panel.SYMBOLS, R.drawable.ic_pref_symbols), + ToolEntry("语音输入", Panel.VOICE, R.drawable.ic_pref_mic), + ToolEntry("切换键盘", Panel.KEYBOARD_SELECT, R.drawable.ic_pref_globe), + ToolEntry("文本编辑", Panel.TEXT_EDITOR, R.drawable.ic_pref_cursor), + ToolEntry("浮动键盘", iconRes = R.drawable.ic_pref_floating, action = onEnableFloatingKeyboard), + ToolEntry("设置", Panel.SETTINGS, R.drawable.ic_pref_settings), ).filter { it.enabled } cards.chunked(4).forEach { chunk -> @@ -175,7 +175,7 @@ internal class ImePanelRenderer( LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, toPx(ImeGeometryTokens.TOOL_CARD_HEIGHT_DP), - ).apply { bottomMargin = toPx(8) }, + ).apply { bottomMargin = toPx(ImeSpacingTokens.LG_DP) }, ) } body.addView( @@ -639,6 +639,10 @@ internal class ImePanelRenderer( } } + /** + * One tool: a 56dp icon tile with its name underneath. The whole column is + * the touch target; the tile shows the press. + */ private fun toolCard( iconRes: Int, label: String, @@ -646,8 +650,7 @@ internal class ImePanelRenderer( ): LinearLayout { val card = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER - setPadding(0, toPx(8), 0, toPx(6)) + gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP minimumWidth = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) tag = "tool:$label" @@ -664,16 +667,18 @@ internal class ImePanelRenderer( setImageResource(iconRes) scaleType = ImageView.ScaleType.CENTER_INSIDE contentDescription = null + isDuplicateParentStateEnabled = true + tag = "tool-icon" }, - LinearLayout.LayoutParams(toPx(24), toPx(24)).apply { - bottomMargin = toPx(10) + LinearLayout.LayoutParams(toPx(56), toPx(56)).apply { + bottomMargin = toPx(ImeSpacingTokens.SM_DP) }, ) card.addView( TextView(context).apply { text = label - typeface = android.graphics.Typeface.DEFAULT - textSize = ImeTypographyTokens.BODY_SP + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + textSize = ImeTypographyTokens.SMALL_SP gravity = Gravity.CENTER includeFontPadding = false importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO @@ -700,7 +705,7 @@ internal class ImePanelRenderer( private val paint = Paint(Paint.ANTI_ALIAS_FLAG) override fun onDraw(canvas: Canvas) { val dark = resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == android.content.res.Configuration.UI_MODE_NIGHT_YES - val tokens = ImeTheme.IOS.tokens(ImeSettingsRepository.loadAppearance(context), dark, AccentPalette.parse(ImeSettingsRepository.loadSkinColor(context))) + val tokens = ImeTheme.IOS.tokens(ImeSettingsRepository.loadAppearance(context), dark) val h = height / 4f paint.color = tokens.functionKeyBackground for (i in 0..3) { diff --git a/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt b/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt index 1da46d80..887f0b41 100644 --- a/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt +++ b/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt @@ -98,7 +98,6 @@ internal class PanelHeaderFactory( Panel.TEXT_EDITOR -> "文本编辑" Panel.SETTINGS -> "设置" Panel.FUZZY_SETTINGS -> "模糊音纠错" - Panel.SKIN_SETTINGS -> "强调色与按键皮肤" Panel.NONE, Panel.CANDIDATE_EXPANDED -> "键盘" } } diff --git a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt index f19d355d..249c1656 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -1,21 +1,12 @@ package llc.slacker.openime.panel -import android.app.AlertDialog import android.content.Context -import android.content.DialogInterface -import android.content.res.ColorStateList -import android.graphics.Color -import android.graphics.drawable.GradientDrawable import android.os.Build -import android.text.InputFilter -import android.text.InputType import android.text.TextUtils import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.animation.DecelerateInterpolator -import android.view.inputmethod.EditorInfo -import android.widget.EditText import android.widget.FrameLayout import android.widget.HorizontalScrollView import android.widget.ImageView @@ -25,13 +16,10 @@ import android.widget.SeekBar import android.widget.TextView import llc.slacker.openime.ImeSettingsActivity import llc.slacker.openime.R -import llc.slacker.openime.data.ImeHandedness -import llc.slacker.openime.setup.SetupUi -import llc.slacker.openime.theme.AccentPalette import llc.slacker.openime.theme.ImeAppearance -import llc.slacker.openime.theme.ImeDrawableFactory import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeMotionTokens +import llc.slacker.openime.theme.ImeSpacingTokens import llc.slacker.openime.theme.ImeTheme import llc.slacker.openime.theme.ImeTypographyTokens @@ -40,6 +28,12 @@ import llc.slacker.openime.theme.ImeTypographyTokens * * Product settings remain owned by ImeKeyboardView/Service. This controller * owns only panel construction, scroll/focus restoration, and control gestures. + * + * The same settings render two ways. The preferences page is a grouped list: + * one card per section, rows split by inset hairlines, a neutral icon tile per + * row. The keyboard's own panel is only as tall as the keyboard, so it leads + * with four quick toggles, puts appearance and height on the first screen and + * uses compact rows without icons or descriptions below that. */ internal class SettingsPanelController( private val context: Context, @@ -61,31 +55,23 @@ internal class SettingsPanelController( /** State of the toggles added after the first four, looked up by their label. */ private val currentExtraToggle: (String) -> Boolean, private val currentFuzzy: () -> Boolean, - private val currentSkinOpacity: () -> Int, - private val currentSkinRadius: () -> Int, - private val currentSkinFontSize: () -> Int, - private val currentSkinColor: () -> String, - private val currentHandedness: () -> ImeHandedness, private val currentKeyboardHeightPercent: () -> Int, private val currentFloatingWidthPercent: () -> Int, private val currentFloatingOpacityPercent: () -> Int, private val onThemeSelected: (ImeTheme) -> Unit, private val onAppearanceSelected: (ImeAppearance) -> Unit, private val onToggleChanged: (String, Boolean) -> Unit, - private val onSkinChanged: (Int, Int, Int, String) -> Unit, - private val onHandednessChanged: (ImeHandedness) -> Unit, private val onKeyboardHeightChanged: (Int) -> Unit, private val onFloatingStyleChanged: (Int, Int) -> Unit, private val onShowFuzzySettings: () -> Unit, - private val onShowSkinSettings: () -> Unit, private val onOpenAboutData: () -> Unit, private val onFeedback: () -> Unit, private val applyTheme: () -> Unit, private val onHierarchyRebuilt: () -> Unit, ) { + private val standalone = context is ImeSettingsActivity private var storedScrollY = 0 private var settingsScroll: ScrollView? = null - private var showingSkinOnly = false fun scrollPosition(): Int = (settingsScroll?.scrollY ?: storedScrollY).coerceAtLeast(0) @@ -97,8 +83,7 @@ internal class SettingsPanelController( } } - fun renderSettings(reusePanel: Boolean = false, skinOnly: Boolean = false) { - showingSkinOnly = skinOnly + fun renderSettings(reusePanel: Boolean = false) { val previousFocusKey = if (reusePanel) semanticFocusKey(expandedPanel.findFocus()) else null val previousScrollY = @@ -106,10 +91,10 @@ internal class SettingsPanelController( if (!reusePanel || expandedPanel.childCount == 0) { expandedPanel.addView( - createHeader(if (skinOnly) "强调色与按键皮肤" else "偏好设置"), + createHeader(if (standalone) "偏好设置" else "设置"), LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOP_BAR_HEIGHT_DP), + if (standalone) context.resources.getDimensionPixelSize(R.dimen.setup_top_bar_height) else toPx(ImeGeometryTokens.TOP_BAR_HEIGHT_DP), ), ) } else { @@ -131,91 +116,15 @@ internal class SettingsPanelController( val content = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - setPadding(toPx(12), toPx(12), toPx(12), toPx(18)) + if (standalone) { + setPadding(toPx(ImeSpacingTokens.LG_DP), 0, toPx(ImeSpacingTokens.LG_DP), toPx(ImeSpacingTokens.XXL_DP)) + } else { + setPadding(toPx(ImeSpacingTokens.MD_DP), toPx(ImeSpacingTokens.XS_DP), toPx(ImeSpacingTokens.MD_DP), toPx(ImeSpacingTokens.LG_DP)) + } tag = "settings-panel" } + if (standalone) appSettings(content) else keyboardSettings(content) - if (skinOnly) { - content.addView(createSectionTitle("强调色"), wrapParams()) - content.addView(accentColorRow(), groupParams()) - content.addView(createSectionTitle("按键皮肤"), wrapParams()) - content.addView(skinSliders(), groupParams()) - } else { - content.addView(createSectionTitle("外观与布局"), wrapParams()) - content.addView(settingGroup( - segmentedSetting("外观", ImeAppearance.entries.map { it.label }, currentAppearance().label) { label -> - ImeAppearance.entries.first { it.label == label }.let(onAppearanceSelected) - renderSettings(reusePanel = true) - }, - segmentedSetting("单手模式", ImeHandedness.entries.map { it.label }, currentHandedness().label) { label -> - ImeHandedness.entries.first { it.label == label }.let(onHandednessChanged) - renderSettings(reusePanel = true) - }, - settingsSlider("键盘高度", 80, 120, currentKeyboardHeightPercent(), onKeyboardHeightChanged), - ), groupParams()) - if (context !is ImeSettingsActivity) { - content.addView(createSectionTitle("更多外观"), wrapParams()) - content.addView(settingGroup(settingNavigationRow("强调色与按键皮肤", "颜色、圆角、不透明度与字号", onShowSkinSettings)), groupParams()) - } - - if (context is ImeSettingsActivity) { - content.addView(createSectionTitle("强调色"), wrapParams()) - content.addView(accentColorRow(), groupParams()) - content.addView(createSectionTitle("按键皮肤"), wrapParams()) - content.addView(skinSliders(), groupParams()) - } - content.addView(createSectionTitle("浮动键盘"), wrapParams()) - content.addView(settingGroup( - settingsSlider("浮动宽度", 72, 100, currentFloatingWidthPercent()) { onFloatingStyleChanged(it, currentFloatingOpacityPercent()) }, - settingsSlider("浮动透明度", 82, 100, currentFloatingOpacityPercent()) { onFloatingStyleChanged(currentFloatingWidthPercent(), it) }, - ), groupParams()) - content.addView(createSectionTitle("按键与输入"), wrapParams()) - content.addView( - settingGroup( - settingToggleRow("按键音效", "机械轴敲击反馈"), - settingToggleRow("触感震动", "轻微触感反馈"), - settingToggleRow("按键气泡", "按下时显示字母预览"), - settingToggleRow("数字和符号提示", "字母键右上角显示数字和符号"), - settingToggleRow("上滑输入数字", "按键上滑输入右上角的数字或符号"), - ), - groupParams(), - ) - - content.addView(createSectionTitle("智能输入"), wrapParams()) - content.addView( - settingGroup( - settingNavigationRow( - "模糊音与智能纠错", - "进入后配置 z/zh、c/ch、s/sh 等规则", - onShowFuzzySettings, - ), - settingToggleRow("表情联想", "选词后在联想栏先给出相关表情"), - ), - groupParams(), - ) - - content.addView(createSectionTitle("语音输入"), wrapParams()) - content.addView( - settingGroup( - settingToggleRow("语音去语气词", "去掉“嗯”“呃”等口头停顿"), - settingToggleRow("标点用空格代替", "语音里的逗号、句号等写成空格"), - ), - groupParams(), - ) - - content.addView(createSectionTitle("数据"), wrapParams()) - content.addView( - settingGroup( - settingNavigationRow( - "关于与数据", - "版本、隐私、导出与导入", - onOpenAboutData, - ), - ), - groupParams(), - ) - - } scroll.addView( content, ViewGroup.LayoutParams( @@ -244,44 +153,92 @@ internal class SettingsPanelController( } } - private fun skinSliders() = settingGroup( - settingsSlider("圆角", 0, 24, currentSkinRadius()) { onSkinChanged(currentSkinOpacity(), it, currentSkinFontSize(), currentSkinColor()) }, - settingsSlider("不透明度", 70, 100, currentSkinOpacity()) { onSkinChanged(it, currentSkinRadius(), currentSkinFontSize(), currentSkinColor()) }, - settingsSlider("按键字号", 14, 22, currentSkinFontSize()) { onSkinChanged(currentSkinOpacity(), currentSkinRadius(), it, currentSkinColor()) }, - ) + private fun appSettings(content: LinearLayout) { + content.addSection("外观", first = true) + content.addCard( + segmentedRow("外观", R.drawable.ic_pref_appearance), + sliderRow("键盘高度", R.drawable.ic_pref_height, 80, 120, currentKeyboardHeightPercent(), onChange = onKeyboardHeightChanged), + ) + content.addSection("浮动键盘") + content.addCard( + sliderRow("浮动宽度", R.drawable.ic_pref_width, 72, 100, currentFloatingWidthPercent()) { + onFloatingStyleChanged(it, currentFloatingOpacityPercent()) + }, + sliderRow("浮动透明度", R.drawable.ic_pref_opacity, 82, 100, currentFloatingOpacityPercent()) { + onFloatingStyleChanged(currentFloatingWidthPercent(), it) + }, + ) + content.addSection("按键与输入") + content.addCard( + toggleRow("按键音效", "机械轴敲击反馈", R.drawable.ic_pref_sound), + toggleRow("触感震动", "清脆短促,按下即停", R.drawable.ic_pref_haptic), + toggleRow("按键气泡", "按下时显示字母预览", R.drawable.ic_pref_bubble), + toggleRow("数字和符号提示", "字母键右上角显示数字和符号", R.drawable.ic_pref_hints), + toggleRow("上滑输入数字", "按键上滑输入右上角的数字或符号", R.drawable.ic_pref_swipe_up), + ) + content.addSection("智能输入") + content.addCard( + navigationRow("模糊音与智能纠错", "z/zh、c/ch、s/sh 等规则", R.drawable.ic_pref_fuzzy, onShowFuzzySettings), + toggleRow("表情联想", "选词后在联想栏先给出相关表情", R.drawable.ic_pref_emoji), + ) + content.addSection("语音输入") + content.addCard( + toggleRow("语音去语气词", "去掉“嗯”“呃”等口头停顿", R.drawable.ic_pref_waveform), + toggleRow("标点用空格代替", "语音里的逗号、句号等写成空格", R.drawable.ic_pref_space), + ) + content.addSection("数据") + content.addCard( + navigationRow("关于与数据", "版本、隐私、导出与导入", R.drawable.ic_pref_info, onOpenAboutData), + ) + } - private fun segmentedSetting(label: String, labels: List, selected: String, onSelected: (String) -> Unit): View = - LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(toPx(16), 0, toPx(16), 0) - addView(labelText(label, ImeTypographyTokens.BODY_SP).apply { typeface = android.graphics.Typeface.DEFAULT_BOLD; gravity = Gravity.CENTER_VERTICAL }, - LinearLayout.LayoutParams(0, toPx(56), 1f)) - addView(LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - // The painted track is 34dp (inset in the theme) but every option is a - // full 48dp touch target. - tag = "segmented-track-tall" - setPadding(toPx(2), toPx(2), toPx(2), toPx(2)) - labels.forEach { value -> - addView(TextView(context).apply { - text = value; textSize = ImeTypographyTokens.BODY_SP; gravity = Gravity.CENTER; includeFontPadding = false - minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) - tag = if (value == selected) "segment-selected-tall" else "segment-option-tall" - contentDescription = "$value,${if (value == selected) "已选中" else "未选中"}" - isClickable = true; isFocusable = true - setOnClickListener { onFeedback(); onSelected(value) } - }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) - } - }, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 2f)) - } + private fun keyboardSettings(content: LinearLayout) { + content.addView( + quickToggles( + Triple("按键音效", "按键音效", R.drawable.ic_pref_sound), + Triple("触感震动", "触感震动", R.drawable.ic_pref_haptic), + Triple("按键气泡", "按键气泡", R.drawable.ic_pref_bubble), + Triple("数字和符号提示", "数字提示", R.drawable.ic_pref_hints), + ), + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT), + ) + content.addCard( + segmentedRow("外观", iconRes = 0), + sliderRow("键盘高度", 0, 80, 120, currentKeyboardHeightPercent(), onChange = onKeyboardHeightChanged), + topMarginDp = ImeSpacingTokens.MD_DP, + ) + content.addSection("浮动键盘") + content.addCard( + sliderRow("浮动宽度", 0, 72, 100, currentFloatingWidthPercent(), shortLabel = "宽度") { + onFloatingStyleChanged(it, currentFloatingOpacityPercent()) + }, + sliderRow("浮动透明度", 0, 82, 100, currentFloatingOpacityPercent(), shortLabel = "透明度") { + onFloatingStyleChanged(currentFloatingWidthPercent(), it) + }, + ) + content.addSection("输入") + content.addCard( + toggleRow("上滑输入数字", null, 0), + toggleRow("表情联想", null, 0), + navigationRow("模糊音与智能纠错", null, 0, onShowFuzzySettings), + ) + content.addSection("语音") + content.addCard( + toggleRow("语音去语气词", null, 0), + toggleRow("标点用空格代替", null, 0), + ) + content.addCard( + navigationRow("关于与数据", null, 0, onOpenAboutData), + topMarginDp = ImeSpacingTokens.MD_DP, + ) + } fun renderFuzzySettings() { expandedPanel.addView( createHeader("模糊音纠错"), LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + if (standalone) context.resources.getDimensionPixelSize(R.dimen.setup_top_bar_height) else toPx(ImeGeometryTokens.TOUCH_TARGET_DP), ), ) val scroll = ScrollView(context).apply { @@ -291,50 +248,51 @@ internal class SettingsPanelController( } val content = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - setPadding(toPx(12), toPx(12), toPx(12), toPx(18)) + val inset = toPx(if (standalone) ImeSpacingTokens.LG_DP else ImeSpacingTokens.MD_DP) + setPadding(inset, toPx(ImeSpacingTokens.SM_DP), inset, toPx(if (standalone) ImeSpacingTokens.XL_DP else ImeSpacingTokens.LG_DP)) tag = "fuzzy-settings-panel" } content.addView( - TextView(context).apply { - text = "近音输入时,候选会同时尝试相近声母;不会改变你已输入的拼音。" - textSize = ImeTypographyTokens.PANEL_BODY_SP - setLineSpacing(0f, 1.15f) - tag = "panel-note" - }, + noteText("近音输入时,候选会同时尝试相近声母;不会改变你已输入的拼音。"), LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(54), - ).apply { bottomMargin = toPx(10) }, + LinearLayout.LayoutParams.WRAP_CONTENT, + ).apply { + marginStart = toPx(ImeSpacingTokens.LG_DP) + marginEnd = toPx(ImeSpacingTokens.LG_DP) + bottomMargin = toPx(ImeSpacingTokens.MD_DP) + }, ) - content.addView( - settingGroup( - settingToggleRow("启用模糊音", "z/zh · c/ch · s/sh · l/n"), + content.addCard( + toggleRow( + "启用模糊音", + if (standalone) "z/zh · c/ch · s/sh · l/n" else null, + if (standalone) R.drawable.ic_pref_fuzzy else 0, ), - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = toPx(14) }, ) - content.addView(createSectionTitle("当前规则"), wrapParams()) + content.addSection("当前规则") content.addView( TextView(context).apply { text = "z / zh · c / ch · s / sh · l / n · en / eng · in / ing" - textSize = ImeTypographyTokens.PANEL_BODY_SP - setPadding(toPx(16), toPx(16), toPx(16), toPx(16)) + textSize = ImeTypographyTokens.BODY_SP + setPadding(toPx(16), toPx(14), toPx(16), toPx(14)) tag = "fuzzy-rules" }, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = toPx(14) }, + ), ) content.addView( - TextView(context).apply { - text = "规则由输入法自动参与候选计算,暂不单独修改每一组映射。" - textSize = ImeTypographyTokens.PANEL_NOTE_SP - tag = "panel-note" + noteText("规则由输入法自动参与候选计算,暂不单独修改每一组映射。"), + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ).apply { + marginStart = toPx(ImeSpacingTokens.LG_DP) + marginEnd = toPx(ImeSpacingTokens.LG_DP) + topMargin = toPx(ImeSpacingTokens.SM_DP) }, - wrapParams(), ) scroll.addView( content, @@ -353,165 +311,412 @@ internal class SettingsPanelController( ) } - private fun settingGroup(vararg rows: View): LinearLayout = - LinearLayout(context).apply { + // ---- Sections and cards ---------------------------------------------- + + private fun LinearLayout.addSection(title: String, first: Boolean = false) { + addView( + TextView(context).apply { + text = title + textSize = if (standalone) ImeTypographyTokens.DETAIL_SP else ImeTypographyTokens.SMALL_SP + includeFontPadding = false + tag = "panel-section-title" + if (Build.VERSION.SDK_INT >= 28) isAccessibilityHeading = true + }, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ).apply { + marginStart = toPx(if (standalone) ImeSpacingTokens.LG_DP else 14) + topMargin = toPx( + when { + first -> ImeSpacingTokens.MD_DP + standalone -> 28 + else -> ImeSpacingTokens.LG_DP + }, + ) + bottomMargin = toPx(if (standalone) ImeSpacingTokens.SM_DP else 6) + }, + ) + } + + /** + * One rounded card holding [rows], separated by hairlines that start where + * the row text starts (after the icon tile on the preferences page). + */ + private fun LinearLayout.addCard(vararg rows: View, topMarginDp: Int = 0) { + val card = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - tag = "setting-group" - rows.forEachIndexed { index, row -> - if (index > 0) { - addView( - View(context).apply { tag = "setting-divider" }, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(1), - ).apply { - marginStart = 0 - marginEnd = 0 - }, - ) - } + tag = "settings-card" + clipToOutline = true + } + rows.forEachIndexed { index, row -> + if (index > 0) { + card.addView( + View(context).apply { + tag = "row-hairline" + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(1).coerceAtLeast(1)).apply { + marginStart = toPx(if (standalone) 60 else 14) + }, + ) + } + card.addView( + row, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT), + ) + } + addView( + card, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + topMargin = toPx(topMarginDp) + }, + ) + } + + // ---- Rows --------------------------------------------------------------- + + /** Row shell: an optional icon tile, then [body] filling the rest. */ + private fun rowShell(iconRes: Int, alignTop: Boolean = false): LinearLayout = + LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = if (alignTop) Gravity.TOP else Gravity.CENTER_VERTICAL + tag = "setting-row" + minimumHeight = toPx(if (standalone) ImeGeometryTokens.SETTING_ROW_HEIGHT_DP else 52) + if (standalone) { + setPadding(toPx(16), toPx(12), toPx(16), toPx(12)) + } else { + setPadding(toPx(14), toPx(2), toPx(12), toPx(2)) + } + if (iconRes != 0) { addView( - row, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - if (row is TextView) toPx(54) - else LinearLayout.LayoutParams.WRAP_CONTENT, - ), + ImageView(context).apply { + setImageResource(iconRes) + scaleType = ImageView.ScaleType.FIT_CENTER + setPadding(toPx(7), toPx(7), toPx(7), toPx(7)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + tag = "setting-icon" + }, + LinearLayout.LayoutParams(toPx(32), toPx(32)).apply { marginEnd = toPx(12) }, ) } } - private fun settingIcon(label: String): ImageView = - ImageView(context).apply { - setImageResource( - when (label) { - "按键音效" -> R.drawable.ic_volume - "触感震动" -> R.drawable.ic_vibration - "按键气泡" -> R.drawable.ic_bubble - "模糊音与智能纠错", "启用模糊音" -> R.drawable.ic_tune - "关于与数据" -> R.drawable.ic_info - else -> R.drawable.ic_tune - }, - ) - scaleType = ImageView.ScaleType.CENTER + private fun labelBlock(label: String, sub: String?): LinearLayout = + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_VERTICAL importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - tag = "setting-icon" + addView(rowLabel(label), wrapParams()) + if (sub != null) { + addView( + TextView(context).apply { + text = sub + textSize = ImeTypographyTokens.DETAIL_SP + includeFontPadding = false + setLineSpacing(0f, 1.2f) + tag = "setting-sub" + setPadding(0, toPx(ImeSpacingTokens.XS_DP), 0, 0) + }, + wrapParams(), + ) + } } - private fun settingToggleRow(label: String, sub: String): LinearLayout { - val row = LinearLayout(context) + private fun rowLabel(label: String): TextView = TextView(context).apply { + text = label + textSize = ImeTypographyTokens.ROW_SP + includeFontPadding = false + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + tag = "setting-label" + } + + private fun toggleRow(label: String, sub: String?, iconRes: Int): LinearLayout { + val row = rowShell(iconRes) fun updateRowAccessibility(enabled: Boolean) { - row.contentDescription = - "$label,$sub,${if (enabled) "已开启" else "已关闭"}" + row.contentDescription = listOfNotNull(label, sub, if (enabled) "已开启" else "已关闭").joinToString(",") if (Build.VERSION.SDK_INT >= 30) { row.stateDescription = if (enabled) "已开启" else "已关闭" } } - val toggleView = toggle(label, ::updateRowAccessibility).apply { isFocusable = false - importantForAccessibility = - View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS } row.apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(toPx(14), 0, toPx(14), 0) - tag = "setting-row" - minimumHeight = toPx(ImeGeometryTokens.SETTING_ROW_HEIGHT_DP) isClickable = true isFocusable = true + accessibilityDelegate = object : View.AccessibilityDelegate() { + override fun onInitializeAccessibilityNodeInfo(host: View, info: android.view.accessibility.AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.className = "android.widget.Switch" + info.isCheckable = true + info.isChecked = toggleState(label) + } + } setOnClickListener { toggleView.performClick() } + addView(labelBlock(label, sub), LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) + addView(toggleView, wrapParams().apply { marginStart = toPx(ImeSpacingTokens.MD_DP) }) + } + updateRowAccessibility(toggleState(label)) + return row + } + + private fun navigationRow(label: String, sub: String?, iconRes: Int, onTap: () -> Unit): LinearLayout = + rowShell(iconRes).apply { + contentDescription = listOfNotNull(label, sub, "点击进入").joinToString(",") + isClickable = true + isFocusable = true + setOnClickListener { + onFeedback() + onTap() + } + addView(labelBlock(label, sub), LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) addView( - settingIcon(label), - LinearLayout.LayoutParams(toPx(26), toPx(26)).apply { - marginEnd = toPx(8) + ImageView(context).apply { + setImageResource(R.drawable.ic_chevron_right) + scaleType = ImageView.ScaleType.FIT_CENTER + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + tag = "setting-chevron" }, + LinearLayout.LayoutParams(toPx(18), toPx(18)).apply { marginStart = toPx(ImeSpacingTokens.MD_DP) }, ) - addView( + } + + /** + * Appearance picker. On the preferences page the options sit under the + * label, full width; in the keyboard they share the label's line. + */ + private fun segmentedRow(label: String, iconRes: Int): View { + val labels = ImeAppearance.entries.map { it.label } + val selected = currentAppearance().label + // When three equal columns cannot hold the longest option at the + // current width and font scale, the options stack, each a full 48dp. + val stacked = standalone && !segmentsFitInOneRow(labels) + val track = LinearLayout(context).apply { + orientation = if (stacked) LinearLayout.VERTICAL else LinearLayout.HORIZONTAL + tag = if (stacked) "segmented-track" else "segmented-track-tall" + setPadding(toPx(2), if (stacked) toPx(2) else 0, toPx(2), if (stacked) toPx(2) else 0) + labels.forEach { value -> + addView( + TextView(context).apply { + text = value + textSize = if (standalone) ImeTypographyTokens.BODY_SP else ImeTypographyTokens.DETAIL_SP + gravity = Gravity.CENTER + includeFontPadding = false + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) + tag = when { + stacked -> if (value == selected) "segment-selected" else "segment-option" + else -> if (value == selected) "segment-selected-tall" else "segment-option-tall" + } + contentDescription = "$value,${if (value == selected) "已选中" else "未选中"}" + isClickable = true + isFocusable = true + setOnClickListener { + onFeedback() + ImeAppearance.entries.first { it.label == value }.let(onAppearanceSelected) + renderSettings(reusePanel = true) + } + }, + if (stacked) LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + else LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f), + ) + } + } + val row = rowShell(iconRes, alignTop = standalone) + if (standalone) { + row.addView( LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER_VERTICAL - addView(labelText(label, ImeTypographyTokens.BODY_SP), wrapParams()) + addView(rowLabel(label), wrapParams().apply { topMargin = toPx(6) }) addView( - labelText(sub, ImeTypographyTokens.CAPTION_SP).apply { - setPadding(0, toPx(3), 0, 0) + track, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + if (stacked) LinearLayout.LayoutParams.WRAP_CONTENT else toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + ).apply { + topMargin = toPx(if (stacked) ImeSpacingTokens.SM_DP else ImeSpacingTokens.XS_DP) }, - wrapParams(), ) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, - LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f, - ), + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f), ) - addView(toggleView, wrapParams()) + } else { + row.addView(rowLabel(label), LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) + row.addView(track, LinearLayout.LayoutParams(toPx(216), toPx(ImeGeometryTokens.TOUCH_TARGET_DP))) } - updateRowAccessibility(toggleState(label)) return row } - private fun settingNavigationRow( + /** + * Whether [labels] fit side by side in the preferences card: page and row + * padding 16dp each side, the 32dp icon plus 12dp gap, 2dp track inset and + * 8dp breathing room per option. Measured with the real paint, so system + * font scale and narrow windows are both covered. + */ + private fun segmentsFitInOneRow(labels: List): Boolean { + val metrics = context.resources.displayMetrics + val contentDp = minOf(context.resources.configuration.screenWidthDp, 600) - 16 * 4 - 44 - 4 + val perOptionPx = contentDp * metrics.density / labels.size - 8 * metrics.density + val paint = android.text.TextPaint().apply { + textSize = android.util.TypedValue.applyDimension( + android.util.TypedValue.COMPLEX_UNIT_SP, + ImeTypographyTokens.BODY_SP, + metrics, + ) + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + } + return labels.all { paint.measureText(it) <= perOptionPx } + } + + private fun sliderRow( label: String, - sub: String, - onTap: () -> Unit, - ): LinearLayout = - LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(toPx(14), 0, toPx(14), 0) - tag = "setting-row" - contentDescription = "$label,$sub,点击进入" - minimumHeight = toPx(ImeGeometryTokens.SETTING_ROW_HEIGHT_DP) - isClickable = true + iconRes: Int, + min: Int, + max: Int, + initial: Int, + shortLabel: String = label, + onChange: (Int) -> Unit, + ): LinearLayout { + val valueView = TextView(context).apply { + tag = "setting-value" + textSize = ImeTypographyTokens.BODY_SP + gravity = Gravity.CENTER_VERTICAL or Gravity.END + includeFontPadding = false + setSingleLine() + fontFeatureSettings = "tnum" + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + // Room for half the thumb at each end, so 0% and 100% draw whole. + val thumbInset = toPx(11) + val seekBar = SeekBar(context).apply { + this.min = min + this.max = max + progress = initial.coerceIn(min, max) + minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) + setPadding(thumbInset, 0, thumbInset, 0) isFocusable = true - setOnClickListener { - onFeedback() - onTap() - } - addView( - settingIcon(label), - LinearLayout.LayoutParams(toPx(26), toPx(26)).apply { - marginEnd = toPx(8) + tag = "settings-slider:$label" + setOnSeekBarChangeListener( + object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + val description = "$label,$progress%" + valueView.text = "$progress%" + contentDescription = description + if (Build.VERSION.SDK_INT >= 30) stateDescription = description + if (fromUser) onChange(progress) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) = Unit + override fun onStopTrackingTouch(seekBar: SeekBar) = Unit }, ) - addView( + } + val initialDescription = "$label,${seekBar.progress}%" + valueView.text = "${seekBar.progress}%" + seekBar.contentDescription = initialDescription + if (Build.VERSION.SDK_INT >= 30) seekBar.stateDescription = initialDescription + + val row = rowShell(iconRes, alignTop = standalone) + if (standalone) { + row.addView( LinearLayout(context).apply { orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER_VERTICAL - addView(labelText(label, ImeTypographyTokens.BODY_SP), wrapParams()) addView( - labelText(sub, ImeTypographyTokens.CAPTION_SP).apply { - setPadding(0, toPx(3), 0, 0) + LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(rowLabel(label), LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) + addView(valueView, wrapParams()) + }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + topMargin = toPx(6) }, - wrapParams(), ) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, - LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f, - ), - ) - addView( - ImageView(context).apply { - setImageResource(R.drawable.ic_arrow_back) - rotation = 180f - imageTintList = ColorStateList.valueOf( - ImeTheme.IOS.tokens( - appearance = currentAppearance(), - accentOverride = AccentPalette.parse(currentSkinColor()), - ).keySecondaryText, + addView( + seekBar, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(40)), ) - scaleType = ImageView.ScaleType.CENTER_INSIDE - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - tag = "setting-chevron" }, - LinearLayout.LayoutParams(toPx(28), toPx(44)), + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f), ) + row.setPadding(row.paddingLeft, row.paddingTop, row.paddingRight, toPx(4)) + } else { + row.addView(rowLabel(shortLabel), LinearLayout.LayoutParams(toPx(64), LinearLayout.LayoutParams.WRAP_CONTENT)) + row.addView(seekBar, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f)) + row.addView(valueView, LinearLayout.LayoutParams(toPx(44), LinearLayout.LayoutParams.WRAP_CONTENT)) + } + return row + } + + /** + * The keyboard panel's first row: four equal tiles that flip a setting in + * one tap. "On" is the accent fill; "off" is a plain card. + */ + private fun quickToggles(vararg tiles: Triple): LinearLayout = + LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + tag = "quick-toggles" + tiles.forEachIndexed { index, (seed, label, iconRes) -> + val tile = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + tag = "quick-tile" + isClickable = true + isFocusable = true + isSelected = toggleState(seed) + fun describe(enabled: Boolean) { + contentDescription = "$seed,${if (enabled) "已开启" else "已关闭"}" + if (Build.VERSION.SDK_INT >= 30) stateDescription = if (enabled) "已开启" else "已关闭" + } + describe(isSelected) + accessibilityDelegate = object : View.AccessibilityDelegate() { + override fun onInitializeAccessibilityNodeInfo(host: View, info: android.view.accessibility.AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.className = "android.widget.ToggleButton" + info.isCheckable = true + info.isChecked = toggleState(seed) + } + } + addView( + ImageView(context).apply { + setImageResource(iconRes) + scaleType = ImageView.ScaleType.FIT_CENTER + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + tag = "quick-tile-icon" + }, + LinearLayout.LayoutParams(toPx(20), toPx(20)), + ) + addView( + TextView(context).apply { + text = label + textSize = ImeTypographyTokens.SMALL_SP + includeFontPadding = false + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + tag = "quick-tile-label" + }, + wrapParams().apply { topMargin = toPx(6) }, + ) + setOnClickListener { + onFeedback() + val next = !toggleState(seed) + onToggleChanged(seed, next) + isSelected = next + describe(next) + applyTheme() + } + } + addView( + tile, + LinearLayout.LayoutParams(0, toPx(68), 1f).apply { + if (index > 0) marginStart = toPx(ImeSpacingTokens.SM_DP) + }, + ) + } } private fun toggle( @@ -527,6 +732,7 @@ internal class SettingsPanelController( gravity = Gravity.START or Gravity.CENTER_VERTICAL } tag = "toggle-knob" + elevation = toPx(1).toFloat() translationX = if (isOn) toPx(ImeGeometryTokens.SWITCH_KNOB_TRAVEL_DP).toFloat() else 0f @@ -535,12 +741,12 @@ internal class SettingsPanelController( return FrameLayout(context).apply { setPadding( toPx(ImeGeometryTokens.SWITCH_PADDING_DP), + 0, toPx(ImeGeometryTokens.SWITCH_PADDING_DP), - toPx(ImeGeometryTokens.SWITCH_PADDING_DP), - toPx(ImeGeometryTokens.SWITCH_PADDING_DP), + 0, ) minimumWidth = toPx(ImeGeometryTokens.SWITCH_WIDTH_DP) - minimumHeight = toPx(ImeGeometryTokens.SWITCH_HEIGHT_DP) + minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) tag = "toggle" isClickable = true isFocusable = true @@ -563,20 +769,10 @@ internal class SettingsPanelController( onChanged(next) val knobView = getChildAt(0) - knobView.layoutParams = FrameLayout.LayoutParams( - toPx(ImeGeometryTokens.SWITCH_KNOB_DP), - toPx(ImeGeometryTokens.SWITCH_KNOB_DP), - ).apply { - gravity = Gravity.START or Gravity.CENTER_VERTICAL - } knobView.animate().cancel() knobView.animate() .translationX( - if (next) { - toPx(ImeGeometryTokens.SWITCH_KNOB_TRAVEL_DP).toFloat() - } else { - 0f - }, + if (next) toPx(ImeGeometryTokens.SWITCH_KNOB_TRAVEL_DP).toFloat() else 0f, ) .setDuration(ImeMotionTokens.STANDARD_TRANSITION_MS) .setInterpolator(DecelerateInterpolator(1.5f)) @@ -595,306 +791,11 @@ internal class SettingsPanelController( else -> currentExtraToggle(seed) } - private fun accentColorRow(): LinearLayout { - val current = AccentPalette.normalize(currentSkinColor()) - val row = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - tag = "setting-group" - setPadding(toPx(12), toPx(12), toPx(12), toPx(12)) - } - val swatchGrid = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - gravity = Gravity.CENTER_HORIZONTAL - } - - AccentPalette.presets.chunked(6).forEach { presetRow -> - val swatchRow = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - } - presetRow.forEach { (hex, label) -> - val selected = AccentPalette.normalize(hex) == current - swatchRow.addView( - FrameLayout(context).apply { - tag = "accent-swatch" - contentDescription = - "强调色$label,${if (selected) "已选中" else "未选中"}" - if (Build.VERSION.SDK_INT >= 30) { - stateDescription = - if (selected) "已选中" else "未选中" - } - isClickable = true - isFocusable = true - addView( - View(context).apply { - background = GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(AccentPalette.parse(hex)) - if (selected) { - setStroke( - toPx(2), - ImeDrawableFactory.contrastText( - AccentPalette.parse(hex), - ), - ) - } - } - }, - FrameLayout.LayoutParams(toPx(32), toPx(32)).apply { - gravity = Gravity.CENTER - }, - ) - if (selected) { - addView( - ImageView(context).apply { - setImageResource(R.drawable.ic_check) - imageTintList = ColorStateList.valueOf( - ImeDrawableFactory.contrastText( - AccentPalette.parse(hex), - ), - ) - scaleType = ImageView.ScaleType.CENTER_INSIDE - tag = "accent-selected-mark:$hex" - importantForAccessibility = - View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, - FrameLayout.LayoutParams(toPx(32), toPx(32)).apply { - gravity = Gravity.CENTER - }, - ) - } - setOnClickListener { - onFeedback() - applyAccentColor(hex) - } - }, - LinearLayout.LayoutParams(0, toPx(44), 1f), - ) - } - swatchGrid.addView( - swatchRow, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(44), - ), - ) - } - row.addView( - swatchGrid, - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(132), - ), - ) - - val customSelected = AccentPalette.presets.none { - AccentPalette.normalize(it.first) == current - } - row.addView( - TextView(context).apply { - text = - "自定义颜色\n输入 6 位十六进制,如 5B6B7A" - textSize = ImeTypographyTokens.PANEL_NOTE_SP - gravity = Gravity.CENTER_VERTICAL or Gravity.START - includeFontPadding = false - maxLines = 2 - ellipsize = TextUtils.TruncateAt.END - setPadding(toPx(10), 0, toPx(10), 0) - tag = "accent-custom" - minHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) - minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) - isClickable = true - isFocusable = true - contentDescription = - "自定义强调色,${if (customSelected) "已选中" else "未选中"}" - if (Build.VERSION.SDK_INT >= 30) { - stateDescription = - if (customSelected) "已选中" else "未选中" - } - setOnClickListener { - onFeedback() - showCustomAccentDialog() - } - }, - LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(48)).apply { - topMargin = toPx(6) - }, - ) - return row - } - - private fun applyAccentColor(hex: String) { - val normalized = AccentPalette.normalize(hex) - onSkinChanged( - currentSkinOpacity(), - currentSkinRadius(), - currentSkinFontSize(), - normalized, - ) - renderSettings(reusePanel = true, skinOnly = showingSkinOnly) - } - - fun showCustomAccentDialog() { - if (context is android.inputmethodservice.InputMethodService) { - context.startActivity(android.content.Intent(context, ImeSettingsActivity::class.java) - .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(ImeSettingsActivity.EXTRA_EDIT_ACCENT, true)) - return - } - val field = EditText(context).apply { - setText(AccentPalette.normalize(currentSkinColor()).removePrefix("#")) - hint = "RRGGBB" - inputType = - InputType.TYPE_CLASS_TEXT or - InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS or - InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS - imeOptions = EditorInfo.IME_ACTION_DONE - setSingleLine(true) - setSelectAllOnFocus(true) - filters = arrayOf(InputFilter.LengthFilter(6)) - } - val palette = ImeTheme.IOS.tokens(currentAppearance(), resourcesDark(), AccentPalette.parse(currentSkinColor())) - val inputRow = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL - setPadding(toPx(12), 0, toPx(12), 0) - background = ImeDrawableFactory.rounded(palette.toolbarBackground, toPx(8), palette.primary, toPx(1)) - addView(View(context).apply { - background = ImeDrawableFactory.rounded(palette.primary, toPx(99)) - }, LinearLayout.LayoutParams(toPx(16), toPx(16)).apply { marginEnd = toPx(8) }) - addView(TextView(context).apply { text = "#"; textSize = ImeTypographyTokens.BODY_SP; setTextColor(palette.keyText) }, wrapParams()) - field.textSize = ImeTypographyTokens.BODY_SP; field.background = null; field.setPadding(0, 0, 0, 0) - field.setTextColor(palette.keyText) - addView(field, LinearLayout.LayoutParams(0, toPx(44), 1f)) - } - val dialog = AlertDialog.Builder(context) - .setTitle("自定义强调色") - .setMessage("输入 6 位十六进制颜色,例如 5B6B7A") - .setView(FrameLayout(context).apply { - setPadding(toPx(24), toPx(8), toPx(24), toPx(8)) - addView(inputRow, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, toPx(44))) - }) - .setPositiveButton("应用", null) - .setNegativeButton("取消", null) - .create() - - dialog.setOnShowListener { - SetupUi.styleDialog(dialog, context) - dialog.window?.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - SetupUi.styleCursor(context, field) - field.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_DONE) { - dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick() - true - } else { - false - } - } - field.requestFocus() - field.selectAll() - dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { - val value = field.text.toString().trim().removePrefix("#") - if (!value.matches(Regex("[0-9a-fA-F]{6}"))) { - field.error = "请输入 6 位十六进制颜色" - field.requestFocus() - return@setOnClickListener - } - applyAccentColor("#$value") - dialog.dismiss() - } - } - SetupUi.showDialog(dialog, context, expandedPanel) - } - - private fun resourcesDark(): Boolean = context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == android.content.res.Configuration.UI_MODE_NIGHT_YES - - private fun settingsSlider( - labelText: String, - min: Int, - max: Int, - initial: Int, - onChange: (Int) -> Unit, - ): LinearLayout { - val row = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(toPx(16), toPx(4), toPx(16), toPx(4)) - tag = "setting-row" - minimumHeight = toPx(ImeGeometryTokens.SETTING_ROW_HEIGHT_DP) - } - row.addView( - TextView(context).apply { - text = labelText - textSize = ImeTypographyTokens.PANEL_BODY_SP - typeface = android.graphics.Typeface.DEFAULT_BOLD - }, - LinearLayout.LayoutParams( - toPx(72), - LinearLayout.LayoutParams.WRAP_CONTENT, - ), - ) - val valueView = TextView(context).apply { - tag = "setting-value" - textSize = ImeTypographyTokens.BODY_SP - gravity = Gravity.CENTER - includeFontPadding = false - minWidth = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) - contentDescription = "$labelText 当前值" - } - val suffix = when (labelText) { - "圆角" -> " dp" - "不透明度" -> "%" - "按键字号" -> " sp" - "键盘高度", "浮动宽度", "浮动透明度" -> "%" - else -> "" - } - val seekBar = SeekBar(context).apply { - this.min = min - this.max = max - progress = initial.coerceIn(min, max) - minimumHeight = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) - isFocusable = true - tag = "settings-slider:$labelText" - setOnSeekBarChangeListener( - object : SeekBar.OnSeekBarChangeListener { - override fun onProgressChanged( - seekBar: SeekBar, - progress: Int, - fromUser: Boolean, - ) { - val description = "$labelText,$progress$suffix" - valueView.text = "$progress$suffix" - contentDescription = description - if (Build.VERSION.SDK_INT >= 30) { - stateDescription = description - } - if (fromUser) onChange(progress) - } - - override fun onStartTrackingTouch(seekBar: SeekBar) = Unit - override fun onStopTrackingTouch(seekBar: SeekBar) = Unit - }, - ) - } - row.addView( - seekBar, - LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f, - ), - ) - row.addView( - valueView, - LinearLayout.LayoutParams(toPx(44), toPx(ImeGeometryTokens.TOUCH_TARGET_DP)), - ) - val initialDescription = - "$labelText,${seekBar.progress}$suffix" - valueView.text = "${seekBar.progress}$suffix" - seekBar.contentDescription = initialDescription - if (Build.VERSION.SDK_INT >= 30) { - seekBar.stateDescription = initialDescription - } - return row + private fun noteText(value: String): TextView = TextView(context).apply { + text = value + textSize = ImeTypographyTokens.DETAIL_SP + setLineSpacing(0f, 1.2f) + tag = "panel-note" } private fun semanticFocusKey(view: View?): String? { @@ -916,26 +817,6 @@ internal class SettingsPanelController( return null } - private fun labelText(value: String, size: Float): TextView = - TextView(context).apply { - text = value - textSize = size - includeFontPadding = false - tag = if (size <= 11.5f) "panel-note" else "setting-label" - } - - private fun chipParams() = - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), - ).apply { bottomMargin = toPx(12) } - - private fun groupParams() = - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT, - ).apply { bottomMargin = toPx(12) } - private fun wrapParams() = LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, diff --git a/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt b/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt index be137867..e5292109 100644 --- a/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt +++ b/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt @@ -17,11 +17,9 @@ import android.widget.LinearLayout import android.widget.TextView import llc.slacker.openime.R import llc.slacker.openime.data.ImeSettingsRepository -import llc.slacker.openime.theme.AccentPalette import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeDrawableFactory import llc.slacker.openime.theme.ImeGeometryTokens -import llc.slacker.openime.theme.ImeReferenceSizing import llc.slacker.openime.theme.ImeSpacingTokens import llc.slacker.openime.theme.ImeSurfacePolicy import llc.slacker.openime.theme.ImeTheme @@ -39,11 +37,6 @@ object SetupUi { ImeAppearance.SYSTEM -> configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK } configuration.uiMode = (configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK.inv()) or night - val metrics = context.resources.displayMetrics - val density = metrics.density * ImeReferenceSizing.scale(context) - configuration.densityDpi = kotlin.math.round(density * 160f).toInt() - configuration.screenWidthDp = kotlin.math.round(metrics.widthPixels / density).toInt() - configuration.screenHeightDp = kotlin.math.round(metrics.heightPixels / density).toInt() return context.createConfigurationContext(configuration) } @@ -51,28 +44,65 @@ object SetupUi { fun dp(context: Context, value: Int): Int = (value * context.resources.displayMetrics.density).toInt() - fun accent(context: Context): Int = - tokensForAccent(context).primary + /** The app accent: interactive controls and their on/current state, nothing else. */ + fun accent(context: Context): Int = context.getColor(R.color.setup_primary) - private fun tokensForAccent(context: Context): ImeTheme.Tokens = ImeTheme.IOS.tokens( - ImeSettingsRepository.loadAppearance(context), - context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == android.content.res.Configuration.UI_MODE_NIGHT_YES, - AccentPalette.parse(ImeSettingsRepository.loadSkinColor(context)), - ) + internal fun primaryButtonColor(context: Context): Int = accent(context) + + /** The accent as text, e.g. a tinted secondary button's label. */ + internal fun secondaryTextColor(context: Context): Int = context.getColor(R.color.setup_primary_text) - private fun tokens(context: Context): ImeTheme.Tokens { + /** + * App-page palette in the keyboard's token shape, so the standalone + * preferences page (rendered by the keyboard's own panel code) paints with + * the same colours as every other app page instead of the key colours. + */ + internal fun tokens(context: Context): ImeTheme.Tokens { val nightMask = context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK - return ImeTheme.IOS.tokens( + val base = ImeTheme.IOS.tokens( appearance = ImeSettingsRepository.loadAppearance(context), systemDark = nightMask == android.content.res.Configuration.UI_MODE_NIGHT_YES, - accentOverride = accent(context), + ) + val page = context.getColor(R.color.setup_page_bg) + val surface = context.getColor(R.color.setup_surface) + val body = context.getColor(R.color.setup_body) + return base.copy( + primary = accent(context), + keyboardBackground = page, + toolbarBackground = page, + expandedBackground = page, + panelHeadBackground = page, + keyBackground = surface, + toolCardBackground = surface, + keyText = context.getColor(R.color.setup_title), + keySecondaryText = body, + textSecondaryRole = body, + functionKeyBackground = context.getColor(R.color.setup_muted), + keyPressedBackground = context.getColor(R.color.setup_muted_pressed), + border = context.getColor(R.color.setup_hairline), ) } fun contrastText(background: Int): Int = ImeDrawableFactory.contrastText(background) + /** A grouped-list card: one rounded surface, no shadow. */ + fun cardBackground(context: Context) = ImeDrawableFactory.rounded( + context.getColor(R.color.setup_surface), + dp(context, ImeGeometryTokens.CARD_RADIUS_DP).toFloat(), + ) + + /** Press feedback for a row inside a card; the card itself paints the surface. */ + fun rowBackground(context: Context): StateListDrawable = + ImeDrawableFactory.statefulRounded( + Color.TRANSPARENT, + context.getColor(R.color.setup_muted), + 0, + accent(context), + dp(context, 1), + ) + fun rounded( color: Int, radiusPx: Float, @@ -91,7 +121,7 @@ object SetupUi { fun buttonBackground( context: Context, color: Int, - radiusDp: Float = ImeGeometryTokens.CONTROL_RADIUS_DP.toFloat(), + radiusDp: Float = ImeGeometryTokens.PILL_RADIUS_DP.toFloat(), ): StateListDrawable { val pressed = if (color == accent(context)) tokens(context).accentPressed else dim(color, 0.86f) @@ -129,12 +159,13 @@ object SetupUi { } } - /** Secondary rounded-rectangle control used by setup actions. */ + /** Tinted secondary action, sharing the primary button's capsule shape. */ fun secondaryBackground(context: Context): StateListDrawable { - val surface = context.getColor(R.color.setup_muted) - val pressed = context.getColor(R.color.setup_muted_pressed) + val t = tokens(context) + val surface = context.getColor(R.color.setup_primary_tint) + val pressed = ImeSurfacePolicy.pressedSurface(surface, t) val accent = accent(context) - val radius = dp(context, ImeGeometryTokens.CONTROL_RADIUS_DP).toFloat() + val radius = dp(context, ImeGeometryTokens.PILL_RADIUS_DP).toFloat() return StateListDrawable().apply { addState( intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed), @@ -170,7 +201,7 @@ object SetupUi { setPadding(dp(context, 8), 0, dp(context, 8), 0) val accent = accent(context) background = secondaryBackground(context) - setTextColor(context.getColor(R.color.setup_title)) + setTextColor(secondaryTextColor(context)) setOnClickListener { performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY) onClick() @@ -223,8 +254,8 @@ object SetupUi { minHeight = dp(context, ImeGeometryTokens.PRIMARY_ROW_HEIGHT_DP) background = buttonBackground( context, - accent(context), - ImeGeometryTokens.CONTROL_RADIUS_DP.toFloat(), + primaryButtonColor(context), + ImeGeometryTokens.PILL_RADIUS_DP.toFloat(), ) val accent = accent(context) setTextColor( @@ -235,7 +266,7 @@ object SetupUi { ), intArrayOf( context.getColor(R.color.setup_muted_text), - contrastText(accent), + Color.WHITE, ), ), ) @@ -308,8 +339,8 @@ object SetupUi { LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - minimumHeight = dp(context, ImeGeometryTokens.TOP_BAR_HEIGHT_DP) - setPadding(dp(context, 4), 0, dp(context, 4), 0) + minimumHeight = context.resources.getDimensionPixelSize(R.dimen.setup_top_bar_height) + setPadding(dp(context, ImeSpacingTokens.LG_DP), 0, dp(context, ImeSpacingTokens.LG_DP), 0) addView( ImageButton(context).apply { @@ -352,9 +383,11 @@ object SetupUi { addView( TextView(context).apply { text = title - textSize = ImeTypographyTokens.TITLE_SP + textSize = ImeTypographyTokens.PAGE_TITLE_SP + maxLines = 1 + ellipsize = android.text.TextUtils.TruncateAt.END setTextColor(context.getColor(R.color.setup_title)) - typeface = android.graphics.Typeface.DEFAULT_BOLD + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) gravity = Gravity.CENTER_VERTICAL includeFontPadding = false if (Build.VERSION.SDK_INT >= 28) setAccessibilityHeading(true) @@ -379,8 +412,8 @@ object SetupUi { } fun styleDialog(dialog: AlertDialog, context: Context, destructivePositive: Boolean = false) { - val palette = tokensForAccent(context) - val accent = ImeSurfacePolicy.selectedText(palette) + val palette = tokens(context) + val accent = secondaryTextColor(context) val alertTitleId = context.resources.getIdentifier("alertTitle", "id", "android") if (alertTitleId != 0) { dialog.findViewById(alertTitleId)?.setTextColor( diff --git a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt index fd186d3f..5b05bdd1 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -25,14 +25,14 @@ internal object ImeGeometryTokens { const val TOUCH_TARGET_DP = 48 const val FIELD_HEIGHT_DP = 56 - const val PRIMARY_ROW_HEIGHT_DP = 56 + const val PRIMARY_ROW_HEIGHT_DP = 52 const val SETTING_ROW_HEIGHT_DP = 56 - const val TOOL_CARD_HEIGHT_DP = 92 + const val TOOL_CARD_HEIGHT_DP = 80 const val VOICE_CONTROL_HEIGHT_DP = TOUCH_TARGET_DP - const val SWITCH_WIDTH_DP = 48 + const val SWITCH_WIDTH_DP = 46 const val SWITCH_HEIGHT_DP = 28 - const val SWITCH_KNOB_DP = 20 - const val SWITCH_PADDING_DP = 4 + const val SWITCH_KNOB_DP = 24 + const val SWITCH_PADDING_DP = 2 const val SWITCH_KNOB_TRAVEL_DP = SWITCH_WIDTH_DP - SWITCH_PADDING_DP * 2 - SWITCH_KNOB_DP const val KEY_GAP_DP = 6 const val KEY_ROW_GAP_DP = KEY_GAP_DP @@ -90,6 +90,14 @@ internal object ImeTypographyTokens { const val SYMBOL_SP = 24f const val DISPLAY_SP = 28f + // App-page roles (home, preferences): a grouped list reads one step larger + // than the keyboard's panels. Mirrored by the ime_text_* dimens. + const val DETAIL_SP = 13f + const val ROW_SP = 15f + const val STATUS_SP = 17f + const val PAGE_TITLE_SP = 18f + const val HEADING_SP = 26f + // Compatibility names for existing callers; every alias resolves to the // canonical six-step scale above. const val PANEL_TITLE_SP = TITLE_SP @@ -150,7 +158,6 @@ enum class ImeTheme(val key: String, val label: String) { fun tokens( appearance: ImeAppearance = ImeAppearance.DARK, systemDark: Boolean = false, - accentOverride: Int? = null, ): Tokens { fun c(hex: String): Int = Color.parseColor(hex) val useDark = when (appearance) { @@ -158,7 +165,7 @@ enum class ImeTheme(val key: String, val label: String) { ImeAppearance.LIGHT -> false ImeAppearance.DARK -> true } - val base = if (useDark) { + return if (useDark) { Tokens( c("#6EC3F7"), c("#1C1C1E"), c("#242426"), c("#242426"), c("#F2F2F7"), c("#3A3A3C"), c("#F2F2F7"), c("#AEAEB2"), c("#2C2C2E"), c("#F2F2F7"), c("#4A4A4D"), @@ -176,9 +183,6 @@ enum class ImeTheme(val key: String, val label: String) { textSecondaryRole = c("#6D6D72"), ) } - val accent = accentOverride ?: return base - if (accent == AccentPalette.parse(AccentPalette.DEFAULT)) return base - return base.copy(primary = accent) } } @@ -296,6 +300,39 @@ internal object ImeSurfacePolicy { fun destructiveLabel(tokens: ImeTheme.Tokens): Int = if (isDark(tokens)) Color.parseColor("#FF6771") else Color.parseColor("#D60016") + /** Switch track: the accent marks "on" (an interactive state); off is neutral. */ + fun switchTrack(on: Boolean, tokens: ImeTheme.Tokens): Int = when { + on -> tokens.primary + else -> if (isDark(tokens)) Color.parseColor("#3A3D43") else Color.parseColor("#D7DAE0") + } + + /** Switch knob: white on light surfaces, a soft white on dark ones. */ + fun switchKnob(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) Color.parseColor("#F2F3F5") else Color.WHITE + + /** Inset separator between rows of one grouped card. */ + fun hairline(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend(tokens.keyText, tokens.toolCardBackground, if (isDark(tokens)) 0.10f else 0.08f) + + /** Decorative icon tile behind a setting's glyph: neutral, never the accent. */ + fun iconTile(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend(tokens.keyText, tokens.toolCardBackground, if (isDark(tokens)) 0.08f else 0.06f) + + fun iconTint(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend(tokens.keyText, tokens.toolCardBackground, 0.82f) + + fun chevron(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend(tokens.keySecondaryText, tokens.toolCardBackground, 0.62f) + + /** Unfilled slider track and segmented-control track, a step off the card. */ + fun controlTrack(tokens: ImeTheme.Tokens): Int = + ImeDrawableFactory.blend(tokens.keyText, tokens.toolCardBackground, if (isDark(tokens)) 0.12f else 0.08f) + + /** The selected segment sits lighter than its track in both themes. */ + fun segmentSelected(tokens: ImeTheme.Tokens): Int = + if (isDark(tokens)) ImeDrawableFactory.blend(tokens.keyText, controlTrack(tokens), 0.18f) + else tokens.toolCardBackground + fun destructiveText(tokens: ImeTheme.Tokens): Int { val surface = destructiveSurface(tokens) return if (ImeContrastPolicy.contrastRatio(tokens.destructive, surface) >= 4.5) { @@ -352,39 +389,3 @@ internal object ImeFocusRingPolicy { ImeContrastPolicy.contrastText(background) } } - -/** User-selectable accents share the same color source as the rest of the IME. */ -object AccentPalette { - const val DEFAULT = "#1D9BF0" - val presets = listOf( - "#1D9BF0" to "蓝色", - "#FFD400" to "黄色", - "#F91880" to "粉色", - "#7856FF" to "紫色", - "#FF7A00" to "橙色", - "#00BA7C" to "绿色", - "#00C2D7" to "青色", - "#38BDF8" to "天蓝", - "#5865F2" to "靛蓝", - "#9B5DE5" to "深紫", - "#E94FB8" to "洋红", - "#F4212E" to "红色", - "#FF5A5F" to "珊瑚红", - "#F59E0B" to "琥珀", - "#84CC16" to "青柠", - "#22C55E" to "翠绿", - "#10CFA0" to "薄荷", - "#14B8A6" to "蓝绿", - ) - - fun parse(value: String?): Int = runCatching { - val raw = normalize(value).removePrefix("#") - (0xFF000000L or raw.toLong(16)).toInt() - }.getOrDefault((0xFF000000L or DEFAULT.removePrefix("#").toLong(16)).toInt()) - - fun normalize(value: String?): String { - val raw = value.orEmpty().trim() - val hex = if (raw.startsWith("#")) raw else "#$raw" - return if (hex.matches(Regex("#?[0-9a-fA-F]{6}"))) hex.uppercase() else DEFAULT - } -} diff --git a/app/src/main/java/llc/slacker/openime/theme/ImeDrawableFactory.kt b/app/src/main/java/llc/slacker/openime/theme/ImeDrawableFactory.kt index 191304c8..0f12f067 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDrawableFactory.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDrawableFactory.kt @@ -3,6 +3,8 @@ package llc.slacker.openime.theme import android.graphics.Color import android.graphics.drawable.GradientDrawable import android.graphics.drawable.StateListDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable import llc.slacker.openime.R /** @@ -10,6 +12,12 @@ import llc.slacker.openime.R * Radius and stroke values are pixels; callers keep density conversion. */ internal object ImeDrawableFactory { + /** The same one-dp bottom edge as the keyboard's key caps. */ + fun keyCap(face: Drawable, edgeColor: Int, radiusPx: Int, edgePx: Int): LayerDrawable = + LayerDrawable(arrayOf(rounded(edgeColor, radiusPx), face)).apply { + setLayerInset(1, 0, 0, 0, edgePx.coerceAtLeast(1)) + } + fun rounded( color: Int, radiusPx: Int, diff --git a/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt b/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt index 45c8b9a2..b8d70de7 100644 --- a/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt +++ b/app/src/main/java/llc/slacker/openime/voice/LocalAudioVoiceBackend.kt @@ -11,7 +11,6 @@ import android.os.SystemClock import android.util.Log import llc.slacker.openime.hotword.HotwordRuntime import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import kotlin.math.sqrt @@ -48,7 +47,7 @@ class PcmRingBuffer(private val capacitySamples: Int) { var sourceOffset = offset var accepted = length if (accepted >= capacitySamples) { - droppedSamples += (accepted - capacitySamples).toLong() + droppedSamples += count.toLong() + (accepted - capacitySamples).toLong() sourceOffset += accepted - capacitySamples accepted = capacitySamples readIndex = 0 @@ -63,10 +62,10 @@ class PcmRingBuffer(private val capacitySamples: Int) { droppedSamples += overflow.toLong() } - repeat(accepted) { index -> - buffer[writeIndex] = samples[sourceOffset + index] - writeIndex = (writeIndex + 1) % capacitySamples - } + val first = minOf(accepted, capacitySamples - writeIndex) + samples.copyInto(buffer, writeIndex, sourceOffset, sourceOffset + first) + samples.copyInto(buffer, 0, sourceOffset + first, sourceOffset + accepted) + writeIndex = (writeIndex + accepted) % capacitySamples count += accepted (this as java.lang.Object).notifyAll() return accepted @@ -76,6 +75,19 @@ class PcmRingBuffer(private val capacitySamples: Int) { @Synchronized fun awaitAndDrain(maxSamples: Int, timeoutMs: Long): ShortArray { require(maxSamples > 0) { "maxSamples must be positive" } + awaitDataLocked(timeoutMs) + return drainLocked(maxSamples) + } + + /** Drain into the inference worker's reusable PCM block. */ + @Synchronized + internal fun awaitAndDrainInto(target: ShortArray, offset: Int, timeoutMs: Long): Int { + require(offset >= 0 && offset < target.size) { "invalid PCM target offset" } + awaitDataLocked(timeoutMs) + return drainIntoLocked(target, offset, target.size - offset) + } + + private fun awaitDataLocked(timeoutMs: Long) { if (count == 0 && timeoutMs > 0) { try { (this as java.lang.Object).wait(timeoutMs) @@ -83,7 +95,6 @@ class PcmRingBuffer(private val capacitySamples: Int) { Thread.currentThread().interrupt() } } - return drainLocked(maxSamples) } @Synchronized @@ -109,13 +120,19 @@ class PcmRingBuffer(private val capacitySamples: Int) { val take = minOf(maxSamples, count) if (take == 0) return ShortArray(0) val result = ShortArray(take) - repeat(take) { index -> - result[index] = buffer[readIndex] - readIndex = (readIndex + 1) % capacitySamples - } - count -= take + drainIntoLocked(result, 0, take) return result } + + private fun drainIntoLocked(target: ShortArray, offset: Int, maxSamples: Int): Int { + val take = minOf(maxSamples, count) + val first = minOf(take, capacitySamples - readIndex) + buffer.copyInto(target, offset, readIndex, readIndex + first) + buffer.copyInto(target, offset + first, 0, take - first) + readIndex = (readIndex + take) % capacitySamples + count -= take + return take + } } /** Android/ASR audio constants kept in one place for model integration. */ @@ -183,8 +200,8 @@ class LocalAudioVoiceBackend( private val startGeneration = AtomicLong(0L) private val stopRequestedGeneration = AtomicLong(-1L) private val stopRequestedAtMs = AtomicLong(0L) - private val stopExecutor = Executors.newSingleThreadScheduledExecutor { runnable -> - Thread(runnable, "local-voice-tail-stop").apply { isDaemon = true } + private val stopExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "local-voice-capture-stop").apply { isDaemon = true } } private val routeManager = VoiceAudioRouteManager(context) @@ -333,7 +350,12 @@ class LocalAudioVoiceBackend( } startCaptureThread(session) events.onReady() - if (session.stopRequested.get()) scheduleCaptureStop(session, generation) + if (session.stopRequested.get()) dispatchCaptureStop(session, generation) + + if (session.cancelled.get() || session.failed.get()) { + cleanupStartingSession(session) + return + } // Waiting for verification/model mapping is safe here: capture is // already live and the bounded ring keeps the spoken prefix. @@ -371,7 +393,7 @@ class LocalAudioVoiceBackend( if (current == null) return current.releaseAtMs = releasedAt current.stopRequested.set(true) - if (current.running.get()) scheduleCaptureStop(current, generation) + if (current.running.get()) dispatchCaptureStop(current, generation) if (current.modelReady.get()) startInferenceThread(current) // If the first model load is still running, initializeSession keeps // this session alive, then drains the buffered PCM and emits one final. @@ -421,22 +443,18 @@ class LocalAudioVoiceBackend( session.captureThread?.interrupt() } - private fun scheduleCaptureStop(session: Session, generation: Long) { + private fun dispatchCaptureStop(session: Session, generation: Long) { if (!session.captureStopScheduled.compareAndSet(false, true)) return - stopExecutor.schedule( - { - if ( - isCurrent(generation, session) && - session.stopRequested.get() && - !session.cancelled.get() && - !session.failed.get() - ) { - requestCaptureStop(session) - } - }, - TAIL_CAPTURE_MILLIS, - TimeUnit.MILLISECONDS, - ) + stopExecutor.execute { + if ( + isCurrent(generation, session) && + session.stopRequested.get() && + !session.cancelled.get() && + !session.failed.get() + ) { + requestCaptureStop(session) + } + } } private fun startCaptureThread(session: Session) { @@ -459,6 +477,7 @@ class LocalAudioVoiceBackend( when { read > 0 -> { VoicePerformanceTrace.markFirstPcm(session.traceToken) + session.capturedSamples.addAndGet(read.toLong()) val droppedBefore = session.ring.droppedSamples session.ring.offer(pcm, 0, read) if ( @@ -469,6 +488,8 @@ class LocalAudioVoiceBackend( TAG, "PCM pipeline degraded droppedPcmSamples=${session.ring.droppedSamples}", ) + fail(session, "语音处理跟不上录音,音频不完整,请分短句重试") + break } var sum = 0.0 repeat(read) { index -> @@ -491,33 +512,40 @@ class LocalAudioVoiceBackend( // to be offered to the ring before ASR finalization. session.ring.wake() runCatching { session.record.release() } + val captureDelay = if (session.releaseAtMs > 0L) { + SystemClock.elapsedRealtime() - session.releaseAtMs + } else { + -1L + } + Log.i(TAG, "captureFinished releaseToCaptureMs=$captureDelay capturedSamples=${session.capturedSamples.get()}") + session.events.onCaptureStopped() } } private fun inferenceLoop(session: Session) { val voiceSession = checkNotNull(session.voiceSession) { "本地语音模型尚未连接" } val pending = ShortArray(LocalVoiceAudioSpec.CHUNK_SAMPLES) + val waveform = FloatArray(LocalVoiceAudioSpec.CHUNK_SAMPLES) var pendingCount = 0 + var decodedSamples = 0L try { while (!session.captureFinished.get() || session.ring.size > 0) { - val part = session.ring.awaitAndDrain( - LocalVoiceAudioSpec.CHUNK_SAMPLES - pendingCount, - 50, - ) - if (part.isNotEmpty()) { - part.copyInto(pending, pendingCount) - pendingCount += part.size - } + if (session.cancelled.get() || session.failed.get()) return + pendingCount += session.ring.awaitAndDrainInto(pending, pendingCount, 50) if (pendingCount == 0) continue if (pendingCount < pending.size && !session.captureFinished.get()) continue - val chunk = pending.copyOf(pendingCount) + val chunk = if (pendingCount == waveform.size) waveform else FloatArray(pendingCount) + repeat(pendingCount) { index -> chunk[index] = pending[index] / 32768f } pendingCount = 0 VoicePerformanceTrace.markFirstDecode(session.traceToken) - voiceSession.acceptWaveform(pcm16ToFloat(chunk)) + voiceSession.acceptWaveform(chunk) + decodedSamples += chunk.size } + if (session.cancelled.get() || session.failed.get()) return if (pendingCount > 0) { VoicePerformanceTrace.markFirstDecode(session.traceToken) voiceSession.acceptWaveform(pcm16ToFloat(pending.copyOf(pendingCount))) + decodedSamples += pendingCount } if ( !session.failed.get() && @@ -530,6 +558,7 @@ class LocalAudioVoiceBackend( TAG, "inputFinished begin releaseAtMs=${session.releaseAtMs} " + "nowMs=$finalizeStartedAt ringRemainingSamples=$remainingBeforeFinish " + + "capturedSamples=${session.capturedSamples.get()} decodedSamples=$decodedSamples " + "droppedSamples=${session.ring.droppedSamples} " + "lastPartialLength=${session.lastPartial.length}", ) @@ -591,6 +620,7 @@ class LocalAudioVoiceBackend( var voiceSession: StreamingVoiceModelSession? = null val running = AtomicBoolean(false) val captureFinished = AtomicBoolean(false) + val capturedSamples = AtomicLong(0L) val stopRequested = AtomicBoolean(false) val cancelled = AtomicBoolean(false) val modelReady = AtomicBoolean(false) @@ -608,6 +638,5 @@ class LocalAudioVoiceBackend( private companion object { const val TAG = "OpenImeVoicePerf" - const val TAIL_CAPTURE_MILLIS = 300L } } diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceMediaMuteController.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceMediaMuteController.kt index 80fa9ab4..b644695c 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceMediaMuteController.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceMediaMuteController.kt @@ -137,5 +137,6 @@ internal class VoiceMediaMuteController(context: Context) { }.onFailure { Log.w(TAG, "restoreFailed", it) } + Log.i(TAG, "mediaRestored") } } diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceModelLifecycleManager.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceModelLifecycleManager.kt index 0112dfa6..ebaf5c37 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceModelLifecycleManager.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceModelLifecycleManager.kt @@ -109,9 +109,10 @@ internal object VoiceMemoryAdmissionPolicy { // memoryClass does not cap native ONNX allocations, but an extremely // small heap class is a strong signal that loading a 400+ MB runtime is // unsafe even when cached pages temporarily make availMem look large. + // Punctuation is native too; do not treat its size as a Java heap limit. if ( snapshot.memoryClassBytes > 0L && - snapshot.memoryClassBytes < requiredMemory / 2L + snapshot.memoryClassBytes < minOf(requiredMemory / 2L, 256L * 1024L * 1024L) ) { return VoiceMemoryAdmission( false, @@ -385,6 +386,10 @@ class VoiceModelLifecycleManager( events.onModelReady() } } + + override fun onCaptureStopped() { + if (isCurrentSession(token)) events.onCaptureStopped() + } }) } diff --git a/app/src/main/java/llc/slacker/openime/voice/VoicePanelController.kt b/app/src/main/java/llc/slacker/openime/voice/VoicePanelController.kt index f5d68f02..4a2b47c9 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoicePanelController.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoicePanelController.kt @@ -101,7 +101,7 @@ internal class VoicePanelController( "松开空格结束语音并自动上屏,上滑取消", ) setModelStatus("正在使用离线模型 · 音频不出设备") - setTranscript("正在聆听… 松开空格结束") + setTranscript("正在准备麦克风…") listener.onVoiceSessionStarted(true) listener.startVoiceRecognition( @@ -118,7 +118,7 @@ internal class VoicePanelController( stopRequested = true refreshLanguageControl() setModelStatus("正在整理识别结果…") - onInlineState("正在识别…", false, false, null) + onInlineState(recognizedText.ifBlank { "正在转写已录音频…" }, false, false, null) } fun cancel() { @@ -212,7 +212,7 @@ internal class VoicePanelController( "整理识别结果…", "正在整理语音识别结果,请稍候", ) - onInlineState("正在识别…", false, false, null) + onInlineState(text.ifBlank { "正在转写已录音频…" }, false, false, null) } else { setModelStatus("正在聆听 · 松开空格结束") setMicState(R.drawable.ic_stop, "语音输入进行中,松开空格结束,上滑取消") @@ -330,6 +330,7 @@ internal class VoicePanelController( override fun onReady() { expandedPanel.post { if (generation != eventGeneration || cancelled) return@post + pending = false if (active && !stopRequested) { setMicState(R.drawable.ic_stop, "语音输入进行中,松开空格结束,上滑取消") setGestureHint( @@ -337,6 +338,7 @@ internal class VoicePanelController( "松开空格结束语音并自动上屏,上滑取消", ) setModelStatus("正在录音 · 本地模型准备中") + setTranscript("可以说话了 · 松开空格结束") onInlineState( "正在录音 · 模型准备中…", false, @@ -355,6 +357,13 @@ internal class VoicePanelController( } } + override fun onCaptureStopped() { + expandedPanel.post { + if (generation != eventGeneration || !active || cancelled) return@post + listener.onVoiceCaptureStopped() + } + } + override fun onModelReady() { expandedPanel.post { if (generation != eventGeneration) return@post @@ -434,6 +443,7 @@ internal class VoicePanelController( } private fun conciseVoiceError(message: String): String = when { + message.contains("音频不完整") -> "语音不完整 · 请分短句重试" message.contains("模型") -> "语音不可用 · 请检查本地模型" message.contains("麦克风") || message.contains("权限") -> "语音不可用 · 请检查麦克风权限" diff --git a/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt b/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt index a50bdd63..b9450cf6 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt @@ -13,7 +13,6 @@ import android.widget.LinearLayout import android.widget.TextView import llc.slacker.openime.R import llc.slacker.openime.data.ImeSettingsRepository -import llc.slacker.openime.theme.AccentPalette import llc.slacker.openime.theme.ImeDrawableFactory import llc.slacker.openime.theme.ImeSurfacePolicy import llc.slacker.openime.theme.ImeTheme @@ -52,7 +51,7 @@ internal class VoicePanelView( setPadding(toPx(12), toPx(14), toPx(12), toPx(10)) gravity = Gravity.CENTER_HORIZONTAL val dark = resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == android.content.res.Configuration.UI_MODE_NIGHT_YES - val palette = ImeTheme.IOS.tokens(ImeSettingsRepository.loadAppearance(context), dark, AccentPalette.parse(ImeSettingsRepository.loadSkinColor(context))) + val palette = ImeTheme.IOS.tokens(ImeSettingsRepository.loadAppearance(context), dark) val modelReady = initialModelState in setOf(VoiceModelLifecycleState.HOT, VoiceModelLifecycleState.RECORDING, VoiceModelLifecycleState.COOLDOWN) modelStatus.apply { text = if (modelReady) "离线模型已就绪 · 音频不出设备" else "离线模型准备中 · 音频不出设备" @@ -156,7 +155,7 @@ internal class VoicePanelView( private fun updateLanguagePresentation(locked: Boolean) { val selected = LANGUAGES[languageIndex].first val dark = resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == android.content.res.Configuration.UI_MODE_NIGHT_YES - val palette = ImeTheme.IOS.tokens(ImeSettingsRepository.loadAppearance(context), dark, AccentPalette.parse(ImeSettingsRepository.loadSkinColor(context))) + val palette = ImeTheme.IOS.tokens(ImeSettingsRepository.loadAppearance(context), dark) listOf(languageButton, englishButton).forEachIndexed { index, button -> val active = index == languageIndex button.tag = if (active) "segment-selected" else "segment-option" diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceRecognitionBackend.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceRecognitionBackend.kt index 46bad2db..955321c4 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceRecognitionBackend.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceRecognitionBackend.kt @@ -7,6 +7,9 @@ import com.k2fsa.sherpa.onnx.OnlineModelConfig import com.k2fsa.sherpa.onnx.OnlineParaformerModelConfig import com.k2fsa.sherpa.onnx.OnlineRecognizer import com.k2fsa.sherpa.onnx.OnlineRecognizerConfig +import com.k2fsa.sherpa.onnx.OfflinePunctuation +import com.k2fsa.sherpa.onnx.OfflinePunctuationConfig +import com.k2fsa.sherpa.onnx.OfflinePunctuationModelConfig import com.k2fsa.sherpa.onnx.OnlineStream import java.io.File import java.util.concurrent.atomic.AtomicBoolean @@ -23,6 +26,8 @@ interface VoiceRecognitionEvents { fun onError(message: String) /** Microphone capture is live; model loading may still be in progress. */ fun onReady() + /** Capture has ended; decoding may still be draining buffered audio. */ + fun onCaptureStopped() {} /** The buffered audio is now connected to the local recognizer. */ fun onModelReady() {} } @@ -184,6 +189,7 @@ private class SherpaOnnxStreamingRuntime( private var modelFiles: SherpaRuntimeModelFiles? = resolveSherpaRuntimeModelFiles(initialSelection, downloadedRoot) private var recognizer: OnlineRecognizer? = null + private var punctuation: OfflinePunctuation? = null private val activeSessions = mutableSetOf() private var legacySession: SherpaSession? = null @@ -250,6 +256,8 @@ private class SherpaOnnxStreamingRuntime( legacySession = null recognizer?.release() recognizer = null + punctuation?.release() + punctuation = null } private fun ensureRecognizerLocked(): OnlineRecognizer { @@ -322,6 +330,10 @@ private class SherpaOnnxStreamingRuntime( // silence before InputFinished so the last chunk can flush. currentStream.acceptWaveform(FloatArray(PARAFORMER_TAIL_PADDING_SAMPLES), SAMPLE_RATE) currentStream.inputFinished() + // InputFinished ends feature extraction, but Paraformer's + // IsReady accepts the remaining short chunk only with this + // option. Otherwise already-recorded tail frames stay undecoded. + currentStream.setOption("is_final", "1") val decoded = decodeReadyLocked(currentRecognizer, currentStream) val recognizerFinal = currentRecognizer.getResult(currentStream).text.trim() val usedFallback = recognizerFinal.isBlank() && lastPartial.isNotBlank() @@ -340,7 +352,40 @@ private class SherpaOnnxStreamingRuntime( return result } - override fun punctuate(text: String): String = VoiceTextProcessor.process(text, languageTag) + override fun punctuate(text: String): String { + val policy = VoiceTextProcessingPolicy( + autoTerminalPunctuation = VoiceEditorContext.allowNaturalPunctuation(), + stripFillers = VoiceEditorContext.stripFillers(), + punctuationAsSpace = VoiceEditorContext.punctuationAsSpace(), + ) + val prepared = VoiceTextProcessor.process(text, languageTag, policy.copy( + autoTerminalPunctuation = false, punctuationAsSpace = false, + )) + // Preserve explicitly dictated marks and structured editor values. + val restored = if (policy.autoTerminalPunctuation && prepared.none { it in ",。!?;:,.!?;:" }) { + synchronized(runtimeLock) { + checkOpenLocked() + runCatching { + val model = punctuation ?: OfflinePunctuation(context.assets, + OfflinePunctuationConfig(model = OfflinePunctuationModelConfig( + ctTransformer = "models/voice/punctuation/model.int8.onnx", + numThreads = 2, + )), + ).also { punctuation = it } + model.addPunctuation(prepared) + }.onFailure { Log.w("OpenImeVoicePerf", "punctuation failed; keeping transcript", it) } + .getOrNull() + } + } else null + val normalized = if (prepared.none { it in '\u4e00'..'\u9fff' }) { + (restored ?: prepared).replace(',', ',').replace('。', '.') + .replace('?', '?').replace('!', '!').replace(':', ':').replace(';', ';') + } else restored ?: prepared + return VoiceTextProcessor.process(normalized, languageTag, policy.copy( + autoTerminalPunctuation = restored == null && policy.autoTerminalPunctuation, + stripFillers = false, + )) + } override fun close() { synchronized(runtimeLock) { diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceSessionHost.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceSessionHost.kt index 681dac1b..2cc67993 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceSessionHost.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceSessionHost.kt @@ -8,6 +8,7 @@ package llc.slacker.openime.voice interface VoiceSessionHost { /** Kept for compatibility; production voice always commits on release of space. */ fun onVoiceSessionStarted(autoCommitOnFinal: Boolean) {} + fun onVoiceCaptureStopped() {} fun onVoicePartial(text: String) {} fun onVoiceFinal(text: String) {} fun onVoiceError(message: String) {} diff --git a/app/src/main/java/llc/slacker/openime/voice/VoiceTextProcessor.kt b/app/src/main/java/llc/slacker/openime/voice/VoiceTextProcessor.kt index c7cd1e45..90017cc1 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoiceTextProcessor.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoiceTextProcessor.kt @@ -9,8 +9,8 @@ internal data class VoiceTextProcessingPolicy( /** * Fast, deterministic post-processing kept between ASR and InputConnection. * - * The APK currently ships a streaming recognition model without a separate - * punctuation model. Spoken punctuation is interpreted conservatively and + * The streaming runtime restores punctuation before this final formatting step. + * Spoken punctuation is interpreted conservatively and * terminal punctuation is enabled only for prose-like EditorInfo contexts. */ object VoiceTextProcessor { @@ -80,7 +80,7 @@ object VoiceTextProcessor { if ( policy.autoTerminalPunctuation && text.length >= 4 && - text.last() !in "。!?…" + text.last() !in "。!?….!?" ) { text += if (text.endsWith("吗") || text.endsWith("呢") || text.endsWith("么")) { "?" @@ -127,6 +127,8 @@ object VoiceTextProcessor { private fun punctuationToSpaces(value: String): String { var text = value .replace(Regex("[,。!?;:、]+|…+|—{2,}"), " ") + // ASCII clause marks next to Chinese are punctuation too. + .replace(Regex("(?<=[\\u4e00-\\u9fff])[,.!?;:]+|[,.!?;:]+(?=[\\u4e00-\\u9fff])"), " ") // Latin marks only when they end a word, so 3.5 and a.b stay intact. .replace(Regex("(?<=\\S)[,.!?;:]+(?=\\s|$)"), " ") text = text.replace(Regex("\\s+"), " ").trim() @@ -155,7 +157,7 @@ object VoiceTextProcessor { private fun collapsePunctuation(value: String): String = value .replace(Regex("[。]{2,}"), "。") - .replace(Regex("[!!]{2,}"), "!") - .replace(Regex("[??]{2,}"), "?") - .replace(Regex("[,,]{2,}"), ",") + .replace(Regex("[!!]{2,}")) { it.value.take(1) } + .replace(Regex("[??]{2,}")) { it.value.take(1) } + .replace(Regex("[,,]{2,}")) { it.value.take(1) } } diff --git a/app/src/main/res/drawable/ic_brand_mark.xml b/app/src/main/res/drawable/ic_brand_mark.xml index f66e0185..6046cecb 100644 --- a/app/src/main/res/drawable/ic_brand_mark.xml +++ b/app/src/main/res/drawable/ic_brand_mark.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 00000000..cab662d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_appearance.xml b/app/src/main/res/drawable/ic_pref_appearance.xml new file mode 100644 index 00000000..a79f8a10 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_appearance.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_bubble.xml b/app/src/main/res/drawable/ic_pref_bubble.xml new file mode 100644 index 00000000..0d6af701 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_bubble.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_check.xml b/app/src/main/res/drawable/ic_pref_check.xml new file mode 100644 index 00000000..181c234e --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_check.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_clipboard.xml b/app/src/main/res/drawable/ic_pref_clipboard.xml new file mode 100644 index 00000000..7adb7d21 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_clipboard.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_cursor.xml b/app/src/main/res/drawable/ic_pref_cursor.xml new file mode 100644 index 00000000..ef5b2f3e --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_cursor.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_emoji.xml b/app/src/main/res/drawable/ic_pref_emoji.xml new file mode 100644 index 00000000..b65d0838 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_emoji.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_floating.xml b/app/src/main/res/drawable/ic_pref_floating.xml new file mode 100644 index 00000000..01530bd6 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_floating.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_fuzzy.xml b/app/src/main/res/drawable/ic_pref_fuzzy.xml new file mode 100644 index 00000000..c6e15716 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_fuzzy.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_globe.xml b/app/src/main/res/drawable/ic_pref_globe.xml new file mode 100644 index 00000000..08e08b99 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_globe.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_haptic.xml b/app/src/main/res/drawable/ic_pref_haptic.xml new file mode 100644 index 00000000..2d36410e --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_haptic.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_height.xml b/app/src/main/res/drawable/ic_pref_height.xml new file mode 100644 index 00000000..bd44c88f --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_height.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_hints.xml b/app/src/main/res/drawable/ic_pref_hints.xml new file mode 100644 index 00000000..9a354a2f --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_hints.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_info.xml b/app/src/main/res/drawable/ic_pref_info.xml new file mode 100644 index 00000000..898aea16 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_info.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_mic.xml b/app/src/main/res/drawable/ic_pref_mic.xml new file mode 100644 index 00000000..89ebdb67 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_mic.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_opacity.xml b/app/src/main/res/drawable/ic_pref_opacity.xml new file mode 100644 index 00000000..b4e8335e --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_opacity.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_settings.xml b/app/src/main/res/drawable/ic_pref_settings.xml new file mode 100644 index 00000000..f7de25eb --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_settings.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_sound.xml b/app/src/main/res/drawable/ic_pref_sound.xml new file mode 100644 index 00000000..e1f5c416 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_sound.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_space.xml b/app/src/main/res/drawable/ic_pref_space.xml new file mode 100644 index 00000000..56107cbd --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_space.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_swipe_up.xml b/app/src/main/res/drawable/ic_pref_swipe_up.xml new file mode 100644 index 00000000..54829a15 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_swipe_up.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_symbols.xml b/app/src/main/res/drawable/ic_pref_symbols.xml new file mode 100644 index 00000000..82cb1b23 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_symbols.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_waveform.xml b/app/src/main/res/drawable/ic_pref_waveform.xml new file mode 100644 index 00000000..c96d3a18 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_waveform.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_width.xml b/app/src/main/res/drawable/ic_pref_width.xml new file mode 100644 index 00000000..a7f5d386 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_width.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index e0a6444b..1c746bd5 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,55 +1,118 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +