Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs

## [Unreleased]

- 英文键盘的大写:单击 Shift 输入一个大写;快速连点两下锁定大写;长按 Shift 直接锁定(再长按解除)。Shift 键三个状态图标不同:空心 = 小写,实心 = 下一个大写,实心带横线 = 锁定。
- 键盘上方的工具栏降低 10dp(70 → 60),拼音行和候选行压矮;底部空白从 10dp 增到 17dp,键盘总高度净减 3dp。九键、26 键、数字、笔画键盘都一样。
- 26 键字母键长按也弹出选择面板(另一种大小写 + 右上角的数字或符号),按住滑到目标、松手输入,和搜狗、讯飞一致;上滑仍直接输入右上角的数字或符号。
- 回车键统一:输入拼音时是“确定”;编辑框要求了动作(发送、搜索、前往、下一项、完成)时显示动作文字并高亮;其余(换行、多行、无动作)一律是不高亮的“↵”。九键、26 键、笔画、数字键盘都按同一规则。
- 按键气泡和长按选择面板同高(52dp);长按选择面板缩小(格子 40×44dp),松手后没选就自动消失(松在原键上 1.5 秒后,没松手 4 秒后)。
- 候选字背景上下各缩 0.5dp,工具栏总高不变;键盘整体再上抬 2dp。
- 工具栏提示:刚复制的文字(3 分钟内)显示在工具栏,点一下输入;设置里新增“短信验证码”(默认关闭):打开后请求“读取短信”权限,5 分钟内的验证码短信显示成“验证码 123456”,点一下输入。两项都只在本机读取,不联网;密码框不显示。可在“设置 → 工具栏提示”关闭。
- 按住 Shift 滑到字母再松手,输入那一个大写字母,Shift 状态保持原样(和 Gboard 一样);松手在别处则什么都不输入。
- 长按后滑动选择:九键长按弹出的“数字 + 大小写字母”面板,按住不放滑到目标、松手即输入;松手在原按键上则面板保留,可以再点。小写和数字一行离手指最近,大写在上一行;横屏空间不够时合成一行。长按“1”(@ # /)同样支持。长按之后不再同时触发上滑数字。
- 横屏布局可选:设置里新增“横屏布局”:浮动(默认)、整宽、左右分离。浮动模式自动进入、转回竖屏自动恢复;手动贴底后本次横屏不再自动浮动。左右分离参照搜狗:26 键分成左右两半,G、V 两边都有,两边各一个空格,中间留空;九键一边是九宫格(删除、重输、0、确定在左,符号列在右),另一边是常用字面板(的了我你是……),中间的“镜像”键左右互换。横屏工具栏多一个“左右分离键盘”按钮,点一下分离或恢复。
- 横屏整宽键盘铺满屏宽(最宽 900dp),之前被缩成中间一小块。
- 横屏顶栏压缩到只比按键行高一点(48dp),输入时拼音在左、候选在右同一行。
- 浮动键盘:整张卡片圆角并带细边,底部为系统“返回/切换输入法”按钮留出一条空位,不再盖住最下面一行。

## [0.0.7-beta.1] - 2026-10-06

第七个测试版:九键预编辑不再出现数字,拼音栏点哪个都管用,符号、表情、颜文字大幅补全。从 0.0.6-beta.1 覆盖安装即可,用户数据保留。
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Key-press clicks drive the vibrator directly (normal permission, granted at install). -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Optional: show a fresh verification code from a text message in the toolbar. Off until the user turns it on. -->
<uses-permission android:name="android.permission.READ_SMS" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />

<application
android:allowBackup="false"
Expand All @@ -27,6 +30,11 @@
</intent-filter>
</activity>

<activity
android:name=".SmsPermissionActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />

<activity
android:name=".ImeSettingsActivity"
android:exported="false"
Expand Down
42 changes: 41 additions & 1 deletion app/src/main/java/llc/slacker/openime/LocalVoiceImeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ 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.core.LandscapeLayout
import llc.slacker.openime.keyboard.EnglishShiftPolicy
import llc.slacker.openime.keyboard.HardwareContext
import llc.slacker.openime.keyboard.HardwareKey
Expand Down Expand Up @@ -126,6 +127,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
},
)
}
/** The landscape setting floated the keyboard; a floating one the user chose is never touched. */
private var landscapeAutoFloating = false
/** The user docked the keyboard during this landscape session; leave it docked until rotation. */
private var landscapeFloatingDeclined = false
private var voiceComposing = false
private var voiceAutoCommitOnFinal = true
private val voiceCorrectionTracker by lazy {
Expand Down Expand Up @@ -322,9 +327,39 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
// Orientation changes affect keyboard geometry, not the user's chosen
// window mode. A manually floating keyboard stays floating; a docked
// keyboard stays docked.
applyLandscapeLayout()
floatingWindow.onConfigurationChanged()
}

private fun isLandscape() =
resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE

/**
* Landscape layout setting: floating (default), full width, or split halves.
* Only the transitions the setting itself caused are undone: a keyboard the
* user floated from the tools page stays floating in both orientations.
*/
private fun applyLandscapeLayout() {
val landscape = isLandscape()
val layout = ImeSettingsRepository.loadLandscapeLayout(this)
if (!landscape) landscapeFloatingDeclined = false
val wantFloating = landscape && layout == LandscapeLayout.FLOATING && !landscapeFloatingDeclined
if (wantFloating && !floatingWindow.enabled) {
landscapeAutoFloating = true
keyboardView?.setFloatingWindowMode(true, compact = true)
floatingWindow.enable(resetPosition = true)
} else if (!wantFloating && landscapeAutoFloating) {
landscapeAutoFloating = false
keyboardView?.setFloatingWindowMode(false)
floatingWindow.restore()
}
}

