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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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 @@ +