diff --git a/CHANGELOG.md b/CHANGELOG.md index 872ee999..7efd1fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +## [0.0.8-beta.1] - 2026-10-09 + +第八个测试版:英文大写手势、长按后滑动选字、横屏浮动与左右分离键盘、工具栏的复制和验证码提示,修复九键拼音栏缺音节和排序错误、一次点按两次反馈、分段选词学不到。从 0.0.7-beta.1 覆盖安装即可,用户数据保留。 + +- 修复九键拼音栏里缺少 xian、liu、pin、tian、tuan、yuan、yue 七个音节:它们和某个词同名(西安、浏览、拼音、提案……),被当成词组而不是音节,选不到,这些音节下的字(先、现、天、月、院……)也排到词后面。 +- 九键拼音栏排序:原来按整条读法的分数总和排,两个音节的读法永远压过一个音节的(输入 94264 先出 xia、yi,输入 7426 先出 qi);现在按每个音节的平均分排,输入 94264 的 xiang 排第一,qian 排在 qi 前面。去掉 ng、hm、重复的 lue、nue,以及没有音节以它开头的字母(i、u、v)。 +- 候选里的错词:删掉内置词表里 18 个手写错词(泥壕、审么、屑屑、美关系、块乐、膨友……);模糊音候选排在原拼写候选后面;按前缀补全时按字频取音节,不再按字母顺序取到 96 个就停(输入 sh 到不了 shi,输入 x 到不了 xiang);带分词符的输入(xi'an)先查连写后的词。 +- 选词学习:只选了一部分输入时(先选“你”,剩下的 haoma 继续打),点中的词现在也会让 Rime 记住,分段选的词组常用后也能前移,不再只有一次选完的单字才会前移。剩余没选的输入不会被学习。 +- 英文模式的候选行在上方留白里偏上约 9dp,现在在工具栏条里垂直居中。 +- 一次点按两次反馈:系统在手指抬起时多播一次点击音,长按成功时多震一次(不受应用内开关控制),现在都关掉,只由键盘自己按下时反馈一次;文本编辑方向键、剪贴板“重新读取”、符号“管理自定义符号”、语音语言键、面板返回箭头、展开候选长按、退格上滑清空松手时,都不再多响一次;不可点的占位格不再响。长按弹窗打开时震一次,滑过格子和抬起选字时静音(和 HeliBoard、fcitx5 一致)。 +- 长按弹窗里滑到格子再抬起、按住 Shift 滑选时,第二根手指按下后不再多打一个字母;手指刚出 Shift 边缘不再改变 Shift 状态;弹窗里选英文字母会用掉“大写一次”;内嵌输入框有焦点时长按弹窗不再输入两次;长按后点同一个键的上半部分不再误触发上滑数字。 +- 分离键盘右半边的 G、V 现在能被“按住 Shift 滑选”选中;分离布局在设置面板打开时旋转屏幕,关掉面板后会按新方向重建。 +- 横屏:窗口高度改为取键盘自己的期望高度(新进程直接横屏浮动时窗口曾被压成 1 像素);在横屏里新建的键盘顶栏和“左右分离”按钮会立即生效;手动浮动后在横屏停靠,换输入框不再被自动浮起来。 +- 工具栏提示:验证码提取改为取与所有关键词的最小间距,排除 yyyyMMdd 日期,新增“动态密码、随机码、登录码、授权码”;点过的验证码不再重复出现;系统自动填充返回空结果时不再清掉键盘自己的提示;遵守“关闭个性化学习”的输入框(无痕);设置里“短信验证码”开关按真实权限显示;权限申请页旋转不重复请求,不进最近任务。 +- 文档:README 隐私一节补充可选的短信权限;测试清单、兼容性、端到端脚本同步长按行为和横屏默认浮动。 + +- 英文键盘的大写:单击 Shift 输入一个大写;快速连点两下锁定大写;长按 Shift 直接锁定(再长按解除)。Shift 键三个状态图标不同:空心 = 小写,实心 = 下一个大写,实心带横线 = 锁定。 +- 键盘上方的工具栏降低 10dp(70 → 60),拼音行和候选行压矮;键位上方留白 6dp → 4dp,底部空白 10dp → 19dp,键盘总高度净减 3dp。九键、26 键、数字、笔画键盘都一样。 +- 26 键字母键长按也弹出选择面板(另一种大小写 + 右上角的数字或符号),按住滑到目标、松手输入,和搜狗、讯飞一致;上滑仍直接输入右上角的数字或符号。 +- 回车键统一:输入拼音时是“确定”;编辑框要求了动作(发送、搜索、前往、下一项、完成)时显示动作文字并高亮;其余(换行、多行、无动作)一律是不高亮的“↵”。九键、26 键、笔画、数字键盘都按同一规则。 +- 按键气泡和长按选择面板同高(52dp);长按选择面板缩小(格子 40×44dp),松手后没选就自动消失(松在原键上 1.5 秒后,没松手 4 秒后)。 +- 候选字背景上下各缩 0.5dp,工具栏总高不变;键盘整体再上抬 2dp。 +- 工具栏提示:刚复制的文字(3 分钟内)显示在工具栏,点一下输入;设置里新增“短信验证码”(默认关闭):打开后请求“读取短信”权限,5 分钟内的验证码短信显示成“验证码 123456”,点一下输入。两项都只在本机读取,不联网;密码框不显示。可在“设置 → 工具栏提示”关闭。 +- 按住 Shift 滑到字母再松手,输入那一个大写字母,Shift 状态保持原样(和 Gboard 一样);松手在别处则什么都不输入。 +- 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。 +- 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右分离。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。左右分离参照搜狗:26 键分成左右两半,G、V 两边都有,两边各一个空格,中间留空;九键一边是九宫格(删除、重输、0、确定在左,符号列在右),另一边是常用字面板(的了我你是……),中间的“镜像”键左右互换。横屏工具栏多一个“左右分离键盘”按钮,点一下分离或恢复。 +- 横屏整宽键盘铺满屏宽,之前被缩成中间一小块。 +- 横屏顶栏压缩到只比按键行高一点(48dp),输入时拼音在左、候选在右同一行;横屏下按键下方的留白只有 6dp(竖屏仍是 19dp),横屏键盘高度相应降低。HeliBoard、fcitx5 的横屏底部留白为 0。 +- 浮动键盘:整张卡片圆角并带细边,底部为系统“返回/切换输入法”按钮留出一条空位,不再盖住最下面一行。 + ## [0.0.7-beta.1] - 2026-10-06 第七个测试版:九键预编辑不再出现数字,拼音栏点哪个都管用,符号、表情、颜文字大幅补全。从 0.0.6-beta.1 覆盖安装即可,用户数据保留。 diff --git a/README.md b/README.md index a6123de6..67afebc1 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ The app has no `INTERNET` permission, so your text cannot leave the phone. The word list is in the APK. - **Autofill (Android 11 and later):** account names and codes from your password manager appear in the toolbar. - **Numbers and symbols:** each letter key shows a number or symbol. - Swipe up or press and hold to enter it. + Swipe up to enter it. Press and hold to open a popup, then slide to a cell and release. - **Display support:** portrait, landscape, tablets, foldables, dark theme and large font sizes. Raw key input fields (terminals, remote desktops, games) and physical keyboards also work. See [Compatibility](docs/COMPATIBILITY.md). @@ -116,6 +116,7 @@ You can install a new release over an old one. - Voice audio stays in memory. openIME clears it when recognition ends, is canceled or fails. - In password fields, openIME does not compose pinyin, learn words, read the field text or write logs. Voice input inserts the final text only. +- The optional SMS code chip is off by default. It asks for the read-SMS permission only when you turn it on, and reads only the last 5 minutes of messages on the device. The copied-text chip shows only text copied in the last 3 minutes. Neither chip shows in password fields or in fields that ask for no personalised learning. - Crash records contain the exception type and the stack trace. They never contain typed text. - Uninstall removes all data on the device, including learned words. diff --git a/README.zh-CN.md b/README.zh-CN.md index d939d363..5a0391bb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -51,7 +51,7 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库 - **手势**:删除键上滑清空;空格左右滑动移动光标,滑动时底行其他按键锁定。 - **表情联想**:选词后联想栏先给出相关表情(开心 → 😊),词表内置,不联网。 - **自动填充**:Android 11+ 上,密码管理器的账号、验证码直接显示在键盘工具栏位置。 -- **数字和符号**:26 键字母键右上角印着数字和符号,上滑或长按直接输入。 +- **数字和符号**:26 键字母键右上角印着数字和符号,上滑直接输入;长按弹出选择面板,滑到目标松手输入。 - **适配**:横竖屏、平板与折叠屏、深色模式、大字号;终端、远程桌面、游戏等原始按键输入框; 外接键盘可直接打拼音。详见 [兼容性说明](docs/COMPATIBILITY.md)。 - **自我保护**:连续崩溃或卡死后自动进入安全模式,保证仍然可以打字; @@ -85,6 +85,7 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库 - 应用不声明 `INTERNET` 权限,`allowBackup` 关闭;词典、语音模型和 runtime 都随 APK 提供。 - 语音音频只在内存中处理,结束、取消或失败时清空,不落盘。 +- 可选的“短信验证码”默认关闭,打开后才请求“读取短信”权限,只在本机读取最近 5 分钟的验证码;“复制内容提示”只显示 3 分钟内复制的文字,密码框和要求关闭个性化学习的输入框都不显示。 - 密码输入框不组合拼音、不学习词库、不读取输入框内容、不写日志;可使用剪贴板历史(来源应用标记为敏感的内容和要求关闭个性化学习的输入框除外);语音可用,识别结果只在结束时一次性上屏。 - 崩溃记录只保存异常类型和调用栈,不含输入内容。 - 卸载会清除本机全部数据,包括学习到的用户词库。 diff --git a/VERSION b/VERSION index a9b673b6..d3fe5be0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.7-beta.1 +0.0.8-beta.1 diff --git a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt index e1015a37..411d166e 100644 --- a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt @@ -92,9 +92,8 @@ class DisplayEnvironmentInstrumentedTest { @Test fun landscapeContentBoxKeepsEveryRowInsideIt() { - // A landscape configuration makes the reference scale follow the height, - // so the content box (padding on both sides) is narrower than the window. - // Rows used to keep the width they were measured with before that padding + // Landscape fills the whole width (padding 0), like Gboard and the Chinese keyboards. + // Rows used to keep the width they were measured with before the padding // was applied: as wide as the window, clipped to the box. for (mode in listOf(KeyboardMode.PINYIN_26, KeyboardMode.ENGLISH_26, KeyboardMode.PINYIN_9, KeyboardMode.DIGITS)) { withKeyboard( @@ -110,7 +109,7 @@ class DisplayEnvironmentInstrumentedTest { val body = keyboard.findViewWithTag("keyboard-body") assertNotNull("keyboard body", body) val content = body.width - body.paddingLeft - body.paddingRight - assertTrue("$mode must be laid out in a clamped content box (padding ${body.paddingLeft})", body.paddingLeft > 0) + assertEquals("$mode landscape fills the full width", 0, body.paddingLeft) for (index in 0 until body.childCount) { val row = body.getChildAt(index) assertTrue( diff --git a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt index b00c6af0..17961f56 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt @@ -361,15 +361,27 @@ class SpaceCursorAndLetterHintsInstrumentedTest { } @Test - fun longPressTypesTheSameHint() = withKeyboard { harness, recorder, keyboard -> + fun longPressOffersTheHintInAPopup() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } - listOf('w', 'd', 'k', 'v').forEach { letter -> + listOf('w' to "2", 'd' to "@", 'k' to "*", 'v' to "_").forEach { (letter, hint) -> val key = settled(harness, keyboard, "key:$letter") assertTrue(harness.awaitMain { key.performLongClick() }) + // The popup lists the other case, then the hint; a tap on its cell types it. + assertTrue("popup cell $hint", harness.awaitMain { chooseFromPopup(keyboard, hint) }) } assertEquals(listOf("2", "@", "*", "_"), recorder.characters.toList()) } + /** Taps the long-press popup cell showing [text]; false when no such cell is on screen. */ + private fun chooseFromPopup(root: View, text: String): Boolean { + fun walk(view: View): android.widget.TextView? { + if (view is android.widget.TextView && view.text.toString() == text && view.hasOnClickListeners()) return view + if (view is android.view.ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))?.let { return it } + return null + } + return walk(root)?.performClick() == true + } + @Test fun aPlainTapStillTypesTheLetter() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.ENGLISH_26); true } @@ -386,6 +398,7 @@ class SpaceCursorAndLetterHintsInstrumentedTest { assertTrue("swipe is off: ${recorder.characters}", recorder.characters.isEmpty()) harness.awaitMain { assertTrue("1" in texts(q)); true } assertTrue(harness.awaitMain { q.performLongClick() }) + assertTrue("popup cell 1", harness.awaitMain { chooseFromPopup(keyboard, "1") }) assertEquals("long press still works", listOf("1"), recorder.characters.toList()) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7198eae8..55930f45 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + + + @@ -126,6 +127,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can }, ) } + /** The landscape setting floated the keyboard; a floating one the user chose is never touched. */ + private var landscapeAutoFloating = false + /** The user docked the keyboard during this landscape session; leave it docked until rotation. */ + private var landscapeFloatingDeclined = false private var voiceComposing = false private var voiceAutoCommitOnFinal = true private val voiceCorrectionTracker by lazy { @@ -322,9 +327,39 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can // Orientation changes affect keyboard geometry, not the user's chosen // window mode. A manually floating keyboard stays floating; a docked // keyboard stays docked. + applyLandscapeLayout() floatingWindow.onConfigurationChanged() } + private fun isLandscape() = + resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + /** + * Landscape layout setting: floating (default), full width, or split halves. + * Only the transitions the setting itself caused are undone: a keyboard the + * user floated from the tools page stays floating in both orientations. + */ + private fun applyLandscapeLayout() { + val landscape = isLandscape() + val layout = ImeSettingsRepository.loadLandscapeLayout(this) + if (!landscape) landscapeFloatingDeclined = false + val wantFloating = landscape && layout == LandscapeLayout.FLOATING && !landscapeFloatingDeclined + if (wantFloating && !floatingWindow.enabled) { + landscapeAutoFloating = true + keyboardView?.setFloatingWindowMode(true, compact = true) + floatingWindow.enable(resetPosition = true) + } else if (!wantFloating && landscapeAutoFloating) { + landscapeAutoFloating = false + keyboardView?.setFloatingWindowMode(false) + floatingWindow.restore() + } + } + + override fun onLandscapeLayoutChanged(layout: LandscapeLayout) { + landscapeFloatingDeclined = false + applyLandscapeLayout() + } + override fun onEvaluateInputViewShown(): Boolean { // The emulator and some Chromebooks expose a hardware keyboard, so // InputMethodService's default policy suppresses the on-screen view @@ -503,8 +538,9 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onStartInputView(attribute: EditorInfo?, restarting: Boolean) { super.onStartInputView(attribute, restarting) ensureInputViewAfterFinish() + applyLandscapeLayout() if (floatingWindow.enabled) { - keyboardView?.setFloatingWindowMode(true) + keyboardView?.setFloatingWindowMode(true, compact = landscapeAutoFloating) floatingWindow.reapply() } else { floatingWindow.restore() @@ -865,6 +901,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } override fun onFloatingKeyboardChanged(floating: Boolean) { + // A manual change replaces the automatic one: docking in landscape means + // "not now", and floating by hand is the user's own choice. + if (!floating && isLandscape()) landscapeFloatingDeclined = true + landscapeAutoFloating = false keyboardView?.setFloatingWindowMode(floating) if (floating) floatingWindow.enable() else floatingWindow.restore() } @@ -1696,6 +1736,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can val reference = entry.nativeReference val remaining = partialRemainder(reference, mode) if (remaining != null) { + reference?.let { learnPartialPick(it, entry.text) } finishPartialCandidateCommit(entry.text, remaining) return } @@ -1726,6 +1767,22 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can return normalized.substring(ref.consumed).trim('\'').ifEmpty { null } } + /** + * Teach librime the word the user tapped, and only that word: its own spelling is replayed + * as a separate selection, so the rest of the input (which nobody chose) is never learned. + * Stock Rime learns a run of picks the same way; without this a phrase chosen piece by piece + * never moves up, while a character typed alone does. + */ + private fun learnPartialPick(reference: NativeCandidateReference, text: String) { + if (!rime.isReady || !allowsPersonalizedLearning()) return + val spelling = RimeInputNormalizer.normalize(reference.input) + .take(reference.consumed) + .trim('\'') + if (spelling.isEmpty()) return + val deferred = NativeCandidateReference.deferred(spelling, text) + rime.selectCandidate(deferred.input, deferred.nativeIndex, true) + } + private fun finishPartialCandidateCommit(committed: String, remaining: String) { if (committed.isEmpty()) return // One atomic edit: commit the word and start the next composing span diff --git a/app/src/main/java/llc/slacker/openime/SmsPermissionActivity.kt b/app/src/main/java/llc/slacker/openime/SmsPermissionActivity.kt new file mode 100644 index 00000000..3561c43d --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/SmsPermissionActivity.kt @@ -0,0 +1,36 @@ +package llc.slacker.openime + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Bundle +import llc.slacker.openime.data.ImeSettingsRepository + +/** + * Asks for the text-message permission the toolbar's verification-code chip + * needs. A keyboard cannot show a permission dialog itself, so the setting opens + * this invisible screen. Refusing turns the setting back off. + */ +class SmsPermissionActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Recreated while the dialog is open: the pending request still answers. + if (savedInstanceState != null) return + if (checkSelfPermission(Manifest.permission.READ_SMS) == PackageManager.PERMISSION_GRANTED) { + finish() + return + } + requestPermissions(arrayOf(Manifest.permission.READ_SMS), REQUEST) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + val granted = grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED + ImeSettingsRepository.saveSmsCodeChip(this, granted) + finish() + } + + private companion object { + const val REQUEST = 7 + } +} diff --git a/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt index aff7d82a..bf334b13 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt @@ -168,19 +168,17 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { } val result = linkedSetOf() + // What was typed comes first; a fuzzy reading (si for shi) only follows it. + result.addAll(ImeData.phraseDict[py].orEmpty()) + result.addAll(pinyinDict[py].orEmpty()) if (fuzzy) { fuzzyVariants(py).forEach { variant -> result.addAll(ImeData.phraseDict[variant].orEmpty()) } - } - result.addAll(ImeData.phraseDict[py].orEmpty()) - - if (fuzzy) { fuzzyVariants(py).forEach { variant -> result.addAll(pinyinDict[variant].orEmpty()) } } - result.addAll(pinyinDict[py].orEmpty()) // Compact phrase fallback used to scan every phrase on every key. // Jump to the sorted prefix interval, then restore source-map order so @@ -188,14 +186,21 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { phrasePrefixIndex.lookup(py).values.forEach { candidates -> result.addAll(candidates) } + // Syllables that continue the typed letters, most used first (an alphabetical + // walk ran out of room before shi, xiang, zhe, nei, dai). var prefixIndex = lowerBound(sortedPinyinKeys, py) - while (prefixIndex < sortedPinyinKeys.size) { + val continuations = ArrayList>>() + while (prefixIndex < sortedPinyinKeys.size && continuations.size < PREFIX_KEY_LIMIT) { val key = sortedPinyinKeys[prefixIndex] if (!key.startsWith(py)) break - result.addAll(pinyinDict[key].orEmpty().take(3)) - if (result.size >= 96) break + val top = pinyinDict[key].orEmpty().take(3) + if (top.isNotEmpty()) continuations += (PinyinLexicon.weightFor(key, top.first())) to top prefixIndex++ } + // Stable sort: equal weights keep the alphabetical order. + continuations.sortedByDescending { it.first }.forEach { (_, chars) -> + if (result.size < 96) result.addAll(chars) + } if (result.isEmpty() && py.length > 2) { val max = minOf(py.length, 6) @@ -266,6 +271,12 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { val key = parts.joinToString("|") val result = linkedSetOf() result.addAll(ImeData.segmentedPhraseDict[key].orEmpty()) + // A boundary inside a known word's spelling (xi'an, wo'lai) still finds the word. + val joined = parts.joinToString("") + result.addAll(ImeData.phraseDict[joined].orEmpty().filter { it.codePointCount(0, it.length) == parts.size }) + phrasePrefixIndex.lookup(joined).values.forEach { words -> + result.addAll(words.filter { it.codePointCount(0, it.length) == parts.size }) + } // The general fallback handles arbitrary boundaries even when a // phrase is not yet in the compact phrase table. Limit the product so @@ -572,6 +583,7 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { } internal companion object { + private const val PREFIX_KEY_LIMIT = 400 const val MAX_NINE_KEY_DIGITS = 64 private const val MAX_NINE_MATCHES = 12 private const val MAX_LOCAL_RESOLVE_LENGTH = 32 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 761c468e..25a02655 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt @@ -407,8 +407,15 @@ internal class NineKeyLocalDecoder( // First syllables, each only if the rest can still be read. val firsts = LinkedHashMap() - paths.forEach { path -> firsts.putIfAbsent(path.syllables.first(), path.score) } + // Rank by the path's score per syllable: a plain sum let any two-syllable + // reading outrank a one-syllable one (xia before xiang, qi before qian). + paths.forEach { path -> + val first = path.syllables.first() + if (first !in RAIL_DUPLICATES) firsts.putIfAbsent(first, path.score / path.syllables.size) + } syllableOptions(bounded, preferred, limit * 2).forEach { syllable -> + if (syllable.length > 1 && syllable !in completeSyllables) return@forEach + if (syllable in RAIL_DUPLICATES) return@forEach val code = digitsForPinyin(syllable) ?: return@forEach if (code.length == bounded.length || canRead(bounded.substring(code.length))) { firsts.putIfAbsent(syllable, Int.MIN_VALUE) @@ -525,7 +532,7 @@ internal class NineKeyLocalDecoder( for (depth in 1..minOf(bounded.length, MAX_SYLLABLE_LENGTH)) { node = node.children[bounded[depth - 1]] ?: break node.exact.forEach { entry -> - if (!entry.phrase && entry.pinyin.length == depth && entry.pinyin.all { it in 'a'..'z' }) { + if (isSyllableEntry(entry) && entry.pinyin.length == depth) { found += Option(entry.pinyin, depth, entryScore(entry)) } } @@ -549,6 +556,8 @@ internal class NineKeyLocalDecoder( ImeData.keypad9Map[bounded] .orEmpty() .filter { it.length == 1 && it[0] in 'a'..'z' } + // A bare letter no syllable starts with (i, u, v) is a dead end. + .filter { letter -> completeSyllables.any { it.startsWith(letter) } } .forEach { if (it !in ordered) ordered += it } } return ordered.take(limit) @@ -568,22 +577,26 @@ internal class NineKeyLocalDecoder( merged.getOrPut(pinyin) { mutableListOf() }.addAll(values) } - return merged.mapNotNull { (pinyin, rawValues) -> - val digits = digitsForPinyin(pinyin) ?: return@mapNotNull null + return merged.flatMap { (pinyin, rawValues) -> + val digits = digitsForPinyin(pinyin) ?: return@flatMap emptyList() val values = rawValues.distinct().take(MAX_CANDIDATES) - if (values.isEmpty()) return@mapNotNull null - val corpusWeight = values.maxOfOrNull { value -> - PinyinLexicon.weightFor(pinyin, value) - } ?: 0 - Entry( + if (values.isEmpty()) return@flatMap emptyList() + fun entryOf(list: List, phrase: Boolean) = Entry( pinyin = pinyin, digits = digits, - candidates = values, - phrase = pinyin in ImeData.phraseDict || values.any { value -> - value.codePointCount(0, value.length) > 1 - }, - weight = corpusWeight, + candidates = list, + phrase = phrase, + weight = list.maxOfOrNull { value -> PinyinLexicon.weightFor(pinyin, value) } ?: 0, ) + val singles = values.filter { it.codePointCount(0, it.length) == 1 } + val multis = values.filter { it.codePointCount(0, it.length) > 1 } + if (singles.isNotEmpty() && multis.isNotEmpty()) { + // A syllable that is also a word's spelling (xian / 西安, liu / 浏览, pin / 拼音) + // is still a syllable: keep its characters apart from the words. + listOf(entryOf(singles, phrase = false), entryOf(multis, phrase = true)) + } else { + listOf(entryOf(values, phrase = pinyin in ImeData.phraseDict || multis.isNotEmpty())) + } } } @@ -734,6 +747,9 @@ internal class NineKeyLocalDecoder( private const val PER_PATH_CANDIDATES = 24 private const val PART_PENALTY = 55 + /** Spellings of lve / nve that the legacy table also holds; the rail shows one of each. */ + private val RAIL_DUPLICATES = setOf("lue", "nue") + /** The letters printed on each nine-key digit. */ private val KEY_LETTERS = mapOf( '2' to "abc", '3' to "def", '4' to "ghi", '5' to "jkl", diff --git a/app/src/main/java/llc/slacker/openime/core/ImeData.kt b/app/src/main/java/llc/slacker/openime/core/ImeData.kt index fc3708b0..a76e77bd 100644 --- a/app/src/main/java/llc/slacker/openime/core/ImeData.kt +++ b/app/src/main/java/llc/slacker/openime/core/ImeData.kt @@ -409,33 +409,33 @@ object ImeData { ) val phraseDict = mapOf( - "nihao" to listOf("你好", "您好", "泥壕", "拟好"), - "shurufa" to listOf("输入法", "输入方案", "数如发"), - "zhongguo" to listOf("中国", "重过", "中果"), + "nihao" to listOf("你好", "您好", "拟好"), + "shurufa" to listOf("输入法", "输入方案"), + "zhongguo" to listOf("中国", "重过"), "beijing" to listOf("北京", "背景", "倍加"), - "shanghai" to listOf("上海", "伤害", "上嗨"), - "gongzuo" to listOf("工作", "公作", "供桌"), - "xuexi" to listOf("学习", "血洗", "学系"), - "kaifa" to listOf("开发", "开花", "凯发"), + "shanghai" to listOf("上海", "伤害"), + "gongzuo" to listOf("工作", "供桌"), + "xuexi" to listOf("学习", "血洗"), + "kaifa" to listOf("开发", "开花"), "chengxuyuan" to listOf("程序员", "程序源"), - "rengongzhineng" to listOf("人工智能", "人工只能"), - "diannao" to listOf("电脑", "电闹"), + "rengongzhineng" to listOf("人工智能"), + "diannao" to listOf("电脑"), "shouji" to listOf("手机", "首记", "收紧"), "jianpan" to listOf("键盘", "建攀"), "weixin" to listOf("微信", "微芯", "威信"), "guge" to listOf("谷歌", "骨骼"), "tianqi" to listOf("天气", "田七"), - "xiexie" to listOf("谢谢", "屑屑"), + "xiexie" to listOf("谢谢"), "zaijian" to listOf("再见", "在建"), - "meiguanxi" to listOf("没关系", "美关系"), - "haode" to listOf("好的", "豪的", "浩德"), - "shenme" to listOf("什么", "审么"), + "meiguanxi" to listOf("没关系"), + "haode" to listOf("好的"), + "shenme" to listOf("什么"), "zenmeyang" to listOf("怎么样", "怎样"), - "mingtian" to listOf("明天", "名天"), + "mingtian" to listOf("明天"), "jintian" to listOf("今天", "金田"), - "zuotian" to listOf("昨天", "作天"), - "pengyou" to listOf("朋友", "膨友"), - "kuailer" to listOf("快乐", "块乐"), + "zuotian" to listOf("昨天"), + "pengyou" to listOf("朋友"), + "kuailer" to listOf("快乐"), "gongxi" to listOf("恭喜", "功勋"), // Keep the unsegmented full-Pinyin result first. The same word is // also available from the explicit "xi|an" boundary action below. diff --git a/app/src/main/java/llc/slacker/openime/core/LandscapeLayout.kt b/app/src/main/java/llc/slacker/openime/core/LandscapeLayout.kt new file mode 100644 index 00000000..4d06db06 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/LandscapeLayout.kt @@ -0,0 +1,21 @@ +package llc.slacker.openime.core + +/** + * How the keyboard sits when the phone is held sideways. + * + * WeChat Keyboard floats by itself in landscape and Gboard does the same; the + * Chinese keyboards otherwise stretch the full width. Sogou also offers a split + * keyboard, one half under each thumb. Floating is the default because a + * full-width keyboard of four rows covers most of a landscape screen. + */ +enum class LandscapeLayout(val key: String, val label: String) { + FLOATING("floating", "浮动"), + FULL("full", "整宽"), + SPLIT("split", "左右分离"); + + companion object { + // "side" was an earlier name of the split layout. + fun fromKey(key: String?): LandscapeLayout = + entries.firstOrNull { it.key == key } ?: if (key == "side") SPLIT else FLOATING + } +} 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 7dbe8889..f75fab4e 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -3,6 +3,7 @@ package llc.slacker.openime.data import android.content.Context import llc.slacker.openime.core.FuzzyRule import llc.slacker.openime.core.KeyboardMode +import llc.slacker.openime.core.LandscapeLayout import llc.slacker.openime.theme.ImeAppearance import llc.slacker.openime.theme.ImeTheme @@ -29,6 +30,10 @@ object ImeSettingsRepository { private const val KEY_KEYBOARD_HEIGHT = "keyboard_height_percent" private const val KEY_FLOATING_WIDTH = "floating_width_percent" private const val KEY_FLOATING_OPACITY = "floating_opacity_percent" + private const val KEY_LANDSCAPE_LAYOUT = "landscape_layout" + private const val KEY_SPLIT_MIRRORED = "split_mirrored" + private const val KEY_SMS_CODE_CHIP = "sms_code_chip" + private const val KEY_CLIPBOARD_CHIP = "clipboard_chip" /** * The user's preferred Chinese layout (26-key vs 9-key). Only PINYIN_26 and @@ -59,6 +64,37 @@ object ImeSettingsRepository { .edit().putInt(KEY_KEYBOARD_HEIGHT, percent.coerceIn(80, 120)).apply() } + fun loadSplitMirrored(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_SPLIT_MIRRORED, false) + + fun saveSplitMirrored(context: Context, mirrored: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_SPLIT_MIRRORED, mirrored).apply() + } + + fun loadSmsCodeChip(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_SMS_CODE_CHIP, false) + + fun saveSmsCodeChip(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_SMS_CODE_CHIP, enabled).apply() + } + + fun loadClipboardChip(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_CLIPBOARD_CHIP, true) + + fun saveClipboardChip(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_CLIPBOARD_CHIP, enabled).apply() + } + + fun loadLandscapeLayout(context: Context): LandscapeLayout = + LandscapeLayout.fromKey( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_LANDSCAPE_LAYOUT, null), + ) + + fun saveLandscapeLayout(context: Context, layout: LandscapeLayout) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_LANDSCAPE_LAYOUT, layout.key).apply() + } + fun loadFloatingWidthPercent(context: Context): Int = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getInt(KEY_FLOATING_WIDTH, 100) diff --git a/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt b/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt index 4799a66a..30749af7 100644 --- a/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/editor/EnterActionPolicy.kt @@ -30,6 +30,13 @@ internal fun enterKeyPresentationFor(imeOptions: Int): EnterKeyPresentation { } } +/** The label drawn on the key: 确定 while composing, the action word when the editor asked for one, otherwise ↵. */ +internal fun enterKeyFaceFor(imeOptions: Int?, composing: Boolean): String { + if (composing) return "确定" + val presentation = imeOptions?.let(::enterKeyPresentationFor) ?: return "↵" + return if (presentation.editorAction != null) presentation.label else "↵" +} + /** Resolve an editor action for an Enter press, or null when a real Enter is required. */ internal fun editorActionForEnter(imeOptions: Int): Int? = enterKeyPresentationFor(imeOptions).editorAction diff --git a/app/src/main/java/llc/slacker/openime/floating/FloatingKeyboardController.kt b/app/src/main/java/llc/slacker/openime/floating/FloatingKeyboardController.kt index 31965b0f..cdf267f7 100644 --- a/app/src/main/java/llc/slacker/openime/floating/FloatingKeyboardController.kt +++ b/app/src/main/java/llc/slacker/openime/floating/FloatingKeyboardController.kt @@ -8,8 +8,6 @@ import android.os.Build import android.view.MotionEvent import android.view.View import android.widget.LinearLayout -import llc.slacker.openime.theme.ImeDrawableFactory -import llc.slacker.openime.theme.ImeGeometryTokens import llc.slacker.openime.theme.ImeTheme /** @@ -75,17 +73,10 @@ internal class FloatingKeyboardController( fun applyTheme(tokens: ImeTheme.Tokens) { (handle as? DragHandleView)?.setDotColor(tokens.border) handle.setBackgroundColor(tokens.toolbarBackground) - mainDock.elevation = if (enabled) toPx(10).toFloat() else 0f - if (enabled) { - mainDock.background = ImeDrawableFactory.rounded( - tokens.keyboardBackground, - toPx(ImeGeometryTokens.CARD_RADIUS_DP), - ) - mainDock.clipToOutline = true - } else { - mainDock.setBackgroundColor(tokens.keyboardBackground) - mainDock.clipToOutline = false - } + // The root view clips to the rounded card; the dock only supplies the fill. + mainDock.elevation = 0f + mainDock.setBackgroundColor(tokens.keyboardBackground) + mainDock.clipToOutline = false } private fun handleTouch(event: MotionEvent): Boolean { 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 f09f7076..7fbe4020 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt @@ -175,7 +175,7 @@ internal class BackspaceGestureController( when { clearAll -> { - onHapticFeedback() + // The arming tick already told the user; no second one on release. onClearAll() } deleteOnce -> onDeleteOne() 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 af7cd1d7..037c7065 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/CandidateBarController.kt @@ -157,7 +157,7 @@ internal class CandidateBarController( createItem(index, candidate), LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, - toPx(42), + toPx(41), ).apply { marginEnd = toPx(4) }, ) } else { @@ -331,7 +331,7 @@ internal class CandidateBarController( LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - minimumHeight = toPx(42) + minimumHeight = toPx(41) isFocusable = true importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES isClickable = true diff --git a/app/src/main/java/llc/slacker/openime/keyboard/EnglishShiftPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/EnglishShiftPolicy.kt index 7814b53e..4ab44f03 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/EnglishShiftPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/EnglishShiftPolicy.kt @@ -1,6 +1,7 @@ package llc.slacker.openime.keyboard import android.text.InputType +import llc.slacker.openime.R import llc.slacker.openime.core.ShiftState /** @@ -18,4 +19,30 @@ internal object EnglishShiftPolicy { } else { ShiftState.LOWERCASE } + + /** Two taps closer than this lock Caps. */ + const val DOUBLE_TAP_MS = 400L + + /** + * A tap on Shift: off becomes one capital; a second quick tap locks Caps + * (the double tap Gboard and SwiftKey use); a slow second tap or a tap on + * Caps Lock turns it off. + */ + fun afterTap(current: ShiftState, sinceLastTapMs: Long?): ShiftState = when (current) { + ShiftState.LOWERCASE -> ShiftState.SHIFT_ONCE + ShiftState.SHIFT_ONCE -> + if (sinceLastTapMs != null && sinceLastTapMs <= DOUBLE_TAP_MS) ShiftState.CAPS_LOCK else ShiftState.LOWERCASE + ShiftState.CAPS_LOCK -> ShiftState.LOWERCASE + } + + /** A long press on Shift locks Caps at once, or releases the lock. */ + fun afterLongPress(current: ShiftState): ShiftState = + if (current == ShiftState.CAPS_LOCK) ShiftState.LOWERCASE else ShiftState.CAPS_LOCK + + /** Outline = off, solid = one capital, solid with a bar = Caps Lock. */ + fun icon(state: ShiftState): Int = when (state) { + ShiftState.LOWERCASE -> R.drawable.ic_shift + ShiftState.SHIFT_ONCE -> R.drawable.ic_shift_on + ShiftState.CAPS_LOCK -> R.drawable.ic_caps_lock + } } 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 7f1dd10c..61c4c468 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -27,6 +27,7 @@ import llc.slacker.openime.R import llc.slacker.openime.candidate.CandidatePipeline import llc.slacker.openime.candidate.CandidateResolver import llc.slacker.openime.candidate.NineKeyLocalDecoder +import llc.slacker.openime.candidate.personalizedLearningAllowed import llc.slacker.openime.candidate.NineKeyPerformanceTrace import llc.slacker.openime.candidate.NineKeyReading import llc.slacker.openime.candidate.StrokeLexicon @@ -34,6 +35,7 @@ import llc.slacker.openime.core.FuzzyRule import llc.slacker.openime.core.ImeState import llc.slacker.openime.core.KeyboardMode import llc.slacker.openime.core.Panel +import llc.slacker.openime.core.LandscapeLayout import llc.slacker.openime.core.ShiftState import llc.slacker.openime.data.HapticStyle import llc.slacker.openime.data.ImeSettingsRepository @@ -41,6 +43,7 @@ import llc.slacker.openime.data.KeySoundStyle import llc.slacker.openime.data.QuickPhrase import llc.slacker.openime.data.QuickPhraseRepository import llc.slacker.openime.editor.EditorInfoAdapter +import llc.slacker.openime.editor.enterKeyFaceFor import llc.slacker.openime.editor.enterKeyPresentationFor import llc.slacker.openime.floating.FloatingKeyboardController import llc.slacker.openime.panel.ClipboardPanelController @@ -133,6 +136,7 @@ open class ImeKeyboardView( /** 震动手感 or 按键音效 changed; the new value is already saved. */ fun onFeedbackStyleChanged() {} fun onFloatingStyleChanged(widthPercent: Int, opacityPercent: Int) {} + fun onLandscapeLayoutChanged(layout: LandscapeLayout) {} fun onOpenAbout() {} fun onOpenDataManagement() {} } @@ -146,6 +150,7 @@ open class ImeKeyboardView( companion object { /** Scale differences below this are rounding noise, not a new geometry. */ private const val SCALE_CHANGE_EPSILON = 0.01f + private const val FLOATING_NAV_STRIP_DP = 28 /** How often a deferred row rebuild re-checks whether the press ended. */ private const val ROW_REBUILD_POLL_MS = 40L @@ -300,11 +305,13 @@ open class ImeKeyboardView( private var floatingOpacityPercent = ImeSettingsRepository.loadFloatingOpacityPercent(context) private var referenceScale = if (standalonePanel) 1f else ImeReferenceSizing.scale(context) private var floatingWindowMode = false + /** Floating that the landscape setting started: it keeps the compact landscape rows. */ + private var floatingCompact = false private var layoutMetrics = buildLayoutMetrics() /** Landscape uses compact rows, except in floating mode, which keeps portrait size. */ private fun buildLayoutMetrics() = KeyboardLayoutMetrics( landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE && - !floatingWindowMode, + (!floatingWindowMode || floatingCompact), fontScale = appliedFontScale, heightPercent = keyboardHeightPercent, availableWidthDp = resources.configuration.screenWidthDp, @@ -327,7 +334,61 @@ open class ImeKeyboardView( } /** Refresh data owned by auxiliary editor Activities when they return. */ + private var dismissedSuggestion: String? = null + private var dismissedSmsCode: String? = null + private var ownSuggestionsShown = false + + /** + * Toolbar chips for a fresh text-message code and the text just copied. They + * share the strip system autofill suggestions use, which keeps priority. + */ + internal fun refreshSuggestions() { + if (standalonePanel || !::topZone.isInitialized) return + // System autofill chips keep the strip; only this view's own chips are refreshed. + if (topZone.autofillChipsShown && !ownSuggestionsShown) return + val found = ToolbarSuggestions.collect(context, allowed = personalizedLearningAllowed(passwordField, currentImeOptions()), skipClip = dismissedSuggestion, skipSms = dismissedSmsCode) + if (found.isEmpty()) { + if (ownSuggestionsShown) topZone.setAutofillChips(emptyList()) + ownSuggestionsShown = false + return + } + val t = currentThemeTokens() + val chips = found.map { suggestion -> + TextView(context).apply { + text = suggestion.label + maxLines = 1 + ellipsize = android.text.TextUtils.TruncateAt.END + textSize = ImeTypographyTokens.BODY_SP + gravity = Gravity.CENTER_VERTICAL + setTextColor(t.keyText) + setPadding(dp(14), 0, dp(14), 0) + maxWidth = (resources.displayMetrics.widthPixels * 0.6f).toInt() + background = ImeDrawableFactory.rounded(t.lightKeyBackground, dp(ImeGeometryTokens.KEY_RADIUS_DP)) + contentDescription = if (suggestion.kind == ToolbarSuggestion.Kind.SMS_CODE) { + "验证码${suggestion.text},点击输入" + } else { + "刚复制的内容,点击输入" + } + setOnClickListener { + feedback() + if (!insertIntoInlineEditor(suggestion.text)) listener.onCharacter(suggestion.text) + if (suggestion.kind == ToolbarSuggestion.Kind.SMS_CODE) dismissedSmsCode = suggestion.text + else dismissedSuggestion = suggestion.text + ownSuggestionsShown = false + topZone.setAutofillChips(emptyList()) + } + } + } + topZone.onAutofillDismissed = { + found.lastOrNull { it.kind == ToolbarSuggestion.Kind.CLIPBOARD }?.let { dismissedSuggestion = it.text } + found.firstOrNull { it.kind == ToolbarSuggestion.Kind.SMS_CODE }?.let { dismissedSmsCode = it.text } + } + ownSuggestionsShown = true + topZone.setAutofillChips(chips, chipHeightPx = dp(36)) + } + internal fun refreshAuxiliaryContent() { + refreshSuggestions() when (panel) { Panel.CLIPBOARD -> renderClipboard(reusePanel = true) Panel.SYMBOLS -> panelRenderer.refreshCustomSymbols() @@ -419,7 +480,8 @@ open class ImeKeyboardView( statefulRounded = { normal, pressed, radius -> statefulRounded(normal, pressed, radius) }, contrastText = ImeDrawableFactory::contrastText, feedback = ::feedback, - onSymbolSelected = listener::onCharacter, + hoverFeedback = ::hapticFeedback, + onSymbolSelected = ::onPopupChoice, ) private val nineKeySegmentRepairController = NineKeySegmentRepairController( context = context, @@ -466,6 +528,9 @@ open class ImeKeyboardView( onModeSwitch = ::cycleMode, onRetranslate = ::retype, onEnter = listener::onEnter, + splitLayout = ::splitKeyboard, + mirrored = { ImeSettingsRepository.loadSplitMirrored(context) }, + onMirror = ::mirrorSplit, ) } private val strokeRenderer: StrokeKeyboardRenderer by lazy { @@ -676,6 +741,7 @@ open class ImeKeyboardView( currentHaptic = { hapticEnabled }, currentHapticStrengthPercent = { keyHaptics.strengthPercent }, currentHapticStyle = { keyHaptics.style }, + currentLandscapeLayout = { ImeSettingsRepository.loadLandscapeLayout(context) }, currentKeySoundStyle = { keySounds.style }, currentPopup = { popupEnabled }, currentSwipeUpDigits = { ImeSettingsRepository.loadSwipeUpDigits(context) }, @@ -693,6 +759,7 @@ open class ImeKeyboardView( onKeyboardHeightChanged = ::setKeyboardHeightPercent, onHapticStrengthChanged = ::setHapticStrengthPercent, onHapticStyleChanged = ::setHapticStyle, + onLandscapeLayoutChanged = ::setLandscapeLayout, onKeySoundStyleChanged = ::setKeySoundStyle, onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, @@ -783,18 +850,21 @@ open class ImeKeyboardView( hintsEnabled = { ImeSettingsRepository.loadLetterHints(context) }, swipeUpEnabled = { ImeSettingsRepository.loadSwipeUpDigits(context) }, onShift = ::cycleShift, + onShiftLongPress = ::lockShift, onDigits = { setMode(KeyboardMode.DIGITS) }, onModeSwitch = ::cycleMode, onSpace = listener::onSpace, onEnter = listener::onEnter, + onSymbols = { showPanel(Panel.SYMBOLS) }, + splitLayout = ::splitKeyboard, ) } init { tag = "ime_root" - // Some IME windows inherit the host's disabled sound-effect flag. - // Keep the view channel enabled; the preference still gates feedback(). - isSoundEffectsEnabled = true + // Keys play their sound through AudioManager (KeySounds), not the view channel, + // so the platform's own click sound stays off. + isSoundEffectsEnabled = false val rootHeight = if (standalonePanel) { FrameLayout.LayoutParams.MATCH_PARENT } else { @@ -827,7 +897,7 @@ open class ImeKeyboardView( } keyboardBody.orientation = LinearLayout.VERTICAL keyboardBody.tag = "keyboard-body" - keyboardBody.setPadding(dp(0), dp(6), dp(0), dp(10)) + keyboardBody.setPadding(dp(0), dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), dp(0), dp(layoutMetrics.bottomPadDp)) expandedPanel.orientation = LinearLayout.VERTICAL expandedPanel.tag = "panel-overlay" expandedPanel.visibility = View.GONE @@ -883,7 +953,7 @@ open class ImeKeyboardView( @Suppress("DEPRECATION") insets.systemWindowInsetBottom }.coerceAtMost(dp(32)) - val next = if (floatingWindowMode) 0 else ImeBottomInsetPolicy.clampInset(systemBottomInsetPx, dp(32)) + val next = bottomInsetFor(systemBottomInsetPx) if (next != navigationBottomInsetPx) { navigationBottomInsetPx = next applyDynamicHeights() @@ -894,6 +964,16 @@ open class ImeKeyboardView( } } + /** + * Docked, the keyboard sits above the system bar. A floating card gets a strip + * of its own for the back and keyboard-switch buttons the system draws inside + * the IME window, so they never cover the bottom row (Gboard does the same). + */ + private fun bottomInsetFor(reportedPx: Int): Int { + val clamped = ImeBottomInsetPolicy.clampInset(reportedPx, dp(32)) + return if (floatingWindowMode) maxOf(clamped, dp(FLOATING_NAV_STRIP_DP)) else clamped + } + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { super.onSizeChanged(width, height, oldWidth, oldHeight) updateResponsiveGeometry(width) @@ -916,6 +996,9 @@ open class ImeKeyboardView( appliedDensityDpi = newConfig.densityDpi layoutMetrics = buildLayoutMetrics() if (!geometryChanged) return + // The rows under an open panel are stale too (split layout, row heights): + // closing the panel must rebuild them. + renderedMode = null // Do not yank the user out of an open panel. applyDynamicHeights() if (standalonePanel) return @@ -951,8 +1034,17 @@ open class ImeKeyboardView( return spaceVoiceGestureController.trackingTouch || walk(this) } + /** The height this view wants as a window: rows plus the system-bar strip; valid before the first measure. */ + internal fun desiredWindowHeightPx(): Int? = + if (standalonePanel) null else dp(imeHeightDp()) + navigationBottomInsetPx + override fun onAttachedToWindow() { super.onAttachedToWindow() + // A view created while already in landscape never sees a configuration change. + if (::topZone.isInitialized && !standalonePanel) { + topZone.setLandscapeStrip(layoutMetrics.landscape) + syncSplitToggle() + } requestApplyInsets() } @@ -1011,6 +1103,8 @@ open class ImeKeyboardView( */ private fun applyDynamicHeights() { if (standalonePanel) return + if (::topZone.isInitialized) topZone.setLandscapeStrip(layoutMetrics.landscape) + syncSplitToggle() val totalPx = dp(imeHeightDp()) val bodyPx = dp(keyboardBodyHeightDp()) (layoutParams as? FrameLayout.LayoutParams)?.let { @@ -1048,7 +1142,7 @@ open class ImeKeyboardView( private fun updateResponsiveGeometry(measuredWidthPx: Int) { if (measuredWidthPx <= 0) return - val nextScale = if (standalonePanel) 1f else ImeReferenceSizing.scale(context, measuredWidthPx, landscapeCompact = !floatingWindowMode) + val nextScale = if (standalonePanel) 1f else ImeReferenceSizing.scale(context, measuredWidthPx, landscapeCompact = !floatingWindowMode || floatingCompact) // Configuration.screenWidthDp is a whole number while the measured width // is not (411dp vs 411.43dp on a 1080px / 420dpi screen), so the two // scales differ by up to 1/390 for the same window. That rounding noise @@ -1080,7 +1174,7 @@ open class ImeKeyboardView( // width before WindowManager applies the floating window bounds. // Keep the normal keyboard's content inset local to its window. contentInsetPx = dp(0) - keyboardBody.setPadding(contentInsetPx, dp(6), contentInsetPx, dp(10)) + keyboardBody.setPadding(contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, dp(layoutMetrics.bottomPadDp)) keyboardBody.findViewWithTag("key-row-secondary")?.let { row -> // The portrait layout narrows this row to 90% of the full // display for optical centering. A floating window can be @@ -1102,14 +1196,21 @@ open class ImeKeyboardView( return } val minimumInset = dp(0) - val maxWidth = if (standalonePanel) dp(maxContentWidthDp) - else (minOf(maxContentWidthDp.toFloat(), 390f * referenceScale) * resources.displayMetrics.density).toInt() + val landscape = appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + val maxWidth = when { + standalonePanel -> dp(maxContentWidthDp) + // Landscape rows are short, so the width is what makes keys easy to hit. + // Like Gboard and the Chinese keyboards, fill the whole width instead of + // shrinking the keys to the height-derived portrait width. + landscape -> measuredWidthPx + else -> (minOf(maxContentWidthDp.toFloat(), 390f * referenceScale) * resources.displayMetrics.density).toInt() + } contentInsetPx = maxOf(minimumInset, (measuredWidthPx - maxWidth) / 2) keyboardBody.setPadding( contentInsetPx, - dp(6), + dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, - dp(10), + dp(layoutMetrics.bottomPadDp), ) keyboardBody.findViewWithTag("key-row-secondary")?.let { row -> val rowWidth = ((measuredWidthPx - contentInsetPx * 2) * 0.9f).toInt() @@ -1202,6 +1303,7 @@ open class ImeKeyboardView( listener.onCandidateExpanded(open) }, onUndo = { listener.onTextEdit("undo") }, + onSplitToggle = ::toggleSplit, onAssociationDismiss = ::clearAssociationCandidates, ) candidateBarController = CandidateBarController( @@ -1221,7 +1323,6 @@ open class ImeKeyboardView( allowTwoLineLabel() contentDescription = "候选:$candidate" setOnLongClickListener { - feedback() listener.onCandidateLongPressed(candidate) true } @@ -1364,9 +1465,10 @@ open class ImeKeyboardView( } /** Keep content geometry local when the service changes the window bounds. */ - fun setFloatingWindowMode(enabled: Boolean) { + fun setFloatingWindowMode(enabled: Boolean, compact: Boolean = false) { floatingWindowMode = enabled - navigationBottomInsetPx = if (enabled) 0 else ImeBottomInsetPolicy.clampInset(systemBottomInsetPx, dp(32)) + floatingCompact = enabled && compact + navigationBottomInsetPx = bottomInsetFor(systemBottomInsetPx) floatingKeyboardController.setEnabled(enabled) topZone.setCompactToolbar(enabled) layoutMetrics = buildLayoutMetrics() @@ -1377,7 +1479,7 @@ open class ImeKeyboardView( if (!standalonePanel && panel == Panel.NONE) renderModeBody() if (enabled) { contentInsetPx = dp(0) - keyboardBody.setPadding(contentInsetPx, dp(6), contentInsetPx, dp(10)) + keyboardBody.setPadding(contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, dp(layoutMetrics.bottomPadDp)) expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0) candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0) topZone.setContentInset(contentInsetPx) @@ -1385,7 +1487,7 @@ open class ImeKeyboardView( updateTopZone(composition.text?.isNotEmpty() == true) if (width > 0) updateResponsiveGeometry(width) } - floatingKeyboardController.applyTheme(currentThemeTokens()) + applyTheme() requestLayout() } @@ -1518,14 +1620,17 @@ open class ImeKeyboardView( syncEnterKeyPresentation(state.editorInfo?.imeOptions) } + private fun currentImeOptions(): Int? = + (context as? android.inputmethodservice.InputMethodService)?.currentInputEditorInfo?.imeOptions + private fun syncEnterKeyPresentation(imeOptions: Int?) { val options = imeOptions ?: return val enter = findViewWithTag("key-enter") ?: return val composing = composition.text?.isNotEmpty() == true - val label = if (composing) "确定" else if (mode == KeyboardMode.PINYIN_9 || mode == KeyboardMode.STROKE || mode == KeyboardMode.DIGITS) "↵" else enterKeyPresentationFor(options).label - enter.setMainText(label) + enter.setMainText(enterKeyFaceFor(options, composing)) enter.applyMainTextScale(referenceScale) - enter.contentDescription = label + enter.contentDescription = if (composing) "确定" else enterKeyPresentationFor(options).label + themeApplier.apply(enter, currentThemeTokens()) } private var inlineAutofillController: InlineAutofillController? = null @@ -1538,6 +1643,11 @@ open class ImeKeyboardView( lateinit var controller: InlineAutofillController controller = InlineAutofillController(context, context.mainExecutor) { chips -> if (::topZone.isInitialized) { + // An empty response (the usual "nothing to fill") must not wipe this view's own chips. + if (chips.isEmpty() && ownSuggestionsShown) return@InlineAutofillController + // The system's chips replace this view's own; a later refresh must not touch them. + ownSuggestionsShown = false + topZone.onAutofillDismissed = null topZone.setAutofillChips(chips, controller.chipSize.width, controller.chipSize.height) } } @@ -1625,6 +1735,41 @@ open class ImeKeyboardView( } /** Save the 震动手感 choice and let it be felt once. */ + /** Landscape split halves (Sogou's 左右分离), only for the docked full-width keyboard. */ + private fun splitKeyboard(): Boolean = + !standalonePanel && !floatingWindowMode && + appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE && + ImeSettingsRepository.loadLandscapeLayout(context) == LandscapeLayout.SPLIT + + private fun toggleSplit() { + setLandscapeLayout( + if (splitKeyboard()) LandscapeLayout.FULL else LandscapeLayout.SPLIT, + ) + syncSplitToggle() + } + + /** The toolbar split button shows only on a docked landscape keyboard. */ + private fun syncSplitToggle() { + if (!::topZone.isInitialized) return + val landscape = !standalonePanel && !floatingWindowMode && + appliedOrientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + topZone.setSplitToggle(landscape, splitKeyboard()) + } + + private fun mirrorSplit() { + ImeSettingsRepository.saveSplitMirrored(context, !ImeSettingsRepository.loadSplitMirrored(context)) + renderedMode = null + if (panel == Panel.NONE) renderModeBody() + } + + private fun setLandscapeLayout(next: LandscapeLayout) { + if (ImeSettingsRepository.loadLandscapeLayout(context) == next) return + ImeSettingsRepository.saveLandscapeLayout(context, next) + listener.onLandscapeLayoutChanged(next) + renderedMode = null + if (panel == Panel.NONE && !standalonePanel) renderModeBody() + } + private fun setHapticStyle(next: HapticStyle) { keyHaptics.style = next ImeSettingsRepository.saveHapticStyle(context, next.key) @@ -1883,9 +2028,9 @@ open class ImeKeyboardView( } keyboardBody.setPadding( contentInsetPx, - dp(6), + dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, - dp(10), + dp(layoutMetrics.bottomPadDp), ) val state = when { inlineVoicePresenter.active -> ImeTopZoneState.VOICE_INLINE @@ -1972,7 +2117,7 @@ open class ImeKeyboardView( pinyin26Renderer.render( english = false, shiftState = shiftState, - enterLabel = enterKeyLabel(false), + enterLabel = enterKeyLabel(), ) } @@ -1980,24 +2125,17 @@ open class ImeKeyboardView( pinyin26Renderer.render( english = true, shiftState = shiftState, - enterLabel = enterKeyLabel(true), + enterLabel = enterKeyLabel(), ) } - /** - * Resolve the Enter label from the bound editor at render time so the key is - * correct on its first frame (V2 keeps an idempotent re-sync as a safety net). - * Outside an InputMethodService host (settings/test) the legacy fallback is used. - */ - private fun enterKeyLabel(english: Boolean, fallback: String? = null): String { - val imeOptions = (context as? android.inputmethodservice.InputMethodService) - ?.currentInputEditorInfo?.imeOptions - if (imeOptions != null) return enterKeyPresentationFor(imeOptions).label - return fallback ?: if (english) "Go" else "确定" + /** One rule for every keyboard: 确定 composing, the action word for a real action, otherwise ↵. */ + private fun enterKeyLabel(): String { + return enterKeyFaceFor(currentImeOptions(), composition.text?.isNotEmpty() == true) } private fun renderPinyin9() { - pinyin9Renderer.render(enterLabel = if (composition.text?.isNotEmpty() == true) "确定" else "↵") + pinyin9Renderer.render(enterLabel = enterKeyLabel()) } private fun requireNineKeySymbolRailController(): NineKeySymbolRailController { @@ -2022,7 +2160,7 @@ open class ImeKeyboardView( start() } } - strokeRenderer.render(enterLabel = if (composition.text?.isNotEmpty() == true) "确定" else "↵") + strokeRenderer.render(enterLabel = enterKeyLabel()) } /** @@ -2045,7 +2183,7 @@ open class ImeKeyboardView( ?.currentInputEditorInfo numericKeyboardRenderer.render( editorKind = EditorInfoAdapter.kind(info), - enterLabel = "↵", + enterLabel = enterKeyLabel(), ) } @@ -2065,8 +2203,17 @@ open class ImeKeyboardView( override fun dispatchTouchEvent(event: MotionEvent): Boolean { if (event.actionMasked == MotionEvent.ACTION_DOWN) { keyPopupController.hideIfOutside(event.x, event.y) + silencePlatformFeedback(this) } val handled = super.dispatchTouchEvent(event) + // Slide-to-select: the finger that opened a long-press popup picks from it. + if (keyPopupController.isShowing) { + when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> keyPopupController.trackChoiceTouch(event.rawX, event.rawY, lifted = false) + MotionEvent.ACTION_UP -> keyPopupController.trackChoiceTouch(event.rawX, event.rawY, lifted = true) + } + } + trackShiftSlide(event) if (backspaceGestureController.active) { when (event.actionMasked) { MotionEvent.ACTION_MOVE -> { @@ -2287,6 +2434,9 @@ open class ImeKeyboardView( "模糊音纠错", "启用模糊音" -> fuzzyEnabled "按键气泡" -> popupEnabled "上滑输入数字" -> ImeSettingsRepository.loadSwipeUpDigits(context) + "复制内容提示" -> ImeSettingsRepository.loadClipboardChip(context) + "短信验证码" -> ImeSettingsRepository.loadSmsCodeChip(context) && + context.checkSelfPermission(android.Manifest.permission.READ_SMS) == android.content.pm.PackageManager.PERMISSION_GRANTED "数字和符号提示" -> ImeSettingsRepository.loadLetterHints(context) "表情联想" -> ImeSettingsRepository.loadEmojiAssociation(context) "语音去语气词" -> ImeSettingsRepository.loadVoiceStripFillers(context) @@ -2314,6 +2464,23 @@ open class ImeKeyboardView( } // Read at gesture time, so it needs no listener round trip. "上滑输入数字" -> ImeSettingsRepository.saveSwipeUpDigits(context, enabled) + "复制内容提示" -> { + ImeSettingsRepository.saveClipboardChip(context, enabled) + refreshSuggestions() + } + "短信验证码" -> { + ImeSettingsRepository.saveSmsCodeChip(context, enabled) + if (enabled && context.checkSelfPermission(android.Manifest.permission.READ_SMS) != + android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + // A keyboard cannot show the permission dialog; an invisible screen does. + context.startActivity( + android.content.Intent(context, llc.slacker.openime.SmsPermissionActivity::class.java) + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + refreshSuggestions() + } "数字和符号提示" -> { ImeSettingsRepository.saveLetterHints(context, enabled) // Rebuilt when the keyboard is next shown (right away if it is). @@ -2365,6 +2532,20 @@ open class ImeKeyboardView( "Candidate-producing keyboard modes require a CandidateResolver host" } + /** A cell of a long-press popup. English letters join the word being typed; the rest commit at once. */ + private fun onPopupChoice(text: String) { + if (insertIntoInlineEditor(text)) return + val letter = text.singleOrNull() + if (mode == KeyboardMode.ENGLISH_26 && letter != null && letter.isLetter()) { + clearAssociationCandidates() + val (py, selection) = replaceCompositionSelection(text) + publishComposition(py, candidatesForComposition(py), selection) + if (shiftState == ShiftState.SHIFT_ONCE) applyShift(ShiftState.LOWERCASE) + return + } + listener.onCharacter(text) + } + private fun onKeyTapped(base: String) { if (insertIntoInlineEditor(base)) return clearAssociationCandidates() @@ -2885,12 +3066,102 @@ open class ImeKeyboardView( return true } - private fun cycleShift() { - shiftState = when (shiftState) { - ShiftState.LOWERCASE -> ShiftState.SHIFT_ONCE - ShiftState.SHIFT_ONCE -> ShiftState.CAPS_LOCK - ShiftState.CAPS_LOCK -> ShiftState.LOWERCASE + private var lastShiftTapAt = 0L + private var shiftHeld = false + private var shiftSliding = false + private var shiftBeforeSlide = ShiftState.LOWERCASE + + private fun forEachKeyWithTag(action: (String, ImeKeyView) -> Unit) { + fun walk(view: View) { + if (view is ImeKeyView) (view.tag as? String)?.let { action(it, view) } + if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i)) + } + walk(this) + } + + private fun shiftKeyView(): ImeKeyView? = findViewWithTag("key-shift") + ?: findViewWithTag("key-shift-active") + ?: findViewWithTag("key-shift-caps") + + private fun rawContains(view: View, x: Float, y: Float): Boolean { + val at = IntArray(2) + view.getLocationOnScreen(at) + return x >= at[0] && x < at[0] + view.width && y >= at[1] && y < at[1] + view.height + } + + /** + * Gboard's hold-Shift-and-slide: press Shift, slide onto a letter and lift to + * type that one capital; the Shift state goes back to what it was. Lifting + * anywhere else changes nothing. + */ + private fun trackShiftSlide(event: MotionEvent) { + if (mode != KeyboardMode.ENGLISH_26) return + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val shift = shiftKeyView() + shiftHeld = shift != null && rawContains(shift, event.rawX, event.rawY) + shiftSliding = false + } + // A second finger means the first one is typing, not sliding from Shift. + MotionEvent.ACTION_POINTER_DOWN -> if (!shiftSliding) shiftHeld = false + MotionEvent.ACTION_MOVE -> { + if (!shiftHeld || shiftSliding) return + val shift = shiftKeyView() ?: return + // Beyond the touch slop, so Shift has already let go of the press (no click on lift). + val slop = android.view.ViewConfiguration.get(context).scaledTouchSlop + val x = event.rawX + val y = event.rawY + val outside = !rawContains(shift, x - slop, y) || !rawContains(shift, x + slop, y) || + !rawContains(shift, x, y - slop) || !rawContains(shift, x, y + slop) + if (outside) { + shiftSliding = true + shiftBeforeSlide = shiftState + if (shiftState == ShiftState.LOWERCASE) applyShift(ShiftState.SHIFT_ONCE) + hapticFeedback() + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + val sliding = shiftSliding + shiftHeld = false + shiftSliding = false + if (!sliding) return + // The split layout repeats G and V, so look at every key carrying the tag. + var letter: Char? = null + forEachKeyWithTag { tag, key -> + val ch = tag.removePrefix("key:").singleOrNull() + if (letter == null && tag.startsWith("key:") && ch != null && ch in 'a'..'z' && + rawContains(key, event.rawX, event.rawY) + ) letter = ch + } + if (letter != null && event.actionMasked == MotionEvent.ACTION_UP) { + onKeyTapped(letter.toString()) + // One capital was typed: a lock stays, anything else ends lower case. + applyShift( + if (shiftBeforeSlide == ShiftState.CAPS_LOCK) ShiftState.CAPS_LOCK else ShiftState.LOWERCASE, + ) + } else { + applyShift(shiftBeforeSlide) + } + } } + } + + private fun cycleShift() { + val now = android.os.SystemClock.uptimeMillis() + val since = if (lastShiftTapAt == 0L) null else now - lastShiftTapAt + lastShiftTapAt = now + applyShift(EnglishShiftPolicy.afterTap(shiftState, since)) + } + + /** Long press on Shift: lock Caps (or release it) without a second tap. */ + private fun lockShift() { + lastShiftTapAt = 0L + hapticFeedback() + applyShift(EnglishShiftPolicy.afterLongPress(shiftState)) + } + + private fun applyShift(next: ShiftState) { + shiftState = next listener.onShiftStateChanged(shiftState) refreshEnglishShiftPresentation() } @@ -2899,10 +3170,12 @@ open class ImeKeyboardView( private fun refreshEnglishShiftPresentation() { if (mode != KeyboardMode.ENGLISH_26) return val uppercase = shiftState != ShiftState.LOWERCASE - "qwertyuiopasdfghjklzxcvbnm".forEach { ch -> - findViewWithTag("key:$ch")?.setMainText( - if (uppercase) ch.uppercaseChar().toString() else ch.toString(), - ) + // The split layout repeats G and V on both halves, so set every key with the tag. + forEachKeyWithTag { tag, key -> + val ch = tag.removePrefix("key:").singleOrNull() + if (tag.startsWith("key:") && ch != null && ch in 'a'..'z') { + key.setMainText(if (uppercase) ch.uppercaseChar().toString() else ch.toString()) + } } val shift = findViewWithTag("key-shift") ?: findViewWithTag("key-shift-active") @@ -2913,7 +3186,7 @@ open class ImeKeyboardView( ShiftState.SHIFT_ONCE -> "key-shift-active" ShiftState.CAPS_LOCK -> "key-shift-caps" } - setIcon(if (shiftState == ShiftState.CAPS_LOCK) R.drawable.ic_caps_lock else R.drawable.ic_shift) + setIcon(EnglishShiftPolicy.icon(shiftState)) contentDescription = when (shiftState) { ShiftState.LOWERCASE -> "大写" ShiftState.SHIFT_ONCE -> "大写一次" @@ -2939,6 +3212,18 @@ open class ImeKeyboardView( if (soundEnabled) keySounds.play() } + /** + * Every key plays its own haptic and click (feedback()). The platform adds a second + * click sound on performClick and a LONG_PRESS buzz on a handled long click, so each + * view under the keyboard opts out of both. The root keeps its haptic channel: the + * system-style haptic goes through it. + */ + private fun silencePlatformFeedback(view: View) { + if (view.isSoundEffectsEnabled) view.isSoundEffectsEnabled = false + if (view !== this && view.isHapticFeedbackEnabled) view.isHapticFeedbackEnabled = false + if (view is ViewGroup) for (i in 0 until view.childCount) silencePlatformFeedback(view.getChildAt(i)) + } + /** Haptic-only confirmation (no key click sound), e.g. when voice arms. */ private fun hapticFeedback() { // A gesture threshold gets the same crisp click; LONG_PRESS rings for too long. @@ -3011,7 +3296,7 @@ open class ImeKeyboardView( isClickable = true isFocusable = true setOnTouchListener { _, event -> - if (event.actionMasked == MotionEvent.ACTION_DOWN && isEnabled) feedback() + if (event.actionMasked == MotionEvent.ACTION_DOWN && isEnabled && isClickable) feedback() false } } @@ -3085,7 +3370,20 @@ open class ImeKeyboardView( protected fun applyTheme() { val t = currentThemeTokens() - setBackgroundColor(t.keyboardBackground) + if (floatingWindowMode && !standalonePanel) { + // The window is transparent: the root is the card. Clipping to its rounded + // outline rounds the toolbar, the keys and every panel opened over them. + background = ImeDrawableFactory.rounded( + t.keyboardBackground, + dp(ImeGeometryTokens.CARD_RADIUS_DP), + ImeSurfacePolicy.divider(t), + dp(1).coerceAtLeast(1), + ) + clipToOutline = true + } else { + setBackgroundColor(t.keyboardBackground) + clipToOutline = false + } mainDock.setBackgroundColor(t.keyboardBackground) keyboardBody.setBackgroundColor(t.keyboardBackground) topZone.setBackgroundColor(t.toolbarBackground) 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 3f471bcc..3eee034d 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeThemeApplier.kt @@ -83,7 +83,7 @@ internal class ImeThemeApplier( (!function && DIGITS_ONLY.matches(label)) ) val primary = view.tag == "tab-active" || - (view.tag == "key-enter" && (!side || label == "确定")) + (view.tag == "key-enter" && view.currentMainText != "↵") val color = when { primary -> t.primary 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 14afdbf4..5c6cc3ca 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -39,6 +39,10 @@ internal enum class ImeTopZoneState { * The host supplies data/actions. This view owns construction and visibility * of the toolbar, composition/candidate surface, and inline voice surface. */ +/** Composing strip: a short pinyin line over the candidate row, 60dp in all. */ +private const val COMPOSITION_LINE_DP = 18 +private const val CANDIDATE_ROW_DP = 42 + internal class ImeTopZone( context: Context, private val toPx: (Int) -> Int, @@ -55,6 +59,7 @@ internal class ImeTopZone( onTools: () -> Unit, onExpandCandidates: () -> Unit, private val onUndo: () -> Unit, + private val onSplitToggle: () -> Unit = {}, private val onAssociationDismiss: () -> Unit = {}, ) : LinearLayout(context) { val toolbarRow = LinearLayout(context) @@ -80,6 +85,13 @@ internal class ImeTopZone( private var toolbarMode = ToolbarMode.NORMAL private var associationsShown = false private var autofillShown = false + private lateinit var splitIcon: View + private var splitToggleShown = false + private var splitActive = false + /** True while chips occupy the strip (system autofill or the toolbar suggestions). */ + val autofillChipsShown: Boolean get() = autofillShown + /** Called when the user closes the chip strip with its back control. */ + var onAutofillDismissed: (() -> Unit)? = null /** Chips from the system autofill service, hosted in a strip of their own. */ private lateinit var autofillScroll: HorizontalScrollView @@ -113,12 +125,13 @@ internal class ImeTopZone( 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") { onUndo() } + splitIcon = toolbarIcon(R.drawable.ic_keyboard_split, "左右分离键盘", "split-toggle") { onSplitToggle() } 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. toolbarIcons = listOf(keyboardIcon, clipboardIcon, emojiIcon, textEditIcon, undoIcon, toolsIcon) compactToolbarIcons = listOf(keyboardIcon, phraseIcon, emojiIcon, toolsIcon) - (toolbarIcons + phraseIcon).distinct().forEach { + (toolbarIcons + phraseIcon + splitIcon).distinct().forEach { toolbarRow.addView(it, LinearLayout.LayoutParams(0, toPx(48), 1f)) } @@ -224,7 +237,7 @@ internal class ImeTopZone( background = null includeFontPadding = false setPadding(toPx(14), toPx(3), toPx(14), 0) - minimumHeight = toPx(22) + minimumHeight = toPx(COMPOSITION_LINE_DP) addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit @@ -239,7 +252,7 @@ internal class ImeTopZone( composition, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(22), + toPx(COMPOSITION_LINE_DP), ), ) @@ -259,7 +272,7 @@ internal class ImeTopZone( candidateRow, ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + toPx(CANDIDATE_ROW_DP), ), ) } @@ -270,7 +283,7 @@ internal class ImeTopZone( candidateField.addView(expandedCaption, LinearLayout.LayoutParams(0, toPx(48), 1f)) candidateField.addView( candidateScroll, - LinearLayout.LayoutParams(0, toPx(ImeGeometryTokens.TOUCH_TARGET_DP), 1f), + LinearLayout.LayoutParams(0, toPx(CANDIDATE_ROW_DP), 1f), ) candidateEmojiButton.apply { @@ -304,7 +317,7 @@ internal class ImeTopZone( candidateField, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + toPx(CANDIDATE_ROW_DP), ), ) addView( @@ -430,6 +443,7 @@ internal class ImeTopZone( /** Back control of the autofill strip: hide the chips until the next response. */ private fun dismissAutofillChips() { + onAutofillDismissed?.invoke() autofillRow.removeAllViews() autofillShown = false toolbarMode = idleToolbarMode() @@ -443,7 +457,7 @@ internal class ImeTopZone( private var pressRawY = 0f private val longPressRunnable = Runnable { longPressArmed = true - performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS, HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING) } /** @@ -508,6 +522,59 @@ internal class ImeTopZone( } /** Floating (game) mode: a slimmer toolbar without text editing and undo. */ + /** + * Landscape strip: the zone is one key row plus a little, and while composing + * the pinyin sits at the left of the candidates instead of above them. + */ + fun setLandscapeStrip(on: Boolean) { + val height = toPx( + if (on) ImeGeometryTokens.LANDSCAPE_TOP_ZONE_HEIGHT_DP else ImeGeometryTokens.COMPOSED_TOP_ZONE_HEIGHT_DP, + ) + minimumHeight = height + toolbarRow.minimumHeight = minOf(height, toPx(ImeGeometryTokens.TOOLBAR_HEIGHT_DP)) + (toolbarRow.layoutParams as? LinearLayout.LayoutParams)?.let { + if (it.height != height) { it.height = height; toolbarRow.layoutParams = it } + } + (composeZone.layoutParams as? LinearLayout.LayoutParams)?.let { + if (it.height != height) { it.height = height; composeZone.layoutParams = it } + } + composeZone.orientation = if (on) LinearLayout.HORIZONTAL else LinearLayout.VERTICAL + composeZone.gravity = Gravity.CENTER_VERTICAL + (composition.layoutParams as? LinearLayout.LayoutParams)?.let { + if (on) { + it.width = LinearLayout.LayoutParams.WRAP_CONTENT + it.height = LinearLayout.LayoutParams.MATCH_PARENT + } else { + it.width = LinearLayout.LayoutParams.MATCH_PARENT + it.height = toPx(COMPOSITION_LINE_DP) + } + composition.layoutParams = it + } + composition.maxWidth = if (on) toPx(160) else Int.MAX_VALUE + composition.gravity = Gravity.CENTER_VERTICAL or Gravity.START + (candidateField.layoutParams as? LinearLayout.LayoutParams)?.let { + if (on) { + it.width = 0 + it.weight = 1f + it.height = LinearLayout.LayoutParams.MATCH_PARENT + } else { + it.width = LinearLayout.LayoutParams.MATCH_PARENT + it.weight = 0f + it.height = toPx(CANDIDATE_ROW_DP) + } + candidateField.layoutParams = it + } + } + + /** Landscape docked keyboards get Sogou's toolbar button that splits or rejoins the keyboard. */ + fun setSplitToggle(visible: Boolean, active: Boolean) { + splitToggleShown = visible + splitActive = active + splitIcon.contentDescription = if (active) "恢复普通键盘" else "左右分离键盘" + splitIcon.alpha = if (active) 1f else 0.72f + refreshToolbar() + } + fun setCompactToolbar(value: Boolean) { if (compact == value) return compact = value @@ -522,6 +589,7 @@ internal class ImeTopZone( (toolbarIcons + compactToolbarIcons).distinct().forEach { it.visibility = if (normal && it in shown) View.VISIBLE else View.GONE } + splitIcon.visibility = if (normal && splitToggleShown) View.VISIBLE else View.GONE 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 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 5f91e337..4db77ff4 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt @@ -31,6 +31,7 @@ internal class KeyPopupController( private val statefulRounded: (normal: Int, pressed: Int, radius: Int) -> Drawable, private val contrastText: (background: Int) -> Int, private val feedback: () -> Unit, + private val hoverFeedback: () -> Unit, private val onSymbolSelected: (String) -> Unit, ) { private val previewPopup = TextView(host.context).apply { @@ -44,6 +45,9 @@ internal class KeyPopupController( } private var popupView: View? = null private var keepAfterKeyUp = false + private var choiceAnchor: View? = null + private val choiceCells = mutableListOf>() + private var hoveredCell: TextView? = null /** True while a key preview or a long-press choice popup is on screen. */ val isShowing: Boolean get() = popupView != null @@ -193,15 +197,29 @@ internal class KeyPopupController( fun showChoices(anchor: View, choices: List) = showChoiceRows(anchor, listOf(choices)) /** Long-press selector with one horizontal row per entry of [rows]. */ - fun showChoiceRows(anchor: View, rows: List>) { - if (rows.isEmpty() || rows.all { it.isEmpty() }) return + fun showChoiceRows(anchor: View, requestedRows: List>) { + if (requestedRows.isEmpty() || requestedRows.all { it.isEmpty() }) return hide() + // The popup must sit above the key inside the IME window. A landscape + // keyboard has too little room for stacked rows, and a popup covering the + // key would put the pressed finger on a cell. Fall back to one row then, + // nearest letters first. + val anchorTop = IntArray(2).also { anchor.getLocationOnScreen(it) }[1] - + IntArray(2).also { host.getLocationOnScreen(it) }[1] + val needed = dp(CELL_HEIGHT_DP * requestedRows.size + 2 * POPUP_PAD_DP) + dp(ImeGeometryTokens.KEY_POPUP_VERTICAL_GAP_DP) + dp(4) + val rows = if (requestedRows.size > 1 && needed > anchorTop) { + listOf(requestedRows.reversed().flatten()) + } else { + requestedRows + } keepAfterKeyUp = true + choiceAnchor = anchor val t = tokens() val column = LinearLayout(host.context).apply { orientation = LinearLayout.VERTICAL - setPadding(dp(5), dp(5), dp(5), dp(5)) + gravity = Gravity.CENTER_HORIZONTAL + setPadding(dp(POPUP_PAD_DP), dp(POPUP_PAD_DP), dp(POPUP_PAD_DP), dp(POPUP_PAD_DP)) background = ImeDrawableFactory.rounded( t.keyBackground, dp(ImeGeometryTokens.CONTROL_RADIUS_DP), @@ -220,6 +238,7 @@ internal class KeyPopupController( choices.forEach { symbol -> row.addView( TextView(host.context).apply { + choiceCells += this to symbol text = symbol textSize = ImeTypographyTokens.BODY_SP includeFontPadding = false @@ -240,27 +259,68 @@ internal class KeyPopupController( onSymbolSelected(symbol) } }, - LinearLayout.LayoutParams( - dp(ImeGeometryTokens.TOUCH_TARGET_DP), - dp(ImeGeometryTokens.TOUCH_TARGET_DP), - ), + LinearLayout.LayoutParams(dp(CELL_WIDTH_DP), dp(CELL_HEIGHT_DP)), ) } column.addView( row, LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, - dp(ImeGeometryTokens.TOUCH_TARGET_DP), + dp(CELL_HEIGHT_DP), ), ) } val widest = rows.maxOf { it.size } - val popupWidth = dp(48 * widest + 10) - val popupHeight = dp(48 * rows.size + 10) + val popupWidth = dp(CELL_WIDTH_DP * widest + 2 * POPUP_PAD_DP) + val popupHeight = dp(CELL_HEIGHT_DP * rows.size + 2 * POPUP_PAD_DP) placeAbove(anchor, column, popupWidth, popupHeight) popupView = column animateIn(column, popupWidth, popupHeight) + scheduleAutoDismiss(OPEN_DISMISS_MS) + // The one buzz that says the long press worked (the platform's own is switched off). + hoverFeedback() + } + + /** + * Slide-to-select for a choice popup. The finger that opened it is still + * down, so the host forwards its moves and its lift here. Moving over a cell + * highlights it; lifting over a cell types it. Lifting anywhere else leaves + * the popup open when the finger is back on its key (tap a cell instead) and + * closes it otherwise. Returns true when the lift typed a cell. + */ + fun trackChoiceTouch(rawX: Float, rawY: Float, lifted: Boolean): Boolean { + if (choiceCells.isEmpty() || popupView == null) return false + // A cell drawn over the key itself is not a choice yet: the finger has not left the key. + val onAnchor = choiceAnchor?.let { containsRaw(it, rawX, rawY) } == true + val hit = if (onAnchor) null else choiceCells.firstOrNull { (cell, _) -> containsRaw(cell, rawX, rawY) } + if (!lifted) { + val cell = hit?.first + if (cell !== hoveredCell) { + hoveredCell?.isPressed = false + cell?.isPressed = true + hoveredCell = cell + } + return false + } + if (hit != null) { + // Silent, like the other open-source keyboards: the long press already buzzed once. + hide() + onSymbolSelected(hit.second) + return true + } + hoveredCell?.isPressed = false + hoveredCell = null + val anchor = choiceAnchor + if (anchor == null || !containsRaw(anchor, rawX, rawY)) hide() else scheduleAutoDismiss(LIFT_DISMISS_MS) + return false + } + + private fun containsRaw(view: View, rawX: Float, rawY: Float): Boolean { + val location = IntArray(2) + view.getLocationOnScreen(location) + return rawX >= location[0] && rawX < location[0] + view.width && + rawY >= location[1] && rawY < location[1] + view.height } fun hideIfOutside(x: Float, y: Float) { @@ -280,7 +340,16 @@ internal class KeyPopupController( return true } + /** A popup nobody picks from closes by itself. */ + private fun scheduleAutoDismiss(delayMs: Long) { + host.removeCallbacks(autoDismiss) + host.postDelayed(autoDismiss, delayMs) + } + + private val autoDismiss = Runnable { if (choiceCells.isNotEmpty()) hide() } + fun hide() { + host.removeCallbacks(autoDismiss) popupView?.let { popup -> popup.animate().cancel() if (popup === previewPopup) { @@ -291,9 +360,18 @@ internal class KeyPopupController( } popupView = null keepAfterKeyUp = false + choiceAnchor = null + choiceCells.clear() + hoveredCell = null } private companion object { + // Long-press cells and the key preview share one height (44 + 2 x 4 = 52). + const val CELL_WIDTH_DP = 40 + const val CELL_HEIGHT_DP = 44 + const val POPUP_PAD_DP = 4 + const val OPEN_DISMISS_MS = 4000L + const val LIFT_DISMISS_MS = 1500L const val GESTURE_HINT_HEIGHT_DP = 36 const val GESTURE_HINT_MIN_WIDTH_DP = 88 } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt index 8ccfb490..61e713d2 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt @@ -79,12 +79,19 @@ internal data class KeyboardLayoutMetrics( val doubleKeyHeightDp: Int = keyRowHeightDp * 2 + ImeGeometryTokens.KEY_ROW_GAP_DP - val topZoneHeightDp: Int = ImeGeometryTokens.COMPOSED_TOP_ZONE_HEIGHT_DP + val topZoneHeightDp: Int = if (landscape) { + ImeGeometryTokens.LANDSCAPE_TOP_ZONE_HEIGHT_DP + } else { + ImeGeometryTokens.COMPOSED_TOP_ZONE_HEIGHT_DP + } + + val bottomPadDp: Int = + if (landscape) ImeGeometryTokens.LANDSCAPE_BOTTOM_PAD_DP else ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP val imeHeightDp: Int = run { val derived = topZoneHeightDp + - keyRowHeightDp * 4 + 16 - val baseMinimum = if (landscape) 256 else 0 + keyRowHeightDp * 4 + ImeGeometryTokens.KEYBOARD_TOP_PAD_DP + bottomPadDp + val baseMinimum = 0 val scaledMinimum = (baseMinimum * heightPercent.coerceIn(80, 120) / 100f).toInt() maxOf(scaledMinimum, derived) diff --git a/app/src/main/java/llc/slacker/openime/keyboard/LetterHintPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/LetterHintPolicy.kt index c73c1e99..4066d195 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/LetterHintPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/LetterHintPolicy.kt @@ -30,4 +30,14 @@ internal object LetterHintPolicy { /** The hint of [letter] (lower case), or null for none. */ fun hint(letter: Char, english: Boolean): String? = DIGITS[letter] ?: (if (english) ENGLISH_SYMBOLS else CHINESE_SYMBOLS)[letter] + + /** + * What a long press on a letter key offers, in popup order: the letter in the + * other case, then the corner digit or symbol (Sogou's popup lists case, digit + * and mark). [shown] is the case the key currently prints. + */ + fun longPressChoices(letter: Char, english: Boolean, upperShown: Boolean, hintsOn: Boolean): List { + val other = if (upperShown) letter.toString() else letter.uppercaseChar().toString() + return listOfNotNull(other, if (hintsOn) hint(letter, english) else null) + } } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/NineKeyLongPressPolicy.kt b/app/src/main/java/llc/slacker/openime/keyboard/NineKeyLongPressPolicy.kt index 95417164..fd7fb7ef 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/NineKeyLongPressPolicy.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/NineKeyLongPressPolicy.kt @@ -5,15 +5,16 @@ import llc.slacker.openime.core.ImeData /** * What a long press on a nine-key digit key offers. * - * Row one is the digit and the lowercase letters on the key, row two the same - * letters in uppercase, so a letter of either case or the digit is one tap away - * without leaving the nine-key surface. Keys with no letters (1, 0, *, #) keep + * The popup sits above the key, so the last row is the one nearest the thumb: the + * digit and the lowercase letters, which people type most. The row above it holds + * the same letters in uppercase. Press, slide to a cell and lift to type it, or lift + * first and tap it. Keys with no letters (1, 0, *, #) keep * their own behavior and return no rows. */ internal object NineKeyLongPressPolicy { fun choiceRows(digit: String, keypad: Map> = ImeData.keypad9Map): List> { val letters = keypad[digit].orEmpty().filter { it.length == 1 && it[0] in 'a'..'z' } if (letters.isEmpty()) return emptyList() - return listOf(listOf(digit) + letters, letters.map { it.uppercase() }) + return listOf(letters.map { it.uppercase() }, listOf(digit) + letters) } } 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 f8f24a3d..dc2b6c3a 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -4,7 +4,6 @@ import android.content.Context import android.view.Gravity import android.view.View import android.widget.LinearLayout -import llc.slacker.openime.R import llc.slacker.openime.core.ShiftState import llc.slacker.openime.theme.ImeTypographyTokens import llc.slacker.openime.widget.ImeKeyView @@ -37,16 +36,23 @@ internal class Pinyin26KeyboardRenderer( private val hintsEnabled: () -> Boolean, private val swipeUpEnabled: () -> Boolean, private val onShift: () -> Unit, + private val onShiftLongPress: () -> Unit, private val onDigits: () -> Unit, private val onModeSwitch: () -> Unit, private val onSpace: () -> Unit, private val onEnter: () -> Unit, + private val onSymbols: () -> Unit = {}, + private val splitLayout: () -> Boolean = { false }, ) { fun render( english: Boolean, shiftState: ShiftState, enterLabel: String, ) { + if (splitLayout()) { + renderSplit(english, shiftState, enterLabel) + return + } ROWS.forEachIndexed { rowIndex, rowText -> val row = rowHost().apply { if (rowIndex == 1) tag = "key-row-secondary" @@ -103,6 +109,45 @@ internal class Pinyin26KeyboardRenderer( keyboardBody.addView(bottom, rowParams()) } + /** + * Landscape split keyboard, as Sogou draws it: each half is a complete thumb + * keyboard, G and V sit on both sides so either thumb reaches them, and both + * halves carry their own space bar. The gap between them stays empty. + */ + private fun renderSplit(english: Boolean, shiftState: ShiftState, enterLabel: String) { + fun row(build: LinearLayout.() -> Unit) { + keyboardBody.addView(rowHost().apply(build), rowParams()) + } + fun LinearLayout.letters(chars: String) { + chars.forEach { addView(letterKey(it, english, shiftState), flexKeyParams(1f)) } + } + fun LinearLayout.gap() = addView(View(context), flexKeyParams(SPLIT_GAP)) + fun fn(label: String, onTap: () -> Unit) = + createKey(label, true, null, ImeTypographyTokens.BODY_SP, 0, onTap) + + row { letters("qwert"); gap(); letters("yuiop") } + row { letters("asdfg"); gap(); letters("ghjkl") } + row { + addView(shiftKey(shiftState), flexKeyParams(1f)); letters("zxcv") + gap() + letters("vbnm"); addView(createBackspaceKey(), flexKeyParams(1f)) + } + row { + addView(fn("符") { onSymbols() }.apply { tag = "key-symbols" }, flexKeyParams(1f)) + addView(fn("123", onDigits), flexKeyParams(1f)) + addView(punctuationKey(english), flexKeyParams(1f)) + addView(createSpaceVoiceKey("空格", onSpace), flexKeyParams(2f)) + gap() + addView(createSpaceVoiceKey("空格", onSpace), flexKeyParams(2f)) + addView(fn("。") { onCommitCharacter(if (english) "." else "。") }, flexKeyParams(1f)) + addView(fn("中/英") { onModeSwitch() }.apply { tag = "key:mode" }, flexKeyParams(1f)) + addView( + createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, 0, onEnter).apply { tag = "key-enter" }, + flexKeyParams(1f), + ) + } + } + private fun punctuationKey(english: Boolean): ImeKeyView { val spec = PunctuationKeyPolicy.spec(english) // A character key, not a function key: only character keys carry corner hints. @@ -131,17 +176,17 @@ internal class Pinyin26KeyboardRenderer( } private fun shiftKey(shiftState: ShiftState): ImeKeyView { - val iconRes = if (shiftState == ShiftState.CAPS_LOCK) { - R.drawable.ic_caps_lock - } else { - R.drawable.ic_shift - } + val iconRes = EnglishShiftPolicy.icon(shiftState) return createKey("", true, null, null, iconRes, onShift).apply { tag = when (shiftState) { ShiftState.LOWERCASE -> "key-shift" ShiftState.SHIFT_ONCE -> "key-shift-active" ShiftState.CAPS_LOCK -> "key-shift-caps" } + setOnLongClickListener { + onShiftLongPress() + true + } } } @@ -167,13 +212,17 @@ internal class Pinyin26KeyboardRenderer( if (secondary != null) { setSecondaryVisible(true) setSecondaryAlpha(1f) - setOnLongClickListener { - onCommitCharacter(secondary) - true - } onSwipeUp = { onCommitCharacter(secondary) } swipeUpEnabled = this@Pinyin26KeyboardRenderer.swipeUpEnabled } + // Long press opens a popup (other case, digit or mark): slide to one and + // lift to type it, as the Sogou and iFlytek keyboards do. + setOnLongClickListener { + val upperShown = currentMainText.firstOrNull()?.isUpperCase() == true + val choices = LetterHintPolicy.longPressChoices(character, english, upperShown, hintsEnabled()) + onShowChoicePopup(this, choices) + true + } } } @@ -197,6 +246,7 @@ internal class Pinyin26KeyboardRenderer( ) private companion object { + const val SPLIT_GAP = 3.5f val ROWS = listOf("qwertyuiop", "asdfghjkl", "zxcvbnm") } } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt index 686f8fab..1bf104c8 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt @@ -46,6 +46,9 @@ internal class Pinyin9KeyboardRenderer( private val onModeSwitch: () -> Unit, private val onRetranslate: () -> Unit, private val onEnter: () -> Unit, + private val splitLayout: () -> Boolean = { false }, + private val mirrored: () -> Boolean = { false }, + private val onMirror: () -> Unit = {}, ) { fun render(enterLabel: String) { val container = LinearLayout(context).apply { @@ -72,8 +75,6 @@ internal class Pinyin9KeyboardRenderer( toPx(keyRowHeightDp()), ), ) - container.addView(left, adaptiveColumnParams(1f)) - val center = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } center.addView( buildNineGrid().apply { tag = "pinyin9-grid" }, @@ -110,8 +111,6 @@ internal class Pinyin9KeyboardRenderer( toPx(keyRowHeightDp()), ), ) - container.addView(center, adaptiveColumnParams(25f / 7f)) - val side = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL tag = "pinyin9-actions" @@ -144,7 +143,33 @@ internal class Pinyin9KeyboardRenderer( }, sideKeyParams(nineBodyHeightDp() / 4), ) - container.addView(side, adaptiveColumnParams(1f)) + if (splitLayout()) { + // Landscape split (Sogou's 左右分离): a thumb's worth of nine-key on one side, + // a panel of common words on the other, and a mirror key between them. + val keys = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + keys.addView(side, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) + keys.addView(center, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 25f / 7f)) + keys.addView(left, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) + val gap = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = android.view.Gravity.CENTER + addView( + createKey("镜像", true, null, ImeTypographyTokens.BODY_SP, onMirror).apply { + tag = "key-mirror" + contentDescription = "镜像,左右互换" + markSideKey(this) + }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(keyRowHeightDp())), + ) + } + val halves = if (mirrored()) listOf(wordsPanel() to 4.8f, gap to 1.2f, keys to 5.57f) + else listOf(keys to 5.57f, gap to 1.2f, wordsPanel() to 4.8f) + halves.forEach { (view, weight) -> container.addView(view, adaptiveColumnParams(weight)) } + } else { + container.addView(left, adaptiveColumnParams(1f)) + container.addView(center, adaptiveColumnParams(25f / 7f)) + container.addView(side, adaptiveColumnParams(1f)) + } keyboardBody.addView( container, @@ -155,6 +180,29 @@ internal class Pinyin9KeyboardRenderer( ) } + /** Four rows of five frequent words; a tap types the word. */ + private fun wordsPanel(): LinearLayout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + tag = "split-words" + WORDS.forEach { rowWords -> + addView( + LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + rowWords.forEach { word -> + addView( + createKey(word, false, null, ImeTypographyTokens.BODY_SP) { onCommitCharacter(word) }.apply { + tag = "key-word:$word" + markWhiteKey(this) + }, + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f), + ) + } + }, + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f), + ) + } + } + private fun buildNineGrid(): LinearLayout { val grid = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL } NINE_KEYS.forEachIndexed { rowIndex, rowDefinition -> @@ -225,6 +273,12 @@ internal class Pinyin9KeyboardRenderer( ) private companion object { + val WORDS = listOf( + listOf("的", "了", "我", "你", "是"), + listOf("吗", "好", "啊", "有", "吧"), + listOf("就", "在", "去", "没", "要"), + listOf("不", "都", "没有", "说", "这个"), + ) val NINE_KEYS = listOf( listOf("1" to "@#", "2" to "ABC", "3" to "DEF"), listOf("4" to "GHI", "5" to "JKL", "6" to "MNO"), diff --git a/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt b/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt new file mode 100644 index 00000000..75c12a4f --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt @@ -0,0 +1,29 @@ +package llc.slacker.openime.keyboard + +/** + * Pulls a verification code out of a text message: a 4-8 digit run, preferably + * the one next to a word such as 验证码. Messages without such a word give none, + * so a delivery notice with an order number is not offered. + */ +internal object SmsCodeExtractor { + private val KEYWORDS = Regex("验证码|校验码|动态码|确认码|安全码|验证代码|驗證碼|verification|verify|security code|one-time|\\botp\\b|passcode|动态密码|随机码|登录码|授权码|\\bcode\\b", RegexOption.IGNORE_CASE) + /** 8 digits that read as yyyyMMdd are a date, not a code. */ + private val DATE = Regex("(19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])") + private val DIGITS = Regex("(? keywords.minOf { gap(run.range, it) } }?.value + } + + private fun gap(a: IntRange, b: IntRange): Int = when { + a.last < b.first -> b.first - a.last + b.last < a.first -> a.first - b.last + else -> 0 + } +} diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt b/app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt new file mode 100644 index 00000000..878bed00 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt @@ -0,0 +1,70 @@ +package llc.slacker.openime.keyboard + +import android.Manifest +import android.content.ClipboardManager +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import llc.slacker.openime.data.ClipboardSensitivityPolicy +import llc.slacker.openime.data.ImeSettingsRepository + +/** What the toolbar offers to type: a fresh text-message code, the text just copied. */ +internal data class ToolbarSuggestion(val kind: Kind, val label: String, val text: String) { + enum class Kind { SMS_CODE, CLIPBOARD } +} + +internal object ToolbarSuggestions { + private const val SMS_WINDOW_MS = 5 * 60_000L + private const val CLIP_WINDOW_MS = 3 * 60_000L + private const val CLIP_MAX_CHARS = 200 + + /** Both reads are best-effort and silent: a refused permission or empty clipboard gives no chip. */ + fun collect(context: Context, allowed: Boolean, skipClip: String?, skipSms: String? = null): List { + if (!allowed) return emptyList() + val out = mutableListOf() + if (ImeSettingsRepository.loadSmsCodeChip(context)) smsCode(context)?.takeIf { it != skipSms }?.let { + out += ToolbarSuggestion(ToolbarSuggestion.Kind.SMS_CODE, "验证码 $it", it) + } + if (ImeSettingsRepository.loadClipboardChip(context)) freshClip(context)?.takeIf { it != skipClip }?.let { + out += ToolbarSuggestion(ToolbarSuggestion.Kind.CLIPBOARD, it.replace(Regex("\\s+"), " ").trim(), it) + } + return out + } + + private fun smsCode(context: Context): String? { + if (context.checkSelfPermission(Manifest.permission.READ_SMS) != PackageManager.PERMISSION_GRANTED) return null + return runCatching { + val since = System.currentTimeMillis() - SMS_WINDOW_MS + context.contentResolver.query( + Uri.parse("content://sms/inbox"), + arrayOf("body"), + "date > ?", + arrayOf(since.toString()), + "date DESC", + )?.use { cursor -> + var found: String? = null + var seen = 0 + while (found == null && seen < 5 && cursor.moveToNext()) { + found = SmsCodeExtractor.extract(cursor.getString(0).orEmpty()) + seen++ + } + found + } + }.getOrNull() + } + + private fun freshClip(context: Context): String? { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return null + val clip = runCatching { clipboard.primaryClip }.getOrNull() ?: return null + val sensitive = clip.description.extras?.let { extras -> + ClipboardSensitivityPolicy.isSensitive { key -> extras.getBoolean(key, false) } + } ?: false + if (sensitive) return null + val stamp = clip.description.timestamp + if (stamp <= 0L || System.currentTimeMillis() - stamp > CLIP_WINDOW_MS) return null + val text = runCatching { + clip.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString().orEmpty() + }.getOrDefault("") + return text.takeIf { it.isNotBlank() && it.length <= CLIP_MAX_CHARS } + } +} diff --git a/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt index 06d3c70b..ab999aaa 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt @@ -222,7 +222,6 @@ internal class ClipboardPanelController( tag = "clipboard-refresh" contentDescription = "重新读取剪贴板" setOnClickListener { - onFeedback() render(reusePanel = true) } }, 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 100d7156..068aaccd 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -452,7 +452,6 @@ internal class ImePanelRenderer( createPanelButton("管理自定义符号", ImeTypographyTokens.BODY_SP, true).apply { contentDescription = "管理自定义符号" setOnClickListener { - onFeedback() context.startActivity( Intent(context, SymbolManagerActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), diff --git a/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt b/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt index 887f0b41..db5c3e78 100644 --- a/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt +++ b/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt @@ -31,8 +31,8 @@ internal class PanelHeaderFactory( context = context, title = name, backContentDescription = "返回$backTarget", + onPress = onFeedback, ) { - onFeedback() onBack() }.apply { tag = "panel-head" @@ -63,10 +63,7 @@ internal class PanelHeaderFactory( } false } - setOnClickListener { - onFeedback() - onBack() - } + setOnClickListener { onBack() } }, LinearLayout.LayoutParams(toPx(ImeGeometryTokens.TOUCH_TARGET_DP), toPx(ImeGeometryTokens.TOUCH_TARGET_DP)), ) 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 8be5e219..def5f37e 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -20,6 +20,7 @@ import android.widget.SeekBar import android.widget.TextView import llc.slacker.openime.ImeSettingsActivity import llc.slacker.openime.R +import llc.slacker.openime.core.LandscapeLayout import llc.slacker.openime.data.HapticStyle import llc.slacker.openime.data.KeySoundStyle import llc.slacker.openime.theme.ImeAppearance @@ -58,6 +59,7 @@ internal class SettingsPanelController( private val currentHaptic: () -> Boolean, private val currentHapticStrengthPercent: () -> Int, private val currentHapticStyle: () -> HapticStyle, + private val currentLandscapeLayout: () -> LandscapeLayout, private val currentKeySoundStyle: () -> KeySoundStyle, private val currentPopup: () -> Boolean, private val currentSwipeUpDigits: () -> Boolean, @@ -73,6 +75,7 @@ internal class SettingsPanelController( private val onKeyboardHeightChanged: (Int) -> Unit, private val onHapticStrengthChanged: (Int) -> Unit, private val onHapticStyleChanged: (HapticStyle) -> Unit, + private val onLandscapeLayoutChanged: (LandscapeLayout) -> Unit, private val onKeySoundStyleChanged: (KeySoundStyle) -> Unit, private val onFloatingStyleChanged: (Int, Int) -> Unit, private val onShowFuzzySettings: () -> Unit, @@ -174,6 +177,8 @@ internal class SettingsPanelController( segmentedRow("外观", R.drawable.ic_pref_appearance), sliderRow("键盘高度", R.drawable.ic_pref_height, 80, 120, currentKeyboardHeightPercent(), onChange = onKeyboardHeightChanged), ) + content.addSection("横屏") + content.addCard(landscapeLayoutRow(R.drawable.ic_pref_width)) content.addSection("浮动键盘") content.addCard( sliderRow("浮动宽度", R.drawable.ic_pref_width, 72, 100, currentFloatingWidthPercent()) { @@ -194,6 +199,11 @@ internal class SettingsPanelController( toggleRow("数字和符号提示", "字母键右上角显示数字和符号", R.drawable.ic_pref_hints), toggleRow("上滑输入数字", "按键上滑输入右上角的数字或符号", R.drawable.ic_pref_swipe_up), ) + content.addSection("工具栏提示") + content.addCard( + toggleRow("复制内容提示", "刚复制的文字显示在工具栏,点一下输入", R.drawable.ic_pref_clipboard), + toggleRow("短信验证码", "最近 5 分钟的验证码显示在工具栏,需要短信权限", R.drawable.ic_pref_shield), + ) content.addSection("智能输入") content.addCard( navigationRow("模糊音与智能纠错", "z/zh、c/ch、s/sh 等规则", R.drawable.ic_pref_fuzzy, onShowFuzzySettings), @@ -232,6 +242,8 @@ internal class SettingsPanelController( soundStyleRow(0), hapticStyleRow(0), ) + content.addSection("横屏") + content.addCard(landscapeLayoutRow(0)) content.addSection("浮动键盘") content.addCard( sliderRow("浮动宽度", 0, 72, 100, currentFloatingWidthPercent(), shortLabel = "宽度") { @@ -244,6 +256,8 @@ internal class SettingsPanelController( content.addSection("输入") content.addCard( toggleRow("上滑输入数字", null, 0), + toggleRow("复制内容提示", null, 0), + toggleRow("短信验证码", null, 0), toggleRow("表情联想", null, 0), navigationRow("模糊音与智能纠错", null, 0, onShowFuzzySettings), ) @@ -551,6 +565,11 @@ internal class SettingsPanelController( onKeySoundStyleChanged(KeySoundStyle.entries.first { it.label == label }) } + private fun landscapeLayoutRow(iconRes: Int): View = + choiceRow("横屏布局", iconRes, LandscapeLayout.entries.map { it.label }, currentLandscapeLayout().label) { label -> + onLandscapeLayoutChanged(LandscapeLayout.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 }) diff --git a/app/src/main/java/llc/slacker/openime/panel/TextEditorPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/TextEditorPanelController.kt index 0cdd6ac4..363a1d86 100644 --- a/app/src/main/java/llc/slacker/openime/panel/TextEditorPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/TextEditorPanelController.kt @@ -61,7 +61,7 @@ internal class TextEditorPanelController( createPanelButton(label, if (center) 14f else 24f, false).apply { if (action != null) { contentDescription = when (action) { "up" -> "上"; "down" -> "下"; "left" -> "左"; else -> "右" } - setOnClickListener { onFeedback(); onTextEdit(action) } + setOnClickListener { onTextEdit(action) } } else { tag = if (center) "textedit-center" else "textedit-spacer" isClickable = false; isFocusable = false 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 0646f9b2..d7d2d99a 100644 --- a/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt +++ b/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt @@ -376,6 +376,8 @@ object SetupUi { context: Context, title: String, backContentDescription: String = "返回", + /** When set, the keyboard's own feedback plays on touch-down instead of the system haptic. */ + onPress: (() -> Unit)? = null, onBack: () -> Unit, ): LinearLayout = LinearLayout(context).apply { @@ -412,8 +414,16 @@ object SetupUi { } else { background = null } + if (onPress != null) { + isHapticFeedbackEnabled = false + isSoundEffectsEnabled = false + setOnTouchListener { _, event -> + if (event.actionMasked == android.view.MotionEvent.ACTION_DOWN) onPress() + false + } + } setOnClickListener { - performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + if (onPress == null) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) onBack() } }, diff --git a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt index 65cd1846..fca766a6 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -43,7 +43,7 @@ internal object ImeGeometryTokens { const val KEY_GAP_DP = 6 const val KEY_ROW_GAP_DP = KEY_GAP_DP const val KEY_SIDE_MARGIN_DP = 2 - const val KEY_POPUP_HEIGHT_DP = 76 + const val KEY_POPUP_HEIGHT_DP = 52 const val KEY_POPUP_MIN_WIDTH_DP = 40 const val KEY_POPUP_WIDTH_SCALE = 1.08f const val KEY_POPUP_VERTICAL_GAP_DP = 8 @@ -56,7 +56,14 @@ internal object ImeGeometryTokens { // Same as a settings icon tile, so step text lines up with the rows below. const val STEP_MARK_SIZE_DP = 32 const val HERO_MARK_SIZE_DP = 72 - const val COMPOSED_TOP_ZONE_HEIGHT_DP = 70 + const val COMPOSED_TOP_ZONE_HEIGHT_DP = 60 + // Landscape: one strip a little taller than a key row, pinyin and candidates side by side. + // Space under the bottom row (the keys sit 7dp higher than they used to). + const val KEYBOARD_TOP_PAD_DP = 4 + const val KEYBOARD_BOTTOM_PAD_DP = 19 + // Landscape keys are short, so the gap under them shrinks (HeliBoard and fcitx5 use none). + const val LANDSCAPE_BOTTOM_PAD_DP = 6 + const val LANDSCAPE_TOP_ZONE_HEIGHT_DP = 48 } /** Shared spacing scale for every non-keyboard layout. */ diff --git a/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt b/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt index b9450cf6..d20aa18c 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt @@ -80,7 +80,7 @@ internal class VoicePanelView( addView(hero, LayoutParams(toPx(88), toPx(88)).apply { bottomMargin = toPx(14) }) languageButton = createButton(LANGUAGES[0].first, ImeTypographyTokens.BODY_SP, true).apply { tag = "voice-language" - setOnClickListener { onFeedback(); languageIndex = 0; onLanguageChanged(languageIndex); updateLanguagePresentation(false) } + setOnClickListener { languageIndex = 0; onLanguageChanged(languageIndex); updateLanguagePresentation(false) } } val languageTrack = LinearLayout(context).apply { orientation = HORIZONTAL; tag = "segmented-track"; setPadding(toPx(2), toPx(2), toPx(2), toPx(2)) diff --git a/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt b/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt index 4bf5b1e1..9eb9c715 100644 --- a/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt +++ b/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt @@ -41,12 +41,16 @@ class ImeKeyView( var swipeUpEnabled: () -> Boolean = { true } private val swipeUp = SwipeUpDetector(thresholdPx = toPx(SWIPE_UP_THRESHOLD_DP).toFloat()) private var swipeConsumed = false + // A long press already chose this gesture: a later upward move slides over the + // popup (or nothing) and must not also fire the swipe-up digit. + private var longPressed = false private var cancellingForSwipe = false /** True when the swipe logic consumed [event]; the normal touch path then must not see it. */ private fun interceptSwipeUp(event: MotionEvent): Boolean { val action = onSwipeUp ?: return false if (cancellingForSwipe) return false + if (longPressed) return false when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { swipeConsumed = false @@ -114,9 +118,11 @@ class ImeKeyView( override fun dispatchTouchEvent(event: MotionEvent): Boolean { if (touchLocked) return true + if (event.actionMasked == MotionEvent.ACTION_DOWN) longPressed = false if (interceptSwipeUp(event)) return true when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { + longPressed = false touchGeneration++ touchFeedbackPending = isEnabled animate().cancel() @@ -150,6 +156,12 @@ class ImeKeyView( return handled } + override fun performLongClick(): Boolean { + val handled = super.performLongClick() + if (handled) longPressed = true + return handled + } + internal fun consumeTouchFeedback(): Boolean { val pending = touchFeedbackPending touchFeedbackPending = false diff --git a/app/src/main/res/drawable/ic_keyboard_split.xml b/app/src/main/res/drawable/ic_keyboard_split.xml new file mode 100644 index 00000000..38e49c15 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_split.xml @@ -0,0 +1 @@ + diff --git a/app/src/main/res/drawable/ic_shift_on.xml b/app/src/main/res/drawable/ic_shift_on.xml new file mode 100644 index 00000000..dbf13ed0 --- /dev/null +++ b/app/src/main/res/drawable/ic_shift_on.xml @@ -0,0 +1 @@ + diff --git a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt index 37fd2cfb..11b118d3 100644 --- a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt +++ b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt @@ -174,6 +174,26 @@ class CandidatePipelineTest { assertEquals(readings.map { it.display }.distinct(), readings.map { it.display }) } + @Test + fun aSyllableThatAlsoSpellsAWordStaysInTheRail() { + // xian is also the key of 西安, liu of 浏览, pin of 拼音: they are still syllables. + mapOf("9426" to "xian", "548" to "liu", "746" to "pin").forEach { (digits, syllable) -> + val shown = pipeline.nineKeyReadingsFor(digits, null).map { it.display } + assertTrue("$syllable missing for $digits: $shown", syllable in shown) + } + } + + @Test + fun aSyllableThatSpellsAllTheDigitsOutranksAShorterOne() { + assertEquals("xiang", pipeline.nineKeyReadingsFor("94264", null).first().display) + } + + @Test + fun theRailOffersNoInterjectionsOrDuplicateSpellings() { + val shown = listOf("64", "64426", "583", "683").flatMap { pipeline.nineKeyReadingsFor(it, null) }.map { it.display } + assertTrue(shown.none { it in setOf("ng", "hm", "lue", "nue") }) + } + @Test fun theSyllableOfTheShownPreviewLeads() { assertEquals("mi", pipeline.nineKeyReadingsFor("64426", "migao").first().display) diff --git a/app/src/test/java/llc/slacker/openime/EnglishShiftPolicyTest.kt b/app/src/test/java/llc/slacker/openime/EnglishShiftPolicyTest.kt index dd3a7120..903215da 100644 --- a/app/src/test/java/llc/slacker/openime/EnglishShiftPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/EnglishShiftPolicyTest.kt @@ -25,4 +25,28 @@ class EnglishShiftPolicyTest { fun passwordFieldsAlwaysStartLowercase() { assertEquals(ShiftState.LOWERCASE, EnglishShiftPolicy.initial(text or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS, true)) } + + @Test + fun aTapGivesOneCapitalAndAQuickSecondTapLocksCaps() { + assertEquals(ShiftState.SHIFT_ONCE, EnglishShiftPolicy.afterTap(ShiftState.LOWERCASE, null)) + assertEquals(ShiftState.CAPS_LOCK, EnglishShiftPolicy.afterTap(ShiftState.SHIFT_ONCE, 150)) + assertEquals( + ShiftState.CAPS_LOCK, + EnglishShiftPolicy.afterTap(ShiftState.SHIFT_ONCE, EnglishShiftPolicy.DOUBLE_TAP_MS), + ) + } + + @Test + fun aSlowSecondTapOrATapOnCapsLockTurnsShiftOff() { + assertEquals(ShiftState.LOWERCASE, EnglishShiftPolicy.afterTap(ShiftState.SHIFT_ONCE, 900)) + assertEquals(ShiftState.LOWERCASE, EnglishShiftPolicy.afterTap(ShiftState.SHIFT_ONCE, null)) + assertEquals(ShiftState.LOWERCASE, EnglishShiftPolicy.afterTap(ShiftState.CAPS_LOCK, 100)) + } + + @Test + fun aLongPressLocksCapsFromAnyStateAndReleasesTheLock() { + assertEquals(ShiftState.CAPS_LOCK, EnglishShiftPolicy.afterLongPress(ShiftState.LOWERCASE)) + assertEquals(ShiftState.CAPS_LOCK, EnglishShiftPolicy.afterLongPress(ShiftState.SHIFT_ONCE)) + assertEquals(ShiftState.LOWERCASE, EnglishShiftPolicy.afterLongPress(ShiftState.CAPS_LOCK)) + } } diff --git a/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt b/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt index f0408615..f9f5c880 100644 --- a/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/EnterActionPolicyTest.kt @@ -2,6 +2,7 @@ package llc.slacker.openime import android.view.inputmethod.EditorInfo import llc.slacker.openime.editor.editorActionForEnter +import llc.slacker.openime.editor.enterKeyFaceFor import llc.slacker.openime.editor.enterKeyPresentationFor import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -56,4 +57,16 @@ class EnterActionPolicyTest { assertNull(presentation.editorAction) } } + + @Test + fun theKeyFaceIsComposingOrActionOrReturnArrow() { + val send = EditorInfo.IME_ACTION_SEND + assertEquals("确定", enterKeyFaceFor(send, composing = true)) + assertEquals("发送", enterKeyFaceFor(send, composing = false)) + assertEquals("搜索", enterKeyFaceFor(EditorInfo.IME_ACTION_SEARCH, composing = false)) + // No action, or a multi-line editor: a plain newline key, never a promise. + assertEquals("↵", enterKeyFaceFor(EditorInfo.IME_ACTION_NONE, composing = false)) + assertEquals("↵", enterKeyFaceFor(EditorInfo.IME_ACTION_SEND or EditorInfo.IME_FLAG_NO_ENTER_ACTION, composing = false)) + assertEquals("↵", enterKeyFaceFor(null, composing = false)) + } } diff --git a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt index 9c580099..75c46ae3 100644 --- a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt +++ b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt @@ -17,7 +17,8 @@ class KeyboardLayoutMetricsTest { val metrics = KeyboardLayoutMetrics(landscape = false, fontScale = 1f) assertEquals(ImeGeometryTokens.KEY_ROW_HEIGHT_DP, metrics.keyRowHeightDp) assertEquals( - ImeGeometryTokens.COMPOSED_TOP_ZONE_HEIGHT_DP + ImeGeometryTokens.KEY_ROW_HEIGHT_DP * 4 + 16, + ImeGeometryTokens.COMPOSED_TOP_ZONE_HEIGHT_DP + ImeGeometryTokens.KEY_ROW_HEIGHT_DP * 4 + + ImeGeometryTokens.KEYBOARD_TOP_PAD_DP + ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP, metrics.imeHeightDp, ) assertEquals( @@ -32,7 +33,12 @@ class KeyboardLayoutMetricsTest { fun landscapeUsesCompactBaseHeight() { val metrics = KeyboardLayoutMetrics(landscape = true, fontScale = 1f) assertEquals(ImeGeometryTokens.LANDSCAPE_KEY_ROW_HEIGHT_DP, metrics.keyRowHeightDp) - assertTrue(metrics.imeHeightDp >= 256) + // The top strip is only a little taller than a key row. + assertEquals(ImeGeometryTokens.LANDSCAPE_TOP_ZONE_HEIGHT_DP, metrics.topZoneHeightDp) + assertTrue(metrics.topZoneHeightDp - metrics.keyRowHeightDp <= 10) + assertEquals(metrics.topZoneHeightDp + metrics.keyRowHeightDp * 4 + + ImeGeometryTokens.KEYBOARD_TOP_PAD_DP + ImeGeometryTokens.LANDSCAPE_BOTTOM_PAD_DP, + metrics.imeHeightDp) } @Test diff --git a/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt b/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt index eec8f259..bff9484b 100644 --- a/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/LetterHintPolicyTest.kt @@ -68,4 +68,11 @@ class LetterHintPolicyTest { assertTrue(LetterHintPolicy.hint('Q', false) == null) assertTrue(LetterHintPolicy.hint('1', false) == null) } + + @Test + fun longPressOffersTheOtherCaseThenTheHint() { + assertEquals(listOf("A", "~"), LetterHintPolicy.longPressChoices('a', english = true, upperShown = false, hintsOn = true)) + assertEquals(listOf("q", "1"), LetterHintPolicy.longPressChoices('q', english = false, upperShown = true, hintsOn = true)) + assertEquals(listOf("S"), LetterHintPolicy.longPressChoices('s', english = true, upperShown = false, hintsOn = false)) + } } diff --git a/app/src/test/java/llc/slacker/openime/NineKeyLongPressPolicyTest.kt b/app/src/test/java/llc/slacker/openime/NineKeyLongPressPolicyTest.kt index d2c1091a..1e072a82 100644 --- a/app/src/test/java/llc/slacker/openime/NineKeyLongPressPolicyTest.kt +++ b/app/src/test/java/llc/slacker/openime/NineKeyLongPressPolicyTest.kt @@ -7,13 +7,13 @@ import org.junit.Test class NineKeyLongPressPolicyTest { @Test - fun offersTheDigitAndBothCasesOfTheLetters() { + fun offersTheDigitAndBothCasesWithLowercaseNearestTheThumb() { assertEquals( - listOf(listOf("2", "a", "b", "c"), listOf("A", "B", "C")), + listOf(listOf("A", "B", "C"), listOf("2", "a", "b", "c")), NineKeyLongPressPolicy.choiceRows("2"), ) assertEquals( - listOf(listOf("9", "w", "x", "y", "z"), listOf("W", "X", "Y", "Z")), + listOf(listOf("W", "X", "Y", "Z"), listOf("9", "w", "x", "y", "z")), NineKeyLongPressPolicy.choiceRows("9"), ) } diff --git a/app/src/test/java/llc/slacker/openime/SmsCodeExtractorTest.kt b/app/src/test/java/llc/slacker/openime/SmsCodeExtractorTest.kt new file mode 100644 index 00000000..93c2aad6 --- /dev/null +++ b/app/src/test/java/llc/slacker/openime/SmsCodeExtractorTest.kt @@ -0,0 +1,39 @@ +package llc.slacker.openime + +import llc.slacker.openime.keyboard.SmsCodeExtractor +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SmsCodeExtractorTest { + @Test + fun findsTheCodeNextToTheKeyword() { + assertEquals("482913", SmsCodeExtractor.extract("【某某】您的验证码是482913,5分钟内有效。")) + assertEquals("1234", SmsCodeExtractor.extract("验证码:1234,请勿泄露")) + assertEquals("667788", SmsCodeExtractor.extract("Your verification code is 667788.")) + } + + @Test + fun theCodeNearestTheKeywordWins() { + assertEquals("8802", SmsCodeExtractor.extract("订单 20261007123 已发货,验证码 8802")) + } + + @Test + fun aCodeBeforeTheKeywordBeatsALaterNumber() { + assertEquals("1234", SmsCodeExtractor.extract("1234是验证码,订单号 56789012")) + } + + @Test + fun datesAndPlainWordsAreNotCodes() { + assertNull(SmsCodeExtractor.extract("验证码有效期至20261009")) + assertEquals("123456", SmsCodeExtractor.extract("动态密码为123456")) + assertNull(SmsCodeExtractor.extract("hotpot 12345 ready")) + } + + @Test + fun messagesWithoutAKeywordOrCodeGiveNothing() { + assertNull(SmsCodeExtractor.extract("您的快递 123456 已到达,请取件")) + assertNull(SmsCodeExtractor.extract("验证码已发送,请查收")) + assertNull(SmsCodeExtractor.extract("验证码 123")) + } +} diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 46a7b259..d1d8edb1 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -62,7 +62,7 @@ Verification: `HardwareKeyPolicyTest`, `core_regression.sh` cases 040 to 043. openIME supports these environments: -- landscape (the keyboard is a bottom panel and never uses fullscreen extract mode) +- landscape (never uses fullscreen extract mode; the default is a floating keyboard, with full width and split halves as options) - font scale 130% and 200% (key labels grow to at most 1.3 times, and function key labels shrink to fit) - dark theme - small and narrow screens diff --git a/docs/NINE_KEY_REFERENCE.md b/docs/NINE_KEY_REFERENCE.md index dbb17a7a..5afa0ca8 100644 --- a/docs/NINE_KEY_REFERENCE.md +++ b/docs/NINE_KEY_REFERENCE.md @@ -48,8 +48,8 @@ Other keyboards are closed source, so we used only their public help pages. If no word helps, openIME builds the preedit locally from the fewest full syllables, with an unfinished syllable at the end (`46` → `go`). 7. **If a selected word covers only part of the input, openIME commits only that word.** The remaining input stays as preedit. This is the same for nine-key and 26-key. - openIME does no native learning for a partial selection, - because a whole-sentence commit would write phrases that the user never selected into the user dictionary. + openIME teaches the native engine only the word that the user tapped, by selecting it again with its own spelling. + It does not commit the whole input, because that would write phrases that the user never selected into the user dictionary. 8. **Space commits the first candidate.** Enter (Confirm) commits the typed pinyin text. Rime, fcitx and Gboard pinyin work this way. diff --git a/docs/TEST_SOP.md b/docs/TEST_SOP.md index baa66a17..6608aae4 100644 --- a/docs/TEST_SOP.md +++ b/docs/TEST_SOP.md @@ -234,7 +234,7 @@ Voice works in password fields, but it commits only the final text once. It shows no partial text, does no word or correction learning, and keeps no unnecessary PCM or temporary text. The key bubble must anchor to the current key. -It is about 1 to 1.2 times the key width and 70 to 80 dp high, and it moves inward at the edges. +It is about 1 to 1.2 times the key width and about 52 dp high, and it moves inward at the edges. The vibration fires once at the moment of the press, is light by default and respects the system haptic switch. Fast typing must not create a heavy continuous vibration. diff --git a/docs/TEST_SOP_CHECKLIST.md b/docs/TEST_SOP_CHECKLIST.md index 74d770c7..2d90d434 100644 --- a/docs/TEST_SOP_CHECKLIST.md +++ b/docs/TEST_SOP_CHECKLIST.md @@ -67,7 +67,7 @@ Write `NOT TESTED` for an item that you did not run. Never check an item in adva - [ ] Tools and all settings pages scroll, have no dead button, return correctly and show no old theme or corner-radius option. - [ ] The floating keyboard can move to the five positions and the edges, re-constrains after rotation and can dock again. - [ ] On Chinese and English 26-key, each letter key shows a number or symbol at the top right. - A swipe up and a long press enter the hint. A tap still enters the letter. + A swipe up enters the hint. A long press opens a popup with the other case and the hint: slide to a cell and release to enter it. A tap still enters the letter. Chinese mode enters full-width forms (! ¥ ? ( ) : ;). English mode enters half-width forms. Landscape also works. When you turn off 数字和符号提示 ("Number and symbol hints"), the hints and the gestures disappear together, and the layout does not change. diff --git a/scripts/beta3_e2e.py b/scripts/beta3_e2e.py index 8ba29042..a916c9bb 100644 --- a/scripts/beta3_e2e.py +++ b/scripts/beta3_e2e.py @@ -330,7 +330,7 @@ def case_letter_hints(dev: Device, landscape: bool) -> list[str]: problems.append(f"{mode}: {shown} hints shown while the setting is off") if not enabled: continue - # Real gestures: swipe up and long press type the hint, a tap types the letter. + # Real gestures: swipe up types the hint, a long press only opens the popup, a tap types the letter. for letter in ("q", "s", "g", "z"): key = key_node(nodes, letter) if not key: @@ -342,11 +342,13 @@ def case_letter_hints(dev: Device, landscape: bool) -> list[str]: typed = field_text(dev) if typed != before + hint: problems.append(f"{mode}: swipe up on {letter} typed {typed[len(before):]!r}, expected {hint!r}") + # A long press now opens the choice popup (other case + hint); lifting on the key + # types nothing, and the popup closes by itself. dev.shell("input", "swipe", str(key.cx), str(key.cy), str(key.cx), str(key.cy), "900") - time.sleep(1) + time.sleep(1.8) held = field_text(dev) - if held != typed + hint: - problems.append(f"{mode}: long press on {letter} typed {held[len(typed):]!r}, expected {hint!r}") + if held != typed: + problems.append(f"{mode}: long press on {letter} typed {held[len(typed):]!r}, expected nothing") dev.shot(f"{mode}-hints-typed{'-landscape' if landscape else ''}") if len(space_tops) == 2 and abs(space_tops[True] - space_tops[False]) > 2: problems.append(f"the hints changed the keyboard layout (space at {space_tops[True]} vs {space_tops[False]})") diff --git a/scripts/core_regression.sh b/scripts/core_regression.sh index 41916cb3..adbe7942 100755 --- a/scripts/core_regression.sh +++ b/scripts/core_regression.sh @@ -189,7 +189,9 @@ start_real mode PINYIN_26 || say 'WARN: PINYIN_26 not reached' for key in n i h a o; do tap "$key"; done tap key-backspace -check '022 composition backspace -> niha' 'niha' "$(editor_text)" +# Since 0.0.6 the pinyin pre-edit lives in the keyboard's own field, so the app editor stays empty +# (uiautomator reports its hint) while composing and after a partial pick only the picked word is committed. +check '022 composition backspace keeps the app editor empty' '输入拼音,或按住空格说话' "$(editor_text)" start_real mode PINYIN_26 || say 'WARN: PINYIN_26 not reached' @@ -225,7 +227,7 @@ start_real mode PINYIN_9 || say 'WARN: PINYIN_9 not reached' for key in 6 4 4 2 6 6 2; do tap "$key"; done tap '候选:你' -check "033 nine-key partial pick keeps the rest composing -> 你hao'ma" "你hao'ma" "$(editor_text)" +check "033 nine-key partial pick commits only 你; hao'ma stays in the pre-edit" '你' "$(editor_text)" tap key-space check '034 nine-key rest then space -> 你好吗' '你好吗' "$(editor_text)" @@ -241,7 +243,7 @@ start_real mode PINYIN_26 || say 'WARN: PINYIN_26 not reached' for key in n i h a o m a; do tap "$key"; done tap '候选:你' -check '035 26-key partial pick keeps the rest composing -> 你haoma' '你haoma' "$(editor_text)" +check '035 26-key partial pick commits only 你; haoma stays in the pre-edit' '你' "$(editor_text)" tap key-space check '036 26-key rest then space -> 你好吗' '你好吗' "$(editor_text)"