From 58ca5f8cb7fd22e99a2bc2a088c156cef17de5cf Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:35:50 +0800 Subject: [PATCH] =?UTF-8?q?Release=200.0.5-beta.1:=20crisp=20adjustable=20?= =?UTF-8?q?haptics,=20key=20sound=20fix,=20=E4=B8=AD/=E8=8B=B1=20on=20the?= =?UTF-8?q?=20right?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Key haptics drive the vibrator directly (PRIMITIVE_CLICK on API 30+, EFFECT_CLICK on API 29, a 20 ms one-shot before) instead of the system's fixed keyboard amplitude, tagged as touch feedback. A 震动强度 slider (10-100%, default 100%) scales it, previews while dragged, and is part of the user-data archive. - Key clicks use playSoundEffect(effect, volume) so the in-app switch works when the system "touch sounds" setting is off (as AOSP LatinIME does). - 26-key bottom row is now 123 | ,。 | 空格 | 中/英 | 确定 in both languages. - Remove the undo-after-clear: no swipe-down restore, no "已清空 · 撤销" banner and no gateway snapshot. The toolbar undo button stays. - Split 关于与数据 into AboutActivity (privacy, diagnostics, version) and DataManagementActivity (export/import, voice word lists, uninstall note); the settings section 关于与数据 lists both. - Tools panel drops 语音输入 and 切换键盘 and gains 数据管理. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++ README.md | 4 +- VERSION | 2 +- .../AuditInteractionInstrumentedTest.kt | 2 +- app/src/main/AndroidManifest.xml | 11 +- .../java/llc/slacker/openime/AboutActivity.kt | 148 ++++++++++++++++++ ...aActivity.kt => DataManagementActivity.kt} | 120 +++----------- .../slacker/openime/ImeSettingsActivity.kt | 11 +- .../slacker/openime/LocalVoiceImeService.kt | 50 +----- .../openime/data/ImeSettingsRepository.kt | 12 ++ .../slacker/openime/data/UserDataArchive.kt | 5 + .../openime/editor/InputConnectionGateway.kt | 131 +--------------- .../keyboard/BackspaceGestureController.kt | 87 +++------- .../openime/keyboard/ImeKeyboardView.kt | 78 ++++----- .../openime/keyboard/ImeThemeApplier.kt | 9 -- .../slacker/openime/keyboard/ImeTopZone.kt | 105 ++----------- .../slacker/openime/keyboard/KeyHaptics.kt | 114 ++++++++++++++ .../openime/keyboard/KeyPopupController.kt | 16 +- .../keyboard/Pinyin26KeyboardRenderer.kt | 13 +- .../openime/keyboard/ProductionKeyPolicy.kt | 2 +- .../slacker/openime/panel/ImePanelRenderer.kt | 6 +- .../openime/panel/SettingsPanelController.kt | 17 +- .../java/llc/slacker/openime/setup/SetupUi.kt | 42 +++++ app/src/main/res/drawable/ic_pref_data.xml | 1 + app/src/main/res/drawable/ic_pref_shield.xml | 1 + app/src/main/res/values/strings.xml | 3 +- .../openime/InputConnectionGatewayTest.kt | 76 +-------- .../openime/ProductionKeyPolicyTest.kt | 2 +- docs/APP_UI_SPEC.md | 11 +- docs/NINE_KEY_REFERENCE.md | 8 +- docs/TEST_SOP.md | 5 +- scripts/design_reference_e2e.py | 11 +- 32 files changed, 503 insertions(+), 612 deletions(-) create mode 100644 app/src/main/java/llc/slacker/openime/AboutActivity.kt rename app/src/main/java/llc/slacker/openime/{AboutDataActivity.kt => DataManagementActivity.kt} (67%) create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt create mode 100644 app/src/main/res/drawable/ic_pref_data.xml create mode 100644 app/src/main/res/drawable/ic_pref_shield.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index ac455a21..369ce26d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +## [0.0.5-beta.1] - 2026-10-05 + +第五个测试版:按键震动更响更清脆并可调强度,按键音效恢复,“中/英”键移到空格右边,整理“关于与数据”和工具面板。从 0.0.4-beta.1 覆盖安装即可,用户数据保留。 + +- 按键震动更响更清脆:不再走系统给键盘的固定低振幅,直接驱动振动马达播放满强度的短促点击(Android 11 起用 CLICK 原语,Android 10 用系统预设点击,更早版本用 20ms 满幅脉冲),仍跟随系统“触摸反馈”强度;删除键手势到位也是同一下点击。 +- 新增“震动强度”滑块(10%–100%,默认 100%),在偏好设置“触感震动”下方和键盘内设置首屏;拖动时实时试震,随用户数据导出、导入。 +- 修复“按键音效”打开后没有声音:系统“触摸提示音”关闭时按键音也被一起静音,现在只看 App 内的开关。 +- 26 键的“中/英”键移到空格右边,与“,。”键对调,中英文键盘一致。 +- 删除键上滑清空后不再出现“5 秒内下滑撤回”和顶栏的“已清空 · 撤销”;工具栏的普通撤销按钮保留。 +- “关于与数据”拆成两页:“关于”(版本、隐私、诊断)和“数据管理”(导出与导入用户数据、语音词表、卸载前提示)。偏好设置和键盘内设置的最后一组改名为“关于与数据”,下面是这两项。 +- 键盘工具面板去掉“语音输入”(按住空格即可)和“切换键盘”(工具栏已有),新增“数据管理”。 + ## [0.0.4-beta.1] - 2026-10-04 第四个测试版:松开空格立即结束录音,已录音频继续完整转写;加入离线中英标点恢复,重新整理 App 首页与设置页。 diff --git a/README.md b/README.md index 06233c5f..8954cf2c 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,14 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库 - **九键**:输入时左栏列出下一个字的拼音,一个字选一个音节,选定后自动移到下一个字。 - **语音输入**:长按空格说话,松手后识别并上屏;使用内置的中英双语模型,不联网;可去掉“嗯”“呃”等语气词,也可把标点写成空格。 - **语音词表**:内置科技、应用、游戏词表,可导入自己的文本词表;识别后把同音词改成词表写法,不联网。 -- **手势**:删除键上滑清空,5 秒内下滑可撤回;空格左右滑动移动光标,滑动时底行其他按键锁定。 +- **手势**:删除键上滑清空;空格左右滑动移动光标,滑动时底行其他按键锁定。 - **表情联想**:选词后联想栏先给出相关表情(开心 → 😊),词表内置,不联网。 - **自动填充**:Android 11+ 上,密码管理器的账号、验证码直接显示在键盘工具栏位置。 - **数字和符号**:26 键字母键右上角印着数字和符号,上滑或长按直接输入。 - **适配**:横竖屏、平板与折叠屏、深色模式、大字号;终端、远程桌面、游戏等原始按键输入框; 外接键盘可直接打拼音。详见 [兼容性说明](docs/COMPATIBILITY.md)。 - **自我保护**:连续崩溃或卡死后自动进入安全模式,保证仍然可以打字; - “设置 → 关于与数据”可复制诊断信息并导出、导入用户数据。 + “设置 → 关于与数据”下,“关于”可复制诊断信息,“数据管理”可导出、导入用户数据。 ## 下载与安装 diff --git a/VERSION b/VERSION index 47875e82..b16e4f99 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.4-beta.1 +0.0.5-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 1a3031ba..ea3f221a 100644 --- a/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/AuditInteractionInstrumentedTest.kt @@ -87,7 +87,7 @@ class AuditInteractionInstrumentedTest { } "onFuzzyChanged" -> { fuzzyChanges.add(args!![0] as Boolean); null } // A proxy answers null for everything else; a primitive boolean - // query (hasClearUndo, onUndoClear) must answer false instead. + // query must answer false instead. else -> if (method.returnType == java.lang.Boolean.TYPE) false else null } } as ImeKeyboardView.Listener diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 18eb25eb..cdaf5a91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + + android:label="@string/activity_label_about" /> + + + if (android.os.Build.VERSION.SDK_INT >= 30) { + val bars = insets.getInsets( + WindowInsets.Type.systemBars() or + WindowInsets.Type.displayCutout(), + ) + view.setPadding(bars.left, bars.top, bars.right, bars.bottom) + } else { + @Suppress("DEPRECATION") + view.setPadding( + insets.systemWindowInsetLeft, + insets.systemWindowInsetTop, + insets.systemWindowInsetRight, + insets.systemWindowInsetBottom, + ) + } + insets + } + addView(content) + }, + ) + } + + private fun diagnosticsCard(): LinearLayout { + val safeMode = CrashGuard.isSafeMode(this) + val lastReport = CrashGuard.lastReport(this) + val card = SetupUi.infoCard( + this, + title = "诊断", + body = when { + safeMode -> "输入法刚才多次异常退出,已临时关闭原生词库和语音预加载;约 10 分钟后自动恢复,也可以现在退出。" + lastReport != null -> "最近一次异常:" + lastReport.lineSequence().first().substringAfter("| ").substringBefore(" | thread") + else -> "没有异常记录。" + } + "\n诊断信息只含异常类型和代码位置,不含任何输入内容;只有你点“复制”才会离开这里。", + iconRes = R.drawable.ic_pref_info, + ) + if (lastReport == null && !safeMode) return card + val row = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + if (lastReport != null) { + addView( + SetupUi.secondaryButton(this@AboutActivity, "复制诊断信息") { + val clipboard = getSystemService(android.content.ClipboardManager::class.java) + clipboard.setPrimaryClip(android.content.ClipData.newPlainText("openIME diagnostics", lastReport)) + Toast.makeText(this@AboutActivity, "已复制", Toast.LENGTH_SHORT).show() + }, + LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) }, + ) + } + if (safeMode) { + addView( + SetupUi.primaryButton(this@AboutActivity, "退出安全模式") { + CrashGuard.clearHistory(this@AboutActivity) + Toast.makeText(this@AboutActivity, "下次打开键盘时恢复完整功能", Toast.LENGTH_SHORT).show() + recreate() + }, + LinearLayout.LayoutParams(0, dp(44), 1f), + ) + } + } + card.addView( + row, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)).apply { + topMargin = dp(12) + marginStart = dp(44) + }, + ) + return card + } + + @Suppress("DEPRECATION") + private fun versionName(): String = + packageManager.getPackageInfo(packageName, 0).versionName.orEmpty() + .ifBlank { "未知版本" } + + private fun wrap() = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + + private fun dp(value: Int): Int = SetupUi.dp(this, value) +} diff --git a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt b/app/src/main/java/llc/slacker/openime/DataManagementActivity.kt similarity index 67% rename from app/src/main/java/llc/slacker/openime/AboutDataActivity.kt rename to app/src/main/java/llc/slacker/openime/DataManagementActivity.kt index b93a741a..d31c4506 100644 --- a/app/src/main/java/llc/slacker/openime/AboutDataActivity.kt +++ b/app/src/main/java/llc/slacker/openime/DataManagementActivity.kt @@ -8,7 +8,6 @@ import android.os.Bundle import android.view.WindowInsets import android.widget.LinearLayout import android.widget.ScrollView -import android.widget.TextView import android.widget.Toast import llc.slacker.openime.core.CrashGuard import llc.slacker.openime.data.UserDataArchive @@ -17,13 +16,15 @@ import llc.slacker.openime.data.UserDataImportPreview import llc.slacker.openime.data.UserDataRepository import llc.slacker.openime.hotword.HotwordPacksActivity import llc.slacker.openime.setup.SetupUi -import llc.slacker.openime.theme.ImeDrawableFactory import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeSpacingTokens -import llc.slacker.openime.theme.ImeTypographyTokens import java.io.File -class AboutDataActivity : Activity() { +/** + * 数据管理: export / import of user data, the voice word lists, and what an + * uninstall removes. Version, privacy and diagnostics live in [AboutActivity]. + */ +class DataManagementActivity : Activity() { override fun attachBaseContext(newBase: android.content.Context) { super.attachBaseContext(SetupUi.appearanceContext(newBase)) } @@ -43,8 +44,8 @@ class AboutDataActivity : Activity() { ) addView( SetupUi.activityTopBar( - context = this@AboutDataActivity, - title = "关于与数据", + context = this@DataManagementActivity, + title = "数据管理", onBack = ::finish, ), LinearLayout.LayoutParams( @@ -52,22 +53,16 @@ class AboutDataActivity : Activity() { dp(ImeGeometryTokens.TOP_BAR_HEIGHT_DP), ).apply { marginStart = -dp(16); marginEnd = -dp(16) }, ) - addView( - infoCard( - title = "隐私", - body = "本应用不含联网权限,数据只存在本机。", - ), - wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }, - ) val dataCard = infoCard( title = "用户数据", body = "JSON 导出包含常用语、自定义符号、备用用户词条和设置项;剪贴板历史不导出。Rime 自动学习词库在已加载时一并导出,并在导入时按词库合并。", + iconRes = R.drawable.ic_pref_data, ) - addView(dataCard, wrap().apply { topMargin = dp(12) }) - val actions = LinearLayout(this@AboutDataActivity).apply { + addView(dataCard, wrap().apply { topMargin = dp(ImeSpacingTokens.SM_DP) }) + val actions = LinearLayout(this@DataManagementActivity).apply { orientation = LinearLayout.HORIZONTAL addView( - SetupUi.primaryButton(this@AboutDataActivity, "导出") { + SetupUi.primaryButton(this@DataManagementActivity, "导出") { requestExportDocument() }, LinearLayout.LayoutParams( @@ -77,7 +72,7 @@ class AboutDataActivity : Activity() { ).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) }, ) addView( - SetupUi.secondaryButton(this@AboutDataActivity, "导入") { + SetupUi.secondaryButton(this@DataManagementActivity, "导入") { requestImportDocument() }, LinearLayout.LayoutParams( @@ -97,11 +92,12 @@ class AboutDataActivity : Activity() { val hotwordCard = infoCard( title = "语音词表", body = "内置游戏、科技和应用词表,也可以导入自己的词表文件。识别后把同音的词改成词表里的写法,不联网。", + iconRes = R.drawable.ic_pref_waveform, ) addView(hotwordCard, wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }) hotwordCard.addView( - SetupUi.secondaryButton(this@AboutDataActivity, "管理词表") { - startActivity(Intent(this@AboutDataActivity, HotwordPacksActivity::class.java)) + SetupUi.secondaryButton(this@DataManagementActivity, "管理词表") { + startActivity(Intent(this@DataManagementActivity, HotwordPacksActivity::class.java)) }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)) .apply { topMargin = dp(12); marginStart = dp(44) }, @@ -110,53 +106,10 @@ class AboutDataActivity : Activity() { infoCard( title = "卸载前", body = "卸载会清除本机全部数据,包括学习的用户词库。卸载前可先导出用户数据。", + iconRes = R.drawable.ic_pref_info, ), wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }, ) - val safeMode = CrashGuard.isSafeMode(this@AboutDataActivity) - val lastReport = CrashGuard.lastReport(this@AboutDataActivity) - val diagnostics = infoCard( - title = "诊断", - body = when { - safeMode -> "输入法刚才多次异常退出,已临时关闭原生词库和语音预加载;约 10 分钟后自动恢复,也可以现在退出。" - lastReport != null -> "最近一次异常:" + lastReport.lineSequence().first().substringAfter("| ").substringBefore(" | thread") - else -> "没有异常记录。" - } + "\n诊断信息只含异常类型和代码位置,不含任何输入内容;只有你点“复制”才会离开这里。", - ) - addView(diagnostics, wrap().apply { topMargin = dp(ImeSpacingTokens.LG_DP) }) - if (lastReport != null || safeMode) { - val row = LinearLayout(this@AboutDataActivity).apply { - orientation = LinearLayout.HORIZONTAL - if (lastReport != null) { - addView( - SetupUi.secondaryButton(this@AboutDataActivity, "复制诊断信息") { - val clipboard = getSystemService(android.content.ClipboardManager::class.java) - clipboard.setPrimaryClip(android.content.ClipData.newPlainText("openIME diagnostics", lastReport)) - Toast.makeText(this@AboutDataActivity, "已复制", Toast.LENGTH_SHORT).show() - }, - LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginEnd = dp(ImeSpacingTokens.SM_DP) }, - ) - } - if (safeMode) { - addView( - SetupUi.primaryButton(this@AboutDataActivity, "退出安全模式") { - CrashGuard.clearHistory(this@AboutDataActivity) - Toast.makeText(this@AboutDataActivity, "下次打开键盘时恢复完整功能", Toast.LENGTH_SHORT).show() - recreate() - }, - LinearLayout.LayoutParams(0, dp(44), 1f), - ) - } - } - diagnostics.addView( - row, - LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(44)).apply { topMargin = dp(12); marginStart = dp(44) }, - ) - } - addView(TextView(this@AboutDataActivity).apply { - text = "openIME · 版本 " + versionName(); textSize = ImeTypographyTokens.SMALL_SP; gravity = android.view.Gravity.CENTER - setTextColor(getColor(R.color.setup_body)) - }, wrap().apply { topMargin = dp(18) }) } setContentView( @@ -384,45 +337,8 @@ class AboutDataActivity : Activity() { } } - private fun infoCard(title: String, body: String): LinearLayout = - LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding( - dp(ImeSpacingTokens.LG_DP), - dp(ImeSpacingTokens.LG_DP), - dp(ImeSpacingTokens.LG_DP), - dp(ImeSpacingTokens.LG_DP), - ) - background = SetupUi.rounded( - getColor(R.color.setup_surface), - dp(ImeGeometryTokens.CARD_RADIUS_DP).toFloat(), - getColor(R.color.setup_input_line), - ) - addView(LinearLayout(this@AboutDataActivity).apply { - orientation = LinearLayout.HORIZONTAL - addView(android.widget.ImageView(this@AboutDataActivity).apply { - setImageResource(if (title == "用户数据") R.drawable.ic_download else if (title == "隐私") R.drawable.ic_shield else R.drawable.ic_info) - imageTintList = android.content.res.ColorStateList.valueOf(SetupUi.accent(this@AboutDataActivity)) - scaleType = android.widget.ImageView.ScaleType.FIT_CENTER - setPadding(dp(8), dp(8), dp(8), dp(8)) - background = SetupUi.rounded(ImeDrawableFactory.blend(SetupUi.accent(this@AboutDataActivity), getColor(R.color.setup_surface), 0.14f), dp(8).toFloat()) - }, LinearLayout.LayoutParams(dp(32), dp(32)).apply { marginEnd = dp(12) }) - addView(LinearLayout(this@AboutDataActivity).apply { - orientation = LinearLayout.VERTICAL - addView(TextView(this@AboutDataActivity).apply { - text = title; textSize = ImeTypographyTokens.BODY_SP; setTextColor(getColor(R.color.setup_title)); typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) - }, wrap()) - addView(TextView(this@AboutDataActivity).apply { - text = body; textSize = ImeTypographyTokens.BODY_SP; setTextColor(getColor(R.color.setup_body)); setLineSpacing(0f, 1.3f) - }, wrap().apply { topMargin = dp(4) }) - }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) - }, wrap()) - } - - @Suppress("DEPRECATION") - private fun versionName(): String = - packageManager.getPackageInfo(packageName, 0).versionName.orEmpty() - .ifBlank { "未知版本" } + private fun infoCard(title: String, body: String, iconRes: Int): LinearLayout = + SetupUi.infoCard(this, title, body, iconRes) private fun showError(message: String) { val dialog = AlertDialog.Builder(this) diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt index 499fac40..0e3d47c0 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt @@ -200,6 +200,9 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { ImeSettingsRepository.saveFuzzy(this, enabled) refreshLiveIme() } + override fun onHapticStrengthChanged(percent: Int) { + refreshLiveIme() + } override fun onKeyboardHeightChanged(percent: Int) { ImeSettingsRepository.saveKeyboardHeightPercent(this, percent) refreshLiveIme() @@ -210,7 +213,11 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { refreshLiveIme() } - override fun onOpenAboutData() { - startActivity(Intent(this, AboutDataActivity::class.java)) + override fun onOpenAbout() { + startActivity(Intent(this, AboutActivity::class.java)) + } + + override fun onOpenDataManagement() { + startActivity(Intent(this, DataManagementActivity::class.java)) } } diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index c35f8a8d..30cc6935 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -343,10 +343,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { super.onStartInput(attribute, restarting) - if (!restarting) { - gateway.discardClearUndo() - keyboardView?.hideClearUndo() - } reloadPersistedSettings() voiceCorrectionTracker.clear() val previousRimeInputs = candidateQueries.activeInputs @@ -847,41 +843,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can voiceComposing = false state = state.copy(voiceState = VoiceUiState()) keyboardView?.renderState(state) - if (cleared && gateway.hasClearUndo()) { - keyboardView?.showClearUndo() - } else { - keyboardView?.hideClearUndo() - } - } - - override fun onUndoClear(): Boolean { - if (state.passwordField) return false - val restored = gateway.restoreLastClear() - if (!restored) { - showTextEditFeedback("没有可撤回的清空内容") - return false - } - voiceCorrectionTracker.clear() - voiceComposing = false - state = state.copy( - composition = "", - candidates = emptyList(), - expandedCandidates = emptyList(), - voiceState = VoiceUiState(), - ) - lastComposition = "" - renderedCandidateSnapshot = null - keyboardView?.clearAssociationCandidates() - keyboardView?.hideClearUndo() - keyboardView?.renderState(state) - return true - } - - override fun hasClearUndo(): Boolean = ::gateway.isInitialized && gateway.hasClearUndo() - - override fun onUndoClearExpired() { - gateway.discardClearUndo() - keyboardView?.hideClearUndo() } override fun onFloatingKeyboardChanged(floating: Boolean) { @@ -1828,11 +1789,12 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can ::rime.isInitialized && rime.importUserDictionaries(sourceDir, dictionaries, onComplete) - override fun onOpenAboutData() { - startActivity( - Intent(this, AboutDataActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) + override fun onOpenAbout() { + startActivity(Intent(this, AboutActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + override fun onOpenDataManagement() { + startActivity(Intent(this, DataManagementActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } internal companion object { 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 7cda51ec..a443efdb 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -13,6 +13,7 @@ object ImeSettingsRepository { private const val KEY_APPEARANCE = "appearance" private const val KEY_SOUND = "sound" private const val KEY_HAPTIC = "haptic" + private const val KEY_HAPTIC_STRENGTH = "haptic_strength_percent" private const val KEY_POPUP = "popup" private const val KEY_FUZZY = "fuzzy" private const val KEY_SWIPE_UP_DIGITS = "swipe_up_digits" @@ -126,6 +127,17 @@ object ImeSettingsRepository { .edit().putBoolean(KEY_HAPTIC, enabled).apply() } + /** Key-click strength, 10–100% of the full click. */ + fun loadHapticStrengthPercent(context: Context): Int = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getInt(KEY_HAPTIC_STRENGTH, 100) + .coerceIn(10, 100) + + fun saveHapticStrengthPercent(context: Context, percent: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putInt(KEY_HAPTIC_STRENGTH, percent.coerceIn(10, 100)).apply() + } + fun loadSwipeUpDigits(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getBoolean(KEY_SWIPE_UP_DIGITS, true) 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 fd0d2723..77ef2ede 100644 --- a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt +++ b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt @@ -42,6 +42,7 @@ internal data class ArchiveSettings( val emojiAssociation: Boolean = true, val voiceStripFillers: Boolean = true, val voicePunctuationAsSpace: Boolean = false, + val hapticStrengthPercent: Int = 100, ) internal data class RimeUserDictionaryArchive( @@ -174,6 +175,7 @@ internal object UserDataArchiveCodec { .put("appearance", value.appearance) .put("sound", value.sound) .put("haptic", value.haptic) + .put("haptic_strength_percent", value.hapticStrengthPercent) .put("popup", value.popup) .put("fuzzy", value.fuzzy) .put("swipe_up_digits", value.swipeUpDigits) @@ -192,6 +194,7 @@ internal object UserDataArchiveCodec { appearance = value.optString("appearance", ImeAppearance.SYSTEM.name), sound = value.optBoolean("sound", true), haptic = value.optBoolean("haptic", true), + hapticStrengthPercent = value.optInt("haptic_strength_percent", 100).coerceIn(10, 100), popup = value.optBoolean("popup", false), fuzzy = value.optBoolean("fuzzy", false), swipeUpDigits = value.optBoolean("swipe_up_digits", true), @@ -321,6 +324,7 @@ internal object UserDataRepository { appearance = ImeSettingsRepository.loadAppearance(context).name, sound = ImeSettingsRepository.loadSound(context), haptic = ImeSettingsRepository.loadHaptic(context), + hapticStrengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context), popup = ImeSettingsRepository.loadPopup(context), fuzzy = ImeSettingsRepository.loadFuzzy(context), swipeUpDigits = ImeSettingsRepository.loadSwipeUpDigits(context), @@ -403,6 +407,7 @@ internal object UserDataRepository { ) ImeSettingsRepository.saveSound(context, value.sound) ImeSettingsRepository.saveHaptic(context, value.haptic) + ImeSettingsRepository.saveHapticStrengthPercent(context, value.hapticStrengthPercent) ImeSettingsRepository.savePopup(context, value.popup) ImeSettingsRepository.saveFuzzy(context, value.fuzzy) ImeSettingsRepository.saveSwipeUpDigits(context, value.swipeUpDigits) diff --git a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt index 4dc24b75..78ecac7d 100644 --- a/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt +++ b/app/src/main/java/llc/slacker/openime/editor/InputConnectionGateway.kt @@ -45,7 +45,6 @@ class InputConnectionGateway( private val context: Context?, private val connection: () -> InputConnection?, private val isPassword: () -> Boolean = { false }, - private val nowMs: () -> Long = { System.nanoTime() / 1_000_000L }, /** * Editors with no text type (terminals, games, remote desktops: TYPE_NULL). * Their InputConnection is usually Android's BaseInputConnection in dummy @@ -75,38 +74,8 @@ class InputConnectionGateway( } } - private data class ClearUndoSnapshot( - val text: String, - val selectionStart: Int, - val selectionEnd: Int, - val connection: InputConnection, - val expiresAtMs: Long, - ) - - private var clearUndoSnapshot: ClearUndoSnapshot? = null - - private fun invalidateClearUndo() { - clearUndoSnapshot = null - } - - fun discardClearUndo() { - invalidateClearUndo() - } - - fun hasClearUndo(): Boolean { - if (isPassword()) return false - val snapshot = clearUndoSnapshot ?: return false - val current = connection() ?: return false - if (snapshot.connection !== current || nowMs() > snapshot.expiresAtMs) { - invalidateClearUndo() - return false - } - return true - } - fun commitText(text: String) { if (text.isEmpty()) return - invalidateClearUndo() val ic = connection() ?: return if (isRawKeys() && typeAsKeyEvents(ic, text)) return // One Binder transaction carries about 1 MB: committing a huge paste or @@ -133,7 +102,6 @@ class InputConnectionGateway( fun setComposingText(text: String) { if (isPassword()) return - if (text.isNotEmpty()) invalidateClearUndo() val ic = connection() ?: return if (text.isEmpty()) { ic.finishComposingText() @@ -190,7 +158,6 @@ class InputConnectionGateway( } fun deleteBackwards() { - invalidateClearUndo() val ic = connection() ?: return if (isRawKeys()) { sendKeyDownUp(ic, KeyEvent.KEYCODE_DEL) @@ -220,7 +187,6 @@ class InputConnectionGateway( /** Delete the active selection without falling back to one-character delete. */ fun deleteSelection(): Boolean { - invalidateClearUndo() val ic = connection() ?: return false if (isPassword()) { if (knownSelectionStart >= 0 && knownSelectionEnd >= 0 && knownSelectionStart != knownSelectionEnd) { @@ -280,7 +246,6 @@ class InputConnectionGateway( } fun deleteForwards() { - invalidateClearUndo() val ic = connection() ?: return if (isRawKeys()) { sendKeyDownUp(ic, KeyEvent.KEYCODE_FORWARD_DEL) @@ -325,7 +290,6 @@ class InputConnectionGateway( fun clearAllText(): Boolean { if (isPassword()) return false val ic = connection() ?: return false - clearUndoSnapshot = null ic.beginBatchEdit() return try { val originalSelection = selectionBeforeDestructiveSelectAll(ic) @@ -339,7 +303,6 @@ class InputConnectionGateway( restoreSelectionAfterFailedClear(ic, originalSelection) } else { ic.finishComposingText() - rememberClearUndo(selected, originalSelection, ic) } return cleared } @@ -358,7 +321,6 @@ class InputConnectionGateway( restoreSelectionAfterFailedClear(ic, originalSelection) } else { ic.finishComposingText() - rememberClearUndo(selectedWindow.text, originalSelection, ic) } return cleared } @@ -383,7 +345,6 @@ class InputConnectionGateway( restoreSelectionAfterFailedClear(ic, originalSelection) } else { ic.finishComposingText() - rememberClearUndo(window.text, originalSelection, ic) } cleared } finally { @@ -396,7 +357,7 @@ class InputConnectionGateway( * * Editors that implement neither select-all nor a complete ExtractedText * (custom canvas, Compose and web fields) still answer these two queries. - * Everything is captured first, so the clear can be undone, and the + * Everything is captured first, so a failed round can put it back, and the * deletion repeats until the editor itself reports that nothing is left. * That makes an editor which silently caps its answers safe: it is cleared * in several rounds instead of being left half full. @@ -440,101 +401,14 @@ class InputConnectionGateway( } ic.finishComposingText() - val head = beforeParts.asReversed().joinToString("") - val full = head + selected + afterParts.joinToString("") - rememberClearUndo( - text = full, - selection = SelectionSnapshot.Absolute(head.length, head.length + selected.length), - ic = ic, - ) return true } - /** - * Restore only the most recent successful full-document clear. Any normal - * edit invalidates the snapshot, and restoration is rejected unless the - * target document is still empty. - */ - fun restoreLastClear(): Boolean { - if (isPassword()) return false - val snapshot = clearUndoSnapshot ?: return false - val ic = connection() ?: return false - if (snapshot.connection !== ic || nowMs() > snapshot.expiresAtMs) { - invalidateClearUndo() - return false - } - - val window = extractedWindow(ic) - val documentEmpty = if (window?.isCompleteDocument == true) { - window.text.isEmpty() - } else { - val before = runCatching { ic.getTextBeforeCursor(1, 0)?.isEmpty() == true } - .getOrDefault(false) - val after = runCatching { ic.getTextAfterCursor(1, 0)?.isEmpty() == true } - .getOrDefault(false) - val selected = runCatching { ic.getSelectedText(0)?.isEmpty() != false } - .getOrDefault(false) - before && after && selected - } - if (!documentEmpty) { - invalidateClearUndo() - return false - } - - ic.beginBatchEdit() - return try { - val committed = runCatching { ic.commitText(snapshot.text, 1) }.getOrDefault(false) - if (!committed) return false - ic.finishComposingText() - val start = snapshot.selectionStart.coerceIn(0, snapshot.text.length) - val end = snapshot.selectionEnd.coerceIn(0, snapshot.text.length) - runCatching { ic.setSelection(start, end) } - knownSelectionStart = start - knownSelectionEnd = end - invalidateClearUndo() - true - } finally { - ic.endBatchEdit() - } - } - - private fun rememberClearUndo( - text: String, - selection: SelectionSnapshot?, - ic: InputConnection, - ) { - if (text.isEmpty()) return - val start: Int - val end: Int - when (selection) { - is SelectionSnapshot.Absolute -> { - start = selection.start - end = selection.end - } - is SelectionSnapshot.Relative -> { - start = selection.cursor - end = selection.cursor - } - null -> { - start = text.length - end = text.length - } - } - clearUndoSnapshot = ClearUndoSnapshot( - text = text, - selectionStart = start.coerceIn(0, text.length), - selectionEnd = end.coerceIn(0, text.length), - connection = ic, - expiresAtMs = nowMs() + CLEAR_UNDO_TIMEOUT_MS, - ) - } - fun performEditorAction(action: Int) { connection()?.performEditorAction(action) } fun sendKeyDownUp(keyCode: Int) { - invalidateClearUndo() val ic = connection() ?: return sendKeyDownUp(ic, keyCode) } @@ -740,7 +614,6 @@ class InputConnectionGateway( clip.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(safeContext)?.toString().orEmpty() }.getOrDefault("") if (text.isEmpty()) return "" - invalidateClearUndo() val committed = connection()?.commitText(text, 1) == true if (!committed) return "" onPasted(clip) @@ -748,7 +621,6 @@ class InputConnectionGateway( } fun commitContent(info: InputContentInfo): Boolean { - invalidateClearUndo() return connection()?.commitContent(info, 0, null) == true } @@ -853,7 +725,6 @@ class InputConnectionGateway( private companion object { const val FALLBACK_WINDOW_CHARS = 8_192 - const val CLEAR_UNDO_TIMEOUT_MS = 5_000L const val SURROUNDING_CHUNK = 100_000 const val MAX_SURROUNDING_ROUNDS = 8 } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt b/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt index 01bda39c..f09f7076 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt @@ -16,30 +16,18 @@ internal enum class GestureHint { /** Far enough: releasing now clears the whole field. */ CLEAR_ARMED, - - /** Moving down while a clear can still be undone. */ - UNDO_PREVIEW, - - /** Far enough: releasing now restores the cleared text. */ - UNDO_ARMED, } /** - * Owns the held-backspace gesture: repeat delete, upward clear-all, and the - * one-shot downward restore that is available immediately after a successful - * clear gesture. Editor mutations remain callbacks. + * Owns the held-backspace gesture: repeat delete and upward clear-all. A clear + * is final; there is no downward restore. Editor mutations remain callbacks. * - * Feedback is a single [GestureHint] stream. Clear and restore used to be two - * unrelated popups (a red pill and a white card) plus a tiny label inside the - * key; both are now the same bubble with a different label and fill. + * Feedback is a single [GestureHint] stream driving one bubble. */ internal class BackspaceGestureController( private val toPx: (Int) -> Int, private val onDeleteOne: () -> Unit, private val onClearAll: () -> Unit, - private val onUndoClear: () -> Boolean, - /** The editor-side truth: a clear can be undone only while its snapshot is alive. */ - private val hasUndoSnapshot: () -> Boolean, private val onPressFeedback: () -> Unit, private val onHapticFeedback: () -> Unit, private val onGestureHint: (View, GestureHint) -> Unit, @@ -55,8 +43,6 @@ internal class BackspaceGestureController( private set private var clearArmed = false - private var undoArmed = false - private var undoAvailable = false // re-read from the editor on every press private var repeatStarted = false private var repeatSuspended = false private var hint = GestureHint.NONE @@ -67,7 +53,7 @@ internal class BackspaceGestureController( private val repeatAction = object : Runnable { override fun run() { - if (!active || clearArmed || undoArmed) return + if (!active || clearArmed) return repeatStarted = true onDeleteOne() handler.postDelayed(this, REPEAT_INTERVAL_MS) @@ -84,11 +70,9 @@ internal class BackspaceGestureController( handler.removeCallbacks(repeatAction) repeatStartAction?.let(handler::removeCallbacks) - undoAvailable = hasUndoSnapshot() - Log.d(TAG, "bs begin x=$rawX y=$rawY pointer=$pointerId undoAvailable=$undoAvailable") + Log.d(TAG, "bs begin x=$rawX y=$rawY pointer=$pointerId") active = true clearArmed = false - undoArmed = false repeatStarted = false repeatSuspended = false hint = GestureHint.NONE @@ -103,7 +87,7 @@ internal class BackspaceGestureController( onPressFeedback() val startRepeat = Runnable { - if (active && !clearArmed && !undoArmed) repeatAction.run() + if (active && !clearArmed) repeatAction.run() } repeatStartAction = startRepeat handler.postDelayed(startRepeat, longPressTimeoutMs) @@ -112,7 +96,6 @@ internal class BackspaceGestureController( fun update(rawX: Float, rawY: Float) { if (!active) return val upward = startY - rawY - val downward = rawY - startY val horizontal = abs(rawX - startX) val armDistance = toPx(CLEAR_ARM_DP) val previewDistance = toPx(PREVIEW_DP) @@ -120,14 +103,13 @@ internal class BackspaceGestureController( // Suspend repeat-delete as soon as the gesture clearly becomes a // vertical command. Otherwise a slow swipe could mutate text before - // clear/restore is armed. - val verticalCommand = - onAxis && (upward >= previewDistance || (undoAvailable && downward >= previewDistance)) + // clear is armed. + val verticalCommand = onAxis && upward >= previewDistance if (verticalCommand) { repeatSuspended = true repeatStartAction?.let(handler::removeCallbacks) handler.removeCallbacks(repeatAction) - } else if (repeatSuspended && !clearArmed && !undoArmed) { + } else if (repeatSuspended && !clearArmed) { repeatSuspended = false repeatStartAction?.let { handler.postDelayed( @@ -137,46 +119,25 @@ internal class BackspaceGestureController( } } - // Restore is only offered right after a clear; the two commands are - // mutually exclusive, with hysteresis so a wobble cannot flip them. - val shouldUndo = undoAvailable && if (undoArmed) { - downward > toPx(RELEASE_DP) && horizontal <= toPx(HYSTERESIS_DRIFT_DP) + // Hysteresis so a wobble near the threshold cannot flip the state. + val shouldClear = if (clearArmed) { + upward > toPx(RELEASE_DP) && horizontal <= toPx(HYSTERESIS_DRIFT_DP) } else { - downward >= armDistance && onAxis + upward >= armDistance && onAxis } - if (shouldUndo != undoArmed) { - Log.d(TAG, "bs undoArmed=$shouldUndo down=${downward.toInt()} h=${horizontal.toInt()}") - undoArmed = shouldUndo - if (undoArmed) { - clearArmed = false + if (shouldClear != clearArmed) { + Log.d(TAG, "bs clearArmed=$shouldClear up=${upward.toInt()} h=${horizontal.toInt()}") + clearArmed = shouldClear + if (clearArmed) { repeatStartAction?.let(handler::removeCallbacks) handler.removeCallbacks(repeatAction) } onHapticFeedback() } - if (!undoArmed) { - val shouldClear = if (clearArmed) { - upward > toPx(RELEASE_DP) && horizontal <= toPx(HYSTERESIS_DRIFT_DP) - } else { - upward >= armDistance && onAxis - } - if (shouldClear != clearArmed) { - Log.d(TAG, "bs clearArmed=$shouldClear up=${upward.toInt()} h=${horizontal.toInt()}") - clearArmed = shouldClear - if (clearArmed) { - repeatStartAction?.let(handler::removeCallbacks) - handler.removeCallbacks(repeatAction) - } - onHapticFeedback() - } - } - setHint( when { - undoArmed -> GestureHint.UNDO_ARMED clearArmed -> GestureHint.CLEAR_ARMED - undoAvailable && onAxis && downward >= previewDistance -> GestureHint.UNDO_PREVIEW onAxis && upward >= previewDistance -> GestureHint.CLEAR_PREVIEW else -> GestureHint.NONE }, @@ -191,10 +152,9 @@ internal class BackspaceGestureController( fun finish(commit: Boolean, rawX: Float? = null, rawY: Float? = null) { if (!active) return if (commit && rawX != null && rawY != null) update(rawX, rawY) - Log.d(TAG, "bs finish commit=$commit clearArmed=$clearArmed undoArmed=$undoArmed repeat=$repeatStarted") - val restoreClear = commit && undoArmed + Log.d(TAG, "bs finish commit=$commit clearArmed=$clearArmed repeat=$repeatStarted") val clearAll = commit && clearArmed - val deleteOnce = commit && !clearArmed && !undoArmed && !repeatStarted + val deleteOnce = commit && !clearArmed && !repeatStarted handler.removeCallbacks(repeatAction) repeatStartAction?.let(handler::removeCallbacks) @@ -208,18 +168,12 @@ internal class BackspaceGestureController( active = false pointerId = -1 clearArmed = false - undoArmed = false repeatStarted = false repeatSuspended = false anchor = null onHidePopup() when { - restoreClear -> { - onHapticFeedback() - onUndoClear() - // The gateway validates the one-shot snapshot and consumes it. - } clearAll -> { onHapticFeedback() onClearAll() @@ -235,7 +189,6 @@ internal class BackspaceGestureController( handler.removeCallbacksAndMessages(null) repeatStartAction = null pointerId = -1 - undoAvailable = false } private fun setHint(next: GestureHint) { @@ -249,7 +202,7 @@ internal class BackspaceGestureController( private const val TAG = "OpenIme" /** - * Travel from the press point that arms clear (up) or restore (down). + * Upward travel from the press point that arms clear. * Half a key height past its edge: a normal thumb flick reaches it, a * wobble while holding repeat-delete does not. It used to be 56dp, more * than a full key above the key centre, so ordinary flicks fell through 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 a3659982..af16741e 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -13,7 +13,6 @@ import android.os.SystemClock import android.text.TextUtils import android.util.Log import android.view.Gravity -import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.SoundEffectConstants import android.view.View @@ -80,11 +79,6 @@ open class ImeKeyboardView( fun onCharacter(char: String) fun onBackspace() fun onClearAll() - fun onUndoClear(): Boolean = false - fun onUndoClearExpired() {} - - /** Whether the last clear-all can still be restored (drives the swipe-down hint). */ - fun hasClearUndo(): Boolean = false fun onSpace() fun onFloatingKeyboardChanged(floating: Boolean) fun onFloatingKeyboardDragged(deltaX: Float, deltaY: Float) @@ -128,8 +122,10 @@ open class ImeKeyboardView( fun onPopupChanged(enabled: Boolean) fun onFuzzyChanged(enabled: Boolean) fun onKeyboardHeightChanged(percent: Int) {} + fun onHapticStrengthChanged(percent: Int) {} fun onFloatingStyleChanged(widthPercent: Int, opacityPercent: Int) {} - fun onOpenAboutData() {} + fun onOpenAbout() {} + fun onOpenDataManagement() {} } /** Visual class marker for white keys (nine/digits grid). */ @@ -145,13 +141,11 @@ 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 - } + /** Slider drags preview the strength at most this often. */ + private const val STRENGTH_PREVIEW_INTERVAL_MS = 80L + + /** playSoundEffect's "use the default UI click volume" value. */ + private const val KEY_CLICK_DEFAULT_VOLUME = -1f } @@ -160,8 +154,6 @@ open class ImeKeyboardView( toPx = ::dp, onDeleteOne = ::performBackspaceOnce, onClearAll = listener::onClearAll, - onUndoClear = listener::onUndoClear, - hasUndoSnapshot = listener::hasClearUndo, onPressFeedback = ::feedback, onHapticFeedback = ::hapticFeedback, onGestureHint = { anchor, hint -> keyPopupController.showGestureHint(anchor, hint) }, @@ -350,6 +342,10 @@ open class ImeKeyboardView( private var shiftState = ShiftState.LOWERCASE private var soundEnabled = ImeSettingsRepository.loadSound(context) private var hapticEnabled = ImeSettingsRepository.loadHaptic(context) + private val keyHaptics = KeyHaptics(context).apply { + strengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context) + } + private var lastStrengthPreviewMs = 0L private val audioManager by lazy { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager } @@ -565,6 +561,7 @@ open class ImeKeyboardView( onModeSelected = { selected -> setMode(selected) }, onShowPanel = ::showPanel, onEnableFloatingKeyboard = ::enableFloatingKeyboard, + onOpenDataManagement = listener::onOpenDataManagement, onSymbolSelected = listener::onSymbolSelected, onCharacter = listener::onCharacter, onSpace = listener::onSpace, @@ -631,6 +628,7 @@ open class ImeKeyboardView( currentAppearance = { appearance }, currentSound = { soundEnabled }, currentHaptic = { hapticEnabled }, + currentHapticStrengthPercent = { keyHaptics.strengthPercent }, currentPopup = { popupEnabled }, currentSwipeUpDigits = { ImeSettingsRepository.loadSwipeUpDigits(context) }, currentExtraToggle = ::onState, @@ -645,9 +643,11 @@ open class ImeKeyboardView( }, onToggleChanged = ::updateSettingToggle, onKeyboardHeightChanged = ::setKeyboardHeightPercent, + onHapticStrengthChanged = ::setHapticStrengthPercent, onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, - onOpenAboutData = listener::onOpenAboutData, + onOpenAbout = listener::onOpenAbout, + onOpenDataManagement = listener::onOpenDataManagement, onFeedback = ::feedback, applyTheme = ::applyTheme, onHierarchyRebuilt = ::onViewHierarchyRebuilt, @@ -1150,8 +1150,7 @@ open class ImeKeyboardView( updateTopZone(composition.text?.isNotEmpty() == true) listener.onCandidateExpanded(open) }, - onUndoClear = { if (!listener.onUndoClear()) listener.onTextEdit("undo") }, - onUndoClearExpired = listener::onUndoClearExpired, + onUndo = { listener.onTextEdit("undo") }, onAssociationDismiss = ::clearAssociationCandidates, ) candidateBarController = CandidateBarController( @@ -1560,6 +1559,20 @@ open class ImeKeyboardView( listener.onFloatingStyleChanged(width, opacity) } + /** Save the new strength and play one click at it, so the slider is felt while dragged. */ + private fun setHapticStrengthPercent(percent: Int) { + val bounded = percent.coerceIn(KeyHaptics.MIN_STRENGTH, KeyHaptics.MAX_STRENGTH) + if (keyHaptics.strengthPercent == bounded) return + keyHaptics.strengthPercent = bounded + ImeSettingsRepository.saveHapticStrengthPercent(context, bounded) + listener.onHapticStrengthChanged(bounded) + val now = SystemClock.uptimeMillis() + if (now - lastStrengthPreviewMs >= STRENGTH_PREVIEW_INTERVAL_MS) { + lastStrengthPreviewMs = now + keyHaptics.click(this) + } + } + private fun setKeyboardHeightPercent(percent: Int) { val bounded = percent.coerceIn(80, 120) if (keyboardHeightPercent == bounded) return @@ -1608,6 +1621,7 @@ open class ImeKeyboardView( hapticEnabled = haptic popupEnabled = popup fuzzyEnabled = fuzzy + keyHaptics.strengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context) if (heightChanged) { keyboardHeightPercent = persistedHeight renderedMode = null @@ -1641,14 +1655,6 @@ open class ImeKeyboardView( } } - fun showClearUndo() { - if (::topZone.isInitialized) topZone.showUndoClear() - } - - fun hideClearUndo(discardSnapshot: Boolean = false) { - if (::topZone.isInitialized) topZone.hideUndoClear(discardSnapshot) - } - fun setAppearance(newAppearance: ImeAppearance) { if (appearance == newAppearance) return appearance = newAppearance @@ -2823,16 +2829,15 @@ open class ImeKeyboardView( } protected fun feedback() { - // 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 (hapticEnabled) keyHaptics.click(this) 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; - // the view channel remains the fallback for standalone previews. + // playSoundEffect(effect) plays only when the system "touch sounds" + // setting is on, which most phones ship off, so the in-app switch + // did nothing. The volume overload skips that check (AOSP LatinIME + // does the same); -1 is the platform's default key-click volume. runCatching { if (audioManager != null) { - audioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD) + audioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, KEY_CLICK_DEFAULT_VOLUME) } else { isSoundEffectsEnabled = true playSoundEffect(SoundEffectConstants.CLICK) @@ -2843,8 +2848,8 @@ open class ImeKeyboardView( /** Haptic-only confirmation (no key click sound), e.g. when voice arms. */ private fun hapticFeedback() { - // A crisp click marks a gesture threshold; LONG_PRESS rings for too long. - if (hapticEnabled) performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) + // A gesture threshold gets the same crisp click; LONG_PRESS rings for too long. + if (hapticEnabled) keyHaptics.click(this) } private fun key( @@ -2994,7 +2999,6 @@ open class ImeKeyboardView( expandedPanel.setBackgroundColor(t.keyboardBackground) candidateOverlay.setBackgroundColor(t.expandedBackground) themeApplier.apply(this, t) - topZone.applyTokens(t) composition.setTextColor(ImeSurfacePolicy.selectedText(t)) topZone.candidateExpandButton.imageTintList = android.content.res.ColorStateList.valueOf(t.keyText) topZone.candidateEmojiButton.imageTintList = android.content.res.ColorStateList.valueOf(t.keySecondaryText) 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 8832cfd8..b9553d9e 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -365,15 +365,6 @@ internal class ImeThemeApplier( tag == "backspace-clear-hint" -> view.setTextColor(t.keySecondaryText) - tag == "undo-clear-action" -> { - view.setTextColor(t.primary) - view.background = statefulRounded( - Color.TRANSPARENT, - ImeSurfacePolicy.subtleAccentSurface(t), - toPx(ImeGeometryTokens.CONTROL_RADIUS_DP), - ) - } - tag == "candidate-first" -> view.setTextColor(t.keyText) diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt index f0a7e723..14afdbf4 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -54,8 +54,7 @@ internal class ImeTopZone( onHideKeyboard: () -> Unit, onTools: () -> Unit, onExpandCandidates: () -> Unit, - private val onUndoClear: () -> Unit, - private val onUndoClearExpired: () -> Unit, + private val onUndo: () -> Unit, private val onAssociationDismiss: () -> Unit = {}, ) : LinearLayout(context) { val toolbarRow = LinearLayout(context) @@ -73,9 +72,6 @@ internal class ImeTopZone( val voiceInlineStatus = TextView(context) val voiceInlineWaves = mutableListOf() val associationBack = ImageView(context) - val undoBanner = LinearLayout(context) - private val undoBannerLabel = TextView(context) - private val undoBannerAction = TextView(context) private lateinit var toolbarIcons: List private lateinit var compactToolbarIcons: List private var compact = false @@ -89,23 +85,14 @@ internal class ImeTopZone( private lateinit var autofillScroll: HorizontalScrollView private val autofillRow = LinearLayout(context) - /** - * What the toolbar row shows; icons, autofill chips, associations and the - * undo banner are exclusive. - */ - private enum class ToolbarMode { NORMAL, AUTOFILL, ASSOCIATION, UNDO } + /** What the toolbar row shows; icons, autofill chips and associations are exclusive. */ + private enum class ToolbarMode { NORMAL, AUTOFILL, ASSOCIATION } private fun idleToolbarMode(): ToolbarMode = when { associationsShown -> ToolbarMode.ASSOCIATION autofillShown -> ToolbarMode.AUTOFILL else -> ToolbarMode.NORMAL } - private val hideUndoClearRunnable = Runnable { - if (toolbarRow.findViewWithTag("undo-toolbar")?.isActivated == true) { - toolbarRow.findViewWithTag("undo-toolbar")?.isActivated = false - onUndoClearExpired() - } - } init { tag = "ime_toolbar" @@ -125,7 +112,7 @@ internal class ImeTopZone( val phraseIcon = toolbarIcon(R.drawable.ic_bubble, "常用语", "quick-phrase-toolbar", onQuickPhrases) val emojiIcon = toolbarIcon(R.drawable.ic_emoji, "表情", "toolbar", onEmoji) val textEditIcon = toolbarIcon(R.drawable.ic_text_cursor, "文本编辑", "toolbar", onTextEditor) - val undoIcon = toolbarIcon(R.drawable.ic_undo, "撤销", "undo-toolbar") { onUndoClear() } + val undoIcon = toolbarIcon(R.drawable.ic_undo, "撤销", "undo-toolbar") { onUndo() } val toolsIcon = toolbarIcon(R.drawable.ic_grid, "更多", "toolbar", onTools) // Docked keeps the full toolbar. Floating (game) mode drops text editing // and undo and puts quick phrases one tap away. @@ -205,43 +192,6 @@ internal class ImeTopZone( keyboardHide = toolbarIcon(R.drawable.ic_chevron_down, "收起键盘", "keyboard-hide", onHideKeyboard) toolbarRow.addView(keyboardHide, LinearLayout.LayoutParams(0, toPx(48), 1f)) - // After a clear-all: "已清空 [撤销]" for the undo window. - undoBanner.apply { - tag = "undo-banner" - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - visibility = View.GONE - setPadding(toPx(16), 0, toPx(8), 0) - } - undoBannerLabel.apply { - text = "已清空" - textSize = ImeTypographyTokens.BODY_SP - includeFontPadding = false - gravity = Gravity.CENTER_VERTICAL - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - } - undoBannerAction.apply { - tag = "undo-clear-action" - text = "撤销" - textSize = ImeTypographyTokens.BODY_SP - typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) - gravity = Gravity.CENTER - includeFontPadding = false - minWidth = toPx(72) - setPadding(toPx(16), 0, toPx(16), 0) - isClickable = true - isFocusable = true - contentDescription = "撤销清空" - setOnClickListener { - removeCallbacks(hideUndoClearRunnable) - hideUndoClear() - onFeedback() - onUndoClear() - } - } - undoBanner.addView(undoBannerLabel, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) - undoBanner.addView(undoBannerAction, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, toPx(40))) - toolbarRow.addView(undoBanner, LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f)) addView( toolbarRow, LinearLayout.LayoutParams( @@ -448,14 +398,14 @@ internal class ImeTopZone( fun showAssociations(show: Boolean) { associationsShown = show - if (toolbarMode != ToolbarMode.UNDO) toolbarMode = idleToolbarMode() + toolbarMode = idleToolbarMode() refreshToolbar() } /** * Shows the autofill [chips] (already inflated by the system) in the toolbar - * row, or the normal toolbar again when the list is empty. Associations and - * the undo banner keep priority for as long as they are showing. + * row, or the normal toolbar again when the list is empty. Associations keep + * priority for as long as they are showing. */ fun setAutofillChips(chips: List, chipWidthPx: Int = 0, chipHeightPx: Int = 0) { autofillRow.removeAllViews() @@ -474,7 +424,7 @@ internal class ImeTopZone( } autofillShown = chips.isNotEmpty() autofillScroll.scrollTo(0, 0) - if (toolbarMode != ToolbarMode.UNDO) toolbarMode = idleToolbarMode() + toolbarMode = idleToolbarMode() refreshToolbar() } @@ -482,27 +432,8 @@ internal class ImeTopZone( private fun dismissAutofillChips() { autofillRow.removeAllViews() autofillShown = false - if (toolbarMode != ToolbarMode.UNDO) toolbarMode = idleToolbarMode() - refreshToolbar() - } - - fun showUndoClear() { - removeCallbacks(hideUndoClearRunnable) - toolbarRow.findViewWithTag("undo-toolbar")?.isActivated = true - toolbarMode = ToolbarMode.UNDO + toolbarMode = idleToolbarMode() refreshToolbar() - postDelayed(hideUndoClearRunnable, CLEAR_UNDO_VISIBLE_MS) - } - - fun hideUndoClear(discardSnapshot: Boolean = false) { - removeCallbacks(hideUndoClearRunnable) - val wasVisible = toolbarRow.findViewWithTag("undo-toolbar")?.isActivated == true - toolbarRow.findViewWithTag("undo-toolbar")?.isActivated = false - if (toolbarMode == ToolbarMode.UNDO) { - toolbarMode = idleToolbarMode() - refreshToolbar() - } - if (discardSnapshot && wasVisible) onUndoClearExpired() } private var longPressDrag: FloatingDragController? = null @@ -594,8 +525,7 @@ internal class ImeTopZone( associationBack.visibility = if (association || autofill) View.VISIBLE else View.GONE associationScroll.visibility = if (association) View.VISIBLE else View.GONE autofillScroll.visibility = if (autofill) View.VISIBLE else View.GONE - undoBanner.visibility = if (toolbarMode == ToolbarMode.UNDO) View.VISIBLE else View.GONE - keyboardHide.visibility = if (toolbarMode == ToolbarMode.UNDO) View.GONE else View.VISIBLE + keyboardHide.visibility = View.VISIBLE (keyboardHide.layoutParams as? LinearLayout.LayoutParams)?.let { params -> if (association || autofill) { params.width = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) @@ -608,16 +538,6 @@ internal class ImeTopZone( } } - /** Colour the undo banner from the active tokens (called with every theme pass). */ - fun applyTokens(t: ImeTheme.Tokens) { - undoBannerLabel.setTextColor(t.keyText) - undoBannerAction.setTextColor(ImeSurfacePolicy.selectedText(t)) - undoBannerAction.background = ImeDrawableFactory.rounded( - ImeSurfacePolicy.subtleAccentSurface(t), - toPx(ImeGeometryTokens.PILL_RADIUS_DP), - ) - } - fun setContentInset(contentInsetPx: Int) { toolbarRow.setPadding(contentInsetPx + toPx(6), 0, contentInsetPx + toPx(6), 0) composition.setPadding(contentInsetPx + toPx(14), toPx(3), contentInsetPx + toPx(14), 0) @@ -629,7 +549,6 @@ internal class ImeTopZone( ) { val composing = state == ImeTopZoneState.COMPOSING || state == ImeTopZoneState.CANDIDATE_EXPANDED - if (composing && toolbarMode == ToolbarMode.UNDO) hideUndoClear() toolbarRow.visibility = if (state == ImeTopZoneState.IDLE) View.VISIBLE else View.GONE composeZone.visibility = if (composing) View.VISIBLE else View.GONE voiceInlineZone.visibility = if (state == ImeTopZoneState.VOICE_INLINE) View.VISIBLE else View.GONE @@ -666,8 +585,4 @@ internal class ImeTopZone( toPx(ImeGeometryTokens.TOUCH_TARGET_DP), toPx(ImeGeometryTokens.TOUCH_TARGET_DP), ) - - private companion object { - const val CLEAR_UNDO_VISIBLE_MS = 5_000L - } } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt new file mode 100644 index 00000000..739f2e46 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt @@ -0,0 +1,114 @@ +package llc.slacker.openime.keyboard + +import android.content.Context +import android.media.AudioAttributes +import android.os.Build +import android.os.VibrationAttributes +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.view.HapticFeedbackConstants +import android.view.View + +/** + * Key-press haptics: one short, full-strength click. + * + * `performHapticFeedback(KEYBOARD_TAP)` is the platform default, but since + * Android 14 QPR3 the system plays it as PRIMITIVE_CLICK at the device's fixed + * keyboard amplitude, which many phones tune low, and Gboard lost its own + * strength slider to that system setting. Commercial IMEs (Sogou, Huawei 小艺, + * HeliBoard's custom mode) drive the vibrator themselves instead. This does the + * same with the crispest effect the actuator offers: + * + * - API 30+ with primitive support: PRIMITIVE_CLICK, scaled by [strengthPercent]; + * - otherwise with amplitude control: a 20 ms one-shot at that share of full amplitude; + * - otherwise: API 29+ uses the actuator-tuned EFFECT_CLICK at full strength and a + * shorter pulse below it (motors without amplitude control only vary by time). + * + * The 震动强度 slider sets [strengthPercent] (10–100, like Sogou's and Huawei + * 小艺's strength settings); 100 is the full click. + * + * The vibration is tagged as touch feedback, so the system "touch feedback" + * intensity still applies; the in-app 触感震动 switch turns it off entirely. + * A device without a vibrator falls back to the view's haptic feedback. + */ +internal class KeyHaptics(context: Context) { + private val vibrator: Vibrator? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + + /** 10–100; the share of the full click. Changing it rebuilds the effect. */ + var strengthPercent: Int = MAX_STRENGTH + set(value) { + val bounded = value.coerceIn(MIN_STRENGTH, MAX_STRENGTH) + if (field == bounded) return + field = bounded + cachedClick = null + } + + private var cachedClick: VibrationEffect? = null + private val click: VibrationEffect? + get() = cachedClick ?: buildClick().also { cachedClick = it } + + fun click(view: View) { + val target = vibrator + val effect = click + if (target == null || effect == null || !target.hasVibrator()) { + view.performHapticFeedback(FALLBACK_CONSTANT) + return + } + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + target.vibrate(effect, VibrationAttributes.createForUsage(VibrationAttributes.USAGE_TOUCH)) + } else { + @Suppress("DEPRECATION") + target.vibrate(effect, TOUCH_AUDIO_ATTRIBUTES) + } + }.onFailure { view.performHapticFeedback(FALLBACK_CONSTANT) } + } + + private fun buildClick(): VibrationEffect? { + val target = vibrator ?: return null + val share = strengthPercent / 100f + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + target.areAllPrimitivesSupported(VibrationEffect.Composition.PRIMITIVE_CLICK) + ) { + return VibrationEffect.startComposition() + .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, share) + .compose() + } + if (target.hasAmplitudeControl()) { + return VibrationEffect.createOneShot(LEGACY_CLICK_MS, (255 * share).toInt().coerceIn(1, 255)) + } + if (strengthPercent == MAX_STRENGTH && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK) + } + val durationMs = (MIN_PULSE_MS + (LEGACY_CLICK_MS - MIN_PULSE_MS) * share).toLong() + return VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE) + } + + companion object { + const val MIN_STRENGTH = 10 + const val MAX_STRENGTH = 100 + private const val LEGACY_CLICK_MS = 20L + private const val MIN_PULSE_MS = 6L + + /** KEYBOARD_TAP needs API 27; API 26 falls back to the virtual-key click. */ + private val FALLBACK_CONSTANT = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + HapticFeedbackConstants.KEYBOARD_TAP + } else { + HapticFeedbackConstants.VIRTUAL_KEY + } + + private val TOUCH_AUDIO_ATTRIBUTES: AudioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + } +} diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt index 398b36f7..5f91e337 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt @@ -93,19 +93,9 @@ internal class KeyPopupController( return } val t = tokens() - val clear = hint == GestureHint.CLEAR_PREVIEW || hint == GestureHint.CLEAR_ARMED - val armed = hint == GestureHint.CLEAR_ARMED || hint == GestureHint.UNDO_ARMED - val label = when (hint) { - GestureHint.CLEAR_PREVIEW -> "上滑清空" - GestureHint.CLEAR_ARMED -> "松手清空" - GestureHint.UNDO_PREVIEW -> "下滑撤回" - else -> "松手撤回" - } - val fill = when { - !armed -> ImeDrawableFactory.withAlpha(t.keyText, 0xE0) - clear -> t.destructive - else -> t.primary - } + val armed = hint == GestureHint.CLEAR_ARMED + val label = if (armed) "松手清空" else "上滑清空" + val fill = if (armed) t.destructive else ImeDrawableFactory.withAlpha(t.keyText, 0xE0) val textColor = if (armed) contrastText(fill) else t.keyBackground val height = dp(GESTURE_HINT_HEIGHT_DP) diff --git a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt index 38b2d376..f8f24a3d 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -78,6 +78,12 @@ internal class Pinyin26KeyboardRenderer( createKey("123", true, null, ImeTypographyTokens.BODY_SP, 0, onDigits), flexKeyParams(weights.leftOuter), ) + // ,。 sits left of space and 中/英 right of it, next to the enter key. + bottom.addView(punctuationKey(english), flexKeyParams(weights.leftInner)) + bottom.addView( + createSpaceVoiceKey("空格", onSpace), + flexKeyParams(weights.space), + ) bottom.addView( createKey( "中/英", @@ -86,13 +92,8 @@ internal class Pinyin26KeyboardRenderer( ImeTypographyTokens.BODY_SP, 0, ) { onModeSwitch() }.apply { tag = "key:mode" }, - flexKeyParams(weights.leftInner), - ) - bottom.addView( - createSpaceVoiceKey("空格", onSpace), - flexKeyParams(weights.space), + flexKeyParams(weights.rightInner), ) - bottom.addView(punctuationKey(english), flexKeyParams(weights.rightInner)) bottom.addView( createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, 0, onEnter).apply { tag = "key-enter" diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt index e4b8ced1..e65a783b 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ProductionKeyPolicy.kt @@ -22,7 +22,7 @@ internal object ProductionKeyPolicy { /** * Visual reference geometry for the shared Chinese/English 26-key bottom row: - * 123 | 中/英 | 空格 | ,。 | 确定. The row mirrors around the space key so the + * 123 | ,。 | 空格 | 中/英 | 确定. The row mirrors around the space key so the * space bar sits on the keyboard's centre line. */ fun twentySixKeyBottomRowWeights(): BottomRowWeights { 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 bba91702..b3d2041b 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -61,6 +61,7 @@ internal class ImePanelRenderer( private val onModeSelected: (KeyboardMode) -> Unit, private val onShowPanel: (Panel) -> Unit, private val onEnableFloatingKeyboard: () -> Unit, + private val onOpenDataManagement: () -> Unit, private val onSymbolSelected: (String) -> Unit, private val onCharacter: (String) -> Unit, private val onSpace: () -> Unit, @@ -137,11 +138,12 @@ internal class ImePanelRenderer( 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), + // Voice is a long-press on space and the keyboard switcher sits on the + // toolbar, so neither repeats here. + ToolEntry("数据管理", iconRes = R.drawable.ic_pref_data, action = onOpenDataManagement), ).filter { it.enabled } cards.chunked(4).forEach { chunk -> 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 249c1656..1ea069ef 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -50,6 +50,7 @@ internal class SettingsPanelController( private val currentAppearance: () -> ImeAppearance, private val currentSound: () -> Boolean, private val currentHaptic: () -> Boolean, + private val currentHapticStrengthPercent: () -> Int, private val currentPopup: () -> Boolean, private val currentSwipeUpDigits: () -> Boolean, /** State of the toggles added after the first four, looked up by their label. */ @@ -62,9 +63,11 @@ internal class SettingsPanelController( private val onAppearanceSelected: (ImeAppearance) -> Unit, private val onToggleChanged: (String, Boolean) -> Unit, private val onKeyboardHeightChanged: (Int) -> Unit, + private val onHapticStrengthChanged: (Int) -> Unit, private val onFloatingStyleChanged: (Int, Int) -> Unit, private val onShowFuzzySettings: () -> Unit, - private val onOpenAboutData: () -> Unit, + private val onOpenAbout: () -> Unit, + private val onOpenDataManagement: () -> Unit, private val onFeedback: () -> Unit, private val applyTheme: () -> Unit, private val onHierarchyRebuilt: () -> Unit, @@ -172,6 +175,7 @@ internal class SettingsPanelController( content.addCard( toggleRow("按键音效", "机械轴敲击反馈", R.drawable.ic_pref_sound), toggleRow("触感震动", "清脆短促,按下即停", R.drawable.ic_pref_haptic), + sliderRow("震动强度", R.drawable.ic_pref_haptic, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged), toggleRow("按键气泡", "按下时显示字母预览", R.drawable.ic_pref_bubble), toggleRow("数字和符号提示", "字母键右上角显示数字和符号", R.drawable.ic_pref_hints), toggleRow("上滑输入数字", "按键上滑输入右上角的数字或符号", R.drawable.ic_pref_swipe_up), @@ -186,9 +190,10 @@ internal class SettingsPanelController( toggleRow("语音去语气词", "去掉“嗯”“呃”等口头停顿", R.drawable.ic_pref_waveform), toggleRow("标点用空格代替", "语音里的逗号、句号等写成空格", R.drawable.ic_pref_space), ) - content.addSection("数据") + content.addSection("关于与数据") content.addCard( - navigationRow("关于与数据", "版本、隐私、导出与导入", R.drawable.ic_pref_info, onOpenAboutData), + navigationRow("关于", "版本、隐私与诊断", R.drawable.ic_pref_info, onOpenAbout), + navigationRow("数据管理", "导出与导入、语音词表", R.drawable.ic_pref_data, onOpenDataManagement), ) } @@ -205,6 +210,7 @@ internal class SettingsPanelController( content.addCard( segmentedRow("外观", iconRes = 0), sliderRow("键盘高度", 0, 80, 120, currentKeyboardHeightPercent(), onChange = onKeyboardHeightChanged), + sliderRow("震动强度", 0, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged), topMarginDp = ImeSpacingTokens.MD_DP, ) content.addSection("浮动键盘") @@ -227,9 +233,10 @@ internal class SettingsPanelController( toggleRow("语音去语气词", null, 0), toggleRow("标点用空格代替", null, 0), ) + content.addSection("关于与数据") content.addCard( - navigationRow("关于与数据", null, 0, onOpenAboutData), - topMarginDp = ImeSpacingTokens.MD_DP, + navigationRow("关于", null, 0, onOpenAbout), + navigationRow("数据管理", null, 0, onOpenDataManagement), ) } 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 e5292109..0646f9b2 100644 --- a/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt +++ b/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt @@ -93,6 +93,48 @@ object SetupUi { dp(context, ImeGeometryTokens.CARD_RADIUS_DP).toFloat(), ) + /** + * A titled explanation card for the About and Data pages: neutral icon tile, + * title, body. Callers may append buttons below the text. + */ + fun infoCard(context: Context, title: String, body: String, iconRes: Int): LinearLayout = + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + val pad = dp(context, ImeSpacingTokens.LG_DP) + setPadding(pad, pad, pad, pad) + background = cardBackground(context) + addView(LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + addView(android.widget.ImageView(context).apply { + setImageResource(iconRes) + imageTintList = ColorStateList.valueOf(context.getColor(R.color.setup_icon)) + scaleType = android.widget.ImageView.ScaleType.FIT_CENTER + setPadding(dp(context, 7), dp(context, 7), dp(context, 7), dp(context, 7)) + background = rounded(context.getColor(R.color.setup_icon_tile), dp(context, 9).toFloat()) + importantForAccessibility = android.view.View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(dp(context, 32), dp(context, 32)).apply { marginEnd = dp(context, 12) }) + addView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(TextView(context).apply { + text = title + textSize = ImeTypographyTokens.ROW_SP + setTextColor(context.getColor(R.color.setup_title)) + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + if (Build.VERSION.SDK_INT >= 28) isAccessibilityHeading = true + }) + addView(TextView(context).apply { + text = body + textSize = ImeTypographyTokens.DETAIL_SP + setTextColor(context.getColor(R.color.setup_body)) + setLineSpacing(0f, 1.3f) + }, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(context, ImeSpacingTokens.XS_DP) }) + }, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) + }) + } + /** Press feedback for a row inside a card; the card itself paints the surface. */ fun rowBackground(context: Context): StateListDrawable = ImeDrawableFactory.statefulRounded( diff --git a/app/src/main/res/drawable/ic_pref_data.xml b/app/src/main/res/drawable/ic_pref_data.xml new file mode 100644 index 00000000..ff396b56 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_data.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_pref_shield.xml b/app/src/main/res/drawable/ic_pref_shield.xml new file mode 100644 index 00000000..6ba4f16e --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_shield.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 86649d0e..e3bc1510 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,5 +51,6 @@ 常用语 自定义符号 语音词表 - 关于与数据 + 关于 + 数据管理 diff --git a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt index 336f4d3a..aa86c32d 100644 --- a/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt +++ b/app/src/test/java/llc/slacker/openime/InputConnectionGatewayTest.kt @@ -321,54 +321,6 @@ class InputConnectionGatewayTest { assertTrue(fake.events.none { it.startsWith("delete") }) } - @Test - fun clearUndoExpiresAfterFiveSeconds() { - var now = 1_000L - val fake = FakeInputConnection( - selectedText = "可恢复文本", - contextMenuResult = true, - extractedText = extracted("", 0, 0, 0), - ) - val gateway = InputConnectionGateway(null, { fake }, nowMs = { now }) - - assertTrue(gateway.clearAllText()) - assertTrue(gateway.hasClearUndo()) - - now += 5_001L - assertFalse(gateway.hasClearUndo()) - assertFalse(gateway.restoreLastClear()) - } - - @Test - fun clearUndoIsBoundToTheOriginalInputConnection() { - val first = FakeInputConnection( - selectedText = "第一个输入框", - contextMenuResult = true, - extractedText = extracted("", 0, 0, 0), - ) - val second = FakeInputConnection(extractedText = extracted("", 0, 0, 0)) - var current: InputConnection = first - val gateway = InputConnectionGateway(null, { current }, nowMs = { 1_000L }) - - assertTrue(gateway.clearAllText()) - assertTrue(gateway.hasClearUndo()) - - current = second - assertFalse(gateway.hasClearUndo()) - assertFalse(gateway.restoreLastClear()) - assertTrue(second.events.none { it.startsWith("commit:") }) - } - - @Test - fun passwordClearNeverCreatesUndoSnapshot() { - val fake = FakeInputConnection(selectedText = "secret", contextMenuResult = true) - val gateway = InputConnectionGateway(null, { fake }, isPassword = { true }) - - assertFalse(gateway.clearAllText()) - assertFalse(gateway.hasClearUndo()) - assertFalse(gateway.restoreLastClear()) - } - @Test fun failedClearAllRestoresSelectionWithoutPreDeletingContent() { val fake = FakeInputConnection( @@ -621,26 +573,6 @@ class InputConnectionGatewayTest { assertTrue(gateway.clearAllText()) assertEquals("", editor.text.toString()) - assertTrue(gateway.hasClearUndo()) - - assertTrue(gateway.restoreLastClear()) - assertEquals("你好,世界", editor.text.toString()) - assertEquals(2, editor.selStart) - assertEquals(2, editor.selEnd) - } - - @Test - fun surroundingTextClearKeepsAnExistingSelectionForUndo() { - val editor = SurroundingOnlyEditor("abcdef", selStart = 2, selEnd = 4) - val gateway = InputConnectionGateway(null, { editor }) - - assertTrue(gateway.clearAllText()) - assertEquals("", editor.text.toString()) - - assertTrue(gateway.restoreLastClear()) - assertEquals("abcdef", editor.text.toString()) - assertEquals(2, editor.selStart) - assertEquals(4, editor.selEnd) } @Test @@ -650,10 +582,6 @@ class InputConnectionGatewayTest { assertTrue(gateway.clearAllText()) assertEquals("", editor.text.toString()) - - assertTrue(gateway.restoreLastClear()) - assertEquals("0123456789", editor.text.toString()) - assertEquals(5, editor.selStart) } @Test @@ -663,16 +591,14 @@ class InputConnectionGatewayTest { assertFalse(gateway.clearAllText()) assertEquals("abcdef", editor.text.toString()) - assertFalse(gateway.hasClearUndo()) } @Test - fun clearAllOnAnAlreadyEmptySurroundingTextEditorSucceedsWithoutUndo() { + fun clearAllOnAnAlreadyEmptySurroundingTextEditorSucceeds() { val editor = SurroundingOnlyEditor("", selStart = 0, selEnd = 0) val gateway = InputConnectionGateway(null, { editor }) assertTrue(gateway.clearAllText()) - assertFalse(gateway.hasClearUndo()) } // --- terminals, games and remote desktops: TYPE_NULL, key events only --- diff --git a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt index 2e70a203..55333cf9 100644 --- a/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/ProductionKeyPolicyTest.kt @@ -9,7 +9,7 @@ class ProductionKeyPolicyTest { @Test fun twentySixKeyBottomRowFollowsTheReferenceCanvas() { val weights = ProductionKeyPolicy.twentySixKeyBottomRowWeights() - // 123 | 中/英 | 空格 | ,。 | 确定, in the design's 390 units. + // 123 | ,。 | 空格 | 中/英 | 确定, in the design's 390 units. assertEquals(70f, weights.leftOuter, 0.0001f) assertEquals(48f, weights.leftInner, 0.0001f) assertEquals(154f, weights.space, 0.0001f) diff --git a/docs/APP_UI_SPEC.md b/docs/APP_UI_SPEC.md index 785325e6..20bcfbc1 100644 --- a/docs/APP_UI_SPEC.md +++ b/docs/APP_UI_SPEC.md @@ -71,7 +71,8 @@ Apple 的 pt 不是 Android 的 dp/sp。下面是本项目在 Android 上的实 ## 键盘内面板 - 设置面板与键盘同高,首屏依次是四个快捷开关(按键音效、触感震动、按键气泡、数字提示,每个 68dp 高)、外观(分段选项与标签同行,宽 216dp)、键盘高度;其余设置在下方滚动。行高 52dp,不带图标和说明。 -- 工具面板:4 列 × 2 行,每项为 56dp 圆角 16dp 的图标块加 12sp 名称,图标为中性色。 +- 工具面板:剪贴板、表情、符号、文本编辑、浮动键盘、设置、数据管理,4 列排布;每项为 56dp 圆角 16dp 的图标块加 12sp 名称,图标为中性色。不放语音输入(按住空格即可)和切换键盘(工具栏已有)。 +- 偏好设置与键盘内设置的最后一组标题为“关于与数据”,下面两行:“关于”(版本、隐私、诊断)和“数据管理”(导出与导入、语音词表、卸载前提示)。 - 标题行与面板同底色,返回按钮无底色,按下才显示反馈。 ## 宽度与字体变化 @@ -90,9 +91,11 @@ Apple 的 pt 不是 Android 的 dp/sp。下面是本项目在 Android 上的实 ## 按键触感 -- 按键按下用 `HapticFeedbackConstants.KEYBOARD_TAP`(Android 8.0 上回退 `VIRTUAL_KEY`),是系统键盘同款 10–20ms 短击。 -- 删除键手势到位、按住空格进入语音用 `CONTEXT_CLICK`,单下清脆点击。 -- 按键反馈不使用 `LONG_PRESS`、`CLOCK_TICK` 或自定义时长的振动,避免余震拖沓。依据 Android [Haptics design principles](https://developer.android.com/develop/ui/views/haptics/haptics-principles)。 +- Android 14 QPR3 起,`performHapticFeedback(KEYBOARD_TAP)` 由系统按设备配置的固定键盘振幅播放,多数机型偏弱;Gboard 也因此失去了自己的强度滑块。搜狗、小艺、HeliBoard 等都自己驱动振动马达。 +- 本项目由 `KeyHaptics` 直接调用 `Vibrator`:Android 11 起若支持则播放 `PRIMITIVE_CLICK`(强度 1.0),Android 10 用 `EFFECT_CLICK`,Android 8–9 用 20ms 满幅脉冲;用途标记为触摸反馈,仍跟随系统“触摸反馈”强度。没有振动马达时回退 `KEYBOARD_TAP`。 +- “震动强度”滑块 10%–100%(默认 100%)缩放这一下点击:支持原语时按比例缩放 `PRIMITIVE_CLICK`,有振幅控制时缩放振幅,否则缩短脉冲时长;拖动滑杆时最多每 80ms 试震一次。偏好设置放在“触感震动”下方,键盘内设置放在首屏“键盘高度”下方。 +- 按键按下、删除键手势到位、按住空格进入语音都用这一下点击;不使用 `LONG_PRESS`、`CLOCK_TICK` 或长时长振动,避免余震拖沓。依据 Android [Haptics design principles](https://developer.android.com/develop/ui/views/haptics/haptics-principles)。 +- 按键音用带音量参数的 `AudioManager.playSoundEffect(FX_KEYPRESS_STANDARD, -1)`:单参数版本在系统“触摸提示音”关闭时不出声。 ## 验收与可重复工件 diff --git a/docs/NINE_KEY_REFERENCE.md b/docs/NINE_KEY_REFERENCE.md index 71282a2b..73864d15 100644 --- a/docs/NINE_KEY_REFERENCE.md +++ b/docs/NINE_KEY_REFERENCE.md @@ -36,12 +36,12 @@ ## 删除键手势 -- 上滑 ≥ 32dp 清空,下滑 ≥ 32dp 撤回(仅清空后 5 秒内);松手瞬间的坐标也算最后一次移动。 -- 反馈只有一个气泡组件:未到位时深色「上滑清空 / 下滑撤回」,到位后变色「松手清空(红)/ 松手撤回(强调色)」, - 放在键的侧边而不是上方(拇指会挡住上方)。清空后顶栏显示「已清空 · 撤销」。 +- 上滑 ≥ 32dp 清空;松手瞬间的坐标也算最后一次移动。清空是最终操作,不提供下滑撤回(产品决定)。 +- 反馈只有一个气泡组件:未到位时深色「上滑清空」,到位后变红「松手清空」, + 放在键的侧边而不是上方(拇指会挡住上方)。 - 「清空全部」不能只依赖编辑器的全选动作:自绘 / Compose / Web 输入框通常既没有全选也没有完整的 ExtractedText。网关因此有第三条路径:只用光标前后文本,抓取全部内容、循环删除直到编辑器报告为空, - 并保留撤回快照。答案长度等于请求长度时可能只是窗口,一律拒绝删除。 + 中途失败则把已删内容放回。答案长度等于请求长度时可能只是窗口,一律拒绝删除。 - 调试用 `CustomEditorTestActivity` 复现这类编辑器(修复前手势触发但文字纹丝不动,且无提示)。 ## 验证 diff --git a/docs/TEST_SOP.md b/docs/TEST_SOP.md index 64c38949..015bd1eb 100644 --- a/docs/TEST_SOP.md +++ b/docs/TEST_SOP.md @@ -150,9 +150,8 @@ OpenIME 长按松手立即停止,空状态继续删除不能崩溃。 上滑清空必须满足:上滑 ≥ 32dp 才进入(未到位是深色「上滑清空」提示,进入后变红色「松手清空」); -进入时轻反馈;滑回/侧滑取消;松手才执行一次;清空后正文、拼音、候选一致,顶栏显示「已清空 · 撤销」。 -下滑 ≥ 32dp 撤回,仅在清空后 5 秒内可用(提示为「下滑撤回」→ 强调色「松手撤回」)。 -两者必须在没有「全选」也没有完整 ExtractedText 的输入框(自绘 / Compose / Web,可用调试 +进入时轻反馈;滑回/侧滑取消;松手才执行一次;清空后正文、拼音、候选一致,不出现撤回提示或「已清空 · 撤销」。 +清空必须在没有「全选」也没有完整 ExtractedText 的输入框(自绘 / Compose / Web,可用调试 `CustomEditorTestActivity` 复现)里同样生效。 ## 9. 空格、语音、气泡和震动 diff --git a/scripts/design_reference_e2e.py b/scripts/design_reference_e2e.py index 79ef5c7b..a726e2c0 100644 --- a/scripts/design_reference_e2e.py +++ b/scripts/design_reference_e2e.py @@ -228,13 +228,11 @@ def pref_items(items): tap('key:1'); tap('key:0'); tap('key:.'); tap('key:5') assert editor() == before + '10.5', 'Numeric layout commits wrong literals' command('clear-swipe'); command('mode:PINYIN_9'); tap('更多') - capture(stem + '-tools', ('tool:语音输入', 'tool:设置', 'tool:文本编辑', 'tool:浮动键盘')) + capture(stem + '-tools', ('tool:数据管理', 'tool:设置', 'tool:文本编辑', 'tool:浮动键盘')) for label, target, required in [ - ('切换键盘','keyboard-select',('keyboard-choice-selected',)), ('符号','symbols',('symbol-categories',)), ('表情','emoji',('emoji-cell',)), ('文本编辑','text-editor',('textedit-cross', 'textedit-action:paste')), - ('语音输入','voice',('voice-mic', 'segment-selected')), ('剪贴板','clipboard',('segmented-track',)), ('设置','preferences',('segmented-track-tall', 'settings-slider:键盘高度')), ]: @@ -251,8 +249,11 @@ def pref_items(items): capture_app(stem + '-setup', ('shortcut_preferences',)) ui_tap(pkg + ':id/shortcut_preferences') capture_app(stem + '-preferences-full', ('偏好设置', '跟随系统')) - ui_tap('关于与数据') - capture_app(stem + '-about', ('隐私','用户数据','导出','导入')) + ui_tap('关于') + capture_app(stem + '-about', ('隐私','诊断')) + adb('shell','input','keyevent','4'); time.sleep(.5) + ui_tap('数据管理') + capture_app(stem + '-data', ('用户数据','导出','导入','语音词表')) launch() panel('符号', stem + '-symbols-return') tap('自定义'); tap('管理自定义符号')