diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1663c6b..3409dc75 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,20 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs
## [Unreleased]
+- 英文键盘的大写:单击 Shift 输入一个大写;快速连点两下锁定大写;长按 Shift 直接锁定(再长按解除)。Shift 键三个状态图标不同:空心 = 小写,实心 = 下一个大写,实心带横线 = 锁定。
+- 键盘上方的工具栏降低 10dp(70 → 60),拼音行和候选行压矮;底部空白从 10dp 增到 17dp,键盘总高度净减 3dp。九键、26 键、数字、笔画键盘都一样。
+- 26 键字母键长按也弹出选择面板(另一种大小写 + 右上角的数字或符号),按住滑到目标、松手输入,和搜狗、讯飞一致;上滑仍直接输入右上角的数字或符号。
+- 回车键统一:输入拼音时是“确定”;编辑框要求了动作(发送、搜索、前往、下一项、完成)时显示动作文字并高亮;其余(换行、多行、无动作)一律是不高亮的“↵”。九键、26 键、笔画、数字键盘都按同一规则。
+- 按键气泡和长按选择面板同高(52dp);长按选择面板缩小(格子 40×44dp),松手后没选就自动消失(松在原键上 1.5 秒后,没松手 4 秒后)。
+- 候选字背景上下各缩 0.5dp,工具栏总高不变;键盘整体再上抬 2dp。
+- 工具栏提示:刚复制的文字(3 分钟内)显示在工具栏,点一下输入;设置里新增“短信验证码”(默认关闭):打开后请求“读取短信”权限,5 分钟内的验证码短信显示成“验证码 123456”,点一下输入。两项都只在本机读取,不联网;密码框不显示。可在“设置 → 工具栏提示”关闭。
+- 按住 Shift 滑到字母再松手,输入那一个大写字母,Shift 状态保持原样(和 Gboard 一样);松手在别处则什么都不输入。
+- 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。
+- 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右分离。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。左右分离参照搜狗:26 键分成左右两半,G、V 两边都有,两边各一个空格,中间留空;九键一边是九宫格(删除、重输、0、确定在左,符号列在右),另一边是常用字面板(的了我你是……),中间的“镜像”键左右互换。横屏工具栏多一个“左右分离键盘”按钮,点一下分离或恢复。
+- 横屏整宽键盘铺满屏宽(最宽 900dp),之前被缩成中间一小块。
+- 横屏顶栏压缩到只比按键行高一点(48dp),输入时拼音在左、候选在右同一行。
+- 浮动键盘:整张卡片圆角并带细边,底部为系统“返回/切换输入法”按钮留出一条空位,不再盖住最下面一行。
+
## [0.0.7-beta.1] - 2026-10-06
第七个测试版:九键预编辑不再出现数字,拼音栏点哪个都管用,符号、表情、颜文字大幅补全。从 0.0.6-beta.1 覆盖安装即可,用户数据保留。
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7198eae8..b820097b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
+
+
+
+
, 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/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/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..cbeea1ed 100644
--- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt
+++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt
@@ -34,6 +34,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 +42,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 +135,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 +149,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 +304,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 +333,56 @@ open class ImeKeyboardView(
}
/** Refresh data owned by auxiliary editor Activities when they return. */
+ private var dismissedSuggestion: 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 = !passwordField, skipClip = dismissedSuggestion)
+ 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)
+ dismissedSuggestion = suggestion.text
+ ownSuggestionsShown = false
+ topZone.setAutofillChips(emptyList())
+ }
+ }
+ }
+ topZone.onAutofillDismissed = { dismissedSuggestion = found.lastOrNull { it.kind == ToolbarSuggestion.Kind.CLIPBOARD }?.text ?: dismissedSuggestion }
+ 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 +474,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 +522,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 +735,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 +753,7 @@ open class ImeKeyboardView(
onKeyboardHeightChanged = ::setKeyboardHeightPercent,
onHapticStrengthChanged = ::setHapticStrengthPercent,
onHapticStyleChanged = ::setHapticStyle,
+ onLandscapeLayoutChanged = ::setLandscapeLayout,
onKeySoundStyleChanged = ::setKeySoundStyle,
onFloatingStyleChanged = ::setFloatingStyle,
onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) },
@@ -783,10 +844,13 @@ 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,
)
}
@@ -827,7 +891,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(ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP))
expandedPanel.orientation = LinearLayout.VERTICAL
expandedPanel.tag = "panel-overlay"
expandedPanel.visibility = View.GONE
@@ -883,7 +947,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 +958,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)
@@ -1011,6 +1085,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 +1124,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 +1156,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(ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP))
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 +1178,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(ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP),
)
keyboardBody.findViewWithTag("key-row-secondary")?.let { row ->
val rowWidth = ((measuredWidthPx - contentInsetPx * 2) * 0.9f).toInt()
@@ -1202,6 +1285,7 @@ open class ImeKeyboardView(
listener.onCandidateExpanded(open)
},
onUndo = { listener.onTextEdit("undo") },
+ onSplitToggle = ::toggleSplit,
onAssociationDismiss = ::clearAssociationCandidates,
)
candidateBarController = CandidateBarController(
@@ -1364,9 +1448,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 +1462,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(ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP))
expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0)
candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0)
topZone.setContentInset(contentInsetPx)
@@ -1385,7 +1470,7 @@ open class ImeKeyboardView(
updateTopZone(composition.text?.isNotEmpty() == true)
if (width > 0) updateResponsiveGeometry(width)
}
- floatingKeyboardController.applyTheme(currentThemeTokens())
+ applyTheme()
requestLayout()
}
@@ -1522,10 +1607,10 @@ open class ImeKeyboardView(
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
@@ -1625,6 +1710,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 +2003,9 @@ open class ImeKeyboardView(
}
keyboardBody.setPadding(
contentInsetPx,
- dp(6),
+ dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP),
contentInsetPx,
- dp(10),
+ dp(ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP),
)
val state = when {
inlineVoicePresenter.active -> ImeTopZoneState.VOICE_INLINE
@@ -1972,7 +2092,7 @@ open class ImeKeyboardView(
pinyin26Renderer.render(
english = false,
shiftState = shiftState,
- enterLabel = enterKeyLabel(false),
+ enterLabel = enterKeyLabel(),
)
}
@@ -1980,7 +2100,7 @@ open class ImeKeyboardView(
pinyin26Renderer.render(
english = true,
shiftState = shiftState,
- enterLabel = enterKeyLabel(true),
+ enterLabel = enterKeyLabel(),
)
}
@@ -1989,15 +2109,15 @@ open class ImeKeyboardView(
* 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 {
+ /** One rule for every keyboard: 确定 composing, the action word for a real action, otherwise ↵. */
+ private fun enterKeyLabel(): String {
val imeOptions = (context as? android.inputmethodservice.InputMethodService)
?.currentInputEditorInfo?.imeOptions
- if (imeOptions != null) return enterKeyPresentationFor(imeOptions).label
- return fallback ?: if (english) "Go" else "确定"
+ return enterKeyFaceFor(imeOptions, 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 +2142,7 @@ open class ImeKeyboardView(
start()
}
}
- strokeRenderer.render(enterLabel = if (composition.text?.isNotEmpty() == true) "确定" else "↵")
+ strokeRenderer.render(enterLabel = enterKeyLabel())
}
/**
@@ -2045,7 +2165,7 @@ open class ImeKeyboardView(
?.currentInputEditorInfo
numericKeyboardRenderer.render(
editorKind = EditorInfoAdapter.kind(info),
- enterLabel = "↵",
+ enterLabel = enterKeyLabel(),
)
}
@@ -2067,6 +2187,14 @@ open class ImeKeyboardView(
keyPopupController.hideIfOutside(event.x, event.y)
}
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 +2415,8 @@ open class ImeKeyboardView(
"模糊音纠错", "启用模糊音" -> fuzzyEnabled
"按键气泡" -> popupEnabled
"上滑输入数字" -> ImeSettingsRepository.loadSwipeUpDigits(context)
+ "复制内容提示" -> ImeSettingsRepository.loadClipboardChip(context)
+ "短信验证码" -> ImeSettingsRepository.loadSmsCodeChip(context)
"数字和符号提示" -> ImeSettingsRepository.loadLetterHints(context)
"表情联想" -> ImeSettingsRepository.loadEmojiAssociation(context)
"语音去语气词" -> ImeSettingsRepository.loadVoiceStripFillers(context)
@@ -2314,6 +2444,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 +2512,18 @@ 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) {
+ val letter = text.singleOrNull()
+ if (mode == KeyboardMode.ENGLISH_26 && letter != null && letter.isLetter() && !insertIntoInlineEditor(text)) {
+ clearAssociationCandidates()
+ val (py, selection) = replaceCompositionSelection(text)
+ publishComposition(py, candidatesForComposition(py), selection)
+ return
+ }
+ if (!insertIntoInlineEditor(text)) listener.onCharacter(text)
+ }
+
private fun onKeyTapped(base: String) {
if (insertIntoInlineEditor(base)) return
clearAssociationCandidates()
@@ -2885,12 +3044,89 @@ 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
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (!shiftHeld || shiftSliding) return
+ val shift = shiftKeyView() ?: return
+ if (!rawContains(shift, event.rawX, event.rawY)) {
+ 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
+ val letter = "qwertyuiopasdfghjklzxcvbnm".firstOrNull { ch ->
+ findViewWithTag("key:$ch")?.let { rawContains(it, event.rawX, event.rawY) } == true
+ }
+ 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 +3135,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 +3151,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 -> "大写一次"
@@ -3085,7 +3323,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..53d9b776 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()
@@ -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 = if (on) Gravity.CENTER_VERTICAL else Gravity.NO_GRAVITY
+ (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..952d5956 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,67 @@ 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)
+ }
+
+ /**
+ * 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
+ if (cell != null) hoverFeedback()
+ }
+ return false
+ }
+ if (hit != null) {
+ hide()
+ feedback()
+ 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 +339,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 +359,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..53ad30a8 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,16 @@ 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 imeHeightDp: Int = run {
val derived = topZoneHeightDp +
- keyRowHeightDp * 4 + 16
- val baseMinimum = if (landscape) 256 else 0
+ keyRowHeightDp * 4 + ImeGeometryTokens.KEYBOARD_TOP_PAD_DP + ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP
+ 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..df0f5498
--- /dev/null
+++ b/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt
@@ -0,0 +1,19 @@
+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|otp|passcode|\\bcode\\b", RegexOption.IGNORE_CASE)
+ private val DIGITS = Regex("(? {
+ if (!allowed) return emptyList()
+ val out = mutableListOf()
+ if (ImeSettingsRepository.loadSmsCodeChip(context)) smsCode(context)?.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/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/theme/ImeDesignTokens.kt b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt
index 65cd1846..220f24a7 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,12 @@ 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
+ 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/widget/ImeKeyView.kt b/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt
index 4bf5b1e1..75b8b622 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
@@ -117,6 +121,7 @@ class ImeKeyView(
if (interceptSwipeUp(event)) return true
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
+ longPressed = false
touchGeneration++
touchFeedbackPending = isEnabled
animate().cancel()
@@ -150,6 +155,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/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..caa86703 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.KEYBOARD_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..cfb9160e
--- /dev/null
+++ b/app/src/test/java/llc/slacker/openime/SmsCodeExtractorTest.kt
@@ -0,0 +1,27 @@
+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 messagesWithoutAKeywordOrCodeGiveNothing() {
+ assertNull(SmsCodeExtractor.extract("您的快递 123456 已到达,请取件"))
+ assertNull(SmsCodeExtractor.extract("验证码已发送,请查收"))
+ assertNull(SmsCodeExtractor.extract("验证码 123"))
+ }
+}
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)"