From 8d058f254be08d722bda72a103f107691ecf6f0d Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:57:59 +0800 Subject: [PATCH 01/10] feat: shift long press, slide-to-select popups, landscape layout setting Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 7 ++ .../slacker/openime/LocalVoiceImeService.kt | 63 +++++++++++- .../slacker/openime/core/LandscapeLayout.kt | 18 ++++ .../openime/data/ImeSettingsRepository.kt | 12 +++ .../floating/FloatingKeyboardController.kt | 17 +--- .../floating/SideDockWindowController.kt | 82 ++++++++++++++++ .../openime/keyboard/EnglishShiftPolicy.kt | 27 ++++++ .../openime/keyboard/ImeKeyboardView.kt | 97 ++++++++++++++++--- .../slacker/openime/keyboard/ImeTopZone.kt | 44 +++++++++ .../openime/keyboard/KeyPopupController.kt | 68 ++++++++++++- .../openime/keyboard/KeyboardGeometry.kt | 8 +- .../keyboard/NineKeyLongPressPolicy.kt | 9 +- .../keyboard/Pinyin26KeyboardRenderer.kt | 12 +-- .../openime/panel/SettingsPanelController.kt | 12 +++ .../slacker/openime/theme/ImeDesignTokens.kt | 2 + .../llc/slacker/openime/widget/ImeKeyView.kt | 11 +++ app/src/main/res/drawable/ic_shift_on.xml | 1 + .../slacker/openime/EnglishShiftPolicyTest.kt | 24 +++++ .../openime/KeyboardLayoutMetricsTest.kt | 5 +- .../openime/NineKeyLongPressPolicyTest.kt | 6 +- 20 files changed, 478 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/llc/slacker/openime/core/LandscapeLayout.kt create mode 100644 app/src/main/java/llc/slacker/openime/floating/SideDockWindowController.kt create mode 100644 app/src/main/res/drawable/ic_shift_on.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index d1663c6b..73131d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +- 英文键盘的大写:单击 Shift 输入一个大写;快速连点两下锁定大写;长按 Shift 直接锁定(再长按解除)。Shift 键三个状态图标不同:空心 = 小写,实心 = 下一个大写,实心带横线 = 锁定。 +- 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。 +- 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右栏(键盘靠右占约 60%,左边留给应用)。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。 +- 横屏整宽键盘铺满屏宽(最宽 900dp),之前被缩成中间一小块。 +- 横屏顶栏压缩到只比按键行高一点(48dp),输入时拼音在左、候选在右同一行。 +- 浮动键盘:整张卡片圆角并带细边,底部为系统“返回/切换输入法”按钮留出一条空位,不再盖住最下面一行。 + ## [0.0.7-beta.1] - 2026-10-06 第七个测试版:九键预编辑不再出现数字,拼音栏点哪个都管用,符号、表情、颜文字大幅补全。从 0.0.6-beta.1 覆盖安装即可,用户数据保留。 diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index d4496d30..bd5efa80 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -50,6 +50,8 @@ import llc.slacker.openime.editor.InputMethodSubtypePolicy import llc.slacker.openime.editor.editorActionForEnter import llc.slacker.openime.editor.shouldClearCompositionForSelectionUpdate import llc.slacker.openime.floating.FloatingWindowController +import llc.slacker.openime.floating.SideDockWindowController +import llc.slacker.openime.core.LandscapeLayout import llc.slacker.openime.keyboard.EnglishShiftPolicy import llc.slacker.openime.keyboard.HardwareContext import llc.slacker.openime.keyboard.HardwareKey @@ -126,6 +128,18 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can }, ) } + private val sideDock by lazy { + SideDockWindowController( + resources = resources, + mainHandler = mainHandler, + windowProvider = { getWindow().window }, + keyboardHeightPx = { keyboardView?.measuredHeight?.takeIf { it > 0 } }, + ) + } + /** The landscape setting floated the keyboard; a floating one the user chose is never touched. */ + private var landscapeAutoFloating = false + /** The user docked the keyboard during this landscape session; leave it docked until rotation. */ + private var landscapeFloatingDeclined = false private var voiceComposing = false private var voiceAutoCommitOnFinal = true private val voiceCorrectionTracker by lazy { @@ -322,9 +336,50 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can // Orientation changes affect keyboard geometry, not the user's chosen // window mode. A manually floating keyboard stays floating; a docked // keyboard stays docked. + applyLandscapeLayout() floatingWindow.onConfigurationChanged() } + private fun isLandscape() = + resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + /** + * Landscape layout setting: floating (default), full width, or docked right. + * Only the transitions the setting itself caused are undone: a keyboard the + * user floated from the tools page stays floating in both orientations. + */ + private fun applyLandscapeLayout() { + val landscape = isLandscape() + val layout = ImeSettingsRepository.loadLandscapeLayout(this) + if (!landscape) landscapeFloatingDeclined = false + val wantFloating = landscape && layout == LandscapeLayout.FLOATING && !landscapeFloatingDeclined + if (wantFloating && !floatingWindow.enabled) { + landscapeAutoFloating = true + keyboardView?.setFloatingWindowMode(true, compact = true) + floatingWindow.enable(resetPosition = true) + } else if (!wantFloating && landscapeAutoFloating) { + landscapeAutoFloating = false + keyboardView?.setFloatingWindowMode(false) + floatingWindow.restore() + } + val wantSide = landscape && layout == LandscapeLayout.SIDE && !floatingWindow.enabled + // The framework resets the window layout each time the keyboard shows, so a + // docked side window is applied again every time, like the floating one. + if (wantSide) sideDock.enable() else if (sideDock.enabled) sideDock.restore() + } + + override fun onWindowShown() { + super.onWindowShown() + // The framework lays the window out for a full-width dock while it shows; + // put the side width back once that is done. + if (sideDock.enabled) sideDock.enable() + } + + override fun onLandscapeLayoutChanged(layout: LandscapeLayout) { + landscapeFloatingDeclined = false + applyLandscapeLayout() + } + override fun onEvaluateInputViewShown(): Boolean { // The emulator and some Chromebooks expose a hardware keyboard, so // InputMethodService's default policy suppresses the on-screen view @@ -503,8 +558,9 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onStartInputView(attribute: EditorInfo?, restarting: Boolean) { super.onStartInputView(attribute, restarting) ensureInputViewAfterFinish() + applyLandscapeLayout() if (floatingWindow.enabled) { - keyboardView?.setFloatingWindowMode(true) + keyboardView?.setFloatingWindowMode(true, compact = landscapeAutoFloating) floatingWindow.reapply() } else { floatingWindow.restore() @@ -865,6 +921,11 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can } override fun onFloatingKeyboardChanged(floating: Boolean) { + // A manual change replaces the automatic one: docking in landscape means + // "not now", and floating by hand is the user's own choice. + if (!floating && landscapeAutoFloating && isLandscape()) landscapeFloatingDeclined = true + landscapeAutoFloating = false + if (floating && sideDock.enabled) sideDock.restore() keyboardView?.setFloatingWindowMode(floating) if (floating) floatingWindow.enable() else floatingWindow.restore() } 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..62a677ab --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/core/LandscapeLayout.kt @@ -0,0 +1,18 @@ +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. 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", "整宽"), + SIDE("side", "左右栏"); + + companion object { + fun fromKey(key: String?): LandscapeLayout = entries.firstOrNull { it.key == key } ?: 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..ad33f0d4 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,7 @@ 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" /** * The user's preferred Chinese layout (26-key vs 9-key). Only PINYIN_26 and @@ -59,6 +61,16 @@ object ImeSettingsRepository { .edit().putInt(KEY_KEYBOARD_HEIGHT, percent.coerceIn(80, 120)).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/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/floating/SideDockWindowController.kt b/app/src/main/java/llc/slacker/openime/floating/SideDockWindowController.kt new file mode 100644 index 00000000..ce528cf3 --- /dev/null +++ b/app/src/main/java/llc/slacker/openime/floating/SideDockWindowController.kt @@ -0,0 +1,82 @@ +package llc.slacker.openime.floating + +import android.os.Handler +import android.view.Gravity +import android.view.Window +import android.view.WindowManager + +/** + * Docks the IME window to the bottom-right corner at part of the screen width + * (the landscape "left and right columns" layout: the app on the left, the + * keyboard on the right). Unlike floating, the window keeps its bottom edge, so + * the app still resizes around it. + */ +internal class SideDockWindowController( + private val resources: android.content.res.Resources, + private val mainHandler: Handler, + private val windowProvider: () -> Window?, + private val keyboardHeightPx: () -> Int?, +) { + var enabled: Boolean = false + private set + + private var baseGravity: Int? = null + private var baseWidth: Int? = null + private var baseHeight: Int? = null + + fun enable() { + enabled = true + mainHandler.post { + if (!enabled) return@post + val window = windowProvider() ?: return@post + val attrs = window.attributes + if (baseGravity == null) { + baseGravity = attrs.gravity + baseWidth = attrs.width + baseHeight = attrs.height + } + val metrics = resources.displayMetrics + val width = sideWidthPx() + val height = (keyboardHeightPx() ?: return@post).coerceIn(1, metrics.heightPixels) + // Same window technique as the floating card (an explicit size at an + // explicit offset), pinned to the bottom-right corner. + attrs.gravity = Gravity.TOP or Gravity.START + attrs.width = width + attrs.height = height + attrs.x = metrics.widthPixels - width + attrs.y = metrics.heightPixels - height + window.attributes = attrs + } + } + + fun restore() { + enabled = false + mainHandler.post { + if (enabled) return@post + val window = windowProvider() ?: return@post + val attrs = window.attributes + attrs.gravity = baseGravity ?: Gravity.BOTTOM + attrs.width = baseWidth ?: WindowManager.LayoutParams.MATCH_PARENT + attrs.height = baseHeight ?: WindowManager.LayoutParams.WRAP_CONTENT + attrs.x = 0 + attrs.y = 0 + window.attributes = attrs + } + } + + /** About 60% of the width, never narrower than a phone keyboard or wider than a tablet one. */ + internal fun sideWidthPx(): Int { + val metrics = resources.displayMetrics + val screen = metrics.widthPixels + val density = metrics.density + val preferred = (screen * SIDE_FRACTION).toInt() + return preferred.coerceIn((MIN_DP * density).toInt().coerceAtMost(screen), (MAX_DP * density).toInt().coerceAtLeast(1)) + .coerceAtMost(screen) + } + + private companion object { + const val SIDE_FRACTION = 0.6f + const val MIN_DP = 360 + const val MAX_DP = 560 + } +} 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..41a3950c 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 @@ -133,6 +134,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 +148,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 +303,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, @@ -419,6 +424,7 @@ open class ImeKeyboardView( statefulRounded = { normal, pressed, radius -> statefulRounded(normal, pressed, radius) }, contrastText = ImeDrawableFactory::contrastText, feedback = ::feedback, + hoverFeedback = ::hapticFeedback, onSymbolSelected = listener::onCharacter, ) private val nineKeySegmentRepairController = NineKeySegmentRepairController( @@ -537,6 +543,7 @@ open class ImeKeyboardView( } private var systemBottomInsetPx = 0 private val maxContentWidthDp = 600 + private val maxLandscapeContentWidthDp = 900 // Portrait row height follows the available screen width. Landscape stays // compact; larger system fonts and the height preference grow the rows. private fun keyRowHeightDp(): Int = layoutMetrics.keyRowHeightDp @@ -676,6 +683,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 +701,7 @@ open class ImeKeyboardView( onKeyboardHeightChanged = ::setKeyboardHeightPercent, onHapticStrengthChanged = ::setHapticStrengthPercent, onHapticStyleChanged = ::setHapticStyle, + onLandscapeLayoutChanged = ::setLandscapeLayout, onKeySoundStyleChanged = ::setKeySoundStyle, onFloatingStyleChanged = ::setFloatingStyle, onShowFuzzySettings = { showPanel(Panel.FUZZY_SETTINGS) }, @@ -783,6 +792,7 @@ open class ImeKeyboardView( hintsEnabled = { ImeSettingsRepository.loadLetterHints(context) }, swipeUpEnabled = { ImeSettingsRepository.loadSwipeUpDigits(context) }, onShift = ::cycleShift, + onShiftLongPress = ::lockShift, onDigits = { setMode(KeyboardMode.DIGITS) }, onModeSwitch = ::cycleMode, onSpace = listener::onSpace, @@ -883,7 +893,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 +904,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 +1031,7 @@ open class ImeKeyboardView( */ private fun applyDynamicHeights() { if (standalonePanel) return + if (::topZone.isInitialized) topZone.setLandscapeStrip(layoutMetrics.landscape) val totalPx = dp(imeHeightDp()) val bodyPx = dp(keyboardBodyHeightDp()) (layoutParams as? FrameLayout.LayoutParams)?.let { @@ -1048,7 +1069,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 @@ -1102,8 +1123,15 @@ 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 screen up to a tablet cap + // instead of shrinking the keys to the height-derived portrait width. + landscape -> (maxLandscapeContentWidthDp * resources.displayMetrics.density).toInt() + else -> (minOf(maxContentWidthDp.toFloat(), 390f * referenceScale) * resources.displayMetrics.density).toInt() + } contentInsetPx = maxOf(minimumInset, (measuredWidthPx - maxWidth) / 2) keyboardBody.setPadding( contentInsetPx, @@ -1364,9 +1392,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() @@ -1385,7 +1414,7 @@ open class ImeKeyboardView( updateTopZone(composition.text?.isNotEmpty() == true) if (width > 0) updateResponsiveGeometry(width) } - floatingKeyboardController.applyTheme(currentThemeTokens()) + applyTheme() requestLayout() } @@ -1625,6 +1654,12 @@ open class ImeKeyboardView( } /** Save the 震动手感 choice and let it be felt once. */ + private fun setLandscapeLayout(next: LandscapeLayout) { + if (ImeSettingsRepository.loadLandscapeLayout(context) == next) return + ImeSettingsRepository.saveLandscapeLayout(context, next) + listener.onLandscapeLayoutChanged(next) + } + private fun setHapticStyle(next: HapticStyle) { keyHaptics.style = next ImeSettingsRepository.saveHapticStyle(context, next.key) @@ -2067,6 +2102,13 @@ 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) + } + } if (backspaceGestureController.active) { when (event.actionMasked) { MotionEvent.ACTION_MOVE -> { @@ -2885,12 +2927,24 @@ open class ImeKeyboardView( return true } + private var lastShiftTapAt = 0L + private fun cycleShift() { - shiftState = when (shiftState) { - ShiftState.LOWERCASE -> ShiftState.SHIFT_ONCE - ShiftState.SHIFT_ONCE -> ShiftState.CAPS_LOCK - ShiftState.CAPS_LOCK -> ShiftState.LOWERCASE - } + 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() } @@ -2913,7 +2967,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 +3139,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/ImeTopZone.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt index 14afdbf4..54b7c4d7 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -508,6 +508,50 @@ 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(22) + } + 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(ImeGeometryTokens.TOUCH_TARGET_DP) + } + candidateField.layoutParams = it + } + } + fun setCompactToolbar(value: Boolean) { if (compact == value) return compact = value 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..3ebbe26d 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,14 +197,28 @@ 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(48 * requestedRows.size + 10) + 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 + gravity = Gravity.CENTER_HORIZONTAL setPadding(dp(5), dp(5), dp(5), dp(5)) background = ImeDrawableFactory.rounded( t.keyBackground, @@ -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 @@ -263,6 +282,48 @@ internal class KeyPopupController( animateIn(column, popupWidth, popupHeight) } + /** + * 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() + 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) { val popup = popupView ?: return if (x < popup.left || x >= popup.right || y < popup.top || y >= popup.bottom) { @@ -291,6 +352,9 @@ internal class KeyPopupController( } popupView = null keepAfterKeyUp = false + choiceAnchor = null + choiceCells.clear() + hoveredCell = null } private companion object { 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..615a250e 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 + 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/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..878e56bf 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,6 +36,7 @@ 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, @@ -131,17 +131,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 + } } } 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..c0df6450 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()) { @@ -232,6 +237,8 @@ internal class SettingsPanelController( soundStyleRow(0), hapticStyleRow(0), ) + content.addSection("横屏") + content.addCard(landscapeLayoutRow(0)) content.addSection("浮动键盘") content.addCard( sliderRow("浮动宽度", 0, 72, 100, currentFloatingWidthPercent(), shortLabel = "宽度") { @@ -551,6 +558,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..e2a448b2 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -57,6 +57,8 @@ internal object ImeGeometryTokens { const val STEP_MARK_SIZE_DP = 32 const val HERO_MARK_SIZE_DP = 72 const val COMPOSED_TOP_ZONE_HEIGHT_DP = 70 + // Landscape: one strip a little taller than a key row, pinyin and candidates side by side. + 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_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/KeyboardLayoutMetricsTest.kt b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt index 9c580099..16044d57 100644 --- a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt +++ b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt @@ -32,7 +32,10 @@ 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 + 16, metrics.imeHeightDp) } @Test 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"), ) } From b4597a9ae9d20410c30809c83484b590ca5e186d Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:12:16 +0800 Subject: [PATCH 02/10] feat: hold Shift and slide to a letter; align core regression with the keyboard-owned pre-edit Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + .../openime/keyboard/ImeKeyboardView.kt | 58 +++++++++++++++++++ scripts/core_regression.sh | 8 ++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73131d54..ceced114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] - 英文键盘的大写:单击 Shift 输入一个大写;快速连点两下锁定大写;长按 Shift 直接锁定(再长按解除)。Shift 键三个状态图标不同:空心 = 小写,实心 = 下一个大写,实心带横线 = 锁定。 +- 按住 Shift 滑到字母再松手,输入那一个大写字母,Shift 状态保持原样(和 Gboard 一样);松手在别处则什么都不输入。 - 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。 - 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右栏(键盘靠右占约 60%,左边留给应用)。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。 - 横屏整宽键盘铺满屏宽(最宽 900dp),之前被缩成中间一小块。 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 41a3950c..1d452417 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -2109,6 +2109,7 @@ open class ImeKeyboardView( MotionEvent.ACTION_UP -> keyPopupController.trackChoiceTouch(event.rawX, event.rawY, lifted = true) } } + trackShiftSlide(event) if (backspaceGestureController.active) { when (event.actionMasked) { MotionEvent.ACTION_MOVE -> { @@ -2928,6 +2929,63 @@ open class ImeKeyboardView( } private var lastShiftTapAt = 0L + private var shiftHeld = false + private var shiftSliding = false + private var shiftBeforeSlide = ShiftState.LOWERCASE + + 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() 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)" From 57647b69c8aa8f2caf47071efd9695b95c154651 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:42:53 +0800 Subject: [PATCH 03/10] feat: shorter top strip, keys 15dp higher, 26-key long-press popup Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ .../openime/keyboard/ImeKeyboardView.kt | 28 +++++++++++++------ .../slacker/openime/keyboard/ImeTopZone.kt | 18 +++++++----- .../openime/keyboard/KeyboardGeometry.kt | 2 +- .../openime/keyboard/LetterHintPolicy.kt | 10 +++++++ .../keyboard/Pinyin26KeyboardRenderer.kt | 12 +++++--- .../slacker/openime/theme/ImeDesignTokens.kt | 5 +++- .../openime/KeyboardLayoutMetricsTest.kt | 7 +++-- .../slacker/openime/LetterHintPolicyTest.kt | 7 +++++ 9 files changed, 68 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceced114..ce2ec6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] - 英文键盘的大写:单击 Shift 输入一个大写;快速连点两下锁定大写;长按 Shift 直接锁定(再长按解除)。Shift 键三个状态图标不同:空心 = 小写,实心 = 下一个大写,实心带横线 = 锁定。 +- 键盘上方的工具栏降低 10dp(70 → 60),拼音行和候选行压矮;整个键盘往上抬 15dp,底部空白更多,键盘总高度净增 5dp。九键、26 键、数字、笔画键盘都一样。 +- 26 键字母键长按也弹出选择面板(另一种大小写 + 右上角的数字或符号),按住滑到目标、松手输入,和搜狗、讯飞一致;上滑仍直接输入右上角的数字或符号。 - 按住 Shift 滑到字母再松手,输入那一个大写字母,Shift 状态保持原样(和 Gboard 一样);松手在别处则什么都不输入。 - 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。 - 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右栏(键盘靠右占约 60%,左边留给应用)。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。 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 1d452417..51281cca 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -425,7 +425,7 @@ open class ImeKeyboardView( contrastText = ImeDrawableFactory::contrastText, feedback = ::feedback, hoverFeedback = ::hapticFeedback, - onSymbolSelected = listener::onCharacter, + onSymbolSelected = ::onPopupChoice, ) private val nineKeySegmentRepairController = NineKeySegmentRepairController( context = context, @@ -837,7 +837,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 @@ -1101,7 +1101,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 @@ -1135,9 +1135,9 @@ open class ImeKeyboardView( 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() @@ -1406,7 +1406,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) @@ -1918,9 +1918,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 @@ -2408,6 +2408,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() 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 54b7c4d7..36bd92a1 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, @@ -224,7 +228,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 +243,7 @@ internal class ImeTopZone( composition, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(22), + toPx(COMPOSITION_LINE_DP), ), ) @@ -259,7 +263,7 @@ internal class ImeTopZone( candidateRow, ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + toPx(CANDIDATE_ROW_DP), ), ) } @@ -270,7 +274,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 +308,7 @@ internal class ImeTopZone( candidateField, LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - toPx(ImeGeometryTokens.TOUCH_TARGET_DP), + toPx(CANDIDATE_ROW_DP), ), ) addView( @@ -532,7 +536,7 @@ internal class ImeTopZone( it.height = LinearLayout.LayoutParams.MATCH_PARENT } else { it.width = LinearLayout.LayoutParams.MATCH_PARENT - it.height = toPx(22) + it.height = toPx(COMPOSITION_LINE_DP) } composition.layoutParams = it } @@ -546,7 +550,7 @@ internal class ImeTopZone( } else { it.width = LinearLayout.LayoutParams.MATCH_PARENT it.weight = 0f - it.height = toPx(ImeGeometryTokens.TOUCH_TARGET_DP) + it.height = toPx(CANDIDATE_ROW_DP) } candidateField.layoutParams = it } 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 615a250e..53ad30a8 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt @@ -87,7 +87,7 @@ internal data class KeyboardLayoutMetrics( val imeHeightDp: Int = run { val derived = topZoneHeightDp + - keyRowHeightDp * 4 + 16 + keyRowHeightDp * 4 + ImeGeometryTokens.KEYBOARD_TOP_PAD_DP + ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP val baseMinimum = 0 val scaledMinimum = (baseMinimum * heightPercent.coerceIn(80, 120) / 100f).toInt() 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/Pinyin26KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt index 878e56bf..aeac5b6c 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -167,13 +167,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 + } } } 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 e2a448b2..dbf29cd7 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -56,8 +56,11 @@ 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 15dp higher than they used to). + const val KEYBOARD_TOP_PAD_DP = 6 + const val KEYBOARD_BOTTOM_PAD_DP = 25 const val LANDSCAPE_TOP_ZONE_HEIGHT_DP = 48 } diff --git a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt index 16044d57..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( @@ -35,7 +36,9 @@ class KeyboardLayoutMetricsTest { // 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 + 16, metrics.imeHeightDp) + 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)) + } } From 07661ac9d20e998fb7a84b626463c4a5bc6dfbe1 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:53:42 +0800 Subject: [PATCH 04/10] feat: trim the bottom space to 17dp Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- .../main/java/llc/slacker/openime/theme/ImeDesignTokens.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2ec6f4..22b9df57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] - 英文键盘的大写:单击 Shift 输入一个大写;快速连点两下锁定大写;长按 Shift 直接锁定(再长按解除)。Shift 键三个状态图标不同:空心 = 小写,实心 = 下一个大写,实心带横线 = 锁定。 -- 键盘上方的工具栏降低 10dp(70 → 60),拼音行和候选行压矮;整个键盘往上抬 15dp,底部空白更多,键盘总高度净增 5dp。九键、26 键、数字、笔画键盘都一样。 +- 键盘上方的工具栏降低 10dp(70 → 60),拼音行和候选行压矮;底部空白从 10dp 增到 17dp,键盘总高度净减 3dp。九键、26 键、数字、笔画键盘都一样。 - 26 键字母键长按也弹出选择面板(另一种大小写 + 右上角的数字或符号),按住滑到目标、松手输入,和搜狗、讯飞一致;上滑仍直接输入右上角的数字或符号。 - 按住 Shift 滑到字母再松手,输入那一个大写字母,Shift 状态保持原样(和 Gboard 一样);松手在别处则什么都不输入。 - 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。 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 dbf29cd7..30be4842 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -58,9 +58,9 @@ internal object ImeGeometryTokens { const val HERO_MARK_SIZE_DP = 72 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 15dp higher than they used to). + // Space under the bottom row (the keys sit 7dp higher than they used to). const val KEYBOARD_TOP_PAD_DP = 6 - const val KEYBOARD_BOTTOM_PAD_DP = 25 + const val KEYBOARD_BOTTOM_PAD_DP = 17 const val LANDSCAPE_TOP_ZONE_HEIGHT_DP = 48 } From 3d03aebb9caf0a9557fb3dbfdbac3684679b9d20 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:41:58 +0800 Subject: [PATCH 05/10] feat: one Enter-key rule, compact long-press popup with auto dismiss, candidate and spacing tweaks Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 ++ .../openime/editor/EnterActionPolicy.kt | 7 ++++ .../keyboard/CandidateBarController.kt | 4 +-- .../openime/keyboard/ImeKeyboardView.kt | 23 ++++++------- .../openime/keyboard/ImeThemeApplier.kt | 2 +- .../openime/keyboard/KeyPopupController.kt | 33 +++++++++++++------ .../slacker/openime/theme/ImeDesignTokens.kt | 6 ++-- .../slacker/openime/EnterActionPolicyTest.kt | 13 ++++++++ 8 files changed, 64 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b9df57..3df40474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 英文键盘的大写:单击 Shift 输入一个大写;快速连点两下锁定大写;长按 Shift 直接锁定(再长按解除)。Shift 键三个状态图标不同:空心 = 小写,实心 = 下一个大写,实心带横线 = 锁定。 - 键盘上方的工具栏降低 10dp(70 → 60),拼音行和候选行压矮;底部空白从 10dp 增到 17dp,键盘总高度净减 3dp。九键、26 键、数字、笔画键盘都一样。 - 26 键字母键长按也弹出选择面板(另一种大小写 + 右上角的数字或符号),按住滑到目标、松手输入,和搜狗、讯飞一致;上滑仍直接输入右上角的数字或符号。 +- 回车键统一:输入拼音时是“确定”;编辑框要求了动作(发送、搜索、前往、下一项、完成)时显示动作文字并高亮;其余(换行、多行、无动作)一律是不高亮的“↵”。九键、26 键、笔画、数字键盘都按同一规则。 +- 按键气泡和长按选择面板同高(52dp);长按选择面板缩小(格子 40×44dp),松手后没选就自动消失(松在原键上 1.5 秒后,没松手 4 秒后)。 +- 候选字背景上下各缩 0.5dp,工具栏总高不变;键盘整体再上抬 2dp。 - 按住 Shift 滑到字母再松手,输入那一个大写字母,Shift 状态保持原样(和 Gboard 一样);松手在别处则什么都不输入。 - 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。 - 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右栏(键盘靠右占约 60%,左边留给应用)。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。 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/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/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt index 51281cca..aeedb791 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -42,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 @@ -1551,10 +1552,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 @@ -2007,7 +2008,7 @@ open class ImeKeyboardView( pinyin26Renderer.render( english = false, shiftState = shiftState, - enterLabel = enterKeyLabel(false), + enterLabel = enterKeyLabel(), ) } @@ -2015,7 +2016,7 @@ open class ImeKeyboardView( pinyin26Renderer.render( english = true, shiftState = shiftState, - enterLabel = enterKeyLabel(true), + enterLabel = enterKeyLabel(), ) } @@ -2024,15 +2025,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 { @@ -2057,7 +2058,7 @@ open class ImeKeyboardView( start() } } - strokeRenderer.render(enterLabel = if (composition.text?.isNotEmpty() == true) "确定" else "↵") + strokeRenderer.render(enterLabel = enterKeyLabel()) } /** @@ -2080,7 +2081,7 @@ open class ImeKeyboardView( ?.currentInputEditorInfo numericKeyboardRenderer.render( editorKind = EditorInfoAdapter.kind(info), - enterLabel = "↵", + enterLabel = enterKeyLabel(), ) } 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/KeyPopupController.kt b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt index 3ebbe26d..952d5956 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt @@ -206,7 +206,7 @@ internal class KeyPopupController( // nearest letters first. val anchorTop = IntArray(2).also { anchor.getLocationOnScreen(it) }[1] - IntArray(2).also { host.getLocationOnScreen(it) }[1] - val needed = dp(48 * requestedRows.size + 10) + dp(ImeGeometryTokens.KEY_POPUP_VERTICAL_GAP_DP) + dp(4) + 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 { @@ -219,7 +219,7 @@ internal class KeyPopupController( val column = LinearLayout(host.context).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER_HORIZONTAL - setPadding(dp(5), dp(5), dp(5), dp(5)) + 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), @@ -259,27 +259,25 @@ 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) } /** @@ -313,7 +311,7 @@ internal class KeyPopupController( hoveredCell?.isPressed = false hoveredCell = null val anchor = choiceAnchor - if (anchor == null || !containsRaw(anchor, rawX, rawY)) hide() + if (anchor == null || !containsRaw(anchor, rawX, rawY)) hide() else scheduleAutoDismiss(LIFT_DISMISS_MS) return false } @@ -341,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) { @@ -358,6 +365,12 @@ internal class KeyPopupController( } 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/theme/ImeDesignTokens.kt b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt index 30be4842..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 @@ -59,8 +59,8 @@ internal object ImeGeometryTokens { 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 = 6 - const val KEYBOARD_BOTTOM_PAD_DP = 17 + const val KEYBOARD_TOP_PAD_DP = 4 + const val KEYBOARD_BOTTOM_PAD_DP = 19 const val LANDSCAPE_TOP_ZONE_HEIGHT_DP = 48 } 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)) + } } From 5f708b99424d525128a780b6d6316bb4dbdc3fbc Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:10:59 +0800 Subject: [PATCH 06/10] feat: toolbar chips for fresh clipboard text and SMS verification code Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + app/src/main/AndroidManifest.xml | 7 ++ .../slacker/openime/SmsPermissionActivity.kt | 34 +++++++++ .../openime/data/ImeSettingsRepository.kt | 16 +++++ .../openime/keyboard/ImeKeyboardView.kt | 68 ++++++++++++++++++ .../slacker/openime/keyboard/ImeTopZone.kt | 5 ++ .../openime/keyboard/SmsCodeExtractor.kt | 19 +++++ .../openime/keyboard/ToolbarSuggestions.kt | 70 +++++++++++++++++++ .../openime/panel/SettingsPanelController.kt | 7 ++ .../slacker/openime/SmsCodeExtractorTest.kt | 27 +++++++ 10 files changed, 254 insertions(+) create mode 100644 app/src/main/java/llc/slacker/openime/SmsPermissionActivity.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt create mode 100644 app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt create mode 100644 app/src/test/java/llc/slacker/openime/SmsCodeExtractorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3df40474..c121799f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 回车键统一:输入拼音时是“确定”;编辑框要求了动作(发送、搜索、前往、下一项、完成)时显示动作文字并高亮;其余(换行、多行、无动作)一律是不高亮的“↵”。九键、26 键、笔画、数字键盘都按同一规则。 - 按键气泡和长按选择面板同高(52dp);长按选择面板缩小(格子 40×44dp),松手后没选就自动消失(松在原键上 1.5 秒后,没松手 4 秒后)。 - 候选字背景上下各缩 0.5dp,工具栏总高不变;键盘整体再上抬 2dp。 +- 工具栏提示:刚复制的文字(3 分钟内)显示在工具栏,点一下输入;设置里新增“短信验证码”(默认关闭):打开后请求“读取短信”权限,5 分钟内的验证码短信显示成“验证码 123456”,点一下输入。两项都只在本机读取,不联网;密码框不显示。可在“设置 → 工具栏提示”关闭。 - 按住 Shift 滑到字母再松手,输入那一个大写字母,Shift 状态保持原样(和 Gboard 一样);松手在别处则什么都不输入。 - 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。 - 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右栏(键盘靠右占约 60%,左边留给应用)。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7198eae8..8fe8d300 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + + + , 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/data/ImeSettingsRepository.kt b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt index ad33f0d4..7c9e4350 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -31,6 +31,8 @@ object ImeSettingsRepository { 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_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 @@ -61,6 +63,20 @@ object ImeSettingsRepository { .edit().putInt(KEY_KEYBOARD_HEIGHT, percent.coerceIn(80, 120)).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), 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 aeedb791..fad8d942 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -333,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() @@ -2331,6 +2380,8 @@ open class ImeKeyboardView( "模糊音纠错", "启用模糊音" -> fuzzyEnabled "按键气泡" -> popupEnabled "上滑输入数字" -> ImeSettingsRepository.loadSwipeUpDigits(context) + "复制内容提示" -> ImeSettingsRepository.loadClipboardChip(context) + "短信验证码" -> ImeSettingsRepository.loadSmsCodeChip(context) "数字和符号提示" -> ImeSettingsRepository.loadLetterHints(context) "表情联想" -> ImeSettingsRepository.loadEmojiAssociation(context) "语音去语气词" -> ImeSettingsRepository.loadVoiceStripFillers(context) @@ -2358,6 +2409,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). 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 36bd92a1..470d7813 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -84,6 +84,10 @@ internal class ImeTopZone( private var toolbarMode = ToolbarMode.NORMAL private var associationsShown = false private var autofillShown = 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 @@ -434,6 +438,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() 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 c0df6450..def5f37e 100644 --- a/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/SettingsPanelController.kt @@ -199,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), @@ -251,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), ) 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")) + } +} From 13ee1ae4c5799c6595bb763c21773d23d8c62520 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:29:12 +0800 Subject: [PATCH 07/10] feat: Sogou-style split keyboard for landscape (replaces the docked side layout) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- .../slacker/openime/LocalVoiceImeService.kt | 23 +----- .../slacker/openime/core/LandscapeLayout.kt | 11 ++- .../openime/data/ImeSettingsRepository.kt | 8 ++ .../floating/SideDockWindowController.kt | 82 ------------------- .../openime/keyboard/ImeKeyboardView.kt | 18 ++++ .../keyboard/Pinyin26KeyboardRenderer.kt | 47 +++++++++++ .../keyboard/Pinyin9KeyboardRenderer.kt | 35 ++++++-- 8 files changed, 112 insertions(+), 114 deletions(-) delete mode 100644 app/src/main/java/llc/slacker/openime/floating/SideDockWindowController.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c121799f..02134f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 工具栏提示:刚复制的文字(3 分钟内)显示在工具栏,点一下输入;设置里新增“短信验证码”(默认关闭):打开后请求“读取短信”权限,5 分钟内的验证码短信显示成“验证码 123456”,点一下输入。两项都只在本机读取,不联网;密码框不显示。可在“设置 → 工具栏提示”关闭。 - 按住 Shift 滑到字母再松手,输入那一个大写字母,Shift 状态保持原样(和 Gboard 一样);松手在别处则什么都不输入。 - 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。 -- 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右栏(键盘靠右占约 60%,左边留给应用)。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。 +- 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右分离。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。左右分离参照搜狗:26 键分成左右两半,中间留空,空格键贯穿中间;九键是一边九宫格、另一边符号和功能列,中间的“⇄”键左右互换。 - 横屏整宽键盘铺满屏宽(最宽 900dp),之前被缩成中间一小块。 - 横屏顶栏压缩到只比按键行高一点(48dp),输入时拼音在左、候选在右同一行。 - 浮动键盘:整张卡片圆角并带细边,底部为系统“返回/切换输入法”按钮留出一条空位,不再盖住最下面一行。 diff --git a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt index bd5efa80..32f90f2b 100644 --- a/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt +++ b/app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt @@ -50,7 +50,6 @@ import llc.slacker.openime.editor.InputMethodSubtypePolicy import llc.slacker.openime.editor.editorActionForEnter import llc.slacker.openime.editor.shouldClearCompositionForSelectionUpdate import llc.slacker.openime.floating.FloatingWindowController -import llc.slacker.openime.floating.SideDockWindowController import llc.slacker.openime.core.LandscapeLayout import llc.slacker.openime.keyboard.EnglishShiftPolicy import llc.slacker.openime.keyboard.HardwareContext @@ -128,14 +127,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can }, ) } - private val sideDock by lazy { - SideDockWindowController( - resources = resources, - mainHandler = mainHandler, - windowProvider = { getWindow().window }, - keyboardHeightPx = { keyboardView?.measuredHeight?.takeIf { it > 0 } }, - ) - } /** The landscape setting floated the keyboard; a floating one the user chose is never touched. */ private var landscapeAutoFloating = false /** The user docked the keyboard during this landscape session; leave it docked until rotation. */ @@ -344,7 +335,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE /** - * Landscape layout setting: floating (default), full width, or docked right. + * Landscape layout setting: floating (default), full width, or split halves. * Only the transitions the setting itself caused are undone: a keyboard the * user floated from the tools page stays floating in both orientations. */ @@ -362,17 +353,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can keyboardView?.setFloatingWindowMode(false) floatingWindow.restore() } - val wantSide = landscape && layout == LandscapeLayout.SIDE && !floatingWindow.enabled - // The framework resets the window layout each time the keyboard shows, so a - // docked side window is applied again every time, like the floating one. - if (wantSide) sideDock.enable() else if (sideDock.enabled) sideDock.restore() - } - - override fun onWindowShown() { - super.onWindowShown() - // The framework lays the window out for a full-width dock while it shows; - // put the side width back once that is done. - if (sideDock.enabled) sideDock.enable() } override fun onLandscapeLayoutChanged(layout: LandscapeLayout) { @@ -925,7 +905,6 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can // "not now", and floating by hand is the user's own choice. if (!floating && landscapeAutoFloating && isLandscape()) landscapeFloatingDeclined = true landscapeAutoFloating = false - if (floating && sideDock.enabled) sideDock.restore() keyboardView?.setFloatingWindowMode(floating) if (floating) floatingWindow.enable() else floatingWindow.restore() } diff --git a/app/src/main/java/llc/slacker/openime/core/LandscapeLayout.kt b/app/src/main/java/llc/slacker/openime/core/LandscapeLayout.kt index 62a677ab..4d06db06 100644 --- a/app/src/main/java/llc/slacker/openime/core/LandscapeLayout.kt +++ b/app/src/main/java/llc/slacker/openime/core/LandscapeLayout.kt @@ -4,15 +4,18 @@ 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. Floating is the default - * because a full-width keyboard of four rows covers most of a landscape screen. + * 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", "整宽"), - SIDE("side", "左右栏"); + SPLIT("split", "左右分离"); companion object { - fun fromKey(key: String?): LandscapeLayout = entries.firstOrNull { it.key == key } ?: FLOATING + // "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 7c9e4350..f75fab4e 100644 --- a/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt +++ b/app/src/main/java/llc/slacker/openime/data/ImeSettingsRepository.kt @@ -31,6 +31,7 @@ object ImeSettingsRepository { 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" @@ -63,6 +64,13 @@ 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) diff --git a/app/src/main/java/llc/slacker/openime/floating/SideDockWindowController.kt b/app/src/main/java/llc/slacker/openime/floating/SideDockWindowController.kt deleted file mode 100644 index ce528cf3..00000000 --- a/app/src/main/java/llc/slacker/openime/floating/SideDockWindowController.kt +++ /dev/null @@ -1,82 +0,0 @@ -package llc.slacker.openime.floating - -import android.os.Handler -import android.view.Gravity -import android.view.Window -import android.view.WindowManager - -/** - * Docks the IME window to the bottom-right corner at part of the screen width - * (the landscape "left and right columns" layout: the app on the left, the - * keyboard on the right). Unlike floating, the window keeps its bottom edge, so - * the app still resizes around it. - */ -internal class SideDockWindowController( - private val resources: android.content.res.Resources, - private val mainHandler: Handler, - private val windowProvider: () -> Window?, - private val keyboardHeightPx: () -> Int?, -) { - var enabled: Boolean = false - private set - - private var baseGravity: Int? = null - private var baseWidth: Int? = null - private var baseHeight: Int? = null - - fun enable() { - enabled = true - mainHandler.post { - if (!enabled) return@post - val window = windowProvider() ?: return@post - val attrs = window.attributes - if (baseGravity == null) { - baseGravity = attrs.gravity - baseWidth = attrs.width - baseHeight = attrs.height - } - val metrics = resources.displayMetrics - val width = sideWidthPx() - val height = (keyboardHeightPx() ?: return@post).coerceIn(1, metrics.heightPixels) - // Same window technique as the floating card (an explicit size at an - // explicit offset), pinned to the bottom-right corner. - attrs.gravity = Gravity.TOP or Gravity.START - attrs.width = width - attrs.height = height - attrs.x = metrics.widthPixels - width - attrs.y = metrics.heightPixels - height - window.attributes = attrs - } - } - - fun restore() { - enabled = false - mainHandler.post { - if (enabled) return@post - val window = windowProvider() ?: return@post - val attrs = window.attributes - attrs.gravity = baseGravity ?: Gravity.BOTTOM - attrs.width = baseWidth ?: WindowManager.LayoutParams.MATCH_PARENT - attrs.height = baseHeight ?: WindowManager.LayoutParams.WRAP_CONTENT - attrs.x = 0 - attrs.y = 0 - window.attributes = attrs - } - } - - /** About 60% of the width, never narrower than a phone keyboard or wider than a tablet one. */ - internal fun sideWidthPx(): Int { - val metrics = resources.displayMetrics - val screen = metrics.widthPixels - val density = metrics.density - val preferred = (screen * SIDE_FRACTION).toInt() - return preferred.coerceIn((MIN_DP * density).toInt().coerceAtMost(screen), (MAX_DP * density).toInt().coerceAtLeast(1)) - .coerceAtMost(screen) - } - - private companion object { - const val SIDE_FRACTION = 0.6f - const val MIN_DP = 360 - const val MAX_DP = 560 - } -} 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 fad8d942..1b721ede 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -522,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 { @@ -847,6 +850,7 @@ open class ImeKeyboardView( onModeSwitch = ::cycleMode, onSpace = listener::onSpace, onEnter = listener::onEnter, + splitLayout = ::splitKeyboard, ) } @@ -1704,10 +1708,24 @@ 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 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) { 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 aeac5b6c..aecfa0a3 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -41,12 +41,17 @@ internal class Pinyin26KeyboardRenderer( private val onModeSwitch: () -> Unit, private val onSpace: () -> Unit, private val onEnter: () -> 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 +108,47 @@ internal class Pinyin26KeyboardRenderer( keyboardBody.addView(bottom, rowParams()) } + /** + * Landscape split keyboard (Sogou's 左右分离): one half of the letters under each + * thumb with a gap between them, and the space bar running through the gap. + * Every half is five key-widths wide; short rows stretch their keys to fill it. + */ + 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, weight: Float) { + chars.forEach { addView(letterKey(it, english, shiftState), flexKeyParams(weight)) } + } + fun LinearLayout.gap() = addView(View(context), flexKeyParams(SPLIT_GAP)) + + row { letters("qwert", 1f); gap(); letters("yuiop", 1f) } + row { letters("asdfg", 1f); gap(); letters("hjkl", 1.25f) } + row { + addView(shiftKey(shiftState), flexKeyParams(1f)); letters("zxcv", 1f) + gap() + letters("bnm", 1f); addView(createBackspaceKey(), flexKeyParams(2f)) + } + + val bottom = rowHost() + bottom.addView( + createKey("123", true, null, ImeTypographyTokens.BODY_SP, 0, onDigits), + flexKeyParams(1.5f), + ) + bottom.addView(punctuationKey(english), flexKeyParams(1.5f)) + bottom.addView(createSpaceVoiceKey("空格", onSpace), flexKeyParams(SPLIT_GAP + 4f)) + bottom.addView( + createKey("中/英", true, null, ImeTypographyTokens.BODY_SP, 0) { onModeSwitch() } + .apply { tag = "key:mode" }, + flexKeyParams(1.5f), + ) + bottom.addView( + createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, 0, onEnter).apply { tag = "key-enter" }, + flexKeyParams(1.5f), + ) + keyboardBody.addView(bottom, rowParams()) + } + private fun punctuationKey(english: Boolean): ImeKeyView { val spec = PunctuationKeyPolicy.spec(english) // A character key, not a function key: only character keys carry corner hints. @@ -201,6 +247,7 @@ internal class Pinyin26KeyboardRenderer( ) private companion object { + const val SPLIT_GAP = 3f 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..40b16309 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 左右分离): the digit grid under one thumb, the + // symbol and action columns under the other, a mirror key between them + // that swaps the sides. + val functions = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } + functions.addView(left, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) + functions.addView(side, 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(center to 25f / 7f, gap to 0.7f, functions to 2f) + else listOf(functions to 2f, gap to 0.7f, center to 25f / 7f) + 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, From bed6f03aab55613b2788479464e489f3a3a4b28c Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:31:32 +0800 Subject: [PATCH 08/10] fix: declare optional telephony hardware for READ_SMS Co-Authored-By: Claude Sonnet 5.5 --- app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8fe8d300..b820097b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + Date: Wed, 7 Oct 2026 16:58:03 +0800 Subject: [PATCH 09/10] feat: full-width landscape, Sogou-style split keyboard with toolbar toggle Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- .../openime/keyboard/ImeKeyboardView.kt | 43 ++++++++++++--- .../slacker/openime/keyboard/ImeTopZone.kt | 17 +++++- .../keyboard/Pinyin26KeyboardRenderer.kt | 55 +++++++++---------- .../keyboard/Pinyin9KeyboardRenderer.kt | 49 +++++++++++++---- .../main/res/drawable/ic_keyboard_split.xml | 1 + 6 files changed, 119 insertions(+), 48 deletions(-) create mode 100644 app/src/main/res/drawable/ic_keyboard_split.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 02134f89..3409dc75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 工具栏提示:刚复制的文字(3 分钟内)显示在工具栏,点一下输入;设置里新增“短信验证码”(默认关闭):打开后请求“读取短信”权限,5 分钟内的验证码短信显示成“验证码 123456”,点一下输入。两项都只在本机读取,不联网;密码框不显示。可在“设置 → 工具栏提示”关闭。 - 按住 Shift 滑到字母再松手,输入那一个大写字母,Shift 状态保持原样(和 Gboard 一样);松手在别处则什么都不输入。 - 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。 -- 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右分离。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。左右分离参照搜狗:26 键分成左右两半,中间留空,空格键贯穿中间;九键是一边九宫格、另一边符号和功能列,中间的“⇄”键左右互换。 +- 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右分离。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。左右分离参照搜狗:26 键分成左右两半,G、V 两边都有,两边各一个空格,中间留空;九键一边是九宫格(删除、重输、0、确定在左,符号列在右),另一边是常用字面板(的了我你是……),中间的“镜像”键左右互换。横屏工具栏多一个“左右分离键盘”按钮,点一下分离或恢复。 - 横屏整宽键盘铺满屏宽(最宽 900dp),之前被缩成中间一小块。 - 横屏顶栏压缩到只比按键行高一点(48dp),输入时拼音在左、候选在右同一行。 - 浮动键盘:整张卡片圆角并带细边,底部为系统“返回/切换输入法”按钮留出一条空位,不再盖住最下面一行。 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 1b721ede..cbeea1ed 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -596,7 +596,6 @@ open class ImeKeyboardView( } private var systemBottomInsetPx = 0 private val maxContentWidthDp = 600 - private val maxLandscapeContentWidthDp = 900 // Portrait row height follows the available screen width. Landscape stays // compact; larger system fonts and the height preference grow the rows. private fun keyRowHeightDp(): Int = layoutMetrics.keyRowHeightDp @@ -850,6 +849,7 @@ open class ImeKeyboardView( onModeSwitch = ::cycleMode, onSpace = listener::onSpace, onEnter = listener::onEnter, + onSymbols = { showPanel(Panel.SYMBOLS) }, splitLayout = ::splitKeyboard, ) } @@ -1086,6 +1086,7 @@ 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 { @@ -1181,9 +1182,9 @@ open class ImeKeyboardView( 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 screen up to a tablet cap - // instead of shrinking the keys to the height-derived portrait width. - landscape -> (maxLandscapeContentWidthDp * resources.displayMetrics.density).toInt() + // 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) @@ -1284,6 +1285,7 @@ open class ImeKeyboardView( listener.onCandidateExpanded(open) }, onUndo = { listener.onTextEdit("undo") }, + onSplitToggle = ::toggleSplit, onAssociationDismiss = ::clearAssociationCandidates, ) candidateBarController = CandidateBarController( @@ -1714,6 +1716,21 @@ open class ImeKeyboardView( 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 @@ -3032,6 +3049,14 @@ open class ImeKeyboardView( 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") @@ -3110,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") 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 470d7813..53d9b776 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -59,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) @@ -84,6 +85,9 @@ 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. */ @@ -121,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)) } @@ -561,6 +566,15 @@ internal class ImeTopZone( } } + /** 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 @@ -575,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/Pinyin26KeyboardRenderer.kt b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt index aecfa0a3..dc2b6c3a 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin26KeyboardRenderer.kt @@ -41,6 +41,7 @@ internal class Pinyin26KeyboardRenderer( private val onModeSwitch: () -> Unit, private val onSpace: () -> Unit, private val onEnter: () -> Unit, + private val onSymbols: () -> Unit = {}, private val splitLayout: () -> Boolean = { false }, ) { fun render( @@ -109,44 +110,42 @@ internal class Pinyin26KeyboardRenderer( } /** - * Landscape split keyboard (Sogou's 左右分离): one half of the letters under each - * thumb with a gap between them, and the space bar running through the gap. - * Every half is five key-widths wide; short rows stretch their keys to fill it. + * 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, weight: Float) { - chars.forEach { addView(letterKey(it, english, shiftState), flexKeyParams(weight)) } + 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", 1f); gap(); letters("yuiop", 1f) } - row { letters("asdfg", 1f); gap(); letters("hjkl", 1.25f) } + row { letters("qwert"); gap(); letters("yuiop") } + row { letters("asdfg"); gap(); letters("ghjkl") } row { - addView(shiftKey(shiftState), flexKeyParams(1f)); letters("zxcv", 1f) + addView(shiftKey(shiftState), flexKeyParams(1f)); letters("zxcv") gap() - letters("bnm", 1f); addView(createBackspaceKey(), flexKeyParams(2f)) + 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), + ) } - - val bottom = rowHost() - bottom.addView( - createKey("123", true, null, ImeTypographyTokens.BODY_SP, 0, onDigits), - flexKeyParams(1.5f), - ) - bottom.addView(punctuationKey(english), flexKeyParams(1.5f)) - bottom.addView(createSpaceVoiceKey("空格", onSpace), flexKeyParams(SPLIT_GAP + 4f)) - bottom.addView( - createKey("中/英", true, null, ImeTypographyTokens.BODY_SP, 0) { onModeSwitch() } - .apply { tag = "key:mode" }, - flexKeyParams(1.5f), - ) - bottom.addView( - createKey(enterLabel, true, null, ImeTypographyTokens.BODY_SP, 0, onEnter).apply { tag = "key-enter" }, - flexKeyParams(1.5f), - ) - keyboardBody.addView(bottom, rowParams()) } private fun punctuationKey(english: Boolean): ImeKeyView { @@ -247,7 +246,7 @@ internal class Pinyin26KeyboardRenderer( ) private companion object { - const val SPLIT_GAP = 3f + 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 40b16309..1bf104c8 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/Pinyin9KeyboardRenderer.kt @@ -144,26 +144,26 @@ internal class Pinyin9KeyboardRenderer( sideKeyParams(nineBodyHeightDp() / 4), ) if (splitLayout()) { - // Landscape split (Sogou's 左右分离): the digit grid under one thumb, the - // symbol and action columns under the other, a mirror key between them - // that swaps the sides. - val functions = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL } - functions.addView(left, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) - functions.addView(side, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)) + // 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 { + createKey("镜像", true, null, ImeTypographyTokens.BODY_SP, onMirror).apply { tag = "key-mirror" - contentDescription = "左右互换" + contentDescription = "镜像,左右互换" markSideKey(this) }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, toPx(keyRowHeightDp())), ) } - val halves = if (mirrored()) listOf(center to 25f / 7f, gap to 0.7f, functions to 2f) - else listOf(functions to 2f, gap to 0.7f, center to 25f / 7f) + 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)) @@ -180,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 -> @@ -250,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/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 @@ + From 8e88f5a03b874d4e3a414256b9f11ad991d89399 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:52:30 +0800 Subject: [PATCH 10/10] Release 0.0.8-beta.1: nine-key rail fixes, single-feedback taps, landscape and gesture fixes, partial-pick learning Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 22 +++- README.md | 3 +- VERSION | 2 +- .../DisplayEnvironmentInstrumentedTest.kt | 7 +- ...aceCursorAndLetterHintsInstrumentedTest.kt | 17 ++- app/src/main/AndroidManifest.xml | 3 + .../slacker/openime/LocalVoiceImeService.kt | 21 +++- .../slacker/openime/SmsPermissionActivity.kt | 2 + .../openime/candidate/CandidateEngine.kt | 28 +++-- .../openime/candidate/NineKeyLocalDecoder.kt | 44 +++++--- .../java/llc/slacker/openime/core/ImeData.kt | 34 +++--- .../keyboard/BackspaceGestureController.kt | 2 +- .../openime/keyboard/ImeKeyboardView.kt | 101 +++++++++++++----- .../slacker/openime/keyboard/ImeTopZone.kt | 4 +- .../openime/keyboard/KeyPopupController.kt | 5 +- .../openime/keyboard/KeyboardGeometry.kt | 5 +- .../openime/keyboard/SmsCodeExtractor.kt | 22 ++-- .../openime/keyboard/ToolbarSuggestions.kt | 4 +- .../openime/panel/ClipboardPanelController.kt | 1 - .../slacker/openime/panel/ImePanelRenderer.kt | 1 - .../openime/panel/PanelHeaderFactory.kt | 7 +- .../panel/TextEditorPanelController.kt | 2 +- .../java/llc/slacker/openime/setup/SetupUi.kt | 12 ++- .../slacker/openime/theme/ImeDesignTokens.kt | 2 + .../slacker/openime/voice/VoicePanelView.kt | 2 +- .../llc/slacker/openime/widget/ImeKeyView.kt | 1 + .../slacker/openime/CandidatePipelineTest.kt | 20 ++++ .../openime/KeyboardLayoutMetricsTest.kt | 2 +- .../slacker/openime/SmsCodeExtractorTest.kt | 12 +++ docs/COMPATIBILITY.md | 2 +- docs/NINE_KEY_REFERENCE.md | 3 +- docs/TEST_SOP.md | 2 +- docs/TEST_SOP_CHECKLIST.md | 2 +- scripts/beta3_e2e.py | 10 +- 34 files changed, 295 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3409dc75..401751b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,24 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs ## [Unreleased] +## [0.0.8-beta.1] - 2026-10-09 + +第八个测试版:英文大写手势、长按后滑动选字、横屏浮动与左右分离键盘、工具栏的复制和验证码提示,修复九键拼音栏缺音节和排序错误、一次点按两次反馈、分段选词学不到。从 0.0.7-beta.1 覆盖安装即可,用户数据保留。 + +- 修复九键拼音栏里缺少 xian、liu、pin、tian、tuan、yuan、yue 七个音节:它们和某个词同名(西安、浏览、拼音、提案……),被当成词组而不是音节,选不到,这些音节下的字(先、现、天、月、院……)也排到词后面。 +- 九键拼音栏排序:原来按整条读法的分数总和排,两个音节的读法永远压过一个音节的(输入 94264 先出 xia、yi,输入 7426 先出 qi);现在按每个音节的平均分排,输入 94264 的 xiang 排第一,qian 排在 qi 前面。去掉 ng、hm、重复的 lue、nue,以及没有音节以它开头的字母(i、u、v)。 +- 候选里的错词:删掉内置词表里 18 个手写错词(泥壕、审么、屑屑、美关系、块乐、膨友……);模糊音候选排在原拼写候选后面;按前缀补全时按字频取音节,不再按字母顺序取到 96 个就停(输入 sh 到不了 shi,输入 x 到不了 xiang);带分词符的输入(xi'an)先查连写后的词。 +- 选词学习:只选了一部分输入时(先选“你”,剩下的 haoma 继续打),点中的词现在也会让 Rime 记住,分段选的词组常用后也能前移,不再只有一次选完的单字才会前移。剩余没选的输入不会被学习。 +- 英文模式的候选行在上方留白里偏上约 9dp,现在在工具栏条里垂直居中。 +- 一次点按两次反馈:系统在手指抬起时多播一次点击音,长按成功时多震一次(不受应用内开关控制),现在都关掉,只由键盘自己按下时反馈一次;文本编辑方向键、剪贴板“重新读取”、符号“管理自定义符号”、语音语言键、面板返回箭头、展开候选长按、退格上滑清空松手时,都不再多响一次;不可点的占位格不再响。长按弹窗打开时震一次,滑过格子和抬起选字时静音(和 HeliBoard、fcitx5 一致)。 +- 长按弹窗里滑到格子再抬起、按住 Shift 滑选时,第二根手指按下后不再多打一个字母;手指刚出 Shift 边缘不再改变 Shift 状态;弹窗里选英文字母会用掉“大写一次”;内嵌输入框有焦点时长按弹窗不再输入两次;长按后点同一个键的上半部分不再误触发上滑数字。 +- 分离键盘右半边的 G、V 现在能被“按住 Shift 滑选”选中;分离布局在设置面板打开时旋转屏幕,关掉面板后会按新方向重建。 +- 横屏:窗口高度改为取键盘自己的期望高度(新进程直接横屏浮动时窗口曾被压成 1 像素);在横屏里新建的键盘顶栏和“左右分离”按钮会立即生效;手动浮动后在横屏停靠,换输入框不再被自动浮起来。 +- 工具栏提示:验证码提取改为取与所有关键词的最小间距,排除 yyyyMMdd 日期,新增“动态密码、随机码、登录码、授权码”;点过的验证码不再重复出现;系统自动填充返回空结果时不再清掉键盘自己的提示;遵守“关闭个性化学习”的输入框(无痕);设置里“短信验证码”开关按真实权限显示;权限申请页旋转不重复请求,不进最近任务。 +- 文档:README 隐私一节补充可选的短信权限;测试清单、兼容性、端到端脚本同步长按行为和横屏默认浮动。 + - 英文键盘的大写:单击 Shift 输入一个大写;快速连点两下锁定大写;长按 Shift 直接锁定(再长按解除)。Shift 键三个状态图标不同:空心 = 小写,实心 = 下一个大写,实心带横线 = 锁定。 -- 键盘上方的工具栏降低 10dp(70 → 60),拼音行和候选行压矮;底部空白从 10dp 增到 17dp,键盘总高度净减 3dp。九键、26 键、数字、笔画键盘都一样。 +- 键盘上方的工具栏降低 10dp(70 → 60),拼音行和候选行压矮;键位上方留白 6dp → 4dp,底部空白 10dp → 19dp,键盘总高度净减 3dp。九键、26 键、数字、笔画键盘都一样。 - 26 键字母键长按也弹出选择面板(另一种大小写 + 右上角的数字或符号),按住滑到目标、松手输入,和搜狗、讯飞一致;上滑仍直接输入右上角的数字或符号。 - 回车键统一:输入拼音时是“确定”;编辑框要求了动作(发送、搜索、前往、下一项、完成)时显示动作文字并高亮;其余(换行、多行、无动作)一律是不高亮的“↵”。九键、26 键、笔画、数字键盘都按同一规则。 - 按键气泡和长按选择面板同高(52dp);长按选择面板缩小(格子 40×44dp),松手后没选就自动消失(松在原键上 1.5 秒后,没松手 4 秒后)。 @@ -18,8 +34,8 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs - 按住 Shift 滑到字母再松手,输入那一个大写字母,Shift 状态保持原样(和 Gboard 一样);松手在别处则什么都不输入。 - 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。 - 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右分离。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。左右分离参照搜狗:26 键分成左右两半,G、V 两边都有,两边各一个空格,中间留空;九键一边是九宫格(删除、重输、0、确定在左,符号列在右),另一边是常用字面板(的了我你是……),中间的“镜像”键左右互换。横屏工具栏多一个“左右分离键盘”按钮,点一下分离或恢复。 -- 横屏整宽键盘铺满屏宽(最宽 900dp),之前被缩成中间一小块。 -- 横屏顶栏压缩到只比按键行高一点(48dp),输入时拼音在左、候选在右同一行。 +- 横屏整宽键盘铺满屏宽,之前被缩成中间一小块。 +- 横屏顶栏压缩到只比按键行高一点(48dp),输入时拼音在左、候选在右同一行;横屏下按键下方的留白只有 6dp(竖屏仍是 19dp),横屏键盘高度相应降低。HeliBoard、fcitx5 的横屏底部留白为 0。 - 浮动键盘:整张卡片圆角并带细边,底部为系统“返回/切换输入法”按钮留出一条空位,不再盖住最下面一行。 ## [0.0.7-beta.1] - 2026-10-06 diff --git a/README.md b/README.md index 5e3fdae9..2809832c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库 - **手势**:删除键上滑清空;空格左右滑动移动光标,滑动时底行其他按键锁定。 - **表情联想**:选词后联想栏先给出相关表情(开心 → 😊),词表内置,不联网。 - **自动填充**:Android 11+ 上,密码管理器的账号、验证码直接显示在键盘工具栏位置。 -- **数字和符号**:26 键字母键右上角印着数字和符号,上滑或长按直接输入。 +- **数字和符号**:26 键字母键右上角印着数字和符号,上滑直接输入;长按弹出选择面板,滑到目标松手输入。 - **适配**:横竖屏、平板与折叠屏、深色模式、大字号;终端、远程桌面、游戏等原始按键输入框; 外接键盘可直接打拼音。详见 [兼容性说明](docs/COMPATIBILITY.md)。 - **自我保护**:连续崩溃或卡死后自动进入安全模式,保证仍然可以打字; @@ -63,6 +63,7 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库 - 应用不声明 `INTERNET` 权限,`allowBackup` 关闭;词典、语音模型和 runtime 都随 APK 提供。 - 语音音频只在内存中处理,结束、取消或失败时清空,不落盘。 - 密码输入框不组合拼音、不学习词库、不读取输入框内容、不写日志;可使用剪贴板历史(来源应用标记为敏感的内容和要求关闭个性化学习的输入框除外);语音可用,识别结果只在结束时一次性上屏。 +- 可选的“短信验证码”默认关闭,打开后才请求“读取短信”权限,只在本机读取最近 5 分钟的验证码;“复制内容提示”只显示 3 分钟内复制的文字,密码框和要求关闭个性化学习的输入框都不显示。 - 崩溃记录只保存异常类型和调用栈,不含输入内容。 - 卸载会清除本机全部数据,包括学习到的用户词库。 diff --git a/VERSION b/VERSION index a9b673b6..d3fe5be0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.7-beta.1 +0.0.8-beta.1 diff --git a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt index e1015a37..411d166e 100644 --- a/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/DisplayEnvironmentInstrumentedTest.kt @@ -92,9 +92,8 @@ class DisplayEnvironmentInstrumentedTest { @Test fun landscapeContentBoxKeepsEveryRowInsideIt() { - // A landscape configuration makes the reference scale follow the height, - // so the content box (padding on both sides) is narrower than the window. - // Rows used to keep the width they were measured with before that padding + // Landscape fills the whole width (padding 0), like Gboard and the Chinese keyboards. + // Rows used to keep the width they were measured with before the padding // was applied: as wide as the window, clipped to the box. for (mode in listOf(KeyboardMode.PINYIN_26, KeyboardMode.ENGLISH_26, KeyboardMode.PINYIN_9, KeyboardMode.DIGITS)) { withKeyboard( @@ -110,7 +109,7 @@ class DisplayEnvironmentInstrumentedTest { val body = keyboard.findViewWithTag("keyboard-body") assertNotNull("keyboard body", body) val content = body.width - body.paddingLeft - body.paddingRight - assertTrue("$mode must be laid out in a clamped content box (padding ${body.paddingLeft})", body.paddingLeft > 0) + assertEquals("$mode landscape fills the full width", 0, body.paddingLeft) for (index in 0 until body.childCount) { val row = body.getChildAt(index) assertTrue( diff --git a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt index b00c6af0..17961f56 100644 --- a/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt +++ b/app/src/androidTest/java/llc/slacker/openime/SpaceCursorAndLetterHintsInstrumentedTest.kt @@ -361,15 +361,27 @@ class SpaceCursorAndLetterHintsInstrumentedTest { } @Test - fun longPressTypesTheSameHint() = withKeyboard { harness, recorder, keyboard -> + fun longPressOffersTheHintInAPopup() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.PINYIN_26); true } - listOf('w', 'd', 'k', 'v').forEach { letter -> + listOf('w' to "2", 'd' to "@", 'k' to "*", 'v' to "_").forEach { (letter, hint) -> val key = settled(harness, keyboard, "key:$letter") assertTrue(harness.awaitMain { key.performLongClick() }) + // The popup lists the other case, then the hint; a tap on its cell types it. + assertTrue("popup cell $hint", harness.awaitMain { chooseFromPopup(keyboard, hint) }) } assertEquals(listOf("2", "@", "*", "_"), recorder.characters.toList()) } + /** Taps the long-press popup cell showing [text]; false when no such cell is on screen. */ + private fun chooseFromPopup(root: View, text: String): Boolean { + fun walk(view: View): android.widget.TextView? { + if (view is android.widget.TextView && view.text.toString() == text && view.hasOnClickListeners()) return view + if (view is android.view.ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))?.let { return it } + return null + } + return walk(root)?.performClick() == true + } + @Test fun aPlainTapStillTypesTheLetter() = withKeyboard { harness, recorder, keyboard -> harness.awaitMain { keyboard.setMode(KeyboardMode.ENGLISH_26); true } @@ -386,6 +398,7 @@ class SpaceCursorAndLetterHintsInstrumentedTest { assertTrue("swipe is off: ${recorder.characters}", recorder.characters.isEmpty()) harness.awaitMain { assertTrue("1" in texts(q)); true } assertTrue(harness.awaitMain { q.performLongClick() }) + assertTrue("popup cell 1", harness.awaitMain { chooseFromPopup(keyboard, "1") }) assertEquals("long press still works", listOf("1"), recorder.characters.toList()) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b820097b..55930f45 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,9 @@ @@ -903,7 +903,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can override fun onFloatingKeyboardChanged(floating: Boolean) { // A manual change replaces the automatic one: docking in landscape means // "not now", and floating by hand is the user's own choice. - if (!floating && landscapeAutoFloating && isLandscape()) landscapeFloatingDeclined = true + if (!floating && isLandscape()) landscapeFloatingDeclined = true landscapeAutoFloating = false keyboardView?.setFloatingWindowMode(floating) if (floating) floatingWindow.enable() else floatingWindow.restore() @@ -1736,6 +1736,7 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can val reference = entry.nativeReference val remaining = partialRemainder(reference, mode) if (remaining != null) { + reference?.let { learnPartialPick(it, entry.text) } finishPartialCandidateCommit(entry.text, remaining) return } @@ -1766,6 +1767,22 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can return normalized.substring(ref.consumed).trim('\'').ifEmpty { null } } + /** + * Teach librime the word the user tapped, and only that word: its own spelling is replayed + * as a separate selection, so the rest of the input (which nobody chose) is never learned. + * Stock Rime learns a run of picks the same way; without this a phrase chosen piece by piece + * never moves up, while a character typed alone does. + */ + private fun learnPartialPick(reference: NativeCandidateReference, text: String) { + if (!rime.isReady || !allowsPersonalizedLearning()) return + val spelling = RimeInputNormalizer.normalize(reference.input) + .take(reference.consumed) + .trim('\'') + if (spelling.isEmpty()) return + val deferred = NativeCandidateReference.deferred(spelling, text) + rime.selectCandidate(deferred.input, deferred.nativeIndex, true) + } + private fun finishPartialCandidateCommit(committed: String, remaining: String) { if (committed.isEmpty()) return // One atomic edit: commit the word and start the next composing span diff --git a/app/src/main/java/llc/slacker/openime/SmsPermissionActivity.kt b/app/src/main/java/llc/slacker/openime/SmsPermissionActivity.kt index ffb22b23..3561c43d 100644 --- a/app/src/main/java/llc/slacker/openime/SmsPermissionActivity.kt +++ b/app/src/main/java/llc/slacker/openime/SmsPermissionActivity.kt @@ -14,6 +14,8 @@ import llc.slacker.openime.data.ImeSettingsRepository class SmsPermissionActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Recreated while the dialog is open: the pending request still answers. + if (savedInstanceState != null) return if (checkSelfPermission(Manifest.permission.READ_SMS) == PackageManager.PERMISSION_GRANTED) { finish() return diff --git a/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt b/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt index aff7d82a..bf334b13 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/CandidateEngine.kt @@ -168,19 +168,17 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { } val result = linkedSetOf() + // What was typed comes first; a fuzzy reading (si for shi) only follows it. + result.addAll(ImeData.phraseDict[py].orEmpty()) + result.addAll(pinyinDict[py].orEmpty()) if (fuzzy) { fuzzyVariants(py).forEach { variant -> result.addAll(ImeData.phraseDict[variant].orEmpty()) } - } - result.addAll(ImeData.phraseDict[py].orEmpty()) - - if (fuzzy) { fuzzyVariants(py).forEach { variant -> result.addAll(pinyinDict[variant].orEmpty()) } } - result.addAll(pinyinDict[py].orEmpty()) // Compact phrase fallback used to scan every phrase on every key. // Jump to the sorted prefix interval, then restore source-map order so @@ -188,14 +186,21 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { phrasePrefixIndex.lookup(py).values.forEach { candidates -> result.addAll(candidates) } + // Syllables that continue the typed letters, most used first (an alphabetical + // walk ran out of room before shi, xiang, zhe, nei, dai). var prefixIndex = lowerBound(sortedPinyinKeys, py) - while (prefixIndex < sortedPinyinKeys.size) { + val continuations = ArrayList>>() + while (prefixIndex < sortedPinyinKeys.size && continuations.size < PREFIX_KEY_LIMIT) { val key = sortedPinyinKeys[prefixIndex] if (!key.startsWith(py)) break - result.addAll(pinyinDict[key].orEmpty().take(3)) - if (result.size >= 96) break + val top = pinyinDict[key].orEmpty().take(3) + if (top.isNotEmpty()) continuations += (PinyinLexicon.weightFor(key, top.first())) to top prefixIndex++ } + // Stable sort: equal weights keep the alphabetical order. + continuations.sortedByDescending { it.first }.forEach { (_, chars) -> + if (result.size < 96) result.addAll(chars) + } if (result.isEmpty() && py.length > 2) { val max = minOf(py.length, 6) @@ -266,6 +271,12 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { val key = parts.joinToString("|") val result = linkedSetOf() result.addAll(ImeData.segmentedPhraseDict[key].orEmpty()) + // A boundary inside a known word's spelling (xi'an, wo'lai) still finds the word. + val joined = parts.joinToString("") + result.addAll(ImeData.phraseDict[joined].orEmpty().filter { it.codePointCount(0, it.length) == parts.size }) + phrasePrefixIndex.lookup(joined).values.forEach { words -> + result.addAll(words.filter { it.codePointCount(0, it.length) == parts.size }) + } // The general fallback handles arbitrary boundaries even when a // phrase is not yet in the compact phrase table. Limit the product so @@ -572,6 +583,7 @@ class CandidateEngine(externalPinyin: Map> = emptyMap()) { } internal companion object { + private const val PREFIX_KEY_LIMIT = 400 const val MAX_NINE_KEY_DIGITS = 64 private const val MAX_NINE_MATCHES = 12 private const val MAX_LOCAL_RESOLVE_LENGTH = 32 diff --git a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt index 761c468e..25a02655 100644 --- a/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt +++ b/app/src/main/java/llc/slacker/openime/candidate/NineKeyLocalDecoder.kt @@ -407,8 +407,15 @@ internal class NineKeyLocalDecoder( // First syllables, each only if the rest can still be read. val firsts = LinkedHashMap() - paths.forEach { path -> firsts.putIfAbsent(path.syllables.first(), path.score) } + // Rank by the path's score per syllable: a plain sum let any two-syllable + // reading outrank a one-syllable one (xia before xiang, qi before qian). + paths.forEach { path -> + val first = path.syllables.first() + if (first !in RAIL_DUPLICATES) firsts.putIfAbsent(first, path.score / path.syllables.size) + } syllableOptions(bounded, preferred, limit * 2).forEach { syllable -> + if (syllable.length > 1 && syllable !in completeSyllables) return@forEach + if (syllable in RAIL_DUPLICATES) return@forEach val code = digitsForPinyin(syllable) ?: return@forEach if (code.length == bounded.length || canRead(bounded.substring(code.length))) { firsts.putIfAbsent(syllable, Int.MIN_VALUE) @@ -525,7 +532,7 @@ internal class NineKeyLocalDecoder( for (depth in 1..minOf(bounded.length, MAX_SYLLABLE_LENGTH)) { node = node.children[bounded[depth - 1]] ?: break node.exact.forEach { entry -> - if (!entry.phrase && entry.pinyin.length == depth && entry.pinyin.all { it in 'a'..'z' }) { + if (isSyllableEntry(entry) && entry.pinyin.length == depth) { found += Option(entry.pinyin, depth, entryScore(entry)) } } @@ -549,6 +556,8 @@ internal class NineKeyLocalDecoder( ImeData.keypad9Map[bounded] .orEmpty() .filter { it.length == 1 && it[0] in 'a'..'z' } + // A bare letter no syllable starts with (i, u, v) is a dead end. + .filter { letter -> completeSyllables.any { it.startsWith(letter) } } .forEach { if (it !in ordered) ordered += it } } return ordered.take(limit) @@ -568,22 +577,26 @@ internal class NineKeyLocalDecoder( merged.getOrPut(pinyin) { mutableListOf() }.addAll(values) } - return merged.mapNotNull { (pinyin, rawValues) -> - val digits = digitsForPinyin(pinyin) ?: return@mapNotNull null + return merged.flatMap { (pinyin, rawValues) -> + val digits = digitsForPinyin(pinyin) ?: return@flatMap emptyList() val values = rawValues.distinct().take(MAX_CANDIDATES) - if (values.isEmpty()) return@mapNotNull null - val corpusWeight = values.maxOfOrNull { value -> - PinyinLexicon.weightFor(pinyin, value) - } ?: 0 - Entry( + if (values.isEmpty()) return@flatMap emptyList() + fun entryOf(list: List, phrase: Boolean) = Entry( pinyin = pinyin, digits = digits, - candidates = values, - phrase = pinyin in ImeData.phraseDict || values.any { value -> - value.codePointCount(0, value.length) > 1 - }, - weight = corpusWeight, + candidates = list, + phrase = phrase, + weight = list.maxOfOrNull { value -> PinyinLexicon.weightFor(pinyin, value) } ?: 0, ) + val singles = values.filter { it.codePointCount(0, it.length) == 1 } + val multis = values.filter { it.codePointCount(0, it.length) > 1 } + if (singles.isNotEmpty() && multis.isNotEmpty()) { + // A syllable that is also a word's spelling (xian / 西安, liu / 浏览, pin / 拼音) + // is still a syllable: keep its characters apart from the words. + listOf(entryOf(singles, phrase = false), entryOf(multis, phrase = true)) + } else { + listOf(entryOf(values, phrase = pinyin in ImeData.phraseDict || multis.isNotEmpty())) + } } } @@ -734,6 +747,9 @@ internal class NineKeyLocalDecoder( private const val PER_PATH_CANDIDATES = 24 private const val PART_PENALTY = 55 + /** Spellings of lve / nve that the legacy table also holds; the rail shows one of each. */ + private val RAIL_DUPLICATES = setOf("lue", "nue") + /** The letters printed on each nine-key digit. */ private val KEY_LETTERS = mapOf( '2' to "abc", '3' to "def", '4' to "ghi", '5' to "jkl", diff --git a/app/src/main/java/llc/slacker/openime/core/ImeData.kt b/app/src/main/java/llc/slacker/openime/core/ImeData.kt index fc3708b0..a76e77bd 100644 --- a/app/src/main/java/llc/slacker/openime/core/ImeData.kt +++ b/app/src/main/java/llc/slacker/openime/core/ImeData.kt @@ -409,33 +409,33 @@ object ImeData { ) val phraseDict = mapOf( - "nihao" to listOf("你好", "您好", "泥壕", "拟好"), - "shurufa" to listOf("输入法", "输入方案", "数如发"), - "zhongguo" to listOf("中国", "重过", "中果"), + "nihao" to listOf("你好", "您好", "拟好"), + "shurufa" to listOf("输入法", "输入方案"), + "zhongguo" to listOf("中国", "重过"), "beijing" to listOf("北京", "背景", "倍加"), - "shanghai" to listOf("上海", "伤害", "上嗨"), - "gongzuo" to listOf("工作", "公作", "供桌"), - "xuexi" to listOf("学习", "血洗", "学系"), - "kaifa" to listOf("开发", "开花", "凯发"), + "shanghai" to listOf("上海", "伤害"), + "gongzuo" to listOf("工作", "供桌"), + "xuexi" to listOf("学习", "血洗"), + "kaifa" to listOf("开发", "开花"), "chengxuyuan" to listOf("程序员", "程序源"), - "rengongzhineng" to listOf("人工智能", "人工只能"), - "diannao" to listOf("电脑", "电闹"), + "rengongzhineng" to listOf("人工智能"), + "diannao" to listOf("电脑"), "shouji" to listOf("手机", "首记", "收紧"), "jianpan" to listOf("键盘", "建攀"), "weixin" to listOf("微信", "微芯", "威信"), "guge" to listOf("谷歌", "骨骼"), "tianqi" to listOf("天气", "田七"), - "xiexie" to listOf("谢谢", "屑屑"), + "xiexie" to listOf("谢谢"), "zaijian" to listOf("再见", "在建"), - "meiguanxi" to listOf("没关系", "美关系"), - "haode" to listOf("好的", "豪的", "浩德"), - "shenme" to listOf("什么", "审么"), + "meiguanxi" to listOf("没关系"), + "haode" to listOf("好的"), + "shenme" to listOf("什么"), "zenmeyang" to listOf("怎么样", "怎样"), - "mingtian" to listOf("明天", "名天"), + "mingtian" to listOf("明天"), "jintian" to listOf("今天", "金田"), - "zuotian" to listOf("昨天", "作天"), - "pengyou" to listOf("朋友", "膨友"), - "kuailer" to listOf("快乐", "块乐"), + "zuotian" to listOf("昨天"), + "pengyou" to listOf("朋友"), + "kuailer" to listOf("快乐"), "gongxi" to listOf("恭喜", "功勋"), // Keep the unsegmented full-Pinyin result first. The same word is // also available from the explicit "xi|an" boundary action below. diff --git a/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt b/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt index f09f7076..7fbe4020 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/BackspaceGestureController.kt @@ -175,7 +175,7 @@ internal class BackspaceGestureController( when { clearAll -> { - onHapticFeedback() + // The arming tick already told the user; no second one on release. onClearAll() } deleteOnce -> onDeleteOne() diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt index cbeea1ed..61c4c468 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeKeyboardView.kt @@ -27,6 +27,7 @@ import llc.slacker.openime.R import llc.slacker.openime.candidate.CandidatePipeline import llc.slacker.openime.candidate.CandidateResolver import llc.slacker.openime.candidate.NineKeyLocalDecoder +import llc.slacker.openime.candidate.personalizedLearningAllowed import llc.slacker.openime.candidate.NineKeyPerformanceTrace import llc.slacker.openime.candidate.NineKeyReading import llc.slacker.openime.candidate.StrokeLexicon @@ -334,6 +335,7 @@ open class ImeKeyboardView( /** Refresh data owned by auxiliary editor Activities when they return. */ private var dismissedSuggestion: String? = null + private var dismissedSmsCode: String? = null private var ownSuggestionsShown = false /** @@ -344,7 +346,7 @@ open class ImeKeyboardView( 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) + val found = ToolbarSuggestions.collect(context, allowed = personalizedLearningAllowed(passwordField, currentImeOptions()), skipClip = dismissedSuggestion, skipSms = dismissedSmsCode) if (found.isEmpty()) { if (ownSuggestionsShown) topZone.setAutofillChips(emptyList()) ownSuggestionsShown = false @@ -370,13 +372,17 @@ open class ImeKeyboardView( setOnClickListener { feedback() if (!insertIntoInlineEditor(suggestion.text)) listener.onCharacter(suggestion.text) - dismissedSuggestion = suggestion.text + if (suggestion.kind == ToolbarSuggestion.Kind.SMS_CODE) dismissedSmsCode = suggestion.text + else dismissedSuggestion = suggestion.text ownSuggestionsShown = false topZone.setAutofillChips(emptyList()) } } } - topZone.onAutofillDismissed = { dismissedSuggestion = found.lastOrNull { it.kind == ToolbarSuggestion.Kind.CLIPBOARD }?.text ?: dismissedSuggestion } + topZone.onAutofillDismissed = { + found.lastOrNull { it.kind == ToolbarSuggestion.Kind.CLIPBOARD }?.let { dismissedSuggestion = it.text } + found.firstOrNull { it.kind == ToolbarSuggestion.Kind.SMS_CODE }?.let { dismissedSmsCode = it.text } + } ownSuggestionsShown = true topZone.setAutofillChips(chips, chipHeightPx = dp(36)) } @@ -856,9 +862,9 @@ open class ImeKeyboardView( init { tag = "ime_root" - // Some IME windows inherit the host's disabled sound-effect flag. - // Keep the view channel enabled; the preference still gates feedback(). - isSoundEffectsEnabled = true + // Keys play their sound through AudioManager (KeySounds), not the view channel, + // so the platform's own click sound stays off. + isSoundEffectsEnabled = false val rootHeight = if (standalonePanel) { FrameLayout.LayoutParams.MATCH_PARENT } else { @@ -891,7 +897,7 @@ open class ImeKeyboardView( } keyboardBody.orientation = LinearLayout.VERTICAL keyboardBody.tag = "keyboard-body" - keyboardBody.setPadding(dp(0), dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), dp(0), dp(ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP)) + keyboardBody.setPadding(dp(0), dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), dp(0), dp(layoutMetrics.bottomPadDp)) expandedPanel.orientation = LinearLayout.VERTICAL expandedPanel.tag = "panel-overlay" expandedPanel.visibility = View.GONE @@ -990,6 +996,9 @@ open class ImeKeyboardView( appliedDensityDpi = newConfig.densityDpi layoutMetrics = buildLayoutMetrics() if (!geometryChanged) return + // The rows under an open panel are stale too (split layout, row heights): + // closing the panel must rebuild them. + renderedMode = null // Do not yank the user out of an open panel. applyDynamicHeights() if (standalonePanel) return @@ -1025,8 +1034,17 @@ open class ImeKeyboardView( return spaceVoiceGestureController.trackingTouch || walk(this) } + /** The height this view wants as a window: rows plus the system-bar strip; valid before the first measure. */ + internal fun desiredWindowHeightPx(): Int? = + if (standalonePanel) null else dp(imeHeightDp()) + navigationBottomInsetPx + override fun onAttachedToWindow() { super.onAttachedToWindow() + // A view created while already in landscape never sees a configuration change. + if (::topZone.isInitialized && !standalonePanel) { + topZone.setLandscapeStrip(layoutMetrics.landscape) + syncSplitToggle() + } requestApplyInsets() } @@ -1156,7 +1174,7 @@ open class ImeKeyboardView( // width before WindowManager applies the floating window bounds. // Keep the normal keyboard's content inset local to its window. contentInsetPx = dp(0) - keyboardBody.setPadding(contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP)) + keyboardBody.setPadding(contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, dp(layoutMetrics.bottomPadDp)) keyboardBody.findViewWithTag("key-row-secondary")?.let { row -> // The portrait layout narrows this row to 90% of the full // display for optical centering. A floating window can be @@ -1192,7 +1210,7 @@ open class ImeKeyboardView( contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, - dp(ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP), + dp(layoutMetrics.bottomPadDp), ) keyboardBody.findViewWithTag("key-row-secondary")?.let { row -> val rowWidth = ((measuredWidthPx - contentInsetPx * 2) * 0.9f).toInt() @@ -1305,7 +1323,6 @@ open class ImeKeyboardView( allowTwoLineLabel() contentDescription = "候选:$candidate" setOnLongClickListener { - feedback() listener.onCandidateLongPressed(candidate) true } @@ -1462,7 +1479,7 @@ open class ImeKeyboardView( if (!standalonePanel && panel == Panel.NONE) renderModeBody() if (enabled) { contentInsetPx = dp(0) - keyboardBody.setPadding(contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP)) + keyboardBody.setPadding(contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, dp(layoutMetrics.bottomPadDp)) expandedPanel.setPadding(contentInsetPx, 0, contentInsetPx, 0) candidateOverlay.setPadding(contentInsetPx, 0, contentInsetPx, 0) topZone.setContentInset(contentInsetPx) @@ -1603,6 +1620,9 @@ open class ImeKeyboardView( syncEnterKeyPresentation(state.editorInfo?.imeOptions) } + private fun currentImeOptions(): Int? = + (context as? android.inputmethodservice.InputMethodService)?.currentInputEditorInfo?.imeOptions + private fun syncEnterKeyPresentation(imeOptions: Int?) { val options = imeOptions ?: return val enter = findViewWithTag("key-enter") ?: return @@ -1623,6 +1643,11 @@ open class ImeKeyboardView( lateinit var controller: InlineAutofillController controller = InlineAutofillController(context, context.mainExecutor) { chips -> if (::topZone.isInitialized) { + // An empty response (the usual "nothing to fill") must not wipe this view's own chips. + if (chips.isEmpty() && ownSuggestionsShown) return@InlineAutofillController + // The system's chips replace this view's own; a later refresh must not touch them. + ownSuggestionsShown = false + topZone.onAutofillDismissed = null topZone.setAutofillChips(chips, controller.chipSize.width, controller.chipSize.height) } } @@ -2005,7 +2030,7 @@ open class ImeKeyboardView( contentInsetPx, dp(ImeGeometryTokens.KEYBOARD_TOP_PAD_DP), contentInsetPx, - dp(ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP), + dp(layoutMetrics.bottomPadDp), ) val state = when { inlineVoicePresenter.active -> ImeTopZoneState.VOICE_INLINE @@ -2104,16 +2129,9 @@ open class ImeKeyboardView( ) } - /** - * Resolve the Enter label from the bound editor at render time so the key is - * correct on its first frame (V2 keeps an idempotent re-sync as a safety net). - * Outside an InputMethodService host (settings/test) the legacy fallback is used. - */ /** 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 - return enterKeyFaceFor(imeOptions, composition.text?.isNotEmpty() == true) + return enterKeyFaceFor(currentImeOptions(), composition.text?.isNotEmpty() == true) } private fun renderPinyin9() { @@ -2185,6 +2203,7 @@ open class ImeKeyboardView( override fun dispatchTouchEvent(event: MotionEvent): Boolean { if (event.actionMasked == MotionEvent.ACTION_DOWN) { keyPopupController.hideIfOutside(event.x, event.y) + silencePlatformFeedback(this) } val handled = super.dispatchTouchEvent(event) // Slide-to-select: the finger that opened a long-press popup picks from it. @@ -2416,7 +2435,8 @@ open class ImeKeyboardView( "按键气泡" -> popupEnabled "上滑输入数字" -> ImeSettingsRepository.loadSwipeUpDigits(context) "复制内容提示" -> ImeSettingsRepository.loadClipboardChip(context) - "短信验证码" -> ImeSettingsRepository.loadSmsCodeChip(context) + "短信验证码" -> ImeSettingsRepository.loadSmsCodeChip(context) && + context.checkSelfPermission(android.Manifest.permission.READ_SMS) == android.content.pm.PackageManager.PERMISSION_GRANTED "数字和符号提示" -> ImeSettingsRepository.loadLetterHints(context) "表情联想" -> ImeSettingsRepository.loadEmojiAssociation(context) "语音去语气词" -> ImeSettingsRepository.loadVoiceStripFillers(context) @@ -2514,14 +2534,16 @@ open class ImeKeyboardView( /** A cell of a long-press popup. English letters join the word being typed; the rest commit at once. */ private fun onPopupChoice(text: String) { + if (insertIntoInlineEditor(text)) return val letter = text.singleOrNull() - if (mode == KeyboardMode.ENGLISH_26 && letter != null && letter.isLetter() && !insertIntoInlineEditor(text)) { + if (mode == KeyboardMode.ENGLISH_26 && letter != null && letter.isLetter()) { clearAssociationCandidates() val (py, selection) = replaceCompositionSelection(text) publishComposition(py, candidatesForComposition(py), selection) + if (shiftState == ShiftState.SHIFT_ONCE) applyShift(ShiftState.LOWERCASE) return } - if (!insertIntoInlineEditor(text)) listener.onCharacter(text) + listener.onCharacter(text) } private fun onKeyTapped(base: String) { @@ -3080,10 +3102,18 @@ open class ImeKeyboardView( shiftHeld = shift != null && rawContains(shift, event.rawX, event.rawY) shiftSliding = false } + // A second finger means the first one is typing, not sliding from Shift. + MotionEvent.ACTION_POINTER_DOWN -> if (!shiftSliding) shiftHeld = false MotionEvent.ACTION_MOVE -> { if (!shiftHeld || shiftSliding) return val shift = shiftKeyView() ?: return - if (!rawContains(shift, event.rawX, event.rawY)) { + // Beyond the touch slop, so Shift has already let go of the press (no click on lift). + val slop = android.view.ViewConfiguration.get(context).scaledTouchSlop + val x = event.rawX + val y = event.rawY + val outside = !rawContains(shift, x - slop, y) || !rawContains(shift, x + slop, y) || + !rawContains(shift, x, y - slop) || !rawContains(shift, x, y + slop) + if (outside) { shiftSliding = true shiftBeforeSlide = shiftState if (shiftState == ShiftState.LOWERCASE) applyShift(ShiftState.SHIFT_ONCE) @@ -3095,8 +3125,13 @@ open class ImeKeyboardView( shiftHeld = false shiftSliding = false if (!sliding) return - val letter = "qwertyuiopasdfghjklzxcvbnm".firstOrNull { ch -> - findViewWithTag("key:$ch")?.let { rawContains(it, event.rawX, event.rawY) } == true + // The split layout repeats G and V, so look at every key carrying the tag. + var letter: Char? = null + forEachKeyWithTag { tag, key -> + val ch = tag.removePrefix("key:").singleOrNull() + if (letter == null && tag.startsWith("key:") && ch != null && ch in 'a'..'z' && + rawContains(key, event.rawX, event.rawY) + ) letter = ch } if (letter != null && event.actionMasked == MotionEvent.ACTION_UP) { onKeyTapped(letter.toString()) @@ -3177,6 +3212,18 @@ open class ImeKeyboardView( if (soundEnabled) keySounds.play() } + /** + * Every key plays its own haptic and click (feedback()). The platform adds a second + * click sound on performClick and a LONG_PRESS buzz on a handled long click, so each + * view under the keyboard opts out of both. The root keeps its haptic channel: the + * system-style haptic goes through it. + */ + private fun silencePlatformFeedback(view: View) { + if (view.isSoundEffectsEnabled) view.isSoundEffectsEnabled = false + if (view !== this && view.isHapticFeedbackEnabled) view.isHapticFeedbackEnabled = false + if (view is ViewGroup) for (i in 0 until view.childCount) silencePlatformFeedback(view.getChildAt(i)) + } + /** Haptic-only confirmation (no key click sound), e.g. when voice arms. */ private fun hapticFeedback() { // A gesture threshold gets the same crisp click; LONG_PRESS rings for too long. @@ -3249,7 +3296,7 @@ open class ImeKeyboardView( isClickable = true isFocusable = true setOnTouchListener { _, event -> - if (event.actionMasked == MotionEvent.ACTION_DOWN && isEnabled) feedback() + if (event.actionMasked == MotionEvent.ACTION_DOWN && isEnabled && isClickable) feedback() false } } 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 53d9b776..5c6cc3ca 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ImeTopZone.kt @@ -457,7 +457,7 @@ internal class ImeTopZone( private var pressRawY = 0f private val longPressRunnable = Runnable { longPressArmed = true - performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS, HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING) } /** @@ -539,7 +539,7 @@ internal class ImeTopZone( 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 + composeZone.gravity = Gravity.CENTER_VERTICAL (composition.layoutParams as? LinearLayout.LayoutParams)?.let { if (on) { it.width = LinearLayout.LayoutParams.WRAP_CONTENT 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 952d5956..4db77ff4 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyPopupController.kt @@ -278,6 +278,8 @@ internal class KeyPopupController( popupView = column animateIn(column, popupWidth, popupHeight) scheduleAutoDismiss(OPEN_DISMISS_MS) + // The one buzz that says the long press worked (the platform's own is switched off). + hoverFeedback() } /** @@ -298,13 +300,12 @@ internal class KeyPopupController( hoveredCell?.isPressed = false cell?.isPressed = true hoveredCell = cell - if (cell != null) hoverFeedback() } return false } if (hit != null) { + // Silent, like the other open-source keyboards: the long press already buzzed once. hide() - feedback() onSymbolSelected(hit.second) return true } 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 53ad30a8..61e713d2 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/KeyboardGeometry.kt @@ -85,9 +85,12 @@ internal data class KeyboardLayoutMetrics( ImeGeometryTokens.COMPOSED_TOP_ZONE_HEIGHT_DP } + val bottomPadDp: Int = + if (landscape) ImeGeometryTokens.LANDSCAPE_BOTTOM_PAD_DP else ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP + val imeHeightDp: Int = run { val derived = topZoneHeightDp + - keyRowHeightDp * 4 + ImeGeometryTokens.KEYBOARD_TOP_PAD_DP + ImeGeometryTokens.KEYBOARD_BOTTOM_PAD_DP + keyRowHeightDp * 4 + ImeGeometryTokens.KEYBOARD_TOP_PAD_DP + bottomPadDp val baseMinimum = 0 val scaledMinimum = (baseMinimum * heightPercent.coerceIn(80, 120) / 100f).toInt() diff --git a/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt b/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt index df0f5498..75c12a4f 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/SmsCodeExtractor.kt @@ -6,14 +6,24 @@ package llc.slacker.openime.keyboard * 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("(? keywords.minOf { gap(run.range, it) } }?.value + } + + private fun gap(a: IntRange, b: IntRange): Int = when { + a.last < b.first -> b.first - a.last + b.last < a.first -> a.first - b.last + else -> 0 } } diff --git a/app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt b/app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt index ea9e14ca..878bed00 100644 --- a/app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt +++ b/app/src/main/java/llc/slacker/openime/keyboard/ToolbarSuggestions.kt @@ -19,10 +19,10 @@ internal object ToolbarSuggestions { private const val CLIP_MAX_CHARS = 200 /** Both reads are best-effort and silent: a refused permission or empty clipboard gives no chip. */ - fun collect(context: Context, allowed: Boolean, skipClip: String?): List { + fun collect(context: Context, allowed: Boolean, skipClip: String?, skipSms: String? = null): List { if (!allowed) return emptyList() val out = mutableListOf() - if (ImeSettingsRepository.loadSmsCodeChip(context)) smsCode(context)?.let { + if (ImeSettingsRepository.loadSmsCodeChip(context)) smsCode(context)?.takeIf { it != skipSms }?.let { out += ToolbarSuggestion(ToolbarSuggestion.Kind.SMS_CODE, "验证码 $it", it) } if (ImeSettingsRepository.loadClipboardChip(context)) freshClip(context)?.takeIf { it != skipClip }?.let { diff --git a/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt index 06d3c70b..ab999aaa 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ClipboardPanelController.kt @@ -222,7 +222,6 @@ internal class ClipboardPanelController( tag = "clipboard-refresh" contentDescription = "重新读取剪贴板" setOnClickListener { - onFeedback() render(reusePanel = true) } }, diff --git a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt index 100d7156..068aaccd 100644 --- a/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt +++ b/app/src/main/java/llc/slacker/openime/panel/ImePanelRenderer.kt @@ -452,7 +452,6 @@ internal class ImePanelRenderer( createPanelButton("管理自定义符号", ImeTypographyTokens.BODY_SP, true).apply { contentDescription = "管理自定义符号" setOnClickListener { - onFeedback() context.startActivity( Intent(context, SymbolManagerActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), diff --git a/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt b/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt index 887f0b41..db5c3e78 100644 --- a/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt +++ b/app/src/main/java/llc/slacker/openime/panel/PanelHeaderFactory.kt @@ -31,8 +31,8 @@ internal class PanelHeaderFactory( context = context, title = name, backContentDescription = "返回$backTarget", + onPress = onFeedback, ) { - onFeedback() onBack() }.apply { tag = "panel-head" @@ -63,10 +63,7 @@ internal class PanelHeaderFactory( } false } - setOnClickListener { - onFeedback() - onBack() - } + setOnClickListener { onBack() } }, LinearLayout.LayoutParams(toPx(ImeGeometryTokens.TOUCH_TARGET_DP), toPx(ImeGeometryTokens.TOUCH_TARGET_DP)), ) diff --git a/app/src/main/java/llc/slacker/openime/panel/TextEditorPanelController.kt b/app/src/main/java/llc/slacker/openime/panel/TextEditorPanelController.kt index 0cdd6ac4..363a1d86 100644 --- a/app/src/main/java/llc/slacker/openime/panel/TextEditorPanelController.kt +++ b/app/src/main/java/llc/slacker/openime/panel/TextEditorPanelController.kt @@ -61,7 +61,7 @@ internal class TextEditorPanelController( createPanelButton(label, if (center) 14f else 24f, false).apply { if (action != null) { contentDescription = when (action) { "up" -> "上"; "down" -> "下"; "left" -> "左"; else -> "右" } - setOnClickListener { onFeedback(); onTextEdit(action) } + setOnClickListener { onTextEdit(action) } } else { tag = if (center) "textedit-center" else "textedit-spacer" isClickable = false; isFocusable = false diff --git a/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt b/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt index 0646f9b2..d7d2d99a 100644 --- a/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt +++ b/app/src/main/java/llc/slacker/openime/setup/SetupUi.kt @@ -376,6 +376,8 @@ object SetupUi { context: Context, title: String, backContentDescription: String = "返回", + /** When set, the keyboard's own feedback plays on touch-down instead of the system haptic. */ + onPress: (() -> Unit)? = null, onBack: () -> Unit, ): LinearLayout = LinearLayout(context).apply { @@ -412,8 +414,16 @@ object SetupUi { } else { background = null } + if (onPress != null) { + isHapticFeedbackEnabled = false + isSoundEffectsEnabled = false + setOnTouchListener { _, event -> + if (event.actionMasked == android.view.MotionEvent.ACTION_DOWN) onPress() + false + } + } setOnClickListener { - performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + if (onPress == null) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) onBack() } }, diff --git a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt index 220f24a7..fca766a6 100644 --- a/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt +++ b/app/src/main/java/llc/slacker/openime/theme/ImeDesignTokens.kt @@ -61,6 +61,8 @@ internal object ImeGeometryTokens { // Space under the bottom row (the keys sit 7dp higher than they used to). const val KEYBOARD_TOP_PAD_DP = 4 const val KEYBOARD_BOTTOM_PAD_DP = 19 + // Landscape keys are short, so the gap under them shrinks (HeliBoard and fcitx5 use none). + const val LANDSCAPE_BOTTOM_PAD_DP = 6 const val LANDSCAPE_TOP_ZONE_HEIGHT_DP = 48 } diff --git a/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt b/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt index b9450cf6..d20aa18c 100644 --- a/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt +++ b/app/src/main/java/llc/slacker/openime/voice/VoicePanelView.kt @@ -80,7 +80,7 @@ internal class VoicePanelView( addView(hero, LayoutParams(toPx(88), toPx(88)).apply { bottomMargin = toPx(14) }) languageButton = createButton(LANGUAGES[0].first, ImeTypographyTokens.BODY_SP, true).apply { tag = "voice-language" - setOnClickListener { onFeedback(); languageIndex = 0; onLanguageChanged(languageIndex); updateLanguagePresentation(false) } + setOnClickListener { languageIndex = 0; onLanguageChanged(languageIndex); updateLanguagePresentation(false) } } val languageTrack = LinearLayout(context).apply { orientation = HORIZONTAL; tag = "segmented-track"; setPadding(toPx(2), toPx(2), toPx(2), toPx(2)) diff --git a/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt b/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt index 75b8b622..9eb9c715 100644 --- a/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt +++ b/app/src/main/java/llc/slacker/openime/widget/ImeKeyView.kt @@ -118,6 +118,7 @@ class ImeKeyView( override fun dispatchTouchEvent(event: MotionEvent): Boolean { if (touchLocked) return true + if (event.actionMasked == MotionEvent.ACTION_DOWN) longPressed = false if (interceptSwipeUp(event)) return true when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { diff --git a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt index 37fd2cfb..11b118d3 100644 --- a/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt +++ b/app/src/test/java/llc/slacker/openime/CandidatePipelineTest.kt @@ -174,6 +174,26 @@ class CandidatePipelineTest { assertEquals(readings.map { it.display }.distinct(), readings.map { it.display }) } + @Test + fun aSyllableThatAlsoSpellsAWordStaysInTheRail() { + // xian is also the key of 西安, liu of 浏览, pin of 拼音: they are still syllables. + mapOf("9426" to "xian", "548" to "liu", "746" to "pin").forEach { (digits, syllable) -> + val shown = pipeline.nineKeyReadingsFor(digits, null).map { it.display } + assertTrue("$syllable missing for $digits: $shown", syllable in shown) + } + } + + @Test + fun aSyllableThatSpellsAllTheDigitsOutranksAShorterOne() { + assertEquals("xiang", pipeline.nineKeyReadingsFor("94264", null).first().display) + } + + @Test + fun theRailOffersNoInterjectionsOrDuplicateSpellings() { + val shown = listOf("64", "64426", "583", "683").flatMap { pipeline.nineKeyReadingsFor(it, null) }.map { it.display } + assertTrue(shown.none { it in setOf("ng", "hm", "lue", "nue") }) + } + @Test fun theSyllableOfTheShownPreviewLeads() { assertEquals("mi", pipeline.nineKeyReadingsFor("64426", "migao").first().display) diff --git a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt index caa86703..75c46ae3 100644 --- a/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt +++ b/app/src/test/java/llc/slacker/openime/KeyboardLayoutMetricsTest.kt @@ -37,7 +37,7 @@ class KeyboardLayoutMetricsTest { 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, + ImeGeometryTokens.KEYBOARD_TOP_PAD_DP + ImeGeometryTokens.LANDSCAPE_BOTTOM_PAD_DP, metrics.imeHeightDp) } diff --git a/app/src/test/java/llc/slacker/openime/SmsCodeExtractorTest.kt b/app/src/test/java/llc/slacker/openime/SmsCodeExtractorTest.kt index cfb9160e..93c2aad6 100644 --- a/app/src/test/java/llc/slacker/openime/SmsCodeExtractorTest.kt +++ b/app/src/test/java/llc/slacker/openime/SmsCodeExtractorTest.kt @@ -18,6 +18,18 @@ class SmsCodeExtractorTest { assertEquals("8802", SmsCodeExtractor.extract("订单 20261007123 已发货,验证码 8802")) } + @Test + fun aCodeBeforeTheKeywordBeatsALaterNumber() { + assertEquals("1234", SmsCodeExtractor.extract("1234是验证码,订单号 56789012")) + } + + @Test + fun datesAndPlainWordsAreNotCodes() { + assertNull(SmsCodeExtractor.extract("验证码有效期至20261009")) + assertEquals("123456", SmsCodeExtractor.extract("动态密码为123456")) + assertNull(SmsCodeExtractor.extract("hotpot 12345 ready")) + } + @Test fun messagesWithoutAKeywordOrCodeGiveNothing() { assertNull(SmsCodeExtractor.extract("您的快递 123456 已到达,请取件")) diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 9f049c70..9dc0ff44 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -32,7 +32,7 @@ Android 11 以下和不支持内嵌建议的提供者仍使用系统的下拉菜 ## 显示环境 -横屏(不进入全屏提取模式,键盘是底部面板)、字体 130% / 200%(按键标签最多放大到 1.3 倍,功能键标签自动缩小)、 +横屏(不进入全屏提取模式;默认浮动,可选整宽或左右分离)、字体 130% / 200%(按键标签最多放大到 1.3 倍,功能键标签自动缩小)、 深色、小屏、窄屏、平板竖 / 横、折叠屏内屏。验证:`scripts/display_matrix_regression.py`(断言底部面板且每个键都在窗口内)、 `DisplayEnvironmentInstrumentedTest`、`scripts/beta3_e2e.py`(横屏下空格滑动光标、字母键数字和符号、表情联想、语音处理、自动填充)。 diff --git a/docs/NINE_KEY_REFERENCE.md b/docs/NINE_KEY_REFERENCE.md index 8486bdae..ff286ac0 100644 --- a/docs/NINE_KEY_REFERENCE.md +++ b/docs/NINE_KEY_REFERENCE.md @@ -35,7 +35,8 @@ 剩下的数字再拼成字母。**预编辑任何时候都只有字母,不出现数字**:没有词可依时,本地按「最少的完整音节、 末尾允许未打完的音节开头」拼出(`46` → `go`)。 7. **选词只覆盖一部分输入时,只上屏该词,剩余输入继续作为预编辑**(九键 / 26 键一致)。 - 本地不对部分选词做原生学习(Rime 的整句提交会把没选过的短语写进用户词库)。 + 部分选词只学习用户点的那个词:用它自己的拼写单独选一次让 Rime 记住,剩余输入不会被学习 + (Rime 的整句提交会把没选过的短语写进用户词库)。这样分段选词的词组也能因常用而前移。 8. **空格提交首选词;回车(确定)提交已输入的拼音原文**(Rime / fcitx / Gboard 拼音的约定)。 ## 删除键手势 diff --git a/docs/TEST_SOP.md b/docs/TEST_SOP.md index 015bd1eb..1d3e34d4 100644 --- a/docs/TEST_SOP.md +++ b/docs/TEST_SOP.md @@ -162,7 +162,7 @@ OpenIME 固定语料包含中文问句、提醒句、英文句、中英混合、连续 30 秒、环境噪音、静音和口述标点。 离线必须可用,权限拒绝可恢复,密码框可用语音,但只在结束时一次性上屏最终结果(不显示中间结果、不进入热词与纠错学习),不保留无必要 PCM/临时文本。 -按键气泡必须锚定当前键,宽约 1~1.2 倍键宽、高 70~80dp,边缘自动内收。震动在按下 +按键气泡必须锚定当前键,宽约 1~1.2 倍键宽、高约 52dp,边缘自动内收。震动在按下 瞬间仅触发一次,默认轻,尊重系统触觉开关,快速打字不形成沉重连续震动。 ## 10. 模式、浮动键盘和面板 diff --git a/docs/TEST_SOP_CHECKLIST.md b/docs/TEST_SOP_CHECKLIST.md index b182efa6..93ecd733 100644 --- a/docs/TEST_SOP_CHECKLIST.md +++ b/docs/TEST_SOP_CHECKLIST.md @@ -62,7 +62,7 @@ - [ ] 常用语分类及模板增删改、插入、重启和升级保留通过。 - [ ] 工具与全部设置页面可滚动、无死按钮、返回正确、无旧主题/圆角选项。 - [ ] 浮动键盘可拖到五个位置和边缘,横竖屏后重新约束且可恢复停靠。 -- [ ] 中文/英文 26 键每个字母键右上角有数字或符号提示;上滑和长按都输入提示、点按仍输入字母;中文模式输入全角(! ¥ ? ( ) : ;)、英文模式半角;横屏同样可用;关闭“数字和符号提示”后提示和手势一起消失,布局不变。 +- [ ] 中文/英文 26 键每个字母键右上角有数字或符号提示;上滑直接输入提示、长按弹出选择面板(另一种大小写 + 提示,按住滑到目标松手输入)、点按仍输入字母;中文模式输入全角(! ¥ ? ( ) : ;)、英文模式半角;横屏同样可用;关闭“数字和符号提示”后提示和手势一起消失,布局不变。 - [ ] 选“开心”后联想栏最前面是 😊 😄 😁;关闭“表情联想”后不再出现;密码框不出现。 - [ ] 自动填充(Android 11+):登录框里条带显示账号,点一下填入并回到工具栏;“‹”收起条带;普通输入框不出现;横屏同样可用。 diff --git a/scripts/beta3_e2e.py b/scripts/beta3_e2e.py index 8ba29042..a916c9bb 100644 --- a/scripts/beta3_e2e.py +++ b/scripts/beta3_e2e.py @@ -330,7 +330,7 @@ def case_letter_hints(dev: Device, landscape: bool) -> list[str]: problems.append(f"{mode}: {shown} hints shown while the setting is off") if not enabled: continue - # Real gestures: swipe up and long press type the hint, a tap types the letter. + # Real gestures: swipe up types the hint, a long press only opens the popup, a tap types the letter. for letter in ("q", "s", "g", "z"): key = key_node(nodes, letter) if not key: @@ -342,11 +342,13 @@ def case_letter_hints(dev: Device, landscape: bool) -> list[str]: typed = field_text(dev) if typed != before + hint: problems.append(f"{mode}: swipe up on {letter} typed {typed[len(before):]!r}, expected {hint!r}") + # A long press now opens the choice popup (other case + hint); lifting on the key + # types nothing, and the popup closes by itself. dev.shell("input", "swipe", str(key.cx), str(key.cy), str(key.cx), str(key.cy), "900") - time.sleep(1) + time.sleep(1.8) held = field_text(dev) - if held != typed + hint: - problems.append(f"{mode}: long press on {letter} typed {held[len(typed):]!r}, expected {hint!r}") + if held != typed: + problems.append(f"{mode}: long press on {letter} typed {held[len(typed):]!r}, expected nothing") dev.shot(f"{mode}-hints-typed{'-landscape' if landscape else ''}") if len(space_tops) == 2 and abs(space_tops[True] - space_tops[False]) > 2: problems.append(f"the hints changed the keyboard layout (space at {space_tops[True]} vs {space_tops[False]})")