Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ GitHub Release 的发布说明;版本与发布流程见 [docs/RELEASE.md](docs

## [Unreleased]

### 新增
- 语音词表:内置科技与开发、应用与品牌、游戏与电竞三个词表,也可以在“关于与数据 → 管理词表”导入自己的文本词表(每行一个词)。语音识别结束后,读音相同、字不同的词会改成词表里的写法,例如“大爷”改成“打野”。全部在本机完成,不联网;游戏词表默认关闭。
- 26 键底行在空格右边增加“,”键:点按输入逗号,长按输入句号(右上角有提示);英文模式下是 , 和 . 。
- 九键长按:长按带字母的按键弹出“数字 + 小写字母 + 大写字母”两行选择,不用切换键盘;长按“1”仍是 @ # /。
- 九键上滑输入数字:在按键上向上一划直接输入该键的数字,可在“设置 → 按键与输入 → 上滑输入数字”关闭,设置会随用户数据一起导出和导入。
- 语音词表同时用于打字:启用的词表里的词,在 26 键拼音输入与其全拼完全一致时,会排到第一候选之后(最多 3 个),词典里没有的词也能出现。

### 变更
- 内部:源码按功能分成 theme、core、editor、data、setup、widget、floating、handwriting、rime、candidate、hotword、voice、panel、keyboard 等包,依赖方向由测试固定;语音层不再直接依赖键盘视图和 Service。不影响功能。
- 26 键底行左右对称:空格键现在位于键盘正中,“中/英”键变窄。
- 九键底行:空格键更宽,两侧的“123”和“中/英”键变窄。
- 英文模式不再自动首字母大写,键帽显示小写字母;只有要求全大写的输入框才默认开启大写锁定,随时可以点 Shift 输入大写。
- 26 键字母整体小一号(21sp 改为 19sp)。
- 浮动键盘的顶部工具栏精简为:切换键盘、常用语、表情、更多、收起;不再显示文本编辑和撤销,常用语一键直达。
- 浮动键盘横屏时和竖屏一样大:宽度按竖屏宽度计算,键高保持竖屏尺寸,不再用横屏的扁平键盘。
- 浮动键盘可以长按顶部区域(工具栏、候选栏)后拖动来移动,原有的拖动手柄仍可用。

## [0.0.1-beta.1] - 2026-10-03

openIME 的首个公开测试版。功能已经可以日常试用,但稳定性和兼容性还在验证,请不要把它作为唯一的输入法;欢迎在 Issues 反馈问题。
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ openIME 是一款独立的 Android 系统输入法。拼音候选、用户词库
引擎为 librime,内置约 90 万条 Rime Ice 词典记录;首次部署完整词典期间,高频快速词库先提供候选。
- **九键**:输入时左栏列出下一个字的拼音,一个字选一个音节,选定后自动移到下一个字。
- **语音输入**:长按空格说话,松手后识别并上屏;使用内置的中英双语模型,不联网。
- **语音词表**:内置科技、应用、游戏词表,可导入自己的文本词表;识别后把同音词改成词表写法,不联网。
- **手势**:删除键上滑清空,5 秒内下滑可撤回。
- **适配**:横竖屏、平板与折叠屏、深色模式、大字号;终端、远程桌面、游戏等原始按键输入框;
外接键盘可直接打拼音。详见 [兼容性说明](docs/COMPATIBILITY.md)。
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package llc.slacker.openime

import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Rect
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.StateListDrawable
import android.content.res.Configuration
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.InputDevice
import android.view.MotionEvent
import android.view.View
Expand All @@ -16,6 +16,20 @@ import android.view.ViewGroup
import android.widget.ScrollView
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import llc.slacker.openime.core.ImeState
import llc.slacker.openime.core.KeyboardMode
import llc.slacker.openime.core.Panel
import llc.slacker.openime.data.CustomSymbolRepository
import llc.slacker.openime.data.QuickPhraseRepository
import llc.slacker.openime.floating.FloatingWindowController
import llc.slacker.openime.keyboard.ImeKeyboardView
import llc.slacker.openime.theme.AccentPalette
import llc.slacker.openime.theme.ImeAppearance
import llc.slacker.openime.theme.ImeGeometryTokens
import llc.slacker.openime.theme.ImeSurfacePolicy
import llc.slacker.openime.theme.ImeTheme
import llc.slacker.openime.voice.VoiceModelLifecycleState
import llc.slacker.openime.voice.VoiceRecognitionEvents
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package llc.slacker.openime

import android.view.View
import android.view.ViewGroup
import android.widget.ScrollView
import android.widget.EditText
import android.widget.ScrollView
import androidx.test.ext.junit.runners.AndroidJUnit4
import llc.slacker.openime.core.ImeState
import llc.slacker.openime.keyboard.ImeKeyboardView
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,22 @@ import android.view.ViewGroup
import android.view.inputmethod.BaseInputConnection
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import llc.slacker.openime.core.KeyboardMode
import llc.slacker.openime.core.Panel
import llc.slacker.openime.core.ShiftState
import llc.slacker.openime.data.ClipboardEntry
import llc.slacker.openime.data.ClipboardHistoryRepository
import llc.slacker.openime.editor.InputConnectionGateway
import llc.slacker.openime.keyboard.ImeKeyboardView
import llc.slacker.openime.theme.ImeAppearance
import llc.slacker.openime.theme.ImeTheme
import org.junit.After
import org.junit.Before
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import android.view.View
import android.view.inputmethod.BaseInputConnection
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import llc.slacker.openime.candidate.CandidateEngine
import llc.slacker.openime.data.ClipboardHistoryRepository
import llc.slacker.openime.data.ClipboardSensitivityPolicy
import llc.slacker.openime.editor.InputConnectionGateway
import llc.slacker.openime.voice.LocalAudioVoiceBackend
import llc.slacker.openime.voice.VoiceRecognitionEvents
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import llc.slacker.openime.keyboard.ImeKeyboardView
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import android.content.res.Configuration
import android.view.View
import android.view.ViewGroup
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.lang.reflect.Proxy
import llc.slacker.openime.core.KeyboardMode
import llc.slacker.openime.keyboard.ImeKeyboardView
import llc.slacker.openime.widget.ImeKeyView
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.lang.reflect.Proxy

/**
* The keyboard has to stay usable in every display environment, not just the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package llc.slacker.openime

import android.content.res.Resources
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.DisplayMetrics
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.test.ext.junit.runners.AndroidJUnit4
import llc.slacker.openime.core.Panel
import llc.slacker.openime.floating.FloatingWindowController
import llc.slacker.openime.keyboard.ImeKeyboardView
import llc.slacker.openime.voice.VoiceModelLifecycleState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.lang.reflect.Proxy

/** Game-oriented floating keyboard: compact toolbar, long-press drag, portrait-sized landscape. */
@RunWith(AndroidJUnit4::class)
class FloatingGameKeyboardInstrumentedTest {
private class Recorder {
lateinit var keyboard: ImeKeyboardView
val drags = mutableListOf<Pair<Float, Float>>()
val floatingChanges = mutableListOf<Boolean>()
val listener = Proxy.newProxyInstance(
ImeKeyboardView.Listener::class.java.classLoader,
arrayOf(ImeKeyboardView.Listener::class.java),
) { _, method, args ->
when (method.name) {
"voiceModelState" -> VoiceModelLifecycleState.COLD
"onFloatingKeyboardChanged" -> {
floatingChanges += args!![0] as Boolean
keyboard.setFloatingWindowMode(args[0] as Boolean)
null
}
"onFloatingKeyboardDragged" -> {
drags += (args!![0] as Float) to (args[1] as Float)
null
}
else -> if (method.returnType == java.lang.Boolean.TYPE) false else null
}
} as ImeKeyboardView.Listener
}

private fun withKeyboard(
test: (DirectActivityHarness<DebugKeyboardActivity>, Recorder, ImeKeyboardView) -> Any?,
) {
DirectActivityHarness(DebugKeyboardActivity::class.java).use { harness ->
harness.launch()
val recorder = Recorder()
val keyboard = harness.awaitMain { activity ->
ImeKeyboardView(activity, recorder.listener).also {
recorder.keyboard = it
activity.findViewById<ViewGroup>(android.R.id.content).addView(it)
}
}
try {
harness.awaitMain { if (keyboard.width > 0) true else null }
test(harness, recorder, keyboard)
} finally {
harness.awaitMain {
keyboard.shutdown()
(keyboard.parent as? ViewGroup)?.removeView(keyboard)
true
}
}
}
}

private fun View.visibleByTag(tag: String): Boolean =
findViewWithTag<View>(tag)?.visibility == View.VISIBLE

private fun View.toolbarIconByDescription(description: String): View? {
val row = findViewWithTag<ViewGroup>("toolbar-row") ?: return null
return (0 until row.childCount).map(row::getChildAt)
.firstOrNull { it.contentDescription == description }
}

@Test
fun floatingToolbarDropsTextEditAndUndoAndAddsQuickPhrases() = withKeyboard { harness, recorder, keyboard ->
harness.awaitMain {
assertTrue("Docked toolbar keeps the clipboard", keyboard.visibleByTag("clipboard-toolbar"))
assertTrue("Docked toolbar keeps text editing", keyboard.toolbarIconByDescription("文本编辑")!!.visibility == View.VISIBLE)
assertTrue("Docked toolbar keeps undo", keyboard.visibleByTag("undo-toolbar"))
assertFalse("Docked toolbar has no quick-phrase shortcut", keyboard.visibleByTag("quick-phrase-toolbar"))

keyboard.setFloatingWindowMode(true)
assertTrue("Floating toolbar exposes quick phrases", keyboard.visibleByTag("quick-phrase-toolbar"))
assertFalse(keyboard.visibleByTag("clipboard-toolbar"))
assertFalse(keyboard.visibleByTag("undo-toolbar"))
assertEquals(View.GONE, keyboard.toolbarIconByDescription("文本编辑")!!.visibility)
assertTrue("Emoji stays", keyboard.toolbarIconByDescription("表情")!!.visibility == View.VISIBLE)
assertTrue("Keyboard switch stays", keyboard.visibleByTag("keyboard-selector"))

keyboard.setFloatingWindowMode(false)
assertTrue("Docking restores the full toolbar", keyboard.visibleByTag("clipboard-toolbar"))
assertTrue(keyboard.toolbarIconByDescription("文本编辑")!!.visibility == View.VISIBLE)
assertFalse(keyboard.visibleByTag("quick-phrase-toolbar"))
true
}
}

@Test
fun quickPhraseShortcutOpensThePhraseTab() = withKeyboard { harness, _, keyboard ->
harness.awaitMain {
keyboard.setFloatingWindowMode(true)
assertTrue(keyboard.findViewWithTag<View>("quick-phrase-toolbar").performClick())
assertEquals(Panel.CLIPBOARD, keyboard.currentPanel())
assertNotNull(
"The phrase tab shows its add button, not the clipboard refresh",
keyboard.findViewWithTag<View>("quick-phrase-add"),
)
assertNull(keyboard.findViewWithTag<View>("clipboard-refresh"))
true
}
}

private fun touch(view: View, action: Int, downTime: Long, x: Float, y: Float): Boolean {
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0)
return try {
view.dispatchTouchEvent(event)
} finally {
event.recycle()
}
}

@Test
fun longPressOnTopZoneDragsTheFloatingKeyboardWithoutDockingOrClicking() = withKeyboard { harness, recorder, keyboard ->
harness.awaitMain { keyboard.setFloatingWindowMode(true); true }
val topZone = harness.awaitMain { keyboard.findViewWithTag<View>("ime_toolbar") }
val downTime = SystemClock.uptimeMillis()
// Press on an icon, so a leaked click would be visible as a panel opening.
val (x, y) = harness.awaitMain {
val icon = keyboard.findViewWithTag<View>("quick-phrase-toolbar")
val loc = IntArray(2).also { icon.getLocationInWindow(it) }
val zoneLoc = IntArray(2).also { topZone.getLocationInWindow(it) }
(loc[0] - zoneLoc[0] + icon.width / 2f) to (loc[1] - zoneLoc[1] + icon.height / 2f)
}
harness.awaitMain { touch(topZone, MotionEvent.ACTION_DOWN, downTime, x, y); true }
SystemClock.sleep(android.view.ViewConfiguration.getLongPressTimeout() + 250L)
harness.awaitMain {
touch(topZone, MotionEvent.ACTION_MOVE, downTime, x + 30f, y + 20f)
touch(topZone, MotionEvent.ACTION_MOVE, downTime, x + 90f, y + 60f)
touch(topZone, MotionEvent.ACTION_UP, downTime, x + 90f, y + 60f)
true
}
harness.awaitMain {
assertTrue("Long-press drag must move the window", recorder.drags.isNotEmpty())
val totalX = recorder.drags.sumOf { it.first.toDouble() }
val totalY = recorder.drags.sumOf { it.second.toDouble() }
assertEquals("Drag deltas follow the finger after the grab point", 60.0, totalX, 1.0)
assertEquals(40.0, totalY, 1.0)
assertTrue("Releasing a drag must not dock the keyboard", recorder.floatingChanges.isEmpty())
assertEquals("A drag on an icon must not open its panel", Panel.NONE, keyboard.currentPanel())
true
}
}

@Test
fun shortTapOnTopZoneStillClicksAndDoesNotDrag() = withKeyboard { harness, recorder, keyboard ->
harness.awaitMain { keyboard.setFloatingWindowMode(true); true }
harness.awaitMain {
assertTrue(keyboard.findViewWithTag<View>("quick-phrase-toolbar").performClick())
assertTrue(recorder.drags.isEmpty())
assertEquals(Panel.CLIPBOARD, keyboard.currentPanel())
true
}
}

@Test
fun floatingWindowUsesPortraitWidthOnLandscapeScreen() = withKeyboard { harness, _, keyboard ->
val controller = harness.awaitMain { activity ->
val real = keyboard.resources
val landscape = DisplayMetrics().apply {
setTo(real.displayMetrics)
// A landscape phone: the long side is the width.
widthPixels = maxOf(real.displayMetrics.widthPixels, real.displayMetrics.heightPixels)
heightPixels = minOf(real.displayMetrics.widthPixels, real.displayMetrics.heightPixels)
}
@Suppress("DEPRECATION")
val resources = Resources(real.assets, landscape, real.configuration)
FloatingWindowController(
resources = resources,
mainHandler = Handler(Looper.getMainLooper()),
windowProvider = { activity.window },
keyboardHeightPx = { 600 },
floatingWidthPercent = { 88 },
floatingOpacityPercent = { 100 },
).also { it.enable() }
}
try {
harness.awaitMain { activity ->
val metrics = activity.resources.displayMetrics
val shortSide = minOf(metrics.widthPixels, metrics.heightPixels)
val longSide = maxOf(metrics.widthPixels, metrics.heightPixels)
val expected = minOf((shortSide * 0.88f).toInt(), (420 * metrics.density).toInt())
val width = activity.window.attributes.width
// scheduleLayout runs on a posted message; poll until it applied.
if (width != expected) return@awaitMain null
assertTrue(
"Landscape floating width ($width) must follow the portrait width, not the $longSide px long side",
width < longSide * 0.6f,
)
true
}
} finally {
controller.restore()
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package llc.slacker.openime

import androidx.test.ext.junit.runners.AndroidJUnit4
import llc.slacker.openime.candidate.CandidateEngine
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package llc.slacker.openime
import android.view.View
import android.view.ViewGroup
import androidx.test.ext.junit.runners.AndroidJUnit4
import llc.slacker.openime.core.Panel
import llc.slacker.openime.keyboard.ImeKeyboardView
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package llc.slacker.openime

import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import llc.slacker.openime.data.EmojiRecentRepository
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Test
Expand Down
Loading
Loading