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)"