From e585fb45641ea2ae2140ac08ad0eaf4429cd1a0c Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:04:18 +0800 Subject: [PATCH 1/2] Key sound and haptic styles; nine-key lone key shows its initial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按键音效 offers 系统 plus five clicks synthesized by scripts/generate_key_sounds.py (清脆, 机械, 木质, 打字机, 气泡), played through SoundPool as UI sonification; choosing one previews it once. - 震动手感 offers 清脆 (default: PRIMITIVE_TICK / EFFECT_TICK / 8 ms), 有力 (PRIMITIVE_CLICK / EFFECT_CLICK / 12 ms) and 系统 (KEYBOARD_TAP). One-shot fallbacks stay at 8-12 ms because longer pulses ring on linear motors such as Xiaomi's X-axis motor. Both styles are archived. - 关于 → 诊断 lists the vibrator's capabilities. - Nine-key: a lone 4/5/7/8/9 now shows the top candidate's initial (4 -> h) instead of the bare digit; the rail highlights the same letter. - The style enums live in data so panel and keyboard can both use them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +- .../java/llc/slacker/openime/AboutActivity.kt | 3 +- .../slacker/openime/ImeSettingsActivity.kt | 3 + .../openime/candidate/CandidatePipeline.kt | 2 +- .../openime/candidate/NineKeyLocalDecoder.kt | 13 +- .../slacker/openime/data/FeedbackStyles.kt | 39 +++++ .../openime/data/ImeSettingsRepository.kt | 22 +++ .../slacker/openime/data/UserDataArchive.kt | 10 ++ .../openime/keyboard/ImeKeyboardView.kt | 55 +++--- .../slacker/openime/keyboard/KeyHaptics.kt | 74 +++++++-- .../llc/slacker/openime/keyboard/KeySounds.kt | 77 +++++++++ .../openime/panel/SettingsPanelController.kt | 92 ++++++++++- app/src/main/res/raw/key_sound_bubble.wav | Bin 0 -> 4364 bytes app/src/main/res/raw/key_sound_crisp.wav | Bin 0 -> 2732 bytes app/src/main/res/raw/key_sound_mechanical.wav | Bin 0 -> 5324 bytes app/src/main/res/raw/key_sound_typewriter.wav | Bin 0 -> 7244 bytes app/src/main/res/raw/key_sound_wood.wav | Bin 0 -> 5804 bytes .../llc/slacker/openime/NineKeyChineseTest.kt | 16 ++ docs/APP_UI_SPEC.md | 5 +- scripts/generate_key_sounds.py | 156 ++++++++++++++++++ 20 files changed, 531 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/llc/slacker/openime/data/FeedbackStyles.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/KeySounds.kt create mode 100644 app/src/main/res/raw/key_sound_bubble.wav create mode 100644 app/src/main/res/raw/key_sound_crisp.wav create mode 100644 app/src/main/res/raw/key_sound_mechanical.wav create mode 100644 app/src/main/res/raw/key_sound_typewriter.wav create mode 100644 app/src/main/res/raw/key_sound_wood.wav create mode 100644 scripts/generate_key_sounds.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 369ce26d..91c64bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,13 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [0.0.5-beta.1] - 2026-10-05 -第五个测试版:按键震动更响更清脆并可调强度,按键音效恢复,“中/英”键移到空格右边,整理“关于与数据”和工具面板。从 0.0.4-beta.1 覆盖安装即可,用户数据保留。 +第五个测试版:按键震动更清脆、可选手感和强度,按键音效恢复并内置多种音效,“中/英”键移到空格右边,九键单按一个键时拼音栏显示字母,整理“关于与数据”和工具面板。从 0.0.4-beta.1 覆盖安装即可,用户数据保留。 -- 按键震动更响更清脆:不再走系统给键盘的固定低振幅,直接驱动振动马达播放满强度的短促点击(Android 11 起用 CLICK 原语,Android 10 用系统预设点击,更早版本用 20ms 满幅脉冲),仍跟随系统“触摸反馈”强度;删除键手势到位也是同一下点击。 +- 按键震动不再走系统给键盘的固定低振幅,直接驱动振动马达,仍跟随系统“触摸反馈”强度;删除键手势到位也是同一下点击。 +- 新增“震动手感”:清脆(默认,最短促的 TICK)、有力(CLICK)、系统(交给手机厂商调好的键盘震动)。线性马达上长脉冲会“嗡”出余震,不支持原语的机型改用 8–12ms 短脉冲。 +- 新增内置按键音效:系统、清脆、机械、木质、打字机、气泡,点选即试听。音效由脚本合成(`scripts/generate_key_sounds.py`),音量跟随系统音量。 +- 九键单按 4、5、7、8、9 时,拼音栏显示首个候选的声母(如 4 → h),不再停在数字;左侧拼音列同时高亮该字母。 +- “关于 → 诊断”显示本机振动马达支持的能力,便于排查震感问题。 - 新增“震动强度”滑块(10%–100%,默认 100%),在偏好设置“触感震动”下方和键盘内设置首屏;拖动时实时试震,随用户数据导出、导入。 - 修复“按键音效”打开后没有声音:系统“触摸提示音”关闭时按键音也被一起静音,现在只看 App 内的开关。 - 26 键的“中/英”键移到空格右边,与“,。”键对调,中英文键盘一致。 diff --git a/app/src/main/java/llc/slacker/openime/AboutActivity.kt b/app/src/main/java/llc/slacker/openime/AboutActivity.kt index 1c392834..fb19ea85 100644 --- a/app/src/main/java/llc/slacker/openime/AboutActivity.kt +++ b/app/src/main/java/llc/slacker/openime/AboutActivity.kt @@ -97,7 +97,8 @@ class AboutActivity : Activity() { safeMode -> "输入法刚才多次异常退出,已临时关闭原生词库和语音预加载;约 10 分钟后自动恢复,也可以现在退出。" lastReport != null -> "最近一次异常:" + lastReport.lineSequence().first().substringAfter("| ").substringBefore(" | thread") else -> "没有异常记录。" - } + "\n诊断信息只含异常类型和代码位置,不含任何输入内容;只有你点“复制”才会离开这里。", + } + "\n" + llc.slacker.openime.keyboard.KeyHaptics.describe(this) + + "\n诊断信息只含异常类型和代码位置,不含任何输入内容;只有你点“复制”才会离开这里。", iconRes = R.drawable.ic_pref_info, ) if (lastReport == null && !safeMode) return card diff --git a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt index 0e3d47c0..869a2b89 100644 --- a/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt +++ b/app/src/main/java/llc/slacker/openime/ImeSettingsActivity.kt @@ -203,6 +203,9 @@ class ImeSettingsActivity : Activity(), ImeKeyboardView.Listener { override fun onHapticStrengthChanged(percent: Int) { refreshLiveIme() } + override fun onFeedbackStyleChanged() { + refreshLiveIme() + } override fun onKeyboardHeightChanged(percent: Int) { ImeSettingsRepository.saveKeyboardHeightPercent(this, percent) refreshLiveIme() diff --git a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt index 90f49cc3..e5d70ba4 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidatePipeline.kt @@ -33,7 +33,7 @@ interface CandidateResolver { lockPreferred: Boolean = false, ): CandidatePipeline.NineKeyResolution - /** Syllables of [candidate] if it spells exactly all of [digits]; null otherwise. */ + /** Syllables of [candidate] if it spells exactly all of [digits] (a lone key may be its initial); null otherwise. */ fun nineKeyReadingFor(digits: String, candidate: String): List? = null /** Readings of [digits] for the left rail, best first (see [NineKeyReading]). */ diff --git a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt index 61b16c65..4ba03819 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt @@ -257,7 +257,18 @@ internal class NineKeyLocalDecoder( } return false } - return if (walk(0, 0)) picked.filterNotNull() else null + if (walk(0, 0)) return picked.filterNotNull() + // One key and one character: the key can only be the character's + // initial (4 -> 和 shows "h", like the rail). Without this the pre-edit + // stayed on the bare digit for keys whose letters are all initials + // (4, 5, 7, 8, 9) while 2 / 3 / 6 showed a / e / o. + if (bounded.length == 1 && chars.size == 1) { + val initial = readingsByChar[chars[0]].orEmpty() + .map { it.take(1) } + .firstOrNull { digitsForPinyin(it) == bounded } + if (initial != null) return listOf(initial) + } + return null } /** diff --git a/app/src/main/java/llc/slacker/openime/data/FeedbackStyles.kt b/app/src/main/java/llc/slacker/openime/data/FeedbackStyles.kt new file mode 100644 index 00000000..1901eb7f --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/data/FeedbackStyles.kt @@ -0,0 +1,39 @@ +package llc.slacker.openime.data + +import llc.slacker.openime.R + +/** How a key press feels. Labels are what the 震动手感 setting shows. */ +enum class HapticStyle(val key: String, val label: String) { + /** The shortest, sharpest pulse: PRIMITIVE_TICK / EFFECT_TICK / an 8 ms tap. */ + CRISP("crisp", "清脆"), + + /** A fuller click: PRIMITIVE_CLICK / EFFECT_CLICK / a 12 ms tap. */ + FIRM("firm", "有力"), + + /** Whatever the phone maker tuned for keyboards (KEYBOARD_TAP); strength does not apply. */ + SYSTEM("system", "系统"), + ; + + companion object { + fun fromKey(key: String?): HapticStyle = entries.firstOrNull { it.key == key } ?: CRISP + } +} + +/** + * The built-in key-click sounds. [SYSTEM] is the platform keypress sound; the + * rest are short clicks synthesized by scripts/generate_key_sounds.py, so they + * carry no third-party rights. + */ +enum class KeySoundStyle(val key: String, val label: String, val rawRes: Int) { + SYSTEM("system", "系统", 0), + CRISP("crisp", "清脆", R.raw.key_sound_crisp), + MECHANICAL("mechanical", "机械", R.raw.key_sound_mechanical), + WOOD("wood", "木质", R.raw.key_sound_wood), + TYPEWRITER("typewriter", "打字机", R.raw.key_sound_typewriter), + BUBBLE("bubble", "气泡", R.raw.key_sound_bubble), + ; + + companion object { + fun fromKey(key: String?): KeySoundStyle = entries.firstOrNull { it.key == key } ?: SYSTEM + } +} 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 a443efdb..68b02e9f 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -14,6 +14,8 @@ object ImeSettingsRepository { 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_HAPTIC_STYLE = "haptic_style" + private const val KEY_SOUND_STYLE = "key_sound_style" private const val KEY_POPUP = "popup" private const val KEY_FUZZY = "fuzzy" private const val KEY_SWIPE_UP_DIGITS = "swipe_up_digits" @@ -138,6 +140,26 @@ object ImeSettingsRepository { .edit().putInt(KEY_HAPTIC_STRENGTH, percent.coerceIn(10, 100)).apply() } + /** 震动手感 key: "crisp" (default), "firm" or "system". */ + fun loadHapticStyle(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_HAPTIC_STYLE, "crisp") ?: "crisp" + + fun saveHapticStyle(context: Context, key: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_HAPTIC_STYLE, key).apply() + } + + /** 按键音效 sound key: "system" (default) or one of the bundled clicks. */ + fun loadKeySoundStyle(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_SOUND_STYLE, "system") ?: "system" + + fun saveKeySoundStyle(context: Context, key: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_SOUND_STYLE, key).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 77ef2ede..8d863a76 100644 --- a/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt +++ b/app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt @@ -43,6 +43,8 @@ internal data class ArchiveSettings( val voiceStripFillers: Boolean = true, val voicePunctuationAsSpace: Boolean = false, val hapticStrengthPercent: Int = 100, + val hapticStyle: String = "crisp", + val keySoundStyle: String = "system", ) internal data class RimeUserDictionaryArchive( @@ -176,6 +178,8 @@ internal object UserDataArchiveCodec { .put("sound", value.sound) .put("haptic", value.haptic) .put("haptic_strength_percent", value.hapticStrengthPercent) + .put("haptic_style", value.hapticStyle) + .put("key_sound_style", value.keySoundStyle) .put("popup", value.popup) .put("fuzzy", value.fuzzy) .put("swipe_up_digits", value.swipeUpDigits) @@ -195,6 +199,8 @@ internal object UserDataArchiveCodec { sound = value.optBoolean("sound", true), haptic = value.optBoolean("haptic", true), hapticStrengthPercent = value.optInt("haptic_strength_percent", 100).coerceIn(10, 100), + hapticStyle = value.optString("haptic_style", "crisp"), + keySoundStyle = value.optString("key_sound_style", "system"), popup = value.optBoolean("popup", false), fuzzy = value.optBoolean("fuzzy", false), swipeUpDigits = value.optBoolean("swipe_up_digits", true), @@ -325,6 +331,8 @@ internal object UserDataRepository { sound = ImeSettingsRepository.loadSound(context), haptic = ImeSettingsRepository.loadHaptic(context), hapticStrengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context), + hapticStyle = ImeSettingsRepository.loadHapticStyle(context), + keySoundStyle = ImeSettingsRepository.loadKeySoundStyle(context), popup = ImeSettingsRepository.loadPopup(context), fuzzy = ImeSettingsRepository.loadFuzzy(context), swipeUpDigits = ImeSettingsRepository.loadSwipeUpDigits(context), @@ -408,6 +416,8 @@ internal object UserDataRepository { ImeSettingsRepository.saveSound(context, value.sound) ImeSettingsRepository.saveHaptic(context, value.haptic) ImeSettingsRepository.saveHapticStrengthPercent(context, value.hapticStrengthPercent) + ImeSettingsRepository.saveHapticStyle(context, value.hapticStyle) + ImeSettingsRepository.saveKeySoundStyle(context, value.keySoundStyle) 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/keyboard/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt index af16741e..05b44c1f 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.graphics.Color import android.graphics.drawable.StateListDrawable import android.inputmethodservice.InputMethodService -import android.media.AudioManager import android.os.Build import android.os.Handler import android.os.Looper @@ -14,7 +13,6 @@ import android.text.TextUtils import android.util.Log import android.view.Gravity import android.view.MotionEvent -import android.view.SoundEffectConstants import android.view.View import android.view.ViewGroup import android.view.WindowInsets @@ -34,7 +32,9 @@ 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.HapticStyle import llc.slacker.openime.data.ImeSettingsRepository +import llc.slacker.openime.data.KeySoundStyle import llc.slacker.openime.data.QuickPhrase import llc.slacker.openime.data.QuickPhraseRepository import llc.slacker.openime.editor.EditorInfoAdapter @@ -123,6 +123,9 @@ open class ImeKeyboardView( fun onFuzzyChanged(enabled: Boolean) fun onKeyboardHeightChanged(percent: Int) {} fun onHapticStrengthChanged(percent: Int) {} + + /** 震动手感 or 按键音效 changed; the new value is already saved. */ + fun onFeedbackStyleChanged() {} fun onFloatingStyleChanged(widthPercent: Int, opacityPercent: Int) {} fun onOpenAbout() {} fun onOpenDataManagement() {} @@ -144,8 +147,8 @@ open class ImeKeyboardView( /** 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 + /** Long enough for SoundPool to decode a just-selected click. */ + private const val SOUND_PREVIEW_DELAY_MS = 120L } @@ -344,10 +347,11 @@ open class ImeKeyboardView( private var hapticEnabled = ImeSettingsRepository.loadHaptic(context) private val keyHaptics = KeyHaptics(context).apply { strengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context) + style = HapticStyle.fromKey(ImeSettingsRepository.loadHapticStyle(context)) } private var lastStrengthPreviewMs = 0L - private val audioManager by lazy { - context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + private val keySounds = KeySounds(context).apply { + style = KeySoundStyle.fromKey(ImeSettingsRepository.loadKeySoundStyle(context)) } private var popupEnabled = ImeSettingsRepository.loadPopup(context) private var fuzzyEnabled = ImeSettingsRepository.loadFuzzy(context) @@ -629,6 +633,8 @@ open class ImeKeyboardView( currentSound = { soundEnabled }, currentHaptic = { hapticEnabled }, currentHapticStrengthPercent = { keyHaptics.strengthPercent }, + currentHapticStyle = { keyHaptics.style }, + currentKeySoundStyle = { keySounds.style }, currentPopup = { popupEnabled }, currentSwipeUpDigits = { ImeSettingsRepository.loadSwipeUpDigits(context) }, currentExtraToggle = ::onState, @@ -644,6 +650,8 @@ open class ImeKeyboardView( onToggleChanged = ::updateSettingToggle, onKeyboardHeightChanged = ::setKeyboardHeightPercent, onHapticStrengthChanged = ::setHapticStrengthPercent, + onHapticStyleChanged = ::setHapticStyle, + onKeySoundStyleChanged = ::setKeySoundStyle, onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, onOpenAbout = listener::onOpenAbout, @@ -1573,6 +1581,23 @@ open class ImeKeyboardView( } } + /** Save the 震动手感 choice and let it be felt once. */ + private fun setHapticStyle(next: HapticStyle) { + keyHaptics.style = next + ImeSettingsRepository.saveHapticStyle(context, next.key) + listener.onFeedbackStyleChanged() + keyHaptics.click(this) + } + + /** Save the 按键音效 choice and play it once, even while key sounds are off. */ + private fun setKeySoundStyle(next: KeySoundStyle) { + keySounds.style = next + ImeSettingsRepository.saveKeySoundStyle(context, next.key) + listener.onFeedbackStyleChanged() + // A freshly loaded sample decodes asynchronously; give it a moment. + postDelayed({ keySounds.play() }, SOUND_PREVIEW_DELAY_MS) + } + private fun setKeyboardHeightPercent(percent: Int) { val bounded = percent.coerceIn(80, 120) if (keyboardHeightPercent == bounded) return @@ -1622,6 +1647,8 @@ open class ImeKeyboardView( popupEnabled = popup fuzzyEnabled = fuzzy keyHaptics.strengthPercent = ImeSettingsRepository.loadHapticStrengthPercent(context) + keyHaptics.style = HapticStyle.fromKey(ImeSettingsRepository.loadHapticStyle(context)) + keySounds.style = KeySoundStyle.fromKey(ImeSettingsRepository.loadKeySoundStyle(context)) if (heightChanged) { keyboardHeightPercent = persistedHeight renderedMode = null @@ -1687,6 +1714,7 @@ open class ImeKeyboardView( } open fun shutdown() { + keySounds.release() // The field these chips belong to is going away. clearInlineSuggestions() if (panel == Panel.CLIPBOARD) clipboardPanelController.invalidatePendingLoad() @@ -2830,20 +2858,7 @@ open class ImeKeyboardView( protected fun feedback() { if (hapticEnabled) keyHaptics.click(this) - if (soundEnabled) { - // 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, KEY_CLICK_DEFAULT_VOLUME) - } else { - isSoundEffectsEnabled = true - playSoundEffect(SoundEffectConstants.CLICK) - } - } - } + if (soundEnabled) keySounds.play() } /** Haptic-only confirmation (no key click sound), e.g. when voice arms. */ diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt index 739f2e46..14829711 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyHaptics.kt @@ -9,9 +9,10 @@ import android.os.Vibrator import android.os.VibratorManager import android.view.HapticFeedbackConstants import android.view.View +import llc.slacker.openime.data.HapticStyle /** - * Key-press haptics: one short, full-strength click. + * Key-press haptics: one short click in the chosen [HapticStyle]. * * `performHapticFeedback(KEYBOARD_TAP)` is the platform default, but since * Android 14 QPR3 the system plays it as PRIMITIVE_CLICK at the device's fixed @@ -20,11 +21,14 @@ import android.view.View * 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 + * - API 30+ with primitive support: the style's primitive, scaled by [strengthPercent]; + * - otherwise with amplitude control: a short one-shot at that share of full amplitude; + * - otherwise: API 29+ uses the actuator-tuned predefined effect at full strength and a * shorter pulse below it (motors without amplitude control only vary by time). * + * Long pulses ring on linear motors such as the X-axis motor in Xiaomi phones, + * which feels muddy; the one-shot fallbacks are kept to 8–12 ms for that reason. + * * The 震动强度 slider sets [strengthPercent] (10–100, like Sogou's and Huawei * 小艺's strength settings); 100 is the full click. * @@ -50,11 +54,22 @@ internal class KeyHaptics(context: Context) { cachedClick = null } + var style: HapticStyle = HapticStyle.CRISP + set(value) { + if (field == value) return + field = value + cachedClick = null + } + private var cachedClick: VibrationEffect? = null private val click: VibrationEffect? get() = cachedClick ?: buildClick().also { cachedClick = it } fun click(view: View) { + if (style == HapticStyle.SYSTEM) { + view.performHapticFeedback(FALLBACK_CONSTANT) + return + } val target = vibrator val effect = click if (target == null || effect == null || !target.hasVibrator()) { @@ -74,29 +89,56 @@ internal class KeyHaptics(context: Context) { 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() + val crisp = style == HapticStyle.CRISP + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val primitive = + if (crisp) VibrationEffect.Composition.PRIMITIVE_TICK else VibrationEffect.Composition.PRIMITIVE_CLICK + if (target.areAllPrimitivesSupported(primitive)) { + return VibrationEffect.startComposition().addPrimitive(primitive, share).compose() + } } + val pulseMs = if (crisp) CRISP_PULSE_MS else FIRM_PULSE_MS if (target.hasAmplitudeControl()) { - return VibrationEffect.createOneShot(LEGACY_CLICK_MS, (255 * share).toInt().coerceIn(1, 255)) + return VibrationEffect.createOneShot(pulseMs, (255 * share).toInt().coerceIn(1, 255)) } if (strengthPercent == MAX_STRENGTH && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK) + return VibrationEffect.createPredefined(if (crisp) VibrationEffect.EFFECT_TICK else VibrationEffect.EFFECT_CLICK) } - val durationMs = (MIN_PULSE_MS + (LEGACY_CLICK_MS - MIN_PULSE_MS) * share).toLong() + val durationMs = (MIN_PULSE_MS + (pulseMs - 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 + private const val CRISP_PULSE_MS = 8L + private const val FIRM_PULSE_MS = 12L + private const val MIN_PULSE_MS = 4L + + /** One line for 关于 → 诊断: what this phone's vibrator can do. */ + fun describe(context: Context): String { + 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 + } + if (vibrator == null || !vibrator.hasVibrator()) return "振动马达:未检测到" + val parts = mutableListOf() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val tick = vibrator.areAllPrimitivesSupported(VibrationEffect.Composition.PRIMITIVE_TICK) + val click = vibrator.areAllPrimitivesSupported(VibrationEffect.Composition.PRIMITIVE_CLICK) + parts += when { + tick && click -> "支持 TICK/CLICK 原语" + click -> "支持 CLICK 原语" + tick -> "支持 TICK 原语" + else -> "不支持原语" + } + } + parts += if (vibrator.hasAmplitudeControl()) "可调振幅" else "不可调振幅" + return "振动马达:" + parts.joinToString(",") + "(" + Build.MANUFACTURER + " " + Build.MODEL + ")" + } /** KEYBOARD_TAP needs API 27; API 26 falls back to the virtual-key click. */ private val FALLBACK_CONSTANT = diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeySounds.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeySounds.kt new file mode 100644 index 00000000..57b72500 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeySounds.kt @@ -0,0 +1,77 @@ +package llc.slacker.openime.keyboard + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.SoundPool +import llc.slacker.openime.data.KeySoundStyle + +/** + * Plays one key click in the chosen [style]. Bundled clicks go through a small + * [SoundPool] tagged as UI sonification, so their loudness follows the system + * sound volume. The system click uses the volume overload of + * playSoundEffect, which plays even when the system "touch sounds" setting is + * off; the in-app switch alone decides whether keys click. + */ +internal class KeySounds(context: Context) { + private val appContext = context.applicationContext + private val audioManager = appContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + private var pool: SoundPool? = null + private val loaded = HashMap() + private val ready = HashSet() + + var style: KeySoundStyle = KeySoundStyle.SYSTEM + set(value) { + field = value + if (value != KeySoundStyle.SYSTEM) ensureLoaded(value) + } + + fun play() { + val current = style + if (current == KeySoundStyle.SYSTEM) { + runCatching { audioManager?.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, DEFAULT_VOLUME) } + return + } + val sample = ensureLoaded(current) ?: return + // A click requested before its sample finished decoding is dropped, + // not queued: a late click would sound like lag. + if (sample in ready) pool?.play(sample, 1f, 1f, 1, 0, 1f) + } + + fun release() { + pool?.release() + pool = null + loaded.clear() + ready.clear() + } + + private fun ensureLoaded(target: KeySoundStyle): Int? { + loaded[target]?.let { return it } + val soundPool = pool ?: SoundPool.Builder() + .setMaxStreams(MAX_STREAMS) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(), + ) + .build() + .also { created -> + created.setOnLoadCompleteListener { _, sampleId, status -> + if (status == 0) ready += sampleId + } + pool = created + } + return runCatching { soundPool.load(appContext, target.rawRes, 1) } + .getOrNull() + ?.also { loaded[target] = it } + } + + private companion object { + /** Fast typing overlaps a few clicks; more would only pile up noise. */ + const val MAX_STREAMS = 4 + + /** playSoundEffect's "use the default UI click volume" value. */ + const val DEFAULT_VOLUME = -1f + } +} 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 1ea069ef..86aadca0 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -16,6 +16,8 @@ import android.widget.SeekBar import android.widget.TextView import llc.slacker.openime.ImeSettingsActivity import llc.slacker.openime.R +import llc.slacker.openime.data.HapticStyle +import llc.slacker.openime.data.KeySoundStyle import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeMotionTokens @@ -51,6 +53,8 @@ internal class SettingsPanelController( private val currentSound: () -> Boolean, private val currentHaptic: () -> Boolean, private val currentHapticStrengthPercent: () -> Int, + private val currentHapticStyle: () -> HapticStyle, + private val currentKeySoundStyle: () -> KeySoundStyle, private val currentPopup: () -> Boolean, private val currentSwipeUpDigits: () -> Boolean, /** State of the toggles added after the first four, looked up by their label. */ @@ -64,6 +68,8 @@ internal class SettingsPanelController( private val onToggleChanged: (String, Boolean) -> Unit, private val onKeyboardHeightChanged: (Int) -> Unit, private val onHapticStrengthChanged: (Int) -> Unit, + private val onHapticStyleChanged: (HapticStyle) -> Unit, + private val onKeySoundStyleChanged: (KeySoundStyle) -> Unit, private val onFloatingStyleChanged: (Int, Int) -> Unit, private val onShowFuzzySettings: () -> Unit, private val onOpenAbout: () -> Unit, @@ -173,8 +179,10 @@ internal class SettingsPanelController( ) content.addSection("按键与输入") content.addCard( - toggleRow("按键音效", "机械轴敲击反馈", R.drawable.ic_pref_sound), + toggleRow("按键音效", "按键时播放提示音", R.drawable.ic_pref_sound), + soundStyleRow(R.drawable.ic_pref_sound), toggleRow("触感震动", "清脆短促,按下即停", R.drawable.ic_pref_haptic), + hapticStyleRow(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), @@ -213,6 +221,11 @@ internal class SettingsPanelController( sliderRow("震动强度", 0, 10, 100, currentHapticStrengthPercent(), onChange = onHapticStrengthChanged), topMarginDp = ImeSpacingTokens.MD_DP, ) + content.addSection("按键反馈") + content.addCard( + soundStyleRow(0), + hapticStyleRow(0), + ) content.addSection("浮动键盘") content.addCard( sliderRow("浮动宽度", 0, 72, 100, currentFloatingWidthPercent(), shortLabel = "宽度") { @@ -490,6 +503,83 @@ internal class SettingsPanelController( ) } + private fun soundStyleRow(iconRes: Int): View = + choiceRow("音效", iconRes, KeySoundStyle.entries.map { it.label }, currentKeySoundStyle().label) { label -> + onKeySoundStyleChanged(KeySoundStyle.entries.first { it.label == label }) + } + + private fun hapticStyleRow(iconRes: Int): View = + choiceRow("震动手感", iconRes, HapticStyle.entries.map { it.label }, currentHapticStyle().label) { label -> + onHapticStyleChanged(HapticStyle.entries.first { it.label == label }) + } + + /** + * A label with a scrollable row of options under it. Choosing one saves it, + * previews it (the callback plays the sound or vibration) and redraws the + * row so the selection moves; it never plays the old feedback first. + */ + private fun choiceRow( + label: String, + iconRes: Int, + options: List, + selected: String, + onChoose: (String) -> Unit, + ): View { + val chips = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + options.forEachIndexed { index, option -> + val active = option == selected + addView( + TextView(context).apply { + text = option + textSize = ImeTypographyTokens.BODY_SP + gravity = Gravity.CENTER + includeFontPadding = false + minWidth = toPx(56) + setPadding(toPx(14), 0, toPx(14), 0) + tag = if (active) "tab-active" else "panel-tab" + typeface = android.graphics.Typeface.create( + "sans-serif-medium", + if (active) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL, + ) + contentDescription = "$label:$option,${if (active) "已选中" else "未选中"}" + if (Build.VERSION.SDK_INT >= 30) stateDescription = if (active) "已选中" else "未选中" + isClickable = true + isFocusable = true + setOnClickListener { + onChoose(option) + renderSettings(reusePanel = true) + } + }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, toPx(40)).apply { + if (index > 0) marginStart = toPx(6) + }, + ) + } + } + val scroller = HorizontalScrollView(context).apply { + isHorizontalScrollBarEnabled = false + overScrollMode = View.OVER_SCROLL_NEVER + addView(chips) + } + val row = rowShell(iconRes, alignTop = true) + row.addView( + LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(rowLabel(label), wrapParams().apply { topMargin = toPx(if (iconRes != 0) 6 else 8) }) + addView( + scroller, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(ImeGeometryTokens.TOUCH_TARGET_DP)).apply { + topMargin = toPx(ImeSpacingTokens.XS_DP) + }, + ) + }, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f), + ) + return row + } + /** * Appearance picker. On the preferences page the options sit under the * label, full width; in the keyboard they share the label's line. diff --git a/app/src/main/res/raw/key_sound_bubble.wav b/app/src/main/res/raw/key_sound_bubble.wav new file mode 100644 index 0000000000000000000000000000000000000000..7d72f52262ca9b7ce707c29f4a7a8f0298258a77 GIT binary patch literal 4364 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/raw/key_sound_wood.wav b/app/src/main/res/raw/key_sound_wood.wav new file mode 100644 index 0000000000000000000000000000000000000000..43b39735c179ac2791f3f3fd2a7422b53504ce1d GIT binary patch literal 5804 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 literal 0 HcmV?d00001 diff --git a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt index 77110022..8853fd7c 100644 --- a/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt +++ b/app/src/test/java/llc/slacker/openime/NineKeyChineseTest.kt @@ -92,6 +92,22 @@ class NineKeyChineseTest { assertEquals("nihao", decoder.resolve("64426", preferredSuffix = null, fuzzy = false).previewSuffix) } + @Test + fun aLoneKeyAlignsToTheTopCandidatesInitial() { + // 4 / 5 / 7 / 8 / 9 hold only initials; the pre-edit shows the top + // candidate's initial instead of staying on the digit. + assertEquals(listOf("h"), pipeline.nineKeyReadingFor("4", "和")) + assertEquals(listOf("l"), pipeline.nineKeyReadingFor("5", "了")) + assertEquals(listOf("s"), pipeline.nineKeyReadingFor("7", "是")) + assertEquals(listOf("t"), pipeline.nineKeyReadingFor("8", "他")) + assertEquals(listOf("w"), pipeline.nineKeyReadingFor("9", "我")) + // A full syllable still wins, and a character the key cannot start stays unaligned. + assertEquals(listOf("a"), pipeline.nineKeyReadingFor("2", "啊")) + assertEquals(null, pipeline.nineKeyReadingFor("4", "啊")) + // More than one key keeps requiring complete syllables. + assertEquals(null, pipeline.nineKeyReadingFor("44", "和")) + } + private fun resolve(digits: String): CandidatePipeline.NineKeyResolution = pipeline.resolveNineKey( digits = digits, diff --git a/docs/APP_UI_SPEC.md b/docs/APP_UI_SPEC.md index 20bcfbc1..fb56eed3 100644 --- a/docs/APP_UI_SPEC.md +++ b/docs/APP_UI_SPEC.md @@ -92,10 +92,11 @@ Apple 的 pt 不是 Android 的 dp/sp。下面是本项目在 Android 上的实 ## 按键触感 - 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`。 +- 本项目由 `KeyHaptics` 直接调用 `Vibrator`,按“震动手感”选择波形:清脆(默认)用 `PRIMITIVE_TICK` / `EFFECT_TICK` / 8ms 脉冲,有力用 `PRIMITIVE_CLICK` / `EFFECT_CLICK` / 12ms 脉冲,系统用 `KEYBOARD_TAP`(厂商调校,不受强度影响)。用途标记为触摸反馈,仍跟随系统“触摸反馈”强度。线性马达(如小米 X 轴马达)上长脉冲会产生余震,所以不使用超过 12ms 的脉冲。没有振动马达时回退 `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)`:单参数版本在系统“触摸提示音”关闭时不出声。 +- 按键音效可选:系统(带音量参数的 `AudioManager.playSoundEffect(FX_KEYPRESS_STANDARD, -1)`,单参数版本在系统“触摸提示音”关闭时不出声)以及 `res/raw/key_sound_*.wav` 五种合成音(清脆、机械、木质、打字机、气泡,`SoundPool` 播放,用途为 UI 提示音)。选择后立即试听一次,不会先播旧的音效或震动。 +- 偏好设置“按键与输入”里依次是:按键音效、音效、触感震动、震动手感、震动强度;键盘内设置把“音效”“震动手感”放在“按键反馈”一组。 ## 验收与可重复工件 diff --git a/scripts/generate_key_sounds.py b/scripts/generate_key_sounds.py new file mode 100644 index 00000000..bf1f8688 --- /dev/null +++ b/scripts/generate_key_sounds.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Synthesize openIME's built-in key-click sounds. + +Every sound is generated here from sine partials and seeded noise, so the +files carry no third-party rights and can be regenerated byte for byte: + + python3 scripts/generate_key_sounds.py + +Writes 48 kHz mono 16-bit WAV files to app/src/main/res/raw/key_sound_*.wav. +Each click is under 80 ms and peaks at -2 dBFS; playback volume follows the +system sound level. +""" + +import math +import random +import struct +import wave +from pathlib import Path + +RATE = 48_000 +PEAK = 10 ** (-2 / 20) +OUT = Path(__file__).resolve().parent.parent / "app" / "src" / "main" / "res" / "raw" + + +def samples(duration_s): + return int(RATE * duration_s) + + +def noise(n, seed): + rnd = random.Random(seed) + raw = [rnd.uniform(-1.0, 1.0) for _ in range(n + 1)] + # First difference: tilts the noise toward the treble so it reads as a tick. + return [raw[i + 1] - raw[i] for i in range(n)] + + +def decay(t, tau): + return math.exp(-t / tau) + + +def finish(signal): + """Normalise to PEAK and fade the last 2 ms to silence (no end click).""" + top = max(abs(s) for s in signal) or 1.0 + scaled = [s * PEAK / top for s in signal] + fade = samples(0.002) + for i in range(fade): + scaled[-1 - i] *= i / fade + return scaled + + +def crisp(): + n = samples(0.028) + hiss = noise(n, 11) + out = [] + for i in range(n): + t = i / RATE + out.append( + 0.7 * hiss[i] * decay(t, 0.0012) + + 0.6 * math.sin(2 * math.pi * 4200 * t) * decay(t, 0.005) + ) + return finish(out) + + +def mechanical(): + n = samples(0.055) + hiss = noise(n, 23) + second = samples(0.012) + out = [] + for i in range(n): + t = i / RATE + press = ( + 0.8 * hiss[i] * decay(t, 0.0018) + + 0.5 * math.sin(2 * math.pi * 2400 * t) * decay(t, 0.004) + + 0.3 * math.sin(2 * math.pi * 180 * t) * decay(t, 0.008) + ) + bottom = 0.0 + if i >= second: + tb = (i - second) / RATE + bottom = ( + 0.45 * hiss[i - second] * decay(tb, 0.0015) + + 0.35 * math.sin(2 * math.pi * 1300 * tb) * decay(tb, 0.006) + ) + out.append(press + bottom) + return finish(out) + + +def wood(): + n = samples(0.06) + hiss = noise(n, 37) + out = [] + for i in range(n): + t = i / RATE + out.append( + 0.8 * math.sin(2 * math.pi * 950 * t) * decay(t, 0.011) + + 0.35 * math.sin(2 * math.pi * 2300 * t) * decay(t, 0.005) + + 0.3 * hiss[i] * decay(t, 0.0009) + ) + return finish(out) + + +def typewriter(): + n = samples(0.075) + hiss = noise(n, 41) + out = [] + for i in range(n): + t = i / RATE + ring = sum(math.sin(2 * math.pi * f * t) for f in (3150, 4730, 6210)) / 3 + out.append( + 0.9 * hiss[i] * decay(t, 0.0028) + + 0.3 * ring * decay(t, 0.016) + + 0.3 * math.sin(2 * math.pi * 220 * t) * decay(t, 0.01) + ) + return finish(out) + + +def bubble(): + duration = 0.045 + n = samples(duration) + phase = 0.0 + out = [] + for i in range(n): + t = i / RATE + freq = 500 + 900 * (t / duration) ** 0.6 + phase += 2 * math.pi * freq / RATE + envelope = math.sin(math.pi * t / duration) ** 1.5 * decay(t, 0.03) + out.append(math.sin(phase) * envelope) + return finish(out) + + +SOUNDS = { + "key_sound_crisp": crisp, + "key_sound_mechanical": mechanical, + "key_sound_wood": wood, + "key_sound_typewriter": typewriter, + "key_sound_bubble": bubble, +} + + +def write(name, signal): + path = OUT / f"{name}.wav" + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(RATE) + wav.writeframes(b"".join(struct.pack(" Date: Mon, 5 Oct 2026 13:12:11 +0800 Subject: [PATCH 2/2] Fix clipped first nine-key candidate after a lone-digit placeholder A lone 4/5/7/8/9 first renders the raw digit, then Rime's answer reuses that strip slot. The candidate TextView was MATCH_PARENT, so setText kept the digit's width and clipped the character. Use WRAP_CONTENT so a wider candidate relayouts. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../llc/slacker/openime/keyboard/CandidateBarController.kt | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91c64bbf..bcb1e29f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 新增“震动手感”:清脆(默认,最短促的 TICK)、有力(CLICK)、系统(交给手机厂商调好的键盘震动)。线性马达上长脉冲会“嗡”出余震,不支持原语的机型改用 8–12ms 短脉冲。 - 新增内置按键音效:系统、清脆、机械、木质、打字机、气泡,点选即试听。音效由脚本合成(`scripts/generate_key_sounds.py`),音量跟随系统音量。 - 九键单按 4、5、7、8、9 时,拼音栏显示首个候选的声母(如 4 → h),不再停在数字;左侧拼音列同时高亮该字母。 +- 修复九键打第一个字时单按 4、5、7、8、9,首个候选字只显示一半:候选格沿用了先显示的数字的宽度,现在会按新候选重新排版。 - “关于 → 诊断”显示本机振动马达支持的能力,便于排查震感问题。 - 新增“震动强度”滑块(10%–100%,默认 100%),在偏好设置“触感震动”下方和键盘内设置首屏;拖动时实时试震,随用户数据导出、导入。 - 修复“按键音效”打开后没有声音:系统“触摸提示音”关闭时按键音也被一起静音,现在只看 App 内的开关。 diff --git a/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt index c35e6a6e..af7cd1d7 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt @@ -344,8 +344,11 @@ internal class CandidateBarController( isClickable = false importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO }, + // WRAP_CONTENT, not MATCH_PARENT: items are reused across + // queries, and a TextView with a non-wrap width keeps its old + // width on setText, clipping a wider new candidate. LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT, ), )