Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs

## [0.0.5-beta.1] - 2026-10-05

第五个测试版:按键震动更响更清脆并可调强度,按键音效恢复,“中/英”键移到空格右边,整理“关于与数据”和工具面板。从 0.0.4-beta.1 覆盖安装即可,用户数据保留。

- 按键震动更响更清脆:不再走系统给键盘的固定低振幅,直接驱动振动马达播放满强度的短促点击(Android 11 起用 CLICK 原语,Android 10 用系统预设点击,更早版本用 20ms 满幅脉冲),仍跟随系统“触摸反馈”强度;删除键手势到位也是同一下点击。
第五个测试版:按键震动更清脆、可选手感和强度,按键音效恢复并内置多种音效,“中/英”键移到空格右边,九键单按一个键时拼音栏显示字母,整理“关于与数据”和工具面板。从 0.0.4-beta.1 覆盖安装即可,用户数据保留。

- 按键震动不再走系统给键盘的固定低振幅,直接驱动振动马达,仍跟随系统“触摸反馈”强度;删除键手势到位也是同一下点击。
- 新增“震动手感”:清脆(默认,最短促的 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 内的开关。
- 26 键的“中/英”键移到空格右边,与“,。”键对调,中英文键盘一致。
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/llc/slacker/openime/AboutActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = null

/** Readings of [digits] for the left rail, best first (see [NineKeyReading]). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
39 changes: 39 additions & 0 deletions app/src/main/java/llc/slacker/openime/data/FeedbackStyles.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/llc/slacker/openime/data/UserDataArchive.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)
Expand Down
55 changes: 35 additions & 20 deletions app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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() {}
Expand All @@ -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
}


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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. */
Expand Down
Loading
Loading