override fun onLandscapeLayoutChanged(layout: LandscapeLayout) {
landscapeFloatingDeclined = false
applyLandscapeLayout()
}

override fun onEvaluateInputViewShown(): Boolean {
// The emulator and some Chromebooks expose a hardware keyboard, so
// InputMethodService's default policy suppresses the on-screen view
Expand Down Expand Up @@ -503,8 +538,9 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
override fun onStartInputView(attribute: EditorInfo?, restarting: Boolean) {
super.onStartInputView(attribute, restarting)
ensureInputViewAfterFinish()
applyLandscapeLayout()
if (floatingWindow.enabled) {
keyboardView?.setFloatingWindowMode(true)
keyboardView?.setFloatingWindowMode(true, compact = landscapeAutoFloating)
floatingWindow.reapply()
} else {
floatingWindow.restore()
Expand Down Expand Up @@ -865,6 +901,10 @@ class LocalVoiceImeService : InputMethodService(), ImeKeyboardView.Listener, Can
}

override fun onFloatingKeyboardChanged(floating: Boolean) {
// A manual change replaces the automatic one: docking in landscape means
// "not now", and floating by hand is the user's own choice.
if (!floating && landscapeAutoFloating && isLandscape()) landscapeFloatingDeclined = true
landscapeAutoFloating = false
keyboardView?.setFloatingWindowMode(floating)
if (floating) floatingWindow.enable() else floatingWindow.restore()
}
Expand Down
34 changes: 34 additions & 0 deletions app/src/main/java/llc/slacker/openime/SmsPermissionActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package llc.slacker.openime

import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Bundle
import llc.slacker.openime.data.ImeSettingsRepository

/**
* Asks for the text-message permission the toolbar's verification-code chip
* needs. A keyboard cannot show a permission dialog itself, so the setting opens
* this invisible screen. Refusing turns the setting back off.
*/
class SmsPermissionActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (checkSelfPermission(Manifest.permission.READ_SMS) == PackageManager.PERMISSION_GRANTED) {
finish()
return
}
requestPermissions(arrayOf(Manifest.permission.READ_SMS), REQUEST)
}

override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, 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
}
}
21 changes: 21 additions & 0 deletions app/src/main/java/llc/slacker/openime/core/LandscapeLayout.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package llc.slacker.openime.core

/**
* How the keyboard sits when the phone is held sideways.
*
* WeChat Keyboard floats by itself in landscape and Gboard does the same; the
* Chinese keyboards otherwise stretch the full width. Sogou also offers a split
* keyboard, one half under each thumb. Floating is the default because a
* full-width keyboard of four rows covers most of a landscape screen.
*/
enum class LandscapeLayout(val key: String, val label: String) {
FLOATING("floating", "浮动"),
FULL("full", "整宽"),
SPLIT("split", "左右分离");

companion object {
// "side" was an earlier name of the split layout.
fun fromKey(key: String?): LandscapeLayout =
entries.firstOrNull { it.key == key } ?: if (key == "side") SPLIT else FLOATING
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -29,6 +30,10 @@ object ImeSettingsRepository {
private const val KEY_KEYBOARD_HEIGHT = "keyboard_height_percent"
private const val KEY_FLOATING_WIDTH = "floating_width_percent"
private const val KEY_FLOATING_OPACITY = "floating_opacity_percent"
private const val KEY_LANDSCAPE_LAYOUT = "landscape_layout"
private const val KEY_SPLIT_MIRRORED = "split_mirrored"
private const val KEY_SMS_CODE_CHIP = "sms_code_chip"
private const val KEY_CLIPBOARD_CHIP = "clipboard_chip"

/**
* The user's preferred Chinese layout (26-key vs 9-key). Only PINYIN_26 and
Expand Down Expand Up @@ -59,6 +64,37 @@ object ImeSettingsRepository {
.edit().putInt(KEY_KEYBOARD_HEIGHT, percent.coerceIn(80, 120)).apply()
}

fun loadSplitMirrored(context: Context): Boolean =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_SPLIT_MIRRORED, false)

fun saveSplitMirrored(context: Context, mirrored: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_SPLIT_MIRRORED, mirrored).apply()
}

fun loadSmsCodeChip(context: Context): Boolean =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_SMS_CODE_CHIP, false)

fun saveSmsCodeChip(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_SMS_CODE_CHIP, enabled).apply()
}

fun loadClipboardChip(context: Context): Boolean =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_CLIPBOARD_CHIP, true)

fun saveClipboardChip(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_CLIPBOARD_CHIP, enabled).apply()
}

fun loadLandscapeLayout(context: Context): LandscapeLayout =
LandscapeLayout.fromKey(
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_LANDSCAPE_LAYOUT, null),
)

fun saveLandscapeLayout(context: Context, layout: LandscapeLayout) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_LANDSCAPE_LAYOUT, layout.key).apply()
}

fun loadFloatingWidthPercent(context: Context): Int =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getInt(KEY_FLOATING_WIDTH, 100)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package llc.slacker.openime.keyboard

import android.text.InputType
import llc.slacker.openime.R
import llc.slacker.openime.core.ShiftState

/**
Expand All @@ -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
}
}
Loading
Loading